Skip to content

Commit c929b62

Browse files
aledbfclaude
andcommitted
ci(release): add a workflow_dispatch UI button that tags after the gates pass
Releases could only be cut by pushing a bare `v*` tag; there was no safe way to trigger one from the Actions UI. Add a workflow_dispatch button (pick a branch, optional version — blank auto-computes CalVer YYYYMMDD.NN) that: - resolves the version and asserts the tag is free (releases are immutable), - runs the full gate + gate-runtime suites against the chosen commit FIRST, - only then creates and pushes the tag (so a failing gate never leaves an orphan immutable tag), and - builds/releases GoReleaser at that tag. The tag is pushed with GITHUB_TOKEN, which does not re-trigger the workflow, so there is no duplicate run. The bare-tag push path is unchanged (its tag already exists, so the tag job is skipped). Untrusted dispatch input is passed via env and charset-validated to avoid shell injection; post-publish steps derive the version from the resolved output instead of GITHUB_REF_NAME (which is the branch on dispatch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ea0aeb2 commit c929b62

1 file changed

Lines changed: 101 additions & 4 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,87 @@
11
name: Release
22

33
on:
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

821
permissions:
922
contents: write
1023

1124
jobs:
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)
@@ -85,6 +180,8 @@ jobs:
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

Comments
 (0)