fix(KAN-65): normalize Hetzner conditional write ETags
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m5s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m34s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 5m16s

This commit is contained in:
2026-09-28 07:24:12 +02:00
parent dc9f400f34
commit 40b66bf7ce
3 changed files with 251 additions and 13 deletions
+67 -10
View File
@@ -63,6 +63,23 @@ export function storageConfiguration(env = process.env) {
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
export function conditionalEtag(etag) {
const value =
typeof etag === 'string' && etag.startsWith('"') && etag.endsWith('"')
? etag.slice(1, -1)
: etag;
if (
typeof value !== 'string' ||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically update the feed without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
return value;
}
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.
@@ -346,12 +363,7 @@ export async function publishMacos({
);
}
} else {
if (!response.ETag) {
throw new Error(
'Cannot atomically update the feed without its ETag.',
);
}
publishLatest(['--if-match', response.ETag]);
publishLatest(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
@@ -365,6 +377,49 @@ 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
@@ -373,10 +428,12 @@ if (
if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration()));
} else if (
process.argv[2] === 'upload' &&
process.env.RELEASE_DIRECTORY
process.argv[2] === 'resume' ||
(process.argv[2] === 'upload' && process.env.RELEASE_DIRECTORY)
) {
const url = await publishMacos({
const publish =
process.argv[2] === 'resume' ? resumeMacos : publishMacos;
const url = await publish({
directory: process.env.RELEASE_DIRECTORY,
metadata: releaseMetadata(),
});
@@ -389,7 +446,7 @@ if (
}
} else {
throw new Error(
'Use config or upload; upload requires RELEASE_DIRECTORY.',
'Use config, upload or resume; upload requires RELEASE_DIRECTORY.',
);
}
} catch (error) {