Merge pull request #285 from manaflow-ai/task-22266155945-release-assets-guard-rerun
Make release asset guard idempotent for existing immutable assets
This commit is contained in:
commit
044c9ec3d4
3 changed files with 155 additions and 30 deletions
99
.github/workflows/release.yml
vendored
99
.github/workflows/release.yml
vendored
|
|
@ -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,37 +261,8 @@ jobs:
|
|||
fi
|
||||
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
|
||||
|
||||
- name: Guard immutable release assets
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
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(
|
||||
`Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` +
|
||||
'Refusing to overwrite signed artifacts for an existing tag.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.notice(`Release ${tag} exists but does not contain conflicting assets.`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.notice(`Release ${tag} does not exist yet; safe to publish assets.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
- name: Upload release asset
|
||||
if: steps.guard_release_assets.outputs.skip_upload != 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
|
|
|
|||
37
scripts/release_asset_guard.js
Normal file
37
scripts/release_asset_guard.js
Normal file
|
|
@ -0,0 +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 = 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,
|
||||
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,
|
||||
};
|
||||
49
scripts/release_asset_guard.test.js
Normal file
49
scripts/release_asset_guard.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
IMMUTABLE_RELEASE_ASSETS,
|
||||
RELEASE_ASSET_GUARD_STATE,
|
||||
evaluateReleaseAssetGuard,
|
||||
} = require("./release_asset_guard");
|
||||
|
||||
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("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("marks guard as partial when only some immutable assets exist", () => {
|
||||
const result = evaluateReleaseAssetGuard({
|
||||
existingAssetNames: ["appcast.xml"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.conflicts, ["appcast.xml"]);
|
||||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue