Skip to content

Commit ef442bf

Browse files
yyq1025claude
andcommitted
ci: release pipeline (tag → draft GitHub release) + CI checks + protocol-bump guard
- release-menubar.yml: tag push → macos-15 arm64 → version assertion (tag must equal menubar package.json) → typecheck/tests → sign + notarize (CSC_LINK / ASC API key secrets) → electron-builder publishes dmg/zip/blockmap/latest-mac.yml to a DRAFT release → commit log since the previous tag becomes the release body. Draft-first is the safety gate: electron-updater only sees published releases. - ci.yml: push/PR — biome ci, workspace typecheck, app tsc (not covered by `pnpm -r`), vitest; plus the protocol-version guard from #7 (protocol src changed without a package version bump fails; [skip-proto-bump] escape hatch for wire-neutral changes). - electron-builder.cjs: publish switches from the placeholder R2 generic provider to GitHub Releases (releaseType: draft); SIDECODE_UPDATE_URL stays as the local static-feed override for updater testing. - updater.ts: comment sync (feed = GitHub Releases, published only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d5d90c commit ef442bf

4 files changed

Lines changed: 218 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
checks:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- uses: pnpm/action-setup@v4 # version from root package.json `packageManager`
15+
16+
- uses: actions/setup-node@v4
17+
with:
18+
node-version: 24
19+
cache: pnpm
20+
21+
- run: pnpm install --frozen-lockfile
22+
23+
- name: biome
24+
run: pnpm exec biome ci .
25+
26+
- name: Typecheck (workspace)
27+
run: pnpm -r typecheck
28+
29+
# The app package has no typecheck script (`pnpm -r` skips it) — run
30+
# tsc directly so daemon-client / UI changes are covered too.
31+
- name: Typecheck (app)
32+
working-directory: packages/app
33+
run: npx tsc --noEmit
34+
35+
- name: Tests
36+
run: pnpm test
37+
38+
# Guard for sidecodeapp/sidecode#7: the wire contract's version IS
39+
# packages/protocol/package.json `version` (PROTOCOL_VERSION reads it at
40+
# module load). A schema change without a bump means two ends running
41+
# different wire shapes still pass the handshake and break in opaque ways.
42+
# This enforces the cheap invariant: protocol src changed → version changed.
43+
# (Judging patch-vs-minor correctness stays a human call — see the bump
44+
# policy comment in packages/protocol/src/index.ts; closed z.enum case-adds
45+
# are the documented trap.)
46+
protocol-version-guard:
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
with:
51+
fetch-depth: 0
52+
53+
- name: Protocol src changed → version must be bumped
54+
run: |
55+
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
56+
BASE="origin/$GITHUB_BASE_REF"
57+
git fetch --quiet origin "$GITHUB_BASE_REF"
58+
RANGE="$(git merge-base "$BASE" HEAD)..HEAD"
59+
else
60+
BEFORE="${{ github.event.before }}"
61+
# Zero SHA = branch creation / force-push without a usable base;
62+
# nothing meaningful to diff against.
63+
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BEFORE" 2>/dev/null; then
64+
echo "no usable base commit — skipping"
65+
exit 0
66+
fi
67+
RANGE="$BEFORE..HEAD"
68+
fi
69+
70+
if ! git diff --name-only "$RANGE" -- 'packages/protocol/src/**' | grep -q .; then
71+
echo "protocol src untouched — ok"
72+
exit 0
73+
fi
74+
75+
# Escape hatch for comment-only / pure-refactor changes that don't
76+
# alter the wire shape: put [skip-proto-bump] in any commit message
77+
# in the range.
78+
if git log --format=%B "$RANGE" | grep -qF "[skip-proto-bump]"; then
79+
echo "[skip-proto-bump] present — skipping"
80+
exit 0
81+
fi
82+
83+
if git diff "$RANGE" -- packages/protocol/package.json | grep -q '^[-+] "version"'; then
84+
echo "protocol src changed and version bumped — ok"
85+
exit 0
86+
fi
87+
88+
echo "::error::packages/protocol/src changed but packages/protocol/package.json version was not bumped. Bump it (patch = additive, minor = breaking during 0.x — see the bump policy in packages/protocol/src/index.ts), or add [skip-proto-bump] to the commit message for wire-neutral changes."
89+
exit 1
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
name: Release menubar
2+
3+
# Tag-driven release of the Mac menu bar app, draft-first:
4+
#
5+
# git tag v0.0.2 && git push origin v0.0.2
6+
# → this workflow builds, signs, notarizes, and uploads
7+
# dmg + zip + blockmap + latest-mac.yml to a DRAFT GitHub release
8+
# → smoke-test the draft's dmg locally, then click "Publish release"
9+
#
10+
# electron-updater only sees PUBLISHED releases, so the draft is the safety
11+
# gate: installed apps' periodic checks (electron/updater.ts, 6h) start
12+
# picking the version up only at the moment of manual publish. A bad build
13+
# never reaches users — delete the draft and re-tag.
14+
#
15+
# Required repo secrets (Settings → Secrets and variables → Actions):
16+
# CSC_LINK base64 of the Developer ID Application .p12 export
17+
# CSC_KEY_PASSWORD password chosen at .p12 export
18+
# APPLE_API_KEY_P8 base64 of the App Store Connect API .p8 key
19+
# APPLE_API_KEY_ID the key's ID
20+
# APPLE_API_ISSUER the ASC issuer UUID
21+
# (electron-builder imports the cert into a temp keychain via CSC_LINK and
22+
# notarizes via notarytool with the API key — same env contract as the local
23+
# `package:mac:notarize` script's .env.local.)
24+
25+
on:
26+
push:
27+
tags: ["v*"]
28+
29+
permissions:
30+
contents: write # create the draft release + upload assets
31+
32+
jobs:
33+
release:
34+
runs-on: macos-15 # Apple Silicon — we ship arm64-only (V0)
35+
steps:
36+
- uses: actions/checkout@v4
37+
with:
38+
fetch-depth: 0 # full history + tags for the commit-log release notes
39+
40+
- uses: pnpm/action-setup@v4 # version from root package.json `packageManager`
41+
42+
- uses: actions/setup-node@v4
43+
with:
44+
node-version: 24
45+
cache: pnpm
46+
47+
- run: pnpm install --frozen-lockfile
48+
49+
# The feed (latest-mac.yml) advertises the package.json version; if the
50+
# tag disagrees, installed apps would update to something other than
51+
# what the tag claims. Fail fast instead.
52+
- name: Assert tag matches menubar package version
53+
run: |
54+
TAG_VERSION="${GITHUB_REF_NAME#v}"
55+
PKG_VERSION=$(node -p "require('./packages/menubar/package.json').version")
56+
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
57+
echo "::error::tag $GITHUB_REF_NAME but packages/menubar/package.json is $PKG_VERSION — bump the package version (or re-tag) so the update feed stays truthful"
58+
exit 1
59+
fi
60+
61+
- name: Typecheck + tests
62+
run: |
63+
pnpm -r typecheck
64+
pnpm test
65+
66+
- name: Write App Store Connect API key
67+
env:
68+
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
69+
run: |
70+
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/asc-api-key.p8"
71+
echo "APPLE_API_KEY=$RUNNER_TEMP/asc-api-key.p8" >> "$GITHUB_ENV"
72+
73+
- name: Build, sign, notarize, upload to draft release
74+
env:
75+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
76+
CSC_LINK: ${{ secrets.CSC_LINK }}
77+
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
78+
# APPLE_API_KEY exported by the previous step; its presence flips
79+
# electron-builder.cjs's `notarize` gate on.
80+
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
81+
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
82+
run: |
83+
pnpm --filter '@sidecodeapp/menubar...' run build
84+
pnpm --filter @sidecodeapp/menubar exec electron-builder --mac --arm64 --publish always
85+
86+
# electron-builder creates the draft with an empty body; fill it with the
87+
# commit log since the previous tag. Direct-to-main workflow means GitHub's
88+
# PR-based auto-notes would be empty — the commit subjects ARE the
89+
# changelog here. Hand-write a short "What's new" above the list before
90+
# publishing.
91+
- name: Attach commit log to the draft release
92+
env:
93+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
94+
run: |
95+
TAG="$GITHUB_REF_NAME"
96+
PREV=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
97+
RANGE="${PREV:+$PREV..}$TAG"
98+
{
99+
echo "<!-- Add a short user-facing What's New above the commit list before publishing. -->"
100+
echo ""
101+
echo "## Commits${PREV:+ since $PREV}"
102+
echo ""
103+
git log --no-merges --pretty='- %s' "$RANGE"
104+
} > "$RUNNER_TEMP/notes.md"
105+
gh release edit "$TAG" --draft=true --notes-file "$RUNNER_TEMP/notes.md"

