fix(KAN-65): allow rebuilding unpublished macOS releases
CI/CD / preconditions (pull_request) Successful in 2s
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 1m32s
CI/CD / test_and_check_app (pull_request) Successful in 6m53s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-09-28 07:51:56 +02:00
parent 41b6dc8f10
commit 4ee047311f
4 changed files with 333 additions and 33 deletions
+66 -15
View File
@@ -73,7 +73,7 @@ export function conditionalEtag(etag) {
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
) {
throw new Error(
'Cannot atomically update the feed without a valid S3 ETag.',
'Cannot atomically replace an object without a valid S3 ETag.',
);
}
// Hetzner returns quoted ETags on GET but compares bare values on conditional PUT.
@@ -185,12 +185,15 @@ export async function publishMacos({
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
async function verifyPublic(name, contents, prefix = publicPrefix) {
for (let attempt = 1; attempt <= 5; attempt++) {
let response;
try {
response = await fetchPublic(`${prefix}/${name}`, {
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(60000),
});
} catch (error) {
@@ -225,36 +228,86 @@ export async function publishMacos({
await wait(2000);
}
}
function putImmutable(name, contents, contentType) {
function assertUnpublishedRelease() {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
} catch (error) {
if (/\(NoSuchKey\)/.test(error.message)) {
return;
}
if (requiresExistingObjectCheck(error)) {
// A broken CLI error message cannot distinguish missing from denied.
// Only an authenticated, explicitly empty listing proves absence.
const listing = JSON.parse(
s3('list-objects-v2', [
'--prefix',
latestKey,
'--max-keys',
'1',
'--output',
'json',
]),
);
if (
listing?.KeyCount === 0 &&
listing.IsTruncated === false &&
Array.isArray(listing.Contents ?? []) &&
(listing.Contents ?? []).length === 0
) {
return;
}
}
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
if (compareVersions(metadata.version, published.version) <= 0) {
throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
}
}
// The macos-release concurrency group serializes the entire publication.
// Never run independent publishers against the same release destination.
function putReleaseFile(name, contents, contentType) {
const file = join(staging, name);
writeFileSync(file, contents);
try {
function put(condition) {
s3('put-object', [
'--key',
`${keyPrefix}/${name}`,
'--body',
file,
'--if-none-match',
'*',
...condition,
'--content-type',
contentType,
'--cache-control',
'public, max-age=31536000, immutable',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(contents).digest('base64'),
'--metadata',
`sha256=${sha256(contents)}`,
]);
}
try {
put(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const existing = join(staging, `existing-${name}`);
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
const response = JSON.parse(
s3('get-object', [
'--key',
`${keyPrefix}/${name}`,
'--output',
'json',
existing,
]),
);
if (sha256(readFileSync(existing)) !== sha256(contents)) {
throw new Error(
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
);
assertUnpublishedRelease();
put(['--if-match', conditionalEtag(response.ETag)]);
}
}
}
@@ -279,7 +332,7 @@ export async function publishMacos({
],
];
for (const [name, contents, type] of files) {
putImmutable(name, contents, type);
putReleaseFile(name, contents, type);
await verifyPublic(name, contents);
}
// Completion metadata is written only after every public artifact matches.
@@ -296,7 +349,7 @@ export async function publishMacos({
2,
) + '\n',
);
putImmutable('release.json', manifest, 'application/json');
putReleaseFile('release.json', manifest, 'application/json');
await verifyPublic('release.json', manifest);
const latest = Buffer.from(
JSON.stringify(
@@ -310,10 +363,8 @@ export async function publishMacos({
2,
) + '\n',
);
putImmutable('update.json', latest, 'application/json');
putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
const latestPrefix = `${storage.prefix}/${metadata.target}`;
const latestKey = `${latestPrefix}/latest.json`;
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {