From 27e13d92a3da3eae8922d676e062e9d5902aed1b Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Fri, 22 May 2026 13:34:17 +0200 Subject: [PATCH] Adjust build pipeline --- .gitea/workflows/build-image.yml | 416 +++++++++++++++++-------------- docs/automation.md | 118 +++++---- docs/runners.md | 2 +- 3 files changed, 299 insertions(+), 237 deletions(-) diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index 50e5c90..67a927e 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -3,12 +3,17 @@ name: Build image # Reusable workflow. Builds a Docker image from the caller repo and pushes # it to the Gitea container registry at `gitea.alexandru.macocian.me`. # -# Companion to `deploy-stack.yml`: the source repos (`amacocian/`) -# produce images here; the deployment repos (`Charlie/`) consume -# them via the `image:` field in their compose files and call -# `deploy-stack.yml` to roll out. +# Architecture mirrors `deploy-stack.yml`: the runner is stateless and +# every operation happens on the target build host as `sys-gitea-runner` +# (LDAP, member of `charlie-deploy`) over a short-lived step-ca-issued +# SSH user cert. The runner container itself does not have a docker +# socket — `docker build` / `docker push` run on the build host's daemon. # -# Caller pattern (from a source repo, e.g. amacocian/newsletter2atom): +# Companion to `deploy-stack.yml`: source repos produce images here; +# deployment repos (`Charlie/`) consume them via the `image:` +# field in their compose files and call `deploy-stack.yml` to roll out. +# +# Caller pattern (from a source repo): # # on: # push: @@ -16,7 +21,9 @@ name: Build image # workflow_dispatch: # jobs: # build: -# uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main +# uses: https://:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main +# with: +# host: melina # secrets: inherit # # Tags pushed on every successful build: @@ -27,21 +34,39 @@ name: Build image # via the `extra-tags` input — one per line. Empty lines / `#` comments # are ignored. # -# Required caller secrets — both `sys-gitea-runner` PATs. Set once at the -# owning user/org level (Settings → Actions → Secrets) so every source -# repo under that namespace inherits them via `secrets: inherit`: +# Required caller secrets — both belong to the *caller's* Gitea user, NOT +# `sys-gitea-runner`. Set once at the owning user/org level (Settings → +# Actions → Secrets) so every source repo under that namespace inherits +# them via `secrets: inherit`: # -# RUNNER_REPO_PAT `read:repository` -- fetch this workflow file -# + clone the caller source -# RUNNER_REGISTRY_PAT `write:package` -- docker login + push to the -# Gitea container registry +# CALLER_REPO_PAT `read:repository` -- fetch this workflow file, +# clone the caller source on +# the build host +# CALLER_REGISTRY_PAT `write:package` -- docker login + push on the +# build host # -# Kept as two distinct PATs so the high-privilege package-write token is -# never used for git over HTTPS (and vice versa). +# The caller user must be a Read collaborator on `Charlie/project-charlie` +# (or a Read member of the `Charlie` org) so its PAT can fetch this +# workflow file via the absolute-URL `uses:` reference. +# +# Two distinct PATs so the high-privilege package-write token is never +# used for git over HTTPS (and vice versa). +# +# `sys-gitea-runner` is still used as the SSH identity into the build +# host (short-lived step-ca user cert) — that's a separate identity +# layer, unrelated to these PATs. on: workflow_call: inputs: + host: + description: | + Hostname this build runs on. The runner SSHes in here as + `sys-gitea-runner` and invokes `docker build` / `docker push` + against the host's docker daemon. Any host with docker + the + standard `charlie-deploy` setup qualifies. + required: true + type: string image: description: | Fully-qualified image name without tag, e.g. @@ -65,8 +90,7 @@ on: build-args: description: | Multi-line `KEY=VALUE` list passed as `--build-arg`. Empty lines - and `#` comments are ignored. Values containing `=` are preserved - as-is after the first `=`. + and `#` comments are ignored. required: false type: string default: "" @@ -91,46 +115,58 @@ on: required: false type: string default: "" + registry-user: + description: | + Username used for `docker login`. Must own (or be a collaborator + with `write:package` on) the target image namespace, and must + match the user the `CALLER_REGISTRY_PAT` PAT belongs to. + Defaults to the caller repo's owner (`github.repository_owner`), + which is correct for personal-namespace builds. + required: false + type: string + default: "" secrets: - RUNNER_REPO_PAT: + CALLER_REPO_PAT: description: | - `sys-gitea-runner` PAT with `read:repository`. Used in the - absolute-URL `uses:` form to fetch this workflow file and to - clone the caller's source inside the job. Same name and - contract as the secret consumed by `deploy-stack.yml`. + Caller-owned PAT with `read:repository`. Used for: + - the absolute-URL `uses:` reference fetching this workflow + + its sibling composite actions from `Charlie/project-charlie` + (caller user must be a Read collaborator there); + - cloning the caller's source on the build host. required: true - RUNNER_REGISTRY_PAT: + CALLER_REGISTRY_PAT: description: | - `sys-gitea-runner` PAT with `write:package`. Used by - `docker login` against `gitea.alexandru.macocian.me`. Kept - separate from `RUNNER_REPO_PAT` so the package-write PAT is + Caller-owned PAT with `write:package` on the target image + namespace. Used by `docker login` on the build host. Kept + separate from `CALLER_REPO_PAT` so the package-write PAT is never exposed during git clone (and vice versa). required: true outputs: image: - description: Full image reference without tag (e.g. `gitea.../amacocian/foo`). + description: Full image reference without tag (e.g. `gitea.../foo/bar`). value: ${{ jobs.build.outputs.image }} timestamp-tag: description: The UTC timestamp tag pushed for this build. value: ${{ jobs.build.outputs.timestamp-tag }} - digest: - description: The image digest reported by `docker push` (sha256:...). - value: ${{ jobs.build.outputs.digest }} jobs: build: + # `self-hosted` is the only label requirement. Routing to the + # build host happens via SSH inside the job, not via Gitea's runner + # picker. Sidesteps the reusable-workflow `runs-on` matching bug + # (Gitea #32348). Same model as `deploy-stack.yml`. runs-on: self-hosted outputs: image: ${{ steps.resolve.outputs.image }} timestamp-tag: ${{ steps.resolve.outputs.timestamp-tag }} - digest: ${{ steps.push.outputs.digest }} steps: - - name: Resolve image, ref, and timestamp + - name: Resolve image, ref, timestamp, workdir id: resolve shell: bash env: IMAGE_INPUT: ${{ inputs.image }} REF_INPUT: ${{ inputs.ref }} + REGISTRY_USER_INPUT: ${{ inputs.registry-user }} run: | set -euo pipefail image="$IMAGE_INPUT" @@ -145,169 +181,175 @@ jobs: if [ -z "$ref" ]; then ref="${GITHUB_REF_NAME:-main}" fi - # UTC ISO-ish, filename-/tag-safe: 2026-05-22T095126Z + registry_user="$REGISTRY_USER_INPUT" + if [ -z "$registry_user" ]; then + registry_user="${GITHUB_REPOSITORY_OWNER}" + fi + # UTC ISO-ish, filename-/tag-safe: 2026-05-22T130927Z ts="$(date -u +%Y-%m-%dT%H%M%SZ)" - echo "image=$image" >> "$GITHUB_OUTPUT" - echo "ref=$ref" >> "$GITHUB_OUTPUT" - echo "timestamp-tag=$ts" >> "$GITHUB_OUTPUT" - echo "::notice::building ${image}:${ts} (+ latest) from ref ${ref}" + # Per-run scratch dir on the build host. /tmp is fine — the + # tree is wiped at job end and only needs to survive the SSH + # session. GITHUB_RUN_ID is unique per workflow run on this + # Gitea instance, so parallel builds of the same repo to the + # same host don't collide. + workdir="/tmp/charlie-build-${GITHUB_RUN_ID:-manual-$$}" + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "ref=$ref" >> "$GITHUB_OUTPUT" + echo "registry-user=$registry_user" >> "$GITHUB_OUTPUT" + echo "timestamp-tag=$ts" >> "$GITHUB_OUTPUT" + echo "workdir=$workdir" >> "$GITHUB_OUTPUT" + echo "::notice::building ${image}:${ts} (+ latest) from ref ${ref} on ${{ inputs.host }} as ${registry_user}" - - name: Check out source - shell: bash - env: - REF: ${{ steps.resolve.outputs.ref }} - REPO: ${{ github.repository }} - REPO_PAT: ${{ secrets.RUNNER_REPO_PAT }} - run: | - set -euo pipefail - # Manual clone — sidesteps the question of which `actions/checkout` - # mirror this Gitea instance proxies. Same one-shot auth pattern - # as deploy-stack.yml's auto-provisioning step: PAT lives only in - # an `http.extraheader` for this single clone, never persisted to - # `.git/config`. - rm -rf src - auth=$(printf '%s' "sys-gitea-runner:${REPO_PAT}" | base64 -w0) - git -c "http.extraheader=Authorization: Basic ${auth}" \ - clone --depth=1 --branch "${REF}" \ - "https://gitea.alexandru.macocian.me/${REPO}.git" src + - name: Open SSH session + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-open@main + with: + host: ${{ inputs.host }} + + - name: Provision build workdir + clone caller source + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + # Auth model mirrors deploy-stack.yml's auto-provisioning step: + # the PAT is passed as a one-shot `http.extraHeader` for the + # clone only. It is NOT written to `.git/config`, so nothing + # persists on the host after this job. Gitea masks + # `${{ secrets.* }}` in workflow logs; the base64-encoded value + # is briefly visible in the host's process list while git runs. + # Acceptable on the intranet-only homelab. + run: | + set -euo pipefail + workdir="${{ steps.resolve.outputs.workdir }}" + ref="${{ steps.resolve.outputs.ref }}" + repo="${{ github.repository }}" + # Defensive: a leftover dir from a crashed prior run would + # make `git clone` refuse. Re-running the workflow with the + # same RUN_ID is impossible (Gitea allocates per run) but + # being explicit costs nothing. + rm -rf "$workdir" + mkdir -p "$(dirname "$workdir")" + auth=$(printf '%s' '${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}' | base64 -w0) + git -c "http.extraheader=Authorization: Basic ${auth}" \ + clone --depth=1 --branch "$ref" \ + "https://gitea.alexandru.macocian.me/${repo}.git" \ + "$workdir" - name: Log in to Gitea container registry - shell: bash - env: - REGISTRY_PAT: ${{ secrets.RUNNER_REGISTRY_PAT }} - run: | - set -euo pipefail - # `docker login --password-stdin` keeps the PAT out of the - # process list. Gitea masks `${{ secrets.* }}` in logs. - printf '%s' "$REGISTRY_PAT" | \ - docker login gitea.alexandru.macocian.me \ - --username sys-gitea-runner \ - --password-stdin - - - name: Assemble tag and build-arg lists - id: args - shell: bash - env: - IMAGE: ${{ steps.resolve.outputs.image }} - TS_TAG: ${{ steps.resolve.outputs.timestamp-tag }} - EXTRA_TAGS: ${{ inputs.extra-tags }} - BUILD_ARGS: ${{ inputs.build-args }} - run: | - set -euo pipefail - # Tag list: always latest + timestamp. Dedupe so callers that - # pass `latest` in extra-tags don't get a noisy double-push. - declare -A seen=() - tag_args=() - add_tag() { - local t="$1" - [ -z "$t" ] && return 0 - if [ -z "${seen[$t]+x}" ]; then - seen[$t]=1 - tag_args+=(-t "${IMAGE}:${t}") - fi - } - add_tag "latest" - add_tag "$TS_TAG" - while IFS= read -r raw; do - line="${raw%%#*}" - line="$(printf '%s' "$line" | awk '{$1=$1;print}')" - [ -z "$line" ] && continue - add_tag "$line" - done <<< "$EXTRA_TAGS" - - # Build-args: one --build-arg per non-empty / non-comment line. - ba_args=() - while IFS= read -r raw; do - line="${raw%%#*}" - line="$(printf '%s' "$line" | awk '{$1=$1;print}')" - [ -z "$line" ] && continue - case "$line" in - *=*) ba_args+=(--build-arg "$line") ;; - *) echo "::error::invalid build-arg (missing '='): $raw"; exit 1 ;; - esac - done <<< "$BUILD_ARGS" - - # Serialise both arrays into single-line, shell-quoted strings - # so the next step can `eval set --` them safely. printf %q - # handles spaces, quotes, glob chars, etc. - printf '%s\n' "$(printf '%q ' "${tag_args[@]}")" > /tmp/charlie-build-tag-args - printf '%s\n' "$(printf '%q ' "${ba_args[@]:-}")" > /tmp/charlie-build-ba-args - - { - echo "tag-list<> "$GITHUB_OUTPUT" + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + # The PAT is interpolated into the command string here. Gitea + # masks `${{ secrets.* }}` in workflow logs, but the literal + # value is briefly visible in the host's process list while + # `docker login` runs. Acceptable on the intranet-only homelab; + # revisit if the host is ever exposed to untrusted local users. + run: | + set -euo pipefail + printf '%s' '${{ secrets.CALLER_REGISTRY_PAT }}' | \ + docker login gitea.alexandru.macocian.me \ + --username '${{ steps.resolve.outputs.registry-user }}' \ + --password-stdin - name: Build image - shell: bash - env: - CONTEXT: ${{ inputs.context }} - DOCKERFILE: ${{ inputs.dockerfile }} - run: | - set -euo pipefail - tag_args=$(cat /tmp/charlie-build-tag-args) - ba_args=$(cat /tmp/charlie-build-ba-args) - eval "set -- $tag_args $ba_args" - # `--pull` keeps the base image fresh on long-lived runners. - # No buildx / multi-arch by default — every host in the fleet - # is linux/amd64. Add a `platforms` input + `docker buildx` - # branch here if/when that changes. - # Dockerfile path is interpreted relative to the build context, - # matching the `inputs.dockerfile` doc string. - cd src - docker build \ - --pull \ - -f "${CONTEXT%/}/${DOCKERFILE}" \ - "$@" \ - "${CONTEXT}" + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + workdir: ${{ steps.resolve.outputs.workdir }} + run: | + set -euo pipefail + image="${{ steps.resolve.outputs.image }}" + ts="${{ steps.resolve.outputs.timestamp-tag }}" + context="${{ inputs.context }}" + dockerfile="${{ inputs.dockerfile }}" + + # Tag list: always latest + timestamp. Dedupe so callers that + # pass `latest` in extra-tags don't get a noisy double-push. + declare -A seen=() + tag_args=() + add_tag() { + local t="$1" + [ -z "$t" ] && return 0 + if [ -z "${seen[$t]+x}" ]; then + seen[$t]=1 + tag_args+=(-t "${image}:${t}") + fi + } + add_tag "latest" + add_tag "$ts" + while IFS= read -r raw; do + line="${raw%%#*}" + line="$(printf '%s' "$line" | awk '{$1=$1;print}')" + [ -z "$line" ] && continue + add_tag "$line" + done <> .charlie-tags + done + + # --pull keeps the base image fresh on long-lived hosts. + # No buildx / multi-arch by default — every host in the + # fleet is linux/amd64. Add a `platforms` input + buildx + # branch here if/when that changes. + docker build \ + --pull \ + -f "${context%/}/${dockerfile}" \ + "${tag_args[@]}" \ + "${ba_args[@]}" \ + "${context}" - name: Push image - id: push if: ${{ inputs.push }} - shell: bash - env: - IMAGE: ${{ steps.resolve.outputs.image }} - run: | - set -euo pipefail - tag_args=$(cat /tmp/charlie-build-tag-args) - eval "set -- $tag_args" - # `docker push -t` doesn't exist; the tag list was passed to - # `docker build -t` already, so push each resolved tag. Walk - # the same list by stripping the leading `-t` flags. - tags=() - while [ "$#" -gt 0 ]; do - if [ "$1" = "-t" ]; then - tags+=("$2") - shift 2 - else - shift - fi - done - for ref in "${tags[@]}"; do - echo "::notice::pushing ${ref}" - docker push "${ref}" - done - # Capture the digest of the timestamp tag — stable, immutable - # reference suitable for pinning in compose files. - ts_ref="${IMAGE}:${{ steps.resolve.outputs.timestamp-tag }}" - digest="$(docker inspect --format '{{index .RepoDigests 0}}' "${ts_ref}" 2>/dev/null | awk -F'@' '{print $2}')" - echo "digest=${digest}" >> "$GITHUB_OUTPUT" - echo "::notice::pushed digest ${digest}" + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + workdir: ${{ steps.resolve.outputs.workdir }} + run: | + set -euo pipefail + while IFS= read -r ref; do + [ -z "$ref" ] && continue + echo "::notice::pushing ${ref}" + docker push "${ref}" + done < .charlie-tags - - name: Logout + cleanup + - name: Cleanup build host if: always() - shell: bash - env: - IMAGE: ${{ steps.resolve.outputs.image }} - TS_TAG: ${{ steps.resolve.outputs.timestamp-tag }} - run: | - set -u - docker logout gitea.alexandru.macocian.me >/dev/null 2>&1 || true - # Best-effort local image GC: drop the just-built tags so the - # runner host doesn't accumulate them forever. The registry has - # the canonical copies. `latest` is kept because other builds / - # caches on the host may legitimately reference it. - docker rmi -f "${IMAGE}:${TS_TAG}" >/dev/null 2>&1 || true - rm -f /tmp/charlie-build-tag-args /tmp/charlie-build-ba-args - rm -rf src + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-target@main + with: + run: | + set -u + workdir="${{ steps.resolve.outputs.workdir }}" + image="${{ steps.resolve.outputs.image }}" + ts="${{ steps.resolve.outputs.timestamp-tag }}" + # Best-effort local image GC: drop the timestamp-tagged + # image so the build host doesn't accumulate one per build. + # The registry has the canonical copy. `latest` is kept + # because other builds / runtime containers on the host may + # legitimately reference it. + docker rmi -f "${image}:${ts}" >/dev/null 2>&1 || true + # Drop registry credentials. logout is namespaced per + # registry, so it doesn't affect other registries the host + # may be logged into. + docker logout gitea.alexandru.macocian.me >/dev/null 2>&1 || true + # Wipe the scratch tree (sources + .charlie-tags). The + # one-shot `http.extraHeader` PAT was never persisted to + # .git/config, so there's no credential to scrub here. + rm -rf "$workdir" + + - name: Close SSH session + if: always() + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/actions/charlie-ssh-close@main diff --git a/docs/automation.md b/docs/automation.md index 5a1f7b9..4881b63 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -200,14 +200,20 @@ callers pass via `secrets: inherit`). ## Inputs reference — `build-image.yml` -Source repos under `amacocian/*` (newsletter2atom, redlib, caddy, gitea-runner, -cert-watch, gssh, invidious, monica, directoryadmin, …) use this workflow to -build and push their container image to the Gitea registry. Deployment repos -(`Charlie/`) then pin the timestamp tag in their compose file and -trigger `deploy-stack.yml` to roll out. +Source repos use this workflow to build and push their container image +to the Gitea registry. Deployment repos (`Charlie/`) then pin +the timestamp tag in their compose file and trigger `deploy-stack.yml` +to roll out. + +Same SSH-into-host architecture as `deploy-stack.yml`: the runner is +stateless; `docker build` / `docker push` execute on the build host's +daemon as `sys-gitea-runner` over a short-lived step-ca-issued cert. +The runner container deliberately does not have a docker socket bind +mount. | Input | Required | Default | Description | |---|---|---|---| +| `host` | yes | — | Build host the runner SSHes into. Must have docker + the standard `charlie-deploy` setup. Any deploy-target host qualifies. | | `image` | no | `gitea.alexandru.macocian.me/` (lowercased) | Fully-qualified image name without the tag. Override only if the image should not live at its repo's natural path. | | `context` | no | `.` | Docker build context, relative to the caller repo root. | | `dockerfile` | no | `Dockerfile` | Dockerfile path, relative to `context`. | @@ -215,6 +221,7 @@ trigger `deploy-stack.yml` to roll out. | `extra-tags` | no | `""` | Additional tags (one per line) pushed alongside `latest` + the timestamp. Just the tag, not the full `image:tag` form. | | `push` | no | `true` | If `false`, build only — useful for PR validation. | | `ref` | no | The branch the workflow ran on | Git ref to check out and build. | +| `registry-user` | no | `${{ github.repository_owner }}` | Username for `docker login`. Must match the user that owns `CALLER_REGISTRY_PAT` and have `write:package` on the target image namespace. | Every successful build pushes **two** tags: @@ -227,16 +234,15 @@ Outputs (consumable by downstream jobs in the caller workflow): | Output | Example | Use | |---|---|---| -| `image` | `gitea.alexandru.macocian.me/amacocian/newsletter2atom` | The image ref without the tag. | -| `timestamp-tag` | `2026-05-22T095126Z` | The tag the caller can write into the consumer stack's compose file (e.g. via a follow-up job that opens a PR against `Charlie/`). | -| `digest` | `sha256:abc…` | The immutable content digest of the timestamp tag, in case you want to pin by digest instead of tag. | +| `image` | `gitea.alexandru.macocian.me//` | The image ref without the tag. | +| `timestamp-tag` | `2026-05-22T130927Z` | The tag the caller can write into the consumer stack's compose file (e.g. via a follow-up job that opens a PR against `Charlie/`). | -Requires two `sys-gitea-runner` PATs — see [Secret placement](#secret-placement-image-source-repos) below. +Requires two caller-owned PATs — see [Secret placement](#secret-placement-image-source-repos) below. ### Caller — minimal ```yaml -# amacocian/newsletter2atom/.gitea/workflows/build.yaml +# //.gitea/workflows/build.yaml name: Build image on: push: @@ -245,7 +251,9 @@ on: jobs: build: - uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main + with: + host: melina secrets: inherit ``` @@ -254,8 +262,9 @@ jobs: ```yaml jobs: build: - uses: https://sys-gitea-runner:${{ secrets.RUNNER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main + uses: https://${{ github.repository_owner }}:${{ secrets.CALLER_REPO_PAT }}@gitea.alexandru.macocian.me/Charlie/project-charlie/.gitea/workflows/build-image.yml@main with: + host: melina dockerfile: docker/Dockerfile build-args: | VERSION=${{ github.ref_name }} @@ -265,60 +274,71 @@ jobs: secrets: inherit ``` -### Runner-host prerequisites +### Build-host prerequisites -Building runs **on the runner**, not on a remote host. The runner container -therefore needs: +The host named in `host:` runs the actual `docker build` / `docker push`. +Requirements are a strict subset of the regular deploy-target prerequisites +([Host prerequisites](#host-prerequisites)): | Requirement | Why | |---|---| -| `docker` binary inside the container (already shipped by `amacocian/gitea-runner`) | `docker build` / `docker push`. | -| The host's `/var/run/docker.sock` bind-mounted into the runner | Build uses the host's docker daemon; no docker-in-docker. | -| `sys-gitea-runner` PAT has `write:package` on the target image namespace | `docker login` + push to `gitea.alexandru.macocian.me`. | +| `sys-gitea-runner` resolvable via LDAP + member of `charlie-deploy` | SSH session + docker socket access. | +| sshd trusts the step-ca SSH user CA | Runner-issued user certs are accepted. | +| `/var/run/docker.sock` is `root:charlie-deploy` | `docker build` / `push` work without sudo. | +| `git` available on the host | Cloning the caller source into the scratch workdir. | +| Outbound HTTPS to `gitea.alexandru.macocian.me` | `docker push` + `git clone`. | -Builds accumulate base-image layers in the runner host's docker storage. +No SOPS / age key / `/mnt/nas/stacks` is touched by a build — those are +deploy concerns only. + +Builds accumulate base-image layers in the build host's docker storage. The workflow drops the timestamp-tagged image after pushing, but base layers stay cached (intentionally — speeds up subsequent builds). Run -`docker system prune` on the runner host periodically. +`docker system prune` on the build host periodically. ### Secret placement (image-source repos) -`build-image.yml` runs from image-source repos that live **outside** the -`Charlie` org — they sit under the personal namespace that owns the -images. Gitea Actions secrets are scoped by where the workflow executes, -not by who the PAT authenticates as, so the `Charlie`-org-level -`RUNNER_REPO_PAT` is **not** visible to those callers. Both secrets must -be set on the namespace hosting the source repos. +Unlike `deploy-stack.yml` (which relies on the `sys-gitea-runner` system +account for everything), `build-image.yml` uses **caller-owned** PATs. +Each user/org that wants to build images provides their own credentials +— least privilege, no central PAT to share across namespaces, and the +package-write PAT only ever has access to that user's own namespace. -Set once, at the **user-level** Settings → Actions → Secrets on the +`sys-gitea-runner` is still the SSH identity into the build host (via +step-ca cert) — that's unchanged and unrelated to these secrets. + +Set both at the **user-level** Settings → Actions → Secrets on the account that owns the source repos. All repos under that personal namespace then inherit both via `secrets: inherit`: -| Secret | Gitea PAT scope | Used for | -|---|---|---| -| `RUNNER_REPO_PAT` | `read:repository` (All) | Resolving the absolute-URL `uses:` reference to this workflow + cloning the caller source inside the job. Same value as the `Charlie`-org secret of the same name (duplicate it — no cross-namespace inheritance). | -| `RUNNER_REGISTRY_PAT` | `write:package` (All) | `docker login` + push to the Gitea container registry. | +| Secret | Gitea PAT scope | Owner | Used for | +|---|---|---|---| +| `CALLER_REPO_PAT` | `read:repository` (All) | The calling user/org | Resolving the absolute-URL `uses:` reference to this workflow + its sibling composite actions in `Charlie/project-charlie`, and cloning the caller source on the build host. | +| `CALLER_REGISTRY_PAT` | `write:package` (All, or scoped to the target namespace) | The calling user/org | `docker login` + push to the Gitea container registry. | -Both PATs belong to the `sys-gitea-runner` system account. Keeping them -as two distinct tokens means the package-write PAT is never used over -HTTPS git (and the repo-read PAT can never be used to push images); -revoking one doesn't take the other down. +Two distinct tokens so the package-write PAT is never used over HTTPS +git (and the repo-read PAT can never be used to push images); revoking +one doesn't take the other down. -#### Recipe — provisioning the registry PAT (one-time) +#### Recipe — onboarding a new image-building namespace (one-time) -1. Log in to Gitea as `sys-gitea-runner`. Settings → Applications → - "Generate New Token". -2. Name: `gitea-runner-package-rw`. Repository and Organization Access: - All. Permissions: `write:package`. Generate. -3. Copy the token value (shown once). -4. Log in to Gitea as the user that owns the image-source repos. - Settings → Actions → Secrets → "Add Secret". Name: - `RUNNER_REGISTRY_PAT`, value: the token. Save. -5. Also add `RUNNER_REPO_PAT` here, value copied from the equivalent - `Charlie`-org secret (or from the existing `gitea-runner-repo-ro` - token if you can re-export it; otherwise mint a fresh `read:repository` - PAT and store the new value in both places). -6. Retire any prior `gitea-runner-package-ro` PAT — it's superseded. +1. **Grant the calling user read access to `Charlie/project-charlie`.** + Either add them as a Read collaborator on the repo (Charlie/project-charlie + → Settings → Collaborators) or as a Read member of the `Charlie` org. + Required so their PAT can resolve the `uses:` URL. +2. Log in to Gitea as the calling user. Settings → Applications → + "Generate New Token" twice: + - `build-repo-ro`: `read:repository`, Repository and Organization + Access: All (or at minimum: `Charlie/project-charlie` + the user's + own source repos). + - `build-package-rw`: `write:package`, Repository and Organization + Access: All (or scoped to the namespace receiving the images). +3. Still logged in as the calling user → Settings → Actions → Secrets → + "Add Secret": + - `CALLER_REPO_PAT` = the `build-repo-ro` value. + - `CALLER_REGISTRY_PAT` = the `build-package-rw` value. +4. Drop the [Caller — minimal](#caller--minimal) snippet into each + source repo at `.gitea/workflows/build.yaml`. Push. ## Inputs reference — `charlie-ssh-open` diff --git a/docs/runners.md b/docs/runners.md index a3b5648..7ba9aaf 100644 --- a/docs/runners.md +++ b/docs/runners.md @@ -80,7 +80,7 @@ If both runners are down, neither cross-deploy job has anywhere to land. Use the | Identity | Where used | Purpose | |---|---|---| | `sys-gitea-runner` (LDAP) | `~sys-gitea-runner/.git-credentials` (host); SSH user via cert (target host); `RUNNER_REPO_PAT` org secret | THE deployment identity. Member of `charlie-deploy`. Read-only on `Charlie` org. | -| `sys-gitea-runner` (Gitea user) | `RUNNER_REPO_PAT` (`read:repository`) + `RUNNER_REGISTRY_PAT` (`write:package`), set per Gitea namespace | Image builds run from non-`Charlie` namespaces (the personal account that owns the source repos); both PATs are set there so [`build-image.yml`](../.gitea/workflows/build-image.yml) can fetch + clone + push. See [automation.md → Secret placement](automation.md#secret-placement-image-source-repos). | +| Image-source caller (Gitea user) | `CALLER_REPO_PAT` (`read:repository`) + `CALLER_REGISTRY_PAT` (`write:package`), set per Gitea namespace | Image builds run from non-`Charlie` namespaces under that namespace's own identity; both PATs are set there so [`build-image.yml`](../.gitea/workflows/build-image.yml) can fetch + clone + push without loaning out `sys-gitea-runner` credentials. The caller user must be a Read collaborator on `Charlie/project-charlie`. See [automation.md → Secret placement](automation.md#secret-placement-image-source-repos). | | `sys-host-` (LDAP) | `/root/.git-credentials` + `/root/.docker/config.json` (host) | Root-level operations on each host: `sudo git ...`, `sudo docker pull`. Read-only on `Charlie` org. | The act_runner registration credential is its **own** identity in Gitea. It can pick up jobs and report results; it cannot read other repos. When it needs to clone for `act`'s `uses:` resolution, it uses the `RUNNER_REPO_PAT` materialised by the entrypoint shim.