Skip to content

Replay Nightly

Replay Nightly #165

name: Replay Nightly
# The device replay suites this workflow used to carry moved to replays-manual.yml (#1781 A1)
# after failing every scheduled run from 2026-07-24 on. What stays here is what a schedule can
# still keep honest: the device-free parser fuzz lane.
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
fuzz-iterations:
description: 'Parser fuzz cases per target'
required: false
default: '38000'
fuzz-seed:
description: 'Parser fuzz PRNG seed (defaults to the run number)'
required: false
permissions:
contents: read
actions: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Parser fuzz lane (#1414, validation targets #1781 B2): hostile input into parseArgs,
# selector parsing, .ad replay scripts, batch --steps JSON, and the Maestro compat parser,
# plus schema-derived CLI/Maestro validation cases that assert a planted outcome (specific
# error code, or acceptance) so a silent acceptance is a finding too. Every rejection must
# be a typed AppError with a non-empty hint, and no case hangs. Device-free, so it rides
# this nightly rather than owning a workflow; the seed varies per run so the lane keeps
# exploring, and anything it catches is appended to the checked-in corpus that the unit
# lane replays (scripts/fuzz/corpus/regressions.json).
#
# 38,000 cases/target holds the job's wall-clock at the pre-B2 five-target/50k budget now that
# seven targets share it: measured on a quiet host, 7x38k = 16.5s against 5x50k = 16.6-16.9s.
# Chosen by measurement rather than rounding — 33k would have been 2s cheaper but cost the
# five untouched targets a third of their depth for no wall-clock reason.
nightly-parser-fuzz:
name: Parser Fuzz Lane
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
# A lane whose classifier or watchdog regressed would pass forever. This runs the
# broken-on-purpose targets first and fails unless each violation is caught.
- name: Self-check the harness
uses: ./.github/actions/run-gate
with:
gate: fuzz-parsers
args: |
--self-check
--artifact-dir
.tmp/fuzz/self-check
# Runs even when the self-check fails, so the lane always produces a fuzz envelope too
# (a step that never runs writes no envelope, and monitoring cannot tell that apart from
# a lane that went dark). The job still fails because both steps report their status.
- name: Fuzz parsers
if: always()
env:
FUZZ_ITERATIONS: ${{ github.event.inputs.fuzz-iterations || '38000' }}
FUZZ_SEED: ${{ github.event.inputs.fuzz-seed || github.run_number }}
uses: ./.github/actions/run-gate
with:
gate: fuzz-parsers
args: |
--iterations
${{ env.FUZZ_ITERATIONS }}
--seed
${{ env.FUZZ_SEED }}
--artifact-dir
.tmp/fuzz/run
# Uploaded on pass as well as failure: each subdirectory of .tmp/fuzz holds a
# run-envelope.json on #1430's shared lane contract (scripts/lib/lane-envelope.ts) —
# commit/ref/run provenance, tool + config hash, seed, result — which is what freshness
# monitoring reads.
- name: Upload run envelopes and failing cases
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: parser-fuzz-run-${{ github.run_id }}-${{ github.run_attempt }}
path: .tmp/fuzz
if-no-files-found: warn
# Never fails the job on its own: a missing envelope is reported as such rather than
# masking the real failure with a `cat` error.
- name: Summarize
if: always()
run: |
{
echo '### Parser fuzz lane'
found=0
for envelope in .tmp/fuzz/*/run-envelope.json; do
[ -f "$envelope" ] || continue
found=1
echo "#### $envelope"
echo '```json'
cat "$envelope"
echo '```'
done
[ "$found" = 1 ] || echo 'No run envelope was produced — the lane failed before it could run.'
} >> "$GITHUB_STEP_SUMMARY"