From 96afd8b7cae28a71b85399b1b15bf600d85fbbac Mon Sep 17 00:00:00 2001 From: Twyco Date: Sun, 27 Sep 2026 08:57:14 +0200 Subject: [PATCH] feat(KAN-58): add macOS actions and Hetzner release pipeline --- .github/workflows/macos-checks.yml | 58 +++++++ .github/workflows/macos-release.yml | 236 ++++++++++++++++++++++++++++ .github/workflows/native-checks.yml | 57 +++++++ native/macos-ci.mjs | 203 ++++++++++++++++++++++++ native/publish-macos.mjs | 218 +++++++++++++++++++++++++ native/release-macos.mjs | 92 ++++++----- native/tests/macos-ci.test.js | 193 +++++++++++++++++++++++ native/tests/publish-macos.test.js | 231 +++++++++++++++++++++++++++ 8 files changed, 1245 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/macos-checks.yml create mode 100644 .github/workflows/macos-release.yml create mode 100644 .github/workflows/native-checks.yml create mode 100644 native/macos-ci.mjs create mode 100644 native/publish-macos.mjs create mode 100644 native/tests/macos-ci.test.js create mode 100644 native/tests/publish-macos.test.js diff --git a/.github/workflows/macos-checks.yml b/.github/workflows/macos-checks.yml new file mode 100644 index 0000000..1fd5873 --- /dev/null +++ b/.github/workflows/macos-checks.yml @@ -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 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 0000000..2faa865 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -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 diff --git a/.github/workflows/native-checks.yml b/.github/workflows/native-checks.yml new file mode 100644 index 0000000..20d82f4 --- /dev/null +++ b/.github/workflows/native-checks.yml @@ -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 diff --git a/native/macos-ci.mjs b/native/macos-ci.mjs new file mode 100644 index 0000000..3db0478 --- /dev/null +++ b/native/macos-ci.mjs @@ -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; + } +} diff --git a/native/publish-macos.mjs b/native/publish-macos.mjs new file mode 100644 index 0000000..3ce5cf1 --- /dev/null +++ b/native/publish-macos.mjs @@ -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; + } +} diff --git a/native/release-macos.mjs b/native/release-macos.mjs index 0d4b70f..6939daf 100644 --- a/native/release-macos.mjs +++ b/native/release-macos.mjs @@ -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-')); diff --git a/native/tests/macos-ci.test.js b/native/tests/macos-ci.test.js new file mode 100644 index 0000000..da142ff --- /dev/null +++ b/native/tests/macos-ci.test.js @@ -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, + ); +}); diff --git a/native/tests/publish-macos.test.js b/native/tests/publish-macos.test.js new file mode 100644 index 0000000..2dec55a --- /dev/null +++ b/native/tests/publish-macos.test.js @@ -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'); + } +});