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
58 changes: 58 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: release
on:
workflow_dispatch:
inputs:
publish:
description: Publish a GitHub Release for package.json's version
type: boolean
default: false
permissions:
contents: read
concurrency:
group: release
cancel-in-progress: false
jobs:
app:
runs-on: macos-15
timeout-minutes: 90
environment: release
permissions:
contents: write
env:
DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- run: npm ci
# The release imports the checked-in dist/, so it must match the source it ships.
- run: npm run check
- name: Install signing credentials
env:
DT_CERTIFICATE_P12: ${{ secrets.DT_CERTIFICATE_P12 }}
DT_CERTIFICATE_PASSWORD: ${{ secrets.DT_CERTIFICATE_PASSWORD }}
DT_NOTARY_PRIVATE_KEY: ${{ secrets.DT_NOTARY_PRIVATE_KEY }}
run: node scripts/credentials.ts
- name: Build, sign, and notarize
env:
DT_CODESIGN_IDENTITY: ${{ vars.DT_CODESIGN_IDENTITY }}
DT_NOTARY_KEY_ID: ${{ vars.DT_NOTARY_KEY_ID }}
DT_NOTARY_ISSUER: ${{ vars.DT_NOTARY_ISSUER }}
run: node scripts/release.ts
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-tools-macos-arm64
path: build/release/*.dmg
if-no-files-found: error
- name: Publish
if: inputs.publish
env:
GH_TOKEN: ${{ github.token }}
run: |
version=$(node -p "require('./package.json').version")
gh release create "v$version" build/release/*.dmg \
--target "$GITHUB_SHA" --title "Desktop Tools $version" --generate-notes
- name: Remove signing credentials
if: always()
run: node scripts/credentials.ts clean
54 changes: 54 additions & 0 deletions scripts/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Imports the release's Developer ID certificate into a disposable keychain and writes the notary
// key, or removes both with `clean`. Runs only on GitHub Actions, mirroring Ace's release.
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { appendFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";

function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before making a release`);
return value;
}

if (process.env.GITHUB_ACTIONS !== "true") {
throw new Error("This script only manages disposable GitHub Actions signing credentials");
}
const directory = join(required("RUNNER_TEMP"), "desktop-tools-signing");
const keychain = join(directory, "release.keychain-db");

function security(args: string[]) {
const result = spawnSync("/usr/bin/security", args, { stdio: "pipe" });
if (result.status !== 0) throw new Error(`Signing keychain setup failed at ${args[0]}`);
}

if (process.argv[2] === "clean") {
spawnSync("/usr/bin/security", ["delete-keychain", keychain], { stdio: "ignore" });
rmSync(directory, { recursive: true, force: true });
} else {
mkdirSync(directory, { recursive: true, mode: 0o700 });
const certificate = join(directory, "developer-id.p12");
const key = join(directory, "notary.p8");
writeFileSync(certificate, Buffer.from(required("DT_CERTIFICATE_P12"), "base64"), { mode: 0o600 });
writeFileSync(key, required("DT_NOTARY_PRIVATE_KEY"), { mode: 0o600 });
const password = randomUUID();
security(["create-keychain", "-p", password, keychain]);
security(["set-keychain-settings", "-lut", "21600", keychain]);
security(["unlock-keychain", "-p", password, keychain]);
security([
"import",
certificate,
"-k",
keychain,
"-P",
required("DT_CERTIFICATE_PASSWORD"),
"-T",
"/usr/bin/codesign",
"-T",
"/usr/bin/security",
]);
security(["set-key-partition-list", "-S", "apple-tool:,apple:", "-k", password, keychain]);
security(["list-keychains", "-d", "user", "-s", keychain]);
appendFileSync(required("GITHUB_ENV"), `DT_NOTARY_KEY=${key}\n`);
rmSync(certificate);
}
119 changes: 119 additions & 0 deletions scripts/release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Builds Desktop Tools.app, signs it inside-out, notarizes and staples the app and its DMG, and
// verifies both with Gatekeeper, mirroring Ace's desktop release. Publishing is the workflow's job.
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { basename, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

import { assemble, sign } from "../dist/app.js";

function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before making a release`);
return value;
}

function log(message: string): void {
console.log(`${new Date().toISOString()} ${message}`);
}

function run(command: string[]): void {
const result = spawnSync(command[0]!, command.slice(1), { stdio: "inherit" });
if (result.status !== 0) throw new Error(`${command[0]} failed with exit code ${result.status}`);
}

// Apple's ticket lookup intermittently drops connections (stapler exit 68) after notarization succeeds.
async function stapler(action: "staple" | "validate", path: string): Promise<void> {
for (let attempt = 1;; attempt++) {
try {
return run(["xcrun", "stapler", action, path]);
} catch (error) {
if (attempt === 5) throw error;
const seconds = 20 * 2 ** (attempt - 1);
log(`stapler ${action} attempt ${attempt} failed; retrying in ${seconds}s`);
await sleep(seconds * 1000);
}
}
}

const root = join(import.meta.dirname, "..");
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
version: string;
};
const changes = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" });
if (changes.status !== 0 || changes.stdout) {
throw new Error("Commit the lane's changes before making a release");
}
const identity = required("DT_CODESIGN_IDENTITY");
const auth = [
"--key",
required("DT_NOTARY_KEY"),
"--key-id",
required("DT_NOTARY_KEY_ID"),
"--issuer",
required("DT_NOTARY_ISSUER"),
];

function notary(args: string[]) {
return spawnSync("xcrun", ["notarytool", ...args, ...auth, "--output-format", "json"], {
encoding: "utf8",
});
}

function notarize(path: string): void {
const submitted = notary(["submit", path]);
if (submitted.status !== 0) throw new Error(`Notarization upload failed: ${submitted.stderr}`);
const { id } = JSON.parse(submitted.stdout) as { id: string };
log(`Notarization submitted for ${basename(path)}: ${id}`);
// Credentials are named by environment variable so the printed commands are safe to share.
const check = [
`xcrun notarytool info ${id} --key "$DT_NOTARY_KEY" --key-id "$DT_NOTARY_KEY_ID" --issuer "$DT_NOTARY_ISSUER"`,
`xcrun notarytool log ${id} --key "$DT_NOTARY_KEY" --key-id "$DT_NOTARY_KEY_ID" --issuer "$DT_NOTARY_ISSUER"`,
].join("\n");
// The service keeps processing past the wait's timeout, so info decides the outcome.
notary(["wait", id, "--timeout", "20m"]);
const info = notary(["info", id]);
if (info.status !== 0) {
throw new Error(`Notarization ${id} status is unavailable: ${info.stderr}\nCheck it later with:\n${check}`);
}
const { status } = JSON.parse(info.stdout) as { status: string };
if (status !== "Accepted") {
throw new Error(`Notarization ${id} finished with status ${status}. Check it with:\n${check}`);
}
log(`Notarization accepted: ${id}`);
}

const staging = join(root, ".build", `release-${randomUUID()}`);
const output = join(root, "build", "release");
mkdirSync(staging, { recursive: true });
mkdirSync(output, { recursive: true });
try {
log("==> Build");
const app = assemble(join(staging, "app"), join(root, ".build", "native"));
log("==> App signing");
sign(app, identity, true);
const zip = join(staging, "notarize.zip");
run(["/usr/bin/ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", app, zip]);
log("==> App notarization");
notarize(zip);
await stapler("staple", app);
run(["/usr/sbin/spctl", "--assess", "--type", "execute", app]);
log("==> DMG");
const volume = join(staging, "volume");
mkdirSync(volume);
run(["/usr/bin/ditto", app, join(volume, basename(app))]);
symlinkSync("/Applications", join(volume, "Applications"));
const dmg = join(output, `desktop-tools-${version}-macos-arm64.dmg`);
rmSync(dmg, { force: true });
run(["/usr/bin/hdiutil", "create", "-volname", "Desktop Tools", "-srcfolder", volume, "-format", "UDZO", dmg]);
run(["/usr/bin/codesign", "--force", "--sign", identity, "--timestamp", dmg]);
log("==> DMG notarization");
notarize(dmg);
await stapler("staple", dmg);
await stapler("validate", dmg);
run(["/usr/sbin/spctl", "--assess", "--type", "open", "--context", "context:primary-signature", dmg]);
log(`Release ready: ${dmg}`);
} finally {
rmSync(staging, { recursive: true, force: true });
}
Loading