Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude/rules/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ paths:
## Runners

- Xcode jobs run on `xcode-27`, a GitHub preview image. `setup-xcode` pins the Xcode build.
- Release Build runs on `mac-mini-m4pro` (`self-hosted`, `macOS`, `ARM64`, `tablepro-release`), the maintainer's Mac, as the maintainer's user. `MAC_RUNNER=github` moves it to `xcode-27`.
- Release Build runs on `mac-mini-m4pro` (`self-hosted`, `macOS`, `ARM64`, `tablepro-release`), the maintainer's Mac, as the maintainer's user. `gh variable set MAC_RUNNER --body github` moves it to `xcode-27`; `gh variable delete MAC_RUNNER` moves it back.
- Signing, notarization and the feed stay on hosted runners.
- Any workflow on a pushed ref can target that label: never approve a fork PR run that changes `.github/` unread.
- At most 5 hosted macOS jobs and 20 jobs in all run at once.
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/data-sync-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ paths:
This path holds user data. A silent wrong answer here looks exactly like an empty database, so prove late-completion and cancellation behavior with a test.

- **A synced CKRecord type or field reaches CloudKit Production before anything writes it.** Both apps pin the container to Production, where nothing is auto-created, and CloudKit rejects a whole record that carries an undeclared field. Write fields only through `record.fields(SomeSyncField.self)`, never `record["..."]`. To ship one: add it in Development, deploy to Production in the Console, run `apple/scripts/export-cloudkit-schema.sh`, commit `apple/CloudKit/production-schema.ckdb`, then add it to the matching `verifiedInProduction`. `ProductionSchemaParityTests` checks code against the snapshot in both directions, not against the server, so never edit the snapshot by hand: `apple/scripts/export-cloudkit-schema.sh --check` compares it with Production.
- **A storage format stays readable by the previous stable.** Sparkle never downgrades, and a beta user can turn betas off (`apple/RELEASING.md`, Channels).
- **A storage format stays readable by the previous stable.** Sparkle never downgrades, and a beta user can turn betas off (`apple/RELEASING.md`, Storage check).
- **Persist, then notify.** `SyncChangeTracker.markDeleted()` runs after the storage write, because its notification can start a sync that re-uploads the record from the stale file.
- **Concurrent schema loads await the one in-flight `loadTask`** in `SQLSchemaProvider`; never guard with a flag that returns without data.
- **Every metadata read goes through `DatabaseManager.withMetadataDriver`**, never `MetadataConnectionPool.shared` directly, so an embedded engine with `supportsConnectionPooling = false` (DuckDB, PGlite) stays on its session driver; a second connection to it is a different, empty database.
Expand Down
112 changes: 42 additions & 70 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,93 +1,65 @@
---
name: release
description: >
Cuts a TablePro macOS release: a beta, a stable or a patch. Picks fixes onto a release/X.Y line,
curates the changelog fragments and the lead, decides whether a release is critical, opens the
release PR, hands the maintainer the tag command, watches the run and forward-ports the release.
Use whenever the user says "release", "cut a beta", "ship version", "tag a release", "promote the
beta", "backport", or gives a version number ("/release 0.82.0", "/release 0.82.0-beta.2"). Not for
the blog post, newsletter or X posts (the announce skill in TableProApp/web), and not for the iOS app.
Cuts a TablePro macOS release: a beta, a stable or a patch. Reads the release state, decides with
the maintainer what ships and whether it is critical, runs the storage check, picks fixes onto a
release/X.Y line, curates the notes in the release PR, tags, watches the run and forward-ports the
release. Use whenever the user says "release", "cut a beta", "ship version", "tag a release",
"promote the beta", "backport", or gives a version number ("/release 0.83.0", "/release
0.83.0-beta.2"). Not for the blog post, newsletter or X posts (the announce skill in
TableProApp/web), and not for the iOS app.
---

# Release

`apple/RELEASING.md` is the runbook and holds every command. This skill holds the judgment: each section is a decision, then names the runbook section that carries it out.
`apple/RELEASING.md` holds every command and rule. This skill is the order of decisions.

