mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
2083 lines
88 KiB
YAML
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
|
|
}
|