fix(release): resolve the release by id, and make the vulnerability gate cover HIGH (#661)

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.
This commit is contained in:
SnapOtter
2026-07-28 22:49:56 +08:00
committed by GitHub
parent a75a22dd3f
commit 935861bced
7 changed files with 426 additions and 60 deletions
+6
View File
@@ -34,6 +34,7 @@ permissions: {}
jobs:
validate-inputs:
name: Validate release inputs
timeout-minutes: 10
if: inputs.release_commit != ''
runs-on: ubuntu-latest
permissions: {}
@@ -755,6 +756,7 @@ jobs:
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:
@@ -1318,6 +1320,10 @@ jobs:
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
+66 -22
View File
@@ -127,21 +127,36 @@ jobs:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
run: |
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}"
if ! gh api "${release_endpoint}" > /tmp/release.json 2> /tmp/release.error; then
if ! grep -Fq "(HTTP 404)" /tmp/release.error; then
cat /tmp/release.error >&2
echo "::error::Could not determine whether the GitHub draft exists"
exit 1
fi
# Resolve the release by numeric id, never by tag. GitHub's
# /releases/tags/{tag} endpoint returns 404 for a draft, and
# draftRelease is on, so a tag lookup here would 404 on the release
# semantic-release just created and take the whole job down with it.
# gh release view reads drafts, and /releases/{id} then returns the
# same REST shape a tag lookup would.
resolve_release_id() {
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json databaseId \
--jq .databaseId 2>/dev/null
}
release_id="$(resolve_release_id || true)"
if [[ ! "${release_id}" =~ ^[0-9]+$ ]]; then
gh release create "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--draft \
--verify-tag \
--title "v${VERSION}" \
--notes-file /tmp/release-notes.md
gh api "${release_endpoint}" > /tmp/release.json
release_id="$(resolve_release_id)"
fi
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::Could not resolve the GitHub release id for v${VERSION}"
exit 1
}
release_endpoint="repos/${GITHUB_REPOSITORY}/releases/${release_id}"
gh api "${release_endpoint}" > /tmp/release.json
jq -e --arg tag "v${VERSION}" \
'.draft == true and .tag_name == $tag' /tmp/release.json >/dev/null || {
echo "::error::Expected release is missing, public, or bound to the wrong tag"
@@ -422,13 +437,14 @@ jobs:
format: json
output: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
- name: Gate unfixed CRITICAL findings
- name: Gate unfixed CRITICAL and HIGH findings
if: always()
env:
REPORT: snapotter-v${{ needs.release.outputs.new_version }}-archive-linux-${{ matrix.arch }}-trivy.json
LABEL: archive linux/${{ matrix.arch }}
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--severity CRITICAL,HIGH \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Publish verified immutable archive assets
@@ -438,8 +454,10 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
REPOSITORY="snapotter-hq/SnapOtter"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh api "repos/${REPOSITORY}/releases/tags/v${VERSION}" --jq .id
gh release view "v${VERSION}" --repo "${REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
@@ -758,7 +776,17 @@ jobs:
exit 1
fi
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
reuse_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId 2>/dev/null || true
)"
if [[ ! "${reuse_release_id}" =~ ^[0-9]+$ ]]; then
echo "::warning::Could not resolve the release id; rebuilding ${PLATFORM}"
echo "reused=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/${reuse_release_id}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/existing-platform-asset-ids 2> /tmp/existing-platform-asset.error; then
echo "::warning::Could not read ${asset_name}; rebuilding ${PLATFORM}"
@@ -915,7 +943,16 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
asset_name="snapotter-v${VERSION}-${PLATFORM_PAIR}.digest"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
persist_release_id="$(
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${persist_release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
exit 1
}
gh api "repos/${GITHUB_REPOSITORY}/releases/${persist_release_id}" \
--jq ".assets[] | select(.name == \"${asset_name}\") | .id" \
> /tmp/platform-digest-asset-ids
mapfile -t asset_ids < /tmp/platform-digest-asset-ids
@@ -1053,13 +1090,14 @@ jobs:
format: "json"
output: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
- name: Gate unfixed CRITICAL findings
- name: Gate unfixed CRITICAL and HIGH findings
if: always()
env:
REPORT: "snapotter-v${{ needs.release.outputs.new_version }}-image-${{ matrix.platform }}-trivy.json"
LABEL: "image ${{ matrix.platform }}"
run: |
node scripts/trivy-unfixed-gate.mjs "${REPORT}" \
--severity CRITICAL,HIGH \
--label "${LABEL}" --summary "${GITHUB_STEP_SUMMARY}"
- name: Upload Trivy report to GitHub Release
@@ -1070,7 +1108,9 @@ jobs:
run: |
repository="snapotter-hq/SnapOtter"
report="snapotter-v${VERSION}-image-${{ matrix.platform }}-trivy.json"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
mapfile -t asset_ids < <(
gh api "repos/${repository}/releases/${release_id}/assets?per_page=100" \
@@ -1183,7 +1223,9 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
repository="snapotter-hq/SnapOtter"
release_id="$(gh api "repos/${repository}/releases/tags/v${VERSION}" --jq .id)"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(gh release view "v${VERSION}" --repo "${repository}" \
--json databaseId --jq .databaseId)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || exit 1
for sbom in \
"snapotter-v${VERSION}-image-${{ matrix.platform }}-sbom.cdx.json" \
@@ -1664,8 +1706,10 @@ jobs:
}
release_subjects_name="snapotter-v${VERSION}-release-subjects.json"
echo "release_subjects_name=${release_subjects_name}" >> "$GITHUB_ENV"
# By id, not by tag: /releases/tags/{tag} 404s while the release is a draft.
release_id="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" --jq .id
gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq .databaseId
)"
[[ "${release_id}" =~ ^[0-9]+$ ]] || {
echo "::error::GitHub release did not resolve to one immutable ID"
@@ -2006,9 +2050,9 @@ jobs:
VERSION: ${{ needs.release.outputs.new_version }}
run: |
draft="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)"
[[ "${draft}" == "true" ]] || {
echo "::error::Approved release is not a draft before final publication"
@@ -2024,9 +2068,9 @@ jobs:
--repo "${GITHUB_REPOSITORY}" \
--draft=false
[[ "$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/v${VERSION}" \
--jq .draft
gh release view "v${VERSION}" \
--repo "${GITHUB_REPOSITORY}" \
--json isDraft --jq .isDraft
)" == "false" ]] || {
echo "::error::Approved release remained a draft after publication"
exit 1