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
1 change: 1 addition & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ streetsidesoftware
prerelease
workflow_dispatch
checkout
endgroup
ncconfig
nvmrc

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This folder contains:
- **`codeql.yml`**: CodeQL (SAST) static analysis of the TypeScript source. Runs on PRs to `main`, on push to `main`, and weekly; findings surface in the repo Security tab.
- **`full-lockfile-audit.yml`**: PR/push + weekly + manual `pnpm audit --audit-level high` across the full lockfile (dev/build tooling included). PR failures are informational; default-branch failures manage labeled tracking issues.
- **`full-lockfile-audit-heartbeat.yml`**: daily + manual check that a scheduled, manual, or main-push full-lockfile audit has fired recently; opens, updates, or closes a tracking issue for transient cron lapses after an audit has been observed.
- **`mutation-testing.yml`**: weekly + manual Stryker mutation testing against high-value action-owned parsing, dispatcher, filesystem-boundary, and error-aggregation targets. Default-branch failures manage a labeled tracking issue and upload the mutation report artifact when available.
- **`mutation-testing.yml`**: weekly + manual batched per-file Stryker mutation testing for the files listed in `stryker.conf.json`. Default-branch failures manage a labeled tracking issue and upload the `reports/mutation` JSON artifact when available.
- **`release.yml`**: fires on three-component `vX.Y.Z` tags (a bare `v1` does **not** trigger it): full gate + GitHub Release + floats the major-version tag (`v1`, `v2`, …).
- **`daily-smoke.yml`**: 03:13 UTC cron: real-B2 end-to-end smoke against the test bucket.
- **`large-multipart-smoke.yml`**: weekly real-B2 multipart upload + download SHA-1 integrity check for a payload above B2's recommended part size.
Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ concurrency:
jobs:
test:
name: test (${{ matrix.os }})
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
Expand All @@ -38,7 +37,6 @@ jobs:

coverage:
name: coverage (>= 95% statements)
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
Expand All @@ -63,7 +61,6 @@ jobs:

lint:
name: lint
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
Expand All @@ -81,7 +78,6 @@ jobs:

audit:
name: dependency audit
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
Expand All @@ -103,7 +99,6 @@ jobs:

build-and-check-dist:
name: build + dist freshness
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
env:
# Bundle-size ceiling. Bump deliberately if we add genuinely needed deps;
Expand Down Expand Up @@ -144,7 +139,6 @@ jobs:

self-smoke:
name: action smoke test (offline)
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
needs: build-and-check-dist
steps:
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/docs-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ concurrency:
jobs:
sync-check:
name: action.yml <> README
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
Expand All @@ -52,7 +51,6 @@ jobs:

markdownlint:
name: markdownlint
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
Expand Down Expand Up @@ -83,7 +81,6 @@ jobs:

link-check:
name: Markdown link check
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
Expand Down Expand Up @@ -115,7 +112,6 @@ jobs:

spellcheck:
name: cspell
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/full-lockfile-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ concurrency:
jobs:
full-lockfile-audit:
name: full-lockfile dependency audit
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ concurrency:
jobs:
release-provenance-policy:
name: release provenance policy
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
Expand All @@ -35,7 +34,6 @@ jobs:

github-actions:
name: GitHub Actions security
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
Expand Down
70 changes: 47 additions & 23 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ pnpm lint:fix
pnpm typecheck # tsc --noEmit (strict + exactOptionalPropertyTypes)
pnpm test # vitest run: drives against the SDK's in-memory B2Simulator
pnpm test:coverage # same + the 95/85/100/95 coverage gate
pnpm test:mutation # Stryker mutation run against the Vitest suite
pnpm test:mutation # batched per-file Stryker mutation run + aggregate gate
pnpm test:mutation:single # raw Stryker run for focused local investigation
pnpm build # ncc build src/main.ts -o dist
pnpm run audit # pnpm audit --prod --audit-level high (CI gate; needs network)
pnpm spellcheck # cspell across src/, __tests__/, *.md, *.yml, action.yml
Expand Down Expand Up @@ -117,26 +118,46 @@ Interrupted local installs can leave a cache lock directory behind. The next run

## Mutation testing

`pnpm test:mutation` runs Stryker with the Vitest runner. It mutates the
action-owned parsing, dispatcher, filesystem-boundary, and error-aggregation
surfaces listed in the `mutate` array in
[`stryker.conf.json`](./stryker.conf.json).
`pnpm test:mutation` runs
[`scripts/run-batched-mutation.mjs`](./scripts/run-batched-mutation.mjs). The
wrapper runs Stryker once per file listed in the `mutate` array in
[`stryker.conf.json`](./stryker.conf.json), then aggregates the JSON reports and
applies the configured break threshold to the combined score. The per-file
invocation disables Stryker's own break exit so a below-threshold file does not
hide runner, config, or environment failures.

The current mutation scope is the explicit `mutate` list in
`stryker.conf.json`: action-owned `src/*.ts` support modules plus the command
implementations under `src/commands/*.ts` that are named there. Update that
single list when adding or removing mutation targets.

