Files
SnapOtter/.github/workflows/release.yml
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

2034 lines
86 KiB
YAML

name: Release
on:
workflow_dispatch:
permissions: {}
jobs:
release:
name: Semantic Release
runs-on: ubuntu-latest
concurrency:
group: snapotter-semantic-release
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
outputs:
new_version: ${{ steps.check.outputs.version }}
release_commit: ${{ steps.check.outputs.release_commit }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Validate OCR release trust before publishing
env:
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
run: |
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
[[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || {
echo "::error::OCR runtime signing key ID is not a safe identifier"
exit 1
}
umask 077
trap 'rm -f /tmp/ocr-release-public.pem' EXIT
printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" \
| base64 --decode > /tmp/ocr-release-public.pem
[[ "$(base64 --wrap=0 < /tmp/ocr-release-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
echo "::error::OCR runtime public key must use canonical base64"
exit 1
}
openssl pkey -pubin -in /tmp/ocr-release-public.pem -text -noout \
| grep -q ED25519 || {
echo "::error::Configured OCR runtime public key is not Ed25519"
exit 1
}
- uses: ./.github/actions/setup
- name: Verify production Node dependency licenses and notices
run: pnpm check:production-node-licenses
- name: Run semantic-release
env:
# RELEASE_TOKEN is a fine-grained PAT (repo Contents/Issues/PRs: write)
# owned by an admin, so semantic-release's push of the chore(release)
# commit + tag clears branch protection (enforce_admins is off). Falls
# back to the default token if the secret is unset, so behaviour is
# unchanged until the secret exists.
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: npx semantic-release
- name: Check for new release
id: check
run: |
if [ -f .release-version ]; then
version="$(cat .release-version)"
else
# semantic-release found no new commits — tag already exists from a
# previous run. Fall back to the latest git tag so the Docker build
# jobs still run (useful when re-triggering after a push failure).
latest=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')
if [ -n "$latest" ]; then
version="${latest}"
echo "Re-using existing tag v${latest} for Docker build."
else
echo "::error::semantic-release did not produce a new version. No releasable commits found."
exit 1
fi
fi
[[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]] || {
echo "::error::semantic-release produced an invalid version"
exit 1
}
git fetch --force --no-tags origin \
"refs/tags/v${version}:refs/tags/v${version}"
release_commit="$(git rev-parse "refs/tags/v${version}^{commit}")"
[[ "${release_commit}" =~ ^[a-f0-9]{40}$ ]] || {
echo "::error::Release tag did not peel to an immutable commit"
exit 1
}
git checkout --detach "${release_commit}"
[[ "$(git rev-parse HEAD)" == "${release_commit}" ]] || {
echo "::error::Could not check out the immutable release commit"
exit 1
}
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_commit=${release_commit}" >> "$GITHUB_OUTPUT"
- name: Materialize durable release notes
id: notes
env:
VERSION: ${{ steps.check.outputs.version }}
run: |
result="$(
node scripts/manage-release-notes.mjs materialize \
"${VERSION}" /tmp/release-notes.md
)"
[[ "${result}" =~ ^custom=(true|false)$ ]] || {
echo "::error::Release-note materializer returned an invalid result"
exit 1
}
[[ -s /tmp/release-notes.md ]] || {
echo "::error::Committed release notes are empty"
exit 1
}
echo "has_custom_notes=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- name: Ensure exact GitHub draft
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
run: |
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}"
if ! gh api "${release_endpoint}" > /tmp/release.json 2> /tmp/release.error; then
if ! grep -Fq "(HTTP 404)" /tmp/release.error; then
cat /tmp/release.error >&2
echo "::error::Could not determine whether the GitHub draft exists"
exit 1
fi
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft \
--verify-tag \
--title "v${VERSION}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
fi
jq -e --arg tag "v${VERSION}" \
'.draft == true and .tag_name == $tag' /tmp/release.json >/dev/null || {
echo "::error::Expected release is missing, public, or bound to the wrong tag"
exit 1
}
# Normalize both first-run and recovered drafts to the committed body.
gh release edit "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
node --input-type=module - /tmp/release.json /tmp/release-notes.md <<'NODE'
import { readFileSync } from "node:fs";
const [releasePath, notesPath] = process.argv.slice(2);
const release = JSON.parse(readFileSync(releasePath, "utf8"));
const expected = readFileSync(notesPath, "utf8");
if (release.draft !== true || release.body !== expected) {
throw new Error("GitHub draft body differs from committed release notes");
}
NODE
prebuilt:
name: Archive (${{ matrix.arch }})
needs: release
if: needs.release.outputs.new_version
concurrency:
group: snapotter-prebuilt-${{ needs.release.outputs.new_version }}-${{ matrix.arch }}
cancel-in-progress: false
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Export reproducible build epoch
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "${RELEASE_COMMIT}")"
[[ "${SOURCE_DATE_EPOCH}" =~ ^[0-9]+$ ]] || {
echo "::error::Release commit has no deterministic source timestamp"
exit 1
}
echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" >> "$GITHUB_ENV"
- uses: ./.github/actions/setup
- name: Build web frontend
run: pnpm --filter @snapotter/web build
- name: Prune to production dependencies
run: |
rm -rf node_modules apps/*/node_modules packages/*/node_modules
npm pkg delete scripts.prepare
pnpm install --prod --frozen-lockfile
- name: Create archive
env:
VERSION: ${{ needs.release.outputs.new_version }}
ARCH: ${{ matrix.arch }}
run: |
rm -rf apps/web/src apps/web/public apps/web/index.html apps/web/tsconfig.json
rm -rf apps/landing apps/docs apps/demo
rm -rf tests .husky scripts
rm -rf .releaserc.json biome.json .editorconfig .gitattributes
rm -f CHANGELOG.md README.md CONTRIBUTING.md SECURITY.md
ARCHIVE_NAME="snapotter-v${VERSION}-linux-${ARCH}.tar.gz"
cd ..
mv SnapOtter snapotter
LC_ALL=C tar \
--sort=name \
--format=posix \
--mtime="@${SOURCE_DATE_EPOCH}" \
--owner=0 --group=0 --numeric-owner \
--pax-option=delete=atime,delete=ctime \
--exclude='.git' \
--exclude='.github' \
--exclude='.gitignore' \
-cf - snapotter/ \
| gzip -n > "/tmp/${ARCHIVE_NAME}"
mv snapotter SnapOtter
cd SnapOtter
echo "archive_name=${ARCHIVE_NAME}" >> "$GITHUB_ENV"
archive_size="$(du -sh "/tmp/${ARCHIVE_NAME}" | cut -f1)"
echo "Archive: ${ARCHIVE_NAME} (${archive_size})"
- name: Generate checksum
run: cd /tmp && sha256sum "${archive_name}" > "${archive_name}.sha256"
- name: Upload unverified archive for security verification
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: prebuilt-${{ matrix.arch }}
if-no-files-found: error
overwrite: true
retention-days: 7
path: |
/tmp/${{ env.archive_name }}
/tmp/${{ env.archive_name }}.sha256
archive-security:
name: Verify Archive (${{ matrix.arch }})
needs: [release, prebuilt]
if: needs.release.outputs.new_version
permissions:
attestations: write
contents: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Download unverified archive
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: prebuilt-${{ matrix.arch }}
path: /tmp/prebuilt
- name: Verify checksum and safely extract archive
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
archive_name="snapotter-v${VERSION}-linux-${ARCH}.tar.gz"
[[ -f "/tmp/prebuilt/${archive_name}" \
&& ! -L "/tmp/prebuilt/${archive_name}" \
&& -f "/tmp/prebuilt/${archive_name}.sha256" \
&& ! -L "/tmp/prebuilt/${archive_name}.sha256" ]] || {
echo "::error::Archive artifact closure is incomplete"
exit 1
}
[[ "$(find /tmp/prebuilt -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 2 ]] || {
echo "::error::Archive artifact contains unexpected files"
exit 1
}
(cd /tmp/prebuilt && sha256sum --check --strict "${archive_name}.sha256")
rm -rf /tmp/prebuilt-root
mkdir -p /tmp/prebuilt-root
python3 - "/tmp/prebuilt/${archive_name}" <<'PY'
import pathlib
import sys
import tarfile
archive = pathlib.Path(sys.argv[1])
root = pathlib.Path("/tmp/prebuilt-root")
with tarfile.open(archive, "r:gz") as handle:
members = handle.getmembers()
if not members:
raise SystemExit("release archive is empty")
for member in members:
parts = pathlib.PurePosixPath(member.name).parts
if not parts or parts[0] != "snapotter" or ".." in parts:
raise SystemExit(f"unsafe release archive member: {member.name}")
handle.extractall(root, filter="data")
PY
test -s /tmp/prebuilt-root/snapotter/apps/web/dist/index.html
test -s /tmp/prebuilt-root/snapotter/apps/api/src/index.ts
test -x /tmp/prebuilt-root/snapotter/node_modules/.bin/tsx
/tmp/prebuilt-root/snapotter/node_modules/.bin/tsx --version
echo "archive_name=${archive_name}" >> "$GITHUB_ENV"
- name: Install pinned Syft 1.42.3 from verified release bytes
env:
SYFT_VERSION: "1.42.3"
run: |
case "$(uname -m)" in
x86_64)
syft_arch="amd64"
expected_sha256="0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509"
;;
aarch64 | arm64)
syft_arch="arm64"
expected_sha256="dc630590c953347789d08f8ebf57c7d8094db89100785fcd94b1cddeac791804"
;;
*)
echo "::error::Unsupported Syft installer architecture: $(uname -m)"
exit 1
;;
esac
archive="syft_${SYFT_VERSION}_linux_${syft_arch}.tar.gz"
install_root="${RUNNER_TEMP}/syft-${SYFT_VERSION}"
rm -rf "${install_root}"
mkdir -p "${install_root}"
curl --fail --location --silent --show-error \
--proto '=https' --tlsv1.2 --retry 3 \
--output "${install_root}/${archive}" \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
printf '%s %s\n' "${expected_sha256}" "${install_root}/${archive}" \
| sha256sum --check --strict -
tar -xzf "${install_root}/${archive}" -C "${install_root}" syft
chmod 0755 "${install_root}/syft"
"${install_root}/syft" version -o json \
| jq -e --arg version "${SYFT_VERSION}" '.version == $version' >/dev/null
echo "${install_root}" >> "$GITHUB_PATH"
- name: Generate archive SBOMs
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
syft scan dir:/tmp/prebuilt-root/snapotter \
-o "cyclonedx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json"
syft scan dir:/tmp/prebuilt-root/snapotter \
-o "spdx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json"
# Blocks on CRITICAL/HIGH that have a fix available. The unfixed gate
# below covers what this one cannot see.
- name: Scan archive filesystem
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: /tmp/prebuilt-root/snapotter
format: table
exit-code: "1"
ignore-unfixed: true
severity: CRITICAL,HIGH
trivyignores: .trivyignore
- name: Record archive scan
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: /tmp/prebuilt-root/snapotter
format: json
output: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
- name: Gate unfixed CRITICAL findings
if: always()
env:
REPORT: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
LABEL: archive linux/${{ matrix.arch }}
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Publish verified immutable archive assets
env:
ARCH: ${{ matrix.arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
REPOSITORY="snapotter-hq/SnapOtter"
release_id="$(
gh api "repos/${REPOSITORY}/releases/tags/v${VERSION}" --jq .id
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
asset_list="$(mktemp)"
trap 'rm -f "${asset_list}" /tmp/existing-release-asset-*' EXIT
refresh_assets() {
gh api --paginate \
"repos/${REPOSITORY}/releases/${release_id}/assets?per_page=100" \
> "${asset_list}"
}
matching_asset_ids() {
local asset_name="$1"
jq -r --arg name "${asset_name}" \
'.[] | select(.name == $name) | .id' "${asset_list}"
}
compare_asset() {
local asset_id="$1"
local asset_path="$2"
local asset_name
local downloaded
asset_name="$(basename "${asset_path}")"
downloaded="/tmp/existing-release-asset-${asset_id}"
gh api \
-H "Accept: application/octet-stream" \
"repos/${REPOSITORY}/releases/assets/${asset_id}" \
> "${downloaded}"
cmp --silent "${asset_path}" "${downloaded}" || {
echo "::error::Existing immutable release asset differs: ${asset_name}"
exit 1
}
rm -f "${downloaded}"
}
verify_or_upload_asset() {
local asset_path="$1"
local asset_name
local asset_ids
asset_name="$(basename "${asset_path}")"
refresh_assets
mapfile -t asset_ids < <(matching_asset_ids "${asset_name}")
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::Immutable release asset name collides: ${asset_name}"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
compare_asset "${asset_ids[0]}" "${asset_path}"
echo "Verified existing immutable release asset: ${asset_name}"
return
fi
gh release upload "v${VERSION}" "${asset_path}" --repo "${REPOSITORY}"
}
assets=(
"/tmp/prebuilt/${archive_name}"
"/tmp/prebuilt/${archive_name}.sha256"
"snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json"
"snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json"
"snapotter-v${VERSION}-archive-linux-${ARCH}-trivy.json"
)
for asset_path in "${assets[@]}"; do
verify_or_upload_asset "${asset_path}"
done
for asset_path in "${assets[@]}"; do
asset_name="$(basename "${asset_path}")"
refresh_assets
mapfile -t asset_ids < <(matching_asset_ids "${asset_name}")
[[ ${#asset_ids[@]} -eq 1 ]] || {
echo "::error::Expected exactly one immutable release asset after upload: ${asset_name}"
exit 1
}
compare_asset "${asset_ids[0]}" "${asset_path}"
done
# This provenance records the workflow identity that performed the
# verification. The release-subjects job below separately attests a
# canonical manifest that binds the semantic-release-created commit.
- name: Attest verified archive workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "/tmp/prebuilt/${{ env.archive_name }}"
docker:
name: Build (${{ matrix.platform }})
needs: release
concurrency:
group: snapotter-image-${{ needs.release.outputs.new_version }}-${{ matrix.platform }}
cancel-in-progress: false
# Builds and pushes the multi-arch app image (by digest) to Docker Hub +
# GHCR; the manifest job then creates the named tags. Only runs when
# semantic-release produced a version (or fell back to the latest tag).
if: needs.release.outputs.new_version
permissions:
contents: write
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/usr/local/share/boost /opt/hostedtoolcache/CodeQL
sudo docker system prune -af
df -h /
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Validate OCR runtime trust baked into the image
env:
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
run: |
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
[[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || {
echo "::error::OCR runtime signing key ID is not a safe identifier"
exit 1
}
umask 077
trap 'rm -f /tmp/ocr-runtime-public.pem' EXIT
printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" \
| base64 --decode > /tmp/ocr-runtime-public.pem
[[ "$(base64 --wrap=0 < /tmp/ocr-runtime-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
echo "::error::OCR runtime public key must use canonical base64"
exit 1
}
openssl pkey -pubin -in /tmp/ocr-runtime-public.pem -text -noout \
| grep -q ED25519 || {
echo "::error::Configured OCR runtime public key is not Ed25519"
exit 1
}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Reuse an existing published platform digest
id: existing
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLATFORM: ${{ matrix.platform }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
ghcr_ref="ghcr.io/snapotter-hq/snapotter"
dockerhub_ref="snapotter/snapotter"
architecture="${PLATFORM#linux/}"
expected_source="https://github.com/${GITHUB_REPOSITORY}"
registry_digest_state() {
local reference="$1"
local digest="$2"
local label="$3"
local manifest="/tmp/${label}-release-platform-manifest.json"
local error="/tmp/${label}-release-platform-manifest.error"
if docker buildx imagetools inspect "${reference}@${digest}" --raw \
> "${manifest}" 2> "${error}"; then
local actual_digest
actual_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)"
if [[ "${actual_digest}" != "${digest}" ]]; then
echo "::error::Registry returned different bytes for ${reference}@${digest}"
return 2
fi
if ! docker buildx imagetools inspect "${reference}@${digest}" \
--format '{{json .Image}}' > "${manifest}.image" 2> "${error}"; then
cat "${error}" >&2
echo "::error::Could not inspect image configuration for ${reference}@${digest}"
return 2
fi
if ! jq -e --arg architecture "${architecture}" \
'.os == "linux" and .architecture == $architecture' \
"${manifest}.image" >/dev/null; then
echo "::error::Registry digest has the wrong platform: ${reference}@${digest}"
return 2
fi
if ! jq -e \
--arg release_commit "${RELEASE_COMMIT}" \
--arg expected_source "${expected_source}" \
--arg version "${VERSION}" \
'(.config.Labels | type == "object")
and .config.Labels["org.opencontainers.image.revision"] == $release_commit
and .config.Labels["org.opencontainers.image.source"] == $expected_source
and .config.Labels["org.opencontainers.image.version"] == $version' \
"${manifest}.image" >/dev/null; then
echo "::warning::Registry digest does not bind the exact release provenance: ${reference}@${digest}"
return 3
fi
return 0
fi
if grep -Eqi 'manifest unknown|name unknown|not found' "${error}"; then
return 1
fi
cat "${error}" >&2
echo "::error::Could not inspect ${reference}@${digest}"
return 2
}
repair_digest_replica() {
local source="$1"
local destination="$2"
local digest="$3"
local destination_label="$4"
docker buildx imagetools create --prefer-index=false \
--tag "${destination}@${digest}" "${source}@${digest}"
if registry_digest_state "${destination}" "${digest}" "${destination_label}"; then
echo "Repaired exact ${digest} replica in ${destination}."
return 0
fi
echo "::error::Failed to repair exact ${digest} replica in ${destination}"
return 2
}
ensure_digest_replication() {
local digest="$1"
local ghcr_status dockerhub_status
if registry_digest_state "${ghcr_ref}" "${digest}" ghcr; then
ghcr_status=0
else
ghcr_status=$?
fi
if registry_digest_state "${dockerhub_ref}" "${digest}" dockerhub; then
dockerhub_status=0
else
dockerhub_status=$?
fi
if (( ghcr_status == 2 || dockerhub_status == 2 )); then
return 2
fi
if (( ghcr_status == 3 || dockerhub_status == 3 )); then
return 3
fi
if (( ghcr_status == 1 && dockerhub_status == 1 )); then
echo "::warning::Release digest is unavailable in both registries: ${digest}"
return 1
fi
if (( ghcr_status == 1 )); then
repair_digest_replica "${dockerhub_ref}" "${ghcr_ref}" "${digest}" ghcr \
|| return $?
elif (( dockerhub_status == 1 )); then
repair_digest_replica "${ghcr_ref}" "${dockerhub_ref}" "${digest}" dockerhub \
|| return $?
fi
return 0
}
image="${ghcr_ref}:${VERSION}"
digest=""
reuse_description=""
if docker buildx imagetools inspect "${image}" --raw \
> /tmp/existing-release-manifest.json 2> /tmp/existing-release-manifest.error; then
jq -e '.manifests | type == "array"' /tmp/existing-release-manifest.json >/dev/null || {
echo "::error::Existing ${image} is not a multi-platform image index"
exit 1
}
mapfile -t platform_digests < <(
jq -r --arg architecture "${architecture}" \
'.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture) | .digest' \
/tmp/existing-release-manifest.json
)
[[ ${#platform_digests[@]} -eq 1 ]] || {
echo "::error::Existing ${image} does not contain exactly one ${PLATFORM} manifest"
exit 1
}
digest="${platform_digests[0]}"
reuse_description="${image} ${PLATFORM}"
else
if ! grep -Eqi 'manifest unknown|name unknown|not found' \
/tmp/existing-release-manifest.error; then
cat /tmp/existing-release-manifest.error >&2
echo "::error::Could not determine whether ${image} already exists"
exit 1
fi
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/existing-platform-asset-ids 2> /tmp/existing-platform-asset.error; then
echo "::warning::Could not read ${asset_name}; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mapfile -t asset_ids < /tmp/existing-platform-asset-ids
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::GitHub release contains duplicate ${asset_name} checkpoints"
exit 1
}
if [[ ${#asset_ids[@]} -eq 0 ]]; then
echo "reused=false" >> "$GITHUB_OUTPUT"
echo "No published ${image} manifest or ${asset_name} checkpoint exists; building ${PLATFORM}."
exit 0
fi
if ! gh api -H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_ids[0]}" \
> "/tmp/${asset_name}"; then
echo "::warning::Could not download ${asset_name}; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
checkpoint="/tmp/${asset_name}"
digest="$(<"${checkpoint}")"
if [[ "$(wc -c < "${checkpoint}")" -ne 72 ]] \
|| [[ "$(wc -l < "${checkpoint}")" -ne 1 ]] \
|| [[ ! "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::warning::Ignoring unreadable ${asset_name}; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
reuse_description="immutable ${asset_name} checkpoint"
fi
[[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Existing release contains an invalid ${PLATFORM} digest"
exit 1
}
if ensure_digest_replication "${digest}"; then
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "reused=true" >> "$GITHUB_OUTPUT"
echo "Reusing ${reuse_description} at ${digest}."
exit 0
else
replication_status=$?
fi
if (( replication_status == 1 )); then
echo "::warning::No trustworthy registry source remains for ${digest}; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if (( replication_status == 3 )); then
echo "::warning::Existing digest is not from release ${RELEASE_COMMIT}; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
exit "${replication_status}"
- name: Extract metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
snapotter/snapotter
ghcr.io/snapotter-hq/snapotter
labels: |
org.opencontainers.image.revision=${{ needs.release.outputs.release_commit }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.release.outputs.new_version }}
- name: Build and push by digest
id: build
if: steps.existing.outputs.reused != 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: docker/Dockerfile
platforms: ${{ matrix.platform }}
build-args: |
SNAPOTTER_ANALYTICS=on
SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }}
SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }}
SNAPOTTER_SENTRY_DSN_WEB=${{ secrets.SNAPOTTER_SENTRY_DSN_WEB }}
SENTRY_RELEASE=${{ needs.release.outputs.new_version }}
OCR_RUNTIME_TRUST_ID=${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
OCR_RUNTIME_TRUST_PEM_B64=${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
SNAPOTTER_OFFICIAL_CONTAINER=1
secrets: |
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
labels: ${{ steps.meta.outputs.labels }}
# The manual attestation workflow signs release images. Buildx's
# default provenance sidecars show up in GHCR as unknown/unknown
# architectures on the package page.
provenance: false
outputs: type=image,"name=snapotter/snapotter,ghcr.io/snapotter-hq/snapotter",push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }}
cache-to: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
env:
BUILT_DIGEST: ${{ steps.build.outputs.digest }}
EXISTING_DIGEST: ${{ steps.existing.outputs.digest }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
mkdir -p /tmp/digests /tmp/release-digests
digest="${EXISTING_DIGEST:-${BUILT_DIGEST}}"
[[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Release image did not produce a valid digest"
exit 1
}
registry_index=0
architecture="${PLATFORM_PAIR#linux-}"
expected_source="https://github.com/${GITHUB_REPOSITORY}"
for reference in \
ghcr.io/snapotter-hq/snapotter \
snapotter/snapotter; do
manifest="/tmp/exported-release-manifest-${registry_index}.json"
docker buildx imagetools inspect "${reference}@${digest}" --raw > "${manifest}"
actual_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)"
[[ "${actual_digest}" == "${digest}" ]] || {
echo "::error::Registry returned different bytes for ${reference}@${digest}"
exit 1
}
docker buildx imagetools inspect "${reference}@${digest}" \
--format '{{json .Image}}' > "${manifest}.image"
jq -e --arg architecture "${architecture}" \
'.os == "linux" and .architecture == $architecture' \
"${manifest}.image" >/dev/null || {
echo "::error::Registry digest has the wrong platform: ${reference}@${digest}"
exit 1
}
jq -e \
--arg release_commit "${RELEASE_COMMIT}" \
--arg expected_source "${expected_source}" \
--arg version "${VERSION}" \
'(.config.Labels | type == "object")
and .config.Labels["org.opencontainers.image.revision"] == $release_commit
and .config.Labels["org.opencontainers.image.source"] == $expected_source
and .config.Labels["org.opencontainers.image.version"] == $version' \
"${manifest}.image" >/dev/null || {
echo "::error::Release image has invalid source provenance: ${reference}@${digest}"
exit 1
}
registry_index=$((registry_index + 1))
done
touch "/tmp/digests/${digest#sha256:}"
printf '%s\n' "${digest}" \
> "/tmp/release-digests/snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
- name: Persist immutable platform digest on the GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/platform-digest-asset-ids
mapfile -t asset_ids < /tmp/platform-digest-asset-ids
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::GitHub release contains duplicate ${asset_name} assets"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
gh api -H "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${asset_ids[0]}" \
> "/tmp/existing-${asset_name}"
cmp --silent "/tmp/existing-${asset_name}" "/tmp/release-digests/${asset_name}" || {
echo "::error::Existing GitHub release platform digest differs for ${PLATFORM_PAIR}"
exit 1
}
echo "Verified existing immutable GitHub release asset ${asset_name}."
else
gh release upload "v${VERSION}" "/tmp/release-digests/${asset_name}" \
--repo snapotter-hq/SnapOtter
fi
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.PLATFORM_PAIR }}
overwrite: true
path: /tmp/digests/*
if-no-files-found: error
retention-days: 90
scan:
name: Trivy Container Scan (${{ matrix.platform }})
needs: [release, docker]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform:
- linux-amd64
- linux-arm64
permissions:
contents: write
packages: read
security-events: write
steps:
- name: Download architecture digest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: digests-${{ matrix.platform }}
path: /tmp/digests
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Get digest
id: digest
run: |
mapfile -t digest_files < <(find /tmp/digests -maxdepth 1 -type f -print)
[[ ${#digest_files[@]} -eq 1 ]] || {
echo "::error::Expected exactly one architecture digest"
exit 1
}
sha="$(basename "${digest_files[0]}")"
[[ "${sha}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid architecture digest"
exit 1
}
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: Checkout scan policy
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
sparse-checkout: |
.trivyignore
.trivy-unfixed-allow
scripts/trivy-unfixed-gate.mjs
sparse-checkout-cone-mode: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
# Blocks on CRITICAL/HIGH that have a fix available: a patch exists and we
# did not take it. Findings with no fix are invisible here by design; the
# unfixed gate below is what covers them.
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}"
format: "table"
exit-code: "1"
ignore-unfixed: true
severity: "CRITICAL,HIGH"
trivyignores: ".trivyignore"
- name: Upload results to GitHub Security
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}"
format: "sarif"
output: "trivy-results.sarif"
ignore-unfixed: true
severity: "CRITICAL,HIGH"
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v3
if: always()
with:
sarif_file: "trivy-results.sarif"
category: trivy-${{ matrix.platform }}
# No ignore-unfixed here: this report is the published record of what the
# image actually contains, and it feeds the unfixed gate below.
- name: Run Trivy (JSON report)
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}"
format: "json"
output: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
- name: Gate unfixed CRITICAL findings
if: always()
env:
REPORT: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
LABEL: "image ${{ matrix.platform }}"
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Upload Trivy report to GitHub Release
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
repository="snapotter-hq/SnapOtter"
report="snapotter-v${VERSION}-image-${{ matrix.platform }}-trivy.json"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
--jq ".[] | select(.name == \"${report}\") | .id"
)
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::Immutable Trivy report name collides: ${report}"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
gh api -H "Accept: application/octet-stream" \
"repos/${repository}/releases/assets/${asset_ids[0]}" \
> /tmp/existing-trivy-report.json
cmp --silent "${report}" /tmp/existing-trivy-report.json || {
echo "::error::Existing immutable Trivy report differs: ${report}"
exit 1
}
else
gh release upload "v${VERSION}" "${report}" --repo "${repository}"
fi
sbom:
name: Generate SBOM (${{ matrix.platform }})
needs: [release, docker]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform:
- linux-amd64
- linux-arm64
permissions:
contents: write
packages: read
steps:
- name: Download architecture digest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: digests-${{ matrix.platform }}
path: /tmp/digests
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Get digest
id: digest
run: |
mapfile -t digest_files < <(find /tmp/digests -maxdepth 1 -type f -print)
[[ ${#digest_files[@]} -eq 1 ]] || {
echo "::error::Expected exactly one architecture digest"
exit 1
}
sha="$(basename "${digest_files[0]}")"
[[ "${sha}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid architecture digest"
exit 1
}
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: Install pinned Syft 1.42.3 from verified release bytes
env:
SYFT_VERSION: "1.42.3"
run: |
# Published in Syft's v1.42.3 syft_1.42.3_checksums.txt release asset.
case "$(uname -m)" in
x86_64)
syft_arch="amd64"
expected_sha256="0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509"
;;
aarch64 | arm64)
syft_arch="arm64"
expected_sha256="dc630590c953347789d08f8ebf57c7d8094db89100785fcd94b1cddeac791804"
;;
*)
echo "::error::Unsupported Syft installer architecture: $(uname -m)"
exit 1
;;
esac
archive="syft_${SYFT_VERSION}_linux_${syft_arch}.tar.gz"
install_root="${RUNNER_TEMP}/syft-${SYFT_VERSION}"
rm -rf "${install_root}"
mkdir -p "${install_root}"
curl --fail --location --silent --show-error \
--proto '=https' --tlsv1.2 --retry 3 \
--output "${install_root}/${archive}" \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
printf '%s %s\n' "${expected_sha256}" "${install_root}/${archive}" \
| sha256sum --check --strict -
tar -xzf "${install_root}/${archive}" -C "${install_root}" syft
chmod 0755 "${install_root}/syft"
"${install_root}/syft" version -o json \
| jq -e --arg version "${SYFT_VERSION}" '.version == $version' >/dev/null
echo "${install_root}" >> "$GITHUB_PATH"
- name: Generate SBOMs
env:
IMAGE: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}"
VERSION: ${{ needs.release.outputs.new_version }}
run: |
syft scan "$IMAGE" -o "cyclonedx-json=snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json"
syft scan "$IMAGE" -o "spdx-json=snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.spdx.json"
- name: Upload SBOMs to GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
repository="snapotter-hq/SnapOtter"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
for sbom in \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json" \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.spdx.json"; do
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
--jq ".[] | select(.name == \"${sbom}\") | .id"
)
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::Immutable SBOM name collides: ${sbom}"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
gh api -H "Accept: application/octet-stream" \
"repos/${repository}/releases/assets/${asset_ids[0]}" \
> /tmp/existing-sbom.json
cmp --silent "${sbom}" /tmp/existing-sbom.json || {
echo "::error::Existing immutable SBOM differs: ${sbom}"
exit 1
}
else
gh release upload "v${VERSION}" "${sbom}" --repo "${repository}"
fi
done
ai-bundles:
name: AI Bundles
needs: [release, docker, scan]
# Build against the already scanned, architecture-specific image digests.
# The named image manifest stays unpublished until every bundle (including
# both OCR runtimes) is verified and the OCR index is signed.
if: needs.release.outputs.new_version
# The top-level `permissions: {}` default means this reusable-workflow call
# grants no token scopes by default. ai-bundles.yml's jobs declare
# `actions: read` / `contents: read` / `packages: read`, and GitHub rejects a called workflow
# requesting scopes the caller never granted -- failing at startup before any
# job runs. Grant them here so the call passes startup validation.
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/ai-bundles.yml
with:
release_commit: ${{ needs.release.outputs.release_commit }}
version: ${{ needs.release.outputs.new_version }}
secrets:
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
OCR_RUNTIME_INDEX_SIGNING_KEY_B64: ${{ secrets.OCR_RUNTIME_INDEX_SIGNING_KEY_B64 }}
manifest:
name: Create Multi-Arch Manifests
needs: [release, prebuilt, archive-security, docker, scan, sbom, ai-bundles]
runs-on: ubuntu-latest
# Manual publish gate: this job creates only the immutable version tags. A
# downstream, globally serialized job advances moving aliases after checking
# all remote release tags again. Keeping the approval outside that global
# lock avoids blocking newer releases for up to the environment wait limit.
environment: publish-images
permissions:
contents: read
packages: write
outputs:
manifest_digest: ${{ steps.manifest_digest.outputs.digest }}
platform_digests: ${{ steps.verified_digests.outputs.platform_digests }}
steps:
- name: Check out the approved immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Revalidate the remote release tag immediately after approval
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved after publication approval"
exit 1
}
- name: Download digests
id: action_digests
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Recover expired digest artifacts from the GitHub release
id: verified_digests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
valid_action_digests=true
mapfile -t digest_files < <(find /tmp/digests -maxdepth 1 -type f -print 2>/dev/null)
[[ ${#digest_files[@]} -eq 2 ]] || valid_action_digests=false
if [[ "${valid_action_digests}" == true ]]; then
for digest_file in "${digest_files[@]}"; do
[[ "$(basename "${digest_file}")" =~ ^[a-f0-9]{64}$ ]] || \
valid_action_digests=false
done
fi
if [[ "${valid_action_digests}" != true ]]; then
rm -rf /tmp/digests /tmp/release-digest-assets
mkdir -p /tmp/digests /tmp/release-digest-assets
gh release download "v${VERSION}" \
--pattern "snapotter-v${VERSION}-linux-*.digest" \
--dir /tmp/release-digest-assets \
--repo snapotter-hq/SnapOtter
mapfile -t release_assets < <(
find /tmp/release-digest-assets -mindepth 1 -maxdepth 1 -type f -print
)
[[ ${#release_assets[@]} -eq 2 ]] || {
echo "::error::Expected exactly two immutable platform digest release assets"
exit 1
}
for platform in linux-amd64 linux-arm64; do
asset="/tmp/release-digest-assets/snapotter-v${VERSION}-${platform}.digest"
[[ -f "${asset}" && ! -L "${asset}" ]] || {
echo "::error::Missing immutable ${platform} digest release asset"
exit 1
}
digest="$(<"${asset}")"
[[ "$(wc -c < "${asset}")" -eq 72 \
&& "$(wc -l < "${asset}")" -eq 1 \
&& "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Invalid immutable ${platform} digest release asset"
exit 1
}
touch "/tmp/digests/${digest#sha256:}"
done
fi
mapfile -t final_digests < <(find /tmp/digests -maxdepth 1 -type f -print)
[[ ${#final_digests[@]} -eq 2 ]] || {
echo "::error::Expected exactly two verified platform digests"
exit 1
}
for digest_file in "${final_digests[@]}"; do
[[ "$(basename "${digest_file}")" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid platform digest filename"
exit 1
}
done
mapfile -t digest_names < <(
find /tmp/digests -maxdepth 1 -type f -exec basename {} \; | sort
)
platform_digests="$(IFS=,; echo "${digest_names[*]}")"
echo "platform_digests=${platform_digests}" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Revalidate platform digest provenance before publication
working-directory: /tmp/digests
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
mapfile -t digest_files < <(find . -maxdepth 1 -type f -exec basename {} \;)
[[ ${#digest_files[@]} -eq 2 ]] || {
echo "::error::Recovered platform digest closure is invalid"
exit 1
}
expected_source="https://github.com/${GITHUB_REPOSITORY}"
validated_architectures=()
registry_index=0
for digest_sha in "${digest_files[@]}"; do
[[ "${digest_sha}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Recovered platform digest is invalid"
exit 1
}
digest="sha256:${digest_sha}"
digest_architecture=""
for reference in \
ghcr.io/snapotter-hq/snapotter \
snapotter/snapotter; do
manifest="/tmp/publish-platform-manifest-${digest_sha}-${registry_index}.json"
error="${manifest}.error"
if ! docker buildx imagetools inspect "${reference}@${digest}" --raw \
> "${manifest}" 2> "${error}"; then
cat "${error}" >&2
echo "::error::Could not inspect release digest ${reference}@${digest}"
exit 1
fi
actual_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)"
[[ "${actual_digest}" == "${digest}" ]] || {
echo "::error::Registry returned different bytes for ${reference}@${digest}"
exit 1
}
if ! docker buildx imagetools inspect "${reference}@${digest}" \
--format '{{json .Image}}' > "${manifest}.image" 2> "${error}"; then
cat "${error}" >&2
echo "::error::Could not inspect image configuration for ${reference}@${digest}"
exit 1
fi
if ! jq -e \
--arg release_commit "${RELEASE_COMMIT}" \
--arg expected_source "${expected_source}" \
--arg version "${VERSION}" \
'.os == "linux"
and (.architecture == "amd64" or .architecture == "arm64")
and (.config.Labels | type == "object")
and .config.Labels["org.opencontainers.image.revision"] == $release_commit
and .config.Labels["org.opencontainers.image.source"] == $expected_source
and .config.Labels["org.opencontainers.image.version"] == $version' \
"${manifest}.image" >/dev/null; then
echo "::error::Release platform digest has invalid provenance: ${reference}@${digest}"
exit 1
fi
registry_architecture="$(jq -r '.architecture' "${manifest}.image")"
if [[ -n "${digest_architecture}" \
&& "${registry_architecture}" != "${digest_architecture}" ]]; then
echo "::error::Registries disagree on the platform for ${digest}"
exit 1
fi
digest_architecture="${registry_architecture}"
registry_index=$((registry_index + 1))
done
validated_architectures+=("${digest_architecture}")
done
if [[ ! (
"${validated_architectures[0]}" == "amd64" \
&& "${validated_architectures[1]}" == "arm64"
) && ! (
"${validated_architectures[0]}" == "arm64" \
&& "${validated_architectures[1]}" == "amd64"
) ]]; then
echo "::error::Recovered platform digest closure is invalid"
exit 1
fi
- name: Create immutable Docker Hub manifest
working-directory: /tmp/digests
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
(
cd "$GITHUB_WORKSPACE"
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before Docker Hub publication"
exit 1
}
)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \; | sort)
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::Docker Hub manifest input closure is incomplete"
exit 1
}
arguments=("-t" "snapotter/snapotter:${VERSION}")
for digest in "${digests[@]}"; do
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid Docker Hub platform digest"
exit 1
}
arguments+=("snapotter/snapotter@sha256:${digest}")
done
docker buildx imagetools create "${arguments[@]}"
- name: Create immutable GHCR manifest
working-directory: /tmp/digests
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
(
cd "$GITHUB_WORKSPACE"
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before GHCR publication"
exit 1
}
)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \; | sort)
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::GHCR manifest input closure is incomplete"
exit 1
}
arguments=("-t" "ghcr.io/snapotter-hq/snapotter:${VERSION}")
for digest in "${digests[@]}"; do
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid GHCR platform digest"
exit 1
}
arguments+=("ghcr.io/snapotter-hq/snapotter@sha256:${digest}")
done
docker buildx imagetools create "${arguments[@]}"
- name: Verify immutable manifest parity
id: manifest_digest
env:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
docker buildx imagetools inspect \
"snapotter/snapotter:${VERSION}" --raw > /tmp/dockerhub-manifest.json
docker buildx imagetools inspect \
"ghcr.io/snapotter-hq/snapotter:${VERSION}" --raw > /tmp/ghcr-manifest.json
dockerhub_digest="sha256:$(sha256sum /tmp/dockerhub-manifest.json | cut -d ' ' -f 1)"
ghcr_digest="sha256:$(sha256sum /tmp/ghcr-manifest.json | cut -d ' ' -f 1)"
[[ "${dockerhub_digest}" == "${ghcr_digest}" ]] || {
echo "::error::Immutable registry manifests do not have identical bytes"
exit 1
}
[[ "${ghcr_digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Published manifest digest is invalid"
exit 1
}
echo "digest=${ghcr_digest}" >> "$GITHUB_OUTPUT"
image-provenance:
name: Attest Immutable Image Manifest
needs: [release, manifest]
runs-on: ubuntu-latest
permissions:
attestations: write
contents: read
id-token: write
packages: read
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Verify version tags resolve to the release-produced manifest
env:
MANIFEST_DIGEST: ${{ needs.manifest.outputs.manifest_digest }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
[[ "${MANIFEST_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Release manifest output is invalid"
exit 1
}
for reference in \
"docker.io/snapotter/snapotter:${VERSION}" \
"ghcr.io/snapotter-hq/snapotter:${VERSION}"; do
raw="/tmp/$(echo "${reference}" | tr '/:' '_').json"
docker buildx imagetools inspect "${reference}" --raw > "${raw}"
resolved_digest="sha256:$(sha256sum "${raw}" | cut -d ' ' -f 1)"
[[ "${resolved_digest}" == "${MANIFEST_DIGEST}" ]] || {
echo "::error::${reference} does not resolve to the release manifest"
exit 1
}
done
- name: Attest GHCR manifest workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/snapotter-hq/snapotter
subject-digest: ${{ needs.manifest.outputs.manifest_digest }}
push-to-registry: false
- name: Attest Docker Hub manifest workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: docker.io/snapotter/snapotter
subject-digest: ${{ needs.manifest.outputs.manifest_digest }}
push-to-registry: false
release-subjects:
name: Bind Release Commit to Published Subjects
needs: [release, archive-security, manifest, image-provenance]
if: needs.release.outputs.new_version
runs-on: ubuntu-latest
permissions:
attestations: write
contents: write
id-token: write
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Download verified archive inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: prebuilt-*
merge-multiple: true
path: /tmp/prebuilt-subjects
# GITHUB_SHA remains the commit that triggered this workflow even after
# checkout. Record it separately instead of misrepresenting it as the
# semantic-release-created commit. The attested file explicitly binds
# that release commit and tag to every immutable release asset and image.
- name: Build canonical release-subject manifest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MANIFEST_DIGEST: ${{ needs.manifest.outputs.manifest_digest }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
WORKFLOW_TRIGGER_COMMIT: ${{ github.sha }}
run: |
[[ "${GITHUB_REPOSITORY}" == "snapotter-hq/SnapOtter" ]] || {
echo "::error::Release subjects can only be created by the canonical repository"
exit 1
}
[[ "${RELEASE_COMMIT}" =~ ^[a-f0-9]{40}$ \
&& "${WORKFLOW_TRIGGER_COMMIT}" =~ ^[a-f0-9]{40}$ \
&& "${MANIFEST_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Release subject identity is malformed"
exit 1
}
release_subjects_name="snapotter-v${VERSION}-release-subjects.json"
echo "release_subjects_name=${release_subjects_name}" >> "$GITHUB_ENV"
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" --jq .id
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
gh api --paginate --slurp \
"repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \
> /tmp/release-asset-pages.json
RELEASE_ID="${release_id}" python3 <<'PY'
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
repository = os.environ["GITHUB_REPOSITORY"]
release_id = os.environ["RELEASE_ID"]
release_commit = os.environ["RELEASE_COMMIT"]
release_tag = f"v{os.environ['VERSION']}"
version = os.environ["VERSION"]
workflow_trigger_commit = os.environ["WORKFLOW_TRIGGER_COMMIT"]
manifest_digest = os.environ["MANIFEST_DIGEST"]
release_subjects_name = f"snapotter-v{version}-release-subjects.json"
output = Path("/tmp") / release_subjects_name
published_root = Path("/tmp/published-release-subjects")
prebuilt_root = Path("/tmp/prebuilt-subjects")
published_root.mkdir(mode=0o700, exist_ok=False)
if not re.fullmatch(r"[0-9]+", release_id):
raise SystemExit("invalid release ID")
pages = json.loads(Path("/tmp/release-asset-pages.json").read_text())
if not isinstance(pages, list) or not all(isinstance(page, list) for page in pages):
raise SystemExit("GitHub release asset response is not paginated JSON")
assets = [asset for page in pages for asset in page]
if not assets:
raise SystemExit("GitHub release contains no immutable assets")
names = [asset.get("name") for asset in assets]
if any(not isinstance(name, str) or PurePosixPath(name).name != name for name in names):
raise SystemExit("GitHub release contains an unsafe asset name")
if len(names) != len(set(names)):
raise SystemExit("GitHub release contains duplicate asset names")
expected_assets = set()
for arch in ("amd64", "arm64"):
archive = f"snapotter-v{version}-linux-{arch}.tar.gz"
expected_assets.update(
{
archive,
f"{archive}.sha256",
f"snapotter-v{version}-archive-linux-{arch}-sbom.cdx.json",
f"snapotter-v{version}-archive-linux-{arch}-sbom.spdx.json",
f"snapotter-v{version}-archive-linux-{arch}-trivy.json",
f"snapotter-v{version}-linux-{arch}.digest",
f"snapotter-v{version}-image-linux-{arch}-sbom.cdx.json",
f"snapotter-v{version}-image-linux-{arch}-sbom.spdx.json",
f"snapotter-v{version}-image-linux-{arch}-trivy.json",
}
)
missing = sorted(expected_assets - set(names))
if missing:
raise SystemExit(f"release subject closure is incomplete: {missing}")
subjects = []
existing_manifest = None
for asset in sorted(assets, key=lambda item: item["name"]):
name = asset["name"]
asset_id = asset.get("id")
if not isinstance(asset_id, int) or asset_id <= 0:
raise SystemExit(f"release asset has an invalid ID: {name}")
destination = published_root / name
with destination.open("xb") as handle:
subprocess.run(
[
"gh",
"api",
"-H",
"Accept: application/octet-stream",
f"repos/{repository}/releases/assets/{asset_id}",
],
check=True,
stdout=handle,
)
if name == release_subjects_name:
existing_manifest = destination
continue
if name.endswith(".tar.gz") or name.endswith(".tar.gz.sha256"):
candidate = prebuilt_root / name
if not candidate.is_file() or candidate.read_bytes() != destination.read_bytes():
raise SystemExit(f"published archive input differs: {name}")
subjects.append(
{
"digest": {"sha256": hashlib.sha256(destination.read_bytes()).hexdigest()},
"name": f"github-release://{repository}/{release_tag}/{name}",
}
)
image_digest = manifest_digest.removeprefix("sha256:")
for image in (
"docker.io/snapotter/snapotter",
"ghcr.io/snapotter-hq/snapotter",
):
subjects.append({"digest": {"sha256": image_digest}, "name": image})
subjects.sort(key=lambda subject: subject["name"])
statement = {
"_type": "https://snapotter.dev/attestations/release-subjects/v1",
"releaseCommit": release_commit,
"releaseTag": release_tag,
"repository": repository,
"subjects": subjects,
"workflowTriggerCommit": workflow_trigger_commit,
}
payload = json.dumps(statement, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
output.write_text(f"{payload}\n", encoding="utf-8")
if existing_manifest is not None and existing_manifest.read_bytes() != output.read_bytes():
raise SystemExit("Existing release-subject manifest differs")
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as environment:
environment.write(f"release_subjects_exists={str(existing_manifest is not None).lower()}\n")
PY
- name: Revalidate release tag immediately before attesting subjects
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before subject attestation"
exit 1
}
- name: Attest release-commit subject binding
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "/tmp/${{ env.release_subjects_name }}"
- name: Publish immutable release-subject manifest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
if [[ "${release_subjects_exists}" != "true" ]]; then
gh release upload "v${VERSION}" \
"/tmp/${release_subjects_name}" \
--repo snapotter-hq/SnapOtter
fi
rm -rf /tmp/release-subject-verification
mkdir -p /tmp/release-subject-verification
gh release download "v${VERSION}" \
--pattern "${release_subjects_name}" \
--dir /tmp/release-subject-verification \
--repo snapotter-hq/SnapOtter
cmp --silent \
"/tmp/${release_subjects_name}" \
"/tmp/release-subject-verification/${release_subjects_name}" || {
echo "::error::Published release-subject manifest differs"
exit 1
}
aliases:
name: Advance Non-Regressing Image Aliases
needs: [release, manifest, image-provenance, release-subjects]
runs-on: ubuntu-latest
# GitHub does not guarantee FIFO ordering and retains at most one pending
# run for a concurrency group. Every holder therefore fetches the complete
# remote tag set again while holding this lock and only publishes aliases
# for which its version is still the highest stable candidate.
concurrency:
group: snapotter-image-moving-aliases
cancel-in-progress: false
permissions:
contents: read
packages: write
steps:
- name: Check out the approved immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Prepare moving-alias freshness evaluator
run: |
cat > /tmp/eligible-image-aliases.py <<'PY'
import os
import re
import subprocess
from pathlib import Path
version = os.environ["VERSION"]
output = Path(os.environ["ALIAS_OUTPUT"])
stable_pattern = re.compile(
r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$"
)
current_match = stable_pattern.fullmatch(f"v{version}")
if current_match is None:
output.write_text("", encoding="utf-8")
raise SystemExit(0)
current = tuple(int(component) for component in current_match.groups())
stable_versions = {
tuple(int(component) for component in match.groups())
for tag in subprocess.check_output(
["git", "tag", "--list", "v*"], text=True
).splitlines()
if (match := stable_pattern.fullmatch(tag)) is not None
}
if current not in stable_versions:
raise SystemExit("Approved stable release tag is absent after remote refresh")
aliases = []
same_minor = [candidate for candidate in stable_versions if candidate[:2] == current[:2]]
same_major = [candidate for candidate in stable_versions if candidate[0] == current[0]]
if current == max(same_minor):
aliases.append(f"{current[0]}.{current[1]}")
if current == max(same_major):
aliases.append(str(current[0]))
if current == max(stable_versions):
aliases.append("latest")
output.write_text(
"".join(f"{alias}\n" for alias in aliases), encoding="utf-8"
)
PY
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Fetch and evaluate stable tags immediately before Docker Hub aliases
env:
ALIAS_OUTPUT: /tmp/dockerhub-image-aliases
PLATFORM_DIGESTS: ${{ needs.manifest.outputs.platform_digests }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --prune --prune-tags --tags origin
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before Docker Hub alias publication"
exit 1
}
python3 /tmp/eligible-image-aliases.py
mapfile -t aliases < "${ALIAS_OUTPUT}"
if [[ ${#aliases[@]} -eq 0 ]]; then
echo "No non-regressing Docker Hub aliases are eligible for v${VERSION}."
exit 0
fi
IFS=',' read -r -a digests <<< "${PLATFORM_DIGESTS}"
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::Approved platform digest closure is incomplete"
exit 1
}
arguments=()
for alias in "${aliases[@]}"; do
[[ "${alias}" =~ ^([0-9]+(\.[0-9]+)?|latest)$ ]] || {
echo "::error::Freshness evaluator returned an invalid Docker Hub alias"
exit 1
}
arguments+=("-t" "snapotter/snapotter:${alias}")
done
for digest in "${digests[@]}"; do
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid approved Docker Hub platform digest"
exit 1
}
arguments+=("snapotter/snapotter@sha256:${digest}")
done
docker buildx imagetools create "${arguments[@]}"
- name: Fetch and evaluate stable tags immediately before GHCR aliases
env:
ALIAS_OUTPUT: /tmp/ghcr-image-aliases
PLATFORM_DIGESTS: ${{ needs.manifest.outputs.platform_digests }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --prune --prune-tags --tags origin
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before GHCR alias publication"
exit 1
}
python3 /tmp/eligible-image-aliases.py
mapfile -t aliases < "${ALIAS_OUTPUT}"
if [[ ${#aliases[@]} -eq 0 ]]; then
echo "No non-regressing GHCR aliases are eligible for v${VERSION}."
exit 0
fi
IFS=',' read -r -a digests <<< "${PLATFORM_DIGESTS}"
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::Approved platform digest closure is incomplete"
exit 1
}
arguments=()
for alias in "${aliases[@]}"; do
[[ "${alias}" =~ ^([0-9]+(\.[0-9]+)?|latest)$ ]] || {
echo "::error::Freshness evaluator returned an invalid GHCR alias"
exit 1
}
arguments+=("-t" "ghcr.io/snapotter-hq/snapotter:${alias}")
done
for digest in "${digests[@]}"; do
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::Invalid approved GHCR platform digest"
exit 1
}
arguments+=("ghcr.io/snapotter-hq/snapotter@sha256:${digest}")
done
docker buildx imagetools create "${arguments[@]}"
publish-release:
name: Publish Fully Verified GitHub Release
needs: [release, aliases]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Verify approved release is still a draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
draft="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
)"
[[ "${draft}" == "true" ]] || {
echo "::error::Approved release is not a draft before final publication"
exit 1
}
- name: Publish approved release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
gh release edit "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft=false
[[ "$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
)" == "false" ]] || {
echo "::error::Approved release remained a draft after publication"
exit 1
}