Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 119 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ jobs:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
steps:
- uses: actions/checkout@v6
# Build from the EXACT released commit in both trigger paths:
# - push: use github.sha (the immutable commit the tag pointed at when
# the event fired), NOT github.ref. Passing the ref name would make
# checkout re-resolve the tag at checkout time — and desktop runs
# behind a manual approval that can lag up to 30 days, during which
# the tag could be moved (repo ruleset excludes canary/beta/rc from
# tag-update protection). If tag moves A→B after `release` already
# published npm from A, a ref-name checkout would sign B → assets
# diverge from the npm tarball. A pinned SHA cannot move.
# - workflow_dispatch: no event SHA for the tag, so resolve the tag ref
# explicitly (default checkout would be the dispatch ref = master).
# The Verify step below then fail-closes if the tag no longer points at
# the commit we built (tag moved), so mismatched assets never attach.
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.sha }}

- name: Verify HEAD is the tagged commit
# Prove the built commit still equals what the tag points at. On push,
# HEAD is github.sha; if the tag was moved during the approval wait, the
# current tag commit won't match HEAD → fail-closed (never attach assets
# built from a commit the tag no longer names). On workflow_dispatch this
# also catches a mis-resolved ref (e.g. accidental master checkout).
run: |
git fetch origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" --force --quiet 2>/dev/null || true
TAG_SHA=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")
HEAD_SHA=$(git rev-parse HEAD)
echo "tag ${RELEASE_TAG} → $TAG_SHA ; HEAD (built) → $HEAD_SHA"
if [ "$TAG_SHA" != "$HEAD_SHA" ]; then
echo "REFUSING: built commit $HEAD_SHA is not what tag ${RELEASE_TAG} now points at ($TAG_SHA)."
echo "The tag was likely moved after npm was published from the original commit."
echo "Signed assets must match the released commit; aborting to avoid attaching mismatched assets."
exit 1
fi

- uses: actions/setup-node@v6
with:
Expand Down Expand Up @@ -86,8 +119,12 @@ jobs:
retention-days: 7

release:
if: always() && github.event_name == 'push' && (needs.desktop.result == 'success' || needs.desktop.result == 'skipped')
needs: desktop
# npm-first: no longer `needs: desktop`. npm publish + GitHub Release run
# immediately on the tag push, WITHOUT waiting for the manually-gated
# (macos-signing environment) macOS build. The signed desktop assets are
# attached asynchronously afterwards by `attach-desktop-assets`. This keeps
# `npm i -g botmux` unblocked when macOS signing is slow / awaiting approval.
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down Expand Up @@ -123,10 +160,18 @@ jobs:
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Publishing $VERSION → npm dist-tag=$NPM_TAG, prerelease=$PRERELEASE"

- name: Require signed macOS assets for stable releases
if: steps.dist_tag.outputs.tag == 'latest' && needs.desktop.result != 'success'
- name: Require deepcoldy authority for stable (latest) releases
# npm-first removed the transitive authority gate that used to come via
# `needs: desktop` (desktop only runs for deepcoldy; the old "Require
# signed macOS assets" step then failed non-deepcoldy stable tags). With
# release decoupled from desktop, re-assert it explicitly: only deepcoldy
# may publish the `latest` dist-tag that every `npm i botmux` user gets.
# canary/beta/rc/next go to side dist-tags and stay open for branch
# gray-release by anyone.
if: steps.dist_tag.outputs.tag == 'latest' && (github.actor != 'deepcoldy' || github.triggering_actor != 'deepcoldy')
run: |
echo "REFUSING: stable releases require macOS signing by deepcoldy."
echo "REFUSING: stable (latest) releases may only be published by deepcoldy."
echo "actor=${{ github.actor }} triggering_actor=${{ github.triggering_actor }}"
exit 1

- name: Guard against downgrading npm latest
Expand Down Expand Up @@ -253,28 +298,81 @@ jobs:
echo 'CHANGELOG_EOF'
} >> "$GITHUB_OUTPUT"

