From 1e262b04d1a543731b1e1daf4596b530028e556e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 03:57:11 +0100 Subject: [PATCH 1/3] chore(release): take release.yml and docs/releasing.md from master as they are This branch starts from feat/desktop-app (#763), which predates master's MCP registry job and the release-notes body in release.yml and their pages in docs/releasing.md. Both files are master's text verbatim here, so the Mac app commit after this one changes them by additions only, and a rebase onto master drops this commit as empty. release.yml now names .github/workflows/publish-mcp.yml and scripts/generate-release-notes.mjs, which exist on master but not on this branch. The Release workflow runs only on pushes to master, so that matters nowhere else. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 31 +++++++++++++++++++++++++++++-- docs/releasing.md | 22 ++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca7f2b9e..56a1a08c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: permissions: contents: write pull-requests: write + outputs: + published: ${{ steps.changesets.outputs.published }} steps: - name: Checkout repo uses: actions/checkout@v6 @@ -60,15 +62,40 @@ jobs: # 1.9.4 lost it to xterm-zerolag-input@0.1.7 by two seconds. All package # releases already exist by the time this step runs, so setting it here # is deterministic. + # + # The title and body are the page people land on from the update toast. + # changesets copied the body from CHANGELOG.md as one long bullet, so it is + # reshaped here (scripts/lib/release-notes.mjs). npm has already published by + # now, so a failure in that must not stop the retag below: without the body + # field the release keeps the text changesets wrote. RELEASE_ID=$(gh release view "$OLD_TAG" --json databaseId -q .databaseId 2>/dev/null || true) if [ -n "$RELEASE_ID" ]; then + NOTES_FILE="$RUNNER_TEMP/release-notes.md" + BODY_ARGS=() + if node scripts/generate-release-notes.mjs "$VERSION" > "$NOTES_FILE"; then + BODY_ARGS=(-F "body=@$NOTES_FILE") + else + echo "::warning::Keeping the changesets release notes for $VERSION" + fi gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" \ -f tag_name="$NEW_TAG" \ - -f name="$NEW_TAG" \ - -f make_latest=true + -f name="Codeman ${VERSION}" \ + -f make_latest=true \ + "${BODY_ARGS[@]}" fi # Retag git tag "$NEW_TAG" "$OLD_TAG" 2>/dev/null || true git tag -d "$OLD_TAG" 2>/dev/null || true git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true + + # Its own job: this one alone holds id-token, and a registry failure shows as itself + # without touching the npm publish or the tag above. + mcp-registry: + name: MCP registry + needs: release + if: needs.release.outputs.published == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/publish-mcp.yml diff --git a/docs/releasing.md b/docs/releasing.md index 097a03a9..3cab92b9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -19,14 +19,32 @@ When user says "COM": Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too. + **Write it in the shape the release page is built from.** `CHANGELOG.md` keeps changesets' format, and the `Release` workflow rebuilds the GitHub release from that section (`scripts/generate-release-notes.mjs`, which also titles it `Codeman `). The in-app update toast reads the same bold lead-ins: + + ```markdown + ### Thanks + - @someone for what they did (#123). + + **What the change does for the user (#123).** Two or three plain sentences. + + **Fixes.** + - **What works now (#124).** What was wrong. Behaviour change: what a user has to do differently. + + **Fixes applied while landing.** One sentence each. + + **Docs and tests.** One sentence. + ``` + + `### Thanks` stays first and opens the page. A bold lead-in that ends in punctuation becomes a heading under What's new, so make it a short headline. `**Fixes.**` and its list become a Fixes section, and the last two labels share an Also in this release section (the labels are `SECTION_LABELS` in `scripts/lib/whats-new.mjs`). `Behaviour change:` and `Breaking:` are put in bold wherever they appear, so use those words for anything a user has to act on. Keep each change short: how it works belongs in the docs, and a long paragraph is a wall of text even under a heading. Text the script does not recognise stays on the page as plain paragraphs. After step 3, `node scripts/generate-release-notes.mjs` prints the page. If the workflow warns that it kept the changesets notes, fix the changelog and run `gh release edit codeman@ --notes-file `. + 3. **Consume the changeset**: `npm run version-packages` (auto-bumps `package.json` files, updates `CHANGELOG.md`, runs `npm install --package-lock-only`, and verifies lockfile sync via `scripts/check-lockfile-sync.mjs` — all in one command; never hand-edit `CHANGELOG.md` or `package-lock.json` versions. It also writes `src/web/public/whats-new.json` (`scripts/generate-whats-new.mjs`): the bold lead-in of each change in the new `CHANGELOG.md` section, which the browser announces once per version in a toast. Commit it with the release; `test/whats-new.test.ts` fails when its version differs from `package.json`, so a hand bump needs `node scripts/generate-whats-new.mjs && npx prettier --write src/web/public/whats-new.json` too. A release whose notes have no bold lead-ins ships an empty list and shows no toast) 4. **Sync CLAUDE.md version**: Update the `**Version**` line in the root `CLAUDE.md` (under COM Shorthand) to match the new version from `package.json` 5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing: `git add && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so. -7. **Wait for CI**: after `git push`, TWO workflows fire per master push — `CI` and `Release` (the npm publish + GitHub release). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two). +7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release and the MCP registry publish). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two). -8. **Publish the new version to the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there by hand or the listing stays on the old version. `npm run version-packages` has already written the new version into `config/mcp-registry/server.json` (`scripts/sync-plugin.mjs`). Wait until npm serves it (`npm view aicodeman version`; it can lag the green `Release` run by several minutes), because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`. Then run `cd config/mcp-registry && mcp-publisher publish`, preceded by `mcp-publisher login github` when the saved login has expired, signed in as the account that owns the `io.github.Ark0N/` namespace. Use mcp-publisher 1.8.1 or newer (`mcp-publisher --version`), the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. Maintainer only (it needs that GitHub account); contributor clones skip it and say so. +8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. 9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@ -f title='Codeman : ' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to. From 94912241bc10c6ce5cfd524742cce43c3136e862 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 03:57:21 +0100 Subject: [PATCH 2/3] feat(desktop): sign-macos.sh signs, notarizes and staples the app and its DMG The release DMG is signed with the owner's Developer ID and notarized, so a downloaded copy opens after macOS's usual confirmation instead of stopping at Gatekeeper. The work is a script of its own, so CI and a Mac run it the same way: SIGN_IDENTITY=... NOTARY_PROFILE= \ packages/desktop/scripts/sign-macos.sh It re-signs the app with the hardened runtime, a secure timestamp and macos/entitlements.plist, checks the signature is a Developer ID one with the runtime flag, has notarytool notarize it and staples the ticket, builds the DMG around the stapled app, signs it, notarizes and staples it too, and has spctl assess both. Codeman.dmg and Codeman.dmg.sha256 reach only after every check passed; a failed run removes an older pair there rather than leave one to upload by mistake. A submission Apple does not accept stops the script with its id and Apple's log. The notary login is either a notarytool keychain profile (NOTARY_PROFILE, NOTARY_KEYCHAIN) or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID; `sign-macos.sh --check` tests that without doing anything. The DMG layout (the app next to an /Applications link, UDZO, volume "Codeman") moves to make-dmg.sh, which both scripts call, and gains a retry: hosted macOS runners now and then fail hdiutil create with "Resource busy". bundle-macos.sh keeps its SIGN_IDENTITY path as it was; its new --notarize checks the environment before building, then hands the app to sign-macos.sh. packages/desktop/README.md says the release DMG is notarized and how to do the same by hand, and drops the CI build and notarization from its ideas list. All three scripts are bash 3.2 clean, and shellcheck reports nothing. With the macOS tools stubbed, sign-macos.sh was run under bash 3.2 through a full signing, a notarization rejected for the app and for the DMG, two hdiutil failures then a pass, three failures, a relative out-dir and every missing setting. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/README.md | 12 +- packages/desktop/scripts/bundle-macos.sh | 32 +++-- packages/desktop/scripts/make-dmg.sh | 42 +++++++ packages/desktop/scripts/sign-macos.sh | 144 +++++++++++++++++++++++ 4 files changed, 218 insertions(+), 12 deletions(-) create mode 100755 packages/desktop/scripts/make-dmg.sh create mode 100755 packages/desktop/scripts/sign-macos.sh diff --git a/packages/desktop/README.md b/packages/desktop/README.md index cf7e1c17..b5259e81 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -213,10 +213,12 @@ and the Dock icon bounces. Anyway* in System Settings > Privacy & Security (it asks for an administrator password). On macOS 14 and earlier, right-click it and choose *Open* once. Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the - copy in Applications works too. Shipping it to other people needs a Developer - ID signature and notarization: - `SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`, - then `xcrun notarytool submit` and `xcrun stapler staple`. + copy in Applications works too. The `Codeman.dmg` on each GitHub release is + signed with a Developer ID and notarized by CI + (`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the + same by hand: + `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= ./scripts/bundle-macos.sh --universal --notarize`, + where `scripts/sign-macos.sh` lists what it needs. - **Local Network permission (macOS 15 and later).** The first connection to an address on the local network (a LAN IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that first attempt can fail @@ -278,4 +280,4 @@ and the Dock icon bounces. - Trusting a self-signed server certificate by its fingerprint. - Asking before a download that a rendered HTML file or a web tab starts. - A way out of a page that keeps opening dialogs (a "block further dialogs" option). -- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update. +- Windows and Linux builds, auto-update. diff --git a/packages/desktop/scripts/bundle-macos.sh b/packages/desktop/scripts/bundle-macos.sh index ac1b5e6d..6fbdabdf 100755 --- a/packages/desktop/scripts/bundle-macos.sh +++ b/packages/desktop/scripts/bundle-macos.sh @@ -4,6 +4,8 @@ # packages/desktop/scripts/bundle-macos.sh # this Mac's architecture # packages/desktop/scripts/bundle-macos.sh --universal --dmg # SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= \ +# packages/desktop/scripts/bundle-macos.sh --universal --notarize # # Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built # it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15 @@ -11,6 +13,15 @@ # Privacy & Security; on macOS 14 and earlier, right-click it and choose Open. # Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy # in /Applications. A Developer ID signature + notarization avoids all of this. +# +# --dmg writes dist/Codeman-.dmg, signed with SIGN_IDENTITY when it is +# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it +# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does +# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied). +# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE +# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow +# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and +# sign-macos.sh in another, so the job that runs cargo holds no secret. set -euo pipefail cd "$(dirname "$0")/.." @@ -18,14 +29,21 @@ cd "$(dirname "$0")/.." UNIVERSAL=0 DMG=0 +NOTARIZE=0 for arg in "$@"; do case "$arg" in --universal) UNIVERSAL=1 ;; --dmg) DMG=1 ;; + --notarize) NOTARIZE=1 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done +if [ "$NOTARIZE" = 1 ]; then + # Checked before the build, so a missing secret costs seconds, not a build. + ./scripts/sign-macos.sh --check +fi + VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1) [ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } @@ -98,13 +116,13 @@ codesign --verify --strict "$APP" echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)" -if [ "$DMG" = 1 ]; then - STAGE="$WORK/dmg" - mkdir -p "$STAGE" - cp -R "$APP" "$STAGE/" - ln -s /Applications "$STAGE/Applications" +if [ "$NOTARIZE" = 1 ]; then + ./scripts/sign-macos.sh "$APP" dist +elif [ "$DMG" = 1 ]; then DMG_PATH="dist/Codeman-$VERSION.dmg" - rm -f "$DMG_PATH" - hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH" + ./scripts/make-dmg.sh "$APP" "$DMG_PATH" + if [ -n "${SIGN_IDENTITY:-}" ]; then + codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH" + fi echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))" fi diff --git a/packages/desktop/scripts/make-dmg.sh b/packages/desktop/scripts/make-dmg.sh new file mode 100755 index 00000000..22eea160 --- /dev/null +++ b/packages/desktop/scripts/make-dmg.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Put Codeman.app in a compressed disk image next to a link to /Applications, +# so opening the image shows the app and the folder to drag it into. +# +# packages/desktop/scripts/make-dmg.sh +# +# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG +# and the release DMG are laid out the same way. It signs nothing. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; } +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } + +APP=$1 +DMG=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +STAGE="$WORK/dmg" +mkdir -p "$STAGE" +# ditto keeps what cp -R can lose: extended attributes, and with them a +# stapled notarization ticket. +ditto "$APP" "$STAGE/$(basename "$APP")" +ln -s /Applications "$STAGE/Applications" + +# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the +# same command a few seconds later passes. +rm -f "$DMG" +tries=1 +until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do + if [ "$tries" -ge 3 ]; then + echo "hdiutil create failed $tries times" >&2 + exit 1 + fi + tries=$((tries + 1)) + echo "hdiutil create failed, trying again in 10 seconds" >&2 + rm -f "$DMG" + sleep 10 +done +hdiutil verify -quiet "$DMG" diff --git a/packages/desktop/scripts/sign-macos.sh b/packages/desktop/scripts/sign-macos.sh new file mode 100755 index 00000000..46266321 --- /dev/null +++ b/packages/desktop/scripts/sign-macos.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the +# ticket to it, then build Codeman.dmg around the stapled app and do the same +# for the DMG. On macOS. +# +# packages/desktop/scripts/sign-macos.sh +# packages/desktop/scripts/sign-macos.sh --check # only check the environment below +# +# is re-signed and stapled in place. receives +# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it, +# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both +# appear only once every check passed; a failed run leaves neither, not even an +# older pair, so nothing half-done can be uploaded by mistake. +# +# The environment names who signs: +# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain +# on the search list, by name or by the SHA-1 hash that +# `security find-identity -v -p codesigning` prints +# and how to reach Apple's notary service, either through a profile saved with +# `xcrun notarytool store-credentials --apple-id ... --team-id ...`: +# NOTARY_PROFILE the profile's name +# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login) +# or with the Apple ID itself: +# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID +# +# By hand, on a Mac with the certificate in the login keychain: +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \ +# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist +# +# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that +# builds nothing: the job that runs cargo, and with it the build script of every +# crate in Cargo.lock, never holds a signing secret. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; } + +DESKTOP=$(cd "$(dirname "$0")/.." && pwd) +ENTITLEMENTS="$DESKTOP/macos/entitlements.plist" + +# Fails with the name of what is missing, before any work, so a missing secret +# costs seconds rather than a build. +check_env() { + if [ -z "${SIGN_IDENTITY:-}" ]; then + echo "sign-macos.sh needs SIGN_IDENTITY" >&2 + exit 2 + fi + if [ -n "${NOTARY_PROFILE:-}" ]; then + return 0 + fi + local name + for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do + if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then + echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2 + exit 2 + fi + done +} + +if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then + check_env + exit 0 +fi +[ $# -eq 2 ] || { echo "usage: $0 | --check" >&2; exit 2; } +check_env + +APP=$1 +OUT=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) +rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256" +NAME=$(basename "$APP" .app) + +if [ -n "${NOTARY_PROFILE:-}" ]; then + NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE") + if [ -n "${NOTARY_KEYCHAIN:-}" ]; then + NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN") + fi +else + # On notarytool's command line, which the job's log never shows. + NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID") +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Submit a file to the notary service and wait. Anything but Accepted stops the +# script with the submission id and Apple's log, which names the file and the +# reason (an unsigned binary, a missing timestamp, no hardened runtime...). +notarize() { + local file=$1 result="$WORK/notary.plist" rc=0 status id + echo "Submitting $(basename "$file") to Apple's notary service" + xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \ + --output-format plist > "$result" || rc=$? + status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true) + id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true) + if [ "$status" != Accepted ]; then + echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2 + cat "$result" >&2 || true + if [ -n "$id" ]; then + xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true + fi + exit 1 + fi + echo "Notarized $(basename "$file") (submission $id)" +} + +# The app: hardened runtime (which notarization requires), a secure timestamp, +# and the entitlements the hardened runtime needs for microphone and camera. +codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_IDENTITY" "$APP" +codesign --verify --strict --verbose=2 "$APP" +DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1) +printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true +case "$DETAILS" in + *"Authority=Developer ID Application: "*) ;; + *) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;; +esac +case "$DETAILS" in + *"(runtime)"*) ;; + *) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;; +esac + +ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip" +notarize "$WORK/$NAME.zip" +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=2 "$APP" + +# The DMG around the stapled app, signed and notarized in its own right, so the +# image opens without a warning and the app inside opens offline. +DMG="$WORK/Codeman.dmg" +"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG" +codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG" +codesign --verify --strict --verbose=2 "$DMG" +notarize "$DMG" +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" +spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" + +(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256) +mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/" +echo "Signed and notarized $APP" +echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")" From d5273347c30f328c497a3fbed5d4ce1f19b8d397 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 03:57:43 +0100 Subject: [PATCH 3/3] ci(desktop): build, sign, notarize and attach Codeman.dmg on every release Every Codeman release now carries the Mac app as Codeman.dmg and Codeman.dmg.sha256, under a name that never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app. .github/workflows/desktop-dmg.yml (workflow_call from Release with tag codeman@; workflow_dispatch with an optional tag) has three jobs, so no job that holds a secret runs code from a crate: - build (macos-26, contents: read, no secret, no cache) runs cargo test --locked and bundle-macos.sh --universal ad hoc, with CODEMAN_DESKTOP_INSTALLER_URL, CODEMAN_DESKTOP_INSTALL_BRANCH and SIGN_IDENTITY unset, checks both slices, the signature and that no test installer URL or branch is baked in, and uploads the app as a ditto zip. - sign (macos-26, contents: read) refuses an artifact with an entry outside Codeman.app, a ".." or a symlink, imports MACOS_CERT_P12 into a keychain under RUNNER_TEMP with a random password, picks the one Developer ID Application identity by its hash and checks it belongs to APPLE_TEAM_ID, runs sign-macos.sh with the Apple ID login and uploads the signed pair as the codeman-dmg artifact. Its last step deletes the keychain and the decoded .p12 whatever happened before. - attach (ubuntu, contents: write, no secret) runs only with a tag, on master (Release itself, or a rebuild started there) and in Ark0N/Codeman; it uploads the pair with --clobber and checks that the release's download link, and the Latest one when this release holds Latest, serve those bytes. A run without a tag is the dry run: everything but attach. release.yml reads the Codeman version from changesets' publishedPackages (published alone is also true for an xterm-zerolag-input-only release) and runs the macOS app job only for a Codeman release, passing it the five secrets by name, never secrets: inherit. A new step puts Latest back on the newest codeman@ release after every publish, so a release that moved only xterm-zerolag-input no longer takes the badge and breaks the stable link. Both new steps sit after the retag and only warn on failure, so npm, the tag and the MCP publish are never held up by them. desktop-ci.yml checks pull requests and master pushes that touch packages/desktop: cargo fmt --check, clippy -D warnings, cargo test --locked and bash -n under macOS's bash 3.2, on macos-26 with a read-only token. Both workflows pin Rust 1.99.1, what the macos-26 image and the Mac mini ran on 2026-11-08. docs/releasing.md: step 6 holds a site change that links the app until the DMG check passes, step 7 names the new job, and a new section covers the secrets, the check, a dry run, a rebuild and what to do when notarization fails. actionlint 1.7.12 (with shellcheck 0.11.0) reports nothing on the three workflows, and master's test/release-notes.test.ts assertions hold on the new release.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/desktop-ci.yml | 72 +++++++ .github/workflows/desktop-dmg.yml | 337 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 57 +++++ docs/releasing.md | 35 +++- 4 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/desktop-ci.yml create mode 100644 .github/workflows/desktop-dmg.yml diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml new file mode 100644 index 00000000..a5118ce9 --- /dev/null +++ b/.github/workflows/desktop-ci.yml @@ -0,0 +1,72 @@ +name: Desktop app + +# Checks the native macOS app in packages/desktop on the pull requests and master +# pushes that change it: formatting, clippy and the unit tests, on macOS. No +# secret and a read-only token, because cargo runs the build script of every +# crate in Cargo.lock. The release build, signing and notarization live in +# desktop-dmg.yml. + +on: + pull_request: + paths: + - 'packages/desktop/**' + - '.github/workflows/desktop-ci.yml' + push: + branches: [master] + paths: + - 'packages/desktop/**' + - '.github/workflows/desktop-ci.yml' + +permissions: + contents: read + +concurrency: + group: desktop-ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +env: + # Keep in step with desktop-dmg.yml, which builds the release with it. + RUST_TOOLCHAIN: '1.99.1' + +jobs: + check: + name: fmt, clippy, test + runs-on: macos-26 + timeout-minutes: 45 + defaults: + run: + working-directory: packages/desktop + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Rust toolchain + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy --component rustfmt + rustup default "$RUST_TOOLCHAIN" + rustc --version + cargo --version + + - name: Format + run: cargo fmt --check + + # A test build bakes another installer in at compile time (option_env! in + # setup.rs), so neither variable may reach cargo. + - name: Clippy + run: | + unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH + cargo clippy --locked --all-targets -- -D warnings + + - name: Unit tests + run: | + unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH + cargo test --locked + + # /bin/bash on macOS is 3.2, the bash these scripts run under on a Mac. + - name: Scripts parse on bash 3.2 + run: | + for script in scripts/*.sh; do + /bin/bash -n "$script" + done diff --git a/.github/workflows/desktop-dmg.yml b/.github/workflows/desktop-dmg.yml new file mode 100644 index 00000000..a39c87ad --- /dev/null +++ b/.github/workflows/desktop-dmg.yml @@ -0,0 +1,337 @@ +name: macOS app + +# Builds Codeman.app (Apple Silicon + Intel), signs it with the Developer ID, +# has Apple notarize the app and the DMG, and attaches Codeman.dmg and +# Codeman.dmg.sha256 to a Codeman release. The asset name never changes, so +# https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always +# serves the newest app. +# +# Release calls this when npm took a Codeman release, with tag codeman@, +# and it builds the release commit. By hand, once this file is on master: +# gh workflow run desktop-dmg.yml # dry run of master: artifacts only +# gh workflow run desktop-dmg.yml --ref # dry run of a branch that carries this file +# gh workflow run desktop-dmg.yml -f tag=codeman@1.50.0 # rebuild that tag, replace its Codeman.dmg +# Only a run on master (or Release itself) attaches anything; every run leaves +# the signed Codeman.dmg as an artifact on its run page. +# +# Three jobs, so no job that holds a secret runs code from a crate: +# - build runs cargo, and with it the build script of every crate in +# Cargo.lock. It has a read-only token and nothing else: no secret, no cache. +# - sign gets the built app as an artifact and runs only Apple's tools and this +# repository's scripts on it. It alone holds the signing and notary secrets. +# - attach has the one token that can write to a release, and no secret. + +on: + workflow_call: + inputs: + tag: + description: Release to attach Codeman.dmg to (codeman@x.y.z); empty attaches nothing + type: string + required: false + default: '' + secrets: + MACOS_CERT_P12: + description: The Developer ID Application certificate with its private key, a base64 .p12 + required: true + MACOS_CERT_PASSWORD: + description: The .p12's password + required: true + APPLE_ID: + description: Apple ID of the developer team, for the notary service + required: true + APPLE_APP_PASSWORD: + description: An app-specific password for that Apple ID + required: true + APPLE_TEAM_ID: + description: The team ID the certificate belongs to + required: true + workflow_dispatch: + inputs: + tag: + description: 'Release tag to rebuild and attach to, e.g. codeman@1.50.0. Empty: dry run, artifacts only' + type: string + required: false + default: '' + +permissions: + contents: read + +env: + # What the macos-26 image and the Mac mini that built every tested DMG both ran + # on 2026-11-08. Move it deliberately, together with desktop-ci.yml; Cargo.toml's + # rust-version is the floor. + RUST_TOOLCHAIN: '1.99.1' + # The commit to build: a rebuild by hand builds the tag it attaches to; Release + # (a push to master) and a dry run build the commit they ran on. + BUILD_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag != '' && format('refs/tags/{0}', inputs.tag) || github.sha }} + +jobs: + build: + name: Build Codeman.app + runs-on: macos-26 + timeout-minutes: 60 + permissions: + contents: read + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + ref: ${{ env.BUILD_REF }} + persist-credentials: false + + - name: Rust toolchain + run: | + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal \ + --target aarch64-apple-darwin --target x86_64-apple-darwin + rustup default "$RUST_TOOLCHAIN" + rustc --version + cargo --version + + # A release installs Codeman from getcodeman.com/install and master. A test + # build bakes another installer in at compile time (option_env! in setup.rs), + # so neither variable may reach cargo here, and setup.rs's tests pin the default. + - name: Unit tests + working-directory: packages/desktop + run: | + unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH + cargo test --locked + + - name: Bundle Codeman.app (signed ad hoc) + working-directory: packages/desktop + run: | + unset CODEMAN_DESKTOP_INSTALLER_URL CODEMAN_DESKTOP_INSTALL_BRANCH SIGN_IDENTITY + ./scripts/bundle-macos.sh --universal + + - name: Check the build + working-directory: packages/desktop + run: | + BIN=dist/Codeman.app/Contents/MacOS/codeman-desktop + lipo "$BIN" -verify_arch arm64 x86_64 + codesign --verify --strict dist/Codeman.app + strings -a "$BIN" > "$RUNNER_TEMP/strings.txt" + if grep -E 'raw\.githubusercontent\.com|test/desktop-' "$RUNNER_TEMP/strings.txt"; then + echo "::error::A test installer URL or branch is baked into the app" + exit 1 + fi + grep -q 'https://getcodeman.com/install' "$RUNNER_TEMP/strings.txt" + + # ditto keeps the bundle's modes, symlinks and extended attributes; an + # artifact of the bare folder would not (upload-artifact sets every file to + # 644). The artifact is a zip around this zip: download-artifact unpacks the + # outer one and leaves the ditto zip as it was. + - name: Archive Codeman.app + run: | + mkdir -p "$RUNNER_TEMP/unsigned" + ditto -c -k --sequesterRsrc --keepParent packages/desktop/dist/Codeman.app "$RUNNER_TEMP/unsigned/Codeman.app.zip" + + - name: Upload the unsigned app + uses: actions/upload-artifact@v7 + with: + name: codeman-app-unsigned + path: ${{ runner.temp }}/unsigned/Codeman.app.zip + if-no-files-found: error + retention-days: 7 + overwrite: true + + sign: + name: Sign and notarize + needs: build + runs-on: macos-26 + # Two notary submissions; each usually takes minutes, and sign-macos.sh + # gives each at most 45. + timeout-minutes: 120 + permissions: + contents: read + steps: + # For sign-macos.sh, make-dmg.sh and macos/entitlements.plist only. Nothing + # here builds or runs code from the app or its crates. + - name: Checkout repo + uses: actions/checkout@v6 + with: + ref: ${{ env.BUILD_REF }} + persist-credentials: false + + - name: Download the unsigned app + uses: actions/download-artifact@v8 + with: + name: codeman-app-unsigned + path: ${{ runner.temp }}/unsigned + + # The build job ran every crate's build script, so its zip is checked before + # this job, which holds the secrets, unpacks it: entries only under + # Codeman.app/ (and the __MACOSX/ folder where ditto keeps attributes), no + # "..", and no symlink, which the app never has and which could point an + # unpack or a later write outside the app. + - name: Unpack Codeman.app + run: | + ZIP="$RUNNER_TEMP/unsigned/Codeman.app.zip" + ENTRIES=$(zipinfo -1 "$ZIP") + LISTING=$(zipinfo "$ZIP") + if printf '%s\n' "$ENTRIES" | grep -vE '^(Codeman\.app|__MACOSX)/' \ + || printf '%s\n' "$ENTRIES" | grep -E '(^|/)\.\.(/|$)' \ + || printf '%s\n' "$LISTING" | grep '^l'; then + echo "::error::The build artifact holds an entry outside Codeman.app, a '..' or a symlink" + exit 1 + fi + mkdir -p "$RUNNER_TEMP/app" + ditto -x -k "$ZIP" "$RUNNER_TEMP/app" + if [ -n "$(find "$RUNNER_TEMP/app" -type l)" ]; then + echo "::error::The unpacked app holds a symlink" + exit 1 + fi + test -x "$RUNNER_TEMP/app/Codeman.app/Contents/MacOS/codeman-desktop" + + # A keychain of its own under RUNNER_TEMP, with a random password, holds the + # Developer ID key for the length of this job. The last step deletes it and + # the decoded .p12, whatever happened before. All five secrets are checked + # here, so a missing one stops the job before any work. + - name: Signing keychain + env: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + for name in MACOS_CERT_P12 MACOS_CERT_PASSWORD APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do + if [ -z "${!name}" ]; then + echo "::error::The repository secret $name is empty or not set" + exit 1 + fi + done + KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db" + P12="$RUNNER_TEMP/codeman-signing.p12" + trap 'rm -f "$P12"' EXIT + KEYCHAIN_PASSWORD=$(openssl rand -hex 32) + echo "::add-mask::$KEYCHAIN_PASSWORD" + + # Line breaks out first: an encoder that wraps its output (GNU base64 + # without -w0) leaves some that macOS's base64 refuses. + printf '%s' "$MACOS_CERT_P12" | tr -d '\r\n ' | base64 --decode > "$P12" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + # Locks after six hours, not after the default five idle minutes, which + # a notarization wait can outlast. + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security import "$P12" -k "$KEYCHAIN" -f pkcs12 -P "$MACOS_CERT_PASSWORD" -T /usr/bin/codesign + rm -f "$P12" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null + # First on the search list, ahead of the runner's own keychains. + SEARCH=() + while IFS= read -r line; do + line=${line#*\"} + line=${line%\"*} + if [ -n "$line" ]; then SEARCH+=("$line"); fi + done < <(security list-keychains -d user) + security list-keychains -d user -s "$KEYCHAIN" "${SEARCH[@]}" + + # The identity by its SHA-1 hash, which no other certificate can match. + IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN") + MATCHES=$(printf '%s\n' "$IDENTITIES" | grep '"Developer ID Application: ' || true) + COUNT=$(printf '%s' "$MATCHES" | grep -c . || true) + if [ "$COUNT" != 1 ]; then + printf '%s\n' "$IDENTITIES" + echo "::error::MACOS_CERT_P12 holds $COUNT valid Developer ID Application identities; it needs exactly one" + exit 1 + fi + HASH=$(printf '%s\n' "$MATCHES" | awk '{ print $2 }') + IDENTITY=$(printf '%s\n' "$MATCHES" | sed 's/^[^"]*"\(.*\)"$/\1/') + case "$IDENTITY" in + *"($APPLE_TEAM_ID)") ;; + *) + echo "::error::The certificate in MACOS_CERT_P12 ($IDENTITY) is not one of team APPLE_TEAM_ID" + exit 1 + ;; + esac + echo "Signing as $IDENTITY ($HASH)" + echo "SIGN_IDENTITY=$HASH" >> "$GITHUB_ENV" + + # The notary login goes to notarytool on its command line (sign-macos.sh's + # APPLE_ID path), in this job only, which already holds the signing key. + - name: Sign, notarize and staple + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packages/desktop/scripts/sign-macos.sh "$RUNNER_TEMP/app/Codeman.app" "$RUNNER_TEMP/out" + + # A dry run's result: codeman-dmg on the run page (gh run download + # -n codeman-dmg). attach takes it from here too. + - name: Upload Codeman.dmg + uses: actions/upload-artifact@v7 + with: + name: codeman-dmg + path: | + ${{ runner.temp }}/out/Codeman.dmg + ${{ runner.temp }}/out/Codeman.dmg.sha256 + if-no-files-found: error + retention-days: 30 + overwrite: true + + - name: Remove the signing keychain + if: always() + run: | + KEYCHAIN="$RUNNER_TEMP/codeman-signing.keychain-db" + if [ -e "$KEYCHAIN" ]; then + security delete-keychain "$KEYCHAIN" || true + fi + rm -f "$KEYCHAIN" "$RUNNER_TEMP/codeman-signing.p12" + + attach: + name: Attach to the release + needs: sign + # Release runs on pushes to master, so github.ref is master both there and for + # a rebuild started by hand on master. A dry run from any other branch, or a + # fork, attaches nothing. + if: inputs.tag != '' && github.ref == 'refs/heads/master' && github.repository == 'Ark0N/Codeman' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Download Codeman.dmg + uses: actions/download-artifact@v8 + with: + name: codeman-dmg + path: out + + - name: Upload to the release + id: upload + working-directory: out + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_TAG: ${{ inputs.tag }} + run: | + sha256sum -c Codeman.dmg.sha256 + TAG=$REQUESTED_TAG + # Release's retag step ignores its own errors, so a release whose rename to + # codeman@ failed still carries the tag changesets gave it. + if ! gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + case "$TAG" in + codeman@*) TAG="aicodeman@${TAG#codeman@}" ;; + esac + gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null + echo "::warning::Attaching to $TAG: there is no release $REQUESTED_TAG" + fi + gh release upload "$TAG" Codeman.dmg Codeman.dmg.sha256 --clobber -R "$GITHUB_REPOSITORY" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Check the download links + working-directory: out + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.upload.outputs.tag }} + run: | + EXPECTED=$(cut -d' ' -f1 Codeman.dmg.sha256) + check() { + curl -fsSL --retry 6 --retry-delay 10 --retry-all-errors -o "$RUNNER_TEMP/served.dmg" "$1" + echo "$EXPECTED $RUNNER_TEMP/served.dmg" | sha256sum -c --quiet - + echo "$1 serves this build" + } + check "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/Codeman.dmg" + LATEST=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name) + if [ "$LATEST" = "$TAG" ]; then + check "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/Codeman.dmg" + else + echo "::warning::Latest is $LATEST, so releases/latest/download/Codeman.dmg does not serve $TAG" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56a1a08c..2ecb8b13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ jobs: pull-requests: write outputs: published: ${{ steps.changesets.outputs.published }} + # Set only when aicodeman itself was published: `published` is also true for + # a release that moved only xterm-zerolag-input. + codeman_version: ${{ steps.codeman.outputs.version }} steps: - name: Checkout repo uses: actions/checkout@v6 @@ -89,6 +92,38 @@ jobs: git tag -d "$OLD_TAG" 2>/dev/null || true git push origin "$NEW_TAG" ":refs/tags/$OLD_TAG" 2>/dev/null || true + # The macOS app job below runs only for a Codeman release, so it reads the + # version from the list of packages this run published. Never fails the job: + # npm has published by now, and a bad list only means no app build. + - name: Codeman version published by this run + id: codeman + if: steps.changesets.outputs.published == 'true' + env: + PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + run: | + VERSION=$(printf '%s' "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "aicodeman") | .version' || true) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Codeman version published: ${VERSION:-none (no macOS app build)}" + + # releases/latest/download/Codeman.dmg (the Mac app's stable link) serves + # whichever release holds Latest. GitHub gives Latest to the release created + # last, and a publish that moved only xterm-zerolag-input creates one the + # retag above never touches, so Latest goes back to the newest Codeman + # release after every publish. A failure warns and leaves npm, the tag and + # the MCP publish alone. + - name: Keep Latest on the Codeman release + if: steps.changesets.outputs.published == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -p "require('./package.json').version") + RELEASE_ID=$(gh release view "codeman@${VERSION}" --json databaseId -q .databaseId 2>/dev/null || true) + if [ -z "$RELEASE_ID" ]; then + echo "::warning::No codeman@${VERSION} release to mark Latest" + elif ! gh api -X PATCH "repos/${{ github.repository }}/releases/${RELEASE_ID}" -f make_latest=true > /dev/null; then + echo "::warning::Could not mark codeman@${VERSION} Latest" + fi + # Its own job: this one alone holds id-token, and a registry failure shows as itself # without touching the npm publish or the tag above. mcp-registry: @@ -99,3 +134,25 @@ jobs: contents: read id-token: write uses: ./.github/workflows/publish-mcp.yml + + # Builds, signs and notarizes the Mac app and attaches Codeman.dmg to the + # release (.github/workflows/desktop-dmg.yml), only when Codeman itself was + # published. Its own job, so a failed DMG shows as itself and never touches + # npm, the tag or the MCP publish; `gh workflow run desktop-dmg.yml -f + # tag=codeman@` reruns it. It gets the five secrets it uses and no + # other: never `secrets: inherit`, which would hand it NPM_TOKEN. + macos-app: + name: macOS app + needs: release + if: needs.release.outputs.codeman_version != '' + permissions: + contents: write # the ceiling for the called jobs; only its attach job asks for write + uses: ./.github/workflows/desktop-dmg.yml + with: + tag: codeman@${{ needs.release.outputs.codeman_version }} + secrets: + MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} diff --git a/docs/releasing.md b/docs/releasing.md index 3cab92b9..e967cbf7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -42,7 +42,10 @@ When user says "COM": 5. **Commit and deploy**: verify the branch first (`git branch --show-current`), then stage EXPLICIT paths — never `git add -A`, which has swept another session's WIP into a release. `git status --short` and account for every line before committing: `git add && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` 6. **Refresh the getcodeman.com version badge**: the landing page's status bar carries the release version (`v · getcodeman.com · MIT`), so it goes stale on every release if nobody bumps it. The site source and its deploy script are maintained outside this repository, on the maintainer's machine only; follow the local site handbook there, which also covers the numbers strip and `sitemap.xml` refresh that belong in the same pass. Poll production (`curl -s https://getcodeman.com/ | grep v`) before calling it done, since the edge lags a deploy by up to a minute. Not applicable to contributor clones — skip it and say so. -7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release and the MCP registry publish). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two). + + A site change that links the Mac app (`Codeman.dmg`, `/mac`) deploys only after step 7 showed the `macOS app` job green and the [DMG check](#the-mac-app-codemandmg) answered 200: the DMG reaches the release well after the npm publish, and the download link answers 404 until then. + +7. **Wait for CI**: after `git push`, TWO workflows fire per master push: `CI` and `Release` (the npm publish, the GitHub release, the MCP registry publish and, when Codeman itself was published, the `macOS app` job that attaches `Codeman.dmg`). List both runs for the pushed commit with `gh run list --commit $(git rev-parse HEAD) --json databaseId,workflowName` and watch EACH with `gh run watch --exit-status`. Confirm both pass before considering the release done (`gh run list -L 1` returns only one of the two). 8. **Check the MCP registry**: `codeman mcp` is listed in the official MCP registry as `io.github.Ark0N/codeman`, and the registry does not follow npm, so every release is published there. The `Release` workflow does this itself in its `MCP registry` job (`.github/workflows/publish-mcp.yml`): it waits until npm serves the version, because the registry checks that the npm package exists and carries `mcpName: io.github.Ark0N/codeman`, signs in with the workflow's GitHub OIDC token (no secret, no browser) and publishes the `config/mcp-registry/server.json` that `npm run version-packages` wrote (`scripts/sync-plugin.mjs`). A version the registry already has is skipped. Confirm with `curl -s 'https://registry.modelcontextprotocol.io/v0/servers?search=io.github.Ark0N/codeman' | jq -r '.servers[] | "\(.server.version) latest=\(._meta["io.modelcontextprotocol.registry/official"].isLatest)"'`: the new version must be the one with `latest=true`. If the job failed, `gh workflow run publish-mcp.yml` publishes the version on master again. Its publisher is pinned: bump `MCP_PUBLISHER_VERSION` and `MCP_PUBLISHER_SHA256` together, the digest from the release's asset. The local fallback is `cd config/mcp-registry && ~/.local/bin/mcp-publisher login github && ~/.local/bin/mcp-publisher publish`, signed in as the account that owns the `io.github.Ark0N/` namespace, with mcp-publisher 1.8.1 or newer, the release binary from https://github.com/modelcontextprotocol/registry/releases: the Snap Store package is stuck at 1.1.0 and rejects the current `$schema` with a false "deprecated schema detected", so never edit `server.json` for that error. Older publishers also save their login as `.mcpregistry_github_token` and `.mcpregistry_registry_token` (live tokens) in the current directory; move those out of the repo, never commit them. @@ -50,6 +53,36 @@ When user says "COM": CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. +## The Mac app (Codeman.dmg) + +Every Codeman release carries the native macOS app (`packages/desktop`) as `Codeman.dmg` plus `Codeman.dmg.sha256`. The name never changes, so https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg always serves the newest app, while each release keeps its own copy at `https://github.com/Ark0N/Codeman/releases/download/codeman@/Codeman.dmg` (link that one in release notes). The app keeps its own version (`packages/desktop/Cargo.toml`), so the DMG of Codeman 1.50.0 holds app 0.1.0. + +**How it gets there.** When npm took a Codeman release (not one that moved only xterm-zerolag-input), the `Release` run's `macOS app` job calls `.github/workflows/desktop-dmg.yml` with the tag `codeman@`. It has three jobs, so no job that holds a secret runs code from a crate: `build` runs `cargo test --locked` and `bundle-macos.sh --universal` on the release commit with a read-only token and no secret; `sign` imports the Developer ID certificate into a throwaway keychain and runs `packages/desktop/scripts/sign-macos.sh`, which signs the app with the hardened runtime, has Apple notarize it, staples the ticket, builds the DMG around it and signs, notarizes and staples that too; `attach` uploads both files to the release (`--clobber`) and checks that the download links serve those bytes. The release job's `Keep Latest on the Codeman release` step puts the Latest badge back on the newest `codeman@` release after every publish, since the stable link follows that badge. + +**The secrets** are repository secrets, passed to the job by name (never `secrets: inherit`, which would hand it `NPM_TOKEN`): `MACOS_CERT_P12` (the Developer ID Application certificate WITH its private key, exported from Keychain Access as a .p12, then `base64 -i .p12`), `MACOS_CERT_PASSWORD` (the export password), `APPLE_ID`, `APPLE_APP_PASSWORD` (an app-specific password for that Apple ID, made at account.apple.com) and `APPLE_TEAM_ID`. A Developer ID certificate lasts five years; its successor replaces the first two secrets and nothing else. + +**Check it before anything links to it.** The DMG lands after a universal build and two notary submissions, so well after the npm publish, and until then the stable link answers 404: + +```bash +gh release view codeman@ --json assets -q '.assets[].name' # Codeman.dmg and Codeman.dmg.sha256 +curl -sIL -o /dev/null -w '%{http_code}\n' https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg # 200 +``` + +**Dry run.** `gh workflow run desktop-dmg.yml` builds, signs and notarizes master and attaches nothing; `gh workflow run desktop-dmg.yml --ref ` does the same for a branch that carries the workflow (GitHub starts a manual run only for a workflow file that is also on master). The signed DMG is the run's `codeman-dmg` artifact (`gh run download -n codeman-dmg`). For a first-launch test, download it on a Mac through the browser from the run page instead, so the copy is quarantined like one from a release. + +**Rebuild a release's DMG.** `gh workflow run desktop-dmg.yml -f tag=codeman@`, started from master (the only ref allowed to attach), builds that tag and replaces its `Codeman.dmg`. It works for any release that carries `packages/desktop/scripts/sign-macos.sh`. A failed job is also retried in place with `gh run rerun --failed`. + +**If notarization fails.** The `Sign and notarize` job's log names the submission and prints Apple's log for it (`xcrun notarytool log ` with the same login shows it again). + +- `HTTP status code: 401` (invalid credentials) or `403` (a required agreement is missing or has expired) at the first submission: a new Apple ID password revokes every app-specific password, so make a new one and update `APPLE_APP_PASSWORD`, or accept the new agreement at developer.apple.com, then `gh run rerun --failed`. +- `0 valid Developer ID Application identities` in the `Signing keychain` step: the .p12 lacks its private key or holds another certificate type. A team mismatch there means `APPLE_TEAM_ID` names another team than the certificate. +- `In Progress` at the 45-minute limit, or a network error: Apple's service was slow. Rerun the failed job. +- `Invalid`: Apple's log names the file and the reason (a binary without the hardened runtime or a secure timestamp, an entitlement it refuses). That is a code fix and a new release. Until then keep the stable link on the last app that passed, `gh release edit codeman@ --latest`, and hold any site change that links the new one. + +**By hand on a Mac**, only when the workflow cannot be fixed in time: in a clean checkout of the tag, `env -u CODEMAN_DESKTOP_INSTALLER_URL -u CODEMAN_DESKTOP_INSTALL_BRANCH packages/desktop/scripts/bundle-macos.sh --universal`, then `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app ` (a profile saved with `xcrun notarytool store-credentials`), then `gh release upload codeman@ /Codeman.dmg /Codeman.dmg.sha256 --clobber`. Never upload a build made with either of those two variables set: it installs Codeman from the test branch it names. + +**Toolchain and runner.** `desktop-dmg.yml` and the pull-request check `desktop-ci.yml` (`cargo fmt --check`, clippy with `-D warnings` and `cargo test --locked` on macOS, for changes under `packages/desktop`) both pin `RUST_TOOLCHAIN` and run on `macos-26`. Move them together. + ## Security notices A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it.