fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
This commit is contained in:
+2
-2
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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,6 +124,7 @@ export async function publishMacos({
|
||||
's3api',
|
||||
];
|
||||
function s3(operation, args) {
|
||||
try {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
@@ -123,6 +132,13 @@ export async function publishMacos({
|
||||
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');
|
||||
|
||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user