refactor(KAN-65): remove release resume mode

This commit is contained in:
2026-09-28 07:43:31 +02:00
parent 3d5ee223e8
commit 41b6dc8f10
4 changed files with 30 additions and 171 deletions
-50
View File
@@ -12,14 +12,6 @@ on:
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main" description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
required: true required: true
type: string type: string
mode:
description: "Build a release or resume publishing existing verified artifacts"
required: true
default: build
type: choice
options:
- build
- resume
permissions: permissions:
contents: read contents: read
@@ -57,10 +49,8 @@ jobs:
env: env:
REQUESTED_TAG: ${{ inputs.tag }} REQUESTED_TAG: ${{ inputs.tag }}
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }} MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
RELEASE_MODE: ${{ inputs.mode || 'build' }}
run: | run: |
set -Eeuo pipefail 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 if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; } [[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
tag="$REQUESTED_TAG" tag="$REQUESTED_TAG"
@@ -90,7 +80,6 @@ jobs:
release: release:
name: Sign, notarize, verify and publish macOS name: Sign, notarize, verify and publish macOS
needs: prepare needs: prepare
if: ${{ inputs.mode != 'resume' }}
# Gitea resolves runner labels before outputs from prepare are available. # Gitea resolves runner labels before outputs from prepare are available.
runs-on: ${{ vars.MACOS_RUNNER }} runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 120 timeout-minutes: 120
@@ -235,42 +224,3 @@ jobs:
finally: finally:
shutil.rmtree(directory) shutil.rmtree(directory)
PY 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
+5 -56
View File
@@ -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 ( if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href import.meta.url === pathToFileURL(resolve(process.argv[1])).href
@@ -428,20 +385,12 @@ if (
if (process.argv[2] === 'config') { if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration())); console.log(JSON.stringify(storageConfiguration()));
} else if ( } else if (
process.argv[2] === 'resume' || process.argv[2] === 'upload' &&
(process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY) process.env.RELEASE_DIRECTORY
) { ) {
const publish = const url = await publishMacos({
process.argv[2] === 'resume' ? resumeMacos : publishMacos;
const url = await publish({
directory: process.env.RELEASE_DIRECTORY, 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}`); console.log(`Verified public release: ${url}`);
if (process.env.GITHUB_STEP_SUMMARY) { if (process.env.GITHUB_STEP_SUMMARY) {
@@ -452,7 +401,7 @@ if (
} }
} else { } else {
throw new Error( throw new Error(
'Use config, upload or resume; upload requires RELEASE_DIRECTORY.', 'Use config or upload; upload requires RELEASE_DIRECTORY.',
); );
} }
} catch (error) { } catch (error) {
+5 -33
View File
@@ -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(result.status, 0, result.stderr);
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
}); assert.doesNotMatch(workflow, /inputs\.mode|RELEASE_MODE|\n {2}resume:/);
assert.match(workflow, /--ci --target .* --bundles app,dmg/);
test('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => { assert.match(workflow, /node native\/macos-ci.mjs package/);
const f = fixture(t); assert.match(workflow, /node native\/publish-macos.mjs upload/);
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', () => { 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 [ 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' }], ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[ [
+20 -32
View File
@@ -1,18 +1,37 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test'; import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { import {
conditionalEtag, conditionalEtag,
publishMacos, publishMacos,
resumeMacos,
storageConfiguration, storageConfiguration,
} from '../publish-macos.mjs'; } from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.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', () => { test('conditional writes use bare S3 ETags without weakening the condition', () => {
const etag = 'b88312b96169f4b2098a4cc43b0abb6a'; const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) { 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( function fixture(
t, t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists', conflictMessage = 'aws failed: (PreconditionFailed) object exists',