Skip to content

Commit 791f444

Browse files
authored
Merge pull request #13 from polarsource/yopi/notarize-polar-cli
ci: Sign and notarize the MacOS application
2 parents 277c6d6 + 52715bf commit 791f444

5 files changed

Lines changed: 209 additions & 23 deletions

File tree

.github/macos-entitlements.plist

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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>com.apple.security.cs.allow-jit</key>
6+
<true/>
7+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
8+
<true/>
9+
<key>com.apple.security.cs.disable-executable-page-protection</key>
10+
<true/>
11+
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
12+
<true/>
13+
<key>com.apple.security.cs.disable-library-validation</key>
14+
<true/>
15+
</dict>
16+
</plist>

.github/workflows/release.yml

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ on:
44
push:
55
tags:
66
- "v*"
7+
workflow_dispatch:
8+
inputs:
9+
tag_name:
10+
description: "Draft verification tag to create, e.g. verify-signing-2026-04-16"
11+
required: true
12+
type: string
713

814
permissions:
9-
contents: write
15+
contents: read
1016

1117
jobs:
1218
test:
1319
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
1422

1523
steps:
1624
- uses: actions/checkout@v4
@@ -25,18 +33,26 @@ jobs:
2533

2634
build:
2735
needs: test
36+
permissions:
37+
contents: read
2838
strategy:
2939
matrix:
3040
include:
3141
- target: bun-darwin-arm64
32-
artifact: polar-darwin-arm64
33-
os: ubuntu-latest
42+
archive: polar-darwin-arm64.zip
43+
os: macos-15
44+
sign: true
45+
notarize: true
3446
- target: bun-darwin-x64
35-
artifact: polar-darwin-x64
36-
os: ubuntu-latest
47+
archive: polar-darwin-x64.zip
48+
os: macos-15
49+
sign: true
50+
notarize: true
3751
- target: bun-linux-x64
38-
artifact: polar-linux-x64
52+
archive: polar-linux-x64.tar.gz
3953
os: ubuntu-latest
54+
sign: false
55+
notarize: false
4056

4157
runs-on: ${{ matrix.os }}
4258

@@ -49,33 +65,108 @@ jobs:
4965

5066
- run: bun install
5167

68+
- name: Validate macOS signing configuration
69+
if: ${{ matrix.sign }}
70+
env:
71+
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
72+
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
73+
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
74+
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
75+
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
76+
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
77+
run: |
78+
test -n "$MACOS_CERTIFICATE_P12_BASE64" || { echo "Missing MACOS_CERTIFICATE_P12_BASE64 secret"; exit 1; }
79+
test -n "$MACOS_CERTIFICATE_PASSWORD" || { echo "Missing MACOS_CERTIFICATE_PASSWORD secret"; exit 1; }
80+
test -n "$MACOS_SIGNING_IDENTITY" || { echo "Missing MACOS_SIGNING_IDENTITY secret"; exit 1; }
81+
test -n "$APP_STORE_CONNECT_API_KEY_P8" || { echo "Missing APP_STORE_CONNECT_API_KEY_P8 secret"; exit 1; }
82+
test -n "$APP_STORE_CONNECT_API_KEY_ID" || { echo "Missing APP_STORE_CONNECT_API_KEY_ID secret"; exit 1; }
83+
test -n "$APP_STORE_CONNECT_ISSUER_ID" || { echo "Missing APP_STORE_CONNECT_ISSUER_ID secret"; exit 1; }
84+
85+
- name: Import macOS signing certificate
86+
if: ${{ matrix.sign }}
87+
uses: apple-actions/import-codesign-certs@v6
88+
with:
89+
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
90+
p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
91+
5292
- name: Build binary
5393
run: bun build ./src/cli.ts --compile --target=${{ matrix.target }} --outfile polar
5494

