ci: sign every published runner image by digest (zegit#262) #13

Merged
markus merged 1 commits from claude/ci/sign-runner-image into master 2026-10-19 12:06:15 +00:00
+56
View File
@@ -24,6 +24,11 @@ on:
description: 'Image tag, e.g. 12.11.1-ci6 (upstream runner version + ci revision)' description: 'Image tag, e.g. 12.11.1-ci6 (upstream runner version + ci revision)'
required: true required: true
type: string type: string
sign_only:
description: 'Sign the already-published tag instead of building it (zegit#262: signs an image published before signing existed)'
required: false
default: false
type: boolean
push: push:
branches: [master] branches: [master]
paths: paths:
@@ -35,6 +40,7 @@ env:
BUILDKIT_VERSION: v0.32.2 BUILDKIT_VERSION: v0.32.2
REGISTRY: gitea.langenoja.se REGISTRY: gitea.langenoja.se
IMAGE_PATH: zegit-zoo/zegit-forgejo-runner IMAGE_PATH: zegit-zoo/zegit-forgejo-runner
COSIGN_VERSION: v3.1.3
jobs: jobs:
build: build:
@@ -78,6 +84,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "push=true" >> "$GITHUB_OUTPUT" echo "push=true" >> "$GITHUB_OUTPUT"
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "sign_only=${{ inputs.sign_only }}" >> "$GITHUB_OUTPUT"
else else
echo "push=false" >> "$GITHUB_OUTPUT" echo "push=false" >> "$GITHUB_OUTPUT"
echo "tag=ci-validation" >> "$GITHUB_OUTPUT" echo "tag=ci-validation" >> "$GITHUB_OUTPUT"
@@ -103,6 +110,7 @@ jobs:
> "$HOME/.docker/config.json" > "$HOME/.docker/config.json"
- name: Build multi-arch - name: Build multi-arch
if: ${{ steps.meta.outputs.sign_only != 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
REF="${REGISTRY}/${IMAGE_PATH}:${{ steps.meta.outputs.tag }}" REF="${REGISTRY}/${IMAGE_PATH}:${{ steps.meta.outputs.tag }}"
@@ -165,6 +173,54 @@ jobs:
--opt platform=linux/amd64,linux/arm64 --opt platform=linux/amd64,linux/arm64
echo "✓ toolchain intact on linux/amd64 and linux/arm64" echo "✓ toolchain intact on linux/amd64 and linux/arm64"
# zegit#143/#262: a -ci<N> tag is only a name, and ZEGIT_CI_PACKAGE_TOKEN
# (readable by every zegit-zoo branch run) can push one. Consumers pin
# this image only when `cosign verify` accepts its digest with the
# runner-image public key, so every published digest is signed here.
# The private key is a REPOSITORY secret of the runner repos only, never
# an org secret. Classic .sig tag, no transparency log: the registry is
# private and verifiers use --insecure-ignore-tlog with a pinned key.
- name: Sign the published image by digest
if: ${{ steps.meta.outputs.push == 'true' }}
env:
COSIGN_KEY_PEM: ${{ secrets.RUNNER_IMAGE_COSIGN_KEY }}
COSIGN_PASSWORD: ${{ secrets.RUNNER_IMAGE_COSIGN_PASSWORD }}
TOKEN: ${{ secrets.ZEGIT_CI_PACKAGE_TOKEN }}
run: |
set -euo pipefail
if [ -z "${COSIGN_KEY_PEM:-}" ] || [ -z "${COSIGN_PASSWORD:-}" ]; then
echo "::error::RUNNER_IMAGE_COSIGN_KEY / RUNNER_IMAGE_COSIGN_PASSWORD are not visible to this run (repository secrets, zegit#262). An unsigned runner image is never pinned."
exit 1
fi
case "$(uname -m)" in
x86_64) arch=amd64; sum=4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71;;
aarch64) arch=arm64; sum=c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a;;
*) echo "::error::unsupported arch $(uname -m)"; exit 1;;
esac
bin="$GITHUB_WORKSPACE/.bin/cosign"
curl -fsSL -o "$bin" "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${arch}"
echo "${sum} ${bin}" | sha256sum -c -
chmod +x "$bin"
tag="${{ steps.meta.outputs.tag }}"
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
# Credentials go through 0600 curl configs, never argv (zegit#139).
( umask 077; printf 'user = "zegit-zoo:%s"\n' "$TOKEN" > "$tmp/basic.cfg" )
tok=$(curl -fsS -K "$tmp/basic.cfg" "https://${REGISTRY}/v2/token?scope=repository:${IMAGE_PATH}:pull&service=container_registry" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
( umask 077; printf 'header = "Authorization: Bearer %s"\n' "$tok" > "$tmp/bearer.cfg" )
digest=$(curl -fsS -I -K "$tmp/bearer.cfg" \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"https://${REGISTRY}/v2/${IMAGE_PATH}/manifests/${tag}" | tr -d '\r' |
awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}')
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::no digest for ${IMAGE_PATH}:${tag}"; exit 1; }
ref="${REGISTRY}/${IMAGE_PATH}@${digest}"
printf '%s\n' "$COSIGN_KEY_PEM" > "$tmp/cosign.key"; chmod 600 "$tmp/cosign.key"
"$bin" sign --yes --key "$tmp/cosign.key" --tlog-upload=false \
--new-bundle-format=false --use-signing-config=false "$ref"
"$bin" public-key --key "$tmp/cosign.key" > "$tmp/cosign.pub"
"$bin" verify --key "$tmp/cosign.pub" --insecure-ignore-tlog "${REGISTRY}/${IMAGE_PATH}:${tag}@${digest}" >/dev/null
echo "✓ signed and verified ${REGISTRY}/${IMAGE_PATH}:${tag}@${digest}"
- name: Summary - name: Summary
run: | run: |
if [ "${{ steps.meta.outputs.push }}" = "true" ]; then if [ "${{ steps.meta.outputs.push }}" = "true" ]; then