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 }