Release macOS app #59
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: Release macOS app | |
| # Single-lane release pipeline: this is the ONLY macOS release lane. | |
| # - workflow_run: auto-ships from `main` after the "CI" workflow succeeds (green CI on main), | |
| # publishing to a single reused `rolling` release that is overwritten each ship. | |
| # - push tags v*: milestone marketing-version releases (manual `scripts/bump-version.sh` + tag). | |
| # - workflow_dispatch: dry-run build that uploads an artifact instead of publishing. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| branches: [main] | |
| workflow_dispatch: | |
| concurrency: | |
| group: release-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: write | |
| attestations: write | |
| id-token: write | |
| env: | |
| CREATE_DMG_VERSION: 8.0.0 | |
| jobs: | |
| build-sign-notarize: | |
| # Only build for workflow_run events when the upstream CI run actually succeeded. | |
| # Tag pushes and manual dispatch always run. | |
| if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' | |
| runs-on: macos-15 | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| with: | |
| # For workflow_run, github.ref/github.sha resolve to the default branch's | |
| # workflow definition, not the commit that triggered CI. Pin to the exact | |
| # commit CI validated. | |
| ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} | |
| submodules: recursive | |
| - name: Determine release tag and effective build number | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| PROJECT_FILE="GhosttyTabs.xcodeproj/project.pbxproj" | |
| MARKETING_VERSION=$(grep -m1 'MARKETING_VERSION = ' "$PROJECT_FILE" | sed 's/.*= //;s/;.*//') | |
| # Sparkle build number (CFBundleVersion) is ALWAYS derived from the monotonic | |
| # GitHub run ID — for BOTH auto-ship and milestone-tag releases — so there is a | |
| # single strictly-increasing build-number sequence across the one feed. The | |
| # committed CURRENT_PROJECT_VERSION is only a local-dev default and is | |
| # intentionally NOT used for shipping: mixing a small committed build with | |
| # run-ID builds would make the first milestone tag after any auto-ship fail the | |
| # monotonic guard below (and Sparkle would never offer it). Marketing version | |
| # (CFBundleShortVersionString, baked in at build time) carries the human semver. | |
| RUN_ATTEMPT="$(printf '%02d' "${GITHUB_RUN_ATTEMPT:-1}")" | |
| EFFECTIVE_BUILD="${GITHUB_RUN_ID}${RUN_ATTEMPT}" | |
| if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then | |
| # Milestone tag release: semver comes from the committed MARKETING_VERSION/tag. | |
| RELEASE_TAG="${GITHUB_REF_NAME}" | |
| IS_AUTO_SHIP="false" | |
| else | |
| # Auto-ship from main (workflow_run) or dry-run (workflow_dispatch): publish to a | |
| # single reused "rolling" release that is overwritten each ship, so the releases | |
| # page stays clean (one rolling entry + permanent milestone v* tags) instead of | |
| # accumulating one release per commit. | |
| RELEASE_TAG="rolling" | |
| IS_AUTO_SHIP="true" | |
| fi | |
| echo "MARKETING_VERSION=${MARKETING_VERSION}" >> "$GITHUB_ENV" | |
| echo "EFFECTIVE_BUILD=${EFFECTIVE_BUILD}" >> "$GITHUB_ENV" | |
| echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV" | |
| echo "IS_AUTO_SHIP=${IS_AUTO_SHIP}" >> "$GITHUB_ENV" | |
| echo "Marketing version: ${MARKETING_VERSION}" | |
| echo "Effective build: ${EFFECTIVE_BUILD}" | |
| echo "Release tag: ${RELEASE_TAG}" | |
| echo "Auto-ship: ${IS_AUTO_SHIP}" | |
| - name: Guard immutable release assets | |
| id: guard_release_assets | |
| # Only milestone v* tag releases are immutable — this guard prevents a re-run from | |
| # clobbering already-signed milestone artifacts. Auto-ship publishes to the reused | |
| # "rolling" release and intentionally overwrites, so it skips this guard entirely | |
| # (when skipped, the empty skip_all/skip_upload outputs let the build/publish run). | |
| if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 | |
| with: | |
| script: | | |
| const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); | |
| const tag = process.env.RELEASE_TAG; | |
| core.setOutput('skip_all', 'false'); | |
| core.setOutput('skip_upload', 'false'); | |
| core.setOutput('release_state', 'clear'); | |
| try { | |
| const release = await github.rest.repos.getReleaseByTag({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| tag, | |
| }); | |
| const existingAssetNames = (release.data.assets || []).map((asset) => asset.name); | |
| const { | |
| conflicts, | |
| missingImmutableAssets, | |
| guardState, | |
| hasPartialConflict, | |
| shouldSkipBuildAndUpload, | |
| } = evaluateReleaseAssetGuard({ existingAssetNames }); | |
| core.setOutput('release_state', guardState); | |
| if (hasPartialConflict) { | |
| core.setFailed( | |
| `Release ${tag} has a partial immutable asset state. Existing immutable assets: ` + | |
| `${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` + | |
| 'Resolve release assets manually before rerunning.' | |
| ); | |
| return; | |
| } | |
| if (shouldSkipBuildAndUpload) { | |
| core.notice( | |
| `Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` + | |
| 'Skipping build, notarization, and upload to preserve existing signed artifacts.' | |
| ); | |
| core.setOutput('skip_all', 'true'); | |
| core.setOutput('skip_upload', 'true'); | |
| return; | |
| } | |
| core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`); | |
| } catch (error) { | |
| if (error.status === 404) { | |
| core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`); | |
| return; | |
| } | |
| throw error; | |
| } | |
| - name: Guard Sparkle build number is monotonic | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| echo "Effective CURRENT_PROJECT_VERSION=$EFFECTIVE_BUILD" | |
| PUBLISHED_BUILD=$(curl -fsSL --max-time 15 \ | |
| https://github.com/darkroomengineering/programa/releases/latest/download/appcast.xml 2>/dev/null \ | |
| | sed -n 's#.*<sparkle:version>\([0-9][0-9]*\)</sparkle:version>.*#\1#p' \ | |
| | head -n1 || true) | |
| if [[ "$PUBLISHED_BUILD" =~ ^[0-9]+$ ]]; then | |
| echo "Latest published Sparkle build=$PUBLISHED_BUILD" | |
| if (( EFFECTIVE_BUILD <= PUBLISHED_BUILD )); then | |
| echo "::error::Effective build number ($EFFECTIVE_BUILD) must be > latest published Sparkle build ($PUBLISHED_BUILD). Build numbers derive from the monotonic GitHub run ID, so this is unexpected — likely a re-run of an older run or a stale published appcast." >&2 | |
| exit 1 | |
| fi | |
| else | |
| echo "Latest published appcast unavailable; skipping monotonic check" | |
| fi | |
| - name: Select Xcode | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| # Prefer Xcode 26 (macOS 26 SDK) so NSGlassEffectView / Liquid Glass compiles; | |
| # fall back to the image default when no Xcode 26 install exists. | |
| XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" | |
| if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then | |
| XCODE_APP="/Applications/Xcode.app" | |
| fi | |
| if [ -z "$XCODE_APP" ]; then | |
| XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" | |
| fi | |
| if [ -z "$XCODE_APP" ]; then | |
| echo "No Xcode.app found under /Applications" >&2 | |
| exit 1 | |
| fi | |
| XCODE_DIR="$XCODE_APP/Contents/Developer" | |
| echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" | |
| export DEVELOPER_DIR="$XCODE_DIR" | |
| xcodebuild -version | |
| xcrun --sdk macosx --show-sdk-path | |
| - name: Install build deps | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| ZIG_REQUIRED="0.15.2" | |
| if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then | |
| echo "zig ${ZIG_REQUIRED} already installed" | |
| else | |
| echo "Installing zig ${ZIG_REQUIRED} from tarball" | |
| curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz | |
| tar xf /tmp/zig.tar.xz -C /tmp | |
| sudo mkdir -p /usr/local/bin /usr/local/lib | |
| sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig | |
| sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig | |
| export PATH="/usr/local/bin:$PATH" | |
| zig version | |
| fi | |
| ./scripts/install-create-dmg.sh | |
| - name: Cache GhosttyKit.xcframework | |
| id: cache-ghosttykit-release | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 | |
| with: | |
| path: GhosttyKit.xcframework | |
| key: ghosttykit-v2-${{ hashFiles('.gitmodules', 'ghostty') }} | |
| - name: Download pre-built GhosttyKit.xcframework | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' && steps.cache-ghosttykit-release.outputs.cache-hit != 'true' | |
| run: | | |
| ./scripts/download-prebuilt-ghosttykit.sh | |
| - name: Cache Swift packages | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 | |
| with: | |
| path: .spm-cache | |
| key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} | |
| restore-keys: spm- | |
| - name: Setup Go | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 | |
| with: | |
| go-version-file: daemon/remote/go.mod | |
| - name: Derive Sparkle public key from private key | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| run: | | |
| if [ -z "$SPARKLE_PRIVATE_KEY" ]; then | |
| echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 | |
| exit 1 | |
| fi | |
| DERIVED_PUBLIC_KEY=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY") | |
| echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" | |
| echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" | |
| - name: Build universal app (Release) | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| xcodebuild -scheme programa -configuration Release -derivedDataPath build-universal \ | |
| -destination 'generic/platform=macOS' \ | |
| -clonedSourcePackagesDirPath .spm-cache \ | |
| ARCHS="arm64" \ | |
| ONLY_ACTIVE_ARCH=NO \ | |
| CODE_SIGNING_ALLOWED=NO build | |
| - name: Verify binary architectures | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| ./scripts/verify-release-architectures.sh \ | |
| "build-universal/Build/Products/Release/Programa.app/Contents/MacOS/Programa" \ | |
| "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" \ | |
| "build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" | |
| - name: Build remote daemon release assets and inject manifest | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" | |
| APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") | |
| ./scripts/build_remote_daemon_release_assets.sh \ | |
| --version "$APP_VERSION" \ | |
| --release-tag "$RELEASE_TAG" \ | |
| --repo "darkroomengineering/programa" \ | |
| --output-dir "remote-daemon-assets" | |
| MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/programad-remote-manifest.json)" | |
| plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true | |
| plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" | |
| - name: Run CLI version memory guard regression | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| CLI_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/programa" | |
| [ -x "$CLI_BINARY" ] || { echo "programa CLI binary not found at $CLI_BINARY" >&2; exit 1; } | |
| PROGRAMA_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py | |
| - name: Verify bundled Ghostty theme picker helper | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| HELPER_BINARY="build-universal/Build/Products/Release/Programa.app/Contents/Resources/bin/ghostty" | |
| [ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; } | |
| - name: Set effective build number in Info.plist | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" | |
| # No-op for tag releases (EFFECTIVE_BUILD already matches the committed value). | |
| # For auto-ship runs this stamps the monotonic run-derived build number without | |
| # committing anything back to the repo. | |
| /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${EFFECTIVE_BUILD}" "$APP_PLIST" | |
| - name: Inject Sparkle keys into Info.plist | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| APP_PLIST="build-universal/Build/Products/Release/Programa.app/Contents/Info.plist" | |
| /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true | |
| /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true | |
| echo "Adding SUPublicEDKey to Info.plist..." | |
| /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST" | |
| echo "Adding SUFeedURL to Info.plist..." | |
| /usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/darkroomengineering/programa/releases/latest/download/appcast.xml" "$APP_PLIST" | |
| echo "Verifying:" | |
| /usr/libexec/PlistBuddy -c "Print :SUPublicEDKey" "$APP_PLIST" | |
| /usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST" | |
| - name: Import signing cert | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} | |
| run: | | |
| if [ -z "$APPLE_CERTIFICATE_BASE64" ]; then | |
| echo "Missing APPLE_CERTIFICATE_BASE64 secret" >&2 | |
| exit 1 | |
| fi | |
| if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then | |
| echo "Missing APPLE_CERTIFICATE_PASSWORD secret" >&2 | |
| exit 1 | |
| fi | |
| KEYCHAIN_PASSWORD="$(uuidgen)" | |
| echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > /tmp/cert.p12 | |
| security delete-keychain build.keychain >/dev/null 2>&1 || true | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain | |
| security set-keychain-settings -lut 21600 build.keychain | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain | |
| security import /tmp/cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain | |
| security list-keychains -d user -s build.keychain | |
| - name: Codesign app | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| run: | | |
| if [ -z "$APPLE_SIGNING_IDENTITY" ]; then | |
| echo "Missing APPLE_SIGNING_IDENTITY secret" >&2 | |
| exit 1 | |
| fi | |
| APP_PATH="build-universal/Build/Products/Release/Programa.app" | |
| CLI_PATH="$APP_PATH/Contents/Resources/bin/programa" | |
| HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty" | |
| ./scripts/sign-release-app.sh "$APP_PATH" "$APPLE_SIGNING_IDENTITY" programa.entitlements | |
| ./scripts/verify-release-entitlements.sh "$APP_PATH" "$CLI_PATH" "$HELPER_PATH" | |
| - name: Verify embedded Sparkle artifact | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| ./scripts/verify_sparkle_artifact.sh \ | |
| "build-universal/Build/Products/Release/Programa.app" \ | |
| "2.9.4" | |
| - name: Notarize app | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| run: | | |
| if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then | |
| echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2 | |
| exit 1 | |
| fi | |
| APP_PATH="build-universal/Build/Products/Release/Programa.app" | |
| ZIP_SUBMIT="programa-notary.zip" | |
| DMG_RELEASE="programa-macos.dmg" | |
| ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT" | |
| APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" | |
| APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")" | |
| APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")" | |
| if [ "$APP_STATUS" != "Accepted" ]; then | |
| echo "App notarization failed with status: $APP_STATUS" >&2 | |
| xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true | |
| exit 1 | |
| fi | |
| xcrun stapler staple "$APP_PATH" | |
| xcrun stapler validate "$APP_PATH" | |
| spctl -a -vv --type execute "$APP_PATH" | |
| rm -f "$ZIP_SUBMIT" | |
| # create-dmg generates a styled drag-to-install DMG | |
| create-dmg \ | |
| --identity="$APPLE_SIGNING_IDENTITY" \ | |
| "$APP_PATH" \ | |
| ./ | |
| # create-dmg (npm) names the DMG after the app bundle, e.g. "Programa 0.1.0.dmg". | |
| # GitHub macOS runners use a case-sensitive filesystem, so the glob must match | |
| # the capitalized product name exactly. | |
| mv ./Programa*.dmg "$DMG_RELEASE" | |
| DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)" | |
| DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")" | |
| DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")" | |
| if [ "$DMG_STATUS" != "Accepted" ]; then | |
| echo "DMG notarization failed with status: $DMG_STATUS" >&2 | |
| xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true | |
| exit 1 | |
| fi | |
| xcrun stapler staple "$DMG_RELEASE" | |
| xcrun stapler validate "$DMG_RELEASE" | |
| - name: Generate Sparkle appcast | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| run: | | |
| if [ -z "$SPARKLE_PRIVATE_KEY" ]; then | |
| echo "Missing SPARKLE_PRIVATE_KEY secret" >&2 | |
| exit 1 | |
| fi | |
| ./scripts/sparkle_generate_appcast.sh programa-macos.dmg "$RELEASE_TAG" appcast.xml | |
| - name: Attest remote daemon release assets | |
| if: steps.guard_release_assets.outputs.skip_all != 'true' | |
| uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 | |
| with: | |
| subject-path: | | |
| remote-daemon-assets/programad-remote-darwin-arm64 | |
| remote-daemon-assets/programad-remote-darwin-amd64 | |
| remote-daemon-assets/programad-remote-linux-arm64 | |
| remote-daemon-assets/programad-remote-linux-amd64 | |
| remote-daemon-assets/programad-remote-checksums.txt | |
| remote-daemon-assets/programad-remote-manifest.json | |
| - name: Upload build artifacts (dry-run) | |
| if: steps.guard_release_assets.outputs.skip_upload != 'true' && github.event_name == 'workflow_dispatch' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: programa-release-dry-run | |
| path: | | |
| programa-macos.dmg | |
| appcast.xml | |
| remote-daemon-assets/programad-remote-* | |
| if-no-files-found: error | |
| - name: Generate rolling release notes (since previous ship) | |
| id: rolling_notes | |
| if: github.event_name == 'workflow_run' && steps.guard_release_assets.outputs.skip_all != 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| # The rolling release is reused each ship; generate_release_notes on an | |
| # existing release accumulates every past ship's "What's Changed" into | |
| # one ever-growing body. Instead, generate notes for just the span since | |
| # the previous ship (old rolling tag -> new commit) and REPLACE the body. | |
| # Must run BEFORE the rolling tag is force-moved below. | |
| NEW_SHA="${{ github.event.workflow_run.head_sha }}" | |
| ARGS=(-f tag_name="rolling-next" -f target_commitish="$NEW_SHA") | |
| PREV_SHA="" | |
| if PREV_SHA=$(gh api "repos/${{ github.repository }}/git/ref/tags/rolling" --jq .object.sha 2>/dev/null); then | |
| ARGS+=(-f previous_tag_name="rolling") | |
| fi | |
| BODY=$(gh api "repos/${{ github.repository }}/releases/generate-notes" "${ARGS[@]}" --jq .body) | |
| # The synthetic rolling-next tag never exists; fix the compare link to real SHAs. | |
| if [ -n "$PREV_SHA" ]; then | |
| BODY=$(printf '%s' "$BODY" | sed "s|/compare/rolling...rolling-next|/compare/${PREV_SHA}...${NEW_SHA}|") | |
| fi | |
| { | |
| echo "body<<PROGRAMA_ROLLING_NOTES_EOF" | |
| printf '%s\n' "$BODY" | |
| echo "PROGRAMA_ROLLING_NOTES_EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Move rolling tag to shipped commit | |
| if: github.event_name == 'workflow_run' && steps.guard_release_assets.outputs.skip_all != 'true' | |
| run: | | |
| set -euo pipefail | |
| # Reuse a single "rolling" release: point its tag at the commit we just shipped so | |
| # the release page reflects the actual build. Assets are overwritten below. | |
| git tag -f rolling "${{ github.event.workflow_run.head_sha }}" | |
| git push origin refs/tags/rolling --force | |
| - name: Upload release asset | |
| if: steps.guard_release_assets.outputs.skip_upload != 'true' && (github.event_name == 'workflow_run' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))) | |
| uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 | |
| with: | |
| tag_name: ${{ env.RELEASE_TAG }} | |
| name: ${{ env.IS_AUTO_SHIP == 'true' && 'Rolling (latest main)' || env.RELEASE_TAG }} | |
| make_latest: true | |
| files: | | |
| programa-macos.dmg | |
| appcast.xml | |
| remote-daemon-assets/programad-remote-darwin-arm64 | |
| remote-daemon-assets/programad-remote-darwin-amd64 | |
| remote-daemon-assets/programad-remote-linux-arm64 | |
| remote-daemon-assets/programad-remote-linux-amd64 | |
| remote-daemon-assets/programad-remote-checksums.txt | |
| remote-daemon-assets/programad-remote-manifest.json | |
| # Auto-ship: replace the body with freshly-scoped notes (previous ship -> now) | |
| # so the reused rolling release doesn't accumulate every past "What's Changed". | |
| # Milestone v* tags: a fresh release, so GitHub-generated notes are fine. | |
| body: ${{ steps.rolling_notes.outputs.body }} | |
| generate_release_notes: ${{ env.IS_AUTO_SHIP != 'true' }} | |
| overwrite_files: true | |
| - name: Cleanup keychain | |
| if: always() | |
| run: | | |
| security delete-keychain build.keychain >/dev/null 2>&1 || true | |
| rm -f /tmp/cert.p12 |