Skip to content

ci(tss): gated TSS shadow publish lane v2 (TIN-3026) - #263

Draft
Jesssullivan wants to merge 5 commits into
mainfrom
jess/tin-3026-tss-shadow-publish-v2
Draft

Jesssullivan wants to merge 5 commits into
mainfrom
jess/tin-3026-tss-shadow-publish-v2

Conversation

@Jesssullivan

Copy link
Copy Markdown
Owner

Why

tss.tinyland.dev is the operator acceptance URL and public noindex development shadow (TIN-3026), but nothing has published it since #251 retired the legacy shadow workflow on 2026-08-13 — it still serves the 2026-07-14 build (/_app/version.json = 1784027104867). Today the operator ruled to iterate on tss before any production promotion; this is the separately authorized apply lane that makes that possible.

What

  • .github/workflows/tss-shadow-publish-v2.ymlrepository_dispatch: tss-shadow-publish-v2 only. Resolves an exact SHA (current main, or an open same-repo PR head via source_pr), requires a successful canonical CI run at that SHA with build-and-test + bazel-remote-gates, refuses unless client_payload.deploy == "true" and vars.BLOG_TSS_PUBLISH_ENABLED == "true". Builds with PUBLIC_DEPLOY_TIER=shadow, validates site-wide noindex + source-sha stamping, records the static digest, revalidates kill switch + source immediately before wrangler pages deploy, and refuses the production project name (transscendsurvival-org). Publishes to vars.CLOUDFLARE_PAGES_TSS_PROJECT_NAME (required) on vars.CLOUDFLARE_PAGES_TSS_BRANCH (default main).
  • scripts/test-tss-shadow-publish-resolver.mjs — fixtures for every refusal path (wrong event/type, non-exact SHA, stale main, closed/fork/drifted PR, missing/red CI, red gates, deploy not requested, kill switch off).
  • scripts/test-workflow-authority.mjs — pins the lane shape; forbids push/PR/workflow_run/workflow_dispatch triggers, production project/kill-switch reach, and unrelated mutation authority.
  • BUILD.bazel — fixture added to //:workflow_authority_tests.
  • AGENTS.md register row; docs/tss-shadow-publish.md ceremony.

Not in this PR (operator ceremony)

  1. Name the Cloudflare Pages project that owns tss.tinyland.dev → repo var CLOUDFLARE_PAGES_TSS_PROJECT_NAME (no repo records it today).
  2. Rotate the Cloudflare API token first (TIN-2727, leaked 2026-06-23, still unrotated).
  3. Set BLOG_TSS_PUBLISH_ENABLED=true, dispatch at current main, verify version.json + the tinyland-source-sha meta on every route.

Merging this PR publishes nothing: the variable is absent (= false) and the lane has no non-dispatch trigger. Source-only under the TIN-2801 boundary.

Checks

  • local: node scripts/test-workflow-authority.mjs → all 6 fixture suites pass incl. the new TSS resolver fixtures.
  • hosted: pending.

Refs: TIN-3026, TIN-603, TIN-604, TIN-2727.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Operator ceremony for the first tss publish (run in order; nothing here is performed by an agent):

  1. Rotate the Cloudflare API token (TIN-2727) in the Cloudflare dashboard — scope: Pages:Edit on the account — then store it without echoing: env -u GH_TOKEN gh secret set CLOUDFLARE_API_TOKEN --repo Jesssullivan/jesssullivan.github.io (paste at the prompt).
  2. Name the Pages project that owns tss.tinyland.dev (Cloudflare → Pages → the project whose custom domain is tss.tinyland.dev): env -u GH_TOKEN gh variable set CLOUDFLARE_PAGES_TSS_PROJECT_NAME --repo Jesssullivan/jesssullivan.github.io --body '<project-name>' (if the custom domain is bound to a non-main branch, also set CLOUDFLARE_PAGES_TSS_BRANCH).
  3. Enable the switch: env -u GH_TOKEN gh variable set BLOG_TSS_PUBLISH_ENABLED --repo Jesssullivan/jesssullivan.github.io --body true.
  4. Dispatch at current main (after this PR merges): SHA=$(git rev-parse origin/main) && env -u GH_TOKEN gh api repos/Jesssullivan/jesssullivan.github.io/dispatches -f event_type=tss-shadow-publish-v2 -f "client_payload[source_sha]=$SHA" -f 'client_payload[deploy]=true'. For an open PR head add -f 'client_payload[source_pr]=<n>'.
  5. Verify: curl -s https://tss.tinyland.dev/_app/version.json (must exceed 1784027104867) and curl -s https://tss.tinyland.dev/blog | grep -o '<meta name="tinyland-source-sha"[^>]*>' (must equal $SHA); every route must carry noindex,nofollow.

