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. diff --git a/packages/desktop/README.md b/packages/desktop/README.md index a3396adb..75c7066d 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -341,10 +341,12 @@ crosses, so the login is never on a command line. 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 @@ -420,4 +422,4 @@ crosses, so the login is never on a command line. - 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")"