Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ jobs:
pnpm lint
pnpm fmt:check
pnpm architecture:check --changed
- name: Test desktop update and release manifest policy
- name: Test desktop update, release manifest, and main release allocation policy
run: >-
pnpm exec tsx --test
packages/desktop/test/ccbuddy-update-policy.test.mjs
packages/desktop/test/finalize-macos-release.test.mjs
packages/desktop/test/prepare-main-release.test.mjs

macos-package:
name: Build unsigned macOS arm64 package
Expand Down
47 changes: 27 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ name: CCbuddy macOS Release

on:
push:
branches:
- main
tags:
- "v*.*.*"

concurrency:
# Keep every queued main push and serialize version allocation and publication.
group: ccbuddy-macos-release
cancel-in-progress: false
queue: max
Expand All @@ -15,44 +18,46 @@ permissions:

jobs:
verify:
name: Verify annotated tag, main ancestry, and version
name: Allocate or verify the release tag, main ancestry, and version
runs-on: ubuntu-24.04
permissions:
# Pushes only the immutable annotated version tag; main never moves.
contents: write
outputs:
should_release: ${{ steps.existing.outputs.published != 'true' }}
version: ${{ steps.version.outputs.version }}
commit: ${{ steps.version.outputs.commit }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
# This isolated job pushes only the version tag. Checkout removes the token afterwards.
persist-credentials: true
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38
with:
node-version: 24.14.0
- name: Verify release source
- name: Allocate a version for the main push or verify the supplied tag
id: version
run: node packages/desktop/scripts/prepare-main-release.mjs
- name: Do not rebuild an already public release
id: existing
env:
GH_TOKEN: ${{ github.token }}
TAG: v${{ steps.version.outputs.version }}
shell: bash
run: |
set -euo pipefail
tag="${GITHUB_REF#refs/tags/}"
[[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
test "$(git cat-file -t "refs/tags/$tag")" = tag
commit="$(git rev-parse "refs/tags/$tag^{commit}")"
git fetch --force --no-tags origin '+refs/heads/main:refs/remotes/origin/main'
git merge-base --is-ancestor "$commit" refs/remotes/origin/main
version="${tag#v}"
RELEASE_VERSION="$version" node --input-type=module -e '
import { readFileSync } from "node:fs";
for (const path of ["package.json", "apps/ccbuddy-cli/package.json", "apps/ccbuddy-cli/packages/cli/package.json"]) {
const actual = JSON.parse(readFileSync(path, "utf8")).version;
if (actual !== process.env.RELEASE_VERSION) throw new Error(`${path}: expected ${process.env.RELEASE_VERSION}, found ${actual}`);
}
'
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "commit=$commit" >> "$GITHUB_OUTPUT"
if draft="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json isDraft --jq .isDraft 2>/dev/null)" && [ "$draft" = false ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
echo "$TAG is already public; keeping its immutable assets."
fi

build:
name: Sign, notarize, and verify arm64 release
needs: verify
if: needs.verify.outputs.should_release == 'true'
runs-on: macos-26
timeout-minutes: 180
environment: release
Expand Down Expand Up @@ -193,6 +198,7 @@ jobs:
publish:
name: Publish one complete GitHub Release
needs: [verify, build]
if: needs.verify.outputs.should_release == 'true'
runs-on: ubuntu-24.04
environment: release
permissions:
Expand All @@ -218,7 +224,8 @@ jobs:
git fetch --force --no-tags origin '+refs/heads/main:refs/remotes/origin/main'
git fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
test "$(git rev-parse "refs/tags/$TAG^{commit}")" = "${{ needs.verify.outputs.commit }}"
git merge-base --is-ancestor "${{ needs.verify.outputs.commit }}" refs/remotes/origin/main
node packages/desktop/scripts/prepare-main-release.mjs --verify \
"${{ needs.verify.outputs.commit }}" "$VERSION"
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft \
--jq .isDraft > "$RUNNER_TEMP/existing-release" 2>/dev/null; then
test "$(cat "$RUNNER_TEMP/existing-release")" = true
Expand Down
30 changes: 24 additions & 6 deletions docs/specs/macos-release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@

## Product rule and owner

The root `package.json` owns the app version. A reviewed, annotated `vX.Y.Z` tag
on the merged `main` history starts a release; merging a pull request alone does
not publish or change the update channel. GitHub Release owns the published
The root `package.json` owns the app version. Every push to `main` publishes a
release, so installed apps keep receiving updates without a manual tag step. The
`verify` job owns version allocation and runs serialized in the
`ccbuddy-macos-release` concurrency group:

- If the root package version is newer than every `vX.Y.Z` tag, the main commit
itself gets that annotated tag.
- Otherwise the job creates a release snapshot commit whose single parent is the
main commit. It changes only the `version` field of the root, CLI workspace, and
CLI package files to the highest tag's patch + 1, and its message carries
`Release-Source-SHA: <main sha>`. `main` never moves; the tag keeps the snapshot
reachable.
- A rerun for the same main commit reuses the tag already allocated to it, and an
already public release skips the build.

An annotated `vX.Y.Z` tag pushed by a maintainer still starts a release, provided
it points at a commit on `main` or at a valid release snapshot. Tags pushed by the
workflow's `GITHUB_TOKEN` do not start a second run. GitHub Release owns the published
assets. The production Electron build embeds
`https://github.com/<repository>/releases/latest/download/latest-mac.yml` as its
immutable update source. Preview and unsigned PR packages have no update source.
Expand All @@ -27,7 +42,8 @@ backwards to an older version.

```text
PR → pnpm checks + unsigned arm64 package → review and merge
annotated tag on main → version/ancestry check → signed arm64 build
main push → allocate annotated tag (main commit or version snapshot)
annotated tag → version/ancestry check → signed arm64 build
→ notarize ZIP and DMG → staple DMG → finalize YAML and verify assets
→ draft GitHub Release → upload and compare inventory → publish as latest
```
Expand All @@ -51,8 +67,10 @@ and no release credentials.
1. A PR targeting `main` installs the locked pnpm workspace, runs typecheck,
lint, formatting, architecture checks, targeted updater tests, and builds an
unsigned arm64 ZIP and DMG without Apple credentials.
2. A plain push to `main`, a lightweight tag, a tag outside merged `main`, or a
tag whose version differs from the root and CLI packages cannot publish.
2. A push to `main` allocates exactly one annotated version tag for that commit
and publishes it. A lightweight tag, a tag outside merged `main` that is not a
version-only snapshot of a `main` commit, or a tag whose version differs from
the root and CLI packages cannot publish.
3. A valid tagged build embeds the CCbuddy latest manifest URL and emits signed,
notarized artifacts whose ZIP hash matches the published YAML. The manifest
contains no DMG or foreign update source.
Expand Down
Loading
Loading