diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index af33abd..6f8ffd6 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -12,6 +12,14 @@ 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 @@ -49,8 +57,10 @@ 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" @@ -80,6 +90,7 @@ 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 @@ -224,3 +235,42 @@ 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 0c12ce3..10617cf 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -63,6 +63,23 @@ export function storageConfiguration(env = process.env) { const sha256 = (data) => createHash('sha256').update(data).digest('hex'); +export function conditionalEtag(etag) { + const value = + typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"') + ? etag.slice(1, -1) + : etag; + if ( + typeof value !== 'string' || + !/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value) + ) { + throw new Error( + 'Cannot atomically update the feed without a valid S3 ETag.', + ); + } + // Hetzner returns quoted ETags on GET but compares bare values on conditional PUT. + return value; +} + function requiresExistingObjectCheck(error) { // AWS CLI can crash while formatting Hetzner's empty error Message. // This only permits a readback; content checks and conditional writes still apply. @@ -346,12 +363,7 @@ export async function publishMacos({ ); } } else { - if (!response.ETag) { - throw new Error( - 'Cannot atomically update the feed without its ETag.', - ); - } - publishLatest(['--if-match', response.ETag]); + publishLatest(['--if-match', conditionalEtag(response.ETag)]); } } await verifyPublic( @@ -365,6 +377,49 @@ 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 @@ -373,12 +428,20 @@ if ( if (process.argv[2] === 'config') { console.log(JSON.stringify(storageConfiguration())); } else if ( - process.argv[2] === 'upload' && - process.env.RELEASE_DIRECTORY + process.argv[2] === 'resume' || + (process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY) ) { - const url = await publishMacos({ + const publish = + process.argv[2] === 'resume' ? resumeMacos : publishMacos; + const url = await publish({ directory: process.env.RELEASE_DIRECTORY, - metadata: releaseMetadata(), + metadata: releaseMetadata( + process.env, + process.argv[2] === 'resume' && + process.env.RELEASE_METADATA_DIRECTORY + ? resolve(process.env.RELEASE_METADATA_DIRECTORY) + : undefined, + ), }); console.log(`Verified public release: ${url}`); if (process.env.GITHUB_STEP_SUMMARY) { @@ -389,7 +452,7 @@ if ( } } else { throw new Error( - 'Use config or upload; upload requires RELEASE_DIRECTORY.', + 'Use config, upload or resume; upload requires RELEASE_DIRECTORY.', ); } } catch (error) { diff --git a/native/tests/hetzner-conditional.test.js b/native/tests/hetzner-conditional.test.js new file mode 100644 index 0000000..0e3cd82 --- /dev/null +++ b/native/tests/hetzner-conditional.test.js @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } 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 { conditionalEtag } from '../publish-macos.mjs'; + +test( + 'Hetzner conditional writes accept a bare ETag and reject a stale ETag', + { + skip: process.env.KANBANGIT_S3_INTEGRATION !== '1', + }, + () => { + const bucket = process.env.AWS_BUCKET; + assert(bucket, 'Set AWS_BUCKET to a test bucket'); + const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-')); + const key = `kanbangit-integration-tests/${randomUUID()}.txt`; + const file = join(directory, 'object.txt'); + const downloaded = join(directory, 'downloaded.txt'); + const run = (operation, args) => { + const result = spawnSync( + 'aws', + [ + '--debug', + '--endpoint-url', + process.env.AWS_ENDPOINT, + '--region', + process.env.AWS_DEFAULT_REGION, + '--no-cli-pager', + 's3api', + operation, + '--bucket', + bucket, + '--key', + key, + ...args, + ], + { encoding: 'utf8', timeout: 30000 }, + ); + // Never expose debug headers: they contain the signed Authorization header. + return { + status: result.status, + stdout: result.stdout, + codes: [ + ...(result.stderr || '').matchAll(/([^<]*)<\/Code>/g), + ].map((match) => match[1]), + }; + }; + let created = false; + try { + writeFileSync(file, 'first'); + const initial = run('put-object', [ + '--body', + file, + '--if-none-match', + '*', + ]); + assert.equal(initial.status, 0, `Create failed: ${initial.codes}`); + created = true; + const read = run('get-object', ['--output', 'json', downloaded]); + assert.equal(read.status, 0, `Read failed: ${read.codes}`); + const etag = JSON.parse(read.stdout).ETag; + const quoted = run('put-object', [ + '--body', + file, + '--if-match', + etag, + ]); + console.log( + `Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`, + ); + const bare = conditionalEtag(etag); + writeFileSync(file, 'second'); + const updated = run('put-object', [ + '--body', + file, + '--if-match', + bare, + ]); + assert.equal( + updated.status, + 0, + `Bare ETag replacement failed: ${updated.codes}`, + ); + writeFileSync(file, 'stale writer'); + const stale = run('put-object', [ + '--body', + file, + '--if-match', + bare, + ]); + assert.notEqual(stale.status, 0); + assert( + stale.codes.includes('PreconditionFailed'), + `Unexpected stale error: ${stale.codes}`, + ); + assert.equal(run('get-object', [downloaded]).status, 0); + assert.equal(readFileSync(downloaded, 'utf8'), 'second'); + } finally { + try { + if (created) { + const cleanup = run('delete-object', []); + assert.equal( + cleanup.status, + 0, + `Cleanup failed: ${cleanup.codes}`, + ); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + } + }, +); diff --git a/native/tests/macos-workflow.test.js b/native/tests/macos-workflow.test.js index fc35f76..8248215 100644 --- a/native/tests/macos-workflow.test.js +++ b/native/tests/macos-workflow.test.js @@ -113,6 +113,38 @@ test('manual release on main accepts an existing tag without moving it', (t) => 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' \}\}/, + ); +}); + test('release DMG packaging disables Finder automation in the Tauri action environment', () => { const build = workflow .split(' - name: Build, sign and notarize with Tauri\n')[1] @@ -127,6 +159,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir }); for (const [name, overrides] of [ + ['invalid release mode', { RELEASE_MODE: 'overwrite' }], ['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 0745f37..aa859a9 100644 --- a/native/tests/publish-macos.test.js +++ b/native/tests/publish-macos.test.js @@ -5,9 +5,68 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { publishMacos, storageConfiguration } from '../publish-macos.mjs'; +import { + conditionalEtag, + publishMacos, + resumeMacos, + storageConfiguration, +} from '../publish-macos.mjs'; import { signedFixture } from './updater-fixture.mjs'; +test('conditional writes use bare S3 ETags without weakening the condition', () => { + const etag = 'b88312b96169f4b2098a4cc43b0abb6a'; + for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) { + assert.equal(conditionalEtag(value), value.replaceAll('"', '')); + } + for (const invalid of [ + undefined, + null, + '', + '*', + '"*"', + `W/"${etag}"`, + `"${etag}`, + `${etag}"`, + `${etag},${etag}`, + `${etag}\r\n`, + {}, + 42, + ]) { + assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/); + } +}); + +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', @@ -65,14 +124,19 @@ function fixture( if (!args.includes('--if-match')) { throw new Error(conflictMessage); } - assert.equal(value('--if-match'), '"previous-etag"'); + assert.equal( + value('--if-match'), + 'b88312b96169f4b2098a4cc43b0abb6a', + ); } objects.set(key, readFileSync(value('--body'))); return '{}'; } if (operation === 'get-object') { writeFileSync(args.at(-1), objects.get(key)); - return JSON.stringify({ ETag: '"previous-etag"' }); + return JSON.stringify({ + ETag: '"b88312b96169f4b2098a4cc43b0abb6a"', + }); } throw new Error(`Unexpected S3 operation: ${operation}`); };