Publish macOS Release #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"/* |