Compare commits
2
Commits
96afd8b7ca
...
fcba7673c2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcba7673c2 | ||
|
|
2f99b8e65c |
@@ -0,0 +1,58 @@
|
||||
name: macOS checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||
pull_request:
|
||||
paths: ["native/**", ".github/**", "package.json", "pnpm-lock.yaml", "eslint.config.mjs", "resources/css/app.css"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: Native Rust tests and unsigned build
|
||||
runs-on: ${{ vars.MACOS_CHECK_RUNNER || vars.MACOS_RUNNER || 'macos-arm64' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install locked native dependencies
|
||||
run: pnpm --dir native install --frozen-lockfile
|
||||
|
||||
- name: Run native tests
|
||||
run: pnpm --dir native test
|
||||
|
||||
- name: Check Rust formatting and warnings
|
||||
run: pnpm --dir native check
|
||||
|
||||
- name: Build unsigned macOS app without release credentials
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
with:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: --ci --debug --no-sign --bundles app -- --locked
|
||||
uploadUpdaterJson: false
|
||||
uploadUpdaterSignatures: false
|
||||
uploadWorkflowArtifacts: false
|
||||
@@ -0,0 +1,236 @@
|
||||
name: macOS release to Hetzner
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "macos-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing protected macos-vMAJOR.MINOR.PATCH tag on main"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: macos-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: "Developer ID Application: Nico Schröder (ZZX2KZGTHU)"
|
||||
APPLE_TEAM_ID: ZZX2KZGTHU
|
||||
MACOS_RELEASE_TARGET: ${{ vars.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin' }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_PREFIX: ${{ vars.S3_PREFIX || 'releases/macos' }}
|
||||
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Validate release ref and configuration
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
commit: ${{ steps.ref.outputs.commit }}
|
||||
tag: ${{ steps.ref.outputs.tag }}
|
||||
runner: ${{ steps.ref.outputs.runner }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select an existing version tag contained in main
|
||||
id: ref
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.tag }}
|
||||
MACOS_RUNNER: ${{ vars.MACOS_RUNNER }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||
[[ "$GITHUB_REF" == refs/heads/main ]] || { echo 'Dispatch this workflow from main.' >&2; exit 1; }
|
||||
tag="$REQUESTED_TAG"
|
||||
else
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
[[ "$tag" =~ ^macos-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { echo 'Invalid macOS release tag.' >&2; exit 1; }
|
||||
[[ "$MACOS_RUNNER" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo 'Set MACOS_RUNNER to a registered macOS runner label.' >&2; exit 1; }
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
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 checkout --detach "$commit"
|
||||
printf 'commit=%s\ntag=%s\nrunner=%s\n' "$commit" "$tag" "$MACOS_RUNNER" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
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
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.ref.outputs.tag }}
|
||||
run: |
|
||||
node native/macos-ci.mjs metadata
|
||||
node native/publish-macos.mjs config
|
||||
|
||||
release:
|
||||
name: Sign, notarize, verify and publish macOS
|
||||
needs: prepare
|
||||
runs-on: ${{ needs.prepare.outputs.runner }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
steps:
|
||||
- name: Check out the validated commit
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.commit }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate runner and required secrets
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ "$(uname -s)" == Darwin ]] || { echo 'A macOS runner is required.' >&2; exit 1; }
|
||||
case "$MACOS_RELEASE_TARGET:$(uname -m)" in
|
||||
aarch64-apple-darwin:arm64|x86_64-apple-darwin:x86_64) ;;
|
||||
*) echo 'Runner architecture must match MACOS_RELEASE_TARGET.' >&2; exit 1 ;;
|
||||
esac
|
||||
xcodebuild -version
|
||||
xcrun --find notarytool
|
||||
aws --version | grep '^aws-cli/2\.' || { echo 'Install AWS CLI v2 permanently on this runner.' >&2; exit 1; }
|
||||
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_PASSWORD S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
|
||||
[[ -n "${!name}" ]] || { echo "Missing repository secret: $name" >&2; exit 1; }
|
||||
done
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||
with:
|
||||
toolchain: 1.98.1
|
||||
targets: ${{ env.MACOS_RELEASE_TARGET }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install locked dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --dir native install --frozen-lockfile
|
||||
|
||||
- name: Run native checks before importing signing credentials
|
||||
run: |
|
||||
pnpm exec eslint native --max-warnings 0
|
||||
pnpm --dir native test
|
||||
pnpm --dir native check
|
||||
|
||||
- name: Prepare isolated signing keychain
|
||||
id: signing
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
signing_dir="$(mktemp -d "$RUNNER_TEMP/kanbangit-signing.XXXXXX")"
|
||||
printf 'SIGNING_DIRECTORY=%s\n' "$signing_dir" >> "$GITHUB_ENV"
|
||||
security list-keychains -d user > "$signing_dir/original-keychains.txt"
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$signing_dir/certificate.p12"
|
||||
password="$(openssl rand -hex 32)"
|
||||
echo "::add-mask::$password"
|
||||
printf 'keychain=kanbangit-%s-%s\npassword=%s\ncertificate=%s/certificate.p12\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$password" "$signing_dir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import Developer ID certificate
|
||||
uses: Apple-Actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0; deletes its keychain in the post step
|
||||
with:
|
||||
keychain: ${{ steps.signing.outputs.keychain }}
|
||||
keychain-password: ${{ steps.signing.outputs.password }}
|
||||
p12-filepath: ${{ steps.signing.outputs.certificate }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Confirm release signing identity
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
security find-identity -v -p codesigning | grep -F "\"$APPLE_SIGNING_IDENTITY\""
|
||||
|
||||
- name: Build, sign and notarize with Tauri
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
|
||||
env:
|
||||
APPLE_ID: dev@petsch-studios.de
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
with:
|
||||
projectPath: native
|
||||
tauriScript: pnpm exec tauri
|
||||
args: >-
|
||||
--ci --target ${{ env.MACOS_RELEASE_TARGET }} --bundles app
|
||||
--config '{"bundle":{"createUpdaterArtifacts":false,"macOS":{"hardenedRuntime":true}}}'
|
||||
-- --locked
|
||||
uploadUpdaterJson: false
|
||||
uploadUpdaterSignatures: false
|
||||
uploadWorkflowArtifacts: false
|
||||
|
||||
- name: Verify signed app and packaged download
|
||||
id: package
|
||||
run: node native/macos-ci.mjs package
|
||||
|
||||
- name: Publish immutable files and verify public Hetzner downloads
|
||||
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
|
||||
RELEASE_DIRECTORY: ${{ steps.package.outputs.directory }}
|
||||
run: node native/publish-macos.mjs upload
|
||||
|
||||
- name: Restore keychain search list and remove certificate export
|
||||
if: ${{ always() }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import os, pathlib, shlex, shutil, subprocess
|
||||
value = os.environ.get('SIGNING_DIRECTORY')
|
||||
if value:
|
||||
directory = pathlib.Path(value)
|
||||
original = directory / 'original-keychains.txt'
|
||||
try:
|
||||
if original.exists():
|
||||
subprocess.run(['security', 'list-keychains', '-d', 'user', '-s', *shlex.split(original.read_text())], check=True)
|
||||
finally:
|
||||
shutil.rmtree(directory)
|
||||
PY
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Native JavaScript checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "native/**"
|
||||
- ".github/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "eslint.config.mjs"
|
||||
- "resources/css/app.css"
|
||||
pull_request:
|
||||
paths:
|
||||
- "native/**"
|
||||
- ".github/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "eslint.config.mjs"
|
||||
- "resources/css/app.css"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
native_javascript:
|
||||
name: Native launcher and release tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install locked development dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint native JavaScript
|
||||
run: pnpm exec eslint native --max-warnings 0
|
||||
|
||||
- name: Test native launcher and release safeguards without credentials
|
||||
run: node --test native/tests/*.test.js
|
||||
@@ -0,0 +1,203 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
appleTeamId,
|
||||
bundleIdentifier,
|
||||
verifySignedMacosApp,
|
||||
} from './release-macos.mjs';
|
||||
|
||||
const nativeDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function releaseMetadata(
|
||||
env = process.env,
|
||||
directory = nativeDirectory,
|
||||
) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(directory, 'src-tauri/tauri.conf.json'), 'utf8'),
|
||||
);
|
||||
const packageVersion = JSON.parse(
|
||||
readFileSync(join(directory, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
const cargoVersion = readFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'utf8',
|
||||
)
|
||||
.match(/\[package\]([\s\S]*?)(?:\n\[|$)/)?.[1]
|
||||
.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (
|
||||
!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(config.version) ||
|
||||
config.version !== packageVersion ||
|
||||
config.version !== cargoVersion
|
||||
) {
|
||||
throw new Error(
|
||||
'Tauri, package.json and Cargo.toml must have the same stable release version.',
|
||||
);
|
||||
}
|
||||
if (env.RELEASE_TAG !== `macos-v${config.version}`) {
|
||||
throw new Error(
|
||||
'Release tag must be macos-vMAJOR.MINOR.PATCH and match the app version.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
config.identifier !== bundleIdentifier ||
|
||||
config.productName !== 'KanbanGit'
|
||||
) {
|
||||
throw new Error(
|
||||
'The macOS product name and bundle identifier must stay unchanged.',
|
||||
);
|
||||
}
|
||||
const target = env.MACOS_RELEASE_TARGET || 'aarch64-apple-darwin';
|
||||
const arch = {
|
||||
'aarch64-apple-darwin': 'arm64',
|
||||
'x86_64-apple-darwin': 'x86_64',
|
||||
}[target];
|
||||
if (!arch) {
|
||||
throw new Error(
|
||||
'MACOS_RELEASE_TARGET must select Apple Silicon or Intel macOS.',
|
||||
);
|
||||
}
|
||||
const identity = env.APPLE_SIGNING_IDENTITY;
|
||||
if (
|
||||
!identity?.startsWith('Developer ID Application: ') ||
|
||||
!identity.endsWith(` (${appleTeamId})`) ||
|
||||
/[\r\n"]/.test(identity)
|
||||
) {
|
||||
throw new Error(
|
||||
`A Developer ID Application identity for ${appleTeamId} is required.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
version: config.version,
|
||||
target,
|
||||
arch,
|
||||
filename: `KanbanGit-${config.version}-${target}.zip`,
|
||||
};
|
||||
}
|
||||
|
||||
export function execute(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(
|
||||
`${command} failed (exit ${result.status ?? 'unknown'}). ${result.stderr || ''}`,
|
||||
);
|
||||
}
|
||||
return result.stdout?.trim() || result.stderr?.trim() || '';
|
||||
}
|
||||
|
||||
export function verifyNotarizedApp(app, metadata, run = execute) {
|
||||
verifySignedMacosApp({
|
||||
app,
|
||||
version: metadata.version,
|
||||
step: (_label, command, args) => run(command, args),
|
||||
});
|
||||
const arch = run('lipo', [
|
||||
'-archs',
|
||||
join(app, 'Contents/MacOS/kanbangit-native'),
|
||||
]).trim();
|
||||
if (arch !== metadata.arch) {
|
||||
throw new Error(
|
||||
'The app architecture does not match its download path.',
|
||||
);
|
||||
}
|
||||
run('xcrun', ['stapler', 'validate', app]);
|
||||
run('spctl', ['--assess', '--type', 'execute', '--verbose=2', app]);
|
||||
}
|
||||
|
||||
export function packageNotarizedApp({
|
||||
env = process.env,
|
||||
directory = nativeDirectory,
|
||||
run = execute,
|
||||
} = {}) {
|
||||
const metadata = releaseMetadata(env, directory);
|
||||
const root = join(directory, 'src-tauri/target');
|
||||
const app = join(
|
||||
root,
|
||||
metadata.target,
|
||||
'release/bundle/macos/KanbanGit.app',
|
||||
);
|
||||
verifyNotarizedApp(app, metadata, run);
|
||||
const releaseRoot = join(root, 'macos-release');
|
||||
const destination = join(
|
||||
releaseRoot,
|
||||
`${metadata.version}-${metadata.target}`,
|
||||
);
|
||||
if (existsSync(destination)) {
|
||||
throw new Error(
|
||||
'Release output already exists; start from a clean checkout.',
|
||||
);
|
||||
}
|
||||
mkdirSync(releaseRoot, { recursive: true });
|
||||
const staging = mkdtempSync(join(releaseRoot, '.pending-'));
|
||||
try {
|
||||
const archive = join(staging, metadata.filename);
|
||||
run('ditto', [
|
||||
'-c',
|
||||
'-k',
|
||||
'--sequesterRsrc',
|
||||
'--keepParent',
|
||||
app,
|
||||
archive,
|
||||
]);
|
||||
const checksum = createHash('sha256')
|
||||
.update(readFileSync(archive))
|
||||
.digest('hex');
|
||||
writeFileSync(
|
||||
`${archive}.sha256`,
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const extracted = join(staging, 'extracted');
|
||||
run('ditto', ['-x', '-k', archive, extracted]);
|
||||
verifyNotarizedApp(join(extracted, 'KanbanGit.app'), metadata, run);
|
||||
rmSync(extracted, { recursive: true, force: true });
|
||||
renameSync(staging, destination);
|
||||
return { ...metadata, directory: destination, checksum };
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
try {
|
||||
let output;
|
||||
if (process.argv[2] === 'metadata') {
|
||||
output = releaseMetadata();
|
||||
} else if (process.argv[2] === 'package') {
|
||||
output = packageNotarizedApp();
|
||||
} else {
|
||||
throw new Error(
|
||||
'Use metadata or package. Building and notarizing are handled by tauri-action.',
|
||||
);
|
||||
}
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries(output)
|
||||
.map(([key, value]) => `${key}=${value}\n`)
|
||||
.join(''),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify(output));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
appendFileSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execute, releaseMetadata } from './macos-ci.mjs';
|
||||
|
||||
export function storageConfiguration(env = process.env) {
|
||||
const region = env.S3_REGION;
|
||||
const bucket = env.S3_BUCKET;
|
||||
const prefix = env.S3_PREFIX || 'releases/macos';
|
||||
if (!['nbg1', 'fsn1', 'hel1'].includes(region)) {
|
||||
throw new Error('S3_REGION must be nbg1, fsn1 or hel1.');
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket || '')) {
|
||||
throw new Error('Set S3_BUCKET to the Hetzner release bucket name.');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/.test(prefix)) {
|
||||
throw new Error(
|
||||
'S3_PREFIX must contain plain path segments without leading or trailing slashes.',
|
||||
);
|
||||
}
|
||||
const endpoint = `https://${region}.your-objectstorage.com`;
|
||||
const publicBase =
|
||||
env.S3_PUBLIC_BASE_URL ||
|
||||
`https://${bucket}.${region}.your-objectstorage.com`;
|
||||
const url = new URL(publicBase);
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash ||
|
||||
/[\s]/.test(publicBase)
|
||||
) {
|
||||
throw new Error(
|
||||
'S3_PUBLIC_BASE_URL must be a public HTTPS URL without credentials or query parameters.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
region,
|
||||
bucket,
|
||||
prefix,
|
||||
endpoint,
|
||||
publicBase: publicBase.replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
||||
|
||||
export async function publishMacos({
|
||||
env = process.env,
|
||||
directory,
|
||||
metadata,
|
||||
run = execute,
|
||||
fetchPublic = fetch,
|
||||
} = {}) {
|
||||
const storage = storageConfiguration(env);
|
||||
if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY) {
|
||||
throw new Error(
|
||||
'Set the dedicated S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY release secrets.',
|
||||
);
|
||||
}
|
||||
const archive = readFileSync(join(directory, metadata.filename));
|
||||
const checksum = sha256(archive);
|
||||
const checksumFile = readFileSync(
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
);
|
||||
if (checksumFile.toString() !== `${checksum} ${metadata.filename}\n`) {
|
||||
throw new Error('Release archive does not match its SHA-256 file.');
|
||||
}
|
||||
const common = [
|
||||
'--endpoint-url',
|
||||
storage.endpoint,
|
||||
'--region',
|
||||
storage.region,
|
||||
'--no-cli-pager',
|
||||
's3api',
|
||||
];
|
||||
function s3(operation, args) {
|
||||
return run('aws', [
|
||||
...common,
|
||||
operation,
|
||||
'--bucket',
|
||||
storage.bucket,
|
||||
...args,
|
||||
]);
|
||||
}
|
||||
const versioning = JSON.parse(s3('get-bucket-versioning', []));
|
||||
if (versioning.Status) {
|
||||
throw new Error(
|
||||
'Use an unversioned release bucket: Hetzner does not support conditional writes on versioned buckets.',
|
||||
);
|
||||
}
|
||||
const staging = mkdtempSync(join(tmpdir(), 'kanbangit-s3-'));
|
||||
const keyPrefix = `${storage.prefix}/${metadata.target}/${metadata.version}`;
|
||||
const publicPrefix = `${storage.publicBase}/${keyPrefix}`;
|
||||
async function verifyPublic(name, contents) {
|
||||
const response = await fetchPublic(`${publicPrefix}/${name}`, {
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (
|
||||
!response.ok ||
|
||||
sha256(Buffer.from(await response.arrayBuffer())) !==
|
||||
sha256(contents)
|
||||
) {
|
||||
throw new Error(`Public download verification failed for ${name}.`);
|
||||
}
|
||||
}
|
||||
function putImmutable(name, contents, contentType) {
|
||||
const file = join(staging, name);
|
||||
writeFileSync(file, contents);
|
||||
try {
|
||||
s3('put-object', [
|
||||
'--key',
|
||||
`${keyPrefix}/${name}`,
|
||||
'--body',
|
||||
file,
|
||||
'--if-none-match',
|
||||
'*',
|
||||
'--content-type',
|
||||
contentType,
|
||||
'--cache-control',
|
||||
'public, max-age=31536000, immutable',
|
||||
'--content-md5',
|
||||
createHash('md5').update(contents).digest('base64'),
|
||||
'--metadata',
|
||||
`sha256=${sha256(contents)}`,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!/\(PreconditionFailed\)|\(412\)/.test(error.message)) {
|
||||
throw error;
|
||||
}
|
||||
const existing = join(staging, `existing-${name}`);
|
||||
s3('get-object', ['--key', `${keyPrefix}/${name}`, existing]);
|
||||
if (sha256(readFileSync(existing)) !== sha256(contents)) {
|
||||
throw new Error(
|
||||
`Published ${name} differs; bump the app version. Existing objects were not overwritten.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const files = [
|
||||
[metadata.filename, archive, 'application/zip'],
|
||||
[
|
||||
`${metadata.filename}.sha256`,
|
||||
checksumFile,
|
||||
'text/plain; charset=utf-8',
|
||||
],
|
||||
];
|
||||
for (const [name, contents, type] of files) {
|
||||
putImmutable(name, contents, type);
|
||||
await verifyPublic(name, contents);
|
||||
}
|
||||
// This completion record is written only after both public downloads match.
|
||||
const manifest = Buffer.from(
|
||||
JSON.stringify(
|
||||
{
|
||||
version: metadata.version,
|
||||
target: metadata.target,
|
||||
url: `${publicPrefix}/${metadata.filename}`,
|
||||
sha256: checksum,
|
||||
checksumUrl: `${publicPrefix}/${metadata.filename}.sha256`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
putImmutable('release.json', manifest, 'application/json');
|
||||
await verifyPublic('release.json', manifest);
|
||||
return `${publicPrefix}/${metadata.filename}`;
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
) {
|
||||
try {
|
||||
if (process.argv[2] === 'config') {
|
||||
console.log(JSON.stringify(storageConfiguration()));
|
||||
} else if (
|
||||
process.argv[2] === 'upload' &&
|
||||
process.env.RELEASE_DIRECTORY
|
||||
) {
|
||||
const url = await publishMacos({
|
||||
directory: process.env.RELEASE_DIRECTORY,
|
||||
metadata: releaseMetadata(),
|
||||
});
|
||||
console.log(`Verified public release: ${url}`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`Download: [KanbanGit](${url})\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Use config or upload; upload requires RELEASE_DIRECTORY.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+49
-43
@@ -37,6 +37,54 @@ function execute(command, args, options) {
|
||||
return result.stdout?.trim() || result.stderr?.trim() || '';
|
||||
}
|
||||
|
||||
export function verifySignedMacosApp({ app, version, step }) {
|
||||
step('Verifying Developer ID signature and team', 'codesign', [
|
||||
'--verify',
|
||||
'--deep',
|
||||
'--strict',
|
||||
'--verbose=2',
|
||||
'-R',
|
||||
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`,
|
||||
app,
|
||||
]);
|
||||
const signature = step('Checking hardened runtime', 'codesign', [
|
||||
'--display',
|
||||
'--verbose=4',
|
||||
app,
|
||||
]);
|
||||
|
||||
if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) {
|
||||
throw new Error(
|
||||
'The release signature does not enable Hardened Runtime.',
|
||||
);
|
||||
}
|
||||
|
||||
const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1];
|
||||
|
||||
if (!cdhash) {
|
||||
throw new Error(
|
||||
'The signed app has no CodeDirectory hash to match against Apple notarization.',
|
||||
);
|
||||
}
|
||||
|
||||
const bundledVersion = step('Checking bundled release version', 'plutil', [
|
||||
'-extract',
|
||||
'CFBundleShortVersionString',
|
||||
'raw',
|
||||
'-o',
|
||||
'-',
|
||||
join(app, 'Contents/Info.plist'),
|
||||
]);
|
||||
|
||||
if (bundledVersion.trim() !== version) {
|
||||
throw new Error(
|
||||
'The built app version does not match the release version.',
|
||||
);
|
||||
}
|
||||
|
||||
return cdhash;
|
||||
}
|
||||
|
||||
export function releaseMacos({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
@@ -213,49 +261,7 @@ export function releaseMacos({
|
||||
throw new Error('The release build did not produce KanbanGit.app.');
|
||||
}
|
||||
|
||||
step('Verifying Developer ID signature and team', 'codesign', [
|
||||
'--verify',
|
||||
'--deep',
|
||||
'--strict',
|
||||
'--verbose=2',
|
||||
'-R',
|
||||
`=anchor apple generic and identifier "${bundleIdentifier}" and certificate leaf[subject.OU] = "${appleTeamId}" and certificate leaf[field.1.2.840.113635.100.6.1.13] exists`,
|
||||
app,
|
||||
]);
|
||||
const signature = step('Checking hardened runtime', 'codesign', [
|
||||
'--display',
|
||||
'--verbose=4',
|
||||
app,
|
||||
]);
|
||||
|
||||
if (!/^CodeDirectory .*flags=.*\bruntime\b/m.test(signature)) {
|
||||
throw new Error(
|
||||
'The release signature does not enable Hardened Runtime.',
|
||||
);
|
||||
}
|
||||
|
||||
const cdhash = signature.match(/^CDHash=([\da-f]+)$/m)?.[1];
|
||||
|
||||
if (!cdhash) {
|
||||
throw new Error(
|
||||
'The signed app has no CodeDirectory hash to match against Apple notarization.',
|
||||
);
|
||||
}
|
||||
|
||||
const version = step('Checking bundled release version', 'plutil', [
|
||||
'-extract',
|
||||
'CFBundleShortVersionString',
|
||||
'raw',
|
||||
'-o',
|
||||
'-',
|
||||
join(app, 'Contents/Info.plist'),
|
||||
]);
|
||||
|
||||
if (version.trim() !== config.version) {
|
||||
throw new Error(
|
||||
'The built app version does not match the release version.',
|
||||
);
|
||||
}
|
||||
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
|
||||
|
||||
mkdirSync(releaseDirectory, { recursive: true });
|
||||
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
packageNotarizedApp,
|
||||
releaseMetadata,
|
||||
verifyNotarizedApp,
|
||||
} from '../macos-ci.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-ci-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
mkdirSync(join(directory, 'src-tauri'));
|
||||
const config = {
|
||||
version: '1.2.3',
|
||||
productName: 'KanbanGit',
|
||||
identifier: 'de.kanbangit.desktop',
|
||||
};
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/tauri.conf.json'),
|
||||
JSON.stringify(config),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'package.json'),
|
||||
JSON.stringify({ version: '1.2.3' }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, 'src-tauri/Cargo.toml'),
|
||||
'[package]\nname = "kanbangit-native"\nversion = "1.2.3"\n[dependencies]\nother = "2"\n',
|
||||
);
|
||||
const env = {
|
||||
RELEASE_TAG: 'macos-v1.2.3',
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (ZZX2KZGTHU)',
|
||||
};
|
||||
const calls = [];
|
||||
const run = (command, args) => {
|
||||
calls.push({ command, args });
|
||||
if (command === 'codesign' && args.includes('--display')) {
|
||||
return 'CodeDirectory flags=0x10000(runtime)\nCDHash=abcdef123456';
|
||||
}
|
||||
if (command === 'plutil') {
|
||||
return '1.2.3';
|
||||
}
|
||||
if (command === 'lipo') {
|
||||
return 'arm64';
|
||||
}
|
||||
if (command === 'ditto' && args.includes('-c')) {
|
||||
writeFileSync(args.at(-1), 'notarized archive');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
return { directory, env, config, run, calls };
|
||||
}
|
||||
|
||||
test('release metadata binds tag, all app versions, identity and architecture', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.deepEqual(releaseMetadata(f.env, f.directory), {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
arch: 'arm64',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
});
|
||||
assert.equal(
|
||||
releaseMetadata(
|
||||
{ ...f.env, MACOS_RELEASE_TARGET: 'x86_64-apple-darwin' },
|
||||
f.directory,
|
||||
).arch,
|
||||
'x86_64',
|
||||
);
|
||||
});
|
||||
|
||||
for (const env of [
|
||||
{ RELEASE_TAG: 'v1.2.3' },
|
||||
{ RELEASE_TAG: 'macos-v1.2.4' },
|
||||
{ RELEASE_TAG: 'macos-v1.2.3; echo bad' },
|
||||
{ MACOS_RELEASE_TARGET: 'x86_64-unknown-linux-gnu' },
|
||||
{ APPLE_SIGNING_IDENTITY: '-' },
|
||||
{ APPLE_SIGNING_IDENTITY: 'Apple Development: Example (ZZX2KZGTHU)' },
|
||||
{
|
||||
APPLE_SIGNING_IDENTITY:
|
||||
'Developer ID Application: Example (OLDTEAM123)',
|
||||
},
|
||||
]) {
|
||||
test(`rejects invalid release inputs ${JSON.stringify(env)}`, (t) => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => releaseMetadata({ ...f.env, ...env }, f.directory));
|
||||
});
|
||||
}
|
||||
|
||||
for (const [file, contents] of [
|
||||
['package.json', '{"version":"1.2.4"}'],
|
||||
['src-tauri/Cargo.toml', '[package]\nversion = "1.2.4"'],
|
||||
['src-tauri/tauri.conf.json', '{"version":"01.2.3"}'],
|
||||
[
|
||||
'src-tauri/tauri.conf.json',
|
||||
'{"version":"1.2.3","identifier":"other","productName":"KanbanGit"}',
|
||||
],
|
||||
]) {
|
||||
test(`rejects inconsistent metadata in ${file}: ${contents}`, (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, file), contents);
|
||||
assert.throws(() => releaseMetadata(f.env, f.directory));
|
||||
});
|
||||
}
|
||||
|
||||
test('packages only a verified app and checks the extracted download again', (t) => {
|
||||
const f = fixture(t);
|
||||
const output = packageNotarizedApp(f);
|
||||
assert.equal(
|
||||
readFileSync(join(output.directory, output.filename), 'utf8'),
|
||||
'notarized archive',
|
||||
);
|
||||
assert.equal(
|
||||
readFileSync(
|
||||
join(output.directory, `${output.filename}.sha256`),
|
||||
'utf8',
|
||||
),
|
||||
`${output.checksum} ${output.filename}\n`,
|
||||
);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'spctl').length, 2);
|
||||
assert.equal(f.calls.filter((call) => call.command === 'xcrun').length, 2);
|
||||
assert.match(
|
||||
f.calls.findLast((call) => call.command === 'spctl').args.at(-1),
|
||||
/extracted\/KanbanGit\.app$/,
|
||||
);
|
||||
assert.throws(() => packageNotarizedApp(f), /already exists/);
|
||||
});
|
||||
|
||||
for (const failure of ['codesign', 'lipo', 'xcrun', 'spctl', 'ditto']) {
|
||||
test(`does not publish local output when ${failure} verification fails`, (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (command === failure) {
|
||||
throw new Error('verification failed');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
assert.throws(() => packageNotarizedApp(f), /verification failed/);
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
f.directory,
|
||||
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an app built for a different architecture', (t) => {
|
||||
const f = fixture(t);
|
||||
const metadata = releaseMetadata(f.env, f.directory);
|
||||
assert.throws(
|
||||
() =>
|
||||
verifyNotarizedApp('KanbanGit.app', metadata, (command, args) =>
|
||||
command === 'lipo' ? 'x86_64' : f.run(command, args),
|
||||
),
|
||||
/architecture/,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed validation of the extracted app discards staged download', (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (command === 'spctl' && args.at(-1).includes('extracted/')) {
|
||||
throw new Error('roundtrip invalid');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
assert.throws(() => packageNotarizedApp(f), /roundtrip invalid/);
|
||||
assert.equal(
|
||||
existsSync(
|
||||
join(
|
||||
f.directory,
|
||||
'src-tauri/target/macos-release/1.2.3-aarch64-apple-darwin',
|
||||
),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { publishMacos, storageConfiguration } from '../publish-macos.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'kanbangit-upload-test-'));
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const metadata = {
|
||||
version: '1.2.3',
|
||||
target: 'aarch64-apple-darwin',
|
||||
filename: 'KanbanGit-1.2.3-aarch64-apple-darwin.zip',
|
||||
};
|
||||
const archive = Buffer.from('verified release');
|
||||
const checksum = createHash('sha256').update(archive).digest('hex');
|
||||
writeFileSync(join(directory, metadata.filename), archive);
|
||||
writeFileSync(
|
||||
join(directory, `${metadata.filename}.sha256`),
|
||||
`${checksum} ${metadata.filename}\n`,
|
||||
);
|
||||
const env = {
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_REGION: 'nbg1',
|
||||
AWS_ACCESS_KEY_ID: 'test-key',
|
||||
AWS_SECRET_ACCESS_KEY: 'test-secret',
|
||||
};
|
||||
const objects = new Map();
|
||||
const events = [];
|
||||
const run = (command, args) => {
|
||||
assert.equal(command, 'aws');
|
||||
assert.equal(args[1], 'https://nbg1.your-objectstorage.com');
|
||||
const operation = args[args.indexOf('s3api') + 1];
|
||||
const value = (name) => args[args.indexOf(name) + 1];
|
||||
const key = value('--key');
|
||||
events.push({ operation, key });
|
||||
if (operation === 'get-bucket-versioning') {
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'put-object') {
|
||||
assert.equal(value('--if-none-match'), '*');
|
||||
if (objects.has(key)) {
|
||||
throw new Error(
|
||||
'aws failed: (PreconditionFailed) object exists',
|
||||
);
|
||||
}
|
||||
objects.set(key, readFileSync(value('--body')));
|
||||
return '{}';
|
||||
}
|
||||
if (operation === 'get-object') {
|
||||
writeFileSync(args.at(-1), objects.get(key));
|
||||
return '{}';
|
||||
}
|
||||
throw new Error(`Unexpected S3 operation: ${operation}`);
|
||||
};
|
||||
const fetchPublic = async (url, options) => {
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.equal(
|
||||
new URL(url).hostname,
|
||||
'release-bucket.nbg1.your-objectstorage.com',
|
||||
);
|
||||
const key = new URL(url).pathname.slice(1);
|
||||
events.push({ operation: 'public-get', key });
|
||||
return {
|
||||
ok: objects.has(key),
|
||||
arrayBuffer: async () => objects.get(key),
|
||||
};
|
||||
};
|
||||
return { directory, metadata, env, objects, events, run, fetchPublic };
|
||||
}
|
||||
|
||||
test('uploads immutable files and writes completion record after public verification', async (t) => {
|
||||
const f = fixture(t);
|
||||
const url = await publishMacos(f);
|
||||
assert.equal(
|
||||
url,
|
||||
`https://release-bucket.nbg1.your-objectstorage.com/releases/macos/aarch64-apple-darwin/1.2.3/${f.metadata.filename}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
f.events.map((event) => event.operation),
|
||||
[
|
||||
'get-bucket-versioning',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
'put-object',
|
||||
'public-get',
|
||||
],
|
||||
);
|
||||
assert.equal(f.objects.size, 3);
|
||||
assert(f.events.at(-1).key.endsWith('/release.json'));
|
||||
assert(![...f.objects.keys()].some((key) => key.includes('latest')));
|
||||
assert.deepEqual(JSON.parse(f.objects.get(f.events.at(-1).key)), {
|
||||
version: '1.2.3',
|
||||
target: f.metadata.target,
|
||||
url,
|
||||
sha256: createHash('sha256').update('verified release').digest('hex'),
|
||||
checksumUrl: `${url}.sha256`,
|
||||
});
|
||||
});
|
||||
|
||||
test('retry accepts byte-identical objects without replacing them', async (t) => {
|
||||
const f = fixture(t);
|
||||
await publishMacos(f);
|
||||
const originals = [...f.objects.entries()];
|
||||
await publishMacos(f);
|
||||
assert.deepEqual([...f.objects.entries()], originals);
|
||||
assert.equal(
|
||||
f.events.filter((event) => event.operation === 'get-object').length,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('different existing release is never 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('previous version content'));
|
||||
await assert.rejects(publishMacos(f), /bump the app version/);
|
||||
assert.equal(f.objects.get(key).toString(), 'previous version content');
|
||||
assert.equal(f.objects.size, 1);
|
||||
});
|
||||
|
||||
test('checksum mismatch aborts before contacting S3', async (t) => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(join(f.directory, f.metadata.filename), 'tampered');
|
||||
await assert.rejects(publishMacos(f), /SHA-256/);
|
||||
assert.equal(f.events.length, 0);
|
||||
});
|
||||
|
||||
for (const status of ['Enabled', 'Suspended']) {
|
||||
test(`rejects ${status} bucket versioning before any write`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.run = () => JSON.stringify({ Status: status });
|
||||
await assert.rejects(publishMacos(f), /unversioned/);
|
||||
assert.equal(f.objects.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const operation of ['get-bucket-versioning', 'put-object', 'get-object']) {
|
||||
test(`${operation} access or network failure cannot be mistaken for a missing object`, async (t) => {
|
||||
const f = fixture(t);
|
||||
await publishMacos(f);
|
||||
const run = f.run;
|
||||
f.events.length = 0;
|
||||
f.run = (command, args) => {
|
||||
if (args.includes(operation)) {
|
||||
throw new Error('AccessDenied');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /AccessDenied/);
|
||||
assert(!f.events.some((event) => event.key.endsWith('/release.json')));
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['private', 'wrong-content', 'network']) {
|
||||
test(`public download ${failure} prevents completion record`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.fetchPublic = async () => {
|
||||
if (failure === 'network') {
|
||||
throw new Error('network failure');
|
||||
}
|
||||
return {
|
||||
ok: failure !== 'private',
|
||||
arrayBuffer: async () => Buffer.from('wrong'),
|
||||
};
|
||||
};
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert(
|
||||
![...f.objects.keys()].some((key) => key.endsWith('/release.json')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('a partial upload can resume with the same files', async (t) => {
|
||||
const f = fixture(t);
|
||||
const run = f.run;
|
||||
f.run = (command, args) => {
|
||||
if (
|
||||
args.includes('put-object') &&
|
||||
args[args.indexOf('--key') + 1].endsWith('.sha256')
|
||||
) {
|
||||
throw new Error('upload interrupted');
|
||||
}
|
||||
return run(command, args);
|
||||
};
|
||||
await assert.rejects(publishMacos(f), /interrupted/);
|
||||
assert.equal(f.objects.size, 1);
|
||||
f.run = run;
|
||||
await publishMacos(f);
|
||||
assert.equal(f.objects.size, 3);
|
||||
});
|
||||
|
||||
for (const overrides of [
|
||||
{ S3_REGION: '' },
|
||||
{ S3_REGION: 'eu-central-1' },
|
||||
{ S3_BUCKET: '' },
|
||||
{ S3_BUCKET: 'other/../bucket' },
|
||||
{ S3_PREFIX: '../other' },
|
||||
{ S3_PUBLIC_BASE_URL: 'http://downloads.example.com' },
|
||||
{ S3_PUBLIC_BASE_URL: 'https://secret@downloads.example.com' },
|
||||
{ S3_PUBLIC_BASE_URL: 'https://downloads.example.com?token=secret' },
|
||||
{ AWS_ACCESS_KEY_ID: '' },
|
||||
{ AWS_SECRET_ACCESS_KEY: '' },
|
||||
]) {
|
||||
test(`invalid storage configuration is rejected before S3 access: ${JSON.stringify(overrides)}`, async (t) => {
|
||||
const f = fixture(t);
|
||||
f.env = { ...f.env, ...overrides };
|
||||
await assert.rejects(publishMacos(f));
|
||||
assert.equal(f.events.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('derives Hetzner endpoints and supports an explicit public proxy URL', () => {
|
||||
for (const region of ['nbg1', 'fsn1', 'hel1']) {
|
||||
const config = storageConfiguration({
|
||||
S3_REGION: region,
|
||||
S3_BUCKET: 'release-bucket',
|
||||
S3_PUBLIC_BASE_URL: 'https://downloads.example.com/',
|
||||
});
|
||||
assert.equal(
|
||||
config.endpoint,
|
||||
`https://${region}.your-objectstorage.com`,
|
||||
);
|
||||
assert.equal(config.publicBase, 'https://downloads.example.com');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user