## 1. Ground rules
When the maintainer says to release, you run every release command yourself: picks, the release PR, creating `release/X.Y`, pushing the tag, the forward-port and re-runs. The maintainer decides what ships and whether it is critical: say which and why in one line, then wait for a yes.

- Never push a tag, never create or delete a `release/*` branch, never push to `main` or `release/*`. The maintainer runs every block you hand over, in their own terminal, and tells you when it landed.
- A tag never moves. A fix after a tag is the next number.
- Release PRs and backport PRs go through the required checks like any other PR.
## 1. Read the state

## 2. Read the state
```bash
git fetch origin --tags
git -c versionsort.suffix=-beta tag -l 'v*' --sort=-v:refname | head
git ls-remote --heads origin 'release/*'
gh issue list --label regression --state open
gh release view vX.Y.0-beta.N --json publishedAt -q .publishedAt
scripts/ci/detect-changed-paths.sh --base vX.Y.0-beta.N --head origin/release/X.Y shipped
gh run list --workflow ci.yml --branch main --event push --limit 5
gh run list --workflow release-build.yml --branch main --limit 3
gh api --paginate 'repos/TableProApp/TablePro/dependabot/alerts?state=open&per_page=100' -q '.[].security_advisory.summary'
```

Before any decision, gather:
- The detector prints `true` when shipped source landed after the newest beta: promote only after beta.N+1.
- `git grep -c '^## \[X.Y.Z\]' origin/main -- apple/CHANGELOG.md` finds the last stable's heading. Without it, the forward-port goes first.
- A red Release Build is no gate, but the tag's `app` or `plugins` job likely fails the same way.

- the tags, newest first: `git -c versionsort.suffix=-beta tag -l 'v*' --sort=-v:refname | head`;
- the live lines: `git ls-remote --heads origin 'release/*'`;
- open regressions: `gh issue list --label regression --state open`;
- the newest beta's age, `gh release view v<X.Y>.0-beta.<N> --json publishedAt -q .publishedAt`, and the code on the line after it, `git log --oneline v<X.Y>.0-beta.<N>..origin/release/<X.Y> -- . ':!apple/changelog.d' ':!apple/CHANGELOG.md'`, without `apple/` on the 0.82 line;
- open Dependabot alerts: `gh api --paginate 'repos/TableProApp/TablePro/dependabot/alerts?state=open&per_page=100' -q '.[].security_advisory.summary'`;
- main's push runs: `gh run list --workflow ci.yml --branch main --event push --limit 10`, with its macOS Tests and iOS and Package Tests jobs green on the commit you would cut;
- main's Release Builds: `gh run list --workflow release-build.yml --branch main --status success --limit 5`. The commit you would cut needs one, nightly or dispatched (`gh workflow run release-build.yml --ref main`);
- whether `main` has the last stable's `## [X.Y.Z]` heading. If not, the forward-port is missing and the next tag will refuse to build: do that first (section 9).
## 2. Decide what ships

## 3. Decide what ships
Apply `apple/RELEASING.md`, Train: a promotion, a beta.1, a beta.N for a line with new picks, a patch past the bar in Branches, or nothing. Ask about an X.0.0.

The days, the gate and the patch bar live in `apple/RELEASING.md`, Schedule.
- Pick candidates: `fix` PRs merged to `main` since the line was cut, for a bug the line has: `gh pr list --state merged --base main --search "merged:>=<cut date>" --limit 1000 --json number,title,files`.
- Already picked: `gh pr list --base release/X.Y --state merged --limit 200`.
- A pick conflict that needs more than a mechanical fix goes back to the maintainer.

