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
129 changes: 129 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Dependency automation for this repo (issue #54). Renovate, not Dependabot:
// several of this project's dependency decisions are deliberate HOLDS whose
// reasoning lives in CLAUDE.md prose, and Dependabot's `ignore` entries cannot
// express most of them. Everything below exists to stop a bot from cheerfully
// re-proposing, every week, the exact bump this project has already decided
// against - and to make the reason travel with the proposal when there is one.
//
// This file does nothing on its own. Renovate only acts once the Renovate
// GitHub App is enabled for the repository (https://github.com/apps/renovate).
// Validated with `renovate-config-validator` before landing.
//
// THE pnpm INTERACTION, which is the subtle part.
// This workspace relies on pnpm 11's built-in release-age cooldown - there is no
// `minimumReleaseAge` line in pnpm-workspace.yaml, the guard is a pnpm default.
// When pnpm is asked to install something newer than that cooldown allows it
// does not fail; it writes a `minimumReleaseAgeExclude:` bypass into
// pnpm-workspace.yaml, which is how `vue-tsc@3.3.8` once silently waived the
// guard. A dependency bot is a machine for producing exactly that situation.
// So `minimumReleaseAge` is declared HERE, explicitly, at a value comfortably
// above pnpm's default: Renovate must never open a PR for a release pnpm would
// want a bypass for. Declaring it also means a future pnpm changing its default
// cannot move this repo's floor silently.
// If `minimumReleaseAgeExclude:` ever appears in a Renovate PR's diff, that PR
// is wrong - do not merge it, fix the age rule instead.
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: [
"config:recommended",
// Pin every GitHub Action to a commit SHA rather than a moving tag, and keep
// the `# vX.Y.Z` comment beside it updated. .github/workflows/verify.yml is
// already written that way by hand; this makes it the rule for anything
// added later, so the choice cannot drift action-by-action.
"helpers:pinGitHubActionDigests",
],

// See the pnpm interaction above. This is a floor, not a preference.
minimumReleaseAge: "3 days",

// The central invariant here is byte-exactness against captured fixtures, and
// a green CI run is evidence of consistency, not of a bump being *correct*
// (`pnpm run verify` does not exercise every dependency - see the js-beautify
// note in pnpm-workspace.yaml). Nothing merges unread.
automerge: false,

dependencyDashboard: true,

// One weekly batch instead of a trickle. Times are UTC (no `timezone` set).
schedule: ["before 6am on monday"],

packageRules: [
{
// THE ONE THAT MATTERS MOST. Without it a bot proposes 6.0.3 -> 7.x every
// single week and someone re-derives the refusal every time (see #48, #52).
// TypeScript 7 exposes no programmatic API yet, the official migration is
// a dual-install alias, and vue-tsc/Volar cannot type-check .vue against
// it. Revisit at 7.1 deliberately, by hand.
matchPackageNames: ["typescript"],
enabled: false,
},
{
// The codec's byte-exactness rests on `{ level: 9, legacyHash: true }`.
// `legacyHash` is a pako EXTENSION with no zlib-API contract, from a
// library that already flipped that default inside a single major (2.2.0
// added it defaulting true, 3.0.0 flipped it false). A pako bump is a
// codec change until proven otherwise.
matchPackageNames: ["pako"],
minimumReleaseAge: "14 days",
automerge: false,
prBodyNotes: [
"**Read `src/codec/deflate.ts` and the pako table in CLAUDE.md before merging.** Deflate must stay madler-zlib-compatible at level 9 and byte-exact against all 9 fixtures. `test/deflate.spec.ts` has a block that fails by name if `legacyHash` is dropped, renamed or re-defaulted - do not silence it by editing a fixture.",
],
},
{
// @cloudflare/vitest-pool-workers hard-pins an exact wrangler. Split PRs
// cannot resolve, so these two only ever move together.
matchPackageNames: ["wrangler", "@cloudflare/vitest-pool-workers"],
groupName: "cloudflare worker toolchain",
prBodyNotes: [
"After merging, regenerate the worker types: `pnpm --filter @fmw/preview-worker exec wrangler types && pnpm vp check --fix`. The formatter pass is not optional.",
],
},
{
// `overrides.brace-expansion: 2.1.3` in pnpm-workspace.yaml is a
// deliberate maintenance-backport pin, not a stale version. Renovate would
// read it as three majors behind and propose 5.x, which is a spike into a
// dual-ESM/CJS package consumed by a CJS minimatch - explicitly rejected.
// The advisory's flat `<=5.0.7` range also means `pnpm audit` still flags
// 2.1.3 even though it carries the fix, so "the audit is red" is not a
// reason to take this bump. Read the comment in pnpm-workspace.yaml first.
//
// Deliberately NOT narrowed with `matchDepTypes: ["pnpm.overrides"]`. The
// override lives in pnpm-workspace.yaml, not package.json, and if the
// depType Renovate reports for that location is anything other than the
// string guessed here the rule would silently match nothing - a config bug
// no validator can catch. brace-expansion appears in no manifest in this
// repo (only as a transitive of js-beautify), so matching it by name alone
// is both correct and the only form that cannot miss.
matchPackageNames: ["brace-expansion"],
enabled: false,
},
{
// `engines.node` is a deliberately permissive FLOOR (">=24.18.0"): older
// versions are untested, not known-broken. It is not a version to keep
// current. The version CI and development actually run on is
// `.node-version`, which Renovate may bump (see below).
matchDepTypes: ["engines"],
enabled: false,
},
{
// `.node-version` became machinery, not documentation, the moment
// .github/workflows/verify.yml started feeding it to setup-node. Bumps are
// welcome but never unattended.
matchFileNames: [".node-version"],
prBodyNotes: [
"This changes the Node version **CI** runs on, not just a local hint. Run `pnpm run verify` locally on the proposed version before merging.",
],
},
{
// vite-plus is pre-1.0 and is the entire static-check + test toolchain.
// Its tsgolint engine bumps have twice re-triggered the `TS2321: Excessive
// stack depth ... UserConfig` pathology in vite.config.ts, which looks
// like a type error in this repo and is not one.
matchPackageNames: ["vite-plus"],
prBodyNotes: [
"If `vp check` starts reporting `TS2321: Excessive stack depth comparing types ... 'UserConfig'`, that is the known vite.config.ts comparison-depth pathology, not a real type error - see the type-checking section of CLAUDE.md for the `vue() as Plugin` cast that collapses it.",
],
},
],
}
77 changes: 77 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# The whole quality gate, run automatically. Until this file existed, `pnpm run
# verify` only ran when a human remembered to (or at deploy time, which can be
# days after the breaking merge) - see issue #54.
#
# This job deliberately runs `pnpm run verify` VERBATIM rather than re-listing
# its three phases as separate steps. `verify` is the single definition of "the
# repo is consistent"; splitting it here would create a second definition that
# can silently drift from package.json. Note it is `verify`, not `check` -
# `check` is `vp check --fix` and CI must never rewrite files.
#
# What is deliberately NOT here:
# - `pnpm refs:sync`. It needs a Factorio binary and ~/GitHub/factorio-data,
# neither of which exists on a runner. `verify` is designed to pass with no
# Factorio installed and that property is what makes this workflow possible.
# - `pnpm vp build`. Not part of `verify`, and the build stamp reads git
# history, so it wants a checkout policy of its own. Tracked as a follow-up.
# - Any deploy step or credential. Cloudflare Pages does not build this repo
# (`deploy:app` uploads an already-built `dist`), so CI here is a check
# only and the job needs no secrets at all.
name: verify

