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] 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); +});