Skip to content

Commit 0edebc1

Browse files
johnscaclaude
andcommitted
chore: share the release workflow and cut releases here (ENG-9233)
[ENG-9233](https://stacklet.atlassian.net/browse/ENG-9233) What ---- - Adds `cut-release.yml`, a `workflow_call` workflow that works out the next version from the tag history and publishes a GitHub release for it. It is the release workflow from `terraform-azure-stacklet-cost-setup`, lifted so the other five module repositories call it instead of copying it. - Adds `release.yml`, the dispatch caller, so this repository cuts its own releases down the same path the module repositories will. - Documents `cut-release` in the README beside `run-script`, with the caller block to copy. Why ---- Five Terraform module repositories have no release workflow, and the sixth already had this one. Copying it five more times leaves six sets of the same guards to keep in step. The guards are the part worth sharing, because a published release is immutable and a slip in one cannot be taken back. The lift departs from the original in three places. 1. The `bump` input is a string rather than a choice, because `workflow_call` has no choice type. A new step rejects anything but patch, minor, or major. Without it the tag action reads an unrecognised `force_bump` as no bump, falls back to the commit messages, and publishes a version nobody asked for. 2. The branch guard compares against the calling repository's default branch rather than the literal `main`, so it holds for a caller whose default branch has another name. 3. The version job gains the Harden Runner step `run-script` already uses. That job fetches and runs a third-party action, while the publish job runs nothing but `gh`. The caller keeps the `workflow_dispatch` trigger and the `concurrency` group. Both matter only on the workflow a person dispatches, and a called workflow cannot serialise its caller. Testing ---- - [x] `just lint` passes. - [x] `uvx zizmor@1.23.1 -v --gh-token=... .github/workflows/` reports no findings, with the five online audits scheduled rather than skipped. - [x] Each action pin resolves to a commit via `gh api repos/<o>/<r>/commits/<sha>`. - [ ] After merge, dispatch Release on main with dry run checked, and confirm the summary reports v0.0.0 as previous and v1.0.0 as new. - [ ] Dispatch Release with a major bump and confirm v1.0.0 is published against the dispatched commit. Docs ---- The README gains a `cut-release` section under Reusable workflows. The pinning note at the top no longer says this repository publishes no release tags, because after this it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f9c9366 commit 0edebc1

3 files changed

Lines changed: 309 additions & 3 deletions

File tree

‎.github/workflows/cut-release.yml‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Cuts a tag and a GitHub release for the repository that calls this workflow.
2+
# The caller owns the `workflow_dispatch` trigger and the `concurrency` group,
3+
# which only mean anything on the workflow a person dispatches. The README
4+
# carries the caller block to copy.
5+
name: "Cut release"
6+
7+
on:
8+
workflow_call:
9+
inputs:
10+
bump:
11+
# `workflow_call` has no choice type, so only the caller's choice list
12+
# narrows this. The version job checks the value before acting on it.
13+
description: "Which part of the version to raise. One of patch, minor, or major."
14+
required: true
15+
type: string
16+
dry_run:
17+
description: "Work out the next version and stop without publishing"
18+
required: false
19+
type: boolean
20+
default: false
21+
secrets:
22+
STEP_SECURITY_API_KEY:
23+
required: false
24+
25+
permissions: {}
26+
27+
jobs:
28+
# Split from `publish` so the third-party action never runs under a token
29+
# that can write refs. This job reads; only the job below can publish.
30+
version:
31+
runs-on: ubuntu-latest
32+
permissions:
33+
contents: read
34+
outputs:
35+
new_tag: ${{ steps.version.outputs.new_tag }}
36+
changelog: ${{ steps.version.outputs.changelog }}
37+
env:
38+
HARDEN_RUNNER: ${{ secrets.STEP_SECURITY_API_KEY != '' }} # zizmor: ignore[secrets-outside-env]
39+
steps:
40+
# First, ahead of the guards below, because egress policy only covers
41+
# what runs after it. This job fetches and runs a third-party action.
42+
# The publish job holds the write token but runs nothing but `gh`.
43+
- name: Harden Runner
44+
if: env.HARDEN_RUNNER == 'true'
45+
uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1
46+
with:
47+
use-policy-store: true
48+
api-key: ${{ secrets.STEP_SECURITY_API_KEY }} # zizmor: ignore[secrets-outside-env]
49+
50+
# The tag action reads an unrecognised `force_bump` as no bump at all and
51+
# falls back to the commit messages, so without this a typo publishes a
52+
# version nobody asked for.
53+
- name: Check the requested bump
54+
if: ${{ !contains(fromJSON('["patch", "minor", "major"]'), inputs.bump) }}
55+
env:
56+
BUMP: ${{ inputs.bump }}
57+
run: |
58+
echo "The bump has to be patch, minor, or major. This run asked for" >&2
59+
echo "'${BUMP}'." >&2
60+
exit 1
61+
62+
# workflow_dispatch takes a ref, so the caller can start against any
63+
# branch. A release cut from a topic branch names code that never merged,
64+
# and immutability makes that permanent. The branch comes from the
65+
# calling repository, so this holds for a caller whose default branch has
66+
# another name.
67+
- name: Refuse to release from anywhere but the default branch
68+
if: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}
69+
env:
70+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
71+
run: |
72+
echo "Releases are cut from ${DEFAULT_BRANCH}. This run targeted ${GITHUB_REF}." >&2
73+
exit 1
74+
75+
- name: Checkout
76+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
77+
with:
78+
# The next version comes from the tag history, and the action walks
79+
# the local checkout when the compare API is unavailable.
80+
fetch-depth: 0
81+
persist-credentials: false
82+
83+
# Works out the version and stops. `push: false` leaves every ref write to
84+
# the publish job, so one step owns the tag. Splitting them lets a failure
85+
# halfway through leave a bare tag with no release, which is mutable and
86+
# in the way of the retry.
87+
- name: Work out the next version
88+
id: version
89+
uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7
90+
with:
91+
github_token: ${{ github.token }}
92+
force_bump: ${{ inputs.bump }}
93+
push: false
94+
95+
- name: Report the version
96+
env:
97+
PREVIOUS_TAG: ${{ steps.version.outputs.previous_tag }}
98+
NEW_TAG: ${{ steps.version.outputs.new_tag }}
99+
DRY_RUN: ${{ inputs.dry_run }}
100+
run: |
101+
{
102+
echo "| | |"
103+
echo "|---|---|"
104+
echo "| Previous | ${PREVIOUS_TAG} |"
105+
echo "| New | ${NEW_TAG} |"
106+
echo "| Dry run | ${DRY_RUN} |"
107+
} >> "${GITHUB_STEP_SUMMARY}"
108+
109+
# The tag action reports v0.0.0 as the previous tag when no tag exists, so
110+
# this fires only on the first release. A patch bump from that baseline
111+
# lands v0.0.1 permanently, so the input description is not enough.
112+
- name: Require the first release to be major
113+
if: ${{ steps.version.outputs.previous_tag == 'v0.0.0' && inputs.bump != 'major' }}
114+
env:
115+
BUMP: ${{ inputs.bump }}
116+
run: |
117+
echo "This repository has no releases yet, so the first one has to be a" >&2
118+
echo "major bump to land v1.0.0. This run asked for ${BUMP}." >&2
119+
exit 1
120+
121+
publish:
122+
needs: version
123+
# `success()` is implicit on a job `if:` that names no status function, and
124+
# it keeps the version job's three guards binding. Spelling it out matters
125+
# because adding `always()` or a `failure()` branch later detaches them
126+
# silently, and that fails open: `new_tag` is already set by the time the
127+
# first-release guard runs.
128+
#
129+
# `inputs.dry_run`, not `github.event.inputs.dry_run`. The former keeps a
130+
# boolean a boolean. The latter yields the string "false", which is truthy,
131+
# so the negation is always false and this job never runs.
132+
if: ${{ success() && !inputs.dry_run }}
133+
runs-on: ubuntu-latest
134+
permissions:
135+
contents: write
136+
steps:
137+
# Re-running stays on offer for 30 days, and the two modes go wrong in
138+
# opposite directions. "Re-run all jobs" re-runs the version job, and
139+
# because `force_bump` overrides the no-new-commits early return it works
140+
# out the version after this one and publishes that too. "Re-run failed
141+
# jobs" replays the succeeded version job's outputs and republishes the
142+
# same tag. run_attempt catches both. Retry by dispatching again, which
143+
# reads the same latest tag and so arrives at the same number.
144+
- name: Refuse a re-run
145+
if: ${{ github.run_attempt != 1 }}
146+
run: |
147+
echo "Attempt ${GITHUB_RUN_ATTEMPT} of this run. Re-running would publish the" >&2
148+
echo "version after the one this run was for. Dispatch the workflow again." >&2
149+
exit 1
150+
151+
# No checkout: the release is cut against a commit SHA and carries no
152+
# assets. That also keeps this job, the one holding `contents: write`,
153+
# free of third-party actions, which is why it has no Harden Runner step.
154+
#
155+
# The checkout was doing a second job though, which is why --repo appears
156+
# below. `gh` resolves the repository from --repo, then GH_REPO, then the
157+
# git remotes of the working directory. It never reads GITHUB_REPOSITORY,
158+
# so without one of those it exits 1 with "not a git repository". `gh api`
159+
# is unaffected because its path is literal.
160+
#
161+
# Publishing the release locks the tag, so the tag is created here rather
162+
# than by the version job.
163+
#
164+
# zizmor rejects a release action here as work the runner already does,
165+
# which is why this is the CLI rather than ncipollo/release-action.
166+
- name: Publish the release
167+
env:
168+
GH_TOKEN: ${{ github.token }}
169+
TAG: ${{ needs.version.outputs.new_tag }}
170+
COMMIT: ${{ github.sha }}
171+
NOTES: ${{ needs.version.outputs.changelog }}
172+
run: |
173+
set -euo pipefail
174+
if [ -z "${TAG}" ]; then
175+
echo "The version job worked out no tag, so there is nothing to publish." >&2
176+
echo "That should not happen on the default branch with an explicit bump." >&2
177+
exit 1
178+
fi
179+
# The version job derives the number by incrementing the highest tag,
180+
# so the name was free when it ran. A tag pushed from anywhere else
181+
# since then still collides, and `gh release create` answers a
182+
# collision by releasing the existing tag and ignoring --target, which
183+
# publishes an immutable release for a commit nobody chose. This
184+
# narrows the window rather than closing it.
185+
#
186+
# `git/matching-refs` answers "no such tag" with 200 and an empty
187+
# array, so a non-zero exit means the check itself failed rather than
188+
# the name being free. `git/ref/tags/...` answers 404, which shares an
189+
# exit status with a 401, a 5xx, or a dropped connection, and would
190+
# leave the guard passing whenever the API misbehaved. Keeping the
191+
# call out of an `if` matters for the same reason: errexit is
192+
# suppressed for a condition. matching-refs is a prefix match, hence
193+
# the exact-ref filter.
194+
matches=$(gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/${TAG}" \
195+
--jq '[.[] | select(.ref == "refs/tags/" + env.TAG)] | length')
196+
if [ "${matches}" != "0" ]; then
197+
echo "Tag ${TAG} already exists, so this run would publish a release over" >&2
198+
echo "a tag it did not create. Refusing." >&2
199+
exit 1
200+
fi
201+
gh release create "${TAG}" \
202+
--repo "${GITHUB_REPOSITORY}" \
203+
--target "${COMMIT}" \
204+
--title "${TAG}" \
205+
--notes "${NOTES}"

