Image building steps

This commit is contained in:
Alexandru Macocian
2026-05-22 12:54:43 +02:00
parent 5f8f544feb
commit eda07b9c30
4 changed files with 438 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ manual-fallback recipes, and host prerequisites: [docs/automation.md](../docs/au
| Path | Purpose |
|---|---|
| [`workflows/deploy-stack.yml`](workflows/deploy-stack.yml) | `git pull` + (optional) SOPS decrypt + per-host compose resolution + `docker compose up -d`. Replaces the old per-repo `deploy.sh`. |
| [`workflows/build-image.yml`](workflows/build-image.yml) | Build + push a Docker image from a source repo to the Gitea container registry. Tags `latest` + UTC timestamp (`YYYY-MM-DDTHHMMSSZ`). Called from `amacocian/<name>` repos. |
### Composite actions
+313
View File
@@ -0,0 +1,313 @@
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.
#
# Caller pattern (from a source repo, e.g. amacocian/newsletter2atom):
#
# on:
# push:
# branches: [main]
# 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
# secrets: inherit
#
# Tags pushed on every successful build:
# - `latest`
# - `YYYY-MM-DDTHHMMSSZ` (UTC build timestamp, immutable)
#
# Optional extra tags (e.g. semver, branch name, short SHA) can be supplied
# 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`:
#
# 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
#
# Kept as two distinct PATs so the high-privilege package-write token is
# never used for git over HTTPS (and vice versa).
on:
workflow_call:
inputs:
image:
description: |
Fully-qualified image name without tag, e.g.
`gitea.alexandru.macocian.me/amacocian/newsletter2atom`.
Defaults to `gitea.alexandru.macocian.me/${{ github.repository }}`
(lowercased), which matches the convention where the caller repo
is the image's home.
required: false
type: string
default: ""
context:
description: Docker build context. Relative to the caller repo root.
required: false
type: string
default: "."
dockerfile:
description: Path to the Dockerfile, relative to `context`.
required: false
type: string
default: "Dockerfile"
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 `=`.
required: false
type: string
default: ""
extra-tags:
description: |
Additional tags (one per line) to push alongside `latest` and
the UTC timestamp. Whitespace-trimmed; empty lines and `#`
comments ignored. The image reference itself is NOT included —
just the tag portion (e.g. `v1.2.3`, `main`, `pr-42`).
required: false
type: string
default: ""
push:
description: |
Push the built image to the registry. Set to `false` for
build-only smoke tests (e.g. PR validation).
required: false
type: boolean
default: true
ref:
description: Git ref to check out in the caller repo. Defaults to the triggering ref.
required: false
type: string
default: ""
secrets:
RUNNER_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`.
required: true
RUNNER_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
never exposed during git clone (and vice versa).
required: true
outputs:
image:
description: Full image reference without tag (e.g. `gitea.../amacocian/foo`).
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:
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
id: resolve
shell: bash
env:
IMAGE_INPUT: ${{ inputs.image }}
REF_INPUT: ${{ inputs.ref }}
run: |
set -euo pipefail
image="$IMAGE_INPUT"
if [ -z "$image" ]; then
# github.repository on Gitea is `<owner>/<repo>`; lowercase to
# satisfy registry naming rules (mixed case in repo names is
# legal on Gitea but illegal as an image name).
repo="$(printf '%s' "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')"
image="gitea.alexandru.macocian.me/${repo}"
fi
ref="$REF_INPUT"
if [ -z "$ref" ]; then
ref="${GITHUB_REF_NAME:-main}"
fi
# UTC ISO-ish, filename-/tag-safe: 2026-05-22T095126Z
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}"
- 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: 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"
- 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}"
- 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}"
- name: Logout + cleanup
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
+123
View File
@@ -43,6 +43,7 @@ runs `step ca bootstrap` at container start.
| Kind | Path | Purpose |
|---|---|---|
| Reusable workflow | [`.gitea/workflows/deploy-stack.yml`](../.gitea/workflows/deploy-stack.yml) | The deployment task. Opens SSH session → per-host compose resolution + `git fetch && reset` + SOPS decrypt + `docker compose up -d` + cleanup, all on the target host. |
| Reusable workflow | [`.gitea/workflows/build-image.yml`](../.gitea/workflows/build-image.yml) | Build + push a Docker image from a source repo (`amacocian/<name>`) to the Gitea container registry. Tags with `latest` and a UTC timestamp (`YYYY-MM-DDTHHMMSSZ`); deployment repos pin the timestamp tag to roll out. |
| Composite action | [`.gitea/actions/charlie-ssh-open`](../.gitea/actions/charlie-ssh-open/action.yml) | Mints a 5-min SSH user cert for `sys-gitea-runner`, opens an SSH ControlMaster session to the target. Exports `CHARLIE_SSH_*` env to subsequent steps. |
| Composite action | [`.gitea/actions/charlie-ssh-target`](../.gitea/actions/charlie-ssh-target/action.yml) | Runs a command on the target through the multiplex socket. Optional `workdir:` cd's into a directory first. |
| Composite action | [`.gitea/actions/charlie-ssh-close`](../.gitea/actions/charlie-ssh-close/action.yml) | Tears down the multiplex + wipes the ephemeral cert. Idempotent; intended for `if: always()`. |
@@ -197,6 +198,128 @@ jobs:
Requires the `RUNNER_REPO_PAT` secret to be in scope (set at org level;
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.
| Input | Required | Default | Description |
|---|---|---|---|
| `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`. |
| `build-args` | no | `""` | Multi-line `KEY=VALUE` list. Empty lines and `#` comments ignored. |
| `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. |
Every successful build pushes **two** tags:
| Tag | Mutability | Purpose |
|---|---|---|
| `latest` | mutable | Convenience for local pulls and one-off `docker run`. **Never reference from a stack compose file** — there's no way for `deploy-stack.yml` to know a new digest landed. |
| `YYYY-MM-DDTHHMMSSZ` (UTC) | immutable | The tag deployment repos pin. Bumping it in `docker-compose.yml` and pushing the stack triggers a redeploy. Sortable lexicographically. |
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. |
Requires two `sys-gitea-runner` PATs — see [Secret placement](#secret-placement-image-source-repos) below.
### Caller — minimal
```yaml
# amacocian/newsletter2atom/.gitea/workflows/build.yaml
name: Build image
on:
push:
branches: [main]
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
secrets: inherit
```
### Caller — with build args and an extra tag
```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
with:
dockerfile: docker/Dockerfile
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
extra-tags: |
${{ github.ref_name }}
secrets: inherit
```
### Runner-host prerequisites
Building runs **on the runner**, not on a remote host. The runner container
therefore needs:
| 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`. |
Builds accumulate base-image layers in the runner 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.
### 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.
Set once, 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. |
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.
#### Recipe — provisioning the registry PAT (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.
## Inputs reference — `charlie-ssh-open`
| Input | Required | Default | Description |
+1
View File
@@ -80,6 +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). |
| `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.