Skip to content

Commit 37442d4

Browse files
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

File tree

‎.github/workflows/release.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,10 @@ jobs:
790790
# `contents: write` is for GitHub Releases, never for refs: this job runs
791791
# no `git push` of any kind.
792792
contents: write
793+
# `actions: read` is the Releases backfill's guard: the audit reads this
794+
# workflow's runs and their jobs to ask whether the same version's
795+
# publish is in flight in another run. Reads only, never a cancel.
796+
actions: read
793797
outputs:
794798
# "the docker job must build" — set only when npm ALREADY has this
795799
# version's WHOLE fixed group and its runtime image is missing.
@@ -1066,9 +1070,33 @@ jobs:
10661070
;;
10671071
esac
10681072
1073+
# ── the publish in flight, before any Releases backfill ───────────
1074+
# npm settles BEFORE the publish job reaches its own "Create GitHub
1075+
# Releases" step, so a landing audited in that window found the group
1076+
# complete and wrote the same Releases beside it: on 17.6.0 the
1077+
# publish of run 36955885276 wrote them 03:03:47Z -> 03:05:42Z, this
1078+
# backfill in run 36958423332 started at 03:04:37Z, and five tags got
1079+
# two Release objects each. The two writers are in different runs,
1080+
# so no `needs:` can order them; the publish's own run creates its
1081+
# Releases and D4 asset, and a landing after it finishes backfills
1082+
# whatever it did not. scripts/release-pending-publish.mjs `in-flight`
1083+
# (its --self-test, run by lint.yml, holds the rule) answers
1084+
# `in-flight` on anything it cannot read, so nothing is backfilled off
1085+
# a guess. It only ever leaves `releases-missing` unset: this step
1086+
# stays green and the image request below is not its business.
10691087
if [ "$releases_ok" = true ]; then
10701088
echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}."
1089+
elif ! flight=$(GITHUB_TOKEN="$GH_TOKEN" node scripts/release-pending-publish.mjs in-flight --version "$version" --version-commit "$version_commit" --head "$SHA" --workflow release.yml); then
1090+
echo "::warning::${version}'s GitHub Releases or ADR-0087 D4 asset are incomplete, and whether its publish is still in flight could not be asked (reason above). Nothing is backfilled off a guess; the next landing reads again."
1091+
elif [ "$(jq -r '.state' <<<"$flight")" != 'clear' ]; then
1092+
jq -c . <<<"$flight"
1093+
if [ "$(jq -r '.reason' <<<"$flight")" = 'publish-in-flight' ]; then
1094+
echo "::notice::${version}'s GitHub Releases or ADR-0087 D4 asset are incomplete, but its publish is still in flight ($(jq -r '.detail' <<<"$flight")). That run creates them; nothing is backfilled beside it. A landing after it finishes backfills whatever it did not."
1095+
else
1096+
echo "::warning::${version}'s GitHub Releases or ADR-0087 D4 asset are incomplete, and whether its publish is still in flight could not be read ($(jq -r '.detail' <<<"$flight")). Nothing is backfilled off a guess; the next landing reads again."
1097+
fi
10711098
else
1099+
jq -c . <<<"$flight"
10721100
echo "::warning::${version} is on npm but its GitHub Releases or the ADR-0087 D4 asset are incomplete (#4900) — backfilling."
10731101
echo "releases-missing=true" >> "$GITHUB_OUTPUT"
10741102
fi

0 commit comments

Comments
 (0)