Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 340 additions & 0 deletions .github/workflows/tss-shadow-publish-v2.yml
Original file line number Diff line number Diff line change
@@ -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 "<meta name=\"tinyland-source-sha\" content=\"${SOURCE_SHA}\""; then
echo "tss.tinyland.dev serves ${SOURCE_SHA}"
exit 0
fi
sleep 10
done
echo "::error::tss.tinyland.dev did not serve source SHA ${SOURCE_SHA} within four minutes of publishing."
exit 1

- 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 }}
run: |
{
echo "## TSS shadow artifact"
echo ""
echo "- Project: \`${TSS_PROJECT}\` (branch \`${TSS_BRANCH}\`)"
echo "- Source: \`${SOURCE_REF}\` at \`${SOURCE_SHA}\`"
echo "- Successful CI: ${CI_URL}"
echo "- Static content digest: \`${EXPECTED_DIGEST}\`"
echo "- Deploy tier: \`shadow\` (HTML noindex + robots Disallow + X-Robots-Tag; source SHA stamped and verified live)"
} >> "$GITHUB_STEP_SUMMARY"
Loading