diff --git a/.fork/AGENTS.md b/.fork/AGENTS.md index 737f7d7dbd8f..6e42c8766df0 100644 --- a/.fork/AGENTS.md +++ b/.fork/AGENTS.md @@ -25,8 +25,12 @@ fork only ever reads from; there is no valid reason to open one. - Default branch: `custom` — GitHub only runs `schedule` workflows from the default branch, so the hourly mirror depends on it. - Branch ruleset on `main`: block direct pushes and force pushes for everyone except the - `fork-sync-mirror` workflow's token, so the mirror invariant is enforced server-side rather - than only detected after the fact by the workflow's fast-forward check. + mirror's push credential, so the mirror invariant is enforced server-side rather than only + detected after the fact by the workflow's fast-forward check. The mirror pushes with the + `fork-sync-mirror push key` deploy key (secret `FORK_SYNC_PUSH_KEY`), not its workflow token — + `GITHUB_TOKEN` can never hold the `workflows` permission, so token pushes are rejected whenever + upstream touches `.github/workflows/*`. Any ruleset on `main` must put deploy keys (or that key + specifically) on its bypass list, or the hourly mirror stops dead. ## Where a change goes — in order of preference diff --git a/.fork/README.md b/.fork/README.md index 73437d4a4d13..ede31b63cd53 100644 --- a/.fork/README.md +++ b/.fork/README.md @@ -280,6 +280,22 @@ reserving agent runs for the judgment calls. A scheduled workflow that fetches upstream and fast-forwards `origin/main`. Pure plumbing, zero judgment, no reason to spend a routine run on it. Run it hourly. +The push authenticates with the `fork-sync-mirror push key` deploy key (read-write, this repo +only), stored as the `FORK_SYNC_PUSH_KEY` Actions secret — not with `GITHUB_TOKEN`, which can +never hold the `workflows` permission and so is rejected whenever the mirrored range touches +`.github/workflows/*` (that outage was the a148e08197 incident). Two consequences to keep true: + +- Deploy-key pushes, unlike token pushes, **do trigger `push` workflows**. Anything watching + `push: main` fires on every mirror advance over unreviewed upstream code, so such workflows + must either exclude `main` (ci.yml) or gate on `github.repository == 'pingdotgg/t3code'` + (deploy-relay.yml, release.yml). +- The `mirror` job's checkout holds that write key while containing unreviewed upstream code. + It must stay git-plumbing-only; never add a step to it that executes checked-out content. + +If the key is lost, mint a new ed25519 pair, add the public half as a read-write deploy key, and +store the private half as `FORK_SYNC_PUSH_KEY`; the job's preflight fails with a pointer here +until then. + Give it a second job: **drift detection.** Diff the newly-mirrored commits against the `shadows:` and Tier-4 file lists in `customizations.yaml`. When upstream touches a file you have shadowed or patched, that is the signal worth acting on. diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 0413213e1f51..541afa84fc13 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -26,10 +26,14 @@ - id: ci-on-custom intent: > - Direct pushes to the custom branch run the full CI suite. Upstream's CI - triggers on pull_request and pushes to main only; custom is this fork's - working branch, so a push to it (like a merged sync) must be validated - the same way main is upstream. + CI runs on pushes to custom and does not run on pushes to main. Custom is + this fork's working branch, so a push to it (like a merged sync) must be + validated the way main is upstream. Main is the opposite: a pure upstream + mirror whose every commit upstream CI already validated, so running the + fork's CI there is pure spend — and since the mirror push authenticates + with a deploy key (see fork-sync-mirror.yml), which unlike GITHUB_TOKEN + does trigger push workflows, main must stay out of the push trigger list + or every hourly mirror advance schedules a full CI run. tier: 4 files: [] shadows: [] @@ -102,6 +106,27 @@ verify: - apps/web/src/__fork_guards__/releaseUpstreamOnly.test.ts +- id: relay-upstream-only + intent: > + The relay production deploy never runs from this fork. deploy-relay.yml + fires on every push to main and deploys with live Cloudflare, PlanetScale, + Clerk and APNs credentials; on the fork a push to main is the hourly + mirror advancing over unreviewed upstream code, authenticated by a deploy + key that — unlike GITHUB_TOKEN — does trigger push workflows. Before this + gate existed the runs were stopped only by the same two accidents + release-upstream-only records: Blacksmith runner labels the fork cannot + schedule, and token loop-prevention the deploy key retired. Gate the job + on the repository being upstream, independent of runner labels, so + installing Blacksmith here can never start deploying production from the + fork. + tier: 4 + files: [] + shadows: [] + watch: + - .github/workflows/deploy-relay.yml + verify: + - apps/web/src/__fork_guards__/relayUpstreamOnly.test.ts + - id: fork-desktop-release intent: > The fork can cut its own downloadable desktop build without borrowing any diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9f9a5a49a65..e93a9dcddaf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,11 @@ on: pull_request: push: branches: - - main # fork:begin ci-on-custom — see .fork/customizations.yaml#ci-on-custom + # `main` is deliberately absent on the fork: it is a pure upstream + # mirror whose commits upstream CI has already validated, and mirror + # pushes authenticate with a deploy key, which — unlike GITHUB_TOKEN — + # triggers `push` workflows. - custom # fork:end ci-on-custom diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 94d4af17e41a..0b297e3ddea2 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,6 +17,9 @@ concurrency: jobs: deploy_relay: name: Deploy production relay + # fork:begin relay-upstream-only — see .fork/customizations.yaml#relay-upstream-only + if: github.repository == 'pingdotgg/t3code' + # fork:end relay-upstream-only runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 environment: diff --git a/.github/workflows/fork-sync-mirror.yml b/.github/workflows/fork-sync-mirror.yml index 62b3345e756f..2500266e26bb 100644 --- a/.github/workflows/fork-sync-mirror.yml +++ b/.github/workflows/fork-sync-mirror.yml @@ -22,8 +22,11 @@ on: - cron: "23 * * * *" workflow_dispatch: +# `contents` stays read-only on purpose: the mirror pushes with the +# FORK_SYNC_PUSH_KEY deploy key, not the token, so a missing key fails the +# preflight below unambiguously instead of half-working on token auth. permissions: - contents: write + contents: read issues: write concurrency: @@ -31,16 +34,36 @@ concurrency: cancel-in-progress: false jobs: + # SECURITY INVARIANT: this job's workspace is unreviewed upstream code and + # its checkout holds a read-write deploy key. It must never run anything + # from the checked-out tree — git plumbing only. Steps that execute repo + # content (node, npm ci, vp, scripts) belong in `drift`, which checks out + # `custom` and carries no key. mirror: runs-on: ubuntu-latest outputs: old: ${{ steps.sync.outputs.old }} new: ${{ steps.sync.outputs.new }} steps: + # If the key is unset, checkout would silently fall back to token auth + # and the push would fail later with the same opaque workflow-permission + # error this key exists to avoid. Fail here, with the real cause. + - name: Require the push key + run: | + if [ -z "${{ secrets.FORK_SYNC_PUSH_KEY }}" ]; then + echo "::error::FORK_SYNC_PUSH_KEY is unset — the mirror push would be rejected on any range touching .github/workflows/*. Restore the deploy-key secret (see .fork/README.md §5)." + exit 1 + fi + + # The default GITHUB_TOKEN can never hold the `workflows` permission, so a + # fast-forward range that touches .github/workflows/* is rejected at push + # time. The FORK_SYNC_PUSH_KEY deploy key (read-write, repo-scoped) pushes + # over SSH, which is exempt from that token restriction. - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 + ssh-key: ${{ secrets.FORK_SYNC_PUSH_KEY }} - name: Fast-forward main from upstream id: sync @@ -63,6 +86,44 @@ jobs: git push origin "$new:refs/heads/main" echo "Mirrored $(git rev-list --count "$old..$new") upstream commit(s)." + # A broken mirror is silent from the outside — main just goes stale + # (that cost a day in the a148e08197 incident). Surface failures where + # the drift signal already lives: a single find-or-create issue. + - name: Report failure on the tracking issue + if: failure() + uses: actions/github-script@v7 + with: + script: | + const title = "[fork-sync] fork-sync-mirror is failing"; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + `The hourly mirror job failed — \`main\` is no longer tracking upstream: ${runUrl}`, + "", + "Until this is fixed, `main` stays stale and drift detection is blind to new upstream changes. See `.fork/README.md` §5.", + ].join("\n"); + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }); + const existing = issues.find((issue) => !issue.pull_request && issue.title === title); + if (existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + }); + } + drift: needs: mirror if: needs.mirror.outputs.old != needs.mirror.outputs.new diff --git a/apps/web/src/__fork_guards__/ciOnCustom.test.ts b/apps/web/src/__fork_guards__/ciOnCustom.test.ts index 441defb9de4e..71f00ef49155 100644 --- a/apps/web/src/__fork_guards__/ciOnCustom.test.ts +++ b/apps/web/src/__fork_guards__/ciOnCustom.test.ts @@ -2,10 +2,13 @@ /** * Fork guard — see `.fork/customizations.yaml#ci-on-custom`. * - * Upstream's CI triggers on pull_request and pushes to main; this fork also - * needs pushes to `custom` validated. The hunk is a Tier-4 inline edit to an - * upstream workflow file, so an upstream rework of ci.yml could silently - * drop it in a "clean" merge. + * Upstream's CI triggers on pull_request and pushes to main; this fork + * instead validates pushes to `custom` — and deliberately not `main`, which + * is a pure upstream mirror (upstream CI already ran) whose hourly mirror + * pushes authenticate with a deploy key that, unlike GITHUB_TOKEN, triggers + * push workflows. The hunk is a Tier-4 inline edit to an upstream workflow + * file, so an upstream rework of ci.yml could silently drop it in a "clean" + * merge. */ import * as NodeFS from "node:fs"; @@ -19,8 +22,22 @@ const repoRoot = NodePath.resolve( ); describe("fork guard: ci-on-custom", () => { - it("keeps the custom branch in CI's push triggers", () => { + const readPushBranches = (): readonly string[] => { const ci = NodeFS.readFileSync(NodePath.join(repoRoot, ".github/workflows/ci.yml"), "utf8"); - expect(ci).toMatch(/push:\s*\n\s+branches:\s*\n(?:.*\n)*?\s+- custom/u); + // The push trigger's branch list: `- ` lines (comments excluded) + // between `branches:` and the next key at equal-or-lower indentation. + const block = /push:\s*\n\s+branches:\s*\n((?:\s+(?:#.*|- \S+)\n)*)/u.exec(ci); + if (block?.[1] === undefined) return []; + return [...block[1].matchAll(/^\s+- (\S+)$/gmu)].flatMap((match) => match[1] ?? []); + }; + + it("keeps the custom branch in CI's push triggers", () => { + expect(readPushBranches()).toContain("custom"); + }); + + it("keeps main out of CI's push triggers", () => { + // main is a pure upstream mirror; its hourly deploy-key pushes would + // otherwise schedule a redundant full CI run (macOS jobs included). + expect(readPushBranches()).not.toContain("main"); }); }); diff --git a/apps/web/src/__fork_guards__/relayUpstreamOnly.test.ts b/apps/web/src/__fork_guards__/relayUpstreamOnly.test.ts new file mode 100644 index 000000000000..225e8594a21b --- /dev/null +++ b/apps/web/src/__fork_guards__/relayUpstreamOnly.test.ts @@ -0,0 +1,59 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Fork guard — see `.fork/customizations.yaml#relay-upstream-only`. + * + * deploy-relay.yml deploys the production relay with live credentials on + * every push to main. On the fork, pushes to main are the hourly mirror + * advancing over unreviewed upstream code — authenticated by a deploy key, + * which (unlike GITHUB_TOKEN) does trigger push workflows. Every job in the + * file must therefore be gated on the repository being upstream, and the + * gate must stay independent of runner labels: Blacksmith not being + * installed here is an accident, not a safeguard. + */ + +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +const repoRoot = NodePath.resolve( + NodeURL.fileURLToPath(new URL(".", import.meta.url)), + "../../../..", +); + +const UPSTREAM_GATE = "github.repository == 'pingdotgg/t3code'"; + +/** Split the `jobs:` mapping into one entry per job, keyed by job id. */ +const readRelayJobs = (): ReadonlyArray<{ id: string; body: string }> => { + const yaml = NodeFS.readFileSync( + NodePath.join(repoRoot, ".github/workflows/deploy-relay.yml"), + "utf8", + ); + const jobsSection = yaml.slice(yaml.search(/^jobs:$/mu)); + const jobs: Array<{ id: string; body: string }> = []; + // Job ids sit at exactly two spaces of indent; anything deeper is job body. + const jobHeader = /^ {2}([A-Za-z_][\w-]*):$/gmu; + const headers = [...jobsSection.matchAll(jobHeader)]; + for (const [index, header] of headers.entries()) { + const id = header[1]; + if (id === undefined) continue; + const start = header.index + header[0].length; + const end = headers[index + 1]?.index ?? jobsSection.length; + jobs.push({ id, body: jobsSection.slice(start, end) }); + } + return jobs; +}; + +describe("fork guard: relay-upstream-only", () => { + it("gates every relay deploy job on the upstream repository", () => { + const jobs = readRelayJobs(); + const ungated = jobs.filter((job) => !job.body.includes(UPSTREAM_GATE)).map((job) => job.id); + expect(ungated).toEqual([]); + }); + + it("reads every job in the workflow", () => { + // Guards the parser itself: if the indentation convention changes and this + // finds nothing, the assertion above would pass vacuously. + expect(readRelayJobs().length).toBeGreaterThanOrEqual(1); + }); +});