11name : Release
22
33on :
4+ # Traditional flow: push a bare tag `vX` (a human/PAT push triggers the gates
5+ # + build below). Do NOT create the release from the GitHub Releases UI — that
6+ # creates a published (immutable) release the build then can't attach to.
47 push :
58 tags :
69 - " v*"
10+ # UI button (Actions → Release → Run workflow). Pick the branch to release and
11+ # optionally a version; the tag is created only AFTER the gates pass, so a
12+ # failing gate never leaves an orphan immutable tag. The tag is pushed with
13+ # GITHUB_TOKEN, which does not re-trigger this workflow (no double run).
14+ workflow_dispatch :
15+ inputs :
16+ version :
17+ description : " Version without leading v (blank = auto CalVer YYYYMMDD.NN)"
18+ required : false
19+ type : string
720
821permissions :
922 contents : write
1023
1124jobs :
25+ # Resolve the version, tag and commit to release. On a tag push the tag already
26+ # exists; on dispatch we compute the version and validate the tag is free, but
27+ # DO NOT tag yet (the `tag` job below tags only after the gates pass).
28+ prepare :
29+ runs-on : ubuntu-latest
30+ outputs :
31+ version : ${{ steps.resolve.outputs.version }}
32+ tag : ${{ steps.resolve.outputs.tag }}
33+ sha : ${{ steps.resolve.outputs.sha }}
34+ steps :
35+ - uses : actions/checkout@v7
36+ with :
37+ fetch-depth : 0
38+ - id : resolve
39+ # Untrusted dispatch input passed via env (never interpolated into the
40+ # script) to avoid shell injection.
41+ env :
42+ EVENT_NAME : ${{ github.event_name }}
43+ VERSION_INPUT : ${{ inputs.version }}
44+ run : |
45+ set -euo pipefail
46+ if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
47+ VERSION="${VERSION_INPUT#v}"
48+ if [ -z "$VERSION" ]; then
49+ # Auto CalVer: YYYYMMDD.<next build number for today>.
50+ DATE="$(date -u +%Y%m%d)"
51+ N="$(git tag -l "v${DATE}.*" | wc -l | tr -d ' ')"
52+ VERSION="${DATE}.$(printf '%02d' "$((N + 1))")"
53+ fi
54+ # Reject anything that is not a plain version token (defense in depth:
55+ # this value later reaches git/docker command lines).
56+ case "$VERSION" in
57+ *[!0-9A-Za-z.+-]*|"")
58+ echo "::error::invalid version '${VERSION}': use only [0-9A-Za-z.+-]"; exit 1 ;;
59+ esac
60+ TAG="v${VERSION}"
61+ if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
62+ echo "::error::tag ${TAG} already exists — releases are immutable, bump the build number"
63+ exit 1
64+ fi
65+ SHA="$(git rev-parse HEAD)"
66+ else
67+ TAG="${GITHUB_REF_NAME}"
68+ VERSION="${TAG#v}"
69+ SHA="${GITHUB_SHA}"
70+ fi
71+ echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
72+ echo "tag=${TAG}" >>"$GITHUB_OUTPUT"
73+ echo "sha=${SHA}" >>"$GITHUB_OUTPUT"
74+ echo "Releasing ${TAG} (version ${VERSION}) from ${SHA}" >>"$GITHUB_STEP_SUMMARY"
75+
1276 # Hermetic gates — the same task targets CI runs on every push/PR.
1377 # The release is blocked unless these pass.
1478 gate :
1579 runs-on : ubuntu-latest
80+ needs : prepare
1681 steps :
1782 - uses : actions/checkout@v7
1883 with :
84+ ref : ${{ needs.prepare.outputs.sha }}
1985 submodules : recursive
2086 - uses : actions/setup-go@v6
2187 with :
@@ -41,9 +107,11 @@ jobs:
41107 # Runtime lane (real Docker containers). ubuntu-latest ships Docker.
42108 gate-runtime :
43109 runs-on : ubuntu-latest
110+ needs : prepare
44111 steps :
45112 - uses : actions/checkout@v7
46113 with :
114+ ref : ${{ needs.prepare.outputs.sha }}
47115 submodules : recursive
48116 - uses : actions/setup-go@v6
49117 with :
@@ -71,9 +139,36 @@ jobs:
71139 - if : always()
72140 run : task clean
73141
142+ # Create the tag ONLY on the dispatch path and ONLY after the gates pass, so a
143+ # failing gate never leaves an orphan (immutable) tag. On a tag push this job is
144+ # skipped (the tag already exists). The GITHUB_TOKEN push does not re-trigger the
145+ # workflow, so there is no second run.
146+ tag :
147+ runs-on : ubuntu-latest
148+ needs : [prepare, gate, gate-runtime]
149+ if : github.event_name == 'workflow_dispatch'
150+ steps :
151+ - uses : actions/checkout@v7
152+ with :
153+ ref : ${{ needs.prepare.outputs.sha }}
154+ fetch-depth : 0
155+ - run : |
156+ set -euo pipefail
157+ git config user.name "github-actions[bot]"
158+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
159+ git tag -a "${{ needs.prepare.outputs.tag }}" -m "Release ${{ needs.prepare.outputs.version }}"
160+ git push origin "${{ needs.prepare.outputs.tag }}"
161+
74162 goreleaser :
75163 runs-on : ubuntu-latest
76- needs : [gate, gate-runtime]
164+ needs : [prepare, gate, gate-runtime, tag]
165+ # `tag` is skipped on the push path, so gate on explicit success/skip rather
166+ # than the default all-succeeded semantics.
167+ if : |
168+ always() &&
169+ needs.gate.result == 'success' &&
170+ needs.gate-runtime.result == 'success' &&
171+ (needs.tag.result == 'success' || needs.tag.result == 'skipped')
77172 # Scoped least-privilege for the publishing job:
78173 # contents: write -> create the draft GitHub Release
79174 # packages: write -> push images to GHCR (ghcr.io/spin-stack/devcontainer-cli)
85180 steps :
86181 - uses : actions/checkout@v7
87182 with :
183+ # Build at the tag so GoReleaser runs in release (not snapshot) mode.
184+ ref : ${{ needs.prepare.outputs.tag }}
88185 fetch-depth : 0
89186 - uses : actions/setup-go@v6
90187 with :
@@ -117,11 +214,10 @@ jobs:
117214 GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
118215
119216 # --- Post-publish: smoke test, sign, and record the image digest. ---
120- # Derive the goreleaser version (tag without the leading "v").
121217 - name : Compute image ref
122218 id : img
123219 run : |
124- VERSION="${GITHUB_REF_NAME#v }"
220+ VERSION="${{ needs.prepare.outputs.version } }"
125221 IMAGE="ghcr.io/spin-stack/devcontainer-cli:${VERSION}"
126222 echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
127223 echo "image=${IMAGE}" >>"$GITHUB_OUTPUT"
@@ -164,11 +260,12 @@ jobs:
164260 # Non-gating: perf/distribution metrics recorded per release, never blocks.
165261 metrics :
166262 runs-on : ubuntu-latest
167- needs : [goreleaser]
263+ needs : [prepare, goreleaser]
168264 continue-on-error : true
169265 steps :
170266 - uses : actions/checkout@v7
171267 with :
268+ ref : ${{ needs.prepare.outputs.tag }}
172269 submodules : recursive
173270 - uses : actions/setup-go@v6
174271 with :
0 commit comments