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
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:
+2
-2
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+1
-1
@@ -1770,7 +1770,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kanbangit-native"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "KanbanGit native application"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user