From 03c9e2d1a3d37e0a3809e0137d01aeeb19801c34 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:03:43 -0600 Subject: [PATCH 01/17] Upload assets to the published release --- .github/workflows/release.yml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1de0358..95b2829b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Publish release on: - push: - tags: - - 'v*' + release: + types: + - published permissions: contents: write @@ -11,27 +11,30 @@ permissions: jobs: release: runs-on: ubuntu-latest + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' cache: maven - name: Set release version - run: mvn -B versions:set -DnewVersion="${GITHUB_REF_NAME#v}" -DgenerateBackupPoms=false + run: mvn -B versions:set -DnewVersion="${RELEASE_TAG#v}" -DgenerateBackupPoms=false - name: Build and verify run: mvn -B clean verify - name: Stage immutable release assets run: | - cp "target/votingplugin-control-${GITHUB_REF_NAME#v}-all.jar" votingplugin-control.jar + cp "target/votingplugin-control-${RELEASE_TAG#v}-all.jar" votingplugin-control.jar sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 - - name: Create GitHub release + - name: Upload immutable release assets env: GH_TOKEN: ${{ github.token }} run: >- - gh release create "$GITHUB_REF_NAME" + gh release upload "$RELEASE_TAG" votingplugin-control.jar votingplugin-control.jar.sha256 --repo "$GITHUB_REPOSITORY" - --verify-tag - --generate-notes + --clobber From 446d4eb4743a6226d1a20c16f60c487ea836e27b Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:08:39 -0600 Subject: [PATCH 02/17] Publish only after release assets validate --- .github/workflows/release.yml | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95b2829b..ff8327f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Publish release on: - release: - types: - - published + push: + tags: + - 'v*' permissions: contents: write @@ -12,11 +12,11 @@ jobs: release: runs-on: ubuntu-latest env: - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ github.ref_name }} steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.ref_name }} - uses: actions/setup-java@v4 with: distribution: temurin @@ -30,11 +30,19 @@ jobs: run: | cp "target/votingplugin-control-${RELEASE_TAG#v}-all.jar" votingplugin-control.jar sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 - - name: Upload immutable release assets + - name: Upload and publish verified release env: GH_TOKEN: ${{ github.token }} - run: >- - gh release upload "$RELEASE_TAG" - votingplugin-control.jar votingplugin-control.jar.sha256 - --repo "$GITHUB_REPOSITORY" - --clobber + run: | + draft_id="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true) | .id" | head -n 1)" + if [[ -z "$draft_id" ]]; then + echo "::error::Create a draft GitHub release for $RELEASE_TAG before publishing the tag" + exit 1 + fi + gh release upload "$RELEASE_TAG" \ + votingplugin-control.jar votingplugin-control.jar.sha256 \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ + -F draft=false >/dev/null From 44570e589d48884ae8df5e67564b04fa18db3105 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:15:19 -0600 Subject: [PATCH 03/17] Create releases after manual validation --- .github/workflows/release.yml | 63 ++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff8327f8..2c9429f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,48 +1,65 @@ name: Publish release +run-name: Publish v${{ inputs.version }} on: - push: - tags: - - 'v*' + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix (for example, 0.1.1) + required: true + type: string permissions: contents: write +concurrency: + group: release-v${{ inputs.version }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ github.ref_name }} steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' cache: maven + - name: Validate release version + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then + echo "::error::Release version must be a valid version without the v prefix" + exit 1 + fi + release_tag="v$RELEASE_VERSION" + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ + || gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" >/dev/null 2>&1; then + echo "::error::Release or tag $release_tag already exists" + exit 1 + fi + { + echo "RELEASE_VERSION=$RELEASE_VERSION" + echo "RELEASE_TAG=$release_tag" + } >> "$GITHUB_ENV" - name: Set release version - run: mvn -B versions:set -DnewVersion="${RELEASE_TAG#v}" -DgenerateBackupPoms=false + run: mvn -B versions:set -DnewVersion="$RELEASE_VERSION" -DgenerateBackupPoms=false - name: Build and verify run: mvn -B clean verify - name: Stage immutable release assets run: | - cp "target/votingplugin-control-${RELEASE_TAG#v}-all.jar" votingplugin-control.jar + cp "target/votingplugin-control-$RELEASE_VERSION-all.jar" votingplugin-control.jar sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 - - name: Upload and publish verified release + - name: Create verified GitHub release env: GH_TOKEN: ${{ github.token }} - run: | - draft_id="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ - --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true) | .id" | head -n 1)" - if [[ -z "$draft_id" ]]; then - echo "::error::Create a draft GitHub release for $RELEASE_TAG before publishing the tag" - exit 1 - fi - gh release upload "$RELEASE_TAG" \ - votingplugin-control.jar votingplugin-control.jar.sha256 \ - --repo "$GITHUB_REPOSITORY" \ - --clobber - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ - -F draft=false >/dev/null + run: >- + gh release create "$RELEASE_TAG" + votingplugin-control.jar votingplugin-control.jar.sha256 + --repo "$GITHUB_REPOSITORY" + --target "$GITHUB_SHA" + --title "$RELEASE_TAG" + --generate-notes From a37f65dbc51106de28e27d395e098f240a9b2f6e Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:22:07 -0600 Subject: [PATCH 04/17] Bind releases to the verified commit --- .github/workflows/release.yml | 68 ++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c9429f0..79a181bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,13 +53,65 @@ jobs: run: | cp "target/votingplugin-control-$RELEASE_VERSION-all.jar" votingplugin-control.jar sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 - - name: Create verified GitHub release + - name: Publish verified GitHub release env: GH_TOKEN: ${{ github.token }} - run: >- - gh release create "$RELEASE_TAG" - votingplugin-control.jar votingplugin-control.jar.sha256 - --repo "$GITHUB_REPOSITORY" - --target "$GITHUB_SHA" - --title "$RELEASE_TAG" - --generate-notes + run: | + draft_id="" + tag_created=false + cleanup() { + exit_code=$? + trap - ERR + set +e + if [[ -n "$draft_id" ]]; then + release_is_draft="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" --jq '.draft' 2>/dev/null)" + if [[ "$release_is_draft" == "true" ]]; then + gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null + elif [[ "$release_is_draft" == "false" ]]; then + tag_created=false + fi + fi + if [[ "$tag_created" == "true" ]]; then + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '.object.sha' 2>/dev/null)" + if [[ "$current_sha" == "$GITHUB_SHA" ]]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/git/refs/tags/$RELEASE_TAG" >/dev/null + fi + fi + exit "$exit_code" + } + trap cleanup ERR + + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" \ + -f sha="$GITHUB_SHA" >/dev/null + tag_created=true + + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --draft \ + --title "$RELEASE_TAG" \ + --generate-notes + draft_id="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true) | .id" | head -n 1)" + if [[ -z "$draft_id" ]]; then + echo "::error::Created draft release could not be identified" + false + fi + + gh release upload "$RELEASE_TAG" \ + votingplugin-control.jar votingplugin-control.jar.sha256 \ + --repo "$GITHUB_REPOSITORY" + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha')" + if [[ "$current_sha" != "$GITHUB_SHA" ]]; then + echo "::error::Release tag changed after the verified build" + false + fi + + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ + -F draft=false >/dev/null + tag_created=false + draft_id="" + trap - ERR From 6bd25cd63b94a67eccfdd3393cb4a03d86cd40f9 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:28:57 -0600 Subject: [PATCH 05/17] Handle interrupted release cleanup safely --- .github/workflows/release.yml | 68 +++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79a181bf..7399ebf5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,19 +59,48 @@ jobs: run: | draft_id="" tag_created=false + release_marker="" + cleanup() { exit_code=$? - trap - ERR + trap - EXIT INT TERM set +e + + release_state=missing + if [[ -z "$draft_id" ]]; then + draft_lookup="$( + gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true and ((.body // \"\") | contains(\"$release_marker\"))) | .id" \ + 2>/dev/null + )" + lookup_status=$? + if [[ "$lookup_status" -ne 0 ]]; then + release_state=unknown + else + draft_id="$(head -n 1 <<< "$draft_lookup")" + fi + fi + if [[ -n "$draft_id" ]]; then - release_is_draft="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" --jq '.draft' 2>/dev/null)" - if [[ "$release_is_draft" == "true" ]]; then - gh api --method DELETE "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null - elif [[ "$release_is_draft" == "false" ]]; then - tag_created=false + if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" 2>/dev/null)"; then + release_is_draft="$(jq -r '.draft' <<< "$release_json")" + if [[ "$release_is_draft" == "true" ]]; then + if gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null; then + release_state=removed + else + release_state=unknown + fi + else + release_state=published + fi + else + release_state=unknown fi fi - if [[ "$tag_created" == "true" ]]; then + + if [[ "$tag_created" == "true" \ + && ( "$release_state" == "missing" || "$release_state" == "removed" ) ]]; then current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ --jq '.object.sha' 2>/dev/null)" if [[ "$current_sha" == "$GITHUB_SHA" ]]; then @@ -81,23 +110,26 @@ jobs: fi exit "$exit_code" } - trap cleanup ERR + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$RELEASE_TAG" \ -f sha="$GITHUB_SHA" >/dev/null tag_created=true - gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft \ - --title "$RELEASE_TAG" \ - --generate-notes - draft_id="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ - --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true) | .id" | head -n 1)" + draft_id="$( + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$RELEASE_TAG" \ + -f name="$RELEASE_TAG" \ + -f body="$release_marker" \ + -F draft=true \ + -F generate_release_notes=true \ + --jq '.id' + )" if [[ -z "$draft_id" ]]; then - echo "::error::Created draft release could not be identified" + echo "::error::Created draft release did not return an ID" false fi @@ -114,4 +146,4 @@ jobs: -F draft=false >/dev/null tag_created=false draft_id="" - trap - ERR + trap - EXIT INT TERM From f5e49df6678546472e249ec9d1875f3ecc5a03f8 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:30:42 -0600 Subject: [PATCH 06/17] Preserve tags referenced by concurrent releases --- .github/workflows/release.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7399ebf5..53fe840c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,11 +101,19 @@ jobs: if [[ "$tag_created" == "true" \ && ( "$release_state" == "missing" || "$release_state" == "removed" ) ]]; then - current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '.object.sha' 2>/dev/null)" - if [[ "$current_sha" == "$GITHUB_SHA" ]]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/git/refs/tags/$RELEASE_TAG" >/dev/null + if releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>/dev/null)"; then + releases_for_tag="$( + jq -r --arg tag "$RELEASE_TAG" \ + '[.[] | select(.tag_name == $tag)] | length' <<< "$releases_json" + )" + if [[ "$releases_for_tag" == "0" ]]; then + current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ + --jq '.object.sha' 2>/dev/null)" + if [[ "$current_sha" == "$GITHUB_SHA" ]]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/git/refs/tags/$RELEASE_TAG" >/dev/null + fi + fi fi fi exit "$exit_code" From 0acdd53c32d62d5452bd7f7bafdda3efa94d27bb Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:37:38 -0600 Subject: [PATCH 07/17] Make interrupted release tags safely reusable --- .github/workflows/release.yml | 111 +++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53fe840c..9a992c4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,16 +35,39 @@ jobs: echo "::error::Release version must be a valid version without the v prefix" exit 1 fi + release_tag="v$RELEASE_VERSION" - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \ - || gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" >/dev/null 2>&1; then - echo "::error::Release or tag $release_tag already exists" + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "::error::Release $release_tag already exists" exit 1 fi + + release_sha="$GITHUB_SHA" + if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" 2>/dev/null)"; then + tag_type="$(jq -r '.object.type' <<< "$tag_json")" + if [[ "$tag_type" != "commit" ]]; then + echo "::error::Existing tag $release_tag is not a lightweight commit tag" + exit 1 + fi + release_sha="$(jq -r '.object.sha' <<< "$tag_json")" + echo "::notice::Reusing existing tag $release_tag at $release_sha" + fi + { echo "RELEASE_VERSION=$RELEASE_VERSION" echo "RELEASE_TAG=$release_tag" + echo "RELEASE_SHA=$release_sha" } >> "$GITHUB_ENV" + - name: Check out release source + run: | + if [[ "$(git rev-parse HEAD)" != "$RELEASE_SHA" ]]; then + git fetch --no-tags origin "$RELEASE_SHA" + git checkout --detach "$RELEASE_SHA" + fi + if [[ "$(git rev-parse HEAD)" != "$RELEASE_SHA" ]]; then + echo "::error::Unable to check out release commit $RELEASE_SHA" + exit 1 + fi - name: Set release version run: mvn -B versions:set -DnewVersion="$RELEASE_VERSION" -DgenerateBackupPoms=false - name: Build and verify @@ -58,7 +81,6 @@ jobs: GH_TOKEN: ${{ github.token }} run: | draft_id="" - tag_created=false release_marker="" cleanup() { @@ -66,18 +88,16 @@ jobs: trap - EXIT INT TERM set +e - release_state=missing if [[ -z "$draft_id" ]]; then - draft_lookup="$( - gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ - --jq ".[] | select(.tag_name == \"$RELEASE_TAG\" and .draft == true and ((.body // \"\") | contains(\"$release_marker\"))) | .id" \ - 2>/dev/null - )" - lookup_status=$? - if [[ "$lookup_status" -ne 0 ]]; then - release_state=unknown - else - draft_id="$(head -n 1 <<< "$draft_lookup")" + if releases_json="$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>/dev/null + )"; then + draft_id="$( + jq -r --arg tag "$RELEASE_TAG" --arg marker "$release_marker" \ + '[.[][] | select(.tag_name == $tag and .draft == true and ((.body // "") | contains($marker)))] | first | .id // empty' \ + <<< "$releases_json" + )" fi fi @@ -85,34 +105,8 @@ jobs: if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" 2>/dev/null)"; then release_is_draft="$(jq -r '.draft' <<< "$release_json")" if [[ "$release_is_draft" == "true" ]]; then - if gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null; then - release_state=removed - else - release_state=unknown - fi - else - release_state=published - fi - else - release_state=unknown - fi - fi - - if [[ "$tag_created" == "true" \ - && ( "$release_state" == "missing" || "$release_state" == "removed" ) ]]; then - if releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>/dev/null)"; then - releases_for_tag="$( - jq -r --arg tag "$RELEASE_TAG" \ - '[.[] | select(.tag_name == $tag)] | length' <<< "$releases_json" - )" - if [[ "$releases_for_tag" == "0" ]]; then - current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" \ - --jq '.object.sha' 2>/dev/null)" - if [[ "$current_sha" == "$GITHUB_SHA" ]]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/git/refs/tags/$RELEASE_TAG" >/dev/null - fi + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null fi fi fi @@ -122,10 +116,26 @@ jobs: trap 'exit 130' INT trap 'exit 143' TERM - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$RELEASE_TAG" \ - -f sha="$GITHUB_SHA" >/dev/null - tag_created=true + if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null)"; then + current_type="$(jq -r '.object.type' <<< "$tag_json")" + current_sha="$(jq -r '.object.sha' <<< "$tag_json")" + if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Release tag no longer points at the verified commit" + false + fi + else + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" \ + -f sha="$RELEASE_SHA" >/dev/null; then + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + current_type="$(jq -r '.object.type' <<< "$tag_json")" + current_sha="$(jq -r '.object.sha' <<< "$tag_json")" + if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then + echo "::error::Release tag was concurrently created at another commit" + false + fi + fi + fi draft_id="$( gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ @@ -144,14 +154,15 @@ jobs: gh release upload "$RELEASE_TAG" \ votingplugin-control.jar votingplugin-control.jar.sha256 \ --repo "$GITHUB_REPOSITORY" - current_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha')" - if [[ "$current_sha" != "$GITHUB_SHA" ]]; then + tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + current_type="$(jq -r '.object.type' <<< "$tag_json")" + current_sha="$(jq -r '.object.sha' <<< "$tag_json")" + if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then echo "::error::Release tag changed after the verified build" false fi gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ -F draft=false >/dev/null - tag_created=false draft_id="" trap - EXIT INT TERM From 68704c8b84d0254f8b5d6cab81fa5a265346279f Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:43:10 -0600 Subject: [PATCH 08/17] Resume interrupted workflow-owned drafts --- .github/workflows/release.yml | 61 ++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a992c4f..7f8f08ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,26 @@ jobs: fi release_tag="v$RELEASE_VERSION" - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::error::Release $release_tag already exists" - exit 1 + releases_json="$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" + )" + release_count="$( + jq -r --arg tag "$release_tag" \ + '[.[][] | select(.tag_name == $tag)] | length' <<< "$releases_json" + )" + release_draft_id="" + if [[ "$release_count" != "0" ]]; then + release_draft_id="$( + jq -r --arg tag "$release_tag" \ + '[.[][] | select(.tag_name == $tag and .draft == true and ((.body // "") | test("")))] | first | .id // empty' \ + <<< "$releases_json" + )" + if [[ "$release_count" != "1" || -z "$release_draft_id" ]]; then + echo "::error::Release $release_tag already exists and is not a recoverable workflow draft" + exit 1 + fi + echo "::notice::Resuming workflow-owned draft release $release_draft_id" fi release_sha="$GITHUB_SHA" @@ -57,6 +74,7 @@ jobs: echo "RELEASE_VERSION=$RELEASE_VERSION" echo "RELEASE_TAG=$release_tag" echo "RELEASE_SHA=$release_sha" + echo "RELEASE_DRAFT_ID=$release_draft_id" } >> "$GITHUB_ENV" - name: Check out release source run: | @@ -80,7 +98,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - draft_id="" + draft_id="$RELEASE_DRAFT_ID" release_marker="" cleanup() { @@ -137,22 +155,33 @@ jobs: fi fi - draft_id="$( - gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ - -f tag_name="$RELEASE_TAG" \ - -f name="$RELEASE_TAG" \ - -f body="$release_marker" \ - -F draft=true \ - -F generate_release_notes=true \ - --jq '.id' - )" - if [[ -z "$draft_id" ]]; then - echo "::error::Created draft release did not return an ID" - false + if [[ -n "$draft_id" ]]; then + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" + release_is_draft="$(jq -r '.draft' <<< "$release_json")" + release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" + if [[ "$release_is_draft" != "true" || "$release_tag_name" != "$RELEASE_TAG" ]]; then + echo "::error::Recoverable draft changed while the release was building" + false + fi + else + draft_id="$( + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$RELEASE_TAG" \ + -f name="$RELEASE_TAG" \ + -f body="$release_marker" \ + -F draft=true \ + -F generate_release_notes=true \ + --jq '.id' + )" + if [[ -z "$draft_id" ]]; then + echo "::error::Created draft release did not return an ID" + false + fi fi gh release upload "$RELEASE_TAG" \ votingplugin-control.jar votingplugin-control.jar.sha256 \ + --clobber \ --repo "$GITHUB_REPOSITORY" tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" current_type="$(jq -r '.object.type' <<< "$tag_json")" From 787d627bb2e16269df7b85ce36ba0f8f52019c41 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:48:27 -0600 Subject: [PATCH 09/17] Revalidate and clean recovered release drafts --- .github/workflows/release.yml | 56 ++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f8f08ee..9bdff105 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,22 @@ jobs: draft_id="$RELEASE_DRAFT_ID" release_marker="" + verify_owned_draft() { + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" + release_is_draft="$(jq -r '.draft' <<< "$release_json")" + release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" + release_is_owned="$( + jq -r '((.body // "") | test(""))' \ + <<< "$release_json" + )" + if [[ "$release_is_draft" != "true" \ + || "$release_tag_name" != "$RELEASE_TAG" \ + || "$release_is_owned" != "true" ]]; then + echo "::error::Workflow-owned draft changed while the release was building" + return 1 + fi + } + cleanup() { exit_code=$? trap - EXIT INT TERM @@ -122,7 +138,14 @@ jobs: if [[ -n "$draft_id" ]]; then if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" 2>/dev/null)"; then release_is_draft="$(jq -r '.draft' <<< "$release_json")" - if [[ "$release_is_draft" == "true" ]]; then + release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" + release_is_owned="$( + jq -r '((.body // "") | test(""))' \ + <<< "$release_json" + )" + if [[ "$release_is_draft" == "true" \ + && "$release_tag_name" == "$RELEASE_TAG" \ + && "$release_is_owned" == "true" ]]; then gh api --method DELETE \ "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null fi @@ -155,15 +178,7 @@ jobs: fi fi - if [[ -n "$draft_id" ]]; then - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" - release_is_draft="$(jq -r '.draft' <<< "$release_json")" - release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" - if [[ "$release_is_draft" != "true" || "$release_tag_name" != "$RELEASE_TAG" ]]; then - echo "::error::Recoverable draft changed while the release was building" - false - fi - else + if [[ -z "$draft_id" ]]; then draft_id="$( gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ -f tag_name="$RELEASE_TAG" \ @@ -179,6 +194,7 @@ jobs: fi fi + verify_owned_draft gh release upload "$RELEASE_TAG" \ votingplugin-control.jar votingplugin-control.jar.sha256 \ --clobber \ @@ -191,7 +207,27 @@ jobs: false fi + verify_owned_draft gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ -F draft=false >/dev/null draft_id="" trap - EXIT INT TERM + - name: Clean up a recovered draft after an earlier failure + if: ${{ always() && env.RELEASE_DRAFT_ID != '' && (failure() || cancelled()) }} + env: + GH_TOKEN: ${{ github.token }} + run: | + if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_DRAFT_ID" 2>/dev/null)"; then + release_is_draft="$(jq -r '.draft' <<< "$release_json")" + release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" + release_is_owned="$( + jq -r '((.body // "") | test(""))' \ + <<< "$release_json" + )" + if [[ "$release_is_draft" == "true" \ + && "$release_tag_name" == "$RELEASE_TAG" \ + && "$release_is_owned" == "true" ]]; then + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_DRAFT_ID" >/dev/null + fi + fi From df008ed8c1875793ee674486aa6159be8fa4cd87 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 21:53:11 -0600 Subject: [PATCH 10/17] Keep failed release drafts safely resumable --- .github/workflows/release.yml | 60 ----------------------------------- 1 file changed, 60 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bdff105..c08c4ed6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,45 +117,6 @@ jobs: fi } - cleanup() { - exit_code=$? - trap - EXIT INT TERM - set +e - - if [[ -z "$draft_id" ]]; then - if releases_json="$( - gh api --paginate --slurp \ - "repos/$GITHUB_REPOSITORY/releases?per_page=100" 2>/dev/null - )"; then - draft_id="$( - jq -r --arg tag "$RELEASE_TAG" --arg marker "$release_marker" \ - '[.[][] | select(.tag_name == $tag and .draft == true and ((.body // "") | contains($marker)))] | first | .id // empty' \ - <<< "$releases_json" - )" - fi - fi - - if [[ -n "$draft_id" ]]; then - if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id" 2>/dev/null)"; then - release_is_draft="$(jq -r '.draft' <<< "$release_json")" - release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" - release_is_owned="$( - jq -r '((.body // "") | test(""))' \ - <<< "$release_json" - )" - if [[ "$release_is_draft" == "true" \ - && "$release_tag_name" == "$RELEASE_TAG" \ - && "$release_is_owned" == "true" ]]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/releases/$draft_id" >/dev/null - fi - fi - fi - exit "$exit_code" - } - trap cleanup EXIT - trap 'exit 130' INT - trap 'exit 143' TERM if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null)"; then current_type="$(jq -r '.object.type' <<< "$tag_json")" @@ -210,24 +171,3 @@ jobs: verify_owned_draft gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ -F draft=false >/dev/null - draft_id="" - trap - EXIT INT TERM - - name: Clean up a recovered draft after an earlier failure - if: ${{ always() && env.RELEASE_DRAFT_ID != '' && (failure() || cancelled()) }} - env: - GH_TOKEN: ${{ github.token }} - run: | - if release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_DRAFT_ID" 2>/dev/null)"; then - release_is_draft="$(jq -r '.draft' <<< "$release_json")" - release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" - release_is_owned="$( - jq -r '((.body // "") | test(""))' \ - <<< "$release_json" - )" - if [[ "$release_is_draft" == "true" \ - && "$release_tag_name" == "$RELEASE_TAG" \ - && "$release_is_owned" == "true" ]]; then - gh api --method DELETE \ - "repos/$GITHUB_REPOSITORY/releases/$RELEASE_DRAFT_ID" >/dev/null - fi - fi From 963a9a75da54a24e66dad267000580b6663d8b65 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:04:25 -0600 Subject: [PATCH 11/17] Require protected immutable release publication --- .github/workflows/release.yml | 138 ++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c08c4ed6..7076c855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,36 @@ jobs: echo "RELEASE_SHA=$release_sha" echo "RELEASE_DRAFT_ID=$release_draft_id" } >> "$GITHUB_ENV" + - name: Require immutable release tag rules + env: + GH_TOKEN: ${{ github.token }} + run: | + release_ruleset_id="" + while IFS= read -r ruleset_id; do + ruleset_json="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$ruleset_id")" + if jq -e ' + .target == "tag" + and .enforcement == "active" + and ((.bypass_actors // []) | length == 0) + and (.conditions.ref_name.include == ["refs/tags/v*"]) + and ((.conditions.ref_name.exclude // []) | length == 0) + and (([.rules[].type] | index("update")) != null) + and (([.rules[].type] | index("deletion")) != null) + ' <<< "$ruleset_json" >/dev/null; then + release_ruleset_id="$ruleset_id" + break + fi + done < <( + gh api --paginate \ + "repos/$GITHUB_REPOSITORY/rulesets?targets=tag&per_page=100" \ + --jq '.[] | select(.enforcement == "active") | .id' + ) + + if [[ -z "$release_ruleset_id" ]]; then + echo "::error::Create an active tag ruleset for refs/tags/v* with no bypass actors and Restrict updates/deletions enabled" + exit 1 + fi + echo "RELEASE_RULESET_ID=$release_ruleset_id" >> "$GITHUB_ENV" - name: Check out release source run: | if [[ "$(git rev-parse HEAD)" != "$RELEASE_SHA" ]]; then @@ -94,6 +124,10 @@ jobs: run: | cp "target/votingplugin-control-$RELEASE_VERSION-all.jar" votingplugin-control.jar sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 + { + echo "JAR_ASSET_DIGEST=sha256:$(sha256sum votingplugin-control.jar | cut -d ' ' -f 1)" + echo "CHECKSUM_ASSET_DIGEST=sha256:$(sha256sum votingplugin-control.jar.sha256 | cut -d ' ' -f 1)" + } >> "$GITHUB_ENV" - name: Publish verified GitHub release env: GH_TOKEN: ${{ github.token }} @@ -101,6 +135,22 @@ jobs: draft_id="$RELEASE_DRAFT_ID" release_marker="" + verify_release_ruleset() { + ruleset_json="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$RELEASE_RULESET_ID")" + if ! jq -e ' + .target == "tag" + and .enforcement == "active" + and ((.bypass_actors // []) | length == 0) + and (.conditions.ref_name.include == ["refs/tags/v*"]) + and ((.conditions.ref_name.exclude // []) | length == 0) + and (([.rules[].type] | index("update")) != null) + and (([.rules[].type] | index("deletion")) != null) + ' <<< "$ruleset_json" >/dev/null; then + echo "::error::Required release tag ruleset changed while the release was building" + return 1 + fi + } + verify_owned_draft() { release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" release_is_draft="$(jq -r '.draft' <<< "$release_json")" @@ -115,6 +165,59 @@ jobs: echo "::error::Workflow-owned draft changed while the release was building" return 1 fi + release_body="$(jq -r '.body // ""' <<< "$release_json")" + } + + ensure_asset() { + asset_path="$1" + expected_digest="$2" + asset_name="$(basename "$asset_path")" + + verify_owned_draft + asset_count="$( + jq -r --arg name "$asset_name" \ + '[.assets[] | select(.name == $name)] | length' <<< "$release_json" + )" + if [[ "$asset_count" == "0" ]]; then + gh release upload "$RELEASE_TAG" "$asset_path" \ + --repo "$GITHUB_REPOSITORY" + return + fi + if [[ "$asset_count" != "1" ]]; then + echo "::error::Draft contains duplicate assets named $asset_name" + return 1 + fi + + asset_state="$( + jq -r --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .state' <<< "$release_json" + )" + asset_digest="$( + jq -r --arg name "$asset_name" \ + '.assets[] | select(.name == $name) | .digest // ""' <<< "$release_json" + )" + if [[ "$asset_state" != "uploaded" || "$asset_digest" != "$expected_digest" ]]; then + echo "::error::Existing $asset_name is not the verified asset from this build" + return 1 + fi + echo "::notice::Reusing verified existing asset $asset_name" + } + + verify_release_assets() { + verify_owned_draft + jar_digest="$( + jq -r '.assets[] | select(.name == "votingplugin-control.jar") | .digest // ""' \ + <<< "$release_json" + )" + checksum_digest="$( + jq -r '.assets[] | select(.name == "votingplugin-control.jar.sha256") | .digest // ""' \ + <<< "$release_json" + )" + if [[ "$jar_digest" != "$JAR_ASSET_DIGEST" \ + || "$checksum_digest" != "$CHECKSUM_ASSET_DIGEST" ]]; then + echo "::error::Draft asset digests changed before publication" + return 1 + fi } @@ -155,11 +258,10 @@ jobs: fi fi - verify_owned_draft - gh release upload "$RELEASE_TAG" \ - votingplugin-control.jar votingplugin-control.jar.sha256 \ - --clobber \ - --repo "$GITHUB_REPOSITORY" + ensure_asset votingplugin-control.jar "$JAR_ASSET_DIGEST" + ensure_asset votingplugin-control.jar.sha256 "$CHECKSUM_ASSET_DIGEST" + verify_release_assets + verify_release_ruleset tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" @@ -168,6 +270,26 @@ jobs: false fi - verify_owned_draft - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ - -F draft=false >/dev/null + verify_release_assets + verified_body="$( + sed '//,//d' \ + <<< "$release_body" + )" + verified_body="$verified_body + + +votingplugin-control.jar: $JAR_ASSET_DIGEST +votingplugin-control.jar.sha256: $CHECKSUM_ASSET_DIGEST +" + published_json="$( + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ + -f tag_name="$RELEASE_TAG" \ + -f target_commitish="$RELEASE_SHA" \ + -f name="$RELEASE_TAG" \ + -f body="$verified_body" \ + -F draft=false + )" + if [[ "$(jq -r '.immutable' <<< "$published_json")" != "true" ]]; then + echo "::error::Published release is not immutable; enable release immutability in repository Settings" + exit 1 + fi From f00da146244c67cbb4f13ad15e8967d6529d74d5 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:10:49 -0600 Subject: [PATCH 12/17] Publish only from an unchanged verified draft --- .github/workflows/release.yml | 80 +++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7076c855..7e2de0be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,24 +270,76 @@ jobs: false fi - verify_release_assets - verified_body="$( + release_headers="release-response.headers" + release_document="release-response.json" + release_url="https://api.github.com/repos/$GITHUB_REPOSITORY/releases/$draft_id" + curl --fail-with-body --silent --show-error \ + --dump-header "$release_headers" \ + --output "$release_document" \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + "$release_url" + + release_etag="$( + awk 'tolower($1) == "etag:" { gsub(/\r/, "", $2); print $2 }' \ + "$release_headers" | tail -n 1 + )" + if [[ -z "$release_etag" ]]; then + echo "::error::GitHub did not return an ETag for conditional publication" + exit 1 + fi + + release_json="$(< "$release_document")" + release_is_draft="$(jq -r '.draft' <<< "$release_json")" + release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" + release_is_owned="$( + jq -r '((.body // "") | test(""))' \ + <<< "$release_json" + )" + jar_digest="$( + jq -r '.assets[] | select(.name == "votingplugin-control.jar") | .digest // ""' \ + <<< "$release_json" + )" + checksum_digest="$( + jq -r '.assets[] | select(.name == "votingplugin-control.jar.sha256") | .digest // ""' \ + <<< "$release_json" + )" + if [[ "$release_is_draft" != "true" \ + || "$release_tag_name" != "$RELEASE_TAG" \ + || "$release_is_owned" != "true" \ + || "$jar_digest" != "$JAR_ASSET_DIGEST" \ + || "$checksum_digest" != "$CHECKSUM_ASSET_DIGEST" ]]; then + echo "::error::Draft ownership or assets changed before conditional publication" + exit 1 + fi + + release_body="$(jq -r '.body // ""' <<< "$release_json")" + clean_body="$( sed '//,//d' \ <<< "$release_body" )" - verified_body="$verified_body - - -votingplugin-control.jar: $JAR_ASSET_DIGEST -votingplugin-control.jar.sha256: $CHECKSUM_ASSET_DIGEST -" + printf -v verified_body \ + '%s\n\n\nvotingplugin-control.jar: %s\nvotingplugin-control.jar.sha256: %s\n' \ + "$clean_body" "$JAR_ASSET_DIGEST" "$CHECKSUM_ASSET_DIGEST" + publish_payload="$( + jq -n \ + --arg tag "$RELEASE_TAG" \ + --arg target "$RELEASE_SHA" \ + --arg name "$RELEASE_TAG" \ + --arg body "$verified_body" \ + '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, draft: false}' + )" published_json="$( - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ - -f tag_name="$RELEASE_TAG" \ - -f target_commitish="$RELEASE_SHA" \ - -f name="$RELEASE_TAG" \ - -f body="$verified_body" \ - -F draft=false + curl --fail-with-body --silent --show-error \ + --request PATCH \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Content-Type: application/json" \ + --header "If-Match: $release_etag" \ + --header "X-GitHub-Api-Version: 2026-03-10" \ + --data "$publish_payload" \ + "$release_url" )" if [[ "$(jq -r '.immutable' <<< "$published_json")" != "true" ]]; then echo "::error::Published release is not immutable; enable release immutability in repository Settings" From 206b7f930f2a8a94698052e6949ea4c172989194 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:19:35 -0600 Subject: [PATCH 13/17] Use supported immutable release publication flow --- .github/workflows/release.yml | 86 ++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e2de0be..d4d3259d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,47 +270,35 @@ jobs: false fi - release_headers="release-response.headers" - release_document="release-response.json" - release_url="https://api.github.com/repos/$GITHUB_REPOSITORY/releases/$draft_id" - curl --fail-with-body --silent --show-error \ - --dump-header "$release_headers" \ - --output "$release_document" \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer $GH_TOKEN" \ - --header "X-GitHub-Api-Version: 2026-03-10" \ - "$release_url" - - release_etag="$( - awk 'tolower($1) == "etag:" { gsub(/\r/, "", $2); print $2 }' \ - "$release_headers" | tail -n 1 - )" - if [[ -z "$release_etag" ]]; then - echo "::error::GitHub did not return an ETag for conditional publication" - exit 1 - fi - - release_json="$(< "$release_document")" + release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" release_is_draft="$(jq -r '.draft' <<< "$release_json")" release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" release_is_owned="$( jq -r '((.body // "") | test(""))' \ <<< "$release_json" )" - jar_digest="$( - jq -r '.assets[] | select(.name == "votingplugin-control.jar") | .digest // ""' \ - <<< "$release_json" - )" - checksum_digest="$( - jq -r '.assets[] | select(.name == "votingplugin-control.jar.sha256") | .digest // ""' \ + release_assets_valid="$( + jq -r \ + --arg jar "$JAR_ASSET_DIGEST" \ + --arg checksum "$CHECKSUM_ASSET_DIGEST" \ + '(.assets | length == 2) + and ([.assets[] | select( + .name == "votingplugin-control.jar" + and .state == "uploaded" + and .digest == $jar + )] | length == 1) + and ([.assets[] | select( + .name == "votingplugin-control.jar.sha256" + and .state == "uploaded" + and .digest == $checksum + )] | length == 1)' \ <<< "$release_json" )" if [[ "$release_is_draft" != "true" \ || "$release_tag_name" != "$RELEASE_TAG" \ || "$release_is_owned" != "true" \ - || "$jar_digest" != "$JAR_ASSET_DIGEST" \ - || "$checksum_digest" != "$CHECKSUM_ASSET_DIGEST" ]]; then - echo "::error::Draft ownership or assets changed before conditional publication" + || "$release_assets_valid" != "true" ]]; then + echo "::error::Draft ownership or exact asset set changed before publication" exit 1 fi @@ -331,17 +319,33 @@ jobs: '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, draft: false}' )" published_json="$( - curl --fail-with-body --silent --show-error \ - --request PATCH \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer $GH_TOKEN" \ - --header "Content-Type: application/json" \ - --header "If-Match: $release_etag" \ - --header "X-GitHub-Api-Version: 2026-03-10" \ - --data "$publish_payload" \ - "$release_url" + gh api --method PATCH \ + "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ + --input - <<< "$publish_payload" + )" + published_assets_valid="$( + jq -r \ + --arg jar "$JAR_ASSET_DIGEST" \ + --arg checksum "$CHECKSUM_ASSET_DIGEST" \ + '(.assets | length == 2) + and ([.assets[] | select( + .name == "votingplugin-control.jar" + and .state == "uploaded" + and .digest == $jar + )] | length == 1) + and ([.assets[] | select( + .name == "votingplugin-control.jar.sha256" + and .state == "uploaded" + and .digest == $checksum + )] | length == 1)' \ + <<< "$published_json" )" - if [[ "$(jq -r '.immutable' <<< "$published_json")" != "true" ]]; then - echo "::error::Published release is not immutable; enable release immutability in repository Settings" + if [[ "$(jq -r '.immutable' <<< "$published_json")" != "true" \ + || "$(jq -r '.draft' <<< "$published_json")" != "false" \ + || "$(jq -r '.tag_name' <<< "$published_json")" != "$RELEASE_TAG" \ + || "$(jq -r '.name' <<< "$published_json")" != "$RELEASE_TAG" \ + || "$(jq -r '.body // ""' <<< "$published_json")" != "$verified_body" \ + || "$published_assets_valid" != "true" ]]; then + echo "::error::Published release is not the expected immutable release; verify repository settings and do not edit workflow-owned drafts during a run" exit 1 fi From 64b9f573a137f3454f2bd7ca2f71f1d49bd4f8b8 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:24:13 -0600 Subject: [PATCH 14/17] Verify release immutability before publication --- .github/workflows/release.yml | 41 ++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4d3259d..c36e0cfc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,10 +76,27 @@ jobs: echo "RELEASE_SHA=$release_sha" echo "RELEASE_DRAFT_ID=$release_draft_id" } >> "$GITHUB_ENV" - - name: Require immutable release tag rules + - name: Require release safeguards env: GH_TOKEN: ${{ github.token }} + RELEASE_SETTINGS_TOKEN: ${{ secrets.RELEASE_SETTINGS_TOKEN }} run: | + if [[ -z "$RELEASE_SETTINGS_TOKEN" ]]; then + echo "::error::Add the RELEASE_SETTINGS_TOKEN secret with Administration read permission" + exit 1 + fi + if ! immutable_json="$( + GH_TOKEN="$RELEASE_SETTINGS_TOKEN" \ + gh api "repos/$GITHUB_REPOSITORY/immutable-releases" + )"; then + echo "::error::Release immutability is disabled or RELEASE_SETTINGS_TOKEN cannot verify it" + exit 1 + fi + if ! jq -e '.enabled == true' <<< "$immutable_json" >/dev/null; then + echo "::error::Enable release immutability in repository Settings" + exit 1 + fi + release_ruleset_id="" while IFS= read -r ruleset_id; do ruleset_json="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$ruleset_id")" @@ -131,6 +148,7 @@ jobs: - name: Publish verified GitHub release env: GH_TOKEN: ${{ github.token }} + RELEASE_SETTINGS_TOKEN: ${{ secrets.RELEASE_SETTINGS_TOKEN }} run: | draft_id="$RELEASE_DRAFT_ID" release_marker="" @@ -151,6 +169,24 @@ jobs: fi } + verify_release_immutability() { + if [[ -z "$RELEASE_SETTINGS_TOKEN" ]]; then + echo "::error::RELEASE_SETTINGS_TOKEN is unavailable" + return 1 + fi + if ! immutable_json="$( + GH_TOKEN="$RELEASE_SETTINGS_TOKEN" \ + gh api "repos/$GITHUB_REPOSITORY/immutable-releases" + )"; then + echo "::error::Release immutability was disabled or can no longer be verified" + return 1 + fi + if ! jq -e '.enabled == true' <<< "$immutable_json" >/dev/null; then + echo "::error::Release immutability was disabled while the release was building" + return 1 + fi + } + verify_owned_draft() { release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" release_is_draft="$(jq -r '.draft' <<< "$release_json")" @@ -221,6 +257,8 @@ jobs: } + verify_release_immutability + if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null)"; then current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" @@ -262,6 +300,7 @@ jobs: ensure_asset votingplugin-control.jar.sha256 "$CHECKSUM_ASSET_DIGEST" verify_release_assets verify_release_ruleset + verify_release_immutability tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" From e5a0780737e775ce133f2f87edd920b83c6c1296 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:25:35 -0600 Subject: [PATCH 15/17] Pin GitHub release API version --- .github/workflows/release.yml | 42 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c36e0cfc..a99a780f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,10 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} run: | + gh_api() { + gh api --header "X-GitHub-Api-Version: 2026-03-10" "$@" + } + if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then echo "::error::Release version must be a valid version without the v prefix" exit 1 @@ -38,7 +42,7 @@ jobs: release_tag="v$RELEASE_VERSION" releases_json="$( - gh api --paginate --slurp \ + gh_api --paginate --slurp \ "repos/$GITHUB_REPOSITORY/releases?per_page=100" )" release_count="$( @@ -60,7 +64,7 @@ jobs: fi release_sha="$GITHUB_SHA" - if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" 2>/dev/null)"; then + if tag_json="$(gh_api "repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag" 2>/dev/null)"; then tag_type="$(jq -r '.object.type' <<< "$tag_json")" if [[ "$tag_type" != "commit" ]]; then echo "::error::Existing tag $release_tag is not a lightweight commit tag" @@ -81,13 +85,17 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_SETTINGS_TOKEN: ${{ secrets.RELEASE_SETTINGS_TOKEN }} run: | + gh_api() { + gh api --header "X-GitHub-Api-Version: 2026-03-10" "$@" + } + if [[ -z "$RELEASE_SETTINGS_TOKEN" ]]; then echo "::error::Add the RELEASE_SETTINGS_TOKEN secret with Administration read permission" exit 1 fi if ! immutable_json="$( GH_TOKEN="$RELEASE_SETTINGS_TOKEN" \ - gh api "repos/$GITHUB_REPOSITORY/immutable-releases" + gh_api "repos/$GITHUB_REPOSITORY/immutable-releases" )"; then echo "::error::Release immutability is disabled or RELEASE_SETTINGS_TOKEN cannot verify it" exit 1 @@ -99,7 +107,7 @@ jobs: release_ruleset_id="" while IFS= read -r ruleset_id; do - ruleset_json="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$ruleset_id")" + ruleset_json="$(gh_api "repos/$GITHUB_REPOSITORY/rulesets/$ruleset_id")" if jq -e ' .target == "tag" and .enforcement == "active" @@ -113,7 +121,7 @@ jobs: break fi done < <( - gh api --paginate \ + gh_api --paginate \ "repos/$GITHUB_REPOSITORY/rulesets?targets=tag&per_page=100" \ --jq '.[] | select(.enforcement == "active") | .id' ) @@ -150,11 +158,15 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_SETTINGS_TOKEN: ${{ secrets.RELEASE_SETTINGS_TOKEN }} run: | + gh_api() { + gh api --header "X-GitHub-Api-Version: 2026-03-10" "$@" + } + draft_id="$RELEASE_DRAFT_ID" release_marker="" verify_release_ruleset() { - ruleset_json="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/$RELEASE_RULESET_ID")" + ruleset_json="$(gh_api "repos/$GITHUB_REPOSITORY/rulesets/$RELEASE_RULESET_ID")" if ! jq -e ' .target == "tag" and .enforcement == "active" @@ -176,7 +188,7 @@ jobs: fi if ! immutable_json="$( GH_TOKEN="$RELEASE_SETTINGS_TOKEN" \ - gh api "repos/$GITHUB_REPOSITORY/immutable-releases" + gh_api "repos/$GITHUB_REPOSITORY/immutable-releases" )"; then echo "::error::Release immutability was disabled or can no longer be verified" return 1 @@ -188,7 +200,7 @@ jobs: } verify_owned_draft() { - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" + release_json="$(gh_api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" release_is_draft="$(jq -r '.draft' <<< "$release_json")" release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" release_is_owned="$( @@ -259,7 +271,7 @@ jobs: verify_release_immutability - if tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null)"; then + if tag_json="$(gh_api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" 2>/dev/null)"; then current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then @@ -267,10 +279,10 @@ jobs: false fi else - if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh_api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$RELEASE_TAG" \ -f sha="$RELEASE_SHA" >/dev/null; then - tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + tag_json="$(gh_api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then @@ -282,7 +294,7 @@ jobs: if [[ -z "$draft_id" ]]; then draft_id="$( - gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + gh_api --method POST "repos/$GITHUB_REPOSITORY/releases" \ -f tag_name="$RELEASE_TAG" \ -f name="$RELEASE_TAG" \ -f body="$release_marker" \ @@ -301,7 +313,7 @@ jobs: verify_release_assets verify_release_ruleset verify_release_immutability - tag_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" + tag_json="$(gh_api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG")" current_type="$(jq -r '.object.type' <<< "$tag_json")" current_sha="$(jq -r '.object.sha' <<< "$tag_json")" if [[ "$current_type" != "commit" || "$current_sha" != "$RELEASE_SHA" ]]; then @@ -309,7 +321,7 @@ jobs: false fi - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" + release_json="$(gh_api "repos/$GITHUB_REPOSITORY/releases/$draft_id")" release_is_draft="$(jq -r '.draft' <<< "$release_json")" release_tag_name="$(jq -r '.tag_name' <<< "$release_json")" release_is_owned="$( @@ -358,7 +370,7 @@ jobs: '{tag_name: $tag, target_commitish: $target, name: $name, body: $body, draft: false}' )" published_json="$( - gh api --method PATCH \ + gh_api --method PATCH \ "repos/$GITHUB_REPOSITORY/releases/$draft_id" \ --input - <<< "$publish_payload" )" From b8e0a84dccf7779a399ada3d8118179309896e58 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:30:47 -0600 Subject: [PATCH 16/17] Make release builds reproducible and main-only --- .github/workflows/release.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a99a780f..0d850e25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,11 @@ jobs: gh api --header "X-GitHub-Api-Version: 2026-03-10" "$@" } + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::Releases must be dispatched from the main branch" + exit 1 + fi + if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then echo "::error::Release version must be a valid version without the v prefix" exit 1 @@ -141,10 +146,23 @@ jobs: echo "::error::Unable to check out release commit $RELEASE_SHA" exit 1 fi + echo "RELEASE_OUTPUT_TIMESTAMP=$(git show -s --format=%cI "$RELEASE_SHA")" >> "$GITHUB_ENV" - name: Set release version - run: mvn -B versions:set -DnewVersion="$RELEASE_VERSION" -DgenerateBackupPoms=false - - name: Build and verify - run: mvn -B clean verify + run: >- + mvn -B versions:set + -DnewVersion="$RELEASE_VERSION" + -DgenerateBackupPoms=false + -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + - name: Build and verify reproducibility + run: | + mvn -B clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + first_digest="$(sha256sum "target/votingplugin-control-$RELEASE_VERSION-all.jar" | cut -d ' ' -f 1)" + mvn -B clean verify -Dproject.build.outputTimestamp="$RELEASE_OUTPUT_TIMESTAMP" + second_digest="$(sha256sum "target/votingplugin-control-$RELEASE_VERSION-all.jar" | cut -d ' ' -f 1)" + if [[ "$first_digest" != "$second_digest" ]]; then + echo "::error::Release JAR is not byte-reproducible" + exit 1 + fi - name: Stage immutable release assets run: | cp "target/votingplugin-control-$RELEASE_VERSION-all.jar" votingplugin-control.jar From cc4bbf568cdc849b6a865aab343fb95ef96f2359 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 28 Aug 2026 22:31:48 -0600 Subject: [PATCH 17/17] Require release commits to belong to main --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d850e25..571a5db2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-java@v4 with: distribution: temurin @@ -146,6 +148,10 @@ jobs: echo "::error::Unable to check out release commit $RELEASE_SHA" exit 1 fi + if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then + echo "::error::Release commit is not contained in the main branch" + exit 1 + fi echo "RELEASE_OUTPUT_TIMESTAMP=$(git show -s --format=%cI "$RELEASE_SHA")" >> "$GITHUB_ENV" - name: Set release version run: >-