Skip to content

Commit cf6ec80

Browse files
committed
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 <noreply@anthropic.com>
1 parent 045b946 commit cf6ec80

1 file changed

Lines changed: 181 additions & 18 deletions

File tree

‎scripts/release-pending-publish.mjs‎

Lines changed: 181 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
/**
55
* release-pending-publish -- WHICH commit a release publishes, WHICH push queues
66
* its approval prompt, WHICH waiting prompts are no longer a release
7-
* (ADR-0125 D1, as amended 2026-09-29), and WHETHER a version's publish is in
8-
* flight, so the Releases backfill does not write beside it.
7+
* (ADR-0125 D1, as amended 2026-09-29), and WHETHER a version's publish, or its
8+
* publishing run's image build, is in flight, so neither backfill (the
9+
* Releases, the runtime image) writes beside it.
910
*
1011
* node scripts/release-pending-publish.mjs select --event push --head SHA --before SHA
1112
* node scripts/release-pending-publish.mjs select --event workflow_dispatch --head SHA
@@ -135,7 +136,7 @@
135136
* `--dry-run` reports what `sweep` would cancel and refuses any non-GET request
136137
* at the HTTP layer, so it is safe to point at the live repository.
137138
*
138-
* `in-flight` -- the Releases backfill's guard. `release-integrity` backfills
139+
* `in-flight` -- the backfills' guard. `release-integrity` backfills
139140
* the GitHub Releases of a version whose whole fixed group is on npm, and npm
140141
* settles BEFORE the publish job reaches its own "Create GitHub Releases"
141142
* step. So a landing audited in that window used to write the same Releases
@@ -163,10 +164,26 @@
163164
* cannot find is not unreadable: the filters still speak for it, and a guard
164165
* that blocked on it would block the repair of that version for good.
165166
*
166-
* It skips writes and refuses nothing: the audit leaves `releases-missing`
167-
* unset, says why, and stays green. Read-only by construction: it runs over
168-
* the `--dry-run` HTTP layer, so a non-GET cannot leave it. Permission:
169-
* `actions: read`.
167+
* The same read answers the image backfill (`docker` in the answer, beside the
168+
* publish job's top-level verdict). `release-integrity` requests the runtime
169+
* image of a version whose whole group is on npm and whose image is missing,
170+
* and npm settles before the publishing run's own `docker` job even exists:
171+
* that job is `needs: [release-integrity, publish]`, so GitHub creates it when
172+
* the publish job completes. Measured on 17.6.0: run 36955885276's publish job
173+
* completed at 03:05:45Z and its docker job was created that second and built
174+
* 03:05:49Z -> 03:10:02Z, while the backfill of run 36958423332, requested by
175+
* an audit that read the image missing at about 03:04Z, built 03:05:48Z ->
176+
* 03:09:18Z. Both pushed the 17.6.0 tag. So the image is in flight in a run
177+
* that holds the version's publish job past the `release` environment (not
178+
* `waiting`) until every docker job of that run is `completed` -- including
179+
* while it has none yet. A publish held at the environment builds nothing
180+
* before a human approves it, so it is not in flight for the image either, and
181+
* an unreadable read answers `in-flight` with reason `unreadable` here too.
182+
*
183+
* It skips writes and refuses nothing: the audit leaves `releases-missing` and
184+
* `image-missing` unset, says why, and stays green. Read-only by construction:
185+
* it runs over the `--dry-run` HTTP layer, so a non-GET cannot leave it.
186+
* Permission: `actions: read`.
170187
*
171188
* ## Why the pins live here, not in the workflow
172189
*
@@ -759,7 +776,7 @@ async function sweep({ workflow, dryRun }) {
759776
}
760777

761778
// ─────────────────────────────────────────────────────────────────────────────
762-
// Is this version's publish in flight? (the Releases backfill's guard)
779+
// Is this version's publish, or its image build, in flight? (the backfills' guard)
763780
// ─────────────────────────────────────────────────────────────────────────────
764781

765782
/**
@@ -772,31 +789,84 @@ export const PUBLISH_SETTLED_JOB_STATUSES = Object.freeze(['completed', 'waiting
772789
/** The run-list filters read for a publish in flight. */
773790
export const IN_FLIGHT_RUN_FILTERS = Object.freeze(['in_progress', 'queued']);
774791

