diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 6f8ffd6..af33abd 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -12,14 +12,6 @@ on: description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main" required: true type: string - mode: - description: "Build a release or resume publishing existing verified artifacts" - required: true - default: build - type: choice - options: - - build - - resume permissions: contents: read @@ -57,10 +49,8 @@ jobs: env: REQUESTED_TAG: ${{ inputs.tag }} MACOS_RUNNER: ${{ vars.MACOS_RUNNER }} - RELEASE_MODE: ${{ inputs.mode || 'build' }} run: | set -Eeuo pipefail - [[ "${RELEASE_MODE:-build}" == build || "${RELEASE_MODE:-build}" == resume ]] || { echo 'Invalid release mode.' >&2; exit 1; } if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then [[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; } tag="$REQUESTED_TAG" @@ -90,7 +80,6 @@ jobs: release: name: Sign, notarize, verify and publish macOS needs: prepare - if: ${{ inputs.mode != 'resume' }} # Gitea resolves runner labels before outputs from prepare are available. runs-on: ${{ vars.MACOS_RUNNER }} timeout-minutes: 120 @@ -235,42 +224,3 @@ jobs: finally: shutil.rmtree(directory) PY - - resume: - name: Resume verified release publication - needs: prepare - if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'resume' }} - runs-on: ${{ vars.MACOS_RUNNER }} - timeout-minutes: 15 - env: - RELEASE_TAG: ${{ needs.prepare.outputs.tag }} - RELEASE_METADATA_DIRECTORY: release-source/native - steps: - - name: Check out current publishing tools - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Check out validated release metadata - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.prepare.outputs.commit }} - path: release-source - persist-credentials: false - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 22 - - - name: Verify existing artifacts and resume publication - env: - AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} - AWS_EC2_METADATA_DISABLED: "true" - AWS_REQUEST_CHECKSUM_CALCULATION: when_required - AWS_RESPONSE_CHECKSUM_VALIDATION: when_required - run: | - aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; } - node native/publish-macos.mjs resume diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs index 10617cf..b327fbe 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -377,49 +377,6 @@ export async function publishMacos({ } } -export async function resumeMacos(options = {}) { - const env = options.env ?? process.env; - const metadata = options.metadata ?? releaseMetadata(env); - const storage = storageConfiguration(env); - const feedBase = options.feedBase ?? updateSource.baseUrl; - if (`${storage.publicBase}/${storage.prefix}` !== feedBase) { - throw new Error( - 'Release storage does not match the update source bundled in the app.', - ); - } - const directory = mkdtempSync(join(tmpdir(), 'kanbangit-resume-')); - const fetchPublic = options.fetchPublic ?? fetch; - const prefix = `${feedBase}/${metadata.target}/${metadata.version}`; - try { - for (const name of [ - metadata.filename, - `${metadata.filename}.sha256`, - metadata.updaterFilename, - `${metadata.updaterFilename}.sha256`, - `${metadata.updaterFilename}.sig`, - ]) { - const response = await fetchPublic(`${prefix}/${name}`, { - redirect: 'error', - signal: AbortSignal.timeout(60000), - }); - if (!response.ok) { - await response.body?.cancel(); - throw new Error( - `Cannot resume incomplete release: ${name} HTTP ${response.status}.`, - ); - } - writeFileSync( - join(directory, name), - Buffer.from(await response.arrayBuffer()), - ); - } - // Reuse signature, checksum, immutable content and feed concurrency checks. - return await publishMacos({ ...options, env, metadata, directory }); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -} - if ( process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href @@ -428,20 +385,12 @@ if ( if (process.argv[2] === 'config') { console.log(JSON.stringify(storageConfiguration())); } else if ( - process.argv[2] === 'resume' || - (process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY) + process.argv[2] === 'upload' && + process.env.RELEASE_DIRECTORY ) { - const publish = - process.argv[2] === 'resume' ? resumeMacos : publishMacos; - const url = await publish({ + const url = await publishMacos({ directory: process.env.RELEASE_DIRECTORY, - metadata: releaseMetadata( - process.env, - process.argv[2] === 'resume' && - process.env.RELEASE_METADATA_DIRECTORY - ? resolve(process.env.RELEASE_METADATA_DIRECTORY) - : undefined, - ), + metadata: releaseMetadata(), }); console.log(`Verified public release: ${url}`); if (process.env.GITHUB_STEP_SUMMARY) { @@ -452,7 +401,7 @@ if ( } } else { throw new Error( - 'Use config, upload or resume; upload requires RELEASE_DIRECTORY.', + 'Use config or upload; upload requires RELEASE_DIRECTORY.', ); } } catch (error) { diff --git a/native/tests/macos-workflow.test.js b/native/tests/macos-workflow.test.js index 8248215..5bb1b04 100644 --- a/native/tests/macos-workflow.test.js +++ b/native/tests/macos-workflow.test.js @@ -111,38 +111,10 @@ test('manual release on main accepts an existing tag without moving it', (t) => }); assert.equal(result.status, 0, result.stderr); assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); -}); - -test('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => { - const f = fixture(t); - const result = f.run({ - GITHUB_EVENT_NAME: 'workflow_dispatch', - GITHUB_REF: 'refs/heads/main', - REQUESTED_TAG: 'macos-v1.2.3', - RELEASE_MODE: 'resume', - }); - assert.equal(result.status, 0, result.stderr); - const resume = workflow.split('\n resume:\n')[1]; - assert.match(resume, /needs: prepare/); - assert.match( - resume, - /github.event_name == 'workflow_dispatch' && inputs.mode == 'resume'/, - ); - assert.match(resume, /ref: \$\{\{ github.sha \}\}/); - assert.match(resume, /ref: \$\{\{ needs.prepare.outputs.commit \}\}/); - assert.match(resume, /RELEASE_METADATA_DIRECTORY: release-source\/native/); - assert.match(resume, /path: release-source/); - assert.match(resume, /secrets.S3_ACCESS_KEY_ID/); - assert.match(resume, /secrets.S3_SECRET_ACCESS_KEY/); - assert.match(resume, /node native\/publish-macos.mjs resume/); - assert.doesNotMatch( - resume, - /tauri-action|APPLE_PASSWORD|APPLE_CERTIFICATE|TAURI_SIGNING_PRIVATE_KEY/, - ); - assert.match( - workflow.split('\n release:\n')[1], - /if: \$\{\{ inputs.mode != 'resume' \}\}/, - ); + assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/); + assert.match(workflow, /--ci --target .* --bundles app,dmg/); + assert.match(workflow, /node native\/macos-ci.mjs package/); + assert.match(workflow, /node native\/publish-macos.mjs upload/); }); test('release DMG packaging disables Finder automation in the Tauri action environment', () => { @@ -159,7 +131,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir }); for (const [name, overrides] of [ - ['invalid release mode', { RELEASE_MODE: 'overwrite' }], + ['wrong tag case', { GITHUB_REF: 'refs/tags/macOS-V1.2.3' }], ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }], ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], [ diff --git a/native/tests/publish-macos.test.js b/native/tests/publish-macos.test.js index aa859a9..7e1b69d 100644 --- a/native/tests/publish-macos.test.js +++ b/native/tests/publish-macos.test.js @@ -1,18 +1,37 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; +import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import process from 'node:process'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { conditionalEtag, publishMacos, - resumeMacos, storageConfiguration, } from '../publish-macos.mjs'; import { signedFixture } from './updater-fixture.mjs'; +test('publisher rejects the removed resume command before accessing storage', () => { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('../publish-macos.mjs', import.meta.url)), + 'resume', + ], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 1); + assert.equal(result.stdout, ''); + assert.match( + result.stderr, + /Use config or upload; upload requires RELEASE_DIRECTORY/, + ); +}); + test('conditional writes use bare S3 ETags without weakening the condition', () => { const etag = 'b88312b96169f4b2098a4cc43b0abb6a'; for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) { @@ -36,37 +55,6 @@ test('conditional writes use bare S3 ETags without weakening the condition', () } }); -test('resume verifies already published artifacts and promotes the feed without rebuilding', async (t) => { - const f = fixture(t); - await publishMacos(f); - const key = `releases/macos/${f.metadata.target}/latest.json`; - f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' }))); - const originals = new Map(f.objects); - await resumeMacos(f); - assert.equal(JSON.parse(f.objects.get(key)).version, f.metadata.version); - for (const [name, contents] of originals) { - if (name !== key) { - assert.deepEqual(f.objects.get(name), contents); - } - } -}); - -for (const failure of ['missing', 'tampered']) { - test(`resume rejects ${failure} artifacts before any S3 writes`, async (t) => { - const f = fixture(t); - await publishMacos(f); - const key = `releases/macos/${f.metadata.target}/${f.metadata.version}/${f.metadata.updaterFilename}`; - if (failure === 'missing') { - f.objects.delete(key); - } else { - f.objects.set(key, Buffer.from('tampered')); - } - f.events.length = 0; - await assert.rejects(resumeMacos(f), /incomplete release|signature/); - assert(!f.events.some((event) => event.operation === 'put-object')); - }); -} - function fixture( t, conflictMessage = 'aws failed: (PreconditionFailed) object exists',