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 ""}')