From 778c74c6352cd55369142d11dbadcc3406477acd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 16 Aug 2026 17:30:10 -0700 Subject: [PATCH] Guard app releases against missing artifacts --- .github/scripts/verify-release-assets.sh | 140 +++++++++++++++++++++++ .github/workflows/release-app.yml | 56 +++++++++ 2 files changed, 196 insertions(+) create mode 100644 .github/scripts/verify-release-assets.sh diff --git a/.github/scripts/verify-release-assets.sh b/.github/scripts/verify-release-assets.sh new file mode 100644 index 00000000..4bc622c6 --- /dev/null +++ b/.github/scripts/verify-release-assets.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +set -euo pipefail + +tag="${1:?usage: verify-release-assets.sh }" +repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +version="${tag#v}" + +expected_assets=( + "Yaak_${version}_aarch64.dmg" + "Yaak_${version}_arm64-setup-machine.exe" + "Yaak_${version}_arm64-setup-machine.exe.sig" + "Yaak_${version}_arm64-setup.exe" + "Yaak_${version}_arm64-setup.exe.sig" + "Yaak_${version}_x64-setup-machine.exe" + "Yaak_${version}_x64-setup-machine.exe.sig" + "Yaak_${version}_x64-setup.exe" + "Yaak_${version}_x64-setup.exe.sig" + "Yaak_${version}_x64.dmg" + "Yaak_aarch64.app.tar.gz" + "Yaak_aarch64.app.tar.gz.sig" + "Yaak_x64.app.tar.gz" + "Yaak_x64.app.tar.gz.sig" + "latest.json" + "yaak-${version}-1.aarch64.rpm" + "yaak-${version}-1.aarch64.rpm.sig" + "yaak-${version}-1.x86_64.rpm" + "yaak-${version}-1.x86_64.rpm.sig" + "yaak-cef_${version}_amd64.deb" + "yaak-cef_${version}_arm64.deb" + "yaak-cef_${version}_linux_arm64.tar.gz" + "yaak-cef_${version}_linux_x64.tar.gz" + "yaak_${version}_aarch64.AppImage" + "yaak_${version}_aarch64.AppImage.sig" + "yaak_${version}_amd64.AppImage" + "yaak_${version}_amd64.AppImage.sig" + "yaak_${version}_amd64.deb" + "yaak_${version}_amd64.deb.sig" + "yaak_${version}_arm64.deb" + "yaak_${version}_arm64.deb.sig" +) + +expected_platforms=( + darwin-aarch64 + darwin-aarch64-app + darwin-x86_64 + darwin-x86_64-app + linux-aarch64 + linux-aarch64-appimage + linux-aarch64-deb + linux-aarch64-rpm + linux-x86_64 + linux-x86_64-appimage + linux-x86_64-deb + linux-x86_64-rpm + windows-aarch64 + windows-aarch64-nsis + windows-x86_64 + windows-x86_64-nsis +) + +compare_sets() { + local label="$1" + local expected="$2" + local actual="$3" + local missing unexpected + + missing=$(comm -23 \ + <(printf '%s\n' "$expected" | sort -u) \ + <(printf '%s\n' "$actual" | sort -u)) + unexpected=$(comm -13 \ + <(printf '%s\n' "$expected" | sort -u) \ + <(printf '%s\n' "$actual" | sort -u)) + + if [[ -n "$missing" || -n "$unexpected" ]]; then + [[ -z "$missing" ]] || printf 'Missing %s:\n%s\n' "$label" "$missing" >&2 + [[ -z "$unexpected" ]] || printf 'Unexpected %s:\n%s\n' "$label" "$unexpected" >&2 + return 1 + fi +} + +releases=$(gh api "repos/${repo}/releases?per_page=100") +matches=$(jq -c --arg tag "$tag" '[.[] | select(.tag_name == $tag)]' <<<"$releases") +match_count=$(jq 'length' <<<"$matches") + +if [[ "$match_count" -ne 1 ]]; then + printf 'Expected exactly one GitHub release for %s, found %s:\n' "$tag" "$match_count" >&2 + jq -r '.[] | "- id=\(.id) draft=\(.draft) url=\(.html_url)"' <<<"$matches" >&2 + exit 1 +fi + +release=$(jq -c '.[0]' <<<"$matches") +if [[ "${REQUIRE_DRAFT:-true}" == "true" && "$(jq -r '.draft' <<<"$release")" != "true" ]]; then + printf 'Release %s was published before artifact verification completed\n' "$tag" >&2 + exit 1 +fi +if [[ "$(jq -r '.prerelease' <<<"$release")" != "true" ]]; then + printf 'Release %s must be marked as a prerelease\n' "$tag" >&2 + exit 1 +fi + +actual_assets=$(jq -r '.assets[].name' <<<"$release") +compare_sets \ + "release assets" \ + "$(printf '%s\n' "${expected_assets[@]}")" \ + "$actual_assets" + +invalid_assets=$(jq -r ' + .assets[] + | select( + (.size // 0) <= 0 + or ((.digest // "") | startswith("sha256:") | not) + ) + | .name +' <<<"$release") +if [[ -n "$invalid_assets" ]]; then + printf 'Assets without a non-empty SHA-256 digest:\n%s\n' "$invalid_assets" >&2 + exit 1 +fi + +latest_id=$(jq -r '.assets[] | select(.name == "latest.json") | .id' <<<"$release") +latest=$(gh api \ + -H 'Accept: application/octet-stream' \ + "repos/${repo}/releases/assets/${latest_id}") + +if [[ "$(jq -r '.version' <<<"$latest")" != "$version" ]]; then + printf 'latest.json version does not match %s\n' "$version" >&2 + exit 1 +fi + +actual_platforms=$(jq -r '.platforms | keys[]' <<<"$latest") +compare_sets \ + "updater platforms" \ + "$(printf '%s\n' "${expected_platforms[@]}")" \ + "$actual_platforms" + +printf 'Verified %s: %s assets and %s updater platforms\n' \ + "$tag" \ + "$(jq '.assets | length' <<<"$release")" \ + "$(jq '.platforms | length' <<<"$latest")" diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index 2f0d626a..610385fa 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -4,7 +4,48 @@ on: tags: [v*] jobs: + prepare-release: + if: github.repository == 'mountain-loop/yaak' + permissions: + contents: write + runs-on: ubuntu-24.04 + steps: + - name: Ensure exactly one draft release exists + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + releases=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100") + matches=$(jq -c --arg tag "$GITHUB_REF_NAME" '[.[] | select(.tag_name == $tag)]' <<<"$releases") + count=$(jq 'length' <<<"$matches") + + if [[ "$count" -gt 1 ]]; then + echo "Found $count releases for $GITHUB_REF_NAME; refusing to split artifacts" + jq -r '.[] | "- id=\(.id) draft=\(.draft) url=\(.html_url)"' <<<"$matches" + exit 1 + fi + + if [[ "$count" -eq 0 ]]; then + gh release create "$GITHUB_REF_NAME" \ + --draft \ + --prerelease \ + --title "Release ${GITHUB_REF_NAME#v}" \ + --notes '' + exit 0 + fi + + if [[ "$(jq -r '.[0].draft' <<<"$matches")" != "true" ]]; then + echo "Release $GITHUB_REF_NAME is already published; refusing to modify it" + exit 1 + fi + + if [[ "$(jq -r '.[0].prerelease' <<<"$matches")" != "true" ]]; then + echo "Release $GITHUB_REF_NAME is not marked as a prerelease" + exit 1 + fi + build-artifacts: + needs: prepare-release if: github.repository == 'mountain-loop/yaak' permissions: contents: write @@ -232,3 +273,18 @@ jobs: Copy-Item $setupSig $destSig gh release upload "${{ github.ref_name }}" "$dest" --clobber gh release upload "${{ github.ref_name }}" "$destSig" --clobber + + verify-artifacts: + name: Verify release artifacts + needs: build-artifacts + if: github.repository == 'mountain-loop/yaak' + permissions: + contents: read + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Verify complete release asset set + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash .github/scripts/verify-release-assets.sh "$GITHUB_REF_NAME"