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:
Bastian de Byl
2026-09-21 11:10:43 -04:00
co-authored by Claude Opus 5
parent a52209ff6a
commit d0e76bd6cf
11 changed files with 475 additions and 63 deletions
@@ -0,0 +1,45 @@
# Default Gitea Actions job image (managed by ansible: roles/gitea-actions).
# Covers Go/web/node jobs plus `docker build` (talks to the mounted rootless
# podman socket). Go toolchains are provided per-job by actions/setup-go.
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result (see gitea_ci_build_local for the local-build fallback).
# A plain Containerfile, not a template, so CI and ansible build the same bytes.
FROM node:20-bookworm-slim
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ARG DOCKER_CLI_VERSION=27.3.1
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git openssh-client make build-essential \
python3 python3-pip python3-yaml python3-jinja2 jq zip unzip \
gcc-arm-none-eabi binutils-arm-none-eabi libnewlib-arm-none-eabi \
&& rm -rf /var/lib/apt/lists/*
# Static docker client (no daemon) for jobs that run `docker build` against the
# mounted podman socket (/var/run/docker.sock).
RUN curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" \
| tar -xz -C /tmp \
&& install -m0755 /tmp/docker/docker /usr/local/bin/docker \
&& rm -rf /tmp/docker
# AWS CLI v2 — several workflows upload artifacts / deploy Lambda.
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
# Terraform via tfenv — workflows can pin a version with a .terraform-version
# file (or TFENV_TERRAFORM_VERSION); the image ships "latest" as the default.
ENV TFENV_ROOT=/opt/tfenv
ARG TFENV_TERRAFORM_VERSION=latest
RUN git clone --depth=1 https://github.com/tfutils/tfenv.git "${TFENV_ROOT}" \
&& ln -s "${TFENV_ROOT}/bin/tfenv" /usr/local/bin/tfenv \
&& ln -s "${TFENV_ROOT}/bin/terraform" /usr/local/bin/terraform \
&& tfenv install "${TFENV_TERRAFORM_VERSION}" \
&& tfenv use "${TFENV_TERRAFORM_VERSION}"
@@ -0,0 +1,40 @@
# ESP-IDF firmware job image (managed by ansible: roles/gitea-actions).
# Adds node (required by actions/checkout and other JS actions), the AWS CLI
# (firmware artifacts ship to S3), jq (the release script rewrites the protocol
# manifest with it), and the common-yaml header generator's Python deps on top
# of the official Espressif toolchain.
# IDF lives at /opt/esp/idf — firmware jobs source /opt/esp/idf/export.sh.
# python3-yaml + python3-jinja2 are installed as distro packages so the
# common-yaml generator runs with a plain `python3 generate.py` — no pip at job
# time (the base image's system Python is PEP 668 externally-managed) and no
# need to source the IDF venv just to generate headers.
#
# jq is required by esp32-stm32-vcu scripts/release.sh, which publishes the
# protocol manifest and BLE_API.md to S3 after the firmware upload. Without it
# the release aborts *after* the firmware and version.json are already live —
# clients get the new build while the tag, Gitea release and protocol manifest
# are never written. Keep it installed.
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. ESP_IDF_VERSION is a build arg rather than an ansible
# template var so CI and ansible build the same bytes -- its value is read from
# esp_idf_version in roles/gitea-actions/defaults/main.yml by both.
ARG ESP_IDF_VERSION
FROM espressif/idf:${ESP_IDF_VERSION}
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls 7.8 GB.
LABEL io.debyl.ci-base="true"
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates unzip jq python3-yaml python3-jinja2 \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
@@ -0,0 +1,48 @@
# PlatformIO firmware job image (managed by ansible: roles/gitea-actions).
# For Arduino-framework ESP32 projects (e.g. Skudak/esp32-web-interface) that
# build with PlatformIO rather than raw ESP-IDF. Node is required by
# actions/checkout and other JS actions.
# The espressif32 platform, xtensa toolchain and Arduino framework are
# pre-baked into PLATFORMIO_CORE_DIR via a throwaway seed project so ephemeral
# job containers download nothing at build time. Versions are pinned to what
# was validated on hardware — bump pio_espressif32_version /
# platformio_core_version in defaults/main.yml to upgrade (the image tag
# tracks the platform version).
#
# Built and published by .gitea/workflows/ci-images.yml; roles/gitea-actions
# only pulls the result. The pins are build args rather than ansible template
# vars so CI and ansible build the same bytes -- their values are read from
# platformio_core_version / pio_espressif32_version in
# roles/gitea-actions/defaults/main.yml by both.
FROM python:3.12-slim-bookworm
ARG PLATFORMIO_CORE_VERSION
ARG PIO_ESPRESSIF32_VERSION
# Exempts the image from the nightly CI prune -- see podman_prune_ci_keep_label
# in roles/podman/defaults/main.yml. Declared here rather than passed as a
# --label at build time so neither builder can forget it: without the label the
# prune deletes the image every night and the next job re-pulls a gigabyte.
LABEL io.debyl.ci-base="true"
ENV PLATFORMIO_CORE_DIR=/opt/platformio
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl ca-certificates unzip \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir platformio==${PLATFORMIO_CORE_VERSION}
# Seed project mirroring the real projects' platformio.ini so `pio pkg install`
# pulls the platform + toolchain + framework packages into the core dir.
# %s + a quoted argument, not ${...} inside the single-quoted format string:
# RUN is `sh -c`, and sh does not expand inside single quotes, so an inlined
# ${PIO_ESPRESSIF32_VERSION} would be written to platformio.ini literally.
RUN mkdir -p /tmp/seed/src \
&& printf '[env:seed]\nplatform = espressif32@%s\nframework = arduino\nboard = esp32dev\nboard_build.filesystem = spiffs\nplatform_packages = platformio/tool-esptoolpy\n' "${PIO_ESPRESSIF32_VERSION}" > /tmp/seed/platformio.ini \
&& pio pkg install -d /tmp/seed \
&& pio pkg install -d /tmp/seed --tool platformio/tool-mkspiffs \
&& rm -rf /tmp/seed \
&& chmod -R a+rX ${PLATFORMIO_CORE_DIR}