From 9d08f3428917d30b5c1de3a32b00aef876b5b05d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 16 Jul 2026 17:47:04 +0200 Subject: [PATCH 1/4] docs(agents): add development principles, slim release flow, correct branch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributor feedback on AGENTS.md, plus doc corrections the release-pipeline fixes made necessary. AGENTS.md: - Add a "Development principles" section: simplest readable solution, no speculative abstraction, and an explicit "no mandated app-stack choice yet" rule so agents stop imposing one state/data library across the codebase. Deliberately avoids "prefer one-liners" phrasing, which pushes models toward dense code that hides control flow. - Relabel "README tone" -> "Product constraints". The note bundles copy rules with a hard product rule (no paywalls/premium gating); "tone" read as if the model were being asked to reflect on the business model rather than follow a constraint. - Slim "Release flow" from 305 to 186 words, deferring the branch contract, cherry-pick rules and manual fallback to .harness/docs/git-workflow.md, which already documents them in full. The section was duplicated context loaded into every agent run. - Make the npm pin rationale explicit: it exists because the native helpers are rebuilt against Electron's ABI via package-lock.json, not as a style choice. .harness/docs/git-workflow.md — corrected to match the shipped pipeline: - Release branch is release/vX.Y.Z (one per stable version, created at rc.1 and reused), not release/vX.Y.Z-rc.N. The old naming is what broke promote, which resolves release/v${STABLE_VERSION}. - The RC tag push does NOT trigger build.yml; prerelease.yml dispatches it explicitly with --ref pinned to the tag. Documented why. - Manual fallback: fix the branch naming and add the missing `git tag` commands (it pushed tags it never created). Co-Authored-By: Claude Opus 4.8 --- .harness/docs/git-workflow.md | 35 ++++++++++++++++++----------------- AGENTS.md | 23 +++++++++++++++-------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index 99fc1ff0..fac3f649 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -57,8 +57,8 @@ The workflow: 1. Computes the next SemVer from `package.json` + `bump`, builds `vX.Y.Z-rc.N`. 2. Migrates every issue/PR in the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone. Each migrated item gets a hidden marker comment so re-running is idempotent. -3. Commits `package.json` → `X.Y.Z-rc.N` on a fresh branch `release/vX.Y.Z-rc.N`. **The branch is NOT merged into `main`** — it stays frozen so the RC build only contains what was on `main` at the moment of cut. -4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip. This triggers `build.yml`, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). RC tags are signed and notarized like stable ones, so testers do not have to clear the quarantine attribute by hand. +3. Creates `release/vX.Y.Z` (at rc.1) or reuses it (rc.2+), and commits `package.json` → `X.Y.Z-rc.N` there. **The branch is NOT merged into `main`** — it stays frozen, so the RC contains the cut snapshot plus cherry-picks only. The branch is named for the **stable** version, with no `-rc.N` suffix, because `promote.yml` has to resolve the same ref. +4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip, then **explicitly dispatches `build.yml` with `--ref` pinned to that tag**, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). Two reasons the dispatch is explicit and pinned: a `GITHUB_TOKEN` tag push does not fire `build.yml`'s `push:` trigger, and the build must check out the *tag* — `main` still carries the previous stable version and would fail the publish step's version guard. RC tags are signed and notarized like stable ones, so testers do not have to clear the quarantine attribute by hand. 5. Posts in `#rc-testing` on Discord with the download link. Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're already gated on `!prerelease`. @@ -67,9 +67,9 @@ Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're al Pin the pre-release link in `#rc-testing`. Get the maintainer team + a few early adopters to install and smoke-test. -**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z-rc.N` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. +**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. -If the RC has a regression, fix forward on `main`, then **cherry-pick the fix commit onto the release branch** with `git cherry-pick `, then re-cut as `vX.Y.Z-rc.(N+1)` (the rerun of `prerelease.yml` re-tags the release branch tip; no rebase required because the branch is frozen). The previous RC is auto-superseded by GitHub. +If the RC has a regression, fix forward on `main`, then **cherry-pick the fix commit onto the release branch** with `git cherry-pick `, then re-cut as `vX.Y.Z-rc.(N+1)` (the rerun of `prerelease.yml` reuses the frozen branch and re-tags its tip; no rebase required). The previous RC is auto-superseded by GitHub. ### Step 3: promote to stable @@ -82,8 +82,8 @@ The workflow: 1. Validates the tag matches `^vX.Y.Z-(rc|beta|alpha)\.N$`. 2. Closes the `vX.Y.Z` milestone (snapshotting it for the release notes). -3. Checks out `release/vX.Y.Z-rc.N` (the frozen branch), strips `-rc.N` from `package.json`, and commits the bump there. The stable tag points at this tip — the released code is the exact RC + cherry-picks. -4. Pushes the tag `vX.Y.Z` and triggers `build.yml` (full notarization). The `release: published` event fires Tier 3 (homebrew/winget/nix/aur) thanks to `OPENSCREEN_RELEASE_TOKEN`. +3. Checks out `release/vX.Y.Z` (the frozen branch), strips `-rc.N` from `package.json`, and commits the bump there. The stable tag points at this tip — the released code is the exact RC + cherry-picks. +4. Pushes the tag `vX.Y.Z` and dispatches `build.yml` pinned to that tag (full notarization). The `release: published` event fires Tier 3 (homebrew/winget/nix/aur) thanks to `OPENSCREEN_RELEASE_TOKEN`. 5. Opens a **release-sync PR** (e.g. `release/v1.6.0-sync → main`) that brings `main` into line with the released snapshot. Rebase-merged via PAT (EtienneLescot is a ruleset bypass actor). 6. Posts in `#announcements` on Discord with the release notes + a "Closed issues in this release" list pulled from the milestone. @@ -91,20 +91,21 @@ The release branch itself **stays around** indefinitely — it is the frozen his ### Release branches (the contract) -Every released version has a corresponding **frozen branch**: +Every released version has **exactly one frozen branch**, named for the stable version, living from the first RC cut onward: ``` -release/vX.Y.Z-rc.N exists from RC cut until promote finishes +release/vX.Y.Z created at rc.1, frozen through promote, kept for backports release/vX.Y.Z-sync ephemeral, created by promote to merge into main -release/vX.Y.Z stable snapshot post-promote (kept for backports) ``` +The name carries **no `-rc.N` suffix**. `prerelease.yml` and `promote.yml` must resolve the same ref, and every RC of a version re-cuts from this one branch. + Key rules: -1. **`prerelease.yml` creates the branch.** Nothing else pushes to it except the cherry-pick workflow during the RC window. +1. **`prerelease.yml` creates the branch at rc.1 and reuses it for later RCs.** It must never delete or recreate it: that would drop the cherry-picks and silently re-cut from `main`, which defeats the freeze this contract exists to guarantee. 2. **`promote.yml` is the only writer** that turns `-rc.N` into the stable version on the branch. 3. **`main` is never frozen.** Develop as usual. The release branch is the freeze. -4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z-rc.N && git cherry-pick `), or rerun `prerelease.yml` to re-tag the branch tip with the same RC version (then bump rc_number). +4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z && git cherry-pick `), then rerun `prerelease.yml` with the next `rc_number` to re-tag the branch tip. This exists because of the v1.6.0 incident (2026-07-05): the original `promote.yml` checked out `main`, so the stable tag captured the post-RC tip of `main` rather than the RC snapshot. Twenty-three commits (Tiptap, NotesWindow, an in-recorder lint button, AI handoff) ended up in v1.6.0 without ever being in v1.6.0-rc.1. The re-release of v1.6.0 on 2026-07-05 used `release/v1.6.0` and cherry-picked only the truly safe commits. @@ -114,21 +115,21 @@ If the dispatch UI is unavailable, the workflow still works from a shell: ```bash # Cut RC (skips milestone migration and Discord announce) -git checkout -b release/v1.5.0-rc.1 main +git checkout -b release/v1.5.0 main # rc.2+: git checkout release/v1.5.0 instead sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0-rc.1\2|' package.json git add package.json && git commit -m "chore(release): bump to 1.5.0-rc.1 [skip ci]" -git push origin release/v1.5.0-rc.1 -git push origin v1.5.0-rc.1 +git push origin release/v1.5.0 +git tag v1.5.0-rc.1 && git push origin v1.5.0-rc.1 # Promote (skips milestone close and Discord announce) -git checkout release/v1.5.0-rc.1 +git checkout release/v1.5.0 sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0\2|' package.json git commit -am "chore(release): bump to 1.5.0 [skip ci]" git push origin release/v1.5.0 -git push origin v1.5.0 +git tag v1.5.0 && git push origin v1.5.0 ``` -The pipeline can't tell the difference between a manually-pushed tag and a workflow-pushed one — same `build.yml` runs either way. +A tag pushed manually with your own credentials **does** fire `build.yml`'s `push:` trigger, so the release publishes on its own. (The workflows push tags with `GITHUB_TOKEN`, which does *not* fire it — that's why they dispatch `build.yml` explicitly.) Either way the same `build.yml` builds and publishes. ### Backports / patch on a previous line diff --git a/AGENTS.md b/AGENTS.md index 2d8e2d04..b14a4090 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,15 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - Format: `npm run format` (Biome, tabs, double quotes, 100-col) - i18n check: `npm run i18n:check` (validates the 13 locale files) +**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference: the native Swift (macOS) and C++ (Windows) capture helpers are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild` resolving `package-lock.json`, and other package managers break that path. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. + +## Development principles + +- Prefer the simplest solution that stays readable. No abstraction for hypothetical needs (YAGNI). +- **No mandated app-stack choice yet.** Contributors pick their own state/data library. Don't impose one across the codebase and don't refactor existing code onto a different one — keep each addition self-contained and consistent within its own module. A single choice may be enforced later. +- Don't optimize for line count. A dense one-liner that hides control flow is worse than the explicit version. +- Match the surrounding code's idiom rather than introducing a new pattern next to it. + ## Project layout - `src/` — React app: UI, editor components, timeline, i18n, captioning/cursor/exporter libs @@ -118,15 +127,13 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi ## Release flow -Two `workflow_dispatch` workflows cut a release with a pre-release candidate (RC) first, then promote to stable. Trunk-based, no extra branch. Full operational guide in `.harness/docs/git-workflow.md` § Release flow. - -- **Cut RC**: Actions → "Cut a release candidate" → Run workflow. Inputs: `bump` (patch|minor|major), `rc_number` (default 1), optional `target_version` override. Snaps issues out of the rolling `Next Release` milestone into a versioned `vX.Y.Z` milestone, bumps `package.json`, pushes the `vX.Y.Z-rc.N` tag, which triggers the existing `build.yml` to publish a GitHub pre-release. RCs are notarized like stable releases, which also rehearses the credentials before the promotion build depends on them. Notifies `#rc-testing` on Discord. -- **Promote RC**: Actions → "Promote RC to stable release" → Run workflow. Input: `rc_tag` (e.g. `v1.5.0-rc.2`), optional `release_notes_extra`. Closes the `vX.Y.Z` milestone, strips `-rc.N` from `package.json`, pushes `vX.Y.Z` tag, which triggers `build.yml` to publish a stable release (full notarization, Tier 3 homebrew/winget/nix/aur fires). Notifies `#announcements` on Discord. -- **Manual fallback**: `git tag vX.Y.Z-rc.N && git push origin vX.Y.Z-rc.N` does the same as Cut RC (minus the milestone migration and Discord announce) — useful for emergency cuts. +Two `workflow_dispatch` workflows: cut an RC, then promote it to stable. **Full operational guide, branch contract, cherry-pick rules, and manual fallback: `.harness/docs/git-workflow.md` § Release flow.** Read it before touching a release. -Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT with `contents: write` + `issues: write`). This is the standard fix for `release: published` not triggering downstream workflows when the release is created by `GITHUB_TOKEN`. See `technical-documentation/engineering/release-and-secrets.md`. +- **Cut RC**: Actions → "Cut a release candidate". Inputs: `bump` (patch|minor|major), `rc_number`, optional `target_version`. Migrates the rolling `Next Release` milestone into a versioned one, creates or reuses `release/vX.Y.Z`, bumps `package.json`, pushes `vX.Y.Z-rc.N`, then dispatches `build.yml` **pinned to that tag** to publish a GitHub pre-release. RCs are notarized like stable releases, which rehearses the credentials before the promotion build depends on them. Announces in `#rc-testing`. +- **Promote**: Actions → "Promote RC to stable release". Input: `rc_tag`. Closes the milestone, strips `-rc.N` on the same branch, tags `vX.Y.Z`, publishes a stable release (notarized; Tier 3 homebrew/winget/nix/aur fires), and opens a `release/vX.Y.Z-sync → main` PR. Announces in `#announcements`. +- **One release branch per stable version** (`release/vX.Y.Z`), created at rc.1 and **frozen** until promote: only cherry-picked bugfixes land on it, so anything merged to `main` after the cut ships in the next cycle. Later RCs re-cut from that same branch. -**Release branches freeze the build between cut and promote.** Every RC cut creates `release/vX.Y.Z-rc.N`. The branch is *not* merged into `main` until the stable tag is published; only cherry-picks of bugfixes land on the release branch during the RC window. The stable tag points at the branch tip (RC + cherry-picks), then `promote.yml` opens a `release/vX.Y.Z-sync → main` PR to bring main into line. This contract exists because of the v1.6.0 incident (2026-07-05) where the original promote workflow tagged `main` instead of the RC snapshot, causing 23 unreleased commits to ship in `v1.6.0`. Full rules in `.harness/docs/git-workflow.md` § Release branches. +Both workflows need the `OPENSCREEN_RELEASE_TOKEN` secret (see `technical-documentation/engineering/release-and-secrets.md`); `GITHUB_TOKEN`-created releases don't fire the downstream `release: published` workflows. ## Security @@ -140,4 +147,4 @@ Both workflows require the `OPENSCREEN_RELEASE_TOKEN` secret (a fine-grained PAT - **Pixi.js v8** is the rendering engine. Filters come from `pixi-filters` and `@pixi/filter-drop-shadow`. GSAP + `motion` for animation. - **i18n**: 13 locales in `src/i18n/locales//` (e.g. `src/i18n/locales/en/settings.json`). The `i18n:check` script validates them — run it after touching translation files. - **Build pipeline**: `npm run build` is full electron-builder. For iterating on renderer only, use `npm run build-vite` (Vite + tsc, no packaging). -- **README tone**: the project is explicitly "not production-grade" and free forever — don't add paywalls, premium tiers, or upsell language to UI/copy. +- **Product constraints**: the project is free forever and explicitly "not production-grade". Don't add paywalls, premium tiers, or feature-gating logic, and don't add upsell language to the README or UI copy. This is a hard constraint, not a judgement call. From 8da0c3cfef6c09722e4039ea94b2868b9a0b6680 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 11 Aug 2026 22:48:39 +0200 Subject: [PATCH 2/4] docs(agents): address review feedback on the release-flow docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual fallback was the only real defect. It bumped `package.json` with a `sed` while `set-release-version.mjs` — which prerelease.yml and promote.yml both call — also writes `package-lock.json`. A fallback release cut by hand therefore shipped a lockfile disagreeing with the package it locks, the exact drift that script was written to stop. It now calls the script, and hoists the RC version into `$RC` so rc.2+ does not need three edits kept in sync. Also: "promote.yml is the only writer" now says *automated* writer, so it stops contradicting the manual fallback three sections below; the cherry-pick rule points at `git log release/vX.Y.Z..main` for seeing what is not in the RC; the product constraint distinguishes gating on readiness from gating on the user, so an unfinished capture backend can still hide behind a flag; and `.harness/memory/MEMORY.md` no longer describes the pre-#90 `release/vX.Y.Z-rc.N` naming. --- .harness/docs/git-workflow.md | 16 ++++++++++------ .harness/memory/MEMORY.md | 2 +- AGENTS.md | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index fac3f649..bbdcc4bf 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -67,7 +67,7 @@ Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're al Pin the pre-release link in `#rc-testing`. Get the maintainer team + a few early adopters to install and smoke-test. -**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. +**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. `git log release/vX.Y.Z..main --oneline` lists exactly what is *not* in the RC. If the RC has a regression, fix forward on `main`, then **cherry-pick the fix commit onto the release branch** with `git cherry-pick `, then re-cut as `vX.Y.Z-rc.(N+1)` (the rerun of `prerelease.yml` reuses the frozen branch and re-tags its tip; no rebase required). The previous RC is auto-superseded by GitHub. @@ -103,7 +103,7 @@ The name carries **no `-rc.N` suffix**. `prerelease.yml` and `promote.yml` must Key rules: 1. **`prerelease.yml` creates the branch at rc.1 and reuses it for later RCs.** It must never delete or recreate it: that would drop the cherry-picks and silently re-cut from `main`, which defeats the freeze this contract exists to guarantee. -2. **`promote.yml` is the only writer** that turns `-rc.N` into the stable version on the branch. +2. **`promote.yml` is the only automated writer** that turns `-rc.N` into the stable version on the branch. A maintainer doing that by hand means the dispatch failed — see § Manual fallback. 3. **`main` is never frozen.** Develop as usual. The release branch is the freeze. 4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z && git cherry-pick `), then rerun `prerelease.yml` with the next `rc_number` to re-tag the branch tip. @@ -113,17 +113,21 @@ This exists because of the v1.6.0 incident (2026-07-05): the original `promote.y If the dispatch UI is unavailable, the workflow still works from a shell: +Use `set-release-version.mjs`, not a hand-rolled `sed`: it writes the version to **`package-lock.json` too**, and a release commit that bumps only `package.json` ships a lockfile disagreeing with the package it locks (`npm ci` never catches it). + ```bash +RC=1.5.0-rc.1 # bump the rc.N for every later candidate + # Cut RC (skips milestone migration and Discord announce) git checkout -b release/v1.5.0 main # rc.2+: git checkout release/v1.5.0 instead -sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0-rc.1\2|' package.json -git add package.json && git commit -m "chore(release): bump to 1.5.0-rc.1 [skip ci]" +node .github/scripts/set-release-version.mjs "$RC" +git commit -am "chore(release): bump to $RC [skip ci]" git push origin release/v1.5.0 -git tag v1.5.0-rc.1 && git push origin v1.5.0-rc.1 +git tag "v$RC" && git push origin "v$RC" # Promote (skips milestone close and Discord announce) git checkout release/v1.5.0 -sed -i -E 's|("version"[[:space:]]*:[[:space:]]*")[^"]*(")|\11.5.0\2|' package.json +node .github/scripts/set-release-version.mjs 1.5.0 git commit -am "chore(release): bump to 1.5.0 [skip ci]" git push origin release/v1.5.0 git tag v1.5.0 && git push origin v1.5.0 diff --git a/.harness/memory/MEMORY.md b/.harness/memory/MEMORY.md index 0f5205bc..97367de4 100644 --- a/.harness/memory/MEMORY.md +++ b/.harness/memory/MEMORY.md @@ -23,4 +23,4 @@ There's no Prettier/ESLint — Biome 2.4 does both. Config in `biome.json`: tabs `npm run build` runs tsc + vite build + electron-builder packaging. For renderer-only iteration use `npm run build-vite` (tsc + vite only, no packaging). Only run the full `build` when verifying a release artifact. ## Release tag must point at the release branch, not main (2026-07-05) -On 2026-07-05 the original `promote.yml` did `git checkout main && git tag vX.Y.Z`, which captured the post-RC tip of `main` (23 commits after the RC cut) as the "stable" v1.6.0. The fix landed the same day: both `prerelease.yml` and `promote.yml` now use a frozen `release/vX.Y.Z-rc.N` branch and tag its tip — see `.github/workflows/prerelease.yml` § Push RC tag and `.github/workflows/promote.yml` § Push stable tag. When reviewing release-related changes, **always verify the tag is being applied to the release branch tip, not to main.** The build.yml `release_tag` input is the SHA, not a branch name; if you set it to a tag the GitHub Release check will look for the source ref — pass the release branch name when smoke-testing without a tag. +On 2026-07-05 the original `promote.yml` did `git checkout main && git tag vX.Y.Z`, which captured the post-RC tip of `main` (23 commits after the RC cut) as the "stable" v1.6.0. The fix landed the same day: both `prerelease.yml` and `promote.yml` now use a frozen `release/vX.Y.Z` branch — one per stable version, named without the `-rc.N` suffix so both workflows resolve the same ref — and tag its tip, then dispatch `build.yml` pinned to that tag. See `.github/workflows/prerelease.yml` § Push RC tag and `.github/workflows/promote.yml` § Push stable tag. When reviewing release-related changes, **always verify the tag is being applied to the release branch tip, not to main.** The build.yml `release_tag` input is the SHA, not a branch name; if you set it to a tag the GitHub Release check will look for the source ref — pass the release branch name when smoke-testing without a tag. diff --git a/AGENTS.md b/AGENTS.md index b14a4090..c90184f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,11 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R - Format: `npm run format` (Biome, tabs, double quotes, 100-col) - i18n check: `npm run i18n:check` (validates the 13 locale files) -**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference: the native Swift (macOS) and C++ (Windows) capture helpers are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild` resolving `package-lock.json`, and other package managers break that path. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. +**Use npm, not bun/pnpm/yarn/Deno.** Not a style preference. The native Swift (macOS) and C++ (Windows) capture helpers are rebuilt against Electron's ABI by electron-builder + `@electron/rebuild`, which resolve the tree through `package-lock.json`. Another package manager writes a different lockfile, so that rebuild breaks. `packageManager` + `engines` in `package.json` pin the versions; CI installs with `npm ci`. ## Development principles -- Prefer the simplest solution that stays readable. No abstraction for hypothetical needs (YAGNI). +- Prefer the simplest solution that stays readable — no abstraction for hypothetical needs (YAGNI). - **No mandated app-stack choice yet.** Contributors pick their own state/data library. Don't impose one across the codebase and don't refactor existing code onto a different one — keep each addition self-contained and consistent within its own module. A single choice may be enforced later. - Don't optimize for line count. A dense one-liner that hides control flow is worse than the explicit version. - Match the surrounding code's idiom rather than introducing a new pattern next to it. @@ -147,4 +147,4 @@ Both workflows need the `OPENSCREEN_RELEASE_TOKEN` secret (see `technical-docume - **Pixi.js v8** is the rendering engine. Filters come from `pixi-filters` and `@pixi/filter-drop-shadow`. GSAP + `motion` for animation. - **i18n**: 13 locales in `src/i18n/locales//` (e.g. `src/i18n/locales/en/settings.json`). The `i18n:check` script validates them — run it after touching translation files. - **Build pipeline**: `npm run build` is full electron-builder. For iterating on renderer only, use `npm run build-vite` (Vite + tsc, no packaging). -- **Product constraints**: the project is free forever and explicitly "not production-grade". Don't add paywalls, premium tiers, or feature-gating logic, and don't add upsell language to the README or UI copy. This is a hard constraint, not a judgement call. +- **Product constraints**: the project is free forever and explicitly "not production-grade". Don't add paywalls, premium tiers, or logic that gates a feature on who the user is, and don't add upsell language to the README or UI copy. This is a hard constraint, not a judgement call. (A flag that hides an unfinished capture backend is fine — it gates on readiness, not on the user.) From 98e38c9381064340657996974051f10beef83451 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 11 Aug 2026 23:24:19 +0200 Subject: [PATCH 3/4] docs(release): make release-and-secrets.md the only release reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.harness/docs/git-workflow.md` and `technical-documentation/engineering/release-and-secrets.md` both documented the release flow, and only one of them was ever checked. `check-docs.mjs` walks `technical-documentation/` alone, so when #90 changed the release-branch naming the docs tree was corrected and the harness copy was not — it kept sending readers to `release/vX.Y.Z-rc.N`, a branch the workflows never create, for a month. `.harness/memory/MEMORY.md` repeated it. Both files already declared non-overlapping scopes: git-workflow.md is "conventions for the Mavis reins", release-and-secrets.md is "the operational reference for cutting releases", and the latter already delegated branching and PR procedure back. The release flow sitting in git-workflow.md violated that split. So: move, then collapse. Moved into release-and-secrets.md, none of it duplicated there before — why the build dispatch is explicit and pinned to the tag, the numbered branch contract including "never delete or recreate", the v1.6.0 postmortem with what actually shipped, the runnable manual fallback, backports, and milestone handling. git-workflow.md §Release flow is now four lines and a pointer: 154 lines to 48, in a file loaded into every agent run. The dispatch rationale is documented per workflow rather than in general, because the two do not push their tags the same way: promote.yml uses GITHUB_TOKEN, whose tag push does not fire build.yml's `push:` trigger, while prerelease.yml uses the PAT because a GITHUB_TOKEN tag push is answered with a 500 from a tag ruleset — the failure that took down the v1.8.0-rc.1 cut. Only the `--ref` pinning is required in both cases, for the publish version guard. The fallback there also still said to prepare "the correct `package.json` commit" — the same lockfile trap the harness copy had, in the file operators actually read. It now points at set-release-version.mjs and says why. Root cause last: `check-docs.mjs` walks `.harness/` too, and the retired branch naming is a LEGACY identifier, so CI fails on any doc reintroducing it. `.harness/` passes all three existing passes today, so this adds a gate without a migration. The new test asserts the script *rejects* a planted violation, because a checker that silently stops checking still prints OK — which is how this rotted unnoticed in the first place. --- .harness/docs/git-workflow.md | 110 +----------------- AGENTS.md | 8 +- scripts/check-docs.mjs | 40 +++++-- scripts/check-docs.test.mjs | 56 +++++++++ .../engineering/release-and-secrets.md | 80 +++++++++++-- 5 files changed, 156 insertions(+), 138 deletions(-) create mode 100644 scripts/check-docs.test.mjs diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index bbdcc4bf..1e67d642 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -43,112 +43,6 @@ All must be green before merge. Native helper code is NOT covered by CI — manu ## Release flow -Two `workflow_dispatch` workflows cut a release. Trunk-based on `main`, but **release branches freeze the RC codebase between cut and promote** (see § Release branches below). Both require the `OPENSCREEN_RELEASE_TOKEN` secret — see `technical-documentation/engineering/release-and-secrets.md`. +Releases are cut by two `workflow_dispatch` workflows (`prerelease.yml`, then `promote.yml`). Trunk-based on `main`, but **a release branch freezes the RC codebase between cut and promote**. -### Step 1: cut a release candidate - -`Actions` → `Cut a release candidate` → `Run workflow`. - -- `bump`: `patch | minor | major` (default `minor`) -- `rc_number`: integer, default `1` (use `.2`, `.3`, … for subsequent RCs) -- `target_version` (optional): override the auto-computed next version (e.g. `2.0.0` when bumping straight to a major) - -The workflow: - -1. Computes the next SemVer from `package.json` + `bump`, builds `vX.Y.Z-rc.N`. -2. Migrates every issue/PR in the rolling `Next Release` milestone into a fresh `vX.Y.Z` milestone. Each migrated item gets a hidden marker comment so re-running is idempotent. -3. Creates `release/vX.Y.Z` (at rc.1) or reuses it (rc.2+), and commits `package.json` → `X.Y.Z-rc.N` there. **The branch is NOT merged into `main`** — it stays frozen, so the RC contains the cut snapshot plus cherry-picks only. The branch is named for the **stable** version, with no `-rc.N` suffix, because `promote.yml` has to resolve the same ref. -4. Pushes the tag `vX.Y.Z-rc.N` at the release branch tip, then **explicitly dispatches `build.yml` with `--ref` pinned to that tag**, which publishes a **GitHub pre-release** (badged as such, does not become "Latest"). Two reasons the dispatch is explicit and pinned: a `GITHUB_TOKEN` tag push does not fire `build.yml`'s `push:` trigger, and the build must check out the *tag* — `main` still carries the previous stable version and would fail the publish step's version guard. RC tags are signed and notarized like stable ones, so testers do not have to clear the quarantine attribute by hand. -5. Posts in `#rc-testing` on Discord with the download link. - -Tier 3 (homebrew/winget/nix/aur) does **not** run on pre-releases — they're already gated on `!prerelease`. - -### Step 2: announce and QA - -Pin the pre-release link in `#rc-testing`. Get the maintainer team + a few early adopters to install and smoke-test. - -**Between RC cut and promote**, the only thing that may happen on `release/vX.Y.Z` is **cherry-picks of bugfixes** that address problems discovered in the RC. Features, refactors, and CI/docs changes are **not** applied to the release branch — they live on `main` and ship in the next release cycle. `git log release/vX.Y.Z..main --oneline` lists exactly what is *not* in the RC. - -If the RC has a regression, fix forward on `main`, then **cherry-pick the fix commit onto the release branch** with `git cherry-pick `, then re-cut as `vX.Y.Z-rc.(N+1)` (the rerun of `prerelease.yml` reuses the frozen branch and re-tags its tip; no rebase required). The previous RC is auto-superseded by GitHub. - -### Step 3: promote to stable - -`Actions` → `Promote RC to stable release` → `Run workflow`. - -- `rc_tag`: e.g. `v1.5.0-rc.2` -- `release_notes_extra` (optional): a one-paragraph note that gets prepended to the auto-generated release notes - -The workflow: - -1. Validates the tag matches `^vX.Y.Z-(rc|beta|alpha)\.N$`. -2. Closes the `vX.Y.Z` milestone (snapshotting it for the release notes). -3. Checks out `release/vX.Y.Z` (the frozen branch), strips `-rc.N` from `package.json`, and commits the bump there. The stable tag points at this tip — the released code is the exact RC + cherry-picks. -4. Pushes the tag `vX.Y.Z` and dispatches `build.yml` pinned to that tag (full notarization). The `release: published` event fires Tier 3 (homebrew/winget/nix/aur) thanks to `OPENSCREEN_RELEASE_TOKEN`. -5. Opens a **release-sync PR** (e.g. `release/v1.6.0-sync → main`) that brings `main` into line with the released snapshot. Rebase-merged via PAT (EtienneLescot is a ruleset bypass actor). -6. Posts in `#announcements` on Discord with the release notes + a "Closed issues in this release" list pulled from the milestone. - -The release branch itself **stays around** indefinitely — it is the frozen history of the release, useful for backports and forensics. Deletion happens only when a future major cuts over and supersedes it. - -### Release branches (the contract) - -Every released version has **exactly one frozen branch**, named for the stable version, living from the first RC cut onward: - -``` -release/vX.Y.Z created at rc.1, frozen through promote, kept for backports -release/vX.Y.Z-sync ephemeral, created by promote to merge into main -``` - -The name carries **no `-rc.N` suffix**. `prerelease.yml` and `promote.yml` must resolve the same ref, and every RC of a version re-cuts from this one branch. - -Key rules: - -1. **`prerelease.yml` creates the branch at rc.1 and reuses it for later RCs.** It must never delete or recreate it: that would drop the cherry-picks and silently re-cut from `main`, which defeats the freeze this contract exists to guarantee. -2. **`promote.yml` is the only automated writer** that turns `-rc.N` into the stable version on the branch. A maintainer doing that by hand means the dispatch failed — see § Manual fallback. -3. **`main` is never frozen.** Develop as usual. The release branch is the freeze. -4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z && git cherry-pick `), then rerun `prerelease.yml` with the next `rc_number` to re-tag the branch tip. - -This exists because of the v1.6.0 incident (2026-07-05): the original `promote.yml` checked out `main`, so the stable tag captured the post-RC tip of `main` rather than the RC snapshot. Twenty-three commits (Tiptap, NotesWindow, an in-recorder lint button, AI handoff) ended up in v1.6.0 without ever being in v1.6.0-rc.1. The re-release of v1.6.0 on 2026-07-05 used `release/v1.6.0` and cherry-picked only the truly safe commits. - -### Manual fallback (emergency) - -If the dispatch UI is unavailable, the workflow still works from a shell: - -Use `set-release-version.mjs`, not a hand-rolled `sed`: it writes the version to **`package-lock.json` too**, and a release commit that bumps only `package.json` ships a lockfile disagreeing with the package it locks (`npm ci` never catches it). - -```bash -RC=1.5.0-rc.1 # bump the rc.N for every later candidate - -# Cut RC (skips milestone migration and Discord announce) -git checkout -b release/v1.5.0 main # rc.2+: git checkout release/v1.5.0 instead -node .github/scripts/set-release-version.mjs "$RC" -git commit -am "chore(release): bump to $RC [skip ci]" -git push origin release/v1.5.0 -git tag "v$RC" && git push origin "v$RC" - -# Promote (skips milestone close and Discord announce) -git checkout release/v1.5.0 -node .github/scripts/set-release-version.mjs 1.5.0 -git commit -am "chore(release): bump to 1.5.0 [skip ci]" -git push origin release/v1.5.0 -git tag v1.5.0 && git push origin v1.5.0 -``` - -A tag pushed manually with your own credentials **does** fire `build.yml`'s `push:` trigger, so the release publishes on its own. (The workflows push tags with `GITHUB_TOKEN`, which does *not* fire it — that's why they dispatch `build.yml` explicitly.) Either way the same `build.yml` builds and publishes. - -### Backports / patch on a previous line - -For a `v1.4.2` while `v1.5.0` is in flight: - -1. Branch `release/1.4.x` from the `v1.4.0` (or `v1.4.1`) tag. -2. Cherry-pick the fix commits. -3. Push the branch, then `git tag v1.4.2-rc.1` on the branch tip. -4. `git push origin release/1.4.x v1.4.2-rc.1` — `build.yml` works from any branch. - -No new workflow code is needed; the tag-pushed trigger is branch-agnostic. - -### Issue tracking during a release cycle - -- **Daily state**: issues/PRs accumulate in the rolling `Next Release` milestone. `merged-pr-bookkeeping.yml` adds them automatically on PR merge; maintainers can also drag issues in by hand. -- **At RC cut**: `prerelease.yml` snapshots `Next Release` into a versioned `vX.Y.Z` milestone. The rolling milestone is left open and empty for new work. -- **Between RC cut and promote**: any PR that merges during the RC window lands back in the empty `Next Release`. It is **not** retroactively added to `vX.Y.Z`. If a critical fix lands, cut `vX.Y.Z-rc.(N+1)` instead of promoting. -- **At promote**: `promote.yml` closes the `vX.Y.Z` milestone and uses its closed issues to populate the Discord release announcement. +The operational reference — workflow inputs, the release-branch contract, cherry-pick rules, the manual fallback, backports, milestone handling and the credentials — is `technical-documentation/engineering/release-and-secrets.md`. Read it before touching a release; do not restate it here, because the copy that lives outside the docs checker is the one that goes stale. diff --git a/AGENTS.md b/AGENTS.md index c90184f1..d7b2ec8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,13 +127,9 @@ Unit/browser tests can't exercise real capture (native screen recording, a physi ## Release flow -Two `workflow_dispatch` workflows: cut an RC, then promote it to stable. **Full operational guide, branch contract, cherry-pick rules, and manual fallback: `.harness/docs/git-workflow.md` § Release flow.** Read it before touching a release. +Two `workflow_dispatch` workflows: cut an RC, then promote it to stable. **Full operational guide, branch contract, cherry-pick rules, and manual fallback: `technical-documentation/engineering/release-and-secrets.md`.** Read it before touching a release. -- **Cut RC**: Actions → "Cut a release candidate". Inputs: `bump` (patch|minor|major), `rc_number`, optional `target_version`. Migrates the rolling `Next Release` milestone into a versioned one, creates or reuses `release/vX.Y.Z`, bumps `package.json`, pushes `vX.Y.Z-rc.N`, then dispatches `build.yml` **pinned to that tag** to publish a GitHub pre-release. RCs are notarized like stable releases, which rehearses the credentials before the promotion build depends on them. Announces in `#rc-testing`. -- **Promote**: Actions → "Promote RC to stable release". Input: `rc_tag`. Closes the milestone, strips `-rc.N` on the same branch, tags `vX.Y.Z`, publishes a stable release (notarized; Tier 3 homebrew/winget/nix/aur fires), and opens a `release/vX.Y.Z-sync → main` PR. Announces in `#announcements`. -- **One release branch per stable version** (`release/vX.Y.Z`), created at rc.1 and **frozen** until promote: only cherry-picked bugfixes land on it, so anything merged to `main` after the cut ships in the next cycle. Later RCs re-cut from that same branch. - -Both workflows need the `OPENSCREEN_RELEASE_TOKEN` secret (see `technical-documentation/engineering/release-and-secrets.md`); `GITHUB_TOKEN`-created releases don't fire the downstream `release: published` workflows. +The one rule to know before you merge anything: **there is one release branch per stable version** (`release/vX.Y.Z`), created at rc.1 and **frozen** until promote. Only cherry-picked bugfixes land on it, so anything merged to `main` after the cut ships in the *next* cycle, not the one in flight. ## Security diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index c15497c1..2fb5bb93 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -1,15 +1,25 @@ #!/usr/bin/env node -// Docs lint for technical-documentation/: relative links resolve, no legacy -// identifiers are presented as current, and every expected file is real. -// ponytail: three regex passes over ~30 files, no deps. Run: node scripts/check-docs.mjs +// Docs lint: relative links resolve, no legacy identifiers are presented as +// current, and every expected file is real. +// ponytail: three regex passes over ~40 files, no deps. Run: node scripts/check-docs.mjs import { readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; const ROOT = resolve(import.meta.dirname, ".."); const DOCS = join(ROOT, "technical-documentation"); -// Names of components / docs that no longer exist on this branch. A doc may only -// mention them inside a "removed / superseded" note, which lives in decisions.md. +// `.harness/` is prose too, and it rots the same way — it just had no checker. +// `.harness/docs/git-workflow.md` described the release-branch naming that #90 +// had already replaced, and `.harness/memory/MEMORY.md` repeated it, for a +// month, while the same fact stayed correct under technical-documentation/ +// because this script was watching that tree and not this one. Every file here +// is loaded into agent runs, so a stale one is worse than a stale page nobody +// opens. Only REQUIRED (the "expected file is real" pass) stays docs-only. +const TREES = [DOCS, join(ROOT, ".harness")]; + +// Names of components, docs, or conventions that no longer exist on this branch. +// A doc may only mention them inside a "removed / superseded" note, which lives +// in decisions.md. const LEGACY = [ "TimelinePane", "RightPanelStack", @@ -31,8 +41,13 @@ const LEGACY = [ "provider-parity-plan", "github-actions-workflows", "ux-ui-spec", + // Pre-#90 release-branch naming. One branch per *stable* version now + // (`release/vX.Y.Z`), created at rc.1 and reused, because prerelease.yml and + // promote.yml have to resolve the same ref. Docs that reintroduce the + // suffixed name send a maintainer to a branch that was never created. + "release/vX.Y.Z-rc.N", ]; -const LEGACY_ALLOWED = new Set(["architecture/decisions.md"]); +const LEGACY_ALLOWED = new Set(["technical-documentation/architecture/decisions.md"]); const REQUIRED = [ "README.md", @@ -59,7 +74,7 @@ const REQUIRED = [ "testing/native-cursor-diagnostics.md", ]; -// `--only a.md,b/c.md` limits both checks to those docs-relative paths, so a +// `--only a.md,b/c.md` limits both checks to those repo-relative paths, so a // task that owns a slice of the tree can gate on its slice alone. const onlyArg = process.argv.find((a) => a.startsWith("--only=")); const only = onlyArg ? new Set(onlyArg.slice("--only=".length).split(",")) : null; @@ -77,23 +92,24 @@ function walk(dir) { } const errors = []; -const files = walk(DOCS); +const files = TREES.flatMap(walk); for (const abs of REQUIRED) { - if (!owned(abs)) continue; + const rel = `technical-documentation/${abs}`; + if (!owned(rel)) continue; const full = join(DOCS, abs); let lines = -1; try { lines = readFileSync(full, "utf8").split("\n").length; } catch { - errors.push(`missing: technical-documentation/${abs}`); + errors.push(`missing: ${rel}`); continue; } - if (lines < 30) errors.push(`stub (${lines} lines): technical-documentation/${abs}`); + if (lines < 30) errors.push(`stub (${lines} lines): ${rel}`); } for (const file of files) { - const rel = relative(DOCS, file).replaceAll("\\", "/"); + const rel = relative(ROOT, file).replaceAll("\\", "/"); if (!owned(rel)) continue; const text = readFileSync(file, "utf8"); diff --git a/scripts/check-docs.test.mjs b/scripts/check-docs.test.mjs new file mode 100644 index 00000000..30fba432 --- /dev/null +++ b/scripts/check-docs.test.mjs @@ -0,0 +1,56 @@ +// check-docs.mjs gained `.harness/` because that tree rotted unwatched: +// `git-workflow.md` and `memory/MEMORY.md` both described the pre-#90 +// `release/vX.Y.Z-rc.N` branch naming for a month, sending anyone who read them +// to a branch the workflows never create, while the same fact stayed correct +// under technical-documentation/ because this script was watching that tree. +// +// The failure mode worth guarding is not a missed doc — it is this script +// reporting OK while checking nothing. If the walk silently stops covering a +// tree, or a LEGACY entry stops matching, the output is still a green +// `check-docs: OK` and the rot resumes invisibly for months. So assert the +// script actually *rejects* something, not just that it exits 0. +// +// Run as a subprocess rather than imported: the script exits on failure and +// resolves its own root from `import.meta.dirname`, so it is not parameterisable +// without a refactor the guard does not need. + +import { execFileSync } from "node:child_process"; +import { rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT = path.join(ROOT, "scripts", "check-docs.mjs"); + +/** @returns {{code: number, out: string}} */ +function run() { + try { + return { code: 0, out: execFileSync(process.execPath, [SCRIPT], { encoding: "utf8" }) }; + } catch (e) { + return { code: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + } +} + +describe("check-docs", () => { + it("passes on the tree as committed", () => { + const { code, out } = run(); + expect(out).toContain("check-docs: OK"); + expect(code).toBe(0); + }); + + it("rejects the retired release-branch naming inside .harness/", () => { + // .harness/ specifically: technical-documentation/ was never the tree that + // went stale, so planting there would pass even if the walk lost .harness/. + const probe = path.join(ROOT, ".harness", "docs", "_check-docs-probe.md"); + writeFileSync(probe, "# probe\n\nCut from `release/vX.Y.Z-rc.N`.\n"); + try { + const { code, out } = run(); + expect(out).toContain("_check-docs-probe.md"); + expect(out).toContain("release/vX.Y.Z-rc.N"); + expect(code).toBe(1); + } finally { + rmSync(probe, { force: true }); + } + }); +}); diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 4adac7d4..aaf7f9fd 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -14,6 +14,15 @@ Run the `Cut a release candidate` workflow (`prerelease.yml`) with: The workflow computes `X.Y.Z-rc.N`, migrates items from `Next Release` to the `vX.Y.Z` milestone, creates or reuses `release/vX.Y.Z`, commits the prerelease version there, tags the frozen branch tip, explicitly dispatches `build.yml` at the RC tag, and announces the pre-release in the configured RC Discord channel. +`build.yml` does have a `push:` trigger on `v*` tags, but the release workflows do not rely on it. The dispatch is explicit *and* pinned to the tag, for two separate reasons: + +- **Explicit**, because a tag pushed with `GITHUB_TOKEN` does not fire `build.yml`'s `push:` trigger in this org's setup — GitHub withholds that to stop workflows triggering each other in a loop. `promote.yml` does push the stable tag that way, so without the dispatch nothing would build. +- **Pinned with `--ref`**, because the build must check out the **tag**, not the default branch. The version bump lives only on the release branch; `main` still carries the previous stable version, and `build.yml`'s publish step would fail its guard (`package.json version X does not match `). + +The two workflows push their tags with different credentials, which is deliberate: `promote.yml` uses `GITHUB_TOKEN` (a tag is a ref, not a file change), while `prerelease.yml` pushes the RC tag with `OPENSCREEN_RELEASE_TOKEN`. A `GITHUB_TOKEN` tag push is answered with `remote: Internal Server Error` — a 500, not a 403 — by a tag ruleset that rejects the Actions token, and that failure took down the whole `v1.8.0-rc.1` cut, skipping the build trigger and the Discord announce with it. + +RC tags are signed and notarized exactly like stable ones. That keeps testers out of `xattr -rd com.apple.quarantine`, and exercises the whole credential path on every candidate instead of first proving it on the promotion build. + ### Promote to stable Run `Promote RC to stable release` (`promote.yml`) with: @@ -23,26 +32,73 @@ Run `Promote RC to stable release` (`promote.yml`) with: The workflow validates the tag, closes the version milestone, checks out `release/vX.Y.Z`, changes `package.json` to the stable version, tags that branch tip, opens and rebase-merges a release-sync PR into `main`, explicitly dispatches `build.yml` at the stable tag, and announces the stable release. The build publishes signed/notarized artifacts when Apple credentials are complete; publication with `OPENSCREEN_RELEASE_TOKEN` emits the event that starts stable Homebrew, WinGet, Nix, and AUR workflows. -### Release-branch freeze rule +### Release branches (the contract) + +Every released version has **exactly one frozen branch**, named for the stable version, living from the first RC cut onward: -An RC cut creates `release/vX.Y.Z`. That branch is not merged into `main` until the stable tag is published, and only cherry-picked RC bug fixes land on it during the RC window. Subsequent RCs reuse the same branch. This rule exists because a promote workflow once tagged `main` instead of the tested RC snapshot and shipped unreleased commits. +```text +release/vX.Y.Z created at rc.1, frozen through promote, kept for backports +release/vX.Y.Z-sync ephemeral, created by promote to merge into main +``` -Development continues on `main`; the freeze applies to the release branch. Day-to-day branching, PR, review, and cherry-pick procedure is maintained in [the operational git workflow](../../.harness/docs/git-workflow.md). +The name carries **no `-rc.N` suffix**. `prerelease.yml` and `promote.yml` must resolve the same ref, and every RC of a version re-cuts from this one branch. -### Manual tag fallback +1. **`prerelease.yml` creates the branch at rc.1 and reuses it for later RCs.** It must never delete or recreate it: that would drop the cherry-picks and silently re-cut from `main`, defeating the freeze this contract exists to guarantee. +2. **`promote.yml` is the only automated writer** that turns `-rc.N` into the stable version on the branch. A maintainer doing that by hand means the dispatch failed — see [Manual fallback](#manual-fallback). +3. **`main` is never frozen.** Development continues as usual; the release branch is the freeze. +4. **Cherry-picks during the RC window** are committed manually by a maintainer (`git checkout release/vX.Y.Z && git cherry-pick `), then rerun `prerelease.yml` with the next `rc_number` to re-tag the branch tip. -When the dispatch UI is unavailable, prepare the correct prerelease or stable `package.json` commit on the frozen release branch, then push the tag at that exact commit: +Only cherry-picked bug fixes land on the branch between cut and promote. Features, refactors, and CI/docs changes are **not** applied — they live on `main` and ship in the next cycle. `git log release/vX.Y.Z..main --oneline` lists exactly what is *not* in the RC. -```bash -git tag v1.8.0-rc.1 -git push origin v1.8.0-rc.1 +The branch **stays around** indefinitely: it is the frozen history of the release, useful for backports and forensics. Retiring one is a manual decision, taken only once a future major supersedes the line it froze. + +This contract exists because of the **v1.6.0 incident (2026-07-05)**: the original `promote.yml` checked out `main`, so the stable tag captured the post-RC tip of `main` rather than the RC snapshot. Twenty-three commits (Tiptap, NotesWindow, an in-recorder lint button, AI handoff) shipped in v1.6.0 without ever having been in v1.6.0-rc.1. The re-release the same day used `release/v1.6.0` and cherry-picked only the commits that were genuinely safe. + +Day-to-day branching, PR, and review procedure is maintained in [the operational git workflow](../../.harness/docs/git-workflow.md). + +### Manual fallback -# After QA and the stable version commit on the same release branch: -git tag v1.8.0 -git push origin v1.8.0 +When the dispatch UI is unavailable, the release can be cut from a shell. Set the version with `.github/scripts/set-release-version.mjs` — the same script both workflows call — and **never with a hand-rolled `sed` on `package.json`**: the script also writes `package-lock.json`, and a release commit that bumps only `package.json` ships a lockfile whose root version disagrees with the package it locks. `npm ci` does not reject that (the root `version` field is not a dependency, so the sync check ignores it), which is how three releases shipped with the mismatch before anyone noticed. + +```bash +RC=1.5.0-rc.1 # bump the rc.N for every later candidate + +# Cut RC (skips milestone migration and Discord announce) +git checkout -b release/v1.5.0 main # rc.2+: git checkout release/v1.5.0 instead +node .github/scripts/set-release-version.mjs "$RC" +git commit -am "chore(release): bump to $RC [skip ci]" +git push origin release/v1.5.0 +git tag "v$RC" && git push origin "v$RC" + +# Promote (skips milestone close and Discord announce) +git checkout release/v1.5.0 +node .github/scripts/set-release-version.mjs 1.5.0 +git commit -am "chore(release): bump to 1.5.0 [skip ci]" +git push origin release/v1.5.0 +git tag v1.5.0 && git push origin v1.5.0 ``` -Any `v*` tag triggers `build.yml`. The fallback skips milestone migration/closure, release-branch automation, explicit build dispatch, main synchronization, and Discord announcements, so the operator must preserve the freeze and version/tag match manually. +A tag pushed with your own credentials **does** fire `build.yml`'s `push:` trigger, so the release publishes on its own and no explicit dispatch is needed — that restriction only applies to `GITHUB_TOKEN`. Either way the same `build.yml` builds and publishes. + +The fallback skips milestone migration/closure, release-branch automation, main synchronization, and Discord announcements, so the operator must preserve the freeze and the version/tag match by hand. + +### Backports / patch on a previous line + +For a `v1.4.2` while `v1.5.0` is in flight: + +1. Branch `release/1.4.x` from the `v1.4.0` (or `v1.4.1`) tag. +2. Cherry-pick the fix commits. +3. Push the branch, then `git tag v1.4.2-rc.1` on the branch tip. +4. `git push origin release/1.4.x v1.4.2-rc.1` — `build.yml` works from any branch. + +No new workflow code is needed; the tag-pushed trigger is branch-agnostic. + +### Issue tracking during a release cycle + +- **Daily state**: issues/PRs accumulate in the rolling `Next Release` milestone. `merged-pr-bookkeeping.yml` adds them automatically on PR merge; maintainers can also drag issues in by hand. +- **At RC cut**: `prerelease.yml` snapshots `Next Release` into a versioned `vX.Y.Z` milestone. The rolling milestone is left open and empty for new work. +- **Between RC cut and promote**: any PR that merges during the RC window lands back in the empty `Next Release`. It is **not** retroactively added to `vX.Y.Z`. If a critical fix lands, cut `vX.Y.Z-rc.(N+1)` instead of promoting. +- **At promote**: `promote.yml` closes the `vX.Y.Z` milestone and uses its closed issues to populate the Discord release announcement. ## Required release credential From ca5124b6ec0a0c70525ebd272eb7c485e36616ec Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 11 Aug 2026 23:44:37 +0200 Subject: [PATCH 4/4] fix(docs-check): catch the retired branch naming in both spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LEGACY entry added alongside the release-docs consolidation was a literal substring test on `release/vX.Y.Z-rc.N`, so it only caught the placeholder spelling used in prose. The rot it exists to prevent used both: the prose placeholder *and* a concrete `release/v1.5.0-rc.1` inside the copy-pasteable manual-fallback shell block. Restoring that block from git history — exactly what an operator does when the dispatch UI is down — passed the lint clean. The concrete spelling is the more dangerous one, because it is the one someone pastes into a terminal. Replaced with a regex over both forms. It matches the branch shape only, so the RC tags (`v1.6.0-rc.1`, no `release/` prefix) that the v1.6.0 postmortem depends on, the stable `release/vX.Y.Z`, its ephemeral `-sync` branch, and backport lines like `release/1.4.x` all still pass — pinned as a test, since a guard that over-fires is a guard someone deletes. Found by adversarially verifying the parent commit rather than by trusting it. --- scripts/check-docs.mjs | 22 +++++++++++++++----- scripts/check-docs.test.mjs | 41 ++++++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 2fb5bb93..fc798edb 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -41,12 +41,20 @@ const LEGACY = [ "provider-parity-plan", "github-actions-workflows", "ux-ui-spec", - // Pre-#90 release-branch naming. One branch per *stable* version now - // (`release/vX.Y.Z`), created at rc.1 and reused, because prerelease.yml and - // promote.yml have to resolve the same ref. Docs that reintroduce the - // suffixed name send a maintainer to a branch that was never created. - "release/vX.Y.Z-rc.N", ]; + +// Pre-#90 release-branch naming. One branch per *stable* version now +// (`release/vX.Y.Z`), created at rc.1 and reused, because prerelease.yml and +// promote.yml have to resolve the same ref; a doc that reintroduces the +// suffixed name sends a maintainer to a branch nothing ever creates. +// +// A LEGACY substring entry is not enough here: the rot used BOTH spellings, and +// the one that mattered was the concrete `release/v1.5.0-rc.1` sitting in a +// copy-pasteable shell block, not the `release/vX.Y.Z-rc.N` placeholder in the +// prose. Someone restoring that block from git history would have passed the +// lint. Matches the branch form only — the RC *tags* (`v1.6.0-rc.1`, no +// `release/` prefix) are current and appear in the v1.6.0 postmortem. +const RETIRED_BRANCH = /release\/v(?:X\.Y\.Z|\d+\.\d+\.\d+)-(?:rc|beta|alpha)\.(?:N|\d+)/g; const LEGACY_ALLOWED = new Set(["technical-documentation/architecture/decisions.md"]); const REQUIRED = [ @@ -139,6 +147,10 @@ for (const file of files) { for (const name of LEGACY) { if (text.includes(name)) errors.push(`${rel}: mentions removed "${name}"`); } + + for (const [match] of text.matchAll(RETIRED_BRANCH)) { + errors.push(`${rel}: retired release-branch naming "${match}" (it is release/vX.Y.Z)`); + } } if (errors.length) { diff --git a/scripts/check-docs.test.mjs b/scripts/check-docs.test.mjs index 30fba432..9227c691 100644 --- a/scripts/check-docs.test.mjs +++ b/scripts/check-docs.test.mjs @@ -39,16 +39,43 @@ describe("check-docs", () => { expect(code).toBe(0); }); - it("rejects the retired release-branch naming inside .harness/", () => { - // .harness/ specifically: technical-documentation/ was never the tree that - // went stale, so planting there would pass even if the walk lost .harness/. + // Both spellings, because the rot used both and the first version of this + // guard only caught one. The prose said `release/vX.Y.Z-rc.N`, but the + // copy-pasteable shell block said `release/v1.5.0-rc.1` — and that block is + // what an operator restores from git history when the dispatch UI is down. + // A substring check on the placeholder waves the concrete one straight + // through, which is the more dangerous of the two. + for (const naming of ["release/vX.Y.Z-rc.N", "release/v1.5.0-rc.1"]) { + it(`rejects the retired release-branch naming "${naming}" inside .harness/`, () => { + // .harness/ specifically: technical-documentation/ was never the tree that + // went stale, so planting there would pass even if the walk lost .harness/. + const probe = path.join(ROOT, ".harness", "docs", "_check-docs-probe.md"); + writeFileSync(probe, `# probe\n\n\`\`\`bash\ngit checkout ${naming}\n\`\`\`\n`); + try { + const { code, out } = run(); + expect(out).toContain("_check-docs-probe.md"); + expect(out).toContain(naming); + expect(code).toBe(1); + } finally { + rmSync(probe, { force: true }); + } + }); + } + + // A guard that over-fires gets switched off, so pin what must NOT trip it: + // RC *tags* carry the `-rc.N` suffix and are current, the stable release + // branch and its ephemeral sync branch are current, and backport lines use + // `release/1.4.x`. + it("accepts the naming that is still current", () => { const probe = path.join(ROOT, ".harness", "docs", "_check-docs-probe.md"); - writeFileSync(probe, "# probe\n\nCut from `release/vX.Y.Z-rc.N`.\n"); + writeFileSync( + probe, + "# probe\n\nTag `v1.6.0-rc.1` on `release/v1.6.0`, synced via `release/v1.6.0-sync`; backports on `release/1.4.x`.\n", + ); try { const { code, out } = run(); - expect(out).toContain("_check-docs-probe.md"); - expect(out).toContain("release/vX.Y.Z-rc.N"); - expect(code).toBe(1); + expect(out).toContain("check-docs: OK"); + expect(code).toBe(0); } finally { rmSync(probe, { force: true }); }