Skip to content

Commit 9667a82

Browse files
Automate releases
1 parent 6f38e2e commit 9667a82

7 files changed

Lines changed: 625 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
name: release / desktop publish
2+
3+
# Triggered when a desktop release-prepare PR is merged. The merge commit's
4+
# message begins with "release(desktop): vX.Y.Z" — that's how we filter.
5+
#
6+
# Order: assert secrets → import signing identity → install JS deps + Pods →
7+
# stamp CFBundleVersion → xcodebuild archive + export → notarize + staple →
8+
# create-dmg → move tag onto merge commit → create GitHub Release with .dmg.
9+
10+
on:
11+
push:
12+
branches:
13+
- main
14+
15+
permissions:
16+
contents: write
17+
18+
jobs:
19+
publish:
20+
if: "${{ startsWith(github.event.head_commit.message, 'release(desktop): v') }}"
21+
runs-on: macos-14
22+
timeout-minutes: 60
23+
24+
env:
25+
APP_NAME: entangle
26+
SCHEME: entangle-macOS
27+
WORKSPACE: apps/desktop/macos/entangle.xcworkspace
28+
ARCHIVE_PATH: build/entangle.xcarchive
29+
EXPORT_PATH: build/export
30+
EXPORT_OPTIONS: apps/desktop/macos/ExportOptions.plist
31+
32+
steps:
33+
- uses: actions/checkout@v4
34+
with:
35+
fetch-depth: 0
36+
37+
- name: Parse version from commit message
38+
id: parse
39+
run: |
40+
MSG=$(printf '%s' "${{ github.event.head_commit.message }}" | head -n1)
41+
VERSION=$(printf '%s' "$MSG" | sed -nE 's/^release\(desktop\): v([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')
42+
if [ -z "$VERSION" ]; then
43+
echo "Could not parse version from: $MSG" >&2
44+
exit 1
45+
fi
46+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
47+
48+
- name: Assert required secrets
49+
env:
50+
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
51+
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
52+
APPLE_ID: ${{ secrets.APPLE_ID }}
53+
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
54+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
55+
run: |
56+
missing=()
57+
for s in MACOS_CERT_P12_BASE64 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do
58+
if [ -z "${!s}" ]; then missing+=("$s"); fi
59+
done
60+
if [ ${#missing[@]} -gt 0 ]; then
61+
echo "Missing required repo secrets: ${missing[*]}" >&2
62+
echo "See RELEASING.md for how to set them up." >&2
63+
exit 1
64+
fi
65+
66+
- name: Stamp CFBundleVersion
67+
env:
68+
BUILD_NUMBER: ${{ github.run_number }}
69+
run: |
70+
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" \
71+
apps/desktop/macos/entangle-macOS/Info.plist
72+
73+
- name: Setup Node + pnpm
74+
uses: actions/setup-node@v4
75+
with:
76+
node-version: '20'
77+
- run: corepack enable && corepack prepare pnpm@10.33.0 --activate
78+
79+
- name: Install JS deps
80+
working-directory: apps/desktop
81+
run: pnpm install --ignore-workspace --frozen-lockfile
82+
83+
- name: Setup Ruby + Bundler
84+
uses: ruby/setup-ruby@v1
85+
with:
86+
ruby-version: '3.2'
87+
bundler-cache: true
88+
working-directory: apps/desktop
89+
90+
- name: Install Pods
91+
working-directory: apps/desktop/macos
92+
run: bundle exec pod install
93+
94+
- name: Import signing certificate
95+
env:
96+
MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }}
97+
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
98+
run: |
99+
KEYCHAIN=build.keychain
100+
KEYCHAIN_PWD=$(uuidgen)
101+
102+
echo "$MACOS_CERT_P12_BASE64" | base64 --decode > /tmp/cert.p12
103+
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
104+
security set-keychain-settings -lut 21600 "$KEYCHAIN"
105+
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
106+
security import /tmp/cert.p12 -k "$KEYCHAIN" -P "$MACOS_CERT_PASSWORD" \
107+
-T /usr/bin/codesign -T /usr/bin/productbuild -T /usr/bin/security
108+
security set-key-partition-list -S apple-tool:,apple:,codesign: \
109+
-s -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
110+
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
111+
rm /tmp/cert.p12
112+
113+
- name: xcodebuild archive
114+
run: |
115+
xcodebuild \
116+
-workspace "$WORKSPACE" \
117+
-scheme "$SCHEME" \
118+
-configuration Release \
119+
-archivePath "$ARCHIVE_PATH" \
120+
-destination 'generic/platform=macOS' \
121+
CODE_SIGN_STYLE=Manual \
122+
DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \
123+
archive | xcpretty && exit ${PIPESTATUS[0]}
124+
125+
- name: xcodebuild exportArchive
126+
run: |
127+
xcodebuild \
128+
-exportArchive \
129+
-archivePath "$ARCHIVE_PATH" \
130+
-exportPath "$EXPORT_PATH" \
131+
-exportOptionsPlist "$EXPORT_OPTIONS" | xcpretty && exit ${PIPESTATUS[0]}
132+
133+
- name: Notarize
134+
env:
135+
APPLE_ID: ${{ secrets.APPLE_ID }}
136+
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
137+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
138+
run: |
139+
APP_PATH="$EXPORT_PATH/$APP_NAME.app"
140+
ZIP_PATH="$EXPORT_PATH/$APP_NAME.zip"
141+
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
142+
xcrun notarytool submit "$ZIP_PATH" \
143+
--apple-id "$APPLE_ID" \
144+
--password "$APPLE_APP_PASSWORD" \
145+
--team-id "$APPLE_TEAM_ID" \
146+
--wait
147+
xcrun stapler staple "$APP_PATH"
148+
149+
- name: Build DMG
150+
env:
151+
VERSION: ${{ steps.parse.outputs.version }}
152+
run: |
153+
brew install create-dmg
154+
DMG_PATH="$EXPORT_PATH/${APP_NAME}-${VERSION}.dmg"
155+
create-dmg \
156+
--volname "${APP_NAME} ${VERSION}" \
157+
--window-size 600 400 \
158+
--icon-size 100 \
159+
--app-drop-link 450 200 \
160+
"$DMG_PATH" \
161+
"$EXPORT_PATH/${APP_NAME}.app"
162+
# Re-staple the DMG itself for offline Gatekeeper checks.
163+
xcrun stapler staple "$DMG_PATH" || true
164+
echo "DMG=$DMG_PATH" >> "$GITHUB_ENV"
165+
166+
- name: Move tag onto merge commit
167+
env:
168+
VERSION: ${{ steps.parse.outputs.version }}
169+
# The original tag pointed at the developer's pre-bump commit; we move
170+
# it so the GitHub Release reflects the actually-released tree.
171+
run: |
172+
git config user.name "entangle-release-bot"
173+
git config user.email "release-bot@users.noreply.github.com"
174+
git tag -f "v${VERSION}-desktop"
175+
git push -f origin "v${VERSION}-desktop"
176+
177+
- name: Create GitHub Release
178+
env:
179+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
180+
VERSION: ${{ steps.parse.outputs.version }}
181+
run: |
182+
gh release create "v${VERSION}-desktop" "$DMG" \
183+
--title "Desktop v${VERSION}" \
184+
--generate-notes \
185+
--verify-tag
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
name: release / prepare PR
2+
3+
# Tag-driven release prepare. Pushing `vX.Y.Z-{mobile,desktop}` opens a PR that
4+
# bumps version files for that app. Merging that PR triggers the matching
5+
# publish workflow (release-desktop-publish.yml here, or the mobile EAS Workflow
6+
# at apps/mobile/.eas/workflows/release-publish.yml).
7+
8+
on:
9+
push:
10+
tags:
11+
- 'v*-mobile'
12+
- 'v*-desktop'
13+
14+
permissions:
15+
contents: write
16+
pull-requests: write
17+
18+
jobs:
19+
prepare:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
with:
24+
ref: ${{ github.ref }}
25+
fetch-depth: 0
26+
27+
- name: Parse tag
28+
id: parse
29+
run: node scripts/parse-release-tag.mjs
30+
31+
- name: Configure git identity
32+
run: |
33+
git config user.name "entangle-release-bot"
34+
git config user.email "release-bot@users.noreply.github.com"
35+
36+
- name: Create release branch
37+
env:
38+
APP: ${{ steps.parse.outputs.app }}
39+
VERSION: ${{ steps.parse.outputs.version }}
40+
run: git checkout -b "release/${APP}-${VERSION}"
41+
42+
- name: Bump version files
43+
env:
44+
APP: ${{ steps.parse.outputs.app }}
45+
VERSION: ${{ steps.parse.outputs.version }}
46+
run: node scripts/bump-version.mjs "$APP" "$VERSION"
47+
48+
- name: Commit + push branch
49+
env:
50+
APP: ${{ steps.parse.outputs.app }}
51+
VERSION: ${{ steps.parse.outputs.version }}
52+
run: |
53+
git add -A
54+
if git diff --cached --quiet; then
55+
echo "No version changes to commit — does package.json/app.json/Info.plist already match?"
56+
exit 1
57+
fi
58+
git commit -m "release(${APP}): v${VERSION}"
59+
# Force push so a leftover branch from a previous failed run of this
60+
# same tag doesn't block us. The branch is automation-owned.
61+
git push -fu origin "release/${APP}-${VERSION}"
62+
63+
- name: Open PR
64+
env:
65+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
66+
APP: ${{ steps.parse.outputs.app }}
67+
VERSION: ${{ steps.parse.outputs.version }}
68+
run: |
69+
if [ "$APP" = "mobile" ]; then
70+
TARGETS="TestFlight + Play Console (via EAS Submit)"
71+
else
72+
TARGETS="a notarized .dmg attached to GitHub Release v${VERSION}-${APP}"
73+
fi
74+
# If a PR already exists for this branch (e.g. earlier failed run of
75+
# the same tag pushed an updated commit via force push), just print
76+
# its URL instead of failing.
77+
existing=$(gh pr list --head "release/${APP}-${VERSION}" --state open --json url --jq '.[0].url')
78+
if [ -n "$existing" ]; then
79+
echo "PR already exists: $existing"
80+
exit 0
81+
fi
82+
BODY_FILE=$(mktemp)
83+
{
84+
echo "Merging this PR will publish **${APP} v${VERSION}** to ${TARGETS}."
85+
echo
86+
echo "The tag \`v${VERSION}-${APP}\` will be force-moved onto the merge commit by the publish workflow."
87+
} > "$BODY_FILE"
88+
gh pr create \
89+
--base main \
90+
--head "release/${APP}-${VERSION}" \
91+
--title "release(${APP}): v${VERSION}" \
92+
--body-file "$BODY_FILE"

