fix(#2504): make auto-backmerge survive a broken workflow copy + gate the invariants (#2506)

The main->next auto-backmerge fails after nearly every release, leaving
main not an ancestor of next, so the following release->main merge-back
conflicts. Root cause is a copy-shuffling loop: auto-backmerge.yml must be
identical on main and next, but `-s ours` (main->next) and the release-tree
merge-back (release->main) each overwrite one copy wholesale, so a fix
applied to one copy is repeatedly overwritten by the copy that lacks it.
The build:lib step proves it: added to main (329233fc8), overwritten by
the 1.7.0 merge-back, re-added to next (#2281), never on old-main -> the
1.7.0 backmerge ran on main's broken copy and failed at "Sync next's
version" (npm version -> gen-capability-registry needs the gitignored
capability-ledger from build:lib -> absent -> step fails -> "Open PR"
skipped -> no PR -> main never becomes an ancestor of next).

Two-part durable fix:

1. Blast-radius containment: mark the version-sync steps continue-on-error.
   The job's load-bearing purpose is opening + admin-merging the back-merge
   PR (the ancestry that keeps release->main clean). A version-sync failure
   (missing build:lib after a copy regression, or any npm-version lifecycle
   hiccup) can no longer abort that PR. A sync failure now costs only a
   stale next version, trivially re-synced -- never a broken back-merge.

2. Required-steps gate: tests/release-backmerge-invariants.test.cjs parses
   the workflow YAML and asserts build:lib runs before the version-sync,
   both steps are continue-on-error, the ancestry steps exist, and the
   finalize timeout is >= 30 (sibling #2281 regression). It runs on every
   branch, so a PR shipping a fix-less copy fails at PR time instead of at
   release time -- which is exactly what #1855/#1928/#1990 did undetected.

Verified the test fails on both regression modes (continue-on-error removed;
build:lib step removed) and passes on the fixed workflow.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-21 21:27:29 -04:00
committed by GitHub
parent 807edc3559
commit bcdfd21c61
2 changed files with 136 additions and 0 deletions

View File

@@ -147,12 +147,26 @@ jobs:
# when it is absent the bounded fragment reader falls back to a fail-closed
# stub and every capability fragment reports "could not be read", failing
# the sync. Build the ledger first so fragments materialize.
#
# BLAST-RADIUS CONTAINMENT (#2504): version-sync is best-effort and MUST
# NOT be able to abort the job. The job's load-bearing purpose is to open
# and admin-merge the back-merge PR so `main` becomes an ancestor of
# `next` — the invariant that keeps the next `release → main` merge clean.
# Historically a failure here (missing build:lib after a workflow-copy
# regression, or any `npm version` lifecycle hiccup) skipped "Open PR" and
# left `main` diverged, breaking the following release. `continue-on-error`
# on both steps below keeps the ancestry PR unconditional: a sync failure
# is surfaced (the step shows red) but only costs a stale `next` version,
# which is trivially re-synced — never a broken back-merge. Do not remove;
# a required-steps test (release-backmerge-invariants.test.cjs) enforces it.
- name: Install dependencies and build (required by the version-sync hook)
if: steps.check.outputs.next_exists == 'true'
continue-on-error: true
run: npm ci --silent && npm run build:lib
- name: Sync next's version to main's released version
if: steps.check.outputs.next_exists == 'true'
continue-on-error: true
run: |
set -euo pipefail
VERSION=$(git show origin/main:package.json | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).version")

View File

@@ -0,0 +1,122 @@
'use strict';
// allow-test-rule: source-text-is-the-product #2504
// These assertions read the release-critical workflow YAML because the YAML
// *is* the contract. They lock in the durable fix for the recurring
// auto-backmerge breakage (#2504): the workflow file lives in divergent
// main/next copies that the release merges overwrite, so a fix applied to one
// copy silently regresses in the other. This test runs on every branch, so a
// PR that ships a copy missing these invariants fails HERE — at PR time —
// instead of at release time when `main` has already diverged from `next`.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const REPO_ROOT = path.join(__dirname, '..');
const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
function loadWorkflow(name) {
return yaml.load(fs.readFileSync(path.join(WORKFLOWS_DIR, name), 'utf8'));
}
// Locate a step by a substring of its `name`, returning { step, index }.
function findStep(steps, nameSubstring) {
const index = steps.findIndex((s) => typeof s.name === 'string' && s.name.includes(nameSubstring));
return { step: index === -1 ? null : steps[index], index };
}
describe('release backmerge invariants (#2504) — auto-backmerge.yml', () => {
const wf = loadWorkflow('auto-backmerge.yml');
const steps = wf.jobs && wf.jobs.backmerge && wf.jobs.backmerge.steps;
test('the backmerge job exists with a steps array', () => {
assert.ok(Array.isArray(steps), 'jobs.backmerge.steps must be an array');
});
// Part 1 — blast-radius containment. The version-sync must NOT be able to
// abort the job; if it could, a regressed/again-broken copy (or any npm
// `version` lifecycle hiccup) skips "Open PR" and leaves `main` diverged.
test("the 'Sync next's version' step is continue-on-error (cannot abort the ancestry PR)", () => {
const { step } = findStep(steps, "Sync next's version");
assert.ok(step, "expected a step named like \"Sync next's version\"");
assert.equal(
step['continue-on-error'],
true,
"version-sync must be continue-on-error so a sync failure never blocks the back-merge PR " +
'(the load-bearing step that makes `main` an ancestor of `next`). See #2504.'
);
});
test('the build:lib prerequisite step exists, is continue-on-error, and runs BEFORE the version-sync', () => {
const buildIndex = steps.findIndex(
(s) => typeof s.run === 'string' && /npm run build:lib/.test(s.run)
);
assert.notEqual(buildIndex, -1, 'expected a step running `npm run build:lib` before the version sync');
assert.equal(
steps[buildIndex]['continue-on-error'],
true,
'the build:lib step is only a prerequisite of the best-effort version-sync; it must be ' +
'continue-on-error too so its failure cannot abort the ancestry PR. See #2504.'
);
const { index: syncIndex } = findStep(steps, "Sync next's version");
assert.ok(
buildIndex < syncIndex,
'the build:lib step must precede the version-sync step so the `version` lifecycle hook ' +
'(gen-capability-registry.cjs) finds the built capability-ledger.cjs. This exact ordering ' +
'regressed once already (329233fc8 added it; a release merge-back overwrote the copy). See #2504.'
);
});
// The ancestry-establishing steps must be present. Together with the two
// continue-on-error assertions above, this guarantees the back-merge PR is
// opened and admin-merged unconditionally — the whole point of the workflow.
test('the ancestry-establishing steps (open PR + admin-merge) are present', () => {
assert.ok(findStep(steps, 'Open or update PR').step, "expected an 'Open or update PR' step");
assert.ok(findStep(steps, 'Admin-merge the back-merge PR').step, "expected an 'Admin-merge the back-merge PR' step");
});
// Closes the subtler regression the `continue-on-error` alone can't stop: a
// future edit could re-gate the ancestry steps on the version-sync outcome
// (e.g. `if: ... && steps.sync.outcome == 'success'`), silently reinstating
// the exact coupling this fix removes. The ancestry steps must never gate on
// any step's outcome/conclusion — only on next_exists (+ needs_review).
test('the ancestry steps are NOT gated on any step outcome/conclusion', () => {
for (const name of ['Open or update PR', 'Admin-merge the back-merge PR']) {
const { step } = findStep(steps, name);
assert.ok(step, `expected a '${name}' step`);
const cond = typeof step.if === 'string' ? step.if : '';
assert.doesNotMatch(
cond,
/\.(outcome|conclusion)\b/,
`'${name}' must not gate on a step outcome/conclusion — that would let a version-sync ` +
`failure block the ancestry PR again, re-opening the divergence loop. See #2504. (if: ${cond})`
);
}
});
test('the -s ours reconcile step is present (never-conflict back-merge)', () => {
const reconcile = steps.find((s) => typeof s.run === 'string' && /merge\s+-s\s+ours/.test(s.run));
assert.ok(reconcile, 'expected a `git merge -s ours` reconcile step keeping next\'s tree wholesale');
});
});
describe('release backmerge invariants (#2504) — release.yml finalize', () => {
const wf = loadWorkflow('release.yml');
// Sibling of the same regression family (#2281): the finalize job's
// `npm ci` + coverage run exceeds a 10m budget, so a too-small timeout
// cancels it mid-test before tag/publish. The rc job uses 30; finalize must
// match. Prone to the same copy-shuffle regression, so pin it here.
test('the finalize job timeout is at least the rc budget (>= 30 minutes)', () => {
const finalize = wf.jobs && wf.jobs.finalize;
assert.ok(finalize, 'expected a finalize job in release.yml');
assert.ok(
typeof finalize['timeout-minutes'] === 'number' && finalize['timeout-minutes'] >= 30,
`finalize timeout-minutes must be >= 30 (was ${finalize['timeout-minutes']}); a smaller budget ` +
'cancels finalize mid-test before tag/publish as the unit suite grows. See #2280/#2281.'
);
});
});