From a4a1db72e0698585e9a505d3951d14be4ca9090f Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Thu, 27 Aug 2026 19:22:09 +0200 Subject: [PATCH] fix(release): give gh the repo, and make a tagged release recoverable v0.10.0 shipped as a tagged, published, EMPTY release. This is why, and what stops it recurring. The draft-hold step added in #70 runs in the `release-please` job, which has no `actions/checkout`. `gh` infers the repository from the git remote, found none, and died with "fatal: not a git repository". The job failed, so build, sign and aur all skipped - and because release-please had already published the release, /releases/latest moved to a v0.10.0 with zero assets. Every running Colony was offered an update that does not exist, until the release was re-drafted by hand and latest fell back to v0.9.2. aur-publish.yml already carries the warning for exactly this ("`-R` is not optional here. This job has no `actions/checkout`"), and the new call was added without heeding it. Every `gh` call in the workflow now passes `-R` explicitly, so where a job sits can no longer decide whether it works. The second half is that the pipeline had no way to finish a release once release-please had emitted its one `release_created`. "Re-run all jobs" makes release-please report nothing to do and every downstream job skip while the run reports green, which looks like a successful recovery and is the opposite of one. So a failure anywhere left a tagged release permanently unfinishable except by signing four binaries by hand. `workflow_dispatch` with a tag input fixes that. A new `target` job resolves which tag the run is for - from release-please on a merge, from the input on a dispatch - and build, sign and aur read it instead of reaching into release-please's outputs. Both checkouts pin `ref:` to that tag, because the default ref on a dispatch is the branch, and rebuilding from a different commit than the tag names would produce binaries the .meta sidecar then binds to a version they were not built from. Three things an adversarial review of this change caught before it merged: - The `target` job read `needs.target.outputs.tag` - itself. The mechanical rewrite that repointed every consumer at the new job also rewrote the one line that was supposed to be the SOURCE. On a dispatch it is harmless because the input fills in, so the planned v0.10.0 recovery run would have gone green while every future merge failed to resolve a tag and left exactly the empty tagged draft this commit exists to prevent. - `if: always()` on `target` would also have run it when release-please FAILED, removing the brake that stopped the v0.10.0 incident at an empty draft rather than a broken build. `!cancelled() && !failure()` still runs when release-please is SKIPPED, which is what a dispatch does. - The uploader has overwrite_files on by default, so dispatching against an already-published tag would delete and replace its live binaries and only then hit the draft assertion - leaving published assets whose .sig and .meta describe bytes that no longer exist. Since verification is fail-closed, that breaks every install of that version. `target` now refuses any tag whose release is not a draft, before anything is uploaded. --- .github/workflows/release-please.yml | 103 ++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index e87c7ec..fe008f5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -3,6 +3,19 @@ name: Release on: push: branches: [main] + # Recovery path. release-please only ever emits `release_created` once per + # release, so once a tag exists there is no way to re-run build and sign for + # it: "Re-run all jobs" makes release-please report nothing to do and every + # downstream job skip, while the run goes green. That is how v0.10.0 ended up + # tagged with an empty draft release and no way to finish it. + # + # Dispatch with the tag to build, sign and publish an existing release. + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag to build, sign and publish (e.g. v0.10.0)' + required: true + type: string permissions: contents: write @@ -13,6 +26,7 @@ env: jobs: release-please: + if: github.event_name == 'push' runs-on: ubuntu-latest outputs: release_created: ${{ steps.release.outputs.release_created }} @@ -40,12 +54,61 @@ jobs: if: ${{ steps.release.outputs.release_created }} env: GH_TOKEN: ${{ github.token }} - run: gh release edit "${{ steps.release.outputs.tag_name }}" --draft=true + # `-R` is not optional: this job has no `actions/checkout`, so `gh` + # has no git remote to infer the repository from and dies with + # "fatal: not a git repository". aur-publish.yml already carries this + # exact warning; this call was added without heeding it, and the cost + # was a published, empty v0.10.0 sitting at /releases/latest. + run: gh release edit "${{ steps.release.outputs.tag_name }}" -R "${{ github.repository }}" --draft=true + + # One place that answers "which tag are we building?", so the jobs below do + # not each have to know whether this run came from a merge or a dispatch. + target: + name: Resolve the target release + needs: release-please + # NOT `always()`: that would also run this - and everything after it - when + # release-please FAILED, which is the brake that stopped the v0.10.0 + # incident from going further than an empty draft. `!cancelled() && + # !failure()` still lets the job run when release-please is SKIPPED, which + # is what a workflow_dispatch does. + if: ${{ !cancelled() && !failure() && (needs.release-please.outputs.release_created || github.event_name == 'workflow_dispatch') }} + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.pick.outputs.tag }} + steps: + - id: pick + env: + GH_TOKEN: ${{ github.token }} + # release-please's tag on a merge; the dispatch input on a recovery. + # On a dispatch release-please is skipped and its outputs are empty, + # so the fallback resolves in exactly one direction each time. + FROM_RELEASE: ${{ needs.release-please.outputs.tag_name }} + FROM_DISPATCH: ${{ inputs.tag }} + run: | + tag="${FROM_RELEASE:-$FROM_DISPATCH}" + [ -n "$tag" ] || { echo "::error::no tag to build"; exit 1; } + + # Refuse to touch a release that is already visible to users. The + # uploader has overwrite_files on by default, so a dispatch against a + # published tag would DELETE and replace its live binaries and only + # then hit the draft assertion - leaving published assets whose .sig + # and .meta describe bytes that no longer exist. Self-update is + # fail-closed, so that breaks every install of that version. + if ! gh release view "$tag" -R "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft > /tmp/state 2>/dev/null; then + echo "::error::no release found for $tag" + exit 1 + fi + if [ "$(cat /tmp/state)" != "true" ]; then + echo "::error::$tag is already published. Re-running would overwrite its live assets and leave their signatures stale. Draft it first if you really mean to replace it: gh release edit $tag --draft=true" + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "building $tag (draft)" build: name: Build ${{ matrix.asset }} - needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + needs: target runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -65,7 +128,13 @@ jobs: asset: colony-macos-x86 steps: + # Check out the TAG, not the branch. On a recovery dispatch the default + # ref is the branch the workflow was dispatched from, so without this the + # rebuild would produce binaries from a different commit than the one the + # tag names - and the .meta sidecar binds those bytes to that version. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.tag }} - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch with: @@ -132,7 +201,7 @@ jobs: - name: Upload binary to GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - tag_name: ${{ needs.release-please.outputs.tag_name }} + tag_name: ${{ needs.target.outputs.tag }} files: ${{ matrix.asset }} draft: true @@ -143,9 +212,9 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.release-please.outputs.tag_name }} + TAG: ${{ needs.target.outputs.tag }} run: | - state=$(gh release view "$TAG" --json isDraft --jq .isDraft) + state=$(gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft) [ "$state" = "true" ] || { echo "::error::$TAG is no longer a draft after upload - it is public without signatures. Re-draft it NOW: gh release edit $TAG --draft=true" exit 1 @@ -159,8 +228,7 @@ jobs: # silently again. sign: name: Sign release assets - needs: [release-please, build] - if: ${{ needs.release-please.outputs.release_created }} + needs: [target, build] runs-on: ubuntu-latest env: # Single source of truth for the four launcher assets. Declared once so the @@ -169,15 +237,19 @@ jobs: # which is exactly how v0.7.0 shipped. ASSETS: colony-linux colony-windows.exe colony-macos colony-macos-x86 GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.release-please.outputs.tag_name }} + TAG: ${{ needs.target.outputs.tag }} steps: + # Same tag as the build, so the signing script that runs is the one this + # release actually shipped with. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.tag }} - name: Download release binaries run: | mkdir dist for a in $ASSETS; do PATTERNS="$PATTERNS --pattern $a"; done - gh release download "$TAG" --dir dist $PATTERNS + gh release download "$TAG" -R "$GITHUB_REPOSITORY" --dir dist $PATTERNS for a in $ASSETS; do [ -s "dist/$a" ] || { echo "::error::release asset $a is missing or empty"; exit 1; } done @@ -219,13 +291,13 @@ jobs: echo "all assets carry .sig + .meta + .meta.sig, correctly bound" - name: Upload signatures and metadata - run: gh release upload "$TAG" dist/*.sig dist/*.meta --clobber + run: gh release upload "$TAG" -R "$GITHUB_REPOSITORY" dist/*.sig dist/*.meta --clobber # ...and again against the PUBLISHED release, because a local file proves # nothing about what users can actually download. - name: Verify the published release carries every asset run: | - gh release view "$TAG" --json assets --jq '.assets[].name' | sort > /tmp/published + gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | sort > /tmp/published for a in $ASSETS; do for required in "$a" "$a.sig" "$a.meta" "$a.meta.sig"; do grep -qx "$required" /tmp/published \ @@ -239,7 +311,7 @@ jobs: # update it cannot apply, and the README's download link never points at # an empty release. - name: Publish the release - run: gh release edit "$TAG" --draft=false + run: gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=false # Chained here (not on `release: published`) because release-please creates # the release with the default GITHUB_TOKEN, whose events do not trigger @@ -248,11 +320,10 @@ jobs: # the AUR job hashes them. aur: name: Publish colony-bin to AUR - needs: [release-please, sign] - if: ${{ needs.release-please.outputs.release_created }} + needs: [target, sign] uses: ./.github/workflows/aur-publish.yml with: - tag: ${{ needs.release-please.outputs.tag_name }} + tag: ${{ needs.target.outputs.tag }} # NOT `inherit`: the AUR job needs one secret, and inheriting handed it # the release signing key as well. secrets: