From cf1cd096b13812f8fe7e7a8cd568568da18dd866 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:19:10 -0800 Subject: [PATCH 1/2] Make release asset guard idempotent --- .github/workflows/release.yml | 17 +++++++++----- scripts/release_asset_guard.js | 17 ++++++++++++++ scripts/release_asset_guard.test.js | 36 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 scripts/release_asset_guard.js create mode 100644 scripts/release_asset_guard.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3ca4a1a..a3008abe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,36 +194,41 @@ jobs: ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml - name: Guard immutable release assets + id: guard_release_assets uses: actions/github-script@v7 with: script: | + const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); const tag = context.ref.replace('refs/tags/', ''); - const requiredAssets = ['cmux-macos.dmg', 'appcast.xml']; try { const release = await github.rest.repos.getReleaseByTag({ owner: context.repo.owner, repo: context.repo.repo, tag, }); - const assetNames = new Set((release.data.assets || []).map((asset) => asset.name)); - const conflicts = requiredAssets.filter((asset) => assetNames.has(asset)); - if (conflicts.length > 0) { - core.setFailed( + const existingAssetNames = (release.data.assets || []).map((asset) => asset.name); + const { conflicts, shouldSkipUpload } = evaluateReleaseAssetGuard({ existingAssetNames }); + if (shouldSkipUpload) { + core.notice( `Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` + - 'Refusing to overwrite signed artifacts for an existing tag.' + 'Skipping upload to preserve existing signed artifacts.' ); + core.setOutput('skip_upload', 'true'); return; } core.notice(`Release ${tag} exists but does not contain conflicting assets.`); + core.setOutput('skip_upload', 'false'); } catch (error) { if (error.status === 404) { core.notice(`Release ${tag} does not exist yet; safe to publish assets.`); + core.setOutput('skip_upload', 'false'); return; } throw error; } - name: Upload release asset + if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@v2 with: files: | diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js new file mode 100644 index 00000000..0bafc2e1 --- /dev/null +++ b/scripts/release_asset_guard.js @@ -0,0 +1,17 @@ +"use strict"; + +const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; + +function evaluateReleaseAssetGuard({ existingAssetNames, immutableAssetNames = IMMUTABLE_RELEASE_ASSETS }) { + const existing = new Set(existingAssetNames || []); + const conflicts = immutableAssetNames.filter((assetName) => existing.has(assetName)); + return { + conflicts, + shouldSkipUpload: conflicts.length > 0, + }; +} + +module.exports = { + IMMUTABLE_RELEASE_ASSETS, + evaluateReleaseAssetGuard, +}; diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js new file mode 100644 index 00000000..fef57e7d --- /dev/null +++ b/scripts/release_asset_guard.test.js @@ -0,0 +1,36 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + IMMUTABLE_RELEASE_ASSETS, + evaluateReleaseAssetGuard, +} = require("./release_asset_guard"); + +test("skips upload when immutable assets already exist", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + }); + + assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); + assert.equal(result.shouldSkipUpload, true); +}); + +test("allows upload when immutable assets are not present", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["notes.txt", "checksums.txt"], + }); + + assert.deepEqual(result.conflicts, []); + assert.equal(result.shouldSkipUpload, false); +}); + +test("skips upload when any immutable asset would conflict", () => { + const result = evaluateReleaseAssetGuard({ + existingAssetNames: ["appcast.xml"], + }); + + assert.deepEqual(result.conflicts, ["appcast.xml"]); + assert.equal(result.shouldSkipUpload, true); +}); From 5ac633445f31928b3707d5955f2957eaa7d21cba Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:27:24 -0800 Subject: [PATCH 2/2] Fail partial release assets and short-circuit reruns --- .github/workflows/release.yml | 102 ++++++++++++++++++---------- scripts/release_asset_guard.js | 24 ++++++- scripts/release_asset_guard.test.js | 21 ++++-- 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3008abe..3176697b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,63 @@ jobs: with: submodules: recursive + - name: Guard immutable release assets + id: guard_release_assets + uses: actions/github-script@v7 + with: + script: | + const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); + const tag = context.ref.replace('refs/tags/', ''); + 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: Select Xcode + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then @@ -41,15 +97,18 @@ jobs: xcrun --sdk macosx --show-sdk-path - name: Install build deps + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | brew update brew install zig npm install --global create-dmg - name: Download Metal Toolchain + if: steps.guard_release_assets.outputs.skip_all != 'true' run: xcodebuild -downloadComponent MetalToolchain - name: Build GhosttyKit.xcframework + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | cd ghostty zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast @@ -58,11 +117,13 @@ jobs: cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework - name: Clear SPM cache + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | rm -rf ~/Library/Caches/org.swift.swiftpm rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* - name: Configure SwiftPM cache + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | set -euo pipefail CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}" @@ -71,6 +132,7 @@ jobs: echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV" - 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: | @@ -83,10 +145,12 @@ jobs: echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - name: Build app (Release) + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build - name: Inject Sparkle keys into Info.plist + if: steps.guard_release_assets.outputs.skip_all != 'true' run: | APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true @@ -100,6 +164,7 @@ jobs: /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 }} @@ -123,6 +188,7 @@ jobs: 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: | @@ -140,6 +206,7 @@ jobs: /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" - 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 }} @@ -184,6 +251,7 @@ jobs: 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: | @@ -193,40 +261,6 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml - - name: Guard immutable release assets - id: guard_release_assets - uses: actions/github-script@v7 - with: - script: | - const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); - const tag = context.ref.replace('refs/tags/', ''); - 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, shouldSkipUpload } = evaluateReleaseAssetGuard({ existingAssetNames }); - if (shouldSkipUpload) { - core.notice( - `Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` + - 'Skipping upload to preserve existing signed artifacts.' - ); - core.setOutput('skip_upload', 'true'); - return; - } - core.notice(`Release ${tag} exists but does not contain conflicting assets.`); - core.setOutput('skip_upload', 'false'); - } catch (error) { - if (error.status === 404) { - core.notice(`Release ${tag} does not exist yet; safe to publish assets.`); - core.setOutput('skip_upload', 'false'); - return; - } - throw error; - } - - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@v2 diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index 0bafc2e1..d16d328e 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,17 +1,37 @@ "use strict"; const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const RELEASE_ASSET_GUARD_STATE = Object.freeze({ + CLEAR: "clear", + PARTIAL: "partial", + COMPLETE: "complete", +}); function evaluateReleaseAssetGuard({ existingAssetNames, immutableAssetNames = IMMUTABLE_RELEASE_ASSETS }) { + const immutableAssets = immutableAssetNames || IMMUTABLE_RELEASE_ASSETS; const existing = new Set(existingAssetNames || []); - const conflicts = immutableAssetNames.filter((assetName) => existing.has(assetName)); + const conflicts = immutableAssets.filter((assetName) => existing.has(assetName)); + const missingImmutableAssets = immutableAssets.filter((assetName) => !existing.has(assetName)); + + let guardState = RELEASE_ASSET_GUARD_STATE.CLEAR; + if (conflicts.length === immutableAssets.length && immutableAssets.length > 0) { + guardState = RELEASE_ASSET_GUARD_STATE.COMPLETE; + } else if (conflicts.length > 0) { + guardState = RELEASE_ASSET_GUARD_STATE.PARTIAL; + } + return { conflicts, - shouldSkipUpload: conflicts.length > 0, + missingImmutableAssets, + guardState, + hasPartialConflict: guardState === RELEASE_ASSET_GUARD_STATE.PARTIAL, + shouldSkipBuildAndUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE, + shouldSkipUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE, }; } module.exports = { IMMUTABLE_RELEASE_ASSETS, + RELEASE_ASSET_GUARD_STATE, evaluateReleaseAssetGuard, }; diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index fef57e7d..c320cf81 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -5,32 +5,45 @@ const assert = require("node:assert/strict"); const { IMMUTABLE_RELEASE_ASSETS, + RELEASE_ASSET_GUARD_STATE, evaluateReleaseAssetGuard, } = require("./release_asset_guard"); -test("skips upload when immutable assets already exist", () => { +test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { const result = evaluateReleaseAssetGuard({ existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); + assert.deepEqual(result.missingImmutableAssets, []); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.COMPLETE); + assert.equal(result.hasPartialConflict, false); + assert.equal(result.shouldSkipBuildAndUpload, true); assert.equal(result.shouldSkipUpload, true); }); -test("allows upload when immutable assets are not present", () => { +test("marks guard as clear when immutable assets are not present", () => { const result = evaluateReleaseAssetGuard({ existingAssetNames: ["notes.txt", "checksums.txt"], }); assert.deepEqual(result.conflicts, []); + assert.deepEqual(result.missingImmutableAssets, IMMUTABLE_RELEASE_ASSETS); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.CLEAR); + assert.equal(result.hasPartialConflict, false); + assert.equal(result.shouldSkipBuildAndUpload, false); assert.equal(result.shouldSkipUpload, false); }); -test("skips upload when any immutable asset would conflict", () => { +test("marks guard as partial when only some immutable assets exist", () => { const result = evaluateReleaseAssetGuard({ existingAssetNames: ["appcast.xml"], }); assert.deepEqual(result.conflicts, ["appcast.xml"]); - assert.equal(result.shouldSkipUpload, true); + assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); + assert.equal(result.hasPartialConflict, true); + assert.equal(result.shouldSkipBuildAndUpload, false); + assert.equal(result.shouldSkipUpload, false); });