feat: add stable macOS app download to website
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m3s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m41s
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 09:12:37 +02:00
parent aece653970
commit d18fffe665
12 changed files with 310 additions and 31 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "kanbangit-native",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"type": "module",
"scripts": {
@@ -24,5 +24,5 @@
"devDependencies": {
"@tauri-apps/cli": "2.11.4"
},
"releaseNotes": "Update to full release v0.2.0"
"releaseNotes": "Add the stable Mac download for the KanbanGit website."
}
+55 -2
View File
@@ -228,7 +228,7 @@ export async function publishMacos({
await wait(2000);
}
}
function assertUnpublishedRelease() {
function assertUnpublishedRelease(allowSameVersion = false) {
const file = join(staging, 'published-latest.json');
try {
s3('get-object', ['--key', latestKey, file]);
@@ -261,7 +261,11 @@ export async function publishMacos({
throw error;
}
const published = JSON.parse(readFileSync(file, 'utf8'));
if (compareVersions(metadata.version, published.version) <= 0) {
const comparison = compareVersions(metadata.version, published.version);
if (allowSameVersion && comparison < 0) {
throw new Error('Refusing to downgrade the stable download.');
}
if (comparison < 0 || (!allowSameVersion && comparison === 0)) {
throw new Error(
'This version has already been released through the update feed; bump the app version. Published objects were not overwritten.',
);
@@ -365,6 +369,55 @@ export async function publishMacos({
);
putReleaseFile('update.json', latest, 'application/json');
await verifyPublic('update.json', latest);
// An older release must not populate an absent download alias either.
assertUnpublishedRelease(true);
const downloadName = 'KanbanGit.dmg';
const downloadKey = `${latestPrefix}/${downloadName}`;
const downloadFile = join(staging, downloadName);
writeFileSync(downloadFile, archive);
function publishDownload(condition) {
s3('put-object', [
'--key',
downloadKey,
'--body',
downloadFile,
...condition,
'--content-type',
'application/x-apple-diskimage',
'--cache-control',
'no-cache, max-age=0, must-revalidate',
'--content-md5',
createHash('md5').update(archive).digest('base64'),
'--metadata',
`sha256=${checksum}`,
]);
}
try {
publishDownload(['--if-none-match', '*']);
} catch (error) {
if (!requiresExistingObjectCheck(error)) {
throw error;
}
const previousFile = join(staging, 'previous-download.dmg');
const response = JSON.parse(
s3('get-object', [
'--key',
downloadKey,
'--output',
'json',
previousFile,
]),
);
if (sha256(readFileSync(previousFile)) !== checksum) {
assertUnpublishedRelease();
publishDownload(['--if-match', conditionalEtag(response.ETag)]);
}
}
await verifyPublic(
downloadName,
archive,
`${storage.publicBase}/${latestPrefix}`,
);
const latestFile = join(staging, 'latest.json');
writeFileSync(latestFile, latest);
function publishLatest(condition) {
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]]
name = "kanbangit-native"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"semver",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kanbangit-native"
version = "0.2.0"
version = "0.2.1"
description = "KanbanGit native application"
edition = "2021"
publish = false
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit",
"version": "0.2.0",
"version": "0.2.1",
"identifier": "de.kanbangit.desktop",
"build": {
"frontendDist": "../ui"
@@ -25,7 +25,7 @@
],
"macOS": {
"minimumSystemVersion": "14.0",
"bundleVersion": "6",
"bundleVersion": "7",
"dmg": {
"windowSize": {
"width": 660,
+114 -9
View File
@@ -168,9 +168,9 @@ test('uploads revalidated files and writes completion record after public verifi
);
assert.equal(
f.events.filter((event) => event.operation === 'put-object').length,
8,
9,
);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
for (const event of f.events.filter(
(event) => event.operation === 'put-object',
)) {
@@ -184,6 +184,26 @@ test('uploads revalidated files and writes completion record after public verifi
f.events.at(-1).key,
'releases/macos/aarch64-apple-darwin/latest.json',
);
const downloadKey = 'releases/macos/aarch64-apple-darwin/KanbanGit.dmg';
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
const downloadPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' && event.key === downloadKey,
);
const downloadVerified = f.events.findIndex(
(event) =>
event.operation === 'public-get' && event.key === downloadKey,
);
const feedPut = f.events.findIndex(
(event) =>
event.operation === 'put-object' &&
event.key.endsWith('/latest.json'),
);
assert(downloadPut > 0 && downloadVerified > downloadPut);
assert(feedPut > downloadVerified);
assert.deepEqual(
JSON.parse(
f.objects.get(
@@ -202,6 +222,91 @@ test('uploads revalidated files and writes completion record after public verifi
);
});
test('replaces the stable download only for a newer verified release', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.deepEqual(
f.objects.get(downloadKey),
readFileSync(join(f.directory, f.metadata.filename)),
);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
const replacement = f.events.find(
(event) =>
event.operation === 'put-object' &&
event.key === downloadKey &&
event.args.includes('--if-match'),
);
assert(replacement);
});
test('a newer published version protects the stable download even when its alias is missing', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '2.0.0' })));
await assert.rejects(publishMacos(f), /downgrade/);
assert.equal(f.objects.has(downloadKey), false);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '2.0.0');
});
test('a published version cannot replace a different stable download', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from('published download');
f.objects.set(downloadKey, original);
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.2.3' })));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.deepEqual(f.objects.get(downloadKey), original);
});
test('a failed public stable download blocks feed promotion', async (t) => {
const f = fixture(t);
const fetchPublic = f.fetchPublic;
f.fetchPublic = (url, options) =>
url.endsWith('/KanbanGit.dmg')
? Promise.resolve({ ok: false, status: 403 })
: fetchPublic(url, options);
await assert.rejects(publishMacos(f), /KanbanGit\.dmg: HTTP 403/);
assert.equal(
f.objects.has(`releases/macos/${f.metadata.target}/latest.json`),
false,
);
});
test('concurrent stable download replacement preserves the winner', async (t) => {
const f = fixture(t);
const downloadKey = `releases/macos/${f.metadata.target}/KanbanGit.dmg`;
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(downloadKey, Buffer.from('previous download'));
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
f.run = (command, args) => {
if (args.includes('--if-match') && args.includes(downloadKey)) {
f.objects.set(downloadKey, Buffer.from('concurrent winner'));
throw new Error('(PreconditionFailed) concurrent replacement');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /concurrent replacement/);
assert.equal(f.objects.get(downloadKey).toString(), 'concurrent winner');
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.0.0');
});
for (const response of ['', ' \n\t', '{}']) {
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
const f = fixture(t);
@@ -211,7 +316,7 @@ for (const response of ['', ' \n\t', '{}']) {
return args.includes('get-bucket-versioning') ? response : result;
};
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
assert.equal(f.events.at(-1).operation, 'public-get');
assert(f.events.at(-1).key.endsWith('/latest.json'));
});
@@ -237,7 +342,7 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
assert.deepEqual([...f.objects.entries()], originals);
assert.equal(
f.events.filter((event) => event.operation === 'get-object').length,
8,
11,
);
});
@@ -325,7 +430,7 @@ test('a partial upload can be retried with the same files', async (t) => {
assert.equal(f.objects.size, 3);
f.run = run;
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
});
function rebuild(f) {
@@ -387,7 +492,7 @@ for (const previous of [null, '1.0.0']) {
return result;
};
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
assert.equal(
JSON.parse(f.objects.get(latestKey)).notes,
@@ -516,7 +621,7 @@ for (const listing of [
!('Contents' in listing)
) {
await publishMacos(f);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
} else {
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
@@ -549,7 +654,7 @@ for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
await publishMacos(f);
assert.deepEqual(waits, [2000, 2000]);
assert.equal(cancelled, status === 'network' ? 0 : 2);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
assert(f.events.at(-1).key.endsWith('/latest.json'));
});
}
@@ -593,7 +698,7 @@ test('checksum access retries finish before publishing the completion record', a
};
await publishMacos(f);
assert.equal(failures, 3);
assert.equal(f.objects.size, 8);
assert.equal(f.objects.size, 9);
});
for (const overrides of [