Skip to content
Merged
Show file tree
Hide file tree
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
16 changes: 16 additions & 0 deletions .github/macos-entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
111 changes: 101 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag_name:
description: "Draft verification tag to create, e.g. verify-signing-2026-04-16"
required: true
type: string

permissions:
contents: write
contents: read

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4
Expand All @@ -25,18 +33,26 @@ jobs:

build:
needs: test
permissions:
contents: read
strategy:
matrix:
include:
- target: bun-darwin-arm64
artifact: polar-darwin-arm64
os: ubuntu-latest
archive: polar-darwin-arm64.zip
os: macos-15
sign: true
notarize: true
- target: bun-darwin-x64
artifact: polar-darwin-x64
os: ubuntu-latest
archive: polar-darwin-x64.zip
os: macos-15
sign: true
notarize: true
- target: bun-linux-x64
artifact: polar-linux-x64
archive: polar-linux-x64.tar.gz
os: ubuntu-latest
sign: false
notarize: false

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

Expand All @@ -49,33 +65,108 @@ jobs:

- run: bun install

- name: Validate macOS signing configuration
if: ${{ matrix.sign }}
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
test -n "$MACOS_CERTIFICATE_P12_BASE64" || { echo "Missing MACOS_CERTIFICATE_P12_BASE64 secret"; exit 1; }
test -n "$MACOS_CERTIFICATE_PASSWORD" || { echo "Missing MACOS_CERTIFICATE_PASSWORD secret"; exit 1; }
test -n "$MACOS_SIGNING_IDENTITY" || { echo "Missing MACOS_SIGNING_IDENTITY secret"; exit 1; }
test -n "$APP_STORE_CONNECT_API_KEY_P8" || { echo "Missing APP_STORE_CONNECT_API_KEY_P8 secret"; exit 1; }
test -n "$APP_STORE_CONNECT_API_KEY_ID" || { echo "Missing APP_STORE_CONNECT_API_KEY_ID secret"; exit 1; }
test -n "$APP_STORE_CONNECT_ISSUER_ID" || { echo "Missing APP_STORE_CONNECT_ISSUER_ID secret"; exit 1; }

- name: Import macOS signing certificate
if: ${{ matrix.sign }}
uses: apple-actions/import-codesign-certs@v6
with:
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
p12-password: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}

- name: Build binary
run: bun build ./src/cli.ts --compile --target=${{ matrix.target }} --outfile polar

- name: Sign macOS binary
if: ${{ matrix.sign }}
env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
codesign --force --options runtime \
--entitlements ./.github/macos-entitlements.plist \
--sign "$MACOS_SIGNING_IDENTITY" \
--timestamp \
./polar

- name: Verify macOS signature
if: ${{ matrix.sign }}
run: codesign --verify --strict --verbose=2 ./polar

- name: Write App Store Connect API key
if: ${{ matrix.notarize }}
env:
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
run: |
key_path="$RUNNER_TEMP/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" > "$key_path"
chmod 600 "$key_path"
echo "APP_STORE_CONNECT_API_KEY_PATH=$key_path" >> "$GITHUB_ENV"

- name: Package binary
run: tar -czf ${{ matrix.artifact }}.tar.gz polar
run: |
if [[ "${{ matrix.archive }}" == *.zip ]]; then
ditto -c -k --keepParent polar "${{ matrix.archive }}"
else
tar -czf "${{ matrix.archive }}" polar
fi

- name: Notarize macOS archive
if: ${{ matrix.notarize }}
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
run: |
xcrun notarytool submit "${{ matrix.archive }}" \
--key "$APP_STORE_CONNECT_API_KEY_PATH" \
--key-id "$APP_STORE_CONNECT_API_KEY_ID" \
--issuer "$APP_STORE_CONNECT_ISSUER_ID" \
--wait

- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}.tar.gz
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}

release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true

- name: Generate checksums
run: sha256sum *.tar.gz > checksums.txt
run: sha256sum *.tar.gz *.zip > checksums.txt

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag_name || github.ref_name }}
target_commitish: ${{ github.sha }}
draft: ${{ github.event_name == 'workflow_dispatch' }}
prerelease: ${{ github.event_name == 'workflow_dispatch' }}
name: ${{ github.event_name == 'workflow_dispatch' && format('Signing Verification {0}', inputs.tag_name) || github.ref_name }}
files: |
*.tar.gz
*.zip
checksums.txt
generate_release_notes: true
18 changes: 16 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ get_latest_version() {
echo "$version"
}

