Compare commits

..
Author SHA1 Message Date
Twyco 3d5ee223e8 Merge pull request 'KAN-65: Fix Hetzner feed ETags and resume published releases' (#161) from fix/KAN-65-hetzner-feed-etag into main
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 42s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
macOS release to Hetzner / Resume verified release publication (push) Skipped
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m0s
Reviewed-on: #161
2026-09-28 05:27:36 +00:00
Twyco 9dbb491dc9 fix(KAN-65): resume release publication with CI credentials
CI/CD / preconditions (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 11s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 0s
2026-09-28 07:27:05 +02:00
Twyco 40b66bf7ce 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
2026-09-28 07:24:12 +02:00
Twyco dc9f400f34 fix(KAN-65): recover verified S3 writes from empty error responses
CI/CD / test_and_check_app (push) Successful in 6m31s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 4m57s
CI/CD / preconditions (push) Successful in 2s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m35s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
2026-09-27 23:05:35 +02:00
9 changed files with 460 additions and 33 deletions
+50
View File
@@ -12,6 +12,14 @@ on:
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main" description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
required: true required: true
type: string type: string
mode:
description: "Build a release or resume publishing existing verified artifacts"
required: true
default: build
type: choice
options:
- build
- resume
permissions: permissions:
contents: read contents: read
@@ -49,8 +57,10 @@ jobs:
env: env:
REQUESTED_TAG: ${{ inputs.tag }} REQUESTED_TAG: ${{ inputs.tag }}
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }} MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
RELEASE_MODE: ${{ inputs.mode || 'build' }}
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
[[ "${RELEASE_MODE:-build}" == build || "${RELEASE_MODE:-build}" == resume ]] || { echo 'Invalid release mode.' >&2; exit 1; }
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; } [[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
tag="$REQUESTED_TAG" tag="$REQUESTED_TAG"
@@ -80,6 +90,7 @@ jobs:
release: release:
name: Sign, notarize, verify and publish macOS name: Sign, notarize, verify and publish macOS
needs: prepare needs: prepare
if: ${{ inputs.mode != 'resume' }}
# Gitea resolves runner labels before outputs from prepare are available. # Gitea resolves runner labels before outputs from prepare are available.
runs-on: ${{ vars.MACOS_RUNNER }} runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 120 timeout-minutes: 120
@@ -224,3 +235,42 @@ jobs:
finally: finally:
shutil.rmtree(directory) shutil.rmtree(directory)
PY PY
resume:
name: Resume verified release publication
needs: prepare
if: ${{ github.event_name == 'workflow_dispatch' && inputs.mode == 'resume' }}
runs-on: ${{ vars.MACOS_RUNNER }}
timeout-minutes: 15
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_METADATA_DIRECTORY: release-source/native
steps:
- name: Check out current publishing tools
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Check out validated release metadata
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ needs.prepare.outputs.commit }}
path: release-source
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
- name: Verify existing artifacts and resume publication
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_EC2_METADATA_DISABLED: "true"
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
run: |
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
node native/publish-macos.mjs resume
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "kanbangit-native", "name": "kanbangit-native",
"version": "0.1.3", "version": "0.1.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -24,5 +24,5 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.11.4" "@tauri-apps/cli": "2.11.4"
}, },
"releaseNotes": "About KanbanGit and Software updates now show the installed app build number correctly." "releaseNotes": "About KanbanGit and Software updates show the installed app build number correctly. Release publishing now handles Hetzner responses with empty error messages when updating the feed."
} }
+99 -20
View File
@@ -63,6 +63,31 @@ export function storageConfiguration(env = process.env) {
const sha256 = (data) => createHash('sha256').update(data).digest('hex'); 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.
return /\(PreconditionFailed\)|\(412\)|argument of type 'NoneType' is not (?:a container or )?iterable/.test(
error.message,
);
}
export async function publishMacos({ export async function publishMacos({
env = process.env, env = process.env,
directory, directory,
@@ -116,13 +141,21 @@ export async function publishMacos({
's3api', 's3api',
]; ];
function s3(operation, args) { function s3(operation, args) {
return run('aws', [ try {
...common, return run('aws', [
operation, ...common,
'--bucket', operation,
storage.bucket, '--bucket',
...args, storage.bucket,
]); ...args,
]);
} catch (error) {
const keyIndex = args.indexOf('--key');
const key = keyIndex >= 0 ? ` ${args[keyIndex + 1]}` : '';
throw new Error(`S3 ${operation}${key}: ${error.message}`, {
cause: error,
});
}
} }
const versioningOutput = s3('get-bucket-versioning', [ const versioningOutput = s3('get-bucket-versioning', [
'--output', '--output',
@@ -213,7 +246,7 @@ export async function publishMacos({
`sha256=${sha256(contents)}`, `sha256=${sha256(contents)}`,
]); ]);
} catch (error) { } catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { if (!requiresExistingObjectCheck(error)) {
throw error; throw error;
} }
const existing = join(staging, `existing-${name}`); const existing = join(staging, `existing-${name}`);
@@ -301,7 +334,7 @@ export async function publishMacos({
try { try {
publishLatest(['--if-none-match', '*']); publishLatest(['--if-none-match', '*']);
} catch (error) { } catch (error) {
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) { if (!requiresExistingObjectCheck(error)) {
throw error; throw error;
} }
const previousFile = join(staging, 'previous-latest.json'); const previousFile = join(staging, 'previous-latest.json');
@@ -330,12 +363,7 @@ export async function publishMacos({
); );
} }
} else { } else {
if (!response.ETag) { publishLatest(['--if-match', conditionalEtag(response.ETag)]);
throw new Error(
'Cannot atomically update the feed without its ETag.',
);
}
publishLatest(['--if-match', response.ETag]);
} }
} }
await verifyPublic( await verifyPublic(
@@ -349,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 ( if (
process.argv[1] && process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href import.meta.url === pathToFileURL(resolve(process.argv[1])).href
@@ -357,12 +428,20 @@ if (
if (process.argv[2] === 'config') { if (process.argv[2] === 'config') {
console.log(JSON.stringify(storageConfiguration())); console.log(JSON.stringify(storageConfiguration()));
} else if ( } else if (
process.argv[2] === 'upload' && process.argv[2] === 'resume' ||
process.env.RELEASE_DIRECTORY (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, directory: process.env.RELEASE_DIRECTORY,
metadata: releaseMetadata(), metadata: releaseMetadata(
process.env,
process.argv[2] === 'resume' &&
process.env.RELEASE_METADATA_DIRECTORY
? resolve(process.env.RELEASE_METADATA_DIRECTORY)
: undefined,
),
}); });
console.log(`Verified public release: ${url}`); console.log(`Verified public release: ${url}`);
if (process.env.GITHUB_STEP_SUMMARY) { if (process.env.GITHUB_STEP_SUMMARY) {
@@ -373,7 +452,7 @@ if (
} }
} else { } else {
throw new Error( throw new Error(
'Use config or upload; upload requires RELEASE_DIRECTORY.', 'Use config, upload or resume; upload requires RELEASE_DIRECTORY.',
); );
} }
} catch (error) { } catch (error) {
+1 -1
View File
@@ -1770,7 +1770,7 @@ dependencies = [
[[package]] [[package]]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"semver", "semver",
"serde", "serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kanbangit-native" name = "kanbangit-native"
version = "0.1.3" version = "0.1.4"
description = "KanbanGit native application" description = "KanbanGit native application"
edition = "2021" edition = "2021"
publish = false publish = false
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "KanbanGit", "productName": "KanbanGit",
"version": "0.1.3", "version": "0.1.4",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
@@ -25,7 +25,7 @@
], ],
"macOS": { "macOS": {
"minimumSystemVersion": "14.0", "minimumSystemVersion": "14.0",
"bundleVersion": "3", "bundleVersion": "4",
"dmg": { "dmg": {
"windowSize": { "windowSize": {
"width": 660, "width": 660,
+117
View File
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import process from 'node:process';
import test from 'node:test';
import { conditionalEtag } from '../publish-macos.mjs';
test(
'Hetzner conditional writes accept a bare ETag and reject a stale ETag',
{
skip: process.env.KANBANGIT_S3_INTEGRATION !== '1',
},
() => {
const bucket = process.env.AWS_BUCKET;
assert(bucket, 'Set AWS_BUCKET to a test bucket');
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-s3-etag-'));
const key = `kanbangit-integration-tests/${randomUUID()}.txt`;
const file = join(directory, 'object.txt');
const downloaded = join(directory, 'downloaded.txt');
const run = (operation, args) => {
const result = spawnSync(
'aws',
[
'--debug',
'--endpoint-url',
process.env.AWS_ENDPOINT,
'--region',
process.env.AWS_DEFAULT_REGION,
'--no-cli-pager',
's3api',
operation,
'--bucket',
bucket,
'--key',
key,
...args,
],
{ encoding: 'utf8', timeout: 30000 },
);
// Never expose debug headers: they contain the signed Authorization header.
return {
status: result.status,
stdout: result.stdout,
codes: [
...(result.stderr || '').matchAll(/<Code>([^<]*)<\/Code>/g),
].map((match) => match[1]),
};
};
let created = false;
try {
writeFileSync(file, 'first');
const initial = run('put-object', [
'--body',
file,
'--if-none-match',
'*',
]);
assert.equal(initial.status, 0, `Create failed: ${initial.codes}`);
created = true;
const read = run('get-object', ['--output', 'json', downloaded]);
assert.equal(read.status, 0, `Read failed: ${read.codes}`);
const etag = JSON.parse(read.stdout).ETag;
const quoted = run('put-object', [
'--body',
file,
'--if-match',
etag,
]);
console.log(
`Quoted ETag result: exit ${quoted.status}, S3 ${quoted.codes.join(', ')}`,
);
const bare = conditionalEtag(etag);
writeFileSync(file, 'second');
const updated = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.equal(
updated.status,
0,
`Bare ETag replacement failed: ${updated.codes}`,
);
writeFileSync(file, 'stale writer');
const stale = run('put-object', [
'--body',
file,
'--if-match',
bare,
]);
assert.notEqual(stale.status, 0);
assert(
stale.codes.includes('PreconditionFailed'),
`Unexpected stale error: ${stale.codes}`,
);
assert.equal(run('get-object', [downloaded]).status, 0);
assert.equal(readFileSync(downloaded, 'utf8'), 'second');
} finally {
try {
if (created) {
const cleanup = run('delete-object', []);
assert.equal(
cleanup.status,
0,
`Cleanup failed: ${cleanup.codes}`,
);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
},
);
+33
View File
@@ -113,6 +113,38 @@ test('manual release on main accepts an existing tag without moving it', (t) =>
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit); assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
}); });
test('resume uses current tools, validated tagged metadata and release credentials without rebuilding', (t) => {
const f = fixture(t);
const result = f.run({
GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_REF: 'refs/heads/main',
REQUESTED_TAG: 'macos-v1.2.3',
RELEASE_MODE: 'resume',
});
assert.equal(result.status, 0, result.stderr);
const resume = workflow.split('\n resume:\n')[1];
assert.match(resume, /needs: prepare/);
assert.match(
resume,
/github.event_name == 'workflow_dispatch' && inputs.mode == 'resume'/,
);
assert.match(resume, /ref: \$\{\{ github.sha \}\}/);
assert.match(resume, /ref: \$\{\{ needs.prepare.outputs.commit \}\}/);
assert.match(resume, /RELEASE_METADATA_DIRECTORY: release-source\/native/);
assert.match(resume, /path: release-source/);
assert.match(resume, /secrets.S3_ACCESS_KEY_ID/);
assert.match(resume, /secrets.S3_SECRET_ACCESS_KEY/);
assert.match(resume, /node native\/publish-macos.mjs resume/);
assert.doesNotMatch(
resume,
/tauri-action|APPLE_PASSWORD|APPLE_CERTIFICATE|TAURI_SIGNING_PRIVATE_KEY/,
);
assert.match(
workflow.split('\n release:\n')[1],
/if: \$\{\{ inputs.mode != 'resume' \}\}/,
);
});
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]
@@ -127,6 +159,7 @@ test('release DMG packaging disables Finder automation in the Tauri action envir
}); });
for (const [name, overrides] of [ for (const [name, overrides] of [
['invalid release mode', { RELEASE_MODE: 'overwrite' }],
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }], ['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }], ['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
[ [
+155 -7
View File
@@ -5,10 +5,72 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { publishMacos, storageConfiguration } from '../publish-macos.mjs'; import {
conditionalEtag,
publishMacos,
resumeMacos,
storageConfiguration,
} from '../publish-macos.mjs';
import { signedFixture } from './updater-fixture.mjs'; import { signedFixture } from './updater-fixture.mjs';
function fixture(t) { test('conditional writes use bare S3 ETags without weakening the condition', () => {
const etag = 'b88312b96169f4b2098a4cc43b0abb6a';
for (const value of [etag, `"${etag}"`, `${etag}-2`, `"${etag}-2"`]) {
assert.equal(conditionalEtag(value), value.replaceAll('"', ''));
}
for (const invalid of [
undefined,
null,
'',
'*',
'"*"',
`W/"${etag}"`,
`"${etag}`,
`${etag}"`,
`${etag},${etag}`,
`${etag}\r\n`,
{},
42,
]) {
assert.throws(() => conditionalEtag(invalid), /valid S3 ETag/);
}
});
test('resume verifies already published artifacts and promotes the feed without rebuilding', async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const originals = new Map(f.objects);
await resumeMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, f.metadata.version);
for (const [name, contents] of originals) {
if (name !== key) {
assert.deepEqual(f.objects.get(name), contents);
}
}
});
for (const failure of ['missing', 'tampered']) {
test(`resume rejects ${failure} artifacts before any S3 writes`, async (t) => {
const f = fixture(t);
await publishMacos(f);
const key = `releases/macos/${f.metadata.target}/${f.metadata.version}/${f.metadata.updaterFilename}`;
if (failure === 'missing') {
f.objects.delete(key);
} else {
f.objects.set(key, Buffer.from('tampered'));
}
f.events.length = 0;
await assert.rejects(resumeMacos(f), /incomplete release|signature/);
assert(!f.events.some((event) => event.operation === 'put-object'));
});
}
function fixture(
t,
conflictMessage = 'aws failed: (PreconditionFailed) object exists',
) {
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-')); const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
t.after(() => rmSync(directory, { recursive: true, force: true })); t.after(() => rmSync(directory, { recursive: true, force: true }));
const metadata = { const metadata = {
@@ -60,18 +122,21 @@ function fixture(t) {
} }
if (objects.has(key)) { if (objects.has(key)) {
if (!args.includes('--if-match')) { if (!args.includes('--if-match')) {
throw new Error( throw new Error(conflictMessage);
'aws failed: (PreconditionFailed) object exists',
);
} }
assert.equal(value('--if-match'), '"previous-etag"'); assert.equal(
value('--if-match'),
'b88312b96169f4b2098a4cc43b0abb6a',
);
} }
objects.set(key, readFileSync(value('--body'))); objects.set(key, readFileSync(value('--body')));
return '{}'; return '{}';
} }
if (operation === 'get-object') { if (operation === 'get-object') {
writeFileSync(args.at(-1), objects.get(key)); writeFileSync(args.at(-1), objects.get(key));
return JSON.stringify({ ETag: '"previous-etag"' }); return JSON.stringify({
ETag: '"b88312b96169f4b2098a4cc43b0abb6a"',
});
} }
throw new Error(`Unexpected S3 operation: ${operation}`); throw new Error(`Unexpected S3 operation: ${operation}`);
}; };
@@ -427,3 +492,86 @@ test('concurrent feed replacement fails without overwriting the winner', async (
await assert.rejects(publishMacos(f), /concurrent publication/); await assert.rejects(publishMacos(f), /concurrent publication/);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0'); assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
}); });
for (const message of [
"aws failed (exit 255). aws: [ERROR]: argument of type 'NoneType' is not a container or iterable",
"aws failed (exit 255). argument of type 'NoneType' is not iterable",
]) {
test(`empty S3 error message permits verified promotion and identical retry: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
await publishMacos(f);
assert.equal(JSON.parse(f.objects.get(key)).version, '1.2.3');
const published = [...f.objects.entries()];
await publishMacos(f);
assert.deepEqual([...f.objects.entries()], published);
});
test(`empty S3 error cannot bypass immutable content checks: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/1.2.3/${f.metadata.filename}`;
f.objects.set(key, Buffer.from('different existing release'));
await assert.rejects(publishMacos(f), /bump the app version/);
assert.equal(f.objects.size, 1);
assert.equal(
f.objects.get(key).toString(),
'different existing release',
);
});
for (const previous of [
{ version: '2.0.0' },
{ version: '1.2.3', notes: 'different' },
]) {
test(`empty S3 error cannot bypass feed conflict checks ${previous.version}: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
const original = Buffer.from(JSON.stringify(previous));
f.objects.set(key, original);
await assert.rejects(publishMacos(f), /downgrade|differs/);
assert.deepEqual(f.objects.get(key), original);
});
}
test(`empty S3 error aborts when existing contents cannot be read: ${message}`, async (t) => {
const f = fixture(t, message);
const run = f.run;
f.run = (command, args) => {
if (args.includes('put-object')) {
throw new Error(message);
}
if (args.includes('get-object')) {
throw new Error('AccessDenied');
}
return run(command, args);
};
await assert.rejects(publishMacos(f), /S3 get-object .*AccessDenied/);
assert.equal(f.objects.size, 0);
});
test(`empty S3 error during conditional replacement preserves a concurrent winner: ${message}`, async (t) => {
const f = fixture(t, message);
const key = `releases/macos/${f.metadata.target}/latest.json`;
f.objects.set(key, Buffer.from(JSON.stringify({ version: '1.0.0' })));
const run = f.run;
let attempts = 0;
f.run = (command, args) => {
if (args.includes('--if-match')) {
attempts++;
f.objects.set(
key,
Buffer.from(JSON.stringify({ version: '2.0.0' })),
);
throw new Error(message);
}
return run(command, args);
};
await assert.rejects(
publishMacos(f),
/S3 put-object .*latest.json: .*NoneType/,
);
assert.equal(attempts, 1);
assert.equal(JSON.parse(f.objects.get(key)).version, '2.0.0');
});
}