Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 81 additions & 52 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ on:
'Go CI'), not its filename. Empty (the default) keeps today's
behaviour: the release runs regardless of any other workflow.

Several workflows may be named, comma-separated, e.g.
'Go CI, Integration tests'. EVERY one of them must be green; the
guard reports all that are not, rather than stopping at the first,
so one run tells you everything that is wrong. A repository with
more than one quality workflow should name them all, or the
unnamed ones can be red while the release proceeds.

Release is a separate workflow from the quality gates and is
triggered by the same push, so nothing makes it observe them.
That is not theoretical: datatug/datatug-cli published v0.13.2
Expand All @@ -325,10 +332,10 @@ on:
required: false
default: 1800
description: >-
How long to wait for `require_workflow_success` to finish before
giving up and failing the release. Ignored when that input is
empty. Raise it for repositories whose suite runs longer than the
30-minute default.
How long to wait for a required workflow to finish before giving
up and failing the release. Applies PER named workflow, not to the
set. Ignored when `require_workflow_success` is empty. Raise it for
repositories whose suite runs longer than the 30-minute default.
outputs:
tag:
description: >-
Expand Down Expand Up @@ -433,7 +440,7 @@ jobs:
GRACE=180
POLL=15

# Resolve the workflow by its `name:`. A 403 here means the caller
# Resolve each workflow by its `name:`. A 403 here means the caller
# opted in without granting `actions: read`; say so, rather than
# reporting the workflow as missing.
if ! workflows="$(gh api "repos/$REPO/actions/workflows" --paginate \
Expand All @@ -449,60 +456,82 @@ jobs:
fi
rm -f ./wf_err

wf_id="$(printf '%s\n' "$workflows" | awk -F'\t' -v n="$WF_NAME" '$2 == n {print $1; exit}')"
if [ -z "$wf_id" ]; then
echo "::error title=Unknown required workflow::No workflow named '$WF_NAME' exists in $REPO. require_workflow_success matches a workflow's 'name:', not its filename. Available:"
printf '%s\n' "$workflows" | awk -F'\t' '{print " " $2}'
# One name, or several separated by commas. Every one of them must be green.
names="$(printf '%s\n' "$WF_NAME" | tr ',' '\n' \
| sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | grep -v '^$' || true)"
if [ -z "$names" ]; then
echo "::error title=Empty required workflow list::require_workflow_success is set but names no workflow."
exit 1
fi
echo "required workflow '$WF_NAME' resolved to id $wf_id"

runs_for_sha() {
gh api "repos/$REPO/actions/workflows/$wf_id/runs?head_sha=$SHA&per_page=100" \
--jq '[.workflow_runs[] | {id, status, conclusion, created_at, html_url}]
| sort_by(.created_at) | reverse'
}
not_green=""

# The required workflow is usually triggered by the same push as this
# release, so its run may not be registered yet. Wait that race out
# before concluding it simply does not apply to this commit.
waited=0
while :; do
runs="$(runs_for_sha)"
[ "$(printf '%s' "$runs" | jq 'length')" -gt 0 ] && break
if [ "$waited" -ge "$GRACE" ]; then
echo "::notice title=Required workflow did not run::'$WF_NAME' produced no run for $SHA within ${GRACE}s, so it does not apply to this commit (paths filters). Continuing."
exit 0
fi
sleep "$POLL"; waited=$((waited + POLL))
done
while IFS= read -r name; do
[ -z "$name" ] && continue

# Newest run wins, so re-running a red suite can clear this gate.
run_id="$(printf '%s' "$runs" | jq -r '.[0].id')"
run_url="$(printf '%s' "$runs" | jq -r '.[0].html_url')"
echo "gating this release on $run_url"

