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>
274 lines
11 KiB
YAML
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
|