792+
/**
793+
* `release.yml`'s `docker` job as the jobs API names it: `Docker image` while
794+
* it is skipped, `Docker image / <the called job>` once the reusable workflow
795+
* it calls runs. Measured: run 36955885276's `Docker image / Build & push
796+
* ghcr.io/objectstack-ai/objectstack`, run 37148267152's skipped `Docker image`.
797+
* A rename that this misses reads as "not yet created", so the image counts as
798+
* in flight until the publishing run completes: late, never early.
799+
*/
800+
export const DOCKER_JOB_NAME = /^Docker image(?: \/ .+)?$/;
801+
802+
/**
803+
* The publishing run's image build -- pure, one run. A run is the version's
804+
* publishing run when it holds the version's publish job past the `release`
805+
* environment (any status but `waiting`: approved, running or done). Its
806+
* `docker` job is `needs: [release-integrity, publish]`, so GitHub creates it
807+
* only when the publish job completes: until every docker job of the run is
808+
* `completed`, including while there is none yet, the image is in flight.
809+
* Null when the run is not that version's publishing run.
810+
*/
811+
function dockerInFlightIn(run, publishName) {
812+
const publish = run.jobs.find((j) => j.name === publishName && j.status !== 'waiting');
813+
if (!publish) return null;
814+
const docker = run.jobs.filter((j) => DOCKER_JOB_NAME.test(j.name));
815+
if (docker.length > 0 && docker.every((j) => j.status === 'completed')) return null;
816+
return {
817+
run: String(run.id),
818+
event: run.event,
819+
publish: publish.status,
820+
docker: docker.length === 0 ? 'not yet created' : docker.map((j) => j.status).join('+'),
821+
};
822+
}
823+
775824
/**
776825
* Judge the runs read for one version -- pure. Each run is `{ id, event,
777826
* status, jobs }`; `jobs` may be null only for a completed run. A run that is
778827
* not completed and was not read past its status answers `unreadable`.
828+
*
829+
* Two answers from one read: the top level is the publish job's (the Releases
830+
* backfill's guard), and `docker` is the image build of that version's
831+
* publishing run (the image backfill's guard), each `in-flight` or `clear`.
832+
* A completed run is settled for both: its jobs, the docker job included,
833+
* have all finished.
779834
*/
780835
export function judgePublishInFlight({ version, runs, currentRunId }) {
781836
const name = `Publish ${version} to npm (awaiting approval)`;
782837
const inFlight = [];
838+
const building = [];
783839
for (const run of runs) {
784840
const id = String(run.id);
785841
if (id === String(currentRunId) || run.status === 'completed') continue;
786842
if (!Array.isArray(run.jobs)) {
787-
return { state: 'in-flight', reason: 'unreadable', detail: `run ${id} is ${run.status} and its jobs were not read`, inFlight };
843+
const detail = `run ${id} is ${run.status} and its jobs were not read`;
844+
return { state: 'in-flight', reason: 'unreadable', detail, inFlight, docker: { state: 'in-flight', reason: 'unreadable', detail, inFlight: building } };
788845
}
789846
for (const job of run.jobs) {
790847
if (job.name === name && !PUBLISH_SETTLED_JOB_STATUSES.includes(job.status)) {
791848
inFlight.push({ run: id, event: run.event, status: job.status });
792849
}
793850
}
851+
const image = dockerInFlightIn(run, name);
852+
if (image) building.push(image);
794853
}
854+
const docker =
855+
building.length > 0
856+
? {
857+
state: 'in-flight',
858+
reason: 'docker-in-flight',
859+
detail: `the image build of ${version}'s publishing run is not finished: ${building
860+
.map((b) => `run ${b.run} (${b.event}, publish job ${b.publish}, Docker image ${b.docker})`)
861+
.join(', ')}`,
862+
inFlight: building,
863+
}
864+
: { state: 'clear', reason: 'none-in-flight', detail: `no other run is still building ${version}'s image after publishing it`, inFlight: building };
795865
if (inFlight.length > 0) {
796866
const where = inFlight.map((f) => `run ${f.run} (${f.event}, job ${f.status})`).join(', ');
797-
return { state: 'in-flight', reason: 'publish-in-flight', detail: `"${name}" is in flight in ${where}`, inFlight };
867+
return { state: 'in-flight', reason: 'publish-in-flight', detail: `"${name}" is in flight in ${where}`, inFlight, docker };
798868
}
799-
return { state: 'clear', reason: 'none-in-flight', detail: `no other run holds "${name}" in flight`, inFlight };
869+
return { state: 'clear', reason: 'none-in-flight', detail: `no other run holds "${name}" in flight`, inFlight, docker };
800870
}
801871

