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: