* docs(spec): feed-publishing + macOS signing design (#2220) Design for the electron-updater feed-publishing workstream plus the macOS code-signing half of Track B, shipped as one PR so a single review unblocks auto-update. Sidecar-only post-matrix join job emits latest*.yml / nightly*.yml + .blockmap sidecars on all three platforms (no maker changes, no artifact mutation). macOS signing reproduces the proven local runbook in CI: keychain provisioning, hardened-runtime entitlements, and notarization via the App Store Connect API key (.p8) path, with the osxNotarize cast fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(plan): feed-publishing + macOS signing implementation plan (#2220) Six TDD tasks: blockmap wrapper, feed module (selection + yml), osxNotarize API-key rewire, macOS signing-setup composite action, and the two workflow wirings (latest + nightly feed jobs). Two evidence-based simplifications vs the spec: no custom entitlements plist (default osxSign entitlements are proven by the local runbook) and no Node bump (CI already below the crash ceiling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(feed): blockmap sidecar wrapper over app-builder-lib Adds frontend/scripts/blockmap.mjs isolating the single fragile internal import (app-builder-lib/out/targets/blockmap/blockmap.js) behind a thin writeBlockmap(filePath) wrapper. Returns { sha512, size } only, omitting blockMapSize to force the sidecar differential path in electron-updater. Test added with // @vitest-environment node directive (required: project vitest config defaults to jsdom; same pattern as nightly-version.test.mjs). TDD: red on missing module, green after implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(feed): installer selection + electron-updater yml assembly Implement selectInstallers, feedFilename, and buildYml pure functions for packaging versioned installers and generating platform-specific feed metadata. Excludes ao-start aliases and deb/rpm packages. Generates blockmap sidecars and yml with deprecated top-level path/sha512 for compatibility, no blockMapSize (forces sidecar diff). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(sign): notarize via App Store Connect API key; drop osxNotarize cast Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ci): macOS signing-setup composite action Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ci): sign macOS + publish latest feed in the release workflow Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ci): sign macOS + publish nightly feed in the nightly workflow Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: format with prettier [skip ci] --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
a1789a3a1e
commit
5fa53ad976
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: macOS signing setup
|
||||||
|
description: >
|
||||||
|
Import the Developer ID certificate into a keychain and stage App Store Connect
|
||||||
|
API-key notarization credentials for a subsequent electron-forge publish.
|
||||||
|
inputs:
|
||||||
|
csc-link:
|
||||||
|
description: Base64 of the Developer ID Application .p12
|
||||||
|
required: true
|
||||||
|
csc-key-password:
|
||||||
|
description: Password for the .p12
|
||||||
|
required: true
|
||||||
|
apple-api-key-base64:
|
||||||
|
description: Base64 of the App Store Connect API key (.p8)
|
||||||
|
required: true
|
||||||
|
apple-api-key-id:
|
||||||
|
description: App Store Connect API key id
|
||||||
|
required: true
|
||||||
|
apple-api-issuer:
|
||||||
|
description: App Store Connect issuer uuid
|
||||||
|
required: true
|
||||||
|
apple-signing-identity:
|
||||||
|
description: "Developer ID Application: <Holder> (<TEAMID>)"
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
# Imports the .p12 into a temporary keychain so @electron/osx-sign can find
|
||||||
|
# the identity (forge, unlike electron-builder, does not decode CSC_LINK).
|
||||||
|
- uses: apple-actions/import-codesign-certs@v3
|
||||||
|
with:
|
||||||
|
p12-file-base64: ${{ inputs.csc-link }}
|
||||||
|
p12-password: ${{ inputs.csc-key-password }}
|
||||||
|
# Decode the .p8 to a file and export notarization env for the Publish step.
|
||||||
|
# @electron/notarize's appleApiKey is a FILE PATH, so we must materialize it.
|
||||||
|
# Inputs are passed via env (never interpolated into the script body) so a
|
||||||
|
# value's special characters cannot break or inject into the shell. Decoding
|
||||||
|
# is done with node (portable; avoids BSD-vs-GNU base64 flag differences on
|
||||||
|
# the macOS runner).
|
||||||
|
- shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_API_KEY_BASE64: ${{ inputs.apple-api-key-base64 }}
|
||||||
|
APPLE_API_KEY_ID: ${{ inputs.apple-api-key-id }}
|
||||||
|
APPLE_API_ISSUER: ${{ inputs.apple-api-issuer }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ inputs.apple-signing-identity }}
|
||||||
|
run: |
|
||||||
|
key_path="$RUNNER_TEMP/AuthKey.p8"
|
||||||
|
node -e "require('fs').writeFileSync('$key_path', Buffer.from(process.env.APPLE_API_KEY_BASE64, 'base64'))"
|
||||||
|
{
|
||||||
|
echo "APPLE_API_KEY=$key_path"
|
||||||
|
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID"
|
||||||
|
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER"
|
||||||
|
echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
@ -74,16 +74,58 @@ jobs:
|
||||||
NIGHTLY_VERSION: ${{ needs.guard.outputs.version }}
|
NIGHTLY_VERSION: ${{ needs.guard.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
node -e "const v=process.env.NIGHTLY_VERSION.split('+')[0]; const fs=require('fs'); const p=require('./package.json'); p.version=v; fs.writeFileSync('./package.json', JSON.stringify(p,null,'\t')+'\n');"
|
node -e "const v=process.env.NIGHTLY_VERSION.split('+')[0]; const fs=require('fs'); const p=require('./package.json'); p.version=v; fs.writeFileSync('./package.json', JSON.stringify(p,null,'\t')+'\n');"
|
||||||
|
- name: macOS signing setup
|
||||||
|
if: startsWith(matrix.os, 'macos')
|
||||||
|
uses: ./.github/actions/macos-signing-setup
|
||||||
|
with:
|
||||||
|
csc-link: ${{ secrets.CSC_LINK }}
|
||||||
|
csc-key-password: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
apple-api-key-base64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||||
|
apple-api-key-id: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: npm run publish
|
run: npm run publish
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
AO_RELEASE_REPO: ${{ github.repository }}
|
AO_RELEASE_REPO: ${{ github.repository }}
|
||||||
# Mark this a prerelease so it lands on electron-updater's nightly channel
|
|
||||||
# and never becomes the stable `latest` GitHub release.
|
|
||||||
AO_RELEASE_PRERELEASE: "true"
|
AO_RELEASE_PRERELEASE: "true"
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
# macOS signing + notarization env is exported by the "macOS signing
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
# setup" step above via $GITHUB_ENV; the cert is in the keychain.
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
# Generate the nightly electron-updater feed from the versioned prerelease
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
# assets. The nightly version is only stamped in-memory on the build runners,
|
||||||
|
# so derive the tag from the guard job's computed version, not package.json.
|
||||||
|
publish-feed:
|
||||||
|
needs: [guard, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: Generate and upload the nightly feed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NIGHTLY_VERSION: ${{ needs.guard.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Forge stamps package.json to VERSION without +build metadata and
|
||||||
|
# publishes to v<that>. Match it.
|
||||||
|
version="${NIGHTLY_VERSION%%+*}"
|
||||||
|
tag="v$version"
|
||||||
|
mkdir -p dist
|
||||||
|
gh release download "$tag" --dir dist --clobber
|
||||||
|
node scripts/feed.mjs dist "$version" nightly
|
||||||
|
shopt -s nullglob
|
||||||
|
assets=(dist/nightly*.yml dist/*.blockmap)
|
||||||
|
if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi
|
||||||
|
gh release upload "$tag" "${assets[@]}" --clobber
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,25 @@ jobs:
|
||||||
go-version-file: backend/go.mod
|
go-version-file: backend/go.mod
|
||||||
cache-dependency-path: backend/go.sum
|
cache-dependency-path: backend/go.sum
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- name: macOS signing setup
|
||||||
|
if: startsWith(matrix.os, 'macos')
|
||||||
|
uses: ./.github/actions/macos-signing-setup
|
||||||
|
with:
|
||||||
|
csc-link: ${{ secrets.CSC_LINK }}
|
||||||
|
csc-key-password: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
apple-api-key-base64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||||
|
apple-api-key-id: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: npm run publish
|
run: npm run publish
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Release target: the repo this run lives on. On the fork this is
|
|
||||||
# harshitsinghbhandari/agent-orchestrator; on AgentWrapper it is prod.
|
|
||||||
# forge.config.ts parses "owner/repo" into the publisher's repository.
|
|
||||||
AO_RELEASE_REPO: ${{ github.repository }}
|
AO_RELEASE_REPO: ${{ github.repository }}
|
||||||
# macOS signing + notarization — add as repository secrets and
|
# macOS signing + notarization env (APPLE_SIGNING_IDENTITY, APPLE_API_KEY,
|
||||||
# set osxSign/osxNotarize in forge.config.ts to enable.
|
# APPLE_API_KEY_ID, APPLE_API_ISSUER) is exported by the "macOS signing
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
# setup" step above via $GITHUB_ENV; the Developer ID cert is in the
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
# keychain. No-op on non-macOS runners.
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
|
|
||||||
# Upload stable, version-free asset aliases for this runner's platform so
|
# Upload stable, version-free asset aliases for this runner's platform so
|
||||||
# `ao start`'s constant releases/latest/download/<name> URL resolves. The
|
# `ao start`'s constant releases/latest/download/<name> URL resolves. The
|
||||||
|
|
@ -145,3 +149,39 @@ jobs:
|
||||||
gh release upload "$tag" "$alias_name" --clobber
|
gh release upload "$tag" "$alias_name" --clobber
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# After every platform has built and uploaded its installers, generate the
|
||||||
|
# electron-updater feed (latest*.yml + .blockmap sidecars) from the versioned
|
||||||
|
# assets and upload them to the same release. Runs once, on Linux: it only
|
||||||
|
# hashes already-built artifacts, so it needs no per-OS runner.
|
||||||
|
publish-feed:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
# feed.mjs imports app-builder-lib's blockmap generator, so deps are needed.
|
||||||
|
- run: npm ci
|
||||||
|
- name: Generate and upload the latest feed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Forge publishes to v<package.json version> (not the git tag).
|
||||||
|
tag="v$(node -p "require('./package.json').version")"
|
||||||
|
mkdir -p dist
|
||||||
|
gh release download "$tag" --dir dist --clobber
|
||||||
|
node scripts/feed.mjs dist "${tag#v}" latest
|
||||||
|
shopt -s nullglob
|
||||||
|
assets=(dist/latest*.yml dist/*.blockmap)
|
||||||
|
if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi
|
||||||
|
gh release upload "$tag" "${assets[@]}" --clobber
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,681 @@
|
||||||
|
# Feed publishing + macOS signing Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the merged auto-update runtime (#2221) functional by publishing electron-updater feeds (`latest*.yml`/`nightly*.yml` + `.blockmap` sidecars) on all three platforms and code-signing + notarizing the macOS build in CI.
|
||||||
|
|
||||||
|
**Architecture:** A post-matrix feed job per workflow hashes the already-uploaded versioned installers, writes gzip sidecar blockmaps + the channel yml (no `blockMapSize`, forcing the sidecar differential path), and uploads them. Blockmaps come from app-builder-lib's pure-JS `buildBlockMap`, isolated behind one wrapper module. macOS signing reproduces the proven local runbook in CI via a reusable composite action (keychain provisioning + App Store Connect API-key notarization).
|
||||||
|
|
||||||
|
**Tech Stack:** Node ESM scripts (mirroring `frontend/scripts/nightly-version.mjs`), vitest, electron-forge, `app-builder-lib@26.15.3` (transitive), GitHub Actions, `apple-actions/import-codesign-certs`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Git author email:** `dev@theharshitsingh.com`. Each commit ends with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. Use `git -c user.email=dev@theharshitsingh.com commit`.
|
||||||
|
- **No em dashes** anywhere (prose, code, comments, commit messages).
|
||||||
|
- **No `git add -A`** — add explicit paths only. Keep `status.md` / `handoff.md` untracked.
|
||||||
|
- **No backticks in `git commit -m`** — the messages in this plan contain none; keep it that way.
|
||||||
|
- **yml never emits `blockMapSize`** — its absence forces electron-updater onto the sidecar differential path on all platforms (verified contract).
|
||||||
|
- **Feed references versioned asset names** — electron-updater derives the old-version blockmap URL by string-substituting the version, so only versioned installers (not the `ao start` aliases) belong in the feed.
|
||||||
|
- **macOS zip arch discriminator is the literal substring `arm64` in the url** — the arm64 entry must contain `arm64`; the x64 entry must not. The arm64 entry is listed first.
|
||||||
|
- **Exclude `.deb` / `.rpm`** from feeds (system-package-managed, not electron-updater).
|
||||||
|
- **Channel names:** `latest` (stable workflow) and `nightly` (nightly workflow) -> `latest*.yml` / `nightly*.yml`.
|
||||||
|
- **Node stays at 20** on all runners (below the Node-26 `npm run make` crash ceiling per the runbook). Do not bump.
|
||||||
|
- **macOS signing creds (repo secrets):** `CSC_LINK` (base64 `.p12`), `CSC_KEY_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY_BASE64` (base64 `.p8`), `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`.
|
||||||
|
- **Windows code signing is out of scope.**
|
||||||
|
- **Spec deviations (intentional, evidence-based):** (a) no custom `entitlements.mac.plist` — the runbook proves default `@electron/osx-sign` entitlements notarize the app + daemon successfully; add one only if CI notarization rejects on an entitlement. (b) no Node bump — CI is already on a safe version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Blockmap wrapper module
|
||||||
|
|
||||||
|
Isolate the single fragile dependency: app-builder-lib's internal pure-JS blockmap generator. Everything that could break on an app-builder-lib upgrade lives in this one file, and it is smoke-tested first so the risk surfaces immediately.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `frontend/scripts/blockmap.mjs`
|
||||||
|
- Test: `frontend/scripts/blockmap.test.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: `writeBlockmap(filePath: string): Promise<{ sha512: string, size: number }>` — writes `<filePath>.blockmap` (gzip sidecar) and returns the file's base64 SHA-512 and raw byte size.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/scripts/blockmap.test.mjs
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { writeBlockmap } from "./blockmap.mjs";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
describe("writeBlockmap", () => {
|
||||||
|
it("writes a gzip sidecar and returns the file's base64 sha512 + size", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "bm-"));
|
||||||
|
const file = join(dir, "artifact.bin");
|
||||||
|
// ~200KB of varied bytes so the chunker produces multiple chunks.
|
||||||
|
const buf = Buffer.alloc(200_000);
|
||||||
|
for (let i = 0; i < buf.length; i++) buf[i] = (i * 37) % 256;
|
||||||
|
writeFileSync(file, buf);
|
||||||
|
|
||||||
|
const { sha512, size } = await writeBlockmap(file);
|
||||||
|
|
||||||
|
expect(size).toBe(buf.length);
|
||||||
|
// Must match electron-updater's expectation: base64 SHA-512 of the raw file.
|
||||||
|
expect(sha512).toBe(createHash("sha512").update(buf).digest("base64"));
|
||||||
|
// Sidecar exists, is non-empty, and is gzip (magic bytes 1f 8b).
|
||||||
|
expect(existsSync(`${file}.blockmap`)).toBe(true);
|
||||||
|
const sidecar = readFileSync(`${file}.blockmap`);
|
||||||
|
expect(sidecar.length).toBeGreaterThan(0);
|
||||||
|
expect(sidecar[0]).toBe(0x1f);
|
||||||
|
expect(sidecar[1]).toBe(0x8b);
|
||||||
|
expect(statSync(`${file}.blockmap`).size).toBe(sidecar.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run scripts/blockmap.test.mjs`
|
||||||
|
Expected: FAIL — `Cannot find module './blockmap.mjs'` (or `writeBlockmap is not a function`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/scripts/blockmap.mjs
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// The ONE fragile import. app-builder-lib 26.x generates blockmaps in pure JS
|
||||||
|
// (there is no app-builder CLI / app-builder-bin in this tree). This internal
|
||||||
|
// path is pinned via package-lock; if it moves on a major upgrade, only this
|
||||||
|
// file changes. Smoke-tested by blockmap.test.mjs.
|
||||||
|
const { buildBlockMap } = require("app-builder-lib/out/targets/blockmap/blockmap.js");
|
||||||
|
|
||||||
|
// writeBlockmap creates "<filePath>.blockmap" (gzip sidecar) and returns the
|
||||||
|
// file's base64 sha512 + byte size, exactly as electron-updater reads them from
|
||||||
|
// the feed yml. We deliberately do NOT surface blockMapSize: omitting it from
|
||||||
|
// the yml forces the client onto the sidecar differential path on every
|
||||||
|
// platform (verified against MacUpdater / NsisUpdater / AppImage).
|
||||||
|
export async function writeBlockmap(filePath) {
|
||||||
|
const { sha512, size } = await buildBlockMap(filePath, "gzip", `${filePath}.blockmap`);
|
||||||
|
return { sha512, size };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run scripts/blockmap.test.mjs`
|
||||||
|
Expected: PASS (1 test). If it fails on the import, the internal path changed — stop and report; the wrapper is the agreed isolation point.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/scripts/blockmap.mjs frontend/scripts/blockmap.test.mjs
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(feed): blockmap sidecar wrapper over app-builder-lib"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Feed module (installer selection + yml assembly)
|
||||||
|
|
||||||
|
The pure, tested heart of the feature. Given a directory of downloaded release assets, a version, and a channel, it picks the right installers, writes sidecar blockmaps, and emits the channel yml files.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `frontend/scripts/feed.mjs`
|
||||||
|
- Test: `frontend/scripts/feed.test.mjs`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `writeBlockmap` from Task 1.
|
||||||
|
- Produces:
|
||||||
|
- `selectInstallers(filenames: string[], version: string): { win: string[], linux: string[], macArm64: string[], macX64: string[] }`
|
||||||
|
- `feedFilename(channel: string, platform: "win"|"linux"|"mac"): string`
|
||||||
|
- `buildYml(version: string, files: {url,sha512,size}[], releaseDate: string): string`
|
||||||
|
- CLI: `node scripts/feed.mjs <dir> <version> <channel>` writes `<dir>/<channel>*.yml` + `<dir>/<asset>.blockmap`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/scripts/feed.test.mjs
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { selectInstallers, feedFilename, buildYml } from "./feed.mjs";
|
||||||
|
|
||||||
|
const V = "0.10.4";
|
||||||
|
const NAMES = [
|
||||||
|
"Agent.Orchestrator.Setup.0.10.4.exe", // win versioned
|
||||||
|
"Agent.Orchestrator-0.10.4.AppImage", // linux versioned
|
||||||
|
"Agent.Orchestrator-darwin-arm64-0.10.4.zip", // mac arm64 versioned
|
||||||
|
"Agent.Orchestrator-darwin-x64-0.10.4.zip", // mac x64 versioned
|
||||||
|
"agent-orchestrator-darwin-arm64.zip", // ao-start alias (no version) -> excluded
|
||||||
|
"agent-orchestrator-win32-x64.exe", // alias (no version) -> excluded
|
||||||
|
"agent-orchestrator_0.10.4_amd64.deb", // deb -> excluded by extension
|
||||||
|
"agent-orchestrator-0.10.4.x86_64.rpm", // rpm -> excluded by extension
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("selectInstallers", () => {
|
||||||
|
it("keeps only versioned exe/AppImage/darwin-zip, split by arch", () => {
|
||||||
|
const s = selectInstallers(NAMES, V);
|
||||||
|
expect(s.win).toEqual(["Agent.Orchestrator.Setup.0.10.4.exe"]);
|
||||||
|
expect(s.linux).toEqual(["Agent.Orchestrator-0.10.4.AppImage"]);
|
||||||
|
expect(s.macArm64).toEqual(["Agent.Orchestrator-darwin-arm64-0.10.4.zip"]);
|
||||||
|
expect(s.macX64).toEqual(["Agent.Orchestrator-darwin-x64-0.10.4.zip"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feedFilename", () => {
|
||||||
|
it("maps channel + platform to electron-updater names", () => {
|
||||||
|
expect(feedFilename("latest", "win")).toBe("latest.yml");
|
||||||
|
expect(feedFilename("latest", "mac")).toBe("latest-mac.yml");
|
||||||
|
expect(feedFilename("latest", "linux")).toBe("latest-linux.yml");
|
||||||
|
expect(feedFilename("nightly", "win")).toBe("nightly.yml");
|
||||||
|
expect(feedFilename("nightly", "mac")).toBe("nightly-mac.yml");
|
||||||
|
expect(feedFilename("nightly", "linux")).toBe("nightly-linux.yml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildYml", () => {
|
||||||
|
it("serializes one file with deprecated top-level fields and no blockMapSize", () => {
|
||||||
|
const yml = buildYml(
|
||||||
|
"0.10.4",
|
||||||
|
[{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }],
|
||||||
|
"2026-06-27T12:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(yml).toBe(
|
||||||
|
"version: 0.10.4\n" +
|
||||||
|
"files:\n" +
|
||||||
|
" - url: Agent.Orchestrator.Setup.0.10.4.exe\n" +
|
||||||
|
" sha512: AA/BB+cc==\n" +
|
||||||
|
" size: 123\n" +
|
||||||
|
"path: Agent.Orchestrator.Setup.0.10.4.exe\n" +
|
||||||
|
"sha512: AA/BB+cc==\n" +
|
||||||
|
"releaseDate: '2026-06-27T12:00:00.000Z'\n",
|
||||||
|
);
|
||||||
|
expect(yml).not.toContain("blockMapSize");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists both mac arches with arm64 first and points top-level at arm64", () => {
|
||||||
|
const yml = buildYml(
|
||||||
|
"0.10.4",
|
||||||
|
[
|
||||||
|
{ url: "Agent.Orchestrator-darwin-arm64-0.10.4.zip", sha512: "ARM==", size: 10 },
|
||||||
|
{ url: "Agent.Orchestrator-darwin-x64-0.10.4.zip", sha512: "X64==", size: 20 },
|
||||||
|
],
|
||||||
|
"2026-06-27T12:00:00.000Z",
|
||||||
|
);
|
||||||
|
const lines = yml.split("\n");
|
||||||
|
expect(lines[2]).toBe(" - url: Agent.Orchestrator-darwin-arm64-0.10.4.zip");
|
||||||
|
expect(lines[5]).toBe(" - url: Agent.Orchestrator-darwin-x64-0.10.4.zip");
|
||||||
|
expect(yml).toContain("path: Agent.Orchestrator-darwin-arm64-0.10.4.zip");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run scripts/feed.test.mjs`
|
||||||
|
Expected: FAIL — `Cannot find module './feed.mjs'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/scripts/feed.mjs
|
||||||
|
// Generates electron-updater feed metadata (latest*.yml / nightly*.yml) plus
|
||||||
|
// gzip sidecar blockmaps for a release's versioned installers. Dependency-free
|
||||||
|
// ESM (mirrors nightly-version.mjs) so CI runs `node scripts/feed.mjs` directly
|
||||||
|
// and vitest unit-tests the pure functions. The only non-stdlib reach is the
|
||||||
|
// blockmap wrapper (Task 1).
|
||||||
|
import { readdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { writeBlockmap } from "./blockmap.mjs";
|
||||||
|
|
||||||
|
// selectInstallers picks the versioned, auto-updatable installers from a release
|
||||||
|
// download dir, grouped by platform/arch. Excludes the ao-start aliases (no
|
||||||
|
// version string in their names) and deb/rpm (system-package-managed). The mac
|
||||||
|
// arch split keys on the literal "arm64" substring, the same discriminator the
|
||||||
|
// updater (MacUpdater.filterFilesForArch) uses.
|
||||||
|
export function selectInstallers(filenames, version) {
|
||||||
|
const versioned = filenames.filter((f) => f.includes(version));
|
||||||
|
const isDarwinZip = (f) => f.endsWith(".zip") && f.includes("darwin");
|
||||||
|
return {
|
||||||
|
win: versioned.filter((f) => f.endsWith(".exe")),
|
||||||
|
linux: versioned.filter((f) => f.endsWith(".AppImage")),
|
||||||
|
macArm64: versioned.filter((f) => isDarwinZip(f) && f.includes("arm64")),
|
||||||
|
macX64: versioned.filter((f) => isDarwinZip(f) && !f.includes("arm64")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedFilename maps (channel, platform) to electron-updater's expected feed name.
|
||||||
|
// The updater adds its own OS/arch suffix client-side; we name the published
|
||||||
|
// asset to match: "" (win), "-mac", "-linux" (x64 Linux).
|
||||||
|
export function feedFilename(channel, platform) {
|
||||||
|
const suffix = platform === "mac" ? "-mac" : platform === "linux" ? "-linux" : "";
|
||||||
|
return `${channel}${suffix}.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildYml serializes one platform's feed. files is [{ url, sha512, size }];
|
||||||
|
// for mac the arm64 entry comes first. The deprecated top-level path/sha512
|
||||||
|
// point at files[0]. blockMapSize is never written (forces sidecar differential).
|
||||||
|
export function buildYml(version, files, releaseDate) {
|
||||||
|
const lines = [`version: ${version}`, "files:"];
|
||||||
|
for (const f of files) {
|
||||||
|
lines.push(` - url: ${f.url}`);
|
||||||
|
lines.push(` sha512: ${f.sha512}`);
|
||||||
|
lines.push(` size: ${f.size}`);
|
||||||
|
}
|
||||||
|
lines.push(`path: ${files[0].url}`);
|
||||||
|
lines.push(`sha512: ${files[0].sha512}`);
|
||||||
|
lines.push(`releaseDate: '${releaseDate}'`);
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFeeds writes the yml + sidecar blockmaps for every platform present in
|
||||||
|
// dir. version may carry +build metadata (nightly); strip it for the yml.
|
||||||
|
async function generateFeeds(dir, rawVersion, channel, releaseDate) {
|
||||||
|
const version = rawVersion.split("+")[0];
|
||||||
|
const sel = selectInstallers(readdirSync(dir), version);
|
||||||
|
const groups = [
|
||||||
|
{ platform: "win", names: sel.win },
|
||||||
|
{ platform: "linux", names: sel.linux },
|
||||||
|
{ platform: "mac", names: [...sel.macArm64, ...sel.macX64] }, // arm64 first
|
||||||
|
];
|
||||||
|
for (const { platform, names } of groups) {
|
||||||
|
if (names.length === 0) continue;
|
||||||
|
const files = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const { sha512, size } = await writeBlockmap(join(dir, name));
|
||||||
|
files.push({ url: name, sha512, size });
|
||||||
|
}
|
||||||
|
writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node scripts/feed.mjs <dir> <version> <channel>
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const [, , dir, version, channel] = process.argv;
|
||||||
|
if (!dir || !version || !channel) {
|
||||||
|
process.stderr.write("usage: node feed.mjs <dir> <version> <channel>\n");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
generateFeeds(dir, version, channel, new Date().toISOString()).catch((err) => {
|
||||||
|
process.stderr.write(`${err.stack || err}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run scripts/feed.test.mjs`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full scripts test set to confirm no regression**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run scripts/`
|
||||||
|
Expected: PASS — `blockmap.test.mjs`, `feed.test.mjs`, `nightly-version.test.mjs` all green.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/scripts/feed.mjs frontend/scripts/feed.test.mjs
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(feed): installer selection + electron-updater yml assembly"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Rewire macOS notarization to the App Store Connect API key
|
||||||
|
|
||||||
|
Switch `osxNotarize` to the `.p8` API-key variant (matching the proven local creds) and delete the `as unknown as ...` double cast that was the one known typecheck error. `osxSign` is unchanged — the runbook proves `{ identity }` + default entitlements notarize the app and the bundled daemon.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `frontend/forge.config.ts:46-57`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Produces: a `forge.config.ts` whose `osxNotarize` reads `APPLE_API_KEY` / `APPLE_API_KEY_ID` / `APPLE_API_ISSUER` in CI (and keeps `AO_NOTARY_PROFILE` for the local runbook), consumed by the workflows in Tasks 5-6.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the current typecheck error exists**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run typecheck`
|
||||||
|
Expected: exactly one error, at `forge.config.ts` around line 50, on the `as unknown as ForgeConfig["packagerConfig"]["osxNotarize"]` cast. (This is the documented pre-existing error.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `osxNotarize` block**
|
||||||
|
|
||||||
|
Replace `frontend/forge.config.ts:46-57` (the current `osxNotarize: process.env.AO_NOTARY_PROFILE ? ({ tool: "notarytool", ... } as unknown as ...) : process.env.APPLE_ID ? { appleId, ... } : undefined,`) with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Notarization. Two paths:
|
||||||
|
// - CI: an App Store Connect API key. APPLE_API_KEY is a PATH to the .p8
|
||||||
|
// (the workflow decodes APPLE_API_KEY_BASE64 to a temp file), plus the
|
||||||
|
// key id + issuer uuid. Matches the proven local runbook creds.
|
||||||
|
// - Local: AO_NOTARY_PROFILE, a notarytool keychain profile created with
|
||||||
|
// `notarytool store-credentials`. See ao-macos-signed-release runbook.
|
||||||
|
// Both are valid NotaryToolCredentials, so no cast is needed.
|
||||||
|
osxNotarize: process.env.AO_NOTARY_PROFILE
|
||||||
|
? { keychainProfile: process.env.AO_NOTARY_PROFILE }
|
||||||
|
: process.env.APPLE_API_KEY
|
||||||
|
? {
|
||||||
|
appleApiKey: process.env.APPLE_API_KEY,
|
||||||
|
appleApiKeyId: process.env.APPLE_API_KEY_ID!,
|
||||||
|
appleApiIssuer: process.env.APPLE_API_ISSUER!,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the signing comment block above `osxSign` (`forge.config.ts:34-40`) so the CI line reads: `CI: set CSC_LINK/CSC_KEY_PASSWORD + APPLE_SIGNING_IDENTITY for signing, and APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER for notarization.` Leave the `osxSign` expression itself unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck to verify the error is gone**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run typecheck`
|
||||||
|
Expected: PASS — zero errors (the cast is gone and both notarize branches are correctly typed).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/forge.config.ts
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(sign): notarize via App Store Connect API key; drop osxNotarize cast"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: macOS signing-setup composite action
|
||||||
|
|
||||||
|
One reusable action provisions everything signing/notarization needs on the macOS runners: imports the Developer ID cert into a keychain (forge does not decode `CSC_LINK` itself), decodes the `.p8`, and exports the notarization env. Used by both workflows so they cannot drift.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `.github/actions/macos-signing-setup/action.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes (inputs): `csc-link`, `csc-key-password`, `apple-api-key-base64`, `apple-api-key-id`, `apple-api-issuer`, `apple-signing-identity`.
|
||||||
|
- Produces (via `$GITHUB_ENV`, for the later Publish step in the same job): `APPLE_API_KEY` (path to the decoded `.p8`), `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, `APPLE_SIGNING_IDENTITY`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the composite action**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/actions/macos-signing-setup/action.yml
|
||||||
|
name: macOS signing setup
|
||||||
|
description: >
|
||||||
|
Import the Developer ID certificate into a keychain and stage App Store Connect
|
||||||
|
API-key notarization credentials for a subsequent electron-forge publish.
|
||||||
|
inputs:
|
||||||
|
csc-link:
|
||||||
|
description: Base64 of the Developer ID Application .p12
|
||||||
|
required: true
|
||||||
|
csc-key-password:
|
||||||
|
description: Password for the .p12
|
||||||
|
required: true
|
||||||
|
apple-api-key-base64:
|
||||||
|
description: Base64 of the App Store Connect API key (.p8)
|
||||||
|
required: true
|
||||||
|
apple-api-key-id:
|
||||||
|
description: App Store Connect API key id
|
||||||
|
required: true
|
||||||
|
apple-api-issuer:
|
||||||
|
description: App Store Connect issuer uuid
|
||||||
|
required: true
|
||||||
|
apple-signing-identity:
|
||||||
|
description: "Developer ID Application: <Holder> (<TEAMID>)"
|
||||||
|
required: true
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
# Imports the .p12 into a temporary keychain so @electron/osx-sign can find
|
||||||
|
# the identity (forge, unlike electron-builder, does not decode CSC_LINK).
|
||||||
|
- uses: apple-actions/import-codesign-certs@v3
|
||||||
|
with:
|
||||||
|
p12-file-base64: ${{ inputs.csc-link }}
|
||||||
|
p12-password: ${{ inputs.csc-key-password }}
|
||||||
|
# Decode the .p8 to a file and export notarization env for the Publish step.
|
||||||
|
# @electron/notarize's appleApiKey is a FILE PATH, so we must materialize it.
|
||||||
|
# Inputs are passed via env (never interpolated into the script body) so a
|
||||||
|
# value's special characters cannot break or inject into the shell. Decoding
|
||||||
|
# is done with node (portable; avoids BSD-vs-GNU base64 flag differences on
|
||||||
|
# the macOS runner).
|
||||||
|
- shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_API_KEY_BASE64: ${{ inputs.apple-api-key-base64 }}
|
||||||
|
APPLE_API_KEY_ID: ${{ inputs.apple-api-key-id }}
|
||||||
|
APPLE_API_ISSUER: ${{ inputs.apple-api-issuer }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ inputs.apple-signing-identity }}
|
||||||
|
run: |
|
||||||
|
key_path="$RUNNER_TEMP/AuthKey.p8"
|
||||||
|
node -e "require('fs').writeFileSync('$key_path', Buffer.from(process.env.APPLE_API_KEY_BASE64, 'base64'))"
|
||||||
|
{
|
||||||
|
echo "APPLE_API_KEY=$key_path"
|
||||||
|
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID"
|
||||||
|
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER"
|
||||||
|
echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate the action YAML parses**
|
||||||
|
|
||||||
|
Run (from the repo root): `cd frontend && node -e "const y=require('js-yaml'); y.load(require('fs').readFileSync('../.github/actions/macos-signing-setup/action.yml','utf8')); console.log('ok')"`
|
||||||
|
Expected: prints `ok`. (`js-yaml` resolves from `frontend/node_modules` — it is a transitive dep of app-builder-lib, present after `npm ci`.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/actions/macos-signing-setup/action.yml
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(ci): macOS signing-setup composite action"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Wire the stable release workflow (signing + feed job)
|
||||||
|
|
||||||
|
Add the signing-setup step on the macOS legs, swap the publish env to the API-key creds, and append a post-matrix `publish-feed` job that emits the `latest*` feed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `.github/workflows/frontend-release.yml` (the `release` job steps around `:62-77`; add a new `publish-feed` job at the end)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `macos-signing-setup` (Task 4); `frontend/scripts/feed.mjs` (Task 2).
|
||||||
|
- Produces: `latest.yml` / `latest-mac.yml` / `latest-linux.yml` + `.blockmap` sidecars on the `v<version>` release.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the signing-setup step on macOS, before Publish**
|
||||||
|
|
||||||
|
In `.github/workflows/frontend-release.yml`, between the `- run: npm ci` step and the `- name: Publish` step, insert:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: macOS signing setup
|
||||||
|
if: startsWith(matrix.os, 'macos')
|
||||||
|
uses: ./.github/actions/macos-signing-setup
|
||||||
|
with:
|
||||||
|
csc-link: ${{ secrets.CSC_LINK }}
|
||||||
|
csc-key-password: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
apple-api-key-base64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||||
|
apple-api-key-id: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `uses:` steps cannot set `working-directory`, which is fine — the composite action manages its own paths.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the Publish step's apple env**
|
||||||
|
|
||||||
|
In the `- name: Publish` step's `env:` block, replace the five lines `CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` with a comment only — the signing-setup step already exported `APPLE_API_KEY*` and `APPLE_SIGNING_IDENTITY` via `$GITHUB_ENV`, and the cert is in the keychain. Resulting env block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
AO_RELEASE_REPO: ${{ github.repository }}
|
||||||
|
# macOS signing + notarization env (APPLE_SIGNING_IDENTITY, APPLE_API_KEY,
|
||||||
|
# APPLE_API_KEY_ID, APPLE_API_ISSUER) is exported by the "macOS signing
|
||||||
|
# setup" step above via $GITHUB_ENV; the Developer ID cert is in the
|
||||||
|
# keychain. No-op on non-macOS runners.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Append the `publish-feed` job**
|
||||||
|
|
||||||
|
At the end of `.github/workflows/frontend-release.yml`, add a new top-level job (sibling of `release`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# After every platform has built and uploaded its installers, generate the
|
||||||
|
# electron-updater feed (latest*.yml + .blockmap sidecars) from the versioned
|
||||||
|
# assets and upload them to the same release. Runs once, on Linux: it only
|
||||||
|
# hashes already-built artifacts, so it needs no per-OS runner.
|
||||||
|
publish-feed:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
# feed.mjs imports app-builder-lib's blockmap generator, so deps are needed.
|
||||||
|
- run: npm ci
|
||||||
|
- name: Generate and upload the latest feed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Forge publishes to v<package.json version> (not the git tag).
|
||||||
|
tag="v$(node -p "require('./package.json').version")"
|
||||||
|
mkdir -p dist
|
||||||
|
gh release download "$tag" --dir dist --clobber
|
||||||
|
node scripts/feed.mjs dist "${tag#v}" latest
|
||||||
|
shopt -s nullglob
|
||||||
|
assets=(dist/latest*.yml dist/*.blockmap)
|
||||||
|
if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi
|
||||||
|
gh release upload "$tag" "${assets[@]}" --clobber
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate the workflow YAML parses**
|
||||||
|
|
||||||
|
Run: `cd frontend && node -e "const y=require('js-yaml'); y.load(require('fs').readFileSync('../.github/workflows/frontend-release.yml','utf8')); console.log('ok')"`
|
||||||
|
Expected: prints `ok`. If `actionlint` is installed, also run `actionlint .github/workflows/frontend-release.yml` from the repo root and expect no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/frontend-release.yml
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(ci): sign macOS + publish latest feed in the release workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire the nightly workflow (signing + feed job)
|
||||||
|
|
||||||
|
Mirror Task 5 on the nightly workflow, with `channel=nightly` and the tag derived from the guard job's computed version (the nightly version is stamped in-memory on the build runners only, so the feed job cannot read it from `package.json`).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `.github/workflows/frontend-nightly.yml` (the `release` job steps around `:70-89`; add a `publish-feed` job)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `macos-signing-setup` (Task 4); `frontend/scripts/feed.mjs` (Task 2); `needs.guard.outputs.version` (existing).
|
||||||
|
- Produces: `nightly.yml` / `nightly-mac.yml` / `nightly-linux.yml` + `.blockmap` sidecars on the nightly prerelease.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the signing-setup step on macOS, before Publish**
|
||||||
|
|
||||||
|
In `.github/workflows/frontend-nightly.yml`, between the `- name: Stamp nightly version` step and the `- name: Publish` step, insert the same block as Task 5 Step 1:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: macOS signing setup
|
||||||
|
if: startsWith(matrix.os, 'macos')
|
||||||
|
uses: ./.github/actions/macos-signing-setup
|
||||||
|
with:
|
||||||
|
csc-link: ${{ secrets.CSC_LINK }}
|
||||||
|
csc-key-password: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||||
|
apple-api-key-base64: ${{ secrets.APPLE_API_KEY_BASE64 }}
|
||||||
|
apple-api-key-id: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
apple-api-issuer: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
apple-signing-identity: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the Publish step's apple env**
|
||||||
|
|
||||||
|
In the nightly `- name: Publish` step's `env:` block, replace the five `CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` lines with the same comment-only note as Task 5 Step 2, keeping `GITHUB_TOKEN`, `AO_RELEASE_REPO`, and `AO_RELEASE_PRERELEASE: "true"`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
AO_RELEASE_REPO: ${{ github.repository }}
|
||||||
|
AO_RELEASE_PRERELEASE: "true"
|
||||||
|
# macOS signing + notarization env is exported by the "macOS signing
|
||||||
|
# setup" step above via $GITHUB_ENV; the cert is in the keychain.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Append the `publish-feed` job**
|
||||||
|
|
||||||
|
At the end of `.github/workflows/frontend-nightly.yml`, add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Generate the nightly electron-updater feed from the versioned prerelease
|
||||||
|
# assets. The nightly version is only stamped in-memory on the build runners,
|
||||||
|
# so derive the tag from the guard job's computed version, not package.json.
|
||||||
|
publish-feed:
|
||||||
|
needs: [guard, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
- name: Generate and upload the nightly feed
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NIGHTLY_VERSION: ${{ needs.guard.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Forge stamps package.json to VERSION without +build metadata and
|
||||||
|
# publishes to v<that>. Match it.
|
||||||
|
version="${NIGHTLY_VERSION%%+*}"
|
||||||
|
tag="v$version"
|
||||||
|
mkdir -p dist
|
||||||
|
gh release download "$tag" --dir dist --clobber
|
||||||
|
node scripts/feed.mjs dist "$version" nightly
|
||||||
|
shopt -s nullglob
|
||||||
|
assets=(dist/nightly*.yml dist/*.blockmap)
|
||||||
|
if [ ${#assets[@]} -eq 0 ]; then echo "no feed assets generated" >&2; exit 1; fi
|
||||||
|
gh release upload "$tag" "${assets[@]}" --clobber
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate the workflow YAML parses**
|
||||||
|
|
||||||
|
Run: `cd frontend && node -e "const y=require('js-yaml'); y.load(require('fs').readFileSync('../.github/workflows/frontend-nightly.yml','utf8')); console.log('ok')"`
|
||||||
|
Expected: prints `ok`. If `actionlint` is installed, run it on the file too.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/frontend-nightly.yml
|
||||||
|
git -c user.email=dev@theharshitsingh.com commit -m "feat(ci): sign macOS + publish nightly feed in the nightly workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-implementation verification (CI-only, after the PR is up)
|
||||||
|
|
||||||
|
These cannot be checked locally; record them on the PR and validate on the first real run (test on the fork first, per the spec's human-prerequisites section):
|
||||||
|
|
||||||
|
1. A fork `desktop-v*` run produces `latest.yml` / `latest-mac.yml` / `latest-linux.yml` + `.blockmap` sidecars on the `v<version>` release (HTTP 200 each).
|
||||||
|
2. `latest-mac.yml` lists two `files` entries, the `arm64` one first, and neither carries `blockMapSize`.
|
||||||
|
3. The macOS zips and the bundled daemon are signed + notarized: `spctl -a -t exec -vvv <app>` reports `source=Notarized Developer ID` (reuse the runbook's verify block).
|
||||||
|
4. An installed older build auto-updates: win/linux end to end; macOS once the signing secrets are present.
|
||||||
|
5. Risk to watch: forge `maker-zip`'s mac zip being a Squirrel.Mac-acceptable update payload (the runbook proves it is a launchable signed `.app`; the updater-payload path is the last unknown).
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
# Feed publishing + macOS signing (auto-update prerequisites)
|
||||||
|
|
||||||
|
_Design spec. 2026-06-27. Branch off `main`. Closes the #2220 feed-publishing
|
||||||
|
gap and folds in the macOS-signing half of Track B, so the auto-update runtime
|
||||||
|
(PR #2221) becomes functional. Ships as ONE PR._
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The auto-update runtime (electron-updater wiring, settings, prompts) landed inert
|
||||||
|
in PR #2221 because **no platform publishes an electron-updater feed**. This spec
|
||||||
|
makes the feed exist and makes macOS updates actually apply:
|
||||||
|
|
||||||
|
1. **Feed publishing (all 3 platforms).** Generate and upload the channel
|
||||||
|
metadata (`latest*.yml` / `nightly*.yml`) and per-artifact `.blockmap`
|
||||||
|
sidecars to each GitHub release, so electron-updater can see and download
|
||||||
|
updates.
|
||||||
|
2. **macOS code-signing + notarization in CI.** Squirrel.Mac (the native updater
|
||||||
|
electron-updater drives on macOS) refuses to apply an update to an
|
||||||
|
unsigned/unnotarized app. Without this, the mac feed is generated but inert.
|
||||||
|
|
||||||
|
Both land together in one PR so a single review unblocks the whole feature.
|
||||||
|
|
||||||
|
## Background: why the feed is missing today (verified)
|
||||||
|
|
||||||
|
- The custom `maker-nsis.ts` and `maker-appimage.ts` call app-builder-lib's
|
||||||
|
`buildForge` with `config.publish: null`. With `publish: null`,
|
||||||
|
`createUpdateInfoTasks` short-circuits, so NO `*.yml` / `.blockmap` is emitted
|
||||||
|
on Windows or Linux.
|
||||||
|
- macOS uses `@electron-forge/maker-zip`, which never emitted updater metadata.
|
||||||
|
- Net: as built, no platform produces a feed; the updater is inert everywhere.
|
||||||
|
|
||||||
|
`publish: null` is load-bearing (it stops electron-builder from inferring a
|
||||||
|
GitHub target and racing forge on the upload). This spec does NOT touch it.
|
||||||
|
|
||||||
|
## Verified contracts (ground truth, read from installed packages)
|
||||||
|
|
||||||
|
Versions installed: `app-builder-lib@26.15.3`, `builder-util-runtime@9.7.0`,
|
||||||
|
`electron-updater@6.8.9`. **There is no `app-builder` CLI / `app-builder-bin`
|
||||||
|
package** in this tree.
|
||||||
|
|
||||||
|
1. **Blockmap generation is pure JS**, not a CLI. `buildBlockMap(inFile,
|
||||||
|
compressionFormat, outFile)` in
|
||||||
|
`app-builder-lib/out/targets/blockmap/blockmap.js`. Sidecar mode
|
||||||
|
(`compressionFormat: "gzip"`, `outFile` set) writes a gzipped `.blockmap`
|
||||||
|
file and returns `{ size, sha512 }` (raw file size, base64 SHA-512 of the
|
||||||
|
**unmodified** file); it does NOT modify the artifact and does NOT return
|
||||||
|
`blockMapSize`.
|
||||||
|
2. **yml schema** (`builder-util-runtime/out/updateInfo.d.ts`): per-file entry is
|
||||||
|
`{ url, sha512, size?, blockMapSize? }`; top level is
|
||||||
|
`{ version, files[], path (deprecated), sha512 (deprecated), releaseDate }`.
|
||||||
|
When `blockMapSize` is absent, the client uses the **sidecar** delta path
|
||||||
|
(`GenericDifferentialDownloader`, fetches `<url>.blockmap`) on all platforms.
|
||||||
|
3. **macOS multi-arch** (`MacUpdater.filterFilesForArch`): one merged
|
||||||
|
`latest-mac.yml` listing both arch zips is correct; the updater picks by the
|
||||||
|
literal substring **`arm64`** in the file url (no arch field). AO's versioned
|
||||||
|
mac zip names already contain `arm64` / `x64`.
|
||||||
|
4. **macOS delta** (`MacUpdater` + `AppUpdater.differentialDownloadInstaller`):
|
||||||
|
uses a **sidecar `<name>-mac.zip.blockmap`**; `blockMapSize` is ignored on
|
||||||
|
mac; first install is always a full download (no prior cached zip), then the
|
||||||
|
zip is cached for next time. Old-version blockmap URL is derived by
|
||||||
|
substituting the version string in the artifact path, so the feed MUST
|
||||||
|
reference **versioned** asset names.
|
||||||
|
5. **channel -> filename** (`Provider.getChannelFilePrefix` + `getChannelFilename`):
|
||||||
|
`autoUpdater.channel` of `latest` / `nightly` maps to
|
||||||
|
`latest.yml`|`latest-mac.yml`|`latest-linux.yml` and the `nightly*`
|
||||||
|
equivalents; the OS/arch suffix is added client-side. We pass only the bare
|
||||||
|
channel name (already done in PR #2221's `auto-updater.ts`).
|
||||||
|
6. **Signing is enforced by native Squirrel.Mac**, not by electron-updater JS.
|
||||||
|
The app must be code-signed + notarized for the mac update to apply.
|
||||||
|
|
||||||
|
## Approach (chosen): sidecar-only post-matrix join job
|
||||||
|
|
||||||
|
Rejected: in-build generation by flipping `publish:null` to a generic provider.
|
||||||
|
It needs a separate mac join anyway, sits next to the load-bearing `publish:null`,
|
||||||
|
and its only edge (canonical embedded-AppImage blockmap) is moot because the
|
||||||
|
client accepts sidecar blockmaps whenever `blockMapSize` is absent.
|
||||||
|
|
||||||
|
Chosen: a single job per workflow, after the build matrix, that hashes the
|
||||||
|
already-uploaded versioned artifacts, emits sidecar blockmaps + the channel yml,
|
||||||
|
and uploads them. No maker changes, no artifact mutation, one mechanism for all
|
||||||
|
three platforms, and the mac arch-merge falls out naturally because the join job
|
||||||
|
sees all artifacts at once.
|
||||||
|
|
||||||
|
## Component 1: feed publishing
|
||||||
|
|
||||||
|
### Pure module: `frontend/scripts/feed.mjs` (+ `feed.test.mjs`)
|
||||||
|
|
||||||
|
Mirrors the existing `nightly-version.mjs` (+ `.test.mjs`) pattern.
|
||||||
|
|
||||||
|
- Input: `{ channel: "latest" | "nightly", version, platform, files: [paths] }`.
|
||||||
|
- For each file: compute base64 SHA-512 + byte size; call `buildBlockMap(file,
|
||||||
|
"gzip", file + ".blockmap")` (via a one-file wrapper around the internal
|
||||||
|
import, see Risks) to write the sidecar.
|
||||||
|
- Assemble the platform yml:
|
||||||
|
- win: `latest.yml` -> `files: [{ url: <exe>, sha512, size }]`.
|
||||||
|
- linux x64: `latest-linux.yml` -> `files: [{ url: <AppImage>, sha512, size }]`.
|
||||||
|
- mac: `latest-mac.yml` -> `files: [{ url: <arm64.zip>, ... }, { url: <x64.zip>, ... }]`,
|
||||||
|
arm64 entry first; top-level deprecated `path`/`sha512` point at the first zip.
|
||||||
|
- `version`, `releaseDate: new Date().toISOString()` (normal CI node; the
|
||||||
|
Workflow-sandbox `Date` restriction does not apply here).
|
||||||
|
- `blockMapSize` is deliberately omitted (forces sidecar delta).
|
||||||
|
- Output: the yml text + the list of written `.blockmap` files. Pure except for
|
||||||
|
reading/hashing the input files.
|
||||||
|
|
||||||
|
`feed.test.mjs`: run against a real small fixture artifact and assert (a) the
|
||||||
|
yml's `sha512`/`size` match precomputed values, (b) the mac case yields two
|
||||||
|
`files` entries with correct `arm64`/`x64` urls, (c) a `.blockmap` sidecar is
|
||||||
|
written, (d) no `blockMapSize` key is present.
|
||||||
|
|
||||||
|
### Workflow wiring (both `frontend-release.yml` and `frontend-nightly.yml`)
|
||||||
|
|
||||||
|
Add a `publish-feed` job, `needs:` all build-matrix jobs, single ubuntu runner:
|
||||||
|
|
||||||
|
1. `gh release download <tag>` the **versioned** installers only: the win `*.exe`,
|
||||||
|
the linux `*.AppImage`, and both `*-darwin-arm64-*.zip` / `*-darwin-x64-*.zip`.
|
||||||
|
Exclude `.deb` / `.rpm` (not electron-updater-managed; the OS package manager
|
||||||
|
owns those).
|
||||||
|
2. `node scripts/feed.mjs` over the downloaded files to produce
|
||||||
|
`latest*.yml`|`nightly*.yml` + the `.blockmap` sidecars. Channel is `latest`
|
||||||
|
for the release workflow, `nightly` for the nightly workflow.
|
||||||
|
3. `gh release upload <tag> --clobber` the yml + sidecars to the same release.
|
||||||
|
|
||||||
|
The tag is the forge-created `v<version>` release. Asset names are discovered
|
||||||
|
from the downloaded files (not hardcoded).
|
||||||
|
|
||||||
|
## Component 2: macOS signing + notarization
|
||||||
|
|
||||||
|
The manual local flow is proven (runbook: `~/.me/instructions/ao-macos-signed-release.md`):
|
||||||
|
Developer ID + hardened runtime + notarytool + staple works for AO, and forge's
|
||||||
|
`osxSign` already signs the nested Go daemon (`Contents/Resources/daemon/ao` ->
|
||||||
|
`flags=0x10000(runtime)` + Developer ID authority). The CI task is to reproduce
|
||||||
|
that flow on the runners.
|
||||||
|
|
||||||
|
**Notarization uses the App Store Connect API key (`.p8`) path**, matching the
|
||||||
|
proven local creds (the `ao-notary` keychain profile) — NOT the Apple ID +
|
||||||
|
app-specific-password path. The cert and key belong to an external Team "holder";
|
||||||
|
the API key reuses material already in hand, so it needs nothing new from the
|
||||||
|
holder.
|
||||||
|
|
||||||
|
Already present: the `osxSign` activation in `forge.config.ts` (keys on
|
||||||
|
`APPLE_SIGNING_IDENTITY` / `CSC_LINK`) and the `CSC_LINK` / `CSC_KEY_PASSWORD`
|
||||||
|
secret passthrough in both workflows. Gaps to fill:
|
||||||
|
|
||||||
|
1. **Keychain provisioning** (the missing piece — forge/`@electron/osx-sign` does
|
||||||
|
NOT decode `CSC_LINK` itself, unlike electron-builder). Add the maintained
|
||||||
|
`apple-actions/import-codesign-certs` action on the macOS matrix legs, before
|
||||||
|
`npm run publish`, to import the Developer ID Application cert from the base64
|
||||||
|
p12 into a temporary keychain. Do not hand-roll `security` commands. Set
|
||||||
|
`APPLE_SIGNING_IDENTITY` so signing is deterministic.
|
||||||
|
2. **Entitlements**: add `frontend/build/entitlements.mac.plist` with the
|
||||||
|
hardened-runtime entitlements Electron requires (`com.apple.security.cs.allow-jit`,
|
||||||
|
`allow-unsigned-executable-memory`, `allow-dyld-environment-variables`,
|
||||||
|
`disable-library-validation`), and point `osxSign` at it. Required for a
|
||||||
|
working notarized Electron app.
|
||||||
|
3. **Rewire `osxNotarize` to the API-key (`apiKey`) variant** and delete the
|
||||||
|
`as unknown as ...` double cast (the one known typecheck error). New branch:
|
||||||
|
`{ appleApiKey: <path to .p8>, appleApiKeyId: <APPLE_API_KEY_ID>, appleApiIssuer:
|
||||||
|
<APPLE_API_ISSUER> }`. Since `appleApiKey` is a file PATH, a CI step decodes
|
||||||
|
the base64 `.p8` secret to a temp file and exports `APPLE_API_KEY` to it before
|
||||||
|
`npm run publish`. Keep the local `AO_NOTARY_PROFILE` (`keychainProfile`)
|
||||||
|
branch for the manual runbook; just fix its typing.
|
||||||
|
4. **Pin Node 22** on the macOS signing legs — `npm run make` crashes on Node 26
|
||||||
|
(per the runbook). Confirm/align the workflow's Node version.
|
||||||
|
|
||||||
|
### Human prerequisites (release owner provides as repository secrets)
|
||||||
|
|
||||||
|
- `CSC_LINK` — base64 of the Developer ID Application `.p12` (self-served by
|
||||||
|
exporting the identity from the local keychain; the private key is the owner's
|
||||||
|
via the original CSR).
|
||||||
|
- `CSC_KEY_PASSWORD` — the `.p12` export password.
|
||||||
|
- `APPLE_SIGNING_IDENTITY` — `Developer ID Application: <Holder> (<TEAMID>)`.
|
||||||
|
- `APPLE_API_KEY_BASE64` — base64 of the holder's App Store Connect `.p8` (needs
|
||||||
|
the original file; cannot be extracted back out of the `ao-notary` profile).
|
||||||
|
- `APPLE_API_KEY_ID`, `APPLE_API_ISSUER` — the key's ID + issuer UUID.
|
||||||
|
|
||||||
|
Only `APPLE_API_KEY_BASE64`/ID/issuer require the original `.p8`; if lost, the
|
||||||
|
holder re-issues an App Store Connect API key (their only possible involvement).
|
||||||
|
Set on the fork for testing, on AgentWrapper for prod.
|
||||||
|
|
||||||
|
**Out of scope:** Windows code signing (separate EV-cert concern; Windows
|
||||||
|
auto-update works unsigned). This component is macOS-only because Squirrel.Mac is
|
||||||
|
the actual blocker.
|
||||||
|
|
||||||
|
## Ordering / coupling
|
||||||
|
|
||||||
|
The two components are order-independent and both fit in one PR. The feed job
|
||||||
|
hashes whatever bytes the build uploaded: once signing is active the build
|
||||||
|
produces signed mac zips and the feed job naturally hashes the signed bytes, so
|
||||||
|
there is no sequencing constraint between them.
|
||||||
|
|
||||||
|
## Error handling / graceful degradation
|
||||||
|
|
||||||
|
- Missing prior blockmap / first install: full download, then cache. Normal.
|
||||||
|
- If sidecar delta does not engage on a platform, electron-updater full-downloads
|
||||||
|
the installer (still correct; delta is only a bandwidth optimization).
|
||||||
|
- Unsigned/un-notarized mac build (e.g. secrets absent on the fork): the build
|
||||||
|
stays unsigned, the mac feed is generated but Squirrel.Mac will not apply it;
|
||||||
|
win/linux are unaffected. Same inert-but-harmless posture as before.
|
||||||
|
|
||||||
|
## Risks (all CI-verifiable; all degrade gracefully)
|
||||||
|
|
||||||
|
1. **Internal import path.** `buildBlockMap` lives at an internal app-builder-lib
|
||||||
|
path (`out/targets/blockmap/blockmap.js`) that could move on a major upgrade.
|
||||||
|
Mitigate: pin `app-builder-lib`, isolate the import in one wrapper module, and
|
||||||
|
add a smoke check that the function is callable.
|
||||||
|
2. **Sidecar delta on NSIS-one-file / AppImage** when `blockMapSize` is absent:
|
||||||
|
high confidence from the client's downloader-selection logic, but only
|
||||||
|
provable on CI. Failure mode is a silent full-download (still correct).
|
||||||
|
3. **forge `maker-zip` shape**: confirm the mac zip is the `.app`-at-root shape
|
||||||
|
Squirrel.Mac expects for an update payload. Partially de-risked: the runbook's
|
||||||
|
verify step unzips the maker-zip output to a launchable, Gatekeeper-accepted
|
||||||
|
signed `.app`, so the zip is well-formed; only the updater-payload path is
|
||||||
|
still CI-unverified.
|
||||||
|
4. **First CI notarization** via the `.p8` API-key path + entitlements (standard
|
||||||
|
but first-time-in-CI; the same creds are proven locally via the `ao-notary`
|
||||||
|
profile).
|
||||||
|
|
||||||
|
## Components (isolation)
|
||||||
|
|
||||||
|
- `feed.mjs` (pure-ish): yml + blockmap assembly. Unit-tested.
|
||||||
|
- `blockmap` wrapper (one file): isolates the internal app-builder-lib import.
|
||||||
|
- `publish-feed` workflow job: download -> generate -> upload. CI-only.
|
||||||
|
- `entitlements.mac.plist` + the keychain step + `forge.config.ts` tweak:
|
||||||
|
signing. CI-only.
|
||||||
|
|
||||||
|
## What this unblocks
|
||||||
|
|
||||||
|
With this PR + the already-open #2221 merged: win/linux auto-update is live
|
||||||
|
immediately; macOS auto-update is live as soon as the signing secrets are present
|
||||||
|
on the production (AgentWrapper) repo. Only then may v1 copy promise auto-update.
|
||||||
|
|
@ -31,28 +31,25 @@ const config: ForgeConfig = {
|
||||||
// deb/rpm makers below, and the runtime window icon from src/main.ts.
|
// deb/rpm makers below, and the runtime window icon from src/main.ts.
|
||||||
icon: "assets/icon",
|
icon: "assets/icon",
|
||||||
extraResource: ["daemon", "assets/icon.png"],
|
extraResource: ["daemon", "assets/icon.png"],
|
||||||
// macOS signing + notarization. Two paths are supported:
|
// Notarization. Two paths:
|
||||||
// - CI: set CSC_LINK/CSC_KEY_PASSWORD and
|
// - CI: an App Store Connect API key. APPLE_API_KEY is a PATH to the .p8
|
||||||
// APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID.
|
// (the workflow decodes APPLE_API_KEY_BASE64 to a temp file), plus the
|
||||||
// - Local keychain: set APPLE_SIGNING_IDENTITY (a Developer ID Application
|
// key id + issuer uuid. Matches the proven local runbook creds.
|
||||||
// identity in the login keychain) and AO_NOTARY_PROFILE (a notarytool
|
// - Local: AO_NOTARY_PROFILE, a notarytool keychain profile created with
|
||||||
// keychain profile created with `notarytool store-credentials`).
|
// `notarytool store-credentials`. See ao-macos-signed-release runbook.
|
||||||
// See frontend/docs/desktop-release.md.
|
// Both are valid NotaryToolCredentials, so no cast is needed.
|
||||||
osxSign: process.env.APPLE_SIGNING_IDENTITY
|
osxSign: process.env.APPLE_SIGNING_IDENTITY
|
||||||
? { identity: process.env.APPLE_SIGNING_IDENTITY }
|
? { identity: process.env.APPLE_SIGNING_IDENTITY }
|
||||||
: process.env.CSC_LINK
|
: process.env.CSC_LINK
|
||||||
? {}
|
? {}
|
||||||
: undefined,
|
: undefined,
|
||||||
osxNotarize: process.env.AO_NOTARY_PROFILE
|
osxNotarize: process.env.AO_NOTARY_PROFILE
|
||||||
? ({
|
? { keychainProfile: process.env.AO_NOTARY_PROFILE }
|
||||||
tool: "notarytool",
|
: process.env.APPLE_API_KEY
|
||||||
keychainProfile: process.env.AO_NOTARY_PROFILE,
|
|
||||||
} as unknown as ForgeConfig["packagerConfig"]["osxNotarize"])
|
|
||||||
: process.env.APPLE_ID
|
|
||||||
? {
|
? {
|
||||||
appleId: process.env.APPLE_ID,
|
appleApiKey: process.env.APPLE_API_KEY,
|
||||||
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD!,
|
appleApiKeyId: process.env.APPLE_API_KEY_ID!,
|
||||||
teamId: process.env.APPLE_TEAM_ID!,
|
appleApiIssuer: process.env.APPLE_API_ISSUER!,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// frontend/scripts/blockmap.mjs
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// The ONE fragile import. app-builder-lib 26.x generates blockmaps in pure JS
|
||||||
|
// (there is no app-builder CLI / app-builder-bin in this tree). This internal
|
||||||
|
// path is pinned via package-lock; if it moves on a major upgrade, only this
|
||||||
|
// file changes. Smoke-tested by blockmap.test.mjs.
|
||||||
|
const { buildBlockMap } = require("app-builder-lib/out/targets/blockmap/blockmap.js");
|
||||||
|
|
||||||
|
// writeBlockmap creates "<filePath>.blockmap" (gzip sidecar) and returns the
|
||||||
|
// file's base64 sha512 + byte size, exactly as electron-updater reads them from
|
||||||
|
// the feed yml. We deliberately do NOT surface blockMapSize: omitting it from
|
||||||
|
// the yml forces the client onto the sidecar differential path on every
|
||||||
|
// platform (verified against MacUpdater / NsisUpdater / AppImage).
|
||||||
|
export async function writeBlockmap(filePath) {
|
||||||
|
const { sha512, size } = await buildBlockMap(filePath, "gzip", `${filePath}.blockmap`);
|
||||||
|
return { sha512, size };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
// frontend/scripts/blockmap.test.mjs
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { writeBlockmap } from "./blockmap.mjs";
|
||||||
|
import { mkdtempSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
describe("writeBlockmap", () => {
|
||||||
|
it("writes a gzip sidecar and returns the file's base64 sha512 + size", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "bm-"));
|
||||||
|
const file = join(dir, "artifact.bin");
|
||||||
|
// ~200KB of varied bytes so the chunker produces multiple chunks.
|
||||||
|
const buf = Buffer.alloc(200_000);
|
||||||
|
for (let i = 0; i < buf.length; i++) buf[i] = (i * 37) % 256;
|
||||||
|
writeFileSync(file, buf);
|
||||||
|
|
||||||
|
const { sha512, size } = await writeBlockmap(file);
|
||||||
|
|
||||||
|
expect(size).toBe(buf.length);
|
||||||
|
// Must match electron-updater's expectation: base64 SHA-512 of the raw file.
|
||||||
|
expect(sha512).toBe(createHash("sha512").update(buf).digest("base64"));
|
||||||
|
// Sidecar exists, is non-empty, and is gzip (magic bytes 1f 8b).
|
||||||
|
expect(existsSync(`${file}.blockmap`)).toBe(true);
|
||||||
|
const sidecar = readFileSync(`${file}.blockmap`);
|
||||||
|
expect(sidecar.length).toBeGreaterThan(0);
|
||||||
|
expect(sidecar[0]).toBe(0x1f);
|
||||||
|
expect(sidecar[1]).toBe(0x8b);
|
||||||
|
expect(statSync(`${file}.blockmap`).size).toBe(sidecar.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Generates electron-updater feed metadata (latest*.yml / nightly*.yml) plus
|
||||||
|
// gzip sidecar blockmaps for a release's versioned installers. Dependency-free
|
||||||
|
// ESM (mirrors nightly-version.mjs) so CI runs `node scripts/feed.mjs` directly
|
||||||
|
// and vitest unit-tests the pure functions. The only non-stdlib reach is the
|
||||||
|
// blockmap wrapper (Task 1).
|
||||||
|
import { readdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { writeBlockmap } from "./blockmap.mjs";
|
||||||
|
|
||||||
|
// selectInstallers picks the versioned, auto-updatable installers from a release
|
||||||
|
// download dir, grouped by platform/arch. Excludes the ao-start aliases (no
|
||||||
|
// version string in their names) and deb/rpm (system-package-managed). The mac
|
||||||
|
// arch split keys on the literal "arm64" substring, the same discriminator the
|
||||||
|
// updater (MacUpdater.filterFilesForArch) uses.
|
||||||
|
export function selectInstallers(filenames, version) {
|
||||||
|
const versioned = filenames.filter((f) => f.includes(version));
|
||||||
|
const isDarwinZip = (f) => f.endsWith(".zip") && f.includes("darwin");
|
||||||
|
return {
|
||||||
|
win: versioned.filter((f) => f.endsWith(".exe")),
|
||||||
|
linux: versioned.filter((f) => f.endsWith(".AppImage")),
|
||||||
|
macArm64: versioned.filter((f) => isDarwinZip(f) && f.includes("arm64")),
|
||||||
|
macX64: versioned.filter((f) => isDarwinZip(f) && !f.includes("arm64")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedFilename maps (channel, platform) to electron-updater's expected feed name.
|
||||||
|
// The updater adds its own OS/arch suffix client-side; we name the published
|
||||||
|
// asset to match: "" (win), "-mac", "-linux" (x64 Linux).
|
||||||
|
export function feedFilename(channel, platform) {
|
||||||
|
const suffix = platform === "mac" ? "-mac" : platform === "linux" ? "-linux" : "";
|
||||||
|
return `${channel}${suffix}.yml`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildYml serializes one platform's feed. files is [{ url, sha512, size }];
|
||||||
|
// for mac the arm64 entry comes first. The deprecated top-level path/sha512
|
||||||
|
// point at files[0]. blockMapSize is never written (forces sidecar differential).
|
||||||
|
export function buildYml(version, files, releaseDate) {
|
||||||
|
const lines = [`version: ${version}`, "files:"];
|
||||||
|
for (const f of files) {
|
||||||
|
lines.push(` - url: ${f.url}`);
|
||||||
|
lines.push(` sha512: ${f.sha512}`);
|
||||||
|
lines.push(` size: ${f.size}`);
|
||||||
|
}
|
||||||
|
lines.push(`path: ${files[0].url}`);
|
||||||
|
lines.push(`sha512: ${files[0].sha512}`);
|
||||||
|
lines.push(`releaseDate: '${releaseDate}'`);
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateFeeds writes the yml + sidecar blockmaps for every platform present in
|
||||||
|
// dir. version may carry +build metadata (nightly); strip it for the yml.
|
||||||
|
async function generateFeeds(dir, rawVersion, channel, releaseDate) {
|
||||||
|
const version = rawVersion.split("+")[0];
|
||||||
|
const sel = selectInstallers(readdirSync(dir), version);
|
||||||
|
const groups = [
|
||||||
|
{ platform: "win", names: sel.win },
|
||||||
|
{ platform: "linux", names: sel.linux },
|
||||||
|
{ platform: "mac", names: [...sel.macArm64, ...sel.macX64] }, // arm64 first
|
||||||
|
];
|
||||||
|
for (const { platform, names } of groups) {
|
||||||
|
if (names.length === 0) continue;
|
||||||
|
const files = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const { sha512, size } = await writeBlockmap(join(dir, name));
|
||||||
|
files.push({ url: name, sha512, size });
|
||||||
|
}
|
||||||
|
writeFileSync(join(dir, feedFilename(channel, platform)), buildYml(version, files, releaseDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node scripts/feed.mjs <dir> <version> <channel>
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const [, , dir, version, channel] = process.argv;
|
||||||
|
if (!dir || !version || !channel) {
|
||||||
|
process.stderr.write("usage: node feed.mjs <dir> <version> <channel>\n");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
generateFeeds(dir, version, channel, new Date().toISOString()).catch((err) => {
|
||||||
|
process.stderr.write(`${err.stack || err}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { selectInstallers, feedFilename, buildYml } from "./feed.mjs";
|
||||||
|
|
||||||
|
const V = "0.10.4";
|
||||||
|
const NAMES = [
|
||||||
|
"Agent.Orchestrator.Setup.0.10.4.exe", // win versioned
|
||||||
|
"Agent.Orchestrator-0.10.4.AppImage", // linux versioned
|
||||||
|
"Agent.Orchestrator-darwin-arm64-0.10.4.zip", // mac arm64 versioned
|
||||||
|
"Agent.Orchestrator-darwin-x64-0.10.4.zip", // mac x64 versioned
|
||||||
|
"agent-orchestrator-darwin-arm64.zip", // ao-start alias (no version) -> excluded
|
||||||
|
"agent-orchestrator-win32-x64.exe", // alias (no version) -> excluded
|
||||||
|
"agent-orchestrator_0.10.4_amd64.deb", // deb -> excluded by extension
|
||||||
|
"agent-orchestrator-0.10.4.x86_64.rpm", // rpm -> excluded by extension
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("selectInstallers", () => {
|
||||||
|
it("keeps only versioned exe/AppImage/darwin-zip, split by arch", () => {
|
||||||
|
const s = selectInstallers(NAMES, V);
|
||||||
|
expect(s.win).toEqual(["Agent.Orchestrator.Setup.0.10.4.exe"]);
|
||||||
|
expect(s.linux).toEqual(["Agent.Orchestrator-0.10.4.AppImage"]);
|
||||||
|
expect(s.macArm64).toEqual(["Agent.Orchestrator-darwin-arm64-0.10.4.zip"]);
|
||||||
|
expect(s.macX64).toEqual(["Agent.Orchestrator-darwin-x64-0.10.4.zip"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feedFilename", () => {
|
||||||
|
it("maps channel + platform to electron-updater names", () => {
|
||||||
|
expect(feedFilename("latest", "win")).toBe("latest.yml");
|
||||||
|
expect(feedFilename("latest", "mac")).toBe("latest-mac.yml");
|
||||||
|
expect(feedFilename("latest", "linux")).toBe("latest-linux.yml");
|
||||||
|
expect(feedFilename("nightly", "win")).toBe("nightly.yml");
|
||||||
|
expect(feedFilename("nightly", "mac")).toBe("nightly-mac.yml");
|
||||||
|
expect(feedFilename("nightly", "linux")).toBe("nightly-linux.yml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildYml", () => {
|
||||||
|
it("serializes one file with deprecated top-level fields and no blockMapSize", () => {
|
||||||
|
const yml = buildYml(
|
||||||
|
"0.10.4",
|
||||||
|
[{ url: "Agent.Orchestrator.Setup.0.10.4.exe", sha512: "AA/BB+cc==", size: 123 }],
|
||||||
|
"2026-06-27T12:00:00.000Z",
|
||||||
|
);
|
||||||
|
expect(yml).toBe(
|
||||||
|
"version: 0.10.4\n" +
|
||||||
|
"files:\n" +
|
||||||
|
" - url: Agent.Orchestrator.Setup.0.10.4.exe\n" +
|
||||||
|
" sha512: AA/BB+cc==\n" +
|
||||||
|
" size: 123\n" +
|
||||||
|
"path: Agent.Orchestrator.Setup.0.10.4.exe\n" +
|
||||||
|
"sha512: AA/BB+cc==\n" +
|
||||||
|
"releaseDate: '2026-06-27T12:00:00.000Z'\n",
|
||||||
|
);
|
||||||
|
expect(yml).not.toContain("blockMapSize");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists both mac arches with arm64 first and points top-level at arm64", () => {
|
||||||
|
const yml = buildYml(
|
||||||
|
"0.10.4",
|
||||||
|
[
|
||||||
|
{ url: "Agent.Orchestrator-darwin-arm64-0.10.4.zip", sha512: "ARM==", size: 10 },
|
||||||
|
{ url: "Agent.Orchestrator-darwin-x64-0.10.4.zip", sha512: "X64==", size: 20 },
|
||||||
|
],
|
||||||
|
"2026-06-27T12:00:00.000Z",
|
||||||
|
);
|
||||||
|
const lines = yml.split("\n");
|
||||||
|
expect(lines[2]).toBe(" - url: Agent.Orchestrator-darwin-arm64-0.10.4.zip");
|
||||||
|
expect(lines[5]).toBe(" - url: Agent.Orchestrator-darwin-x64-0.10.4.zip");
|
||||||
|
expect(yml).toContain("path: Agent.Orchestrator-darwin-arm64-0.10.4.zip");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,16 +35,14 @@ function setupBridge() {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
ensure: vi.fn(
|
ensure: vi.fn(async (sessionId: string): Promise<BrowserNavState> => ({
|
||||||
async (sessionId: string): Promise<BrowserNavState> => ({
|
viewId: `42:${sessionId}`,
|
||||||
viewId: `42:${sessionId}`,
|
url: "",
|
||||||
url: "",
|
title: "",
|
||||||
title: "",
|
canGoBack: false,
|
||||||
canGoBack: false,
|
canGoForward: false,
|
||||||
canGoForward: false,
|
isLoading: false,
|
||||||
isLoading: false,
|
})),
|
||||||
}),
|
|
||||||
),
|
|
||||||
setBounds: vi.fn(),
|
setBounds: vi.fn(),
|
||||||
navigate: vi.fn(async ({ viewId }: { viewId: string }) => bridge.stateFor(viewId)),
|
navigate: vi.fn(async ({ viewId }: { viewId: string }) => bridge.stateFor(viewId)),
|
||||||
clear: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
clear: vi.fn(async (viewId: string) => bridge.stateFor(viewId)),
|
||||||
|
|
|
||||||
|
|
@ -134,13 +134,7 @@ export type WorkspaceSession = {
|
||||||
|
|
||||||
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
|
/** Glanceable worker status. Maps 1:1 to the accent colors in DESIGN.md. */
|
||||||
export type WorkerDisplayStatus =
|
export type WorkerDisplayStatus =
|
||||||
| "working"
|
"working" | "needs_you" | "mergeable" | "ci_failed" | "no_signal" | "done" | "unknown";
|
||||||
| "needs_you"
|
|
||||||
| "mergeable"
|
|
||||||
| "ci_failed"
|
|
||||||
| "no_signal"
|
|
||||||
| "done"
|
|
||||||
| "unknown";
|
|
||||||
|
|
||||||
export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus {
|
export function workerDisplayStatus(session: WorkspaceSession): WorkerDisplayStatus {
|
||||||
if (session.displayStatus) return session.displayStatus;
|
if (session.displayStatus) return session.displayStatus;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue