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
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
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. */
773790export 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 = / ^ D o c k e r i m a g e (?: \/ .+ ) ? $ / ;
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 */
780835export 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 */
860931export 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
9181001async function selfTest ( ) {
9191002 let failed = 0 ;
@@ -1511,6 +1594,86 @@ async function selfTest() {
15111594 readOnly = / - - d r y - r u n r e f u s e s P O S T / . 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+ / r u n 3 6 9 5 5 8 8 5 2 7 6 \( p u s h , p u b l i s h j o b i n _ p r o g r e s s , D o c k e r i m a g e n o t y e t c r e a t e d \) / . 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' && / D o c k e r i m a g e n o t y e t c r e a t e d / . 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 : / s t a t u s = q u e u e d / } ) ,
1665+ flightWith ( { tips : pushedAt , direct : { 36955885276 : runAt ( 36955885276 , 'push' , 'in_progress' ) } , fail : / \/ j o b s / } ) ,
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' && / n o p u s h r u n o f r e l e a s e \. y m l w a s f o u n d / . 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