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",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "2.11.4"
|
"@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');
|
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({
|
export async function publishMacos({
|
||||||
env = process.env,
|
env = process.env,
|
||||||
directory,
|
directory,
|
||||||
@@ -116,13 +124,21 @@ export async function publishMacos({
|
|||||||
's3api',
|
's3api',
|
||||||
];
|
];
|
||||||
function s3(operation, args) {
|
function s3(operation, args) {
|
||||||
return run('aws', [
|
try {
|
||||||
...common,
|
return run('aws', [
|
||||||
operation,
|
...common,
|
||||||
'--bucket',
|
operation,
|
||||||
storage.bucket,
|
'--bucket',
|
||||||
...args,
|
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', [
|
const versioningOutput = s3('get-bucket-versioning', [
|
||||||
'--output',
|
'--output',
|
||||||
@@ -213,7 +229,7 @@ export async function publishMacos({
|
|||||||
`sha256=${sha256(contents)}`,
|
`sha256=${sha256(contents)}`,
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const existing = join(staging, `existing-${name}`);
|
const existing = join(staging, `existing-${name}`);
|
||||||
@@ -301,7 +317,7 @@ export async function publishMacos({
|
|||||||
try {
|
try {
|
||||||
publishLatest(['--if-none-match', '*']);
|
publishLatest(['--if-none-match', '*']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const previousFile = join(staging, 'previous-latest.json');
|
const previousFile = join(staging, 'previous-latest.json');
|
||||||
|
|||||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "KanbanGit",
|
"productName": "KanbanGit",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"minimumSystemVersion": "14.0",
|
"minimumSystemVersion": "14.0",
|
||||||
"bundleVersion": "3",
|
"bundleVersion": "4",
|
||||||
"dmg": {
|
"dmg": {
|
||||||
"windowSize": {
|
"windowSize": {
|
||||||
"width": 660,
|
"width": 660,
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import test from 'node:test';
|
|||||||
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||||
import { signedFixture } from './updater-fixture.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-'));
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
const metadata = {
|
const metadata = {
|
||||||
@@ -60,9 +63,7 @@ function fixture(t) {
|
|||||||
}
|
}
|
||||||
if (objects.has(key)) {
|
if (objects.has(key)) {
|
||||||
if (!args.includes('--if-match')) {
|
if (!args.includes('--if-match')) {
|
||||||
throw new Error(
|
throw new Error(conflictMessage);
|
||||||
'aws failed: (PreconditionFailed) object exists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
assert.equal(value('--if-match'), '"previous-etag"');
|
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/);
|
await assert.rejects(publishMacos(f), /concurrent publication/);
|
||||||
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
|
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