mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
1413 lines
59 KiB
YAML
1413 lines
59 KiB
YAML
name: Release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
release:
|
|
name: Semantic Release
|
|
runs-on: ubuntu-latest
|
|
concurrency:
|
|
group: snapotter-semantic-release
|
|
cancel-in-progress: false
|
|
permissions:
|
|
contents: write
|
|
issues: write
|
|
pull-requests: write
|
|
outputs:
|
|
new_version: ${{ steps.check.outputs.version }}
|
|
release_commit: ${{ steps.check.outputs.release_commit }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Validate OCR release trust before publishing
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
|
|
run: |
|
|
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
|
|
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
|
|
[[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || {
|
|
echo "::error::OCR runtime signing key ID is not a safe identifier"
|
|
exit 1
|
|
}
|
|
umask 077
|
|
trap 'rm -f /tmp/ocr-release-public.pem' EXIT
|
|
printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" \
|
|
| base64 --decode > /tmp/ocr-release-public.pem
|
|
[[ "$(base64 --wrap=0 < /tmp/ocr-release-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
|
|
echo "::error::OCR runtime public key must use canonical base64"
|
|
exit 1
|
|
}
|
|
openssl pkey -pubin -in /tmp/ocr-release-public.pem -text -noout \
|
|
| grep -q ED25519 || {
|
|
echo "::error::Configured OCR runtime public key is not Ed25519"
|
|
exit 1
|
|
}
|
|
|
|
- uses: ./.github/actions/setup
|
|
|
|
- name: 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[@]}"
|