Files
image-deploy/action.yaml
T
Dave Kowalski 70e1a06816
validate / validate (pull_request) Successful in 4s
fix(image-deploy): monotonic tag guard — refuse backwards newTag writes
Two concurrent release runs can race such that the older build's
image-deploy step finishes after the newer build already committed its
tag. Without an ordering check, last-writer-wins and the manifest
silently reverts to a stale image.

Add _monotonic_check: reads origin/main's current newTag for the image
and exits 0 (with a loud SKIP log) if it is numerically greater than the
tag we are trying to write. The guard runs before the kustomize edit and
again after each rebase in the push-retry loop, so both race shapes are
covered:
- Clone-then-lose: second clone already sees the newer tag → initial
  guard fires before any commit.
- Concurrent-push: both cloned the same base; one pushes first; the
  other rebases, then the post-rebase guard fires before the re-push.

Fixes bug-313bg8bgezp3
2026-08-24 22:59:36 +00:00

151 lines
5.6 KiB
YAML

name: Deploy Image (kustomize image-pin in apps repo)
description: |
Pin an image tag in fritzlab/apps via `kustomize edit set image`, validate the
rendered manifests, and push to apps-repo main. Retries on push conflict.
Refuses to lower the pinned tag (monotonic write guard).
inputs:
image:
description: Full image name without tag (must match an entry in the target kustomization.yaml `images:` block)
required: true
tag:
description: Tag to pin. Defaults to github.run_number when empty.
required: false
default: ''
path:
description: Path inside fritzlab/apps to the manifests dir (e.g. sjc001/infra/chrony/manifests)
required: true
token:
description: CI_BOT_TOKEN with write access to fritzlab/apps
required: true
apps-repo:
description: Apps repo URL (without protocol)
required: false
default: code.fritzlab.net/fritzlab/apps
message:
description: Commit message. Defaults to "deploy <name> #<tag>".
required: false
default: ''
runs:
using: composite
steps:
- name: Pin image and push
shell: bash
env:
IMAGE: ${{ inputs.image }}
TAG_INPUT: ${{ inputs.tag }}
PATH_IN_REPO: ${{ inputs.path }}
TOKEN: ${{ inputs.token }}
APPS_REPO: ${{ inputs.apps-repo }}
MESSAGE: ${{ inputs.message }}
RUN_NUMBER: ${{ github.run_number }}
run: |
set -euo pipefail
TAG="${TAG_INPUT:-$RUN_NUMBER}"
NAME="$(basename "$IMAGE")"
MSG="${MESSAGE:-deploy ${NAME} #${TAG}}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
git clone --depth 1 "https://ci-bot:${TOKEN}@${APPS_REPO}.git" "$WORK"
cd "$WORK"
git config user.name ci-bot
git config user.email ci-bot@fritzlab.net
# Read the numeric newTag currently pinned for IMAGE in origin/main.
# Returns empty string when the image entry is absent.
_origin_tag() {
git -C "$WORK" show "origin/main:${PATH_IN_REPO}/kustomization.yaml" 2>/dev/null \
| awk -v img="$IMAGE" '
/^images:/ { in_b=1 }
in_b && /- name:/ { found=(index($0, img) > 0) }
found && /newTag:/ { gsub(/[^0-9]/, ""); print; exit }
'
}
# Refuse to write a tag older than what origin/main already carries.
# Two concurrent releases can race such that the older build's deploy step
# runs after the newer build already committed its tag; without this guard
# the older run silently reverts the manifest to a stale image.
_monotonic_check() {
local existing
existing="$(_origin_tag)"
if [ -n "$existing" ] && [ "$existing" -gt "$TAG" ] 2>/dev/null; then
echo "SKIP: origin/main already has ${NAME}:${existing} > ${NAME}:${TAG} — refusing backwards write (bug-313bg8bgezp3)"
exit 0
fi
}
cd "$PATH_IN_REPO"
# Monotonic guard before touching the kustomization.
_monotonic_check
kustomize edit set image "${IMAGE}=${IMAGE}:${TAG}"
# Validate the kustomization renders cleanly before we push.
if ! kustomize build . > /dev/null; then
echo "FATAL: kustomize build failed after image pin"
git --no-pager diff
exit 1
fi
if git -C "$WORK" diff --quiet; then
echo "apps repo already on ${NAME}:${TAG}, skipping"
exit 0
fi
# Detect an open PR already targeting the same image:tag in the same
# kustomization.yaml. If one is open, defer to it so the review gate
# is the actual control path — not a race with the direct push.
GITEA_HOST="${APPS_REPO%%/*}"
GITEA_REPO_PATH="${APPS_REPO#*/}"
GITEA_API="https://${GITEA_HOST}/api/v1"
KUSTOMIZATION_FILE="${PATH_IN_REPO}/kustomization.yaml"
BLOCKING_PR=""
PR_NUMS=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${GITEA_REPO_PATH}/pulls?state=open&limit=50" \
| jq -r '.[].number' 2>/dev/null || true)
for pr_num in $PR_NUMS; do
FILES_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${GITEA_REPO_PATH}/pulls/${pr_num}/files" || echo "[]")
if echo "$FILES_JSON" | jq -e \
--arg f "$KUSTOMIZATION_FILE" \
--arg t "$TAG" \
'any(.[]; .filename == $f and (.patch // "" | contains($t)))' \
> /dev/null 2>&1; then
BLOCKING_PR="$pr_num"
break
fi
done
if [ -n "$BLOCKING_PR" ]; then
echo "PR #${BLOCKING_PR} is open and already targets ${NAME}:${TAG} in ${KUSTOMIZATION_FILE}; deferring to PR"
exit 0
fi
git -C "$WORK" add "${PATH_IN_REPO}/kustomization.yaml"
git -C "$WORK" commit -m "$MSG"
# Push with rebase-on-conflict retry: concurrent CI from another image
# repo could have pushed since our clone.
ATTEMPTS=0
until git -C "$WORK" push origin main; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge 3 ]; then
echo "FATAL: push to apps repo failed after ${ATTEMPTS} attempts"
exit 1
fi
echo "push rejected, attempt ${ATTEMPTS}; rebasing and retrying"
git -C "$WORK" pull --rebase origin main
# After rebase origin/main refs are updated; re-check the monotonic
# invariant before attempting to push the rebased commit.
_monotonic_check
sleep $((ATTEMPTS * 2))
done
echo "deployed ${NAME}:${TAG}"