Repository navigation
Commit 37442d4
fix(release): the Releases backfill skips a version whose publish is in flight in another run (#21605)
Fixes #21359
Clause-②: no
## What this does
`release-integrity`'s audit step now asks one read-only question before
it sets `releases-missing=true`: is this version's publish job in flight
in another run of `release.yml`? If it is, or if the answer cannot be
read, the step leaves `releases-missing` unset, says why in a notice or
a warning, and stays green. So the Releases backfill and its ADR-0087 D4
sibling no longer write beside a running publish. Nothing else in the
job changes. The image request is untouched, and the guard never fails
the step or refuses anything.
- `.github/workflows/release.yml`: `actions: read` joins the
`release-integrity` job's `permissions:` (that job only). One
`if`/`elif` branch in the audit step, at the point where it used to set
`releases-missing=true`.
- `scripts/release-pending-publish.mjs`: a new read-only `in-flight`
mode, and a new self-test battery (12 cases). The battery roster floor
rises from 20 to 21.
- `scripts/release-verify-npm.mjs`: batteries 12 and 13 run the audit
step's own text from `release.yml`, so their stubs now answer the
Actions read. Battery 12 gains 7 cases pinning the new branch, and its
floor rises from 17 to 24. See "File surface" below.
## The premise, measured (read-only `GET`s on the runs and jobs)
| Writer | Run | Job | Window |
|:--|:--|:--|:--|
| `Publish 17.6.0 to npm (awaiting approval)` | `36955885276` (push,
head `dcc5ef4c`) | `110678824190` | job 02:31:09Z to 03:05:45Z; "Create
GitHub Releases" ran 03:03:47Z to 03:05:42Z |
| `Release integrity (audit + no-mint backfill)` | `36958423332` (push,
head `4e530568`) | `110686494582` | audit 03:03:50Z to 03:04:15Z;
"Backfill GitHub Releases" 03:04:37Z to 03:05:42Z |
The two writers are in **different runs**: the publish runs in the run
of the push that carried 17.6.0's version commit, and the backfill runs
in a later landing's run. A `needs:` edge cannot order jobs across runs,
so the API read the card proposes is the right shape. Inside one run the
order already holds: `publish` needs `release-integrity`, and the audit
takes the not-on-npm branch there.
Two premises from the card and the claim had moved on `main`:
- `scripts/release-pending-publish.mjs` already exists, with `select`,
`npm-state`, `unconsumed` and `sweep` modes. Its `--self-test` is
already run by `lint.yml` ("Release version-commit selection
self-test"). So the guard is a new mode of that script with its own
battery in that script's roster, not a new file. `check:self-test-wired`
already counts it, and it stays green.
- The `sweep` mode already reads runs and jobs and names the publish job
by `PUBLISH_JOB_NAME`. The new mode reuses its HTTP layer (`makeHttp`,
`expectOk`) and its version-commit walk (`versionCommitsOf`).
## The guard's rule
`in-flight --version V --version-commit SHA --head SHA --workflow
release.yml` answers JSON with `state` set to `in-flight` or `clear`:
- **In flight** means another run of this workflow (the calling run is
excluded) holds a job named exactly `Publish V to npm (awaiting
approval)` whose status is neither `completed` nor `waiting`. `waiting`
is a job GitHub holds at the `release` environment: it has run no step,
and it runs none before a human approves it. `queued` is an approved job
waiting for a runner, so it counts as in flight. A status the script
does not know also counts as in flight. This is a little wider than the
card's literal "status `in_progress`". The `queued` window sits between
the approval and the job's first step, and a job that has started cannot
be anything but in flight.
- **Which runs are read.** Two readings are joined, because the run-list
status filter is not trusted alone: `collectWaitingRuns` records
`?status=waiting` answering the job token an empty list while a waiting
run existed.
- The first reading is the runs that `?status=in_progress` and
`?status=queued` list. This is where a repair-lane (dispatch) publish is
found.
- The second is the push run at the version commit or at the
first-parent commits after it (`head_sha` probes, as `sweep` does). This
is where the push-lane publish runs: for 17.6.0 it is `36955885276`, at
`dcc5ef4c`.
- Each candidate run is then read directly, and its jobs are read when
it is not completed.
- **It fails closed.** Each of these answers `in-flight` with reason
`unreadable`: a non-200, a malformed body, a filter whose `total_count`
exceeds what it listed, a run left unread, a version-commit walk that
disagrees with the audit's version commit, or a shallow clone. The audit
then backfills nothing, with a `::warning::`, and the next landing reads
again. A push run that cannot be *found* is not unreadable: the filters
still speak for it, and blocking on it would block that version's repair
for good. The answer says so in its `detail`.
- **It is read-only by construction.** It runs over `makeHttp({ dryRun:
true })`, whose HTTP layer refuses any non-GET before it is sent.
## File surface
The claim's surface is `release.yml` (the `release-integrity` job only)
plus the reader script. This PR also edits
`scripts/release-verify-npm.mjs`, only inside its self-test harness
(battery 12's stub servers and cases, battery 13's Releases API stub,
and the battery-12 docblock and success line). Batteries 12 and 13
execute the audit step's text from `release.yml` against stubs, so any
new external read in that step needs a stub answer. Measured with the
workflow change committed and the harness not yet updated:
```text
node scripts/release-verify-npm.mjs --self-test
x CONTROL: ...and backfills the missing GitHub Releases
x the audit requests the Releases backfill and names a tree that is at the version commit
x release-verify-npm self-test: 2 of 96 case(s) failed.
```
The guard failed closed against an API that was not stubbed, which is
the intended behaviour, and it reddened two existing pins. The claim's
serial read named `scripts/release-*` as free of open PRs, and this file
is in that set. `scripts/release-github-releases.mjs`, the `publish`
job, `version-pr`, `stale-prompts` and `docker` are not touched.
## Tests (at `a5beac190`, the final commit)
- `node scripts/release-pending-publish.mjs --self-test`: `✓
release-pending-publish self-test: 80 cases across 21 batteries pass.`
(68 cases before this PR, plus the new battery's 12.)
- `node scripts/release-verify-npm.mjs --self-test`: `OK
release-verify-npm self-test: 103 cases pass across 13 batteries` (96
before, plus 7).
- **Ablations.** Each mutation went through
`scripts/ablation-replace.mjs`, which checks the anchor hit and restores
the file. Each restore was proven with blob equal to HEAD and an empty
`git diff HEAD`.
1. The audit branch was disabled (`elif [ "$(jq -r '.state' ...)" !=
'clear' ]` became `elif false`). `release-verify-npm --self-test` went 3
of 103 red: in flight, it backfilled; the notice was missing; with the
API unreadable, it backfilled. The anchor moved from 1 to 0 and the blob
from `14181e3c2fd2` to `837b21b8739f`, then was restored to
`14181e3c2fd2`.
2. `in_progress` was added to the settled statuses.
`release-pending-publish --self-test` went 3 red: the 17.6.0 window, the
push-run reading and the dispatch reading. Blob `77a2302b1140` became
`ebb153b8c49c`, then was restored.
3. The push-run reading was deleted (`for (const id of pushRuns)
ids.add(id);`). The same self-test went 2 red: the 17.6.0 window found
only through the push run, and the jobs-unreadable case. Restored to
`77a2302b1140`.
- **Live read-only smoke against this repository.** `publishInFlight`
was called for 17.6.0 with a plain GET-only client. It made 5 GETs: the
`in_progress` and `queued` filters (both empty), `head_sha` probes at
`617f25f8a4` (none) and `dcc5ef4c5a` (run `36955885276`), and a direct
read of that run, which is `completed`. The answer was `clear` /
`none-in-flight`. This checks the response shapes the stubs assume
against the real endpoints.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `a5beac190` derived 51 commands; all 51
were run, each exited 0. `--ran` printed: `Run reconciliation — 51
derived, 51 run, 0 NOT-MEASURED, 0 UNRUN.`
- **ESLint, as a narrowed run with its proof.**
- `eslint --no-inline-config --format json` was run over the two changed
`.mjs` files: 2 files, 0 errors, 0 warnings.
- ESLint's own `isPathIgnored` / `calculateConfigForFile` puts both
files in the linted population. The YAML file is outside it.
- Their config carries no `parserOptions.project` and no
`projectService`. Type-aware linting is off, so this diff cannot move
any verdict on an untouched file.
- **Declared to CI.** These were not run locally: the type-check lanes
(no TypeScript package is touched), `ci.yml` Test Core's shard steps,
and the families `dispatch-gates` lists as wide-population or
workflow-valued.
## Changeset
None. `skip-changeset` holds because nothing here is published. The diff
is `.github/workflows/` plus two repo-root `scripts/`, the root
`package.json` is private, and none of the 69 public packages' `files[]`
names `scripts/` or `.github/`. As a positive control, all 69 of them
name `dist`.
## Acceptance notes
- `lint.yml`'s comment above "Release version-commit selection
self-test" says the script "holds all three decisions". After this PR it
holds a fourth, the backfill's guard. `lint.yml` is outside this card's
surface, so the comment is left as it is. Carrier: the next PR that
edits that step's comment.
- **Not in this PR: the image half of the same race.** Run `36958423332`
also built the 17.6.0 image (job `110687103770`, 03:05:48Z to
03:09:18Z), while the publish run `36955885276` built it too (job
`110687097666`, 03:05:49Z to 03:10:02Z). A guard on the publish job
alone cannot close that, because the publishing run's `docker` job
starts after `publish` completes. So `image-missing` is left exactly as
it was. This is reported to the dispatching seat with the readings, and
not filed here.
- No ADR governs this. ADR-0125 decides which push publishes and who
approves it; it is silent on the Releases backfill, and this changes
neither.
- The guard sits in a step whose text batteries 12 and 13 execute. A
future change to its branch shape reddens those batteries, not only the
script's own battery.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ec390ec commit 37442d4
3 files changed
Lines changed: 432 additions & 11 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
790 | 790 | | |
791 | 791 | | |
792 | 792 | | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
793 | 797 | | |
794 | 798 | | |
795 | 799 | | |
| |||
1066 | 1070 | | |
1067 | 1071 | | |
1068 | 1072 | | |
| 1073 | + | |
| 1074 | + | |
| 1075 | + | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | + | |
| 1080 | + | |
| 1081 | + | |
| 1082 | + | |
| 1083 | + | |
| 1084 | + | |
| 1085 | + | |
| 1086 | + | |
1069 | 1087 | | |
1070 | 1088 | | |
| 1089 | + | |
| 1090 | + | |
| 1091 | + | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | + | |
| 1096 | + | |
| 1097 | + | |
1071 | 1098 | | |
| 1099 | + | |
1072 | 1100 | | |
1073 | 1101 | | |
1074 | 1102 | | |
| |||
0 commit comments