Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 26 additions & 105 deletions RELEASE-PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -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 <artifact>` 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 <email> --team-id R4RRG93J68 --password <app-specific-password>
python3 scripts/release/verify-zip.py build/YearView.zip --version <app-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.
12 changes: 7 additions & 5 deletions release-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 <app-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.
1 change: 1 addition & 0 deletions scripts/release/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__pycache__/
2 changes: 0 additions & 2 deletions scripts/release/notarize.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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`
#
Expand Down
42 changes: 42 additions & 0 deletions scripts/release/test_verify_zip.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 0 additions & 2 deletions scripts/release/verify-mac-artifact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions scripts/release/verify-zip.py
Original file line number Diff line number Diff line change
@@ -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 ""}')
Loading