* 🎯 ci: Retarget Pull Requests Opened Against `main` to `dev`
Contributions land on `dev`; `main` only moves at release time. With `main` as
the repository's default branch, pull requests default to it, and the mismatch
has been resolved by hand at merge time.
Adds a `pull_request_target` hook that switches a newly opened or reopened pull
request's base from `main` to `dev` and posts a one-time explanation, plus a
manual `workflow_dispatch` sweep (dry-run by default) for the existing backlog.
Release-bound upstream branches keep `main` as their base: `dev`, `release/*`,
`hotfix/*`, `backport/*` and `*-main`. The `target: main` label exempts any
pull request. The exemptions apply only to branches on this repository, so a
fork branch that happens to match one of those names is still retargeted.
The job checks out the trusted base commit rather than the pull request head,
so the write-scoped token never runs contributor code.
`pull_request_target` reads its workflow definition from the base branch, so
this must exist on `main` to take effect.
* 📝 docs: Record the `dev`-First Branch Policy for Agents
`CLAUDE.md` and `AGENTS.md` never stated which branch work targets, and the
default branch is `main` — so `gh pr create` and the GitHub UI point at `main`
unless told otherwise, and an agent following the defaults gets it wrong.
States the policy, the reason a backport to `main` is never needed (`main` is
kept as a strict ancestor of `dev`), and the exemptions the retarget workflow
honors.
* 📐 docs: Record Typecheck, Formatting and Issue-Closing Gotchas for Agents
Four repeat cycle-wasters that neither file stated, each verified against the
current tree:
- `tsdown` emits without typechecking, so `npm run build` proves nothing about
types in `packages/api`, `packages/client` or `packages/data-schemas`; only
`packages/data-provider` runs `tsc` in its build.
- `packages/client/tsconfig.json` excludes `*.spec.ts(x)` and `*.test.ts(x)`,
so type errors in its specs surface only at run time.
- `npm run sort-imports` with no arguments rewrites all six source roots.
- `Fixes #N` never closes an issue here, because closing keywords are honored
only on merges into the default branch and everything merges to `dev`.
Also notes that worktrees share one stash stack, and points at
`npm run static-checks -- --against origin/dev` as the local mirror of CI.
* 🔁 fix: Address Codex Review on the Retarget Workflow
- **Backport branches are no longer exempt.** Exempting `backport/*` and
`*-main` contradicted the invariant the policy rests on: `main` is a
fast-forward of `dev`, so a backport merged straight to `main` is precisely
what breaks it. `target: main` remains the deliberate, explicit escape hatch;
`dev`, `release/*` and `hotfix/*` stay exempt as release-bound branches.
- **`npm run update` does not sync `dev`.** `config/update.js` hard-codes
`git checkout main` and `git pull origin main` — it is the self-host
deployment updater, not a branch-sync tool. Telling contributors to refresh
`dev` with it would have branched them off `main` and recreated the very
flow this change exists to prevent. Replaced with the explicit git commands.
- **A dry run no longer reports work it did not do.** The summary said
"Retargeted N" on a dispatch that wrote nothing, which could read as a
completed sweep.
- **A failed explanation comment is recorded, not swallowed.** The base edit
has already landed at that point, so a later run would skip the pull request
and the contributor would never receive the note. It now counts, fails the
run, and names the retry: `EXPLAIN_MISSING=true`, which posts a missing
explanation onto an already-retargeted pull request.
- **The sweep paginates.** `gh pr list --limit 500` is a cap, not a page size,
and would have silently ignored the remainder while reporting success.
Verified against the stubbed `gh` boundary: `*-main` now retargets, the dry run
performs zero writes and says so, a failing comment yields a non-zero exit, and
`EXPLAIN_MISSING` posts onto a pull request already based on `dev`. The
paginated query returns all 258 open pull requests based on `main`.
* 🧷 fix: Address the Second Codex Round on the Retarget Workflow
- **`already_explained` now separates "lookup failed" from "no marker".** A
transient comments-API failure returned the same status as a clean miss, so
the run would post a second explanation and report success — defeating the
deduplication it exists for. A failed read now declines to post and is
recorded.
- **`CONTRIBUTING.md` syncs from the canonical remote.** The previous fix
pulled `dev` from `origin`, which in the fork-based flow described two
sections later is the contributor's own fork — so it would fail outright or
silently sync a stale branch, landing them on the wrong base again.
- **The hook retries on `synchronize`.** If the `opened` run failed before
`gh pr edit` landed, nothing retried and the pull request stayed on `main`.
A retargeted pull request no longer matches `branches: [main]`, so this adds
no redundant runs — and the existing backlog now drains on contributor
activity rather than only by sweep.
- **Sweeps serialize against each other** via a shared concurrency group, while
the hook stays keyed per pull request. Deliberately not one global group:
GitHub keeps a single pending job per group, so a burst of openings would
cancel queued retargets — trading a rare duplicate comment for dropped
retargets is the wrong way round.
- **`main` is an ancestor of `dev`, not a strict one.** Right after a sync the
two refs are equal, which the previous wording described as a violated
invariant.
Verified: a failing comments lookup edits the base but posts nothing and exits
non-zero.
Every Playwright job spent a flat 90s on `npx playwright install ffmpeg`,
and none of them ended up with a usable ffmpeg.
The 2.3MB download finishes in under a second; extraction then hangs until
`timeout -k 10 90` reaps it (exit 124, masked by `continue-on-error`). That
is a Node 24.16.0 readable-stream change (nodejs/node#62557) colliding with
yauzl/fd-slicer never firing `close` after EOF, which hangs extract-zip.
It leaves a truncated `ffmpeg-linux` — 5,055,201 bytes against the zip's
declared 5,101,056, segfaulting on exec — and no INSTALLATION_COMPLETE
marker, so Playwright treated ffmpeg as uninstalled. `video: 'on-first-retry'`
has therefore never worked in CI, and every first retry of a flaky test died
in browserContext.newPage: exactly the failure the step existed to prevent.
Upstream fixed it in Playwright 1.60.0 (microsoft/playwright#40747) and Node
reverted it in 24.18.0 (nodejs/node#63834). Node 24.16.0 is pinned in 17
places including the Dockerfiles, so bump Playwright instead — it is a dev
dependency, and `^1.56.1` already permitted 1.62.1; only the lockfile pinned
it. Staying at or above 1.62.1 also avoids the tsconfig-resolution
regressions in 1.62.0.
Caching alone could not have fixed this: a cold cache still hangs, and what
would have been cached is the corrupt binary. So the ffmpeg download is now
restored from cache keyed on the resolved playwright-core version, the
install is skipped outright on a hit, and the cache is only saved once the
binary is verified to actually execute — a partial extraction can never be
promoted into a cache that every later job restores.
Per job: 90s to ~0s on a hit, ~2s on a miss.
`npx playwright install-deps chrome` is the third-most-common e2e failure:
three of the last twenty-five Playwright runs died on it, taking the whole
aggregate gate with them. The step is not installing anything CI needs.
The runner's Chrome is an apt package, so apt has already satisfied every
library Playwright lists — the log shows each one "already the newest version".
All `install-deps` adds are decorative CJK/Thai/Cyrillic font packages, ~21MB
pulled from azure.archive.ubuntu.com by seven jobs on every PR. No CI assertion
depends on them: the only spec that screenshots gates its comparison behind
`E2E_VISUAL_SNAPSHOTS`, which no workflow sets, and no baselines are committed.
Keep the install, but demote it. `google-chrome --version` becomes its own
fatal step so a genuinely missing browser still fails loudly and immediately,
while the font install retries with a per-attempt cap and degrades to a warning.
The Redis install in the list_changed job stays fatal — that one is required.