diff --git a/.changeset/authorable-surface-anchor-monotonic.md b/.changeset/authorable-surface-anchor-monotonic.md new file mode 100644 index 0000000000..571cb08404 --- /dev/null +++ b/.changeset/authorable-surface-anchor-monotonic.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `--update-base` re-anchors forward or refuses — never backwards, never mid-merge (#5370) + +#5358 made re-anchoring `packages/spec/authorable-surface.base.json` an explicit act +(`gen:authorable-surface-base`). It settled **when** the anchor may be written, not **where +from**: the baseline is still `merge-base(HEAD, origin/main)`, and that is not always ahead +of the anchor already committed. + +The reported way in is a stopped merge. Until the merge is committed, `HEAD` is the branch +tip from *before* it, so the merge base is the branch's **old fork point** rather than the +main tip being merged in, and re-anchoring there rolls `baseRev` backwards. Measured on the +#5312 sync relay: `1c3da1f` → `5aae790`, returning the 109 keys #5321 had just retired. + +Nothing could catch it. The older rev is a genuine `origin/main` ancestor and the keys +written are that commit's surface verbatim, so the regressed file is **authentic** — +`verifyCommittedSurfaceBase`, `check:authorable-surface` and the pre-commit `os-regen` guard +are green before and after. The only trace is a reverse `baseRev` move in the diff, which +reads like the #4650 attack shape and was written by the generator itself. + +Two refusals, both in `--update-base` only: + +- **Mid-merge**: `MERGE_HEAD` present (resolved via `git rev-parse --git-path`, so linked + worktrees are handled) refuses before a single schema is generated, the way + `--check --update-base` already did, and prescribes the remedy — commit the merge, then + re-anchor. `scripts/regen-artifacts.mjs` states the same rule for the merge driver's side + ("Re-anchor after the merge is committed, or not at all"); this enforces it for the human + who types the command anyway. +- **Monotonicity**: the write happens only when the committed `baseRev` is an ancestor of + the newly resolved rev. Equal keys still take the existing "nothing to re-anchor" path. + +Ancestry that cannot be established refuses too, rather than defaulting to either verdict: +`merge-base --is-ancestor` is read as three answers (`0` / `1` / anything else with a +`fatal:`), and a `1` from a **shallow** checkout is discarded as unusable — truncation makes +git report "not an ancestor" about commits that plainly are one. A `0` is trusted +everywhere, shallow included, because a truncated walk can only lose reachability, never +invent it. + +`gen:schema` and every build are untouched: since #5358 they do not write this file at all, +so a build during a merge behaves exactly as before. diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index e43393a024..a67597f6db 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1010,3 +1010,211 @@ describe('build-schemas.ts — only --update-base moves the in-tree anchor (#535 }, ); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #5370 — the anchor moves FORWARD, or it does not move. +// +// #5358 answered WHEN the anchor may be written (only in `--update-base`). It did +// not answer WHERE FROM, and the answer was still "wherever `merge-base(HEAD, +// origin/main)` lands" — which is not always ahead of the anchor already +// committed. The reported shape is a stopped merge: until it is committed, HEAD is +// the branch tip from BEFORE the merge, so the merge base is the branch's OLD fork +// point rather than the main tip being merged in, and re-anchoring there rolls +// `baseRev` BACKWARDS. Measured on the #5312 sync relay: `1c3da1f` → `5aae790`, +// returning the 109 keys #5321 had just retired. +// +// The reason this needs a refusal rather than a warning is that the regressed file +// is INDISTINGUISHABLE from a good one by every property the gates check. The old +// rev is a genuine origin/main ancestor and the keys written are that commit's +// surface verbatim, so `verifyCommittedSurfaceBase`, `check:authorable-surface` +// and the pre-commit os-regen guard are all green before and after. Nothing +// anywhere reports it; the only trace is a reverse `baseRev` move in the diff, +// which reads like #4650's attack shape and was written by the generator. +// +// So both guards are refusals, and each pins its own half here: MERGE_HEAD present +// is refused before a single schema is generated, and any re-anchor whose new rev +// is not a descendant of the committed one is refused at the write. +describe('build-schemas.ts — --update-base moves the anchor forward or not at all (#5370)', () => { + /** Absent from `older` and from `tip` — the key a backwards move would drop. */ + const FORKED_KEY = 'data/Object:label'; + /** Absent from `older` and from `tip`, present at `mainTip` — so the remedy WRITES. */ + const LANDED_KEY = 'data/Object:description'; + + /** The branch's fork point: upstream, and behind the committed anchor. */ + let older: string; + /** The commit the anchor authentically mirrors — ahead of `older`, on origin/main. */ + let tip: string; + /** origin/main. Ahead of `tip`, so the anchor is authentic and merely lags. */ + let mainTip: string; + let anchorAtTip: string; + + const mergeHeadFile = (): string => + path.resolve(sandbox, git('rev-parse', '--git-path', 'MERGE_HEAD')); + + beforeAll(() => { + const keys = (JSON.parse(pristineSurface) as { keys: string[] }).keys; + for (const k of [FORKED_KEY, LANDED_KEY]) { + expect(keys, `${k} is no longer in the baseline — pick another live key`).toContain(k); + } + }); + + beforeEach(() => { + seedManifest((s) => s); + // Three upstream commits, linear, each one key richer than the last. The + // anchor mirrors the MIDDLE one: authentic (its keys ARE that commit's + // baseline), an ancestor of origin/main, and lagging — the ordinary state of + // every checkout between two surface changes. + older = seedBase((s) => s.filter((k) => k !== FORKED_KEY && k !== LANDED_KEY)); + tip = seedBase((s) => s.filter((k) => k !== LANDED_KEY)); + seedBase((s) => s); + seedSurface((s) => s); + anchorAtTip = seedSurfaceBase(tip, (k) => k.filter((x) => x !== LANDED_KEY)); + git('add', 'authorable-surface.json', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: anchor at the middle upstream commit'); + mainTip = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', mainTip); + + // HEAD forks at `older` and then makes main's own surface change BYTE FOR + // BYTE. Two consequences, both wanted: `merge-base(HEAD, origin/main)` is + // `older` (behind the anchor), and merging main in is conflict-free and stages + // nothing — so `git status` staying empty across a run means the run wrote + // nothing, with no merge noise to subtract. + git('checkout', '-q', '-B', 'issue-5370-fork', older); + seedSurface((s) => s); + seedSurfaceBase(tip, (k) => k.filter((x) => x !== LANDED_KEY)); + git('add', 'authorable-surface.json', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: branch work, forked before the anchor advanced'); + expect(git('status', '--porcelain', '-uno')).toBe(''); + expect(git('merge-base', 'HEAD', mainTip)).toBe(older); + }); + + afterEach(() => { + if (fs.existsSync(mergeHeadFile())) git('merge', '--abort'); + git('checkout', '-q', '-f', 'main'); + git('update-ref', 'refs/remotes/origin/main', mainTip); + }); + + it( + 'refuses while a merge is uncommitted — before generating anything — and the commit-first remedy works', + { timeout: SPAWN_TIMEOUT_MS * 3 }, + () => { + git('merge', '--no-commit', '--no-ff', mainTip); + expect(fs.existsSync(mergeHeadFile()), 'fixture is not actually mid-merge').toBe(true); + expect(git('status', '--porcelain', '-uno')).toBe(''); + + const refused = run(['--update-base']); + + expect(refused.status).toBe(1); + expect(refused.output).toContain('refuses to run mid-merge (#5370)'); + expect(refused.output).toContain('Commit the merge first'); + expect(refused.output).toContain('gen:authorable-surface-base'); + // Refused before the ~1600-schema generation, like `--check --update-base`. + expect(refused.output).not.toContain('Generating JSON Schemas'); + expect(readSurfaceBase()).toBe(anchorAtTip); + expect(git('status', '--porcelain', '-uno')).toBe(''); + + // The guard is narrow: a plain build mid-merge is NOT refused. #5807 took the + // anchor out of every build, which is why `scripts/regen-artifacts.mjs` can + // still prescribe `gen:schema` after a merge — refusing that too would break + // the driver's own deferred regeneration. + const build = run([]); + expect(build.status).toBe(0); + expect(build.output).not.toContain('#5370'); + expect(readSurfaceBase()).toBe(anchorAtTip); + expect(git('status', '--porcelain', '-uno')).toBe(''); + + // Now the prescription, literally: commit the merge, then re-anchor. HEAD's + // merge base with origin/main is `mainTip` once the merge is a commit, so the + // move is forward and the write lands — the refusal has a working remedy, not + // just a rule. + git('commit', '-q', '--no-edit'); + const reanchored = run(['--update-base']); + + expect(reanchored.status).toBe(0); + expect(reanchored.output).toContain('⚓'); + const doc = JSON.parse(readSurfaceBase()) as { baseRev: string; keys: string[] }; + expect(doc.baseRev).toBe(mainTip); + expect(doc.keys).toContain(LANDED_KEY); + expect(doc.keys).toEqual((JSON.parse(pristineSurface) as { keys: string[] }).keys); + }, + ); + + it( + 'refuses a re-anchor whose new rev is an ancestor of the committed one, and writes nothing', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // No merge in sight: the same backwards move reached by ordinary means — a + // branch forked before the anchor advanced (a `git checkout origin/main -- + // ` or a driver resolution to THEIRS gets here too). MERGE_HEAD is + // not the defect, it is one way in; the write is where the defect is decided. + const { status, output } = run(['--update-base']); + + expect(status).toBe(1); + expect(output).toContain('would move authorable-surface.base.json BACKWARDS (#5370)'); + expect(output).toContain(tip.slice(0, 12)); // committed baseRev + expect(output).toContain(older.slice(0, 12)); // what the run resolved + expect(output).not.toContain('⚓'); + expect(readSurfaceBase()).toBe(anchorAtTip); + expect(git('status', '--porcelain', '-uno')).toBe(''); + }, + ); + + it( + 'refuses rather than guesses when a NEGATIVE ancestry answer cannot be trusted — a shallow checkout', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Truncating history AT `mainTip` cuts the walk that would reach the anchor's + // own commit, so `merge-base --is-ancestor tip mainTip` reports "not an + // ancestor" about a commit that plainly is one — the shape that failed #5358's + // first CI run, and the live state of every agent container today (the + // anchor's baseRev sits in `.git/shallow` as its own grafted root). + // + // The move here is genuinely FORWARD, so this is the guard's own cost, stated + // rather than hidden: where the answer is unusable it refuses, and the message + // has to name the reason and the remedy instead of the backwards verdict. The + // opposite disposition — trusting the 1 — would fail every re-anchor in a + // shallow clone with an accusation of a backwards move that never happened. + fs.writeFileSync(path.join(sandbox, '.git', 'shallow'), `${mainTip}\n`); + try { + expect(git('rev-parse', '--is-shallow-repository')).toBe('true'); + + const { status, output } = run(['--update-base']); + + expect(status).toBe(1); + expect(output).toContain('cannot establish which way'); + expect(output).toContain('shallow checkout'); + // The reason it refuses is truncation, NOT a backwards move — a message + // that said otherwise would send the reader to fix a history that is fine. + expect(output).not.toContain('BACKWARDS'); + expect(output).toContain('git fetch --unshallow origin'); + expect(output).not.toContain('⚓'); + expect(readSurfaceBase()).toBe(anchorAtTip); + } finally { + fs.rmSync(path.join(sandbox, '.git', 'shallow'), { force: true }); + } + }, + ); + + it( + 'an origin/main rewound BEHIND the anchor is caught one gate earlier, by the authenticity check', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Recorded because it is the fixture this guard reads like it needs and does + // not: pointing origin/main at an ancestor of the committed `baseRev` never + // reaches the write at all. `verifyCommittedSurfaceBase` runs first and the + // anchor is no longer an ancestor of origin/main, so the run dies on + // authenticity — a different fact, with a different remedy. The backwards move + // the monotonicity guard exists for is the one where BOTH revs are authentic + // (the case above), which is precisely why nothing else could see it. + git('update-ref', 'refs/remotes/origin/main', older); + + const { status, output } = run(['--update-base']); + + expect(status).toBe(1); + expect(output).toContain('NOT an ancestor of'); + expect(output).toContain(tip.slice(0, 12)); + expect(output).not.toContain('#5370'); + expect(readSurfaceBase()).toBe(anchorAtTip); + }, + ); +}); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index c6e1b3758f..5db0ae92ba 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -95,6 +95,80 @@ if (CHECK && UPDATE_BASE) { ); process.exit(1); } + +/** + * The ONE command that (re)writes the in-tree anchor (#5358). Every prescription + * about that file names this and nothing else — a message that still said + * `gen:schema` would send the reader to a command that no longer touches it. + * + * Declared up here, above the mode's own refusals, because the earliest of them + * runs before this file has read a single schema (#5370). + */ +const REANCHOR_COMMAND = 'pnpm --filter @objectstack/spec gen:authorable-surface-base'; + +/** + * The path git itself reports for `$GIT_DIR/MERGE_HEAD`, or null when this is not + * a git repository at all (an image-build stage, an unpacked tarball). + * + * Asked of git rather than assembled by hand: in every linked worktree — which + * AGENTS.md §11 requires every agent in this repo to work in — `.git` is a FILE, + * and the real per-worktree MERGE_HEAD lives under `.git/worktrees//`. A + * literal `.git/MERGE_HEAD` finds nothing there, so the hand-built path would + * leave this guard dead in exactly the checkouts the repo tells people to use. + */ +function mergeHeadPath(cwd: string): string | null { + const probe = spawnSync('git', ['rev-parse', '--git-path', 'MERGE_HEAD'], { + cwd, + encoding: 'utf-8', + timeout: 60_000, + }); + if (probe.status !== 0) return null; + const reported = (probe.stdout ?? '').trim(); + // Relative to cwd inside a main worktree, absolute inside a linked one. + return reported ? path.resolve(cwd, reported) : null; +} + +// ── Re-anchoring mid-merge resolves the WRONG baseline (#5370) ──────── +// +// `resolveSurfaceBase()` anchors on `merge-base(HEAD, origin/main)`. While a merge +// is stopped before its commit, HEAD is still the branch tip from BEFORE the +// merge, so that merge base is the branch's OLD fork point rather than the main +// tip being merged in. Re-anchoring there moves `baseRev` BACKWARDS. +// +// What makes this worth a refusal rather than a warning is that nothing else can +// catch it: the older rev is a genuine origin/main ancestor and the keys written +// are that commit's surface verbatim, so the anchor stays AUTHENTIC — the +// authenticity check, `check:authorable-surface` and the pre-commit os-regen +// guard all pass on the regressed file. The only trace is a reverse `baseRev` +// move in the diff, which is the #4650 attack shape, written by the generator +// itself. Observed on the #5312 sync relay: `baseRev` rolled from main's `1c3da1f` +// back to `5aae790`, returning the 109 keys #5321 had just retired. +// +// `scripts/regen-artifacts.mjs` states the same rule for the merge driver's side — +// "Re-anchor after the merge is committed, or not at all" — and keeps the driver +// from ever prescribing this command mid-merge. This is that sentence enforced for +// the human who types it anyway, and it refuses BEFORE the ~1600-schema +// generation, like the mutual-exclusion refusal above. +if (UPDATE_BASE) { + const mergeHead = mergeHeadPath(path.resolve(__dirname, '..')); + if (mergeHead && fs.existsSync(mergeHead)) { + console.error( + `\n❌ --update-base refuses to run mid-merge (#5370).\n\n` + + ` MERGE_HEAD is present, so this merge has not been committed yet. The baseline this\n` + + ` mode re-anchors on is \`merge-base(HEAD, origin/main)\`, and mid-merge HEAD is still\n` + + ` the branch tip from before the merge — that merge base is the branch's OLD fork\n` + + ` point, not the main tip being merged in. Re-anchoring here moves baseRev BACKWARDS,\n` + + ` and nothing downstream reports it: the older rev is a real origin/main ancestor and\n` + + ` its keys are that commit's surface verbatim, so the anchor stays AUTHENTIC while\n` + + ` silently undoing whatever main had already anchored past — a retirement included.\n\n` + + ` Commit the merge first, then re-anchor on the merged tree:\n\n` + + ` git commit # finish the merge\n` + + ` ${REANCHOR_COMMAND}\n\n` + + ` Re-anchor after the merge is committed, or not at all (scripts/regen-artifacts.mjs).`, + ); + process.exit(1); + } +} const SPEC_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')).version; const SCHEMA_BASE_URL = `https://schema.objectstack.io/v${SPEC_VERSION}`; @@ -471,12 +545,9 @@ const AUTHORABLE_SURFACE_PATH = path.resolve(__dirname, '../authorable-surface.j const AUTHORABLE_SURFACE_BASE_PATH = path.resolve(__dirname, '../authorable-surface.base.json'); const SURFACE_FILE_NAME = path.basename(AUTHORABLE_SURFACE_PATH); const SURFACE_BASE_FILE_NAME = path.basename(AUTHORABLE_SURFACE_BASE_PATH); -/** - * The ONE command that (re)writes the in-tree anchor (#5358). Every prescription - * about that file names this and nothing else — a message that still said - * `gen:schema` would send the reader to a command that no longer touches it. - */ -const REANCHOR_COMMAND = 'pnpm --filter @objectstack/spec gen:authorable-surface-base'; +// `REANCHOR_COMMAND` — the ONE command that writes this file — is declared near +// the top of this script, next to the `--update-base` flag it names: the merge +// refusal there quotes it, and that refusal runs before anything here (#5370). const RETIRED_MARK = ' [RETIRED]'; interface AuthorableSurface { description: string; keys: string[] } @@ -913,6 +984,20 @@ function readCommittedSurfaceBase(): { raw: string; doc: AuthorableSurfaceBase } type GitRun = (...args: string[]) => { status: number | null; stdout: string; stderr: string }; +/** + * Git, run in the package directory — the one runner every check below shares + * (`resolveSurfaceBase()` used to hold a private copy of it; the anchor's + * monotonicity guard needs the same one, at a different point in the script). + */ +const gitInPackage: GitRun = (...args: string[]) => + // A network-less environment that BLACKHOLES rather than refuses (proxied + // air gaps do) would otherwise hang the whole build in the self-heal fetch. + spawnSync('git', args, { + cwd: path.dirname(AUTHORABLE_SURFACE_PATH), + encoding: 'utf-8' as const, + timeout: 60_000, + }); + /** * The in-tree anchor must be an authentic copy of an UPSTREAM commit's baseline, * and this is the environment that can prove it (#5235). @@ -1034,6 +1119,93 @@ function compareAnchorKeys( process.exit(1); } +/** + * The anchor moves FORWARD, or it does not move (#5370). + * + * Called immediately before the only write, so a re-anchor may replace `baseRev` + * only with a commit the committed `baseRev` is an ancestor of. Everything the + * gate normally proves is *about a single rev* — that it is on origin/main and + * that its keys are that commit's surface — and a rev the branch merely forked + * from earlier passes both. So a backwards move is invisible to every other + * check: `verifyCommittedSurfaceBase` is green before and after, and so is + * `check:authorable-surface`. The loss is real anyway. The anchor main advanced + * is replaced by an older one, so a retirement main had already anchored past + * comes back, the deletion gate stops seeing it, and the offline consumers of + * #5235 get a baseline older than the published one. + * + * Three exit codes from `merge-base --is-ancestor`, and they must be read as + * three answers, not two: 0 is "ancestor", 1 is "not an ancestor", and anything + * else (128 with a `fatal:`, or `null` from the timeout) is git declining to + * answer. Folded into a `&&`/`||` chain the third collapses into the second and + * an ERROR becomes a verdict — the trap cloud#1116 paid for. Here it fails + * CLOSED: an ancestry nobody could establish refuses the write. + * + * Shallow history makes a FOURTH reading necessary, and it is asymmetric. A + * truncated walk can only ever LOSE reachability, never invent it, so exit 0 is + * proof wherever it appears — while exit 1 in a shallow checkout means nothing at + * all (#5358's own first CI run was failed by exactly that answer, about a commit + * that plainly was an ancestor; the same false 1 is reproducible today in any + * agent container, where the anchor's `baseRev` sits in `.git/shallow` as its own + * grafted root). So shallowness is consulted only to decide whether a NEGATIVE + * counts — never to discard a positive, which would refuse re-anchors that are + * demonstrably fine. + */ +function assertAnchorMovesForward(git: GitRun, committedRev: string, resolvedRev: string): void { + if (committedRev === resolvedRev) return; + const from = committedRev.slice(0, 12); + const to = resolvedRev.slice(0, 12); + const refuseIndeterminate = (why: string): never => { + console.error( + `\n❌ --update-base cannot establish which way ${SURFACE_BASE_FILE_NAME} would move (#5370).\n\n` + + ` committed baseRev: ${from}\n` + + ` resolved baseline: ${to} (merge base of HEAD with origin/main)\n\n` + + ` ${why}\n\n` + + ` The anchor may only ever move forward, so an ancestry that could not be established\n` + + ` refuses the write instead of defaulting to one of the two answers. Re-anchor from a\n` + + ` checkout with walkable history (\`git fetch --unshallow origin\`, or a full clone) and\n` + + ` run \`${REANCHOR_COMMAND}\` there.`, + ); + process.exit(1); + }; + + const probe = git('merge-base', '--is-ancestor', committedRev, resolvedRev); + // Reachability was demonstrated. Truncation cannot fake that, so this is the + // one answer that stands in every checkout, shallow included. + if (probe.status === 0) return; + if (probe.status !== 1) { + return refuseIndeterminate( + `\`git merge-base --is-ancestor ${from} ${to}\` did not answer (exit ${probe.status}):\n` + + ` ${(probe.stderr || '').trim().split('\n')[0] || '(no output)'}`, + ); + } + // A negative, on the other hand, is only meaningful where history is WALKABLE — + // the same truncation `verifyCommittedSurfaceBase` accounts for. There it SKIPS + // a verification, which is safe; here it would BLESS a write, which is not. + if (git('rev-parse', '--is-shallow-repository').stdout.trim() === 'true') { + return refuseIndeterminate( + 'This is a shallow checkout: history is truncated, so `merge-base --is-ancestor` reports\n' + + ` "not an ancestor" about commits that plainly are one — ${from} is very likely one of\n` + + ' them (a `--depth=1` fetch grafts it in as its own root, unreachable from origin/main).', + ); + } + console.error( + `\n❌ --update-base would move ${SURFACE_BASE_FILE_NAME} BACKWARDS (#5370).\n\n` + + ` committed baseRev: ${from}\n` + + ` resolved baseline: ${to} (merge base of HEAD with origin/main)\n\n` + + ` ${to} is not a descendant of ${from}, so re-anchoring here would replace an anchor main\n` + + ` has already advanced with an older one. Both are authentic — ${to} is a real origin/main\n` + + ` ancestor and its keys are that commit's surface verbatim — which is exactly why no other\n` + + ` gate objects: this file's authenticity check passes, check:authorable-surface passes, and\n` + + ` the only trace left is a reverse baseRev move in the diff, indistinguishable at a glance\n` + + ` from the #4650 attack shape. What it silently drops is whatever main anchored past in\n` + + ` between, a retirement included.\n\n` + + ` HEAD's merge base with origin/main is behind the committed anchor. Bring HEAD up to date\n` + + ` and COMMIT that first — \`git fetch origin main\` then merge or rebase — and re-anchor on\n` + + ` the result: \`${REANCHOR_COMMAND}\`.`, + ); + process.exit(1); +} + /** * Set when THIS run resolved the baseline from git. It is the ONLY input * `--update-base` may write the in-tree anchor from: an offline build must never @@ -1066,11 +1238,7 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null; * closes. With no anchor of either kind this still exits 1. */ function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { - const cwd = path.dirname(AUTHORABLE_SURFACE_PATH); - const git: GitRun = (...args: string[]) => - // A network-less environment that BLACKHOLES rather than refuses (proxied - // air gaps do) would otherwise hang the whole build in the self-heal fetch. - spawnSync('git', args, { cwd, encoding: 'utf-8' as const, timeout: 60_000 }); + const git = gitInPackage; const committed = readCommittedSurfaceBase(); // CI's typecheck job checks out shallow with no branch refs, so fetch the @@ -1289,6 +1457,13 @@ function resolveSurfaceBase(): { rev: string; doc: AuthorableSurface } | null { } if (UPDATE_BASE) { if (drifted) { + // Direction first: the anchor moves forward or not at all (#5370). Asked + // HERE rather than up front because this is where the rev that would be + // written is finally known — and because it is the write, not the run, + // that has to be refused: a `--update-base` with nothing to write is not + // turned into a failure by a rev comparison. A creating run (no committed + // anchor) has no direction to check. + if (committed) assertAnchorMovesForward(gitInPackage, committed.doc.baseRev, anchor.rev); fs.writeFileSync(AUTHORABLE_SURFACE_BASE_PATH, serializeSurfaceBase(anchor.rev, anchor.keys)); console.log( `\n⚓ ${SURFACE_BASE_FILE_NAME} ${committed ? 'refreshed to' : 'created at'} ` +