waited=0
while :; do
read -r status conclusion <<<"$(gh api "repos/$REPO/actions/runs/$run_id" --jq '"\(.status) \(.conclusion // "-")"')"
if [ "$status" = "completed" ]; then
case "$conclusion" in
success|skipped)
echo "::notice title=Required workflow green::'$WF_NAME' concluded $conclusion for $SHA."
exit 0 ;;
*)
echo "::error title=Required workflow not green::'$WF_NAME' concluded '$conclusion' for $SHA; refusing to tag or publish. See $run_url"
exit 1 ;;
esac
fi
if [ "$waited" -ge "$TIMEOUT" ]; then
echo "::error title=Required workflow timed out::'$WF_NAME' was still '$status' for $SHA after ${TIMEOUT}s; refusing to tag or publish. See $run_url"
wf_id="$(printf '%s\n' "$workflows" | awk -F'\t' -v n="$name" '$2 == n {print $1; exit}')"
if [ -z "$wf_id" ]; then
echo "::error title=Unknown required workflow::No workflow named '$name' exists in $REPO. require_workflow_success matches a workflow's 'name:', not its filename. Available:"
printf '%s\n' "$workflows" | awk -F'\t' '{print " " $2}'
exit 1
fi
echo " '$WF_NAME' is $status; waited ${waited}s of ${TIMEOUT}s"
sleep "$POLL"; waited=$((waited + POLL))
done

echo "required workflow '$name' resolved to id $wf_id"

# The required workflow is usually triggered by the same push as this
# release, so its run may not be registered yet. Wait that race out before
# concluding it simply does not apply to this commit.
waited=0
runs=""
while :; do
runs="$(gh api "repos/$REPO/actions/workflows/$wf_id/runs?head_sha=$SHA&per_page=100" \
--jq '[.workflow_runs[] | {id, status, conclusion, created_at, html_url}]
| sort_by(.created_at) | reverse')"
[ "$(printf '%s' "$runs" | jq 'length')" -gt 0 ] && break
if [ "$waited" -ge "$GRACE" ]; then
echo "::notice title=Required workflow did not run::'$name' produced no run for $SHA within ${GRACE}s, so it does not apply to this commit (paths filters). Continuing."
runs=""
break
fi
sleep "$POLL"; waited=$((waited + POLL))
done
[ -z "$runs" ] && continue

# Newest run wins, so re-running a red suite can clear this gate.
run_id="$(printf '%s' "$runs" | jq -r '.[0].id')"
run_url="$(printf '%s' "$runs" | jq -r '.[0].html_url')"
echo "gating this release on $run_url"

waited=0
while :; do
read -r status conclusion <<<"$(gh api "repos/$REPO/actions/runs/$run_id" --jq '"\(.status) \(.conclusion // "-")"')"
if [ "$status" = "completed" ]; then
case "$conclusion" in
success|skipped)
echo "::notice title=Required workflow green::'$name' concluded $conclusion for $SHA."
;;
*)
echo "::error title=Required workflow not green::'$name' concluded '$conclusion' for $SHA. See $run_url"
not_green="$not_green '$name'"
;;
esac
break
fi
if [ "$waited" -ge "$TIMEOUT" ]; then
echo "::error title=Required workflow timed out::'$name' was still '$status' for $SHA after ${TIMEOUT}s. See $run_url"
not_green="$not_green '$name'"
break
fi
echo " '$name' is $status; waited ${waited}s of ${TIMEOUT}s"
sleep "$POLL"; waited=$((waited + POLL))
done
done <<EOF_NAMES
$names
EOF_NAMES

