Compare commits

..
Author SHA1 Message Date
Twyco 3e5c316b8f Merge pull request 'KAN-58: Retry public S3 downloads and prepare macOS 0.1.1' (#157) from fix/KAN-58-retry-public-downloads into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 2m29s
Reviewed-on: #157
2026-09-27 14:40:36 +00:00
Twyco 4dd73ec583 fix(KAN-58): retry transient public downloads for macOS 0.1.1
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m28s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m15s
CI/CD / preconditions (pull_request) Successful in 2s
2026-09-27 16:37:16 +02:00
6 changed files with 125 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+39 -10
View File
@@ -10,6 +10,7 @@ import {
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { execute, releaseMetadata } from './macos-ci.mjs'; import { execute, releaseMetadata } from './macos-ci.mjs';
@@ -62,6 +63,7 @@ export async function publishMacos({
metadata, metadata,
run = execute, run = execute,
fetchPublic = fetch, fetchPublic = fetch,
wait = setTimeout,
} = {}) { } = {}) {
const storage = storageConfiguration(env); const storage = storageConfiguration(env);
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) { if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
@@ -123,16 +125,43 @@ export async function publishMacos({
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`; const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`; const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
async function verifyPublic(name, contents) { async function verifyPublic(name, contents) {
const response = await fetchPublic(`${publicPrefix}/${name}`, { for (let attempt = 1; attempt <= 5; attempt++) {
redirect: 'error', let response;
signal: AbortSignal.timeout(60000), try {
}); response = await fetchPublic(`${publicPrefix}/${name}`, {
if ( redirect: 'error',
!response.ok || signal: AbortSignal.timeout(60000),
sha256(Buffer.from(await response.arrayBuffer())) !== });
sha256(contents) } catch (error) {
) { if (attempt === 5) {
throw new Error(`Public download verification failed for ${name}.`); throw new Error(
`Public download request failed for ${name}: ${error.message}`,
);
}
await wait(2000);
continue;
}
if (response.ok) {
if (
sha256(Buffer.from(await response.arrayBuffer())) !==
sha256(contents)
) {
throw new Error(
`Public download verification failed for ${name}: SHA-256 mismatch.`,
);
}
return;
}
await response.body?.cancel();
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
response.status,
);
if (!retryable || attempt === 5) {
throw new Error(
`Public download verification failed for ${name}: HTTP ${response.status}.`,
);
}
await wait(2000);
} }
} }
function putImmutable(name, contents, contentType) { function putImmutable(name, contents, contentType) {
+1 -1
View File
@@ -1673,7 +1673,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.0" version = "0.1.1"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
+1 -1
View File
@@ -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.0", "version": "0.1.1",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+82 -1
View File
@@ -70,7 +70,16 @@ function fixture(t) {
arrayBuffer: async () => objects.get(key), arrayBuffer: async () => objects.get(key),
}; };
}; };
return { directory, metadata, env, objects, events, run, fetchPublic }; return {
directory,
metadata,
env,
objects,
events,
run,
fetchPublic,
wait: async () => {},
};
} }
test('uploads immutable files and writes completion record after public verification', async (t) => { test('uploads immutable files and writes completion record after public verification', async (t) => {
@@ -194,6 +203,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
} }
return { return {
ok: failure !== 'private', ok: failure !== 'private',
status: failure === 'private' ? 403 : 200,
arrayBuffer: async () => Buffer.from('wrong'), arrayBuffer: async () => Buffer.from('wrong'),
}; };
}; };
@@ -223,6 +233,77 @@ test('a partial upload can resume with the same files', async (t) => {
assert.equal(f.objects.size, 3); assert.equal(f.objects.size, 3);
}); });
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);
const fetchPublic = f.fetchPublic;
let attempts = 0;
let cancelled = 0;
const waits = [];
f.wait = async (ms) => waits.push(ms);
f.fetchPublic = async (...args) => {
if (++attempts <= 2) {
if (status === 'network') {
throw new Error('temporary network failure');
}
return {
ok: false,
status,
body: { cancel: async () => cancelled++ },
};
}
return fetchPublic(...args);
};
await publishMacos(f);
assert.deepEqual(waits, [2000, 2000]);
assert.equal(cancelled, status === 'network' ? 0 : 2);
assert.equal(f.objects.size, 3);
assert(f.events.at(-1).key.endsWith('/release.json'));
});
}
test('persistent public access denial stops after five attempts with HTTP status', async (t) => {
const f = fixture(t);
let attempts = 0;
const waits = [];
f.wait = async (ms) => waits.push(ms);
f.fetchPublic = async () => {
attempts++;
return { ok: false, status: 403 };
};
await assert.rejects(publishMacos(f), /HTTP 403/);
assert.equal(attempts, 5);
assert.equal(waits.length, 4);
assert.equal(f.objects.size, 1);
});
test('incorrect public bytes fail immediately without retry or completion record', async (t) => {
const f = fixture(t);
f.wait = async () => assert.fail('Do not retry mismatched content');
f.fetchPublic = async () => ({
ok: true,
arrayBuffer: async () => Buffer.from('incorrect'),
});
await assert.rejects(publishMacos(f), /SHA-256 mismatch/);
assert.equal(f.objects.size, 1);
});
test('checksum access retries finish before publishing the completion record', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
let failures = 0;
f.fetchPublic = async (url, options) => {
if (url.endsWith('.sha256') && ++failures <= 2) {
assert.equal(f.objects.size, 2);
return { ok: false, status: 403 };
}
return fetchPublic(url, options);
};
await publishMacos(f);
assert.equal(failures, 3);
assert.equal(f.objects.size, 3);
});
for (const overrides of [ for (const overrides of [
{ S3_REGION: '' }, { S3_REGION: '' },
{ S3_REGION: 'eu-central-1' }, { S3_REGION: 'eu-central-1' },