mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -0,0 +1,3 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- snapotter-nvidia
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user