Skip to content

feat(release): gate publishes on the weekly mutation result, shard the weekly run - #119

Merged
NSchatz merged 5 commits into
mainfrom
feat/release-proves
Nov 10, 2026
Merged

NSchatz merged 5 commits into
mainfrom
feat/release-proves

Conversation

@NSchatz

@NSchatz NSchatz commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

What

  • weekly-mutation.yml is sharded and incremental. A new shard job runs the caller's mutate-cmd on each of shard-count shards (new input, default 8, 1 to 16) in parallel; mutation keeps its id, merges the shard reports into one report per package and applies the floor exactly as before.
  • release.yml runs four pre-publish gates in its version job, after Verify and before anything can publish, and every GitHub release carries a CycloneDX SBOM. release.yml runs no full mutation pass anywhere: the mutation gate reads the latest weekly result and starts nothing.
  • New scripts, each with its own tests: scripts/mutation-shards.mjs (the merge), scripts/mutation-gate.mjs, scripts/tarball-samples.mjs, scripts/sbom.mjs; scripts/github-release.mjs takes --sbom.
  • self-check.yml runs a sharded weekly run (2 shards) and then an incremental one (weekly-mutation-incremental, which restores what the first saved), and a new release-gates job runs the mutation gate, the samples gate and the SBOM for real on this repository and the fixture, with no write permission.
  • README.md: new sections "The sharded weekly run" and "The release gates", the caller examples, and that ci / mutation is meant to become a required check when a library adopts it. CHANGELOG.md "Unreleased" says what a caller must act on.