- **Promote to X.Y.0**: Monday, when the gate passes: the line's age, the newest beta's age and no shipped source after it (section 2), no open `regression` issue, and the storage check (section 4).
- **beta.1 of X.(Y+1)**: the same Monday, after X.Y.0's forward-port merges (section 9). The tag refuses to build until `main` has `## [X.Y.0]`.
- **beta.N**: Tuesday to Thursday, when the line has picks since its last beta.
- **Patch X.Y.Z**: Tuesday to Thursday, when the stable line has a fix past the patch bar.
- **Gate fails**: nothing is promoted or cut; the Schedule says when the next beta, the promotion and the next beta.1 go.
- **Friday to Sunday**: nothing, except what the Schedule allows.
- **X.0.0**: ask the maintainer.
## 3. Storage check

Say which and why in one line, then wait for the maintainer to agree. Run: `apple/RELEASING.md`, Cutting a beta, or Cutting a stable or patch.
Before beta.1, before every stable, and for a pick on its paths, run `apple/RELEASING.md`, Storage check, and read each commit it lists. A format the previous stable cannot read blocks the release.

## 4. Storage check
## 4. Critical

Before beta.1 and before a promotion, list the commits that touch user data since the previous stable: `git log --oneline v<prev>..<sha> --` followed by every `paths:` entry of `.claude/rules/data-sync-storage.md`, and each again without `apple/` while the range reaches back before the move into `apple/` (0.82 and 0.83). Read each one for a changed storage or sync format. Sparkle never downgrades, so a format the previous stable cannot read blocks the release until it is fixed on `main`. Run: `apple/RELEASING.md`, Channels.
For a stable or patch, name the fix and the reason, and ask. Only a security fix or data loss counts. An open Dependabot alert on a shipped dependency is a candidate.

## 5. Critical or not
## 5. Curate in the release PR

What counts as critical: `apple/RELEASING.md`, Channels. Name the fix and the reason, and let the maintainer confirm. A critical stable gets ` [CRITICAL]` appended to its heading in the release PR. Run: `apple/RELEASING.md`, Cutting a stable or patch.
- Every user-visible PR in the release has a fragment, and no fragment says more than its PR did (`.claude/rules/changelog.md`).
- On `prep/vX.Y.Z`, add or reword fragments before `notes.py assemble`. For an X.Y.0, write the lead in the reader's words (`apple/RELEASING.md`, Notes); a release of fixes only can go without one.
- A beta's notes are the fragments as they stand: `.venv/bin/python apple/scripts/release/notes.py body X.Y.0-beta.N` on the line head previews them.

## 6. Backport candidates
## 6. Follow RELEASING

- Candidates: merged `main` PRs labeled `backport release/<X.Y>`, plus `fix` PRs for a bug the line has.
- Never a `feat` or a PluginKit bump (`.claude/rules/plugin-system.md`, ABI). On the stable line, only a fix past the patch bar (`apple/RELEASING.md`, Schedule). A closed line takes no picks (`apple/RELEASING.md`, Branches).
- Already picked: `gh pr list --base release/<X.Y> --state merged --limit 200`. A pick's title keeps the original subject and its ` (#N)`.
- One fix per pick PR, from `backport/<PR>-<X.Y>`. A conflict that needs more than a mechanical resolution goes back to the maintainer.
Run `apple/RELEASING.md`: Cutting a beta, or Cutting a stable or patch, then Forward-port. On a failure, When a run fails or Withdrawing a release.

Run: `apple/RELEASING.md`, Branches.

## 7. Curate

- Compare what merged with the fragments: `gh pr list --state merged --base main --search "merged:>=<date of the last X.Y.0 cut>" --limit 1000 --json number,title,files`. A user-visible PR with no fragment needs one; a fragment that says more than its PR did needs rewording.
- Add or reword fragments through a PR to `main`, then pick it to the line. Entries change only through fragments; the lead and ` [CRITICAL]` are typed in the release PR.
- For X.Y.0, write the lead per `apple/RELEASING.md`, Notes, in the reader's words. A release of fixes only can go without one.
- Check every fragment against `.claude/rules/changelog.md` and `scripts/check-banned-words.sh`.

Run: `apple/RELEASING.md`, Notes, then Cutting a stable or patch.

## 8. Watch