on:
push:
branches: [main]
pull_request:
# So a run can be forced without an empty commit.
workflow_dispatch:

# Superseded pushes to the same PR are pointless at ~2 minutes a run.
concurrency:
group: verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Minimum scope: the job only reads the tree. It writes no statuses, comments,
# packages or releases, so nothing beyond `contents: read` is granted.
permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
# `verify` is ~60-90s of work; 15 minutes is a hang detector, not a budget.
timeout-minutes: 15
env:
# `preview:test` shells out to wrangler (`wrangler types --check`). Keep it
# from phoning home for telemetry on a runner where nobody can answer the
# opt-in prompt.
WRANGLER_SEND_METRICS: "false"
steps:
# Third-party actions are pinned to a full commit SHA, never a moving tag.
# The trailing comment names the release each SHA is, and Renovate updates
# the SHA and that comment together - see .github/renovate.json5.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# No `version:` input on purpose. pnpm/action-setup >= 6 reads
# `devEngines.packageManager` from package.json, so the pnpm pin stays in
# exactly one place. Hard-coding it here would be a second pin to drift.
# This step must precede setup-node: `cache: pnpm` below resolves the
# store path by running pnpm, so pnpm has to be on PATH already.
- uses: pnpm/action-setup@008330803749db0355799c700092d9a85fd074e9 # v6.0.9

