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/RELEASE-PROTOCOL.md b/RELEASE-PROTOCOL.md index 9973679..8dbac6a 100644 --- a/RELEASE-PROTOCOL.md +++ b/RELEASE-PROTOCOL.md @@ -1,111 +1,32 @@ -# Release protocol (shared across the productivity suite) +# Year View release checks -Canonical copy: `peterjthomson/marktext`. Mirrored into `peterjthomson/ledger` -and `peterjthomson/year-view` so all three release the same way. +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. -## 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 | - -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. - -```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 -``` - -`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 - -`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: - -- 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 - -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. - -## Credentials - -One notarytool keychain profile per machine, shared by all three repos: +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 -xcrun notarytool store-credentials AC_PASSWORD \ - --apple-id --team-id R4RRG93J68 --password +python3 scripts/release/verify-zip.py build/YearView.zip --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 | +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 53c4659..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 five-stage protocol in `../RELEASE-PROTOCOL.md`: +Archive, export and notarize the direct-download candidate: ```bash xcodebuild archive -project YearView.xcodeproj -scheme YearView \ @@ -31,10 +31,12 @@ xcrun stapler validate build/github-export/YearView.app 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 app extracted from it: ```bash -spctl --assess --type execute -v build/github-export/YearView.app -xcrun stapler validate build/github-export/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/.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/notarize.sh b/scripts/release/notarize.sh index 8dd5584..fc48772 100755 --- a/scripts/release/notarize.sh +++ b/scripts/release/notarize.sh @@ -2,8 +2,6 @@ # ============================================================================ # 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` # 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 7a9a677..e060caf 100755 --- a/scripts/release/verify-mac-artifact.sh +++ b/scripts/release/verify-mac-artifact.sh @@ -2,8 +2,6 @@ # ============================================================================ # 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 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 ""}')