--- # 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- 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