From 232f1b659a37e8f1b9559b23cb880a402ca861cc Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 28 Aug 2026 11:06:03 -0400 Subject: [PATCH 1/5] ci(tss): add the gated TSS shadow publish lane (TIN-3026) tss.tinyland.dev has had no publisher since the legacy shadow workflow was retired on 2026-08-13; it still serves the 2026-07-14 build. This adds the default-branch-owned, dispatch-only lane TIN-3026 names as the separately authorized apply path for the public noindex development shadow: - resolves an exact current-main SHA or an open same-repo PR head, requires a successful canonical CI run at that SHA with build-and-test and bazel-remote-gates green, and fails closed unless deploy=true and BLOG_TSS_PUBLISH_ENABLED=true; - builds with PUBLIC_DEPLOY_TIER=shadow (site-wide noindex + source-sha meta, validated by validate-deploy-tier-output.mjs), records the static digest, revalidates the kill switch and the source immediately before publish, and refuses the production Pages project by name; - publishes to CLOUDFLARE_PAGES_TSS_PROJECT_NAME only (repo var, required). Resolver fixtures cover every refusal path; the workflow-authority contract pins the lane shape and forbids production reach or manual carriers. AGENTS.md registers the lane; docs/tss-shadow-publish.md records the ceremony. --- .github/workflows/tss-shadow-publish-v2.yml | 268 +++++++++++++++++++ AGENTS.md | 1 + BUILD.bazel | 1 + docs/tss-shadow-publish.md | 7 + scripts/test-tss-shadow-publish-resolver.mjs | 162 +++++++++++ scripts/test-workflow-authority.mjs | 43 ++- 6 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/tss-shadow-publish-v2.yml create mode 100644 docs/tss-shadow-publish.md create mode 100644 scripts/test-tss-shadow-publish-resolver.mjs diff --git a/.github/workflows/tss-shadow-publish-v2.yml b/.github/workflows/tss-shadow-publish-v2.yml new file mode 100644 index 00000000..191e9abf --- /dev/null +++ b/.github/workflows/tss-shadow-publish-v2.yml @@ -0,0 +1,268 @@ +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 Cloudflare Pages project +# that serves https://tss.tinyland.dev — the public, site-wide-noindex development +# shadow (TIN-3026). It never touches the production project and is fail-closed behind +# the BLOG_TSS_PUBLISH_ENABLED repository variable, revalidated immediately before publish. + +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 }} + 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' }} + with: + script: | + const { owner, repo } = context.repo; + const expectedRepository = `${owner}/${repo}`; + + 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."); + } + + 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", + 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("deploy", "true"); + await core.summary + .addHeading("TSS shadow provenance") + .addRaw(`Source: \`${sourceRef}\` at \`${sourceSha}\`\n\n`) + .addLink("Successful canonical CI", ciRun.html_url) + .write(); + + publish: + name: Build and publish exact shadow to Cloudflare Pages + needs: resolve + if: needs.resolve.result == 'success' && needs.resolve.outputs.deploy == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + pull-requests: read + env: + PUBLIC_DEPLOY_TIER: shadow + PUBLIC_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + CLOUDFLARE_PAGES_TSS_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }} + CLOUDFLARE_PAGES_TSS_BRANCH: ${{ vars.CLOUDFLARE_PAGES_TSS_BRANCH || 'main' }} + 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: 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: Require shadow project and Cloudflare deploy credentials + env: + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + set -euo pipefail + test -n "${CLOUDFLARE_PAGES_TSS_PROJECT_NAME}" || { echo "::error::CLOUDFLARE_PAGES_TSS_PROJECT_NAME repository variable is required."; exit 1; } + if [[ "${CLOUDFLARE_PAGES_TSS_PROJECT_NAME}" == "transscendsurvival-org" ]]; then + echo "::error::The shadow lane must never target the production Pages project." + exit 1 + fi + 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; } + + - name: Revalidate shadow kill switch and 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 }} + with: + script: | + const { owner, repo } = context.repo; + let enabled = false; + try { + const response = await github.rest.actions.getRepoVariable({ + owner, + repo, + name: "BLOG_TSS_PUBLISH_ENABLED", + }); + enabled = response.data.value === "true"; + } catch (error) { + if (error.status !== 404) throw error; + } + if (!enabled) { + 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=${{ env.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }} --branch=${{ env.CLOUDFLARE_PAGES_TSS_BRANCH }} --commit-hash=${{ needs.resolve.outputs.source_sha }} --commit-dirty=false + + - name: Publish provenance summary + env: + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + SOURCE_REF: ${{ needs.resolve.outputs.source_ref }} + CI_URL: ${{ needs.resolve.outputs.ci_url }} + ARTIFACT_DIGEST: ${{ steps.artifact.outputs.digest }} + run: | + { + echo "## TSS shadow artifact" + echo "" + echo "- Project: \`${CLOUDFLARE_PAGES_TSS_PROJECT_NAME}\` (branch \`${CLOUDFLARE_PAGES_TSS_BRANCH}\`)" + echo "- Source: \`${SOURCE_REF}\` at \`${SOURCE_SHA}\`" + echo "- Successful CI: ${CI_URL}" + echo "- Static content digest: \`${ARTIFACT_DIGEST}\`" + echo "- Deploy tier: \`shadow\` (site-wide noindex, source SHA stamped)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 166e4fb5..b49715e3 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`); publishes the site-wide-noindex shadow build to the dedicated TSS Pages project only (production project name is rejected) | repo vars `BLOG_TSS_PUBLISH_ENABLED` (default false, revalidated immediately before publish) + `CLOUDFLARE_PAGES_TSS_PROJECT_NAME` (required; `CLOUDFLARE_PAGES_TSS_BRANCH` optional, default `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 | diff --git a/BUILD.bazel b/BUILD.bazel index 9992fed9..573f137b 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/tss-shadow-publish.md b/docs/tss-shadow-publish.md new file mode 100644 index 00000000..a3c60512 --- /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: set repo var `CLOUDFLARE_PAGES_TSS_PROJECT_NAME` (from the Cloudflare dashboard), 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`), kill switch and source revalidated immediately before `wrangler pages deploy`, production project name refused. 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 00000000..6f0b2401 --- /dev/null +++ b/scripts/test-tss-shadow-publish-resolver.mjs @@ -0,0 +1,162 @@ +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', + 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, + }, + }); + return outputs; +} + +assert.deepEqual(await runFixture(), { + source_sha: sourceSha, + source_ref: 'main', + pr_number: '', + ci_url: 'https://github.example/ci/101', + deploy: 'true', +}); +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', 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 fb354b7f..03779814 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,43 @@ 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' }}", + 'process.env.TSS_ENABLED !== "true"', + 'process.env.REQUEST_DEPLOY !== "true"', + '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}"', + 'CLOUDFLARE_PAGES_TSS_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }}', + 'The shadow lane must never target the production Pages project.', + 'Revalidate shadow kill switch and source immediately before publish', + 'name: "BLOG_TSS_PUBLISH_ENABLED"', + 'Refusing stale shadow publish', + '--commit-hash=${{ needs.resolve.outputs.source_sha }} --commit-dirty=false', + 'cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4', + 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6', + 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6', + ], + 'TSS shadow publish workflow', +); +requireOrder( + tssPublish, + ['Revalidate shadow kill switch and source immediately before publish', 'Publish exact shadow build to Cloudflare Pages'], + 'TSS live-authority recheck must precede publication', +); +forbid(tssPublish, /^ {2}(?:push|pull_request|workflow_run):|^\s*workflow_dispatch:/m, 'TSS shadow publish must be default-owned dispatch 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/, 'TSS shadow publish must not carry unrelated mutation authority'); + requireAll( vite, [ @@ -219,7 +257,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 +353,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); } From 3ec5e7a335374b0731200d091a4bf44fbd93394b Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 28 Aug 2026 14:05:41 -0400 Subject: [PATCH 2/5] ci(tss): split the lane into a credential-free build and a digest-verified deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on the first cut: the publish job ran npm ci / npm run build on the requested PR head in the same job that held the Cloudflare account token, so source-tree code could reach the token and the production-project name guard was decorative. The lane now mirrors shadow-source-build-v2 → shadow-source-publish-v2: the build job has contents:read only and no secret, uploads the built tree with its digest; the deploy job never checks out or executes source-tree code, re-derives the digest from the downloaded artifact, revalidates the kill switch and the source, and only then runs wrangler against the artifact. Also: the Pages project name is validated as a slug and refused when it is the production project (trailing whitespace can no longer slip past an exact compare into a shell word), the project defaults to tss-shadow as recorded in docs/dns-architecture.md, the canonical-CI lookup filters by head_sha, the shadow ships robots.txt Disallow plus an X-Robots-Tag header alongside the HTML meta, the run fails unless tss.tinyland.dev serves the published source SHA within four minutes, the authority test pins the single trigger, the build/deploy split, workflow_id ci.yml and the step order, and AGENTS.md no longer says Cloudflare publication comes only from the production lane. --- .github/workflows/tss-shadow-publish-v2.yml | 120 +++++++++++++++---- AGENTS.md | 4 +- docs/tss-shadow-publish.md | 4 +- scripts/test-tss-shadow-publish-resolver.mjs | 14 ++- scripts/test-workflow-authority.mjs | 40 ++++++- 5 files changed, 151 insertions(+), 31 deletions(-) diff --git a/.github/workflows/tss-shadow-publish-v2.yml b/.github/workflows/tss-shadow-publish-v2.yml index 191e9abf..c69205df 100644 --- a/.github/workflows/tss-shadow-publish-v2.yml +++ b/.github/workflows/tss-shadow-publish-v2.yml @@ -1,10 +1,13 @@ 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 Cloudflare Pages project -# that serves https://tss.tinyland.dev — the public, site-wide-noindex development -# shadow (TIN-3026). It never touches the production project and is fail-closed behind -# the BLOG_TSS_PUBLISH_ENABLED repository variable, revalidated immediately before publish. +# (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: @@ -34,6 +37,8 @@ jobs: 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 @@ -44,10 +49,13 @@ jobs: 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, { @@ -73,6 +81,18 @@ jobs: 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; @@ -108,6 +128,7 @@ jobs: workflow_id: "ci.yml", event: ciEvent, status: "completed", + head_sha: sourceSha, per_page: 100, }); const ciRun = runs.find((run) => @@ -132,28 +153,28 @@ jobs: 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}\`\n\n`) + .addRaw(`Source: \`${sourceRef}\` at \`${sourceSha}\` → project \`${project}\` (branch \`${branch}\`)\n\n`) .addLink("Successful canonical CI", ciRun.html_url) .write(); - publish: - name: Build and publish exact shadow to Cloudflare Pages + 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: - actions: read contents: read - pull-requests: read + outputs: + digest: ${{ steps.artifact.outputs.digest }} env: PUBLIC_DEPLOY_TIER: shadow PUBLIC_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} - CLOUDFLARE_PAGES_TSS_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }} - CLOUDFLARE_PAGES_TSS_BRANCH: ${{ vars.CLOUDFLARE_PAGES_TSS_BRANCH || 'main' }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -179,6 +200,13 @@ jobs: - 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: | @@ -190,19 +218,55 @@ jobs: fi echo "digest=sha256:${digest}" >> "$GITHUB_OUTPUT" - - name: Require shadow project and Cloudflare deploy credentials + - 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] + if: needs.build.result == 'success' + 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 "${CLOUDFLARE_PAGES_TSS_PROJECT_NAME}" || { echo "::error::CLOUDFLARE_PAGES_TSS_PROJECT_NAME repository variable is required."; exit 1; } - if [[ "${CLOUDFLARE_PAGES_TSS_PROJECT_NAME}" == "transscendsurvival-org" ]]; then + 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 - 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; } - name: Revalidate shadow kill switch and source immediately before publish uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 @@ -248,21 +312,35 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} wranglerVersion: '4.95.0' - command: pages deploy build --project-name=${{ env.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }} --branch=${{ env.CLOUDFLARE_PAGES_TSS_BRANCH }} --commit-hash=${{ needs.resolve.outputs.source_sha }} --commit-dirty=false + 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 b49715e3..6e558b82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +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`); publishes the site-wide-noindex shadow build to the dedicated TSS Pages project only (production project name is rejected) | repo vars `BLOG_TSS_PUBLISH_ENABLED` (default false, revalidated immediately before publish) + `CLOUDFLARE_PAGES_TSS_PROJECT_NAME` (required; `CLOUDFLARE_PAGES_TSS_BRANCH` optional, default `main`) | +| 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 | @@ -74,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/docs/tss-shadow-publish.md b/docs/tss-shadow-publish.md index a3c60512..d92c0d87 100644 --- a/docs/tss-shadow-publish.md +++ b/docs/tss-shadow-publish.md @@ -2,6 +2,6 @@ `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: set repo var `CLOUDFLARE_PAGES_TSS_PROJECT_NAME` (from the Cloudflare dashboard), 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. +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`), kill switch and source revalidated immediately before `wrangler pages deploy`, production project name refused. Rotate the Cloudflare API token (TIN-2727) before the first publish. +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 and 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 index 6f0b2401..03758af8 100644 --- a/scripts/test-tss-shadow-publish-resolver.mjs +++ b/scripts/test-tss-shadow-publish-resolver.mjs @@ -49,6 +49,8 @@ async function runFixture({ manualPr = '', manualDeploy = 'true', tssEnabled = 'true', + tssProject = 'tss-shadow', + tssBranch = 'main', mainSha = sourceSha, pr = pullRequest(), runs = [ciRun()], @@ -100,6 +102,8 @@ async function runFixture({ REQUEST_SOURCE_PR: manualPr, REQUEST_DEPLOY: manualDeploy, TSS_ENABLED: tssEnabled, + TSS_PROJECT: tssProject, + TSS_BRANCH: tssBranch, }, }); return outputs; @@ -110,11 +114,19 @@ assert.deepEqual(await runFixture(), { 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', deploy: 'true' }, + { 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'); diff --git a/scripts/test-workflow-authority.mjs b/scripts/test-workflow-authority.mjs index 03779814..3cc2a667 100644 --- a/scripts/test-workflow-authority.mjs +++ b/scripts/test-workflow-authority.mjs @@ -179,19 +179,31 @@ requireAll( [ '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}"', - 'CLOUDFLARE_PAGES_TSS_PROJECT_NAME: ${{ vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME }}', - 'The shadow lane must never target the production Pages project.', + "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}"', 'Revalidate shadow kill switch and source immediately before publish', 'name: "BLOG_TSS_PUBLISH_ENABLED"', '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', @@ -200,10 +212,28 @@ requireAll( ); requireOrder( tssPublish, - ['Revalidate shadow kill switch and source immediately before publish', 'Publish exact shadow build to Cloudflare Pages'], - 'TSS live-authority recheck must precede publication', + [ + 'Verify artifact digest matches the credential-free build', + 'Revalidate shadow kill switch and 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', ); -forbid(tssPublish, /^ {2}(?:push|pull_request|workflow_run):|^\s*workflow_dispatch:/m, 'TSS shadow publish must be default-owned dispatch only'); +{ + 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/, From ecd262f7dd6b7d24cbed8cf90f2fa599f5a48629 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 28 Aug 2026 15:53:25 -0400 Subject: [PATCH 3/5] ci(tss): re-read the kill switch at deploy-job start, not via the variables API Review finding: GITHUB_TOKEN carries no Variables permission, so the getRepoVariable recheck copied from the sibling v2 lanes would 403 and refuse every publish after a full build. The deploy job is now gated with 'if: vars.BLOG_TSS_PUBLISH_ENABLED == true', which Actions resolves when the job is scheduled (after the build), and the pre-publish step keeps the source freshness checks (main head / PR still open at the exact SHA). The authority contract now forbids getRepoVariable in this lane. --- .github/workflows/tss-shadow-publish-v2.yml | 22 ++++++++------------- docs/tss-shadow-publish.md | 2 +- scripts/test-workflow-authority.mjs | 10 ++++++---- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tss-shadow-publish-v2.yml b/.github/workflows/tss-shadow-publish-v2.yml index c69205df..94f005a4 100644 --- a/.github/workflows/tss-shadow-publish-v2.yml +++ b/.github/workflows/tss-shadow-publish-v2.yml @@ -229,7 +229,11 @@ jobs: deploy: name: Publish the digest-verified shadow artifact to Cloudflare Pages needs: [resolve, build] - if: needs.build.result == 'success' + # 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 + # getRepoVariable 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: @@ -268,27 +272,17 @@ jobs: exit 1 fi - - name: Revalidate shadow kill switch and source immediately before publish + - 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; - let enabled = false; - try { - const response = await github.rest.actions.getRepoVariable({ - owner, - repo, - name: "BLOG_TSS_PUBLISH_ENABLED", - }); - enabled = response.data.value === "true"; - } catch (error) { - if (error.status !== 404) throw error; - } - if (!enabled) { + 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") { diff --git a/docs/tss-shadow-publish.md b/docs/tss-shadow-publish.md index d92c0d87..805e7daf 100644 --- a/docs/tss-shadow-publish.md +++ b/docs/tss-shadow-publish.md @@ -4,4 +4,4 @@ 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 and 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. +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-workflow-authority.mjs b/scripts/test-workflow-authority.mjs index 3cc2a667..27d74e5f 100644 --- a/scripts/test-workflow-authority.mjs +++ b/scripts/test-workflow-authority.mjs @@ -198,8 +198,10 @@ requireAll( 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', 'Verify artifact digest matches the credential-free build', 'test "${digest}" = "${EXPECTED_DIGEST}"', - 'Revalidate shadow kill switch and source immediately before publish', - 'name: "BLOG_TSS_PUBLISH_ENABLED"', + "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', @@ -214,7 +216,7 @@ requireOrder( tssPublish, [ 'Verify artifact digest matches the credential-free build', - 'Revalidate shadow kill switch and source immediately before publish', + 'Revalidate the source immediately before publish', 'Publish exact shadow build to Cloudflare Pages', 'Verify the served shadow carries the published source SHA', ], @@ -239,7 +241,7 @@ forbid( /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/, 'TSS shadow publish must not carry unrelated mutation authority'); +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, From b80102f51bb1d97599eeaa516652cd6ec77041f9 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 28 Aug 2026 15:54:08 -0400 Subject: [PATCH 4/5] ci(tss): keep the variables-API name out of the lane so the contract's forbid holds --- .github/workflows/tss-shadow-publish-v2.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tss-shadow-publish-v2.yml b/.github/workflows/tss-shadow-publish-v2.yml index 94f005a4..04cee085 100644 --- a/.github/workflows/tss-shadow-publish-v2.yml +++ b/.github/workflows/tss-shadow-publish-v2.yml @@ -232,7 +232,7 @@ jobs: # 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 - # getRepoVariable recheck would 403 and refuse every publish. + # 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 From 55f38650aff96d5952938ab237e394169f5f698e Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 28 Aug 2026 16:16:58 -0400 Subject: [PATCH 5/5] docs: index tss-shadow-publish.md (landing after #264's docs index) --- docs/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/README.md b/docs/README.md index 1bb13414..0c7e33b6 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).