Every service tracked the mutable `:prod` tag, so a deploy applied whatever that tag happened to name at the time rather than the commit it was deploying. A rollback had no way to state what it was rolling back to, and two deploys of one commit could land different images. CI now publishes an immutable `sha-<commit12>` tag beside `:prod` on main, and re-tags it for every image a push did not rebuild. That re-tag copies the manifest list, so no layer moves. The deploy resolves the immutable tag to a digest and pins the workload to it, and only falls back to the moving tag when the immutable one cannot be resolved -- which it says out loud, because that fallback is the deploy quietly ceasing to be reproducible from its own commit. The image list comes out of the tree with git grep rather than being written out a second time, so adding a service no longer means keeping two lists in step. build also gains the three jobs it was skipping -- scan-deps, test-backend, test-frontend -- so a change that breaks them cannot be tagged at all. The two run blocks where a mid-loop failure was survivable now run under set -euo pipefail: the build loop and the service detector both carried on past an error and could report a green build having produced nothing. The registry password moves from run: substitution into an env: block. A quote, a backtick or a $(...) in the password is parsed as shell before the command ever runs, and a login that failed that way looked exactly like a build that failed. The apply and verify timeouts stay at 45 and 30 minutes. The comments now record the arithmetic that says so rather than leaving the numbers to be raised on the next scare: three no-op helm upgrades run 3-5 minutes, one broken release is a single 10 minute rollback because the loop aborts on the first failure, and the apply loop itself is about a minute. That is roughly 15 minutes of work against a 45 minute budget. verify is 32 workloads at 8 wide -- four waves of 300 seconds, 20 minutes -- which leaves room for two serial rollbacks, and only becomes derivable at 45 once rollback_workloads is parallelised.
758 lines
30 KiB
YAML
758 lines
30 KiB
YAML
name: ci
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- "**"
|
|
pull_request:
|
|
workflow_dispatch:
|
|
|
|
# Every job here is checkout plus local tools. The token needs to read the tree
|
|
# and nothing else, and saying so keeps a future step that reaches for the API
|
|
# from quietly holding a token that can write to the repository.
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
|
|
env:
|
|
REGISTRY: gcr.forust.xyz
|
|
|
|
jobs:
|
|
lint-compose:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
# Structure check for every committed Compose file, active or not.
|
|
# Interpolation, env-file and bind-mount resolution are all switched off,
|
|
# because inactive stacks have no .env here and would only fail on their
|
|
# ${VAR:?} guards. Active stacks get the full check with interpolation in
|
|
# the deploy workflow, where the real .env files live.
|
|
- name: Validate Compose files
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
source .gitea/workflows/compose-lint.sh
|
|
|
|
mapfile -t safe_flags < <(compose_safe_flags)
|
|
echo "docker compose config ${safe_flags[*]-}"
|
|
|
|
mapfile -t files < <(compose_files)
|
|
if [ "${#files[@]}" -eq 0 ]; then
|
|
echo "No Compose files found."
|
|
exit 0
|
|
fi
|
|
|
|
failed=0
|
|
for f in "${files[@]}"; do
|
|
if ! out="$(validate_compose_file "$f" ${safe_flags[@]+"${safe_flags[@]}"} 2>&1)"; then
|
|
failed=1
|
|
echo "::error file=${f}::$(printf '%s' "$out" | head -1)"
|
|
fi
|
|
done
|
|
|
|
if [ "$failed" -ne 0 ]; then
|
|
echo "Compose validation failed."
|
|
exit 1
|
|
fi
|
|
echo "checked ${#files[@]} Compose file(s)"
|
|
|
|
lint-actionlint:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Lint Gitea Actions workflows with actionlint
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh actionlint)"
|
|
export PATH="$tools_dir:$PATH"
|
|
actionlint -config-file .gitea/actionlint.yaml -color .gitea/workflows/*.yaml
|
|
|
|
lint-shellcheck:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Lint shell scripts with ShellCheck
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh shellcheck)"
|
|
export PATH="$tools_dir:$PATH"
|
|
# userbot/ is a git subtree synced from forust/userbot, so its shell
|
|
# scripts are upstream's to maintain, not ours. Linting them would let a
|
|
# routine subtree pull turn the deploy gate red on code we do not own.
|
|
mapfile -t scripts < <(
|
|
git ls-files '*.sh' ':(glob)**/*.bash' ':!userbot/**'
|
|
)
|
|
if [ "${#scripts[@]}" -eq 0 ]; then
|
|
echo "No shell scripts found."
|
|
exit 0
|
|
fi
|
|
shellcheck --external-sources --source-path=SCRIPTDIR --severity=style "${scripts[@]}"
|
|
|
|
lint-prettier:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Check formatting with Prettier
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh prettier)"
|
|
export PATH="$tools_dir:$PATH"
|
|
|
|
mapfile -t prettier_files < <(
|
|
git ls-files \
|
|
| grep -E '\.(md|json|ya?ml|html|css)$' \
|
|
| grep -Ev '^(\.docs/|\.zed/|errorpages/html/|homepages/(forust_files|xdfnx_files)/)'
|
|
)
|
|
|
|
if [ "${#prettier_files[@]}" -eq 0 ]; then
|
|
echo "No Prettier-managed files found."
|
|
exit 0
|
|
fi
|
|
|
|
prettier --check --ignore-unknown "${prettier_files[@]}"
|
|
|
|
lint-ruff:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Lint and format-check Python with Ruff
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh ruff)"
|
|
export PATH="$tools_dir:$PATH"
|
|
ruff check .
|
|
ruff format --check .
|
|
|
|
lint-yaml:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Lint YAML syntax
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh yamllint)"
|
|
export PATH="$tools_dir:$PATH"
|
|
|
|
mapfile -t yaml_files < <(
|
|
git ls-files '*.yaml' '*.yml' \
|
|
':!node_modules/**' \
|
|
':!**/.venv/**'
|
|
)
|
|
|
|
if [ "${#yaml_files[@]}" -eq 0 ]; then
|
|
echo "No YAML files found."
|
|
exit 0
|
|
fi
|
|
|
|
yamllint -c .yamllint "${yaml_files[@]}"
|
|
|
|
lint-dockerfiles:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Lint Dockerfiles
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh hadolint)"
|
|
export PATH="$tools_dir:$PATH"
|
|
|
|
mapfile -t dockerfiles < <(
|
|
git ls-files ':(glob)**/Dockerfile' ':(glob)**/Dockerfile.*'
|
|
)
|
|
|
|
if [ "${#dockerfiles[@]}" -eq 0 ]; then
|
|
echo "No Dockerfiles found."
|
|
exit 0
|
|
fi
|
|
|
|
hadolint -c .hadolint.yaml "${dockerfiles[@]}"
|
|
|
|
# Known, accepted, and recorded. Each line is a real advisory against a
|
|
# package we build into the panel image, kept in this workflow rather than in
|
|
# the package manifest so that a subtree sync from forust/userbot cannot
|
|
# silently widen the exemption.
|
|
#
|
|
# starlette is the reason this job is not simply "fail on everything":
|
|
# fastapi 0.115.12 pins `starlette<0.47.0`, and the fixes for the last four
|
|
# below need 0.49.1 through 1.3.1, so clearing them means a jump from fastapi
|
|
# 0.115.12 to 0.141.x. That is upstream's call, not a drive-by in a lint
|
|
# commit. Of the seven, four are reachable here in principle: 1942 is a
|
|
# crafted Range header hitting FileResponse, and the panel serves its built
|
|
# SPA through exactly that; 249 is request.form() ignoring max_fields for
|
|
# x-www-form-urlencoded, which is the login form; 1941 is a large multipart
|
|
# body blocking the event loop; 161 and 248 are unvalidated Host and request
|
|
# path reaching request.url. 2280 needs HTTPEndpoint, which the panel does
|
|
# not use, and 2281 is Windows-only, and this deploys on Linux.
|
|
#
|
|
# The panel answers on userbot.workstation.internal and has no public
|
|
# forust.xyz route, which is what keeps the four reachable ones from being
|
|
# an internet-facing DoS. It still manages Telegram credentials.
|
|
#
|
|
# Deleting an entry here is how you accept a new advisory, so the diff says
|
|
# so out loud.
|
|
scan-deps:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 15
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Audit the Python dependencies that ship in the image
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh pip-audit)"
|
|
export PATH="$tools_dir:$PATH"
|
|
# requirements.txt, not requirements-dev.txt: this is what the image
|
|
# installs, and the test tooling is not a shipped attack surface.
|
|
pip-audit -r userbot/panel/backend/requirements.txt --strict \
|
|
--ignore-vuln CVE-2025-67720 \
|
|
--ignore-vuln PYSEC-2026-161 \
|
|
--ignore-vuln PYSEC-2026-1941 \
|
|
--ignore-vuln PYSEC-2026-1942 \
|
|
--ignore-vuln PYSEC-2026-2280 \
|
|
--ignore-vuln PYSEC-2026-2281 \
|
|
--ignore-vuln PYSEC-2026-248 \
|
|
--ignore-vuln PYSEC-2026-249
|
|
|
|
# devDependencies are excluded on purpose. `npm audit` on the full tree
|
|
# reports 7 findings, and every one of them is a build- or test-time
|
|
# package: the esbuild CORS advisory needs a vite dev server serving to
|
|
# the internet, and nanoid's infinite loop needs a custom generator
|
|
# called with size 0, which postcss does not do. None of them are in the
|
|
# 91 kB bundle the panel serves. The one production finding, devalue
|
|
# via svelte, is moderate, which is where --audit-level draws the line;
|
|
# this fails on the next high or critical one.
|
|
- name: Audit the production npm dependencies
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
# The pinned node, not whatever the runner has. Its system node is a
|
|
# rolling Arch package: during this very push its npm was missing
|
|
# entirely, and an hour later it was npm 12 on node 26. Both are the
|
|
# wrong major anyway — the panel image is node:22-alpine.
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh node)"
|
|
export PATH="$tools_dir:$PATH"
|
|
cd userbot/panel/frontend
|
|
npm ci
|
|
npm audit --omit=dev --audit-level=high
|
|
|
|
test-backend:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 15
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
# 25 tests over the panel's pydantic models, its auth flow, the SPA
|
|
# fallback and the Kubernetes client it shells out with. They existed and
|
|
# had never been executed by anything.
|
|
#
|
|
# Note that userbot/ is a subtree synced from forust/userbot, so a routine
|
|
# sync can turn this red on upstream's code. Unlike the shellcheck job,
|
|
# which skips that tree because style disagreements there are ours to
|
|
# lose, a failing test here is a real defect in a service we deploy.
|
|
- name: Run the panel backend test suite
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh uv)"
|
|
export PATH="$tools_dir:$PATH"
|
|
|
|
# A venv in a temp dir rather than a checked-out one: the runner is
|
|
# shared, and a leftover .venv would let a dependency the
|
|
# requirements no longer pin still satisfy an import.
|
|
#
|
|
# --python is not optional. uv otherwise takes whatever interpreter it
|
|
# finds first, and which one that is depends on the machine: this
|
|
# runner runs jobs on the host, where the only interpreter is 3.14,
|
|
# and pyrogram's sync.py calls the bare asyncio.get_event_loop() that
|
|
# 3.14 no longer auto-creates, so three tests fail at collection. The
|
|
# image is python:3.13-slim, so 3.13 is also the version worth
|
|
# testing: uv fetches a managed build of it when the host has none,
|
|
# which is what makes this job independent of the runner.
|
|
venv="$(mktemp -d)/venv"
|
|
uv venv --python 3.13 --quiet "$venv"
|
|
uv pip install --quiet --python "$venv/bin/python" \
|
|
-r userbot/panel/backend/requirements-dev.txt
|
|
|
|
# `python -m`, not bare `pytest`: the tests import `app.*` relative to
|
|
# the backend directory, which only works if the cwd is on sys.path,
|
|
# and only `python -m` puts it there.
|
|
cd userbot/panel/backend
|
|
"$venv/bin/python" -m pytest tests/ -q
|
|
|
|
test-frontend:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 15
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
# One `npm ci` for both checks below: it is by far the slowest part of
|
|
# this job, and a second one would learn nothing the first did not.
|
|
#
|
|
# `npm ci`, not `npm install`, for the same reason the Dockerfile uses it:
|
|
# the lockfile is what makes the tree that gets checked the tree that
|
|
# gets shipped.
|
|
- name: Type-check and test the panel frontend
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
# The pinned node, not whatever the runner has. Its system node is a
|
|
# rolling Arch package: during this very push its npm was missing
|
|
# entirely, and an hour later it was npm 12 on node 26. Both are the
|
|
# wrong major anyway — the panel image is node:22-alpine.
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh node)"
|
|
export PATH="$tools_dir:$PATH"
|
|
cd userbot/panel/frontend
|
|
npm ci
|
|
|
|
# svelte-check has been a devDependency all along with no script
|
|
# pointing at it, so the type errors it reports had nowhere to
|
|
# surface. It is clean today, which is the only reason it can be a
|
|
# gate: it stops at whatever upstream introduces rather than
|
|
# reporting a backlog we inherited.
|
|
npm run check
|
|
npm test
|
|
|
|
validate:
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 20
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
|
|
- name: Validate Kubernetes manifests against JSON schemas
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
tools_dir="$(bash .gitea/workflows/install-ci-tools.sh kubeconform)"
|
|
export PATH="$tools_dir:$PATH"
|
|
|
|
mapfile -t manifests < <(
|
|
git ls-files ':(glob)**/k8s/**/*.yaml' ':(glob)**/k8s/**/*.yml' \
|
|
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
|
|
)
|
|
|
|
if [ "${#manifests[@]}" -eq 0 ]; then
|
|
echo "No Kubernetes manifests found."
|
|
exit 0
|
|
fi
|
|
|
|
kubeconform \
|
|
-strict \
|
|
-ignore-missing-schemas \
|
|
-summary \
|
|
"${manifests[@]}"
|
|
|
|
# kubeconform has no schemas for CRDs, so every IngressRoute, Certificate,
|
|
# PrometheusRule, Middleware, ServersTransport and ServiceMonitor is silently
|
|
# skipped above. The live API server knows the real CRD schemas (and runs the
|
|
# cert-manager / Traefik admission webhooks), so validate there too.
|
|
#
|
|
# Only services marked with a k8s/active marker are checked: server-side
|
|
# dry-run needs the target namespace to exist, and inactive services are not
|
|
# deployed. Services being enabled for the first time are still covered by
|
|
# the JSON-schema pass above.
|
|
#
|
|
# Main pushes only. `--dry-run=server` persists nothing, but it does execute
|
|
# the admission webhooks of the production API server, so anyone able to open
|
|
# a pull request would be able to run arbitrary manifest content through
|
|
# cert-manager and Traefik. A pull request has nothing to gain from it either:
|
|
# only main is ever deployed, and this job runs to completion before the
|
|
# deploy workflow is allowed to start, so a bad CRD is still caught before
|
|
# anything reaches the cluster -- just on the push rather than on the PR.
|
|
- name: Note the server-side check is not running here
|
|
if: github.event_name == 'pull_request' || github.ref != 'refs/heads/main'
|
|
shell: bash
|
|
run: |
|
|
echo "::notice::Skipping the server-side dry-run. It executes the cert-manager and" \
|
|
"Traefik admission webhooks against the production API server, so it is limited" \
|
|
"to pushes to main. CRDs are still schema-checked by kubeconform above, and the" \
|
|
"server-side pass still runs on main before the deploy."
|
|
|
|
- name: Validate active manifests against the live API server
|
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if ! kubectl get --raw='/readyz' --request-timeout=10s >/dev/null 2>&1; then
|
|
echo "::warning::Cluster unreachable — skipped server-side validation of CRDs (IngressRoute, Certificate, PrometheusRule). Review manifest changes manually."
|
|
exit 0
|
|
fi
|
|
|
|
mapfile -t k8s_dirs < <(
|
|
git ls-files '*.yaml' '*.yml' \
|
|
| grep -E '(^|/)k8s/' \
|
|
| sed -E 's#((^|.*/)k8s)/.*#\1#' \
|
|
| sort -u
|
|
)
|
|
|
|
manifests=()
|
|
kustomize_apps=()
|
|
for dir in "${k8s_dirs[@]}"; do
|
|
if [ ! -f "${dir}/active" ]; then
|
|
echo "skip (no k8s/active): ${dir}"
|
|
continue
|
|
fi
|
|
if [ -f "${dir}/overlays/prod/kustomization.yaml" ]; then
|
|
kustomize_apps+=("${dir}/overlays/prod")
|
|
elif [ -f "${dir}/base/kustomization.yaml" ]; then
|
|
kustomize_apps+=("${dir}/base")
|
|
else
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] && manifests+=("$f")
|
|
done < <(
|
|
git ls-files "${dir}/*.yaml" "${dir}/*.yml" \
|
|
| grep -Ev '(^|/)(kustomization\.ya?ml|.*\.example\.ya?ml|.*values\.ya?ml|patch-.*\.ya?ml)$'
|
|
)
|
|
fi
|
|
done
|
|
|
|
echo "server-side dry-run: ${#manifests[@]} manifests, ${#kustomize_apps[@]} kustomize apps"
|
|
failed=0
|
|
for m in ${manifests[@]+"${manifests[@]}"}; do
|
|
if ! out="$(kubectl apply --dry-run=server -f "$m" 2>&1)"; then
|
|
failed=1
|
|
echo "::error file=${m}::$(printf '%s' "$out" | head -1)"
|
|
fi
|
|
done
|
|
for k in ${kustomize_apps[@]+"${kustomize_apps[@]}"}; do
|
|
if ! out="$(kubectl apply -k "$k" --dry-run=server 2>&1)"; then
|
|
failed=1
|
|
echo "::error file=${k}::$(printf '%s' "$out" | head -1)"
|
|
fi
|
|
done
|
|
|
|
if [ "$failed" -ne 0 ]; then
|
|
echo "Server-side validation failed. The API server (or an admission webhook) rejected these manifests."
|
|
exit 1
|
|
fi
|
|
echo "server-side dry-run: all active manifests accepted by the API server"
|
|
|
|
build:
|
|
needs:
|
|
# scan-deps and the two test jobs were missing here, so a commit with a
|
|
# known-vulnerable dependency or a failing test still moved the :prod tag.
|
|
# The deploy was blocked either way - it requires the whole workflow to
|
|
# have succeeded - but the tag had already moved, and the next deploy to
|
|
# run resolved it. Publishing and passing the checks are the same gate.
|
|
[
|
|
lint-actionlint,
|
|
lint-shellcheck,
|
|
lint-compose,
|
|
lint-prettier,
|
|
lint-ruff,
|
|
lint-yaml,
|
|
lint-dockerfiles,
|
|
scan-deps,
|
|
test-backend,
|
|
test-frontend,
|
|
validate,
|
|
]
|
|
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
|
runs-on: [self-hosted, linux, arch, homelab]
|
|
timeout-minutes: 60
|
|
outputs:
|
|
services: ${{ steps.services.outputs.services }}
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Detect changed docker-built services
|
|
id: services
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
base="${{ github.event.before }}"
|
|
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then
|
|
base="$(git rev-list --max-parents=0 HEAD)"
|
|
fi
|
|
|
|
# A failed diff used to leave changed_files empty, which reads exactly
|
|
# like "nothing to build": the job went green having built nothing and
|
|
# the tag never moved. The status is checked, not assumed.
|
|
if ! changed="$(git diff --name-only "$base" "${GITHUB_SHA}")"; then
|
|
echo "::error::cannot diff ${base}..${GITHUB_SHA}"
|
|
exit 1
|
|
fi
|
|
mapfile -t changed_files <<<"$changed"
|
|
|
|
services=()
|
|
|
|
add_service() {
|
|
local name="$1"
|
|
local seen=0
|
|
for existing in "${services[@]}"; do
|
|
if [ "$existing" = "$name" ]; then
|
|
seen=1
|
|
break
|
|
fi
|
|
done
|
|
if [ "$seen" -eq 0 ]; then
|
|
services+=("$name")
|
|
fi
|
|
}
|
|
|
|
for file in "${changed_files[@]}"; do
|
|
case "$file" in
|
|
dtek_notif/*)
|
|
add_service dtek_notif
|
|
;;
|
|
errorpages/*)
|
|
add_service errorpages
|
|
;;
|
|
userbot/*)
|
|
add_service userbot
|
|
;;
|
|
homepages/*)
|
|
add_service homepages
|
|
;;
|
|
edu_master/phpsessid-bot/*|edu_master/webinar-checker/*|edu_master/compose.yaml)
|
|
add_service edu_master
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [ "${#services[@]}" -eq 0 ]; then
|
|
echo "No docker-built services changed."
|
|
echo "services=" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
printf '%s\n' "${services[@]}" | tee /tmp/services.txt
|
|
echo "services=$(paste -sd, /tmp/services.txt)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to registry
|
|
if: steps.services.outputs.services != ''
|
|
shell: bash
|
|
# Through env, not by substitution into the script. A secret written
|
|
# into a run: block is pasted into the shell source before bash parses
|
|
# it, so a password containing a quote, a backtick or $(...) becomes
|
|
# code that runs. Masking the value in the log does not prevent that.
|
|
env:
|
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
run: |
|
|
set -euo pipefail
|
|
printf '%s' "$REGISTRY_PASSWORD" | docker login "${REGISTRY}" \
|
|
-u "$REGISTRY_USERNAME" \
|
|
--password-stdin
|
|
|
|
- name: Build and push changed images
|
|
if: steps.services.outputs.services != ''
|
|
shell: bash
|
|
run: |
|
|
# This step was the one run: block in the workflow without it, and it
|
|
# is the one that cannot afford it: a docker push that failed partway
|
|
# through the loop used to be followed by more pushes, the loop's exit
|
|
# status came from the last one, and the job went green with half the
|
|
# images missing from the registry.
|
|
set -euo pipefail
|
|
IFS=, read -r -a services <<< "${{ steps.services.outputs.services }}"
|
|
|
|
# Tags for this push. The commit-pinned name is the point of this
|
|
# step: the deploy resolves it in preference to :prod, so a deploy
|
|
# that sat in the queue behind a later push still gets the build of
|
|
# the commit CI validated, instead of whatever :prod points at by the
|
|
# time it runs. See render_pinned in deploy-lib.sh.
|
|
commit_tag=""
|
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
|
commit_tag="sha-${GITHUB_SHA:0:12}"
|
|
fi
|
|
|
|
set_tags() {
|
|
tags=()
|
|
case "${GITHUB_REF_NAME}" in
|
|
main) tags+=("main" "prod") ;;
|
|
dev) tags+=("dev") ;;
|
|
esac
|
|
if [ -n "$commit_tag" ]; then
|
|
tags+=("$commit_tag")
|
|
fi
|
|
}
|
|
|
|
for service in "${services[@]}"; do
|
|
case "$service" in
|
|
dtek_notif)
|
|
image="${REGISTRY}/forust/dtek-notif"
|
|
set_tags
|
|
build_args=()
|
|
for tag in "${tags[@]}"; do
|
|
build_args+=(-t "${image}:${tag}")
|
|
done
|
|
docker build \
|
|
--cache-from "type=registry,ref=${image}:buildcache" \
|
|
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
|
|
"${build_args[@]}" dtek_notif
|
|
for tag in "${tags[@]}"; do
|
|
docker push "${image}:${tag}"
|
|
done
|
|
;;
|
|
errorpages)
|
|
image="${REGISTRY}/forust/error-pages"
|
|
set_tags
|
|
build_args=()
|
|
for tag in "${tags[@]}"; do
|
|
build_args+=(-t "${image}:${tag}")
|
|
done
|
|
docker build \
|
|
--cache-from "type=registry,ref=${image}:buildcache" \
|
|
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
|
|
"${build_args[@]}" errorpages
|
|
for tag in "${tags[@]}"; do
|
|
docker push "${image}:${tag}"
|
|
done
|
|
;;
|
|
userbot)
|
|
set_tags
|
|
for target in runtime panel; do
|
|
case "$target" in
|
|
runtime)
|
|
context="userbot"
|
|
image="${REGISTRY}/forust/userbot"
|
|
;;
|
|
panel)
|
|
context="userbot/panel"
|
|
image="${REGISTRY}/forust/userbot-panel"
|
|
;;
|
|
esac
|
|
build_args=()
|
|
for tag in "${tags[@]}"; do
|
|
build_args+=(-t "${image}:${tag}")
|
|
done
|
|
docker build \
|
|
--cache-from "type=registry,ref=${image}:buildcache" \
|
|
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
|
|
"${build_args[@]}" "$context"
|
|
for tag in "${tags[@]}"; do
|
|
docker push "${image}:${tag}"
|
|
done
|
|
done
|
|
;;
|
|
homepages)
|
|
for variant in forust xdfnx; do
|
|
case "$variant" in
|
|
forust)
|
|
image="${REGISTRY}/forust/forust-homepage"
|
|
;;
|
|
xdfnx)
|
|
image="${REGISTRY}/forust/xdfnx-homepage"
|
|
;;
|
|
esac
|
|
set_tags
|
|
build_args=()
|
|
for tag in "${tags[@]}"; do
|
|
build_args+=(-t "${image}:${tag}")
|
|
done
|
|
docker build \
|
|
--cache-from "type=registry,ref=${image}:buildcache" \
|
|
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
|
|
"${build_args[@]}" -f "homepages/Dockerfile.${variant}" homepages
|
|
for tag in "${tags[@]}"; do
|
|
docker push "${image}:${tag}"
|
|
done
|
|
done
|
|
;;
|
|
edu_master)
|
|
for variant in session-keeper webinar-checker; do
|
|
case "$variant" in
|
|
session-keeper)
|
|
context="edu_master/phpsessid-bot"
|
|
image="${REGISTRY}/forust/session-keeper"
|
|
;;
|
|
webinar-checker)
|
|
context="edu_master/webinar-checker"
|
|
image="${REGISTRY}/forust/webinar-checker"
|
|
;;
|
|
esac
|
|
set_tags
|
|
build_args=()
|
|
for tag in "${tags[@]}"; do
|
|
build_args+=(-t "${image}:${tag}")
|
|
done
|
|
docker build \
|
|
--cache-from "type=registry,ref=${image}:buildcache" \
|
|
--cache-to "type=registry,ref=${image}:buildcache,mode=max" \
|
|
"${build_args[@]}" "$context"
|
|
for tag in "${tags[@]}"; do
|
|
docker push "${image}:${tag}"
|
|
done
|
|
done
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Every image the tree names has to carry the commit-pinned name, not only
|
|
# the ones this push rebuilt. A push that touches nothing but manifests
|
|
# builds nothing, and its deploy would then find no commit-pinned tag to
|
|
# resolve and quietly fall back to the moving :prod - which is the whole
|
|
# failure the commit-pinned name exists to remove.
|
|
#
|
|
# Re-tagging copies the manifest list and transfers no layers, so pinning
|
|
# six images that already exist costs six registry writes.
|
|
#
|
|
# The list is derived from the tree rather than written out here, so an
|
|
# image added to a manifest is covered without a second place to update.
|
|
- name: Pin the commit name on the images this push did not rebuild
|
|
if: github.ref_name == 'main'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
commit_tag="sha-${GITHUB_SHA:0:12}"
|
|
mapfile -t repos < <(
|
|
git grep -hoE 'gcr\.forust\.xyz/forust/[A-Za-z0-9._-]+' -- '*.yaml' '*.yml' \
|
|
| sort -u
|
|
)
|
|
if [ "${#repos[@]}" -eq 0 ]; then
|
|
echo "No own images referenced by the tree."
|
|
exit 0
|
|
fi
|
|
echo "pinning ${#repos[@]} image(s) to $commit_tag"
|
|
for repo in "${repos[@]}"; do
|
|
if docker buildx imagetools inspect "$repo:$commit_tag" >/dev/null 2>&1; then
|
|
echo " already built by this push: ${repo##*/}"
|
|
continue
|
|
fi
|
|
if ! docker buildx imagetools inspect "$repo:prod" >/dev/null 2>&1; then
|
|
echo " WARNING: ${repo##*/} has no :prod to pin and no build produced it"
|
|
continue
|
|
fi
|
|
docker buildx imagetools create --tag "$repo:$commit_tag" "$repo:prod"
|
|
echo " pinned ${repo##*/}"
|
|
done
|