fix: release QA hardening across processing, media, security, and CI gates (#649)

A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
This commit is contained in:
SnapOtter
2026-07-27 15:37:30 +08:00
committed by GitHub
parent bc32f86a07
commit d10d0f544f
855 changed files with 54564 additions and 13092 deletions
+3
View File
@@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- snapotter-nvidia
+8
View File
@@ -7,6 +7,8 @@ updates:
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
open-pull-requests-limit: 10
groups:
production-deps:
@@ -29,6 +31,8 @@ updates:
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
@@ -40,6 +44,8 @@ updates:
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
open-pull-requests-limit: 3
ignore:
- dependency-name: "*"
@@ -59,6 +65,8 @@ updates:
schedule:
interval: "weekly"
day: "monday"
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
actions:
+457 -1
View File
@@ -16,6 +16,14 @@ on:
required: true
OCR_RUNTIME_INDEX_SIGNING_KEY_B64:
required: true
workflow_dispatch:
inputs:
source_run_id:
description: Optional successful Release workflow run containing the scanned amd64 digest
required: false
type: string
schedule:
- cron: "17 3 * * 1"
concurrency:
group: ai-bundles-${{ inputs.version }}
@@ -26,6 +34,7 @@ permissions: {}
jobs:
validate-inputs:
name: Validate release inputs
if: inputs.release_commit != ''
runs-on: ubuntu-latest
permissions: {}
steps:
@@ -117,6 +126,24 @@ jobs:
exit 1
}
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install pinned dependency auditor
run: pip install "pip-audit==2.10.0"
- name: Audit exact OCR runtime dependency lock
env:
TARGET: ${{ matrix.target }}
run: |
case "${TARGET}" in
linux-amd64-*) requirements="docker/ocr-runtime-requirements-amd64.txt" ;;
linux-arm64-*) requirements="docker/ocr-runtime-requirements-arm64.txt" ;;
*) echo "::error::Unknown OCR runtime target: ${TARGET}"; exit 1 ;;
esac
pip-audit -r "${requirements}" --no-deps --disable-pip --aliases
- name: Download unpublished image digest
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -1252,7 +1279,6 @@ jobs:
concurrency:
group: snapotter-hf-feature-bundles-publish
cancel-in-progress: false
queue: max
runs-on: ubuntu-latest
permissions:
contents: read
@@ -1974,3 +2000,433 @@ jobs:
env:
VERSION: ${{ inputs.version }}
run: echo "Verified immutable existing bundle release v${VERSION}; upload skipped"
installed-ai-production:
name: Verify installed AI in production image
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
actions: read
contents: read
packages: read
steps:
- name: Resolve a successful scanned release run
id: source
if: inputs.release_commit == ''
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
run: |
set -euo pipefail
if [[ -n "${SOURCE_RUN_ID}" ]]; then
[[ "${SOURCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]] || {
echo "::error::source_run_id must be a positive workflow run ID"
exit 1
}
candidate_run_ids=("${SOURCE_RUN_ID}")
else
gh api \
"repos/${GITHUB_REPOSITORY}/actions/workflows/release.yml/runs?status=completed&per_page=50" \
> "${RUNNER_TEMP}/release-runs.json"
mapfile -t candidate_run_ids < <(
jq -r '.workflow_runs[] | select(.conclusion == "success") | .id' \
"${RUNNER_TEMP}/release-runs.json"
)
fi
selected_run=""
for candidate_run_id in "${candidate_run_ids[@]}"; do
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${candidate_run_id}" \
> "${RUNNER_TEMP}/candidate-run.json"
if ! jq -e \
'.path == ".github/workflows/release.yml"
and .status == "completed"
and .conclusion == "success"' \
"${RUNNER_TEMP}/candidate-run.json" >/dev/null; then
if [[ -n "${SOURCE_RUN_ID}" ]]; then
echo "::error::Selected run is not a completed successful Release workflow"
exit 1
fi
continue
fi
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${candidate_run_id}/artifacts?per_page=100" \
> "${RUNNER_TEMP}/candidate-artifacts.json"
artifact_count="$(
jq '[.artifacts[] | select(.name == "digests-linux-amd64" and .expired == false)] | length' \
"${RUNNER_TEMP}/candidate-artifacts.json"
)"
if [[ "${artifact_count}" != "1" ]]; then
if [[ -n "${SOURCE_RUN_ID}" ]]; then
echo "::error::Selected release run does not have one live amd64 digest artifact"
exit 1
fi
continue
fi
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${candidate_run_id}/jobs?per_page=100" \
> "${RUNNER_TEMP}/candidate-jobs.json"
if ! jq -e \
'([.jobs[] | select(.name == "Trivy Container Scan (linux-amd64)")] | length) == 1
and ([.jobs[] | select(.name == "Trivy Container Scan (linux-amd64)")][0].conclusion == "success")' \
"${RUNNER_TEMP}/candidate-jobs.json" >/dev/null; then
if [[ -n "${SOURCE_RUN_ID}" ]]; then
echo "::error::Selected release run has no successful amd64 container scan"
exit 1
fi
continue
fi
selected_run="${candidate_run_id}"
break
done
[[ -n "${selected_run}" ]] || {
echo "::error::No successful Release run has a live, scanned amd64 digest artifact"
exit 1
}
echo "run_id=${selected_run}" >> "${GITHUB_OUTPUT}"
- name: Download current release amd64 digest
if: inputs.release_commit != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/installed-ai-digest
- name: Download historical release amd64 digest
if: inputs.release_commit == ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/installed-ai-digest
github-token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
run-id: ${{ steps.source.outputs.run_id }}
- name: Resolve the exact image digest
run: |
set -euo pipefail
mapfile -t digest_files < <(
find "${RUNNER_TEMP}/installed-ai-digest" -mindepth 1 -maxdepth 1 -type f -print
)
[[ ${#digest_files[@]} -eq 1 ]] || {
echo "::error::Expected exactly one amd64 image digest"
exit 1
}
IMAGE_DIGEST="$(basename "${digest_files[0]}")"
[[ "${IMAGE_DIGEST}" =~ ^[a-f0-9]{64}$ ]] || {
echo "::error::amd64 image artifact contains an invalid digest"
exit 1
}
[[ ! -s "${digest_files[0]}" ]] || {
echo "::error::amd64 digest marker unexpectedly contains mutable payload data"
exit 1
}
echo "IMAGE_DIGEST=${IMAGE_DIGEST}" >> "${GITHUB_ENV}"
- name: Reclaim unused hosted-runner SDK space
run: |
sudo rm -rf \
/usr/share/dotnet \
/usr/local/lib/android \
/opt/ghc \
/usr/local/share/boost \
/opt/hostedtoolcache/CodeQL
- name: Require enough free disk for the image and installed bundles
run: |
set -euo pipefail
docker_root_dir="$(docker info --format '{{.DockerRootDir}}')"
[[ "${docker_root_dir}" == /* ]] || {
echo "::error::Docker reported an invalid storage root"
exit 1
}
available_kib="$(sudo df -Pk "${docker_root_dir}" | awk 'NR == 2 { print $4 }')"
[[ "${available_kib}" =~ ^[0-9]+$ ]] || {
echo "::error::Could not measure runner disk availability"
exit 1
}
# background-removal's 4.8 GB archive reserves roughly 18.4 GiB in
# /data by itself; keep room for the image, venv, and transcription.
minimum_kib=$((30 * 1024 * 1024))
(( available_kib >= minimum_kib )) || {
echo "::error::Installed-AI verification requires 30 GiB free; runner has $((available_kib / 1024 / 1024)) GiB"
exit 1
}
sudo df -h "${docker_root_dir}"
- 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: Pull and validate the exact amd64 release image
id: image
env:
RELEASE_COMMIT: ${{ inputs.release_commit }}
run: |
set -euo pipefail
image="ghcr.io/snapotter-hq/snapotter@sha256:${IMAGE_DIGEST}"
docker pull "${image}"
architecture="$(docker image inspect --format '{{.Architecture}}' "${image}")"
source_repository="$(
docker image inspect \
--format '{{ index .Config.Labels "org.opencontainers.image.source" }}' \
"${image}"
)"
source_commit="$(
docker image inspect \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
"${image}"
)"
[[ "${architecture}" == "amd64" ]] || {
echo "::error::Digest resolved to ${architecture}, not amd64"
exit 1
}
[[ "${source_repository}" == "https://github.com/${GITHUB_REPOSITORY}" ]] || {
echo "::error::Release image source label does not match this repository"
exit 1
}
[[ "${source_commit}" =~ ^[a-f0-9]{40}$ ]] || {
echo "::error::Release image has no immutable source revision"
exit 1
}
if [[ -n "${RELEASE_COMMIT}" && "${source_commit}" != "${RELEASE_COMMIT}" ]]; then
echo "::error::Release image revision differs from the requested release commit"
exit 1
fi
echo "source_commit=${source_commit}" >> "${GITHUB_OUTPUT}"
- name: Checkout the immutable QA harness revision
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.workflow_sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify immutable QA harness checkout
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
[[ "${WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ \
&& "$(git rev-parse HEAD)" == "${WORKFLOW_SHA}" ]] || {
echo "::error::QA harness checkout differs from the workflow revision"
exit 1
}
- uses: ./.github/actions/setup
- name: Create exact labeled production resources
run: |
set -euo pipefail
RUN_SCOPE="snapotter-ai-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
[[ "${RUN_SCOPE}" =~ ^snapotter-ai-[0-9]+-[0-9]+$ ]] || {
echo "::error::Unsafe installed-AI run scope"
exit 1
}
QA_PASSWORD="$(openssl rand -hex 24)"
echo "::add-mask::${QA_PASSWORD}"
echo "RUN_SCOPE=${RUN_SCOPE}" >> "${GITHUB_ENV}"
echo "QA_PASSWORD=${QA_PASSWORD}" >> "${GITHUB_ENV}"
docker network create \
--label "io.snapotter.qa.scope=${RUN_SCOPE}" \
"${RUN_SCOPE}-network"
for suffix in data workspace pgdata redisdata; do
docker volume create \
--label "io.snapotter.qa.scope=${RUN_SCOPE}" \
"${RUN_SCOPE}-${suffix}"
done
docker run --detach \
--name "${RUN_SCOPE}-postgres" \
--label "io.snapotter.qa.scope=${RUN_SCOPE}" \
--network "${RUN_SCOPE}-network" \
--network-alias postgres \
--env POSTGRES_USER=snapotter \
--env "POSTGRES_PASSWORD=${QA_PASSWORD}" \
--env POSTGRES_DB=snapotter \
--volume "${RUN_SCOPE}-pgdata:/var/lib/postgresql/data" \
postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
docker run --detach \
--name "${RUN_SCOPE}-redis" \
--label "io.snapotter.qa.scope=${RUN_SCOPE}" \
--network "${RUN_SCOPE}-network" \
--network-alias redis \
--volume "${RUN_SCOPE}-redisdata:/data" \
redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005 \
redis-server --maxmemory-policy noeviction --maxmemory 512mb \
--appendonly yes --requirepass "${QA_PASSWORD}"
dependencies_ready=false
for _attempt in $(seq 1 90); do
if docker exec "${RUN_SCOPE}-postgres" \
pg_isready -U snapotter -d snapotter >/dev/null 2>&1 \
&& docker exec "${RUN_SCOPE}-redis" \
redis-cli -a "${QA_PASSWORD}" --no-auth-warning ping 2>/dev/null \
| grep -qx PONG; then
dependencies_ready=true
break
fi
sleep 2
done
[[ "${dependencies_ready}" == "true" ]] || {
echo "::error::Production dependencies did not become ready"
exit 1
}
docker run --detach \
--name "${RUN_SCOPE}-app" \
--label "io.snapotter.qa.scope=${RUN_SCOPE}" \
--network "${RUN_SCOPE}-network" \
--publish 127.0.0.1::1349 \
--env AUTH_ENABLED=true \
--env DEFAULT_USERNAME=admin \
--env "DEFAULT_PASSWORD=${QA_PASSWORD}" \
--env SKIP_MUST_CHANGE_PASSWORD=true \
--env SNAPOTTER_GPU=0 \
--env "DATABASE_URL=postgres://snapotter:${QA_PASSWORD}@postgres:5432/snapotter" \
--env "REDIS_URL=redis://:${QA_PASSWORD}@redis:6379" \
--env PROCESSING_TIMEOUT_S=0 \
--env RATE_LIMIT_PER_MIN=1000 \
--volume "${RUN_SCOPE}-data:/data" \
--volume "${RUN_SCOPE}-workspace:/tmp/workspace" \
"ghcr.io/snapotter-hq/snapotter@sha256:${IMAGE_DIGEST}"
mapfile -t port_bindings < <(docker port "${RUN_SCOPE}-app" 1349/tcp)
[[ ${#port_bindings[@]} -eq 1 \
&& "${port_bindings[0]}" =~ ^127\.0\.0\.1:([1-9][0-9]{0,4})$ ]] || {
echo "::error::Production app did not publish one loopback-only dynamic port"
exit 1
}
QA_PORT="${BASH_REMATCH[1]}"
(( QA_PORT <= 65535 )) || {
echo "::error::Docker returned an invalid published port"
exit 1
}
echo "QA_BASE_URL=http://127.0.0.1:${QA_PORT}" >> "${GITHUB_ENV}"
- name: Wait for the production API
run: |
set -euo pipefail
api_ready=false
for _attempt in $(seq 1 120); do
if curl --fail --silent --show-error --max-time 5 \
"${QA_BASE_URL}/api/v1/health" \
| jq -e '.status == "healthy"' >/dev/null; then
api_ready=true
break
fi
if ! docker inspect --format '{{.State.Running}}' "${RUN_SCOPE}-app" \
| grep -qx true; then
echo "::error::Production app container exited before becoming healthy"
docker logs "${RUN_SCOPE}-app" || true
exit 1
fi
sleep 3
done
[[ "${api_ready}" == "true" ]] || {
echo "::error::Production API did not become healthy"
docker logs "${RUN_SCOPE}-app" || true
exit 1
}
- name: Install bundles and verify four public tool artifacts
env:
QA_USERNAME: admin
run: ./apps/api/node_modules/.bin/tsx tests/qa/verify-installed-ai-production.mts
- name: Show exact production-container diagnostics
if: ${{ failure() }}
run: |
docker logs "${RUN_SCOPE}-app" || true
docker logs "${RUN_SCOPE}-postgres" || true
docker logs "${RUN_SCOPE}-redis" || true
- name: Remove only exact labeled run resources
if: ${{ always() }}
run: |
set -u
cleanup_failed=0
if [[ "${RUN_SCOPE:-}" =~ ^snapotter-ai-[0-9]+-[0-9]+$ ]]; then
if docker container inspect "${RUN_SCOPE}-app" >/dev/null 2>&1; then
app_scope="$(
docker container inspect \
--format '{{ index .Config.Labels "io.snapotter.qa.scope" }}' \
"${RUN_SCOPE}-app"
)"
if [[ "${app_scope}" == "${RUN_SCOPE}" ]]; then
docker rm --force "${RUN_SCOPE}-app" || cleanup_failed=1
else
echo "::error::Refusing to remove app container without the exact run label"
cleanup_failed=1
fi
fi
for suffix in postgres redis; do
name="${RUN_SCOPE}-${suffix}"
if docker container inspect "${name}" >/dev/null 2>&1; then
scope="$(
docker container inspect \
--format '{{ index .Config.Labels "io.snapotter.qa.scope" }}' \
"${name}"
)"
if [[ "${scope}" == "${RUN_SCOPE}" ]]; then
docker rm --force "${name}" || cleanup_failed=1
else
echo "::error::Refusing to remove ${name} without the exact run label"
cleanup_failed=1
fi
fi
done
if docker volume inspect "${RUN_SCOPE}-data" >/dev/null 2>&1; then
data_scope="$(
docker volume inspect \
--format '{{ index .Labels "io.snapotter.qa.scope" }}' \
"${RUN_SCOPE}-data"
)"
if [[ "${data_scope}" == "${RUN_SCOPE}" ]]; then
docker volume rm "${RUN_SCOPE}-data" || cleanup_failed=1
else
echo "::error::Refusing to remove data volume without the exact run label"
cleanup_failed=1
fi
fi
for suffix in workspace pgdata redisdata; do
name="${RUN_SCOPE}-${suffix}"
if docker volume inspect "${name}" >/dev/null 2>&1; then
scope="$(
docker volume inspect \
--format '{{ index .Labels "io.snapotter.qa.scope" }}' \
"${name}"
)"
if [[ "${scope}" == "${RUN_SCOPE}" ]]; then
docker volume rm "${name}" || cleanup_failed=1
else
echo "::error::Refusing to remove ${name} without the exact run label"
cleanup_failed=1
fi
fi
done
if docker network inspect "${RUN_SCOPE}-network" >/dev/null 2>&1; then
network_scope="$(
docker network inspect \
--format '{{ index .Labels "io.snapotter.qa.scope" }}' \
"${RUN_SCOPE}-network"
)"
if [[ "${network_scope}" == "${RUN_SCOPE}" ]]; then
docker network rm "${RUN_SCOPE}-network" || cleanup_failed=1
else
echo "::error::Refusing to remove network without the exact run label"
cleanup_failed=1
fi
fi
elif [[ -n "${RUN_SCOPE:-}" ]]; then
echo "::error::Refusing cleanup for an unsafe run scope"
cleanup_failed=1
fi
exit "${cleanup_failed}"
-69
View File
@@ -1,69 +0,0 @@
name: Attest Provenance
# Generates SLSA build-provenance attestations for an already-published release,
# so no image rebuild is required. Dispatch it after a release once the image
# manifest and source archives exist.
#
# Keep image attestations in GitHub's attestation API instead of pushing them
# back to registries. GHCR renders OCI fallback sha256-* attestation tags as
# package versions, which makes the package page recommend non-runtime artifacts.
# Verify with: gh attestation verify oci://<image>@<digest> -R snapotter-hq/SnapOtter
on:
workflow_dispatch:
inputs:
version:
description: "Release version without the leading v (e.g. 2.0.0)"
required: true
type: string
image_digest:
description: "Multi-arch manifest digest, sha256:... (same on both registries)"
required: true
type: string
permissions: {}
jobs:
image:
name: Attest image
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Attest GHCR image
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/snapotter-hq/snapotter
subject-digest: ${{ inputs.image_digest }}
push-to-registry: false
- name: Attest Docker Hub image
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: docker.io/snapotter/snapotter
subject-digest: ${{ inputs.image_digest }}
push-to-registry: false
archives:
name: Attest source archives
runs-on: ubuntu-latest
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Download release archives
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
run: |
gh release download "v${VERSION}" --repo "$REPO" --pattern 'snapotter-v*-linux-*.tar.gz'
ls -l snapotter-v*-linux-*.tar.gz
- name: Attest archives
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "snapotter-v*-linux-*.tar.gz"
+72 -22
View File
@@ -38,6 +38,7 @@ jobs:
outputs:
code: ${{ steps.filter.outputs.code }}
landing: ${{ steps.filter.outputs.landing }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- id: filter
@@ -53,10 +54,12 @@ jobs:
base="$PUSH_BEFORE_SHA"
fi
# `code`: anything outside the docs/landing surfaces changed (gates the
# main pipeline). `landing`: the landing's own surface changed (gates the
# landing e2e job). Both fail open to true when the base is unknown.
# main pipeline). `landing` and `docs`: that surface's own files changed
# (gates its e2e job). All three fail open to true when the base is
# unknown.
code=true
landing=true
docs=true
if [ -n "$base" ] && [ "$base" != "0000000000000000000000000000000000000000" ]; then
if git fetch --no-tags --depth=1 origin "$base" && git cat-file -e "$base" 2>/dev/null; then
changed=$(git diff --name-only "$base" HEAD)
@@ -75,6 +78,12 @@ jobs:
apps/landing/*|tests/e2e-landing/*|playwright.landing.config.ts|packages/shared/*) landing=true; break ;;
esac
done
docs=false
for f in $changed; do
case "$f" in
apps/docs/*|tests/e2e-docs/*|playwright.docs.config.ts) docs=true; break ;;
esac
done
else
echo "Base commit unavailable; running the full pipeline"
fi
@@ -83,6 +92,7 @@ jobs:
fi
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "landing=$landing" >> "$GITHUB_OUTPUT"
echo "docs=$docs" >> "$GITHUB_OUTPUT"
lint:
name: Lint
@@ -95,6 +105,8 @@ jobs:
- run: pnpm lint
- name: License boundary check (D15)
run: pnpm check:license-boundary
- name: Production Node dependency license and notices check
run: pnpm check:production-node-licenses
typecheck:
name: Typecheck
@@ -371,6 +383,44 @@ jobs:
path: playwright-report/
retention-days: 7
test-e2e-docs:
name: E2E Docs (Chromium)
runs-on: ubuntu-latest
# vitepress build renders ~3,800 pages and then runs Pagefind over them,
# which the config allows up to 10 minutes for on a loaded machine.
timeout-minutes: 30
needs: changes
if: needs.changes.outputs.docs == 'true'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Run docs e2e suite
# Static VitePress site with no database, so no Postgres/Redis services.
# The suite's webServer builds the site and serves the preview itself.
# This job is path-filtered like the landing one, so a docs-only PR gets
# a real check instead of the nothing it used to get, and a code-only PR
# still reports a conclusion via the skip.
run: pnpm test:e2e:docs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload report on failure
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-docs-report
path: playwright-report/
retention-days: 7
pip-audit:
name: Python Dependency Audit
runs-on: ubuntu-latest
@@ -385,31 +435,31 @@ jobs:
- run: pip install "pip-audit==2.10.0"
- name: Run pip-audit (ignoring CVEs blocked by dependency constraints)
# CVE-2025-3000: torch 2.12.0, no fixed release available as of 2026-06-11
# Three entries, each one live against the current lock. 19 others were
# dropped after a re-audit showed the pins had moved past them: five
# Pillow (all fixed at or below 12.2.0, lock pins 12.3.0), twelve torch
# (all last_affected at or below 2.10.0, resolves to 2.13.0), joblib
# (not in the closure), markdown (last_affected 3.8, resolves 3.10.2).
# PYSEC-2025-194 was a duplicate of CVE-2025-3000, which is itself now
# dead. Re-check the three below whenever requirements.txt moves.
#
# CVE-2024-27763: basicsr, no fixed release exists in any channel.
# CVE-2026-40086 + GHSA-55v6-g8pm-pw4c: rembg 2.0.69. The fix is
# rembg 2.0.75, held back by the numpy 1.26.4 pin (see the rationale
# in packages/ai/python/requirements.txt).
run: >-
pip-audit -r packages/ai/python/requirements.txt
--ignore-vuln CVE-2024-27763
--ignore-vuln CVE-2025-3000
--ignore-vuln CVE-2026-40086
--ignore-vuln CVE-2026-25990
--ignore-vuln CVE-2026-40192
--ignore-vuln GHSA-55v6-g8pm-pw4c
--ignore-vuln CVE-2026-42308
--ignore-vuln CVE-2026-42310
--ignore-vuln CVE-2026-42311
--ignore-vuln PYSEC-2025-189
--ignore-vuln PYSEC-2025-190
--ignore-vuln PYSEC-2025-191
--ignore-vuln PYSEC-2025-192
--ignore-vuln PYSEC-2025-193
--ignore-vuln PYSEC-2025-194
--ignore-vuln PYSEC-2025-195
--ignore-vuln PYSEC-2025-196
--ignore-vuln PYSEC-2025-197
--ignore-vuln PYSEC-2025-210
--ignore-vuln PYSEC-2026-139
--ignore-vuln PYSEC-2024-277
--ignore-vuln PYSEC-2026-89
- name: Audit exact OCR runtime dependency locks
run: |
for requirements in \
docker/ocr-runtime-requirements-amd64.txt \
docker/ocr-runtime-requirements-arm64.txt; do
pip-audit -r "${requirements}" --no-deps --disable-pip --aliases
done
build:
name: Build
+2 -2
View File
@@ -35,11 +35,11 @@ jobs:
- lane: api
run: packages/image-engine/node_modules/.bin/stryker run stryker.api.config.json
incremental: reports/stryker-api-incremental.json
report: reports/mutation/
report: reports/mutation-api/
- lane: shared
run: packages/image-engine/node_modules/.bin/stryker run stryker.shared.config.json
incremental: reports/stryker-shared-incremental.json
report: reports/mutation/
report: reports/mutation-shared/
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: ./.github/actions/setup
+105 -6
View File
@@ -52,7 +52,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
@@ -106,7 +109,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
@@ -120,6 +126,11 @@ jobs:
run: pnpm playwright install --with-deps chromium
- name: Run serial bucket (global-state specs)
run: pnpm playwright test --project=chromium-serial --workers=1
# chromium-widths owns the exact CSS boundary and wide-screen assertions.
# It is five plain functional specs with no screenshot baselines, so it
# runs here rather than needing the platform-suffixed visual machinery.
- name: Run viewport-width bucket
run: pnpm playwright test --project=chromium-widths --workers=1
- name: Upload report on failure
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
@@ -128,6 +139,81 @@ jobs:
path: playwright-report/
retention-days: 7
# The editor, the analytics opt-out flow and the AUTH_ENABLED=false mode each
# have their own Playwright config and, until now, no workflow at all: 174 + 9
# + 5 tests that collected and passed locally but gated nothing. All three need
# Postgres and Redis and build the web app themselves, so they share a runner.
e2e-surfaces:
name: E2E Editor + Analytics Opt-Out + No-Auth
runs-on: ubuntu-latest
# The editor suite is 174 tests pinned to workers: 1 by its own config and
# took 23.4 minutes on a loaded dev machine, so leave real headroom for it
# plus the apt install, the browser download and the two smaller suites.
timeout-minutes: 60
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: snapotter
POSTGRES_PASSWORD: snapotter
POSTGRES_DB: snapotter
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U snapotter"
--health-interval 5s
--health-timeout 3s
--health-retries 10
redis:
image: redis:8-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm playwright install --with-deps chromium
- name: Run editor suite
run: pnpm playwright test --config playwright.editor.config.ts
env:
PW_WORKERS: "2"
- name: Run analytics opt-out suite
# The in-repo docker/feature-manifest.json makes the API believe it is
# inside a container and mkdir /data; this config does not pin DATA_DIR
# itself, so pin it here the way playwright.config.ts does.
run: pnpm test:e2e:analytics
env:
DATA_DIR: ${{ runner.temp }}/analytics-local-data
- name: Run no-auth mode suite
# AUTH_ENABLED=false injects a synthetic anonymous admin. Nothing else in
# CI exercises that path, so a regression only surfaced on a user's box.
run: pnpm test:e2e:noauth
- name: Upload report on failure
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: e2e-surfaces-report
path: playwright-report/
retention-days: 7
e2e-cross-browser:
name: E2E Cross-Browser (Firefox + WebKit)
runs-on: ubuntu-latest
@@ -210,7 +296,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- uses: ./.github/actions/setup
- name: Get Playwright version
id: pw-version
@@ -266,7 +355,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- name: Allow ImageMagick to read EPS/PS via Ghostscript delegate
run: |
POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1)
@@ -292,6 +384,7 @@ jobs:
FULL_MATRIX: "1"
FUZZ: "1"
FUZZ_RUNS: "50"
REQUIRE_AI_FEATURES: "1"
# Full-matrix tests iterate every format x tool; with 4 forks the heavy
# media conversions starve and hit the 30s default. Fewer forks (more
# CPU each) plus a generous timeout keeps them from flaking.
@@ -329,7 +422,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- uses: ./.github/actions/setup
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
@@ -392,7 +488,10 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install system dependencies
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends $SYSTEM_DEPS
run: |
read -r -a system_deps <<< "$SYSTEM_DEPS"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends "${system_deps[@]}"
- name: Allow ImageMagick to read EPS/PS via Ghostscript delegate
run: |
POLICY_FILE=$(find /etc/ImageMagick* -name policy.xml 2>/dev/null | head -1)
+709 -88
View File
@@ -53,16 +53,8 @@ jobs:
- 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: Verify production Node dependency licenses and notices
run: pnpm check:production-node-licenses
- name: Run semantic-release
env:
@@ -103,57 +95,74 @@ jobs:
echo "::error::Release tag did not peel to an immutable commit"
exit 1
}
git checkout --detach "${release_commit}"
[[ "$(git rev-parse HEAD)" == "${release_commit}" ]] || {
echo "::error::Could not check out the immutable release commit"
exit 1
}
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_commit=${release_commit}" >> "$GITHUB_OUTPUT"
- name: Update GitHub release notes
if: steps.notes.outputs.has_notes == 'true' && steps.check.outputs.version
- name: Materialize durable release notes
id: notes
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
result="$(
node scripts/manage-release-notes.mjs materialize \
"${VERSION}" /tmp/release-notes.md
)"
[[ "${result}" =~ ^custom=(true|false)$ ]] || {
echo "::error::Release-note materializer returned an invalid result"
exit 1
}
[[ -s /tmp/release-notes.md ]] || {
echo "::error::Committed release notes are empty"
exit 1
}
echo "has_custom_notes=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT"
- name: Ensure exact GitHub draft
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
run: |
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}"
if ! gh api "${release_endpoint}" > /tmp/release.json 2> /tmp/release.error; then
if ! grep -Fq "(HTTP 404)" /tmp/release.error; then
cat /tmp/release.error >&2
echo "::error::Could not determine whether the GitHub draft exists"
exit 1
fi
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft \
--verify-tag \
--title "v${VERSION}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
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}."
jq -e --arg tag "v${VERSION}" \
'.draft == true and .tag_name == $tag' /tmp/release.json >/dev/null || {
echo "::error::Expected release is missing, public, or bound to the wrong tag"
exit 1
}
# Normalize both first-run and recovered drafts to the committed body.
gh release edit "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
node --input-type=module - /tmp/release.json /tmp/release-notes.md <<'NODE'
import { readFileSync } from "node:fs";
const [releasePath, notesPath] = process.argv.slice(2);
const release = JSON.parse(readFileSync(releasePath, "utf8"));
const expected = readFileSync(notesPath, "utf8");
if (release.draft !== true || release.body !== expected) {
throw new Error("GitHub draft body differs from committed release notes");
}
NODE
prebuilt:
name: Archive (${{ matrix.arch }})
@@ -250,8 +259,181 @@ jobs:
- name: Generate checksum
run: cd /tmp && sha256sum "${archive_name}" > "${archive_name}.sha256"
- name: Upload to GitHub Release
- name: Upload unverified archive for security verification
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: prebuilt-${{ matrix.arch }}
if-no-files-found: error
overwrite: true
retention-days: 7
path: |
/tmp/${{ env.archive_name }}
/tmp/${{ env.archive_name }}.sha256
archive-security:
name: Verify Archive (${{ matrix.arch }})
needs: [release, prebuilt]
if: needs.release.outputs.new_version
permissions:
attestations: write
contents: write
id-token: write
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Download unverified archive
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: prebuilt-${{ matrix.arch }}
path: /tmp/prebuilt
- name: Verify checksum and safely extract archive
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
archive_name="snapotter-v${VERSION}-linux-${ARCH}.tar.gz"
[[ -f "/tmp/prebuilt/${archive_name}" \
&& ! -L "/tmp/prebuilt/${archive_name}" \
&& -f "/tmp/prebuilt/${archive_name}.sha256" \
&& ! -L "/tmp/prebuilt/${archive_name}.sha256" ]] || {
echo "::error::Archive artifact closure is incomplete"
exit 1
}
[[ "$(find /tmp/prebuilt -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 2 ]] || {
echo "::error::Archive artifact contains unexpected files"
exit 1
}
(cd /tmp/prebuilt && sha256sum --check --strict "${archive_name}.sha256")
rm -rf /tmp/prebuilt-root
mkdir -p /tmp/prebuilt-root
python3 - "/tmp/prebuilt/${archive_name}" <<'PY'
import pathlib
import sys
import tarfile
archive = pathlib.Path(sys.argv[1])
root = pathlib.Path("/tmp/prebuilt-root")
with tarfile.open(archive, "r:gz") as handle:
members = handle.getmembers()
if not members:
raise SystemExit("release archive is empty")
for member in members:
parts = pathlib.PurePosixPath(member.name).parts
if not parts or parts[0] != "snapotter" or ".." in parts:
raise SystemExit(f"unsafe release archive member: {member.name}")
handle.extractall(root, filter="data")
PY
test -s /tmp/prebuilt-root/snapotter/apps/web/dist/index.html
test -s /tmp/prebuilt-root/snapotter/apps/api/src/index.ts
test -x /tmp/prebuilt-root/snapotter/node_modules/.bin/tsx
/tmp/prebuilt-root/snapotter/node_modules/.bin/tsx --version
echo "archive_name=${archive_name}" >> "$GITHUB_ENV"
- name: Install pinned Syft 1.42.3 from verified release bytes
env:
SYFT_VERSION: "1.42.3"
run: |
case "$(uname -m)" in
x86_64)
syft_arch="amd64"
expected_sha256="0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509"
;;
aarch64 | arm64)
syft_arch="arm64"
expected_sha256="dc630590c953347789d08f8ebf57c7d8094db89100785fcd94b1cddeac791804"
;;
*)
echo "::error::Unsupported Syft installer architecture: $(uname -m)"
exit 1
;;
esac
archive="syft_${SYFT_VERSION}_linux_${syft_arch}.tar.gz"
install_root="${RUNNER_TEMP}/syft-${SYFT_VERSION}"
rm -rf "${install_root}"
mkdir -p "${install_root}"
curl --fail --location --silent --show-error \
--proto '=https' --tlsv1.2 --retry 3 \
--output "${install_root}/${archive}" \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
printf '%s %s\n' "${expected_sha256}" "${install_root}/${archive}" \
| sha256sum --check --strict -
tar -xzf "${install_root}/${archive}" -C "${install_root}" syft
chmod 0755 "${install_root}/syft"
"${install_root}/syft" version -o json \
| jq -e --arg version "${SYFT_VERSION}" '.version == $version' >/dev/null
echo "${install_root}" >> "$GITHUB_PATH"
- name: Generate archive SBOMs
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
syft scan dir:/tmp/prebuilt-root/snapotter \
-o "cyclonedx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json"
syft scan dir:/tmp/prebuilt-root/snapotter \
-o "spdx-json=snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json"
# Blocks on CRITICAL/HIGH that have a fix available. The unfixed gate
# below covers what this one cannot see.
- name: Scan archive filesystem
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: /tmp/prebuilt-root/snapotter
format: table
exit-code: "1"
ignore-unfixed: true
severity: CRITICAL,HIGH
trivyignores: .trivyignore
- name: Record archive scan
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: /tmp/prebuilt-root/snapotter
format: json
output: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
- name: Gate unfixed CRITICAL findings
if: always()
env:
REPORT: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
LABEL: archive linux/${{ matrix.arch }}
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Publish verified immutable archive assets
env:
ARCH: ${{ matrix.arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
@@ -263,7 +445,6 @@ jobs:
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
@@ -304,10 +485,10 @@ jobs:
asset_name="$(basename "${asset_path}")"
refresh_assets
mapfile -t asset_ids < <(matching_asset_ids "${asset_name}")
if [[ ${#asset_ids[@]} -gt 1 ]]; then
[[ ${#asset_ids[@]} -le 1 ]] || {
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}"
@@ -316,10 +497,17 @@ jobs:
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
assets=(
"/tmp/prebuilt/${archive_name}"
"/tmp/prebuilt/${archive_name}.sha256"
"snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.cdx.json"
"snapotter-v${VERSION}-archive-linux-${ARCH}-sbom.spdx.json"
"snapotter-v${VERSION}-archive-linux-${ARCH}-trivy.json"
)
for asset_path in "${assets[@]}"; do
verify_or_upload_asset "${asset_path}"
done
for asset_path in "${assets[@]}"; do
asset_name="$(basename "${asset_path}")"
refresh_assets
mapfile -t asset_ids < <(matching_asset_ids "${asset_name}")
@@ -330,6 +518,14 @@ jobs:
compare_asset "${asset_ids[0]}" "${asset_path}"
done
# This provenance records the workflow identity that performed the
# verification. The release-subjects job below separately attests a
# canonical manifest that binds the semantic-release-created commit.
- name: Attest verified archive workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "/tmp/prebuilt/${{ env.archive_name }}"
docker:
name: Build (${{ matrix.platform }})
needs: release
@@ -647,8 +843,8 @@ jobs:
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 }}
OCR_RUNTIME_TRUST_ID=${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
OCR_RUNTIME_TRUST_PEM_B64=${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
SNAPOTTER_OFFICIAL_CONTAINER=1
secrets: |
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -793,13 +989,16 @@ jobs:
}
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: Checkout for trivyignore
- name: Checkout scan policy
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
sparse-checkout: .trivyignore
sparse-checkout: |
.trivyignore
.trivy-unfixed-allow
scripts/trivy-unfixed-gate.mjs
sparse-checkout-cone-mode: false
- name: Verify immutable release tag binding
@@ -814,6 +1013,9 @@ jobs:
exit 1
}
# Blocks on CRITICAL/HIGH that have a fix available: a patch exists and we
# did not take it. Findings with no fix are invisible here by design; the
# unfixed gate below is what covers them.
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
@@ -841,14 +1043,24 @@ jobs:
sarif_file: "trivy-results.sarif"
category: trivy-${{ matrix.platform }}
# No ignore-unfixed here: this report is the published record of what the
# image actually contains, and it feeds the unfixed gate below.
- name: Run Trivy (JSON report)
if: always()
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "ghcr.io/snapotter-hq/snapotter@sha256:${{ steps.digest.outputs.sha }}"
format: "json"
output: "snapotter-v${{ needs.release.outputs.new_version }}-${{ matrix.platform }}-trivy.json"
ignore-unfixed: true
output: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
- name: Gate unfixed CRITICAL findings
if: always()
env:
REPORT: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
LABEL: "image ${{ matrix.platform }}"
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Upload Trivy report to GitHub Release
if: always()
@@ -856,9 +1068,29 @@ jobs:
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
repository="snapotter-hq/SnapOtter"
report="snapotter-v${VERSION}-image-${{ matrix.platform }}-trivy.json"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
--jq ".[] | select(.name == \"${report}\") | .id"
)
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::Immutable Trivy report name collides: ${report}"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
gh api -H "Accept: application/octet-stream" \
"repos/${repository}/releases/assets/${asset_ids[0]}" \
> /tmp/existing-trivy-report.json
cmp --silent "${report}" /tmp/existing-trivy-report.json || {
echo "::error::Existing immutable Trivy report differs: ${report}"
exit 1
}
else
gh release upload "v${VERSION}" "${report}" --repo "${repository}"
fi
sbom:
name: Generate SBOM (${{ matrix.platform }})
@@ -942,18 +1174,40 @@ jobs:
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"
syft scan "$IMAGE" -o "cyclonedx-json=snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json"
syft scan "$IMAGE" -o "spdx-json=snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.spdx.json"
- name: Upload SBOMs to GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
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
repository="snapotter-hq/SnapOtter"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
for sbom in \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json" \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.spdx.json"; do
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
--jq ".[] | select(.name == \"${sbom}\") | .id"
)
[[ ${#asset_ids[@]} -le 1 ]] || {
echo "::error::Immutable SBOM name collides: ${sbom}"
exit 1
}
if [[ ${#asset_ids[@]} -eq 1 ]]; then
gh api -H "Accept: application/octet-stream" \
"repos/${repository}/releases/assets/${asset_ids[0]}" \
> /tmp/existing-sbom.json
cmp --silent "${sbom}" /tmp/existing-sbom.json || {
echo "::error::Existing immutable SBOM differs: ${sbom}"
exit 1
}
else
gh release upload "v${VERSION}" "${sbom}" --repo "${repository}"
fi
done
ai-bundles:
name: AI Bundles
@@ -964,10 +1218,11 @@ jobs:
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
# `actions: read` / `contents: read` / `packages: read`, and GitHub rejects a called workflow
# requesting scopes the caller never granted -- failing at startup before any
# job runs. Grant them here so the call passes startup validation.
permissions:
actions: read
contents: read
packages: read
uses: ./.github/workflows/ai-bundles.yml
@@ -981,7 +1236,7 @@ jobs:
manifest:
name: Create Multi-Arch Manifests
needs: [release, docker, scan, sbom, ai-bundles]
needs: [release, prebuilt, archive-security, docker, scan, sbom, ai-bundles]
runs-on: ubuntu-latest
# Manual publish gate: this job creates only the immutable version tags. A
# downstream, globally serialized job advances moving aliases after checking
@@ -992,6 +1247,7 @@ jobs:
contents: read
packages: write
outputs:
manifest_digest: ${{ steps.manifest_digest.outputs.digest }}
platform_digests: ${{ steps.verified_digests.outputs.platform_digests }}
steps:
- name: Check out the approved immutable release commit
@@ -1198,7 +1454,7 @@ jobs:
}
)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \;)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \; | sort)
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::Docker Hub manifest input closure is incomplete"
exit 1
@@ -1231,7 +1487,7 @@ jobs:
}
)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \;)
mapfile -t digests < <(find . -maxdepth 1 -type f -exec basename {} \; | sort)
[[ ${#digests[@]} -eq 2 ]] || {
echo "::error::GHCR manifest input closure is incomplete"
exit 1
@@ -1246,18 +1502,344 @@ jobs:
done
docker buildx imagetools create "${arguments[@]}"
aliases:
name: Advance Non-Regressing Image Aliases
- name: Verify immutable manifest parity
id: manifest_digest
env:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
docker buildx imagetools inspect \
"snapotter/snapotter:${VERSION}" --raw > /tmp/dockerhub-manifest.json
docker buildx imagetools inspect \
"ghcr.io/snapotter-hq/snapotter:${VERSION}" --raw > /tmp/ghcr-manifest.json
dockerhub_digest="sha256:$(sha256sum /tmp/dockerhub-manifest.json | cut -d ' ' -f 1)"
ghcr_digest="sha256:$(sha256sum /tmp/ghcr-manifest.json | cut -d ' ' -f 1)"
[[ "${dockerhub_digest}" == "${ghcr_digest}" ]] || {
echo "::error::Immutable registry manifests do not have identical bytes"
exit 1
}
[[ "${ghcr_digest}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Published manifest digest is invalid"
exit 1
}
echo "digest=${ghcr_digest}" >> "$GITHUB_OUTPUT"
image-provenance:
name: Attest Immutable Image Manifest
needs: [release, manifest]
runs-on: ubuntu-latest
# 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.
permissions:
attestations: write
contents: read
id-token: write
packages: read
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Log in to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Verify version tags resolve to the release-produced manifest
env:
MANIFEST_DIGEST: ${{ needs.manifest.outputs.manifest_digest }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
[[ "${MANIFEST_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Release manifest output is invalid"
exit 1
}
for reference in \
"docker.io/snapotter/snapotter:${VERSION}" \
"ghcr.io/snapotter-hq/snapotter:${VERSION}"; do
raw="/tmp/$(echo "${reference}" | tr '/:' '_').json"
docker buildx imagetools inspect "${reference}" --raw > "${raw}"
resolved_digest="sha256:$(sha256sum "${raw}" | cut -d ' ' -f 1)"
[[ "${resolved_digest}" == "${MANIFEST_DIGEST}" ]] || {
echo "::error::${reference} does not resolve to the release manifest"
exit 1
}
done
- name: Attest GHCR manifest workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ghcr.io/snapotter-hq/snapotter
subject-digest: ${{ needs.manifest.outputs.manifest_digest }}
push-to-registry: false
- name: Attest Docker Hub manifest workflow provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: docker.io/snapotter/snapotter
subject-digest: ${{ needs.manifest.outputs.manifest_digest }}
push-to-registry: false
release-subjects:
name: Bind Release Commit to Published Subjects
needs: [release, archive-security, manifest, image-provenance]
if: needs.release.outputs.new_version
runs-on: ubuntu-latest
permissions:
attestations: write
contents: write
id-token: write
steps:
- name: Check out the immutable release commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false
- name: Verify immutable release tag binding
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Release tag no longer resolves to the selected commit"
exit 1
}
- name: Download verified archive inputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: prebuilt-*
merge-multiple: true
path: /tmp/prebuilt-subjects
# GITHUB_SHA remains the commit that triggered this workflow even after
# checkout. Record it separately instead of misrepresenting it as the
# semantic-release-created commit. The attested file explicitly binds
# that release commit and tag to every immutable release asset and image.
- name: Build canonical release-subject manifest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MANIFEST_DIGEST: ${{ needs.manifest.outputs.manifest_digest }}
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
WORKFLOW_TRIGGER_COMMIT: ${{ github.sha }}
run: |
[[ "${GITHUB_REPOSITORY}" == "snapotter-hq/SnapOtter" ]] || {
echo "::error::Release subjects can only be created by the canonical repository"
exit 1
}
[[ "${RELEASE_COMMIT}" =~ ^[a-f0-9]{40}$ \
&& "${WORKFLOW_TRIGGER_COMMIT}" =~ ^[a-f0-9]{40}$ \
&& "${MANIFEST_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]] || {
echo "::error::Release subject identity is malformed"
exit 1
}
release_subjects_name="snapotter-v${VERSION}-release-subjects.json"
echo "release_subjects_name=${release_subjects_name}" >> "$GITHUB_ENV"
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" --jq .id
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
gh api --paginate --slurp \
"repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \
> /tmp/release-asset-pages.json
RELEASE_ID="${release_id}" python3 <<'PY'
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
repository = os.environ["GITHUB_REPOSITORY"]
release_id = os.environ["RELEASE_ID"]
release_commit = os.environ["RELEASE_COMMIT"]
release_tag = f"v{os.environ['VERSION']}"
version = os.environ["VERSION"]
workflow_trigger_commit = os.environ["WORKFLOW_TRIGGER_COMMIT"]
manifest_digest = os.environ["MANIFEST_DIGEST"]
release_subjects_name = f"snapotter-v{version}-release-subjects.json"
output = Path("/tmp") / release_subjects_name
published_root = Path("/tmp/published-release-subjects")
prebuilt_root = Path("/tmp/prebuilt-subjects")
published_root.mkdir(mode=0o700, exist_ok=False)
if not re.fullmatch(r"[0-9]+", release_id):
raise SystemExit("invalid release ID")
pages = json.loads(Path("/tmp/release-asset-pages.json").read_text())
if not isinstance(pages, list) or not all(isinstance(page, list) for page in pages):
raise SystemExit("GitHub release asset response is not paginated JSON")
assets = [asset for page in pages for asset in page]
if not assets:
raise SystemExit("GitHub release contains no immutable assets")
names = [asset.get("name") for asset in assets]
if any(not isinstance(name, str) or PurePosixPath(name).name != name for name in names):
raise SystemExit("GitHub release contains an unsafe asset name")
if len(names) != len(set(names)):
raise SystemExit("GitHub release contains duplicate asset names")
expected_assets = set()
for arch in ("amd64", "arm64"):
archive = f"snapotter-v{version}-linux-{arch}.tar.gz"
expected_assets.update(
{
archive,
f"{archive}.sha256",
f"snapotter-v{version}-archive-linux-{arch}-sbom.cdx.json",
f"snapotter-v{version}-archive-linux-{arch}-sbom.spdx.json",
f"snapotter-v{version}-archive-linux-{arch}-trivy.json",
f"snapotter-v{version}-linux-{arch}.digest",
f"snapotter-v{version}-image-linux-{arch}-sbom.cdx.json",
f"snapotter-v{version}-image-linux-{arch}-sbom.spdx.json",
f"snapotter-v{version}-image-linux-{arch}-trivy.json",
}
)
missing = sorted(expected_assets - set(names))
if missing:
raise SystemExit(f"release subject closure is incomplete: {missing}")
subjects = []
existing_manifest = None
for asset in sorted(assets, key=lambda item: item["name"]):
name = asset["name"]
asset_id = asset.get("id")
if not isinstance(asset_id, int) or asset_id <= 0:
raise SystemExit(f"release asset has an invalid ID: {name}")
destination = published_root / name
with destination.open("xb") as handle:
subprocess.run(
[
"gh",
"api",
"-H",
"Accept: application/octet-stream",
f"repos/{repository}/releases/assets/{asset_id}",
],
check=True,
stdout=handle,
)
if name == release_subjects_name:
existing_manifest = destination
continue
if name.endswith(".tar.gz") or name.endswith(".tar.gz.sha256"):
candidate = prebuilt_root / name
if not candidate.is_file() or candidate.read_bytes() != destination.read_bytes():
raise SystemExit(f"published archive input differs: {name}")
subjects.append(
{
"digest": {"sha256": hashlib.sha256(destination.read_bytes()).hexdigest()},
"name": f"github-release://{repository}/{release_tag}/{name}",
}
)
image_digest = manifest_digest.removeprefix("sha256:")
for image in (
"docker.io/snapotter/snapotter",
"ghcr.io/snapotter-hq/snapotter",
):
subjects.append({"digest": {"sha256": image_digest}, "name": image})
subjects.sort(key=lambda subject: subject["name"])
statement = {
"_type": "https://snapotter.dev/attestations/release-subjects/v1",
"releaseCommit": release_commit,
"releaseTag": release_tag,
"repository": repository,
"subjects": subjects,
"workflowTriggerCommit": workflow_trigger_commit,
}
payload = json.dumps(statement, ensure_ascii=True, separators=(",", ":"), sort_keys=True)
output.write_text(f"{payload}\n", encoding="utf-8")
if existing_manifest is not None and existing_manifest.read_bytes() != output.read_bytes():
raise SystemExit("Existing release-subject manifest differs")
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as environment:
environment.write(f"release_subjects_exists={str(existing_manifest is not None).lower()}\n")
PY
- name: Revalidate release tag immediately before attesting subjects
env:
RELEASE_COMMIT: ${{ needs.release.outputs.release_commit }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
git fetch --force --no-tags origin \
"+refs/tags/v${VERSION}:refs/tags/v${VERSION}"
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
echo "::error::Remote release tag moved before subject attestation"
exit 1
}
- name: Attest release-commit subject binding
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "/tmp/${{ env.release_subjects_name }}"
- name: Publish immutable release-subject manifest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
if [[ "${release_subjects_exists}" != "true" ]]; then
gh release upload "v${VERSION}" \
"/tmp/${release_subjects_name}" \
--repo snapotter-hq/SnapOtter
fi
rm -rf /tmp/release-subject-verification
mkdir -p /tmp/release-subject-verification
gh release download "v${VERSION}" \
--pattern "${release_subjects_name}" \
--dir /tmp/release-subject-verification \
--repo snapotter-hq/SnapOtter
cmp --silent \
"/tmp/${release_subjects_name}" \
"/tmp/release-subject-verification/${release_subjects_name}" || {
echo "::error::Published release-subject manifest differs"
exit 1
}
aliases:
name: Advance Non-Regressing Image Aliases
needs: [release, manifest, image-provenance, release-subjects]
runs-on: ubuntu-latest
# GitHub does not guarantee FIFO ordering and retains at most one pending
# run for a concurrency group. Every holder therefore fetches the complete
# remote tag set again while holding this lock and only publishes aliases
# for which its version is still the highest stable candidate.
concurrency:
group: snapotter-image-moving-aliases
cancel-in-progress: false
queue: max
permissions:
contents: read
packages: write
@@ -1410,3 +1992,42 @@ jobs:
arguments+=("ghcr.io/snapotter-hq/snapotter@sha256:${digest}")
done
docker buildx imagetools create "${arguments[@]}"
publish-release:
name: Publish Fully Verified GitHub Release
needs: [release, aliases]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Verify approved release is still a draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
draft="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
)"
[[ "${draft}" == "true" ]] || {
echo "::error::Approved release is not a draft before final publication"
exit 1
}
- name: Publish approved release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.release.outputs.new_version }}
run: |
gh release edit "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft=false
[[ "$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
)" == "false" ]] || {
echo "::error::Approved release remained a draft after publication"
exit 1
}