#!/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= ./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.44.6" YQ_SHA256="9477ac3cc447b6c083986129e35af8122eb2b938fe55c9c3e40436fb966e5813" 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."