Skip to content

Publish macOS Release #7

Publish macOS Release

Publish macOS Release #7

name: Publish macOS Release
on:
release:
types: [published]
workflow_call:
inputs:
tag:
type: string
required: true
ref:
type: string
required: false
default: ''
dry_run:
type: boolean
required: false
default: true
workflow_dispatch:
inputs:
tag:
description: Release tag matching package.json, with or without a v prefix.
type: string
required: true
ref:
description: Git ref for a manual dry-run; defaults to the selected workflow ref.
type: string
required: false
default: ''
dry_run:
description: Sign, notarize, and validate Actions artifacts without uploading Release assets.
type: boolean
required: true
default: true
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
package_macos:
name: Sign and notarize macOS arm64
runs-on: macos-15
environment: apple-release
timeout-minutes: 90
outputs:
version: ${{ steps.release.outputs.version }}
release_tag: ${{ steps.release.outputs.release_tag }}
dry_run: ${{ steps.release.outputs.dry_run }}
source_sha: ${{ steps.release.outputs.source_sha }}
steps:
- name: Require Apple release configuration
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ vars.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ vars.APPLE_NOTARY_ISSUER_ID }}
run: |
set -euo pipefail
[[ "$(uname -m)" == arm64 ]] || { echo '::error::An ARM64 macOS runner is required'; exit 1; }
missing=()
for name in APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_NOTARY_KEY_BASE64 APPLE_NOTARY_KEY_ID APPLE_NOTARY_ISSUER_ID; do
if [[ -z "${!name}" ]]; then missing+=("$name"); fi
done
if (( ${#missing[@]} )); then
echo "::error::Missing Apple release configuration: ${missing[*]}"
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.dry_run && (inputs.ref || github.sha) || inputs.tag || github.event.release.tag_name }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22
architecture: arm64
cache: npm
- name: Validate release source and version
id: release
env:
RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }}
MANUAL_REF: ${{ inputs.ref }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
node --input-type=module <<'JS'
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
const { version } = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const tag = process.env.RELEASE_TAG;
const dryRun = process.env.DRY_RUN === 'true';
assert.match(version, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
assert.equal(tag?.replace(/^v/, ''), version, 'Release tag must match package.json version');
assert.ok(dryRun || !process.env.MANUAL_REF, 'A manual ref override requires dry_run=true');
const source = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
if (!dryRun) {
const tagged = execFileSync('git', ['rev-parse', '--verify', `refs/tags/${tag}^{commit}`], { encoding: 'utf8' }).trim();
assert.equal(source, tagged, 'Publishing must build the exact release tag');
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nrelease_tag=${tag}\ndry_run=${dryRun}\nsource_sha=${source}\n`);
console.log(`Release ${tag}; source ${source}; dry-run ${dryRun}`);
JS
- name: Install dependencies
run: npm ci
- name: Verify source
run: |
set -euo pipefail
log_file="$RUNNER_TEMP/errand-verification.log"
if (npm run typecheck && npm run lint && npm test) >"$log_file" 2>&1; then
cat "$log_file"
else
cat "$log_file" >&2
exit 1
fi
- name: Build signed and notarized release
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_NOTARY_KEY_BASE64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }}
APPLE_NOTARY_KEY_ID: ${{ vars.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ vars.APPLE_NOTARY_ISSUER_ID }}
run: npm run package:release
- name: Smoke-test packaged app and require final artifacts
run: |
set -euo pipefail
log_file="$RUNNER_TEMP/errand-release-smoke.log"
if npm run smoke >"$log_file" 2>&1; then
cat "$log_file"
else
cat "$log_file" >&2
exit 1
fi
shopt -s nullglob
dmgs=(release/*.dmg)
zips=(release/*.zip)
(( ${#dmgs[@]} > 0 && ${#zips[@]} > 0 ))
for file in "${dmgs[@]}" "${zips[@]}"; do
[[ -s "$file" && -s "$file.blockmap" ]]
done
[[ -s release/latest-mac.yml ]]
- name: Upload verified macOS artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: errand-macos-arm64-${{ steps.release.outputs.version }}
path: |
release/*.dmg
release/*.zip
release/*.blockmap
release/latest-mac.yml
if-no-files-found: error
compression-level: 0
retention-days: 7
publish_assets:
name: Upload GitHub Release assets
needs: package_macos
if: needs.package_macos.outputs.dry_run == 'false'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: errand-macos-arm64-${{ needs.package_macos.outputs.version }}
path: release
- name: Upload assets to the existing release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.package_macos.outputs.release_tag }}
SOURCE_SHA: ${{ needs.package_macos.outputs.source_sha }}
run: |
set -euo pipefail
gh release view --repo "$GITHUB_REPOSITORY" "$RELEASE_TAG" >/dev/null
[[ "$(gh api "repos/$GITHUB_REPOSITORY/commits/$RELEASE_TAG" --jq .sha)" == "$SOURCE_SHA" ]] || { echo '::error::Release tag moved after the build'; exit 1; }
shopt -s nullglob
assets=(release/*.dmg release/*.zip release/*.blockmap release/latest-mac.yml)
destination="$RUNNER_TEMP/errand-release-assets"
mkdir -p "$destination"
for asset in "${assets[@]}"; do
[[ -s "$asset" ]]
name="${asset##*/}"
cp "$asset" "$destination/${name// /-}"
done
gh release upload --repo "$GITHUB_REPOSITORY" --clobber "$RELEASE_TAG" "$destination"/*