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.