The released-artifact forward roll qualifies durable state by writing it with a published build and reading it with the build under test. Which published build it picks is resolved at run time, and nothing establishes that build as an ancestor of the change being tested.
resolveRegistryNightlyPredecessor (scripts/release-cli-publication.mjs:221) reads dist-tags.nightly from the registry and validates only that the version metadata's identity matches the tag it came from:
const version = packageMetadata?.['dist-tags']?.nightly;
parseProductNightlyVersion(version);
// ...
if (versionMetadata.name !== PACKAGE_NAME || versionMetadata.version !== version) {
throw new Error('Registry Nightly identity does not match its dist-tag');
}
There is no check that the commit this Nightly was published from is reachable from the checkout under test.
How it goes wrong
A branch based on main@X enters CI. While that run is in flight — or simply because the branch has not been rebased — main advances to Y and publishes nightly(Y). The forward roll then writes state with nightly(Y) and reads it with a workspace whose code predates it. The roll runs backwards.
Two outcomes, and the quieter one is worse:
- False failure.
nightly(Y) writes a record the older workspace cannot decode. The job fails for a reason that has nothing to do with the change under review.
- Silent no-op.
nightly(Y) already contains the very migration the change is meant to exercise, so both sides of the roll speak the same vocabulary. The job passes without having crossed a version boundary at all. Nothing distinguishes this from a real pass.
The second is the reason to fence it: a guard that quietly stops guarding reports the same green as one that works.
Reachability
Ordinary path — no adversary and no unusual timing required. Any pull request whose branch point predates the current Nightly is exposed, which over a day of merges is most of them.
Material already available for a fence
- npm version metadata carries
gitHead for packages published from a repository.
- The CI checkout already uses
fetch-depth: 0 (.github/workflows/ci.yml), so git merge-base --is-ancestor <gitHead> HEAD can be evaluated in the job without an extra fetch.
assertRegistryNightlyPredecessor in the same module already demonstrates the shape of a fence — it refuses a predecessor that is no longer current — but it is used by the release flow, not by the forward roll.
A fence would establish the baseline as an ancestor and fail loudly when it is not, rather than qualifying in whichever direction the registry happens to point.
Origin
Raised during review of the durable-state forward roll in #4427. That pull request moves the forward roll onto the main CI lane, which widens how often this baseline is resolved; it does not introduce the gap. Fixing it is a separate change with its own verification, so it is tracked here rather than folded in.
The released-artifact forward roll qualifies durable state by writing it with a published build and reading it with the build under test. Which published build it picks is resolved at run time, and nothing establishes that build as an ancestor of the change being tested.
resolveRegistryNightlyPredecessor(scripts/release-cli-publication.mjs:221) readsdist-tags.nightlyfrom the registry and validates only that the version metadata's identity matches the tag it came from:There is no check that the commit this Nightly was published from is reachable from the checkout under test.
How it goes wrong
A branch based on
main@Xenters CI. While that run is in flight — or simply because the branch has not been rebased —mainadvances toYand publishesnightly(Y). The forward roll then writes state withnightly(Y)and reads it with a workspace whose code predates it. The roll runs backwards.Two outcomes, and the quieter one is worse:
nightly(Y)writes a record the older workspace cannot decode. The job fails for a reason that has nothing to do with the change under review.nightly(Y)already contains the very migration the change is meant to exercise, so both sides of the roll speak the same vocabulary. The job passes without having crossed a version boundary at all. Nothing distinguishes this from a real pass.The second is the reason to fence it: a guard that quietly stops guarding reports the same green as one that works.
Reachability
Ordinary path — no adversary and no unusual timing required. Any pull request whose branch point predates the current Nightly is exposed, which over a day of merges is most of them.
Material already available for a fence
gitHeadfor packages published from a repository.fetch-depth: 0(.github/workflows/ci.yml), sogit merge-base --is-ancestor <gitHead> HEADcan be evaluated in the job without an extra fetch.assertRegistryNightlyPredecessorin the same module already demonstrates the shape of a fence — it refuses a predecessor that is no longer current — but it is used by the release flow, not by the forward roll.A fence would establish the baseline as an ancestor and fail loudly when it is not, rather than qualifying in whichever direction the registry happens to point.
Origin
Raised during review of the durable-state forward roll in #4427. That pull request moves the forward roll onto the main CI lane, which widens how often this baseline is resolved; it does not introduce the gap. Fixing it is a separate change with its own verification, so it is tracked here rather than folded in.