RELEASING.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Releasing
2+
3+
Releases are tag-driven. Pushing a tag opens a version-bump PR; merging that PR runs the build and ships it.
4+
5+
## How to cut a release
6+
7+
```sh
8+
# Mobile (iOS + Android)
9+
git tag v0.1.2-mobile
10+
git push origin v0.1.2-mobile
11+
12+
# Desktop (macOS)
13+
git tag v0.1.2-desktop
14+
git push origin v0.1.2-desktop
15+
```
16+
17+
What happens next:
18+
19+
1. [.github/workflows/release-prepare.yml](.github/workflows/release-prepare.yml) opens a `release/<app>-<version>` PR with the version bumps.
20+
2. Review and merge the PR.
21+
3. The matching publish workflow runs:
22+
- **Mobile**[apps/mobile/.eas/workflows/release-publish.yml](apps/mobile/.eas/workflows/release-publish.yml) builds with `eas build --profile production` for iOS + Android, submits each via EAS Submit (TestFlight + Play Console), and creates a GitHub Release.
23+
- **Desktop**[.github/workflows/release-desktop-publish.yml](.github/workflows/release-desktop-publish.yml) archives + signs + notarizes + builds a `.dmg`, then attaches it to a GitHub Release.
24+
25+
The publish workflow force-moves the tag onto the merge commit so the GitHub Release reflects the actually-released tree, not the developer's pre-bump commit.
26+
27+
Tag format: `v<major>.<minor>.<patch>-<mobile|desktop>`. Anything else is rejected by `scripts/parse-release-tag.mjs`.
28+
29+
## Prerequisites (one-time setup)
30+
31+
The workflows fail fast with a clear message if any of these are missing.
32+
33+
### EAS — for the mobile publish workflow
34+
35+
In the EAS dashboard for the **mobile** project:
36+
37+
- Connect the GitHub repo so EAS Workflows can fire on push events to `main`. See [EAS Workflows getting started](https://docs.expo.dev/eas/workflows/get-started/).
38+
- Add the project secret `RELEASE_GH_TOKEN` — a GitHub Personal Access Token (or fine-grained token) with `contents: write` on this repo. Used to force-move the tag and create the GitHub Release.
39+
- Configure App Store Connect API key under `eas credentials` for iOS Submit.
40+
- Upload a Google Play Service Account JSON for Android Submit.
41+
42+
### GitHub Actions — for the desktop publish workflow
43+
44+
Repo Settings → Secrets and variables → Actions → New repository secret:
45+
46+
| Secret | Value |
47+
| ----------------------- | --------------------------------------------------------------------------------------------------------------- |
48+
| `MACOS_CERT_P12_BASE64` | `base64 -i DeveloperID.p12 \| pbcopy` — Developer ID Application certificate exported as `.p12`, base64-encoded |
49+
| `MACOS_CERT_PASSWORD` | password for the `.p12` |
50+
| `APPLE_ID` | Apple ID email used for notarization |
51+
| `APPLE_APP_PASSWORD` | app-specific password from [appleid.apple.com](https://appleid.apple.com/account/manage) |
52+
| `APPLE_TEAM_ID` | `KN3BH83GQ4` |
53+
54+
`GITHUB_TOKEN` is provided automatically by GitHub Actions.
55+
56+
### Verifying the setup
57+
58+
- Mobile: tag `v0.0.2-mobile`, merge the prepare PR, watch the EAS Workflow run on the EAS dashboard. It should fail at the `guard` job's `Assert RELEASE_GH_TOKEN` step if the secret is missing.
59+
- Desktop: tag `v0.0.2-desktop`, merge the prepare PR, watch GitHub Actions. It should fail at `Assert required secrets` if any of the five GHA secrets are missing.
60+
61+
## Version files touched by the prepare PR
62+
63+
| App | Files |
64+
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65+
| mobile | [apps/mobile/package.json](apps/mobile/package.json) (`version`), [apps/mobile/app.json](apps/mobile/app.json) (`expo.version`) |
66+
| desktop | [apps/desktop/package.json](apps/desktop/package.json) (`version`), [apps/desktop/macos/entangle-macOS/Info.plist](apps/desktop/macos/entangle-macOS/Info.plist) (`CFBundleShortVersionString`) |
67+
68+
Build numbers are not touched here. EAS handles `ios.buildNumber` / `android.versionCode` via `production.autoIncrement` in [apps/mobile/eas.json](apps/mobile/eas.json) (paired with `appVersionSource: remote`). The desktop publish workflow stamps `CFBundleVersion` from `GITHUB_RUN_NUMBER` at build time.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>method</key>
6+
<string>developer-id</string>
7+
<key>teamID</key>
8+
<string>KN3BH83GQ4</string>
9+
<key>signingStyle</key>
10+
<string>manual</string>
11+
<key>destination</key>
12+
<string>export</string>
13+
</dict>
14+
</plist>

0 commit comments

Comments
 (0)