get_archive_name() {
local platform="$1"

case "$platform" in
darwin-*) echo "${BINARY_NAME}-${platform}.zip" ;;
*) echo "${BINARY_NAME}-${platform}.tar.gz" ;;
esac
}

main() {
local platform version url

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

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

Expand Down Expand Up @@ -94,7 +104,11 @@ main() {
info "Checksum verified"

info "Extracting..."
tar -xzf "${tmpdir}/${archive}" -C "$tmpdir"
case "$archive" in
*.zip) ditto -x -k "${tmpdir}/${archive}" "$tmpdir" ;;
*.tar.gz) tar -xzf "${tmpdir}/${archive}" -C "$tmpdir" ;;
*) error "Unsupported archive format: ${archive}" ;;
esac

info "Installing to ${INSTALL_DIR}..."
if [ -w "$INSTALL_DIR" ]; then
Expand Down
41 changes: 40 additions & 1 deletion src/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,51 @@ import { mkdtemp, rm, writeFile, readFile, stat } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { Effect } from "effect";
import { replaceBinary } from "./update";
import {
getArchiveExtractionCommand,
getReleaseArchiveName,
replaceBinary,
} from "./update";

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

describe("getReleaseArchiveName", () => {
test("uses zip archives for darwin releases", () => {
expect(getReleaseArchiveName({ os: "darwin", arch: "arm64" })).toBe(
"polar-darwin-arm64.zip",
);
expect(getReleaseArchiveName({ os: "darwin", arch: "x64" })).toBe(
"polar-darwin-x64.zip",
);
});

test("uses tar.gz archives for linux releases", () => {
expect(getReleaseArchiveName({ os: "linux", arch: "x64" })).toBe(
"polar-linux-x64.tar.gz",
);
});
});

describe("getArchiveExtractionCommand", () => {
test("uses ditto for zip archives", () => {
expect(getArchiveExtractionCommand("/tmp/polar.zip", "/tmp/out")).toEqual([
"ditto",
"-x",
"-k",
"/tmp/polar.zip",
"/tmp/out",
]);
});

test("uses tar for tar.gz archives", () => {
expect(
getArchiveExtractionCommand("/tmp/polar.tar.gz", "/tmp/out"),
).toEqual(["tar", "-xzf", "/tmp/polar.tar.gz", "-C", "/tmp/out"]);
});
});

describe("replaceBinary", () => {
let dir: string;
let newBinaryPath: string;
Expand Down
46 changes: 36 additions & 10 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ function detectPlatform(): { os: string; arch: string } {
return { os, arch: normalizedArch };
}

export function getReleaseArchiveName(platform: {
os: string;
arch: string;
}): string {
const baseName = `polar-${platform.os}-${platform.arch}`;
return platform.os === "darwin" ? `${baseName}.zip` : `${baseName}.tar.gz`;
}

export function getArchiveExtractionCommand(
archivePath: string,
destinationDir: string,
): string[] {
if (archivePath.endsWith(".zip")) {
return ["ditto", "-x", "-k", archivePath, destinationDir];
}

if (archivePath.endsWith(".tar.gz")) {
return ["tar", "-xzf", archivePath, "-C", destinationDir];
}

throw new Error(`Unsupported archive format: ${archivePath}`);
}

const downloadAndUpdate = (
release: typeof GitHubRelease.Type,
latestVersion: string,
Expand All @@ -127,7 +150,7 @@ const downloadAndUpdate = (

const { os, arch } = detectPlatform();
const platform = `${os}-${arch}`;
const archiveName = `polar-${platform}.tar.gz`;
const archiveName = getReleaseArchiveName({ os, arch });

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

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

const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], {
stdout: "ignore",
stderr: "pipe",
});
const extract = Bun.spawn(
getArchiveExtractionCommand(archivePath, tempDir),
{
stdout: "ignore",
stderr: "pipe",
},
);

const tarExitCode = yield* Effect.tryPromise({
try: () => tar.exited,
const extractExitCode = yield* Effect.tryPromise({
try: () => extract.exited,
catch: () => new Error("Failed to extract archive"),
});

if (tarExitCode !== 0) {
if (extractExitCode !== 0) {
const stderr = yield* Effect.tryPromise({
try: () => new Response(tar.stderr).text(),
catch: () => new Error("Failed to read tar stderr"),
try: () => new Response(extract.stderr).text(),
catch: () => new Error("Failed to read archive extractor stderr"),
});
return yield* Effect.fail(
new Error(`Failed to extract archive: ${stderr}`),
Expand Down