# `.node-version` (26.5.0) is the single source of truth for the Node
# version, and this is its first real consumer - the file went from
# documentation to machinery when this workflow landed. `engines.node`
# stays a permissive floor and is deliberately NOT what CI runs.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm

# A full workspace install. Anything narrower can leave a sibling
# workspace's symlink dangling and produce fake
# `TS2307: Cannot find module 'vitest'` errors; a real full install is the
# one that reports `Scope: all 3 workspace projects`.
- run: pnpm install --frozen-lockfile

- run: pnpm run verify
89 changes: 84 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,15 @@ bare `vp` or `npx vp` from the project root fails with `EBADDEVENGINES`.

Node **26.5.0** (`.node-version`) is what the repo is developed and verified on.
`engines.node` stays a permissive floor (`>=24.18.0`) rather than matching the
pin - older versions are simply untested, not known-broken. Nothing local
consumes `.node-version` (node comes from Homebrew, no version manager is
installed) and Cloudflare Pages never builds this repo - `deploy:app` uploads an
already-built `dist` - so the file is documentation, not machinery.
pin - older versions are simply untested, not known-broken.

**`.node-version` is machinery now, not documentation.** That changed when
`.github/workflows/verify.yml` landed: `actions/setup-node` reads the file via
`node-version-file`, so it is what CI actually installs. Nothing _local_ consumes
it still (node comes from Homebrew, no version manager is installed) and
Cloudflare Pages never builds this repo - `deploy:app` uploads an already-built
`dist` - so an edit to it changes the version the gate runs on and nothing else.
Bump it only alongside a local `pnpm run verify` on the new version.

Adding a root dependency needs `pnpm add -w` (or `--workspace-root`); a bare
`pnpm add <pkg>` at the root fails with `ERR_PNPM_ADDING_TO_ROOT`. Prefer
Expand All @@ -148,7 +153,11 @@ note below for why `pnpm up`'s transitive re-resolution can break `vp check`.
step (see the type-checking note below; there is still **no** `vue-tsc` check
of `.vue` bodies)
- `pnpm vp build` - production build
- `pnpm run verify` - `vp check` + `vp test` + `preview:test` in one gate (~9.5s)
- `pnpm run verify` - `vp check` + `vp test` + `preview:test` in one gate.
**~65-90s on a dev machine, ~4 minutes on a CI runner** - the `~9.5s` this
line claimed for a long time was simply wrong, and the gap mattered: 63s is
exactly the duration at which people start skipping a manual gate, which is
half the argument for the CI workflow below.
- `pnpm refs:sync` - pin `factorioLuaAPI/` + `~/GitHub/factorio-data` to the
installed binary's version (`--check` reports drift only; `--fixtures` reports
which oracle fixtures predate the binary). Deliberately **not** part of
Expand All @@ -157,6 +166,76 @@ note below for why `pnpm up`'s transitive re-resolution can break `vp check`.
- `pnpm run verify:deploy` - after deploying, confirm the live site is running
local `HEAD` (see below). Takes an optional origin argument.

### CI (`.github/`) runs the same `verify`, and nothing else

`.github/workflows/verify.yml` runs `pnpm run verify` on every pull request and
every push to `main`. It invokes the script **verbatim** rather than re-listing
its phases as separate steps, so there is exactly one definition of "this repo is
consistent" and CI cannot drift from local. If you change what `verify` means,
CI follows automatically - do not mirror the change into the YAML.

Conventions that file establishes, and that anything added under `.github/`
should keep:

- **Third-party actions are pinned to a full commit SHA**, with the release named
in a trailing `# vX.Y.Z` comment. Never a moving tag.
`helpers:pinGitHubActionDigests` in the Renovate config makes that automatic
for actions added later, and Renovate updates the SHA and the comment together.
- **`permissions:` is declared explicitly and minimally** (`contents: read`). Do
not fall back on the default token scope.
- **No `version:` input on `pnpm/action-setup`.** v6+ reads
`devEngines.packageManager` from `package.json`, so the pnpm pin lives in one
place. It must run _before_ `setup-node`, because `cache: pnpm` resolves the
store path by invoking pnpm.
- **No secrets, no deploy job.** Cloudflare Pages does not build this repo, so CI
is a check only. `pnpm refs:sync` is absent for the same reason it is absent
from `verify`: no runner has a Factorio binary. `pnpm vp build` is also absent
(the build stamp reads git history) - that is a known gap, not an oversight.

`preview:test` needs **no Docker** on a runner, which was confirmed rather than
assumed: the worker tests are pool-workers (`workerd` arrives from npm) and the
container tests are `node --test` against `render.mjs`.

