Skip to content

Etappe 34: the panel must not be first in the kill order #27

Etappe 34: the panel must not be first in the kill order

Etappe 34: the panel must not be first in the kill order #27

Workflow file for this run

# 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.
- name: Build image (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
- name: Push image
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -x
docker push "ghcr.io/bxnnyg/matrixctrl:$VERSION"
docker push "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
- 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"