From 82584c1d2f00ac7d1151bc999e03665a06228a76 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Tue, 22 Sep 2026 21:58:11 +0200 Subject: [PATCH] feat(release-status): add --watch, and find the tag's run by branch release-status.sh looked for the tag's workflow run among the 12 newest runs of every workflow. Renovate, a merge queue and CI push the release run out of that window within hours, so the script reported "workflow : none" for releases whose run had succeeded: netresearch/raybeam v1.2.0 (run 32944806553) and netresearch/terraform-provider-ad v0.5.3, both on 2026-09-22. A failed release run falls out of the window the same way, and then nothing reports it. The lookup now asks GitHub for the tag's runs with --branch. Among them it prefers the run whose name says release or publish, because one tag push can also start CI. A failed lookup prints "unknown" rather than "none", so a broken query no longer reads as a missing run. A running run prints "in_progress/-": gh gives it conclusion "", and jq's `//` treats the empty string as present, so the old filter printed "in_progress/". --watch waits while the tag's publishing workflow has not completed, prints each state change to stderr, and then gives the normal verdict. The release side had no counterpart to pr-status.sh --watch, so every wait on a release run was a hand-written loop; one session on 2026-09-22 wrote the same loop four times. The new test stubs gh so that `run list` honours --branch and applies the --jq filter with real jq, behind twelve newer runs of another workflow. Each part was checked by breaking it: restoring the original query from main verbatim fails six cases, and dropping --branch, the release preference, the "unknown" fallback, the empty-conclusion handling or the --watch flag each fails at least one case, every run completing all 29 cases. Assisted-by: claude-code:claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01GY5RqMQzgKbEvCALQ7r1ec Agent-Host: 0493f0 Signed-off-by: Sebastian Mendel --- CHANGELOG.md | 5 ++ commands/release-status.md | 4 + skills/github-release/SKILL.md | 3 +- .../github-release/scripts/release-status.sh | 58 +++++++++++-- .../scripts/tests/release-status.test.sh | 85 +++++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2632c9d..138ef20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ their notes were not backfilled here rather than reconstructed after the fact. - an unquoted shell comment is no longer read as arguments. `gh release create v1.2.3 # --verify-tag` offered a flag the shell discards, so the guard allowed the invocation and bash ran the bare create that can mint a lightweight tag; `gh release delete v1.2.3 # --help` passed the same way. The comment is now stripped with the quoted spans, before either check, and the notes-only test for `gh release edit` uses the same stripping. Found by CodeRabbit on the pull request that introduced the `--verify-tag` exemption, reproduced before the fix, and pinned by seven cases including two where a `#` is part of an argument rather than a comment - `gh release create --help` and `gh release delete -h` are no longer blocked. Reading the help runs no release operation, and the blocked command was the one that would have explained `--verify-tag` - a block message spanning several lines emitted its continuation lines at column 0, which ends the YAML block scalar and leaves the rest as stray text +- `release-status.sh` finds the tag's workflow run again once other runs have happened since the tag. It filtered the 12 newest runs of every workflow on its own side, so Renovate, a merge queue and CI pushed the release run out of that window within hours: `netresearch/raybeam` v1.2.0 and `netresearch/terraform-provider-ad` v0.5.3 both reported `workflow : none` while their release runs had succeeded, and a failed release run would have been missed the same way. The lookup now asks GitHub for the tag's runs with `--branch`, prefers the run whose name says release or publish over CI started by the same push, prints `unknown` rather than `none` when the lookup itself fails, and prints `in_progress/-` for a running run, where gh's empty `conclusion` used to leave `in_progress/` + +### Added + +- `release-status.sh --watch` waits while the tag's publishing workflow has not completed, prints each state change to stderr, and then gives the normal verdict. Until now the release side had no counterpart to `pr-status.sh --watch`, and every wait on a release run was a hand-written loop ## [1.0.4] - 2026-09-20 diff --git a/commands/release-status.md b/commands/release-status.md index be4f3a6..d3789de 100644 --- a/commands/release-status.md +++ b/commands/release-status.md @@ -20,6 +20,10 @@ Run the manual inspection below only to explain a verdict it already gave, or when the script is unavailable — reasoning the phase out by hand is how a step gets skipped. +Right after pushing a tag, add `--watch`: the script then waits until the tag's +publishing workflow has completed and reports the verdict after it, instead of +answering `await-release-workflow` at once. + ### 1. List recent releases Run: diff --git a/skills/github-release/SKILL.md b/skills/github-release/SKILL.md index 57f8c29..e5cebbb 100644 --- a/skills/github-release/SKILL.md +++ b/skills/github-release/SKILL.md @@ -27,7 +27,8 @@ Where the repository has a workflow that publishes the release, that workflow do ## Start Here `scripts/release-status.sh -R owner/repo` reports the phase and a computed -`NEXT`, exiting 0 only when finished. `scripts/release-notes-status.sh` +`NEXT`, exiting 0 only when finished; after a tag push, `--watch` waits for +the tag's publishing workflow first. `scripts/release-notes-status.sh` checks the published body. ## Release Flow diff --git a/skills/github-release/scripts/release-status.sh b/skills/github-release/scripts/release-status.sh index 67cbbe0..b293768 100755 --- a/skills/github-release/scripts/release-status.sh +++ b/skills/github-release/scripts/release-status.sh @@ -19,16 +19,23 @@ # # ./release-status.sh [-R owner/repo] # ./release-status.sh -R owner/repo --json +# ./release-status.sh -R owner/repo --watch # wait for the tag's workflow, then report +# +# --watch waits only while the tag exists and its publishing workflow has not +# completed: it prints each state change to stderr and then gives the normal +# verdict. RELEASE_STATUS_WATCH_INTERVAL (seconds, default 20) and +# RELEASE_STATUS_WATCH_TIMEOUT (default 2700) tune it. # # Exit: 0 = ok, 1 = action needed, 2 = usage/lookup error. set -euo pipefail -REPO=""; JSON=0 +REPO=""; JSON=0; WATCH=0 while [ $# -gt 0 ]; do case "$1" in -R|--repo) REPO="$2"; shift 2 ;; --json) JSON=1; shift ;; - -h|--help) sed -n '2,26p' "$0"; exit 0 ;; + --watch) WATCH=1; shift ;; + -h|--help) sed -n '2,29p' "$0"; exit 0 ;; *) shift ;; esac done @@ -166,11 +173,49 @@ if [ -n "$declared" ] && [ "$LOCAL_ONLY" = 0 ]; then fi # --- phase 4: the publishing workflow --------------------------------------- +# Ask GitHub for the tag's runs by name. Filtering the 12 newest runs of EVERY +# workflow on this side found nothing once a dozen other runs had happened since +# the tag -- Renovate, a merge queue and CI get there in hours -- and reported +# "none" for a release whose run existed (netresearch/raybeam v1.2.0, run +# 32944806553, measured 2026-09-22). A failed release run falls out of that +# window just the same, and then nothing reports it. +# +# One tag push can start several workflows (CI beside Release). The publishing +# one is the run whose name says release or publish; only without such a run is +# the newest run on the tag taken. A failed lookup is "unknown", not "none": +# "the query failed" and "there is no run" must stay distinguishable. gh gives a +# run in progress `conclusion: ""`, which jq's `//` treats as present, so the +# empty string is tested for explicitly. +wf_query() { + gh run list --repo "$REPO" --branch "v$declared" --limit 20 \ + --json status,conclusion,name \ + --jq '([.[] | select(.name|test("release|publish";"i"))] + .) | .[0] + | if .==null then "none" + else "\(.status)/\(if (.conclusion // "") == "" then "-" else .conclusion end)" end' \ + 2>/dev/null || echo "unknown" +} + wf_state="" if [ "$tag_state" = "annotated" ]; then - wf_state=$(gh run list --repo "$REPO" --limit 12 \ - --json headBranch,status,conclusion,name \ - --jq "[.[] | select(.headBranch==\"v$declared\")] | .[0] | if .==null then \"none\" else \"\(.status)/\(.conclusion // \"-\")\" end" 2>/dev/null || echo "none") + wf_state=$(wf_query) + if [ "$WATCH" = 1 ]; then + interval="${RELEASE_STATUS_WATCH_INTERVAL:-20}" + deadline=$(( $(date +%s) + ${RELEASE_STATUS_WATCH_TIMEOUT:-2700} )) + last="" + while [ "${wf_state%%/*}" != "completed" ]; do + if [ "$wf_state" != "$last" ]; then + printf 'watch: v%s workflow %s\n' "$declared" "$wf_state" >&2 + last="$wf_state" + fi + if [ "$(date +%s)" -ge "$deadline" ]; then + add_note "watch timed out while the workflow was $wf_state" + break + fi + sleep "$interval" + wf_state=$(wf_query) + done + [ "${wf_state%%/*}" = "completed" ] && printf 'watch: v%s workflow %s\n' "$declared" "$wf_state" >&2 + fi fi # --- phase 5: is the published body finished? -------------------------------- @@ -273,7 +318,8 @@ else printf ' declared : %s%s\n' "${declared:-}" "$([ "$version_source" = release ] && echo ' (from the latest release; no version file in the tree)')" printf ' latest rel : %s\n' "${latest:-}" printf ' tag : %s\n' "${tag_state:-n/a}" - [ -n "$wf_state" ] && printf ' workflow : %s\n' "$wf_state" + [ -n "$wf_state" ] && printf ' workflow : %s%s\n' "$wf_state" \ + "$([ "$wf_state" = none ] && echo " (no run found for v$declared)")" [ -n "$notes_next" ] && printf ' notes : %s\n' "$notes_next" [ -n "$reg_missing" ] && printf ' registries : missing on%s\n' "$reg_missing" echo diff --git a/skills/github-release/scripts/tests/release-status.test.sh b/skills/github-release/scripts/tests/release-status.test.sh index 94bdb8a..c697497 100755 --- a/skills/github-release/scripts/tests/release-status.test.sh +++ b/skills/github-release/scripts/tests/release-status.test.sh @@ -181,6 +181,91 @@ norelease_out=$(cd "$tagonly/repo" && env -i PATH="$tagonly/bin:/usr/bin:/bin" H check "versionless with no release still says so" "no version file found" "$norelease_out" check "and still names the manifests" "herdr-plugin.toml" "$norelease_out" +# --------------------------------------------------------------------------- +# The tag's workflow run, behind a dozen newer runs of other workflows +# --------------------------------------------------------------------------- +# netresearch/raybeam v1.2.0 reported "workflow : none" on 2026-09-22 although +# its Release run 32944806553 had succeeded: the script filtered the 12 newest +# runs of every workflow, and Renovate, the merge queue and CI had long pushed +# the tag's run out of that window. This stub behaves like gh: `run list` honours +# --branch and applies the --jq filter with real jq, so a lookup that does not +# ask for the tag gets the twelve foreign runs and nothing else. + +runs=$(mktemp -d) +trap 'rm -rf "$work" "$addon" "$herdr" "$tagonly" "$runs"' EXIT +mkdir -p "$runs/bin" "$runs/repo" +ln -sf "$jq_path" "$runs/bin/jq" +cat >"$runs/bin/gh" <<'STUB' +#!/usr/bin/env bash +# A repo released as v6.4.0 whose tag push started CI and Release. Twelve CI +# runs on main came after it. RUNS_MODE selects the state of the tag's runs. +case "$1 $2" in + "auth status") exit 0 ;; + "repo view") echo "acme/runs"; exit 0 ;; + "pr list") echo "null"; exit 0 ;; + "release view") exit 1 ;; + "run list") + [ "${RUNS_MODE:-}" = fail ] && exit 1 + branch=""; filter="."; shift 2 + while [ $# -gt 0 ]; do + case "$1" in + --branch) branch="$2"; shift 2 ;; + --jq) filter="$2"; shift 2 ;; + *) shift ;; + esac + done + if [ "$branch" = "v6.4.0" ]; then + n=0; [ -f "$RUNS_COUNTER" ] && n=$(cat "$RUNS_COUNTER"); echo $((n + 1)) >"$RUNS_COUNTER" + rel='{"name":"Release","status":"completed","conclusion":"success","headBranch":"v6.4.0"}' + if [ "${RUNS_MODE:-}" = watch ] && [ "$n" -lt 2 ]; then + rel='{"name":"Release","status":"in_progress","conclusion":"","headBranch":"v6.4.0"}' + fi + # CI on the same tag is newer and still running; it is not the publisher. + data="[{\"name\":\"CI\",\"status\":\"in_progress\",\"conclusion\":\"\",\"headBranch\":\"v6.4.0\"},$rel]" + elif [ -z "$branch" ]; then + # Cancelled, so a foreign run can never pass for the tag's successful one. + data=$(jq -nc '[range(12) | {name:"Release",status:"completed",conclusion:"cancelled",headBranch:"main"}]') + else + data='[]' + fi + printf '%s' "$data" | jq -r "$filter" + exit 0 ;; +esac +if [ "$1" = api ]; then + case "$2" in + */releases/latest) echo "v6.4.0"; exit 0 ;; + */git/ref/tags/v6.4.0) echo "tag"; exit 0 ;; + */git/ref/tags/*) exit 1 ;; + */contents/*) exit 1 ;; + esac +fi +exit 1 +STUB +chmod +x "$runs/bin/gh" + +runs_env() { env -i PATH="$runs/bin:/usr/bin:/bin" HOME="$runs" RUNS_COUNTER="$runs/count" "$@"; } + +rm -f "$runs/count" +runs_out=$(cd "$runs/repo" && runs_env bash --noprofile --norc "$SCRIPT" -R acme/runs 2>&1) +check "finds the tag's run behind twelve newer ones" "workflow : completed/success" "$runs_out" +refute "does not report the run as missing" "workflow : none" "$runs_out" + +# The failed lookup must not read as "there is no run". +rm -f "$runs/count" +fail_out=$(cd "$runs/repo" && runs_env RUNS_MODE=fail bash --noprofile --norc "$SCRIPT" -R acme/runs 2>&1) +check "a failed lookup says unknown" "workflow : unknown" "$fail_out" + +# --watch: the Release run is in progress for the first two lookups, then done. +# The timeout is short so that a broken watch fails this test in seconds rather +# than holding it for the default 45 minutes. +rm -f "$runs/count" +watch_out=$(cd "$runs/repo" && runs_env RUNS_MODE=watch RELEASE_STATUS_WATCH_INTERVAL=0 \ + RELEASE_STATUS_WATCH_TIMEOUT=3 bash --noprofile --norc "$SCRIPT" -R acme/runs --watch 2>&1) +check "watch reports the state it waited through" "watch: v6.4.0 workflow in_progress/-" "$watch_out" +check "watch ends on the completed run" "workflow : completed/success" "$watch_out" +refute "watch did not run into its timeout" "watch timed out" "$watch_out" +check "watch polled exactly until the state changed" "lookups=3;" "lookups=$(cat "$runs/count");" + # The guard is a case pattern over the value gh returned; a JSON error body # contains characters a tag cannot. tagshaped() { case "$1" in *[!A-Za-z0-9._-]* | "" | null) echo no ;; *) echo yes ;; esac; }