95+
- name: Sign macOS binary
96+
if: ${{ matrix.sign }}
97+
env:
98+
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
99+
run: |
100+
codesign --force --options runtime \
101+
--entitlements ./.github/macos-entitlements.plist \
102+
--sign "$MACOS_SIGNING_IDENTITY" \
103+
--timestamp \
104+
./polar
105+
106+
- name: Verify macOS signature
107+
if: ${{ matrix.sign }}
108+
run: codesign --verify --strict --verbose=2 ./polar
109+
110+
- name: Write App Store Connect API key
111+
if: ${{ matrix.notarize }}
112+
env:
113+
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
114+
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
115+
run: |
116+
key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
117+
printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" > "$key_path"
118+
chmod 600 "$key_path"
119+
echo "APP_STORE_CONNECT_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"
120+
55121
- name: Package binary
56-
run: tar -czf ${{ matrix.artifact }}.tar.gz polar
122+
run: |
123+
if [[ "${{ matrix.archive }}" == *.zip ]]; then
124+
ditto -c -k --keepParent polar "${{ matrix.archive }}"
125+
else
126+
tar -czf "${{ matrix.archive }}" polar
127+
fi
128+
129+
- name: Notarize macOS archive
130+
if: ${{ matrix.notarize }}
131+
env:
132+
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
133+
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
134+
run: |
135+
xcrun notarytool submit "${{ matrix.archive }}" \
136+
--key "$APP_STORE_CONNECT_API_KEY_PATH" \
137+
--key-id "$APP_STORE_CONNECT_API_KEY_ID" \
138+
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
139+
--wait
57140
58141
- uses: actions/upload-artifact@v4
59142
with:
60-
name: ${{ matrix.artifact }}
61-
path: ${{ matrix.artifact }}.tar.gz
143+
name: ${{ matrix.archive }}
144+
path: ${{ matrix.archive }}
62145

63146
release:
64147
needs: build
65148
runs-on: ubuntu-latest
149+
permissions:
150+
contents: write
66151

67152
steps:
68153
- uses: actions/download-artifact@v4
69154
with:
70155
merge-multiple: true
71156

72157
- name: Generate checksums
73-
run: sha256sum *.tar.gz > checksums.txt
158+
run: sha256sum *.tar.gz *.zip > checksums.txt
74159

75160
- name: Create GitHub Release
76161
uses: softprops/action-gh-release@v2
77162
with:
163+
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}
164+
target_commitish: ${{ github.sha }}
165+
draft: ${{ github.event_name == 'workflow_dispatch' }}
166+
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
167+
name: ${{ github.event_name == 'workflow_dispatch' && format('Signing Verification {0}', inputs.tag_name) || github.ref_name }}
78168
files: |
79169
*.tar.gz
170+
*.zip
80171
checksums.txt
81172
generate_release_notes: true

install.sh

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ get_latest_version() {
5050
echo "$version"
5151
}
5252

53+
get_archive_name() {
54+
local platform="$1"
55+
56+
case "$platform" in
57+
darwin-*) echo "${BINARY_NAME}-${platform}.zip" ;;
58+
*) echo "${BINARY_NAME}-${platform}.tar.gz" ;;
59+
esac
60+
}
61+
5362
main() {
5463
local platform version url
5564

@@ -61,7 +70,8 @@ main() {
6170
version="$(get_latest_version)"
6271
info "Version: ${version}"
6372

64-
local archive="${BINARY_NAME}-${platform}.tar.gz"
73+
local archive
74+
archive="$(get_archive_name "$platform")"
6575
local url="https://github.com/${REPO}/releases/download/${version}/${archive}"
6676
local checksums_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"
6777

@@ -94,7 +104,11 @@ main() {
94104
info "Checksum verified"
95105

96106
info "Extracting..."
97-
tar -xzf "${tmpdir}/${archive}" -C "$tmpdir"
107+
case "$archive" in
108+
*.zip) ditto -x -k "${tmpdir}/${archive}" "$tmpdir" ;;
109+
*.tar.gz) tar -xzf "${tmpdir}/${archive}" -C "$tmpdir" ;;
110+
*) error "Unsupported archive format: ${archive}" ;;
111+
esac
98112

99113
info "Installing to ${INSTALL_DIR}..."
100114
if [ -w "$INSTALL_DIR" ]; then

src/commands/update.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,51 @@ import { mkdtemp, rm, writeFile, readFile, stat } from "fs/promises";
33
import { tmpdir } from "os";
44
import { join } from "path";
55
import { Effect } from "effect";
6-
import { replaceBinary } from "./update";
6+
import {
7+
getArchiveExtractionCommand,
8+
getReleaseArchiveName,
9+
replaceBinary,
10+
} from "./update";
711

812
async function makeTemp() {
913
return mkdtemp(join(tmpdir(), "polar-test-"));
1014
}
1115

