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
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:
@@ -17,6 +17,8 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
# Partial release files may be replaced until the feed is promoted.
|
||||||
|
# Keep every publisher in this group to prevent overlapping rebuilds.
|
||||||
group: macos-release
|
group: macos-release
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ jobs:
|
|||||||
id: package
|
id: package
|
||||||
run: node native/macos-ci.mjs package
|
run: node native/macos-ci.mjs package
|
||||||
|
|
||||||
- name: Publish immutable files and verify public Hetzner downloads
|
- name: Upload release files and promote the verified update feed
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||||
|
|||||||
+66
-15
@@ -73,7 +73,7 @@ export function conditionalEtag(etag) {
|
|||||||
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
!/^[a-f\d]{32}(?:-[1-9]\d*)?$/i.test(value)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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.
|
// 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 staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||||
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}`;
|
||||||
|
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
||||||
|
const latestKey = `${latestPrefix}/latest.json`;
|
||||||
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
async function verifyPublic(name, contents, prefix = publicPrefix) {
|
||||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetchPublic(`${prefix}/${name}`, {
|
response = await fetchPublic(`${prefix}/${name}`, {
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
|
cache: 'no-store',
|
||||||
signal: AbortSignal.timeout(60000),
|
signal: AbortSignal.timeout(60000),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -225,36 +228,86 @@ export async function publishMacos({
|
|||||||
await wait(2000);
|
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);
|
const file = join(staging, name);
|
||||||
writeFileSync(file, contents);
|
writeFileSync(file, contents);
|
||||||
try {
|
function put(condition) {
|
||||||
s3('put-object', [
|
s3('put-object', [
|
||||||
'--key',
|
'--key',
|
||||||
`${keyPrefix}/${name}`,
|
`${keyPrefix}/${name}`,
|
||||||
'--body',
|
'--body',
|
||||||
file,
|
file,
|
||||||
'--if-none-match',
|
...condition,
|
||||||
'*',
|
|
||||||
'--content-type',
|
'--content-type',
|
||||||
contentType,
|
contentType,
|
||||||
'--cache-control',
|
'--cache-control',
|
||||||
'public, max-age=31536000, immutable',
|
'no-cache, max-age=0, must-revalidate',
|
||||||
'--content-md5',
|
'--content-md5',
|
||||||
createHash('md5').update(contents).digest('base64'),
|
createHash('md5').update(contents).digest('base64'),
|
||||||
'--metadata',
|
'--metadata',
|
||||||
`sha256=${sha256(contents)}`,
|
`sha256=${sha256(contents)}`,
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
put(['--if-none-match', '*']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!requiresExistingObjectCheck(error)) {
|
if (!requiresExistingObjectCheck(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const existing = join(staging, `existing-${name}`);
|
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)) {
|
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||||
throw new Error(
|
assertUnpublishedRelease();
|
||||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
put(['--if-match', conditionalEtag(response.ETag)]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +332,7 @@ export async function publishMacos({
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
for (const [name, contents, type] of files) {
|
for (const [name, contents, type] of files) {
|
||||||
putImmutable(name, contents, type);
|
putReleaseFile(name, contents, type);
|
||||||
await verifyPublic(name, contents);
|
await verifyPublic(name, contents);
|
||||||
}
|
}
|
||||||
// Completion metadata is written only after every public artifact matches.
|
// Completion metadata is written only after every public artifact matches.
|
||||||
@@ -296,7 +349,7 @@ export async function publishMacos({
|
|||||||
2,
|
2,
|
||||||
) + '\n',
|
) + '\n',
|
||||||
);
|
);
|
||||||
putImmutable('release.json', manifest, 'application/json');
|
putReleaseFile('release.json', manifest, 'application/json');
|
||||||
await verifyPublic('release.json', manifest);
|
await verifyPublic('release.json', manifest);
|
||||||
const latest = Buffer.from(
|
const latest = Buffer.from(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -310,10 +363,8 @@ export async function publishMacos({
|
|||||||
2,
|
2,
|
||||||
) + '\n',
|
) + '\n',
|
||||||
);
|
);
|
||||||
putImmutable('update.json', latest, 'application/json');
|
putReleaseFile('update.json', latest, 'application/json');
|
||||||
await verifyPublic('update.json', latest);
|
await verifyPublic('update.json', latest);
|
||||||
const latestPrefix = `${storage.prefix}/${metadata.target}`;
|
|
||||||
const latestKey = `${latestPrefix}/latest.json`;
|
|
||||||
const latestFile = join(staging, 'latest.json');
|
const latestFile = join(staging, 'latest.json');
|
||||||
writeFileSync(latestFile, latest);
|
writeFileSync(latestFile, latest);
|
||||||
function publishLatest(condition) {
|
function publishLatest(condition) {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function fixture(t) {
|
|||||||
);
|
);
|
||||||
return { ...result, output: readFileSync(output, 'utf8') };
|
return { ...result, output: readFileSync(output, 'utf8') };
|
||||||
};
|
};
|
||||||
return { run, git, checkout, commit };
|
return { run, git, checkout, remote, commit };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
||||||
@@ -117,6 +117,35 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
|
|||||||
assert.match(workflow, /node native\/publish-macos.mjs upload/);
|
assert.match(workflow, /node native\/publish-macos.mjs upload/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a moved macOS tag selects the new main commit for a full rebuild', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.git(
|
||||||
|
f.remote,
|
||||||
|
'-c',
|
||||||
|
'commit.gpgsign=false',
|
||||||
|
'commit',
|
||||||
|
'--allow-empty',
|
||||||
|
'-m',
|
||||||
|
'Fix failed release',
|
||||||
|
);
|
||||||
|
const rebuilt = f.git(f.remote, 'rev-parse', 'HEAD');
|
||||||
|
f.git(f.remote, 'tag', '--force', 'macos-v1.2.3');
|
||||||
|
f.git(
|
||||||
|
f.checkout,
|
||||||
|
'fetch',
|
||||||
|
'--force',
|
||||||
|
'origin',
|
||||||
|
'refs/tags/macos-v1.2.3:refs/tags/macos-v1.2.3',
|
||||||
|
);
|
||||||
|
const result = f.run();
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.output, `commit=${rebuilt}\ntag=macos-v1.2.3\n`);
|
||||||
|
assert.notEqual(rebuilt, f.commit);
|
||||||
|
assert.match(workflow, /tags:\n\s+- "macos-v\*"/);
|
||||||
|
assert.match(workflow, /^ {2}group: macos-release$/m);
|
||||||
|
assert.match(workflow, /^ {2}cancel-in-progress: false$/m);
|
||||||
|
});
|
||||||
|
|
||||||
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
test('release DMG packaging disables Finder automation in the Tauri action environment', () => {
|
||||||
const build = workflow
|
const build = workflow
|
||||||
.split(' - name: Build, sign and notarize with Tauri\n')[1]
|
.split(' - name: Build, sign and notarize with Tauri\n')[1]
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function fixture(
|
|||||||
const operation = args[args.indexOf('s3api') + 1];
|
const operation = args[args.indexOf('s3api') + 1];
|
||||||
const value = (name) => args[args.indexOf(name) + 1];
|
const value = (name) => args[args.indexOf(name) + 1];
|
||||||
const key = value('--key');
|
const key = value('--key');
|
||||||
events.push({ operation, key });
|
events.push({ operation, key, args });
|
||||||
if (operation === 'get-bucket-versioning') {
|
if (operation === 'get-bucket-versioning') {
|
||||||
assert.equal(value('--output'), 'json');
|
assert.equal(value('--output'), 'json');
|
||||||
return '';
|
return '';
|
||||||
@@ -121,6 +121,9 @@ function fixture(
|
|||||||
return '{}';
|
return '{}';
|
||||||
}
|
}
|
||||||
if (operation === 'get-object') {
|
if (operation === 'get-object') {
|
||||||
|
if (!objects.has(key)) {
|
||||||
|
throw new Error('aws failed: (NoSuchKey) object missing');
|
||||||
|
}
|
||||||
writeFileSync(args.at(-1), objects.get(key));
|
writeFileSync(args.at(-1), objects.get(key));
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
|
||||||
@@ -156,7 +159,7 @@ function fixture(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
test('uploads revalidated files and writes completion record after public verification', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const url = await publishMacos(f);
|
const url = await publishMacos(f);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -168,6 +171,14 @@ test('uploads immutable files and writes completion record after public verifica
|
|||||||
8,
|
8,
|
||||||
);
|
);
|
||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 8);
|
||||||
|
for (const event of f.events.filter(
|
||||||
|
(event) => event.operation === 'put-object',
|
||||||
|
)) {
|
||||||
|
assert.equal(
|
||||||
|
event.args[event.args.indexOf('--cache-control') + 1],
|
||||||
|
'no-cache, max-age=0, must-revalidate',
|
||||||
|
);
|
||||||
|
}
|
||||||
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
assert(f.events.at(-1).key.endsWith('/latest.json'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.events.at(-1).key,
|
f.events.at(-1).key,
|
||||||
@@ -230,14 +241,20 @@ test('retry accepts byte-identical objects without replacing them', async (t) =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('different existing release is never overwritten', async (t) => {
|
for (const published of ['1.2.3', '1.2.4']) {
|
||||||
const f = fixture(t);
|
test(`published release stays protected when the feed is at ${published}`, async (t) => {
|
||||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
const f = fixture(t);
|
||||||
f.objects.set(key, Buffer.from('previous version content'));
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
f.objects.set(key, Buffer.from('previous version content'));
|
||||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
f.objects.set(
|
||||||
assert.equal(f.objects.size, 1);
|
`releases/macos/${f.metadata.target}/latest.json`,
|
||||||
});
|
Buffer.from(JSON.stringify({ version: published })),
|
||||||
|
);
|
||||||
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||||
|
assert.equal(f.objects.size, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('checksum mismatch aborts before contacting S3', async (t) => {
|
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -292,7 +309,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a partial upload can resume with the same files', async (t) => {
|
test('a partial upload can be retried with the same files', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const run = f.run;
|
const run = f.run;
|
||||||
f.run = (command, args) => {
|
f.run = (command, args) => {
|
||||||
@@ -311,6 +328,203 @@ test('a partial upload can resume with the same files', async (t) => {
|
|||||||
assert.equal(f.objects.size, 8);
|
assert.equal(f.objects.size, 8);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function rebuild(f) {
|
||||||
|
const updater = Buffer.from('rebuilt signed update');
|
||||||
|
const signing = signedFixture(updater);
|
||||||
|
f.publicKey = signing.publicKey;
|
||||||
|
f.metadata.notes = 'Fixed build of the same version';
|
||||||
|
for (const [name, contents] of [
|
||||||
|
[f.metadata.filename, Buffer.from('rebuilt disk image')],
|
||||||
|
[f.metadata.updaterFilename, updater],
|
||||||
|
]) {
|
||||||
|
writeFileSync(join(f.directory, name), contents);
|
||||||
|
writeFileSync(
|
||||||
|
join(f.directory, `${name}.sha256`),
|
||||||
|
`${createHash('sha256').update(contents).digest('hex')} ${name}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(f.directory, `${f.metadata.updaterFilename}.sig`),
|
||||||
|
signing.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const previous of [null, '1.0.0']) {
|
||||||
|
for (const interruptedFile of ['.sha256', '/latest.json']) {
|
||||||
|
test(`rebuild replaces unfinished ${interruptedFile} release with previous feed ${previous}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
const previousBytes =
|
||||||
|
previous === null
|
||||||
|
? undefined
|
||||||
|
: Buffer.from(JSON.stringify({ version: previous }));
|
||||||
|
if (previousBytes) {
|
||||||
|
f.objects.set(latestKey, previousBytes);
|
||||||
|
}
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (
|
||||||
|
args.includes('put-object') &&
|
||||||
|
args[args.indexOf('--key') + 1].endsWith(interruptedFile)
|
||||||
|
) {
|
||||||
|
throw new Error('upload interrupted');
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /upload interrupted/);
|
||||||
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||||
|
rebuild(f);
|
||||||
|
f.events.length = 0;
|
||||||
|
let promoted = false;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (!promoted) {
|
||||||
|
assert.deepEqual(f.objects.get(latestKey), previousBytes);
|
||||||
|
}
|
||||||
|
const result = run(command, args);
|
||||||
|
if (args.includes('put-object') && args.includes(latestKey)) {
|
||||||
|
promoted = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 8);
|
||||||
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(f.objects.get(latestKey)).notes,
|
||||||
|
f.metadata.notes,
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
f.metadata.filename,
|
||||||
|
`${f.metadata.filename}.sha256`,
|
||||||
|
f.metadata.updaterFilename,
|
||||||
|
`${f.metadata.updaterFilename}.sig`,
|
||||||
|
`${f.metadata.updaterFilename}.sha256`,
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(
|
||||||
|
f.objects.get(
|
||||||
|
`releases/macos/${f.metadata.target}/1.2.3/${name}`,
|
||||||
|
),
|
||||||
|
readFileSync(join(f.directory, name)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const published = [...f.objects.entries()];
|
||||||
|
rebuild(f);
|
||||||
|
await assert.rejects(publishMacos(f), /already been released/);
|
||||||
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('failure after promoting the feed still protects that version from rebuilding', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
f.fetchPublic = async (url, options) =>
|
||||||
|
url.endsWith('/latest.json')
|
||||||
|
? { ok: false, status: 403 }
|
||||||
|
: fetchPublic(url, options);
|
||||||
|
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||||
|
const published = [...f.objects.entries()];
|
||||||
|
rebuild(f);
|
||||||
|
await assert.rejects(publishMacos(f), /already been released/);
|
||||||
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a concurrent change to a partial artifact is not overwritten', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('--if-match')) {
|
||||||
|
assert(args.includes(key));
|
||||||
|
f.objects.set(key, 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(key).toString(), 'concurrent winner');
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [
|
||||||
|
'AccessDenied',
|
||||||
|
'network failure',
|
||||||
|
'invalid JSON',
|
||||||
|
'invalid version',
|
||||||
|
]) {
|
||||||
|
test(`cannot replace a partial artifact when the feed check has ${failure}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
f.objects.set(
|
||||||
|
latestKey,
|
||||||
|
Buffer.from(
|
||||||
|
failure === 'invalid version' ? '{"version":"invalid"}' : '{',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const originals = [...f.objects.entries()];
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (
|
||||||
|
args.includes('get-object') &&
|
||||||
|
args.includes(latestKey) &&
|
||||||
|
!failure.startsWith('invalid')
|
||||||
|
) {
|
||||||
|
throw new Error(failure);
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f));
|
||||||
|
assert.deepEqual([...f.objects.entries()], originals);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const listing of [
|
||||||
|
{ KeyCount: 0, IsTruncated: false },
|
||||||
|
{ KeyCount: 1, IsTruncated: false, Contents: [{ Key: 'latest.json' }] },
|
||||||
|
{},
|
||||||
|
{ KeyCount: 0, IsTruncated: true },
|
||||||
|
{ KeyCount: 0, IsTruncated: false, Contents: '' },
|
||||||
|
'AccessDenied',
|
||||||
|
]) {
|
||||||
|
test(`ambiguous feed read error needs a proven empty listing: ${JSON.stringify(listing)}`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
|
f.objects.set(key, Buffer.from('unfinished old build'));
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
if (args.includes('get-object') && args.includes(latestKey)) {
|
||||||
|
throw new Error(
|
||||||
|
"argument of type 'NoneType' is not a container or iterable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (args.includes('list-objects-v2')) {
|
||||||
|
assert.equal(args[args.indexOf('--prefix') + 1], latestKey);
|
||||||
|
if (listing === 'AccessDenied') {
|
||||||
|
throw new Error('AccessDenied');
|
||||||
|
}
|
||||||
|
return JSON.stringify(listing);
|
||||||
|
}
|
||||||
|
return run(command, args);
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
listing.KeyCount === 0 &&
|
||||||
|
listing.IsTruncated === false &&
|
||||||
|
!('Contents' in listing)
|
||||||
|
) {
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 8);
|
||||||
|
} else {
|
||||||
|
await assert.rejects(publishMacos(f), /NoneType|AccessDenied/);
|
||||||
|
assert.equal(f.objects.get(key).toString(), 'unfinished old build');
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||||
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
@@ -431,13 +645,13 @@ test('rejects a corrupt updater archive before publishing any object', async (t)
|
|||||||
assert.equal(f.objects.size, 0);
|
assert.equal(f.objects.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('atomically promotes a newer feed only after all immutable objects are public', async (t) => {
|
test('atomically promotes a newer feed only after all release files are public', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
const latestKey = `releases/macos/${f.metadata.target}/latest.json`;
|
||||||
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
f.objects.set(latestKey, Buffer.from(JSON.stringify({ version: '1.0.0' })));
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
assert.equal(JSON.parse(f.objects.get(latestKey)).version, '1.2.3');
|
||||||
const immutableVerified = f.events.findIndex(
|
const releaseVerified = f.events.findIndex(
|
||||||
(event) =>
|
(event) =>
|
||||||
event.operation === 'public-get' &&
|
event.operation === 'public-get' &&
|
||||||
event.key.endsWith('/update.json'),
|
event.key.endsWith('/update.json'),
|
||||||
@@ -445,7 +659,7 @@ test('atomically promotes a newer feed only after all immutable objects are publ
|
|||||||
const latestPut = f.events.findIndex(
|
const latestPut = f.events.findIndex(
|
||||||
(event) => event.operation === 'put-object' && event.key === latestKey,
|
(event) => event.operation === 'put-object' && event.key === latestKey,
|
||||||
);
|
);
|
||||||
assert(latestPut > immutableVerified);
|
assert(latestPut > releaseVerified);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const previous of [
|
for (const previous of [
|
||||||
@@ -496,12 +710,16 @@ for (const message of [
|
|||||||
assert.deepEqual([...f.objects.entries()], published);
|
assert.deepEqual([...f.objects.entries()], published);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`empty S3 error cannot bypass immutable content checks: ${message}`, async (t) => {
|
test(`empty S3 error cannot replace a published release: ${message}`, async (t) => {
|
||||||
const f = fixture(t, message);
|
const f = fixture(t, message);
|
||||||
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
|
||||||
f.objects.set(key, Buffer.from('different existing release'));
|
f.objects.set(key, Buffer.from('different existing release'));
|
||||||
|
f.objects.set(
|
||||||
|
`releases/macos/${f.metadata.target}/latest.json`,
|
||||||
|
Buffer.from(JSON.stringify({ version: '1.2.3' })),
|
||||||
|
);
|
||||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||||
assert.equal(f.objects.size, 1);
|
assert.equal(f.objects.size, 2);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.objects.get(key).toString(),
|
f.objects.get(key).toString(),
|
||||||
'different existing release',
|
'different existing release',
|
||||||
|
|||||||
Reference in New Issue
Block a user