feat(gitea-actions): build the CI job images in Gitea CI
The runner's job images were built by ansible into localhost/ only, so the nightly CI prune deleted them and every idle stretch ended with CI failing in under a second on `docker pull localhost/gitea-ci:latest` until someone re-ran the role and waited out a rebuild. The previous commit moved them to the Gitea registry; this moves the *build* off the deploy path entirely. - .gitea/workflows/ci-images.yml builds files/Containerfile.* and pushes to git.debyl.io/gitbot/. Per-image change detection, so an ESP-IDF pin bump does not rebuild the other two; weekly schedule for base-image updates; a workflow_dispatch selector. PRs build under a throwaway :pr-<n> tag and drop it -- the build lands in the live runner's store, and act_runner will not re-pull a tag it already has, so a PR using the real tag would hand every later job on this host an unmerged image. - The Containerfiles stop being ansible templates: their version vars are now --build-arg, read by the workflow out of the same defaults/main.yml the role interpolates, so CI and ansible build the same bytes from one set of pins. - LABEL io.debyl.ci-base moves into each Containerfile so neither builder can forget the prune exemption; the workflow re-checks it before pushing. - roles/gitea-actions pulls instead of building. gitea_ci_build_local=true restores the local build+push for seeding a cold registry or when CI is down -- the workflow that builds gitea-ci runs in gitea-ci. - Lint .gitea/ alongside ansible/, and document the flow in the role README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a52209ff6a
commit
d0e76bd6cf
@@ -0,0 +1,242 @@
|
||||
---
|
||||
# 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:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
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]
|
||||
|
||||
print(f"matrix={json.dumps({'include': picked})}")
|
||||
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
|
||||
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Gitea Container Registry
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
echo "image=${{ matrix.tag }}-pr${{ github.event.number }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "image=${{ matrix.tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build ${{ matrix.key }}
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.image }}
|
||||
BUILD_ARGS: ${{ matrix.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/${{ matrix.containerfile }}" \
|
||||
"$CONTEXT"
|
||||
|
||||
- name: Verify the prune-exemption label survived the build
|
||||
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'
|
||||
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'
|
||||
env:
|
||||
IMAGE: ${{ steps.tag.outputs.image }}
|
||||
run: docker rmi -f "$IMAGE" || true
|
||||
Reference in New Issue
Block a user