diff --git a/.harness/docs/git-workflow.md b/.harness/docs/git-workflow.md index 99fc1ff0d..1e67d6428 100644 --- a/.harness/docs/git-workflow.md +++ b/.harness/docs/git-workflow.md @@ -43,107 +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. 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. -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-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. - -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. - -### 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-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`. -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 a corresponding **frozen branch**: - -``` -release/vX.Y.Z-rc.N exists from RC cut until promote finishes -release/vX.Y.Z-sync ephemeral, created by promote to merge into main -release/vX.Y.Z stable snapshot post-promote (kept for backports) -``` - -Key rules: - -1. **`prerelease.yml` creates the branch.** Nothing else pushes to it except the cherry-pick workflow during the RC window. -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). - -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: - -```bash -# Cut RC (skips milestone migration and Discord announce) -git checkout -b release/v1.5.0-rc.1 main -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 - -# Promote (skips milestone close and Discord announce) -git checkout release/v1.5.0-rc.1 -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 -``` - -The pipeline can't tell the difference between a manually-pushed tag and a workflow-pushed one — same `build.yml` runs either way. - -### 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/.harness/memory/MEMORY.md b/.harness/memory/MEMORY.md index 0f5205bc0..97367de4f 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 2d8e2d049..d7b2ec8ab 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`, 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). +- **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,9 @@ 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. - -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`. +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. -**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. +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 @@ -140,4 +143,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 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.) diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index c15497c12..fc798edb2 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", @@ -32,7 +42,20 @@ const LEGACY = [ "github-actions-workflows", "ux-ui-spec", ]; -const LEGACY_ALLOWED = new Set(["architecture/decisions.md"]); + +// 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 = [ "README.md", @@ -59,7 +82,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 +100,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"); @@ -123,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 new file mode 100644 index 000000000..9227c691c --- /dev/null +++ b/scripts/check-docs.test.mjs @@ -0,0 +1,83 @@ +// 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); + }); + + // 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\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: OK"); + expect(code).toBe(0); + } finally { + rmSync(probe, { force: true }); + } + }); +}); diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 4adac7d4d..aaf7f9fd7 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