Compare commits

...
9 Commits
Author SHA1 Message Date
Twyco 3e5c316b8f Merge pull request 'KAN-58: Retry public S3 downloads and prepare macOS 0.1.1' (#157) from fix/KAN-58-retry-public-downloads into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 22s
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
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Successful in 2m29s
Reviewed-on: #157
2026-09-27 14:40:36 +00:00
Twyco 4dd73ec583 fix(KAN-58): retry transient public downloads for macOS 0.1.1
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m4s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m28s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 2m15s
CI/CD / preconditions (pull_request) Successful in 2s
2026-09-27 16:37:16 +02:00
Twyco d8ca4975e5 Merge pull request 'KAN-58: Handle empty S3 bucket versioning response' (#156) from fix/KAN-58-empty-s3-versioning into main
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m24s
CI/CD / preconditions (push) Successful in 1s
Native JavaScript checks / Native launcher and release tests (push) Successful in 54s
CI/CD / test_and_check_app (push) Successful in 6m26s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m17s
CI/CD / Publish immutable image (push) Successful in 1m23s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 20s
Reviewed-on: #156
2026-09-27 10:06:22 +00:00
Twyco f2e1bd6a0d fix(KAN-58): accept empty unversioned bucket response
CI/CD / test_and_check_app (pull_request) Successful in 6m34s
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 55s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 1m23s
2026-09-27 12:05:11 +02:00
Twyco b0cc9dad14 Merge pull request 'KAN-58: Fix macOS release runner selection' (#155) from fix/KAN-58-macos-runner-selection into main
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / preconditions (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS checks / Native Rust tests and unsigned build (push) Canceled after 6s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 21s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Failing after 3m11s
Reviewed-on: #155
2026-09-27 09:30:31 +00:00
Twyco 52304c7eb2 fix(KAN-58): select macOS runner from repository variable
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 36s
Native JavaScript checks / Native launcher and release tests (pull_request) Canceled after 42s
CI/CD / preconditions (pull_request) Successful in 1s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 14s
2026-09-27 11:30:18 +02:00
Twyco b4cd6a5808 Merge pull request 'KAN-58: Fix macOS release token permissions' (#154) from fix/KAN-58-release-token-permissions into main
macOS checks / Native Rust tests and unsigned build (push) Canceled after 1m17s
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
Native JavaScript checks / Native launcher and release tests (push) Canceled after 0s
macOS release to Hetzner / Validate release ref and configuration (push) Successful in 44s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Canceled after 0s
Reviewed-on: #154
2026-09-27 09:17:12 +00:00
Twyco f52cffeaeb fix(KAN-58): avoid admin API in macOS release workflow
CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 58s
macOS checks / Native Rust tests and unsigned build (pull_request) Successful in 3m22s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 4m18s
2026-09-27 11:13:13 +02:00
Twyco 1c620654a3 Merge pull request 'KAN-58: macOS Actions release to Hetzner Object Storage' (#153) from feature/KAN-58-macos-release-pipeline into main
CI/CD / preconditions (push) Successful in 2s
Native JavaScript checks / Native launcher and release tests (push) Successful in 58s
macOS checks / Native Rust tests and unsigned build (push) Successful in 1m27s
CI/CD / test_and_check_app (push) Successful in 6m34s
CI/CD / Publish immutable image (push) Successful in 1m18s
macOS release to Hetzner / Validate release ref and configuration (push) Failing after 23s
macOS release to Hetzner / Sign, notarize, verify and publish macOS (push) Skipped
Reviewed-on: #153
2026-09-27 08:58:30 +00:00
8 changed files with 340 additions and 38 deletions
+5 -21
View File
@@ -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
View File
@@ -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": {
+52 -4
View File
@@ -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) {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
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.0", "version": "0.1.1",
"identifier": "de.kanbangit.desktop", "identifier": "de.kanbangit.desktop",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+161
View File
@@ -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, '');
});
+111 -2
View File
@@ -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' },