- name: Download macOS release assets
if: needs.desktop.result == 'success'
uses: actions/download-artifact@v4
with:
name: botmux-macos
path: release-assets

- name: Create GitHub Release with macOS assets
if: needs.desktop.result == 'success'
- name: Create GitHub Release (desktop assets attached asynchronously)
# npm-first: the Release is created right after npm publish, WITHOUT the
# macOS assets — those are uploaded later by `attach-desktop-assets` once
# signing/notarization finishes. So the Release exists with npm already
# live but no .dmg/.zip yet. Normally the desktop bundle lands within the
# same run (a few minutes) once macOS signing completes — but signing is
# behind a human approval gate that can wait up to 30 days, or be
# rejected / fail / be cancelled; in those cases the Release stays
# npm-only until the manual recovery re-run (see attach-desktop-assets).
# softprops keeps the release editable so the later --clobber upload just
# adds the files.
uses: softprops/action-gh-release@v3
with:
body: ${{ steps.changelog.outputs.body }}
prerelease: ${{ steps.dist_tag.outputs.prerelease == 'true' }}
files: release-assets/*
fail_on_unmatched_files: true

- name: Create GitHub prerelease without macOS assets
if: needs.desktop.result == 'skipped' && steps.dist_tag.outputs.prerelease == 'true'
uses: softprops/action-gh-release@v3
- name: Note — desktop assets pending
# Make the npm-first window visible in the run log: npm + Release are
# live now; the macOS .dmg/.zip attach in the parallel attach-desktop-assets
# job after signing. If signing is rejected/fails/cancelled, recover by
# re-running this workflow via workflow_dispatch with this release_tag.
run: |
echo "npm ${GITHUB_REF_NAME#v} published and GitHub Release ${GITHUB_REF_NAME} created."
echo "macOS desktop assets (.dmg/.zip) attach asynchronously in attach-desktop-assets after signing."
echo "If signing is rejected/fails/cancelled, re-run this workflow via workflow_dispatch with release_tag=${GITHUB_REF_NAME} to rebuild+sign and attach."
attach-desktop-assets:
# npm-first tail: once the parallel `desktop` job has signed+notarized the
# macOS build AND the `release` job has created the GitHub Release, upload
# the .dmg/.zip onto that Release. Runs on the tag-push path (the
# workflow_dispatch refresh path is handled by `refresh-desktop-assets`).
# `needs: [desktop, release]` both must succeed: desktop for the artifact,
# release so the GitHub Release exists to upload onto.
#
# FAILURE MODE (accepted trade-off of npm-first): the `desktop` job sits
# behind the macos-signing environment approval, which can wait up to 30
# days and may be rejected / the build may fail / the run may be cancelled.
# In any of those cases npm + the GitHub Release are already live but this
# job never runs, so the Release keeps npm-only (no .dmg/.zip). There is no
# auto-retry here on purpose — signing needs a human approval, so retrying
# would just re-block. Recovery is manual and idempotent: re-run this
# workflow via workflow_dispatch with release_tag=vX.Y.Z; that rebuilds+signs
# macOS (fresh artifact, so the 7-day retention is irrelevant) and
# `refresh-desktop-assets` --clobber-attaches to the existing Release.
if: github.event_name == 'push' && needs.desktop.result == 'success' && needs.release.result == 'success'
needs: [desktop, release]
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Download macOS build artifact
uses: actions/download-artifact@v4
with:
body: ${{ steps.changelog.outputs.body }}
prerelease: true
name: botmux-macos
path: release-assets

- name: Attach macOS assets to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY"

- name: Verify uploaded release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir downloaded-assets
gh release download "$RELEASE_TAG" \
--pattern 'Botmux-*.dmg' \
--pattern 'Botmux-*.zip' \
--dir downloaded-assets \
--repo "$GITHUB_REPOSITORY"
for file in release-assets/*; do
cmp "$file" "downloaded-assets/$(basename "$file")"
done

refresh-desktop-assets:
if: github.event_name == 'workflow_dispatch'
Expand Down