802872
/**
@@ -853,9 +923,10 @@ export async function collectPublishRuns({ http, repo, workflow, tips, currentRu
853923

854924
/**
855925
* The guard's whole answer for one version, never a throw: whatever cannot be
856-
* read answers `in-flight` with reason `unreadable`, so the caller backfills
857-
* nothing off a guess. `walk` yields the version commits of the head
858-
* (`versionCommitsOf`); the first must be the one the caller names.
926+
* read answers `in-flight` with reason `unreadable`, for the publish job and
927+
* the image build alike, so the caller backfills nothing off a guess. `walk`
928+
* yields the version commits of the head (`versionCommitsOf`); the first must
929+
* be the one the caller names.
859930
*/
860931
export async function publishInFlight({ http, repo, workflow, version, versionCommit, walk, currentRunId = '' }) {
861932
try {
@@ -868,12 +939,23 @@ export async function publishInFlight({ http, repo, workflow, version, versionCo
868939
}
869940
const read = await collectPublishRuns({ http, repo, workflow, tips: first.tips, currentRunId });
870941
const verdict = judgePublishInFlight({ version, runs: read.runs, currentRunId });
871-
if (read.pushRuns.length === 0 && verdict.state === 'clear') {
872-
verdict.detail += `; no push run of ${workflow} was found at the version commit, so the run-list filters alone speak for it`;
942+
if (read.pushRuns.length === 0) {
943+
const note = `; no push run of ${workflow} was found at the version commit, so the run-list filters alone speak for it`;
944+
if (verdict.state === 'clear') verdict.detail += note;
945+
if (verdict.docker.state === 'clear') verdict.docker.detail += note;
873946
}
874947
return { version, ...verdict, readings: read.readings };
875948
} catch (err) {
876-
return { version, state: 'in-flight', reason: 'unreadable', detail: err instanceof Error ? err.message : String(err), inFlight: [], readings: '' };
949+
const detail = err instanceof Error ? err.message : String(err);
950+
return {
951+
version,
952+
state: 'in-flight',
953+
reason: 'unreadable',
954+
detail,
955+
inFlight: [],
956+
docker: { state: 'in-flight', reason: 'unreadable', detail, inFlight: [] },
957+
readings: '',
958+
};
877959
}
878960
}
879961

@@ -912,8 +994,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
912994
'a shallow clone or an unresolvable version commit -> refused, never a boundary commit or an empty answer': 2,
913995
'the report -> a warning naming every changeset with its commit, a summary section, or a plain line for none': 4,
914996
"a publish of the version in another run -> in flight until it completes, and an unreadable read is in flight": 12,
997+
"the publishing run's image build -> in flight from the approval until its docker job completes, and an unreadable read is in flight": 12,
915998
});
916-
const SELF_TEST_BATTERY_FLOOR = 21;
999+
const SELF_TEST_BATTERY_FLOOR = 22;
9171000

9181001
async function selfTest() {
9191002
let failed = 0;
@@ -1511,6 +1594,86 @@ async function selfTest() {
15111594
readOnly = /--dry-run refuses POST/.test(String(err && err.message));
15121595
}
15131596
check(readOnly, 'the HTTP layer in-flight runs over refuses a non-GET before it is sent');
1597+
1598+
// The same read, asked about the publishing run's image build. Opened in
1599+
// this block on purpose: it reuses the stub API above rather than a copy.
1600+
battery("the publishing run's image build -> in flight from the approval until its docker job completes, and an unreadable read is in flight");
1601+
const docker = (status, name = 'Docker image / Build & push ghcr.io/objectstack-ai/objectstack') => ({ name, status });
1602+
const run176 = (jobs, status = 'in_progress') => ({ id: 36955885276, event: 'push', status, jobs: [integrity, ...jobs] });
1603+
const publishing = judge([run176([job('17.6.0', 'in_progress')])]);
1604+
check(
1605+
publishing.docker.state === 'in-flight' && publishing.docker.reason === 'docker-in-flight' &&
1606+
/run 36955885276 \(push, publish job in_progress, Docker image not yet created\)/.test(publishing.docker.detail),
1607+
'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',
1608+
);
1609+
const between = judge([run176([job('17.6.0', 'completed')])]);
1610+
check(
1611+
between.state === 'clear' && between.docker.state === 'in-flight' && /Docker image not yet created/.test(between.docker.detail),
1612+
'the publish job completed and GitHub has not created the docker job yet -> the publish is clear, the image still in flight',
1613+
);
1614+
check(
1615+
['queued', 'in_progress', 'waiting', 'some_new_status'].every((s) => judge([run176([job('17.6.0', 'completed'), docker(s)])]).docker.state === 'in-flight') &&
1616+
judge([run176([job('17.6.0', 'completed'), docker('queued', 'Docker image')])]).docker.state === 'in-flight',
1617+
'the docker job queued, in_progress, waiting or in a status this script does not know, under either of its names -> in flight',
1618+
);
1619+
check(
1620+
judge([run176([job('17.6.0', 'completed'), docker('completed')])]).docker.state === 'clear' &&
1621+
judge([run176([job('17.6.0', 'completed'), docker('completed', 'Docker image')])]).docker.state === 'clear' &&
1622+
judge([{ id: 36955885276, event: 'push', status: 'completed', jobs: null }]).docker.state === 'clear',
1623+
'a completed docker job, in a run still finishing or in a completed run -> clear, so the image probe alone decides the backfill',
1624+
);
1625+
const held = judge([{ id: 3, event: 'workflow_dispatch', status: 'waiting', jobs: [integrity, job('17.6.0', 'waiting')] }]);
1626+
check(
1627+
held.state === 'clear' && held.docker.state === 'clear',
1628+
'a publish held at the release environment builds nothing before a human approves it -> clear for the image too',
1629+
);
1630+
check(
1631+
judge([
1632+
{ id: 900, event: 'push', status: 'in_progress', jobs: [job('17.6.0', 'completed')] },
1633+
{ id: 6, event: 'push', status: 'in_progress', jobs: [job('17.7.0', 'completed')] },
1634+
{ 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')] },
1635+
]).docker.state === 'clear',
1636+
"the calling run, another version's publishing run, and a run whose skipped publish job names no version -> clear",
1637+
);
1638+
const unreadJobs = judge([{ id: 8, event: 'push', status: 'in_progress', jobs: null }]);
1639+
check(
1640+
unreadJobs.docker.state === 'in-flight' && unreadJobs.docker.reason === 'unreadable',
1641+
'a run still running whose jobs were never read -> unreadable for the image too, never clear',
1642+
);
1643+
1644+
const pushedAt = { dcc5ef4c: [runAt(36955885276, 'push', 'in_progress')] };
1645+
const imageWindow = await flightWith({
1646+
tips: pushedAt,
1647+
direct: { 36955885276: runAt(36955885276, 'push', 'in_progress', [integrity, job('17.6.0', 'completed')]) },
1648+
});
1649+
check(
1650+
imageWindow.answer.state === 'clear' && imageWindow.answer.docker.state === 'in-flight' && imageWindow.answer.docker.reason === 'docker-in-flight',
1651+
'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',
1652+
);
1653+
const imageBuilt = await flightWith({
1654+
tips: pushedAt,
1655+
direct: { 36955885276: runAt(36955885276, 'push', 'in_progress', [integrity, job('17.6.0', 'completed'), docker('completed')]) },
1656+
});
1657+
check(imageBuilt.answer.state === 'clear' && imageBuilt.answer.docker.state === 'clear', '...and once that docker job completed -> clear for both');
1658+
const dispatchedImage = await flightWith({
1659+
filters: { in_progress: [runAt(77, 'workflow_dispatch', 'in_progress')] },
1660+
direct: { 77: runAt(77, 'workflow_dispatch', 'in_progress', [job('17.6.0', 'completed'), docker('in_progress')]) },
1661+
});
1662+
check(dispatchedImage.answer.docker.state === 'in-flight', "a repair-lane run's image build, which only the in_progress filter lists -> in flight");
1663+
const unreadImage = await Promise.all([
1664+
flightWith({ fail: /status=queued/ }),
1665+
flightWith({ tips: pushedAt, direct: { 36955885276: runAt(36955885276, 'push', 'in_progress') }, fail: /\/jobs/ }),
1666+
flightWith({ walk: (function* shallow() { throw new Error('refusing to list version commits in a shallow clone'); })() }),
1667+
]);
1668+
check(
1669+
unreadImage.every((u) => u.answer.docker.state === 'in-flight' && u.answer.docker.reason === 'unreadable'),
1670+
'a filter answering non-200, jobs answering non-200, or a shallow clone -> the image is unreadable, so in flight',
1671+
);
1672+
const lostImage = await flightWith();
1673+
check(
1674+
lostImage.answer.docker.state === 'clear' && /no push run of release\.yml was found/.test(lostImage.answer.docker.detail),
1675+
'no push run found and nothing in flight -> the image is clear on the filters, and the answer says so',
1676+
);
15141677
}
15151678

15161679
battery('an event with no release predicate -> refused');

0 commit comments

Comments
 (0)