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: Save release notes id: notes run: | if [ -f .release-notes.md ]; then cp .release-notes.md /tmp/release-notes.md echo "has_notes=true" >> "$GITHUB_OUTPUT" echo "Custom release notes found -- will apply after release." else echo "No .release-notes.md found -- using default release notes." fi - 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 } echo "version=${version}" >> "$GITHUB_OUTPUT" echo "release_commit=${release_commit}" >> "$GITHUB_OUTPUT" - name: Update GitHub release notes if: steps.notes.outputs.has_notes == 'true' && steps.check.outputs.version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "Updating release v${{ steps.check.outputs.version }} with custom notes..." gh release edit "v${{ steps.check.outputs.version }}" \ --notes-file /tmp/release-notes.md echo "Release notes updated successfully." - name: Update docs changelog if: steps.notes.outputs.has_notes == 'true' && steps.check.outputs.version env: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }} VERSION: ${{ steps.check.outputs.version }} run: | CHANGELOG="apps/docs/changelog.md" if [ ! -f "$CHANGELOG" ]; then echo "No docs changelog found, skipping." exit 0 fi NOTES="/tmp/release-notes.md" # Build the new entry: ## vX.Y.Z header + release notes body (skip the first ## Highlights/Upgrade sections wrapper) { echo "" echo "## v${VERSION}" echo "" # Strip the ## Highlights header and ## Upgrade section, keep the rest sed '1{/^## Highlights$/d}' "$NOTES" | sed '/^## Upgrade$/,/^---$/d' | sed '/^---$/d' echo "" echo "[Full diff on GitHub](https://github.com/snapotter-hq/SnapOtter/compare/v$(git tag --sort=-v:refname | grep -E '^v[0-9]' | sed -n '2p' | sed 's/^v//')...v${VERSION})" echo "" echo "---" echo "" } > /tmp/changelog-entry.md # Insert after the "# Changelog" header sed -i '/^# Changelog$/r /tmp/changelog-entry.md' "$CHANGELOG" # Commit and push git config user.name "SnapOtter" git config user.email "snapotter.hq@gmail.com" git add "$CHANGELOG" git commit -m "docs: update changelog for v${VERSION} [skip ci]" || true # Authenticate this direct push explicitly: the job's checkout uses # persist-credentials: false, so there is no ambient credential. The # PAT's admin identity bypasses branch protection; if the secret is # unset this no-ops (|| true) exactly as before. git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main || true echo "Docs changelog updated for v${VERSION}." 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 to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | REPOSITORY="snapotter-hq/SnapOtter" release_id="$( gh api "repos/${REPOSITORY}/releases/tags/v${VERSION}" --jq .id )" [[ "${release_id}" =~ ^[0-9]+$ ]] || { 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}") if [[ ${#asset_ids[@]} -gt 1 ]]; then echo "::error::Immutable release asset name collides: ${asset_name}" exit 1 fi 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}" } verify_or_upload_asset "/tmp/${archive_name}" verify_or_upload_asset "/tmp/${archive_name}.sha256" for asset_path in "/tmp/${archive_name}" "/tmp/${archive_name}.sha256"; 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 docker: name: Build (${{ matrix.platform }}) needs: release concurrency: group: snapotter-image-${{ needs.release.outputs.new_version }}-${{ matrix.platform }} cancel-in-progress: false # Builds and pushes the multi-arch app image (by digest) to Docker Hub + # GHCR; the manifest job then creates the named tags. Only runs when # semantic-release produced a version (or fell back to the latest tag). if: needs.release.outputs.new_version permissions: contents: write packages: write strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} steps: - name: Free disk space run: | sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ /usr/local/share/boost /opt/hostedtoolcache/CodeQL sudo docker system prune -af df -h / - name: Prepare run: | platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - name: Checkout release tag uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release.outputs.release_commit }} fetch-depth: 0 persist-credentials: false - name: Verify immutable release tag binding env: RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }} VERSION: ${{ needs.release.outputs.new_version }} run: | tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")" [[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \ && "${tag_commit}" == "${RELEASE_COMMIT}" ]] || { echo "::error::Release tag no longer resolves to the selected commit" exit 1 } - name: Validate OCR runtime trust baked into the image env: OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }} OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }} run: | : "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}" : "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" [[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || { echo "::error::OCR runtime signing key ID is not a safe identifier" exit 1 } umask 077 trap 'rm -f /tmp/ocr-runtime-public.pem' EXIT printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" \ | base64 --decode > /tmp/ocr-runtime-public.pem [[ "$(base64 --wrap=0 < /tmp/ocr-runtime-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || { echo "::error::OCR runtime public key must use canonical base64" exit 1 } openssl pkey -pubin -in /tmp/ocr-runtime-public.pem -text -noout \ | grep -q ED25519 || { echo "::error::Configured OCR runtime public key is not Ed25519" exit 1 } - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to Docker Hub uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Reuse an existing published platform digest id: existing env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.platform }} RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }} VERSION: ${{ needs.release.outputs.new_version }} run: | ghcr_ref="ghcr.io/snapotter-hq/snapotter" dockerhub_ref="snapotter/snapotter" architecture="${PLATFORM#linux/}" expected_source="https://github.com/${GITHUB_REPOSITORY}" registry_digest_state() { local reference="$1" local digest="$2" local label="$3" local manifest="/tmp/${label}-release-platform-manifest.json" local error="/tmp/${label}-release-platform-manifest.error" if docker buildx imagetools inspect "${reference}@${digest}" --raw \ > "${manifest}" 2> "${error}"; then local actual_digest actual_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)" if [[ "${actual_digest}" != "${digest}" ]]; then echo "::error::Registry returned different bytes for ${reference}@${digest}" return 2 fi if ! docker buildx imagetools inspect "${reference}@${digest}" \ --format '{{json .Image}}' > "${manifest}.image" 2> "${error}"; then cat "${error}" >&2 echo "::error::Could not inspect image configuration for ${reference}@${digest}" return 2 fi if ! jq -e --arg architecture "${architecture}" \ '.os == "linux" and .architecture == $architecture' \ "${manifest}.image" >/dev/null; then echo "::error::Registry digest has the wrong platform: ${reference}@${digest}" return 2 fi if ! jq -e \ --arg release_commit "${RELEASE_COMMIT}" \ --arg expected_source "${expected_source}" \ --arg version "${VERSION}" \ '(.config.Labels | type == "object") and .config.Labels["org.opencontainers.image.revision"] == $release_commit and .config.Labels["org.opencontainers.image.source"] == $expected_source and .config.Labels["org.opencontainers.image.version"] == $version' \ "${manifest}.image" >/dev/null; then echo "::warning::Registry digest does not bind the exact release provenance: ${reference}@${digest}" return 3 fi return 0 fi if grep -Eqi 'manifest unknown|name unknown|not found' "${error}"; then return 1 fi cat "${error}" >&2 echo "::error::Could not inspect ${reference}@${digest}" return 2 } repair_digest_replica() { local source="$1" local destination="$2" local digest="$3" local destination_label="$4" docker buildx imagetools create --prefer-index=false \ --tag "${destination}@${digest}" "${source}@${digest}" if registry_digest_state "${destination}" "${digest}" "${destination_label}"; then echo "Repaired exact ${digest} replica in ${destination}." return 0 fi echo "::error::Failed to repair exact ${digest} replica in ${destination}" return 2 } ensure_digest_replication() { local digest="$1" local ghcr_status dockerhub_status if registry_digest_state "${ghcr_ref}" "${digest}" ghcr; then ghcr_status=0 else ghcr_status=$? fi if registry_digest_state "${dockerhub_ref}" "${digest}" dockerhub; then dockerhub_status=0 else dockerhub_status=$? fi if (( ghcr_status == 2 || dockerhub_status == 2 )); then return 2 fi if (( ghcr_status == 3 || dockerhub_status == 3 )); then return 3 fi if (( ghcr_status == 1 && dockerhub_status == 1 )); then echo "::warning::Release digest is unavailable in both registries: ${digest}" return 1 fi if (( ghcr_status == 1 )); then repair_digest_replica "${dockerhub_ref}" "${ghcr_ref}" "${digest}" ghcr \ || return $? elif (( dockerhub_status == 1 )); then repair_digest_replica "${ghcr_ref}" "${dockerhub_ref}" "${digest}" dockerhub \ || return $? fi return 0 } image="${ghcr_ref}:${VERSION}" digest="" reuse_description="" if docker buildx imagetools inspect "${image}" --raw \ > /tmp/existing-release-manifest.json 2> /tmp/existing-release-manifest.error; then jq -e '.manifests | type == "array"' /tmp/existing-release-manifest.json >/dev/null || { echo "::error::Existing ${image} is not a multi-platform image index" exit 1 } mapfile -t platform_digests < <( jq -r --arg architecture "${architecture}" \ '.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture) | .digest' \ /tmp/existing-release-manifest.json ) [[ ${#platform_digests[@]} -eq 1 ]] || { echo "::error::Existing ${image} does not contain exactly one ${PLATFORM} manifest" exit 1 } digest="${platform_digests[0]}" reuse_description="${image} ${PLATFORM}" else if ! grep -Eqi 'manifest unknown|name unknown|not found' \ /tmp/existing-release-manifest.error; then cat /tmp/existing-release-manifest.error >&2 echo "::error::Could not determine whether ${image} already exists" exit 1 fi asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest" if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \ --jq ".assets[] | select(.name == \"${asset_name}\") | .id" \ > /tmp/existing-platform-asset-ids 2> /tmp/existing-platform-asset.error; then echo "::warning::Could not read ${asset_name}; rebuilding ${PLATFORM}" echo "reused=false" >> "$GITHUB_OUTPUT" exit 0 fi mapfile -t asset_ids < /tmp/existing-platform-asset-ids [[ ${#asset_ids[@]} -le 1 ]] || { echo "::error::GitHub release contains duplicate ${asset_name} checkpoints" exit 1 } if [[ ${#asset_ids[@]} -eq 0 ]]; then echo "reused=false" >> "$GITHUB_OUTPUT" echo "No published ${image} manifest or ${asset_name} checkpoint exists; building ${PLATFORM}." exit 0 fi if ! gh api -H "Accept: application/octet-stream" \ "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_ids[0]}" \ > "/tmp/${asset_name}"; then echo "::warning::Could not download ${asset_name}; rebuilding ${PLATFORM}" echo "reused=false" >> "$GITHUB_OUTPUT" exit 0 fi checkpoint="/tmp/${asset_name}" digest="$(<"${checkpoint}")" if [[ "$(wc -c < "${checkpoint}")" -ne 72 ]] \ || [[ "$(wc -l < "${checkpoint}")" -ne 1 ]] \ || [[ ! "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then echo "::warning::Ignoring unreadable ${asset_name}; rebuilding ${PLATFORM}" echo "reused=false" >> "$GITHUB_OUTPUT" exit 0 fi reuse_description="immutable ${asset_name} checkpoint" fi [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || { echo "::error::Existing release contains an invalid ${PLATFORM} digest" exit 1 } if ensure_digest_replication "${digest}"; then echo "digest=${digest}" >> "$GITHUB_OUTPUT" echo "reused=true" >> "$GITHUB_OUTPUT" echo "Reusing ${reuse_description} at ${digest}." exit 0 else replication_status=$? fi if (( replication_status == 1 )); then echo "::warning::No trustworthy registry source remains for ${digest}; rebuilding ${PLATFORM}" echo "reused=false" >> "$GITHUB_OUTPUT" exit 0 fi if (( replication_status == 3 )); then echo "::warning::Existing digest is not from release ${RELEASE_COMMIT}; rebuilding ${PLATFORM}" echo "reused=false" >> "$GITHUB_OUTPUT" exit 0 fi exit "${replication_status}" - name: Extract metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | snapotter/snapotter ghcr.io/snapotter-hq/snapotter labels: | org.opencontainers.image.revision=${{ needs.release.outputs.release_commit }} org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.version=${{ needs.release.outputs.new_version }} - name: Build and push by digest id: build if: steps.existing.outputs.reused != 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: docker/Dockerfile platforms: ${{ matrix.platform }} build-args: | SNAPOTTER_ANALYTICS=on SNAPOTTER_POSTHOG_PROJECT_ID=${{ secrets.SNAPOTTER_POSTHOG_KEY }} SNAPOTTER_SENTRY_DSN=${{ secrets.SNAPOTTER_SENTRY_DSN }} SNAPOTTER_SENTRY_DSN_WEB=${{ secrets.SNAPOTTER_SENTRY_DSN_WEB }} SENTRY_RELEASE=${{ needs.release.outputs.new_version }} OCR_RUNTIME_INDEX_KEY_ID=${{ vars.OCR_RUNTIME_INDEX_KEY_ID }} OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64=${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }} SNAPOTTER_OFFICIAL_CONTAINER=1 secrets: | sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} labels: ${{ steps.meta.outputs.labels }} # The manual attestation workflow signs release images. Buildx's # default provenance sidecars show up in GHCR as unknown/unknown # architectures on the package page. provenance: false outputs: type=image,"name=snapotter/snapotter,ghcr.io/snapotter-hq/snapotter",push-by-digest=true,name-canonical=true,push=true cache-from: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }} cache-to: type=registry,ref=ghcr.io/snapotter-hq/snapotter:cache-${{ env.PLATFORM_PAIR }},mode=max - name: Export digest env: BUILT_DIGEST: ${{ steps.build.outputs.digest }} EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }} VERSION: ${{ needs.release.outputs.new_version }} run: | mkdir -p /tmp/digests /tmp/release-digests digest="${EXISTING_DIGEST:-${BUILT_DIGEST}}" [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || { echo "::error::Release image did not produce a valid digest" exit 1 } registry_index=0 architecture="${PLATFORM_PAIR#linux-}" expected_source="https://github.com/${GITHUB_REPOSITORY}" for reference in \ ghcr.io/snapotter-hq/snapotter \ snapotter/snapotter; do manifest="/tmp/exported-release-manifest-${registry_index}.json" docker buildx imagetools inspect "${reference}@${digest}" --raw > "${manifest}" actual_digest="sha256:$(sha256sum "${manifest}" | cut -d ' ' -f 1)" [[ "${actual_digest}" == "${digest}" ]] || { echo "::error::Registry returned different bytes for ${reference}@${digest}" exit 1 } docker buildx imagetools inspect "${reference}@${digest}" \ --format '{{json .Image}}' > "${manifest}.image" jq -e --arg architecture "${architecture}" \ '.os == "linux" and .architecture == $architecture' \ "${manifest}.image" >/dev/null || { echo "::error::Registry digest has the wrong platform: ${reference}@${digest}" exit 1 } jq -e \ --arg release_commit "${RELEASE_COMMIT}" \ --arg expected_source "${expected_source}" \ --arg version "${VERSION}" \ '(.config.Labels | type == "object") and .config.Labels["org.opencontainers.image.revision"] == $release_commit and .config.Labels["org.opencontainers.image.source"] == $expected_source and .config.Labels["org.opencontainers.image.version"] == $version' \ "${manifest}.image" >/dev/null || { echo "::error::Release image has invalid source provenance: ${reference}@${digest}" exit 1 } registry_index=$((registry_index + 1)) done touch "/tmp/digests/${digest#sha256:}" printf '%s\n' "${digest}" \ > "/tmp/release-digests/snapotter-v${VERSION}-${PLATFORM_PAIR}.digest" - name: Persist immutable platform digest on the GitHub release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest" gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \ --jq ".assets[] | select(.name == \"${asset_name}\") | .id" \ > /tmp/platform-digest-asset-ids mapfile -t asset_ids < /tmp/platform-digest-asset-ids [[ ${#asset_ids[@]} -le 1 ]] || { echo "::error::GitHub release contains duplicate ${asset_name} assets" exit 1 } if [[ ${#asset_ids[@]} -eq 1 ]]; then gh api -H "Accept: application/octet-stream" \ "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_ids[0]}" \ > "/tmp/existing-${asset_name}" cmp --silent "/tmp/existing-${asset_name}" "/tmp/release-digests/${asset_name}" || { echo "::error::Existing GitHub release platform digest differs for ${PLATFORM_PAIR}" exit 1 } echo "Verified existing immutable GitHub release asset ${asset_name}." else gh release upload "v${VERSION}" "/tmp/release-digests/${asset_name}" \ --repo snapotter-hq/SnapOtter fi - name: Upload digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.PLATFORM_PAIR }} overwrite: true path: /tmp/digests/* if-no-files-found: error retention-days: 90 scan: name: Trivy Container Scan (${{ matrix.platform }}) needs: [release, docker] runs-on: ubuntu-latest strategy: fail-fast: false matrix: platform: - linux-amd64 - linux-arm64 permissions: contents: write packages: read security-events: write steps: - name: Download architecture digest uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: digests-${{ matrix.platform }} path: /tmp/digests - name: Log in to GitHub Container Registry uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GHCR_TOKEN }} - name: Get digest id: digest run: | mapfile -t digest_files < <(find /tmp/digests -maxdepth 1 -type f -print) [[ ${#digest_files[@]} -eq 1 ]] || { echo "::error::Expected exactly one architecture digest" exit 1 } sha="$(basename "${digest_files[0]}")" [[ "${sha}" =~ ^[a-f0-9]{64}$ ]] || { echo "::error::Invalid architecture digest" exit 1 } echo "sha=${sha}" >> "$GITHUB_OUTPUT" - name: Checkout for trivyignore uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ needs.release.outputs.release_commit }} fetch-depth: 0 persist-credentials: false sparse-checkout: .trivyignore 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 } - 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 }} - 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 }}-${{ matrix.platform }}-trivy.json" ignore-unfixed: true - name: Upload Trivy report to GitHub Release if: always() env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | gh release upload "v${VERSION}" \ "snapotter-v${VERSION}-${{ matrix.platform }}-trivy.json" \ --clobber --repo snapotter-hq/SnapOtter 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}-${{ matrix.platform }}-sbom.cdx.json" syft scan "$IMAGE" -o "spdx-json=snapotter-v${VERSION}-${{ matrix.platform }}-sbom.spdx.json" - name: Upload SBOMs to GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.release.outputs.new_version }} run: | gh release upload "v${VERSION}" \ "snapotter-v${VERSION}-${{ matrix.platform }}-sbom.cdx.json" \ "snapotter-v${VERSION}-${{ matrix.platform }}-sbom.spdx.json" \ --clobber --repo snapotter-hq/SnapOtter 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 # `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: 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, 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: 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 {} \;) [[ ${#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 {} \;) [[ ${#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[@]}" aliases: name: Advance Non-Regressing Image Aliases needs: [release, manifest] runs-on: ubuntu-latest # GitHub does not guarantee FIFO ordering 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 queue: max 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[@]}"