diff --git a/.github/workflows/cms-scheduled-publish-loop.yml b/.github/workflows/cms-scheduled-publish-loop.yml new file mode 100644 index 0000000..32f6a76 --- /dev/null +++ b/.github/workflows/cms-scheduled-publish-loop.yml @@ -0,0 +1,232 @@ +# Reusable scheduled-publish validation loop for platform sites. +# +# A consuming site calls this from a thin daily-cron wrapper (see +# examples/site/.github/workflows/cms-scheduled-publish-loop.yml). The e2e +# harness and the post-failure-comment composite live in the PLATFORM +# repo, so the job checks the platform out into `.cms-platform/` and runs +# the suite from there. Site identity (CMS_PROD_URL / CMS_APEX) + the +# PROD_PLAYGROUND_MODE sunset gate resolve to the CALLER's repo VARS under +# workflow_call. +# +# Runs ONLY e2e/cms-scheduled-publish-loop.spec.js: seed a +# `published: false` draft with a near-future `publish_date` (labelled +# fixture PR), prove the consumer's publish-scheduled-posts.yml is a +# no-op BEFORE the deadline, cross the deadline, prove it opens the +# `cms/posts/scheduled-publish-*` PR that auto-merges + deploys, then +# delete the post (resting state is absence). See the spec header for the +# full rationale — the pre-fix scheduler pushed straight to the +# ruleset-protected main and had ZERO successful publishes, ever; this +# loop is the live guard that the PR + auto-merge rework keeps working. +# +# SHAPE NOTES (vs the cms-publish-loop-prod.yml sibling this mirrors): +# - NO recursion-gate job. That gate exists to stop a PUSH-triggered +# loop from re-firing on its own canary auto-merge landing on main +# (it diffs the push's changed files against the loop's self-churn +# set; on schedule/workflow_dispatch it unconditionally emits +# run=true — see .github/actions/cms-recursion-gate). This loop's +# caller triggers ONLY on schedule + workflow_dispatch, so there is +# no push event to gate and the job would be constant-true dead +# weight. If a push trigger is ever added to the caller, port the +# recursion-gate job (and a self-churn set) back in first. +# - NO await-prod-deploy step, for the same reason: the gate only runs +# on push events ("do not drive prod until THIS commit is live"), +# and this workflow never fires on push. +# - The spec is API+HTTP only (no Decap admin drive), so it runs on the +# public-lane chromium project; the browser install is kept so the +# harness globalSetup (install-browsers-on-miss) stays a no-op. +# +# Sunset path: the actual mutation is gated on the repo variable +# `PROD_PLAYGROUND_MODE`, exactly like the sibling prod loops. While prod +# is a "full mutation playground", set it to `true`; when prod stops +# being a playground, set it to `false` (or unset it) — the workflow +# keeps reporting success without mutating anything. +name: CMS Scheduled Publish Loop — Prod (reusable) + +on: + workflow_call: + inputs: + platform_repo: + description: "owner/name of the platform repo holding the e2e harness + composite actions." + required: false + type: string + default: Adam-S-Daniel/cms-platform + platform_ref: + description: "Tag/sha of the platform repo to fetch the harness from (pin to the same ref as this workflow)." + required: false + type: string + default: main + secrets: + CMS_E2E_PAT: + required: false + +permissions: + # The spec does every read/write (PR polling, workflow dispatch, the + # fixture PRs) through CMS_E2E_PAT; the workflow itself only needs + # contents:read for the two checkouts. `pull-requests: write` is for + # the post-failure-comment composite. + contents: read + pull-requests: write + +defaults: + run: + shell: bash + +jobs: + scheduled-publish-loop: + runs-on: ubuntu-latest + concurrency: + # SAME shared lane as the three real-prod-mutating loops + # (cms-publish-loop-prod / cms-media-roundtrip / + # cms-publish-loop-host): this loop also mutates prod through + # labelled cms/* PRs → auto-merge → deploy-production, so running + # it beside another prod loop races deploy-production's + # `group: production` lane and blows both loops' URL-reflect + # budgets. GHA keys concurrency groups by string ACROSS workflows, + # so declaring the same constant group here makes this a fourth + # member of the mutual-exclusion lane; cancel-in-progress:false + # queues rather than killing an in-flight loop (a mid-flow cancel + # can leave the canary dirty). The canonical rationale comment + # lives on cms-publish-loop-prod.yml's prod-mutate job; the three + # original loops' blocks are byte-locked by + # e2e/workflow-prod-loop-serialized.test.js, and this member is + # locked by e2e/publish-scheduled-posts-flow.test.js. + group: prod-mutating-loop + cancel-in-progress: false + env: + # Site identity for the parameterized e2e helpers — supplied by + # repo VARS so a consuming deployment passes its own deployed URLs. + CMS_PROD_URL: ${{ vars.CMS_PROD_URL }} + CMS_APEX: ${{ vars.CMS_APEX }} + CMS_REPO: ${{ github.repository }} + # The spec's TEST_TIMEOUT_MS is 150 min (the deadline window + two + # scheduler runs + two auto-merge waits + two deploy reflects — see + # the budget block in e2e/cms-scheduled-publish-loop.spec.js); 165 + # leaves bring-up headroom so the job cap can never truncate a leg. + # Alignment locked by e2e/publish-scheduled-posts-flow.test.js. + timeout-minutes: 165 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2 (2026-01-09) + + - name: Checkout platform (e2e harness + composite actions) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2 (2026-01-09) + with: + repository: ${{ inputs.platform_repo }} + ref: ${{ inputs.platform_ref }} + path: .cms-platform + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 (2026-04-20) + with: + node-version: "20" + + - name: Install harness deps + working-directory: .cms-platform/e2e + run: npm ci + + - name: Install Playwright browser + system deps + working-directory: .cms-platform/e2e + run: npx playwright install --with-deps chromium + + - name: Skip — playground mode disabled + if: vars.PROD_PLAYGROUND_MODE != 'true' + run: echo "::notice::PROD_PLAYGROUND_MODE is not 'true'; skipping the scheduled-publish loop." + + - name: Run scheduled-publish loop spec + if: vars.PROD_PLAYGROUND_MODE == 'true' + working-directory: .cms-platform/e2e + env: + # SITE_ROOT — the CONSUMING site's checkout (github.workspace). The e2e + # harness runs from .cms-platform/e2e here, so the base_collections guards' + # default (resolve __dirname/..) would point at the PLATFORM, not the + # consumer; set it so keepsBaseCollection() reads the consumer's _config.yml + # and a single-page bio (base_collections:[]) correctly SKIPS this posts + # loop instead of failing on a /blog/ surface it never renders. Locked by + # e2e/loop-site-root-lint.test.js. + SITE_ROOT: ${{ github.workspace }} + CMS_E2E_PAT: ${{ secrets.CMS_E2E_PAT }} + TARGET: prod + RUN_SCHEDULED_PUBLISH_LOOP: "1" + run: | + set -o pipefail + npx playwright test \ + cms-scheduled-publish-loop.spec.js \ + --project=chromium-desktop-1080 \ + --reporter=list \ + --max-failures=1 \ + --workers=1 \ + 2>&1 | tee /tmp/scheduled-publish-loop.log + + - name: Upload test results on failure + if: ${{ (failure() || cancelled()) && vars.PROD_PLAYGROUND_MODE == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (2026-04-10) + with: + name: scheduled-publish-loop-results + path: | + .cms-platform/e2e/test-results/ + /tmp/scheduled-publish-loop.log + retention-days: 14 + + - name: Post failure summary + if: ${{ failure() && vars.PROD_PLAYGROUND_MODE == 'true' }} + uses: ./.cms-platform/.github/actions/post-failure-comment + with: + mode: post + log-file: /tmp/scheduled-publish-loop.log + marker: scheduled-publish-loop-failure-summary + title: scheduled-publish-loop + + - name: Resolve failure summary on success + if: ${{ success() && vars.PROD_PLAYGROUND_MODE == 'true' }} + uses: ./.cms-platform/.github/actions/post-failure-comment + with: + mode: resolve + marker: scheduled-publish-loop-failure-summary + title: scheduled-publish-loop + + # #22 (mirrors the sibling loops): prune this flow's EPHEMERAL + # branches on completion AND on cancel/failure. Every + # publish-scheduled-posts run that flips posts pushes a per-run + # cms/posts/scheduled-publish- branch; once its PR merges + # (or a killed run orphans it) the branch lingers on origin — one + # per loop day. Pattern-delete every branch on this flow's OWN + # prefix that has NO open PR (an in-flight publish PR always keeps + # its branch). Idempotent + FAIL-OPEN: every delete is + # `|| echo`-guarded and the step is continue-on-error, so a cleanup + # hiccup (API blip, missing PAT) NEVER fails the loop. Uses + # CMS_E2E_PAT (contents r/w) since the workflow only grants + # contents:read. + - name: Clean up ephemeral scheduled-publish branches + if: always() + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.CMS_E2E_PAT }} + GH_REPO: ${{ github.repository }} + BRANCH_PREFIX: cms/posts/scheduled-publish- + run: | + set -uo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::notice::no CMS_E2E_PAT — skipping ephemeral branch cleanup (fail-open)" + exit 0 + fi + echo "::notice::Cleanup — deleting orphaned ${BRANCH_PREFIX}* branches with no open PR" + gh api --paginate "repos/${GH_REPO}/branches?per_page=100" \ + --jq ".[] | select(.name | startswith(\"${BRANCH_PREFIX}\")) | .name" \ + 2>/dev/null | while read -r branch; do + [ -z "$branch" ] && continue + # Belt-and-braces prefix re-verify: never touch a branch outside + # this flow's own ephemeral prefix. + case "$branch" in + "${BRANCH_PREFIX}"*) : ;; + *) echo "skip ${branch} — prefix mismatch"; continue ;; + esac + open_count=$(gh pr list --state open --search "head:${branch}" --limit 1 --json number --jq 'length' 2>/dev/null || echo "1") + if [ "$open_count" != "0" ]; then + echo "skip ${branch} — has open PR(s) (in-flight publish)" + continue + fi + echo "delete ${branch}" + gh api -X DELETE "repos/${GH_REPO}/git/refs/heads/${branch}" \ + || echo "::warning::failed to delete ${branch} (already gone?) — ignoring (fail-open)" + done + echo "::notice::Cleanup complete (fail-open)" diff --git a/.github/workflows/publish-scheduled-posts.yml b/.github/workflows/publish-scheduled-posts.yml index 9394d38..c217353 100644 --- a/.github/workflows/publish-scheduled-posts.yml +++ b/.github/workflows/publish-scheduled-posts.yml @@ -1,3 +1,70 @@ +# Reusable: publish due scheduled posts via the platform's own PR + +# auto-merge path. +# +# A consuming site calls this from a thin daily-cron caller (see +# examples/site/.github/workflows/publish-scheduled-posts.yml). The +# platform-owned scripts/publish_scheduled_posts.py flips +# `published: false → true` on every `_posts/*.md` whose `publish_date` +# has arrived; this workflow lands those flips on the default branch. +# +# WHY A PR + AUTO-MERGE, NOT `git push origin main` (read before +# "simplifying" this back to the push it used to be): +# +# 1. Ruleset rejection. Consumer repos protect `main` with a ruleset +# (`pull_request` rule + required status checks, NO bypass actors), +# so a direct push to main is rejected outright — for ANY token. +# The old push shape therefore failed the FIRST time a post ever +# came due, in a schedule-event run nobody watches; scheduled +# publishing was silently broken from day one (no auto-publish +# commit has ever landed on a consumer's history). +# 2. Token suppression. Even where a push could land, a push made with +# the default GITHUB_TOKEN does not trigger downstream workflows +# (GitHub's anti-recursion policy — the exact trap documented on +# cms-editorial-workflow.yml's auto-merge-when-ready job), so +# deploy-production would never fire and prod would keep serving +# the pre-flip build even after a "successful" push. +# +# THE FLOW (rides the same machinery every other non-Decap content +# writer already uses — the delete-recovery PRs in +# theme/admin/publish-via-auto-merge.js and the labelled fixture PRs in +# e2e/cms-fixture-pr.js): +# +# 1. Run the publish script; it flips due posts in the working tree +# and emits `changed` / `count` step outputs (and prints the +# flipped file names to the run log). +# 2. When posts flipped: commit them to a per-run branch +# `cms/posts/scheduled-publish-` and push it, authenticated +# as CMS_E2E_PAT. The `cms/` first segment keeps the branch +# Decap-shaped, so label-non-decap-prs.yml and the content-PR +# guards classify it with the rest of the CMS content PRs. +# 3. Open a PR and label it `cms/draft` + `cms/ready` (plus +# `decap-cms/pending_publish` — the label-at-creation convention +# that keeps Decap's "adding labels…" migration dialog off /admin +# and the editorial-label audit green while the PR is open). The +# `labeled` event fires cms-editorial-workflow.yml's +# auto-merge-when-ready job, which enables native auto-merge as the +# PAT user; GitHub squash-merges once the required checks pass, and +# that merge — pushed by a real user, not github-actions[bot] — +# fires deploy-production like any other content merge. +# +# CALLERS MUST PASS `secrets: CMS_E2E_PAT` — there is deliberately NO +# degraded mode. A PR created with the default GITHUB_TOKEN cannot +# trigger the `pull_request` workflows that produce the required status +# checks (the same anti-recursion policy as above), so such a PR would +# sit BLOCKED forever with nothing to auto-merge on. When posts are due +# and the secret is absent, the run fails LOUD naming the secret instead +# of silently half-publishing. +# +# STACKING GUARD: if an OPEN `cms/posts/scheduled-publish-*` PR already +# exists (yesterday's flip may still be waiting on checks/auto-merge), +# the run logs a notice and exits success WITHOUT opening another — a +# second PR flipping the same posts would only conflict with the first, +# and the pending PR already carries every currently-due flip once it +# merges (the next scheduled run picks up any stragglers). +# +# The end-to-end chain is validated live by +# e2e/cms-scheduled-publish-loop.spec.js (the cms-scheduled-publish-loop +# reusable) and statically by e2e/publish-scheduled-posts-flow.test.js. name: Publish Scheduled Posts (reusable) on: workflow_call: @@ -8,8 +75,17 @@ on: platform_ref: type: string default: main + secrets: + CMS_E2E_PAT: + required: false + +# GITHUB_TOKEN needs nothing beyond reading the repo for the two +# checkouts: the branch push rides the CMS_E2E_PAT the site checkout +# persists, and the PR/label writes go through github-script with the +# same PAT. (The retired push-to-main shape needed `contents: write`; +# the PR flow deliberately does not.) permissions: - contents: write + contents: read jobs: publish: @@ -18,9 +94,13 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2 (2026-01-09) with: - # Use the default GITHUB_TOKEN; the push below will trigger - # deploy-production.yml via the standard push event. - token: ${{ secrets.GITHUB_TOKEN }} + # CMS_E2E_PAT when available so the branch push below is + # PAT-authenticated (checkout persists this token for git + # pushes). The github.token fallback only serves the no-op + # path (nothing due, nothing pushed) on a caller that hasn't + # wired the secret yet — the guard step below fails loud + # before any write is attempted. + token: ${{ secrets.CMS_E2E_PAT || github.token }} - name: Checkout platform scripts uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.2 (2026-01-09) @@ -33,12 +113,137 @@ jobs: id: check run: python3 .cms-platform/scripts/publish_scheduled_posts.py - - name: Commit and push + - name: Fail loud when CMS_E2E_PAT is missing (no degraded mode) if: steps.check.outputs.changed == 'true' + env: + # `secrets.*` is not readable inside a step-level `if:` in a + # reusable workflow — surface presence through env and branch + # in the script instead. + HAS_CMS_E2E_PAT: ${{ secrets.CMS_E2E_PAT != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (2026-04-09) + with: + script: | + if (process.env.HAS_CMS_E2E_PAT !== 'true') { + core.setFailed( + 'Scheduled post(s) are due but the CMS_E2E_PAT secret was not provided. ' + + 'This workflow publishes via a PR + auto-merge — the default branch is ' + + 'ruleset-protected against direct pushes, and a PR created with the ' + + 'default GITHUB_TOKEN cannot trigger the required status checks (see ' + + 'this workflow\'s header comment) — so there is no degraded mode worth ' + + 'having: wire a `secrets: CMS_E2E_PAT:` map in the thin caller ' + + '(see examples/site/.github/workflows/publish-scheduled-posts.yml) and re-run.' + ); + } + + - name: Stacking guard — skip when a scheduled-publish PR is already open + id: stack + if: steps.check.outputs.changed == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (2026-04-09) + with: + github-token: ${{ secrets.CMS_E2E_PAT }} + script: | + // Yesterday's flip may still be waiting on its required + // checks / auto-merge. A second PR flipping the same posts + // would only conflict with the first, so keep at most ONE + // scheduled-publish PR in flight; the next scheduled run + // picks up any posts still due after it lands. + const prefix = 'cms/posts/scheduled-publish-'; + const prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + base: 'main', + per_page: 100, + }); + const open = prs.find((pr) => ((pr.head && pr.head.ref) || '').startsWith(prefix)); + if (open) { + core.notice( + `Open scheduled-publish PR already exists (#${open.number}, ` + + `head ${open.head.ref}) — not opening another; the due flips ` + + `land when it merges.` + ); + core.setOutput('skip', 'true'); + } else { + core.setOutput('skip', 'false'); + } + + - name: Commit the flips to a scheduled-publish branch and push + if: steps.check.outputs.changed == 'true' && steps.stack.outputs.skip != 'true' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "cms/posts/scheduled-publish-${{ github.run_id }}" git add _posts/ git commit -m "chore: auto-publish ${{ steps.check.outputs.count }} scheduled post(s)" - git push origin main + git push origin "cms/posts/scheduled-publish-${{ github.run_id }}" + + - name: Open the auto-publish PR and label it for auto-merge + if: steps.check.outputs.changed == 'true' && steps.stack.outputs.skip != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (2026-04-09) + env: + COUNT: ${{ steps.check.outputs.count }} + BRANCH: cms/posts/scheduled-publish-${{ github.run_id }} + with: + github-token: ${{ secrets.CMS_E2E_PAT }} + script: | + const count = process.env.COUNT; + const branch = process.env.BRANCH; + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Auto-publish ${count} scheduled post(s)`, + head: branch, + base: 'main', + body: [ + `Flips \`published: false → true\` on ${count} scheduled post(s) whose`, + '`publish_date` has arrived. The flipped file names are printed in this', + `run's log (the "Check and publish due posts" step).`, + '', + 'This PR auto-merges via the `cms/ready` label once the required status', + "checks pass (the editorial workflow's auto-merge-when-ready job), and", + 'the resulting merge triggers the production deploy. A direct push to', + 'the default branch is not used because the branch ruleset rejects it', + '(pull_request rule + required checks, no bypass actors) and a push made', + 'with the default GITHUB_TOKEN would not trigger the deploy workflow.', + ].join('\n'), + }); + + // Ensure the labels exist before applying them (idempotent — + // the same createLabel try/catch pattern as + // cms-editorial-workflow.yml). + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'cms/draft', + color: '1a2a5e', + description: 'Content draft — not ready for publish', + }); + } catch (_) {} + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'cms/ready', + color: '22c27a', + description: 'Content approved and ready to publish', + }); + } catch (_) {} + // `cms/ready` engages auto-merge-when-ready (the `labeled` + // event, sent as the PAT user, so the eventual merge push + // fires deploy-production). `decap-cms/pending_publish` rides + // along per the label-at-creation convention: a `cms/*` PR + // with no `decap-cms/*` label re-triggers Decap's label + // migration dialog on every /admin load and reds the daily + // editorial-label audit for as long as it is open. + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['cms/draft', 'cms/ready', 'decap-cms/pending_publish'], + }); + core.notice( + `Opened auto-publish PR #${pr.number} (${count} post(s), head ${branch}); ` + + 'auto-merge lands it once the required checks pass.' + ); diff --git a/e2e/base-collections-guards.js b/e2e/base-collections-guards.js index b4f8990..209c048 100644 --- a/e2e/base-collections-guards.js +++ b/e2e/base-collections-guards.js @@ -219,6 +219,12 @@ const ADMIN_WRITE_GUARDS = { reason: 'consumer opts out of the "posts" collection via cms.base_collections — the preview prod-mutation parity loop waits for the Posts sidebar link and publishes a post, absent on a base_collections:[] bio (#33) (#21)', }, + "cms-scheduled-publish-loop.spec.js": { + collections: ["posts"], + mode: "any", + reason: + 'consumer opts out of the "posts" collection via cms.base_collections — the scheduled-publish loop seeds a _posts/ draft and asserts the /blog/ surface, absent on a base_collections:[] bio (#33) (#21)', + }, }; // ── CAPABILITY_GUARDS (#21, v0.1.13+) ──────────────────────────────────────── diff --git a/e2e/cms-scheduled-post.spec.js b/e2e/cms-scheduled-post.spec.js index 8a67181..f3adbca 100644 --- a/e2e/cms-scheduled-post.spec.js +++ b/e2e/cms-scheduled-post.spec.js @@ -128,16 +128,19 @@ Body. } }); - test("workflow YAML wires up the publish script and writes back to repo", () => { - // Light yaml-parse — no need to exec the workflow, just confirm the - // wiring an editor would expect: the cron entry exists, the script - // is invoked, and a commit-and-push step lands the change so the - // normal deploy fires. + test("workflow YAML wires up the publish script and lands the flips via a PR", () => { + // Light text probe — no need to exec the workflow, just confirm the + // wiring an editor would expect: the script is invoked and the + // flips land through the PR + auto-merge flow, never a direct push + // to main (the ruleset rejects it, and a GITHUB_TOKEN push would + // not fire the deploy — see the workflow's header). The daily cron + // lives on the thin caller, not this reusable. The full structural + // lock is e2e/publish-scheduled-posts-flow.test.js. const yaml = fs.readFileSync(WORKFLOW_FILE, "utf8"); - expect(yaml).toMatch(/cron:\s*['"]0 14 \* \* \*['"]/); expect(yaml).toContain("scripts/publish_scheduled_posts.py"); - expect(yaml).toMatch(/git push/); expect(yaml).toMatch(/git add _posts\//); + expect(yaml).toContain("cms/posts/scheduled-publish-"); + expect(yaml).not.toMatch(/git push origin main\b/); }); }, ); diff --git a/e2e/cms-scheduled-publish-loop.spec.js b/e2e/cms-scheduled-publish-loop.spec.js new file mode 100644 index 0000000..26da1c3 --- /dev/null +++ b/e2e/cms-scheduled-publish-loop.spec.js @@ -0,0 +1,539 @@ +// @lane: real — seeds, scheduler-flips, and deletes a real, ephemeral prod _posts/ entry via labelled PRs +// @select-skip-when-head-ref-prefix: cms/ +// +// On `cms/*` PRs (Decap-opened editorial PRs) this spec self-skips at +// runtime — CMS_E2E_PAT and RUN_SCHEDULED_PUBLISH_LOOP aren't wired into +// the standard PR matrix — so selecting + bringing it up just to no-op is +// pure waste. The dedicated cms-scheduled-publish-loop workflow runs it. + +/* + * Real-HTTP, real-GitHub end-to-end test for the SCHEDULED-PUBLISH chain: + * + * scheduled draft on main → publish-scheduled-posts.yml (dispatch) → + * cms/posts/scheduled-publish-* PR → cms/ready auto-merge → + * deploy-production → URL serves → delete → 404. + * + * WHY THIS LOOP EXISTS (the failure it guards against): the scheduler + * used to flip `published: false → true` and `git push origin main` + * with the default GITHUB_TOKEN. Consumer repos protect main with a + * ruleset (pull_request rule + required status checks, NO bypass + * actors), so that push was rejected the FIRST time a post ever came + * due — and even if it had landed, a GITHUB_TOKEN push does not trigger + * deploy-production (the token-suppression anti-recursion policy + * documented on cms-editorial-workflow.yml's auto-merge-when-ready + * job). The pre-fix scheduler therefore had ZERO successful publishes, + * ever, and the breakage was invisible: schedule-event failures have no + * PR to go red on. The reworked scheduler rides the platform's own + * PR + auto-merge path (publish-scheduled-posts.yml); this loop is the + * live proof that the whole chain — including the "not before the + * deadline" half — actually works against prod. + * + * API + HTTP only: no browser page is used (the scheduler, not Decap, + * is the machinery under test), but the spec keeps the standard ./base + * scaffolding so TARGET=prod baseURL resolution and the shared helpers + * work exactly as in the sibling loops. + * + * Flow: + * 1. Seed `_posts/2099-12-31-e2e-scheduled-publish-.md` via + * seedFixtureViaPr — front matter mirrors the prod-mutate canary + * (robots noindex,nofollow; sitemap false; test_fixture true) but + * `published: false` and `publish_date` = seed time + + * DEADLINE_WINDOW_MS. Unadvertised: noindex + no sitemap + never + * linked; the slug carries the runId so the path is per-run-unique. + * 2. "Not before": assert /blog// 404s, dispatch the consumer's + * publish-scheduled-posts.yml, wait for that run to complete, + * assert it concluded success WITHOUT creating a + * cms/posts/scheduled-publish-* PR (the changed=false path), and + * that the URL still 404s. + * 3. Wait out the remaining seconds of the deadline window. + * 4. "At/after": dispatch again; the run must open the auto-publish + * PR; wait for auto-merge to land it, then for prod to serve the + * run marker (deploy-production fires off the PAT-user merge). + * 5. Delete leg: removeFixtureViaPr, assert the URL 404s again. + * afterAll: existence-only removal PR (fire-and-forget) if the test + * died mid-flow. Resting state is ABSENCE (404) — absence has no + * corrupt variant (#1771 step 4). A killed run leaks at most ONE + * inert, noindexed, uniquely-named orphan, swept by + * sweep-stale-cms-prs.yml — never a shared mutable baseline. + * + * THE DEADLINE WINDOW (why it is NOT a small constant like 4 minutes): + * `publish_date` is written into the seed PR's front matter BEFORE the + * seed auto-merges, and seedFixtureViaPr legitimately takes up to its + * 25-min merge budget under required-check contention. The "not before" + * leg can only run after the seed lands, so the window must span the + * worst-case seed merge PLUS the leg-2 scheduler run PLUS a safety + * margin — otherwise leg 2 races the deadline and the changed=false + * assertion flakes. Hence DEADLINE_WINDOW_MS = SEED_MERGE_TIMEOUT_MS + + * SCHEDULER_RUN_TIMEOUT_MS + DEADLINE_MARGIN_MS (40 min): long enough + * that leg 2 PROVABLY runs pre-deadline (each budget throws before the + * window can be overrun), short enough that the whole loop still fits + * the job budget. + * + * Gating: + * - `CMS_E2E_PAT` must be set (Contents/PR/Actions on the host repo). + * - `RUN_SCHEDULED_PUBLISH_LOOP=1` (set only in + * cms-scheduled-publish-loop.yml). + * + * IMPORTANT: do NOT run this spec locally against prod. It mutates the + * real production tree. The dedicated workflow runs it on a schedule. + */ +const path = require("node:path"); +const { guard } = require("./base-collections-guards"); +// #33/#21 — resolved like the other registered specs so the drift lint matches it. +const SITE_ROOT = process.env.SITE_ROOT || path.resolve(__dirname, ".."); +const { test, expect } = require("./base"); +const { getPat, HOST_REPO } = require("./decap-pat"); +const { seedFixtureViaPr, removeFixtureViaPr } = require("./cms-fixture-pr"); +const { gh, waitForMerge, fetchPublicUrl } = require("./github-actions-poll"); +const { prodTarget } = require("./cms-host"); +const { loudBail } = require("./fixture-baseline"); +const { EPHEMERAL_DATE } = require("./prod-mutate-fixture"); + +const { host: PROD_HOST } = prodTarget(); + +// The consumer's thin caller (same filename as the platform reusable it +// delegates to) — the workflow_dispatch target for both scheduler legs. +const SCHEDULER_WORKFLOW = "publish-scheduled-posts.yml"; +// The reusable's per-run PR branch prefix. `cms/` first segment keeps it +// Decap-shaped (label-non-decap-prs + the content-PR guards); locked by +// e2e/publish-scheduled-posts-flow.test.js. +const SCHEDULED_PUBLISH_BRANCH_PREFIX = "cms/posts/scheduled-publish-"; +const SLUG_PREFIX = "e2e-scheduled-publish"; + +// ── Budgets ─────────────────────────────────────────────────────────── +// Seed / remove PR merges ride the same labelled-PR auto-merge path as +// every fixture PR — 25 min each, matching seedFixtureViaPr's documented +// rationale (required-check matrix under busy runners). +const SEED_MERGE_TIMEOUT_MS = 25 * 60 * 1000; +const REMOVE_MERGE_TIMEOUT_MS = 25 * 60 * 1000; +// One publish-scheduled-posts run is two checkouts + a python scan + (at +// most) a branch push and a PR create — a couple of minutes of work; 10 +// min absorbs runner queue depth. +const SCHEDULER_RUN_TIMEOUT_MS = 10 * 60 * 1000; +// Safety margin on the deadline derivation so a jittery run poll can +// never straddle the boundary. +const DEADLINE_MARGIN_MS = 5 * 60 * 1000; +// See "THE DEADLINE WINDOW" in the header — spans worst-case seed merge +// + the pre-deadline scheduler leg + margin (40 min). +const DEADLINE_WINDOW_MS = SEED_MERGE_TIMEOUT_MS + SCHEDULER_RUN_TIMEOUT_MS + DEADLINE_MARGIN_MS; +// Wait this far PAST the deadline before the at/after dispatch so +// runner-vs-harness clock skew can't make the scheduler still read the +// post as "due in ~0h". +const POST_DEADLINE_SKEW_MS = 60 * 1000; +// The at/after run opens its PR synchronously before completing, so the +// PR should be visible the moment the run concludes; 5 min absorbs API +// list lag. +const PR_APPEAR_TIMEOUT_MS = 5 * 60 * 1000; +// The auto-publish PR merges via auto-merge-when-ready — same 25-min +// budget class as the fixture PRs (same required-check matrix). +const PUBLISH_MERGE_TIMEOUT_MS = 25 * 60 * 1000; +// URL reflect AFTER the merge is already confirmed (unlike the +// prod-mutate reflect legs, which start pre-merge and need the 30-min +// auto-merge floor, #1815): this only spans deploy-production + CDN. +const REFLECT_TIMEOUT_MS = 20 * 60 * 1000; +// Worst-case sum: 25 (seed) + 10 (leg 2) + 40-window remainder + 10 +// (leg 4 run) + 5 (PR appear) + 25 (publish merge) + 20 (reflect) + 25 +// (remove merge) + 20 (404 reflect) ≈ 146 min → 150. Fits the 165-min +// job timeout in cms-scheduled-publish-loop.yml (alignment locked by +// e2e/publish-scheduled-posts-flow.test.js). Retries disabled — this +// mutates real prod; a retry re-runs the same broken chain. +const TEST_TIMEOUT_MS = 150 * 60 * 1000; + +test.describe.configure({ + mode: "serial", + timeout: TEST_TIMEOUT_MS, + retries: 0, +}); + +// Module-scoped handle so the afterAll safety-net can see what the test +// generated. The forward DELETE leg IS the cleanup; the safety net only +// acts when the test died mid-flow. +let pendingFixture = null; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// `publish_date` in the format publish_scheduled_posts.py parses first +// ("%Y-%m-%d %H:%M:%S %z"), always UTC. +function formatPublishDate(epochMs) { + const d = new Date(epochMs); + const p = (n) => String(n).padStart(2, "0"); + return ( + `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` + + `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} +0000` + ); +} + +// Per-run scheduled-draft builder. Front matter mirrors the prod-mutate +// canary (prod-mutate-fixture.js composePost) except `published: false` +// + a real near-future `publish_date` — the two fields the scheduler +// keys on. Future-dated 2099-12-31 so the post, once flipped, serves the +// same way the sibling canaries do (`_config.yml` `future: true`). +function buildScheduledPublishPost({ runId, publishDateMs }) { + const slug = `${SLUG_PREFIX}-${runId}`; + const filePath = `_posts/${EPHEMERAL_DATE}-${slug}.md`; + const publicPath = `/blog/${slug}/`; + const title = `E2E Scheduled Publish ${runId}`; + const marker = `${SLUG_PREFIX}:${runId}`; + const body = + `Ephemeral E2E scheduled-publish canary (run ${runId}; do not edit by hand).\n\n` + + `This post is SEEDED with published: false, flipped live by the ` + + `publish-scheduled-posts workflow's PR + auto-merge flow, asserted served, ` + + `then DELETED within a single run of e2e/cms-scheduled-publish-loop.spec.js. ` + + `Its resting state is absence (404). The run marker is ${marker}.\n`; + const fileText = [ + "---", + `title: ${title}`, + `slug: ${slug}`, + `date: ${EPHEMERAL_DATE} 00:00:00 +0000`, + "tags: []", + 'featured_image: ""', + "published: false", + "robots: noindex,nofollow", + "sitemap: false", + `publish_date: ${formatPublishDate(publishDateMs)}`, + "test_fixture: true", + "---", + "", + body, + ].join("\n"); + return { runId, slug, filePath, publicPath, title, marker, fileText }; +} + +async function fileExistsOnMain(filePath) { + try { + await gh(`/repos/${HOST_REPO}/contents/${filePath}?ref=main`); + return true; + } catch (e) { + if (/\b404\b/.test(String(e.message))) return false; + throw e; + } +} + +// Every OPEN PR whose head branch is a scheduled-publish branch. +async function openScheduledPublishPrs() { + const prs = await gh(`/repos/${HOST_REPO}/pulls?state=open&base=main&per_page=100`); + return (prs || []).filter( + (pr) => + pr.head && + typeof pr.head.ref === "string" && + pr.head.ref.startsWith(SCHEDULED_PUBLISH_BRANCH_PREFIX), + ); +} + +// Dispatch the consumer's publish-scheduled-posts.yml and wait for THAT +// run (not a stale one) to complete. New-run identity is by run id +// ordering — capture the newest existing id BEFORE dispatching and wait +// for a workflow_dispatch run with a greater id — so harness-vs-GitHub +// clock skew can't misattribute a run. +async function dispatchSchedulerAndAwait(label) { + const wfBase = `/repos/${HOST_REPO}/actions/workflows/${SCHEDULER_WORKFLOW}`; + const runsUrl = `${wfBase}/runs`; + const before = await gh(`${runsUrl}?per_page=1`); + const maxSeenId = ((before.workflow_runs || [])[0] || {}).id || 0; + + // POST .../dispatches answers 204 No Content; gh() unconditionally + // res.json()s, which rejects on the empty body — treat that specific + // SyntaxError as the success it is (GitHub accepted the dispatch). + await gh(`${wfBase}/dispatches`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ref: "main" }), + retries: 2, + }).catch((err) => { + if (err instanceof SyntaxError) return null; + throw err; + }); + + const deadline = Date.now() + SCHEDULER_RUN_TIMEOUT_MS; + let run = null; + while (Date.now() < deadline && !run) { + const data = await gh(`${runsUrl}?event=workflow_dispatch&per_page=10`); + run = (data.workflow_runs || []).find((r) => r.id > maxSeenId) || null; + if (!run) await sleep(6000); + } + expect( + run, + `[${label}] no new ${SCHEDULER_WORKFLOW} workflow_dispatch run appeared within ` + + `${SCHEDULER_RUN_TIMEOUT_MS / 60000} min of dispatching (last seen run id ${maxSeenId})`, + ).toBeTruthy(); + + while (Date.now() < deadline && run.status !== "completed") { + await sleep(10_000); + run = await gh(`/repos/${HOST_REPO}/actions/runs/${run.id}`); + } + expect( + run.status, + `[${label}] ${SCHEDULER_WORKFLOW} run ${run.id} did not complete within ` + + `${SCHEDULER_RUN_TIMEOUT_MS / 60000} min (status ${run.status})`, + ).toBe("completed"); + expect( + run.conclusion, + `[${label}] ${SCHEDULER_WORKFLOW} run ${run.id} concluded ${run.conclusion} — expected ` + + `success (${run.html_url})`, + ).toBe("success"); + return run; +} + +// Poll until the URL stops serving (4xx). The inverse of fetchPublicUrl. +async function waitForUrlGone(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastStatus = null; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { cache: "no-store" }); + lastStatus = res.status; + if (res.status >= 400 && res.status < 500) return; + } catch (err) { + console.warn(`[waitForUrlGone] transient fetch error on ${url}: ${err && err.message}`); + } + await sleep(6000); + } + throw new Error( + `Timed out waiting for ${url} to 404 after the delete merged (last status ${lastStatus}).`, + ); +} + +test("scheduled-publish loop — draft seeds, scheduler flips it live via PR + auto-merge, delete restores absence", async () => { + // Only the dedicated cms-scheduled-publish-loop.yml workflow opts in. + // Plain green skip FIRST so a PR-matrix run exits before the loud + // guards below (mirrors the prod-mutate gate). + test.skip( + process.env.RUN_SCHEDULED_PUBLISH_LOOP !== "1", + "RUN_SCHEDULED_PUBLISH_LOOP not set — only the cms-scheduled-publish-loop workflow runs this spec.", + ); + // #33/#21 — a base_collections:[] bio ships no posts collection / /blog/ surface; skip green there. + test.skip(...guard(SITE_ROOT, "cms-scheduled-publish-loop.spec.js")); + + // Past the gates the spec is SUPPOSED to run — an unmet precondition + // is a loud red on a schedule/dispatch run (#1053). + if (!getPat()) { + loudBail(test, "CMS_E2E_PAT not set — scheduled-publish loop cannot run."); + return; + } + + const runId = Date.now(); + const publishDateMs = Date.now() + DEADLINE_WINDOW_MS; + const built = buildScheduledPublishPost({ runId, publishDateMs }); + const { slug, filePath, marker, fileText } = built; + const publicUrl = `${PROD_HOST}${built.publicPath}`; + pendingFixture = { runId, slug, filePath }; + test.info().annotations.push({ type: "fixture-path", description: filePath }); + + // ── 0. Preflight: no scheduled-publish PR may already be in flight ── + // The reusable's stacking guard suppresses PR creation while one is + // open, so a lingering PR would make BOTH scheduler legs no-op and the + // failure would only surface 40+ minutes in. Fail fast + actionable. + await test.step("Preflight — no open scheduled-publish PR (the stacking guard would suppress this run)", async () => { + const open = await openScheduledPublishPrs(); + expect( + open.map((pr) => `#${pr.number} (${pr.head.ref})`), + `an open ${SCHEDULED_PUBLISH_BRANCH_PREFIX}* PR is already in flight — the ` + + `scheduler's stacking guard will refuse to open another, so this loop cannot ` + + `validate anything. Let it auto-merge (it carries cms/ready) or close it, then re-run.`, + ).toEqual([]); + }); + + // ── 1. Unique per-run path starts absent ───────────────────────────── + await test.step("Confirm /blog// 404s before seeding (unique per-run path)", async () => { + const res = await fetch(publicUrl, { cache: "no-store" }); + expect( + res.status, + `${publicUrl} must not exist yet (unique per-run name) — got ${res.status}`, + ).toBe(404); + }); + + // ── 2. Seed the scheduled draft on main via a labelled fixture PR ──── + await test.step("Seed the published:false draft with publish_date = T+40min (seedFixtureViaPr)", async () => { + await seedFixtureViaPr({ + slug, + runId, + filePath, + bodyText: fileText, + message: `test(scheduled-publish): seed ${filePath} for run ${runId}`, + prTitle: `test(scheduled-publish): seed ${filePath} for run ${runId}`, + prBody: + "Automated fixture seed for `e2e/cms-scheduled-publish-loop.spec.js` " + + `(run \`${runId}\`): a \`published: false\` post whose \`publish_date\` the ` + + "scheduled-publish workflow will flip live via its PR + auto-merge flow. " + + "Auto-merges via the `cms/ready` label; the loop deletes the post at the end.", + timeoutMs: SEED_MERGE_TIMEOUT_MS, + }); + }); + + // ── 3. "Not before" — the pre-deadline run must be a no-op ────────── + await test.step("Not-before — URL still 404s (published:false never renders)", async () => { + const res = await fetch(publicUrl, { cache: "no-store" }); + expect( + res.status, + `${publicUrl} must still 404 after the seed merged — the draft is published:false`, + ).toBe(404); + }); + + let prsBeforeDeadline = []; + await test.step("Not-before — dispatch the scheduler; run succeeds WITHOUT creating a PR", async () => { + // Provably pre-deadline: the whole leg-2 run budget must fit before + // publish_date. The window derivation guarantees this whenever the + // earlier budgets held (see the header); assert it so a budget edit + // that breaks the derivation fails HERE, not as a flaky changed=true. + expect( + Date.now() + SCHEDULER_RUN_TIMEOUT_MS, + "leg 2 no longer provably runs pre-deadline — DEADLINE_WINDOW_MS must cover " + + "the seed merge budget + the scheduler run budget + margin (see the header)", + ).toBeLessThan(publishDateMs); + + prsBeforeDeadline = await openScheduledPublishPrs(); + await dispatchSchedulerAndAwait("not-before"); + + const after = await openScheduledPublishPrs(); + const beforeNums = new Set(prsBeforeDeadline.map((pr) => pr.number)); + const created = after.filter((pr) => !beforeNums.has(pr.number)); + expect( + created.map((pr) => `#${pr.number} (${pr.head.ref})`), + "the pre-deadline scheduler run must take the changed=false path and create NO " + + "scheduled-publish PR — publish_date has not arrived yet", + ).toEqual([]); + + const res = await fetch(publicUrl, { cache: "no-store" }); + expect( + res.status, + `${publicUrl} must still 404 after the pre-deadline scheduler run`, + ).toBe(404); + }); + + // ── 4. Wait out the deadline ───────────────────────────────────────── + await test.step("Wait out the remaining seconds of the deadline window", async () => { + // A wall-clock wait is inherent to testing a wall-clock scheduler: + // publish_date was fixed in the seed's front matter, so the spec + // must genuinely cross it. Bounded by the window (≤40 min; typically + // far less — the seed + leg 2 already consumed most of it). + const remaining = publishDateMs + POST_DEADLINE_SKEW_MS - Date.now(); + if (remaining > 0) { + console.log( + `[scheduled-publish] waiting ${Math.ceil(remaining / 1000)}s for publish_date to arrive`, + ); + await sleep(remaining); + } + }); + + // ── 5. "At/after" — the run must open the auto-publish PR ─────────── + let publishPr = null; + await test.step("At/after — dispatch the scheduler; expect the cms/posts/scheduled-publish-* PR", async () => { + await dispatchSchedulerAndAwait("at-after"); + + // The run opens the PR synchronously before completing; poll only to + // absorb list-API lag. Identify OUR PR by the diff actually flipping + // this run's fixture file — robust even if a concurrent scheduler + // run (e.g. the consumer's own daily cron) created the PR first. + const deadline = Date.now() + PR_APPEAR_TIMEOUT_MS; + while (Date.now() < deadline && !publishPr) { + for (const pr of await openScheduledPublishPrs()) { + let files = []; + try { + files = await gh(`/repos/${HOST_REPO}/pulls/${pr.number}/files?per_page=100`); + } catch (err) { + console.warn( + `[scheduled-publish] transient files read on PR #${pr.number}: ${err && err.message}`, + ); + continue; + } + if (files.some((f) => f.filename === filePath && f.status === "modified")) { + publishPr = pr; + break; + } + } + if (!publishPr) await sleep(6000); + } + expect( + publishPr, + `no open ${SCHEDULED_PUBLISH_BRANCH_PREFIX}* PR flipping ${filePath} appeared within ` + + `${PR_APPEAR_TIMEOUT_MS / 60000} min of the post-deadline scheduler run — the ` + + "changed=true → PR path regressed", + ).toBeTruthy(); + }); + + // ── 6. Auto-merge lands the flip; prod serves the marker ──────────── + await test.step("Wait for the auto-publish PR to merge (auto-merge-when-ready)", async () => { + await waitForMerge({ prNumber: publishPr.number, timeoutMs: PUBLISH_MERGE_TIMEOUT_MS }); + }); + + await test.step("Wait for /blog// to serve 200 + run marker (deploy-production reflects)", async () => { + // Merge already confirmed above, so this wait spans only + // deploy-production + CDN — the merge, made as the PAT user, is what + // fires the deploy (the GITHUB_TOKEN trap this loop exists to catch). + await fetchPublicUrl(publicUrl, { + timeoutMs: REFLECT_TIMEOUT_MS, + expectContent: marker, + }); + }); + + // ── 7. Delete leg — resting state is absence ──────────────────────── + await test.step("Delete the published post via a labelled removal PR (removeFixtureViaPr)", async () => { + await removeFixtureViaPr({ + slug, + runId, + filePath, + message: `test(scheduled-publish): remove ${filePath} after run ${runId}`, + prTitle: `test(scheduled-publish): remove ${filePath} after run ${runId}`, + prBody: + "Forward delete leg of `e2e/cms-scheduled-publish-loop.spec.js` " + + `(run \`${runId}\`) — the loop's resting state is absence (404). ` + + "Auto-merges via the `cms/ready` label.", + timeoutMs: REMOVE_MERGE_TIMEOUT_MS, + }); + }); + + await test.step("Confirm /blog// 404s again (resting state restored)", async () => { + await waitForUrlGone(publicUrl, REFLECT_TIMEOUT_MS); + }); +}); + +// ── Test-harness cleanup safety net — existence-only DELETE ─────────── +// The forward delete leg IS the cleanup; if the test body completed the +// post is gone from main and this no-ops. If the test threw mid-flow, +// the uniquely-named draft (or flipped post) may still be on main — +// open a fire-and-forget removal PR so the next run starts clean. A +// failure here leaks ONE inert, noindexed, uniquely-named orphan that +// sweep-stale-cms-prs.yml reaps — never a shared corrupt baseline +// (#1771 step 4). Mirrors the prod-mutate safety net. +test.afterAll(async () => { + if (!getPat()) return; + if (process.env.RUN_SCHEDULED_PUBLISH_LOOP !== "1") return; + if (!pendingFixture) return; // test never ran (skipped) + + // 2 min — enough for the contents read + PR open under contention, + // never blocking on the 25-min waitForMerge (skipWaitForMerge below). + test.setTimeout(2 * 60 * 1000); + + const { filePath, slug, runId } = pendingFixture; + const stillThere = await fileExistsOnMain(filePath).catch(() => false); + if (!stillThere) { + console.log( + `[cleanup-harness] ${filePath} gone from main; forward delete leg succeeded — no safety net needed`, + ); + return; + } + console.warn( + `[cleanup-harness] ${filePath} still on main after the test; opening removal PR (existence-only delete, #1771 step 4)`, + ); + try { + await removeFixtureViaPr({ + slug, + runId, + filePath, + message: `test(scheduled-publish): cleanup leftover scheduled post run ${runId}`, + prTitle: `test(scheduled-publish): cleanup leftover scheduled post run ${runId}`, + prBody: + "Existence-only cleanup PR opened by `e2e/cms-scheduled-publish-loop.spec.js` after a " + + "test failure left the throw-away scheduled post on main. Auto-merges via `cms/ready` " + + "(#1771 step 4 — resting state is absence/404).", + // Fire-and-forget: the editorial workflow auto-merges it in the + // background; the daily sweep reaps any orphan. + skipWaitForMerge: true, + }); + console.warn(`[cleanup-harness] removed ${filePath} via removal PR`); + } catch (e) { + console.warn(`[cleanup-harness] could not remove ${filePath}: ${e && e.message}`); + } +}); diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 1d31f6f..ae73612 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -164,6 +164,12 @@ const PLATFORM_META_SPECS = [ "preview-deploy-superset.test.js", "prod-mutate-fixture.test.js", "public-content.test.js", + // Locks the scheduled-publish PR flow: publish-scheduled-posts.yml must + // publish via a cms/posts/scheduled-publish-* PR + auto-merge (never a + // ruleset-rejected main push) and the cms-scheduled-publish-loop wiring + // must stay budget-aligned. Reads the PLATFORM workflow DEFINITIONS + + // the examples/site caller template — platform self-CI only. + "publish-scheduled-posts-flow.test.js", "publish-via-auto-merge.test.js", "publish-via-auto-merge-browser.spec.js", "regression-video.spec.js", diff --git a/e2e/publish-scheduled-posts-flow.test.js b/e2e/publish-scheduled-posts-flow.test.js new file mode 100644 index 0000000..99d119f --- /dev/null +++ b/e2e/publish-scheduled-posts-flow.test.js @@ -0,0 +1,333 @@ +// @lane: local — pure-fs lint of the scheduled-publish PR flow; no browser, no network +/* + * Regression guard for the publish-scheduled-posts rework: the scheduler + * must publish via a PR + auto-merge, NEVER a direct push to main. + * + * The failure this locks out (verified live): the old workflow flipped + * `published: false → true` and `git push origin main` with the default + * GITHUB_TOKEN. Consumer repos protect main with a ruleset + * (`pull_request` rule + required status checks, no bypass actors), so + * that push was rejected the FIRST time a post ever came due — scheduled + * publishing was silently broken (zero auto-publish commits exist in + * consumer history). And even where a push could land, a GITHUB_TOKEN + * push does not trigger deploy-production (the token-suppression trap + * documented on cms-editorial-workflow.yml's auto-merge-when-ready job). + * + * What this lint asserts, per the AST/yaml-parser rule (structural + * checks parse the real YAML via workflow-yaml-utils; regex only for + * genuinely lexical tokens — a branch-prefix string, a ms literal): + * + * 1. publish-scheduled-posts.yml declares the CMS_E2E_PAT secret, no + * run block ever pushes main again, the stacking guard + fail-loud + * guard exist, and the PR-creation step is github-script + * authenticated as the PAT and applies cms/draft + cms/ready. + * 2. The per-run branch prefix stays Decap-shaped: its first segment + * is derived from e2e/cms-fixture-pr.js's FIXTURE_BRANCH_PREFIX + * (the same source label-non-decap-prs.yml keys off), so + * label-non-decap + the content-PR guards classify the publish PR + * with the rest of the CMS content PRs. Workflow and spec must + * agree on the full prefix. + * 3. Loop wiring: cms-scheduled-publish-loop.yml runs ONLY the new + * spec, joins the shared prod-mutating-loop lane, and its job + * timeout accommodates the spec's TEST_TIMEOUT_MS (the + * cms-loop-budget-alignment doctrine); the thin caller's + * platform_ref matches its uses: pin; the spec is @lane: real. + * + * Platform-internal (reads the platform's own workflow DEFINITIONS + + * the examples/site templates), so it is registered in + * PLATFORM_META_SPECS (playwright.config.js). + */ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("./base"); +const { readWorkflow, parseYaml, runScripts } = require("./workflow-yaml-utils"); +const { FIXTURE_BRANCH_PREFIX } = require("./cms-fixture-pr"); +const { parseLaneDirective } = require("./select-specs"); + +const SCHEDULER_WF = "publish-scheduled-posts.yml"; +const LOOP_WF = "cms-scheduled-publish-loop.yml"; +const LOOP_SPEC = "cms-scheduled-publish-loop.spec.js"; +const LOOP_JOB = "scheduled-publish-loop"; +const BRANCH_PREFIX = "cms/posts/scheduled-publish-"; +const CALLER = path.join( + __dirname, + "..", + "examples", + "site", + ".github", + "workflows", + LOOP_WF, +); + +const GITHUB_SCRIPT_ACTION = /^actions\/github-script@/; + +function schedulerDoc() { + return parseYaml(readWorkflow(SCHEDULER_WF)); +} + +function publishSteps() { + return (schedulerDoc().jobs.publish || {}).steps || []; +} + +// The github-script steps of the publish job, with their inline script text. +function githubScriptSteps() { + return publishSteps() + .filter((s) => s && typeof s.uses === "string" && GITHUB_SCRIPT_ACTION.test(s.uses)) + .map((s) => ({ step: s, script: String((s.with && s.with.script) || "") })); +} + +// Resolve a ` * 60 * 1000` / bare-int / named-const ms expression from a +// spec source — the same idiom cms-loop-budget-alignment.test.js resolves. +function resolveMs(expr, src) { + const e = String(expr).trim(); + let m = e.match(/^(\d+)\s*\*\s*60\s*\*\s*1000$/); + if (m) return Number(m[1]) * 60 * 1000; + m = e.match(/^(\d+)$/); + if (m) return Number(m[1]); + if (/^[A-Za-z_$][\w$]*$/.test(e)) { + const def = src.match(new RegExp(`const\\s+${e}\\s*=\\s*([^;]+);`)); + if (def) return resolveMs(def[1], src); + } + throw new Error(`publish-scheduled-posts-flow: could not resolve ms expression "${expr}"`); +} + +test.describe("publish-scheduled-posts.yml publishes via PR + auto-merge (never a main push)", () => { + test("declares the CMS_E2E_PAT workflow_call secret", () => { + const on = schedulerDoc().on || {}; + const secrets = (on.workflow_call && on.workflow_call.secrets) || {}; + expect( + Object.prototype.hasOwnProperty.call(secrets, "CMS_E2E_PAT"), + `${SCHEDULER_WF} must declare a CMS_E2E_PAT secret on workflow_call — the PR flow ` + + "cannot work without it (a GITHUB_TOKEN-created PR triggers no required checks)", + ).toBe(true); + }); + + test("no run block pushes main (the ruleset-rejected shape must never return)", () => { + const offenders = []; + for (const { script, line } of runScripts(readWorkflow(SCHEDULER_WF))) { + if (/git push origin main\b/.test(script)) offenders.push(`run block at line ${line}`); + } + expect( + offenders, + `${SCHEDULER_WF} contains \`git push origin main\` — the main ruleset rejects direct ` + + "pushes and a GITHUB_TOKEN push would not fire deploy-production; publish via the " + + "cms/posts/scheduled-publish-* PR + auto-merge flow instead (see the workflow header)", + ).toEqual([]); + }); + + test("GITHUB_TOKEN permissions stay read-only (the PAT does every write)", () => { + const perms = schedulerDoc().permissions || {}; + expect( + perms.contents, + `${SCHEDULER_WF} must grant GITHUB_TOKEN only contents:read — the branch push, PR, ` + + "and labels all ride CMS_E2E_PAT (contents:write belonged to the retired push shape)", + ).toBe("read"); + expect( + Object.values(perms).some((v) => v === "write"), + `${SCHEDULER_WF} must not grant GITHUB_TOKEN any write scope`, + ).toBe(false); + }); + + test("fails loud when posts are due but CMS_E2E_PAT is missing (no degraded mode)", () => { + const guard = githubScriptSteps().find( + ({ script }) => /core\.setFailed\(/.test(script) && /CMS_E2E_PAT/.test(script), + ); + expect( + guard, + `${SCHEDULER_WF} must carry a github-script guard that core.setFailed()s naming ` + + "CMS_E2E_PAT when posts are due and the secret is absent", + ).toBeTruthy(); + // Secrets aren't readable in a reusable's step `if:` — presence must be + // surfaced through a step env expression the script branches on. + const env = guard.step.env || {}; + expect( + Object.values(env).some((v) => /secrets\.CMS_E2E_PAT\s*!=\s*''/.test(String(v))), + "the fail-loud guard must detect the secret via a `secrets.CMS_E2E_PAT != ''` " + + "expression passed into step env (not a step if:, where secrets are unreadable)", + ).toBe(true); + }); + + test("stacking guard: an already-open scheduled-publish PR suppresses a second one", () => { + const stack = githubScriptSteps().find( + ({ script }) => script.includes(BRANCH_PREFIX) && /pulls\.list/.test(script), + ); + expect( + stack, + `${SCHEDULER_WF} must carry a github-script stacking guard that lists open PRs on ` + + `the ${BRANCH_PREFIX} head prefix — a second PR flipping the same posts would conflict ` + + "with yesterday's still-pending one", + ).toBeTruthy(); + expect( + String((stack.step.with && stack.step.with["github-token"]) || ""), + "the stacking guard must authenticate as CMS_E2E_PAT", + ).toContain("secrets.CMS_E2E_PAT"); + expect( + /core\.notice\(/.test(stack.script), + "the stacking guard must core.notice() the skip (log + exit success, not fail)", + ).toBe(true); + // Its skip output must actually gate the write steps. + const gated = publishSteps().filter((s) => /steps\.stack\.outputs\.skip/.test(String(s.if || ""))); + expect( + gated.length, + "the commit/push and PR-creation steps must be gated on the stacking guard's output", + ).toBeGreaterThanOrEqual(2); + }); + + test("the flips land on a cms/posts/scheduled-publish- branch", () => { + const commitScript = runScripts(readWorkflow(SCHEDULER_WF)).find(({ script }) => + /git checkout -b/.test(script), + ); + expect(commitScript, `${SCHEDULER_WF} must create the publish branch in a run block`).toBeTruthy(); + expect( + commitScript.script, + `the publish branch must use the ${BRANCH_PREFIX} template`, + ).toContain(BRANCH_PREFIX); + expect( + /git push origin "?cms\/posts\/scheduled-publish-/.test(commitScript.script), + "the branch (not main) must be what gets pushed", + ).toBe(true); + }); + + test("PR creation is github-script, PAT-authenticated, labelled cms/draft + cms/ready", () => { + const create = githubScriptSteps().find(({ script }) => /pulls\.create/.test(script)); + expect( + create, + `${SCHEDULER_WF} must open the auto-publish PR via actions/github-script`, + ).toBeTruthy(); + expect( + String((create.step.with && create.step.with["github-token"]) || ""), + "the PR-creation step must authenticate as CMS_E2E_PAT — a GITHUB_TOKEN-created PR " + + "cannot trigger the required checks, so it would never auto-merge", + ).toContain("secrets.CMS_E2E_PAT"); + // The branch template rides in via step env (the run_id expression). + const envVals = Object.values(create.step.env || {}).map(String); + expect( + envVals.some((v) => v.startsWith(BRANCH_PREFIX)), + `the PR head branch env must carry the ${BRANCH_PREFIX} template`, + ).toBe(true); + // Labels: createLabel try/catch first (the cms-editorial-workflow + // pattern), then cms/draft + cms/ready applied — cms/ready is what + // fires auto-merge-when-ready's `labeled` trigger. + expect(/createLabel/.test(create.script), "labels must be ensured via createLabel").toBe(true); + expect(/addLabels/.test(create.script), "labels must be applied via addLabels").toBe(true); + for (const label of ["cms/draft", "cms/ready"]) { + expect( + create.script.includes(`'${label}'`) || create.script.includes(`"${label}"`), + `the auto-publish PR must be labelled ${label}`, + ).toBe(true); + } + }); +}); + +test.describe("the scheduled-publish branch prefix stays Decap-shaped (lexical)", () => { + // label-non-decap-prs.yml derives Decap's branchPrefix from + // FIXTURE_BRANCH_PREFIX's first segment; the publish branch must live + // under the same segment so the labeller + content-PR guards classify + // the auto-publish PR as CMS-shaped. + const decapSegment = `${FIXTURE_BRANCH_PREFIX.split("/")[0]}/`; + + test(`the branch prefix starts with the Decap segment (${decapSegment})`, () => { + expect( + BRANCH_PREFIX.startsWith(decapSegment), + `the scheduled-publish branch prefix (${BRANCH_PREFIX}) must start with the Decap ` + + `branch segment ${decapSegment} (derived from e2e/cms-fixture-pr.js FIXTURE_BRANCH_PREFIX)`, + ).toBe(true); + expect( + readWorkflow(SCHEDULER_WF).includes(BRANCH_PREFIX), + `${SCHEDULER_WF} must use the ${BRANCH_PREFIX} prefix this lint asserts on`, + ).toBe(true); + }); + + test("workflow and loop spec agree on the full prefix (lockstep)", () => { + const specSrc = fs.readFileSync(path.join(__dirname, LOOP_SPEC), "utf8"); + expect( + specSrc.includes(`"${BRANCH_PREFIX}"`), + `${LOOP_SPEC} must key its PR discovery off the same ${BRANCH_PREFIX} prefix the workflow pushes`, + ).toBe(true); + }); +}); + +test.describe("cms-scheduled-publish-loop wiring (reusable + caller + spec)", () => { + test("the reusable runs ONLY the scheduled-publish loop spec", () => { + const doc = parseYaml(readWorkflow(LOOP_WF)); + const job = doc.jobs[LOOP_JOB]; + expect(job, `${LOOP_WF} must define the ${LOOP_JOB} job`).toBeTruthy(); + const specStep = (job.steps || []).find( + (s) => s && typeof s.run === "string" && /playwright\s+test\b/.test(s.run), + ); + expect(specStep, `${LOOP_WF} must run the spec via \`npx playwright test\``).toBeTruthy(); + const specTokens = specStep.run.match(/[\w./-]+\.spec\.js/g) || []; + expect( + specTokens, + `${LOOP_WF} must run ONLY ${LOOP_SPEC} — this loop validates one chain; bundling ` + + "another spec would stretch the shared prod-mutating lane hold", + ).toEqual([LOOP_SPEC]); + expect( + String((specStep.env || {}).RUN_SCHEDULED_PUBLISH_LOOP || ""), + `${LOOP_WF} must opt the spec in via RUN_SCHEDULED_PUBLISH_LOOP=1`, + ).toBe("1"); + }); + + test("the loop job joins the shared prod-mutating lane (queued, never cancelled)", () => { + const job = parseYaml(readWorkflow(LOOP_WF)).jobs[LOOP_JOB]; + expect( + job.concurrency && job.concurrency.group, + `${LOOP_WF}: ${LOOP_JOB} must join the shared prod-mutating-loop concurrency lane — ` + + "it mutates prod through the same PR → auto-merge → deploy-production chain as the " + + "three sibling loops", + ).toBe("prod-mutating-loop"); + expect( + job.concurrency["cancel-in-progress"], + `${LOOP_WF}: cancel-in-progress must be false (a mid-flow cancel leaves the fixture dirty)`, + ).toBe(false); + }); + + test("job timeout-minutes accommodates the spec's TEST_TIMEOUT_MS (budget alignment)", () => { + const specSrc = fs.readFileSync(path.join(__dirname, LOOP_SPEC), "utf8"); + const m = specSrc.match(/const\s+TEST_TIMEOUT_MS\s*=\s*([^;]+);/); + expect(m, `${LOOP_SPEC} must declare TEST_TIMEOUT_MS`).toBeTruthy(); + const specMin = resolveMs(m[1], specSrc) / 60000; + const job = parseYaml(readWorkflow(LOOP_WF)).jobs[LOOP_JOB]; + expect( + Number(job["timeout-minutes"]), + `${LOOP_WF}'s timeout-minutes (${job["timeout-minutes"]}) must be >= the spec's ` + + `TEST_TIMEOUT_MS (${specMin}min) so the job cap can never truncate a deploy leg ` + + "(the cms-loop-budget-alignment doctrine, #1815)", + ).toBeGreaterThanOrEqual(specMin); + }); + + test("the spec is @lane: real", () => { + expect( + parseLaneDirective(path.join(__dirname, LOOP_SPEC)), + `${LOOP_SPEC} drives real GitHub + prod HTTP — it must carry the \`// @lane: real\` header`, + ).toBe("real"); + }); + + test("the thin caller pins platform_ref to the same ref as its uses: pin", () => { + const doc = parseYaml(fs.readFileSync(CALLER, "utf8")); + const jobs = Object.values(doc.jobs || {}); + expect(jobs.length, "the caller must declare exactly one job").toBe(1); + const job = jobs[0]; + const uses = String(job.uses || ""); + const ref = uses.split("@")[1]; + expect(ref, `caller uses: must be @-pinned (${uses})`).toBeTruthy(); + expect( + (job.with || {}).platform_ref, + "the caller's platform_ref input must equal the @ref on its uses: line so the " + + "harness checkout matches the reusable that runs it", + ).toBe(ref); + expect( + String(((job.secrets || {}).CMS_E2E_PAT) || ""), + "the caller must pass secrets: CMS_E2E_PAT through to the reusable", + ).toContain("secrets.CMS_E2E_PAT"); + // Schedule + dispatch only: the reusable dropped the recursion-gate + // job the push-triggered loops carry, so a push trigger here would + // reopen the self-churn recursion class (see the reusable's notes). + const on = doc.on || doc[true] || {}; + expect( + Object.keys(on).sort(), + "the caller must trigger on schedule + workflow_dispatch ONLY (no push — the " + + "reusable has no recursion gate)", + ).toEqual(["schedule", "workflow_dispatch"]); + }); +}); diff --git a/e2e/select-specs.js b/e2e/select-specs.js index 1a1b6bc..8c6b77b 100644 --- a/e2e/select-specs.js +++ b/e2e/select-specs.js @@ -199,6 +199,10 @@ const HEAVY = new Set([ "e2e/cms-publish-loop.spec.js", "e2e/cms-publish-loop-preview.spec.js", "e2e/cms-publish-loop-prod-mutate.spec.js", + // Scheduled-publish PR-flow loop — gated to RUN_SCHEDULED_PUBLISH_LOOP + // (only cms-scheduled-publish-loop.yml sets it), so a PR-matrix pick + // is a no-op skip; don't let it inflate the shard budget. + "e2e/cms-scheduled-publish-loop.spec.js", "e2e/cms-delete-published.spec.js", "e2e/cms-delete-published-preview.spec.js", // Issue #999 preview-parity loops — heavy, self-skip on PR runs @@ -543,6 +547,22 @@ const SPEC_RULES = { /^\.github\/workflows\/cms-publish-loop-prod\.yml$/, /^e2e\/(decap-pat|github-actions-poll|cms-fixture-pr|cms-host)\.js$/, ], + // Scheduled-publish PR-flow loop. Self-skips on PR runs (gated on + // RUN_SCHEDULED_PUBLISH_LOOP, set only by cms-scheduled-publish-loop.yml); + // selected here so a change to the scheduler workflow it dispatches, the + // editorial/deploy chain it rides, its own loop workflow, or the shared + // helpers (incl. the base_collections guard registry its skip is keyed on) + // refreshes PR-time coverage of the gating/skip path. + "e2e/cms-scheduled-publish-loop.spec.js": [ + /^\.github\/workflows\/publish-scheduled-posts\.yml$/, + /^\.github\/workflows\/cms-scheduled-publish-loop\.yml$/, + /^\.github\/workflows\/cms-editorial-workflow\.yml$/, + /^\.github\/workflows\/deploy-production\.yml$/, + /^scripts\/publish_scheduled_posts\.py$/, + /^e2e\/(decap-pat|github-actions-poll|cms-fixture-pr|cms-host|prod-mutate-fixture)\.js$/, + /^e2e\/site-capabilities\.js$/, + /^e2e\/base-collections-guards\.js$/, + ], // Issue #999 preview-parity loops. Each is the preview-env // counterpart of a prod-only real-backend loop, driving the same // Decap mutation through `preview-pr.adamdaniel.ai` against the diff --git a/examples/site/.github/workflows/cms-scheduled-publish-loop.yml b/examples/site/.github/workflows/cms-scheduled-publish-loop.yml new file mode 100644 index 0000000..628fe23 --- /dev/null +++ b/examples/site/.github/workflows/cms-scheduled-publish-loop.yml @@ -0,0 +1,44 @@ +# Thin caller — copied into a site repo as +# `.github/workflows/cms-scheduled-publish-loop.yml`. Owns the schedule / +# workflow_dispatch triggers + run-name; delegates the scheduled-publish +# validation loop to the platform's reusable workflow. The e2e harness + +# the post-failure-comment composite live in the platform repo. Keep +# `platform_ref` equal to the `@vX.Y.Z` pin on the `uses:` line. +# +# Site identity (CMS_PROD_URL / CMS_APEX) + the PROD_PLAYGROUND_MODE +# sunset gate are read from THIS repo's VARS by the reusable workflow. +# +# Schedule + dispatch ONLY — deliberately NO push trigger: the reusable +# drops the recursion-gate job the push-triggered prod loops carry, so a +# push trigger must not be added here without porting that gate back +# (see the reusable's SHAPE NOTES). The loop joins the shared +# `prod-mutating-loop` concurrency lane, so a cron overlap with the +# other prod loops queues rather than colliding. +name: CMS Scheduled Publish Loop — Prod +run-name: >- + ${{ github.event_name == 'schedule' + && format('scheduled — {0}', github.event.schedule) + || format('manual — @{0}', github.actor) }} + +on: + schedule: + # 05:00 UTC daily — a free slot in the consumer's daily cron map + # (04 sweep, 07 platform-bump, 08 rearm/health, 11 cleanup, + # 12 canary + host loop, 13 label audit, 14 scheduled posts, + # 15 media loop), well clear of the other prod-mutating loop crons + # so the shared lane rarely queues. Tune per site. + - cron: '0 5 * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + scheduled-publish-loop: + uses: Adam-S-Daniel/cms-platform/.github/workflows/cms-scheduled-publish-loop.yml@v0.1.59 + with: + # Pin to the SAME ref as the `uses:` pin above so the harness matches. + platform_ref: v0.1.59 + secrets: + CMS_E2E_PAT: ${{ secrets.CMS_E2E_PAT }} diff --git a/examples/site/.github/workflows/publish-scheduled-posts.yml b/examples/site/.github/workflows/publish-scheduled-posts.yml index b4c6d4c..47f43a0 100644 --- a/examples/site/.github/workflows/publish-scheduled-posts.yml +++ b/examples/site/.github/workflows/publish-scheduled-posts.yml @@ -1,3 +1,14 @@ +# Thin caller — copied into a site repo as +# `.github/workflows/publish-scheduled-posts.yml`. Owns the daily cron + +# dispatch trigger + run-name; delegates the scheduled-post publish to +# the platform's reusable workflow. Keep `platform_ref` equal to the +# `@vX.Y.Z` pin on the `uses:` line. +# +# `secrets: CMS_E2E_PAT` is REQUIRED whenever a post is actually due: +# the reusable publishes via a PR + auto-merge (main is ruleset-protected +# against direct pushes, and a GITHUB_TOKEN-created PR cannot trigger the +# required status checks), and it fails loud rather than degrade when the +# secret is missing. See the reusable's header for the full rationale. name: Publish Scheduled Posts run-name: >- ${{ github.event_name == 'schedule' @@ -7,10 +18,15 @@ on: schedule: - cron: '0 14 * * *' workflow_dispatch: +# The reusable only reads the repo with GITHUB_TOKEN — every write (the +# branch push, the PR, the labels) goes through CMS_E2E_PAT. The old +# `contents: write` grant belonged to the retired push-to-main shape. permissions: - contents: write + contents: read jobs: publish: uses: Adam-S-Daniel/cms-platform/.github/workflows/publish-scheduled-posts.yml@v0.1.1 with: platform_ref: v0.1.1 + secrets: + CMS_E2E_PAT: ${{ secrets.CMS_E2E_PAT }}