diff --git a/.github/workflows/tss-shadow-publish-v2.yml b/.github/workflows/tss-shadow-publish-v2.yml new file mode 100644 index 0000000..04cee08 --- /dev/null +++ b/.github/workflows/tss-shadow-publish-v2.yml @@ -0,0 +1,340 @@ +name: Publish TSS shadow v2 + +# Default-branch-owned repository dispatch. Publishes an exact, CI-proven source SHA +# (current main, or the head of an open same-repo PR) to the dedicated Cloudflare Pages +# project that serves https://tss.tinyland.dev — the public, site-wide-noindex development +# shadow (TIN-3026). Structure mirrors shadow-source-build-v2 → shadow-source-publish-v2: +# the job that executes source-tree code holds no secret; the job that holds the +# Cloudflare token never checks out or executes source-tree code — it deploys a +# digest-verified artifact. Fail-closed behind BLOG_TSS_PUBLISH_ENABLED, revalidated +# immediately before publish; the production project name is refused by construction. + +on: + repository_dispatch: + types: [tss-shadow-publish-v2] + +permissions: + contents: read + +concurrency: + group: tss-shadow-publish + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + resolve: + name: Resolve exact shadow source + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + pull-requests: read + outputs: + source_sha: ${{ steps.source.outputs.source_sha }} + source_ref: ${{ steps.source.outputs.source_ref }} + pr_number: ${{ steps.source.outputs.pr_number }} + ci_url: ${{ steps.source.outputs.ci_url }} + project: ${{ steps.source.outputs.project }} + branch: ${{ steps.source.outputs.branch }} + deploy: ${{ steps.source.outputs.deploy }} + steps: + - name: Resolve exact shadow source + id: source + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + REQUEST_SOURCE_SHA: ${{ github.event.client_payload.source_sha }} + REQUEST_SOURCE_PR: ${{ github.event.client_payload.source_pr }} + REQUEST_DEPLOY: ${{ github.event.client_payload.deploy }} + TSS_ENABLED: ${{ vars.BLOG_TSS_PUBLISH_ENABLED || 'false' }} + TSS_PROJECT: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME || 'tss-shadow' }} + TSS_BRANCH: ${{ vars.CLOUDFLARE_PAGES_TSS_BRANCH || 'main' }} + with: + script: | + const { owner, repo } = context.repo; + const expectedRepository = `${owner}/${repo}`; + const PRODUCTION_PROJECT = "transscendsurvival-org"; + + async function requireAuthorityJobs(runId) { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: runId, + filter: "latest", + per_page: 100, + }); + for (const requiredName of ["build-and-test", "bazel-remote-gates"]) { + const job = jobs.find((candidate) => candidate.name === requiredName); + if (!job || job.conclusion !== "success") { + throw new Error(`Required CI job ${requiredName} was missing or not successful for run ${runId}.`); + } + } + } + + if (context.eventName !== "repository_dispatch" || context.payload.action !== "tss-shadow-publish-v2") { + throw new Error("TSS shadow publish requires the exact repository dispatch type."); + } + const sourceSha = (process.env.REQUEST_SOURCE_SHA || "").trim().toLowerCase(); + if (!/^[0-9a-f]{40}$/.test(sourceSha)) { + throw new Error("source_sha must be an exact 40-character lowercase commit SHA."); + } + + const project = (process.env.TSS_PROJECT || "").trim(); + if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(project)) { + throw new Error("CLOUDFLARE_PAGES_TSS_PROJECT_NAME must be a lowercase Pages project slug."); + } + if (project === PRODUCTION_PROJECT) { + throw new Error("The shadow lane must never target the production Pages project."); + } + const branch = (process.env.TSS_BRANCH || "").trim(); + if (!/^[A-Za-z0-9._\/-]{1,120}$/.test(branch)) { + throw new Error("CLOUDFLARE_PAGES_TSS_BRANCH must be a plain branch name."); + } + + let sourceRef; + let prNumber = ""; + let ciEvent; + const requestedPr = (process.env.REQUEST_SOURCE_PR || "").trim(); + if (requestedPr !== "") { + if (!/^[1-9][0-9]*$/.test(requestedPr)) { + throw new Error("client_payload.source_pr must be a positive integer when present."); + } + const pr = (await github.rest.pulls.get({ owner, repo, pull_number: Number(requestedPr) })).data; + if ( + pr.state !== "open" || + pr.base.ref !== "main" || + pr.head.repo?.full_name !== expectedRepository || + pr.head.sha !== sourceSha + ) { + throw new Error(`PR #${pr.number} is not an open same-repo main PR at exact head ${sourceSha}.`); + } + sourceRef = `pr-${pr.number}`; + prNumber = String(pr.number); + ciEvent = "pull_request"; + } else { + const mainRef = await github.rest.git.getRef({ owner, repo, ref: "heads/main" }); + if (mainRef.data.object.sha !== sourceSha) { + throw new Error(`Requested ${sourceSha} is not the current main SHA ${mainRef.data.object.sha}.`); + } + sourceRef = "main"; + ciEvent = "push"; + } + + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: "ci.yml", + event: ciEvent, + status: "completed", + head_sha: sourceSha, + per_page: 100, + }); + const ciRun = runs.find((run) => + run.head_sha === sourceSha && + run.event === ciEvent && + run.conclusion === "success" && + run.head_repository?.full_name === expectedRepository + ); + if (!ciRun) { + throw new Error(`No successful canonical CI ${ciEvent} run found for exact SHA ${sourceSha}.`); + } + await requireAuthorityJobs(ciRun.id); + + if (process.env.REQUEST_DEPLOY !== "true") { + throw new Error("client_payload.deploy must be the string true; use the parity workflow for build-only requests."); + } + if (process.env.TSS_ENABLED !== "true") { + throw new Error("BLOG_TSS_PUBLISH_ENABLED must be true for a shadow publish request."); + } + + core.setOutput("source_sha", sourceSha); + core.setOutput("source_ref", sourceRef); + core.setOutput("pr_number", prNumber); + core.setOutput("ci_url", ciRun.html_url); + core.setOutput("project", project); + core.setOutput("branch", branch); + core.setOutput("deploy", "true"); + await core.summary + .addHeading("TSS shadow provenance") + .addRaw(`Source: \`${sourceRef}\` at \`${sourceSha}\` → project \`${project}\` (branch \`${branch}\`)\n\n`) + .addLink("Successful canonical CI", ciRun.html_url) + .write(); + + build: + name: Build exact shadow artifact without credentials + needs: resolve + if: needs.resolve.result == 'success' && needs.resolve.outputs.deploy == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + digest: ${{ steps.artifact.outputs.digest }} + env: + PUBLIC_DEPLOY_TIER: shadow + PUBLIC_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.resolve.outputs.source_sha }} + persist-credentials: false + + - name: Verify exact checkout + env: + EXPECTED_SHA: ${{ needs.resolve.outputs.source_sha }} + run: | + set -euo pipefail + actual_sha="$(git rev-parse HEAD)" + test "${actual_sha}" = "${EXPECTED_SHA}" + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: '.nvmrc' + cache: npm + + - run: npm ci + - run: npm run build + - run: node scripts/validate-deploy-tier-output.mjs shadow "${PUBLIC_SOURCE_SHA}" + - run: npx tsx scripts/validate-redirects.mts + - run: npx tsx scripts/validate-directory-index-aliases.mts + + - name: Make the shadow uncrawlable beyond the HTML meta + run: | + set -euo pipefail + printf 'User-agent: *\nDisallow: /\n' > build/robots.txt + printf '/*\n X-Robots-Tag: noindex, nofollow\n' > build/_headers + test "$(cat build/robots.txt)" = "$(printf 'User-agent: *\nDisallow: /')" + + - name: Record static artifact digest + id: artifact + run: | + set -euo pipefail + digest="$(find build -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')" + if [[ ! "${digest}" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Static build did not produce a valid content digest." + exit 1 + fi + echo "digest=sha256:${digest}" >> "$GITHUB_OUTPUT" + + - name: Upload exact shadow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: tss-shadow-build-${{ github.run_id }}-${{ github.run_attempt }} + path: build + if-no-files-found: error + retention-days: 3 + + deploy: + name: Publish the digest-verified shadow artifact to Cloudflare Pages + needs: [resolve, build] + # The kill switch is re-read here by Actions itself at job start (vars.* is + # resolved when the job is scheduled, after the build), not through the REST + # variables API — GITHUB_TOKEN carries no Variables permission, so a + # REST variables-API recheck would 403 and refuse every publish. + if: needs.build.result == 'success' && vars.BLOG_TSS_PUBLISH_ENABLED == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + pull-requests: read + env: + EXPECTED_DIGEST: ${{ needs.build.outputs.digest }} + TSS_PROJECT: ${{ needs.resolve.outputs.project }} + TSS_BRANCH: ${{ needs.resolve.outputs.branch }} + steps: + - name: Download exact shadow artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v6 + with: + name: tss-shadow-build-${{ github.run_id }}-${{ github.run_attempt }} + path: build + + - name: Verify artifact digest matches the credential-free build + run: | + set -euo pipefail + digest="sha256:$(find build -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')" + test "${digest}" = "${EXPECTED_DIGEST}" + test -f build/robots.txt && grep -q '^Disallow: /$' build/robots.txt + test -f build/_headers && grep -q 'X-Robots-Tag: noindex, nofollow' build/_headers + + - name: Require Cloudflare deploy credentials + env: + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + set -euo pipefail + test -n "${CF_ACCOUNT_ID}" || { echo "::error::CLOUDFLARE_ACCOUNT_ID is required."; exit 1; } + test -n "${CF_API_TOKEN}" || { echo "::error::CLOUDFLARE_API_TOKEN is required."; exit 1; } + if [[ "${TSS_PROJECT}" == "transscendsurvival-org" ]]; then + echo "::error::The shadow lane must never target the production Pages project." + exit 1 + fi + + - name: Revalidate the source immediately before publish + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + EXPECTED_SHA: ${{ needs.resolve.outputs.source_sha }} + SOURCE_REF: ${{ needs.resolve.outputs.source_ref }} + PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} + TSS_ENABLED_AT_PUBLISH: ${{ vars.BLOG_TSS_PUBLISH_ENABLED || 'false' }} + with: + script: | + const { owner, repo } = context.repo; + if (process.env.TSS_ENABLED_AT_PUBLISH !== "true") { + throw new Error("BLOG_TSS_PUBLISH_ENABLED is not true at publish time."); + } + if (process.env.SOURCE_REF === "main") { + const mainRef = await github.rest.git.getRef({ owner, repo, ref: "heads/main" }); + if (mainRef.data.object.sha !== process.env.EXPECTED_SHA) { + throw new Error( + `Refusing stale shadow publish: expected ${process.env.EXPECTED_SHA}, current main is ${mainRef.data.object.sha}.`, + ); + } + } else { + const pr = (await github.rest.pulls.get({ owner, repo, pull_number: Number(process.env.PR_NUMBER) })).data; + if (pr.state !== "open" || pr.head.sha !== process.env.EXPECTED_SHA || pr.head.repo?.full_name !== `${owner}/${repo}`) { + throw new Error(`Refusing stale shadow publish: PR #${process.env.PR_NUMBER} is no longer open at ${process.env.EXPECTED_SHA}.`); + } + } + + - name: Publish exact shadow build to Cloudflare Pages + id: cloudflare + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: '4.95.0' + command: pages deploy build --project-name="${{ needs.resolve.outputs.project }}" --branch="${{ needs.resolve.outputs.branch }}" --commit-hash=${{ needs.resolve.outputs.source_sha }} --commit-dirty=false + + - name: Verify the served shadow carries the published source SHA + env: + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + run: | + set -euo pipefail + for attempt in $(seq 1 24); do + if curl -fsS --max-time 20 https://tss.tinyland.dev/blog | grep -q "> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 166e4fb..6e558b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ re-read immediately before their credentialed mutation. | CI proof (check/test/e2e authority) | `.github/workflows/ci.yml` | push to `main`/`dev` + same-repo PR | none; read-only proof consumed by the exact-source production and rollback lanes | | Post date validation | `.github/workflows/validate-blog-dates.yml` | same-repo PR to `main` touching `src/posts/**` | none; secretless read-only proof with no serving-state authority | | Production publish (Cloudflare Pages) | `.github/workflows/cloudflare-pages-production-v2.yml` | dispatch-gated (`cloudflare-pages-production-v2`, `deploy=true`, exact current-`main` SHA proven by canonical CI + private-CV authority); its `workflow_run` lane from CI is build-only | repo var `CLOUDFLARE_PAGES_PRODUCTION_ENABLED` (default false), revalidated immediately before publish | +| TSS shadow publish (Cloudflare Pages, `tss.tinyland.dev`) | `.github/workflows/tss-shadow-publish-v2.yml` | dispatch-gated (`tss-shadow-publish-v2`, `deploy=true`, exact current-`main` SHA or open same-repo PR head proven by canonical CI incl. `bazel-remote-gates`); a credential-free job builds the shadow artifact (HTML noindex + `robots.txt` Disallow + `X-Robots-Tag`), the credentialed job deploys only the digest-verified artifact to the dedicated `tss-shadow` Pages project (production project name is rejected) and proves the served source SHA | repo var `BLOG_TSS_PUBLISH_ENABLED` (default false, revalidated immediately before publish); `CLOUDFLARE_PAGES_TSS_PROJECT_NAME` defaults to `tss-shadow`, `CLOUDFLARE_PAGES_TSS_BRANCH` to `main` | | Exact-PR parity build | `.github/workflows/cloudflare-pages-parity-v2.yml` | dispatch-gated (`cloudflare-pages-parity-v2`), build-only against an exact open same-repo PR head | none needed; `permissions: {}`, secretless, no deploy step | | Cache purge (one production URL) | `.github/workflows/cloudflare-cache-purge-v2.yml` | dispatch-gated (`cloudflare-cache-purge-v2`), one canonical non-root path per run; path-only, with no source SHA | repo var `CLOUDFLARE_CACHE_PURGE_ENABLED`, revalidated immediately before credential use | | Rollback (GitHub Pages) | `.github/workflows/github-pages-rollback-v2.yml` | dispatch-gated (`github-pages-rollback-v2` + `confirm_rollback=true`, exact current-`main` SHA with successful canonical CI) | repo var `BLOG_GITHUB_PAGES_ROLLBACK_ENABLED` (default false) + `github-pages` environment, revalidated at publish time | @@ -73,7 +74,7 @@ re-read immediately before their credentialed mutation. - Normal local development is npm/SvelteKit: `npm ci`, `npm run build`, `npm run lint`, and focused scripts from `package.json`. - Production behavior changes require `npm run test:production-health`. That check covers public DNS, apex/`www` HTTPS, canonical redirects, slash variants, Tinyland broker contract, and browser hydration. - CI has two lanes. `build-and-test` runs hosted checks such as gitleaks, production dependency audit, lint, npm build, bundle reporting, and Lighthouse. `bazel-remote-gates` is the check/test/e2e authority. -- Credentialed Cloudflare publication comes only from the default-branch-owned `.github/workflows/cloudflare-pages-production-v2.yml`; it publishes only on the exact typed repository-dispatch request for a current `main` SHA proven by canonical CI and private-CV consistency, plus the live kill switch. Exact-PR parity is secretless in `.github/workflows/cloudflare-pages-parity-v2.yml`. Review-shadow PR code builds without package or secret authority in `.github/workflows/shadow-source-build-v2.yml`; the default-branch `.github/workflows/shadow-source-publish-v2.yml` independently revalidates provenance before any package write. Shadow apply is unavailable: no v2 workflow carries an App key, private sender, or dispatch call. GitHub Pages is not CI/CD: `.github/workflows/github-pages-rollback-v2.yml` is a disabled-by-default, exact-main, explicitly confirmed repository-dispatch rollback path only. +- Credentialed Cloudflare publication comes only from two default-branch-owned lanes: production from `.github/workflows/cloudflare-pages-production-v2.yml`, and the `tss.tinyland.dev` shadow from `.github/workflows/tss-shadow-publish-v2.yml` (typed dispatch, exact CI-proven SHA, credential-free build → digest-verified deploy, `tss-shadow` project only). Production it publishes only on the exact typed repository-dispatch request for a current `main` SHA proven by canonical CI and private-CV consistency, plus the live kill switch. Exact-PR parity is secretless in `.github/workflows/cloudflare-pages-parity-v2.yml`. Review-shadow PR code builds without package or secret authority in `.github/workflows/shadow-source-build-v2.yml`; the default-branch `.github/workflows/shadow-source-publish-v2.yml` independently revalidates provenance before any package write. Shadow apply is unavailable: no v2 workflow carries an App key, private sender, or dispatch call. GitHub Pages is not CI/CD: `.github/workflows/github-pages-rollback-v2.yml` is a disabled-by-default, exact-main, explicitly confirmed repository-dispatch rollback path only. - `.github/workflows/production-health-v2.yml` runs every 30 minutes and sends ntfy alerts on failure. Treat a red scheduled monitor as production evidence, not noise. - Hosted-runner exception, recorded 2026-08-28: the v2 workflow estate pins `runs-on: ubuntu-latest` at 17 sites. That is a recorded exception to TIN-3914 (no `ubuntu-latest` since `ci-templates` v3.0.0), not compliance with it. Closing it means adopting `spoke-ci.yml@v3.1.0` in the week of 2026-09-01, which first needs a `jesssullivan-blog-nix` ARS in `Jesssullivan/jesssullivan-infra` plus a governed apply, `tinyland.repo.json`, and `.github/lanes.json`. Do not hand-migrate individual `runs-on` lines ahead of that adoption. (Count as of 2026-08-28 before the TSS shadow lane; the invariant is that a GitHub-hosted label is used only where no self-hosted pool serves that job class — assert it, do not re-count by hand.) diff --git a/BUILD.bazel b/BUILD.bazel index 9992fed..573f137 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -343,6 +343,7 @@ js_test( "scripts/test-github-pages-rollback-resolver.mjs", "scripts/test-private-cv-authority-resolver.mjs", "scripts/test-shadow-preview-resolver.mjs", + "scripts/test-tss-shadow-publish-resolver.mjs", "scripts/test-workflow-authority.mjs", "scripts/validate-deploy-tier-output.mjs", "src/lib/components/ThemeSwitcher.svelte", diff --git a/docs/README.md b/docs/README.md index 1bb1341..0c7e33b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,8 @@ Live: `runbooks/dns-cutover-and-rollback.md` (read before any DNS or Pages change), `dns-architecture.md`, `blog-staging.md`, `blog-shadow-preview.md`, -`blog-node-shadow.md`, `blog-editorial-taxonomy-2026-07-03.md`, the +`blog-node-shadow.md`, `tss-shadow-publish.md` (the gated `tss.tinyland.dev` +lane and its operator ceremony), `blog-editorial-taxonomy-2026-07-03.md`, the `tinyland-pulse-*` contract set, `plans/2026-07-07-blog-operating-priorities.md`, and `build-metrics.md` (superseded numbers, kept as the Vite 6 baseline). diff --git a/docs/tss-shadow-publish.md b/docs/tss-shadow-publish.md new file mode 100644 index 0000000..805e7da --- /dev/null +++ b/docs/tss-shadow-publish.md @@ -0,0 +1,7 @@ +# TSS shadow publish (`tss.tinyland.dev`) + +`tss.tinyland.dev` is the public, site-wide-noindex development shadow (TIN-3026). `.github/workflows/tss-shadow-publish-v2.yml` publishes an exact CI-proven SHA — current `main`, or the head of an open same-repo PR — to the dedicated TSS Cloudflare Pages project; it never touches `transscendsurvival-org`. + +Operator ceremony: confirm the Pages project is `tss-shadow` (the lane's default; override with repo var `CLOUDFLARE_PAGES_TSS_PROJECT_NAME`), set `BLOG_TSS_PUBLISH_ENABLED=true`, then send `repository_dispatch` type `tss-shadow-publish-v2` with `client_payload: {source_sha, deploy: "true"}` (add `source_pr` for a PR head). Verify with `curl https://tss.tinyland.dev/_app/version.json` and the `tinyland-source-sha` meta on any route. + +Gates: canonical CI green at the exact SHA (`build-and-test` + `bazel-remote-gates`); the build job holds no credential and the deploy job never executes source-tree code (digest-verified artifact only); kill switch re-read by Actions at deploy-job start and the source revalidated immediately before `wrangler pages deploy`; production project name refused; the shadow ships `robots.txt` `Disallow: /` and `X-Robots-Tag: noindex, nofollow` on top of the HTML meta; the run fails unless `tss.tinyland.dev/blog` serves the published `tinyland-source-sha` within four minutes. Rotate the Cloudflare API token (TIN-2727) before the first publish. diff --git a/scripts/test-tss-shadow-publish-resolver.mjs b/scripts/test-tss-shadow-publish-resolver.mjs new file mode 100644 index 0000000..03758af --- /dev/null +++ b/scripts/test-tss-shadow-publish-resolver.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const workflow = await readFile(new URL('../.github/workflows/tss-shadow-publish-v2.yml', import.meta.url), 'utf8'); +const resolverSource = extractGithubScript('Resolve exact shadow source'); +assert.match(resolverSource, /requireAuthorityJobs/); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +const executeResolver = new AsyncFunction('github', 'context', 'core', 'process', resolverSource); + +const repository = 'Jesssullivan/jesssullivan.github.io'; +const sourceSha = 'a'.repeat(40); +const otherSha = 'b'.repeat(40); + +function authorityJobs(overrides = {}) { + return [ + { name: 'build-and-test', conclusion: overrides.build ?? 'success' }, + { name: 'bazel-remote-gates', conclusion: overrides.bazel ?? 'success' }, + ].filter((job) => !overrides.missing?.includes(job.name)); +} + +function ciRun(overrides = {}) { + return { + id: 101, + name: 'CI', + conclusion: 'success', + event: 'push', + head_branch: 'main', + head_repository: { full_name: repository }, + head_sha: sourceSha, + html_url: 'https://github.example/ci/101', + ...overrides, + }; +} + +function pullRequest(overrides = {}) { + return { + number: 263, + state: 'open', + base: { ref: 'main' }, + head: { sha: sourceSha, repo: { full_name: repository } }, + ...overrides, + }; +} + +async function runFixture({ + eventName = 'repository_dispatch', + dispatchAction = 'tss-shadow-publish-v2', + manualSha = sourceSha, + manualPr = '', + manualDeploy = 'true', + tssEnabled = 'true', + tssProject = 'tss-shadow', + tssBranch = 'main', + mainSha = sourceSha, + pr = pullRequest(), + runs = [ciRun()], + jobs = authorityJobs(), +} = {}) { + const outputs = {}; + const github = { + rest: { + actions: { + listJobsForWorkflowRun: async () => ({ data: { jobs } }), + listWorkflowRuns: async (args) => ({ + data: { workflow_runs: runs.filter((run) => run.event === args.event).map((run) => ({ status: 'completed', ...run })) }, + }), + }, + git: { getRef: async () => ({ data: { object: { sha: mainSha } } }) }, + pulls: { get: async () => ({ data: pr }) }, + }, + paginate: async (method, args) => { + const response = await method(args); + return response.data.jobs ?? response.data.workflow_runs; + }, + }; + const summary = { + addHeading() { + return this; + }, + addRaw() { + return this; + }, + addLink() { + return this; + }, + async write() {}, + }; + const context = { + eventName, + repo: { owner: 'Jesssullivan', repo: 'jesssullivan.github.io' }, + payload: { action: dispatchAction }, + }; + const core = { + summary, + setOutput(name, value) { + outputs[name] = value; + }, + }; + await executeResolver(github, context, core, { + env: { + REQUEST_SOURCE_SHA: manualSha, + REQUEST_SOURCE_PR: manualPr, + REQUEST_DEPLOY: manualDeploy, + TSS_ENABLED: tssEnabled, + TSS_PROJECT: tssProject, + TSS_BRANCH: tssBranch, + }, + }); + return outputs; +} + +assert.deepEqual(await runFixture(), { + source_sha: sourceSha, + source_ref: 'main', + pr_number: '', + ci_url: 'https://github.example/ci/101', + project: 'tss-shadow', + branch: 'main', + deploy: 'true', +}); +assert.equal((await runFixture({ tssProject: ' tss-shadow ' })).project, 'tss-shadow', 'surrounding whitespace is trimmed, never shell-interpolated'); +await assert.rejects(() => runFixture({ tssProject: 'transscendsurvival-org' }), /never target the production Pages project/, 'production project refused'); +await assert.rejects(() => runFixture({ tssProject: 'transscendsurvival-org ' }), /never target the production Pages project/, 'production project with trailing space still refused'); +await assert.rejects(() => runFixture({ tssProject: 'tss-shadow; rm -rf' }), /lowercase Pages project slug/, 'shell metacharacters refused'); +await assert.rejects(() => runFixture({ tssProject: '' }), /lowercase Pages project slug/, 'empty project refused'); +await assert.rejects(() => runFixture({ tssBranch: 'main $(x)' }), /plain branch name/, 'branch metacharacters refused'); +assert.deepEqual( + await runFixture({ manualPr: '263', runs: [ciRun({ event: 'pull_request', head_branch: 'feature' })], mainSha: otherSha }), + { source_sha: sourceSha, source_ref: 'pr-263', pr_number: '263', ci_url: 'https://github.example/ci/101', project: 'tss-shadow', branch: 'main', deploy: 'true' }, + 'open same-repo PR head with green pull_request CI resolves', +); +await assert.rejects(() => runFixture({ eventName: 'workflow_dispatch' }), /exact repository dispatch type/, 'manual carrier fails closed'); +await assert.rejects(() => runFixture({ dispatchAction: 'cloudflare-pages-production-v2' }), /exact repository dispatch type/, 'wrong dispatch type'); +await assert.rejects(() => runFixture({ manualSha: 'a'.repeat(39) }), /exact 40-character lowercase commit SHA/, 'non-exact SHA'); +await assert.rejects(() => runFixture({ mainSha: otherSha }), /not the current main SHA/, 'stale main SHA without a PR fails closed'); +await assert.rejects(() => runFixture({ manualPr: '0' }), /positive integer/, 'non-positive PR number'); +await assert.rejects(() => runFixture({ manualPr: '263', pr: pullRequest({ state: 'closed' }) }), /not an open same-repo main PR/, 'closed PR'); +await assert.rejects( + () => runFixture({ manualPr: '263', pr: pullRequest({ head: { sha: sourceSha, repo: { full_name: 'fork/blog' } } }) }), + /not an open same-repo main PR/, + 'fork PR', +); +await assert.rejects( + () => runFixture({ manualPr: '263', pr: pullRequest({ head: { sha: otherSha, repo: { full_name: repository } } }) }), + /not an open same-repo main PR/, + 'PR head drift', +); +await assert.rejects(() => runFixture({ runs: [] }), /No successful canonical CI push run/, 'missing CI run'); +await assert.rejects(() => runFixture({ runs: [ciRun({ conclusion: 'failure' })] }), /No successful canonical CI push run/, 'red CI run'); +await assert.rejects(() => runFixture({ jobs: authorityJobs({ bazel: 'failure' }) }), /bazel-remote-gates was missing or not successful/, 'gates red'); +await assert.rejects(() => runFixture({ jobs: authorityJobs({ missing: ['build-and-test'] }) }), /build-and-test was missing/, 'job missing'); +await assert.rejects(() => runFixture({ manualDeploy: 'false' }), /deploy must be the string true/, 'deploy not requested'); +await assert.rejects(() => runFixture({ tssEnabled: 'false' }), /BLOG_TSS_PUBLISH_ENABLED must be true/, 'kill switch off'); + +console.log('TSS shadow publish resolver fixtures passed'); + +function extractGithubScript(stepName) { + const marker = ` - name: ${stepName}`; + const markerIndex = workflow.indexOf(marker); + assert.notEqual(markerIndex, -1, `${stepName} step exists`); + const lines = workflow.slice(markerIndex).split('\n'); + const scriptStart = lines.findIndex((line) => line === ' script: |'); + assert.notEqual(scriptStart, -1, `${stepName} has an inline script`); + const scriptLines = []; + for (const line of lines.slice(scriptStart + 1)) { + if (line === '') { + scriptLines.push(''); + continue; + } + if (!line.startsWith(' ')) break; + scriptLines.push(line.slice(12)); + } + return scriptLines.join('\n'); +} diff --git a/scripts/test-workflow-authority.mjs b/scripts/test-workflow-authority.mjs index fb354b7..27d74e5 100644 --- a/scripts/test-workflow-authority.mjs +++ b/scripts/test-workflow-authority.mjs @@ -14,9 +14,10 @@ const paths = { pagesRollback: '.github/workflows/github-pages-rollback-v2.yml', productionHealth: '.github/workflows/production-health-v2.yml', privateCv: '.github/workflows/private-cv-authority-v2.yml', + tssPublish: '.github/workflows/tss-shadow-publish-v2.yml', }; -const [production, parity, cachePurge, shadowSource, shadowPublish, pagesRollback, productionHealth, privateCv] = +const [production, parity, cachePurge, shadowSource, shadowPublish, pagesRollback, productionHealth, privateCv, tssPublish] = await Promise.all(Object.values(paths).map(read)); const [dockerfile, layout, vite, packageJson, stamper, validator, themeSwitcher] = await Promise.all( [ @@ -173,6 +174,75 @@ requireAll( ); forbid(parity, /secrets\.|deployments: write|cloudflare\/wrangler-action|pages deploy/, 'parity must remain secretless'); +requireAll( + tssPublish, + [ + 'types: [tss-shadow-publish-v2]', + "TSS_ENABLED: ${{ vars.BLOG_TSS_PUBLISH_ENABLED || 'false' }}", + "TSS_PROJECT: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME || 'tss-shadow' }}", + 'process.env.TSS_ENABLED !== "true"', + 'process.env.REQUEST_DEPLOY !== "true"', + 'const PRODUCTION_PROJECT = "transscendsurvival-org";', + 'if (project === PRODUCTION_PROJECT)', + '/^[a-z0-9][a-z0-9-]{0,62}$/.test(project)', + 'workflow_id: "ci.yml"', + 'head_sha: sourceSha,', + 'for (const requiredName of ["build-and-test", "bazel-remote-gates"])', + 'pr.head.repo?.full_name !== expectedRepository', + 'mainRef.data.object.sha !== sourceSha', + 'PUBLIC_DEPLOY_TIER: shadow', + 'node scripts/validate-deploy-tier-output.mjs shadow "${PUBLIC_SOURCE_SHA}"', + "printf 'User-agent: *\\nDisallow: /\\n' > build/robots.txt", + 'X-Robots-Tag: noindex, nofollow', + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', + 'Verify artifact digest matches the credential-free build', + 'test "${digest}" = "${EXPECTED_DIGEST}"', + "if: needs.build.result == 'success' && vars.BLOG_TSS_PUBLISH_ENABLED == 'true'", + 'Revalidate the source immediately before publish', + "TSS_ENABLED_AT_PUBLISH: ${{ vars.BLOG_TSS_PUBLISH_ENABLED || 'false' }}", + 'process.env.TSS_ENABLED_AT_PUBLISH !== "true"', + 'Refusing stale shadow publish', + '--project-name="${{ needs.resolve.outputs.project }}"', + '--commit-hash=${{ needs.resolve.outputs.source_sha }} --commit-dirty=false', + 'Verify the served shadow carries the published source SHA', + 'cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4', + 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6', + 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6', + ], + 'TSS shadow publish workflow', +); +requireOrder( + tssPublish, + [ + 'Verify artifact digest matches the credential-free build', + 'Revalidate the source immediately before publish', + 'Publish exact shadow build to Cloudflare Pages', + 'Verify the served shadow carries the published source SHA', + ], + 'TSS deploy job order: digest check, live-authority recheck, publish, served-SHA proof', +); +{ + const triggerBlock = tssPublish.slice(tssPublish.indexOf('\non:\n') + 5, tssPublish.indexOf('\npermissions:')); + assert.equal( + triggerBlock.trim(), + 'repository_dispatch:\n types: [tss-shadow-publish-v2]', + 'TSS shadow publish must have exactly one trigger: the typed repository dispatch', + ); + const buildJob = tssPublish.slice(tssPublish.indexOf('\n build:\n'), tssPublish.indexOf('\n deploy:\n')); + const deployJob = tssPublish.slice(tssPublish.indexOf('\n deploy:\n')); + assert.ok(buildJob.length > 0 && deployJob.length > 0, 'TSS lane keeps separate build and deploy jobs'); + forbid(buildJob, /secrets\.|wrangler|id-token/, 'the job that executes source-tree code must hold no credential'); + forbid(deployJob, /actions\/checkout|npm ci|npm run|npx tsx/, 'the job that holds the Cloudflare token must never check out or execute source-tree code'); + assert.ok(/permissions:\n {6}contents: read\n/.test(buildJob), 'build job permissions are contents: read only'); +} +forbid( + tssPublish, + /CLOUDFLARE_PAGES_PRODUCTION_ENABLED|CLOUDFLARE_PAGES_PROJECT_NAME[^_]|--project-name=transscendsurvival-org|PUBLIC_DEPLOY_TIER: production/, + 'TSS shadow publish must never reach the production project or its kill switch', +); +forbid(tssPublish, /deployments:\s*write|secrets\.GITHUB_TOKEN|github\.token|create-github-app-token|createWorkflowDispatch|getRepoVariable/, 'TSS shadow publish must not carry unrelated mutation authority or an unexecutable variables-API recheck'); + requireAll( vite, [ @@ -219,7 +289,7 @@ requireAll( ); forbid(pagesRollback, /^ {2}push:|deploy-pages\.yml/m, 'Pages must remain an explicit rollback path'); forbid( - `${production}\n${parity}\n${shadowSource}\n${pagesRollback}\n${privateCv}`, + `${production}\n${parity}\n${shadowSource}\n${pagesRollback}\n${privateCv}\n${tssPublish}`, /^\s*workflow_dispatch:/m, 'v2 authority paths must be default-owned', ); @@ -315,6 +385,7 @@ for (const fixture of [ './test-cloudflare-parity-resolver.mjs', './test-github-pages-rollback-resolver.mjs', './test-private-cv-authority-resolver.mjs', + './test-tss-shadow-publish-resolver.mjs', ]) { await import(fixture); }