**Renovate, not Dependabot** - `.github/renovate.json5`. The reason is that this
project's dependency decisions are _holds_ with reasoning behind them, and
Dependabot's `ignore` entries cannot express them; Renovate's `packageRules` +
`prBodyNotes` can, so the reasoning arrives attached to the proposal. `typescript`
is disabled outright, `pako` carries a 14-day age and a pointer at the
byte-exactness invariant, `wrangler` + `@cloudflare/vitest-pool-workers` are
grouped because pool-workers hard-pins wrangler, and the `brace-expansion`
override and `engines.node` floor are both marked as deliberate rather than stale.

One interaction is worth knowing before touching that file. The workspace's
release-age guard is a **pnpm default**, not a line in `pnpm-workspace.yaml`, and
pnpm's response to being asked for something too fresh is to write a
`minimumReleaseAgeExclude:` bypass - which is how `vue-tsc@3.3.8` once waived it
silently. `minimumReleaseAge: "3 days"` is therefore declared in the Renovate
config, above pnpm's default, so Renovate can never propose a release pnpm would
want a bypass for. If `minimumReleaseAgeExclude:` appears in a bot PR's diff,
that PR is wrong; fix the age rule, don't commit the bypass.

Renovate is inert until the GitHub App is enabled on the repo. Validate any edit
with `renovate-config-validator` (run it from outside the project root - a bare
`npx` here fails with `EBADDEVENGINES`).

Branch protection on `main` is **not** configured. It is the natural follow-up
now that a check exists, but it is a repository setting rather than a file.

#### `testTimeout` is 30s, deliberately, and retries are not used

Vitest's 5s default was too tight for this suite long before CI existed - 24
individual tests across 10 files carry an explicit `}, 120000)`, which is the
same complaint made 24 times by hand. The first CI run proved the default was the
real problem rather than any one test: on a 4-core runner (~3x slower, 230s vs
71s for the same suite) `elevationRenderRequest.spec.ts`'s `view 'all'` case
needs **9.8s**, and that file has 27 tests and zero annotations. `vite.config.ts`
now sets `testTimeout: 30_000`; the existing 120000 annotations still win over it.

Do **not** reach for `retry` when a heavy render test fails in CI. Nothing here is
nondeterministic - these tests compare pixels against captured game output - so a
retry would only hide a genuine regression. A timeout means slow; read the
duration the reporter prints before assuming a hang.

### Deploys are gated on `verify`

Both deploy paths refuse to ship a broken tree. `deploy:app` runs
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Factorio Map WebUI

[![verify](https://github.com/wormeyman/FactorioMapWebUI/actions/workflows/verify.yml/badge.svg?branch=main)](https://github.com/wormeyman/FactorioMapWebUI/actions/workflows/verify.yml)

A static SPA for authoring and exchanging Factorio 2.1.x map generation presets
(exchange-string codec format `2.1.9.3`). The core editor has no backend -
everything runs in the browser. An optional, opt-in map preview service (the app's only outbound call)
Expand Down Expand Up @@ -73,15 +75,29 @@ docs).

## Development

Requires Node 24.18.0 (see `.node-version`) and the `vp` CLI (Vite+). The
project pins pnpm via `devEngines`, so run `vp` through pnpm (a bare `vp` or
`npx vp` from the project root fails with `EBADDEVENGINES`).
Built and verified on Node **26.5.0** (`.node-version`, which is also what CI
installs); `engines.node` is a permissive floor of `>=24.18.0` because older
versions are untested rather than known-broken. The project pins pnpm via
`devEngines`, so run `vp` through pnpm (a bare `vp` or `npx vp` from the project
root fails with `EBADDEVENGINES`).

- `pnpm install` - install dependencies
- `pnpm vp dev` - dev server
- `pnpm vp check --fix` - lint + format
- `pnpm vp check --fix` - lint + format + type-check
- `pnpm vp test` - test suite (fixture-driven codec tests and UI tests)
- `pnpm vp build` - production build
- `pnpm run verify` - the whole gate: `vp check` + `vp test` + `preview:test`.
~65-90s locally. Needs no Factorio install.

### CI

`.github/workflows/verify.yml` runs `pnpm run verify` on every pull request and
every push to `main` - the same command, invoked verbatim, so CI and local cannot
disagree about what passing means. It needs no secrets and does not deploy.

Dependency updates are handled by Renovate (`.github/renovate.json5`), which
encodes this project's deliberate holds rather than proposing them weekly - see
the CI section of `CLAUDE.md` for what is held and why.

## Map preview service

Expand Down
Loading