mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
A dispatch with -f tag=<any tag> built that tag, and sign then ran the tag's sign-macos.sh with the Developer ID key unlocked and the notary login in its environment. Nothing checked the tag: xterm-zerolag-input@0.1.7 would have put Codeman.dmg on that package's release, and a tag on a commit that never reached master would have run unreviewed scripts with the secrets. build and sign now each check their own checkout before anything else runs: the tag must read codeman@x.y.z (or aicodeman@x.y.z, which a failed rename leaves) and GitHub's compare of master with the checked-out commit must say behind or identical. The check is inline, since a script from the checkout would be the tag's own code. Release's own call (a push, not a dispatch) and the branch dry run are unchanged. The header says why a master-only Environment would not close this path, and release.yml's macos-app job now says that any permission a called job asks for must be granted there too, or Release fails at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
17 KiB
YAML
400 lines
17 KiB
YAML
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. A rebuild takes only a
|
|
# Codeman release tag whose commit is on master ("Check the tag", in build and
|
|
# in sign).
|
|
#
|
|
# A dispatch runs its ref's copy of this file with the secrets, as every
|
|
# repository secret reaches anyone with write access. A GitHub Environment
|
|
# limited to master would not narrow the rebuild path (the job's ref is master
|
|
# while it runs the tag's scripts) and would refuse the branch dry run above;
|
|
# the tag check is what keeps a rebuild on master's history.
|
|
#
|
|
# 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
|
|
|
|
# A rebuild by hand builds the tag it names, and sign then runs that tag's
|
|
# scripts with the Developer ID key unlocked. So the tag must be a Codeman
|
|
# release (another package's tag would put Codeman.dmg on its release), and
|
|
# its commit must be on master's history, where those scripts were reviewed.
|
|
# aicodeman@ is the tag a failed rename leaves (Release's retag step).
|
|
# A tag can move between jobs, so build and sign each check their own
|
|
# checkout. Inline on purpose: a script from the checkout would be the
|
|
# tag's own code. Kept the same as the step in sign.
|
|
- name: Check the tag
|
|
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ inputs.tag }}
|
|
run: |
|
|
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
|
if ! [[ $TAG =~ $TAG_RE ]]; then
|
|
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
|
exit 1
|
|
fi
|
|
SHA=$(git rev-parse HEAD)
|
|
# behind: an ancestor of master's tip; identical: the tip itself.
|
|
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
|
case "$STATUS" in
|
|
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
|
*)
|
|
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
- 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
|
|
|
|
# The same check as in build, on this job's own checkout, before anything
|
|
# here touches a secret. Kept the same as the step in build.
|
|
- name: Check the tag
|
|
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ inputs.tag }}
|
|
run: |
|
|
TAG_RE='^(ai)?codeman@[0-9]+\.[0-9]+\.[0-9]+$'
|
|
if ! [[ $TAG =~ $TAG_RE ]]; then
|
|
echo "::error::The tag input is not a Codeman release tag (codeman@x.y.z)"
|
|
exit 1
|
|
fi
|
|
SHA=$(git rev-parse HEAD)
|
|
# behind: an ancestor of master's tip; identical: the tip itself.
|
|
STATUS=$(gh api "repos/$GITHUB_REPOSITORY/compare/master...$SHA" --jq .status)
|
|
case "$STATUS" in
|
|
behind | identical) echo "$TAG ($SHA) is on master's history" ;;
|
|
*)
|
|
echo "::error::$TAG ($SHA) is not on master's history (compare says $STATUS)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
- 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
|