Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e5c316b8f | ||
|
|
4dd73ec583 | ||
|
|
d8ca4975e5 | ||
|
|
f2e1bd6a0d | ||
|
|
b0cc9dad14 | ||
|
|
52304c7eb2 | ||
|
|
b4cd6a5808 | ||
|
|
f52cffeaeb | ||
|
|
1c620654a3 |
@@ -2,6 +2,8 @@ name: macOS release to Hetzner
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# Gitea enforces the configured macos-v* protection when tags are pushed.
|
||||||
|
# Reading /tag_protections requires admin rights unavailable to job tokens.
|
||||||
tags:
|
tags:
|
||||||
- "macos-v*"
|
- "macos-v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -35,7 +37,6 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
commit: ${{ steps.ref.outputs.commit }}
|
commit: ${{ steps.ref.outputs.commit }}
|
||||||
tag: ${{ steps.ref.outputs.tag }}
|
tag: ${{ steps.ref.outputs.tag }}
|
||||||
runner: ${{ steps.ref.outputs.runner }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
@@ -62,31 +63,13 @@ jobs:
|
|||||||
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
commit="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
|
||||||
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
|
git merge-base --is-ancestor "$commit" origin/main || { echo 'Release tag must belong to main.' >&2; exit 1; }
|
||||||
git checkout --detach "$commit"
|
git checkout --detach "$commit"
|
||||||
printf 'commit=%s\ntag=%s\nrunner=%s\n' "$commit" "$tag" "$MACOS_RUNNER" >> "$GITHUB_OUTPUT"
|
printf 'commit=%s\ntag=%s\n' "$commit" "$tag" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Require protected macOS release tags
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
curl --fail --silent --show-error \
|
|
||||||
--header "Authorization: Bearer $GITEA_TOKEN" \
|
|
||||||
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/tag_protections" \
|
|
||||||
--output "$RUNNER_TEMP/macos-tag-protections.json"
|
|
||||||
node --input-type=module - <<'JS'
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
const rules = JSON.parse(readFileSync(`${process.env.RUNNER_TEMP}/macos-tag-protections.json`, 'utf8'));
|
|
||||||
if (!rules.some((rule) => rule.name_pattern === 'macos-v*')) {
|
|
||||||
throw new Error('Configure a protected macos-v* tag rule in Gitea before publishing.');
|
|
||||||
}
|
|
||||||
JS
|
|
||||||
|
|
||||||
- name: Validate app versions and Hetzner configuration
|
- name: Validate app versions and Hetzner configuration
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
||||||
@@ -97,7 +80,8 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Sign, notarize, verify and publish macOS
|
name: Sign, notarize, verify and publish macOS
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ${{ needs.prepare.outputs.runner }}
|
# Gitea resolves runner labels before outputs from prepare are available.
|
||||||
|
runs-on: ${{ vars.MACOS_RUNNER }}
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
env:
|
env:
|
||||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kanbangit-native",
|
"name": "kanbangit-native",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export async function publishMacos({
|
|||||||
metadata,
|
metadata,
|
||||||
run = execute,
|
run = execute,
|
||||||
fetchPublic = fetch,
|
fetchPublic = fetch,
|
||||||
|
wait = setTimeout,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const storage = storageConfiguration(env);
|
const storage = storageConfiguration(env);
|
||||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||||
@@ -94,7 +96,26 @@ export async function publishMacos({
|
|||||||
...args,
|
...args,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const versioning = JSON.parse(s3('get-bucket-versioning', []));
|
const versioningOutput = s3('get-bucket-versioning', [
|
||||||
|
'--output',
|
||||||
|
'json',
|
||||||
|
]).trim();
|
||||||
|
let versioning;
|
||||||
|
try {
|
||||||
|
// AWS CLI prints nothing for a successful empty VersioningConfiguration.
|
||||||
|
versioning = JSON.parse(versioningOutput || '{}');
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON from S3 bucket versioning check.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!versioning ||
|
||||||
|
typeof versioning !== 'object' ||
|
||||||
|
Array.isArray(versioning)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Invalid S3 bucket versioning response: expected an object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (versioning.Status) {
|
if (versioning.Status) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
||||||
@@ -104,16 +125,43 @@ export async function publishMacos({
|
|||||||
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}`;
|
||||||
async function verifyPublic(name, contents) {
|
async function verifyPublic(name, contents) {
|
||||||
const response = await fetchPublic(`${publicPrefix}/${name}`, {
|
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetchPublic(`${publicPrefix}/${name}`, {
|
||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
signal: AbortSignal.timeout(60000),
|
signal: AbortSignal.timeout(60000),
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Public download request failed for ${name}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await wait(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
if (
|
if (
|
||||||
!response.ok ||
|
|
||||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||||
sha256(contents)
|
sha256(contents)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Public download verification failed for ${name}.`);
|
throw new Error(
|
||||||
|
`Public download verification failed for ${name}: SHA-256 mismatch.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await response.body?.cancel();
|
||||||
|
const retryable = [403, 404, 408, 429, 500, 502, 503, 504].includes(
|
||||||
|
response.status,
|
||||||
|
);
|
||||||
|
if (!retryable || attempt === 5) {
|
||||||
|
throw new Error(
|
||||||
|
`Public download verification failed for ${name}: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await wait(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function putImmutable(name, contents, contentType) {
|
function putImmutable(name, contents, contentType) {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1673,7 +1673,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kanbangit-native"
|
name = "kanbangit-native"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "KanbanGit native application"
|
description = "KanbanGit native application"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|||||||
@@ -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.0",
|
"version": "0.1.1",
|
||||||
"identifier": "de.kanbangit.desktop",
|
"identifier": "de.kanbangit.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
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';
|
||||||
|
|
||||||
|
const workflow = readFileSync(
|
||||||
|
new URL('../../.github/workflows/macos-release.yml', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const selectRef = workflow
|
||||||
|
.split(
|
||||||
|
' - name: Select an existing version tag contained in main\n',
|
||||||
|
)[1]
|
||||||
|
.split('\n - name:')[0]
|
||||||
|
.split(' run: |\n')[1]
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^ {10}/, ''))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-release-ref-'));
|
||||||
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const remote = join(directory, 'remote');
|
||||||
|
const checkout = join(directory, 'checkout');
|
||||||
|
mkdirSync(remote);
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: 'Release test',
|
||||||
|
GIT_COMMITTER_NAME: 'Release test',
|
||||||
|
GIT_AUTHOR_EMAIL: 'release@example.invalid',
|
||||||
|
GIT_COMMITTER_EMAIL: 'release@example.invalid',
|
||||||
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
|
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||||
|
};
|
||||||
|
const git = (cwd, ...args) => {
|
||||||
|
const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
return result.stdout.trim();
|
||||||
|
};
|
||||||
|
git(remote, 'init', '-b', 'main');
|
||||||
|
writeFileSync(join(remote, 'app.txt'), 'release');
|
||||||
|
git(remote, 'add', 'app.txt');
|
||||||
|
git(remote, '-c', 'commit.gpgsign=false', 'commit', '-m', 'Release');
|
||||||
|
const commit = git(remote, 'rev-parse', 'HEAD');
|
||||||
|
git(remote, 'tag', 'macos-v1.2.3');
|
||||||
|
git(directory, 'clone', remote, checkout);
|
||||||
|
const output = join(directory, 'outputs');
|
||||||
|
const run = (overrides = {}) => {
|
||||||
|
writeFileSync(output, '');
|
||||||
|
const result = spawnSync(
|
||||||
|
'bash',
|
||||||
|
['--noprofile', '--norc', '-c', selectRef],
|
||||||
|
{
|
||||||
|
cwd: checkout,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...env,
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REF: 'refs/tags/macos-v1.2.3',
|
||||||
|
REQUESTED_TAG: '',
|
||||||
|
MACOS_RUNNER: 'macos-local',
|
||||||
|
GITHUB_OUTPUT: output,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { ...result, output: readFileSync(output, 'utf8') };
|
||||||
|
};
|
||||||
|
return { run, git, checkout, commit };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release selection succeeds using Git alone without an administrative API token', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const result = f.run({
|
||||||
|
GITEA_TOKEN: '',
|
||||||
|
GITHUB_TOKEN: '',
|
||||||
|
GITHUB_API_URL: 'http://127.0.0.1:1',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.output, `commit=${f.commit}\ntag=macos-v1.2.3\n`);
|
||||||
|
assert.equal(f.git(f.checkout, 'rev-parse', 'HEAD'), f.commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS scheduling uses a repository variable while release steps remain gated by preparation', () => {
|
||||||
|
const release = workflow.split('\n release:\n')[1];
|
||||||
|
const runner = release.match(/^ {4}runs-on: (.+)$/m)?.[1];
|
||||||
|
assert.equal(runner, '${{ vars.MACOS_RUNNER }}');
|
||||||
|
assert.match(release, /^ {4}needs: prepare$/m);
|
||||||
|
assert.match(release, /ref: \$\{\{ needs\.prepare\.outputs\.commit \}\}/);
|
||||||
|
assert.match(
|
||||||
|
release,
|
||||||
|
/RELEASE_TAG: \$\{\{ needs\.prepare\.outputs\.tag \}\}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual release on main accepts an existing tag without moving it', (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',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(f.git(f.checkout, 'rev-parse', 'macos-v1.2.3'), f.commit);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, overrides] of [
|
||||||
|
['web release tag', { GITHUB_REF: 'refs/tags/v1.2.3' }],
|
||||||
|
['missing tag', { GITHUB_REF: 'refs/tags/macos-v9.9.9' }],
|
||||||
|
[
|
||||||
|
'shell characters in tag',
|
||||||
|
{ GITHUB_REF: 'refs/tags/macos-v1.2.3;exit 0' },
|
||||||
|
],
|
||||||
|
['missing runner label', { MACOS_RUNNER: '' }],
|
||||||
|
[
|
||||||
|
'output injection in runner label',
|
||||||
|
{ MACOS_RUNNER: 'macos-local\ncommit=other' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'manual release outside main',
|
||||||
|
{
|
||||||
|
GITHUB_EVENT_NAME: 'workflow_dispatch',
|
||||||
|
GITHUB_REF: 'refs/heads/feature',
|
||||||
|
REQUESTED_TAG: 'macos-v1.2.3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
test(`release selection rejects ${name} before producing job outputs`, (t) => {
|
||||||
|
const result = fixture(t).run(overrides);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.equal(result.output, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('release selection rejects a tag whose commit is outside main', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.git(f.checkout, 'switch', '-c', 'unmerged');
|
||||||
|
f.git(
|
||||||
|
f.checkout,
|
||||||
|
'-c',
|
||||||
|
'commit.gpgsign=false',
|
||||||
|
'commit',
|
||||||
|
'--allow-empty',
|
||||||
|
'-m',
|
||||||
|
'Unmerged',
|
||||||
|
);
|
||||||
|
f.git(f.checkout, 'tag', 'macos-v1.2.4');
|
||||||
|
const result = f.run({ GITHUB_REF: 'refs/tags/macos-v1.2.4' });
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /Release tag must belong to main/);
|
||||||
|
assert.equal(result.output, '');
|
||||||
|
});
|
||||||
@@ -38,7 +38,8 @@ function fixture(t) {
|
|||||||
const key = value('--key');
|
const key = value('--key');
|
||||||
events.push({ operation, key });
|
events.push({ operation, key });
|
||||||
if (operation === 'get-bucket-versioning') {
|
if (operation === 'get-bucket-versioning') {
|
||||||
return '{}';
|
assert.equal(value('--output'), 'json');
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
if (operation === 'put-object') {
|
if (operation === 'put-object') {
|
||||||
assert.equal(value('--if-none-match'), '*');
|
assert.equal(value('--if-none-match'), '*');
|
||||||
@@ -69,7 +70,16 @@ function fixture(t) {
|
|||||||
arrayBuffer: async () => objects.get(key),
|
arrayBuffer: async () => objects.get(key),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return { directory, metadata, env, objects, events, run, fetchPublic };
|
return {
|
||||||
|
directory,
|
||||||
|
metadata,
|
||||||
|
env,
|
||||||
|
objects,
|
||||||
|
events,
|
||||||
|
run,
|
||||||
|
fetchPublic,
|
||||||
|
wait: async () => {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
||||||
@@ -103,6 +113,33 @@ test('uploads immutable files and writes completion record after public verifica
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const response of ['', ' \n\t', '{}']) {
|
||||||
|
test(`unversioned bucket response ${JSON.stringify(response)} permits a verified upload`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const run = f.run;
|
||||||
|
f.run = (command, args) => {
|
||||||
|
const result = run(command, args);
|
||||||
|
return args.includes('get-bucket-versioning') ? response : result;
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
assert.equal(f.events.at(-1).operation, 'public-get');
|
||||||
|
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const response of ['{', 'null', '[]', '"Enabled"', 'false']) {
|
||||||
|
test(`invalid versioning response ${response} aborts before any upload`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.run = (_command, args) => {
|
||||||
|
assert(args.includes('get-bucket-versioning'));
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /Invalid .*versioning/);
|
||||||
|
assert.equal(f.objects.size, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
await publishMacos(f);
|
await publishMacos(f);
|
||||||
@@ -166,6 +203,7 @@ for (const failure of ['private', 'wrong-content', 'network']) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: failure !== 'private',
|
ok: failure !== 'private',
|
||||||
|
status: failure === 'private' ? 403 : 200,
|
||||||
arrayBuffer: async () => Buffer.from('wrong'),
|
arrayBuffer: async () => Buffer.from('wrong'),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -195,6 +233,77 @@ test('a partial upload can resume with the same files', async (t) => {
|
|||||||
assert.equal(f.objects.size, 3);
|
assert.equal(f.objects.size, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const status of [403, 404, 408, 429, 500, 502, 503, 504, 'network']) {
|
||||||
|
test(`temporary public download failure ${status} recovers before completion`, async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
let attempts = 0;
|
||||||
|
let cancelled = 0;
|
||||||
|
const waits = [];
|
||||||
|
f.wait = async (ms) => waits.push(ms);
|
||||||
|
f.fetchPublic = async (...args) => {
|
||||||
|
if (++attempts <= 2) {
|
||||||
|
if (status === 'network') {
|
||||||
|
throw new Error('temporary network failure');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
body: { cancel: async () => cancelled++ },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fetchPublic(...args);
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.deepEqual(waits, [2000, 2000]);
|
||||||
|
assert.equal(cancelled, status === 'network' ? 0 : 2);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('persistent public access denial stops after five attempts with HTTP status', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
let attempts = 0;
|
||||||
|
const waits = [];
|
||||||
|
f.wait = async (ms) => waits.push(ms);
|
||||||
|
f.fetchPublic = async () => {
|
||||||
|
attempts++;
|
||||||
|
return { ok: false, status: 403 };
|
||||||
|
};
|
||||||
|
await assert.rejects(publishMacos(f), /HTTP 403/);
|
||||||
|
assert.equal(attempts, 5);
|
||||||
|
assert.equal(waits.length, 4);
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incorrect public bytes fail immediately without retry or completion record', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.wait = async () => assert.fail('Do not retry mismatched content');
|
||||||
|
f.fetchPublic = async () => ({
|
||||||
|
ok: true,
|
||||||
|
arrayBuffer: async () => Buffer.from('incorrect'),
|
||||||
|
});
|
||||||
|
await assert.rejects(publishMacos(f), /SHA-256 mismatch/);
|
||||||
|
assert.equal(f.objects.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('checksum access retries finish before publishing the completion record', async (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const fetchPublic = f.fetchPublic;
|
||||||
|
let failures = 0;
|
||||||
|
f.fetchPublic = async (url, options) => {
|
||||||
|
if (url.endsWith('.sha256') && ++failures <= 2) {
|
||||||
|
assert.equal(f.objects.size, 2);
|
||||||
|
return { ok: false, status: 403 };
|
||||||
|
}
|
||||||
|
return fetchPublic(url, options);
|
||||||
|
};
|
||||||
|
await publishMacos(f);
|
||||||
|
assert.equal(failures, 3);
|
||||||
|
assert.equal(f.objects.size, 3);
|
||||||
|
});
|
||||||
|
|
||||||
for (const overrides of [
|
for (const overrides of [
|
||||||
{ S3_REGION: '' },
|
{ S3_REGION: '' },
|
||||||
{ S3_REGION: 'eu-central-1' },
|
{ S3_REGION: 'eu-central-1' },
|
||||||
|
|||||||
Reference in New Issue
Block a user