diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1b74e60 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/scripts/credentials.ts b/scripts/credentials.ts new file mode 100644 index 0000000..c8889a2 --- /dev/null +++ b/scripts/credentials.ts @@ -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); +} diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..a52f4fc --- /dev/null +++ b/scripts/release.ts @@ -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 { + 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 }); +}