Merging this PR alone changes nothing on any served surface.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Reworked at HEAD after the adversarial review of the first cut (blocker confirmed: the publish job executed PR-head code in the same job that held the Cloudflare token).

  • Split into build (contents: read, no secret, no id-token) → deploy (never checks out or runs source-tree code; downloads the artifact, re-derives and asserts the digest, revalidates switch + source, then wrangler pages deploy) — the same shape as shadow-source-build-v2 → shadow-source-publish-v2. The authority test now pins the split: the build job cannot contain secrets./wrangler/id-token, the deploy job cannot contain actions/checkout/npm ci/npm run/npx tsx.
  • Project name: validated as a lowercase slug in the resolver, refused when it equals the production project even with surrounding whitespace, passed quoted; defaults to tss-shadow (recorded three times in docs/dns-architecture.md — my PR body was wrong that no repo records it).
  • Trigger allowlist: the on: block must be exactly the typed repository_dispatch; workflow_id: "ci.yml" and head_sha filtering pinned.
  • Shadow crawl posture beyond the meta: robots.txt Disallow: / + _headers X-Robots-Tag: noindex, nofollow written into the artifact and asserted in the deploy job.
  • Post-publish proof: the run fails unless tss.tinyland.dev/blog serves the published tinyland-source-sha within four minutes.
  • AGENTS.md sentence fixed (two credentialed Cloudflare lanes, not one).

Open from the review, deliberately not changed here: the pre-publish getRepoVariable recheck is the pattern all four sibling v2 lanes use and none has ever executed — the deploy job carries actions: read, which is the permission the variables read endpoint requires, but the first real dispatch is the proof. If it 403s, the lane fails closed (rethrown), not open.

Local: node scripts/test-workflow-authority.mjs → all 6 fixture suites green (file reads only, no build). Hosted: pending.

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Three things before this can be dispatched (the job split and served-SHA
proof on 53db1c9 look right):

  1. .github/workflows/tss-shadow-publish-v2.yml:207-229 cannot succeed.
    GITHUB_TOKEN has no Variables permission, so getRepoVariable returns
    403 and :225 rethrows. The lane will fail after a ~10 min build, every
    time. No sibling lane has ever executed this call. Replace with a job
    boundary: if: vars.BLOG_TSS_PUBLISH_ENABLED == 'true' on the deploy
    job, keep the getRef/pulls.get freshness checks as a script step.
  2. :199-203 compares exactly while :251 interpolates. transscendsurvival-org
    (trailing space) passes the guard, then wrangler-action's argStringToArray
    drops the empty token and deploys production. Validate against the Pages
    name grammar and compare a normalized value.
  3. scripts/test-workflow-authority.mjs:177-198: add workflow_id: "ci.yml"
    (pinned for the production and rollback lanes at :128/:253, missing here)
    and assert the PR-checkout job contains no secrets..

@Jesssullivan

Copy link
Copy Markdown
Owner Author

Fixed at HEAD: (1) the kill switch is now re-read by Actions at deploy-job start (if: needs.build.result == 'success' && vars.BLOG_TSS_PUBLISH_ENABLED == 'true') and asserted again inside the pre-publish step from vars, with the main-head / PR-open freshness checks kept; getRepoVariable is gone and the authority contract forbids it in this lane. (2) was already addressed at 53db1c9 (slug regex + trim in the resolver, quoted interpolation). (3) workflow_id: "ci.yml" and the no-secrets.-in-the-checkout-job assertion were already pinned at 53db1c9. The same unexecutable getRepoVariable recheck exists in the four sibling v2 lanes — separate ticket, not this PR.

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.
…ified deploy

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.
…iables 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.
@Jesssullivan
Jesssullivan force-pushed the jess/tin-3026-tss-shadow-publish-v2 branch from 2b6f2bd to 8929f27 Compare August 28, 2026 20:17
@Jesssullivan
Jesssullivan force-pushed the jess/tin-3026-tss-shadow-publish-v2 branch from 8929f27 to 55f3865 Compare August 28, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant