Files
Bastian de BylandClaude Opus 5.5 be50798096
CI Images / Plan (push) Successful in 29s
CI Images / Build ci (push) Failing after 1m8s
CI Images / Build espidf (push) Failing after 1m16s
CI Images / Build platformio (push) Failing after 1m30s
fix(ci-images): static build matrix for Gitea
Gitea expands a job's matrix when the run is created, before plan has any
outputs, so fromJSON(needs.plan.outputs.matrix) collapsed to a single empty
"Build ${{ matrix.key }}" job and nothing was ever built. The matrix is now
the fixed list of image keys; plan emits every image's spec with a build flag
and each matrix job looks its own entry up, no-opping when it wasn't picked.

Also document that both registry tokens need write:package -- the vault token
was read-only, so the gitea_ci_build_local bootstrap failed its push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:00:35 -04:00

274 lines
11 KiB
YAML

---
# Builds the Gitea Actions job images and publishes them to the Gitea container
# registry, so the runner can re-pull one the nightly podman prune removed
# instead of waiting for a human to re-run `make deploy TAGS=gitea-actions`.
#
# Source of truth is ansible/roles/gitea-actions: files/Containerfile.* for the
# image contents, defaults/main.yml for the version pins and the registry path.
# This workflow reads those vars rather than repeating them. roles/gitea-actions
# then only pulls what lands here (gitea_ci_build_local is the escape hatch for
# seeding an empty namespace, since the job below runs *in* gitea-ci).
#
# `docker build` here talks to the gitea-runner user's rootless podman socket,
# mounted into every job container by roles/gitea-actions (config.yaml.j2), so
# the build happens in the same image store the runner pulls from and the layer
# cache survives between runs. That also means a build writes tags the live
# runner will use -- which is why pull requests build under a throwaway
# :pr-<n> tag and delete it again.
name: CI Images
on:
push:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
pull_request:
branches: [master]
paths:
- ansible/roles/gitea-actions/files/Containerfile.*
- ansible/roles/gitea-actions/defaults/main.yml
- .gitea/workflows/ci-images.yml
workflow_dispatch:
inputs:
image:
description: Which image to rebuild
type: choice
options: [all, ci, espidf, platformio]
default: all
schedule:
# Weekly rebuild so base-image security updates land without a commit.
# Sunday 04:00, after the 02:00 podman prune has finished.
- cron: "0 4 * * 0"
env:
DEFAULTS: ansible/roles/gitea-actions/defaults/main.yml
CONTEXT: ansible/roles/gitea-actions/files
REGISTRY: git.debyl.io
# Not a secret: the same namespace is in defaults/main.yml. It must be the
# owner of REGISTRY_TOKEN -- Gitea authorises a package push by the token's
# user, not by the path, so pushing to gitbot/ means logging in as gitbot.
REGISTRY_USER: gitbot
KEEP_LABEL: io.debyl.ci-base
# One publisher at a time. Two runs pushing :latest concurrently would leave the
# registry holding whichever finished last, which need not be the newest commit.
concurrency:
group: ci-images
cancel-in-progress: false
jobs:
plan:
name: Plan
runs-on: fedora
outputs:
images: ${{ steps.plan.outputs.images }}
any: ${{ steps.plan.outputs.any }}
steps:
- uses: actions/checkout@v4
with:
# Full history so the change detection below can diff against the
# pushed-from commit / the PR base.
fetch-depth: 0
- name: Decide which images to build
id: plan
env:
EVENT: ${{ github.event_name }}
SELECTED: ${{ github.event.inputs.image }}
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, subprocess, sys, yaml
defaults = yaml.safe_load(open(os.environ["DEFAULTS"]))
ctx = os.environ["CONTEXT"]
reg, ns = os.environ["REGISTRY"], os.environ["REGISTRY_USER"]
# Mirrors gitea_ci_images in defaults/main.yml. The tags are rebuilt
# from the same version vars the role interpolates, so a pin bump in
# that file moves the image tag here and in ansible together.
images = [
{
"key": "ci",
"containerfile": "Containerfile.ci",
"tag": f"{reg}/{ns}/gitea-ci:latest",
"build_args": "",
},
{
"key": "espidf",
"containerfile": "Containerfile.espidf",
"tag": f"{reg}/{ns}/gitea-ci-espidf:{defaults['esp_idf_version']}",
"build_args": f"ESP_IDF_VERSION={defaults['esp_idf_version']}",
},
{
"key": "platformio",
"containerfile": "Containerfile.platformio",
"tag": f"{reg}/{ns}/gitea-ci-platformio:{defaults['pio_espressif32_version']}",
"build_args": (
f"PLATFORMIO_CORE_VERSION={defaults['platformio_core_version']} "
f"PIO_ESPRESSIF32_VERSION={defaults['pio_espressif32_version']}"
),
},
]
event = os.environ["EVENT"]
def changed_files(base):
"""Paths touched since `base`, or None if the diff is not usable."""
if not base or set(base) == {"0"}:
return None
try:
out = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout
except subprocess.CalledProcessError:
# Force push, shallow clone, first push of a branch: fall back
# to building everything rather than silently skipping a real
# change.
return None
return set(out.split())
if event == "workflow_dispatch":
selected = os.environ.get("SELECTED") or "all"
picked = images if selected == "all" else [i for i in images if i["key"] == selected]
elif event == "schedule":
picked = images
else:
base = os.environ["PR_BASE"] if event == "pull_request" else os.environ["BEFORE"]
touched = changed_files(base)
if touched is None:
picked = images
else:
# defaults/main.yml holds every pin, so a change there could
# retag any image; the workflow file itself changes how all of
# them are built. Either one rebuilds the lot.
wide = {os.environ["DEFAULTS"], ".gitea/workflows/ci-images.yml"}
if touched & wide:
picked = images
else:
picked = [i for i in images if f"{ctx}/{i['containerfile']}" in touched]
# Every image, keyed by matrix.key, each flagged build or skip. The
# build job's matrix is static (see there), so it needs the full set
# to look its own entry up in, not just the picked ones.
picked_keys = {i["key"] for i in picked}
specs = {i["key"]: {**i, "build": i["key"] in picked_keys} for i in images}
print(f"images={json.dumps(specs)}")
print(f"any={'true' if picked else 'false'}")
print("building: " + (", ".join(i["tag"] for i in picked) or "nothing"), file=sys.stderr)
PY
build:
name: Build ${{ matrix.key }}
needs: plan
if: needs.plan.outputs.any == 'true'
runs-on: fedora
strategy:
# One image failing must not cancel the others: they are independent, and
# a half-published set is what this whole workflow exists to avoid.
fail-fast: false
# Static on purpose. Gitea expands the matrix when the run is created,
# before plan has produced any outputs, so a
# fromJSON(needs.plan.outputs.*) matrix collapses to one empty job. Each
# entry instead looks its spec up in plan's output and no-ops its steps
# when plan did not pick it. Keys must match `images` in plan.
matrix:
key: [ci, espidf, platformio]
steps:
- name: Look up the ${{ matrix.key }} image spec
id: spec
env:
IMAGES: ${{ needs.plan.outputs.images }}
KEY: ${{ matrix.key }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os
spec = json.loads(os.environ["IMAGES"])[os.environ["KEY"]]
for k in ("containerfile", "tag", "build_args"):
print(f"{k}={spec[k]}")
print(f"build={'true' if spec['build'] else 'false'}")
PY
- uses: actions/checkout@v4
if: steps.spec.outputs.build == 'true'
- name: Log in to the Gitea Container Registry
if: steps.spec.outputs.build == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
# The build lands in the live runner's image store, and act_runner will
# not re-pull a tag it already has locally. Tagging a PR build with the
# real tag would therefore hand every later job on this host an unmerged
# image, so PRs get a throwaway tag that the cleanup step removes.
- name: Resolve build tag
id: tag
if: steps.spec.outputs.build == 'true'
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "image=${{ steps.spec.outputs.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "image=${{ steps.spec.outputs.tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Build ${{ matrix.key }}
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
BUILD_ARGS: ${{ steps.spec.outputs.build_args }}
run: |
set -euo pipefail
args=()
for a in $BUILD_ARGS; do args+=(--build-arg "$a"); done
# --pull so a scheduled run actually picks up a refreshed base image;
# without it an unchanged FROM line just hits the local layer cache.
docker build --pull \
"${args[@]}" \
-t "$IMAGE" \
-f "$CONTEXT/${{ steps.spec.outputs.containerfile }}" \
"$CONTEXT"
- name: Verify the prune-exemption label survived the build
if: steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
# roles/podman's nightly prune keeps an image only if it carries this
# label (podman_prune_ci_keep_label). Publishing one without it would
# quietly restore the nightly-deletion behaviour this replaced, and
# nothing would notice until CI failed on a Monday morning.
got=$(docker inspect -f "{{ index .Config.Labels \"$KEEP_LABEL\" }}" "$IMAGE")
test "$got" = "true" || {
echo "::error::$IMAGE is missing LABEL $KEEP_LABEL=true"
exit 1
}
- name: Push ${{ matrix.key }}
if: github.event_name != 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: |
set -euo pipefail
docker push "$IMAGE"
echo "Pushed: $IMAGE"
# Always, including on failure: the throwaway tag carries the keep label,
# so the nightly prune will not reclaim it and a few skipped cleanups add
# up to gigabytes in the runner's store.
- name: Drop the pull-request image
if: always() && github.event_name == 'pull_request' && steps.spec.outputs.build == 'true'
env:
IMAGE: ${{ steps.tag.outputs.image }}
run: docker rmi -f "$IMAGE" || true