diff --git a/.github/scripts/check-source-stack.sh b/.github/scripts/check-source-stack.sh new file mode 100644 index 00000000000..5597c5a920e --- /dev/null +++ b/.github/scripts/check-source-stack.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Verify that a manually supplied nightly source contains every fork patch +# present on main. This catches accidentally stale stacks; it is not a +# security boundary or a net-tree comparison (a later revert can still retain +# an earlier commit's patch-id). The refs must already exist locally, and the +# fork's squash-merged patch stack is expected to have linear history. +set -euo pipefail + +main_ref="${1:?main ref is required}" +candidate_ref="${2:?candidate ref is required}" +upstream_ref="${3:?upstream ref is required}" +allow_missing="${4:-false}" + +for ref in "$main_ref" "$candidate_ref" "$upstream_ref"; do + git rev-parse --verify "${ref}^{commit}" >/dev/null +done + +cherry_output=$(git cherry "$candidate_ref" "$main_ref" "$upstream_ref") + +missing_commits=() +while read -r status commit; do + if [[ "$status" == "+" ]]; then + missing_commits+=("$commit") + fi +done <<< "$cherry_output" + +if (( ${#missing_commits[@]} == 0 )); then + echo "source_ref contains every patch currently carried by main." + exit 0 +fi + +{ + echo "source_ref is missing patch-id-equivalent commits currently carried by main:" + echo + for commit in "${missing_commits[@]}"; do + printf ' %s %s\n' \ + "$(git rev-parse --short=12 "$commit")" \ + "$(git show -s --format=%s "$commit")" + done + echo +} >&2 + +if [[ "$allow_missing" == "true" ]]; then + echo "Conflict resolution may intentionally reshape patches; continuing after the explicit override." >&2 + echo "::warning title=Missing main patches explicitly allowed::Continuing because allow_missing_main_patches was enabled." >&2 + exit 0 +fi + +echo "Refresh the resolution from current main before dispatching it." >&2 +echo "If conflict resolution intentionally reshaped these patches, re-dispatch" >&2 +echo "with allow_missing_main_patches enabled after reviewing this list." >&2 +echo "::error title=source_ref is missing patches from main::Refresh the stack or explicitly allow the reported patch-id differences." >&2 +exit 1 diff --git a/.github/workflows/fork-nightly.yml b/.github/workflows/fork-nightly.yml index 7988000ea64..820bde67c80 100644 --- a/.github/workflows/fork-nightly.yml +++ b/.github/workflows/fork-nightly.yml @@ -25,6 +25,13 @@ on: required: false type: string default: "" + allow_missing_main_patches: + description: >- + Allow source_ref when patch-id checks report main patches missing; + use only after reviewing intentionally reshaped conflict resolutions + required: false + type: boolean + default: false permissions: contents: write @@ -93,6 +100,7 @@ jobs: name: Rebase candidate onto upstream shell: bash env: + ALLOW_MISSING_MAIN_PATCHES: ${{ inputs.allow_missing_main_patches == true }} DRY_RUN: ${{ inputs.dry_run == true }} SOURCE_REF: ${{ inputs.source_ref }} run: | @@ -109,6 +117,23 @@ jobs: echo "origin has no main branch to promote onto." >&2 exit 1 fi + + # Fetch the immutable main snapshot and current upstream before + # rebasing. The guard itself is loaded from main so a stale + # source_ref cannot omit or roll back the check it must pass. + git fetch --no-tags origin "$main_ref" + git fetch --no-tags https://github.com/pingdotgg/t3code.git main + guard_upstream_ref=$(git rev-parse FETCH_HEAD) + guard_script="$RUNNER_TEMP/check-source-stack.sh" + if ! git show "${main_ref}:.github/scripts/check-source-stack.sh" > "$guard_script"; then + echo "main snapshot ${main_ref} does not contain the source_ref patch guard; update main before dispatching a source_ref run." >&2 + exit 1 + fi + bash "$guard_script" \ + "$main_ref" \ + "$fork_ref" \ + "$guard_upstream_ref" \ + "$ALLOW_MISSING_MAIN_PATCHES" else main_ref=$fork_ref fi diff --git a/docs/operations/fork-nightly.md b/docs/operations/fork-nightly.md index 35ffd0d0ace..9152d40eebf 100644 --- a/docs/operations/fork-nightly.md +++ b/docs/operations/fork-nightly.md @@ -27,7 +27,12 @@ publishes a GitHub prerelease. when it did), verifies it, publishes the release, and promotes it to `main` through the same backup and lease mechanics as an automated run. The ruleset blocks force-pushing `main` from the CLI, so this dispatch is how a resolved stack reaches `main`. Pair `source_ref` with `dry_run` first to - verify a resolution without publishing or promoting anything. + verify a resolution without publishing or promoting anything. Prepare compares the supplied + stack's patch IDs with every fork patch currently carried by `main` and fails with the missing + commit subjects before rebasing or verifying a stale stack. Refresh the resolution from current + `main` immediately before dispatching it. Conflict resolution can intentionally reshape a patch + enough to change its patch ID; after reviewing every reported commit, set + `allow_missing_main_patches` to acknowledge those differences explicitly. ## Fork features summary diff --git a/scripts/check-source-stack.test.ts b/scripts/check-source-stack.test.ts new file mode 100644 index 00000000000..2bb69ee9eb5 --- /dev/null +++ b/scripts/check-source-stack.test.ts @@ -0,0 +1,221 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { assert, describe, it } from "@effect/vitest"; + +const repoRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const helperPath = NodePath.resolve(repoRoot, ".github/scripts/check-source-stack.sh"); + +function runGit(cwd: string, ...args: ReadonlyArray): string { + const result = NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function commitFile(cwd: string, fileName: string, contents: string, subject: string): string { + NodeFS.writeFileSync(NodePath.join(cwd, fileName), contents); + runGit(cwd, "add", fileName); + runGit(cwd, "commit", "-m", subject); + return runGit(cwd, "rev-parse", "HEAD"); +} + +function checkStack( + cwd: string, + mainRef: string, + candidateRef: string, + upstreamRef: string, + allowMissing = false, + env: NodeJS.ProcessEnv = process.env, +): NodeChildProcess.SpawnSyncReturns { + return NodeChildProcess.spawnSync( + "bash", + [helperPath, mainRef, candidateRef, upstreamRef, allowMissing ? "true" : "false"], + { cwd, encoding: "utf8", env }, + ); +} + +function withRepository(run: (fixtureRoot: string, upstreamRef: string) => void): void { + const fixtureRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-source-stack-")); + + try { + runGit(fixtureRoot, "init"); + runGit(fixtureRoot, "symbolic-ref", "HEAD", "refs/heads/main"); + runGit(fixtureRoot, "config", "user.name", "Source Stack Test"); + runGit(fixtureRoot, "config", "user.email", "source-stack@example.com"); + const upstreamRef = commitFile(fixtureRoot, "base.txt", "base\n", "feat: upstream base"); + run(fixtureRoot, upstreamRef); + } finally { + NodeFS.rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +describe("nightly source stack guard", () => { + it("accepts a candidate with patch-id-equivalent main commits", () => { + withRepository((fixtureRoot, upstreamRef) => { + commitFile(fixtureRoot, "one.txt", "one\n", "feat: first fork patch"); + commitFile(fixtureRoot, "two.txt", "two\n", "fix: second fork patch"); + + runGit(fixtureRoot, "switch", "-c", "candidate", upstreamRef); + runGit(fixtureRoot, "cherry-pick", "main~1", "main"); + + const result = checkStack(fixtureRoot, "main", "candidate", upstreamRef); + + assert.equal(result.status, 0, result.stderr); + assert.include(result.stdout, "contains every patch currently carried by main"); + }); + }); + + it("does not require upstream-only commits to be present in the candidate", () => { + withRepository((fixtureRoot, originalUpstreamRef) => { + runGit(fixtureRoot, "switch", "-c", "candidate"); + const candidatePatch = commitFile( + fixtureRoot, + "fork.txt", + "fork\n", + "feat: retained fork patch", + ); + + runGit(fixtureRoot, "switch", "-c", "upstream-next", originalUpstreamRef); + const currentUpstreamRef = commitFile( + fixtureRoot, + "upstream.txt", + "upstream\n", + "feat: newer upstream change", + ); + + runGit(fixtureRoot, "switch", "main"); + runGit(fixtureRoot, "merge", "--ff-only", "upstream-next"); + runGit(fixtureRoot, "cherry-pick", candidatePatch); + + const result = checkStack(fixtureRoot, "main", "candidate", currentUpstreamRef); + + assert.equal(result.status, 0, result.stderr); + }); + }); + + it("accepts a main patch that has since been absorbed by upstream", () => { + withRepository((fixtureRoot, originalUpstreamRef) => { + const mainPatch = commitFile( + fixtureRoot, + "feature.txt", + "fork feature\n", + "feat: fork patch accepted upstream", + ); + + runGit(fixtureRoot, "switch", "-c", "upstream-next", originalUpstreamRef); + runGit(fixtureRoot, "cherry-pick", mainPatch); + runGit(fixtureRoot, "commit", "--amend", "-m", "feat: upstream version of fork patch"); + const currentUpstreamRef = runGit(fixtureRoot, "rev-parse", "HEAD"); + + const result = checkStack(fixtureRoot, "main", "upstream-next", currentUpstreamRef); + + assert.equal(result.status, 0, result.stderr); + }); + }); + + it("accepts equivalent patches applied on top of a newer upstream base", () => { + withRepository((fixtureRoot, originalUpstreamRef) => { + const mainPatch = commitFile( + fixtureRoot, + "fork.txt", + "fork\n", + "feat: fork patch rebased after upstream", + ); + + runGit(fixtureRoot, "switch", "-c", "upstream-next", originalUpstreamRef); + const currentUpstreamRef = commitFile( + fixtureRoot, + "upstream.txt", + "new upstream\n", + "feat: newer upstream base", + ); + runGit(fixtureRoot, "switch", "-c", "candidate"); + runGit(fixtureRoot, "cherry-pick", mainPatch); + + const result = checkStack(fixtureRoot, "main", "candidate", currentUpstreamRef); + + assert.equal(result.status, 0, result.stderr); + }); + }); + + it("reports main patches when the candidate has no fork commits", () => { + withRepository((fixtureRoot, upstreamRef) => { + commitFile(fixtureRoot, "missing.txt", "missing\n", "fix: absent from empty candidate"); + runGit(fixtureRoot, "branch", "candidate", upstreamRef); + + const result = checkStack(fixtureRoot, "main", "candidate", upstreamRef); + + assert.equal(result.status, 1); + assert.include(result.stderr, "fix: absent from empty candidate"); + }); + }); + + it("fails closed when git cherry fails", () => { + withRepository((fixtureRoot, upstreamRef) => { + const wrapperDirectory = NodePath.join(fixtureRoot, "bin"); + const gitWrapper = NodePath.join(wrapperDirectory, "git"); + NodeFS.mkdirSync(wrapperDirectory); + NodeFS.writeFileSync( + gitWrapper, + `#!/bin/sh +if [ "$1" = "cherry" ]; then + exit 42 +fi +PATH="$ORIGINAL_PATH" exec git "$@" +`, + ); + NodeFS.chmodSync(gitWrapper, 0o755); + + const result = checkStack(fixtureRoot, "main", "main", upstreamRef, false, { + ...process.env, + ORIGINAL_PATH: process.env.PATH, + PATH: `${wrapperDirectory}:${process.env.PATH}`, + }); + + assert.equal(result.status, 42); + assert.notInclude(result.stdout, "contains every patch currently carried by main"); + }); + }); + + it("fails a stale candidate and lists each missing main commit by subject", () => { + withRepository((fixtureRoot, upstreamRef) => { + runGit(fixtureRoot, "switch", "-c", "candidate"); + commitFile(fixtureRoot, "one.txt", "one\n", "feat: retained fork patch"); + + runGit(fixtureRoot, "switch", "main"); + runGit(fixtureRoot, "merge", "--ff-only", "candidate"); + commitFile(fixtureRoot, "two.txt", "two\n", "fix: dropped workflow guard (#91)"); + commitFile(fixtureRoot, "three.txt", "three\n", "feat: dropped follow-up (#92)"); + + const result = checkStack(fixtureRoot, "main", "candidate", upstreamRef); + + assert.equal(result.status, 1); + assert.include(result.stderr, "fix: dropped workflow guard (#91)"); + assert.include(result.stderr, "feat: dropped follow-up (#92)"); + assert.include(result.stderr, "Refresh the resolution from current main"); + }); + }); + + it("requires an explicit override for intentionally reshaped patches", () => { + withRepository((fixtureRoot, upstreamRef) => { + commitFile(fixtureRoot, "feature.txt", "original resolution\n", "feat: resolved feature"); + + runGit(fixtureRoot, "switch", "-c", "candidate", upstreamRef); + commitFile(fixtureRoot, "feature.txt", "reshaped resolution\n", "feat: resolved feature"); + + const rejected = checkStack(fixtureRoot, "main", "candidate", upstreamRef); + const allowed = checkStack(fixtureRoot, "main", "candidate", upstreamRef, true); + + assert.equal(rejected.status, 1); + assert.equal(allowed.status, 0, allowed.stderr); + assert.include(allowed.stderr, "feat: resolved feature"); + assert.include(allowed.stderr, "Missing main patches explicitly allowed"); + }); + }); +});