diff --git a/native/package.json b/native/package.json index 4df0810..aea262d 100644 --- a/native/package.json +++ b/native/package.json @@ -1,6 +1,6 @@ { "name": "kanbangit-native", - "version": "0.1.3", + "version": "0.1.4", "private": true, "type": "module", "scripts": { @@ -24,5 +24,5 @@ "devDependencies": { "@tauri-apps/cli": "2.11.4" }, - "releaseNotes": "About KanbanGit and Software updates now show the installed app build number correctly." + "releaseNotes": "About KanbanGit and Software updates show the installed app build number correctly. Release publishing now handles Hetzner responses with empty error messages when updating the feed." } diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs index a38fe02..0c12ce3 100644 --- a/native/publish-macos.mjs +++ b/native/publish-macos.mjs @@ -63,6 +63,14 @@ export function storageConfiguration(env = process.env) { const sha256 = (data) => createHash('sha256').update(data).digest('hex'); +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. + return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test( + error.message, + ); +} + export async function publishMacos({ env = process.env, directory, @@ -116,13 +124,21 @@ export async function publishMacos({ 's3api', ]; function s3(operation, args) { - return run('aws', [ - ...common, - operation, - '--bucket', - storage.bucket, - ...args, - ]); + try { + return run('aws', [ + ...common, + operation, + '--bucket', + storage.bucket, + ...args, + ]); + } catch (error) { + const keyIndex = args.indexOf('--key'); + const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : ''; + throw new Error(`S3 ${operation}${key}: ${error.message}`, { + cause: error, + }); + } } const versioningOutput = s3('get-bucket-versioning', [ '--output', @@ -213,7 +229,7 @@ export async function publishMacos({ `sha256=${sha256(contents)}`, ]); } catch (error) { - if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { + if (!requiresExistingObjectCheck(error)) { throw error; } const existing = join(staging, `existing-${name}`); @@ -301,7 +317,7 @@ export async function publishMacos({ try { publishLatest(['--if-none-match', '*']); } catch (error) { - if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { + if (!requiresExistingObjectCheck(error)) { throw error; } const previousFile = join(staging, 'previous-latest.json'); diff --git a/native/src-tauri/Cargo.lock b/native/src-tauri/Cargo.lock index c69890d..2bb4dd3 100644 --- a/native/src-tauri/Cargo.lock +++ b/native/src-tauri/Cargo.lock @@ -1770,7 +1770,7 @@ dependencies = [ [[package]] name = "kanbangit-native" -version = "0.1.3" +version = "0.1.4" dependencies = [ "semver", "serde", diff --git a/native/src-tauri/Cargo.toml b/native/src-tauri/Cargo.toml index badf744..30e4a59 100644 --- a/native/src-tauri/Cargo.toml +++ b/native/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kanbangit-native" -version = "0.1.3" +version = "0.1.4" description = "KanbanGit native application" edition = "2021" publish = false diff --git a/native/src-tauri/tauri.conf.json b/native/src-tauri/tauri.conf.json index 6cc217e..ad00e5f 100644 --- a/native/src-tauri/tauri.conf.json +++ b/native/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "KanbanGit", - "version": "0.1.3", + "version": "0.1.4", "identifier": "de.kanbangit.desktop", "build": { "frontendDist": "../ui" @@ -25,7 +25,7 @@ ], "macOS": { "minimumSystemVersion": "14.0", - "bundleVersion": "3", + "bundleVersion": "4", "dmg": { "windowSize": { "width": 660, diff --git a/native/tests/publish-macos.test.js b/native/tests/publish-macos.test.js index 5b9e59e..0745f37 100644 --- a/native/tests/publish-macos.test.js +++ b/native/tests/publish-macos.test.js @@ -8,7 +8,10 @@ import test from 'node:test'; import { publishMacos, storageConfiguration } from '../publish-macos.mjs'; import { signedFixture } from './updater-fixture.mjs'; -function fixture(t) { +function fixture( + t, + conflictMessage = 'aws failed: (PreconditionFailed) object exists', +) { const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-')); t.after(() => rmSync(directory, { recursive: true, force: true })); const metadata = { @@ -60,9 +63,7 @@ function fixture(t) { } if (objects.has(key)) { if (!args.includes('--if-match')) { - throw new Error( - 'aws failed: (PreconditionFailed) object exists', - ); + throw new Error(conflictMessage); } assert.equal(value('--if-match'), '"previous-etag"'); } @@ -427,3 +428,86 @@ test('concurrent feed replacement fails without overwriting the winner', async ( await assert.rejects(publishMacos(f), /concurrent publication/); assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0'); }); + +for (const message of [ + "aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable", + "aws failed (exit 255). argument of type 'NoneType' is not iterable", +]) { + test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => { + const f = fixture(t, message); + const key = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' }))); + await publishMacos(f); + assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3'); + const published = [...f.objects.entries()]; + await publishMacos(f); + assert.deepEqual([...f.objects.entries()], published); + }); + + test(`empty S3 error cannot bypass immutable content checks: ${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')); + await assert.rejects(publishMacos(f), /bump the app version/); + assert.equal(f.objects.size, 1); + assert.equal( + f.objects.get(key).toString(), + 'different existing release', + ); + }); + + for (const previous of [ + { version: '2.0.0' }, + { version: '1.2.3', notes: 'different' }, + ]) { + test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => { + const f = fixture(t, message); + const key = `releases/macos/${f.metadata.target}/latest.json`; + const original = Buffer.from(JSON.stringify(previous)); + f.objects.set(key, original); + await assert.rejects(publishMacos(f), /downgrade|differs/); + assert.deepEqual(f.objects.get(key), original); + }); + } + + test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => { + const f = fixture(t, message); + const run = f.run; + f.run = (command, args) => { + if (args.includes('put-object')) { + throw new Error(message); + } + if (args.includes('get-object')) { + throw new Error('AccessDenied'); + } + return run(command, args); + }; + await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/); + assert.equal(f.objects.size, 0); + }); + + test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => { + const f = fixture(t, message); + const key = `releases/macos/${f.metadata.target}/latest.json`; + f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' }))); + const run = f.run; + let attempts = 0; + f.run = (command, args) => { + if (args.includes('--if-match')) { + attempts++; + f.objects.set( + key, + Buffer.from(JSON.stringify({ version: '2.0.0' })), + ); + throw new Error(message); + } + return run(command, args); + }; + await assert.rejects( + publishMacos(f), + /S3 put-object .*latest.json: .*NoneType/, + ); + assert.equal(attempts, 1); + assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0'); + }); +}