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
22 changes: 13 additions & 9 deletions .claude/rules/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/

- `gh-pages` takes no required checks (`apple/RELEASING.md`, Keys and environments).
- The Apple workflows have no gate job. A test job runs on `!cancelled() && needs.changes.outputs.run != 'false'`. A job its `if:` skips reports success, and a failed changes job runs the suite rather than skipping it.
- `Changes (macOS)`, `Changes (iOS)`, `Changes (Linux)` and `Changes (Release)` are not required checks.
- `Changes (macOS)`, `Changes (iOS)` and `Changes (Linux)` are not required checks.
- A PR need not be up to date with main to merge (`strict_required_status_checks_policy: false`).
- A required check's name is its job's `name:`. Renaming a job changes the ruleset in the same change, and every open PR then merges main in before its checks report the new name.

## Runners and toolchain

- Every Xcode job in the test and release workflows runs on the `xcode-27` runner label, a GitHub preview image, except Release Build. actionlint learns the label from `.github/actionlint.yaml`.
- Release Build runs on `mac-mini-m4pro`, a repo-level self-hosted runner with the labels `self-hosted`, `macOS`, `ARM64` and `tablepro-release`: the maintainer's M4 Pro (12 cores, 24 GB, the Xcode build `setup-xcode` pins, at `/Applications/Xcode.app`), run by launchd as the maintainer's user.
- No other workflow names `tablepro-release`. Release Build never takes a PR, and fork PR workflows need approval for every outside contributor.
- Only Release Build uses `mac-mini-m4pro`, a repo-level self-hosted runner with the labels `self-hosted`, `macOS`, `ARM64` and `tablepro-release`: the maintainer's M4 Pro (12 cores, 24 GB, the Xcode build `setup-xcode` pins, at `/Applications/Xcode.app`), run by launchd as the maintainer's user.
- The repository variable `MAC_RUNNER` picks Release Build's machine: unset is the Mac, `github` is `xcode-27`. A dispatch's `runner` input overrides it. Switching: `apple/RELEASING.md`, Reproducing a Release build.
- The weekly Release Build always takes `xcode-27`, so it proves the hosted path while the Mac takes the nightlies.
- Signing, notarization and the feed never run on the Mac: `app`, `plugins` and `feed` in `release.yml` stay on hosted runners, and `MAC_RUNNER` does not reach them.
- On main only Release Build names `tablepro-release`. A run takes its workflow files from its own ref, though, so any workflow on a pushed branch can send a job there, and so can any fork PR run the maintainer approves. The repo asks approval for every outside contributor's run.
- Every job on the label runs as the maintainer's user, with their `gh` and git credentials.
- Never approve a fork PR run that changes `.github/` without reading that change first.
- The Xcode version, the build every job uses and Apple's released build are written once, in `.github/actions/setup-xcode/action.yml`. It takes the one non-symlink `/Applications/Xcode*.app` whose `version.plist` has that version and build, fails on none or several, exports `DEVELOPER_DIR` to the job's later steps and prints `xcodebuild -version`. The version alone is no pin: the 27.1 beta, 27A9269, is also 27.1.
- A workflow never selects Xcode another way, and never runs an Xcode tool under `sudo`, which drops `DEVELOPER_DIR`. A test host gets it from the Unit plan (`.claude/rules/tests.md`, Plans).
- A stable tag builds only on Apple's released Xcode: `setup-xcode` with `require-release` needs Apple's released build and `licenseType` `GM` in the bundle's `LicenseInfo.plist`. The folder name is no signal: the image names the released 27A9275 `Xcode_27.1_Release_Candidate.app`.
Expand Down Expand Up @@ -66,10 +71,9 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/

## Release Build (`release-build.yml`)

- Nightly at 19:00 UTC on main and dispatch; never a PR or a push. Never cancelled. Not a required check, and no tag waits for it.
- `Changes (Release)` outputs `run`. A dispatch always builds. A nightly runs the `release` detector against the head of the last nightly that ended green, read with `gh api`, so a quiet day builds nothing.
- `Release Build` runs on the self-hosted Mac, or on `xcode-27` when a dispatch picks `runner: github` or, for a nightly, the repository variable `RELEASE_BUILD_RUNNER` is `github`. It has a 150-minute limit and VERSION 0.0.0-dev and BUILD 9999999, so build.sh passes the version settings a tag passes.
- While the Mac is offline a run waits for it, and GitHub cancels a job still queued after 24 hours. `gh variable set RELEASE_BUILD_RUNNER --body github` sends nightlies to `xcode-27` until `gh variable delete RELEASE_BUILD_RUNNER`.
- Nightly at 19:00 UTC on main, weekly at 07:00 UTC on Sunday, and dispatch; never a PR or a push. Every run builds. Never cancelled. Not a required check, and no tag waits for it.
- The machine: Runners and toolchain. `Release Build` has a 150-minute limit and VERSION 0.0.0-dev and BUILD 9999999, so build.sh passes the version settings a tag passes.
- While the Mac is offline a run waits for it, and GitHub cancels a job still queued after 24 hours.
- Setup as macOS Tests up to the SPM restore, which it never saves; then Go, `build-dameng.sh both` and `build-hana.sh both`.
- `apple/scripts/release/build.sh app --unsigned` archives the `TablePro` scheme; `build.sh plugins --unsigned` builds `RegistryPlugins` and copies the publish set, the targets `apple/scripts/release/registry.py targets` reads from `.github/plugin-registry.json`, to `apple/build/release/plugins`. Both build universal. They run `lipo -verify_arch` once per arch (arm64, x86_64), since Xcode 27's `lipo` takes one arch per call, then `verify-plugin-loads.sh`.
- `apple/scripts/release/package.sh app --unsigned` writes the release assets: the DMG, the `.tar.xz` (extracted and compared with the app) and the dSYMs zip; `package.sh plugins` writes one zip per plugin and the plugin dSYMs zip.
Expand Down Expand Up @@ -133,7 +137,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/
## What runs where

- A pull request: both changes jobs; macOS Tests and iOS and Package Tests unless their detector says false (Oracle only when its detector is not false); SwiftLint; Repo Hygiene; PR title. `AllPlugins` only when the `plugins` detector is not false.
- A push to main or `release/**`: the same minus PR title; never cancelled, the SPM cache saved on a miss. Every code push runs both plans in full. On main a docs-only or changelog-only push skips the suites through the detector; a `release/**` push runs every suite (Change detector). Release Build runs nightly on main and by dispatch only.
- A push to main or `release/**`: the same minus PR title; never cancelled, the SPM cache saved on a miss. Every code push runs both plans in full. On main a docs-only or changelog-only push skips the suites through the detector; a `release/**` push runs every suite (Change detector). Release Build runs nightly and weekly on main and by dispatch only.
- A `v*` tag runs only `release.yml`, and a `linux-v*` tag only `release-linux.yml`, which calls `linux-packages.yml`. The test workflows never run on a tag.
- `workflow_dispatch` exists on every workflow except `pr-title.yml` and `release.yml`.
- `build-linux.yml` builds and tests the Linux app, and `linux-packages.yml` builds its packages (`linux/docs/testing.md`, CI). What starts each is in its `on:` and job `if:` lines. Flatpak Devel and Minimum Rust version never run on a PR or push, and `linux-packages.yml` never on a push.
Expand All @@ -145,7 +149,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/
- `release.yml` restores that cache and never saves it, since a save in tag scope is unreadable from other refs.
- `release-build.yml` restores it and leaves the save to macOS Tests.
- There is no other Apple cache: no libs or DerivedData cache, and none in the iOS or plugin workflows. The Linux jobs cache Cargo builds through `Swatinem/rust-cache` and the Flatpak build.
- CI fits the GitHub Free and Pro plans: no paid larger runner. At most 5 hosted macOS jobs run at once, and main runs queue behind PR runs for those slots. Release Build takes none of them unless a dispatch or `RELEASE_BUILD_RUNNER` picks `github`.
- CI fits the GitHub Free and Pro plans: no paid larger runner. At most 5 hosted macOS jobs run at once, and main runs queue behind PR runs for those slots. Release Build takes one only in its weekly run, or when `MAC_RUNNER` or a dispatch picks `github`.

## Writing workflow and lint code

Expand Down
50 changes: 9 additions & 41 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
@@ -1,62 +1,30 @@
name: Release Build

# The Release configuration of the app and every registry plugin, universal and unsigned, packaged
# the way a tag packages it, so a packaging failure shows before the tag: nightly on main and on demand.
# the way a tag packages it, so a packaging failure shows before the tag: nightly on main, weekly on
# the hosted runner, and on demand.

on:
schedule:
- cron: '0 19 * * *'
# Always hosted, so the MAC_RUNNER fallback stays tested. runs-on matches this string.
- cron: '0 7 * * 0'
workflow_dispatch:
inputs:
runner:
description: Where the build runs. github is the fallback while the self-hosted Mac is offline.
description: Where the build runs. default follows the MAC_RUNNER repository variable.
type: choice
options: [self-hosted, github]
default: self-hosted
options: [default, self-hosted, github]
default: default