if [ -n "$not_green" ]; then
echo "::error title=Refusing to release::not green for $SHA:$not_green; refusing to tag or publish."
exit 1
fi
# Authenticate private-module fetches (GoReleaser's `go mod tidy` hook runs
# BEFORE the build and will fail to reach private deps otherwise). No-op for
# repos that don't pass GOPRIVATE.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ jobs:
uses: strongo/cicd/.github/workflows/release.yml@v1.18.0
with:
require_workflow_success: 'Go CI' # the workflow's `name:`, not its filename
# Several, comma-separated, are allowed and ALL must be green:
# require_workflow_success: 'Go CI, Integration tests'
# require_workflow_success_timeout_seconds: 1800 # optional, default 30 min
```

Expand All @@ -329,6 +331,7 @@ Behaviour, all of it fail-closed:
| Concluded `failure`, `cancelled`, `timed_out` | **refused before any tag is cut** |
| Still running | waited for, then refused on timeout |
| Name matches no workflow in the repo | **refused** — a typo must not quietly disable the gate |
| Several workflows named, any one not green | **refused**, and the error names every one that was not green |
| Workflow produced no run for this commit (`paths:` filtered it out) | proceeds, with a notice |

The guard waits, because your quality workflow usually starts on the same push
Expand Down
76 changes: 76 additions & 0 deletions release_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2452,3 +2452,79 @@ func TestReleaseJobDoesNotNarrowTheCallersPermissions(t *testing.T) {
}
}
}

// require_workflow_success accepts several workflow names, comma-separated,
// and EVERY one must be green. A repository with more than one quality
// workflow that could only name a single one would leave the others free to
// be red while the release proceeded.
func TestReleaseWorkflowGatesOnEveryNamedWorkflow(t *testing.T) {
script := releaseWorkflowRunBlock(t,
"Require the caller's quality workflow to be green before tagging")

// Stub `gh`: two workflows exist; each run's conclusion is keyed by id, so
// one can be green while the other is red.
stub := func(t *testing.T, greenID, redID string) string {
t.Helper()
dir := t.TempDir()
runs := func(id string) string {
return `[{"id":` + id + `,"status":"completed","conclusion":"x",` +
`"created_at":"2026-09-09T07:49:09Z","html_url":"https://example.test/run/` + id + `"}]`
}
body := "#!/usr/bin/env bash\nprev=\"\"\nfor arg in \"$@\"; do\n" +
" case \"$arg\" in\n" +
" repos/*/actions/workflows) printf '11\\tAlpha CI\\n22\\tBeta CI\\n'; exit 0 ;;\n" +
" repos/*/actions/workflows/11/runs*) cat <<'J'\n" + runs("111") + "\nJ\n exit 0 ;;\n" +
" repos/*/actions/workflows/22/runs*) cat <<'J'\n" + runs("222") + "\nJ\n exit 0 ;;\n" +
" repos/*/actions/runs/" + greenID + ") echo 'completed success'; exit 0 ;;\n" +
" repos/*/actions/runs/" + redID + ") echo 'completed failure'; exit 0 ;;\n" +
" esac\ndone\nexit 1\n"
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return dir
}
env := map[string]string{
"GH_TOKEN": "stub", "REPO": "acme/widget",
"SHA": "a913de4fd6455e35d2cf9b4f54c4bb5ed36bb4fe",
"WF_NAME": "Alpha CI, Beta CI", "TIMEOUT": "1",
}

t.Run("every named workflow green passes", func(t *testing.T) {
dir := stub(t, "111", "999") // 222 unmatched would exit 1, so make both green
body, err := os.ReadFile(filepath.Join(dir, "gh"))
if err != nil {
t.Fatal(err)
}
// Make the second run green too.
green := strings.Replace(string(body), "repos/*/actions/runs/999", "repos/*/actions/runs/222", 1)
green = strings.Replace(green, "echo 'completed failure'", "echo 'completed success'", 1)
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(green), 0o755); err != nil {
t.Fatal(err)
}
if out, err := runBash(dir, "PATH="+dir+":$PATH\n"+script, env); err != nil {
t.Fatalf("all-green must pass: %v\n%s", err, out)
}
})

// The whole point: a second named workflow being red must block, even
// though the first one passed.
t.Run("one red among several blocks and names it", func(t *testing.T) {
dir := stub(t, "111", "222") // Alpha green, Beta red
out, err := runBash(dir, "PATH="+dir+":$PATH\n"+script, env)
if err == nil {
t.Fatalf("a red workflow among several must fail the release:\n%s", out)
}
text := string(out)
if !strings.Contains(text, "'Beta CI'") {
t.Fatalf("failure must name the workflow that was not green:\n%s", text)
}
if !strings.Contains(text, "Refusing to release") {
t.Fatalf("failure must say the release is refused:\n%s", text)
}
// It must not stop at the first name: the green one is still reported,
// so one run shows the whole picture.
if !strings.Contains(text, "'Alpha CI' concluded success") {
t.Fatalf("every named workflow must be checked, not just up to the first failure:\n%s", text)
}
})
}