Files
svcforge/scripts/bump-digests.sh
T
Nguyen Minh Phuc c537073c21
ci / dockerfile (push) Has been cancelled
ci / types (push) Has been cancelled
ci / lint (push) Has been cancelled
ci / security (push) Has been cancelled
ci / chart (push) Has been cancelled
ci / image (api) (push) Has been cancelled
ci / image (reconciler) (push) Has been cancelled
ci / image (worker) (push) Has been cancelled
ci / integration (push) Has been cancelled
ci / unit (push) Has been cancelled
ci / bump (push) Has been cancelled
deps: everything to latest stable
Python 3.12 -> 3.14, postgres 16 -> 18, uv 0.5.11 -> 0.11.29,
trivy 0.58.1 -> 0.72.0, gitleaks 8.21.2 -> 8.30.1, yq 4.44.6 -> 4.53.3,
and every action re-pinned to the SHA of its latest tag (checkout v7,
setup-uv v8, buildx v4, login v4, hadolint v3.3.0). helm stays 3.21.3:
already current for 3.x, and helm 4 is a breaking change, not a CVE fix.

trivy mattered most. A vulnerability scanner fourteen minor versions behind is
the one stale pin that hides all the others.

ruff target-version is deliberately py313 while the runtime is 3.14. It
controls the syntax the formatter may emit, and at py314 it rewrites
'except (A, B):' into PEP 758's unparenthesized form — which reads exactly
like Python 2's 'except E, name:' and is a hard SyntaxError below 3.14. No
semantic gain, real readability cost, in a repo meant to be read.

Verified on 3.14: ruff, ruff format, mypy --strict, 166 tests, helm lint,
bandit, pip-audit. The digest guard still rejects placeholder digests.

Risk carried knowingly: the bumped actions run on node24. If act_runner only
provides node20, every job fails at action startup and this commit is the
revert.
2026-07-19 09:39:38 +00:00

111 lines
4.0 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# CI's last act.
#
# Resolves the digest each service's commit-SHA tag points at, writes those digests into
# deploy/chart/values.yaml, and commits. That commit is the deploy: ArgoCD is watching
# master and picks it up. This script does not, and must not, talk to the cluster.
#
# Called by .gitea/workflows/ci.yaml on master only. Runnable by hand for a re-bump:
# REGISTRY=gitea.oci-oci.duckdns.org IMAGE_NS=gitea_admin IMAGE_TAG=<sha> ./scripts/bump-digests.sh
#
# -e a failed inspect must not lead to committing a stale digest
# -u an unset REGISTRY would silently resolve the wrong image
# -o pipefail the digest comes out of a pipe; without this, a failing inspect that pipes
# into a successful grep exits 0 and writes garbage
set -euo pipefail
: "${REGISTRY:?REGISTRY must be set}"
: "${IMAGE_NS:?IMAGE_NS must be set}"
: "${IMAGE_TAG:?IMAGE_TAG must be set (the commit sha the images were built from)}"
SERVICES=(api worker reconciler)
CHART_VALUES="deploy/chart/values.yaml"
WORKDIR="$(mktemp -d)"
cleanup() {
rm -rf "${WORKDIR}"
}
trap cleanup EXIT
# yq as a checksum-pinned binary, NOT `docker run -v "$PWD:/work"`.
#
# The container form cannot see the checkout on the Gitea runner: the -v source path is
# resolved by the dind sidecar's daemon, not by the job container, so yq reported
# "stat deploy/chart/values.yaml: no such file or directory" for a file that plainly
# exists. The same boundary silently broke helm and gitleaks.
#
# Not python+pyyaml: a yaml round-trip strips every comment in values.yaml, and those
# comments are the only thing explaining why the digests are there. yq edits in place.
YQ_VERSION="4.53.3"
YQ_SHA256="578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea"
ensure_yq() {
if command -v yq >/dev/null 2>&1; then
return
fi
local arch
case "$(uname -m)" in
aarch64|arm64) arch=arm64 ;;
x86_64) arch=amd64 ;;
*) echo "!! unsupported arch $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL -o "${WORKDIR}/yq" \
"https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${arch}"
# Only the arm64 digest is pinned; this runner is Ampere. On another arch the download
# is still version-pinned, and the mismatch is reported rather than silently accepted.
if [ "${arch}" = "arm64" ]; then
echo "${YQ_SHA256} ${WORKDIR}/yq" | sha256sum -c -
else
echo "warning: no pinned checksum for ${arch}; version-pinned only" >&2
fi
chmod +x "${WORKDIR}/yq"
PATH="${WORKDIR}:${PATH}"
export PATH
}
ensure_yq
echo "==> resolving digests for tag ${IMAGE_TAG}"
for svc in "${SERVICES[@]}"; do
image="${REGISTRY}/${IMAGE_NS}/svcforge-${svc}"
digest="$(docker buildx imagetools inspect "${image}:${IMAGE_TAG}" --format '{{.Manifest.Digest}}')"
# Defence against a silently empty inspect. Without this, `yq` would happily write an
# empty digest and the chart's own guard would fail the release later, further from
# the cause.
if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "!! ${svc}: refusing to write a non-digest: '${digest}'" >&2
exit 1
fi
echo " ${svc} -> ${digest}"
echo "${digest}" > "${WORKDIR}/${svc}.digest"
done
echo "==> writing ${CHART_VALUES}"
for svc in "${SERVICES[@]}"; do
digest="$(cat "${WORKDIR}/${svc}.digest")"
# env(...) rather than string interpolation: a digest is attacker-controlled only in
# theory, but yq expression injection is not a thing worth leaving open.
DIGEST="${digest}" yq -i ".image.${svc}.digest = strenv(DIGEST)" "${CHART_VALUES}"
done
if git diff --quiet -- "${CHART_VALUES}"; then
echo "==> no digest changed; nothing to commit"
exit 0
fi
echo "==> committing"
git config user.name "svcforge-ci"
git config user.email "ci@svcforge.invalid"
git add "${CHART_VALUES}"
git commit -m "ci: bump image digests to ${IMAGE_TAG}
Built and scanned by ${IMAGE_TAG}. ArgoCD syncs from this commit.
[skip ci]"
git push origin HEAD:master
echo "==> done. ArgoCD owns it from here."