permissions:
contents: read

jobs:
changes:
name: Changes (Release)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
outputs:
run: ${{ steps.decide.outputs.run }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

# A nightly diffs against the last nightly that ended green, built or skipped: main up to
# that commit is covered.
- name: Decide whether the Release build runs
id: decide
env:
EVENT: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ "$EVENT" = workflow_dispatch ]; then
run=true
else
base=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release-build.yml/runs?event=schedule&status=success&per_page=1" \
--jq '.workflow_runs[0].head_sha // ""')
run=$(scripts/ci/detect-changed-paths.sh --base "$base" release)
fi
echo "run=$run" >> "$GITHUB_OUTPUT"

# Reads "not false" so a failed changes job builds instead of passing.
build:
name: Release Build
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }}
# A nightly has no input, so it reads the RELEASE_BUILD_RUNNER repository variable; unset is the Mac.
runs-on: ${{ (inputs.runner || vars.RELEASE_BUILD_RUNNER) == 'github' && 'xcode-27' || fromJSON('["self-hosted", "macOS", "ARM64", "tablepro-release"]') }}
# The dispatch input wins, then the weekly schedule, then MAC_RUNNER; unset is the Mac.
runs-on: ${{ (inputs.runner == 'github' || (inputs.runner != 'self-hosted' && (github.event.schedule == '0 7 * * 0' || vars.MAC_RUNNER == 'github'))) && 'xcode-27' || fromJSON('["self-hosted", "macOS", "ARM64", "tablepro-release"]') }}
timeout-minutes: 150
# The tag's version settings run here first; 9999999 is the largest build at MAJOR 0 (apple/RELEASING.md, Versions).
env:
Expand Down
3 changes: 2 additions & 1 deletion apple/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ python3 apple/scripts/release/notes.py docs X.Y.Z --ref vX.Y.Z --mdx ../tablepro

## What a tag runs

A `v*` tag runs `release.yml` and nothing else. A run is never cancelled, and every job reads the version, build and channel from `verify`.
A `v*` tag runs `release.yml` and nothing else. A run is never cancelled, and every job reads the version, build and channel from `verify`. `app`, `plugins` and `feed` run on GitHub-hosted runners.

- `verify`: `version.py check` refuses a tag that breaks the grammar or the bounds; that `origin/release/<X.Y>` does not contain; whose previous tag on the line is not its ancestor; on a commit that already carries another `v*` tag; a stable X.Y.0 with no published beta, or a beta after its X.Y.0; a stable that changes shipped source (the `shipped` suite of `scripts/ci/detect-changed-paths.sh`) since its line's newest published beta, until a stable tag on the line has matched that beta, so a patch after a matching X.Y.0 passes; a `CHANGELOG.md` missing the stable's heading, or a published stable of this or the previous line below it; a lead on a patch, of more than 6 lines, or with a line starting with `#` or `- `; a stable whose tag still holds fragments in `changelog.d/`. Then it reads the tag commit's push runs on `release/<X.Y>` once: macOS Tests and iOS and Package Tests must each have completed with success, their test job run and not skipped. A `release/**` push runs every suite, so the line head always gets both (`.claude/rules/ci.md`, Change detector). It never waits: a run still queued or in progress fails it. Release Build is no gate. Last, it writes the notes.
- `app` (`signing`): a GA Xcode for a stable (`setup-xcode` `require-release`), the CloudKit Production schema check, `build.sh app` (archive, export with `scripts/release/ExportOptions.plist`, universal and plugin load checks), `notarize.sh` on the app, `package.sh app`, `notarize.sh` on the DMG.
Expand Down Expand Up @@ -170,3 +170,4 @@ apple/scripts/release/build.sh plugins --unsigned && apple/scripts/release/packa
```

- `build.sh` writes `build/release/` (xcodebuild logs in `build/release/logs/`), `package.sh` writes `dist/`. CI builds the same in Release Build (`.claude/rules/ci.md`, Release Build). Signed: drop `--unsigned`, export the tag's `VERSION` and `BUILD`, and have the Developer ID identity and the provisioning profile `ExportOptions.plist` names. `notarize.sh <path>...` needs the profile `xcrun notarytool store-credentials TablePro --key <p8> --key-id <id> --issuer <issuer>` creates.
- Nightly Release Builds run on the self-hosted Mac. While it is offline, `gh variable set MAC_RUNNER --body github` sends them to `xcode-27`, and `gh variable delete MAC_RUNNER` switches back.
Loading