‎.github/workflows/release.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump:
7+
description: Which part of the version to raise. The first release has to be major, because the count starts from 0.0.0.
8+
required: true
9+
type: choice
10+
default: patch
11+
options:
12+
- patch
13+
- minor
14+
- major
15+
dry_run:
16+
description: Work out the next version and stop without publishing
17+
required: false
18+
type: boolean
19+
default: false
20+
21+
# Two runs would otherwise read the same latest tag and claim the same version.
22+
# The second fails at the release step rather than corrupting anything, but
23+
# serialising stops it happening at all. Only one run queues behind the active
24+
# one, so a third dispatch drops the second.
25+
#
26+
# This belongs to the dispatched workflow rather than the called one, so every
27+
# caller carries it. Copy this file rather than writing one.
28+
concurrency: release
29+
30+
permissions: {}
31+
32+
jobs:
33+
release:
34+
uses: ./.github/workflows/cut-release.yml
35+
# The ceiling for the called workflow, not what each job gets. It narrows
36+
# this to read for the job running the third-party version action, and only
37+
# its publish job keeps write.
38+
permissions:
39+
contents: write
40+
secrets:
41+
STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }}
42+
with:
43+
bump: ${{ inputs.bump }}
44+
dry_run: ${{ inputs.dry_run }}