packages/menubar/electron-builder.cjs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,25 @@ module.exports = {
5353
// (Squirrel.Mac) requires a signed AND notarized build to install.
5454
notarize,
5555
},
56-
// electron-updater feed. Generic = any static host; generating this makes the
57-
// build emit latest-mac.yml + .blockmap (differential updates). The packaged
58-
// app's embedded app-update.yml bakes in whatever url is set at BUILD time.
59-
// SIDECODE_UPDATE_URL overrides the feed at build time (e.g. point a test
60-
// build at a local static server); otherwise the real R2 host (TODO).
61-
publish: {
62-
provider: "generic",
63-
url:
64-
process.env.SIDECODE_UPDATE_URL || "https://REPLACE_WITH_R2_PUBLIC_URL",
65-
channel: "latest",
66-
},
56+
// electron-updater feed. The packaged app's embedded app-update.yml bakes in
57+
// whatever is set at BUILD time:
58+
// - default: GitHub Releases (latest-mac.yml + .blockmap published as release
59+
// assets; electron-updater polls the latest PUBLISHED release anonymously —
60+
// this is why drafts are the release pipeline's safety gate).
61+
// - SIDECODE_UPDATE_URL: generic-provider override for pointing a local test
62+
// build at a static server (e.g. `npx serve release/`).
63+
// `releaseType: "draft"` means CI's `--publish always` creates a DRAFT release;
64+
// installed apps only see it once it's manually published.
65+
publish: process.env.SIDECODE_UPDATE_URL
66+
? {
67+
provider: "generic",
68+
url: process.env.SIDECODE_UPDATE_URL,
69+
channel: "latest",
70+
}
71+
: {
72+
provider: "github",
73+
owner: "sidecodeapp",
74+
repo: "sidecode",
75+
releaseType: "draft",
76+
},
6777
};

packages/menubar/electron/updater.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ export function getUpdateState(): UpdateState {
3333
export function initUpdater(opts: { onStateChange: () => void }): void {
3434
// Auto-update only runs in packaged builds — Squirrel.Mac needs the installed
3535
// .app, and the feed is the embedded app-update.yml (from electron-builder.cjs
36-
// publish.url). In dev it's inert; test via a packaged build pointed at a feed
37-
// with SIDECODE_UPDATE_URL.
36+
// `publish`: GitHub Releases by default, latest PUBLISHED release only). In dev
37+
// it's inert; test via a packaged build pointed at a static feed with
38+
// SIDECODE_UPDATE_URL.
3839
if (!app.isPackaged) return;
3940
onChange = opts.onStateChange;
4041
autoUpdater.autoDownload = true;

0 commit comments

Comments
 (0)