-
Notifications
You must be signed in to change notification settings - Fork 0
269 lines (248 loc) · 12.3 KB
/
Copy pathrelease.yml
File metadata and controls
269 lines (248 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# Publishing is a consequence of tagging, not a checklist someone remembers.
#
# Before this existed, releasing meant running the commands in CONTRIBUTING by
# hand — so images 0.1.10 through 0.1.14 were built locally, imported straight
# into k3s, and never published. GHCR sat on 0.1.9 while the README told strangers
# to install `latest`, and nothing anywhere said the two disagreed.
name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to publish (without the leading v), e.g. 0.1.15"
required: true
permissions:
# write, because this workflow publishes the GitHub Release too. A release page
# created by hand is the same bug §4.17 removed from the artefacts: a publishing
# step that survives only as long as someone remembers it.
contents: write
packages: write
jobs:
release:
name: Publish image & chart to GHCR
runs-on: ubuntu-latest
# The first attempt spent 25 minutes emulating an arm64 frontend build before
# dying. With the builder stages running natively this job takes a few minutes,
# so anything approaching this limit means something is wrong again — fail
# while that is still obvious rather than burning half an hour first.
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
# One version for the whole release. Everything below reads $VERSION;
# nothing re-derives it, so there is no second place to drift.
- name: Resolve version
id: v
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${GITHUB_REF_NAME#v}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing $VERSION"
# A tag that disagrees with the chart means one of the two is a lie. Stop
# rather than publish it and find out later which one.
- name: Chart version must match the tag
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
CHART=$(grep '^version:' deploy/helm/matrixctrl/Chart.yaml | awk '{print $2}')
APP=$(grep '^appVersion:' deploy/helm/matrixctrl/Chart.yaml | awk '{print $2}' | tr -d '"')
echo "tag=$VERSION chart=$CHART appVersion=$APP"
[ "$CHART" = "$VERSION" ] || { echo "::error::Chart.yaml version ($CHART) != tag ($VERSION)"; exit 1; }
[ "$APP" = "$VERSION" ] || { echo "::error::Chart.yaml appVersion ($APP) != tag ($VERSION)"; exit 1; }
# The README deliberately does not pin a chart version — Helm resolves the
# newest, so the quickstart cannot go stale and there is nothing to remember.
# This guard is only a backstop: if someone ever writes a concrete version
# into the docs, it has to be the one being released, or the docs start lying
# again the moment the next version ships.
#
# No pinned version is the expected case and passes.
- name: Any version pinned in the docs must match the tag
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
FOUND=$(grep -ohE '\-\-version [0-9]+\.[0-9]+\.[0-9]+' README.md docs/*.md 2>/dev/null | awk '{print $2}' | sort -u)
if [ -z "$FOUND" ]; then
echo "No hardcoded chart version in the docs — nothing to drift."
exit 0
fi
echo "Found pinned versions: $FOUND"
for v in $FOUND; do
[ "$v" = "$VERSION" ] || { echo "::error::docs pin --version $v but the tag is $VERSION"; exit 1; }
done
# Checked here, before anything is published, rather than at the end where
# the artefacts would already be in GHCR and a missing entry could only be
# reported after the fact.
- name: CHANGELOG must have a section for this version
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
awk -v v="## [$VERSION]" '
index($0, v) == 1 { found = 1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::CHANGELOG.md has no '## [$VERSION]' section — write it before tagging"
exit 1
fi
echo "Release notes ($(wc -l < release-notes.md) lines):"
cat release-notes.md
# The embedded frontend is committed, and a stale copy has shipped before
# (see BACKLOG P2-2). Rebuild it here so the released image cannot embed an
# older UI than the source it was built from.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build frontend
run: |
cd web && npm ci && npm run build
cd .. && rm -rf cmd/matrixctrl/dist && cp -r web/dist cmd/matrixctrl/dist
# No setup-qemu: nothing is emulated any more.
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# amd64 only, for now.
#
# Two release attempts failed in this step. The first took 25 minutes —
# buildx was emulating the frontend build for arm64, fixed in the Dockerfile
# by building the builder stages natively. The second failed in 4 minutes,
# so emulation is no longer the bottleneck but something else in the arm64
# path still breaks: the runtime stage's `apk add` runs under QEMU, and that
# is exactly where a local reproduction died.
#
# A release that publishes nothing is worth less than an amd64 release that
# publishes, so arm64 is dropped rather than debugged further under a tag.
# The proper fix is a native arm64 runner (ubuntu-24.04-arm, free for public
# repos) building each architecture on its own hardware and merging the
# manifests — no emulation anywhere. Tracked in BACKLOG.
#
# This attempt doubles as the experiment that tells the two candidate causes
# apart: if amd64 alone publishes, arm64/QEMU was the problem; if it still
# fails, the Actions token cannot write to these pre-existing packages.
# Build and push are deliberately separate steps.
#
# Three attempts failed inside a combined "Build & push image", and job logs
# need a token the agent does not have — so "which half broke" was unknowable
# from the API, and each guess cost a full release cycle. Split, the step
# names themselves carry the diagnosis: a failure in "Build image" is the
# Dockerfile, a failure in "Push image" is registry permissions.
#
# Worth keeping even once this is resolved. It costs nothing and turns an
# opaque failure into a labelled one.
# amd64 first, loaded into the daemon. Kept as its own step for the reason above:
# it separates "the Dockerfile is broken" from "the registry said no", and a
# multi-platform build cannot be loaded locally at all, so the diagnostic would be
# lost if this were merged into the push below. The layer cache makes the second
# build of amd64 nearly free.
- name: Build image, amd64 (no push)
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
platforms: linux/amd64
build-args: |
VERSION=${{ steps.v.outputs.version }}
tags: |
ghcr.io/bxnnyg/matrixctrl:${{ steps.v.outputs.version }}
ghcr.io/bxnnyg/matrixctrl:latest
# Both architectures, pushed as one manifest list (etappe 66, P2-7).
#
# No QEMU is set up here, and that is deliberate rather than an omission: since
# E66 the runtime stage runs *no command*, every other stage is pinned to
# $BUILDPLATFORM, and Go cross-compiles — so assembling the arm64 image is copying
# files and needs no emulation. Two earlier attempts failed precisely in the one
# emulated instruction, `apk add` in the final stage, which no longer exists.
#
# Leaving QEMU out keeps that property honest: if someone adds a RUN to the
# runtime stage, this fails loudly instead of silently becoming a slow emulated
# build that works until it does not.
- name: Build and push image, amd64 + arm64
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.v.outputs.version }}
tags: |
ghcr.io/bxnnyg/matrixctrl:${{ steps.v.outputs.version }}
ghcr.io/bxnnyg/matrixctrl:latest
# The chart's default image tag is "latest", which is how a chart pinned to
# one version ends up pulling a different build months later. Pin the
# released copy to the exact version it was cut with, so
# `helm install --version X` is reproducible.
#
# helm package has no --set, so values.yaml is rewritten in the workspace
# first. This is a throwaway checkout; the committed default stays "latest".
- name: Package & push chart
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
helm registry login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
sed -i "s|^ tag: \".*\"| tag: \"$VERSION\"|" deploy/helm/matrixctrl/values.yaml
grep -A1 '^image:' deploy/helm/matrixctrl/values.yaml
PINNED=$(grep -E '^ tag:' deploy/helm/matrixctrl/values.yaml | awk '{print $2}' | tr -d '"')
[ "$PINNED" = "$VERSION" ] || { echo "::error::failed to pin image.tag (got '$PINNED')"; exit 1; }
helm package deploy/helm/matrixctrl --version "$VERSION" --app-version "$VERSION"
helm push "matrixctrl-$VERSION.tgz" oci://ghcr.io/bxnnyg/charts
helm registry logout ghcr.io || true
# Last, so the page only appears once the artefacts it describes exist.
# Idempotent: re-running a tag updates the notes instead of failing, which
# matters because tags do get re-cut (the history rewrite in P0-1c moved
# v0.1.15).
- name: Publish GitHub Release
env:
VERSION: ${{ steps.v.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "v$VERSION" >/dev/null 2>&1; then
echo "Release v$VERSION exists — updating notes."
gh release edit "v$VERSION" --title "MatrixCtrl $VERSION" --notes-file release-notes.md
else
gh release create "v$VERSION" --title "MatrixCtrl $VERSION" --notes-file release-notes.md
fi
# Ask the registry what actually arrived.
#
# Everything above reports success for having *sent* something. On 2026-09-05 the
# job died at a guard before the push, nothing reached GHCR, and the failure was
# noticed days later by an operator installing the previous version (§4.75). The
# same shape applies one level in: the image is built for two architectures, and a
# silently missing one would surface as "no matching manifest" on somebody's ARM
# board, not here.
#
# The tag is the intention. This step is where the result is checked.
- name: Verify what the registry holds
env:
VERSION: ${{ steps.v.outputs.version }}
run: ./scripts/check-published.sh "$VERSION"
- name: Summary
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
{
echo "### Released \`$VERSION\` (linux/amd64)"
echo
echo "What a new user runs — no version pinned, Helm resolves the newest:"
echo '```bash'
echo "helm install matrixctrl oci://ghcr.io/bxnnyg/charts/matrixctrl \\"
echo " --namespace matrixctrl --create-namespace \\"
echo " --set ingress.host=matrixctrl.example.com"
echo '```'
echo
echo "To verify this exact release:"
echo '```bash'
echo "helm show chart oci://ghcr.io/bxnnyg/charts/matrixctrl --version $VERSION"
echo '```'
echo
echo "New GHCR packages default to **private** — check visibility if this is a first publish."
} >> "$GITHUB_STEP_SUMMARY"