Skip to content
Merged
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
8 changes: 6 additions & 2 deletions .fork/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions .fork/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 29 additions & 4 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/deploy-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
63 changes: 62 additions & 1 deletion .github/workflows/fork-sync-mirror.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,48 @@ 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:
group: fork-sync-mirror
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
Expand All @@ -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
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/__fork_guards__/ciOnCustom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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: `- <name>` 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");
});
});
59 changes: 59 additions & 0 deletions apps/web/src/__fork_guards__/relayUpstreamOnly.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading