The `mutation-pr`/`mutation-full` CI jobs were failing outright, and a
full `stryker run` never got past the initial dry run, so none of this
was actually usable yet.
- `mutation.yml`: pin all actions to a commit SHA, add a `permissions`
block, set `persist-credentials: false`, and pass the base ref/changed
files through `env` instead of interpolating them into the shell
script directly (zizmor was flagging all of these). Also switch to the
project's own `./.github/actions/setup-node` composite action instead
of `pnpm/action-setup`/`actions/setup-node` (which additionally
referenced a non-existent `.node-version` file), and fix the `pnpm
mutation`/`pnpm stryker run --mutate` calls to use the actual
`test:mutation`/`test:mutation:changed` script names.
- Drop the now-unused `@stryker-mutator/typescript-checker` dependency,
left over from when `stryker.config.json` still had `checkers:
["typescript"]`.
- `lib/util/timestamp.ts`: the local-timezone-offset fallback used in
`asTimestamp()` was cached in a module-level constant computed once at
import time. Combined with vitest's `threads` pool (used by Stryker's
vitest-runner) not reliably honouring a `process.env.TZ` set from our
own `test/setup.ts`, this made `asTimestamp()` parse plain dates (e.g.
Jenkins' `buildDate: 'Jan 02, 2020'`) using the host's real timezone
instead of UTC. Compute the offset per call instead, and set `TZ=UTC`
in the actual environment via the `test:mutation*` scripts, since that's
the one thing that reliably works across pools.
- `test/setup.ts`: restore real timers in a top-level `afterAll`, so a
spec that leaves fake timers running (there's at least one) can't leak
a frozen clock into whichever spec runs next in the same worker -
something that's only reachable under low-concurrency/single-threaded
execution.
- Raise `dryRunTimeoutMinutes` and the global vitest `testTimeout`: our
full suite's per-test coverage instrumentation run took ~25 minutes
end-to-end, well past Stryker's 5 minute default, and a couple of
CPU-heavy specs (WASM PGP crypto) timed out under that contention too.
- Ignore `.stryker-tmp/` (Stryker's sandbox dir, which can survive an
interrupted run) in ls-lint/oxlint/biome/prettier/vitest, since a
leftover sandbox copy of the repo was otherwise picked up as extra
source/test files and broke those tools outright.
Co-Authored-By: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
Sets each floor to the current measured coverage, rounded down to two
decimals:
statements 99.993944% -> 99.99
branches 98.703754% -> 98.70
functions 100% -> 100
lines 99.993880% -> 99.99
This leaves very little slack by design: one uncovered branch is
0.00386%, so the branches floor tolerates none.
Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
lib/config/decrypt/legacy.ts was removed in #39111, so
getCoverageIgnorePatterns() only ever excluded a path that no longer
exists, and the TEST_LEGACY_DECRYPTION workflow env var that gated it
was read by nothing else.
Coverage is unaffected: a full run reports the same numbers before and
after, so the thresholds stay as they are.
Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
ci: tighten statements and functions thresholds
Both metrics have headroom against the current measured coverage
(statements 99.9899%, functions 99.9864%), so the floors can move up
without depending on any other pull request landing first.
Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
Due to a long-standing issue with `semantic-release` not checking if
`#...` reference is for a Discussion or Issue, if a contributor says
`Closes #<discussion>`, then our release process fails.
We can extend our existing checks to try and catch this in the PR
itself, as users may not read the big warning that says "don't add a
Discussion here".
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
As per request in #45462, we can perform a prefactor to ensure that our
scripts for CI-related functionality are part of `tools`, so they're
linted appropriately.
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
While as a project, **??**
This can give us a slight sense of **??**, and find **??**.
Instead, we can use Stryker's mutation testing to **??**.
This isn't currently enforced in CI, but is used as a **??**.
Co-authored-by: Claude Opus 4.6 <jamie.tanna+github-copilot@mend.io>
Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
When running the Release job, we restore our `node_modules` cache via
`setup-node`. This leads to `semantic-release` executing - with
`contents: write`, `packages: write`, `id-token: write` and Docker Hub
credentials - the release process.
If we were to have a poisoned cache, this could lead to backdoored npm
packages and Docker images.
Although not currently exploitable, we should further harden this by:
- never using the cache for `release` jobs
- requiring a full `pnpm install --frozen-lockfile` on release jobs
- increasing the build time slightly on release jobs
This ensures that we don't leave ourselves vulnerable to cache poisoning
in the future.
It appears that zizmor's `cache-poisoning` audit covers this pattern,
but doesn't recognise a branch-push `semantic-release` job as a release
workflow, so won't flag regressions here.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
As per the previous commit, we currently don't lint our composite
actions, but should do.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
The Unified Agent scan passes `secrets.WS_ORG_TOKEN` as a `-apiKey`
CLI argument. Actions masks it in logs, but command-line arguments
are visible in the runner's process table to anything else running on
the machine.
As the Unified Agent reads any configuration parameter from a
`WS_`-prefixed environment variable, we can supply `WS_APIKEY` via
`env:` and drop the argument.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
To avoid potentially expanding a `discussion.created_at` event, we can
make sure we don't interpolate it, by injecting it in via `env`.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
To reduce risk of template injection, we can pass secrets and inputs as
an `env` input, which avoids shell expansion and hardens our usage.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
To make sure that we cover these steps explicitly, we can add a check
for them in `test-success`.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
As we're missing the `${{ }}`, we're passing in the literal string
`github.event_name ...` to `fail_ci_if_error` which may not evaluate as
expected.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
So we can run them both in parallel, speeding up the overall run time.
This may lead to a case where we run `build-docs` in the case a linting
failure is hit, but it also allows running them both in parallel which
could save ~1m of wasted waiting time.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
Like the vitest cache, the tsbuildinfo cache in `lint-other` can only
restore entries whose key contains the current
`hashFiles('pnpm-lock.yaml')` — its existing `restore-keys` prefix
still embeds the hash — so every lockfile change makes `type-check`
run a full, rather than incremental, `tsc`.
We can add a bare `tsbuildinfo-` fallback after the existing
same-hash prefix, restoring the newest cache from before the lockfile
bump. tsc records the version of every input file in the buildinfo
and recompiles whatever changed, so a stale restore is safe.
We intentionally leave the similarly-shaped prettier cache without a
cross-hash fallback: prettier can't detect plugin changes, so the
lockfile hash in its key is the only thing invalidating its cache
when a plugin updates — a cross-hash restore could wrongly skip
reformatting files.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
The vitest cache key in `test` embeds `hashFiles('pnpm-lock.yaml')`
and has no `restore-keys`, so every lockfile change starts each test
shard fully cold, re-transforming the whole codebase. As Renovate
updates its own lockfile near-daily, this is the common case rather
than the exception.
Because `restore-keys` performs prefix matching, it can only wildcard
the end of a key, so we first reorder the key to put
`matrix.cache-key` before the lockfile hash. The fallback then means
"same shard, any lockfile". vitest validates restored entries itself
(transforms are keyed by content hash), so a stale restore degrades
to a rebuild, never a wrong result.
Due to the reorder, the first run after this change won't match any
existing cache entry, so will start cold once.
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
As `lint-actions` doesn't currently need any access to `pnpm` artifacts,
we can run this check earlier than needing to have `setup-build` (and a
cache hit or miss).
Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io>
As a way to get better visibility for - predominantly - transient issues
GitHub are having, via [0] we can auto-label PRs, so they can then be
picked up by a maintainer.
We can use the `-slim` runners, as this doesn't need much compute, and
it reduces ours and GitHub's costs.
[0]: https://www.jvt.me/posts/2026/08/11/github-merge-queue-prs/
Co-authored-by: Sonnet 5 <claude-code@jamietanna.co.uk>