mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Two release-pipeline defects found while pre-flighting 2.2.0, plus the image hardening that the second one exposed. The release job would have died immediately after pushing the v2.2.0 tag. draftRelease was turned on in #649 and never executed, and GitHub's /releases/tags/{tag} endpoint does not return draft releases, so all nine tag lookups in release.yml would have 404'd against the draft semantic-release had just created. Verified against this repo with a throwaway draft: the tag endpoint 404s while gh release view reads it and /releases/{id} returns the same REST shape. Every site now resolves the numeric id first, so existing jq expressions are untouched. The unfixed-vulnerability gate was measuring almost nothing. The blocking Trivy steps run ignore-unfixed, and trivy-unfixed-gate.mjs was meant to cover the remainder but defaults to CRITICAL with neither call site passing --severity. An unfixed HIGH was gated by nothing, and the arm64 image carried 79 of them while the summary read clean. Rather than document 79 findings, the image lost what it did not need: libde265 1.1.1 and libheif 1.23.1 are now built from source (the old libheif pin was itself affected by CVE-2026-3950, and Debian's libde265 1.0.11 was the decoder every .heic upload actually reached), and xvfb, wget and openssh-client are purged. 15 CVEs left the image outright and the HIGH gap fell to 65, each now carrying a rationale verified against the running container. curl gets its own section: bookworm-backports has a fixed 8.14.1, so claiming no fix was available would have been false. It is recorded as a declined fix. Verified on both architectures: gate exits 0, the source-built libde265 is the one libheif links, and HEIC, RAW, ImageMagick, Sharp AVIF and headless chromium all still work after the purge.
2485 lines
110 KiB
YAML
2485 lines
110 KiB
YAML
name: AI Bundles
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
version:
|
|
type: string
|
|
required: true
|
|
release_commit:
|
|
type: string
|
|
required: true
|
|
secrets:
|
|
GHCR_TOKEN:
|
|
required: true
|
|
HF_TOKEN:
|
|
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 }}
|
|
cancel-in-progress: false
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
validate-inputs:
|
|
name: Validate release inputs
|
|
timeout-minutes: 10
|
|
if: inputs.release_commit != ''
|
|
runs-on: ubuntu-latest
|
|
permissions: {}
|
|
steps:
|
|
- name: Reject unsafe or non-semantic versions
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
[[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]] || {
|
|
echo "::error::Bundle version must be a path-safe semantic version"
|
|
exit 1
|
|
}
|
|
[[ ${#VERSION} -le 64 ]] || {
|
|
echo "::error::Bundle version is too long"
|
|
exit 1
|
|
}
|
|
[[ "${RELEASE_COMMIT}" =~ ^[a-f0-9]{40}$ ]] || {
|
|
echo "::error::Release commit must be a full immutable Git commit"
|
|
exit 1
|
|
}
|
|
|
|
- name: Validate OCR runtime trust configuration
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
|
|
run: |
|
|
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
|
|
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
|
|
[[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || {
|
|
echo "::error::OCR runtime signing key ID is not a safe identifier"
|
|
exit 1
|
|
}
|
|
umask 077
|
|
trap 'rm -f /tmp/ocr-runtime-public.pem' EXIT
|
|
printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" \
|
|
| base64 --decode > /tmp/ocr-runtime-public.pem
|
|
[[ "$(base64 --wrap=0 < /tmp/ocr-runtime-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
|
|
echo "::error::OCR runtime public key must use canonical base64"
|
|
exit 1
|
|
}
|
|
openssl pkey -pubin -in /tmp/ocr-runtime-public.pem -text -noout \
|
|
| grep -q ED25519 || {
|
|
echo "::error::Configured OCR runtime public key is not Ed25519"
|
|
exit 1
|
|
}
|
|
|
|
preflight-gpu-runner:
|
|
# The NVIDIA verification below is the only job that needs physical hardware,
|
|
# and it cannot start until build-ocr has produced an image digest. That used
|
|
# to mean an offline GPU box burned a full multi-arch build and then parked in
|
|
# "Queued" for 24h at sign-ocr-index, with nothing in the run explaining why.
|
|
#
|
|
# This job claims the same labels with no dependencies, so it is the first
|
|
# thing scheduled. If the box is offline the run visibly sits here at second
|
|
# zero instead of failing much later and much less legibly. It also proves the
|
|
# GPU is usable BEFORE the expensive build, rather than after it.
|
|
#
|
|
# Note: this cannot be an API check. Listing self-hosted runners needs
|
|
# Administration:read, which is not an available GITHUB_TOKEN permission, so
|
|
# an API preflight would 403 on every run and pass vacuously. Claiming the
|
|
# label is the only signal available to the workflow itself. RELEASE.md
|
|
# carries the maintainer-side `gh api` check to run before dispatching.
|
|
name: Preflight GPU runner
|
|
needs: validate-inputs
|
|
timeout-minutes: 10
|
|
runs-on: [self-hosted, linux, x64, snapotter-nvidia]
|
|
permissions: {}
|
|
steps:
|
|
- name: Require a working NVIDIA container runtime
|
|
run: |
|
|
command -v nvidia-smi >/dev/null || {
|
|
echo "::error::nvidia-smi is missing on the snapotter-nvidia runner"
|
|
exit 1
|
|
}
|
|
nvidia-smi --query-gpu=name,driver_version --format=csv,noheader
|
|
docker info --format '{{json .Runtimes}}' | grep -q 'nvidia' || {
|
|
echo "::error::Docker on the snapotter-nvidia runner has no nvidia runtime"
|
|
exit 1
|
|
}
|
|
|
|
- name: Require enough free disk for the release image and bundles
|
|
run: |
|
|
free_gb="$(df -BG --output=avail / | tail -1 | tr -dc '0-9')"
|
|
echo "free on /: ${free_gb}G"
|
|
[[ "${free_gb}" -ge 25 ]] || {
|
|
echo "::error::Need at least 25G free on the runner, found ${free_gb}G"
|
|
exit 1
|
|
}
|
|
|
|
build-ocr:
|
|
name: Build OCR (${{ matrix.target }})
|
|
# Gated on preflight-gpu-runner: nothing here can be signed or published
|
|
# without the NVIDIA verification, so spending 90 minutes building before
|
|
# confirming that box is up only makes the failure slower and less obvious.
|
|
needs: [validate-inputs, preflight-gpu-runner]
|
|
timeout-minutes: 90
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- target: linux-amd64-cpu-py312
|
|
image_platform: linux-amd64
|
|
runner: ubuntu-latest
|
|
- target: linux-arm64-cpu-py311
|
|
image_platform: linux-arm64
|
|
runner: ubuntu-24.04-arm
|
|
runs-on: ${{ matrix.runner }}
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
steps:
|
|
- name: Free disk space
|
|
run: |
|
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
|
/usr/local/share/boost /opt/hostedtoolcache/CodeQL
|
|
sudo docker system prune -af
|
|
df -h /
|
|
|
|
- name: Checkout release tag
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
ref: ${{ inputs.release_commit }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Verify immutable release tag binding
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.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
|
|
}
|
|
|
|
- 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:
|
|
name: digests-${{ matrix.image_platform }}
|
|
path: /tmp/image-digest
|
|
|
|
- name: Resolve exact build image
|
|
env:
|
|
IMAGE_PLATFORM: ${{ matrix.image_platform }}
|
|
run: |
|
|
mapfile -t digest_files < <(find /tmp/image-digest -maxdepth 1 -type f -print)
|
|
[[ ${#digest_files[@]} -eq 1 ]] || {
|
|
echo "::error::Expected exactly one ${IMAGE_PLATFORM} image digest"
|
|
exit 1
|
|
}
|
|
digest="$(basename "${digest_files[0]}")"
|
|
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
|
|
echo "::error::Invalid image digest artifact"
|
|
exit 1
|
|
}
|
|
echo "SNAPOTTER_BUNDLE_IMAGE=ghcr.io/snapotter-hq/snapotter@sha256:${digest}" >> "$GITHUB_ENV"
|
|
echo "SNAPOTTER_BUNDLE_IMAGE_DIGEST=${digest}" >> "$GITHUB_ENV"
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- name: Build immutable OCR runtime in its native app image
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
TARGET: ${{ matrix.target }}
|
|
VERSION: ${{ inputs.version }}
|
|
WORKFLOW_COMMIT: ${{ github.workflow_sha }}
|
|
run: |
|
|
rm -rf /tmp/bundles
|
|
mkdir -p /tmp/bundles/primary /tmp/bundles/rebuild
|
|
[[ "${WORKFLOW_COMMIT}" =~ ^[a-f0-9]{40}$ ]] || {
|
|
echo "::error::Reusable workflow revision is not an immutable commit"
|
|
exit 1
|
|
}
|
|
for build in primary rebuild; do
|
|
docker run --rm --entrypoint bash \
|
|
-v "$PWD/docker/build-bundle.sh:/build-bundle.sh:ro" \
|
|
-v "$PWD/docker/build-ocr-runtime.sh:/build-ocr-runtime.sh:ro" \
|
|
-v "$PWD/docker/ocr-runtime-models.json:/ocr-runtime-models.json:ro" \
|
|
-v "$PWD/docker/ocr-runtime-requirements-amd64.txt:/ocr-runtime-requirements-amd64.txt:ro" \
|
|
-v "$PWD/docker/ocr-runtime-requirements-arm64.txt:/ocr-runtime-requirements-arm64.txt:ro" \
|
|
-v "$PWD/docker/ocr-best-v1-calibration.json:/app/docker/ocr-best-v1-calibration.json:ro" \
|
|
-v "$PWD/docker/feature-manifest.json:/app/docker/feature-manifest.json:ro" \
|
|
-v "/tmp/bundles/${build}:/output" \
|
|
-e SNAPOTTER_VERSION="${VERSION}" \
|
|
-e SNAPOTTER_OCR_SOURCE_IMAGE_DIGEST="${SNAPOTTER_BUNDLE_IMAGE_DIGEST}" \
|
|
-e SNAPOTTER_OCR_SOURCE_COMMIT="${RELEASE_COMMIT}" \
|
|
-e SNAPOTTER_OCR_BUILDER_ID="github-actions:.github/workflows/ai-bundles.yml@${WORKFLOW_COMMIT}" \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/build-bundle.sh ocr "${TARGET}" /output
|
|
done
|
|
for suffix in tar.gz tar.gz.sha256 artifact.json index-input.json; do
|
|
test -s "/tmp/bundles/primary/ocr-${TARGET}.${suffix}"
|
|
test -s "/tmp/bundles/rebuild/ocr-${TARGET}.${suffix}"
|
|
cmp --silent "/tmp/bundles/primary/ocr-${TARGET}.${suffix}" \
|
|
"/tmp/bundles/rebuild/ocr-${TARGET}.${suffix}" || {
|
|
echo "::error::OCR runtime rebuild is not byte-reproducible: ${suffix}"
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
- name: Upload OCR build artifact for verification
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ocr-${{ matrix.target }}
|
|
overwrite: true
|
|
path: |
|
|
/tmp/bundles/primary/ocr-${{ matrix.target }}.tar.gz
|
|
/tmp/bundles/primary/ocr-${{ matrix.target }}.tar.gz.sha256
|
|
/tmp/bundles/primary/ocr-${{ matrix.target }}.artifact.json
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
verify-ocr:
|
|
name: Verify OCR (${{ matrix.target }})
|
|
needs: build-ocr
|
|
timeout-minutes: 90
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- target: linux-amd64-cpu-py312
|
|
image_platform: linux-amd64
|
|
runner: ubuntu-latest
|
|
gpu_visible: none
|
|
cuda_visible: ""
|
|
- target: linux-arm64-cpu-py311
|
|
image_platform: linux-arm64
|
|
runner: ubuntu-24.04-arm
|
|
gpu_visible: none
|
|
cuda_visible: ""
|
|
runs-on: ${{ matrix.runner }}
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
steps:
|
|
- name: Free disk space
|
|
run: |
|
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
|
/usr/local/share/boost /opt/hostedtoolcache/CodeQL
|
|
sudo docker system prune -af
|
|
df -h /
|
|
|
|
- name: Checkout release tag
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
ref: ${{ inputs.release_commit }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Verify immutable release tag binding
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.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 unpublished image digest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: digests-${{ matrix.image_platform }}
|
|
path: /tmp/image-digest
|
|
|
|
- name: Resolve exact verification image
|
|
env:
|
|
IMAGE_PLATFORM: ${{ matrix.image_platform }}
|
|
run: |
|
|
mapfile -t digest_files < <(find /tmp/image-digest -maxdepth 1 -type f -print)
|
|
[[ ${#digest_files[@]} -eq 1 ]] || {
|
|
echo "::error::Expected exactly one ${IMAGE_PLATFORM} image digest"
|
|
exit 1
|
|
}
|
|
digest="$(basename "${digest_files[0]}")"
|
|
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
|
|
echo "::error::Invalid image digest artifact"
|
|
exit 1
|
|
}
|
|
echo "SNAPOTTER_BUNDLE_IMAGE=ghcr.io/snapotter-hq/snapotter@sha256:${digest}" >> "$GITHUB_ENV"
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- name: Verify image has the release trust identity
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
|
|
run: |
|
|
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
|
|
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
|
|
actual_key_id="$(docker run --rm --entrypoint sh "${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
-c 'printf %s "$OCR_RUNTIME_INDEX_KEY_ID"')"
|
|
actual_public_key="$(docker run --rm --entrypoint sh "${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
-c 'printf %s "$OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64"')"
|
|
actual_official_container="$(docker run --rm --entrypoint sh "${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
-c 'printf %s "$SNAPOTTER_OFFICIAL_CONTAINER"')"
|
|
[[ "${actual_key_id}" == "${OCR_RUNTIME_INDEX_KEY_ID}" ]] || {
|
|
echo "::error::Verification image OCR key ID does not match the release identity"
|
|
exit 1
|
|
}
|
|
[[ "${actual_public_key}" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
|
|
echo "::error::Verification image OCR public key does not match the release identity"
|
|
exit 1
|
|
}
|
|
[[ "${actual_official_container}" == "1" ]] || {
|
|
echo "::error::Verification image is not marked as an official container"
|
|
exit 1
|
|
}
|
|
|
|
- name: Download OCR build artifact
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ocr-${{ matrix.target }}
|
|
path: /tmp/bundles
|
|
|
|
- name: Verify exact Fast OCR payload
|
|
run: |
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
docker run --rm --network none \
|
|
-e EMBEDDED=0 \
|
|
-e PUID="${RUNNER_UID}" \
|
|
-e PGID="${RUNNER_GID}" \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
bash -ceu '
|
|
expected=(chi_sim deu eng fra jpn spa)
|
|
mapfile -t actual < <(tesseract --list-langs 2>/dev/null | tail -n +2 | LC_ALL=C sort)
|
|
[[ "${actual[*]}" == "${expected[*]}" ]] || {
|
|
echo "FAIL: Fast OCR language payload changed: ${actual[*]}" >&2
|
|
exit 1
|
|
}
|
|
tessdata=/usr/share/tesseract-ocr/5/tessdata
|
|
[[ ! -e "${tessdata}/osd.traineddata" && ! -e "${tessdata}/kor.traineddata" ]] || {
|
|
echo "FAIL: Fast OCR unexpectedly contains orientation or Korean traineddata" >&2
|
|
exit 1
|
|
}
|
|
traineddata_bytes="$(find "${tessdata}" -maxdepth 1 -type f -name "*.traineddata" -printf "%s\n" \
|
|
| awk "{ total += \$1 } END { print total + 0 }")"
|
|
[[ "${traineddata_bytes}" == "14003738" ]] || {
|
|
echo "FAIL: Fast OCR traineddata size changed: ${traineddata_bytes}" >&2
|
|
exit 1
|
|
}
|
|
echo "PASS: exact Fast OCR languages and 14003738 traineddata bytes; about 25 MiB remains a measured approximate image-layer size"
|
|
'
|
|
|
|
- name: Verify offline install and runtime smoke
|
|
env:
|
|
CUDA_VISIBLE_DEVICES: ${{ matrix.cuda_visible }}
|
|
NVIDIA_VISIBLE_DEVICES: ${{ matrix.gpu_visible }}
|
|
TARGET: ${{ matrix.target }}
|
|
run: |
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
rm -rf /tmp/ocr-quality
|
|
install -d -m 1777 /tmp/ocr-quality
|
|
docker run --rm --network none --memory 3g --memory-swap 3g \
|
|
-v "$PWD/docker/verify-ocr-runtime.sh:/verify-ocr-runtime.sh:ro" \
|
|
-v "$PWD/packages/ai/python/install_runtime.py:/app/packages/ai/python/install_runtime.py:ro" \
|
|
-v "/tmp/bundles:/bundles:ro" \
|
|
-e EMBEDDED=0 \
|
|
-e PUID="${RUNNER_UID}" \
|
|
-e PGID="${RUNNER_GID}" \
|
|
-e CUDA_VISIBLE_DEVICES \
|
|
-e NVIDIA_VISIBLE_DEVICES \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/verify-ocr-runtime.sh "${TARGET}" memory-preflight
|
|
docker run --rm --network none --memory 4g --memory-swap 4g \
|
|
--user 20001:0 \
|
|
-v "$PWD/docker/verify-ocr-runtime.sh:/verify-ocr-runtime.sh:ro" \
|
|
-v "$PWD/packages/ai/python/install_runtime.py:/app/packages/ai/python/install_runtime.py:ro" \
|
|
-v "/tmp/bundles:/bundles:ro" \
|
|
-e EMBEDDED=0 \
|
|
-e CUDA_VISIBLE_DEVICES \
|
|
-e NVIDIA_VISIBLE_DEVICES \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/verify-ocr-runtime.sh "${TARGET}" install-smoke
|
|
docker run --rm --network none --memory 4g --memory-swap 4g \
|
|
-v "$PWD/docker/verify-ocr-runtime.sh:/verify-ocr-runtime.sh:ro" \
|
|
-v "$PWD/packages/ai/python/install_runtime.py:/app/packages/ai/python/install_runtime.py:ro" \
|
|
-v "$PWD/tests/fixtures:/fixtures:ro" \
|
|
-v "/tmp/bundles:/bundles:ro" \
|
|
-v "/tmp/ocr-quality:/reports" \
|
|
-e EMBEDDED=0 \
|
|
-e PUID="${RUNNER_UID}" \
|
|
-e PGID="${RUNNER_GID}" \
|
|
-e CUDA_VISIBLE_DEVICES \
|
|
-e NVIDIA_VISIBLE_DEVICES \
|
|
-e OCR_VERIFY_ENVIRONMENT=native-cpu \
|
|
-e OCR_VERIFY_REPORT_DIR=/reports \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/verify-ocr-runtime.sh "${TARGET}" full
|
|
chmod 0755 /tmp/ocr-quality
|
|
test -r "/tmp/ocr-quality/ocr-${TARGET}-native-cpu.quality.json"
|
|
|
|
- name: Upload native OCR quality and resource report
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ocr-quality-${{ matrix.target }}
|
|
overwrite: true
|
|
path: /tmp/ocr-quality/*.quality.json
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
|
|
- name: Extract verified OCR runtime for supply-chain inspection
|
|
id: extract_runtime
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
run: |
|
|
rm -rf /tmp/ocr-runtime-scan /tmp/ocr-security
|
|
mkdir -p /tmp/ocr-runtime-scan /tmp/ocr-security
|
|
python3 - <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import stat
|
|
import tarfile
|
|
|
|
bundles = pathlib.Path("/tmp/bundles")
|
|
scan_root = pathlib.Path("/tmp/ocr-runtime-scan")
|
|
target = os.environ["TARGET"]
|
|
artifact_path = bundles / f"ocr-{target}.artifact.json"
|
|
artifact = json.loads(artifact_path.read_bytes())
|
|
archive_metadata = artifact["archive"]
|
|
archive_name = f"ocr-{target}.tar.gz"
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
if (
|
|
artifact.get("family") != "ocr"
|
|
or artifact.get("target") != target
|
|
or archive_metadata.get("file") != archive_name
|
|
):
|
|
raise SystemExit("OCR artifact identity changed after runtime verification")
|
|
if artifact.get("resources", {}).get("minimumMemoryBytes") != 4 * 1024 * 1024 * 1024:
|
|
raise SystemExit("OCR artifact memory policy changed after runtime verification")
|
|
|
|
archive_path = bundles / archive_name
|
|
if archive_path.stat().st_size != archive_metadata["size"]:
|
|
raise SystemExit("OCR archive size changed after runtime verification")
|
|
if file_sha256(archive_path) != artifact["archive"]["sha256"]:
|
|
raise SystemExit("OCR archive digest changed after runtime verification")
|
|
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
path = pathlib.PurePosixPath(member.name)
|
|
if path.is_absolute() or ".." in path.parts:
|
|
raise SystemExit(f"Unsafe OCR archive path: {member.name}")
|
|
if not member.isfile() and not member.isdir():
|
|
raise SystemExit(f"Unsupported OCR archive entry: {member.name}")
|
|
archive.extractall(scan_root, filter=tarfile.data_filter)
|
|
|
|
expected_files = {record["path"]: record for record in artifact["files"]}
|
|
if len(expected_files) != len(artifact["files"]):
|
|
raise SystemExit("OCR artifact file manifest contains duplicate paths")
|
|
actual_files = {
|
|
path.relative_to(scan_root).as_posix(): path
|
|
for path in scan_root.rglob("*")
|
|
if path.is_file()
|
|
}
|
|
if set(actual_files) != set(expected_files):
|
|
raise SystemExit("Extracted OCR runtime file manifest mismatch")
|
|
for relative_path, expected in expected_files.items():
|
|
path = actual_files[relative_path]
|
|
info = path.stat()
|
|
if (
|
|
info.st_size != expected["size"]
|
|
or stat.S_IMODE(info.st_mode) != expected["mode"]
|
|
or file_sha256(path) != expected["sha256"]
|
|
):
|
|
raise SystemExit(f"Extracted OCR runtime file mismatch: {relative_path}")
|
|
PY
|
|
echo "ready=true" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Install pinned Syft 1.42.3 from verified release bytes
|
|
env:
|
|
SYFT_VERSION: "1.42.3"
|
|
run: |
|
|
# Published in Syft's v1.42.3 syft_1.42.3_checksums.txt release asset.
|
|
case "$(uname -m)" in
|
|
x86_64)
|
|
syft_arch="amd64"
|
|
expected_sha256="0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509"
|
|
;;
|
|
aarch64 | arm64)
|
|
syft_arch="arm64"
|
|
expected_sha256="dc630590c953347789d08f8ebf57c7d8094db89100785fcd94b1cddeac791804"
|
|
;;
|
|
*)
|
|
echo "::error::Unsupported Syft installer architecture: $(uname -m)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
archive="syft_${SYFT_VERSION}_linux_${syft_arch}.tar.gz"
|
|
install_root="${RUNNER_TEMP}/syft-${SYFT_VERSION}"
|
|
rm -rf "${install_root}"
|
|
mkdir -p "${install_root}"
|
|
curl --fail --location --silent --show-error \
|
|
--proto '=https' --tlsv1.2 --retry 3 \
|
|
--output "${install_root}/${archive}" \
|
|
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/${archive}"
|
|
printf '%s %s\n' "${expected_sha256}" "${install_root}/${archive}" \
|
|
| sha256sum --check --strict -
|
|
tar -xzf "${install_root}/${archive}" -C "${install_root}" syft
|
|
chmod 0755 "${install_root}/syft"
|
|
"${install_root}/syft" version -o json \
|
|
| jq -e --arg version "${SYFT_VERSION}" '.version == $version' >/dev/null
|
|
echo "${install_root}" >> "$GITHUB_PATH"
|
|
|
|
- name: Generate per-target OCR runtime SBOMs
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
syft scan "dir:/tmp/ocr-runtime-scan" \
|
|
--source-name "snapotter-ocr-${TARGET}" \
|
|
--source-version "${VERSION}" \
|
|
-o "cyclonedx-json=/tmp/ocr-security/ocr-${TARGET}-sbom.cdx.json" \
|
|
-o "spdx-json=/tmp/ocr-security/ocr-${TARGET}-sbom.spdx.json"
|
|
|
|
- name: Gate OCR runtime vulnerabilities
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
version: v0.70.0
|
|
scan-type: sbom
|
|
scan-ref: /tmp/ocr-security/ocr-${{ matrix.target }}-sbom.cdx.json
|
|
scanners: vuln
|
|
format: table
|
|
exit-code: "1"
|
|
severity: "CRITICAL,HIGH"
|
|
trivyignores: ".trivyignore"
|
|
timeout: 10m0s
|
|
|
|
- name: Record per-target OCR runtime vulnerability report
|
|
if: always() && steps.extract_runtime.outputs.ready == 'true'
|
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
with:
|
|
version: v0.70.0
|
|
scan-type: sbom
|
|
scan-ref: /tmp/ocr-security/ocr-${{ matrix.target }}-sbom.cdx.json
|
|
scanners: vuln
|
|
format: json
|
|
output: /tmp/ocr-security/ocr-${{ matrix.target }}-trivy.json
|
|
exit-code: "0"
|
|
severity: "CRITICAL,HIGH"
|
|
trivyignores: ".trivyignore"
|
|
timeout: 10m0s
|
|
|
|
- name: Upload per-target OCR security reports
|
|
if: always() && steps.extract_runtime.outputs.ready == 'true'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ocr-security-${{ matrix.target }}
|
|
overwrite: true
|
|
path: |
|
|
/tmp/ocr-security/ocr-${{ matrix.target }}-sbom.cdx.json
|
|
/tmp/ocr-security/ocr-${{ matrix.target }}-sbom.spdx.json
|
|
/tmp/ocr-security/ocr-${{ matrix.target }}-trivy.json
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
|
|
verify-ocr-nvidia:
|
|
name: Verify amd64 CPU OCR with a real NVIDIA GPU exposed
|
|
needs: build-ocr
|
|
timeout-minutes: 60
|
|
runs-on: [self-hosted, linux, x64, snapotter-nvidia]
|
|
env:
|
|
DOCKER_CONFIG: /tmp/snapotter-ocr-docker-config
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
steps:
|
|
- name: Clear persistent runner release state
|
|
run: |
|
|
rm -rf /tmp/image-digest /tmp/bundles /tmp/ocr-quality /tmp/snapotter-ocr-docker-config
|
|
mkdir -p /tmp/image-digest /tmp/bundles /tmp/ocr-quality /tmp/snapotter-ocr-docker-config
|
|
|
|
- name: Checkout release tag
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
ref: ${{ inputs.release_commit }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Verify immutable release tag binding
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.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: Require a working NVIDIA container runtime
|
|
run: |
|
|
command -v nvidia-smi >/dev/null
|
|
nvidia-smi --query-gpu=name,driver_version --format=csv,noheader
|
|
docker info --format '{{json .Runtimes}}' | grep -q 'nvidia'
|
|
|
|
- name: Download unpublished amd64 image digest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: digests-linux-amd64
|
|
path: /tmp/image-digest
|
|
|
|
- name: Resolve exact amd64 verification image
|
|
run: |
|
|
mapfile -t digest_files < <(find /tmp/image-digest -maxdepth 1 -type f -print)
|
|
[[ ${#digest_files[@]} -eq 1 ]] || {
|
|
echo "::error::Expected exactly one linux-amd64 image digest"
|
|
exit 1
|
|
}
|
|
digest="$(basename "${digest_files[0]}")"
|
|
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
|
|
echo "::error::Invalid amd64 image digest artifact"
|
|
exit 1
|
|
}
|
|
echo "SNAPOTTER_BUNDLE_IMAGE=ghcr.io/snapotter-hq/snapotter@sha256:${digest}" >> "$GITHUB_ENV"
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- name: Download amd64 OCR build artifact
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ocr-linux-amd64-cpu-py312
|
|
path: /tmp/bundles
|
|
|
|
- name: Prove the GPU is exposed inside the release image
|
|
run: |
|
|
docker run --rm --gpus all --entrypoint sh "${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
-c 'test -e /dev/nvidiactl && test -r /proc/driver/nvidia/version'
|
|
|
|
- name: Verify the portable CPU runtime with NVIDIA exposed
|
|
run: |
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
rm -rf /tmp/ocr-quality
|
|
install -d -m 1777 /tmp/ocr-quality
|
|
docker run --rm --gpus all --network none --memory 4g --memory-swap 4g \
|
|
-v "$PWD/docker/verify-ocr-runtime.sh:/verify-ocr-runtime.sh:ro" \
|
|
-v "$PWD/packages/ai/python/install_runtime.py:/app/packages/ai/python/install_runtime.py:ro" \
|
|
-v "$PWD/tests/fixtures:/fixtures:ro" \
|
|
-v "/tmp/bundles:/bundles:ro" \
|
|
-v "/tmp/ocr-quality:/reports" \
|
|
-e EMBEDDED=0 \
|
|
-e PUID="${RUNNER_UID}" \
|
|
-e PGID="${RUNNER_GID}" \
|
|
-e CUDA_VISIBLE_DEVICES=0 \
|
|
-e NVIDIA_VISIBLE_DEVICES=all \
|
|
-e OCR_VERIFY_ENVIRONMENT=nvidia-exposed-cpu \
|
|
-e OCR_VERIFY_REPORT_DIR=/reports \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/verify-ocr-runtime.sh linux-amd64-cpu-py312 full
|
|
chmod 0755 /tmp/ocr-quality
|
|
test -r /tmp/ocr-quality/ocr-linux-amd64-cpu-py312-nvidia-exposed-cpu.quality.json
|
|
|
|
- name: Upload NVIDIA-exposed CPU OCR quality and resource report
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ocr-quality-linux-amd64-cpu-py312-nvidia
|
|
overwrite: true
|
|
path: /tmp/ocr-quality/*.quality.json
|
|
if-no-files-found: error
|
|
retention-days: 30
|
|
|
|
- name: Remove release-scoped state from persistent runner
|
|
if: always()
|
|
run: |
|
|
if [[ -n "${SNAPOTTER_BUNDLE_IMAGE:-}" ]]; then
|
|
docker image rm "${SNAPOTTER_BUNDLE_IMAGE}" || true
|
|
fi
|
|
rm -rf /tmp/image-digest /tmp/bundles /tmp/ocr-quality /tmp/snapotter-ocr-docker-config
|
|
|
|
sign-ocr-index:
|
|
name: Sign verified OCR runtime index
|
|
timeout-minutes: 20
|
|
needs: [build-ocr, verify-ocr, verify-ocr-nvidia]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Checkout exact release tag for provenance verification
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
ref: ${{ inputs.release_commit }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Bind signing to the checked-out release tag commit
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.version }}
|
|
WORKFLOW_COMMIT: ${{ github.workflow_sha }}
|
|
run: |
|
|
tag_commit="$(git rev-parse "refs/tags/v${VERSION}^{commit}")"
|
|
[[ "$(git rev-parse HEAD)" == "${RELEASE_COMMIT}" \
|
|
&& "${tag_commit}" == "${RELEASE_COMMIT}" ]] || {
|
|
echo "::error::Checked-out commit does not match release tag v${VERSION}"
|
|
exit 1
|
|
}
|
|
[[ "${WORKFLOW_COMMIT}" =~ ^[a-f0-9]{40}$ ]] || {
|
|
echo "::error::Reusable workflow revision is not an immutable commit"
|
|
exit 1
|
|
}
|
|
echo "SNAPOTTER_RELEASE_COMMIT=${RELEASE_COMMIT}" >> "$GITHUB_ENV"
|
|
echo "SNAPOTTER_WORKFLOW_COMMIT=${WORKFLOW_COMMIT}" >> "$GITHUB_ENV"
|
|
|
|
- name: Download exact amd64 release image digest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: digests-linux-amd64
|
|
path: /tmp/image-digests/linux-amd64
|
|
|
|
- name: Download exact arm64 release image digest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: digests-linux-arm64
|
|
path: /tmp/image-digests/linux-arm64
|
|
|
|
- name: Download both verified OCR artifact sets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: ocr-linux-*-cpu-py*
|
|
path: /tmp/ocr-artifacts
|
|
merge-multiple: true
|
|
|
|
- name: Download OCR security attestations
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: ocr-security-*
|
|
path: /tmp/ocr-attestations
|
|
merge-multiple: true
|
|
|
|
- name: Download OCR quality and resource attestations
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: ocr-quality-*
|
|
path: /tmp/ocr-attestations
|
|
merge-multiple: true
|
|
|
|
- name: Create canonical unsigned index and validate its archives
|
|
env:
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
mkdir -p /tmp/ocr-index
|
|
python3 - <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
source = Path("/tmp/ocr-artifacts")
|
|
attestation_root = Path("/tmp/ocr-attestations")
|
|
output = Path("/tmp/ocr-index")
|
|
expected_targets = {"linux-amd64-cpu-py312", "linux-arm64-cpu-py311"}
|
|
expected_image_platforms = {
|
|
"linux-amd64-cpu-py312": "linux-amd64",
|
|
"linux-arm64-cpu-py311": "linux-arm64",
|
|
}
|
|
artifacts = []
|
|
seen_targets = set()
|
|
source_commits = set()
|
|
|
|
def canonical(value):
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
# OCR_MEASURED_ESTIMATE_CONTRACT_BEGIN
|
|
MEASURED_ESTIMATE_MINIMUM_TOLERANCE_BYTES = 1024 * 1024
|
|
|
|
def validate_measured_estimate(target, manifest_target, archive):
|
|
if not isinstance(manifest_target, dict):
|
|
raise SystemExit(f"missing measured-estimate metadata for {target}")
|
|
if manifest_target.get("sizeKind") != "measured-estimate":
|
|
raise SystemExit(f"invalid measured-estimate sizeKind for {target}")
|
|
if not isinstance(archive, dict):
|
|
raise SystemExit(f"missing archive size metadata for {target}")
|
|
|
|
size_fields = (
|
|
("compressed", "compressedSizeEstimate", archive.get("size")),
|
|
("expanded", "extractedSizeEstimate", archive.get("expandedSize")),
|
|
)
|
|
for label, estimate_field, actual in size_fields:
|
|
estimate = manifest_target.get(estimate_field)
|
|
if type(estimate) is not int or estimate <= 0:
|
|
raise SystemExit(
|
|
f"invalid {label} measured-estimate field for {target}"
|
|
)
|
|
if type(actual) is not int or actual <= 0:
|
|
raise SystemExit(f"invalid actual {label} size for {target}")
|
|
difference = abs(actual - estimate)
|
|
if (
|
|
difference > MEASURED_ESTIMATE_MINIMUM_TOLERANCE_BYTES
|
|
and difference * 100 > estimate
|
|
):
|
|
raise SystemExit(
|
|
f"OCR {label} size drift exceeds measured-estimate tolerance for "
|
|
f"{target}: estimate={estimate}, actual={actual}, "
|
|
f"difference={difference}, minimumTolerance="
|
|
f"{MEASURED_ESTIMATE_MINIMUM_TOLERANCE_BYTES}, "
|
|
"relativeTolerance=1%"
|
|
)
|
|
# OCR_MEASURED_ESTIMATE_CONTRACT_END
|
|
|
|
manifest_path = Path("docker/feature-manifest.json")
|
|
try:
|
|
manifest = json.loads(manifest_path.read_bytes())
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
raise SystemExit(f"invalid checked-out OCR feature manifest: {error}") from error
|
|
bundles = manifest.get("bundles") if isinstance(manifest, dict) else None
|
|
ocr_manifest = bundles.get("ocr") if isinstance(bundles, dict) else None
|
|
manifest_targets = (
|
|
ocr_manifest.get("targets") if isinstance(ocr_manifest, dict) else None
|
|
)
|
|
if (
|
|
not isinstance(manifest_targets, dict)
|
|
or set(manifest_targets) != expected_targets
|
|
):
|
|
raise SystemExit("checked-out OCR measured-estimate target set is invalid")
|
|
|
|
workflow_commit = os.environ["SNAPOTTER_WORKFLOW_COMMIT"]
|
|
if (
|
|
len(workflow_commit) != 40
|
|
or any(character not in "0123456789abcdef" for character in workflow_commit)
|
|
):
|
|
raise SystemExit("invalid reusable-workflow provenance")
|
|
expected_image_digests = {}
|
|
for target, image_platform in expected_image_platforms.items():
|
|
digest_root = Path("/tmp/image-digests") / image_platform
|
|
digest_entries = list(digest_root.iterdir())
|
|
if (
|
|
len(digest_entries) != 1
|
|
or not digest_entries[0].is_file()
|
|
or digest_entries[0].is_symlink()
|
|
or digest_entries[0].stat().st_size != 0
|
|
):
|
|
raise SystemExit(
|
|
f"expected exactly one release image digest for {image_platform}"
|
|
)
|
|
digest = digest_entries[0].name
|
|
if len(digest) != 64 or any(
|
|
character not in "0123456789abcdef" for character in digest
|
|
):
|
|
raise SystemExit(f"invalid release image digest for {image_platform}")
|
|
expected_image_digests[target] = f"sha256:{digest}"
|
|
|
|
for metadata_path in sorted(source.glob("*.artifact.json")):
|
|
if metadata_path.is_symlink() or not metadata_path.is_file():
|
|
raise SystemExit(f"unsafe OCR artifact metadata: {metadata_path.name}")
|
|
raw = metadata_path.read_bytes()
|
|
artifact = json.loads(raw)
|
|
if raw != canonical(artifact):
|
|
raise SystemExit(f"artifact metadata is not canonical: {metadata_path.name}")
|
|
target = artifact.get("target")
|
|
if artifact.get("family") != "ocr" or target not in expected_targets:
|
|
raise SystemExit(f"unexpected OCR artifact identity: {metadata_path.name}")
|
|
if target in seen_targets:
|
|
raise SystemExit(f"duplicate OCR target: {target}")
|
|
seen_targets.add(target)
|
|
if metadata_path.name != f"ocr-{target}.artifact.json":
|
|
raise SystemExit(f"artifact filename does not bind target {target}")
|
|
expected_arch = "amd64" if target == "linux-amd64-cpu-py312" else "arm64"
|
|
if artifact.get("platform") != "linux" or artifact.get("arch") != expected_arch:
|
|
raise SystemExit(f"platform mismatch for {target}")
|
|
if artifact.get("version") != os.environ["VERSION"]:
|
|
raise SystemExit(f"version mismatch for {target}")
|
|
if artifact.get("capabilities", {}).get("providers") != ["CPUExecutionProvider"]:
|
|
raise SystemExit(f"non-portable provider set for {target}")
|
|
if artifact.get("resources", {}).get("minimumMemoryBytes") != 4 * 1024 * 1024 * 1024:
|
|
raise SystemExit(f"invalid memory policy for {target}")
|
|
provenance = artifact.get("provenance")
|
|
if not isinstance(provenance, dict):
|
|
raise SystemExit(f"missing build provenance for {target}")
|
|
source_commit = provenance.get("sourceCommit")
|
|
if (
|
|
not isinstance(source_commit, str)
|
|
or len(source_commit) != 40
|
|
or any(character not in "0123456789abcdef" for character in source_commit)
|
|
or provenance.get("builderId")
|
|
!= f"github-actions:.github/workflows/ai-bundles.yml@{workflow_commit}"
|
|
or source_commit != os.environ["SNAPOTTER_RELEASE_COMMIT"]
|
|
or provenance.get("sourceImageDigest") != expected_image_digests[target]
|
|
):
|
|
raise SystemExit(f"invalid build provenance for {target}")
|
|
source_commits.add(source_commit)
|
|
model_objects = artifact.get("modelObjects")
|
|
models = artifact.get("models")
|
|
if (
|
|
not isinstance(model_objects, list)
|
|
or not isinstance(models, dict)
|
|
or len(model_objects) != len(models)
|
|
):
|
|
raise SystemExit(f"missing model provenance for {target}")
|
|
if {model.get("id") for model in model_objects if isinstance(model, dict)} != set(models):
|
|
raise SystemExit(f"model provenance IDs do not match the digest map for {target}")
|
|
file_records = {
|
|
record.get("path"): record
|
|
for record in artifact.get("files", [])
|
|
if isinstance(record, dict)
|
|
}
|
|
if len(file_records) != len(artifact.get("files", [])):
|
|
raise SystemExit(f"duplicate or malformed runtime file records for {target}")
|
|
for model in model_objects:
|
|
if not isinstance(model, dict):
|
|
raise SystemExit(f"malformed model provenance for {target}")
|
|
model_id = model.get("id")
|
|
path = model.get("path")
|
|
digest = model.get("sha256")
|
|
file_record = file_records.get(path)
|
|
if (
|
|
not isinstance(model_id, str)
|
|
or models.get(model_id) != digest
|
|
or not isinstance(file_record, dict)
|
|
or file_record.get("sha256") != digest
|
|
or file_record.get("size") != model.get("size")
|
|
or not isinstance(model.get("license"), str)
|
|
or not model["license"]
|
|
):
|
|
raise SystemExit(f"model provenance does not match runtime bytes for {target}")
|
|
if "url" in model:
|
|
revision = model.get("revision")
|
|
if (
|
|
not isinstance(revision, str)
|
|
or len(revision) != 40
|
|
or f"/resolve/{revision}/" not in str(model.get("url"))
|
|
):
|
|
raise SystemExit(
|
|
f"model provenance does not bind its immutable revision for {target}"
|
|
)
|
|
elif not isinstance(model.get("source"), str):
|
|
raise SystemExit(f"local model provenance is missing its source for {target}")
|
|
legal_materials = artifact.get("legalMaterials")
|
|
expected_legal_ids = {
|
|
"snapotter-agpl",
|
|
"apache-2.0",
|
|
"antlr-4.9.3-license",
|
|
}
|
|
if (
|
|
not isinstance(legal_materials, list)
|
|
or len(legal_materials) != len(expected_legal_ids)
|
|
or {
|
|
material.get("id")
|
|
for material in legal_materials
|
|
if isinstance(material, dict)
|
|
}
|
|
!= expected_legal_ids
|
|
):
|
|
raise SystemExit(f"incomplete legal material set for {target}")
|
|
for material in legal_materials:
|
|
if not isinstance(material, dict):
|
|
raise SystemExit(f"malformed legal material for {target}")
|
|
file_record = file_records.get(material.get("path"))
|
|
if (
|
|
not isinstance(file_record, dict)
|
|
or file_record.get("sha256") != material.get("sha256")
|
|
or file_record.get("size") != material.get("size")
|
|
):
|
|
raise SystemExit(
|
|
f"legal material does not match runtime bytes for {target}"
|
|
)
|
|
notices = file_records.get("THIRD_PARTY_NOTICES.json")
|
|
if (
|
|
not isinstance(notices, dict)
|
|
or not isinstance(notices.get("sha256"), str)
|
|
or len(notices["sha256"]) != 64
|
|
or not isinstance(notices.get("size"), int)
|
|
or notices["size"] <= 0
|
|
):
|
|
raise SystemExit(f"runtime third-party notices are missing for {target}")
|
|
archive = artifact.get("archive", {})
|
|
archive_name = archive.get("file")
|
|
if archive_name != f"ocr-{target}.tar.gz":
|
|
raise SystemExit(f"unexpected archive name for {target}")
|
|
archive_path = source / archive_name
|
|
if not archive_path.is_file() or archive_path.is_symlink():
|
|
raise SystemExit(f"missing archive for {target}")
|
|
if archive_path.stat().st_size != archive.get("size"):
|
|
raise SystemExit(f"archive size mismatch for {target}")
|
|
digest = file_sha256(archive_path)
|
|
if digest != archive.get("sha256"):
|
|
raise SystemExit(f"archive digest mismatch for {target}")
|
|
checksum_path = source / f"{archive_name}.sha256"
|
|
if (
|
|
not checksum_path.is_file()
|
|
or checksum_path.is_symlink()
|
|
or checksum_path.read_bytes() != f"{digest}\n".encode()
|
|
):
|
|
raise SystemExit(f"archive checksum sidecar mismatch for {target}")
|
|
validate_measured_estimate(target, manifest_targets[target], archive)
|
|
artifacts.append(artifact)
|
|
|
|
if seen_targets != expected_targets:
|
|
raise SystemExit(f"expected both OCR targets, found {sorted(seen_targets)}")
|
|
if len(source_commits) != 1:
|
|
raise SystemExit(f"OCR targets were built from different commits: {source_commits}")
|
|
expected_attestations = {
|
|
*(f"ocr-{target}-sbom.cdx.json" for target in expected_targets),
|
|
*(f"ocr-{target}-sbom.spdx.json" for target in expected_targets),
|
|
*(f"ocr-{target}-trivy.json" for target in expected_targets),
|
|
*(f"ocr-{target}-native-cpu.quality.json" for target in expected_targets),
|
|
"ocr-linux-amd64-cpu-py312-nvidia-exposed-cpu.quality.json",
|
|
}
|
|
actual_attestations = {
|
|
path.name for path in attestation_root.iterdir() if path.is_file()
|
|
}
|
|
if actual_attestations != expected_attestations:
|
|
raise SystemExit(
|
|
"OCR attestation set mismatch: "
|
|
f"expected {sorted(expected_attestations)}, got {sorted(actual_attestations)}"
|
|
)
|
|
attestations = [
|
|
{
|
|
"file": path.name,
|
|
"sha256": file_sha256(path),
|
|
"size": path.stat().st_size,
|
|
}
|
|
for path in sorted(attestation_root.iterdir())
|
|
]
|
|
index = {
|
|
"artifacts": sorted(artifacts, key=lambda artifact: artifact["target"]),
|
|
"attestations": attestations,
|
|
"schemaVersion": 1,
|
|
"version": os.environ["VERSION"],
|
|
}
|
|
(output / "ocr-runtime-index.unsigned.json").write_bytes(canonical(index))
|
|
PY
|
|
|
|
- name: Sign with repository-trusted Ed25519 identity
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
|
|
OCR_RUNTIME_INDEX_SIGNING_KEY_B64: ${{ secrets.OCR_RUNTIME_INDEX_SIGNING_KEY_B64 }}
|
|
run: |
|
|
: "${OCR_RUNTIME_INDEX_KEY_ID:?Set repository variable OCR_RUNTIME_INDEX_KEY_ID}"
|
|
: "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64:?Set repository variable OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}"
|
|
: "${OCR_RUNTIME_INDEX_SIGNING_KEY_B64:?Set secret OCR_RUNTIME_INDEX_SIGNING_KEY_B64}"
|
|
[[ "${OCR_RUNTIME_INDEX_KEY_ID}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || {
|
|
echo "::error::OCR runtime signing key ID is not a safe identifier"
|
|
exit 1
|
|
}
|
|
umask 077
|
|
trap 'rm -f /tmp/ocr-index/private.pem /tmp/ocr-index/configured-public.pem /tmp/ocr-index/derived-public.pem' EXIT
|
|
printf '%s' "${OCR_RUNTIME_INDEX_SIGNING_KEY_B64}" | base64 --decode > /tmp/ocr-index/private.pem
|
|
printf '%s' "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" | base64 --decode > /tmp/ocr-index/configured-public.pem
|
|
[[ "$(base64 --wrap=0 < /tmp/ocr-index/private.pem)" == "${OCR_RUNTIME_INDEX_SIGNING_KEY_B64}" ]] || {
|
|
echo "::error::OCR signing private key must use canonical base64"
|
|
exit 1
|
|
}
|
|
[[ "$(base64 --wrap=0 < /tmp/ocr-index/configured-public.pem)" == "${OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64}" ]] || {
|
|
echo "::error::OCR runtime public key must use canonical base64"
|
|
exit 1
|
|
}
|
|
openssl pkey -in /tmp/ocr-index/private.pem -text -noout | grep -q ED25519 || {
|
|
echo "::error::Configured OCR signing private key is not Ed25519"
|
|
exit 1
|
|
}
|
|
openssl pkey -in /tmp/ocr-index/private.pem -pubout -out /tmp/ocr-index/derived-public.pem
|
|
openssl pkey -pubin -in /tmp/ocr-index/configured-public.pem -pubout -out /tmp/ocr-index/trusted-public.pem
|
|
openssl pkey -pubin -in /tmp/ocr-index/trusted-public.pem -text -noout | grep -q ED25519 || {
|
|
echo "::error::Configured OCR runtime key is not Ed25519"
|
|
exit 1
|
|
}
|
|
cmp \
|
|
<(openssl pkey -pubin -in /tmp/ocr-index/derived-public.pem -outform DER) \
|
|
<(openssl pkey -pubin -in /tmp/ocr-index/trusted-public.pem -outform DER) || {
|
|
echo "::error::Signing key does not match the repository-trusted public key"
|
|
exit 1
|
|
}
|
|
openssl pkeyutl -sign -rawin \
|
|
-inkey /tmp/ocr-index/private.pem \
|
|
-in /tmp/ocr-index/ocr-runtime-index.unsigned.json \
|
|
-out /tmp/ocr-index/ocr-runtime-index.signature
|
|
openssl pkeyutl -verify -rawin \
|
|
-pubin -inkey /tmp/ocr-index/trusted-public.pem \
|
|
-in /tmp/ocr-index/ocr-runtime-index.unsigned.json \
|
|
-sigfile /tmp/ocr-index/ocr-runtime-index.signature
|
|
|
|
- name: Seal canonical index and public trust artifact
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import base64
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
root = Path("/tmp/ocr-index")
|
|
|
|
def canonical(value):
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
unsigned_path = root / "ocr-runtime-index.unsigned.json"
|
|
unsigned_bytes = unsigned_path.read_bytes()
|
|
unsigned = json.loads(unsigned_bytes)
|
|
unsigned["signature"] = {
|
|
"algorithm": "ed25519",
|
|
"keyId": os.environ["OCR_RUNTIME_INDEX_KEY_ID"],
|
|
"value": base64.b64encode((root / "ocr-runtime-index.signature").read_bytes()).decode(),
|
|
}
|
|
sealed_path = root / "ocr-runtime-index.json"
|
|
sealed_path.write_bytes(canonical(unsigned))
|
|
sealed = json.loads(sealed_path.read_bytes())
|
|
sealed.pop("signature")
|
|
if canonical(sealed) != unsigned_bytes:
|
|
raise SystemExit("sealed OCR index does not reconstruct its signed payload")
|
|
|
|
trust = {
|
|
"keys": [{
|
|
"algorithm": "ed25519",
|
|
"keyId": os.environ["OCR_RUNTIME_INDEX_KEY_ID"],
|
|
"publicKey": (root / "trusted-public.pem").read_text(),
|
|
}],
|
|
"schemaVersion": 1,
|
|
}
|
|
(root / "ocr-runtime-trusted-keys.json").write_bytes(canonical(trust))
|
|
PY
|
|
rm -f \
|
|
/tmp/ocr-index/private.pem \
|
|
/tmp/ocr-index/configured-public.pem \
|
|
/tmp/ocr-index/derived-public.pem \
|
|
/tmp/ocr-index/ocr-runtime-index.signature
|
|
|
|
- name: Upload verified signed OCR metadata
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: ocr-runtime-metadata
|
|
overwrite: true
|
|
path: |
|
|
/tmp/ocr-index/ocr-runtime-index.json
|
|
/tmp/ocr-index/ocr-runtime-trusted-keys.json
|
|
if-no-files-found: error
|
|
retention-days: 1
|
|
|
|
verify-signed-ocr-index:
|
|
name: Verify signed OCR index (${{ matrix.target }})
|
|
needs: sign-ocr-index
|
|
timeout-minutes: 15
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- target: linux-amd64-cpu-py312
|
|
image_platform: linux-amd64
|
|
runner: ubuntu-latest
|
|
- target: linux-arm64-cpu-py311
|
|
image_platform: linux-arm64
|
|
runner: ubuntu-24.04-arm
|
|
runs-on: ${{ matrix.runner }}
|
|
permissions:
|
|
packages: read
|
|
steps:
|
|
- name: Download final signed OCR index
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: ocr-runtime-metadata
|
|
path: /tmp/ocr-index
|
|
|
|
- name: Download exact unpublished image digest
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: digests-${{ matrix.image_platform }}
|
|
path: /tmp/image-digest
|
|
|
|
- name: Resolve exact native image
|
|
env:
|
|
IMAGE_PLATFORM: ${{ matrix.image_platform }}
|
|
run: |
|
|
mapfile -t digest_files < <(find /tmp/image-digest -maxdepth 1 -type f -print)
|
|
[[ ${#digest_files[@]} -eq 1 ]] || {
|
|
echo "::error::Expected exactly one ${IMAGE_PLATFORM} image digest"
|
|
exit 1
|
|
}
|
|
digest="$(basename "${digest_files[0]}")"
|
|
[[ "${digest}" =~ ^[a-f0-9]{64}$ ]] || {
|
|
echo "::error::Invalid ${IMAGE_PLATFORM} image digest artifact"
|
|
exit 1
|
|
}
|
|
echo "SNAPOTTER_BUNDLE_IMAGE=ghcr.io/snapotter-hq/snapotter@sha256:${digest}" >> "$GITHUB_ENV"
|
|
|
|
- name: Log in to GHCR
|
|
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- name: Verify final index with image-baked application trust
|
|
env:
|
|
TARGET: ${{ matrix.target }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
docker run --rm --network none \
|
|
-v /tmp/ocr-index/ocr-runtime-index.json:/tmp/ocr-runtime-index.json:ro \
|
|
-e EMBEDDED=0 \
|
|
-e OCR_VERIFY_TARGET="${TARGET}" \
|
|
-e OCR_VERIFY_VERSION="${VERSION}" \
|
|
"${SNAPOTTER_BUNDLE_IMAGE}" \
|
|
/app/apps/api/node_modules/.bin/tsx -e '
|
|
import { readFileSync } from "node:fs";
|
|
import {
|
|
loadOcrRuntimeTrustKeys,
|
|
OCR_RUNTIME_INDEX_MAX_BYTES,
|
|
verifyRuntimeIndex,
|
|
} from "/app/packages/ai/src/runtime-index.ts";
|
|
const target = process.env.OCR_VERIFY_TARGET;
|
|
const version = process.env.OCR_VERIFY_VERSION;
|
|
if (!target || !version) throw new Error("Signed-index verification environment is incomplete");
|
|
const raw = readFileSync("/tmp/ocr-runtime-index.json");
|
|
if (raw.length > OCR_RUNTIME_INDEX_MAX_BYTES) {
|
|
throw new Error(`Final OCR runtime index exceeds ${OCR_RUNTIME_INDEX_MAX_BYTES} bytes`);
|
|
}
|
|
const parsed = JSON.parse(raw.toString("ascii"));
|
|
const targets = parsed.artifacts
|
|
?.map((artifact) => artifact?.target)
|
|
.sort();
|
|
const expectedTargets = [
|
|
"linux-amd64-cpu-py312",
|
|
"linux-arm64-cpu-py311",
|
|
];
|
|
if (JSON.stringify(targets) !== JSON.stringify(expectedTargets)) {
|
|
throw new Error(`Final OCR runtime index target set is invalid: ${JSON.stringify(targets)}`);
|
|
}
|
|
const trustKeys = loadOcrRuntimeTrustKeys();
|
|
const verified = verifyRuntimeIndex(raw, target, trustKeys, version);
|
|
if (verified.artifact.target !== target || verified.artifact.version !== version) {
|
|
throw new Error("Final OCR runtime index selected the wrong target or version");
|
|
}
|
|
process.stdout.write(`PASS: image-baked trust verified ${target} for ${version}\n`);
|
|
'
|
|
|
|
publish:
|
|
name: Publish verified bundles to HuggingFace
|
|
# Uploads the full v3 set to HuggingFace and then re-downloads and hashes
|
|
# every published object. Untimed, a stalled transfer burns the 360 minute
|
|
# default before anyone notices.
|
|
timeout-minutes: 60
|
|
needs: [verify-ocr, sign-ocr-index, verify-signed-ocr-index]
|
|
# Hugging Face exposes one repository-wide branch head. Keep expensive
|
|
# builds parallel across releases, then serialize only the short CAS write
|
|
# boundary so two versions cannot race the shared `main` revision.
|
|
concurrency:
|
|
group: snapotter-hf-feature-bundles-publish
|
|
cancel-in-progress: false
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Check out hash-locked release tooling
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
with:
|
|
ref: ${{ inputs.release_commit }}
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Verify immutable release tag binding
|
|
env:
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.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: Set up pinned release Python
|
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
|
with:
|
|
python-version: "3.11.14"
|
|
|
|
- name: Download all verified build and metadata artifacts
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
path: /tmp/artifacts
|
|
|
|
- name: Install hash-locked hf CLI
|
|
run: |
|
|
python -m venv /tmp/hf-release-venv
|
|
/tmp/hf-release-venv/bin/python -m pip install \
|
|
--disable-pip-version-check --require-hashes --no-deps --only-binary=:all: \
|
|
--requirement docker/hf-release-requirements.txt
|
|
echo "/tmp/hf-release-venv/bin" >> "$GITHUB_PATH"
|
|
|
|
- name: Organize and verify the exact signed OCR release closure
|
|
env:
|
|
OCR_RUNTIME_INDEX_KEY_ID: ${{ vars.OCR_RUNTIME_INDEX_KEY_ID }}
|
|
OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64: ${{ vars.OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64 }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
python3 - <<'PY'
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
root = Path("/tmp/upload") / f"v{os.environ['VERSION']}"
|
|
v3 = root / "v3"
|
|
source = Path("/tmp/artifacts")
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
v3.mkdir(parents=True)
|
|
|
|
expected_targets = {"linux-amd64-cpu-py312", "linux-arm64-cpu-py311"}
|
|
expected_attestations = {
|
|
*(f"ocr-{target}-sbom.cdx.json" for target in expected_targets),
|
|
*(f"ocr-{target}-sbom.spdx.json" for target in expected_targets),
|
|
*(f"ocr-{target}-trivy.json" for target in expected_targets),
|
|
*(f"ocr-{target}-native-cpu.quality.json" for target in expected_targets),
|
|
"ocr-linux-amd64-cpu-py312-nvidia-exposed-cpu.quality.json",
|
|
}
|
|
expected_artifact_objects = {
|
|
*(f"ocr-{target}.tar.gz" for target in expected_targets),
|
|
*(f"ocr-{target}.tar.gz.sha256" for target in expected_targets),
|
|
*(f"ocr-{target}.artifact.json" for target in expected_targets),
|
|
}
|
|
fixed_objects = {"ocr-runtime-index.json", "ocr-runtime-trusted-keys.json"}
|
|
expected_sources = expected_artifact_objects | expected_attestations | fixed_objects
|
|
|
|
source_files = [path for path in source.rglob("*") if path.is_file()]
|
|
for name in sorted(expected_sources):
|
|
matches = [path for path in source_files if path.name == name]
|
|
if len(matches) != 1:
|
|
raise SystemExit(
|
|
f"missing or colliding OCR release objects for {name}: {len(matches)}"
|
|
)
|
|
if matches[0].is_symlink():
|
|
raise SystemExit(f"OCR release object is a symlink: {name}")
|
|
shutil.copyfile(matches[0], v3 / name)
|
|
|
|
unexpected_ocr_sources = {
|
|
path.name for path in source_files
|
|
if path.name.startswith("ocr-") and path.name not in expected_sources
|
|
}
|
|
if unexpected_ocr_sources:
|
|
raise SystemExit(
|
|
f"unexpected OCR release objects: {sorted(unexpected_ocr_sources)}"
|
|
)
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
def canonical(value):
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
index_path = v3 / "ocr-runtime-index.json"
|
|
index_raw = index_path.read_bytes()
|
|
signed_index = json.loads(index_raw)
|
|
if index_raw != canonical(signed_index):
|
|
raise SystemExit("Candidate OCR runtime index is not canonical JSON")
|
|
signature = signed_index.get("signature")
|
|
if (
|
|
not isinstance(signature, dict)
|
|
or set(signature) != {"algorithm", "keyId", "value"}
|
|
or signature.get("algorithm") != "ed25519"
|
|
or signature.get("keyId") != os.environ["OCR_RUNTIME_INDEX_KEY_ID"]
|
|
):
|
|
raise SystemExit("Candidate OCR runtime index has an untrusted signature identity")
|
|
unsigned_index = dict(signed_index)
|
|
unsigned_index.pop("signature")
|
|
try:
|
|
signature_bytes = base64.b64decode(signature["value"], validate=True)
|
|
public_key_bytes = base64.b64decode(
|
|
os.environ["OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64"], validate=True
|
|
)
|
|
except (KeyError, ValueError) as error:
|
|
raise SystemExit("Candidate OCR runtime index signature is invalid") from error
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
temporary_root = Path(temporary)
|
|
payload_path = temporary_root / "index.json"
|
|
signature_path = temporary_root / "index.sig"
|
|
public_key_path = temporary_root / "public.pem"
|
|
payload_path.write_bytes(canonical(unsigned_index))
|
|
signature_path.write_bytes(signature_bytes)
|
|
public_key_path.write_bytes(public_key_bytes)
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"openssl", "pkeyutl", "-verify", "-rawin", "-pubin",
|
|
"-inkey", str(public_key_path), "-in", str(payload_path),
|
|
"-sigfile", str(signature_path),
|
|
],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except subprocess.CalledProcessError as error:
|
|
raise SystemExit(
|
|
"Candidate OCR runtime index signature is invalid"
|
|
) from error
|
|
|
|
if (
|
|
signed_index.get("schemaVersion") != 1
|
|
or signed_index.get("version") != os.environ["VERSION"]
|
|
or not isinstance(signed_index.get("artifacts"), list)
|
|
or not isinstance(signed_index.get("attestations"), list)
|
|
):
|
|
raise SystemExit("Candidate OCR runtime index has an invalid identity")
|
|
|
|
artifact_names = set()
|
|
artifact_targets = set()
|
|
for artifact in signed_index["artifacts"]:
|
|
if not isinstance(artifact, dict):
|
|
raise SystemExit("Signed OCR artifact record is malformed")
|
|
target = artifact.get("target")
|
|
if artifact.get("family") != "ocr" or target not in expected_targets:
|
|
raise SystemExit("Signed OCR artifact has an unexpected identity")
|
|
if target in artifact_targets:
|
|
raise SystemExit(f"Signed OCR artifact target collides: {target}")
|
|
artifact_targets.add(target)
|
|
metadata_name = f"ocr-{target}.artifact.json"
|
|
metadata_path = v3 / metadata_name
|
|
metadata_raw = metadata_path.read_bytes()
|
|
metadata = json.loads(metadata_raw)
|
|
if metadata_raw != canonical(metadata) or metadata != artifact:
|
|
raise SystemExit(f"Signed OCR artifact metadata mismatch: {target}")
|
|
archive = artifact.get("archive")
|
|
archive_name = f"ocr-{target}.tar.gz"
|
|
if not isinstance(archive, dict) or archive.get("file") != archive_name:
|
|
raise SystemExit(f"Signed OCR archive identity mismatch: {target}")
|
|
digest = archive.get("sha256")
|
|
size = archive.get("size")
|
|
if (
|
|
not isinstance(digest, str)
|
|
or len(digest) != 64
|
|
or any(character not in "0123456789abcdef" for character in digest)
|
|
or not isinstance(size, int)
|
|
or size <= 0
|
|
):
|
|
raise SystemExit(f"Signed OCR archive record is malformed: {target}")
|
|
archive_path = v3 / archive_name
|
|
if archive_path.stat().st_size != size or file_sha256(archive_path) != digest:
|
|
raise SystemExit(f"Signed OCR archive bytes mismatch: {target}")
|
|
sidecar_name = f"{archive_name}.sha256"
|
|
if (v3 / sidecar_name).read_bytes() != f"{digest}\n".encode():
|
|
raise SystemExit(f"Signed OCR archive sidecar mismatch: {target}")
|
|
artifact_names.update({metadata_name, archive_name, sidecar_name})
|
|
|
|
if artifact_targets != expected_targets or artifact_names != expected_artifact_objects:
|
|
raise SystemExit("Signed OCR artifact closure mismatch")
|
|
|
|
attestation_names = set()
|
|
for attestation in signed_index["attestations"]:
|
|
if not isinstance(attestation, dict) or set(attestation) != {"file", "sha256", "size"}:
|
|
raise SystemExit("Signed OCR attestation record is malformed")
|
|
name = attestation["file"]
|
|
if not isinstance(name, str) or Path(name).name != name or name in attestation_names:
|
|
raise SystemExit(f"Signed OCR attestation path collides: {name}")
|
|
attestation_names.add(name)
|
|
path = v3 / name
|
|
if not path.is_file() or path.stat().st_size != attestation.get("size"):
|
|
raise SystemExit(f"Signed OCR attestation size mismatch: {name}")
|
|
if file_sha256(path) != attestation.get("sha256"):
|
|
raise SystemExit(f"Signed OCR attestation digest mismatch: {name}")
|
|
if attestation_names != expected_attestations:
|
|
raise SystemExit("Signed OCR attestation closure mismatch")
|
|
|
|
trust_path = v3 / "ocr-runtime-trusted-keys.json"
|
|
trust_raw = trust_path.read_bytes()
|
|
trust = json.loads(trust_raw)
|
|
if trust_raw != canonical(trust) or trust.get("schemaVersion") != 1:
|
|
raise SystemExit("Candidate OCR trust artifact is not canonical")
|
|
keys = trust.get("keys")
|
|
if not isinstance(keys, list) or len(keys) != 1 or not isinstance(keys[0], dict):
|
|
raise SystemExit("Candidate OCR trust artifact has an invalid key set")
|
|
if (
|
|
keys[0].get("algorithm") != "ed25519"
|
|
or keys[0].get("keyId") != os.environ["OCR_RUNTIME_INDEX_KEY_ID"]
|
|
or not isinstance(keys[0].get("publicKey"), str)
|
|
):
|
|
raise SystemExit("Candidate OCR trust artifact has an untrusted identity")
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
temporary_root = Path(temporary)
|
|
configured_path = temporary_root / "configured.pem"
|
|
trust_key_path = temporary_root / "trust.pem"
|
|
configured_der = temporary_root / "configured.der"
|
|
trust_der = temporary_root / "trust.der"
|
|
configured_path.write_bytes(public_key_bytes)
|
|
trust_key_path.write_text(keys[0]["publicKey"])
|
|
for source_path, output_path in (
|
|
(configured_path, configured_der),
|
|
(trust_key_path, trust_der),
|
|
):
|
|
subprocess.run(
|
|
[
|
|
"openssl", "pkey", "-pubin", "-in", str(source_path),
|
|
"-outform", "DER", "-out", str(output_path),
|
|
],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
if configured_der.read_bytes() != trust_der.read_bytes():
|
|
raise SystemExit("Candidate OCR trust artifact contains the wrong public key")
|
|
|
|
actual_objects = {path.name for path in v3.iterdir() if path.is_file()}
|
|
expected_objects = artifact_names | attestation_names | fixed_objects
|
|
if actual_objects != expected_objects or any(path.is_dir() for path in v3.iterdir()):
|
|
raise SystemExit("Signed OCR artifact closure mismatch")
|
|
|
|
files = []
|
|
for file in sorted(path for path in root.rglob("*") if path.is_file()):
|
|
if file.name == "manifest.json":
|
|
continue
|
|
files.append({
|
|
"file": file.relative_to(root).as_posix(),
|
|
"sha256": file_sha256(file),
|
|
"size": file.stat().st_size,
|
|
})
|
|
manifest = {
|
|
"files": files,
|
|
"schemaVersion": 2,
|
|
"version": os.environ["VERSION"],
|
|
}
|
|
(root / "manifest.json").write_bytes(canonical(manifest))
|
|
PY
|
|
|
|
- name: Refuse to mutate an existing version
|
|
env:
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
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 }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
/tmp/hf-release-venv/bin/python - <<'PY'
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path, PurePosixPath
|
|
from huggingface_hub import HfApi, hf_hub_download
|
|
|
|
repo_id = "deepsafe/feature-bundles"
|
|
version = os.environ["VERSION"]
|
|
remote_path = f"v{version}/manifest.json"
|
|
token = os.environ["HF_TOKEN"]
|
|
api = HfApi(token=token)
|
|
snapshot_revision = api.repo_info(
|
|
repo_id, repo_type="model", revision="main", token=token
|
|
).sha
|
|
if (
|
|
not isinstance(snapshot_revision, str)
|
|
or len(snapshot_revision) != 40
|
|
or any(character not in "0123456789abcdef" for character in snapshot_revision)
|
|
):
|
|
raise SystemExit("Hugging Face repository did not resolve to an immutable revision")
|
|
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle:
|
|
handle.write(f"HF_PARENT_COMMIT={snapshot_revision}\n")
|
|
if not api.file_exists(
|
|
repo_id,
|
|
remote_path,
|
|
repo_type="model",
|
|
revision=snapshot_revision,
|
|
token=token,
|
|
):
|
|
existing_version_files = [
|
|
path
|
|
for path in api.list_repo_files(
|
|
repo_id,
|
|
repo_type="model",
|
|
revision=snapshot_revision,
|
|
token=token,
|
|
)
|
|
if path.startswith(f"v{version}/")
|
|
]
|
|
if existing_version_files:
|
|
raise SystemExit(
|
|
f"Refusing to publish into an existing feature-bundle version without a manifest: {existing_version_files[:5]}"
|
|
)
|
|
raise SystemExit(0)
|
|
|
|
def canonical(value):
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
def manifest_map(manifest):
|
|
if (
|
|
not isinstance(manifest, dict)
|
|
or manifest.get("schemaVersion") != 2
|
|
or manifest.get("version") != version
|
|
or not isinstance(manifest.get("files"), list)
|
|
):
|
|
raise SystemExit("Existing feature-bundle manifest has an invalid identity")
|
|
mapped = {}
|
|
for entry in manifest["files"]:
|
|
relative_path = entry.get("file") if isinstance(entry, dict) else None
|
|
parsed_path = PurePosixPath(relative_path) if isinstance(relative_path, str) else None
|
|
if (
|
|
not isinstance(entry, dict)
|
|
or not isinstance(relative_path, str)
|
|
or parsed_path is None
|
|
or parsed_path.is_absolute()
|
|
or ".." in parsed_path.parts
|
|
or not relative_path.startswith("v3/")
|
|
or not isinstance(entry.get("sha256"), str)
|
|
or len(entry["sha256"]) != 64
|
|
or any(
|
|
character not in "0123456789abcdef"
|
|
for character in entry["sha256"]
|
|
)
|
|
or not isinstance(entry.get("size"), int)
|
|
or entry["size"] <= 0
|
|
or relative_path in mapped
|
|
):
|
|
raise SystemExit("Existing feature-bundle manifest has malformed files")
|
|
mapped[relative_path] = entry
|
|
return mapped
|
|
|
|
def is_variable_ocr_attestation(path):
|
|
return path == "v3/ocr-runtime-index.json" or (
|
|
path.startswith("v3/ocr-")
|
|
and (
|
|
"-sbom." in path
|
|
or path.endswith("-trivy.json")
|
|
or path.endswith(".quality.json")
|
|
)
|
|
)
|
|
|
|
existing_path = Path(hf_hub_download(
|
|
repo_id,
|
|
remote_path,
|
|
repo_type="model",
|
|
revision=snapshot_revision,
|
|
token=token,
|
|
))
|
|
existing_raw = existing_path.read_bytes()
|
|
existing_manifest = json.loads(existing_raw)
|
|
if existing_raw != canonical(existing_manifest):
|
|
raise SystemExit("Existing feature-bundle manifest is not canonical JSON")
|
|
candidate_path = Path("/tmp/upload") / f"v{version}" / "manifest.json"
|
|
candidate_raw = candidate_path.read_bytes()
|
|
candidate_manifest = json.loads(candidate_raw)
|
|
if candidate_raw != canonical(candidate_manifest):
|
|
raise SystemExit("Candidate feature-bundle manifest is not canonical JSON")
|
|
existing_files = manifest_map(existing_manifest)
|
|
candidate_files = manifest_map(candidate_manifest)
|
|
candidate_disk_files = {
|
|
path.relative_to(candidate_path.parent).as_posix()
|
|
for path in candidate_path.parent.rglob("*")
|
|
if path.is_file() and path != candidate_path
|
|
}
|
|
if candidate_disk_files != set(candidate_files):
|
|
raise SystemExit("Candidate feature-bundle manifest closure mismatch")
|
|
remote_prefix_files = {
|
|
path.removeprefix(f"v{version}/")
|
|
for path in api.list_repo_files(
|
|
repo_id,
|
|
repo_type="model",
|
|
revision=snapshot_revision,
|
|
token=token,
|
|
)
|
|
if path.startswith(f"v{version}/")
|
|
}
|
|
if remote_prefix_files != {"manifest.json", *existing_files}:
|
|
raise SystemExit("Existing feature-bundle version has uncommitted or missing objects")
|
|
|
|
existing_deterministic = {
|
|
path: entry for path, entry in existing_files.items()
|
|
if not is_variable_ocr_attestation(path)
|
|
}
|
|
candidate_deterministic = {
|
|
path: entry for path, entry in candidate_files.items()
|
|
if not is_variable_ocr_attestation(path)
|
|
}
|
|
if existing_deterministic != candidate_deterministic:
|
|
raise SystemExit(
|
|
"Refusing to replace an existing feature-bundle version with different runtime bytes"
|
|
)
|
|
|
|
# Verify every object committed by the remote manifest before treating
|
|
# an interrupted/retried publication as a no-op. Raw performance and
|
|
# SBOM metadata may differ between valid reruns, so their candidate
|
|
# hashes are deliberately not used as the immutable identity.
|
|
remote_files = {}
|
|
for entry in existing_manifest["files"]:
|
|
remote_file = Path(hf_hub_download(
|
|
repo_id,
|
|
f"v{version}/{entry['file']}",
|
|
repo_type="model",
|
|
revision=snapshot_revision,
|
|
token=token,
|
|
))
|
|
if remote_file.stat().st_size != entry["size"]:
|
|
raise SystemExit(f"Existing bundle object has wrong size: {entry['file']}")
|
|
if file_sha256(remote_file) != entry["sha256"]:
|
|
raise SystemExit(f"Existing bundle object has wrong digest: {entry['file']}")
|
|
remote_files[entry["file"]] = remote_file
|
|
|
|
remote_index_path = remote_files.get("v3/ocr-runtime-index.json")
|
|
if remote_index_path is None:
|
|
raise SystemExit("Existing release has no signed OCR runtime index")
|
|
remote_index_raw = remote_index_path.read_bytes()
|
|
remote_index = json.loads(remote_index_raw)
|
|
if remote_index_raw != canonical(remote_index):
|
|
raise SystemExit("Existing OCR runtime index is not canonical JSON")
|
|
candidate_index_path = (
|
|
Path("/tmp/upload") / f"v{version}" / "v3" / "ocr-runtime-index.json"
|
|
)
|
|
candidate_index_raw = candidate_index_path.read_bytes()
|
|
candidate_index = json.loads(candidate_index_raw)
|
|
if candidate_index_raw != canonical(candidate_index):
|
|
raise SystemExit("Candidate OCR runtime index is not canonical JSON")
|
|
remote_signature = remote_index.get("signature")
|
|
if (
|
|
not isinstance(remote_signature, dict)
|
|
or remote_signature.get("algorithm") != "ed25519"
|
|
or remote_signature.get("keyId") != os.environ["OCR_RUNTIME_INDEX_KEY_ID"]
|
|
):
|
|
raise SystemExit("Existing OCR runtime index has an untrusted signature identity")
|
|
signed_payload = dict(remote_index)
|
|
signed_payload.pop("signature", None)
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
payload_path = root / "index.json"
|
|
signature_path = root / "index.sig"
|
|
public_key_path = root / "public.pem"
|
|
payload_path.write_bytes(canonical(signed_payload))
|
|
signature_path.write_bytes(base64.b64decode(remote_signature.get("value", ""), validate=True))
|
|
public_key_path.write_bytes(
|
|
base64.b64decode(os.environ["OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64"], validate=True)
|
|
)
|
|
subprocess.run(
|
|
[
|
|
"openssl", "pkeyutl", "-verify", "-rawin", "-pubin",
|
|
"-inkey", str(public_key_path), "-in", str(payload_path),
|
|
"-sigfile", str(signature_path),
|
|
],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
)
|
|
|
|
if remote_index.get("artifacts") != candidate_index.get("artifacts"):
|
|
raise SystemExit("Existing release contains different OCR runtime artifacts")
|
|
remote_attestations = remote_index.get("attestations")
|
|
candidate_attestations = candidate_index.get("attestations")
|
|
if not isinstance(remote_attestations, list) or not isinstance(candidate_attestations, list):
|
|
raise SystemExit("OCR runtime index attestation set is malformed")
|
|
if not all(isinstance(entry, dict) for entry in [*remote_attestations, *candidate_attestations]):
|
|
raise SystemExit("OCR runtime index attestation record is malformed")
|
|
remote_attestation_names = {entry.get("file") for entry in remote_attestations}
|
|
candidate_attestation_names = {entry.get("file") for entry in candidate_attestations}
|
|
if (
|
|
len(remote_attestation_names) != len(remote_attestations)
|
|
or len(candidate_attestation_names) != len(candidate_attestations)
|
|
or remote_attestation_names != candidate_attestation_names
|
|
):
|
|
raise SystemExit("Existing release has a different OCR attestation set")
|
|
for attestation in remote_attestations:
|
|
entry = existing_files.get(f"v3/{attestation.get('file')}")
|
|
if (
|
|
entry is None
|
|
or entry.get("sha256") != attestation.get("sha256")
|
|
or entry.get("size") != attestation.get("size")
|
|
):
|
|
raise SystemExit(
|
|
f"Existing signed OCR attestation is not bound by the manifest: {attestation.get('file')}"
|
|
)
|
|
|
|
current_revision = api.repo_info(
|
|
repo_id, repo_type="model", revision="main", token=token
|
|
).sha
|
|
if current_revision != snapshot_revision:
|
|
raise SystemExit("Hugging Face repository changed during release audit")
|
|
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle:
|
|
handle.write("BUNDLE_RELEASE_EXISTS=true\n")
|
|
PY
|
|
|
|
- name: Upload only after every verification and signing gate passed
|
|
if: env.BUNDLE_RELEASE_EXISTS != 'true'
|
|
env:
|
|
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
|
RELEASE_COMMIT: ${{ inputs.release_commit }}
|
|
VERSION: ${{ inputs.version }}
|
|
run: |
|
|
/tmp/hf-release-venv/bin/python - <<'PY'
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
from huggingface_hub import HfApi, hf_hub_download
|
|
|
|
repo_id = "deepsafe/feature-bundles"
|
|
token = os.environ["HF_TOKEN"]
|
|
version = os.environ["VERSION"]
|
|
parent_commit = os.environ["HF_PARENT_COMMIT"]
|
|
if (
|
|
len(parent_commit) != 40
|
|
or any(character not in "0123456789abcdef" for character in parent_commit)
|
|
):
|
|
raise SystemExit("Hugging Face parent revision is not an immutable commit")
|
|
|
|
root = Path("/tmp/upload") / f"v{version}"
|
|
manifest_path = root / "manifest.json"
|
|
manifest_raw = manifest_path.read_bytes()
|
|
manifest = json.loads(manifest_raw)
|
|
|
|
def canonical(value):
|
|
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
|
|
def file_sha256(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
if (
|
|
manifest_raw != canonical(manifest)
|
|
or manifest.get("schemaVersion") != 2
|
|
or manifest.get("version") != version
|
|
or not isinstance(manifest.get("files"), list)
|
|
):
|
|
raise SystemExit("Candidate feature-bundle manifest has an invalid identity")
|
|
expected_files = {}
|
|
for entry in manifest["files"]:
|
|
relative_path = entry.get("file") if isinstance(entry, dict) else None
|
|
parsed_path = PurePosixPath(relative_path) if isinstance(relative_path, str) else None
|
|
if (
|
|
not isinstance(entry, dict)
|
|
or not isinstance(relative_path, str)
|
|
or parsed_path is None
|
|
or parsed_path.is_absolute()
|
|
or ".." in parsed_path.parts
|
|
or not relative_path.startswith("v3/")
|
|
or relative_path in expected_files
|
|
or not isinstance(entry.get("sha256"), str)
|
|
or len(entry["sha256"]) != 64
|
|
or any(
|
|
character not in "0123456789abcdef"
|
|
for character in entry["sha256"]
|
|
)
|
|
or not isinstance(entry.get("size"), int)
|
|
or entry["size"] <= 0
|
|
):
|
|
raise SystemExit("Candidate feature-bundle manifest has malformed files")
|
|
expected_files[relative_path] = entry
|
|
disk_files = {
|
|
path.relative_to(root).as_posix()
|
|
for path in root.rglob("*")
|
|
if path.is_file() and path != manifest_path
|
|
}
|
|
if disk_files != set(expected_files):
|
|
raise SystemExit("Candidate feature-bundle manifest closure mismatch")
|
|
|
|
publish_ref = "refs/snapotter/hf-publish-boundary-tag"
|
|
subprocess.run(
|
|
[
|
|
"git", "fetch", "--force", "--no-tags", "origin",
|
|
f"refs/tags/v{version}:{publish_ref}",
|
|
],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
)
|
|
published_tag_commit = subprocess.check_output(
|
|
["git", "rev-parse", f"{publish_ref}^{{commit}}"], text=True
|
|
).strip()
|
|
checked_out_commit = subprocess.check_output(
|
|
["git", "rev-parse", "HEAD"], text=True
|
|
).strip()
|
|
if (
|
|
published_tag_commit != os.environ["RELEASE_COMMIT"]
|
|
or checked_out_commit != os.environ["RELEASE_COMMIT"]
|
|
):
|
|
raise SystemExit(
|
|
"Release tag changed before Hugging Face publication"
|
|
)
|
|
|
|
api = HfApi(token=token)
|
|
commit_info = api.upload_folder(
|
|
repo_id=repo_id,
|
|
folder_path=root,
|
|
path_in_repo=f"v{version}",
|
|
commit_message=f"Publish immutable feature bundles v{version}",
|
|
token=token,
|
|
repo_type="model",
|
|
revision="main",
|
|
parent_commit=parent_commit,
|
|
)
|
|
published_revision = commit_info.oid
|
|
if (
|
|
not isinstance(published_revision, str)
|
|
or len(published_revision) != 40
|
|
or any(
|
|
character not in "0123456789abcdef"
|
|
for character in published_revision
|
|
)
|
|
):
|
|
raise SystemExit("Hugging Face upload returned an invalid commit")
|
|
head_revision = api.repo_info(
|
|
repo_id, repo_type="model", revision="main", token=token
|
|
).sha
|
|
if head_revision != published_revision:
|
|
raise SystemExit("Hugging Face repository changed during post-upload audit")
|
|
|
|
remote_prefix = f"v{version}/"
|
|
remote_files = {
|
|
path.removeprefix(remote_prefix)
|
|
for path in api.list_repo_files(
|
|
repo_id,
|
|
repo_type="model",
|
|
revision=published_revision,
|
|
token=token,
|
|
)
|
|
if path.startswith(remote_prefix)
|
|
}
|
|
if remote_files != {"manifest.json", *expected_files}:
|
|
raise SystemExit("Published feature-bundle closure mismatch")
|
|
published_manifest = Path(hf_hub_download(
|
|
repo_id,
|
|
f"v{version}/manifest.json",
|
|
repo_type="model",
|
|
revision=published_revision,
|
|
token=token,
|
|
))
|
|
if published_manifest.read_bytes() != manifest_raw:
|
|
raise SystemExit("Published feature-bundle manifest bytes mismatch")
|
|
for relative_path, entry in expected_files.items():
|
|
published_file = Path(hf_hub_download(
|
|
repo_id,
|
|
f"v{version}/{relative_path}",
|
|
repo_type="model",
|
|
revision=published_revision,
|
|
token=token,
|
|
))
|
|
if (
|
|
published_file.stat().st_size != entry["size"]
|
|
or file_sha256(published_file) != entry["sha256"]
|
|
):
|
|
raise SystemExit(
|
|
f"Published feature-bundle object mismatch: {relative_path}"
|
|
)
|
|
final_revision = api.repo_info(
|
|
repo_id, repo_type="model", revision="main", token=token
|
|
).sha
|
|
if final_revision != published_revision:
|
|
raise SystemExit("Hugging Face repository changed during post-upload audit")
|
|
print(
|
|
f"Published: https://huggingface.co/{repo_id}/tree/{published_revision}/v{version}"
|
|
)
|
|
PY
|
|
|
|
- name: Report identical existing bundle release
|
|
if: env.BUNDLE_RELEASE_EXISTS == 'true'
|
|
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}"
|