‎README.md‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
Reusable GitHub Actions and workflows shared across Stacklet repositories.
44

5-
Each tool lives in its own top-level directory with an `action.yml` and a `README.md`. Reference one by path:
5+
Each tool lives in its own top-level directory with an `action.yml` and a `README.md`.
6+
Reference one by path:
67

78
```yaml
89
- uses: stacklet/github-tooling/<tool>@<commit-sha>
910
```
1011
11-
Pin by commit SHA. This repository publishes no release tags, so a SHA is the only reference that cannot move.
12+
Pin by commit SHA. This repository cuts releases, but it cuts them by hand and most
13+
commits never get one, so a SHA is what names the commit you meant.
1214
1315
## Tools
1416
@@ -20,7 +22,9 @@ Pin by commit SHA. This repository publishes no release tags, so a SHA is the on
2022

2123
1. Create a directory named for the tool.
2224
2. Add an `action.yml` and a `README.md` covering usage, inputs, and outputs.
23-
3. Keep any non-trivial logic in its own script beside the `action.yml`, so that a linter reads it and a test can run it without a runner. Prefer Python over shell for anything that parses or compares.
25+
3. Keep any non-trivial logic in its own script beside the `action.yml`, so that a
26+
linter reads it and a test can run it without a runner. Prefer Python over shell for
27+
anything that parses or compares.
2428
4. Add a row to the table above.
2529

