From b7b30d1c6e22ed4d8137fba05760740edf46080b Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Fri, 25 Sep 2026 10:22:28 +0300 Subject: [PATCH] ci: a release goes public only once every artifact is up The publish job lifted the draft as soon as the macOS and Linux jobs exited 0 and claimed that meant every artifact was up. It now runs scripts/check-release-assets.sh first: every file a release of that tag should carry has to be listed by name, finished uploading and not empty, or the release stays a draft and the job names what is missing. The macOS job's branch for a release that already exists uploaded into it while it was public. It now turns it back into a draft first, and the lift goes through the API with make_latest=legacy, so a re-run of an older tag cannot take Latest from a newer one. The same change as Plaza's release workflow, which this one mirrors. Against v0.10.12 the check passes with all five files; CI runs its self-test. --- .github/workflows/ci.yml | 7 +++ .github/workflows/release.yml | 37 +++++++++-- scripts/check-release-assets.sh | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100755 scripts/check-release-assets.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87e802f..b41c73b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,13 @@ jobs: - name: The macOS installer verifies the macOS download run: scripts/check-macos-installer.sh ${{ github.repository }} . + # The release's publish job refuses to lift the draft until this says every + # artifact is up. Its self-test covers a missing file, an upload that never + # finished, an empty file and another version's files, and checks its list + # of architectures against release.yml's matrix. + - name: The release check refuses an incomplete release + run: scripts/check-release-assets.sh --self-test + # The GUI (Native SDK): macOS only, via the native CLI. gui: runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f1a4fd..2e684bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,13 +75,22 @@ jobs: # succeeded, and a re-run is what somebody reaches for when a publish # fails halfway. The tests above still gate it, so nothing arrives here # that has not passed them on the tag's own source. + # + # An existing release goes back to being a draft FIRST, so both branches + # leave it invisible until `publish` has checked every artifact. Uploading + # straight into a published release offered a partial set for as long as + # the other jobs took. While it is a draft the release page and the latest + # link fall back to the newest other release. `publish` lifts it without + # claiming Latest for it, so re-running an older tag cannot take Latest + # away from a newer one. - name: Publish the release env: GH_TOKEN: ${{ github.token }} run: | asset="Notary-${GITHUB_REF_NAME}-macos.zip" if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - echo "release exists; replacing its artifact with the one built here" + echo "release exists; hiding it and replacing its artifact with the one built here" + gh release edit "$GITHUB_REF_NAME" --draft=true gh release upload "$GITHUB_REF_NAME" "$asset" --clobber else # A DRAFT. The Linux tarballs are built by another job that takes @@ -161,16 +170,36 @@ jobs: # Lifts the draft, once and only once every artifact is up. Until this runs # the release is invisible, so there is no window in which the page offers a # Linux install that is not there yet. + # + # "Every artifact is up" is CHECKED, not inferred from the jobs above having + # exited 0: each expected file has to be listed by name, finished uploading, + # and not empty. The check is a script so it can be run by hand against any + # release, and ci.yml runs its self-test on every change. publish: needs: [macos-app, linux-tarball] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Every artifact is up + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" --json assets \ + --jq '.assets[] | "\(.name)\t\(.state)\t\(.size)"' \ + | scripts/check-release-assets.sh "$GITHUB_REF_NAME" + + # `make_latest=legacy`, not gh's `--draft=false`. Publishing a draft + # defaults to making it Latest, which is right for a new tag and wrong for + # an older one re-run through the branch above: it would move + # /releases/latest, where both installers look, back a version. `legacy` + # lets GitHub pick Latest by version and date, so a new release still + # becomes Latest and a re-published old one does not. gh cannot send + # `legacy`, hence the API call. - name: Publish env: GH_TOKEN: ${{ github.token }} run: | - gh release edit "$GITHUB_REF_NAME" --draft=false - echo "published $GITHUB_REF_NAME with:" - gh release view "$GITHUB_REF_NAME" --json assets --jq '.assets[].name' + id="$(gh release view "$GITHUB_REF_NAME" --json databaseId --jq .databaseId)" + gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$id" \ + -F draft=false -f make_latest=legacy --jq '"published \(.tag_name)"' diff --git a/scripts/check-release-assets.sh b/scripts/check-release-assets.sh new file mode 100755 index 0000000..f083ef0 --- /dev/null +++ b/scripts/check-release-assets.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Whether a release carries every artifact it is supposed to, before anybody +# can see it. +# +# gh release view --json assets \ +# --jq '.assets[] | "\(.name)\t\(.state)\t\(.size)"' \ +# | scripts/check-release-assets.sh +# scripts/check-release-assets.sh --self-test +# +# Reads one asset per line (name, state, size, tab separated) and exits 1, +# naming each one that is missing, when the release lacks any artifact a +# release of should carry. An asset counts only when GitHub says it +# finished uploading and it is not empty: an upload that dies halfway leaves an +# entry with the right name and nothing behind it. +# +# The release workflow runs this before it lifts the draft, which is the check +# its `publish` job used to claim and did not make. +set -euo pipefail + +# The Linux architectures release.yml builds. The self-test compares this with +# that workflow's matrix, so the two cannot drift apart unnoticed. +LINUX_ARCHES="x86_64 aarch64" + +# Every file a release of $1 should carry, one per line. +expected() { + local tag="$1" ver="${1#v}" arch + printf '%s\n' "Notary-$tag-macos.zip" + for arch in $LINUX_ARCHES; do + printf '%s\n' "notary-$ver-linux-$arch.tar.gz" "notary-$ver-linux-$arch.tar.gz.sha256" + done +} + +# Checks the asset lines on stdin against what $1 should carry. +check() { + local tag="$1" have name missing=0 + have="$(awk -F '\t' '$2 == "uploaded" && $3 > 0 { print $1 }')" + while IFS= read -r name; do + # A here-string, not a pipe: `grep -q` stops reading at the first match, + # and under pipefail the writer's SIGPIPE would turn a match into a miss. + if ! grep -qxF -- "$name" <<<"$have"; then + echo "missing: $name" >&2 + missing=1 + fi + done < <(expected "$tag") + if [ "$missing" != 0 ]; then + echo "$tag is not ready to publish. It has:" >&2 + printf '%s\n' "${have:- (nothing uploaded)}" >&2 + return 1 + fi + echo "$tag carries all $(expected "$tag" | wc -l | tr -d ' ') artifacts" +} + +# Asserts that the asset lines in $4 are refused, and refused for the right +# reason: exactly $3 files reported missing, $2 among them. Checking only that +# the check failed would pass on a fixture that broke the wrong line. +refuses() { + local what="$1" name="$2" count="$3" err + if err="$(check v9.9.9 <<<"$4" 2>&1 >/dev/null)"; then + echo "$what was accepted" >&2; exit 1 + fi + if ! grep -qxF -- "missing: $name" <<<"$err" || + [ "$(grep -c '^missing: ' <<<"$err")" != "$count" ]; then + echo "$what was refused, but not for the reason expected:" >&2 + printf '%s\n' "$err" >&2 + exit 1 + fi + echo "ok: $what is refused" +} + +self_test() { + local here full + here="$(cd "$(dirname "$0")/.." && pwd)" + full="$(expected v9.9.9 | awk '{ printf "%s\tuploaded\t1000\n", $0 }')" + + check v9.9.9 <<<"$full" >/dev/null || + { echo "a complete release was refused" >&2; exit 1; } + echo "ok: a complete release passes" + + local tab=$'\t' + refuses "a missing file" "notary-9.9.9-linux-aarch64.tar.gz.sha256" 1 \ + "$(grep -v 'aarch64.tar.gz.sha256' <<<"$full")" + # The pattern and replacement live in variables: quotes written inside + # ${var/pattern/replacement} are kept as literal characters, which renamed the + # file in this fixture and made it fail for the wrong reason. + local from to + from="Notary-v9.9.9-macos.zip${tab}uploaded" to="Notary-v9.9.9-macos.zip${tab}starter" + refuses "an unfinished upload" "Notary-v9.9.9-macos.zip" 1 "${full/$from/$to}" + from="x86_64.tar.gz${tab}uploaded${tab}1000" to="x86_64.tar.gz${tab}uploaded${tab}0" + refuses "an empty file" "notary-9.9.9-linux-x86_64.tar.gz" 1 "${full/$from/$to}" + refuses "a release built for another version" "Notary-v9.9.9-macos.zip" 5 \ + "${full//9.9.9/9.9.8}" + + local matrix + matrix="$(grep -o 'arch: [a-z0-9_]*' "$here/.github/workflows/release.yml" | cut -d' ' -f2 | sort | tr '\n' ' ')" + if [ "$matrix" != "$(tr ' ' '\n' <<<"$LINUX_ARCHES" | grep . | sort | tr '\n' ' ')" ]; then + echo "release.yml builds [$matrix] but this checks [$LINUX_ARCHES]" >&2; exit 1 + fi + echo "ok: the architectures match release.yml's matrix" +} + +case "${1:-}" in + --self-test) self_test ;; + "") echo "usage: $0 < assets, or $0 --self-test" >&2; exit 2 ;; + *) check "$1" ;; +esac