- Follow the run with `gh run list --workflow release.yml --limit 3` and `gh run watch <id>`.
- Sort a failure into infrastructure (runner, network, notary service, an expired certificate) or code (a build, test or check the tag's commit fails). Infrastructure is a re-run; code is a fix on `main`, a pick, and the next number.
- Confirm the result from the source, not the cached site: `gh api -H 'Accept: application/vnd.github.raw' 'repos/TableProApp/TablePro/contents/appcast.xml?ref=gh-pages' | grep -c '<sparkle:shortVersionString><version></sparkle:shortVersionString>'` prints 1, and `gh release view v<version> --json assets -q '.assets | length'`.

Run: `apple/RELEASING.md`, What a tag runs, and When a run fails.

## 9. After a stable or patch

- Open the forward-port PR to `main` (and to a newer open line), and the docs changelog PR.
- For an X.Y.0, name the `announce` skill in TableProApp/web as the next step.
- Before the next stable, check `retainFromPluginKit` against the license dashboard.

Run: `apple/RELEASING.md`, Cutting a stable or patch, and Plugins.

## 10. Withdraw

Only when a build loses data, fails to launch or breaks connecting, and the fix is more than minutes away. The fix ships as the next number, critical when data was lost. Run: `apple/RELEASING.md`, Withdrawing a release.
- Watch the tag's run: `gh run list --workflow release.yml --limit 3`, then `gh run watch <id> --exit-status`.
- Then check the source: `gh api -H 'Accept: application/vnd.github.raw' 'repos/TableProApp/TablePro/contents/appcast.xml?ref=gh-pages' | grep -c '<sparkle:shortVersionString>X.Y.Z<'` prints 1; `gh release view vX.Y.Z --json assets -q '.assets | length'` is 4 plus `python3 apple/scripts/release/registry.py targets | wc -l`.
- After an X.Y.0, the `announce` skill in TableProApp/web is next.
3 changes: 1 addition & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

Each app has its own runbook:

- The macOS app, its registry plugins and the static libraries: [`apple/RELEASING.md`](apple/RELEASING.md), with the `release` skill (`.claude/skills/release/SKILL.md`).
- The macOS app, its registry plugins, the static libraries and the manual iOS flow: [`apple/RELEASING.md`](apple/RELEASING.md), with the `release` skill (`.claude/skills/release/SKILL.md`).
- The Linux app: [`linux/docs/releasing.md`](linux/docs/releasing.md).
- The iOS app ships by hand.

## Notes

Expand Down
15 changes: 13 additions & 2 deletions apple/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,21 @@ The app scheme compiles only the plugins the app embeds. The `AllPlugins` aggreg
xcodebuild build -project TablePro.xcodeproj -scheme AllPlugins -destination 'platform=macOS' -skipPackagePluginValidation CODE_SIGNING_ALLOWED=NO
```

`scripts/release/build.sh plugins --unsigned` builds the registry plugins the way a release does, universal, into `build/release/plugins`: [`RELEASING.md`](RELEASING.md#reproducing-a-release-build).

`scripts/install-plugin-dev.sh <Target>` copies a registry plugin you built in Debug into the user plugins folder.

### A Release build

`scripts/release/build.sh` builds the app and the registry plugins the way a tag does, universal, into `build/release/`; `package.sh` writes the assets to `dist/`. Release Build runs the same:

```bash
export VERSION=0.0.0-dev BUILD=9999999
scripts/release/build.sh app --unsigned && scripts/release/package.sh app --unsigned
scripts/build-dameng.sh both && scripts/build-hana.sh both
scripts/release/build.sh plugins --unsigned && scripts/release/package.sh plugins --unsigned
```

Signed: drop `--unsigned`, with the Developer ID identity and the provisioning profile `scripts/release/ExportOptions.plist` names. `scripts/release/notarize.sh <path>...` needs the profile `xcrun notarytool store-credentials TablePro --key <p8> --key-id <id> --issuer <issuer>` creates.

### Clean builds

| Reach for | When |
Expand Down
Loading
Loading