The mutation workflow is scheduled and manually dispatchable only; it is not a
per-PR gate while survivor triage is still being paid down. Reports are written
under `reports/mutation/` locally and uploaded as the `mutation-report`
artifact in CI. The workflow audits the full lockfile and rejects blocked
lookalike dependency names before installing the Stryker toolchain.
artifact in CI. The useful files are:

- `reports/mutation/by-file/*.json`: one Stryker JSON report per mutated file.
- `reports/mutation/aggregate.json`: the wrapper's combined score, threshold,
per-file rows, and status totals.
- `reports/mutation/mutation.json`: Stryker's shared JSON output path from the
last per-file run.

The workflow audits the full lockfile and rejects blocked lookalike dependency
names before installing the Stryker toolchain.

Stryker core and `@stryker-mutator/vitest-runner` are exact-pinned to the same
version because the runner plugin must stay in lockstep with core; the
Dependabot test-runner group updates them together. `stryker.conf.json` also
sets `vitest.related` to `false` so every mutant runs the full Vitest suite.
That is slower, but it avoids missing cross-file assertions in the shared
command fixtures and dispatcher tests while the mutation baseline is still
being triaged.

Initial baseline for this Stryker configuration:
Dependabot test-runner group updates them together. `pnpm test:mutation:single`
runs raw `stryker run` using the config reporters, which is useful for focused
local investigation. Do not use a full-scope raw run as the scheduled gate:
with this suite's `vi.resetModules()` + `vi.doMock()` + dynamic import pattern,
the Vitest runner can mis-attribute mutants when all files are instrumented in
one Stryker process. `stryker.conf.json` also sets `vitest.related` to `false`
so every mutant runs the full Vitest suite. That is slower, but it avoids
missing cross-file assertions in the shared command fixtures and dispatcher
tests while the mutation baseline is still being triaged.

Batched-runner baseline for the current mutation scope:

| Scope | Mutation score | Killed | Timed out | Survived | No coverage |
| --- | ---: | ---: | ---: | ---: | ---: |
Expand All @@ -157,14 +178,17 @@ Survivor triage from the baseline:
report should still be checked before adding disables because some survivors
can be equivalent mutants.

The configured mutation threshold is `break: 65`, with `low: 65` and
`high: 75`. That keeps the scheduled workflow passing the 72.83% baseline with
7.83 points of headroom while still failing on a material regression. Raise the
threshold only after survivors have been triaged and the baseline is re-run.
The headroom and scheduled-only cadence are an intentional bootstrap posture.
Once alerting has proven reliable, either tighten the break threshold toward
the baseline, or add a non-blocking PR information run so sub-threshold drift is
visible before the weekly cron.
The configured aggregate mutation threshold is `break: 65`, with `low: 65` and
`high: 75`. The batched wrapper enforces that threshold only when
`thresholds.break` is a number; setting it to `null` disables the aggregate
failure, matching Stryker's disabled-break semantics. The current 65% break gate
keeps the scheduled workflow passing the 72.83% baseline with 7.83 points of
headroom while still failing on a material regression. Raise the threshold only
after survivors have been triaged and the baseline is re-run. The headroom and
scheduled-only cadence are an intentional bootstrap posture. Once alerting has
proven reliable, either tighten the break threshold toward the baseline, or add
a non-blocking PR information run so sub-threshold drift is visible before the
weekly cron.

