feat(KAN-58): add macOS actions and Hetzner release pipeline
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 57s
CI/CD / test_and_check_app (pull_request) Successful in 6m33s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
This commit is contained in:
@@ -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() || '';
|
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({
|
export function releaseMacos({
|
||||||
env = process.env,
|
env = process.env,
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
@@ -213,49 +261,7 @@ export function releaseMacos({
|
|||||||
throw new Error('The release build did not produce KanbanGit.app.');
|
throw new Error('The release build did not produce KanbanGit.app.');
|
||||||
}
|
}
|
||||||
|
|
||||||
step('Verifying Developer ID signature and team', 'codesign', [
|
const cdhash = verifySignedMacosApp({ app, version: config.version, step });
|
||||||
'--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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(releaseDirectory, { recursive: true });
|
mkdirSync(releaseDirectory, { recursive: true });
|
||||||
const staging = mkdtempSync(join(releaseDirectory, '.pending-'));
|
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