From 59cdc864749fff5197d4e1ff10efe28a2511a1b0 Mon Sep 17 00:00:00 2001 From: Nacho Lopez Date: Thu, 29 Oct 2026 19:28:12 +0100 Subject: [PATCH] ci: replay adapted ledger patches where their retired originals were A ledger patch adapted by a stable sync keeps its original in alas history, retired, and the adaptation reaches alas later through the sync merge. The replay walked alas by commit date, so fork commits that build on the patch replayed before its appliedCommit and conflicted. The v2.5.0 codex sync stopped this way on 6b51376 (docs/codex-hook-review.md), which depends on neutral-codex-hook-review-execution, adapted in v2.4.0 as 78e7be6. Each ledger patch's appliedCommit now replays at the position of its first retired original, as non-ledger downstreamTransitions already do. This does not depend on patch ids, because an adaptation can keep its original's patch id. An appliedCommit that is not in protected alas fails the build. --- docs/ALAS_DOWNSTREAM.md | 6 +- scripts/sync-candidate.mjs | 28 ++- .../sync-ledger-replacement-order.test.mjs | 190 ++++++++++++++++++ 3 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 scripts/sync-ledger-replacement-order.test.mjs diff --git a/docs/ALAS_DOWNSTREAM.md b/docs/ALAS_DOWNSTREAM.md index ee8bcacff..dbad36459 100644 --- a/docs/ALAS_DOWNSTREAM.md +++ b/docs/ALAS_DOWNSTREAM.md @@ -161,7 +161,11 @@ candidate commit at the point where the patch is replayed (stacked adaptation). The candidate is rebuilt deterministically, so a stacked adaptation binds only when every earlier replay reproduces that same parent; any drift fails closed. Push adaptation commits where the workflow can fetch them before rerunning it; -once bound, they are ancestors of the integration commit. +once bound, they are ancestors of the integration commit. The advanced ledger +records the adaptation as the patch's `appliedCommit` and retires the original; +later syncs replay `appliedCommit` at the position of the first retired +original, so fork commits that build on the patch still replay after it. An +`appliedCommit` that is not in protected `alas` fails the build. A fork-only commit outside the ledger is normally replayed as is. When it conflicts with the new stable release, the review may name an adaptation in diff --git a/scripts/sync-candidate.mjs b/scripts/sync-candidate.mjs index 1a3c9888f..4fdc61011 100644 --- a/scripts/sync-candidate.mjs +++ b/scripts/sync-candidate.mjs @@ -94,25 +94,34 @@ function isRedundantPick(cwd, commit) { return redundant; } -// Replay order of the downstream commits. A reviewed adaptation of a non-ledger commit takes the -// place of its retired original (and of the original's retired copies), so fork commits that -// depend on it still replay after it. -function downstreamReplayOrder({downstreamCommits, patchIds, ledgerByPatchId, retiredCommits, transitions}) { - const replacementOf = new Map(); +// Replay order of the downstream commits. A reviewed adaptation takes the place of its retired +// original (and of the original's retired copies), so fork commits that depend on it still replay +// after it. This holds for a non-ledger adaptation recorded in `downstreamTransitions` and for a +// ledger patch whose `appliedCommit` replaced its retired original. +function downstreamReplayOrder({downstreamCommits, patchIds, ledgerByPatchId, retiredCommits, transitions, ledgerPatches}) { + const transitionReplacementOf = new Map(); for (const transition of transitions) { if (transition?.action !== "adapt" || !transition.replacementCommit) continue; for (const commit of [transition.commit, ...(transition.equivalentCommits ?? [])]) { - replacementOf.set(commit, transition.replacementCommit); + transitionReplacementOf.set(commit, transition.replacementCommit); } } + // A ledger adaptation can keep its original's patch id (same change, same context), so ledger + // replacements do not depend on the retired commit's patch id. + const ledgerReplacementOf = new Map(); + for (const patch of ledgerPatches) { + if (!patch.appliedCommit) continue; + for (const commit of patch.retiredCommits ?? []) ledgerReplacementOf.set(commit, patch.appliedCommit); + } const order = []; const placed = new Set(); for (const [index, commit] of downstreamCommits.entries()) { const id = patchIds[index]; - const replacement = replacementOf.get(commit); - if (replacement && retiredCommits.has(commit) && !(id && ledgerByPatchId.has(id))) { + const replacement = ledgerReplacementOf.get(commit) ?? + (id && ledgerByPatchId.has(id) ? undefined : transitionReplacementOf.get(commit)); + if (replacement && retiredCommits.has(commit)) { if (!downstreamCommits.includes(replacement)) { - throw new Error(`Downstream transition replacement ${replacement} for ${commit} is not in protected alas`); + throw new Error(`Replacement ${replacement} for retired downstream commit ${commit} is not in protected alas`); } if (!placed.has(replacement)) order.push(replacement); placed.add(replacement); @@ -661,6 +670,7 @@ export function buildSyncCandidate({ ledgerByPatchId, retiredCommits, transitions: ledger.downstreamTransitions ?? [], + ledgerPatches: ledger.patches, }); for (const commit of replayOrder) { const id = patchIdByCommit.get(commit); diff --git a/scripts/sync-ledger-replacement-order.test.mjs b/scripts/sync-ledger-replacement-order.test.mjs new file mode 100644 index 000000000..5ca536765 --- /dev/null +++ b/scripts/sync-ledger-replacement-order.test.mjs @@ -0,0 +1,190 @@ +// The stable sync after a reviewed ledger adaptation. The ledger patch's original stays in alas +// history, retired, and the adaptation reaches alas later through the sync merge. Fork commits that +// build on the patch sit between the two, so the replay must use the adaptation where the retired +// original was. +import assert from "node:assert/strict"; +import {execFileSync} from "node:child_process"; +import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {test} from "vitest"; +import {buildSyncCandidate} from "./sync-candidate.mjs"; +import {verifySyncSourceReview} from "./verify-sync-source-review.mjs"; + +let clock = 1_820_000_000; + +function git(cwd, ...args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: {...process.env, GIT_AUTHOR_DATE: `${clock} +0000`, GIT_COMMITTER_DATE: `${clock} +0000`}, + }).trim(); +} + +function commitFiles(cwd, files, message) { + clock += 60; + for (const [path, contents] of Object.entries(files)) { + const full = join(cwd, path); + mkdirSync(join(full, ".."), {recursive: true}); + writeFileSync(full, contents); + git(cwd, "add", path); + } + git(cwd, "commit", "-m", message); + return git(cwd, "rev-parse", "HEAD"); +} + +function build(f, {tag, base, ledger, ...options}) { + return buildSyncCandidate({ + cwd: f.cwd, + tagRef: tag, + baseRef: base, + upstreamRef: "upstream", + alasRef: "alas", + branch: `sync/upstream-${tag.slice(1)}`, + syncRefs: [], + ledger, + expectedPatchIdentities: ledger.patches, + ...options, + }); +} + +const FEATURE = "one\ntwo\nthree\nfour\n"; +const UPSTREAM_FEATURE = FEATURE.replace("one", "ONE"); + +function patchIdOf(cwd, commit) { + const patch = execFileSync("git", ["show", "--pretty=format:", "--binary", commit], {cwd, encoding: "utf8"}); + return execFileSync("git", ["patch-id", "--stable"], {cwd, encoding: "utf8", input: `${patch}\n`}).split(/\s+/)[0]; +} + +// alas carries a ledger patch that adds a line to feature.txt and a later fork commit that edits +// that line. v1.1.0 changes another line of the same file, so the v1.1.0 sync adapts the patch onto +// the tag and is merged into alas like a protected PR. v1.2.0 then only changes an unrelated file. +// When `upstreamFeature` changes the patch's context, the adaptation gets another patch id. +function fixture({upstreamFeature = UPSTREAM_FEATURE} = {}) { + const root = mkdtempSync(join(tmpdir(), "alas-sync-ledger-order-")); + const cwd = join(root, "repo"); + mkdirSync(cwd); + git(cwd, "init", "-b", "upstream", cwd); + git(cwd, "config", "user.name", "Fixture"); + git(cwd, "config", "user.email", "fixture@example.test"); + const base = commitFiles(cwd, { + "package.json": '{"version":"1.0.0"}\n', + "feature.txt": FEATURE, + "upstream.txt": "one\n", + }, "old stable"); + git(cwd, "tag", "v1.0.0"); + + git(cwd, "checkout", "-b", "alas", base); + const patch = commitFiles(cwd, {"feature.txt": `${FEATURE}downstream\n`}, "feat: downstream feature"); + const dependent = commitFiles(cwd, {"feature.txt": `${FEATURE}downstream, refined\n`}, "fix: refine downstream feature"); + const ledger = { + schemaVersion: 2, + baseTag: "v1.0.0", + patches: [{ + name: "downstream", + commit: patch, + upstreamPr: null, + files: ["feature.txt"], + tests: ["feature.test.ts"], + }], + retiredCommits: [], + preservedTransitions: [], + }; + const recorded = commitFiles(cwd, {"docs/alas-downstream-patches.json": `${JSON.stringify(ledger, null, 2)}\n`}, "record ledger"); + // Fork commits reach alas through PR merges. Dated like the last commit it merges, this merge makes + // the history walk list the original fix before the v1.1.0 adaptation, as on the real fork. + git(cwd, "reset", "--hard", git(cwd, "commit-tree", `${recorded}^{tree}`, "-p", recorded, "-p", base, + "-m", "Merge pull request #0 from nacho/downstream-feature")); + + git(cwd, "checkout", "upstream"); + const stable = commitFiles(cwd, { + "package.json": '{"version":"1.1.0"}\n', + "feature.txt": upstreamFeature, + }, "new stable"); + git(cwd, "tag", "v1.1.0"); + const f = {root, cwd, base, patch, dependent, stable, ledger}; + + // The reviewed v1.1.0 sync: the patch overlaps the upstream edit and is adapted onto the tag. + const first = {tag: "v1.1.0", base: "v1.0.0", ledger}; + const unresolved = build(f, first); + assert.deepEqual(unresolved.syncReview.patches.map((entry) => entry.classification), ["overlap"]); + git(cwd, "checkout", "--detach", stable); + const adaptation = commitFiles(cwd, {"feature.txt": `${upstreamFeature}downstream\n`}, "feat: adapt downstream feature to v1.1.0"); + const review = structuredClone(unresolved.syncReview); + review.patches[0].resolution = { + action: "adapt", + commit: adaptation, + automatic: false, + rationale: "Keeps the downstream line after the v1.1.0 context.", + tests: ["feature.test.ts"], + }; + const synced = build(f, {...first, reviewOverride: review}); + assert.deepEqual(synced.manualReviewReasons, []); + git(cwd, "checkout", "alas"); + clock += 60; + git(cwd, "merge", "--no-ff", "-m", "Merge pull request #1 from sync/upstream-1.1.0", synced.integrationCommit); + const advanced = JSON.parse(readFileSync(join(cwd, "docs/alas-downstream-patches.json"), "utf8")); + assert.equal(advanced.patches[0].appliedCommit, adaptation); + assert.deepEqual(advanced.patches[0].retiredCommits, [patch]); + + git(cwd, "checkout", "upstream"); + const next = commitFiles(cwd, {"package.json": '{"version":"1.2.0"}\n', "upstream.txt": "two\n"}, "next stable"); + git(cwd, "tag", "v1.2.0"); + return {...f, upstreamFeature, adaptation, advanced, next}; +} + +for (const {variant, upstreamFeature, samePatchId} of [ + {variant: "keeps its original's patch id", upstreamFeature: UPSTREAM_FEATURE, samePatchId: true}, + {variant: "has another patch id", upstreamFeature: FEATURE.replace("three", "THREE"), samePatchId: false}, +]) { + test(`replays an adapted ledger patch that ${variant} where its retired original was on the next stable sync`, () => { + const f = fixture({upstreamFeature}); + try { + assert.equal(patchIdOf(f.cwd, f.adaptation) === patchIdOf(f.cwd, f.patch), samePatchId); + const report = build(f, {tag: "v1.2.0", base: "v1.1.0", ledger: f.advanced}); + assert.deepEqual(report.conflicts, []); + assert.deepEqual(report.manualReviewReasons, []); + assert.deepEqual(report.appliedPatches, ["downstream"]); + assert.equal(git(f.cwd, "show", `${report.exactCandidateCommit}:feature.txt`), `${f.upstreamFeature}downstream, refined`); + const applied = report.appliedDownstreamCommits; + assert.ok(applied.indexOf(f.adaptation) >= 0 && applied.indexOf(f.adaptation) < applied.indexOf(f.dependent)); + assert.equal(applied.filter((commit) => commit === f.adaptation).length, 1); + assert.ok(!applied.includes(f.patch)); + + const repeated = build(f, {tag: "v1.2.0", base: "v1.1.0", ledger: f.advanced}); + assert.equal(repeated.integrationCommit, report.integrationCommit); + + const wrapper = git(f.cwd, "commit-tree", `${report.integrationCommit}^{tree}`, + "-p", git(f.cwd, "rev-parse", "alas"), "-p", report.integrationCommit, "-m", "Merge pull request #2"); + git(f.cwd, "branch", "alas-wrapper", wrapper); + const verified = verifySyncSourceReview({ + cwd: f.cwd, + sourceCommit: wrapper, + alasRef: "alas-wrapper", + upstreamTag: "v1.2.0", + upstreamMainRef: "upstream", + packageVersion: "1.2.0", + review: report.syncReview, + ledger: report.advancedLedger, + expectedPatchIdentities: f.advanced.patches, + }); + assert.equal(verified.reconstructedCandidateCommit, report.exactCandidateCommit); + assert.equal(verified.reconstructedIntegrationCommit, report.integrationCommit); + } finally { + rmSync(f.root, {recursive: true, force: true}); + } + }, 60_000); +} + +test("fails closed when an adapted ledger patch's applied commit is not in protected alas", () => { + const f = fixture(); + try { + const ledger = structuredClone(f.advanced); + ledger.patches[0].appliedCommit = f.next; + ledger.patches[0].lastResolution.replacementCommit = f.next; + assert.throws(() => build(f, {tag: "v1.2.0", base: "v1.1.0", ledger}), /not in protected alas/); + } finally { + rmSync(f.root, {recursive: true, force: true}); + } +}, 60_000);