mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Two release-pipeline defects found while pre-flighting 2.2.0, plus the image hardening that the second one exposed. The release job would have died immediately after pushing the v2.2.0 tag. draftRelease was turned on in #649 and never executed, and GitHub's /releases/tags/{tag} endpoint does not return draft releases, so all nine tag lookups in release.yml would have 404'd against the draft semantic-release had just created. Verified against this repo with a throwaway draft: the tag endpoint 404s while gh release view reads it and /releases/{id} returns the same REST shape. Every site now resolves the numeric id first, so existing jq expressions are untouched. The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the remainder but defaults to CRITICAL with neither call site passing --severity. An unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while the summary read clean. Rather than document 79 findings, the image lost what it did not need: libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the decoder every .heic upload actually reached), and xvfb, wget and openssh-client are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each now carrying a rationale verified against the running container. curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no fix was available would have been false. It is recorded as a declined fix. Verified on both architectures: gate exits 0, the source-built libde265 is the one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium all still work after the purge.
2078 lines
88 KiB
YAML
2078 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
|
|
test -x /tmp/prebuilt-root/snapotter/node_modules/.bin/tsx
|
|
/tmp/prebuilt-root/snapotter/node_modules/.bin/tsx --version
|
|
echo "archive_name=${archive_name}" >> "$GITHUB_ENV"
|
|
|
|
- name: Install pinned Syft 1.42.3 from verified release bytes
|
|
env:
|
|
SYFT_VERSION: "1.42.3"
|
|
run: |
|
|
case "$(uname -m)" in
|
|
x86_64)
|
|
syft_arch="amd64"
|
|
expected_sha256="0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509"
|
|
;;
|
|
aarch64 | arm64)
|
|
syft_arch="arm64"
|
|
expected_sha256="dc630590c953347789d08f8ebf57c7d8094db89100785fcd94b1cddeac791804"
|
|
;;
|
|
*)
|
|
echo "::error::Unsupported Syft installer architecture: $(uname -m)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
archive="syft_${SYFT_VERSION}_linux_${syft_arch}.tar.gz"
|
|
install_root="${RUNNER_TEMP}/syft-${SYFT_VERSION}"
|
|
rm -rf "${install_root}"
|
|
mkdir -p "${install_root}"
|
|
curl --fail --location --silent --show-error \
|
|
--proto '=https' --tlsv1.2 --retry 3 \
|
|
--output "${install_root}/${archive}" \
|
|
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
|
|
printf '%s %s\n' "${expected_sha256}" "${install_root}/${archive}" \
|
|
| sha256sum --check --strict -
|
|
tar -xzf "${install_root}/${archive}" -C "${install_root}" syft
|
|
chmod 0755 "${install_root}/syft"
|
|
"${install_root}/syft" version -o json \
|
|
| jq -e --arg version "${SYFT_VERSION}" '.version == $version' >/dev/null
|
|
echo "${install_root}" >> "$GITHUB_PATH"
|
|
|
|
- name: Generate archive SBOMs
|
|
env:
|
|
ARCH: ${{ matrix.arch }}
|
|
VERSION: ${{ needs.release.outputs.new_version }}
|
|
run: |
|
|
syft scan dir:/tmp/prebuilt-root/snapotter \
|
|
-o "cyclonedx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json"
|
|
syft scan dir:/tmp/prebuilt-root/snapotter \
|
|
-o "spdx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json"
|
|
|
|
# Blocks on CRITICAL/HIGH that have a fix available. The unfixed gate
|
|
# below covers what this one cannot see.
|
|
- name: Scan archive filesystem
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
scan-type: fs
|
|
scan-ref: /tmp/prebuilt-root/snapotter
|
|
format: table
|
|
exit-code: "1"
|
|
ignore-unfixed: true
|
|
severity: CRITICAL,HIGH
|
|
trivyignores: .trivyignore
|
|
|
|
- name: Record archive scan
|
|
if: always()
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
scan-type: fs
|
|
scan-ref: /tmp/prebuilt-root/snapotter
|
|
format: json
|
|
output: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
|
|
|
|
- name: Gate unfixed CRITICAL 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
|
|
}
|