16+
describe("getReleaseArchiveName", () => {
17+
test("uses zip archives for darwin releases", () => {
18+
expect(getReleaseArchiveName({ os: "darwin", arch: "arm64" })).toBe(
19+
"polar-darwin-arm64.zip",
20+
);
21+
expect(getReleaseArchiveName({ os: "darwin", arch: "x64" })).toBe(
22+
"polar-darwin-x64.zip",
23+
);
24+
});
25+
26+
test("uses tar.gz archives for linux releases", () => {
27+
expect(getReleaseArchiveName({ os: "linux", arch: "x64" })).toBe(
28+
"polar-linux-x64.tar.gz",
29+
);
30+
});
31+
});
32+
33+
describe("getArchiveExtractionCommand", () => {
34+
test("uses ditto for zip archives", () => {
35+
expect(getArchiveExtractionCommand("/tmp/polar.zip", "/tmp/out")).toEqual([
36+
"ditto",
37+
"-x",
38+
"-k",
39+
"/tmp/polar.zip",
40+
"/tmp/out",
41+
]);
42+
});
43+
44+
test("uses tar for tar.gz archives", () => {
45+
expect(
46+
getArchiveExtractionCommand("/tmp/polar.tar.gz", "/tmp/out"),
47+
).toEqual(["tar", "-xzf", "/tmp/polar.tar.gz", "-C", "/tmp/out"]);
48+
});
49+
});
50+
1251
describe("replaceBinary", () => {
1352
let dir: string;
1453
let newBinaryPath: string;

src/commands/update.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,29 @@ function detectPlatform(): { os: string; arch: string } {
114114
return { os, arch: normalizedArch };
115115
}
116116

117+
export function getReleaseArchiveName(platform: {
118+
os: string;
119+
arch: string;
120+
}): string {
121+
const baseName = `polar-${platform.os}-${platform.arch}`;
122+
return platform.os === "darwin" ? `${baseName}.zip` : `${baseName}.tar.gz`;
123+
}
124+
125+
export function getArchiveExtractionCommand(
126+
archivePath: string,
127+
destinationDir: string,
128+
): string[] {
129+
if (archivePath.endsWith(".zip")) {
130+
return ["ditto", "-x", "-k", archivePath, destinationDir];
131+
}
132+
133+
if (archivePath.endsWith(".tar.gz")) {
134+
return ["tar", "-xzf", archivePath, "-C", destinationDir];
135+
}
136+
137+
throw new Error(`Unsupported archive format: ${archivePath}`);
138+
}
139+
117140
const downloadAndUpdate = (
118141
release: typeof GitHubRelease.Type,
119142
latestVersion: string,
@@ -127,7 +150,7 @@ const downloadAndUpdate = (
127150

128151
const { os, arch } = detectPlatform();
129152
const platform = `${os}-${arch}`;
130-
const archiveName = `polar-${platform}.tar.gz`;
153+
const archiveName = getReleaseArchiveName({ os, arch });
131154

132155
const asset = release.assets.find((a) => a.name === archiveName);
133156
if (!asset) {
@@ -216,20 +239,23 @@ const downloadAndUpdate = (
216239

217240
yield* Console.log(`${dim}Extracting...${reset}`);
218241

219-
const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], {
220-
stdout: "ignore",
221-
stderr: "pipe",
222-
});
242+
const extract = Bun.spawn(
243+
getArchiveExtractionCommand(archivePath, tempDir),
244+
{
245+
stdout: "ignore",
246+
stderr: "pipe",
247+
},
248+
);
223249

224-
const tarExitCode = yield* Effect.tryPromise({
225-
try: () => tar.exited,
250+
const extractExitCode = yield* Effect.tryPromise({
251+
try: () => extract.exited,
226252
catch: () => new Error("Failed to extract archive"),
227253
});
228254

229-
if (tarExitCode !== 0) {
255+
if (extractExitCode !== 0) {
230256
const stderr = yield* Effect.tryPromise({
231-
try: () => new Response(tar.stderr).text(),
232-
catch: () => new Error("Failed to read tar stderr"),
257+
try: () => new Response(extract.stderr).text(),
258+
catch: () => new Error("Failed to read archive extractor stderr"),
233259
});
234260
return yield* Effect.fail(
235261
new Error(`Failed to extract archive: ${stderr}`),

0 commit comments

Comments
 (0)