From 7bfe969bc44ebd224bead84564bc255b3f384ebb Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 8 Oct 2026 08:16:29 -0500 Subject: [PATCH 1/2] contributing: one step per PR, a required gate, SDK release train How a change gets to main, written down where every session and contributor reads it. The rules lived in one person's local agent memory, where a cloud session (or a second person) never saw them, and CLAUDE.md had drifted (it still called the SDK "0.2.0, unreleased"). CONTRIBUTING.md, imported by CLAUDE.md: one plan step per PR (plans are issues, ~800 lines of non-test diff, `/` branches never reused, the PR title is the squash commit); claiming work with a draft PR before building it; jj in a workspace of one's own; verifying and saying how; and the SDK on a release train. A feature PR records `recorded_sdk_shape_fingerprint` and leaves `sdk_version` alone; only an `sdk: release 0.2.N` PR bumps it. Two branches no longer race for the same number, and a morning's SDK changes cost one repin round instead of several. The doc comments in `sdk/src/version.zig`, `sdk/sdk_version.zig`, `sdk/src/dylib.zig` and docs/PLUGINS.md that tied a bump to every fingerprint change now say the same. CI: `ci-ok`, one job that passes when every other job passed or was skipped, is the check main's ruleset requires (recorded in `.github/rulesets/main.json`). The workflow-level `paths-ignore` goes, since it would leave a Markdown-only PR's required check waiting for good; a `changes` job decides instead whether the builds run. CI also runs for `merge_group`, so a merge queue needs no workflow change. RELEASING.md bumps VERSION through a PR, SDK release first if the fingerprint moved. A PR template asks for the SDK impact and per-platform verification. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 30 +++++++ .github/rulesets/main.json | 41 +++++++++ .github/workflows/ci.yml | 89 +++++++++++++++++--- CLAUDE.md | 19 +++-- CONTRIBUTING.md | 137 +++++++++++++++++++++++++++++++ RELEASING.md | 18 ++-- docs/PLUGINS.md | 13 +-- sdk/sdk_version.zig | 5 +- sdk/src/dylib.zig | 2 +- sdk/src/version.zig | 30 ++++--- 10 files changed, 339 insertions(+), 45 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/rulesets/main.json create mode 100644 CONTRIBUTING.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..397c2ed94 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ + + +Part of # + +## What changes + + + +## SDK impact + +- [ ] None +- [ ] Core-only or additive: reaches plugins at the next SDK release +- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` +- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag + +## Verified + + + +- [ ] macOS: +- [ ] Windows: +- [ ] Linux: +- [ ] Web: + +## Follow-ups + + diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json new file mode 100644 index 000000000..4afa20677 --- /dev/null +++ b/.github/rulesets/main.json @@ -0,0 +1,41 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 1, + "actor_type": "OrganizationAdmin", + "bypass_mode": "pull_request" + } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "do_not_enforce_on_create": false, + "required_status_checks": [{ "context": "ci-ok" }] + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c699cc0f..1fd0fdec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,20 +1,17 @@ name: CI -# PR / manual: three native test jobs (no skipped rows). Push to main: Linux-only (fast). +# PR / merge queue / manual: three native test jobs (no skipped rows). Push to main: Linux-only +# (fast). +# +# `ci-ok` is the one check `main`'s ruleset requires (`.github/rulesets/main.json`), so the +# workflow runs for every change. A workflow-level `paths-ignore` would never start for a +# Markdown-only PR, leaving its required check waiting for good; `changes` works out instead +# whether anything besides docs changed, and the builds skip themselves when nothing did. on: push: branches: [main] - paths-ignore: - - "doc/**" - - "README.md" - - "**.md" - - "LICENSE**" pull_request: - paths-ignore: - - "doc/**" - - "README.md" - - "**.md" - - "LICENSE**" + merge_group: workflow_dispatch: concurrency: @@ -25,8 +22,53 @@ env: ZIG_VERSION: "0.16.0" jobs: + changes: + name: Changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + # A pull request checks out its merge commit, whose first parent is the base: depth 2 + # holds both. + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Anything besides docs? + id: filter + shell: bash + env: + EVENT: ${{ github.event_name }} + PUSH_BEFORE: ${{ github.event.before }} + QUEUE_BASE: ${{ github.event.merge_group.base_sha }} + run: | + set -euo pipefail + case "$EVENT" in + pull_request) base=HEAD^1 ;; + merge_group) base=$QUEUE_BASE ;; + push) base=$PUSH_BEFORE ;; + *) echo "code=true" >> "$GITHUB_OUTPUT"; exit 0 ;; + esac + # A base that can't be fetched (a force push's old tip) builds everything. + if ! git cat-file -e "$base^{commit}" 2>/dev/null && ! git fetch --depth=1 origin "$base"; then + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Assigned first, so a failing diff fails the job (and `ci-ok`) rather than reading as + # "nothing changed". + files=$(git diff --name-only "$base" HEAD) + code=false + while IFS= read -r f; do + case "$f" in + "" | *.md | doc/* | LICENSE*) ;; + *) code=true; echo "builds: $f changed" ;; + esac + done <<< "$files" + echo "code=$code" >> "$GITHUB_OUTPUT" + test: name: Tests (${{ matrix.name }}) + needs: changes + if: needs.changes.outputs.code == 'true' strategy: fail-fast: false matrix: @@ -115,7 +157,8 @@ jobs: # builds do). It shows the app compiles and links, not that it runs. windows-fizzy-backend: name: Windows build (fizzy backend, cross-compiled) - if: github.event_name != 'push' + needs: changes + if: github.event_name != 'push' && needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -129,3 +172,25 @@ jobs: run: bash scripts/ci-retry.sh zig build --fetch - name: Build for x86_64-windows-gnu with -Dnative-backend=fizzy run: bash scripts/ci-retry.sh zig build -Dtarget=x86_64-windows-gnu -Dnative-backend=fizzy -Dno-emit --summary all + + # The required check. Passes when every job above passed or was skipped (a docs-only change + # skips the builds); fails on any failure or cancellation, `changes` included. A job added to + # this workflow joins the gate by being added to `needs`. + ci-ok: + name: ci-ok + if: always() + needs: [changes, test, windows-fizzy-backend] + runs-on: ubuntu-latest + steps: + - name: Every job passed or was skipped + shell: bash + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "results: $RESULTS" + for r in $RESULTS; do + case "$r" in + success | skipped) ;; + *) exit 1 ;; + esac + done diff --git a/CLAUDE.md b/CLAUDE.md index bf22b21d7..e4509c376 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,11 @@ Cross-platform, open-source general editor written in Zig, UI via [DVUI](https:/ **Read this file first, then go deeper via the links below — don't re-derive the architecture from scratch.** +**How we work** — one plan step per PR, claiming work before building it, jj, verifying, the SDK +release train — is [`CONTRIBUTING.md`](CONTRIBUTING.md), imported here so every session reads it: + +@CONTRIBUTING.md + ## The core idea: fizzy + plugins Fizzy the app is itself a near-empty host (window, frame loop, layout shape, document model) that owns **no editing features**. Everything the user sees — pixel-art editing, the file explorer/tabs/splits, text editing — is contributed by **plugins** that register against a stable SDK. Plugins never import each other; they meet only at the SDK. @@ -41,7 +46,7 @@ Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register( 5. User-invoked actions are **`Command`s** — `"."`. 6. `zig build install` drops `{id}/{id}.dylib` (its own directory) into the fizzy plugins dir (no sidecar `.zon`). 7. Memory: `host.allocator` vs `host.arena()`; never touch `dvui.currentWindow().gpa` directly. -8. ABI: structural fingerprint at `dlopen` (`fizzy_plugin_abi_fingerprint`). The SDK is at 0.2.0, unreleased: the fingerprint may move freely under it (update `recorded_sdk_shape_fingerprint`), the version does not until it ships. +8. ABI: structural fingerprint at `dlopen` (`fizzy_plugin_abi_fingerprint`). A change that moves the boundary's shape records the new `recorded_sdk_shape_fingerprint` and leaves `sdk_version` alone; only an SDK release PR bumps the version (`CONTRIBUTING.md`, "Changing the SDK"). Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: **[`docs/PLUGIN_MANIFEST_PLAN.md`](docs/PLUGIN_MANIFEST_PLAN.md)**. @@ -131,8 +136,9 @@ one directory — that is why the `app` module root is `app/root.zig`, not `app/ ```sh zig build # native exe zig build check-web # wasm -zig build test # unit/integration tests -zig build test-sdk-version # CI lock: ABI fingerprint bump must bump sdk_version too +zig build test # unit tests +zig build test-integration # headless integration tests (not run by CI: run it yourself) +zig build test-sdk-version # CI lock: the recorded fingerprint matches the live plugin boundary ``` Run all of these after touching the SDK boundary (`sdk/src/**`) or a plugin's vtable usage. @@ -145,7 +151,7 @@ Pattern: - **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`. - **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. -- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error tells you to bump `sdk_version`, which cannot help. Bump or swap to a local checkout in `sdk/build.zig.zon` only. +- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error asks for a new recorded fingerprint, which cannot satisfy both. Bump or swap to a local checkout in `sdk/build.zig.zon` only. - Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build. Acceptance test after any build-graph change: @@ -159,8 +165,9 @@ CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (se ## When you need more than this file -- **Resuming the library/framework work (bookmark `fizzy-lib`)** → [`docs/LIB_CHECKPOINT.md`](docs/LIB_CHECKPOINT.md): - ground rules, what is done, the verification workflow, and the agreed next steps. +- **The library/framework work (merged from bookmark `fizzy-lib` in #194)** → [`docs/LIB_CHECKPOINT.md`](docs/LIB_CHECKPOINT.md): + what was done and the agreed next steps. Its ground rules predate `CONTRIBUTING.md`, which wins + where they differ. - Demos that play the real app (tapes, the player, rewind, writing a demo, the anchors widgets publish) → [`docs/AUTOMATION.md`](docs/AUTOMATION.md); where it is going (recording, seeking, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..fce18490d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Working on fizzy + +`CLAUDE.md` says what fizzy is. This file says how a change gets from an idea to `main`, for +people and agents alike, on a laptop or in a cloud session. Several agents work on this repo at +once, and these rules are what keep their work from colliding. + +**Rules live here, not in private memory.** A session that learns a rule a second contributor +would need adds it to this file in a PR. Agent memory is for one person's context: their +preferences, findings still in flight. + +## One plan step, one branch, one PR + +- **A plan is an issue** with a checklist of steps, or a plan doc merged on its own and linked + from one. A plan is never the PR its implementation lands in. +- **Each step is its own PR**, saying `Part of #N`. A PR is one change a reviewer can hold in + their head: aim for **800 lines or fewer** of diff outside tests and docs. Bigger work goes in + a stack of PRs, or lands dark behind a flag (`FIZZY_POPOUT` is the pattern) and is switched on + in a small PR of its own. +- **One branch per PR, never reused**, named `/`: `popout/float-overshoot`, + `sdk/file-row-decorator`, `ci/required-gate`. The area is the part of the tree the change is + about: `sdk`, `app`, `core`, `layout`, `workbench`, `text`, `markdown`, `web`, `macos`, + `windows`, `linux`, `ci`, `docs`, and so on. +- **The PR title is the commit on `main`.** PRs are squash-merged, the title becoming the subject + and the body the message. Title: `area: what changes`, at most 72 characters, saying what a + user or plugin author sees. The rest (why, what was measured, what was tried and dropped) goes + in the body. jj descriptions follow the same shape: a short first line, a blank line, then the + paragraph. + +## Claim before you build + +The open PR list is the board of who is working on what. + +1. **Look first.** `gh pr list --json number,title,headRefName,isDraft,files`. If an open PR + touches the same files or the same plan step, coordinate on it (comment, or stack on top of + it) instead of starting a parallel version. +2. **Open a draft PR on your first push** (`gh pr create --draft --body-file `), with the + template filled in as far as you know it. A draft runs CI but doesn't take the one web test + copy (`web.yml`), so claim early. +3. **Mark it ready when it is done and verified.** Merging is the maintainer's. Never push to + `main`, and never merge a PR you opened unless asked to. + +## jj + +`fizzy` is a **Jujutsu** repo with a colocated `.git`. Use `jj`. + +- **No git write commands.** `git checkout -- `, `git restore`, `git reset` and + `git commit` act on the last *git* commit, which can be far behind jj's working copy, and take + undescribed work with them. If something is clobbered, `jj op log` and `jj undo` / + `jj op restore` bring it back. +- **Work in a workspace of your own.** The default checkout is shared: the desktop app moves it + between sessions' branches, and other sessions edit in it. For anything longer than a quick + look, `jj workspace add --name -r main@origin ` and build, test and describe + there. A cloud session or a git worktree is already isolated. +- **`jj new` before the work, not after.** Start each change with + `jj new -m ""` and refine it with `jj describe` when done. Describing + twice without a `jj new` between folds two changes into one and overwrites the first message. +- **Backticks in messages:** write the message to a file through a quoted heredoc (`<<'MSG'`), + then `jj describe --stdin < file` or `gh pr create --body-file file`. With `-m "…"` or an + unquoted heredoc, zsh runs every backticked name as a command. +- **Push a branch:** `jj bookmark create / -r @-`, then + `jj git push --bookmark /`. Pushing `main` publishes (the web app at + fizzyed.it/app, and an SDK release when the version moved), so `main` moves only by a merged + PR. +- **After the merge, drop your stack.** A squash merge leaves the branch's changes behind + locally, and the next agent's `jj log` can't tell landed work from live work. After + `jj git fetch`, check that `jj log -r '::/ ~ ::main@origin'` lists only your PR's + changes, then `jj abandon` that revset. Never abandon a change that is some workspace's + working copy (`jj workspace list`). + +## Verify, and say how + +- **Gates:** `zig build`, `zig build test`, `zig build test-integration`, `zig build check-web`, + `zig build test-sdk-version`. CI runs all of them but `test-integration`, so run that one + yourself. +- **Read the Build Summary, not the test count.** `test-integration` prints `failed command:` + whenever a test logs a warning, even on a pass, and can show `N/N tests passed` beside a failed + step. Only `N/N steps succeeded` is a pass. +- **Know what a green run covered.** `addTest` collects tests from its root module only; check + the list in `build/app.zig` before citing a run for a file's tests. +- **A plugin's plain `zig build` also installs** into the real plugins directory. For a sandbox + run, point `HOME` at the sandbox, so a test build never replaces someone's installed plugin. +- **UI changes say how they were seen** (a test, a demo tape, a screenshot, a measurement) and + on which platforms. CI's Windows cross-compile shows the app links, not that it works. + +## Changing the SDK: a release train + +The plugin boundary has two numbers. `recorded_sdk_shape_fingerprint` (`sdk/src/version.zig`) +must match the boundary's live shape, or the build fails. `sdk_version` (`sdk/sdk_version.zig`) +is what plugins pin, and **a new `sdk_version` merged to `main` is a release**: `sdk-tag.yml` +tags `sdk-v*` and publishes the tarball, and every store plugin is repinned to load against it. +A merge that leaves the version alone publishes nothing: a released tarball is pinned by hash, +so it is never replaced. + +- **A feature PR records the fingerprint and leaves `sdk_version` alone.** When the shape moves, + the build fails with the new value; record it, label the PR `sdk`, and say in the template's + SDK section what changed for plugin authors. +- **Only a release PR bumps `sdk_version`**: `sdk: release 0.2.N`, listing the `sdk` PRs merged + since the last `sdk-v*` tag. Two branches never race for the same number, and a morning's SDK + changes cost one round of repinning instead of several. Changes to `core/` that plugins build + in, with no fingerprint move, reach them the same way. +- **Release soon after a merge that moved the fingerprint.** fizzyed.it/app is built from + `main`, and store plugins don't load there until the release is out and they are repinned. + Batch on purpose, not by forgetting. +- **An app release never ships an unreleased fingerprint.** If it moved since the last `sdk-v*` + tag, release the SDK first (`RELEASING.md`). + +## Where knowledge lives + +| What | Where | +|---|---| +| What fizzy is, and its architecture rules | `CLAUDE.md` | +| How we work | this file | +| The plugin contract | `docs/PLUGINS.md` | +| Plans | issues; a long design as `docs/_PLAN.md`, opening with a status line (`Status: proposed`, `in progress, #N`, or `done`) | +| The forks fizzy builds on | `docs/DEPENDENCIES.md` | +| Releases | `RELEASING.md` | + +## Habits reviewers ask for + +- **Fix the root cause.** When a fix is the third patch to the same mechanism, stop and name + what is wrong with the model. Say plainly when a fix is a workaround. +- **Use dvui's public API** rather than reaching into its state. Changes to dvui itself go to + the fork (`docs/DEPENDENCIES.md`). +- **A deferred feature gets a named seam.** When a plan leaves something for later, say what it + will hook into, not just "not yet". +- **No one-line wrapper functions.** Write the library call at the site. + +## The gate on `main` + +`main` takes changes only through squash-merged PRs whose one required check, `ci-ok`, has +passed. `.github/rulesets/main.json` is the record of that ruleset; changing it is a repo setting +as well as a PR. Admins can merge a PR past a failing check in an emergency, never push to +`main` directly. + +`ci-ok` passes when every other CI job passed or was skipped: a PR changing only Markdown skips +the builds and still gets its check. CI also runs for a merge queue (`merge_group`), so turning +the queue on needs no workflow change. diff --git a/RELEASING.md b/RELEASING.md index e676775b8..1e60c8064 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,13 +57,19 @@ The release script handles all three for all six channels (18 files total). ### Bump VERSION and tag +`main` moves only by a merged PR (`CONTRIBUTING.md`), so the bump is a PR of its own: + +1. **SDK first.** If `recorded_sdk_shape_fingerprint` moved since the last `sdk-v*` tag, merge + an SDK release PR before this one — an app must never ship a plugin boundary no released SDK + matches, or no store plugin loads in it. +2. On a branch of its own, edit `VERSION` (e.g. 0.0.3 -> 0.0.4) and open a PR titled + `release: 0.0.4`. +3. Once it is merged, tag the merge commit: + ```sh -# Edit VERSION (e.g. 0.0.3 -> 0.0.4) -$EDITOR VERSION -git add VERSION -git commit -m "release: 0.0.4" -git tag v0.0.4 -git push origin main +git fetch origin +git log -1 --oneline origin/main # must be the `release: 0.0.4` commit +git tag v0.0.4 origin/main git push origin v0.0.4 ``` diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 995614f99..f5f22af4a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1420,15 +1420,18 @@ rare and deliberate, not something that breaks on every release: signal for authors to rebuild; until they do, the store shows "needs a rebuild for Fizzy SDK x.y" instead of offering an incompatible binary. -CI enforces the pairing on the fizzy side: `zig build test-sdk-version` fails at compile time if -the live shape fingerprint (`dylib.sdk_shape_fingerprint`) drifts from the recorded literal -(`recorded_sdk_shape_fingerprint` in `sdk/src/version.zig`) without an accompanying `sdk_version` -bump. +CI keeps the recorded literal honest on the fizzy side: `zig build test-sdk-version` fails at +compile time if the live shape fingerprint (`dylib.sdk_shape_fingerprint`) drifts from +`recorded_sdk_shape_fingerprint` in `sdk/src/version.zig`. The change that moves the boundary +records the new value; `sdk_version` moves only in an SDK release PR, which ships every boundary +change since the last `sdk-v*` tag at once (`CONTRIBUTING.md`, "Changing the SDK"). The three fields of `sdk_version` are a convention, not semver's — `sdkVersionSatisfies` is a plain lexicographic compare with no "0.x is special" carve-out: -- **patch** — bumped on every `recorded_sdk_shape_fingerprint` change that ships. +- **patch** — bumped by each SDK release that ships `recorded_sdk_shape_fingerprint` changes (or + `core/` changes plugins build in). Between releases the fingerprint moves freely under the + current version. - **minor** — a compatibility *epoch*: a deliberate, announced hard break. **0.2.0** is the first release of the library-shaped SDK (`core/`, `sdk/`, `app/`; regions and surfaces); 0.1.x plugins do not load against it and are rebuilt, not migrated. **While 0.2.0 is unreleased the diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index f60a54cd0..541df3757 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -15,8 +15,9 @@ //! code can't import it. Keeping the triplet here means reading the version can never trigger, //! or depend on, the runtime ABI fingerprint check. //! -//! See `sdk/src/version.zig`'s doc comment for what each field means and when to bump it; -//! `zig build test-sdk-version` is the CI lock tying a fingerprint change to a bump here. +//! See `sdk/src/version.zig`'s doc comment for what each field means and when to bump it. A +//! bump here merged to fizzy's `main` is an SDK release (`sdk-tag.yml` tags and publishes it), +//! so it happens only in a release PR (fizzy's `CONTRIBUTING.md`, "Changing the SDK"). const std = @import("std"); pub const sdk_version = std.SemanticVersion{ diff --git a/sdk/src/dylib.zig b/sdk/src/dylib.zig index bdf8625d3..27f00c05f 100644 --- a/sdk/src/dylib.zig +++ b/sdk/src/dylib.zig @@ -261,7 +261,7 @@ pub const abi_fingerprint: u64 = blk: { /// Target- and optimize-mode-*invariant* hash of the Fizzy-owned boundary's declared shape (see /// `fingerprint.hashAllShape` for what that means and why). Two jobs: -/// * `version.zig`'s "did you forget to bump `sdk_version`" guard checks it against a single +/// * `version.zig`'s "did the boundary just move" guard checks it against a single /// recorded literal — invariant, so that guard needs no per-target table and no /// cross-compiling to populate; and /// * `abi_fingerprint` above is this value folded with the optimize-mode class, i.e. the runtime diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 0abbed861..b4775ce8c 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -1,15 +1,16 @@ //! SDK version and ABI fingerprint lock. //! -//! `sdk_version` is bumped when the plugin ABI boundary changes. `recorded_sdk_shape_fingerprint` -//! must be updated in the same commit — CI fails at compile time if the live shape fingerprint -//! drifts from the recorded literal without an intentional version bump. +//! `recorded_sdk_shape_fingerprint` is updated by the change that moves the plugin ABI boundary — +//! CI fails at compile time if the live shape fingerprint drifts from the recorded literal. +//! `sdk_version` is bumped separately, by the SDK release that ships the change: one release PR +//! per batch of boundary changes (fizzy's `CONTRIBUTING.md`, "Changing the SDK"). //! //! **Two fingerprints, one shape.** Both derive from the same target/mode-invariant declared //! shape (`fingerprint.hashAllShape`: field names/order, integer bit-width, enum tags, pointer //! kind, fn signatures — never a byte offset or size) of the Fizzy-owned boundary: //! //! * `dylib.sdk_shape_fingerprint` — the bare shape hash, checked below against a single -//! recorded literal. The "did you forget to bump `sdk_version`" guard. Invariant, so it needs +//! recorded literal. The "did the boundary just move" guard. Invariant, so it needs //! no per-(arch, os, mode) table and no cross-compiling: `zig build test-sdk-version` on any //! target reports the value to record. //! * `dylib.abi_fingerprint` — the runtime dlopen-time load key: the shape hash folded with the @@ -20,11 +21,12 @@ //! store match one fingerprint per release across every `os-arch` binary. //! //! Because the load key is shape-based, a plugin breaks *only* when the boundary shape actually -//! changes (→ you bump `sdk_version`) or the optimize-mode class differs (a genuinely unloadable -//! combination). A cosmetic dvui/toolchain update that leaves the boundary shape untouched keeps -//! every installed plugin loading. The one thing this no longer catches — pure codegen/padding -//! drift within a single `sdk_version` — only happens on a deliberate, pinned zig/dvui bump that -//! is a coordinated re-release anyway. See `fingerprint.hashAllShape` for the full rationale. +//! changes (→ the next SDK release bumps `sdk_version`) or the optimize-mode class differs (a +//! genuinely unloadable combination). A cosmetic dvui/toolchain update that leaves the boundary +//! shape untouched keeps every installed plugin loading. The one thing this no longer catches — +//! pure codegen/padding drift within a single `sdk_version` — only happens on a deliberate, +//! pinned zig/dvui bump that is a coordinated re-release anyway. See `fingerprint.hashAllShape` +//! for the full rationale. //! //! **Cadence policy (decoupled from the app version).** The app version (`VERSION` / //! `build.zig.zon`) ships often and is *not* an input to either fingerprint or to `sdk_version`. @@ -32,7 +34,7 @@ //! types included, reached transitively where they cross the boundary) — it only moves when one of //! those *shapes* changes. `dvui` and the Zig toolchain are pinned (see the `dvui` dependency in //! `build.zig.zon` and `ZIG_VERSION` in CI) and bumped deliberately/batched; a bump that -//! restructures a boundary-reachable dvui type moves the shape fingerprint (→ bump `sdk_version`), +//! restructures a boundary-reachable dvui type moves the shape fingerprint (→ an SDK release), //! while a cosmetic one does not. A Fizzy release that leaves the boundary shape untouched keeps //! the same fingerprint, so the store's installed plugins keep loading. The store matches plugin //! binaries on `abi_fingerprint` (see `docs/PLUGINS.md` § Compatibility). @@ -46,7 +48,9 @@ pub const VersionTriplet = dylib.VersionTriplet; /// (major, minor, patch) compare with no semver "0.x is special" carve-out, so each field's /// meaning is a convention this project enforces by discipline, not by the type system: /// -/// * **patch** — bump on every `recorded_sdk_shape_fingerprint` change that ships. +/// * **patch** — bumped by each SDK release that ships `recorded_sdk_shape_fingerprint` +/// changes (or `core/` changes plugins build in). Between releases the fingerprint moves +/// freely under the current version. /// * **minor** — a compatibility *epoch*: a deliberate, announced hard break. 0.2.0 is the /// first release of the library-shaped SDK (`core/`, `sdk/`, `app/`; regions and surfaces); /// 0.1.x plugins do not load against it and are rebuilt, not migrated. While 0.2.0 is @@ -69,8 +73,8 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// Recorded `dylib.sdk_shape_fingerprint` — see the module doc above for what this hashes and /// why it is a single target/mode-invariant literal rather than a per-target table. Update this -/// value (from the `@compileError` it triggers) and bump `sdk_version` in the same commit -/// whenever it changes. +/// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the +/// next SDK release. pub const recorded_sdk_shape_fingerprint: u64 = 0x86445a7bc5734576; comptime { From 1ebe74f9ea660a4df2f8520c476be7f7f277832a Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 8 Oct 2026 08:34:36 -0500 Subject: [PATCH 2/2] contributing: after a merge, jj git fetch drops the merged stack The step told agents to abandon `::/ ~ ::main@origin` after fetching, but by then the bookmark is gone: GitHub deletes a merged branch, and `jj git fetch` abandons the changes only it held (seen on #229's merge). What the step needs is the order: forget the PR's workspace first, since a change still checked out somewhere is kept, then fetch. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fce18490d..9e2ca23e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,11 +61,11 @@ The open PR list is the board of who is working on what. `jj git push --bookmark /`. Pushing `main` publishes (the web app at fizzyed.it/app, and an SDK release when the version moved), so `main` moves only by a merged PR. -- **After the merge, drop your stack.** A squash merge leaves the branch's changes behind - locally, and the next agent's `jj log` can't tell landed work from live work. After - `jj git fetch`, check that `jj log -r '::/ ~ ::main@origin'` lists only your PR's - changes, then `jj abandon` that revset. Never abandon a change that is some workspace's - working copy (`jj workspace list`). +- **When your PR has merged, forget its workspace, then fetch.** `jj workspace forget `, + then `jj git fetch`. GitHub deletes a merged branch, and the fetch abandons the changes only + that branch held, so landed work doesn't linger in the next agent's `jj log` looking live. + Forget the workspace first: a change still checked out somewhere is kept. Never abandon a + change that is some workspace's working copy (`jj workspace list`). ## Verify, and say how