2630
## Reusable workflows
@@ -43,3 +47,56 @@ Run a set of commands on the repository, with the required tools installed via `
4347
run: |
4448
just lint
4549
```
50+
51+
### `cut-release`
52+
53+
Works out the next version from the tag history and publishes a GitHub release for it,
54+
on a manual dispatch. The caller owns the `workflow_dispatch` trigger, because that is
55+
the workflow a person picks in the Actions tab, and the `concurrency` group, because a
56+
called workflow cannot serialise its caller. Copy this block whole rather than writing
57+
one:
58+
59+
```yaml
60+
name: Release
61+
62+
on:
63+
workflow_dispatch:
64+
inputs:
65+
bump:
66+
description: Which part of the version to raise. The first release has to be major, because the count starts from 0.0.0.
67+
required: true
68+
type: choice
69+
default: patch
70+
options:
71+
- patch
72+
- minor
73+
- major
74+
dry_run:
75+
description: Work out the next version and stop without publishing
76+
required: false
77+
type: boolean
78+
default: false
79+
80+
concurrency: release
81+
82+
permissions: {}
83+
84+
jobs:
85+
release:
86+
uses: stacklet/github-tooling/.github/workflows/cut-release.yml@<commit-sha>
87+
permissions:
88+
contents: write
89+
secrets:
90+
STEP_SECURITY_API_KEY: ${{ secrets.STEP_SECURITY_API_KEY }}
91+
with:
92+
bump: ${{ inputs.bump }}
93+
dry_run: ${{ inputs.dry_run }}
94+
```
95+
96+
The workflow refuses to run against anything but the calling repository's default
97+
branch, refuses a re-run of a run that already published, and refuses to publish over a
98+
tag it did not create. The first release has to be a major bump, because the version
99+
walk starts from 0.0.0 and a patch from there spends `v0.0.1` permanently.
100+
101+
`STEP_SECURITY_API_KEY` is optional. Without it the Harden Runner step is skipped and
102+
the rest runs unchanged.

0 commit comments

Comments
 (0)