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
187 changes: 44 additions & 143 deletions .claude/rules/ci.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .claude/rules/plugin-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke
- The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `<integer>TABLEPRO_PLUGINKIT_VERSION</integer>` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`.
- After an additive change, built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load.
- A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`. Every tag ships every registry plugin built against its own kit, so the release that carries the change carries plugins in its range.
- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). When CI runs it: `.claude/rules/ci.md`, macOS Tests.
- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). CI runs it in `.github/workflows/macos-tests.yml` when the `plugins` suite is true, `--frozen` on a PR into `release/*`.
- A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's and the base's minimum; an identical one needs the base's kit and minimum. `--frozen` fails on any ABI change.
- The checker reports breakage only; an additive change shows as a changed baseline. Its errors are in the log `verify.sh abi` prints.
- Mark a public enum `@frozen` only when its case set is closed. `PluginCapability` and the transfer structs stay non-frozen so they can grow.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Before any decision, gather:
- 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 --branch main --event push --limit 10`, with macOS Tests and iOS and Package Tests green on the commit you would cut;
- 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).

Expand Down
108 changes: 39 additions & 69 deletions .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
name: Build Linux

# CI calls it. Its own schedule and a manual run of it also take the nightly jobs.
on:
pull_request:
push:
branches: [main]
workflow_call:
inputs:
docs-only:
description: Run only the docs links job.
type: boolean
required: true
schedule:
- cron: "13 14 * * *"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: build-linux-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
CARGO_ABOUT_VERSION: 0.9.2
CARGO_DENY_VERSION: 0.20.2
Expand All @@ -25,27 +25,31 @@ env:
LYCHEE_SHA256: 1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a

jobs:
changes:
name: Changes (Linux)
docs:
name: Documentation links
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
run: ${{ steps.decide.outputs.run }}
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Decide whether the Linux jobs run
id: decide
env:
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
run: echo "run=$(scripts/ci/detect-changed-paths.sh --base "$BASE" linux)" >> "$GITHUB_OUTPUT"
- name: Install lychee
run: |
curl --fail --silent --show-error --location --output "$RUNNER_TEMP/lychee.tar.gz" \
"https://github.com/lycheeverse/lychee/releases/download/lychee-v$LYCHEE_VERSION/lychee-x86_64-unknown-linux-gnu.tar.gz"
echo "$LYCHEE_SHA256 $RUNNER_TEMP/lychee.tar.gz" | sha256sum --check --strict
mkdir "$RUNNER_TEMP/lychee"
tar -xzf "$RUNNER_TEMP/lychee.tar.gz" -C "$RUNNER_TEMP/lychee" --strip-components=1 \
lychee-x86_64-unknown-linux-gnu/lychee
echo "$RUNNER_TEMP/lychee" >> "$GITHUB_PATH"
- name: Links and anchors in the Linux docs resolve
# Offline, so only links to files and headings in the tree are
# checked: the ones a rename or a move breaks.
run: lychee --offline --include-fragments --no-progress 'linux/**/*.md'

fast:
name: Fast checks
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }}
if: ${{ !inputs.docs-only }}
runs-on: ubuntu-24.04
timeout-minutes: 150
# The host runner ships GLib 2.80, below the GNOME 50 floor.
Expand Down Expand Up @@ -105,22 +109,6 @@ jobs:
xvfb \
xauth \
dbus-daemon
# On the bare checkout: the glob ignores .gitignore, so once Cargo
# fills _build it would read every crate's Markdown too.
- name: Install lychee
run: |
curl --fail --silent --show-error --location --output "$RUNNER_TEMP/lychee.tar.gz" \
"https://github.com/lycheeverse/lychee/releases/download/lychee-v$LYCHEE_VERSION/lychee-x86_64-unknown-linux-gnu.tar.gz"
echo "$LYCHEE_SHA256 $RUNNER_TEMP/lychee.tar.gz" | sha256sum --check --strict
mkdir "$RUNNER_TEMP/lychee"
tar -xzf "$RUNNER_TEMP/lychee.tar.gz" -C "$RUNNER_TEMP/lychee" --strip-components=1 \
lychee-x86_64-unknown-linux-gnu/lychee
echo "$RUNNER_TEMP/lychee" >> "$GITHUB_PATH"
- name: Links and anchors in the Linux docs resolve
# Offline, so only links to files and headings in the tree are
# checked: the ones a rename or a move breaks.
working-directory: .
run: lychee --offline --include-fragments --no-progress 'linux/**/*.md'
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: "1.98"
Expand Down Expand Up @@ -231,8 +219,9 @@ jobs:
# compiler and not with rustup's.
msrv:
name: Minimum Rust version
# Nightly and by hand only, to keep a pull request under GitHub Free's 20 concurrent jobs.
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Only in this workflow's own nightly or manual run, to keep a CI run under GitHub Free's 20
# concurrent jobs. A called workflow sees the caller's github context, so from CI this is "CI".
if: ${{ github.workflow == 'Build Linux' }}
runs-on: ubuntu-24.04
timeout-minutes: 60
container:
Expand Down Expand Up @@ -266,8 +255,7 @@ jobs:

supply-chain:
name: Supply chain
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }}
if: ${{ !inputs.docs-only }}
runs-on: ubuntu-24.04
timeout-minutes: 30
defaults:
Expand Down Expand Up @@ -306,14 +294,14 @@ jobs:
working-directory: .
run: |
zizmor --offline --format=github --min-severity=medium \
.github/workflows/ci.yml \
.github/workflows/build-linux.yml \
.github/workflows/linux-packages.yml \
.github/workflows/release-linux.yml

i18n:
name: Translations
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }}
if: ${{ !inputs.docs-only }}
runs-on: ubuntu-24.04
timeout-minutes: 30
# ubuntu:26.10 ships gettext 1.0, the first release whose xgettext
Expand Down Expand Up @@ -358,8 +346,9 @@ jobs:

flatpak-devel:
name: Flatpak Devel (${{ matrix.arch }})
# Nightly and by hand only, to keep a pull request under GitHub Free's 20 concurrent jobs.
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
# Only in this workflow's own nightly or manual run, to keep a CI run under GitHub Free's 20
# concurrent jobs. A called workflow sees the caller's github context, so from CI this is "CI".
if: ${{ github.workflow == 'Build Linux' }}
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -397,8 +386,8 @@ jobs:

integration:
name: Docker tests (${{ matrix.package }}${{ matrix.release != 'default' && format(', {0}', matrix.release) || '' }})
needs: [changes, fast]
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }}
needs: fast
if: ${{ !cancelled() && needs.fast.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 90
strategy:
Expand Down Expand Up @@ -497,8 +486,8 @@ jobs:
# is mounted; testcontainers sees /.dockerenv and reaches the
# containers it starts through the bridge gateway.
name: Docker tests (tablepro-session)
needs: [changes, fast]
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }}
needs: fast
if: ${{ !cancelled() && needs.fast.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 90
container:
Expand Down Expand Up @@ -560,8 +549,8 @@ jobs:
# session job. Only the named test targets run: the app's startup
# test needs Meson and the fast job already covers it.
name: Docker tests (tablepro)
needs: [changes, fast]
if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }}
needs: fast
if: ${{ !cancelled() && needs.fast.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 90
container:
Expand Down Expand Up @@ -617,22 +606,3 @@ jobs:
with:
name: junit-tablepro
path: linux/target/nextest/ci/junit.xml

# The one check a branch rule requires: a job skipped by a path or event
# decision reports success, and this fails when any Linux job failed or was
# cancelled.
linux:
name: Linux
needs: [changes, fast, msrv, supply-chain, i18n, flatpak-devel, integration, integration-session, integration-app]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Every Linux job passed or was skipped
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "$RESULTS"
case " $RESULTS " in
*" failure "* | *" cancelled "*) exit 1 ;;
esac
133 changes: 133 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: CI

# The one entry point for the test and lint workflows. The CI job at the end is the required check.

on:
pull_request:
push:
branches: [main, 'release/**']
workflow_dispatch:

# A pull request keeps only its newest run. main runs one at a time, and a newer pending run
# replaces the older one. Each release/** commit and each dispatch gets its own group, so none is
# replaced.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_dispatch' && format('dispatch-{0}', github.run_id) || github.ref == 'refs/heads/main' && github.ref || format('{0}-{1}', github.ref, github.sha) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
plan:
name: Plan
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
outputs:
macos: ${{ steps.decide.outputs.macos }}
plugins: ${{ steps.decide.outputs.plugins }}
ios: ${{ steps.decide.outputs.ios }}
oracle: ${{ steps.decide.outputs.oracle }}
hana: ${{ steps.decide.outputs.hana }}
swiftlint: ${{ steps.decide.outputs.swiftlint }}
linux: ${{ steps.decide.outputs.linux }}
linux-docs: ${{ steps.decide.outputs.linux-docs }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

# A main push compares with the last commit a CI push run passed on, so a commit whose run was
# replaced or failed is tested again. No such run, one on HEAD itself, or a dispatch runs
# everything.
- name: Find the base
id: base
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
REF: ${{ github.ref }}
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
base=""
if [ "$EVENT" = pull_request ]; then
base="$PR_BASE"
elif [ "$EVENT" = push ] && [ "$REF" = refs/heads/main ]; then
passed="$(gh api "repos/$REPOSITORY/actions/workflows/ci.yml/runs?branch=main&event=push&status=success&per_page=1" \
--jq '.workflow_runs[0].head_sha // ""')"
head="$(git rev-parse HEAD)"
if [ -n "$passed" ] && [ "$passed" != "$head" ] && git merge-base --is-ancestor "$passed" HEAD; then
base="$passed"
fi
fi
echo "Base: ${base:-none, so every suite runs}"
echo "base=$base" >> "$GITHUB_OUTPUT"

# --ref makes a release/** push run every suite. The linux job below still skips there.
- name: Decide which suites run
id: decide
env:
BASE: ${{ steps.base.outputs.base }}
REF: ${{ github.ref }}
run: |
set -euo pipefail
for suite in macos plugins ios oracle hana swiftlint linux linux-docs; do
verdict="$(scripts/ci/detect-changed-paths.sh --base "$BASE" --ref "$REF" "$suite")"
echo "$suite=$verdict"
echo "$suite=$verdict" >> "$GITHUB_OUTPUT"
done

macos:
needs: plan
if: needs.plan.outputs.macos == 'true'
uses: ./.github/workflows/macos-tests.yml
with:
plugins: ${{ needs.plan.outputs.plugins == 'true' }}

ios:
needs: plan
if: needs.plan.outputs.ios == 'true'
uses: ./.github/workflows/ios-tests.yml
with:
oracle: ${{ needs.plan.outputs.oracle == 'true' }}

swiftlint:
needs: plan
if: needs.plan.outputs.swiftlint == 'true'
uses: ./.github/workflows/swiftlint.yml

hygiene:
needs: plan
uses: ./.github/workflows/repo-hygiene.yml
with:
hana: ${{ needs.plan.outputs.hana == 'true' }}

# Linux ships from main or linux-X.Y, so a release/** line skips it and an advisory that cargo deny
# learns later cannot fail the run an Apple tag's verify reads.
linux:
needs: plan
if: needs.plan.outputs.linux-docs == 'true' && !startsWith(github.ref, 'refs/heads/release/')
uses: ./.github/workflows/build-linux.yml
with:
docs-only: ${{ needs.plan.outputs.linux != 'true' }}

ci:
name: CI
needs: [plan, macos, ios, swiftlint, hygiene, linux]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Every job passed or was skipped
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "$RESULTS"
case " $RESULTS " in
*" failure "* | *" cancelled "*) exit 1 ;;
esac
Loading
Loading