The sharding and cache design

  • Each shard runs mutate-cmd with COSYTE_MUTATE_SHARD=<k>/<n> and COSYTE_MUTATE_INCREMENTAL_FILE=reports/mutation/incremental.json; cosyte-mutate full is to mutate only its share and write reports/mutation/shard-<k>-of-<n>.json per package. Variables rather than arguments, so they reach every cosyte-mutate a multi-package command starts.
  • A matrix of 1 to shard-count, fail-fast: false, each shard bounded at 340 minutes, inside GitHub's 6 hour job limit. A count outside 1 to 16 fails before any shard starts.
  • The cache: each shard restores the newest entry with the prefix cosyte-mutation-v1-<working-directory>-<count>-<shard>- (this branch's first, then the default branch's, so a scheduled run on main reuses main's latest results), and saves its incremental files under a key unique to the run, even when its run failed. Each shard's summary and note says whether it started incremental or full.
  • mutation runs with !cancelled() after the shards and fails at once if any shard did not succeed (a skip would satisfy a required context). It merges with scripts/mutation-shards.mjs from the tooling checkout at job.workflow_sha, refusing a package missing a shard or a file in two shards, then runs cosyte-ratchet check --skip coverage as before: red below the floor, never a commit.
  • The header example schedules it twice a week, since GitHub evicts a cache entry unread for 7 days.

The release gates

Gate Input Default Skip condition, logged by name
Latest weekly mutation result mutation-workflow (weekly-mutation.yml), mutation-max-age-days (8) on the caller has no .github/workflows/<mutation-workflow> in the checkout
Benchmarks with the scaling gate (pnpm run test:perf, then cosyte-bench run and check per suite) run-benchmarks, bench-suites, bench-target off run-benchmarks off; with no suite the scaling gate runs alone
Package checks on the packed tarball (prepublish-check.mjs manifest and pack layers; types per exports entry stay pnpm attw in Verify) run-package-checks off run-package-checks off
Docs samples against the exact npm pack tarball, installed in a clean directory run-tarball-samples, docs-dir (docs-content) off input off, no docs directory, or no sample in it
SBOM (CycloneDX 1.5, sbom.cdx.json), attached in the call that creates the release none on none: written before the publish, so a failure stops it
npm provenance unchanged on for a public caller unchanged

The mutation gate reads through the API with the automatic token at actions: read, which version already holds. A refused token, a 404 or 5xx, a network fault or an unreadable body fails closed; a latest run that did not succeed, or finished more than the age limit ago, or no completed run at all, refuses. Whether the caller has the workflow is read from the checkout, not the API, so a token without the grant cannot read as "no workflow". The gates are in version, which holds no id-token: write; release needs it and never opts back in after a failure, so a refusal stops the publish before anyone is asked to approve it.

Why

A full mutation run takes hours: hl7's first full run counted 11,422 mutants, estimated at 45 minutes to 3.5 hours on 16 cores, so roughly 3 to 14 hours on a 4-core hosted runner, past the weekly run's 180 minute bound. So a full run never sits in front of a release. The release reads the latest weekly result instead, and the weekly run is split across shards and made incremental so it finishes. The other gates move checks a reader depends on (the scaling gate, the packed tarball, the docs samples) in front of the publish, off until each library adopts them.

Proof

  • Red first, against the base: 31 failing tests in release-gates, github-release (--sbom), testing-standard-jobs (shards, cache, merge job, self-check) and quality-stubs (the shard contract), and the four new script suites failing to load their scripts.
  • Green: harness gate github-profile passed all 4 commands: actionlint 1.7.12 with shellcheck 0.11.0, node --test "test/*.test.mjs" (1359 pass, 0 fail), node scripts/check-no-emdash.mjs, node test/package-lists.mjs.
  • On this pull request every check passed: the required ones, CodeQL, and every self-check.yml job, among them weekly-mutation / shard (1) and shard (2), weekly-mutation-incremental (whose quality-artifacts proof requires both shards to say they started from the first run's cached results) and release-gates. Code scanning first raised three warnings in the new tests and the stub (an unanchored URL pattern, a check-then-read, an incomplete escape); a later commit answers them.
  • scripts/tarball-samples.mjs against a copy of the fixture found a real defect shape first: dist/ is in the fixture's .gitignore, so npm pack left it out and the sample failed with ERR_MODULE_NOT_FOUND. The fixture now declares files: ["dist"] and the sample passes.

Choices

  • Sharding contract by environment variables (COSYTE_MUTATE_SHARD, COSYTE_MUTATE_INCREMENTAL_FILE) that cosyte-mutate reads, not a wrapper config the workflow writes: a wrapper cannot follow a caller whose command runs cosyte-mutate in several directories or with --config. cosyte-mutate 0.1.4 does not read them yet, so each shard fails with a message naming that; the workflow side is proved against the stand-in. Undo: revert weekly-mutation.yml to the single job.
  • Per-shard caches with a run-unique key and a prefix restore, so each shard saves even when another fails. Undo: one key without the shard number.
  • Gates as steps of version, not a new job: no new check context in any caller, and no publish credential where they run. Undo: move the steps into a job release needs.
  • The three adoptable gates are off by default; the mutation gate is on and skips without the workflow file. The SBOM is on for every caller and is written from pnpm list --prod, offline, so a registry fault cannot hold a release. Undo: put the SBOM step behind an input.
  • No publint download at release time: the package checks reuse our own pre-publish layers and pnpm attw. Undo: add a pinned publint step.
  • version's bound moves from 30 to 60 minutes for the gates.

Kind: code

…e weekly run

The weekly mutation run is split across shard-count parallel jobs, each
keeping its StrykerJS incremental results in the Actions cache, and its
mutation job merges the shards into one report per package before the
floor is applied as before.

release.yml runs pre-publish gates in its version job: the latest weekly
mutation result (read through the API, never re-run), and, when a caller
turns them on, the benchmarks with the scaling gate, the package checks
on the packed tarball and the docs samples against the installed
tarball. Every GitHub release carries a CycloneDX SBOM. release.yml runs
no full mutation pass.
Comment thread test/mutation-gate.test.mjs Fixed
Comment thread test/release-gates.test.mjs Fixed
@NSchatz
NSchatz merged commit ab87aab into main Nov 10, 2026
44 checks passed
@NSchatz
NSchatz deleted the feat/release-proves branch November 10, 2026 17:14
NSchatz added a commit to cosyte/config that referenced this pull request Nov 10, 2026
…tal results (#206)

## What

`cosyte-mutate full` can now run in shards, with Stryker's incremental
results kept between runs.

- `COSYTE_MUTATE_SHARD=<k>/<n>`: the run lists every file the config's
`mutate` patterns name under the working directory (never inside
`node_modules`, `.git` or Stryker's temp directory; picked by the same
rule as `selectChangedFiles`), keeps the files whose 32-bit FNV-1a hash
of the path (relative, forward slashes) mod `n` is `k - 1`, runs Stryker
on just those, and writes `reports/mutation/shard-<k>-of-<n>.json` and
`.html` beside the full report, never `mutation.json`.
- A shard with no file writes the empty report at its shard path and
exits 0 without starting Stryker.
- A value that is not two whole numbers with `1 <= k <= n` exits 2
before anything is written or started.
- `COSYTE_MUTATE_INCREMENTAL_FILE=<file>` passes `incremental: true` and
that file (resolved against the working directory) as `incrementalFile`
to Stryker, sharded or not.
- With neither variable set (or both empty), a full run is exactly as
before: same options to Stryker, same output. A changed run reads
neither.
- New exports from `@cosyte/vitest-config/mutate`: `parseShard`,
`fnv1a32`, `filesInShard`, `expandMutate`, `shardReportPath`,
`SHARD_VARIABLE`, `INCREMENTAL_VARIABLE`; `runMutate` takes an optional
`env` (default `process.env`). Types in `mutate.d.ts`.
- Docs: the package README has a "A full run in shards" section, the
bin's `--help` names both variables, and the testing standard (decision
0004, section 4) describes the sharded full run. A patch changeset for
`@cosyte/vitest-config`.

## Why

The weekly full mutation run in cosyte/.github
(cosyte/.github#119) splits one full run across
parallel jobs and caches Stryker's incremental results, because an
unsharded run of a large library takes hours on a 4-core hosted runner
(hl7: 11,422 mutants). That workflow sets `COSYTE_MUTATE_SHARD` and
`COSYTE_MUTATE_INCREMENTAL_FILE`, collects
`*/reports/mutation/shard-<k>-of-<n>.json`, and merges them per package
with its `scripts/mutation-shards.mjs`, which refuses a missing shard or
a file in two shards. It was proved against a stand-in for this tool;
the released `cosyte-mutate` (0.1.4) does not read the variables, so
every shard fails. This makes the real tool match the stand-in's
contract: the same variable format and refusals, the same hash over the
same path form, the same report names.

## Proof

Red against the base: the new tests were added first and run against the
unchanged `mutate.js`: 16 failed, 27 passed (the passing ones are the
existing suite plus the "unset variables unchanged" and "a changed run
reads neither variable" guards, which must pass on both sides).

Green after the change: `packages/vitest-config` 50 tests pass, coverage
100% statements, branches, functions and lines. `cosyte-mutate changed
--base origin/main` then `cosyte-ratchet check --skip coverage --base
origin/main`: 297 mutants in `mutate.js`, none surviving in changed
code, ratchet OK.

The shard partition test (`filesInShard`, and again through `runMutate`
with a fake Stryker over a real fixture tree):

```ts
it("puts every file in exactly one shard, for 1, 2, 8 and 16 shards", () => {
  for (const n of [1, 2, 8, 16]) {
    const shards = shardsOf(MANY, n);
    const all = shards.flat();
    expect(all.length, `n=${n}`).toBe(MANY.length);
    expect(new Set(all).size, `n=${n}`).toBe(MANY.length);
    expect([...all].sort(), `n=${n}`).toEqual([...MANY].sort());
    shards.forEach((files, i) => {
      for (const file of files) expect(fnv1a32(file) % n, file).toBe(i);
    });
    ...
```

The stub-parity tests: the stand-in's `hashOf` and `sourceFiles` are
copied unchanged from `test/fixtures/quality-stubs/stubs.mjs` into the
test file, and

```ts
it("matches the stand-in's hash on the same paths, non-ASCII ones included", () => {
  ...
  for (const path of paths) expect(fnv1a32(path), path).toBe(stubHashOf(path));
});

it("picks each shard's files exactly as the stand-in does", () => {
  ...
  const ours = expandMutate({ cwd: d, patterns: ["src/**/*.mjs"] });
  expect(ours).toEqual(stubSourceFiles(d));
  for (const n of [1, 2, 3, 8, 16]) {
    for (let k = 1; k <= n; k += 1) {
      expect(filesInShard(ours, { k, n }), `${k}/${n}`).toEqual(
        stubSourceFiles(d).filter((file) => stubHashOf(file) % n === k - 1),
      );
    }
  }
});
```

Also covered: the FNV-1a reference values (`""`, `"a"`, `"foobar"`); 19
malformed shard values refused, and exit 2 through `runMutate` with
nothing written; the empty shard; the incremental options with and
without a shard; and one run of real StrykerJS over a fixture with `1/1`
and an incremental file, which writes `shard-1-of-1.json` and the
incremental file and no `mutation.json`.

The repository gate (`harness gate config`) passed before push.

## Choices

- **Shard value format.** Accepted exactly what the stand-in accepts
(`^\d+/\d+$`, then `1 <= k <= n`), so `08/16` is shard 8 of 16. Undo:
tighten the regular expression in `parseShard`.
- **Unset and empty are the same.** An empty `COSYTE_MUTATE_SHARD` or
`COSYTE_MUTATE_INCREMENTAL_FILE` is read as unset, as the stand-in does.
Undo: compare with `undefined` only.
- **Shard report beside the full report.** `shard-<k>-of-<n>.json` goes
in the directory of the config's `jsonReporter.fileName`, as
`changed.json` does, so a config run from the repository root that
reports under `packages/<name>/reports/mutation/` gets its shards there,
which is where the merge writes `mutation.json`. Undo: write under
`reports/mutation/` always in `shardReportPath`.
- **Also skip `.git`.** The listing skips `.git` besides `node_modules`
and the temp directory (from the config's `tempDirName`), and never
follows a symbolic link. Undo: drop the name check in `expandMutate`.
- **The incremental file is resolved against the working directory**
before it reaches Stryker, as the config file is. Undo: pass the value
as given.
- **`runMutate` reads `process.env` by default**, so a caller of the
entry point shards the same way the bin does; the suite passes `env: {}`
so a test run inside a sharded run is not affected.

Kind: code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants