From 18ab2021b8c292530ca5aa268da05e3fc069e2f9 Mon Sep 17 00:00:00 2001 From: Peter Thomson Date: Fri, 25 Sep 2026 13:13:13 +1200 Subject: [PATCH 1/4] Verify ZIP releases and share native release checks --- .github/workflows/release-tooling.yml | 17 ++ CLAUDE.md | 6 + RELEASE-PROTOCOL.md | 204 +++++++++++---------- release-config/README.md | 17 +- scripts/release/.gitignore | 1 + scripts/release/LOCAL-COMPUTER-USE.md | 40 +++++ scripts/release/mac_artifacts.py | 143 +++++++++++++++ scripts/release/notarize.py | 108 +++++++++++ scripts/release/notarize.sh | 236 +------------------------ scripts/release/refresh-feed.py | 3 + scripts/release/test_release.py | 92 ++++++++++ scripts/release/verify-mac-artifact.sh | 195 +------------------- 12 files changed, 530 insertions(+), 532 deletions(-) create mode 100644 .github/workflows/release-tooling.yml create mode 100644 scripts/release/.gitignore create mode 100644 scripts/release/LOCAL-COMPUTER-USE.md create mode 100644 scripts/release/mac_artifacts.py create mode 100644 scripts/release/notarize.py create mode 100644 scripts/release/test_release.py diff --git a/.github/workflows/release-tooling.yml b/.github/workflows/release-tooling.yml new file mode 100644 index 0000000..9381088 --- /dev/null +++ b/.github/workflows/release-tooling.yml @@ -0,0 +1,17 @@ +name: Release tooling +on: + pull_request: + paths: ['scripts/release/**', '.github/workflows/release-tooling.yml'] + push: + paths: ['scripts/release/**', '.github/workflows/release-tooling.yml'] +permissions: + contents: read +jobs: + release-tooling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python3 -m unittest discover -s scripts/release -p 'test_*.py' diff --git a/CLAUDE.md b/CLAUDE.md index 33ed0a9..1163e23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,3 +289,9 @@ See `Documentation/` for detailed reference: - `GETTING_STARTED.md` - Setup, Google Calendar OAuth, troubleshooting - `APP-STORE.md` - App Store submission details - `LAUNCH.md` - Release checklist + +## Release verification + +Follow `RELEASE-PROTOCOL.md` and `scripts/release/LOCAL-COMPUTER-USE.md`. +Use the final extracted signed package for the native walkthrough. Run the +shared script checks with `python3 -m unittest discover -s scripts/release -p "test_*.py"`. diff --git a/RELEASE-PROTOCOL.md b/RELEASE-PROTOCOL.md index 9973679..0c6cd5c 100644 --- a/RELEASE-PROTOCOL.md +++ b/RELEASE-PROTOCOL.md @@ -1,111 +1,123 @@ -# Release protocol (shared across the productivity suite) +# Release protocol + +Shared by Marktext, Ledger and Year View. The scripts in `scripts/release/` are +mirrored; update and test all three copies together. macOS signing stays local. +Windows/Linux builds run on their target platforms where supported. + +## Release gates + +1. Record the source commit and version. Run typechecking, relevant tests and + platform builds. Keep candidate outputs in a new version/candidate directory. +2. Build and sign the app using the repository's build tool. Do not publish yet. +3. Submit notarization, save the submission ID, and return. Check status later; + pending or invalid submissions block subsequent stages. Never rebuild an app + to work around a slow Apple response. +4. Staple the accepted app, then package it using the repository's packager. + Submit and staple the enclosing DMG if shipping one. Refresh update feeds + after stapling, since it changes the DMG's bytes. +5. Verify the exact DMG/ZIP, then run the native walkthrough on the extracted + package. Record the artifact SHA-256, source commit, test results and observed + UI results. See `scripts/release/LOCAL-COMPUTER-USE.md`. +6. Tag the approved source commit. Stage every intended asset and a complete + SHA-256 manifest in a **draft** release. Download the assets again, verify + checksums and rerun artifact verification. Only then publish the draft. + +Do not overwrite published tags/assets. Check whether an existing release is +immutable before proposing additions; a new patch release may be required. +A release that intentionally omits a platform or installer must say so explicitly. + +## Electron apps: Ledger and Marktext + +Run from the repository root. `release:prepare` compiles, packages a signed app, +creates its submission ZIP and submits it without waiting for Apple. It refuses +to overwrite an existing candidate. It does not publish anything. -Canonical copy: `peterjthomson/marktext`. Mirrored into `peterjthomson/ledger` -and `peterjthomson/year-view` so all three release the same way. - -## The rule that shapes everything - -**Notarization is asynchronous and may take over a day.** Apple's notary -service is usually minutes; it has taken more than 24 hours. Any pipeline that -blocks on the result eventually strands a build, and a stranded build gets -finished by hand. - -That is not hypothetical. Ledger 1.5.0's DMG shipped containing -`Ledger.app/Ledger.app` — a folder wearing the `.app` extension with the real, -correctly-notarized bundle inside it. Finder shows a broken item and -drag-to-Applications installs something that cannot launch. The repository's own -`electron-builder` pipeline produces a **correct** DMG; the nesting was -introduced by the manual step that existed to work around slow notarization. The -workaround, not the tooling, broke the release. - -So: never block on Apple, and never hand-assemble an artifact. - -## The five stages - -Each stage is idempotent and independently re-runnable. State lives on disk, so -a stage can be resumed tomorrow without redoing the one before it. - -| Stage | Command | Blocks on Apple? | -|---|---|---| -| 1. Build | repo-native (`pnpm build:mac:arm64`, `npm run build:mac:arm64`, `xcodebuild archive`) | no | -| 2. Submit | `scripts/release/notarize.sh submit dist/*.dmg` | no — returns after upload | -| 3. Collect | `scripts/release/notarize.sh status` | no — poll whenever | -| 4. Staple | `scripts/release/notarize.sh staple` | no — only staples what is Accepted | -| 5. Verify | `scripts/release/verify-mac-artifact.sh --dmg … --bundle-id …` | no | -| 6. Publish | `gh release upload …` | no | +```bash +# Ledger +npm run typecheck +npm test +npm run test:release +npm run release:prepare + +# Marktext +pnpm check +pnpm test +pnpm test:e2e +pnpm test:release +pnpm release:prepare +``` -If Apple takes 26 hours, stages 1–2 are already done and nothing is lost: run -`status` tomorrow, `staple` when it clears, and the ticket attaches to the -artifact you already built. No rebuild, no re-sign, no hand-made DMG. +The candidate directory is `dist/release-`. For a replacement +candidate, compile first and call `scripts/release/electron-mac.sh prepare` +with a fresh output directory; never overwrite the previous candidate. ```bash -# Day 1 -pnpm build:mac:arm64 # or the repo's build command -./scripts/release/notarize.sh submit dist/*.dmg -# → "submitted as 3ac911ba-…", terminal returns - -# Whenever — an hour later, or Thursday -./scripts/release/notarize.sh status -./scripts/release/notarize.sh staple -./scripts/release/verify-mac-artifact.sh --dmg dist/App.dmg --bundle-id com.example.app +export NOTARIZE_STATE="$PWD/dist/release-/notarize-state.json" +scripts/release/notarize.sh status +scripts/release/notarize.sh staple +# Only after the app is Accepted and stapled: +npm run release:package # Marktext: pnpm release:package +scripts/release/notarize.sh submit dist/release-/artifacts/*.dmg +scripts/release/notarize.sh status +scripts/release/notarize.sh staple ``` -`notarize.sh log ` fetches Apple's detailed report when something is -Invalid. `notarize.sh reset` forgets recorded submissions without cancelling -them. - -## Stage 5 is not optional +`status` and `staple` exit 2 while any submission is not accepted. Use `log +` to investigate an Invalid submission. Submission is idempotent for +unchanged bytes; changed artifacts require a new candidate/state file. The +stored `AC_PASSWORD` keychain profile is shared; `APPLE_KEYCHAIN_PROFILE` +overrides it. Never put credentials in release manifests or logs. -`verify-mac-artifact.sh` is the gate that would have caught Ledger 1.5.0. It -mounts the DMG the way a user's Mac will — quarantined — and asserts: +`build:mac*` creates local installers without notarizing or uploading them. +A successful build alone is not a release gate. Do not remove quarantine to make +release verification pass. The legacy `npm run release` in Ledger now prepares +an app; it no longer uploads unchecked artifacts. -- the DMG carries a stapled ticket, and Gatekeeper accepts it quarantined -- **exactly one `.app` at the volume root, with `Contents/Info.plist` directly - inside it** (the nesting check) -- the bundle identifier is the expected one -- the signature is valid deep+strict, and the authority is Developer ID Application -- the app carries its **own** stapled ticket, so it still validates offline once - copied out of the DMG -- Gatekeeper accepts the app for execution -- every `latest*.yml` entry matches the bytes on disk — stapling rewrites the - DMG *after* electron-builder hashes it, so the feed goes stale silently -- the zip, if shipped, has the same single well-formed bundle at its root +## Year View -Exit code 0 means safe to publish. Run it again on the copy downloaded back from -the release: that is the artifact users actually get. +Use the archive/export commands in `release-config/README.md`. Submit its ZIP +with `notarize.sh submit --app `, then run +`status` and `staple`. Recreate the final ZIP from the stapled app with +`ditto -c -k --keepParent`. ZIP files themselves cannot be stapled. -## Credentials +## Artifact verification -One notarytool keychain profile per machine, shared by all three repos: +Both formats are supported independently. For an Electron DMG+ZIP release: ```bash -xcrun notarytool store-credentials AC_PASSWORD \ - --apple-id --team-id R4RRG93J68 --password +scripts/release/verify-mac-artifact.sh \ + --dmg --zip \ + --feed --bundle-id --version ``` -`APPLE_KEYCHAIN_PROFILE` overrides the name (default `AC_PASSWORD`). The -credentials live in the macOS data-protection keychain, which is why they cannot -be read back out for CI — see `signing-and-release.md`. - -## Why signing stays off CI - -CI builds what needs no secrets (Windows, Linux, unsigned smoke builds and -tests) and stops there. macOS artifacts are built, signed, notarized and stapled -on a Mac, then uploaded. - -This is a decision, not a gap. The Apple credential cannot be minted from a CLI, -notarization is the one step that has never actually failed, and putting a -Developer ID private key in CI buys nothing that the local path does not already -do. Every historical mac CI failure across these repos was a *signing-path* -failure while Windows and Linux went green. - -The real fragility was always the manual assembly around notarization, which -stages 2–5 remove. - -## Per-repo entry points - -| Repo | Build | Notarizes | Publishes | -|---|---|---|---| -| marktext | `pnpm build:mac:arm64` | electron-builder (`notarize: true`) + `build/notarize-dmg.cjs` for the DMG | CI publishes win/linux; mac uploaded after stage 5 | -| ledger | `npm run build:mac:arm64` | **should** use stages 2–4; `scripts/notarize.js` is currently dead code (no `afterSign` wiring) | manual upload | -| year-view | `xcodebuild archive` + `-exportArchive` | stages 2–4 on the exported zip | manual upload | +For ZIP-only releases, omit `--dmg` and, when no updater exists, `--feed`. +Verification checks archive layout, the extracted app's identity/version, +Developer ID signature, stapled ticket and Gatekeeper acceptance with quarantine. +It checks every feed entry and legacy checksum; missing requested files fail. +DMGs are mounted read-only and verified as downloaded, including the copied app. +Verification does not replace the native UI walkthrough. + +## DMG copy permissions + +If the packager reports `ditto: /Volumes/.../App.app: Operation not permitted`, +retain its log and inspect `hdiutil info` plus the matching macOS `tccd` log. +Compare the exact packager, app and parent process before diagnosing the cause. +Do not detach unrelated volumes, change app identity, clear security attributes, +hand-assemble an installer or change OS permissions as an automatic workaround. +An App Management denial may need the user to grant permission to the build host +in System Settings. ZIP-only publication is an explicit release-format decision. + +## Per-repository native paths + +- Ledger: disposable Git repo; branch/worktree navigation, search, selected + stage/unstage/discard and read-only historical commits. Check Git state on disk. +- Marktext: disposable Markdown/profile; unchanged save, edit/undo, dirty prompts, + actual save, external reload and close. Compare file bytes. Include OS clipboard + and drag/drop when the change affects them. +- Year View: all layouts, year/today navigation, day details and preferences; + real calendar permissions and deep links when applicable. Keep calendar data + read-only and redact event details from recorded evidence. + +Marktext's tag workflow stages Windows/Linux artifacts in a draft. Add verified +Mac assets, regenerate the manifest for all assets, then publish. Update the +Homebrew cask only after the referenced DMG is published. diff --git a/release-config/README.md b/release-config/README.md index 53c4659..38a65fe 100644 --- a/release-config/README.md +++ b/release-config/README.md @@ -8,7 +8,7 @@ not build output, and belong in version control. download (`method: developer-id`, automatic signing) - `ExportOptions-AppStore.plist` — App Store export -Release procedure is the shared five-stage protocol in `../RELEASE-PROTOCOL.md`: +Release procedure is the shared release protocol in `../RELEASE-PROTOCOL.md`: ```bash xcodebuild archive -project YearView.xcodeproj -scheme YearView \ @@ -18,23 +18,22 @@ xcodebuild -exportArchive -archivePath build/YearView-GitHub.xcarchive \ -exportOptionsPlist release-config/ExportOptions-DirectDownload.plist \ -exportPath build/github-export -ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView.zip +ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView-submission.zip # Submit and collect status without waiting on Apple. -./scripts/release/notarize.sh submit build/YearView.zip +./scripts/release/notarize.sh submit --app build/github-export/YearView.app build/YearView-submission.zip ./scripts/release/notarize.sh status # After the status is Accepted, staple the app (ZIP files cannot be stapled). -xcrun stapler staple build/github-export/YearView.app -xcrun stapler validate build/github-export/YearView.app +./scripts/release/notarize.sh staple # Repackage the exported app with its ticket before publishing. ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView.zip ``` -Note `verify-mac-artifact.sh` is DMG-oriented; Year View ships a zip, so verify -the exported app directly: +Verify the final ZIP, including the extracted app, then complete the native +walkthrough in `scripts/release/LOCAL-COMPUTER-USE.md`: ```bash -spctl --assess --type execute -v build/github-export/YearView.app -xcrun stapler validate build/github-export/YearView.app +./scripts/release/verify-mac-artifact.sh --zip build/YearView.zip \ + --bundle-id com.yearview.app ``` diff --git a/scripts/release/.gitignore b/scripts/release/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/scripts/release/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/release/LOCAL-COMPUTER-USE.md b/scripts/release/LOCAL-COMPUTER-USE.md new file mode 100644 index 0000000..00a0be3 --- /dev/null +++ b/scripts/release/LOCAL-COMPUTER-USE.md @@ -0,0 +1,40 @@ +# Local native walkthrough + +Run this after automated tests and artifact verification, before publishing. +Use the actual signed app extracted from the candidate ZIP or copied from its +DMG. A development window or Playwright startup alone does not prove this gate. + +1. Record source commit, app version, archive SHA-256 and executable path. Confirm + the visible app belongs to that path; quit only test instances you launched. +2. Create disposable fixtures and an isolated app profile/settings file where + supported. Preserve existing documents, repository changes and calendar data. +3. Exercise the changed behavior through native UI: mouse, keyboard, menus and + dialogs. Use screenshots/accessibility state to observe results. Do not count + directly calling internal APIs or dispatching synthetic DOM events as native + computer-use evidence; those belong to automated tests/debugging. +4. Check meaningful side effects independently: saved file bytes, Git index and + worktree contents, preferences after relaunch. Cover cancel/undo/error paths + where the change can affect data. Test only relevant paths, not every control. +5. If a failure appears, reproduce it, fix it and add a focused regression test + when useful. Rebuild and repeat the affected paths on the final artifact. +6. Close test instances and remove only fixtures you created. Record any checks + that could not be exercised, with the reason. Never mark a blocked path passed. + +Store evidence beside the candidate outside tracked source, for example: + +```text +Source commit: +App version / executable: +Archive SHA-256: +OS / architecture: +Automated checks: +Native path | expected result | observed result | pass/fail +Side-effect checks: +Screenshots/log paths (redacted): +Limitations: +``` + +On Codex desktop, use the available native computer-use tool for UI actions. +CDP and Playwright are useful for debugging and automated checks; they do not +replace the native walkthrough. macOS privacy prompts require the user's +permission choices; do not silently change privacy settings to complete a test. diff --git a/scripts/release/mac_artifacts.py b/scripts/release/mac_artifacts.py new file mode 100644 index 0000000..2a8c9a8 --- /dev/null +++ b/scripts/release/mac_artifacts.py @@ -0,0 +1,143 @@ +"""Checks the bytes that will be distributed, independently of the build tree.""" +import argparse +import base64 +import hashlib +import json +from pathlib import Path, PurePosixPath +import plistlib +import re +import subprocess +import tempfile +import zipfile + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True).stdout + + +def digest(path): + return base64.b64encode(hashlib.sha512(path.read_bytes()).digest()).decode() + + +def scalar(value): + value = value.strip() + if value.startswith('"'): + return json.loads(value) + if value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + return value + + +def check_feed(feed): + text = feed.read_text() + entries = re.findall(r'^[ \t]*- url: (.+)\n((?:[ \t]+[A-Za-z][^\n]*\n?)*)', text, re.M) + if not entries: + raise ValueError(f'{feed}: no artifact entries') + seen = set() + for name, fields in entries: + name = scalar(name) + if name in seen or Path(name).name != name: + raise ValueError(f'{feed}: duplicate or nonlocal artifact {name}') + seen.add(name) + properties = dict(re.findall(r'^\s+(\w+):\s*(.+)$', fields, re.M)) + artifact = feed.parent / name + if not artifact.is_file(): + raise ValueError(f'{feed}: missing artifact {name}') + if (scalar(properties.get('sha512', '')) != digest(artifact) + or int(properties.get('size', '-1')) != artifact.stat().st_size): + raise ValueError(f'{feed}: stale checksum/size for {name}') + path = re.search(r'^path: (.+)$', text, re.M) + sha = re.search(r'^sha512: (.+)$', text, re.M) + if path or sha: + if not path or not sha or scalar(path[1]) not in seen: + raise ValueError(f'{feed}: invalid legacy path/sha512') + if scalar(sha[1]) != digest(feed.parent / scalar(path[1])): + raise ValueError(f'{feed}: stale legacy sha512') + + +def check_zip(path): + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + for name in names: + parts = PurePosixPath(name).parts + if name.startswith('/') or '..' in parts: + raise ValueError(f'{path}: unsafe archive path {name}') + roots = {n.split('/')[0] for n in names if n.split('/')[0].endswith('.app')} + if len(roots) != 1: + raise ValueError(f'{path}: expected exactly one root app') + root = next(iter(roots)) + if f'{root}/Contents/Info.plist' not in names: + raise ValueError(f'{path}: missing root app Contents/Info.plist (nested bundle?)') + if f'{root}/{root}/Contents/Info.plist' in names: + raise ValueError(f'{path}: nested duplicate app') + return root + + +def verify_app(app, bundle_id, version): + with (app / 'Contents/Info.plist').open('rb') as stream: + info = plistlib.load(stream) + if info.get('CFBundleIdentifier') != bundle_id: + raise ValueError(f'{app}: unexpected bundle identifier') + if version and info.get('CFBundleShortVersionString') != version: + raise ValueError(f'{app}: unexpected app version') + run('codesign', '--verify', '--deep', '--strict', str(app)) + signature = subprocess.run(['codesign', '-dv', '--verbose=2', str(app)], + check=True, capture_output=True, text=True).stderr + if 'Authority=Developer ID Application:' not in signature: + raise ValueError(f'{app}: not signed with Developer ID Application') + run('xcrun', 'stapler', 'validate', str(app)) + run('spctl', '--assess', '--type', 'execute', '--verbose=2', str(app)) + print(f'PASS signature, notarization, Gatekeeper and identity: {app.name}') + + +def verify(args): + if not args.dmg and not args.zip: + raise ValueError('provide --dmg and/or --zip') + for path in (args.dmg, args.zip, args.feed): + if path and not path.is_file(): + raise ValueError(f'missing input: {path}') + with tempfile.TemporaryDirectory(prefix='mac-artifact-') as temporary: + work = Path(temporary) + if args.zip: + root = check_zip(args.zip) + destination = work / 'zip' + run('ditto', '-x', '-k', str(args.zip.resolve()), str(destination)) + app = destination / root + run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(app)) + verify_app(app, args.bundle_id, args.version) + if args.dmg: + copied = work / args.dmg.name + run('ditto', str(args.dmg.resolve()), str(copied)) + run('xcrun', 'stapler', 'validate', str(copied)) + run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(copied)) + run('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', str(copied)) + mount = work / 'mount' + run('hdiutil', 'attach', '-nobrowse', '-readonly', '-mountpoint', str(mount), str(copied)) + try: + apps = list(mount.glob('*.app')) + if len(apps) != 1: + raise ValueError('DMG must contain exactly one root app') + # Verify the installed copy as well as the disk image itself. + app = work / 'installed' / apps[0].name + run('ditto', str(apps[0]), str(app)) + run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(app)) + verify_app(app, args.bundle_id, args.version) + finally: + run('hdiutil', 'detach', str(mount)) + if args.feed: + check_feed(args.feed) + print('PASS update feed checksums and sizes') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--dmg', type=Path) + parser.add_argument('--zip', type=Path) + parser.add_argument('--feed', type=Path) + parser.add_argument('--bundle-id', required=True) + parser.add_argument('--version') + try: + verify(parser.parse_args()) + except (ValueError, OSError, subprocess.CalledProcessError, zipfile.BadZipFile) as error: + parser.exit(1, f'verify: {error}\n{getattr(error, "stderr", "") or ""}') + print('All artifact checks passed. Complete the native walkthrough before publication.') diff --git a/scripts/release/notarize.py b/scripts/release/notarize.py new file mode 100644 index 0000000..8bb2296 --- /dev/null +++ b/scripts/release/notarize.py @@ -0,0 +1,108 @@ +"""Resumable notarization; pending or invalid submissions cannot pass stapling.""" +import argparse +import datetime +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +PROFILE = os.environ.get('APPLE_KEYCHAIN_PROFILE', 'AC_PASSWORD') +STATE = Path(os.environ.get('NOTARIZE_STATE', 'dist/.notarize-state.json')).resolve() + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def apple(*args): + result = subprocess.run(['xcrun', 'notarytool', *args, '--keychain-profile', PROFILE, + '--output-format', 'json'], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def save(state): + STATE.parent.mkdir(parents=True, exist_ok=True) + temporary = STATE.with_suffix('.tmp') + temporary.write_text(json.dumps(state, indent=2) + '\n') + temporary.replace(STATE) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + submit = sub.add_parser('submit') + submit.add_argument('--app', type=Path, help='App to staple when its submission ZIP is accepted') + submit.add_argument('artifacts', nargs='+', type=Path) + sub.add_parser('status') + sub.add_parser('staple') + log = sub.add_parser('log') + log.add_argument('artifact') + sub.add_parser('reset') + args = parser.parse_args() + state = json.loads(STATE.read_text()) if STATE.exists() else {} + if args.command == 'reset': + STATE.unlink(missing_ok=True) + return + if args.command == 'submit': + if args.app and (len(args.artifacts) != 1 or args.artifacts[0].suffix != '.zip' or not args.app.is_dir()): + raise ValueError('--app requires one submission ZIP and an existing app') + for artifact in args.artifacts: + artifact = artifact.resolve() + fingerprint = sha(artifact) + old = state.get(artifact.name) + if old: + if old.get('sha256') != fingerprint or old['path'] != str(artifact): + raise ValueError(f'{artifact.name}: recorded artifact differs; use a new state/output directory') + print(f'{artifact.name}: already submitted as {old["id"]}; use status/log') + continue + if artifact.suffix == '.zip' and not args.app: + raise ValueError('ZIP submissions require --app; ZIPs themselves cannot be stapled') + result = apple('submit', str(artifact)) + state[artifact.name] = {'id': result['id'], 'path': str(artifact), 'sha256': fingerprint, + 'app': str(args.app.resolve()) if args.app else None, + 'submitted': datetime.datetime.now(datetime.timezone.utc).isoformat()} + save(state) + print(f'{artifact.name}: submitted as {result["id"]}') + print(f'State: {STATE}\nRun status, then staple after acceptance.') + return + if not state: + raise ValueError(f'No submissions in {STATE}') + if args.command == 'log': + entry = state[Path(args.artifact).name] + subprocess.run(['xcrun', 'notarytool', 'log', entry['id'], '--keychain-profile', PROFILE], check=True) + return + incomplete = False + for name, entry in state.items(): + status = apple('info', entry['id'])['status'] + print(f'{name}: {status}', flush=True) + if status != 'Accepted': + incomplete = True + continue + if args.command != 'staple': + continue + artifact = Path(entry['path']) + if entry.get('sha256') and sha(artifact) != entry['sha256']: + raise ValueError(f'{name}: bytes changed since submission/stapling') + target = entry.get('app') or str(artifact) + if target.endswith('.zip'): + raise ValueError(f'{name}: legacy ZIP state has no app; staple its app explicitly') + subprocess.run(['xcrun', 'stapler', 'staple', target], check=True) + subprocess.run(['xcrun', 'stapler', 'validate', target], check=True) + entry['sha256'] = sha(artifact) + save(state) + for feed in artifact.parent.glob('latest*.yml'): + subprocess.run([sys.executable, str(Path(__file__).with_name('refresh-feed.py')), + str(feed), name, str(artifact)], check=True) + if incomplete: + print('Not all submissions are accepted. Keep this candidate and check again later.', file=sys.stderr) + return 2 + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main() or 0) + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: + sys.exit(f'notarize: {error}\n{getattr(error, "stderr", "") or ""}') diff --git a/scripts/release/notarize.sh b/scripts/release/notarize.sh index 8dd5584..501dd98 100755 --- a/scripts/release/notarize.sh +++ b/scripts/release/notarize.sh @@ -1,236 +1,4 @@ #!/usr/bin/env bash -# ============================================================================ -# notarize.sh — detached macOS notarization -# ============================================================================ -# Canonical source: peterjthomson/marktext scripts/release/notarize.sh -# Shared verbatim with peterjthomson/ledger and peterjthomson/year-view. -# -# WHY THIS IS NOT `notarytool submit --wait` -# -# Apple's notary service is usually minutes and occasionally *over a day*. A -# build that blocks on `--wait` turns that into a lost build: the terminal is -# stuck, CI times out, and the human works around it by hand — which is how a -# release ends up assembled manually and, eventually, shipped broken. -# -# So submission and collection are separate commands with state on disk: -# -# ./notarize.sh submit dist/*.dmg # returns as soon as the upload is done -# ./notarize.sh status # safe to run a minute or a week later -# ./notarize.sh staple # staples everything Apple has Accepted -# -# The build is never held hostage. If Apple takes 26 hours, you run `status` -# tomorrow and `staple` when it clears; nothing needs rebuilding, because the -# ticket is stapled to the artifact you already have. -# -# Credentials come from a notarytool keychain profile (APPLE_KEYCHAIN_PROFILE, -# default AC_PASSWORD), created once with: -# xcrun notarytool store-credentials AC_PASSWORD \ -# --apple-id --team-id --password -# ============================================================================ - +# Shared with Marktext and Year View. Submission returns without waiting on Apple. set -euo pipefail - -PROFILE="${APPLE_KEYCHAIN_PROFILE:-AC_PASSWORD}" -STATE="${NOTARIZE_STATE:-dist/.notarize-state.json}" - -die() { - echo "notarize: $*" >&2 - exit 1 -} - -require_tools() { - command -v xcrun >/dev/null || die "xcrun not found (install Xcode Command Line Tools)" - command -v python3 >/dev/null || die "python3 not found" - xcrun notarytool history --keychain-profile "$PROFILE" >/dev/null 2>&1 || - die "keychain profile '$PROFILE' does not authenticate. Create it with: - xcrun notarytool store-credentials $PROFILE --apple-id --team-id --password " -} - -# State is a JSON object: { "": {id, path, submitted} } -state_py() { - python3 - "$STATE" "$@" -} - -usage() { - cat <<'EOF' -Usage: - notarize.sh submit ... Upload artifacts, record submission ids, return - notarize.sh status Print Apple's status for every recorded submission - notarize.sh staple Staple + validate every Accepted artifact - notarize.sh log Fetch Apple's detailed log for one artifact - notarize.sh reset Forget recorded submissions (does not cancel them) - -Env: - APPLE_KEYCHAIN_PROFILE notarytool profile name (default: AC_PASSWORD) - NOTARIZE_STATE state file path (default: dist/.notarize-state.json) -EOF -} - -cmd_submit() { - [ $# -gt 0 ] || die "submit needs at least one artifact" - require_tools - mkdir -p "$(dirname "$STATE")" - [ -f "$STATE" ] || echo '{}' >"$STATE" - - for artifact in "$@"; do - [ -f "$artifact" ] || die "no such file: $artifact" - local name - name="$(basename "$artifact")" - - echo "notarize: uploading $name ..." - # No --wait: we want the id, not a blocked terminal. - local out id - out="$(xcrun notarytool submit "$artifact" --keychain-profile "$PROFILE" --output-format json)" - id="$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" - - state_py "$name" "$id" "$artifact" <<'PY' -import json, sys, datetime -state_path, name, sid, path = sys.argv[1:5] -try: - with open(state_path) as fh: - data = json.load(fh) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -data[name] = {"id": sid, "path": path, "submitted": datetime.datetime.now().isoformat(timespec="seconds")} -with open(state_path, "w") as fh: - json.dump(data, fh, indent=2) -PY - echo "notarize: $name submitted as $id" - done - - echo "" - echo "notarize: submissions recorded in $STATE" - echo "notarize: run './notarize.sh status' whenever you like — minutes or days from now." -} - -# Prints " " per line and returns 1 if anything is still In Progress. -collect_status() { - local pending=0 - while read -r name id; do - [ -n "$name" ] || continue - local status - status="$(xcrun notarytool info "$id" --keychain-profile "$PROFILE" --output-format json 2>/dev/null | - python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","Unknown"))' 2>/dev/null || echo "Unknown")" - printf ' %-56s %s\n' "$name" "$status" - [ "$status" = "In Progress" ] && pending=1 - done < <(state_py <<'PY' -import json, sys -try: - with open(sys.argv[1]) as fh: - data = json.load(fh) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -for name, entry in data.items(): - print(name, entry["id"]) -PY -) - return $pending -} - -cmd_status() { - require_tools - [ -f "$STATE" ] || die "no submissions recorded in $STATE" - echo "notarize: submission status" - collect_status || { - echo "" - echo "notarize: still in progress. Apple can take minutes or over a day; nothing is lost by waiting." - return 0 - } -} - -# Stapling appends the ticket, so every sha512/size an installer feed recorded -# before this point is now wrong. electron-updater refuses a download whose -# checksum disagrees with the feed, and the failure is silent until someone -# tries to update. Refresh any latest*.yml sitting beside the artifact. -refresh_feeds() { - local artifact="$1" - local dir name - dir="$(dirname "$artifact")" - name="$(basename "$artifact")" - for feed in "$dir"/latest*.yml; do - [ -f "$feed" ] || continue - python3 "$(dirname "${BASH_SOURCE[0]}")/refresh-feed.py" "$feed" "$name" "$artifact" - done -} - -cmd_staple() { - require_tools - [ -f "$STATE" ] || die "no submissions recorded in $STATE" - - local stapled=0 skipped=0 - while read -r name id path; do - [ -n "$name" ] || continue - local status - status="$(xcrun notarytool info "$id" --keychain-profile "$PROFILE" --output-format json 2>/dev/null | - python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","Unknown"))')" - - if [ "$status" != "Accepted" ]; then - echo "notarize: skipping $name (status: $status)" - skipped=$((skipped + 1)) - continue - fi - [ -f "$path" ] || die "$name was Accepted but $path is gone — rebuild would need re-notarizing" - - xcrun stapler staple "$path" - # Fail loudly rather than ship an artifact whose ticket did not attach. - xcrun stapler validate "$path" - refresh_feeds "$path" - echo "notarize: $name stapled and validated" - stapled=$((stapled + 1)) - done < <(state_py <<'PY' -import json, sys -try: - with open(sys.argv[1]) as fh: - data = json.load(fh) -except (FileNotFoundError, json.JSONDecodeError): - data = {} -for name, entry in data.items(): - print(name, entry["id"], entry["path"]) -PY -) - - echo "notarize: $stapled stapled, $skipped skipped" - [ "$skipped" -eq 0 ] || echo "notarize: re-run './notarize.sh staple' once the rest are Accepted" -} - -cmd_log() { - [ $# -eq 1 ] || die "log needs exactly one artifact name" - require_tools - local id - id="$(state_py "$1" <<'PY' -import json, sys -state_path, name = sys.argv[1:3] -with open(state_path) as fh: - data = json.load(fh) -entry = data.get(name) or data.get(__import__("os").path.basename(name)) -print(entry["id"] if entry else "") -PY -)" - [ -n "$id" ] || die "no recorded submission for $1" - xcrun notarytool log "$id" --keychain-profile "$PROFILE" -} - -case "${1:-}" in - submit) - shift - cmd_submit "$@" - ;; - status) - cmd_status - ;; - staple) - cmd_staple - ;; - log) - shift - cmd_log "$@" - ;; - reset) - rm -f "$STATE" - echo "notarize: cleared $STATE" - ;; - *) - usage - exit 1 - ;; -esac +exec python3 "$(dirname "$0")/notarize.py" "$@" diff --git a/scripts/release/refresh-feed.py b/scripts/release/refresh-feed.py index 864a301..da31149 100644 --- a/scripts/release/refresh-feed.py +++ b/scripts/release/refresh-feed.py @@ -19,6 +19,9 @@ text = open(feed).read() pattern = re.compile(r"(- url: %s\s*\n\s*sha512: )\S+(\s*\n\s*size: )\d+" % re.escape(name)) new, count = pattern.subn(lambda m: f"{m.group(1)}{sha}{m.group(2)}{size}", text) +legacy_path = re.search(r"^path: (.+)$", text, re.M) +if legacy_path and legacy_path.group(1).strip("\"'") == name: + new = re.sub(r"^sha512: .+$", f"sha512: {sha}", new, flags=re.M) if count and new != text: open(feed, "w").write(new) print(f"notarize: refreshed {name} checksums in {feed.split('/')[-1]}") diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py new file mode 100644 index 0000000..e39a88e --- /dev/null +++ b/scripts/release/test_release.py @@ -0,0 +1,92 @@ +import contextlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +import mac_artifacts as artifacts +import notarize + + +class ArtifactTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + + def test_zip_names_with_spaces_and_nested_bundle_rejected(self): + valid = self.root / 'valid.zip' + with zipfile.ZipFile(valid, 'w') as archive: + archive.writestr('Oh My Marktext.app/Contents/Info.plist', b'plist') + self.assertEqual(artifacts.check_zip(valid), 'Oh My Marktext.app') + nested = self.root / 'nested.zip' + with zipfile.ZipFile(nested, 'w') as archive: + archive.writestr('Ledger.app/Ledger.app/Contents/Info.plist', b'plist') + with self.assertRaisesRegex(ValueError, 'nested bundle'): + artifacts.check_zip(nested) + + def test_missing_feed_artifact_and_stale_legacy_digest_fail(self): + asset = self.root / 'app.zip' + asset.write_bytes(b'original release') + feed = self.root / 'latest-mac.yml' + feed.write_text(f'version: 1.0.0\nfiles:\n - url: app.zip\n sha512: {artifacts.digest(asset)}\n size: {asset.stat().st_size}\npath: app.zip\nsha512: incorrect\n') + with self.assertRaisesRegex(ValueError, 'legacy sha512'): + artifacts.check_feed(feed) + feed.write_text(feed.read_text().replace('sha512: incorrect', f'sha512: {artifacts.digest(asset)}')) + artifacts.check_feed(feed) + asset.unlink() + with self.assertRaisesRegex(ValueError, 'missing artifact'): + artifacts.check_feed(feed) + + def test_stapling_refreshes_legacy_and_file_digest(self): + import subprocess + import sys + asset = self.root / 'App.dmg' + asset.write_bytes(b'after stapling') + feed = self.root / 'latest-mac.yml' + feed.write_text('version: 1\nfiles:\n - url: App.dmg\n sha512: old\n size: 1\npath: App.dmg\nsha512: old\n') + subprocess.run([sys.executable, str(Path(__file__).with_name('refresh-feed.py')), str(feed), asset.name, str(asset)], check=True, capture_output=True) + artifacts.check_feed(feed) + + def test_feed_multiple_artifacts_do_not_mix_checksums(self): + first = self.root / 'app.zip' + second = self.root / 'app.dmg' + first.write_bytes(b'zip') + second.write_bytes(b'disk image') + feed = self.root / 'latest-mac.yml' + feed.write_text('version: 1\nfiles:\n' + ''.join( + f' - url: {p.name}\n sha512: {artifacts.digest(p)}\n size: {p.stat().st_size}\n' + for p in [first, second])) + artifacts.check_feed(feed) + second.write_bytes(b'corrupt') + with self.assertRaisesRegex(ValueError, 'stale checksum/size for app.dmg'): + artifacts.check_feed(feed) + + def test_requested_missing_zip_fails_before_system_commands(self): + from argparse import Namespace + with self.assertRaisesRegex(ValueError, 'missing input'): + artifacts.verify(Namespace(dmg=None, zip=self.root/'missing.zip', feed=None, + bundle_id='com.example.app', version=None)) + + def test_pending_notarization_cannot_staple(self): + state = self.root / 'state.json' + state.write_text(json.dumps({'app.zip': {'id': 'submission', 'path': '/unused'}})) + with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'In Progress'}), patch.object(notarize.subprocess, 'run') as runner, patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(notarize.main(), 2) + runner.assert_not_called() + + def test_changed_artifact_cannot_be_stapled(self): + asset = self.root / 'App.dmg' + asset.write_bytes(b'changed') + state = self.root / 'state.json' + state.write_text(json.dumps({'App.dmg': {'id': 'submission', 'path': str(asset), 'sha256': 'original'}})) + with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}), patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()): + with self.assertRaisesRegex(ValueError, 'bytes changed'): + notarize.main() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/release/verify-mac-artifact.sh b/scripts/release/verify-mac-artifact.sh index 7a9a677..c7be0f0 100755 --- a/scripts/release/verify-mac-artifact.sh +++ b/scripts/release/verify-mac-artifact.sh @@ -1,195 +1,4 @@ #!/usr/bin/env bash -# ============================================================================ -# verify-mac-artifact.sh — acceptance gate for a macOS release artifact -# ============================================================================ -# Canonical source: peterjthomson/marktext scripts/release/verify-mac-artifact.sh -# Shared verbatim with peterjthomson/ledger and peterjthomson/year-view. -# -# Run this on the artifact you are about to publish — and ideally again on the -# copy you download back from the release. Every check here exists because -# something shipped without it: -# -# * Ledger 1.5.0's DMG contained `Ledger.app/Ledger.app`. The inner app was -# perfectly signed, notarized and stapled; the outer one was a folder -# wearing the extension, so Finder showed a broken item and dragging it to -# Applications installed something that could not launch. Nothing in the -# pipeline looked inside the DMG, so nothing caught it. -# * Oh My Marktext shipped DMG checksums in latest-mac.yml that described the -# pre-staple bytes, because stapling rewrites the file after -# electron-builder hashes it. -# -# Usage: -# verify-mac-artifact.sh --dmg dist/App-1.0.0.dmg --bundle-id com.example.app \ -# [--feed dist/latest-mac.yml] [--zip dist/App-1.0.0-mac.zip] -# ============================================================================ - +# Shared with Marktext and Year View; ZIP-only and DMG releases use the same gate. set -euo pipefail - -DMG="" -ZIP="" -FEED="" -BUNDLE_ID="" -FAILURES=0 -MOUNTED="" - -pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; } -fail() { - printf ' \033[31m✗\033[0m %s\n' "$1" - FAILURES=$((FAILURES + 1)) -} -info() { printf ' · %s\n' "$1"; } - -cleanup() { - [ -n "$MOUNTED" ] && hdiutil detach "$MOUNTED" -quiet 2>/dev/null || true -} -trap cleanup EXIT - -while [ $# -gt 0 ]; do - case "$1" in - --dmg) DMG="$2"; shift 2 ;; - --zip) ZIP="$2"; shift 2 ;; - --feed) FEED="$2"; shift 2 ;; - --bundle-id) BUNDLE_ID="$2"; shift 2 ;; - *) echo "verify: unknown argument $1" >&2; exit 2 ;; - esac -done - -[ -n "$DMG" ] || { echo "verify: --dmg is required" >&2; exit 2; } -[ -f "$DMG" ] || { echo "verify: no such file: $DMG" >&2; exit 2; } - -echo "Verifying $(basename "$DMG")" - -# --- the disk image itself ------------------------------------------------- -if xcrun stapler validate "$DMG" >/dev/null 2>&1; then - pass "DMG has a stapled notarization ticket" -else - fail "DMG is not stapled — offline installs will need to reach Apple to verify" -fi - -# Simulate a browser download so Gatekeeper evaluates it the way a user's Mac will. -WORK="$(mktemp -d)" -cp "$DMG" "$WORK/" -QUARANTINED="$WORK/$(basename "$DMG")" -xattr -w com.apple.quarantine "0081;00000000;Verify;" "$QUARANTINED" 2>/dev/null || true -if printf '%s' "$(spctl -a -t open --context context:primary-signature -v "$QUARANTINED" 2>&1 || true)" | grep -q accepted; then - pass "Gatekeeper accepts the quarantined DMG" -else - fail "Gatekeeper rejects the quarantined DMG" -fi - -# --- what is actually inside it -------------------------------------------- -MOUNTED="$(hdiutil attach -nobrowse -readonly "$QUARANTINED" 2>/dev/null | - awk -F'\t' '/\/Volumes\//{print $NF}' | tail -1)" -[ -n "$MOUNTED" ] || { fail "DMG failed to mount"; echo ""; echo "verify: $FAILURES check(s) failed"; exit 1; } -info "mounted at $MOUNTED" - -APP_COUNT="$(find "$MOUNTED" -maxdepth 1 -name "*.app" | wc -l | tr -d ' ')" -if [ "$APP_COUNT" -eq 1 ]; then - pass "exactly one .app at the volume root" -else - fail "expected exactly one .app at the volume root, found $APP_COUNT" -fi - -APP="$(find "$MOUNTED" -maxdepth 1 -name "*.app" | head -1)" -if [ -n "$APP" ]; then - # The nesting check: a real bundle has Contents/Info.plist directly inside. - if [ -f "$APP/Contents/Info.plist" ]; then - pass "$(basename "$APP") is a well-formed bundle" - else - fail "$(basename "$APP") has no Contents/Info.plist — it is a folder, not a bundle" - if [ -d "$APP/$(basename "$APP")" ]; then - fail "bundle is nested: $(basename "$APP")/$(basename "$APP") — users get a broken drag-to-Applications" - fi - fi - - if [ -n "$BUNDLE_ID" ] && [ -f "$APP/Contents/Info.plist" ]; then - ACTUAL="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Contents/Info.plist" 2>/dev/null || echo "")" - if [ "$ACTUAL" = "$BUNDLE_ID" ]; then - pass "bundle identifier is $BUNDLE_ID" - else - fail "bundle identifier is '$ACTUAL', expected '$BUNDLE_ID'" - fi - fi - - if codesign --verify --deep --strict "$APP" 2>/dev/null; then - pass "code signature is valid (deep, strict)" - else - fail "code signature is invalid" - fi - - SIGN_INFO="$(codesign -dv --verbose=2 "$APP" 2>&1 || true)" - if printf '%s' "$SIGN_INFO" | grep -q "Authority=Developer ID Application"; then - pass "signed with a Developer ID Application certificate" - else - fail "not signed with a Developer ID Application certificate" - fi - - if xcrun stapler validate "$APP" >/dev/null 2>&1; then - pass "app has its own stapled ticket" - else - fail "app is not stapled — it will need a network check once copied out of the DMG" - fi - - if printf '%s' "$(spctl --assess --type execute -v "$APP" 2>&1 || true)" | grep -q accepted; then - pass "Gatekeeper accepts the app for execution" - else - fail "Gatekeeper rejects the app" - fi -fi - -# --- update feed ----------------------------------------------------------- -# electron-updater refuses a download whose sha512/size disagree with the feed, -# and stapling changes both after electron-builder writes them. -if [ -n "$FEED" ] && [ -f "$FEED" ]; then - DIR="$(dirname "$FEED")" - while read -r url; do - [ -n "$url" ] || continue - target="$DIR/$url" - if [ ! -f "$target" ]; then - info "feed references $url (not present locally, skipping)" - continue - fi - want_sha="$(python3 - "$FEED" "$url" <<'PY' -import re, sys -feed, url = sys.argv[1:3] -text = open(feed).read() -m = re.search(r'- url: %s\s*\n\s*sha512: (\S+)\s*\n\s*size: (\d+)' % re.escape(url), text) -print(m.group(1) if m else "") -PY -)" - want_size="$(python3 - "$FEED" "$url" <<'PY' -import re, sys -feed, url = sys.argv[1:3] -text = open(feed).read() -m = re.search(r'- url: %s\s*\n\s*sha512: \S+\s*\n\s*size: (\d+)' % re.escape(url), text) -print(m.group(1) if m else "") -PY -)" - got_sha="$(openssl dgst -sha512 -binary "$target" | openssl base64 -A)" - got_size="$(stat -f%z "$target")" - if [ "$want_sha" = "$got_sha" ] && [ "$want_size" = "$got_size" ]; then - pass "feed entry matches $url" - else - fail "feed entry for $url is stale (size $want_size vs $got_size)" - fi - done < <(grep -E '^[[:space:]]+- url: ' "$FEED" | sed -E 's/^[[:space:]]*- url: //') -fi - -# --- zip, if the release ships one ---------------------------------------- -if [ -n "$ZIP" ] && [ -f "$ZIP" ]; then - TOP="$(unzip -l "$ZIP" | awk '{print $4}' | grep -E '^[^/]+\.app/' | cut -d/ -f1 | sort -u | wc -l | tr -d ' ')" - ZIP_LIST="$(unzip -l "$ZIP" 2>/dev/null || true)" - if [ "$TOP" = "1" ] && printf '%s' "$ZIP_LIST" | grep -q '\.app/Contents/'; then - pass "zip contains one well-formed .app at its root" - else - fail "zip layout is wrong (expected a single .app/Contents/ at the root)" - fi -fi - -echo "" -if [ "$FAILURES" -eq 0 ]; then - echo "verify: all checks passed — safe to publish" -else - echo "verify: $FAILURES check(s) failed — do not publish" - exit 1 -fi +exec python3 "$(dirname "$0")/mac_artifacts.py" "$@" From 38c8384b9848f5a974677ed4042bc828695dfc58 Mon Sep 17 00:00:00 2001 From: Peter Thomson Date: Fri, 25 Sep 2026 13:16:57 +1200 Subject: [PATCH 2/4] Preserve valid notarization tickets when resuming releases --- scripts/release/notarize.py | 8 ++++++-- scripts/release/test_release.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/release/notarize.py b/scripts/release/notarize.py index 8bb2296..fb64547 100644 --- a/scripts/release/notarize.py +++ b/scripts/release/notarize.py @@ -88,8 +88,12 @@ def main(): target = entry.get('app') or str(artifact) if target.endswith('.zip'): raise ValueError(f'{name}: legacy ZIP state has no app; staple its app explicitly') - subprocess.run(['xcrun', 'stapler', 'staple', target], check=True) - subprocess.run(['xcrun', 'stapler', 'validate', target], check=True) + validation = subprocess.run(['xcrun', 'stapler', 'validate', target], capture_output=True) + if validation.returncode != 0: + subprocess.run(['xcrun', 'stapler', 'staple', target], check=True) + subprocess.run(['xcrun', 'stapler', 'validate', target], check=True) + else: + print(f'{name}: ticket already valid; preserving stapled bytes') entry['sha256'] = sha(artifact) save(state) for feed in artifact.parent.glob('latest*.yml'): diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py index e39a88e..9a18179 100644 --- a/scripts/release/test_release.py +++ b/scripts/release/test_release.py @@ -78,6 +78,18 @@ def test_pending_notarization_cannot_staple(self): self.assertEqual(notarize.main(), 2) runner.assert_not_called() + def test_valid_ticket_is_not_stapled_again(self): + from types import SimpleNamespace + asset = self.root / 'app.zip' + asset.write_bytes(b'submission') + state = self.root / 'state.json' + state.write_text(json.dumps({'app.zip': {'id': 'submission', 'path': str(asset), + 'sha256': notarize.sha(asset), 'app': str(self.root / 'App With Spaces.app')}})) + with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}), patch.object(notarize.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as runner, patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(notarize.main(), 0) + self.assertEqual(runner.call_count, 1) + self.assertEqual(runner.call_args.args[0][2], 'validate') + def test_changed_artifact_cannot_be_stapled(self): asset = self.root / 'App.dmg' asset.write_bytes(b'changed') From 70d0d733391a5ab37c7fed4d1bcc4136126bc16d Mon Sep 17 00:00:00 2001 From: Peter Thomson Date: Fri, 25 Sep 2026 13:19:06 +1200 Subject: [PATCH 3/4] Allow notarization collection for individual release stages --- RELEASE-PROTOCOL.md | 8 +++++--- scripts/release/notarize.py | 10 ++++++++-- scripts/release/test_release.py | 13 +++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/RELEASE-PROTOCOL.md b/RELEASE-PROTOCOL.md index 0c6cd5c..a819ef8 100644 --- a/RELEASE-PROTOCOL.md +++ b/RELEASE-PROTOCOL.md @@ -54,15 +54,17 @@ with a fresh output directory; never overwrite the previous candidate. ```bash export NOTARIZE_STATE="$PWD/dist/release-/notarize-state.json" scripts/release/notarize.sh status -scripts/release/notarize.sh staple +scripts/release/notarize.sh staple app.zip # Only after the app is Accepted and stapled: npm run release:package # Marktext: pnpm release:package scripts/release/notarize.sh submit dist/release-/artifacts/*.dmg scripts/release/notarize.sh status -scripts/release/notarize.sh staple +scripts/release/notarize.sh staple dist/release-/artifacts/*.dmg ``` -`status` and `staple` exit 2 while any submission is not accepted. Use `log +`status` exits 2 while any submission is not accepted. `staple` exits 2 while +any selected submission is not accepted; pass names to staple only the current +stage (app, then DMG). Use `log ` to investigate an Invalid submission. Submission is idempotent for unchanged bytes; changed artifacts require a new candidate/state file. The stored `AC_PASSWORD` keychain profile is shared; `APPLE_KEYCHAIN_PROFILE` diff --git a/scripts/release/notarize.py b/scripts/release/notarize.py index fb64547..f6abdaa 100644 --- a/scripts/release/notarize.py +++ b/scripts/release/notarize.py @@ -36,7 +36,8 @@ def main(): submit.add_argument('--app', type=Path, help='App to staple when its submission ZIP is accepted') submit.add_argument('artifacts', nargs='+', type=Path) sub.add_parser('status') - sub.add_parser('staple') + staple = sub.add_parser('staple') + staple.add_argument('artifacts', nargs='*', help='Optional recorded artifact names for this stage') log = sub.add_parser('log') log.add_argument('artifact') sub.add_parser('reset') @@ -73,8 +74,13 @@ def main(): entry = state[Path(args.artifact).name] subprocess.run(['xcrun', 'notarytool', 'log', entry['id'], '--keychain-profile', PROFILE], check=True) return + if args.command == 'staple' and args.artifacts: + names = [Path(name).name for name in args.artifacts] + state_selection = {name: state[name] for name in names} + else: + state_selection = state incomplete = False - for name, entry in state.items(): + for name, entry in state_selection.items(): status = apple('info', entry['id'])['status'] print(f'{name}: {status}', flush=True) if status != 'Accepted': diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py index 9a18179..e549dd5 100644 --- a/scripts/release/test_release.py +++ b/scripts/release/test_release.py @@ -90,6 +90,19 @@ def test_valid_ticket_is_not_stapled_again(self): self.assertEqual(runner.call_count, 1) self.assertEqual(runner.call_args.args[0][2], 'validate') + def test_staple_can_select_the_current_release_stage(self): + from types import SimpleNamespace + asset = self.root / 'App.dmg' + asset.write_bytes(b'dmg') + state = self.root / 'state.json' + state.write_text(json.dumps({ + 'app.zip': {'id': 'old-app', 'path': '/source-no-longer-used'}, + 'App.dmg': {'id': 'disk', 'path': str(asset), 'sha256': notarize.sha(asset)}})) + with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}) as apple, patch.object(notarize.subprocess, 'run', return_value=SimpleNamespace(returncode=0)), patch('sys.argv', ['notarize', 'staple', str(asset)]), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(notarize.main(), 0) + apple.assert_called_once_with('info', 'disk') + self.assertIn('app.zip', json.loads(state.read_text())) + def test_changed_artifact_cannot_be_stapled(self): asset = self.root / 'App.dmg' asset.write_bytes(b'changed') From 8b0051cb981aed1a8dcbac035b346b56fb307e71 Mon Sep 17 00:00:00 2001 From: Peter Thomson Date: Fri, 25 Sep 2026 22:17:08 +1200 Subject: [PATCH 4/4] Keep release checks local and restore existing build workflows --- .github/workflows/ci.yml | 3 + .github/workflows/release-tooling.yml | 17 -- CLAUDE.md | 6 - RELEASE-PROTOCOL.md | 145 +++------------ release-config/README.md | 19 +- scripts/release/LOCAL-COMPUTER-USE.md | 40 ----- scripts/release/mac_artifacts.py | 143 --------------- scripts/release/notarize.py | 118 ------------- scripts/release/notarize.sh | 234 ++++++++++++++++++++++++- scripts/release/refresh-feed.py | 3 - scripts/release/test_release.py | 117 ------------- scripts/release/test_verify_zip.py | 42 +++++ scripts/release/verify-mac-artifact.sh | 193 +++++++++++++++++++- scripts/release/verify-zip.py | 57 ++++++ 14 files changed, 562 insertions(+), 575 deletions(-) delete mode 100644 .github/workflows/release-tooling.yml delete mode 100644 scripts/release/LOCAL-COMPUTER-USE.md delete mode 100644 scripts/release/mac_artifacts.py delete mode 100644 scripts/release/notarize.py delete mode 100644 scripts/release/test_release.py create mode 100644 scripts/release/test_verify_zip.py create mode 100644 scripts/release/verify-zip.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09a5520..77d1c8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,9 @@ jobs: - name: Checkout uses: actions/checkout@v7 + - name: Release ZIP checks + run: python3 -m unittest discover -s scripts/release -p 'test_*.py' + - name: Unit tests run: | set -o pipefail diff --git a/.github/workflows/release-tooling.yml b/.github/workflows/release-tooling.yml deleted file mode 100644 index 9381088..0000000 --- a/.github/workflows/release-tooling.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Release tooling -on: - pull_request: - paths: ['scripts/release/**', '.github/workflows/release-tooling.yml'] - push: - paths: ['scripts/release/**', '.github/workflows/release-tooling.yml'] -permissions: - contents: read -jobs: - release-tooling: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - run: python3 -m unittest discover -s scripts/release -p 'test_*.py' diff --git a/CLAUDE.md b/CLAUDE.md index 1163e23..33ed0a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,9 +289,3 @@ See `Documentation/` for detailed reference: - `GETTING_STARTED.md` - Setup, Google Calendar OAuth, troubleshooting - `APP-STORE.md` - App Store submission details - `LAUNCH.md` - Release checklist - -## Release verification - -Follow `RELEASE-PROTOCOL.md` and `scripts/release/LOCAL-COMPUTER-USE.md`. -Use the final extracted signed package for the native walkthrough. Run the -shared script checks with `python3 -m unittest discover -s scripts/release -p "test_*.py"`. diff --git a/RELEASE-PROTOCOL.md b/RELEASE-PROTOCOL.md index a819ef8..8dbac6a 100644 --- a/RELEASE-PROTOCOL.md +++ b/RELEASE-PROTOCOL.md @@ -1,125 +1,32 @@ -# Release protocol +# Year View release checks -Shared by Marktext, Ledger and Year View. The scripts in `scripts/release/` are -mirrored; update and test all three copies together. macOS signing stays local. -Windows/Linux builds run on their target platforms where supported. +Use the Xcode archive and export commands in +[release-config/README.md](release-config/README.md) for the macOS direct-download +ZIP. This repository owns its release process and signing configuration. +Credentials and export options must be configured for the release operator's +Apple team and build environment. -## Release gates - -1. Record the source commit and version. Run typechecking, relevant tests and - platform builds. Keep candidate outputs in a new version/candidate directory. -2. Build and sign the app using the repository's build tool. Do not publish yet. -3. Submit notarization, save the submission ID, and return. Check status later; - pending or invalid submissions block subsequent stages. Never rebuild an app - to work around a slow Apple response. -4. Staple the accepted app, then package it using the repository's packager. - Submit and staple the enclosing DMG if shipping one. Refresh update feeds - after stapling, since it changes the DMG's bytes. -5. Verify the exact DMG/ZIP, then run the native walkthrough on the extracted - package. Record the artifact SHA-256, source commit, test results and observed - UI results. See `scripts/release/LOCAL-COMPUTER-USE.md`. -6. Tag the approved source commit. Stage every intended asset and a complete - SHA-256 manifest in a **draft** release. Download the assets again, verify - checksums and rerun artifact verification. Only then publish the draft. - -Do not overwrite published tags/assets. Check whether an existing release is -immutable before proposing additions; a new patch release may be required. -A release that intentionally omits a platform or installer must say so explicitly. - -## Electron apps: Ledger and Marktext - -Run from the repository root. `release:prepare` compiles, packages a signed app, -creates its submission ZIP and submits it without waiting for Apple. It refuses -to overwrite an existing candidate. It does not publish anything. - -```bash -# Ledger -npm run typecheck -npm test -npm run test:release -npm run release:prepare - -# Marktext -pnpm check -pnpm test -pnpm test:e2e -pnpm test:release -pnpm release:prepare -``` - -The candidate directory is `dist/release-`. For a replacement -candidate, compile first and call `scripts/release/electron-mac.sh prepare` -with a fresh output directory; never overwrite the previous candidate. +After Apple accepts the submission, staple the exported app and create the final +ZIP as documented there. Verify the app extracted from that ZIP, rather than +only checking the source app in the export directory: ```bash -export NOTARIZE_STATE="$PWD/dist/release-/notarize-state.json" -scripts/release/notarize.sh status -scripts/release/notarize.sh staple app.zip -# Only after the app is Accepted and stapled: -npm run release:package # Marktext: pnpm release:package -scripts/release/notarize.sh submit dist/release-/artifacts/*.dmg -scripts/release/notarize.sh status -scripts/release/notarize.sh staple dist/release-/artifacts/*.dmg +python3 scripts/release/verify-zip.py build/YearView.zip --version ``` -`status` exits 2 while any submission is not accepted. `staple` exits 2 while -any selected submission is not accepted; pass names to staple only the current -stage (app, then DMG). Use `log -` to investigate an Invalid submission. Submission is idempotent for -unchanged bytes; changed artifacts require a new candidate/state file. The -stored `AC_PASSWORD` keychain profile is shared; `APPLE_KEYCHAIN_PROFILE` -overrides it. Never put credentials in release manifests or logs. - -`build:mac*` creates local installers without notarizing or uploading them. -A successful build alone is not a release gate. Do not remove quarantine to make -release verification pass. The legacy `npm run release` in Ledger now prepares -an app; it no longer uploads unchecked artifacts. - -## Year View - -Use the archive/export commands in `release-config/README.md`. Submit its ZIP -with `notarize.sh submit --app `, then run -`status` and `staple`. Recreate the final ZIP from the stapled app with -`ditto -c -k --keepParent`. ZIP files themselves cannot be stapled. - -## Artifact verification - -Both formats are supported independently. For an Electron DMG+ZIP release: - -```bash -scripts/release/verify-mac-artifact.sh \ - --dmg --zip \ - --feed --bundle-id --version -``` - -For ZIP-only releases, omit `--dmg` and, when no updater exists, `--feed`. -Verification checks archive layout, the extracted app's identity/version, -Developer ID signature, stapled ticket and Gatekeeper acceptance with quarantine. -It checks every feed entry and legacy checksum; missing requested files fail. -DMGs are mounted read-only and verified as downloaded, including the copied app. -Verification does not replace the native UI walkthrough. - -## DMG copy permissions - -If the packager reports `ditto: /Volumes/.../App.app: Operation not permitted`, -retain its log and inspect `hdiutil info` plus the matching macOS `tccd` log. -Compare the exact packager, app and parent process before diagnosing the cause. -Do not detach unrelated volumes, change app identity, clear security attributes, -hand-assemble an installer or change OS permissions as an automatic workaround. -An App Management denial may need the user to grant permission to the build host -in System Settings. ZIP-only publication is an explicit release-format decision. - -## Per-repository native paths - -- Ledger: disposable Git repo; branch/worktree navigation, search, selected - stage/unstage/discard and read-only historical commits. Check Git state on disk. -- Marktext: disposable Markdown/profile; unchanged save, edit/undo, dirty prompts, - actual save, external reload and close. Compare file bytes. Include OS clipboard - and drag/drop when the change affects them. -- Year View: all layouts, year/today navigation, day details and preferences; - real calendar permissions and deep links when applicable. Keep calendar data - read-only and redact event details from recorded evidence. - -Marktext's tag workflow stages Windows/Linux artifacts in a draft. Add verified -Mac assets, regenerate the manifest for all assets, then publish. Update the -Homebrew cask only after the referenced DMG is published. +The check requires macOS, Xcode command-line tools and Python 3. It checks the +YearView.app bundle layout, identifier, version, Developer ID signature, +stapled ticket and Gatekeeper acceptance on a temporary quarantined copy. +It does not modify the export or ZIP, and does not launch the app. + +Open the extracted candidate for a native walkthrough. Check calendar permission +handling, year navigation, event display, layout preferences and reopening the +window. Test deep links when relevant to the change; preserve the user's calendar +data. Record source commit, version, ZIP checksum, OS and observed results. +Run the project's existing Xcode tests for application changes. + +Upload the final ZIP with its SHA-256 checksum, then download and verify it again. +Keep App Store submissions separate: follow +[Documentation/APP-STORE.md](Documentation/APP-STORE.md) for their signing and +submission requirements. A direct-download ZIP check does not validate an +App Store or iOS build. diff --git a/release-config/README.md b/release-config/README.md index 38a65fe..559bf31 100644 --- a/release-config/README.md +++ b/release-config/README.md @@ -8,7 +8,7 @@ not build output, and belong in version control. download (`method: developer-id`, automatic signing) - `ExportOptions-AppStore.plist` — App Store export -Release procedure is the shared release protocol in `../RELEASE-PROTOCOL.md`: +Archive, export and notarize the direct-download candidate: ```bash xcodebuild archive -project YearView.xcodeproj -scheme YearView \ @@ -18,22 +18,25 @@ xcodebuild -exportArchive -archivePath build/YearView-GitHub.xcarchive \ -exportOptionsPlist release-config/ExportOptions-DirectDownload.plist \ -exportPath build/github-export -ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView-submission.zip +ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView.zip # Submit and collect status without waiting on Apple. -./scripts/release/notarize.sh submit --app build/github-export/YearView.app build/YearView-submission.zip +./scripts/release/notarize.sh submit build/YearView.zip ./scripts/release/notarize.sh status # After the status is Accepted, staple the app (ZIP files cannot be stapled). -./scripts/release/notarize.sh staple +xcrun stapler staple build/github-export/YearView.app +xcrun stapler validate build/github-export/YearView.app # Repackage the exported app with its ticket before publishing. ditto -c -k --keepParent "build/github-export/YearView.app" build/YearView.zip ``` -Verify the final ZIP, including the extracted app, then complete the native -walkthrough in `scripts/release/LOCAL-COMPUTER-USE.md`: +Verify the final ZIP, including the app extracted from it: ```bash -./scripts/release/verify-mac-artifact.sh --zip build/YearView.zip \ - --bundle-id com.yearview.app +python3 scripts/release/verify-zip.py build/YearView.zip --version ``` + +Complete the native walkthrough in [RELEASE-PROTOCOL.md](../RELEASE-PROTOCOL.md) +before uploading. The ZIP verifier requires macOS, Xcode command-line tools and +Python 3; it does not change the Xcode build or notarization steps above. diff --git a/scripts/release/LOCAL-COMPUTER-USE.md b/scripts/release/LOCAL-COMPUTER-USE.md deleted file mode 100644 index 00a0be3..0000000 --- a/scripts/release/LOCAL-COMPUTER-USE.md +++ /dev/null @@ -1,40 +0,0 @@ -# Local native walkthrough - -Run this after automated tests and artifact verification, before publishing. -Use the actual signed app extracted from the candidate ZIP or copied from its -DMG. A development window or Playwright startup alone does not prove this gate. - -1. Record source commit, app version, archive SHA-256 and executable path. Confirm - the visible app belongs to that path; quit only test instances you launched. -2. Create disposable fixtures and an isolated app profile/settings file where - supported. Preserve existing documents, repository changes and calendar data. -3. Exercise the changed behavior through native UI: mouse, keyboard, menus and - dialogs. Use screenshots/accessibility state to observe results. Do not count - directly calling internal APIs or dispatching synthetic DOM events as native - computer-use evidence; those belong to automated tests/debugging. -4. Check meaningful side effects independently: saved file bytes, Git index and - worktree contents, preferences after relaunch. Cover cancel/undo/error paths - where the change can affect data. Test only relevant paths, not every control. -5. If a failure appears, reproduce it, fix it and add a focused regression test - when useful. Rebuild and repeat the affected paths on the final artifact. -6. Close test instances and remove only fixtures you created. Record any checks - that could not be exercised, with the reason. Never mark a blocked path passed. - -Store evidence beside the candidate outside tracked source, for example: - -```text -Source commit: -App version / executable: -Archive SHA-256: -OS / architecture: -Automated checks: -Native path | expected result | observed result | pass/fail -Side-effect checks: -Screenshots/log paths (redacted): -Limitations: -``` - -On Codex desktop, use the available native computer-use tool for UI actions. -CDP and Playwright are useful for debugging and automated checks; they do not -replace the native walkthrough. macOS privacy prompts require the user's -permission choices; do not silently change privacy settings to complete a test. diff --git a/scripts/release/mac_artifacts.py b/scripts/release/mac_artifacts.py deleted file mode 100644 index 2a8c9a8..0000000 --- a/scripts/release/mac_artifacts.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Checks the bytes that will be distributed, independently of the build tree.""" -import argparse -import base64 -import hashlib -import json -from pathlib import Path, PurePosixPath -import plistlib -import re -import subprocess -import tempfile -import zipfile - - -def run(*args): - return subprocess.run(args, check=True, capture_output=True, text=True).stdout - - -def digest(path): - return base64.b64encode(hashlib.sha512(path.read_bytes()).digest()).decode() - - -def scalar(value): - value = value.strip() - if value.startswith('"'): - return json.loads(value) - if value.startswith("'") and value.endswith("'"): - return value[1:-1].replace("''", "'") - return value - - -def check_feed(feed): - text = feed.read_text() - entries = re.findall(r'^[ \t]*- url: (.+)\n((?:[ \t]+[A-Za-z][^\n]*\n?)*)', text, re.M) - if not entries: - raise ValueError(f'{feed}: no artifact entries') - seen = set() - for name, fields in entries: - name = scalar(name) - if name in seen or Path(name).name != name: - raise ValueError(f'{feed}: duplicate or nonlocal artifact {name}') - seen.add(name) - properties = dict(re.findall(r'^\s+(\w+):\s*(.+)$', fields, re.M)) - artifact = feed.parent / name - if not artifact.is_file(): - raise ValueError(f'{feed}: missing artifact {name}') - if (scalar(properties.get('sha512', '')) != digest(artifact) - or int(properties.get('size', '-1')) != artifact.stat().st_size): - raise ValueError(f'{feed}: stale checksum/size for {name}') - path = re.search(r'^path: (.+)$', text, re.M) - sha = re.search(r'^sha512: (.+)$', text, re.M) - if path or sha: - if not path or not sha or scalar(path[1]) not in seen: - raise ValueError(f'{feed}: invalid legacy path/sha512') - if scalar(sha[1]) != digest(feed.parent / scalar(path[1])): - raise ValueError(f'{feed}: stale legacy sha512') - - -def check_zip(path): - with zipfile.ZipFile(path) as archive: - names = set(archive.namelist()) - for name in names: - parts = PurePosixPath(name).parts - if name.startswith('/') or '..' in parts: - raise ValueError(f'{path}: unsafe archive path {name}') - roots = {n.split('/')[0] for n in names if n.split('/')[0].endswith('.app')} - if len(roots) != 1: - raise ValueError(f'{path}: expected exactly one root app') - root = next(iter(roots)) - if f'{root}/Contents/Info.plist' not in names: - raise ValueError(f'{path}: missing root app Contents/Info.plist (nested bundle?)') - if f'{root}/{root}/Contents/Info.plist' in names: - raise ValueError(f'{path}: nested duplicate app') - return root - - -def verify_app(app, bundle_id, version): - with (app / 'Contents/Info.plist').open('rb') as stream: - info = plistlib.load(stream) - if info.get('CFBundleIdentifier') != bundle_id: - raise ValueError(f'{app}: unexpected bundle identifier') - if version and info.get('CFBundleShortVersionString') != version: - raise ValueError(f'{app}: unexpected app version') - run('codesign', '--verify', '--deep', '--strict', str(app)) - signature = subprocess.run(['codesign', '-dv', '--verbose=2', str(app)], - check=True, capture_output=True, text=True).stderr - if 'Authority=Developer ID Application:' not in signature: - raise ValueError(f'{app}: not signed with Developer ID Application') - run('xcrun', 'stapler', 'validate', str(app)) - run('spctl', '--assess', '--type', 'execute', '--verbose=2', str(app)) - print(f'PASS signature, notarization, Gatekeeper and identity: {app.name}') - - -def verify(args): - if not args.dmg and not args.zip: - raise ValueError('provide --dmg and/or --zip') - for path in (args.dmg, args.zip, args.feed): - if path and not path.is_file(): - raise ValueError(f'missing input: {path}') - with tempfile.TemporaryDirectory(prefix='mac-artifact-') as temporary: - work = Path(temporary) - if args.zip: - root = check_zip(args.zip) - destination = work / 'zip' - run('ditto', '-x', '-k', str(args.zip.resolve()), str(destination)) - app = destination / root - run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(app)) - verify_app(app, args.bundle_id, args.version) - if args.dmg: - copied = work / args.dmg.name - run('ditto', str(args.dmg.resolve()), str(copied)) - run('xcrun', 'stapler', 'validate', str(copied)) - run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(copied)) - run('spctl', '--assess', '--type', 'open', '--context', 'context:primary-signature', str(copied)) - mount = work / 'mount' - run('hdiutil', 'attach', '-nobrowse', '-readonly', '-mountpoint', str(mount), str(copied)) - try: - apps = list(mount.glob('*.app')) - if len(apps) != 1: - raise ValueError('DMG must contain exactly one root app') - # Verify the installed copy as well as the disk image itself. - app = work / 'installed' / apps[0].name - run('ditto', str(apps[0]), str(app)) - run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;ReleaseVerification;', str(app)) - verify_app(app, args.bundle_id, args.version) - finally: - run('hdiutil', 'detach', str(mount)) - if args.feed: - check_feed(args.feed) - print('PASS update feed checksums and sizes') - - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--dmg', type=Path) - parser.add_argument('--zip', type=Path) - parser.add_argument('--feed', type=Path) - parser.add_argument('--bundle-id', required=True) - parser.add_argument('--version') - try: - verify(parser.parse_args()) - except (ValueError, OSError, subprocess.CalledProcessError, zipfile.BadZipFile) as error: - parser.exit(1, f'verify: {error}\n{getattr(error, "stderr", "") or ""}') - print('All artifact checks passed. Complete the native walkthrough before publication.') diff --git a/scripts/release/notarize.py b/scripts/release/notarize.py deleted file mode 100644 index f6abdaa..0000000 --- a/scripts/release/notarize.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Resumable notarization; pending or invalid submissions cannot pass stapling.""" -import argparse -import datetime -import hashlib -import json -import os -from pathlib import Path -import subprocess -import sys - -PROFILE = os.environ.get('APPLE_KEYCHAIN_PROFILE', 'AC_PASSWORD') -STATE = Path(os.environ.get('NOTARIZE_STATE', 'dist/.notarize-state.json')).resolve() - - -def sha(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def apple(*args): - result = subprocess.run(['xcrun', 'notarytool', *args, '--keychain-profile', PROFILE, - '--output-format', 'json'], check=True, capture_output=True, text=True) - return json.loads(result.stdout) - - -def save(state): - STATE.parent.mkdir(parents=True, exist_ok=True) - temporary = STATE.with_suffix('.tmp') - temporary.write_text(json.dumps(state, indent=2) + '\n') - temporary.replace(STATE) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - sub = parser.add_subparsers(dest='command', required=True) - submit = sub.add_parser('submit') - submit.add_argument('--app', type=Path, help='App to staple when its submission ZIP is accepted') - submit.add_argument('artifacts', nargs='+', type=Path) - sub.add_parser('status') - staple = sub.add_parser('staple') - staple.add_argument('artifacts', nargs='*', help='Optional recorded artifact names for this stage') - log = sub.add_parser('log') - log.add_argument('artifact') - sub.add_parser('reset') - args = parser.parse_args() - state = json.loads(STATE.read_text()) if STATE.exists() else {} - if args.command == 'reset': - STATE.unlink(missing_ok=True) - return - if args.command == 'submit': - if args.app and (len(args.artifacts) != 1 or args.artifacts[0].suffix != '.zip' or not args.app.is_dir()): - raise ValueError('--app requires one submission ZIP and an existing app') - for artifact in args.artifacts: - artifact = artifact.resolve() - fingerprint = sha(artifact) - old = state.get(artifact.name) - if old: - if old.get('sha256') != fingerprint or old['path'] != str(artifact): - raise ValueError(f'{artifact.name}: recorded artifact differs; use a new state/output directory') - print(f'{artifact.name}: already submitted as {old["id"]}; use status/log') - continue - if artifact.suffix == '.zip' and not args.app: - raise ValueError('ZIP submissions require --app; ZIPs themselves cannot be stapled') - result = apple('submit', str(artifact)) - state[artifact.name] = {'id': result['id'], 'path': str(artifact), 'sha256': fingerprint, - 'app': str(args.app.resolve()) if args.app else None, - 'submitted': datetime.datetime.now(datetime.timezone.utc).isoformat()} - save(state) - print(f'{artifact.name}: submitted as {result["id"]}') - print(f'State: {STATE}\nRun status, then staple after acceptance.') - return - if not state: - raise ValueError(f'No submissions in {STATE}') - if args.command == 'log': - entry = state[Path(args.artifact).name] - subprocess.run(['xcrun', 'notarytool', 'log', entry['id'], '--keychain-profile', PROFILE], check=True) - return - if args.command == 'staple' and args.artifacts: - names = [Path(name).name for name in args.artifacts] - state_selection = {name: state[name] for name in names} - else: - state_selection = state - incomplete = False - for name, entry in state_selection.items(): - status = apple('info', entry['id'])['status'] - print(f'{name}: {status}', flush=True) - if status != 'Accepted': - incomplete = True - continue - if args.command != 'staple': - continue - artifact = Path(entry['path']) - if entry.get('sha256') and sha(artifact) != entry['sha256']: - raise ValueError(f'{name}: bytes changed since submission/stapling') - target = entry.get('app') or str(artifact) - if target.endswith('.zip'): - raise ValueError(f'{name}: legacy ZIP state has no app; staple its app explicitly') - validation = subprocess.run(['xcrun', 'stapler', 'validate', target], capture_output=True) - if validation.returncode != 0: - subprocess.run(['xcrun', 'stapler', 'staple', target], check=True) - subprocess.run(['xcrun', 'stapler', 'validate', target], check=True) - else: - print(f'{name}: ticket already valid; preserving stapled bytes') - entry['sha256'] = sha(artifact) - save(state) - for feed in artifact.parent.glob('latest*.yml'): - subprocess.run([sys.executable, str(Path(__file__).with_name('refresh-feed.py')), - str(feed), name, str(artifact)], check=True) - if incomplete: - print('Not all submissions are accepted. Keep this candidate and check again later.', file=sys.stderr) - return 2 - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main() or 0) - except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: - sys.exit(f'notarize: {error}\n{getattr(error, "stderr", "") or ""}') diff --git a/scripts/release/notarize.sh b/scripts/release/notarize.sh index 501dd98..fc48772 100755 --- a/scripts/release/notarize.sh +++ b/scripts/release/notarize.sh @@ -1,4 +1,234 @@ #!/usr/bin/env bash -# Shared with Marktext and Year View. Submission returns without waiting on Apple. +# ============================================================================ +# notarize.sh — detached macOS notarization +# ============================================================================ +# +# WHY THIS IS NOT `notarytool submit --wait` +# +# Apple's notary service is usually minutes and occasionally *over a day*. A +# build that blocks on `--wait` turns that into a lost build: the terminal is +# stuck, CI times out, and the human works around it by hand — which is how a +# release ends up assembled manually and, eventually, shipped broken. +# +# So submission and collection are separate commands with state on disk: +# +# ./notarize.sh submit dist/*.dmg # returns as soon as the upload is done +# ./notarize.sh status # safe to run a minute or a week later +# ./notarize.sh staple # staples everything Apple has Accepted +# +# The build is never held hostage. If Apple takes 26 hours, you run `status` +# tomorrow and `staple` when it clears; nothing needs rebuilding, because the +# ticket is stapled to the artifact you already have. +# +# Credentials come from a notarytool keychain profile (APPLE_KEYCHAIN_PROFILE, +# default AC_PASSWORD), created once with: +# xcrun notarytool store-credentials AC_PASSWORD \ +# --apple-id --team-id --password +# ============================================================================ + set -euo pipefail -exec python3 "$(dirname "$0")/notarize.py" "$@" + +PROFILE="${APPLE_KEYCHAIN_PROFILE:-AC_PASSWORD}" +STATE="${NOTARIZE_STATE:-dist/.notarize-state.json}" + +die() { + echo "notarize: $*" >&2 + exit 1 +} + +require_tools() { + command -v xcrun >/dev/null || die "xcrun not found (install Xcode Command Line Tools)" + command -v python3 >/dev/null || die "python3 not found" + xcrun notarytool history --keychain-profile "$PROFILE" >/dev/null 2>&1 || + die "keychain profile '$PROFILE' does not authenticate. Create it with: + xcrun notarytool store-credentials $PROFILE --apple-id --team-id --password " +} + +# State is a JSON object: { "": {id, path, submitted} } +state_py() { + python3 - "$STATE" "$@" +} + +usage() { + cat <<'EOF' +Usage: + notarize.sh submit ... Upload artifacts, record submission ids, return + notarize.sh status Print Apple's status for every recorded submission + notarize.sh staple Staple + validate every Accepted artifact + notarize.sh log Fetch Apple's detailed log for one artifact + notarize.sh reset Forget recorded submissions (does not cancel them) + +Env: + APPLE_KEYCHAIN_PROFILE notarytool profile name (default: AC_PASSWORD) + NOTARIZE_STATE state file path (default: dist/.notarize-state.json) +EOF +} + +cmd_submit() { + [ $# -gt 0 ] || die "submit needs at least one artifact" + require_tools + mkdir -p "$(dirname "$STATE")" + [ -f "$STATE" ] || echo '{}' >"$STATE" + + for artifact in "$@"; do + [ -f "$artifact" ] || die "no such file: $artifact" + local name + name="$(basename "$artifact")" + + echo "notarize: uploading $name ..." + # No --wait: we want the id, not a blocked terminal. + local out id + out="$(xcrun notarytool submit "$artifact" --keychain-profile "$PROFILE" --output-format json)" + id="$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" + + state_py "$name" "$id" "$artifact" <<'PY' +import json, sys, datetime +state_path, name, sid, path = sys.argv[1:5] +try: + with open(state_path) as fh: + data = json.load(fh) +except (FileNotFoundError, json.JSONDecodeError): + data = {} +data[name] = {"id": sid, "path": path, "submitted": datetime.datetime.now().isoformat(timespec="seconds")} +with open(state_path, "w") as fh: + json.dump(data, fh, indent=2) +PY + echo "notarize: $name submitted as $id" + done + + echo "" + echo "notarize: submissions recorded in $STATE" + echo "notarize: run './notarize.sh status' whenever you like — minutes or days from now." +} + +# Prints " " per line and returns 1 if anything is still In Progress. +collect_status() { + local pending=0 + while read -r name id; do + [ -n "$name" ] || continue + local status + status="$(xcrun notarytool info "$id" --keychain-profile "$PROFILE" --output-format json 2>/dev/null | + python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","Unknown"))' 2>/dev/null || echo "Unknown")" + printf ' %-56s %s\n' "$name" "$status" + [ "$status" = "In Progress" ] && pending=1 + done < <(state_py <<'PY' +import json, sys +try: + with open(sys.argv[1]) as fh: + data = json.load(fh) +except (FileNotFoundError, json.JSONDecodeError): + data = {} +for name, entry in data.items(): + print(name, entry["id"]) +PY +) + return $pending +} + +cmd_status() { + require_tools + [ -f "$STATE" ] || die "no submissions recorded in $STATE" + echo "notarize: submission status" + collect_status || { + echo "" + echo "notarize: still in progress. Apple can take minutes or over a day; nothing is lost by waiting." + return 0 + } +} + +# Stapling appends the ticket, so every sha512/size an installer feed recorded +# before this point is now wrong. electron-updater refuses a download whose +# checksum disagrees with the feed, and the failure is silent until someone +# tries to update. Refresh any latest*.yml sitting beside the artifact. +refresh_feeds() { + local artifact="$1" + local dir name + dir="$(dirname "$artifact")" + name="$(basename "$artifact")" + for feed in "$dir"/latest*.yml; do + [ -f "$feed" ] || continue + python3 "$(dirname "${BASH_SOURCE[0]}")/refresh-feed.py" "$feed" "$name" "$artifact" + done +} + +cmd_staple() { + require_tools + [ -f "$STATE" ] || die "no submissions recorded in $STATE" + + local stapled=0 skipped=0 + while read -r name id path; do + [ -n "$name" ] || continue + local status + status="$(xcrun notarytool info "$id" --keychain-profile "$PROFILE" --output-format json 2>/dev/null | + python3 -c 'import json,sys; print(json.load(sys.stdin).get("status","Unknown"))')" + + if [ "$status" != "Accepted" ]; then + echo "notarize: skipping $name (status: $status)" + skipped=$((skipped + 1)) + continue + fi + [ -f "$path" ] || die "$name was Accepted but $path is gone — rebuild would need re-notarizing" + + xcrun stapler staple "$path" + # Fail loudly rather than ship an artifact whose ticket did not attach. + xcrun stapler validate "$path" + refresh_feeds "$path" + echo "notarize: $name stapled and validated" + stapled=$((stapled + 1)) + done < <(state_py <<'PY' +import json, sys +try: + with open(sys.argv[1]) as fh: + data = json.load(fh) +except (FileNotFoundError, json.JSONDecodeError): + data = {} +for name, entry in data.items(): + print(name, entry["id"], entry["path"]) +PY +) + + echo "notarize: $stapled stapled, $skipped skipped" + [ "$skipped" -eq 0 ] || echo "notarize: re-run './notarize.sh staple' once the rest are Accepted" +} + +cmd_log() { + [ $# -eq 1 ] || die "log needs exactly one artifact name" + require_tools + local id + id="$(state_py "$1" <<'PY' +import json, sys +state_path, name = sys.argv[1:3] +with open(state_path) as fh: + data = json.load(fh) +entry = data.get(name) or data.get(__import__("os").path.basename(name)) +print(entry["id"] if entry else "") +PY +)" + [ -n "$id" ] || die "no recorded submission for $1" + xcrun notarytool log "$id" --keychain-profile "$PROFILE" +} + +case "${1:-}" in + submit) + shift + cmd_submit "$@" + ;; + status) + cmd_status + ;; + staple) + cmd_staple + ;; + log) + shift + cmd_log "$@" + ;; + reset) + rm -f "$STATE" + echo "notarize: cleared $STATE" + ;; + *) + usage + exit 1 + ;; +esac diff --git a/scripts/release/refresh-feed.py b/scripts/release/refresh-feed.py index da31149..864a301 100644 --- a/scripts/release/refresh-feed.py +++ b/scripts/release/refresh-feed.py @@ -19,9 +19,6 @@ text = open(feed).read() pattern = re.compile(r"(- url: %s\s*\n\s*sha512: )\S+(\s*\n\s*size: )\d+" % re.escape(name)) new, count = pattern.subn(lambda m: f"{m.group(1)}{sha}{m.group(2)}{size}", text) -legacy_path = re.search(r"^path: (.+)$", text, re.M) -if legacy_path and legacy_path.group(1).strip("\"'") == name: - new = re.sub(r"^sha512: .+$", f"sha512: {sha}", new, flags=re.M) if count and new != text: open(feed, "w").write(new) print(f"notarize: refreshed {name} checksums in {feed.split('/')[-1]}") diff --git a/scripts/release/test_release.py b/scripts/release/test_release.py deleted file mode 100644 index e549dd5..0000000 --- a/scripts/release/test_release.py +++ /dev/null @@ -1,117 +0,0 @@ -import contextlib -import io -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch -import zipfile - -import mac_artifacts as artifacts -import notarize - - -class ArtifactTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - - def test_zip_names_with_spaces_and_nested_bundle_rejected(self): - valid = self.root / 'valid.zip' - with zipfile.ZipFile(valid, 'w') as archive: - archive.writestr('Oh My Marktext.app/Contents/Info.plist', b'plist') - self.assertEqual(artifacts.check_zip(valid), 'Oh My Marktext.app') - nested = self.root / 'nested.zip' - with zipfile.ZipFile(nested, 'w') as archive: - archive.writestr('Ledger.app/Ledger.app/Contents/Info.plist', b'plist') - with self.assertRaisesRegex(ValueError, 'nested bundle'): - artifacts.check_zip(nested) - - def test_missing_feed_artifact_and_stale_legacy_digest_fail(self): - asset = self.root / 'app.zip' - asset.write_bytes(b'original release') - feed = self.root / 'latest-mac.yml' - feed.write_text(f'version: 1.0.0\nfiles:\n - url: app.zip\n sha512: {artifacts.digest(asset)}\n size: {asset.stat().st_size}\npath: app.zip\nsha512: incorrect\n') - with self.assertRaisesRegex(ValueError, 'legacy sha512'): - artifacts.check_feed(feed) - feed.write_text(feed.read_text().replace('sha512: incorrect', f'sha512: {artifacts.digest(asset)}')) - artifacts.check_feed(feed) - asset.unlink() - with self.assertRaisesRegex(ValueError, 'missing artifact'): - artifacts.check_feed(feed) - - def test_stapling_refreshes_legacy_and_file_digest(self): - import subprocess - import sys - asset = self.root / 'App.dmg' - asset.write_bytes(b'after stapling') - feed = self.root / 'latest-mac.yml' - feed.write_text('version: 1\nfiles:\n - url: App.dmg\n sha512: old\n size: 1\npath: App.dmg\nsha512: old\n') - subprocess.run([sys.executable, str(Path(__file__).with_name('refresh-feed.py')), str(feed), asset.name, str(asset)], check=True, capture_output=True) - artifacts.check_feed(feed) - - def test_feed_multiple_artifacts_do_not_mix_checksums(self): - first = self.root / 'app.zip' - second = self.root / 'app.dmg' - first.write_bytes(b'zip') - second.write_bytes(b'disk image') - feed = self.root / 'latest-mac.yml' - feed.write_text('version: 1\nfiles:\n' + ''.join( - f' - url: {p.name}\n sha512: {artifacts.digest(p)}\n size: {p.stat().st_size}\n' - for p in [first, second])) - artifacts.check_feed(feed) - second.write_bytes(b'corrupt') - with self.assertRaisesRegex(ValueError, 'stale checksum/size for app.dmg'): - artifacts.check_feed(feed) - - def test_requested_missing_zip_fails_before_system_commands(self): - from argparse import Namespace - with self.assertRaisesRegex(ValueError, 'missing input'): - artifacts.verify(Namespace(dmg=None, zip=self.root/'missing.zip', feed=None, - bundle_id='com.example.app', version=None)) - - def test_pending_notarization_cannot_staple(self): - state = self.root / 'state.json' - state.write_text(json.dumps({'app.zip': {'id': 'submission', 'path': '/unused'}})) - with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'In Progress'}), patch.object(notarize.subprocess, 'run') as runner, patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - self.assertEqual(notarize.main(), 2) - runner.assert_not_called() - - def test_valid_ticket_is_not_stapled_again(self): - from types import SimpleNamespace - asset = self.root / 'app.zip' - asset.write_bytes(b'submission') - state = self.root / 'state.json' - state.write_text(json.dumps({'app.zip': {'id': 'submission', 'path': str(asset), - 'sha256': notarize.sha(asset), 'app': str(self.root / 'App With Spaces.app')}})) - with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}), patch.object(notarize.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as runner, patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()): - self.assertEqual(notarize.main(), 0) - self.assertEqual(runner.call_count, 1) - self.assertEqual(runner.call_args.args[0][2], 'validate') - - def test_staple_can_select_the_current_release_stage(self): - from types import SimpleNamespace - asset = self.root / 'App.dmg' - asset.write_bytes(b'dmg') - state = self.root / 'state.json' - state.write_text(json.dumps({ - 'app.zip': {'id': 'old-app', 'path': '/source-no-longer-used'}, - 'App.dmg': {'id': 'disk', 'path': str(asset), 'sha256': notarize.sha(asset)}})) - with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}) as apple, patch.object(notarize.subprocess, 'run', return_value=SimpleNamespace(returncode=0)), patch('sys.argv', ['notarize', 'staple', str(asset)]), contextlib.redirect_stdout(io.StringIO()): - self.assertEqual(notarize.main(), 0) - apple.assert_called_once_with('info', 'disk') - self.assertIn('app.zip', json.loads(state.read_text())) - - def test_changed_artifact_cannot_be_stapled(self): - asset = self.root / 'App.dmg' - asset.write_bytes(b'changed') - state = self.root / 'state.json' - state.write_text(json.dumps({'App.dmg': {'id': 'submission', 'path': str(asset), 'sha256': 'original'}})) - with patch.object(notarize, 'STATE', state), patch.object(notarize, 'apple', return_value={'status': 'Accepted'}), patch('sys.argv', ['notarize', 'staple']), contextlib.redirect_stdout(io.StringIO()): - with self.assertRaisesRegex(ValueError, 'bytes changed'): - notarize.main() - - -if __name__ == '__main__': - unittest.main() diff --git a/scripts/release/test_verify_zip.py b/scripts/release/test_verify_zip.py new file mode 100644 index 0000000..b4c420d --- /dev/null +++ b/scripts/release/test_verify_zip.py @@ -0,0 +1,42 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +spec = importlib.util.spec_from_file_location('verify_zip', Path(__file__).with_name('verify-zip.py')) +verify_zip = importlib.util.module_from_spec(spec) +spec.loader.exec_module(verify_zip) + + +class ReleaseZipTests(unittest.TestCase): + def test_final_bundle_layout(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / 'candidate.zip' + cases = [ + (['YearView.app/Contents/Info.plist'], True), + (['YearView.app/YearView.app/Contents/Info.plist'], False), + (['YearView.app/Contents/Info.plist', 'Other.app/Contents/Info.plist'], False), + (['YearView.app/Contents/Info.plist', '../outside'], False), + ] + for names, valid in cases: + with self.subTest(names=names): + with zipfile.ZipFile(path, 'w') as archive: + for name in names: + archive.writestr(name, b'fixture') + if valid: + verify_zip.check_layout(path) + else: + with self.assertRaises(ValueError): + verify_zip.check_layout(path) + + def test_missing_zip_fails_before_mac_commands(self): + with tempfile.TemporaryDirectory() as temporary, patch.object(verify_zip, 'run') as run: + with self.assertRaises(FileNotFoundError): + verify_zip.verify(Path(temporary) / 'missing.zip', '1.4') + run.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/release/verify-mac-artifact.sh b/scripts/release/verify-mac-artifact.sh index c7be0f0..e060caf 100755 --- a/scripts/release/verify-mac-artifact.sh +++ b/scripts/release/verify-mac-artifact.sh @@ -1,4 +1,193 @@ #!/usr/bin/env bash -# Shared with Marktext and Year View; ZIP-only and DMG releases use the same gate. +# ============================================================================ +# verify-mac-artifact.sh — acceptance gate for a macOS release artifact +# ============================================================================ +# +# Run this on the artifact you are about to publish — and ideally again on the +# copy you download back from the release. Every check here exists because +# something shipped without it: +# +# * Ledger 1.5.0's DMG contained `Ledger.app/Ledger.app`. The inner app was +# perfectly signed, notarized and stapled; the outer one was a folder +# wearing the extension, so Finder showed a broken item and dragging it to +# Applications installed something that could not launch. Nothing in the +# pipeline looked inside the DMG, so nothing caught it. +# * Oh My Marktext shipped DMG checksums in latest-mac.yml that described the +# pre-staple bytes, because stapling rewrites the file after +# electron-builder hashes it. +# +# Usage: +# verify-mac-artifact.sh --dmg dist/App-1.0.0.dmg --bundle-id com.example.app \ +# [--feed dist/latest-mac.yml] [--zip dist/App-1.0.0-mac.zip] +# ============================================================================ + set -euo pipefail -exec python3 "$(dirname "$0")/mac_artifacts.py" "$@" + +DMG="" +ZIP="" +FEED="" +BUNDLE_ID="" +FAILURES=0 +MOUNTED="" + +pass() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +fail() { + printf ' \033[31m✗\033[0m %s\n' "$1" + FAILURES=$((FAILURES + 1)) +} +info() { printf ' · %s\n' "$1"; } + +cleanup() { + [ -n "$MOUNTED" ] && hdiutil detach "$MOUNTED" -quiet 2>/dev/null || true +} +trap cleanup EXIT + +while [ $# -gt 0 ]; do + case "$1" in + --dmg) DMG="$2"; shift 2 ;; + --zip) ZIP="$2"; shift 2 ;; + --feed) FEED="$2"; shift 2 ;; + --bundle-id) BUNDLE_ID="$2"; shift 2 ;; + *) echo "verify: unknown argument $1" >&2; exit 2 ;; + esac +done + +[ -n "$DMG" ] || { echo "verify: --dmg is required" >&2; exit 2; } +[ -f "$DMG" ] || { echo "verify: no such file: $DMG" >&2; exit 2; } + +echo "Verifying $(basename "$DMG")" + +# --- the disk image itself ------------------------------------------------- +if xcrun stapler validate "$DMG" >/dev/null 2>&1; then + pass "DMG has a stapled notarization ticket" +else + fail "DMG is not stapled — offline installs will need to reach Apple to verify" +fi + +# Simulate a browser download so Gatekeeper evaluates it the way a user's Mac will. +WORK="$(mktemp -d)" +cp "$DMG" "$WORK/" +QUARANTINED="$WORK/$(basename "$DMG")" +xattr -w com.apple.quarantine "0081;00000000;Verify;" "$QUARANTINED" 2>/dev/null || true +if printf '%s' "$(spctl -a -t open --context context:primary-signature -v "$QUARANTINED" 2>&1 || true)" | grep -q accepted; then + pass "Gatekeeper accepts the quarantined DMG" +else + fail "Gatekeeper rejects the quarantined DMG" +fi + +# --- what is actually inside it -------------------------------------------- +MOUNTED="$(hdiutil attach -nobrowse -readonly "$QUARANTINED" 2>/dev/null | + awk -F'\t' '/\/Volumes\//{print $NF}' | tail -1)" +[ -n "$MOUNTED" ] || { fail "DMG failed to mount"; echo ""; echo "verify: $FAILURES check(s) failed"; exit 1; } +info "mounted at $MOUNTED" + +APP_COUNT="$(find "$MOUNTED" -maxdepth 1 -name "*.app" | wc -l | tr -d ' ')" +if [ "$APP_COUNT" -eq 1 ]; then + pass "exactly one .app at the volume root" +else + fail "expected exactly one .app at the volume root, found $APP_COUNT" +fi + +APP="$(find "$MOUNTED" -maxdepth 1 -name "*.app" | head -1)" +if [ -n "$APP" ]; then + # The nesting check: a real bundle has Contents/Info.plist directly inside. + if [ -f "$APP/Contents/Info.plist" ]; then + pass "$(basename "$APP") is a well-formed bundle" + else + fail "$(basename "$APP") has no Contents/Info.plist — it is a folder, not a bundle" + if [ -d "$APP/$(basename "$APP")" ]; then + fail "bundle is nested: $(basename "$APP")/$(basename "$APP") — users get a broken drag-to-Applications" + fi + fi + + if [ -n "$BUNDLE_ID" ] && [ -f "$APP/Contents/Info.plist" ]; then + ACTUAL="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP/Contents/Info.plist" 2>/dev/null || echo "")" + if [ "$ACTUAL" = "$BUNDLE_ID" ]; then + pass "bundle identifier is $BUNDLE_ID" + else + fail "bundle identifier is '$ACTUAL', expected '$BUNDLE_ID'" + fi + fi + + if codesign --verify --deep --strict "$APP" 2>/dev/null; then + pass "code signature is valid (deep, strict)" + else + fail "code signature is invalid" + fi + + SIGN_INFO="$(codesign -dv --verbose=2 "$APP" 2>&1 || true)" + if printf '%s' "$SIGN_INFO" | grep -q "Authority=Developer ID Application"; then + pass "signed with a Developer ID Application certificate" + else + fail "not signed with a Developer ID Application certificate" + fi + + if xcrun stapler validate "$APP" >/dev/null 2>&1; then + pass "app has its own stapled ticket" + else + fail "app is not stapled — it will need a network check once copied out of the DMG" + fi + + if printf '%s' "$(spctl --assess --type execute -v "$APP" 2>&1 || true)" | grep -q accepted; then + pass "Gatekeeper accepts the app for execution" + else + fail "Gatekeeper rejects the app" + fi +fi + +# --- update feed ----------------------------------------------------------- +# electron-updater refuses a download whose sha512/size disagree with the feed, +# and stapling changes both after electron-builder writes them. +if [ -n "$FEED" ] && [ -f "$FEED" ]; then + DIR="$(dirname "$FEED")" + while read -r url; do + [ -n "$url" ] || continue + target="$DIR/$url" + if [ ! -f "$target" ]; then + info "feed references $url (not present locally, skipping)" + continue + fi + want_sha="$(python3 - "$FEED" "$url" <<'PY' +import re, sys +feed, url = sys.argv[1:3] +text = open(feed).read() +m = re.search(r'- url: %s\s*\n\s*sha512: (\S+)\s*\n\s*size: (\d+)' % re.escape(url), text) +print(m.group(1) if m else "") +PY +)" + want_size="$(python3 - "$FEED" "$url" <<'PY' +import re, sys +feed, url = sys.argv[1:3] +text = open(feed).read() +m = re.search(r'- url: %s\s*\n\s*sha512: \S+\s*\n\s*size: (\d+)' % re.escape(url), text) +print(m.group(1) if m else "") +PY +)" + got_sha="$(openssl dgst -sha512 -binary "$target" | openssl base64 -A)" + got_size="$(stat -f%z "$target")" + if [ "$want_sha" = "$got_sha" ] && [ "$want_size" = "$got_size" ]; then + pass "feed entry matches $url" + else + fail "feed entry for $url is stale (size $want_size vs $got_size)" + fi + done < <(grep -E '^[[:space:]]+- url: ' "$FEED" | sed -E 's/^[[:space:]]*- url: //') +fi + +# --- zip, if the release ships one ---------------------------------------- +if [ -n "$ZIP" ] && [ -f "$ZIP" ]; then + TOP="$(unzip -l "$ZIP" | awk '{print $4}' | grep -E '^[^/]+\.app/' | cut -d/ -f1 | sort -u | wc -l | tr -d ' ')" + ZIP_LIST="$(unzip -l "$ZIP" 2>/dev/null || true)" + if [ "$TOP" = "1" ] && printf '%s' "$ZIP_LIST" | grep -q '\.app/Contents/'; then + pass "zip contains one well-formed .app at its root" + else + fail "zip layout is wrong (expected a single .app/Contents/ at the root)" + fi +fi + +echo "" +if [ "$FAILURES" -eq 0 ]; then + echo "verify: all checks passed — safe to publish" +else + echo "verify: $FAILURES check(s) failed — do not publish" + exit 1 +fi diff --git a/scripts/release/verify-zip.py b/scripts/release/verify-zip.py new file mode 100644 index 0000000..be820b2 --- /dev/null +++ b/scripts/release/verify-zip.py @@ -0,0 +1,57 @@ +"""Verify the final Year View direct-download ZIP without changing its contents.""" +import argparse +from pathlib import Path, PurePosixPath +import plistlib +import subprocess +import tempfile +import zipfile + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True) + + +def check_layout(path): + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + for name in names: + if name.startswith('/') or '..' in PurePosixPath(name).parts: + raise ValueError(f'unsafe archive path: {name}') + apps = {name.split('/')[0] for name in names if name.split('/')[0].endswith('.app')} + if apps != {'YearView.app'} or 'YearView.app/Contents/Info.plist' not in names: + raise ValueError('expected one YearView.app with Contents/Info.plist at the ZIP root') + if 'YearView.app/YearView.app/Contents/Info.plist' in names: + raise ValueError('nested duplicate YearView.app') + + +def verify(path, version): + check_layout(path) + with tempfile.TemporaryDirectory(prefix='yearview-verify-') as temporary: + run('ditto', '-x', '-k', str(path.resolve()), temporary) + app = Path(temporary) / 'YearView.app' + with (app / 'Contents/Info.plist').open('rb') as stream: + info = plistlib.load(stream) + if info.get('CFBundleIdentifier') != 'com.yearview.app': + raise ValueError('unexpected bundle identifier') + if info.get('CFBundleShortVersionString') != version: + raise ValueError('unexpected app version') + run('xattr', '-w', 'com.apple.quarantine', '0081;00000000;YearViewVerification;', str(app)) + run('codesign', '--verify', '--deep', '--strict', str(app)) + signature = run('codesign', '-dv', '--verbose=2', str(app)).stderr + if 'Authority=Developer ID Application:' not in signature: + raise ValueError('expected Developer ID Application signing') + run('xcrun', 'stapler', 'validate', str(app)) + run('spctl', '--assess', '--type', 'execute', '--verbose=2', str(app)) + print('PASS: ZIP layout, app identity/version, signature, stapled ticket and Gatekeeper.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('zip', type=Path) + parser.add_argument('--version', required=True, help='expected CFBundleShortVersionString') + args = parser.parse_args() + try: + verify(args.zip, args.version) + except (ValueError, OSError, zipfile.BadZipFile, plistlib.InvalidFileException, + subprocess.CalledProcessError) as error: + parser.exit(1, f'verify: {error}\n{getattr(error, "stderr", "") or ""}')