Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 2 additions & 103 deletions .harness/docs/git-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sha>`, 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 <sha>`), 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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 1 addition & 1 deletion .harness/memory/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 12 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ The "No mandated app-stack choice yet." qualifier with the asterisk'd "may be enforced later" is the right escape hatch. Tells future agents this is provisional, not a forever rule.

Small wording nit: "Open to React/Vue/Svelte/whatever" is a bit informal. Consider "No mandated UI framework; React is current, but other choices are open" — same meaning, less jokey.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed here — the string you quoted isn't in the diff. The bullet reads:

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.

No "React/Vue/Svelte/whatever" anywhere in AGENTS.md (git grep -i svelte is empty), and the scope is deliberately the state/data layer rather than the UI framework — React is already load-bearing here, so it isn't up for contributor choice and the bullet shouldn't imply it is. Flagging in case the rewording suggestion was aimed at something else.


- 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
Expand Down Expand Up @@ -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 <sha> && 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a noun for the promotion event.

frozen until promote is grammatically incomplete and does not identify the event clearly. Use frozen until stable promotion or frozen until \promote.yml` promotes it`.

The supplied LanguageTool hint identifies this line.

🧰 Tools
🪛 LanguageTool

[grammar] ~132-~132: Ensure spelling is correct
Context: ...zen** until promote. Only cherry-picked bugfixes land on it, so anything merged to `main...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` at line 132, Update the release-branch policy sentence near
“frozen until promote” to use a clear noun phrase for the promotion event, such
as “frozen until stable promotion” or the existing promote workflow reference,
while preserving the surrounding release-branch rules.

Source: Linters/SAST tools


## Security

Expand All @@ -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/<locale>/` (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.)
52 changes: 40 additions & 12 deletions scripts/check-docs.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the supported --only= syntax.

The parser only reads an argument that starts with --only=. The comment shows --only a.md,b/c.md; that form leaves only unset and runs both checks over all trees. Use --only=a.md,b/c.md, or add support for the space-separated form.

This follows directly from the parser at Lines 79-80.

Proposed fix
-// `--only a.md,b/c.md` limits both checks to those repo-relative paths, so a
+// `--only=a.md,b/c.md` limits both checks to those repo-relative paths, so a
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// `--only a.md,b/c.md` limits both checks to those repo-relative paths, so a
// `--only=a.md,b/c.md` limits both checks to those repo-relative paths, so a
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-docs.mjs` at line 77, Update the usage comment near the
argument parser in check-docs.mjs to document the supported equals-sign syntax
as --only=a.md,b/c.md, matching the parser’s handling of the only argument; do
not imply that the space-separated form is supported.

// 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;
Expand All @@ -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");

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading