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@<version>; 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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-08 04:03:25 +01:00
1 parent 94912241bc
commit d5273347c3
4 files changed
+500 -1

No files matched your search

+72
View File
@@ -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
+337
View File
@@ -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@<version>,
# 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 <branch> # 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 <run-id>
# -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
+57
View File
@@ -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@<version>` 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 }}