Files
SnapOtter/.github/workflows/release.yml
T
SnapOtterandGitHub 5c75a93484 ci(release): fix the archive tsx path and patch the go-tools x/text HIGH (#665)
Two latent bugs the v2.2.0 release run surfaced, both added by #649 and never run
in a real release (v2.1.0 had no archive-security job).

archive-security asserted tsx at the workspace root, but tsx is a prod dependency
of apps/api, so pnpm places its bin at apps/api/node_modules/.bin/tsx, where the
Docker CMD runs it. The root path never existed and failed the extract step on
both arches. Fixed to the apps/api path, proven against the real prebuilt-amd64
artifact.

The blocking Trivy scans would then have failed on CVE-2026-56852,
golang.org/x/text v0.38.0 -> v0.39.0, the only fixed CRITICAL/HIGH in the image,
compiled into caire and pdfcpu. Pinned to v0.39.0 in both go-tools modules,
verified building in the golang:1.25.12 toolchain with -mod=readonly and linking
v0.39.0, with a clean Trivy rescan.

Guards added for both the tsx path and the x/text pin. Non-releasable type so a
re-dispatch re-runs 2.2.0.
2026-07-29 19:05:30 +08:00

2083 lines
88 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: |
# Resolve the release by numeric id, never by tag. GitHub's
# /releases/tags/{tag} endpoint returns 404 for a draft, and
# draftRelease is on, so a tag lookup here would 404 on the release
# semantic-release just created and take the whole job down with it.
# gh release view reads drafts, and /releases/{id} then returns the
# same REST shape a tag lookup would.
resolve_release_id() {
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json databaseId \
--jq .databaseId 2>/dev/null
}
release_id="$(resolve_release_id || true)"
if [[ ! "${release_id}" =~ ^[0-9]+$ ]]; then
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft \
--verify-tag \
--title "v${VERSION}" \
--notes-file /tmp/release-notes.md
release_id="$(resolve_release_id)"
fi
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::Could not resolve the GitHub release id for v${VERSION}"
exit 1
}
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/${release_id}"
gh api "${release_endpoint}" > /tmp/release.json
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
# tsx is a prod dependency of apps/api, so pnpm's workspace layout puts
# its bin under apps/api/node_modules/.bin, not the workspace root. This
# is exactly where the Docker CMD runs it from (WORKDIR apps/api,
# ./node_modules/.bin/tsx). The root path never existed; this assertion
# was added in #649 and this is its first real release run.
test -x /tmp/prebuilt-root/snapotter/apps/api/node_modules/.bin/tsx
( cd /tmp/prebuilt-root/snapotter/apps/api && ./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 and HIGH 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}" \
--severity CRITICAL,HIGH \
--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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh release view "v${VERSION}" --repo "${REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
reuse_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId 2>/dev/null || true
)"
if [[ ! "${reuse_release_id}" =~ ^[0-9]+$ ]]; then
echo "::warning::Could not resolve the release id; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/${reuse_release_id}" \
--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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
persist_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${persist_release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
gh api "repos/${GITHUB_REPOSITORY}/releases/${persist_release_id}" \
--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 and HIGH 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}" \
--severity CRITICAL,HIGH \
--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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${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"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${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 release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)"
[[ "${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 release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)" == "false" ]] || {
echo "::error::Approved release remained a draft after publication"
exit 1
}