Default-branch scheduled and manual failures open or update one
`mutation-testing-failure` tracking issue through
Expand Down Expand Up @@ -212,7 +236,7 @@ listed in the same table and called out explicitly.
| `test` (matrix: ubuntu/macos/windows) | typecheck + vitest unit suite |
| `lint` | biome `--error-on-warnings` |
| `coverage` | vitest with v8 coverage, threshold 95 % statements / 85 % branches / 100 % functions / 95 % lines |
| `mutation-testing` ([mutation-testing.yml](./.github/workflows/mutation-testing.yml)) | Stryker mutation testing against the Vitest suite for high-value parsing, dispatcher, filesystem-boundary, and error-aggregation targets. Runs weekly and manually; it uploads the HTML/JSON report artifact, opens or updates a tracking issue on default-branch failure, and fails if the mutation score drops below the configured break threshold (65%). |
| `mutation-testing` ([mutation-testing.yml](./.github/workflows/mutation-testing.yml)) | Batched per-file Stryker mutation testing against the configured `stryker.conf.json` mutation scope. Runs weekly and manually; it uploads the JSON report artifact, opens or updates a tracking issue on default-branch failure, and fails if the aggregate mutation score drops below the configured break threshold (65%). |
| `build-and-check-dist` | ncc build, then `git diff --exit-code dist/`. **Drift fails CI**: rebuild with `pnpm build` and commit `dist/`. Bundle size is gated hard at 4 MiB. |
| `release-provenance-policy` ([security.yml](./.github/workflows/security.yml)) | parses release workflow YAML and enforces OIDC/attestation isolation, validated-SHA checkouts, tag re-verification, staged release asset upload, and post-upload verification. |
| `github-actions` ([security.yml](./.github/workflows/security.yml)) | runs the shared GitHub Actions security composite action against every workflow, including actionlint, third-party action pin checks, and zizmor audits (see [Pinning third-party actions](#pinning-third-party-actions)) |
Expand Down
115 changes: 115 additions & 0 deletions __tests__/commands/command-logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { headCommand } from '../../src/commands/head.ts'
import { hideCommand } from '../../src/commands/hide.ts'
import { unhideCommand } from '../../src/commands/unhide.ts'
import { tryStat } from '../../src/fs.ts'
import { captureStdout, makeFixture, makeInputs, seedFile, type TestFixture } from '../_helpers.ts'

// These tests pin the user-visible log surface (group labels + info lines) and
// the missing-source error wording for the small command wrappers, plus the
// tryStat error-swallowing contract. They exist to keep mutation coverage on
// code that is otherwise only exercised for its return value.
describe('head/hide/unhide log + error surface', () => {
let fx: TestFixture

beforeEach(async () => {
fx = await makeFixture('gh-action-logs')
})

afterEach(async () => {
await rm(fx.workDir, { recursive: true, force: true })
})

it('head: groups the probe and logs size/type/sha1', async () => {
await seedFile(fx, 'h.txt', 'head-me')
let result: Awaited<ReturnType<typeof headCommand>> | undefined
const out = await captureStdout(async () => {
result = await headCommand(fx.bucket, makeInputs('head', fx, { source: 'h.txt' }))
})
expect(result?.contentSha1).toBeTruthy()
expect(out).toContain('::group::head b2://gh-action-logs/h.txt')
expect(out).toContain(
`size=${result?.size} type=${result?.contentType} sha1=${result?.contentSha1}`,
)
expect(out).toContain('::endgroup::')
})

it('head: requires a source and names the action in the error', async () => {
await expect(headCommand(fx.bucket, makeInputs('head', fx))).rejects.toThrow(
"'source' input is required for 'head' action (the B2 file name)",
)
})

it('hide: groups the call and logs the created marker', async () => {
await seedFile(fx, 'g.txt', 'hide-me')
let result: Awaited<ReturnType<typeof hideCommand>> | undefined
const out = await captureStdout(async () => {
result = await hideCommand(fx.bucket, makeInputs('hide', fx, { source: 'g.txt' }))
})
expect(out).toContain('::group::hide b2://gh-action-logs/g.txt')
expect(out).toContain(`hidden: g.txt (marker fileId=${result?.fileId})`)
expect(out).toContain('::endgroup::')
})

it('hide: requires a source and names the action in the error', async () => {
await expect(hideCommand(fx.bucket, makeInputs('hide', fx))).rejects.toThrow(
"'source' input is required for 'hide' action (the B2 file name)",
)
})

it('unhide: logs the removed marker when one exists', async () => {
await seedFile(fx, 'u.txt', 'unhide-me')
await fx.bucket.hideFile('u.txt')
let result: Awaited<ReturnType<typeof unhideCommand>> | undefined
const out = await captureStdout(async () => {
result = await unhideCommand(fx.bucket, makeInputs('unhide', fx, { source: 'u.txt' }))
})
expect(result?.removedMarkerFileId).toEqual(expect.any(String))
expect(out).toContain('::group::unhide b2://gh-action-logs/u.txt')
expect(out).toContain(
`removed hide marker fileId=${result?.removedMarkerFileId}, u.txt is now visible`,
)
})

it('unhide: reports a no-op when there is no hide marker', async () => {
await seedFile(fx, 'v.txt', 'visible')
let result: Awaited<ReturnType<typeof unhideCommand>> | undefined
const out = await captureStdout(async () => {
result = await unhideCommand(fx.bucket, makeInputs('unhide', fx, { source: 'v.txt' }))
})
expect(result?.removedMarkerFileId).toBeNull()
expect(out).toContain('no hide marker found for v.txt (already visible or non-existent)')
})

it('unhide: requires a source and names the action in the error', async () => {
await expect(unhideCommand(fx.bucket, makeInputs('unhide', fx))).rejects.toThrow(
"'source' input is required for 'unhide' action (the B2 file name)",
)
})
})

describe('tryStat', () => {
let fx: TestFixture

beforeEach(async () => {
fx = await makeFixture('gh-action-try-stat')
})

afterEach(async () => {
await rm(fx.workDir, { recursive: true, force: true })
})

it('returns Stats for an existing path', async () => {
const p = join(fx.workDir, 'present.txt')
await writeFile(p, 'x')
const s = await tryStat(p)
expect(s?.isFile()).toBe(true)
})

it('returns undefined instead of throwing for a missing path', async () => {
const s = await tryStat(join(fx.workDir, 'does', 'not', 'exist.txt'))
expect(s).toBeUndefined()
})
})
Loading
Loading