Adjust build pipeline

This commit is contained in:
Alexandru Macocian
2026-05-22 13:34:17 +02:00
parent eda07b9c30
commit 27e13d92a3
3 changed files with 299 additions and 237 deletions
+229 -187
View File
@@ -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/<name>`)
# produce images here; the deployment repos (`Charlie/<stack>`) 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/<stack>`) 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://<caller-user>:${{ 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<<EOF"
for t in "${!seen[@]}"; do echo " ${IMAGE}:${t}"; done
echo "EOF"
} >> "$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 <<EXTRA_TAGS_EOF
${{ inputs.extra-tags }}
EXTRA_TAGS_EOF
# 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_EOF
${{ inputs.build-args }}
BUILD_ARGS_EOF
# Stash the tag list for the push step (separate SSH call,
# so no shared shell variables). Same workdir → same host.
: > .charlie-tags
for t in "${!seen[@]}"; do
printf '%s\n' "${image}:${t}" >> .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
+69 -49
View File
@@ -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/<stack>`) 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/<stack>`) 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/<github.repository>` (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/<stack>`). |
| `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/<owner>/<repo>` | 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/<stack>`). |
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
# <owner>/<repo>/.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`
+1 -1
View File
@@ -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-<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.