Skip to content

Commit b67d48b

Browse files
fylornclaude
andcommitted
ci: release workflow — Docker images + Helm chart on tag push
Fires only on `v*.*.*` tag pushes. Three parallel jobs: - `build-images` (matrix over server + web) — buildx multi-arch `linux/amd64,linux/arm64`, publishes to ghcr.io/<owner>/think-watch-* with the tag's version. Stable releases (not 0.x / -rc / -beta / -alpha) also receive a `:latest` tag; pre-releases stay version- pinned so operators chasing `:latest` never get surprised by a beta. - `package-helm` — `helm package` against the bundled chart, forcing both `version` and `appVersion` to match the tag. Uploads the .tgz as a workflow artifact for the release step. - `create-release` — depends on the above; extracts the CHANGELOG section for the tagged version (awk so we don't pull in another dep), prepends a copy-paste image + helm-install block, and attaches the .tgz to a GitHub Release. Fails loud if CHANGELOG has no matching entry — silent "release without notes" is worse than a failed tag. Tests aren't re-run here — the tagged commit must already be green on `ci.yml`. The repo's GITHUB_TOKEN needs read-and-write workflow permissions (Settings → Actions → General) for the release + GHCR push to succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c8f319 commit b67d48b

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
name: Release
2+
3+
# Triggered ONLY on annotated tag push matching `v*.*.*`. The
4+
# expected sequence is:
5+
#
6+
# 1. Commit + push CHANGELOG bump on main (manual).
7+
# 2. `git tag -a vX.Y.Z -m "..."` against that commit (manual).
8+
# 3. `git push origin vX.Y.Z` — this workflow fires.
9+
#
10+
# We do NOT re-run unit tests here; the tag must already point at a
11+
# commit that's green on the `ci.yml` workflow. Re-testing would
12+
# double the wall-clock for tag bumps without catching anything CI
13+
# wouldn't.
14+
on:
15+
push:
16+
tags: ['v*.*.*']
17+
18+
# The GITHUB_TOKEN needs explicit write scopes for the publish steps —
19+
# the default is read-only. `packages: write` is for ghcr.io; `contents:
20+
# write` is for the Release entry.
21+
permissions:
22+
contents: write
23+
packages: write
24+
25+
jobs:
26+
# -------------------------------------------------------------------
27+
# Docker images — server + web in parallel via matrix
28+
# -------------------------------------------------------------------
29+
build-images:
30+
name: Image · ${{ matrix.component }}
31+
runs-on: ubuntu-latest
32+
strategy:
33+
matrix:
34+
component: [server, web]
35+
steps:
36+
- uses: actions/checkout@v4
37+
38+
- name: Reclaim runner disk
39+
# `Dockerfile.server` links 40+ test binaries + ships a stripped
40+
# release build; the default ubuntu-latest ~14 GiB freespace is
41+
# not enough. Mirror what ci.yml's rust job does — drop the
42+
# preinstalled CodeQL / dotnet / Android toolchains before
43+
# buildx allocates layers.
44+
run: |
45+
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
46+
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
47+
df -h /
48+
49+
- name: Set up QEMU
50+
uses: docker/setup-qemu-action@v3
51+
52+
- name: Set up Buildx
53+
uses: docker/setup-buildx-action@v3
54+
55+
- name: Log in to GHCR
56+
uses: docker/login-action@v3
57+
with:
58+
registry: ghcr.io
59+
username: ${{ github.actor }}
60+
password: ${{ secrets.GITHUB_TOKEN }}
61+
62+
- name: Resolve version + image metadata
63+
id: meta
64+
run: |
65+
set -eu
66+
VERSION="${GITHUB_REF_NAME#v}"
67+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
68+
# Lowercase repo owner — GHCR rejects mixed case. This
69+
# matches the default in the project's Helm values
70+
# (`ghcr.io/thinkwatch/...`) so a tag bump publishes to the
71+
# repo Helm already points at.
72+
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
73+
echo "image=ghcr.io/$OWNER/think-watch-${{ matrix.component }}" >> "$GITHUB_OUTPUT"
74+
# Tag `:latest` ONLY for stable releases — anything that
75+
# looks like a pre-release (`0.x.y`, `-rc`, `-beta`, `-alpha`)
76+
# stays version-pinned. Operators chase `:latest` at their
77+
# own risk; betas don't get to surprise them.
78+
PRERELEASE=false
79+
case "$GITHUB_REF_NAME" in
80+
v0.*|*-rc.*|*-beta.*|*-alpha.*) PRERELEASE=true ;;
81+
esac
82+
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
83+
84+
- name: Build & push image
85+
uses: docker/build-push-action@v6
86+
with:
87+
context: .
88+
file: deploy/docker/Dockerfile.${{ matrix.component }}
89+
platforms: linux/amd64,linux/arm64
90+
push: true
91+
# Always publish the version tag; only publish `:latest`
92+
# for stable releases. The `if:` on the latter would
93+
# require splitting the step — `cat <<EOF` form keeps it
94+
# in one place and lets buildx skip the empty tag silently.
95+
tags: |
96+
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
97+
${{ steps.meta.outputs.prerelease == 'false' && format('{0}:latest', steps.meta.outputs.image) || '' }}
98+
# GHCR-side OCI labels — what `helm install` / `docker scout`
99+
# surface in cluster inventories. Pull the source / version /
100+
# license / authors from the tag rather than the Dockerfile
101+
# so they stay in lockstep with the GitHub Release.
102+
labels: |
103+
org.opencontainers.image.source=https://github.com/${{ github.repository }}
104+
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
105+
org.opencontainers.image.revision=${{ github.sha }}
106+
org.opencontainers.image.licenses=BUSL-1.1
107+
cache-from: type=gha,scope=${{ matrix.component }}
108+
cache-to: type=gha,scope=${{ matrix.component }},mode=max
109+
110+
# -------------------------------------------------------------------
111+
# Helm chart — packaged with the same version as the tag
112+
# -------------------------------------------------------------------
113+
package-helm:
114+
name: Helm chart
115+
runs-on: ubuntu-latest
116+
steps:
117+
- uses: actions/checkout@v4
118+
- uses: azure/setup-helm@v4
119+
with:
120+
version: latest
121+
- name: Resolve version
122+
id: meta
123+
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
124+
- name: Package chart
125+
# `--app-version` is the image tag the chart's deployment
126+
# templates reach for by default (via `tw.imageTag`). Pinning
127+
# it here keeps a fresh `helm install think-watch-X.Y.Z.tgz`
128+
# pointing at the matching image bundle from the build-images
129+
# job above.
130+
run: |
131+
helm package deploy/helm/think-watch \
132+
--version "${{ steps.meta.outputs.version }}" \
133+
--app-version "${{ steps.meta.outputs.version }}" \
134+
--destination .
135+
- name: Upload chart artifact
136+
uses: actions/upload-artifact@v4
137+
with:
138+
name: helm-chart
139+
path: think-watch-*.tgz
140+
retention-days: 30
141+
142+
# -------------------------------------------------------------------
143+
# GitHub Release — attaches the Helm chart, links the images
144+
# -------------------------------------------------------------------
145+
create-release:
146+
name: GitHub Release
147+
needs: [build-images, package-helm]
148+
runs-on: ubuntu-latest
149+
steps:
150+
- uses: actions/checkout@v4
151+
- name: Download Helm chart artifact
152+
uses: actions/download-artifact@v4
153+
with:
154+
name: helm-chart
155+
path: .
156+
- name: Extract release notes from CHANGELOG
157+
id: notes
158+
# awk-based section extraction so we don't pull in another
159+
# dep just for `## [X.Y.Z]` markdown chunking. If the
160+
# CHANGELOG doesn't carry an entry for this tag, fail loudly
161+
# — releasing without notes hides what changed from operators.
162+
run: |
163+
set -eu
164+
VERSION="${GITHUB_REF_NAME#v}"
165+
awk -v ver="$VERSION" '
166+
/^## \[/{
167+
if ($0 ~ "^## \\[" ver "\\]") { found=1; next }
168+
else if (found) { exit }
169+
}
170+
found
171+
' CHANGELOG.md > release-notes.md
172+
if [ ! -s release-notes.md ]; then
173+
echo "::error::No CHANGELOG entry for $VERSION — refusing to publish a release without notes."
174+
exit 1
175+
fi
176+
# Add a leading reference to the GHCR images so users on the
177+
# Release page can copy-paste them without hunting through
178+
# the chart values.
179+
OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
180+
{
181+
echo "## Images"
182+
echo ""
183+
echo "- \`ghcr.io/$OWNER/think-watch-server:$VERSION\`"
184+
echo "- \`ghcr.io/$OWNER/think-watch-web:$VERSION\`"
185+
echo ""
186+
echo "## Helm"
187+
echo ""
188+
echo '```bash'
189+
echo "helm install think-watch ./think-watch-$VERSION.tgz \\"
190+
echo " --namespace thinkwatch --create-namespace"
191+
echo '```'
192+
echo ""
193+
echo "---"
194+
echo ""
195+
cat release-notes.md
196+
} > release-body.md
197+
- name: Detect pre-release flag
198+
id: meta
199+
run: |
200+
PRERELEASE=false
201+
case "$GITHUB_REF_NAME" in
202+
v0.*|*-rc.*|*-beta.*|*-alpha.*) PRERELEASE=true ;;
203+
esac
204+
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
205+
- name: Create release
206+
uses: softprops/action-gh-release@v2
207+
with:
208+
tag_name: ${{ github.ref_name }}
209+
name: ThinkWatch ${{ github.ref_name }}
210+
body_path: release-body.md
211+
prerelease: ${{ steps.meta.outputs.prerelease }}
212+
files: |
213+
think-watch-*.tgz

0 commit comments

Comments
 (0)