From cf6ec80339e01539aec4b979f518ad34904fa0b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:27:11 +0000 Subject: [PATCH 1/2] feat(release): the in-flight reader answers whether the publishing run's image build is finished `judgePublishInFlight` now carries a `docker` verdict beside the publish job's: a run holding the version's publish job past the `release` environment builds the image until every docker job of that run is `completed`, including before GitHub creates it (it needs the publish job). An unreadable read answers `in-flight` / `unreadable` for both. New self-test battery (12 cases); the roster floor rises to 22. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- scripts/release-pending-publish.mjs | 199 +++++++++++++++++++++++++--- 1 file changed, 181 insertions(+), 18 deletions(-) diff --git a/scripts/release-pending-publish.mjs b/scripts/release-pending-publish.mjs index 77a2302b114..d12cbc24be6 100644 --- a/scripts/release-pending-publish.mjs +++ b/scripts/release-pending-publish.mjs @@ -4,8 +4,9 @@ /** * release-pending-publish -- WHICH commit a release publishes, WHICH push queues * its approval prompt, WHICH waiting prompts are no longer a release - * (ADR-0125 D1, as amended 2026-09-29), and WHETHER a version's publish is in - * flight, so the Releases backfill does not write beside it. + * (ADR-0125 D1, as amended 2026-09-29), and WHETHER a version's publish, or its + * publishing run's image build, is in flight, so neither backfill (the + * Releases, the runtime image) writes beside it. * * node scripts/release-pending-publish.mjs select --event push --head SHA --before SHA * node scripts/release-pending-publish.mjs select --event workflow_dispatch --head SHA @@ -135,7 +136,7 @@ * `--dry-run` reports what `sweep` would cancel and refuses any non-GET request * at the HTTP layer, so it is safe to point at the live repository. * - * `in-flight` -- the Releases backfill's guard. `release-integrity` backfills + * `in-flight` -- the backfills' guard. `release-integrity` backfills * the GitHub Releases of a version whose whole fixed group is on npm, and npm * settles BEFORE the publish job reaches its own "Create GitHub Releases" * step. So a landing audited in that window used to write the same Releases @@ -163,10 +164,26 @@ * cannot find is not unreadable: the filters still speak for it, and a guard * that blocked on it would block the repair of that version for good. * - * It skips writes and refuses nothing: the audit leaves `releases-missing` - * unset, says why, and stays green. Read-only by construction: it runs over - * the `--dry-run` HTTP layer, so a non-GET cannot leave it. Permission: - * `actions: read`. + * The same read answers the image backfill (`docker` in the answer, beside the + * publish job's top-level verdict). `release-integrity` requests the runtime + * image of a version whose whole group is on npm and whose image is missing, + * and npm settles before the publishing run's own `docker` job even exists: + * that job is `needs: [release-integrity, publish]`, so GitHub creates it when + * the publish job completes. Measured on 17.6.0: run 36955885276's publish job + * completed at 03:05:45Z and its docker job was created that second and built + * 03:05:49Z -> 03:10:02Z, while the backfill of run 36958423332, requested by + * an audit that read the image missing at about 03:04Z, built 03:05:48Z -> + * 03:09:18Z. Both pushed the 17.6.0 tag. So the image is in flight in a run + * that holds the version's publish job past the `release` environment (not + * `waiting`) until every docker job of that run is `completed` -- including + * while it has none yet. A publish held at the environment builds nothing + * before a human approves it, so it is not in flight for the image either, and + * an unreadable read answers `in-flight` with reason `unreadable` here too. + * + * It skips writes and refuses nothing: the audit leaves `releases-missing` and + * `image-missing` unset, says why, and stays green. Read-only by construction: + * it runs over the `--dry-run` HTTP layer, so a non-GET cannot leave it. + * Permission: `actions: read`. * * ## Why the pins live here, not in the workflow * @@ -759,7 +776,7 @@ async function sweep({ workflow, dryRun }) { } // ───────────────────────────────────────────────────────────────────────────── -// Is this version's publish in flight? (the Releases backfill's guard) +// Is this version's publish, or its image build, in flight? (the backfills' guard) // ───────────────────────────────────────────────────────────────────────────── /** @@ -772,31 +789,84 @@ export const PUBLISH_SETTLED_JOB_STATUSES = Object.freeze(['completed', 'waiting /** The run-list filters read for a publish in flight. */ export const IN_FLIGHT_RUN_FILTERS = Object.freeze(['in_progress', 'queued']); +/** + * `release.yml`'s `docker` job as the jobs API names it: `Docker image` while + * it is skipped, `Docker image / ` once the reusable workflow + * it calls runs. Measured: run 36955885276's `Docker image / Build & push + * ghcr.io/objectstack-ai/objectstack`, run 37148267152's skipped `Docker image`. + * A rename that this misses reads as "not yet created", so the image counts as + * in flight until the publishing run completes: late, never early. + */ +export const DOCKER_JOB_NAME = /^Docker image(?: \/ .+)?$/; + +/** + * The publishing run's image build -- pure, one run. A run is the version's + * publishing run when it holds the version's publish job past the `release` + * environment (any status but `waiting`: approved, running or done). Its + * `docker` job is `needs: [release-integrity, publish]`, so GitHub creates it + * only when the publish job completes: until every docker job of the run is + * `completed`, including while there is none yet, the image is in flight. + * Null when the run is not that version's publishing run. + */ +function dockerInFlightIn(run, publishName) { + const publish = run.jobs.find((j) => j.name === publishName && j.status !== 'waiting'); + if (!publish) return null; + const docker = run.jobs.filter((j) => DOCKER_JOB_NAME.test(j.name)); + if (docker.length > 0 && docker.every((j) => j.status === 'completed')) return null; + return { + run: String(run.id), + event: run.event, + publish: publish.status, + docker: docker.length === 0 ? 'not yet created' : docker.map((j) => j.status).join('+'), + }; +} + /** * Judge the runs read for one version -- pure. Each run is `{ id, event, * status, jobs }`; `jobs` may be null only for a completed run. A run that is * not completed and was not read past its status answers `unreadable`. + * + * Two answers from one read: the top level is the publish job's (the Releases + * backfill's guard), and `docker` is the image build of that version's + * publishing run (the image backfill's guard), each `in-flight` or `clear`. + * A completed run is settled for both: its jobs, the docker job included, + * have all finished. */ export function judgePublishInFlight({ version, runs, currentRunId }) { const name = `Publish ${version} to npm (awaiting approval)`; const inFlight = []; + const building = []; for (const run of runs) { const id = String(run.id); if (id === String(currentRunId) || run.status === 'completed') continue; if (!Array.isArray(run.jobs)) { - return { state: 'in-flight', reason: 'unreadable', detail: `run ${id} is ${run.status} and its jobs were not read`, inFlight }; + const detail = `run ${id} is ${run.status} and its jobs were not read`; + return { state: 'in-flight', reason: 'unreadable', detail, inFlight, docker: { state: 'in-flight', reason: 'unreadable', detail, inFlight: building } }; } for (const job of run.jobs) { if (job.name === name && !PUBLISH_SETTLED_JOB_STATUSES.includes(job.status)) { inFlight.push({ run: id, event: run.event, status: job.status }); } } + const image = dockerInFlightIn(run, name); + if (image) building.push(image); } + const docker = + building.length > 0 + ? { + state: 'in-flight', + reason: 'docker-in-flight', + detail: `the image build of ${version}'s publishing run is not finished: ${building + .map((b) => `run ${b.run} (${b.event}, publish job ${b.publish}, Docker image ${b.docker})`) + .join(', ')}`, + inFlight: building, + } + : { state: 'clear', reason: 'none-in-flight', detail: `no other run is still building ${version}'s image after publishing it`, inFlight: building }; if (inFlight.length > 0) { const where = inFlight.map((f) => `run ${f.run} (${f.event}, job ${f.status})`).join(', '); - return { state: 'in-flight', reason: 'publish-in-flight', detail: `"${name}" is in flight in ${where}`, inFlight }; + return { state: 'in-flight', reason: 'publish-in-flight', detail: `"${name}" is in flight in ${where}`, inFlight, docker }; } - return { state: 'clear', reason: 'none-in-flight', detail: `no other run holds "${name}" in flight`, inFlight }; + return { state: 'clear', reason: 'none-in-flight', detail: `no other run holds "${name}" in flight`, inFlight, docker }; } /** @@ -853,9 +923,10 @@ export async function collectPublishRuns({ http, repo, workflow, tips, currentRu /** * The guard's whole answer for one version, never a throw: whatever cannot be - * read answers `in-flight` with reason `unreadable`, so the caller backfills - * nothing off a guess. `walk` yields the version commits of the head - * (`versionCommitsOf`); the first must be the one the caller names. + * read answers `in-flight` with reason `unreadable`, for the publish job and + * the image build alike, so the caller backfills nothing off a guess. `walk` + * yields the version commits of the head (`versionCommitsOf`); the first must + * be the one the caller names. */ export async function publishInFlight({ http, repo, workflow, version, versionCommit, walk, currentRunId = '' }) { try { @@ -868,12 +939,23 @@ export async function publishInFlight({ http, repo, workflow, version, versionCo } const read = await collectPublishRuns({ http, repo, workflow, tips: first.tips, currentRunId }); const verdict = judgePublishInFlight({ version, runs: read.runs, currentRunId }); - if (read.pushRuns.length === 0 && verdict.state === 'clear') { - verdict.detail += `; no push run of ${workflow} was found at the version commit, so the run-list filters alone speak for it`; + if (read.pushRuns.length === 0) { + const note = `; no push run of ${workflow} was found at the version commit, so the run-list filters alone speak for it`; + if (verdict.state === 'clear') verdict.detail += note; + if (verdict.docker.state === 'clear') verdict.docker.detail += note; } return { version, ...verdict, readings: read.readings }; } catch (err) { - return { version, state: 'in-flight', reason: 'unreadable', detail: err instanceof Error ? err.message : String(err), inFlight: [], readings: '' }; + const detail = err instanceof Error ? err.message : String(err); + return { + version, + state: 'in-flight', + reason: 'unreadable', + detail, + inFlight: [], + docker: { state: 'in-flight', reason: 'unreadable', detail, inFlight: [] }, + readings: '', + }; } } @@ -912,8 +994,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'a shallow clone or an unresolvable version commit -> refused, never a boundary commit or an empty answer': 2, 'the report -> a warning naming every changeset with its commit, a summary section, or a plain line for none': 4, "a publish of the version in another run -> in flight until it completes, and an unreadable read is in flight": 12, + "the publishing run's image build -> in flight from the approval until its docker job completes, and an unreadable read is in flight": 12, }); -const SELF_TEST_BATTERY_FLOOR = 21; +const SELF_TEST_BATTERY_FLOOR = 22; async function selfTest() { let failed = 0; @@ -1511,6 +1594,86 @@ async function selfTest() { readOnly = /--dry-run refuses POST/.test(String(err && err.message)); } check(readOnly, 'the HTTP layer in-flight runs over refuses a non-GET before it is sent'); + + // The same read, asked about the publishing run's image build. Opened in + // this block on purpose: it reuses the stub API above rather than a copy. + battery("the publishing run's image build -> in flight from the approval until its docker job completes, and an unreadable read is in flight"); + const docker = (status, name = 'Docker image / Build & push ghcr.io/objectstack-ai/objectstack') => ({ name, status }); + const run176 = (jobs, status = 'in_progress') => ({ id: 36955885276, event: 'push', status, jobs: [integrity, ...jobs] }); + const publishing = judge([run176([job('17.6.0', 'in_progress')])]); + check( + publishing.docker.state === 'in-flight' && publishing.docker.reason === 'docker-in-flight' && + /run 36955885276 \(push, publish job in_progress, Docker image not yet created\)/.test(publishing.docker.detail), + 'the 17.6.0 audit at 03:04Z: the publish job runs and its docker job does not exist yet -> the image is in flight, naming the run', + ); + const between = judge([run176([job('17.6.0', 'completed')])]); + check( + between.state === 'clear' && between.docker.state === 'in-flight' && /Docker image not yet created/.test(between.docker.detail), + 'the publish job completed and GitHub has not created the docker job yet -> the publish is clear, the image still in flight', + ); + check( + ['queued', 'in_progress', 'waiting', 'some_new_status'].every((s) => judge([run176([job('17.6.0', 'completed'), docker(s)])]).docker.state === 'in-flight') && + judge([run176([job('17.6.0', 'completed'), docker('queued', 'Docker image')])]).docker.state === 'in-flight', + 'the docker job queued, in_progress, waiting or in a status this script does not know, under either of its names -> in flight', + ); + check( + judge([run176([job('17.6.0', 'completed'), docker('completed')])]).docker.state === 'clear' && + judge([run176([job('17.6.0', 'completed'), docker('completed', 'Docker image')])]).docker.state === 'clear' && + judge([{ id: 36955885276, event: 'push', status: 'completed', jobs: null }]).docker.state === 'clear', + 'a completed docker job, in a run still finishing or in a completed run -> clear, so the image probe alone decides the backfill', + ); + const held = judge([{ id: 3, event: 'workflow_dispatch', status: 'waiting', jobs: [integrity, job('17.6.0', 'waiting')] }]); + check( + held.state === 'clear' && held.docker.state === 'clear', + 'a publish held at the release environment builds nothing before a human approves it -> clear for the image too', + ); + check( + judge([ + { id: 900, event: 'push', status: 'in_progress', jobs: [job('17.6.0', 'completed')] }, + { id: 6, event: 'push', status: 'in_progress', jobs: [job('17.7.0', 'completed')] }, + { id: 7, event: 'push', status: 'in_progress', jobs: [{ name: 'Publish ${{ needs.release-integrity.outputs.cli-version }} to npm (awaiting approval)', status: 'completed' }, docker('in_progress')] }, + ]).docker.state === 'clear', + "the calling run, another version's publishing run, and a run whose skipped publish job names no version -> clear", + ); + const unreadJobs = judge([{ id: 8, event: 'push', status: 'in_progress', jobs: null }]); + check( + unreadJobs.docker.state === 'in-flight' && unreadJobs.docker.reason === 'unreadable', + 'a run still running whose jobs were never read -> unreadable for the image too, never clear', + ); + + const pushedAt = { dcc5ef4c: [runAt(36955885276, 'push', 'in_progress')] }; + const imageWindow = await flightWith({ + tips: pushedAt, + direct: { 36955885276: runAt(36955885276, 'push', 'in_progress', [integrity, job('17.6.0', 'completed')]) }, + }); + check( + imageWindow.answer.state === 'clear' && imageWindow.answer.docker.state === 'in-flight' && imageWindow.answer.docker.reason === 'docker-in-flight', + 'through the whole read: the push run at the version commit has published and not built its image -> the publish clear, the image in flight', + ); + const imageBuilt = await flightWith({ + tips: pushedAt, + direct: { 36955885276: runAt(36955885276, 'push', 'in_progress', [integrity, job('17.6.0', 'completed'), docker('completed')]) }, + }); + check(imageBuilt.answer.state === 'clear' && imageBuilt.answer.docker.state === 'clear', '...and once that docker job completed -> clear for both'); + const dispatchedImage = await flightWith({ + filters: { in_progress: [runAt(77, 'workflow_dispatch', 'in_progress')] }, + direct: { 77: runAt(77, 'workflow_dispatch', 'in_progress', [job('17.6.0', 'completed'), docker('in_progress')]) }, + }); + check(dispatchedImage.answer.docker.state === 'in-flight', "a repair-lane run's image build, which only the in_progress filter lists -> in flight"); + const unreadImage = await Promise.all([ + flightWith({ fail: /status=queued/ }), + flightWith({ tips: pushedAt, direct: { 36955885276: runAt(36955885276, 'push', 'in_progress') }, fail: /\/jobs/ }), + flightWith({ walk: (function* shallow() { throw new Error('refusing to list version commits in a shallow clone'); })() }), + ]); + check( + unreadImage.every((u) => u.answer.docker.state === 'in-flight' && u.answer.docker.reason === 'unreadable'), + 'a filter answering non-200, jobs answering non-200, or a shallow clone -> the image is unreadable, so in flight', + ); + const lostImage = await flightWith(); + check( + lostImage.answer.docker.state === 'clear' && /no push run of release\.yml was found/.test(lostImage.answer.docker.detail), + 'no push run found and nothing in flight -> the image is clear on the filters, and the answer says so', + ); } battery('an event with no release predicate -> refused'); From 4d31b13409c1cc3e7220f44470f805f42e0b161e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:29:26 +0000 Subject: [PATCH 2/2] fix(release): the image backfill skips a version whose publishing run has not finished building its image The audit step asks the in-flight reader once, before either backfill, and gates `image-missing` on its `docker` verdict the way it gates `releases-missing` on the publish job's: a notice while the publishing run's docker job is not yet created, queued or running; a warning, and no request, when the read is unreadable or cannot be asked; the existing request otherwise. Battery 12 of release-verify-npm's self-test pins the four triage pins plus the not-yet-created case at the audit's own text. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .github/workflows/release.yml | 49 +++++++++----- scripts/release-pending-publish.mjs | 2 +- scripts/release-verify-npm.mjs | 99 ++++++++++++++++++++++++----- 3 files changed, 118 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14181e3c2fd..451300d30c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1070,38 +1070,55 @@ jobs: ;; esac - # ── the publish in flight, before any Releases backfill ─────────── + # ── the publish in flight, before any backfill ──────────────────── # npm settles BEFORE the publish job reaches its own "Create GitHub - # Releases" step, so a landing audited in that window found the group - # complete and wrote the same Releases beside it: on 17.6.0 the - # publish of run 36955885276 wrote them 03:03:47Z -> 03:05:42Z, this - # backfill in run 36958423332 started at 03:04:37Z, and five tags got - # two Release objects each. The two writers are in different runs, - # so no `needs:` can order them; the publish's own run creates its - # Releases and D4 asset, and a landing after it finishes backfills - # whatever it did not. scripts/release-pending-publish.mjs `in-flight` - # (its --self-test, run by lint.yml, holds the rule) answers - # `in-flight` on anything it cannot read, so nothing is backfilled off - # a guess. It only ever leaves `releases-missing` unset: this step - # stays green and the image request below is not its business. + # Releases" step, and before that run's `docker` job even exists (it + # needs `publish`, so GitHub creates it when the publish job + # completes). A landing audited in that window found the group + # complete and wrote beside the publishing run. On 17.6.0 the publish + # of run 36955885276 wrote its Releases 03:03:47Z -> 03:05:42Z, this + # backfill in run 36958423332 started writing them at 03:04:37Z, and + # five tags got two Release objects each; and both runs built and + # pushed the 17.6.0 image, 03:05:49Z -> 03:10:02Z beside 03:05:48Z -> + # 03:09:18Z. The writers are in different runs, so no `needs:` can + # order them; the publishing run creates its Releases, D4 asset and + # image, and a landing after it finishes backfills whatever it did + # not. scripts/release-pending-publish.mjs `in-flight` (its + # --self-test, run by lint.yml, holds the rule) answers both in one + # read: `.state` for the publish job, `.docker.state` for that run's + # image build, in flight from the approval until its docker job + # completes. Anything it cannot read answers `in-flight`, so nothing + # is backfilled off a guess. It only ever leaves `releases-missing` + # and `image-missing` unset: this step stays green. + flight_asked=true + flight=$(GITHUB_TOKEN="$GH_TOKEN" node scripts/release-pending-publish.mjs in-flight --version "$version" --version-commit "$version_commit" --head "$SHA" --workflow release.yml) || flight_asked=false + if [ "$flight_asked" = true ]; then + jq -c . <<<"$flight" + fi if [ "$releases_ok" = true ]; then echo "GitHub Releases + ADR-0087 D4 asset are present for ${version}." - 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 + elif [ "$flight_asked" != true ]; then 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." elif [ "$(jq -r '.state' <<<"$flight")" != 'clear' ]; then - jq -c . <<<"$flight" if [ "$(jq -r '.reason' <<<"$flight")" = 'publish-in-flight' ]; then 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." else 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." fi else - jq -c . <<<"$flight" echo "::warning::${version} is on npm but its GitHub Releases or the ADR-0087 D4 asset are incomplete (#4900) — backfilling." echo "releases-missing=true" >> "$GITHUB_OUTPUT" fi if [ "$image_ok" = true ]; then echo "ghcr: image for $version is present." + elif [ "$flight_asked" != true ]; then + echo "::warning::No ghcr image for ${version} (or the registry could not be probed), and whether its publishing run is still building it could not be asked (reason above). No Docker job is requested off a guess; the next landing reads again." + elif [ "$(jq -r '.docker.state' <<<"$flight")" != 'clear' ]; then + if [ "$(jq -r '.docker.reason' <<<"$flight")" = 'docker-in-flight' ]; then + echo "::notice::No ghcr image for ${version} yet, but its publishing run has not finished building it ($(jq -r '.docker.detail' <<<"$flight")). That run pushes the image; no Docker job is requested beside it. A landing after it finishes requests one if the image is still missing." + else + echo "::warning::No ghcr image for ${version} (or the registry could not be probed), and whether its publishing run is still building it could not be read ($(jq -r '.docker.detail' <<<"$flight")). No Docker job is requested off a guess; the next landing reads again." + fi else echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job." echo "image-missing=true" >> "$GITHUB_OUTPUT" diff --git a/scripts/release-pending-publish.mjs b/scripts/release-pending-publish.mjs index d12cbc24be6..b8fc0ce8044 100644 --- a/scripts/release-pending-publish.mjs +++ b/scripts/release-pending-publish.mjs @@ -856,7 +856,7 @@ export function judgePublishInFlight({ version, runs, currentRunId }) { ? { state: 'in-flight', reason: 'docker-in-flight', - detail: `the image build of ${version}'s publishing run is not finished: ${building + detail: `image build not finished in ${building .map((b) => `run ${b.run} (${b.event}, publish job ${b.publish}, Docker image ${b.docker})`) .join(', ')}`, inFlight: building, diff --git a/scripts/release-verify-npm.mjs b/scripts/release-verify-npm.mjs index b765cb27af5..4c2e7b00ef1 100644 --- a/scripts/release-verify-npm.mjs +++ b/scripts/release-verify-npm.mjs @@ -142,8 +142,9 @@ * * The self-test's battery 12 runs the audit step itself — its text, read out of * `.github/workflows/release.yml` — against a throwaway repository, a stub - * registry, a stub Actions API (the audit asks whether the version's publish - * is in flight in another run before it backfills any Release) and stub + * registry, a stub Actions API (the audit asks whether the version's publish, + * or its publishing run's image build, is in flight in another run before it + * backfills any Release or requests the image) and stub * `npm` / `gh` / `curl`, so the wiring is pinned where it lives rather than * described here. * @@ -205,7 +206,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '9. The job summary is written SYNCHRONOUSLY, before process.exit': 4, '10. The probe: the whole group in one read, three answers': 9, '11. The 17.5.0 publish window, replayed on npm\'s own clock': 7, - '12. The audit step backfills only a version whose whole group is on npm, and no publish of it is in flight': 24, + '12. The audit step backfills only a version whose whole group is on npm, and no publish or image build of it is in flight': 34, '13. The backfill builds from the version commit\'s tree, as the publish does': 11, }); @@ -940,13 +941,15 @@ async function stubRegistryServer() { /** * The Actions API the audit's in-flight read asks (`release-pending-publish.mjs - * in-flight`), on 127.0.0.1. `set({ publishing, broken })`: `publishing` names - * a version whose publish job is `in_progress` in run 4242, which only the - * `in_progress` run-list filter lists; `broken` answers every request 503. - * Records every request, so a case can assert the audit asked, or did not. + * in-flight`), on 127.0.0.1. `set({ publishing, publishStatus, docker, broken })`: + * `publishing` names a version whose publish job is `publishStatus` (default + * `in_progress`) in run 4242, which only the `in_progress` run-list filter + * lists; `docker` is the status of that run's docker job, absent when unset + * (GitHub has not created it); `broken` answers every request 503. Records + * every request, so a case can assert the audit asked, or did not. */ async function stubActionsApi() { - let state = { publishing: null, broken: false }; + let state = { publishing: null, publishStatus: 'in_progress', docker: null, broken: false }; const asked = []; const server = createServer((req, res) => { const url = new URL(String(req.url), 'http://stub.invalid'); @@ -963,7 +966,9 @@ async function stubActionsApi() { } if (state.publishing && url.pathname.endsWith('/actions/runs/4242')) return send(200, run); if (state.publishing && url.pathname.endsWith('/actions/runs/4242/jobs')) { - return send(200, { total_count: 1, jobs: [{ name: `Publish ${state.publishing} to npm (awaiting approval)`, status: 'in_progress' }] }); + const jobs = [{ name: `Publish ${state.publishing} to npm (awaiting approval)`, status: state.publishStatus }]; + if (state.docker) jobs.push({ name: 'Docker image / Build & push ghcr.io/objectstack-ai/objectstack', status: state.docker }); + return send(200, { total_count: jobs.length, jobs }); } return send(404, { message: 'Not Found' }); }); @@ -972,7 +977,12 @@ async function stubActionsApi() { url: `http://127.0.0.1:${server.address().port}`, asked, set(next) { - state = { publishing: next.publishing ?? null, broken: next.broken ?? false }; + state = { + publishing: next.publishing ?? null, + publishStatus: next.publishStatus ?? 'in_progress', + docker: next.docker ?? null, + broken: next.broken ?? false, + }; asked.length = 0; }, close: () => new Promise((resolve_) => { server.close(resolve_); }), @@ -1650,7 +1660,7 @@ export async function selfTest() { } // ── 12. The audit step itself ───────────────────────────────────────────── - battery('12. The audit step backfills only a version whose whole group is on npm, and no publish of it is in flight'); + battery('12. The audit step backfills only a version whose whole group is on npm, and no publish or image build of it is in flight'); { // The step's REAL text, read out of release.yml, run by bash the way // Actions runs it, in a throwaway repository: base (1.0.0) -> the version @@ -1725,7 +1735,7 @@ export async function selfTest() { if (script !== null) writeFileSync(scriptFile, script); let runs = 0; - const audit = async ({ event = 'push', before, head, present, broken = [], releases = false, image = false, flight = {} }) => { + const audit = async ({ event = 'push', before, head, present, broken = [], releases = false, image = false, flight = {}, token = 'stub' }) => { runs += 1; const temp = join(root, `run-${runs}`); mkdirSync(temp); @@ -1738,7 +1748,7 @@ export async function selfTest() { SHA: head, EVENT: event, BEFORE: before ?? '', - GH_TOKEN: 'stub', + GH_TOKEN: token, GITHUB_API_URL: actions.url, GITHUB_RUN_ID: '9999', GITHUB_REPOSITORY: 'objectstack-ai/objectstack', @@ -1840,6 +1850,7 @@ export async function selfTest() { ); const other = await audit({ before: vc, head: landing, present: whole, flight: { publishing: '1.2.0' } }); t('a publish of ANOTHER version in flight does not hold this one back: the Releases are backfilled', other.outputs['releases-missing'] === 'true', said(other)); + t('...and the image is requested', other.outputs['image-missing'] === 'true', said(other)); const unreadable = await audit({ before: vc, head: landing, present: whole, flight: { broken: true } }); t( 'the Actions API unreadable: the audit stays green, backfills no GitHub Release off a guess, and a warning says so', @@ -1848,10 +1859,68 @@ export async function selfTest() { said(unreadable), ); t( - 'Releases present: the in-flight read is never asked — the common path pays no Actions reads', + '...and requests no image build off a guess either, with a warning that names the unread image build', + unreadable.outputs['image-missing'] === undefined && + /::warning::No ghcr image for 1\.1\.0 .* still building it could not be read \(.*HTTP 503/.test(unreadable.stdout), + said(unreadable), + ); + t( + 'nothing missing: the in-flight read is never asked — the common path pays no Actions reads', complete.actionsAsked.length === 0, JSON.stringify(complete.actionsAsked), ); + + // The image half of the 17.6.0 race: npm settled, and the publishing + // run's `docker` job (needs: publish) had not been created yet, so both + // runs built and pushed 17.6.0's image. The same read answers it. + t( + 'PUBLISH IN FLIGHT (its docker job not created yet): no image build is requested beside it', + flying.outputs['image-missing'] === undefined, + said(flying), + ); + t( + '...and a notice says why, naming the run whose image build is not finished', + /::notice::No ghcr image for 1\.1\.0 yet, but its publishing run has not finished building it .*run 4242 .*Docker image not yet created/.test(flying.stdout), + said(flying), + ); + const published = await audit({ before: vc, head: landing, present: whole, releases: true, flight: { publishing: '1.1.0', publishStatus: 'completed' } }); + t( + 'the publish job completed, its docker job not created yet (Releases present): no image build is requested', + published.status === 0 && published.outputs['image-missing'] === undefined && published.outputs['releases-missing'] === undefined && + /::notice::No ghcr image for 1\.1\.0 yet/.test(published.stdout), + said(published), + ); + t( + '...because Releases present no longer skips the in-flight read: the image asks it', + published.actionsAsked.some((a) => /\/actions\/runs\/4242\/jobs/.test(a)) && published.actionsAsked.every((a) => a.startsWith('GET ')), + JSON.stringify(published.actionsAsked), + ); + const building = await audit({ before: vc, head: landing, present: whole, flight: { publishing: '1.1.0', publishStatus: 'completed', docker: 'in_progress' } }); + t( + 'the publishing run\'s docker job in_progress: no image build is requested, while the Releases are backfilled', + building.outputs['image-missing'] === undefined && building.outputs['releases-missing'] === 'true', + said(building), + ); + const built = await audit({ before: vc, head: landing, present: whole, flight: { publishing: '1.1.0', publishStatus: 'completed', docker: 'completed' } }); + t( + 'its docker job completed and the image is absent: the image is requested (the repair)', + built.status === 0 && built.outputs['image-missing'] === 'true', + said(built), + ); + const shipped = await audit({ before: vc, head: landing, present: whole, image: true, flight: { publishing: '1.1.0', publishStatus: 'completed', docker: 'completed' } }); + t( + 'its docker job completed and the image is present: no image build is requested', + shipped.status === 0 && shipped.outputs['image-missing'] === undefined && /ghcr: image for 1\.1\.0 is present\./.test(shipped.stdout), + said(shipped), + ); + const unasked = await audit({ before: vc, head: landing, present: whole, token: '' }); + t( + 'the in-flight read cannot run at all: the audit stays green, backfills nothing, and warns for the Releases and the image', + unasked.status === 0 && !backfills(unasked) && + /::warning::1\.1\.0's GitHub Releases .* could not be asked/.test(unasked.stdout) && + /::warning::No ghcr image for 1\.1\.0 .* could not be asked/.test(unasked.stdout), + said(unasked), + ); } finally { await registry.close(); await actions.close(); @@ -2118,7 +2187,7 @@ export async function selfTest() { `OK release-verify-npm self-test: ${cases.length} cases pass across ` + `${Object.keys(SELF_TEST_BATTERIES).length} batteries (the #15321 false red reproduced and absorbed, ` + 'the masked partial publish caught, absence still fatal, the release audit backfilling ' - + 'only a version whose whole group is on npm and whose publish is not in flight, and that backfill built ' + + 'only a version whose whole group is on npm and whose publish and image build are not in flight, and that backfill built ' + 'from the version commit\'s tree).', ); selfTestReachedVerdict = true;