From 4474b39b28e4e1445e7db2afd10b890081f91ef0 Mon Sep 17 00:00:00 2001 From: Wynne Pirini Date: Fri, 16 Oct 2026 18:03:49 +1300 Subject: [PATCH] fix(ci): look the sibling up by workflow FILE name, not display name release-verify.yml has never once succeeded, and could never have. The gate step called: gh api "repos/${{ github.repository }}/actions/workflows/$SIBLING/runs" where SIBLING is the display name, either 'NS8 module publish' or 'Docker publish'. The REST path wants the workflow FILE name or the numeric id, and answers 404 for a display name. Measured against the real repo: .../actions/workflows/NS8 module publish/runs -> 404 .../actions/workflows/Docker publish/runs -> 404 .../actions/workflows/ns8-module.yml/runs -> 200, total_count 109 .../actions/workflows/docker-publish.yml/runs -> 200, total_count 340 Under `set -euo pipefail` that 404 killed the step, so every run failed before reaching a single line of the decision tree. Main has been red here since #151 merged. The trigger is deliberately untouched. `workflows: ['NS8 module publish', 'Docker publish']` is correct, because workflow_run matches on display NAME, and github.event.workflow_run.name also returns the display name. Only the API path needs the file name, so the mapping is confined to the one call that does. A case statement maps display name to file name, which is simpler than resolving a numeric id and keeps the trigger and the lookup visibly consistent. An unmapped display name now exits 1 with an error annotation naming the file to edit. That is the point: renaming a publish workflow must be loud, because a silent no-op is precisely how this bug survived review. A silent mapping would have let a rename look like a healthy waiting-for-sibling. Two lookups became one, so the sibling's run list is fetched once and both status and conclusion are read from it. WHY THIS WAS MISSED, and it is the same failure as #148: the gate's decision tree was tested and its integration with the thing it calls was not. The test executed the real shipped block but stubbed status and conclusion, so the gh api call was never issued. Both halves were green and the workflow was dead. The block no longer accepts status and conclusion as inputs at all, it derives them from a real response, so the seam that can be stubbed is now gh itself and the API I/O is a separate, real assertion. Proven against the REAL API, with the real shipped block, real workflow names and real commit SHAs: 5b4acef Docker publish -> 340 runs, completed/success -> verify 5b4acef NS8 module publish -> 109 runs, completed/failure -> blocked no run for that SHA -> absent/absent -> waiting-for-sibling And the decision matrix, with only gh stubbed at the API seam, so the mapping, the decision tree and the loud failure all run for real: both succeeded rc=0 ready=true outcome=verify sibling still running rc=0 ready=false outcome=waiting-for-sibling sibling still queued rc=0 ready=false outcome=waiting-for-sibling sibling concluded failure rc=0 ready=false outcome=blocked no sibling run at all rc=0 ready=false outcome=waiting-for-sibling api 404 rc=1 unmapped display name rc=1 A new "Probe both publish workflows" step runs unconditionally, before the gate, and calls the real API for both file names, echoing the run count and the run for this commit. It fails the job if either lookup 404s. So a broken lookup is visible in a live run log rather than inferred from a unit test, which is the gap that let this ship. Unrelated to this commit, and recorded because it is a real open question: the module repository is passed to assert-multiarc.py as `ghcr.io/${{ github.repository_owner }}/ns8-calnode`, and that exact string 404s, while every other spelling of the same image resolves. Left alone here on purpose, since it is a separate change with a separate cause still to isolate. --- .github/workflows/release-verify.yml | 67 ++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 2ec6fe3..4ec35c3 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -42,6 +42,35 @@ jobs: steps: - uses: actions/checkout@v7 + # Live evidence that the sibling lookup actually works against the real API. + # This runs unconditionally and does not depend on the gate's decision, + # because the previous version of this workflow looked correct in every + # local test and had never once issued the call. A lookup bug must be + # visible in a run log, not inferred from a stubbed unit test. + - name: Probe both publish workflows + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + rc=0 + for entry in 'NS8 module publish|ns8-module.yml' 'Docker publish|docker-publish.yml'; do + display="${entry%%|*}" + file="${entry##*|}" + url="repos/${{ github.repository }}/actions/workflows/$file/runs?per_page=100" + if runs=$(gh api "$url" 2>&1); then + echo "$file -> 200, $(printf '%s' "$runs" | jq '.total_count') run(s) on record" + match=$(printf '%s' "$runs" | jq -r --arg sha "$SHA" \ + '[.workflow_runs[] | select(.head_sha == $sha)] | first | + "\(.status // "none") \(.conclusion // "none") \(.name // "?")"') + echo "$file run for $SHA: ${match:-none}" + else + echo "::error::lookup failed for '$display' ($file): $runs" + rc=1 + fi + done + exit $rc + - name: Wait for both publish workflows to finish id: gate env: @@ -64,10 +93,40 @@ jobs: exit 0 fi - status=$(gh api "repos/${{ github.repository }}/actions/workflows/$SIBLING/runs" \ - --jq "[.workflow_runs[] | select(.head_sha == \"$SHA\")] | first | .status // \"absent\"") - conclusion=$(gh api "repos/${{ github.repository }}/actions/workflows/$SIBLING/runs" \ - --jq "[.workflow_runs[] | select(.head_sha == \"$SHA\")] | first | .conclusion // \"absent\"") + # The REST path wants the workflow FILE name or the numeric id. It does + # NOT accept the display name, and answers 404 for one. Measured: + # .../actions/workflows/NS8 module publish/runs -> 404 + # .../actions/workflows/ns8-module.yml/runs -> 200 + # Under `set -e` that 404 kills the step, so every run of this workflow + # failed at this line and the gate could never verify anything. + # + # Note the display name is still what the workflow_run TRIGGER matches on + # and what github.event.workflow_run.name returns. Only the API path needs + # the file name, so this mapping is deliberately confined to that one call. + case "$SIBLING" in + 'NS8 module publish') sibling_file='ns8-module.yml' ;; + 'Docker publish') sibling_file='docker-publish.yml' ;; + *) + # Loud, not silent. A renamed workflow must not look like a healthy + # no-op, which is exactly how the previous version of this bug hid. + echo "::error::no file name is mapped for sibling workflow '$SIBLING'." + echo "::error::Add it to the case statement above. The display names live in .github/workflows/ns8-module.yml and docker-publish.yml." + exit 1 + ;; + esac + echo "sibling display name: $SIBLING" + echo "sibling workflow file: $sibling_file" + + runs=$(gh api "repos/${{ github.repository }}/actions/workflows/$sibling_file/runs?per_page=100") + total=$(printf '%s' "$runs" | jq '.total_count') + echo "sibling workflow run count: $total" + + read -r status conclusion < <( + printf '%s' "$runs" \ + | jq -r --arg sha "$SHA" \ + '([.workflow_runs[] | select(.head_sha == $sha)] | first) // {} | + "\(.status // "absent") \(.conclusion // "absent")"' + ) echo "sibling status=$status conclusion=$conclusion" if [ "$status" != "completed" ]; then