From 71b3249e66d46f75f3c6f358092a4a898586d130 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Wed, 7 Oct 2026 23:28:47 +0000 Subject: [PATCH 1/4] Make Hydra build signed release tags by their commit --- .github/workflows/release-upload.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 5ddb376b44..10ed28dbd1 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -7,6 +7,8 @@ name: Release Upload # 1. The "wait_for_hydra" job looks up the status of the Hydra build corresponding to # the tag for which we want to release. If the status is success, the pipeline # continues. If the status cannot be determined or is a failure, the pipeline stops. +# If the tag is annotated (signed tags are), this job first pushes the tagged commit +# to a ci/ branch, so that Hydra builds and reports on the commit itself. # 2. The "pull" job downloads the binaries from the Hydra build and uploads them as artifacts. # This job uses a matrix to handle all supported platforms. Uploading as an artifact allows to # do the last job without a matrix. @@ -14,6 +16,7 @@ name: Release Upload # This job uses `--builders "" --max-jobs 0` to ensure the assets are downloaded # from the IOG cache. # 3. The "create_release" job downloads the artifacts and attaches them to the target release. +# Then it deletes the ci/ branch from step 1, if there is one. # # This pipeline is triggered in one of the following 3 ways: # @@ -95,6 +98,18 @@ jobs: echo "FLAKE_REF=$flake_ref" >> "$GITHUB_OUTPUT" echo "DRY_RUN=$dry_run" >> "$GITHUB_OUTPUT" + - name: Make Hydra build the tagged commit + run: | + # Hydra builds an annotated tag under the ID of the tag object, not the ID of the commit. + # GitHub rejects Hydra's check-runs for that ID, so the next step would wait forever, + # and the binaries Hydra builds would not match the ones the "pull" job asks for. + # Hydra also builds ci/* branches, by commit ID, so push the tagged commit to one. + if [[ "$(git cat-file -t "refs/tags/${{ env.TARGET_TAG }}" 2>/dev/null)" == "tag" ]] + then + commit=$(git rev-parse "refs/tags/${{ env.TARGET_TAG }}^{commit}") + echo "${{ env.TARGET_TAG }} is an annotated tag: pushing $commit to ci/${{ env.TARGET_TAG }} for Hydra" + git push --force origin "$commit:refs/heads/ci/${{ env.TARGET_TAG }}" + fi - name: Get specific check run status timeout-minutes: 120 run: | @@ -242,3 +257,10 @@ jobs: ${{ needs.wait_for_hydra.outputs.TARGET_TAG }}-win64.zip ${{ needs.wait_for_hydra.outputs.TARGET_TAG }}-sha256sums.txt body_path: RELEASE_CHANGELOG.md + - name: Delete the ci/ branch pushed for Hydra + run: | + branch="refs/heads/ci/${{ needs.wait_for_hydra.outputs.TARGET_TAG }}" + if git ls-remote --exit-code origin "$branch" > /dev/null + then + git push origin --delete "$branch" + fi From 5c38dcfac6622bd494c8f33078d03ed5b1caa1d3 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Wed, 7 Oct 2026 23:33:43 +0000 Subject: [PATCH 2/4] Don't treat a still-running Hydra check as passed --- .github/workflows/release-upload.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 10ed28dbd1..86b24a8a7c 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -114,7 +114,9 @@ jobs: timeout-minutes: 120 run: | while true; do - conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/${{ env.TARGET_TAG }}/check-runs" --jq '.check_runs[] | select(.name | test("ci/hydra-build:.*\\.required")) | .conclusion') + # A check that has not finished has no conclusion (null). gh prints null as an empty line, + # and $(...) drops it when it comes last, so such a check would not be counted. Hence "pending". + conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/${{ env.TARGET_TAG }}/check-runs" --jq '.check_runs[] | select(.name | test("ci/hydra-build:.*\\.required")) | .conclusion // "pending"') # Here we are being careful, because we query the status of multiple jobs (once per line) # But the only thing we are sure is that "success" means a green job. There # could be unknown statuses, which is why we may retry when unsure (see 'sleep') below. From 1d6fca54421e5978dfdd6b146f89696cbbf9d964 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Thu, 15 Oct 2026 18:55:41 +0000 Subject: [PATCH 3/4] Delete only this run's ci/ branch, also when the release fails --- .github/workflows/release-upload.yml | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 86b24a8a7c..14850238a1 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -2,13 +2,13 @@ name: Release Upload # Pipeline to download binaries from Hydra builds and create GitHub # releases out of them (or attaching the binaries to an already existing release). # -# This pipeline is divided in three jobs: +# This pipeline is divided in four jobs: # # 1. The "wait_for_hydra" job looks up the status of the Hydra build corresponding to # the tag for which we want to release. If the status is success, the pipeline # continues. If the status cannot be determined or is a failure, the pipeline stops. # If the tag is annotated (signed tags are), this job first pushes the tagged commit -# to a ci/ branch, so that Hydra builds and reports on the commit itself. +# to a ci/ branch for this run, so that Hydra builds and reports on the commit itself. # 2. The "pull" job downloads the binaries from the Hydra build and uploads them as artifacts. # This job uses a matrix to handle all supported platforms. Uploading as an artifact allows to # do the last job without a matrix. @@ -16,7 +16,9 @@ name: Release Upload # This job uses `--builders "" --max-jobs 0` to ensure the assets are downloaded # from the IOG cache. # 3. The "create_release" job downloads the artifacts and attaches them to the target release. -# Then it deletes the ci/ branch from step 1, if there is one. +# 4. The "delete_ci_branch" job deletes the ci/ branch from step 1, if this run pushed one. +# It also runs when an earlier job failed or the run was cancelled, so that the branch +# does not stay behind. # # This pipeline is triggered in one of the following 3 ways: # @@ -60,6 +62,7 @@ jobs: TARGET_TAG: ${{ steps.store_vars.outputs.TARGET_TAG }} DRY_RUN: ${{ steps.store_vars.outputs.DRY_RUN }} FLAKE_REF: ${{ steps.store_vars.outputs.FLAKE_REF }} + CI_BRANCH: ${{ steps.ci_branch.outputs.CI_BRANCH }} steps: - uses: actions/checkout@v6 with: @@ -99,16 +102,21 @@ jobs: echo "DRY_RUN=$dry_run" >> "$GITHUB_OUTPUT" - name: Make Hydra build the tagged commit + id: ci_branch run: | # Hydra builds an annotated tag under the ID of the tag object, not the ID of the commit. # GitHub rejects Hydra's check-runs for that ID, so the next step would wait forever, # and the binaries Hydra builds would not match the ones the "pull" job asks for. # Hydra also builds ci/* branches, by commit ID, so push the tagged commit to one. + # The branch name includes the run ID, so that this run only ever touches its own branch. if [[ "$(git cat-file -t "refs/tags/${{ env.TARGET_TAG }}" 2>/dev/null)" == "tag" ]] then commit=$(git rev-parse "refs/tags/${{ env.TARGET_TAG }}^{commit}") - echo "${{ env.TARGET_TAG }} is an annotated tag: pushing $commit to ci/${{ env.TARGET_TAG }} for Hydra" - git push --force origin "$commit:refs/heads/ci/${{ env.TARGET_TAG }}" + branch="ci/${{ env.TARGET_TAG }}-run-${{ github.run_id }}" + echo "${{ env.TARGET_TAG }} is an annotated tag: pushing $commit to $branch for Hydra" + git push --force origin "$commit:refs/heads/$branch" + # Tell the "delete_ci_branch" job what to delete + echo "CI_BRANCH=$branch" >> "$GITHUB_OUTPUT" fi - name: Get specific check run status timeout-minutes: 120 @@ -259,9 +267,19 @@ jobs: ${{ needs.wait_for_hydra.outputs.TARGET_TAG }}-win64.zip ${{ needs.wait_for_hydra.outputs.TARGET_TAG }}-sha256sums.txt body_path: RELEASE_CHANGELOG.md - - name: Delete the ci/ branch pushed for Hydra + + delete_ci_branch: + needs: [wait_for_hydra, pull, create_release] + name: "Delete the ci/ branch pushed for Hydra" + runs-on: ubuntu-latest + # always(): also after a failed or cancelled run, so that the branch does not stay behind + if: ${{ always() && needs.wait_for_hydra.outputs.CI_BRANCH != '' }} + steps: + - uses: actions/checkout@v6 # So that git can push to origin below + - name: Delete the ci/ branch run: | - branch="refs/heads/ci/${{ needs.wait_for_hydra.outputs.TARGET_TAG }}" + branch="refs/heads/${{ needs.wait_for_hydra.outputs.CI_BRANCH }}" + # An earlier attempt of this run may have deleted it already if git ls-remote --exit-code origin "$branch" > /dev/null then git push origin --delete "$branch" From b845055a322becb6140610b7f65d21c2074c8534 Mon Sep 17 00:00:00 2001 From: Pablo Lamela Date: Fri, 16 Oct 2026 13:29:45 +0000 Subject: [PATCH 4/4] Declare token permissions per job; pass tag and branch names via env --- .github/workflows/release-upload.yml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-upload.yml b/.github/workflows/release-upload.yml index 14850238a1..9ae7732acd 100644 --- a/.github/workflows/release-upload.yml +++ b/.github/workflows/release-upload.yml @@ -51,6 +51,8 @@ on: push: tags: - '**' +permissions: + contents: read env: GH_TOKEN: ${{ github.token }} @@ -58,6 +60,8 @@ jobs: wait_for_hydra: name: "Wait for Hydra check-runs" runs-on: ubuntu-latest + permissions: + contents: write # To push the ci/ branch outputs: TARGET_TAG: ${{ steps.store_vars.outputs.TARGET_TAG }} DRY_RUN: ${{ steps.store_vars.outputs.DRY_RUN }} @@ -109,11 +113,11 @@ jobs: # and the binaries Hydra builds would not match the ones the "pull" job asks for. # Hydra also builds ci/* branches, by commit ID, so push the tagged commit to one. # The branch name includes the run ID, so that this run only ever touches its own branch. - if [[ "$(git cat-file -t "refs/tags/${{ env.TARGET_TAG }}" 2>/dev/null)" == "tag" ]] + if [[ "$(git cat-file -t "refs/tags/$TARGET_TAG" 2>/dev/null)" == "tag" ]] then - commit=$(git rev-parse "refs/tags/${{ env.TARGET_TAG }}^{commit}") - branch="ci/${{ env.TARGET_TAG }}-run-${{ github.run_id }}" - echo "${{ env.TARGET_TAG }} is an annotated tag: pushing $commit to $branch for Hydra" + commit=$(git rev-parse "refs/tags/$TARGET_TAG^{commit}") + branch="ci/$TARGET_TAG-run-$GITHUB_RUN_ID" + echo "$TARGET_TAG is an annotated tag: pushing $commit to $branch for Hydra" git push --force origin "$commit:refs/heads/$branch" # Tell the "delete_ci_branch" job what to delete echo "CI_BRANCH=$branch" >> "$GITHUB_OUTPUT" @@ -124,7 +128,7 @@ jobs: while true; do # A check that has not finished has no conclusion (null). gh prints null as an empty line, # and $(...) drops it when it comes last, so such a check would not be counted. Hence "pending". - conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/${{ env.TARGET_TAG }}/check-runs" --jq '.check_runs[] | select(.name | test("ci/hydra-build:.*\\.required")) | .conclusion // "pending"') + conclusion=$(gh api "repos/$GITHUB_REPOSITORY/commits/$TARGET_TAG/check-runs" --jq '.check_runs[] | select(.name | test("ci/hydra-build:.*\\.required")) | .conclusion // "pending"') # Here we are being careful, because we query the status of multiple jobs (once per line) # But the only thing we are sure is that "success" means a green job. There # could be unknown statuses, which is why we may retry when unsure (see 'sleep') below. @@ -218,6 +222,8 @@ jobs: needs: [wait_for_hydra, pull] name: "Create Release" runs-on: ubuntu-latest + permissions: + contents: write # To create the release and upload its files if: ${{ needs.wait_for_hydra.outputs.DRY_RUN == 'false' }} steps: - uses: actions/checkout@v6 # We need the repo to execute extract-changelog.sh below @@ -272,13 +278,17 @@ jobs: needs: [wait_for_hydra, pull, create_release] name: "Delete the ci/ branch pushed for Hydra" runs-on: ubuntu-latest + permissions: + contents: write # To delete the ci/ branch # always(): also after a failed or cancelled run, so that the branch does not stay behind if: ${{ always() && needs.wait_for_hydra.outputs.CI_BRANCH != '' }} steps: - uses: actions/checkout@v6 # So that git can push to origin below - name: Delete the ci/ branch + env: + CI_BRANCH: ${{ needs.wait_for_hydra.outputs.CI_BRANCH }} run: | - branch="refs/heads/${{ needs.wait_for_hydra.outputs.CI_BRANCH }}" + branch="refs/heads/$CI_BRANCH" # An earlier attempt of this run may have deleted it already if git ls-remote --exit-code origin "$branch" > /dev/null then