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: # Renovate branches only ever carry version/digest bumps: nothing here can # change the shipped dependency tree, so the audits would just burn runner # time on the same box that serves prod. Static checks still run. if: ${{ !startsWith(github.head_ref, 'renovate/') && !startsWith(github.ref_name, 'renovate/') }} 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: # Same as scan-deps: renovate bumps cannot break panel tests. if: ${{ !startsWith(github.head_ref, 'renovate/') && !startsWith(github.ref_name, 'renovate/') }} 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: # Same as scan-deps: renovate bumps cannot break panel tests. if: ${{ !startsWith(github.head_ref, 'renovate/') && !startsWith(github.ref_name, 'renovate/') }} 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') && !startsWith(github.ref_name, 'renovate/') 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