diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 6f8ffd6..de964b4 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -12,19 +12,13 @@ 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 concurrency: + # Partial release files may be replaced until the feed is promoted. + # Keep every publisher in this group to prevent overlapping rebuilds. group: macos-release cancel-in-progress: false @@ -57,10 +51,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 +82,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 @@ -209,7 +200,7 @@ jobs: id: package run: node native/macos-ci.mjs package - - name: Publish immutable files and verify public Hetzner downloads + - name: Upload release files and promote the verified update feed env: AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }} @@ -235,42 +226,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..e71f875 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -73,7 +73,7 @@ export function conditionalEtag(etag) { !/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value) ) { throw new Error( - 'Cannot atomically update the feed without a valid S3 ETag.', + 'Cannot atomically replace an object without a valid S3 ETag.', ); } // Hetzner returns quoted ETags on GET but compares bare values on conditional PUT. @@ -185,12 +185,15 @@ export async function publishMacos({ const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-')); const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`; + const latestPrefix = `${storage.prefix}/${metadata.target}`; + const latestKey = `${latestPrefix}/latest.json`; async function verifyPublic(name, contents, prefix = publicPrefix) { for (let attempt = 1; attempt <= 5; attempt++) { let response; try { response = await fetchPublic(`${prefix}/${name}`, { redirect: 'error', + cache: 'no-store', signal: AbortSignal.timeout(60000), }); } catch (error) { @@ -225,36 +228,86 @@ export async function publishMacos({ await wait(2000); } } - function putImmutable(name, contents, contentType) { + function assertUnpublishedRelease() { + const file = join(staging, 'published-latest.json'); + try { + s3('get-object', ['--key', latestKey, file]); + } catch (error) { + if (/\(NoSuchKey\)/.test(error.message)) { + return; + } + if (requiresExistingObjectCheck(error)) { + // A broken CLI error message cannot distinguish missing from denied. + // Only an authenticated, explicitly empty listing proves absence. + const listing = JSON.parse( + s3('list-objects-v2', [ + '--prefix', + latestKey, + '--max-keys', + '1', + '--output', + 'json', + ]), + ); + if ( + listing?.KeyCount === 0 && + listing.IsTruncated === false && + Array.isArray(listing.Contents ?? []) && + (listing.Contents ?? []).length === 0 + ) { + return; + } + } + throw error; + } + const published = JSON.parse(readFileSync(file, 'utf8')); + if (compareVersions(metadata.version, published.version) <= 0) { + throw new Error( + 'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.', + ); + } + } + // The macos-release concurrency group serializes the entire publication. + // Never run independent publishers against the same release destination. + function putReleaseFile(name, contents, contentType) { const file = join(staging, name); writeFileSync(file, contents); - try { + function put(condition) { s3('put-object', [ '--key', `${keyPrefix}/${name}`, '--body', file, - '--if-none-match', - '*', + ...condition, '--content-type', contentType, '--cache-control', - 'public, max-age=31536000, immutable', + 'no-cache, max-age=0, must-revalidate', '--content-md5', createHash('md5').update(contents).digest('base64'), '--metadata', `sha256=${sha256(contents)}`, ]); + } + try { + put(['--if-none-match', '*']); } catch (error) { if (!requiresExistingObjectCheck(error)) { throw error; } const existing = join(staging, `existing-${name}`); - s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]); + const response = JSON.parse( + s3('get-object', [ + '--key', + `${keyPrefix}/${name}`, + '--output', + 'json', + existing, + ]), + ); if (sha256(readFileSync(existing)) !== sha256(contents)) { - throw new Error( - `Published ${name} differs; bump the app version. Existing objects were not overwritten.`, - ); + assertUnpublishedRelease(); + put(['--if-match', conditionalEtag(response.ETag)]); } } } @@ -279,7 +332,7 @@ export async function publishMacos({ ], ]; for (const [name, contents, type] of files) { - putImmutable(name, contents, type); + putReleaseFile(name, contents, type); await verifyPublic(name, contents); } // Completion metadata is written only after every public artifact matches. @@ -296,7 +349,7 @@ export async function publishMacos({ 2, ) + '\n', ); - putImmutable('release.json', manifest, 'application/json'); + putReleaseFile('release.json', manifest, 'application/json'); await verifyPublic('release.json', manifest); const latest = Buffer.from( JSON.stringify( @@ -310,10 +363,8 @@ export async function publishMacos({ 2, ) + '\n', ); - putImmutable('update.json', latest, 'application/json'); + putReleaseFile('update.json', latest, 'application/json'); await verifyPublic('update.json', latest); - const latestPrefix = `${storage.prefix}/${metadata.target}`; - const latestKey = `${latestPrefix}/latest.json`; const latestFile = join(staging, 'latest.json'); writeFileSync(latestFile, latest); function publishLatest(condition) { @@ -377,49 +428,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 +436,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 +452,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/src-tauri/src/desktop.rs b/native/src-tauri/src/desktop.rs index 0798424..3a3ee98 100644 --- a/native/src-tauri/src/desktop.rs +++ b/native/src-tauri/src/desktop.rs @@ -308,7 +308,7 @@ pub fn builder() -> tauri::Builder { })); let menu = MenuBuilder::new(app) .item( - &SubmenuBuilder::new(app, en::APP_NAME) + &SubmenuBuilder::with_id(app, "app-menu", en::APP_NAME) .about_with_text( en::ABOUT, Some(about_metadata(app.package_info().version.to_string())), diff --git a/native/src-tauri/src/lib.rs b/native/src-tauri/src/lib.rs index 75b1184..0fa255f 100644 --- a/native/src-tauri/src/lib.rs +++ b/native/src-tauri/src/lib.rs @@ -20,6 +20,9 @@ pub use desktop::{builder, context}; #[cfg(target_os = "ios")] pub use ios::{builder, context}; +#[cfg(all(target_os = "macos", feature = "smoke-test"))] +pub use updates::refresh_menu as refresh_update_menu_for_smoke_test; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { builder().run(context()).expect("Could not start KanbanGit"); diff --git a/native/src-tauri/src/locale.rs b/native/src-tauri/src/locale.rs index 9330aa4..0b9cd36 100644 --- a/native/src-tauri/src/locale.rs +++ b/native/src-tauri/src/locale.rs @@ -2,7 +2,6 @@ pub mod en { pub const ABOUT: &str = "About KanbanGit"; pub const CHECK_UPDATES: &str = "Check For Updates…"; pub const INSTALL_UPDATE: &str = "Install Update…"; - pub const UPDATE_BADGE: &str = "Update"; pub const UPDATES: &str = "KanbanGit Updates"; pub const APP_NAME: &str = "KanbanGit"; pub const SERVER_SETTINGS: &str = "Server settings…"; diff --git a/native/src-tauri/src/updates.rs b/native/src-tauri/src/updates.rs index 7a59b4b..b7dd173 100644 --- a/native/src-tauri/src/updates.rs +++ b/native/src-tauri/src/updates.rs @@ -123,19 +123,28 @@ fn trusted_navigation(url: &Url) -> bool { && url.username().is_empty() && url.password().is_none() } +pub fn refresh_menu(app: &AppHandle, available: bool, busy: bool) { + if let Some(menu) = app + .menu() + .and_then(|menu| menu.get("app-menu")) + .and_then(|item| { + item.as_submenu() + .and_then(|menu| menu.get("install-update")) + }) + { + if let Some(item) = menu.as_menuitem() { + let _ = item.set_enabled(available && !busy); + } + } +} pub fn refresh(app: &AppHandle) { let (view, available, busy) = { let state = app.state::>(); let state = state.lock().unwrap(); (state.view.clone(), state.update.is_some(), state.busy()) }; - if let Some(menu) = app.menu().and_then(|m| m.get("install-update")) { - if let Some(item) = menu.as_menuitem() { - let _ = item.set_enabled(available && !busy); - } - } + refresh_menu(app, available, busy); if let Some(window) = app.get_webview_window("updates") { - let _ = window.set_badge_label(available.then(|| crate::locale::en::UPDATE_BADGE.into())); if let Ok(json) = serde_json::to_string(&view) { let _ = window.eval(format!("window.updateStatus?.({json})")); } diff --git a/native/src-tauri/tests/updater_smoke.rs b/native/src-tauri/tests/updater_smoke.rs index 4cfd397..8a5000f 100644 --- a/native/src-tauri/tests/updater_smoke.rs +++ b/native/src-tauri/tests/updater_smoke.rs @@ -1,3 +1,27 @@ +#[cfg(target_os = "macos")] +#[tauri::command] +fn updater_smoke_menu(app: tauri::AppHandle) -> Result<(), String> { + let submenu = app.menu().unwrap().get("app-menu").unwrap(); + let item = submenu.as_submenu().unwrap().get("install-update").unwrap(); + let item = item.as_menuitem().unwrap(); + for (available, busy, expected) in [ + (false, false, false), // No update. + (true, false, true), // Available update, also after a retryable failure. + (true, true, false), // Download or installation in progress. + (true, false, true), // Retry after a failed download. + (false, true, false), // A new check clears the previous update. + (false, false, false), // Up to date. + ] { + kanbangit_native::refresh_update_menu_for_smoke_test(&app, available, busy); + if item.is_enabled().map_err(|error| error.to_string())? != expected { + return Err(format!( + "Native install menu has wrong state: available={available}, busy={busy}" + )); + } + } + Ok(()) +} + #[cfg(target_os = "macos")] #[tauri::command] fn updater_smoke_report(app: tauri::AppHandle, passed: bool, reason: String) { @@ -36,6 +60,7 @@ fn main() { const $ = id => document.getElementById(id); for (let i = 0; i < 100 && !$('installed').textContent; i++) await new Promise(r => setTimeout(r, 50)); if ($('installed').textContent !== window.expectedInstalled) throw new Error('Actual installed version/build failed: ' + $('installed').textContent); + await window.__TAURI_INTERNALS__.invoke('updater_smoke_menu'); const state = { phase: 'current', currentVersion: '0.1.2', build: '2', version: null, notes: null, downloaded: 0, total: null, error: null, preferences: { automatic: true, intervalHours: 6 } }; window.updateStatus(state); if (!$('installed').textContent.includes('0.1.2 (build 2)') || !$('install').hidden || $('check').disabled) throw new Error('Current version state failed'); @@ -64,7 +89,10 @@ fn main() { context.config_mut().identifier = format!("de.kanbangit.smoke.updates.{}", std::process::id()); let app = kanbangit_native::builder() .manage(passed.clone()) - .invoke_handler(tauri::generate_handler![updater_smoke_report]) + .invoke_handler(tauri::generate_handler![ + updater_smoke_report, + updater_smoke_menu + ]) .plugin(plugin) .build(context) .expect("App must start"); diff --git a/native/tests/macos-workflow.test.js b/native/tests/macos-workflow.test.js index 8248215..588cb92 100644 --- a/native/tests/macos-workflow.test.js +++ b/native/tests/macos-workflow.test.js @@ -75,7 +75,7 @@ function fixture(t) { ); return { ...result, output: readFileSync(output, 'utf8') }; }; - return { run, git, checkout, commit }; + return { run, git, checkout, remote, commit }; } test('release selection succeeds using Git alone without an administrative API token', (t) => { @@ -111,38 +111,39 @@ 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); + 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('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => { +test('a moved macOS tag selects the new main commit for a full rebuild', (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', - }); + f.git( + f.remote, + '-c', + 'commit.gpgsign=false', + 'commit', + '--allow-empty', + '-m', + 'Fix failed release', + ); + const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD'); + f.git(f.remote, 'tag', '--force', 'macos-v1.2.3'); + f.git( + f.checkout, + 'fetch', + '--force', + 'origin', + 'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3', + ); + const result = f.run(); 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.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`); + assert.notEqual(rebuilt, f.commit); + assert.match(workflow, /tags:\n\s+- "macos-v\*"/); + assert.match(workflow, /^ {2}group: macos-release$/m); + assert.match(workflow, /^ {2}cancel-in-progress: false$/m); }); test('release DMG packaging disables Finder automation in the Tauri action environment', () => { @@ -159,7 +160,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..bc78a4f 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', @@ -111,7 +99,7 @@ function fixture( const operation = args[args.indexOf('s3api') + 1]; const value = (name) => args[args.indexOf(name) + 1]; const key = value('--key'); - events.push({ operation, key }); + events.push({ operation, key, args }); if (operation === 'get-bucket-versioning') { assert.equal(value('--output'), 'json'); return ''; @@ -133,6 +121,9 @@ function fixture( return '{}'; } if (operation === 'get-object') { + if (!objects.has(key)) { + throw new Error('aws failed: (NoSuchKey) object missing'); + } writeFileSync(args.at(-1), objects.get(key)); return JSON.stringify({ ETag: '"b88312b96169f4b2098a4cc43b0abb6a"', @@ -168,7 +159,7 @@ function fixture( }; } -test('uploads immutable files and writes completion record after public verification', async (t) => { +test('uploads revalidated files and writes completion record after public verification', async (t) => { const f = fixture(t); const url = await publishMacos(f); assert.equal( @@ -180,6 +171,14 @@ test('uploads immutable files and writes completion record after public verifica 8, ); assert.equal(f.objects.size, 8); + for (const event of f.events.filter( + (event) => event.operation === 'put-object', + )) { + assert.equal( + event.args[event.args.indexOf('--cache-control') + 1], + 'no-cache, max-age=0, must-revalidate', + ); + } assert(f.events.at(-1).key.endsWith('/latest.json')); assert.equal( f.events.at(-1).key, @@ -242,14 +241,20 @@ test('retry accepts byte-identical objects without replacing them', async (t) => ); }); -test('different existing release is never overwritten', async (t) => { - const f = fixture(t); - const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; - f.objects.set(key, Buffer.from('previous version content')); - await assert.rejects(publishMacos(f), /bump the app version/); - assert.equal(f.objects.get(key).toString(), 'previous version content'); - assert.equal(f.objects.size, 1); -}); +for (const published of ['1.2.3', '1.2.4']) { + test(`published release stays protected when the feed is at ${published}`, async (t) => { + const f = fixture(t); + const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; + f.objects.set(key, Buffer.from('previous version content')); + f.objects.set( + `releases/macos/${f.metadata.target}/latest.json`, + Buffer.from(JSON.stringify({ version: published })), + ); + await assert.rejects(publishMacos(f), /bump the app version/); + assert.equal(f.objects.get(key).toString(), 'previous version content'); + assert.equal(f.objects.size, 2); + }); +} test('checksum mismatch aborts before contacting S3', async (t) => { const f = fixture(t); @@ -304,7 +309,7 @@ for (const failure of ['private', 'wrong-content', 'network']) { }); } -test('a partial upload can resume with the same files', async (t) => { +test('a partial upload can be retried with the same files', async (t) => { const f = fixture(t); const run = f.run; f.run = (command, args) => { @@ -323,6 +328,203 @@ test('a partial upload can resume with the same files', async (t) => { assert.equal(f.objects.size, 8); }); +function rebuild(f) { + const updater = Buffer.from('rebuilt signed update'); + const signing = signedFixture(updater); + f.publicKey = signing.publicKey; + f.metadata.notes = 'Fixed build of the same version'; + for (const [name, contents] of [ + [f.metadata.filename, Buffer.from('rebuilt disk image')], + [f.metadata.updaterFilename, updater], + ]) { + writeFileSync(join(f.directory, name), contents); + writeFileSync( + join(f.directory, `${name}.sha256`), + `${createHash('sha256').update(contents).digest('hex')} ${name}\n`, + ); + } + writeFileSync( + join(f.directory, `${f.metadata.updaterFilename}.sig`), + signing.signature, + ); +} + +for (const previous of [null, '1.0.0']) { + for (const interruptedFile of ['.sha256', '/latest.json']) { + test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => { + const f = fixture(t); + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + const previousBytes = + previous === null + ? undefined + : Buffer.from(JSON.stringify({ version: previous })); + if (previousBytes) { + f.objects.set(latestKey, previousBytes); + } + const run = f.run; + f.run = (command, args) => { + if ( + args.includes('put-object') && + args[args.indexOf('--key') + 1].endsWith(interruptedFile) + ) { + throw new Error('upload interrupted'); + } + return run(command, args); + }; + await assert.rejects(publishMacos(f), /upload interrupted/); + assert.deepEqual(f.objects.get(latestKey), previousBytes); + rebuild(f); + f.events.length = 0; + let promoted = false; + f.run = (command, args) => { + if (!promoted) { + assert.deepEqual(f.objects.get(latestKey), previousBytes); + } + const result = run(command, args); + if (args.includes('put-object') && args.includes(latestKey)) { + promoted = true; + } + return result; + }; + await publishMacos(f); + assert.equal(f.objects.size, 8); + assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); + assert.equal( + JSON.parse(f.objects.get(latestKey)).notes, + f.metadata.notes, + ); + for (const name of [ + f.metadata.filename, + `${f.metadata.filename}.sha256`, + f.metadata.updaterFilename, + `${f.metadata.updaterFilename}.sig`, + `${f.metadata.updaterFilename}.sha256`, + ]) { + assert.deepEqual( + f.objects.get( + `releases/macos/${f.metadata.target}/1.2.3/${name}`, + ), + readFileSync(join(f.directory, name)), + ); + } + const published = [...f.objects.entries()]; + rebuild(f); + await assert.rejects(publishMacos(f), /already been released/); + assert.deepEqual([...f.objects.entries()], published); + }); + } +} + +test('failure after promoting the feed still protects that version from rebuilding', async (t) => { + const f = fixture(t); + const fetchPublic = f.fetchPublic; + f.fetchPublic = async (url, options) => + url.endsWith('/latest.json') + ? { ok: false, status: 403 } + : fetchPublic(url, options); + await assert.rejects(publishMacos(f), /HTTP 403/); + const published = [...f.objects.entries()]; + rebuild(f); + await assert.rejects(publishMacos(f), /already been released/); + assert.deepEqual([...f.objects.entries()], published); +}); + +test('a concurrent change to a partial artifact is not overwritten', async (t) => { + const f = fixture(t); + const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; + f.objects.set(key, Buffer.from('unfinished old build')); + const run = f.run; + f.run = (command, args) => { + if (args.includes('--if-match')) { + assert(args.includes(key)); + f.objects.set(key, Buffer.from('concurrent winner')); + throw new Error('(PreconditionFailed) concurrent replacement'); + } + return run(command, args); + }; + await assert.rejects(publishMacos(f), /concurrent replacement/); + assert.equal(f.objects.get(key).toString(), 'concurrent winner'); + assert.equal(f.objects.size, 1); +}); + +for (const failure of [ + 'AccessDenied', + 'network failure', + 'invalid JSON', + 'invalid version', +]) { + test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => { + const f = fixture(t); + const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(key, Buffer.from('unfinished old build')); + f.objects.set( + latestKey, + Buffer.from( + failure === 'invalid version' ? '{"version":"invalid"}' : '{', + ), + ); + const originals = [...f.objects.entries()]; + const run = f.run; + f.run = (command, args) => { + if ( + args.includes('get-object') && + args.includes(latestKey) && + !failure.startsWith('invalid') + ) { + throw new Error(failure); + } + return run(command, args); + }; + await assert.rejects(publishMacos(f)); + assert.deepEqual([...f.objects.entries()], originals); + }); +} + +for (const listing of [ + { KeyCount: 0, IsTruncated: false }, + { KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] }, + {}, + { KeyCount: 0, IsTruncated: true }, + { KeyCount: 0, IsTruncated: false, Contents: '' }, + 'AccessDenied', +]) { + test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => { + const f = fixture(t); + const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; + const latestKey = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(key, Buffer.from('unfinished old build')); + const run = f.run; + f.run = (command, args) => { + if (args.includes('get-object') && args.includes(latestKey)) { + throw new Error( + "argument of type 'NoneType' is not a container or iterable", + ); + } + if (args.includes('list-objects-v2')) { + assert.equal(args[args.indexOf('--prefix') + 1], latestKey); + if (listing === 'AccessDenied') { + throw new Error('AccessDenied'); + } + return JSON.stringify(listing); + } + return run(command, args); + }; + if ( + listing.KeyCount === 0 && + listing.IsTruncated === false && + !('Contents' in listing) + ) { + await publishMacos(f); + assert.equal(f.objects.size, 8); + } else { + await assert.rejects(publishMacos(f), /NoneType|AccessDenied/); + assert.equal(f.objects.get(key).toString(), 'unfinished old build'); + assert.equal(f.objects.size, 1); + } + }); +} + for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) { test(`temporary public download failure ${status} recovers before completion`, async (t) => { const f = fixture(t); @@ -443,13 +645,13 @@ test('rejects a corrupt updater archive before publishing any object', async (t) assert.equal(f.objects.size, 0); }); -test('atomically promotes a newer feed only after all immutable objects are public', async (t) => { +test('atomically promotes a newer feed only after all release files are public', async (t) => { const f = fixture(t); const latestKey = `releases/macos/${f.metadata.target}/latest.json`; f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' }))); await publishMacos(f); assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3'); - const immutableVerified = f.events.findIndex( + const releaseVerified = f.events.findIndex( (event) => event.operation === 'public-get' && event.key.endsWith('/update.json'), @@ -457,7 +659,7 @@ test('atomically promotes a newer feed only after all immutable objects are publ const latestPut = f.events.findIndex( (event) => event.operation === 'put-object' && event.key === latestKey, ); - assert(latestPut > immutableVerified); + assert(latestPut > releaseVerified); }); for (const previous of [ @@ -508,12 +710,16 @@ for (const message of [ assert.deepEqual([...f.objects.entries()], published); }); - test(`empty S3 error cannot bypass immutable content checks: ${message}`, async (t) => { + test(`empty S3 error cannot replace a published release: ${message}`, async (t) => { const f = fixture(t, message); const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`; f.objects.set(key, Buffer.from('different existing release')); + f.objects.set( + `releases/macos/${f.metadata.target}/latest.json`, + Buffer.from(JSON.stringify({ version: '1.2.3' })), + ); await assert.rejects(publishMacos(f), /bump the app version/); - assert.equal(f.objects.size, 1); + assert.equal(f.objects.size, 2); assert.equal( f.objects.get(key).toString(), 'different existing release',