You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implementation plan, ready for development. Every line anchor below was read at commit cb073574 (2026-09-18). Re-verify an anchor with grep -n before editing if main has moved.
Problem
webjs ci --signoff posts exactly ONE commit status (signoff) after a green local run, and nothing at all after a red one. So on a GitHub PR a locally gated change shows a single row in the status list of the merge box, with no indication of which layers ran, and a failed local run is invisible on the PR (the row is simply absent, which reads the same as "nobody ran it").
This is a feature of the published CLI, so it applies to every end-user WebJs app, not only this monorepo. A scaffolded app declares Setup plus a Checks group (Conventions, Health, Types, Security: dependency audit, Tests) in webjs.ci.steps (packages/cli/lib/create.js L586 to L612), and an app owner who adopts the local-CI gate should see those as separate rows on their PR, green or red, the way separate Actions jobs would appear.
There is a second defect in the same block. webjs ci --only Types --signoff posts the full roll-up signoff after a green PARTIAL run, because the bin only checks result.ok (packages/cli/bin/webjs.js L881 to L888) and never looks at the parsed only array (L788 to L797). One green layer can therefore mark a PR mergeable.
Verified against the code at cb073574, with corrections to the body this plan replaces.
The signoff block sits at packages/cli/bin/webjs.js L876 to L892, the --json document at L894 to L911, the exit code at L915, the flag parsing at L785 to L797, the runCi call at L846, the USAGE banner at L93 to L97, and the HELP.ci entry at L182 to L200. All as stated before.
StepResult.group holds the IMMEDIATE parent title only, never a path (runSequence passes node.title at ci-runner.js L257, runPool passes group.title at L302 and a nested group's own title at L289). A leaf under Gate then Browser suites then In-repo app tests reports group: "In-repo app tests", so group cannot map a leaf back to its depth-2 ancestor.
Steps spawn with shell: true at ci-runner.js L418 and L443, as stated.
CORRECTION. The CLI end-to-end test is test/cli/ci.test.mjs at the REPO ROOT, not packages/cli/test/cli/ci.test.mjs (that path does not exist). It has no --signoff behaviour coverage at all today and no fake gh. Its only mention is the help check at L219.
CORRECTION. There are no scaffold copies of the skill under packages/cli/templates/.agents/skills/webjs/. That directory does not exist in the repo. The skill lives once at the repo root .agents/skills/webjs/ and scripts/sync-scaffold-skill.mjs copies it at prepack (packages/cli/lib/create.js L766 to L775 falls back to the repo-root copy in a checkout). Editing the root copy is the whole job.
CORRECTION. gh signoff fail applies NO clean-tree guard (extension source L1410 to L1415, "A red status is a warning, not an attestation, so no cleanliness check applies"). Only create does (L1343 to L1351, is_clean || fail "$UNCLEAN_REASON", which prints Error: repository has uncommitted changes or Error: repository has unpushed changes on stderr and exits 1). So on a dirty or unpushed tree a red run still posts its red rows, and the green rows are refused with a warning.
CORRECTION. A PR lists commit statuses in the merge box status list. The Checks TAB lists check runs only, so the docs and the acceptance criteria say "status list" and never "Checks tab".
gh signoff fail exists only since gh-signoff 0.4.0 (2026-08-21, CHANGELOG.md in the extension directory), and so does the -- end-of-options marker. The installed version here is gh-signoff 0.4.1.
Design / approach
Decided plan, no open options.
Mechanism. GitHub renders one row per distinct commit-status context. gh signoff (basecamp/gh-signoff, already the --signoff dependency) posts named contexts. gh signoff create -- <name>... posts a green signoff/<name> per name, and gh signoff fail --description <text> -- <name> posts a red one. Rich check runs (logs, annotations) are NOT an option, because the Check Runs API only accepts a GitHub App token and a local gh OAuth token is not one.
What --signoff does after this change.
After the run, post one status per ROW (granularity below). Red rows first, one gh signoff fail call PER red row, then every green row in ONE gh signoff create call.
Post the roll-up signoff (bare gh signoff, through the runner exactly as today) only when the WHOLE list ran and was green. It stays the one context gh signoff install requires.
A red run posts red rows for what failed and green rows for what passed, then prints the existing do-not-merge heading. No roll-up.
--only <title> --signoff posts rows for what ran and NEVER the roll-up, which closes the hole described above.
An INTERRUPTED run (Ctrl-C) posts nothing at all and says so. An interrupt is not a verdict, the user asked the process to stop, and a step that failed only because the teardown killed something it depended on would otherwise land as a red row.
Row granularity (the depth-2 rule). A top-level plain step is one row. A top-level group contributes one row per DIRECT child, and a child that is itself a group rolls up into a single row. Nothing deeper gets a row. Per-top-level-group is too coarse for the scaffold (it would yield only Setup and Checks), and per-leaf is too noisy for this monorepo (the Bun runtime smoke + test matrix group alone has 32 leaves). This matches how hosted CI draws the same line: GitHub Actions and CircleCI post one status per JOB, not per workflow and not per shell command.
Depth is measured against the FULL declared tree (cfg.steps), never against the --only selection. selectSteps (ci-config.js L172 to L195) returns a FLAT list of the matched nodes, so --only Conventions turns a depth-2 child into a top-level node of the selection. Measuring there would rename or regroup rows between a whole run and a partial run. Measured against the full tree, signoff/Conventions is the same context either way.
Row verdict. For a row, take the leaves under it (the row itself for a plain step, flattenSteps([group]) for a rolled-up group) and look up each leaf's result.
RED when any leaf that ran failed and was not interrupted.
GREEN only when EVERY leaf under the row ran and passed.
Otherwise NO row (nothing ran, only part of it ran green under --fail-fast or --only, or the only non-green leaves were interrupted).
Mapping a result to its row uses object identity, not titles.selectSteps pushes the SAME node objects it was given (verified: selectSteps(tree, ['tests']).steps[0] === tree[1].steps[4] is true), so a leaf object is the same in cfg.steps and in the selection the runner executes. The runner gains one injectable hook, onResult(step, result), called from runOne, and the bin fills a Map<CiStep, StepResult> from it. Title matching was rejected: duplicate titles are legal today (readCiConfig has no duplicate check, exact or case-folded), group is only the immediate parent, and a leaf titled the same under two different rows would redden both. The hook leaves StepResult and the --json document untouched.
Context name. The row's title verbatim, so the context is signoff/<title>. gh-signoff accepts spaces and punctuation and refuses only an empty name, a double quote, a backslash, or a C0 control character (require_context_name at extension L634 to L638, require_json_safe at L608 to L616, whose pattern is *[\"\\]* | *[$'\x01'-$'\x1f']*). The JS twin is /["\\\u0000-\u001f]/. A title matching it is skipped for the row with a warning, never a crash. The reader already trims titles and refuses empty ones.
Two rows that fold to one context are MERGED, with a warning. GitHub compares contexts case-insensitively (gh-signoff CHANGELOG.md, 0.4.0, "Context identity is case-insensitive, matching how GitHub compares status check contexts"), so Tests and tests are one context. The fold key is title.trim().toLowerCase(), the same fold selectSteps uses for --only. The rows merge into one row carrying the FIRST declared spelling and the union of the leaves, and the ordinary verdict rule applies. Skipping the later row was rejected because it can post a false green: Tests green plus tests red would show a green signoff/Tests. Merging needs no new semantics, since "green only when every leaf under the row is green" is already the roll-up rule.
Exact gh invocations, each settled from the extension source.
Green rows: gh signoff create -- <title1> <title2> ..., ONE call. cmd_create loops over every context (L1370 to L1400). The explicit create subcommand is mandatory, because the top-level dispatch (L2634 to L2668) treats a first positional of create, fail, install, uninstall, check, contexts, status, version, or completion as a COMMAND, so a step titled status passed bare would run gh signoff status and a step titled install would edit the repository ruleset. The -- is mandatory too, because cmd_create fails on any -* argument (L1321 to L1323) unless it follows -- (L1311 to L1320), and a title may begin with a hyphen.
Red rows: gh signoff fail --description "<text>" -- <title>, one call PER red row. cmd_fail takes ONE --description for the whole call (L1429 to L1435) and applies it to every context in its loop (L1496 to L1510), so a single call could not give each row its own text. The description is <leaf title> failed (exit <code or signal>), the runner's own wording (ci-runner.js L161, L354, L407), with (+N more) appended when several leaves under one row failed, cut to 140 characters (gh-signoff cuts at 140 too, L1484 to L1487, and GitHub rejects longer).
Never -f. Never --commit (with --commit the clean-tree check is replaced by an is-on-a-remote check, L1343 to L1351, which would let a dirty worktree attest green).
No version probe. On gh-signoff 0.3.0 (read with git show 444e9e0^:gh-signoff in the extension clone) both calls die in ARGUMENT PARSING, before any POST: create -- X hits fail "unknown option: --", and fail --description ... falls to the implicit create, collects fail as a context, then hits fail "unknown option: --description". Nothing is posted and the exit is 1, so an old extension degrades to a warning. The warning appends an upgrade hint when stderr holds unknown option.
Argv spawn, never a shell string.runCi spawns with shell: true, and real titles contain spaces, parentheses, #, +, and colons (E2E (Puppeteer against the blog example), Postgres prod-engine round-trip (#563), Tests: node + browser). The row posts spawn gh with an argv array and no shell option. Measured on Node 24 and Bun 1.3.14 with a fake gh first on PATH: a title of E2E (Puppeteer) #1 + $(touch marker) arrived as one argv entry and the marker file was never created. A missing gh surfaces on BOTH runtimes as an error event with code: 'ENOENT' (no synchronous throw on either), which the poster turns into one warning and stops.
Failure policy. The roll-up keeps today's contract: a missing gh or a refused signoff is a failed step and a non-zero exit. The per-row posts are best-effort: a failure to post prints a warning and does not change the exit code, because the rows are informational and the roll-up is the gate. There is no timeout on a row call, matching the roll-up, which has none today.
Required vs informational. Only the roll-up signoff is required. The rows stay informational, because requiring each by name means renaming a step in webjs.ci.steps blocks every PR until the ruleset is updated. The docs say an app CAN require a row with gh signoff install "<title>" and name that rename hazard.
Where the code lives. A new sibling module packages/cli/lib/ci-signoff.js. ci-runner.js is 499 lines, so size does not force this. Responsibility does: ci-runner.js owns executing a step tree, and "which statuses a finished run reports to GitHub" is a second responsibility with its own external dependency. The bin (webjs.js, 1875 lines, the orchestration entry) gains only wiring.
Prior art read.
~/Documents/Projects/frameworks/rails/activesupport/lib/active_support/continuous_integration.rb L18 to L21 and L56 to L59, and railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt L36 to L38. Rails models signoff as one more step "Signoff: ...", "gh signoff" inside if success?, a single roll-up. It has no per-step rows, so this feature is a superset and the roll-up wording here stays Rails' own.
~/.local/share/gh/extensions/gh-signoff/README.md L154 to L215, "Advanced usage: Partial signoff", which names this exact use ("use partial signoff to reflect each CI step") and shows gh signoff status listing every context.
~/.local/share/gh/extensions/gh-signoff/gh-signoff (0.4.1) and its CHANGELOG.md, line anchors above.
Alternatives rejected. Posting through gh api repos/:owner/:repo/statuses/<sha> directly (it would bypass the clean-tree attestation that makes a green status trustworthy, and would duplicate gh-signoff). Slugifying titles into contexts (the verbatim title is the decided name, and gh-signoff accepts it). One fail call for all red rows (one shared description). Title-based result matching (ambiguous, see above). A version probe (an extra spawn for a failure that is already safe). Pinning --commit (changes what green attests). pending rows (gh-signoff has no pending verb).
Implementation plan
Work in a dedicated worktree (git worktree add -b feat/ci-signoff-rows ../webjs-ci-signoff-rows origin/main, then npm run worktree:link). NEVER run an install while the node_modules symlink stands (#1442). Exercise the branch's CLI by path (node packages/cli/bin/webjs.js ci ...), because the hoisted webjs bin in a linked worktree resolves into the PRIMARY checkout. packages/ is plain .js with JSDoc, never .ts. No new dependency, no new flag, no new config key.
Step 1. Add the onResult hook to the runner
File packages/cli/lib/ci-runner.js.
In the runCi JSDoc options block (L177 to L189) add one line after closeGraceMs?: number,:
In the ctx literal (L193 to L213) add after closeGraceMs: opts.closeGraceMs ?? CLOSE_GRACE_MS,:
/** Called once per finished step with the NODE it ran, so a caller can key results by identity. */onResult: typeofopts.onResult==='function' ? opts.onResult : null,
Also extend the module header comment (L40 to L44) with one sentence saying onResult hands back the node each result belongs to, which is what ci-signoff.js keys rows on. Nothing else in the runner changes, and StepResult keeps its shape.
Step 2. New module packages/cli/lib/ci-signoff.js
The code below was run as a prototype against the real ci-config.js on Node 24 and Bun 1.3.14 with a fake gh, and produced the row lists in the Tests section. Match the file's comment density to ci-runner.js (a header comment stating what the module owns and why the posts are argv spawns).
import{spawnasnodeSpawn}from'node:child_process';import{flattenSteps}from'./ci-config.js';import{colorize}from'./ci-runner.js';/** * @typedef {import('./ci-config.js').CiNode} CiNode * @typedef {import('./ci-config.js').CiStep} CiStep * @typedef {import('./ci-runner.js').StepResult} StepResult * @typedef {{ title: string, description: string }} SignoffRedRow * @typedef {{ green: string[], red: SignoffRedRow[], skipped: string[], warnings: string[] }} SignoffRows * @typedef {{ rollup: boolean, green: string[], red: SignoffRedRow[], skipped: string[], warnings: string[] }} SignoffReport *//** What gh-signoff's `require_context_name` refuses: `"`, `\`, and the C0 controls. */constUNSAFE_CONTEXT=/["\\\u0000-\u001f]/;/** GitHub's cap on a commit-status description. */constDESCRIPTION_MAX=140;/** * The rows a finished run reports, by the depth-2 rule, measured against the * FULL declared tree so a row keeps one context name under `--only`. * * @param {CiNode[]} tree the full `cfg.steps`, NOT the `--only` selection * @param {Map<CiStep, StepResult>} resultFor filled from `runCi`'s `onResult` * @returns {SignoffRows} */exportfunctionsignoffRows(tree,resultFor){/** @type {Map<string, { title: string, leaves: CiStep[] }>} */constbyContext=newMap();constwarnings=[];constadd=(node)=>{constleaves=node.kind==='group' ? flattenSteps([node]) : [node];constkey=node.title.trim().toLowerCase();constseen=byContext.get(key);if(!seen){byContext.set(key,{title: node.title,leaves: [...leaves]});return;}seen.leaves.push(...leaves);warnings.push(`rows "${seen.title}" and "${node.title}" share one status context (GitHub compares contexts case-insensitively), so they post as one row named "${seen.title}"`,);};for(constnodeoftree){if(node.kind==='group')node.steps.forEach(add);elseadd(node);}constgreen=[];constred=[];constskipped=[];for(constrowofbyContext.values()){constran=row.leaves.map((leaf)=>resultFor.get(leaf)).filter(Boolean);constfailed=ran.filter((r)=>!r.ok&&!r.interrupted);constallGreen=ran.length===row.leaves.length&&ran.every((r)=>r.ok);if(failed.length===0&&!allGreen)continue;if(UNSAFE_CONTEXT.test(row.title)){skipped.push(row.title);warnings.push(`row ${JSON.stringify(row.title)} was not posted: gh signoff refuses a context name holding a double quote, a backslash, or a control character`,);continue;}if(failed.length===0){green.push(row.title);continue;}constfirst=failed[0];constmore=failed.length>1 ? ` (+${failed.length-1} more)` : '';constdescription=`${first.title} failed (exit ${first.code??first.signal})${more}`;red.push({title: row.title,description: [...description].slice(0,DESCRIPTION_MAX).join('')});}return{ green, red, skipped, warnings };}/** * Post the rows through `gh signoff`. ARGV spawns with no shell, because a * title is arbitrary text (`E2E (Puppeteer against the blog example)`). Red * rows first, one `fail` call each (the extension takes one --description per * call), then every green row in one `create` call. Best-effort: a failure is a * warning, never a throw, and a missing `gh` stops after one warning. * * @param {SignoffRows} rows * @param {string} cwd * @param {{ spawn?: typeof nodeSpawn, env?: NodeJS.ProcessEnv }} [opts] * @returns {Promise<{ posted: { green: string[], red: string[] }, warnings: string[] }>} */exportasyncfunctionpostSignoffRows(rows,cwd,opts={}){constspawn=opts.spawn||nodeSpawn;constenv=opts.env||process.env;constposted={green: [],red: []};constwarnings=[];constcalls=[
...rows.red.map((row)=>({kind: 'red',titles: [row.title],// `fail` and `create` are spelled out and `--` ends the options, so a row// titled `status` or `-qa` is a context and never a command or a flag.argv: ['signoff','fail','--description',row.description,'--',row.title],})),
...(rows.green.length>0
? [{kind: 'green',titles: rows.green,argv: ['signoff','create','--', ...rows.green]}]
: []),];for(constcallofcalls){constr=awaitrunGh(spawn,call.argv,cwd,env);if(r.code===0){posted[call.kind].push(...call.titles);continue;}if(r.missing){warnings.push('could not post the signoff rows: `gh` is not on PATH (install the GitHub CLI, then `gh extension install basecamp/gh-signoff`)');break;}constwhat=call.kind==='red' ? `the red row "${call.titles[0]}"` : `${call.titles.length} green row(s)`;consthint=/unknownoption/.test(r.stderr)
? ' (per-row statuses need gh-signoff 0.4.0 or newer: gh extension upgrade signoff)'
: '';warnings.push(`could not post ${what}: ${firstLine(r.stderr)||`gh exited ${r.code}`}${hint}`);}return{ posted, warnings };}/** @returns {Promise<{ code: number, stderr: string, missing: boolean }>} */functionrunGh(spawn,argv,cwd,env){returnnewPromise((resolve)=>{constfailure=(e)=>({code: 1,stderr: String(e&&e.message ? e.message : e),missing: !!e&&e.code==='ENOENT'});letchild;try{child=spawn('gh',argv,{ cwd, env,stdio: ['ignore','pipe','pipe']});}catch(e){resolve(failure(e));return;}letstderr='';letsettled=false;constfinish=(r)=>{if(!settled){settled=true;resolve(r);}};child.stdout?.on('data',()=>{});child.stderr?.on('data',(d)=>{stderr+=d;});child.on('error',(e)=>finish(failure(e)));child.on('close',(code)=>finish({code: code??1, stderr,missing: false}));});}functionfirstLine(s){returnString(s).split('\n').map((l)=>l.trim()).find(Boolean)||'';}/** * The human report of what was posted. * @param {SignoffReport} report * @param {boolean} color */exportfunctionformatSignoffRows(report,color){letout=`\n\n${colorize('Signoff rows','title',color)}\n${colorize('One commit status per row, posted through gh signoff','subtitle',color)}\n`;for(constrowofreport.red)out+=`${colorize(` ❌ signoff/${row.title} (${row.description})`,'error',color)}\n`;for(consttitleofreport.green)out+=`${colorize(` ✅ signoff/${title}`,'success',color)}\n`;if(report.red.length===0&&report.green.length===0)out+=' (no row was posted)\n';for(constwofreport.warnings)out+=`${colorize(` warning: ${w}`,'error',color)}\n`;returnout;}
stdout is drained and discarded on purpose. Under --json the bin's stdout carries exactly one document, and the row report is WebJs's own text written through out.
Step 3. Wire the bin
File packages/cli/bin/webjs.js, case 'ci'.
3a. Collect results by node. Today (L846 to L853):
// Results keyed by the NODE that produced them. selectSteps hands back the// same objects cfg.steps holds, so the signoff rows can be read off the// FULL tree even under --only.constresultFor=newMap();construn=runCi(selected.steps,cwd,{write: out,
isTTY,
color,
failFast,captureAll: json,actions: !!process.env.GITHUB_ACTIONS,onResult: (step,r)=>{resultFor.set(step,r);},});
3b. Replace the signoff block. Today (L876 to L892):
// The Rails signoff step, opt-in. A green run posts a green commit status// (`gh signoff`, which branch protection can require); a red run says so// and posts nothing. It goes through the same runner so it reads as one// more step, and a missing `gh` is a failed step, never a silent skip.letsignoffOk=true;if(signoff){if(result.ok){constso=runCi([{kind: 'step',title: 'Signoff: All systems go. Ready for merge and deploy.',run: 'gh signoff',env: {}}],cwd,{write: out, isTTY, color,captureAll: json},);signoffOk=(awaitso.done).ok;}else{out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.','error',color)}\n${colorize('Fix the issues and try again.','subtitle',color)}\n`);}}
After:
// The Rails signoff step, opt-in (#1481). Every ROW gets its own commit// status, green or red (`signoff/<title>`, best-effort, informational),// and the roll-up `signoff`, the one status branch rules require, is// posted only when the WHOLE list ran green: never under --only, which// would let one green layer mark a PR mergeable. The roll-up goes through// the runner so it reads as one more step, and a missing `gh` there is a// failed step, never a silent skip. An interrupted run posts nothing.letsignoffOk=true;/** @type {import('../lib/ci-signoff.js').SignoffReport | null} */letsignoffReport=null;if(signoff){signoffReport={rollup: false,green: [],red: [],skipped: [],warnings: []};if(result.interrupted){out(`\n\n${colorize('Signoff: the run was interrupted. Nothing was posted.','error',color)}\n`);}else{const{ signoffRows, postSignoffRows, formatSignoffRows }=awaitimport('../lib/ci-signoff.js');constrows=signoffRows(cfg.steps,resultFor);constsent=awaitpostSignoffRows(rows,cwd);signoffReport.green=sent.posted.green;signoffReport.red=rows.red.filter((row)=>sent.posted.red.includes(row.title));signoffReport.skipped=rows.skipped;signoffReport.warnings=[...rows.warnings, ...sent.warnings];out(formatSignoffRows(signoffReport,color));if(!result.ok){out(`\n\n${colorize('Signoff: CI failed. Do not merge or deploy.','error',color)}\n${colorize('Fix the issues and try again.','subtitle',color)}\n`);}elseif(only.length>0){out(`\n\n${colorize('Signoff: a partial run (--only) never posts the roll-up.','subtitle',color)}\n${colorize('Run the whole list with --signoff to post `signoff`.','subtitle',color)}\n`);}else{constso=runCi([{kind: 'step',title: 'Signoff: All systems go. Ready for merge and deploy.',run: 'gh signoff',env: {}}],cwd,{write: out, isTTY, color,captureAll: json},);signoffOk=(awaitso.done).ok;signoffReport.rollup=signoffOk;}}}
3c. The --json document. Today the object literal ends with the steps array (L899 to L909). Add one spread after it so the key exists ONLY when --signoff was passed:
The exit-code line (L915) does not change. signoffOk is only ever set by the roll-up, which is what keeps the row posts out of the exit code.
3d. USAGE banner. Today (L97):
--only runs one step or group by title; --signoff runs "gh signoff" after a green run
After (two lines, same indent):
--only runs one step or group by title. --signoff posts a "signoff/<title>" commit status per
step row (green or red) via gh signoff, plus the roll-up "signoff" after a FULL green run
3e. HELP.ci. Replace the --json and --signoff option descriptions (L191 to L192) and add one notes entry (after L197, the last entry today):
{flag: '--json',description: 'Emit one JSON document on stdout ({ ok, seconds, steps[] }, failed steps carry their output, plus signoff: { rollup, green, red, skipped, warnings } when --signoff is passed); the human output goes to stderr.'},{flag: '--signoff',description: 'Post commit statuses through `gh signoff` (basecamp/gh-signoff 0.4.0+): one `signoff/<title>` row, green or red, per top-level step and per direct child of a top-level group, then the roll-up `signoff` only when the WHOLE list ran green (never with --only). A red run prints the do-not-merge heading.'},
'Signoff rows are informational and best-effort (a row that fails to post is a warning, the exit code is unchanged). The roll-up `signoff` is the status to require (`gh signoff install`).',
Add 'webjs ci --only Tests --signoff' to examples.
Step 4. Commit plan (three logical units, push after each)
The hooks match packages/cli/lib/ (NOT packages/cli/bin/): require-docs-with-src.sh L59 and require-tests-with-src.sh L59 both BLOCK a commit that stages packages/cli/lib/** without a doc surface and a test. require-bun-parity-with-runtime-src.sh L60 to L63 will NOT fire (no file name here matches its keyword list), but the Bun proof below is still required by the workflow rules.
feat(cli): compute and post per-row signoff statuses. lib/ci-runner.js, lib/ci-signoff.js, packages/cli/test/ci-signoff/ci-signoff.test.mjs, the onResult test in packages/cli/test/ci-runner/ci-runner.test.mjs, test/bun/ci-signoff.mjs plus its wrapper, and the packages/cli/AGENTS.md module-map entry (the doc surface the hook wants).
feat(cli): post one signoff row per step from webjs ci --signoff. bin/webjs.js, test/cli/ci.test.mjs, the scaffold and repo-health assertions.
docs: describe the per-row signoff statuses. Every surface in the Docs section.
The PR title must be feat: prefixed (it feeds the generated changelog), the body carries Closes #1481, and no commit carries an AI-attribution trailer.
Tests
Expected row lists (measured by running the prototype helper against the real trees at cb073574).
Scaffold (packages/cli/lib/create.js L586 to L612), 6 rows:
Setup: blog database
Setup: gallery database
Setup: core dist
E2E (Puppeteer against the blog example)
Bun runtime smoke + test matrix
E2E (blog served on Bun)
Browser suites (one web-test-runner at a time)
Unit + integration (node --test)
Conventions
Postgres prod-engine round-trip (#563)
Docker image build (the deploy artifact)
Partial runs on the scaffold tree: --only Tests gives green ["Tests"], and --only "Tests: server" gives NO row (one of three leaves under Tests ran).
Unit, new file packages/cli/test/ci-signoff/ci-signoff.test.mjs
Follows the sibling naming (test/ci-runner/ci-runner.test.mjs, test/ci-config/ci-config.test.mjs). Build trees with normalizeSteps from ../../lib/ci-config.js and a result Map from flattenSteps. Each test states its counterfactual, the file's existing posture.
The scaffold-shaped tree, all green, yields exactly the 6 titles above in that order. COUNTERFACTUAL in the same test: green has length 6, does not include Checks (a per-top-level-group mapping would yield 2 rows) and does not include Tests: server (a per-leaf mapping would yield 8).
A monorepo-shaped fixture (an inline copy of the root tree's TITLES at cb073574, including the depth-4 leaves under In-repo app tests) yields exactly the 11 titles above. It is an inline fixture on purpose, so a later edit to the root list does not rot a CLI unit test.
A rolled-up group with one red leaf is red with description Tests: browser failed (exit 1). Two red leaves give Tests: server failed (exit 2) (+1 more). The sibling rows stay green.
A plain-step row that failed reads <title> failed (exit <code>), and a signal death reads failed (exit SIGKILL) (the runner's code ?? signal wording).
Fail-fast shapes: a row none of whose leaves ran gets no row. A rolled-up row whose leaves only partly ran, all green, gets no row. One whose ran leaf failed is red.
An interrupted leaf (ok: false, interrupted: true) gives no row. An interrupted leaf beside a genuinely failed leaf in the same row is red.
Unsafe titles (Say "hi", a\b, a title holding \u0007, a title holding a tab) land in skipped with one warning each and in neither green nor red. déploiement, $(x) ; rm, -qa, and status are NOT skipped.
Case-fold collision: rows Tests (green) and tests (red) merge into one RED row titled Tests, with one warning. COUNTERFACTUAL: green does not contain Tests, which is what a skip-the-later-row policy would post.
Identity: two leaves share the title lint under two different rolled-up rows and only one fails. Exactly one row is red. COUNTERFACTUAL: a title-keyed lookup reddens both.
--only shape: results only for the leaves of selectSteps(tree, ['tests']).steps give green ["Tests"], and the selected node is === the node in the full tree.
A description longer than 140 characters is cut to 140.
postSignoffRows with a recording fake spawn (reuse the fakeChild / recorder shape from ci-runner.test.mjs): the calls are ['signoff','fail','--description',<desc>,'--',<title>] per red row FIRST, then one ['signoff','create','--',...greens]. The command is 'gh', opts.shell is undefined, and opts.stdio is ['ignore','pipe','pipe']. Empty rows spawn nothing.
A non-zero exit is a warning carrying the first stderr line, the other calls still run, and posted omits the failed titles. Stderr holding unknown option: -- adds the 0.4.0 upgrade hint.
An error event with code: 'ENOENT' yields exactly ONE warning naming the missing gh and NO further spawn. A synchronous throw from spawn does the same.
formatSignoffRows prints a red row before green rows, prints (no row was posted) when both are empty, and prints every warning.
Unit, extend packages/cli/test/ci-runner/ci-runner.test.mjs
One test: onResult fires exactly once per finished step with the SAME node object normalizeSteps produced, for a sequential step, a pooled step, and a step inside a group nested in a pool. COUNTERFACTUAL: with no onResult the run completes and result.steps is unchanged.
CLI end to end, extend test/cli/ci.test.mjs
Add a fakeGh(t) helper: a temp directory holding an executable gh (chmod 0o755), returned with the env to pass (PATH with that directory FIRST, joined by path.delimiter, plus GH_LOG) and a calls() reader that splits the log on the ::end:: line into argv arrays. The script records ONE argv entry per line, so "a title arrives as one entry" is directly assertable (a title can never hold a newline, since a C0 control skips the row):
The fixture needs no git repository, since the fake never calls git. This file also runs under the Bun matrix (scripts/run-bun-tests.js walks test/ for *.test.mjs, and bun test has a 5 second per-test default), so keep every test() to at most three CLI spawns and use exit 0 / exit 3 steps.
Fixture list for these tests: Setup (plain), then a Checks group with parallel: 2 holding E2E (Puppeteer against the blog example), Postgres prod-engine round-trip (#563), and a nested Tests group with Tests: server and Tests: node + browser.
Green full run with --signoff: exit 0, and calls() deep-equals [['signoff','create','--','Setup','E2E (Puppeteer against the blog example)','Postgres prod-engine round-trip (#563)','Tests'], ['signoff']]. That one assertion pins the argv-entry integrity for spaces, parentheses, #, and +, the depth-2 rule, and rows-before-roll-up. Stdout matches signoff/Tests. COUNTERFACTUAL: the same run WITHOUT --signoff never creates the log file.
Red run (Tests: server is exit 3): exit 1, calls() deep-equals [['signoff','fail','--description','Tests: server failed (exit 3)','--','Tests'], ['signoff','create','--','Setup','E2E (Puppeteer against the blog example)','Postgres prod-engine round-trip (#563)']], there is NO ['signoff'] entry, and stdout holds Signoff: CI failed. Do not merge or deploy..
--only Tests --signoff, green: exit 0, calls() deep-equals [['signoff','create','--','Tests']], no ['signoff'] entry, and stdout names the partial run. This is the counterfactual for the hole: on main today the same command calls bare gh signoff.
Failure policy: GH_FAIL=rows on a green full run exits 0, still calls bare ['signoff'], and prints a warning holding boom. GH_FAIL=rollup exits 1.
--json --signoff: stdout parses as ONE document whose signoff equals { rollup: true, green: [...4 titles], red: [], skipped: [], warnings: [] }, and no gh text reached stdout. --json WITHOUT --signoff: Object.keys(doc) deep-equals ['ok','seconds','interrupted','steps'], the byte-identity pin.
An unsafe title (Say "hi") in a green run: exit 0, the title is in no argv array, the warning is printed, and the roll-up still posts.
Extend the existing help test (L216 to L222) to match signoff/<title> in the webjs help ci output.
Bun parity, new pair test/bun/ci-signoff.mjs and test/bun/ci-signoff.test.mjs
Follows the ci-runner.mjs / ci-runner.test.mjs pair exactly: the .mjs is plain assertions runnable as node test/bun/ci-signoff.mjs AND bun test/bun/ci-signoff.mjs, and the .test.mjs is the three-line node:test wrapper that imports it, which is how node scripts/run-bun-tests.js (it walks test/ for *.test.mjs and runs each under bun test) and npm test both pick it up. packages/cli/test/** is NOT walked by the Bun matrix, which is why this lives under test/bun/. No new bun proof: step in the root webjs.ci list is needed (ci-runner.mjs has none either).
With the REAL spawn and the fake gh script above on PATH, assert on this runtime:
A title of E2E (Puppeteer) #1 + $(touch <marker>) arrives as ONE argv entry and <marker> does not exist afterwards (no shell ever saw it).
Titles status and -qa arrive after create and --.
The red call carries --description and its text as two entries.
GH_FAIL=rows yields warnings and an empty posted, never a throw.
A PATH with no gh yields exactly one warning (both runtimes emit error with ENOENT, measured).
Run and report all of: node --test packages/cli/test/ci-signoff/ci-signoff.test.mjs packages/cli/test/ci-runner/ci-runner.test.mjs test/cli/ci.test.mjs, node test/bun/ci-signoff.mjs, bun test/bun/ci-signoff.mjs, bun test test/cli/ci.test.mjs test/bun/ci-signoff.test.mjs, then npm test once.
Scaffold and repo health
test/scaffolds/scaffold-integration.test.js, next to the existing webjs.ci assertions (L296 to L305): normalize the generated ciPkg.webjs.ci.steps, mark every leaf green, and assert signoffRows returns exactly the 6 scaffold titles with empty skipped and warnings. This is the "freshly generated app" proof, and it fails if a later scaffold edit adds a title gh-signoff refuses.
test/repo-health/in-repo-ci-blocks.test.mjs: for the root and the three apps, signoffRows over an all-green map returns empty skipped and empty warnings. It deliberately does NOT pin the 11 root titles, so adding a Gate slot stays a one-file change.
Layers that do not apply
Browser (npm run test:browser), e2e (WEBJS_E2E=1), and smoke (test/examples/*/smoke) do not apply. The change is CLI-only: it touches no rendered HTML, no client module, no server request path, and no example app. Do not run npm run ci per fix (it takes many minutes). Run it at most once before marking the PR ready, from a real install, and NEVER with --signoff as a test of this feature against the real repository from a linked worktree.
Manual verification (once, read-only afterwards)
On the pushed feature branch, from a real install, node packages/cli/bin/webjs.js ci --only Conventions --signoff posts signoff/Conventions and no roll-up. Read it back over REST with gh api repos/webjsdev/webjs/commits/<sha>/statuses --jq '.[].context' (or gh signoff status). Do not run the full list with --signoff just to see the roll-up.
Docs
Invoke the webjs-doc-sync skill. Every signoff mention in the repo (grep at cb073574, excluding node_modules) and what changes on each. The prose hook (.claude/hooks/block-prose-punctuation.sh) scans NEW doc content: backtick every webjs ci mention, no em-dashes, no pause hyphens or semicolons, no colon after a code-shaped left-hand side. Inside website/**/page.ts the text sits in an html template, so use <code> tags and NO backtick characters (invariant 9).
Every surface must state three things plainly: the rows are informational and the roll-up is the gate, how to require one row (gh signoff install "<title>") with the rename hazard, and that an app on GitHub Actions is unaffected and splits rows by splitting jobs with --only.
.agents/skills/webjs/references/built-ins.md L239 (the canonical text, under "Local CI"). Replace the tail of the Flags paragraph, from "and --signoff runs gh signoff after a green run" to "a red run posts nothing", with: --signoff posts commit statuses through gh signoff (basecamp/gh-signoff 0.4.0 or newer). Each ROW gets its own status named signoff/<title>, green or red. A row is a top-level step or a direct child of a top-level group, and a child that is itself a group is one row, green only when every step under it passed. The roll-up signoff is posted only when the WHOLE list ran green, never under --only, and it is the one status to require (gh signoff install once, then npm run ci -- --signoff after pushing). Rows are informational and best-effort. Requiring one is possible with gh signoff install "Tests", at the price that renaming that step blocks every PR until the ruleset is updated. A red run posts red rows for what failed, green rows for what passed, and no roll-up. gh signoff refuses a green status on a tree with uncommitted or unpushed changes, so push first. A title holding a double quote, a backslash, or a control character gets no row. An interrupted run posts nothing. An app that gates on GitHub Actions is unaffected and gets separate rows there by declaring separate jobs that each run npm run ci -- --only "<title>". Also extend the --json shape in the same paragraph with the signoff: { rollup, green, red, skipped, warnings } key, present only with --signoff. This file is what scripts/sync-scaffold-skill.mjs ships into every app, so there is no second copy to edit.
.agents/skills/webjs/references/testing.md L201. The pointer sentence stays accurate. Add "per-step status rows" to the list of what references/built-ins.md covers.
AGENTS.md L137 (code-workflow item 4): "additionally posts a green signoff status on the pushed head" becomes a sentence saying it posts one signoff/<title> status per step row, green or red, plus the roll-up signoff after a full green run. L573 (CLI reference line for webjs ci): the --signoff clause gets the same wording plus "never the roll-up under --only". L89 needs no change.
framework-dev.md, "Local CI" (L175, the gate paragraph at L211 to L228). State that a root run posts the 11 rows listed above, that they line up with the job names in .github/workflows/ci.yml, that scripts/protect-main.sh requires only the roll-up, and that --only Gate --signoff posts rows and no roll-up.
packages/cli/AGENTS.md. Add a ci-signoff.js entry to the module map after ci-runner.js (L122), and rewrite the --signoff sentence in the webjs ci row (L192), adding test/ci-signoff/ and test/bun/ci-signoff.mjs to its Tests list. Another change touched this file at cb073574, so re-grep the anchors.
packages/cli/README.md L53: the --signoff gloss becomes "per-step status rows plus the roll-up via gh signoff".
packages/cli/templates/.agents/rules/workflow.md L59 to L60: one added sentence on the rows and that only signoff should be required.
packages/cli/templates/.github/workflows/ci.yml header L18 to L21: say the local signoff posts a row per step plus the roll-up signoff, and that this workflow is unaffected. Check bunifyCi in packages/cli/lib/runtime-rewrite.js still transforms the file (packages/cli/test/runtime-rewrite/).
website/app/docs/configuration/page.ts L71 (the webjs ci --signoff comment in the code block) and L91 (the "Agents and cloud runners" paragraph): the same facts as item 1, including the signoff JSON key.
website/app/docs/testing/page.ts L215: the pointer stays. Add "and the per-step status rows" to it.
scripts/protect-main.sh header (L7 to L12): one added comment sentence that --signoff also posts informational signoff/<title> rows, that this script requires only the roll-up, and the rename hazard of requiring a row. No behaviour change to the script.
.github/workflows/ci.yml header L6: "posts a green signoff status" gains "and one signoff/<title> row per Gate slot".
.claude/skills/webjs-start-work/SKILL.md L301 to L303: mention the rows and that gh signoff status lists them. Edit the REPO copy only (~/.claude/skills/<name> is a symlink to it).
packages/cli/bin/webjs.js USAGE and HELP.ci, covered in Step 3.
No change: the webjs.ci JSON Schema and the WebjsConfig type (no signoff mention in packages/server or packages/core, confirmed by grep, and no config key is added), README.md at the root, the marketing pages, the blog, and the gallery demos. After the docs land, run ( cd website && node ../packages/cli/bin/webjs.js check ) and the website doctor, since website/ was touched.
Acceptance criteria
npm run ci -- --signoff on a green run in a scaffolded app calls gh signoff create -- with the six titles Setup, Conventions, Health, Types, Security: dependency audit, Tests, then bare gh signoff, so the PR's status list shows six signoff/<title> rows plus signoff
A red run posts one red row per failed depth-2 row with a <title> failed (exit <code>) description, green rows for the passed ones, and no roll-up, and still exits 1
--only <title> --signoff posts only the rows that ran to a verdict and NEVER the roll-up, and a row keeps the same context name as in a whole run
Titles with spaces, parentheses, #, +, colons, a leading hyphen, or a gh-signoff command word (status, install) arrive as single argv entries after create -- / fail ... --, with no shell interpolation, on Node AND Bun
A failed or impossible row post (non-zero gh, missing gh, gh-signoff older than 0.4.0) warns and leaves the exit code alone, while a failed roll-up still fails the run
A title holding a double quote, a backslash, or a C0 control is skipped with a warning, and two rows that fold to one context merge into one row with a warning and never show a false green
An interrupted run posts nothing
webjs ci without --signoff is byte-identical to today, including the --json document keys, and --json --signoff adds exactly one signoff key while stdout stays one document
Counterfactuals fire: the depth-2 test fails under a per-group or per-leaf mapping, the merge test fails under skip-the-later-row, the identity test fails under title matching, and the --only test fails on today's bin
Tests land at the unit (packages/cli/test/ci-signoff/, ci-runner), CLI (test/cli/ci.test.mjs with a fake gh), Bun (test/bun/ci-signoff.mjs plus wrapper), scaffold, and repo-health layers, and every command in the Tests section was run and reported
Every doc surface in the Docs section is updated, and a freshly generated app's .agents/skills/webjs/references/built-ins.md describes the per-row behaviour
Out of scope
The GitHub Actions path. The scaffolded .github/workflows/ci.yml and this repo's workflow are unchanged apart from their comment headers. An app that prefers cloud CI gets separate rows by declaring separate jobs that each run npm run ci -- --only <title>.
pending rows while a run is in flight (gh-signoff has no pending verb), and rich check runs (the Check Runs API needs a GitHub App token).
Any new flag, config key (no webjs.ci.signoff, no per-step opt-out), dependency, or version probe. gh plus the extension stay an optional, user-installed tool that only --signoff touches.
Cleaning up a stale row left by a step renamed between two runs on the SAME commit. Statuses are per SHA and the latest post per context wins, so a re-run correctly turns a red row green. The stale-name case is accepted.
Pinning the statuses to the SHA the run started on (--commit), a timeout on gh calls, and passing --url. git config signoff.url already works for an app that wants a Details link.
A duplicate-title check in readCiConfig. Duplicates stay legal. The helper handles them.
Running scripts/protect-main.sh, changing which contexts main requires, or requiring any row. Only the maintainer runs that script.
Splitting packages/cli/bin/webjs.js (1875 lines). The bin gains wiring only.
Problem
webjs ci --signoffposts exactly ONE commit status (signoff) after a green local run, and nothing at all after a red one. So on a GitHub PR a locally gated change shows a single row in the status list of the merge box, with no indication of which layers ran, and a failed local run is invisible on the PR (the row is simply absent, which reads the same as "nobody ran it").This is a feature of the published CLI, so it applies to every end-user WebJs app, not only this monorepo. A scaffolded app declares
Setupplus aChecksgroup (Conventions,Health,Types,Security: dependency audit,Tests) inwebjs.ci.steps(packages/cli/lib/create.jsL586 to L612), and an app owner who adopts the local-CI gate should see those as separate rows on their PR, green or red, the way separate Actions jobs would appear.There is a second defect in the same block.
webjs ci --only Types --signoffposts the full roll-upsignoffafter a green PARTIAL run, because the bin only checksresult.ok(packages/cli/bin/webjs.jsL881 to L888) and never looks at the parsedonlyarray (L788 to L797). One green layer can therefore mark a PR mergeable.Verified against the code at
cb073574, with corrections to the body this plan replaces.packages/cli/bin/webjs.jsL876 to L892, the--jsondocument at L894 to L911, the exit code at L915, the flag parsing at L785 to L797, therunCicall at L846, the USAGE banner at L93 to L97, and theHELP.cientry at L182 to L200. All as stated before.StepResult.groupholds the IMMEDIATE parent title only, never a path (runSequencepassesnode.titleatci-runner.jsL257,runPoolpassesgroup.titleat L302 and a nested group's own title at L289). A leaf underGatethenBrowser suitesthenIn-repo app testsreportsgroup: "In-repo app tests", sogroupcannot map a leaf back to its depth-2 ancestor.shell: trueatci-runner.jsL418 and L443, as stated.test/cli/ci.test.mjsat the REPO ROOT, notpackages/cli/test/cli/ci.test.mjs(that path does not exist). It has no--signoffbehaviour coverage at all today and no fakegh. Its only mention is the help check at L219.packages/cli/templates/.agents/skills/webjs/. That directory does not exist in the repo. The skill lives once at the repo root.agents/skills/webjs/andscripts/sync-scaffold-skill.mjscopies it atprepack(packages/cli/lib/create.jsL766 to L775 falls back to the repo-root copy in a checkout). Editing the root copy is the whole job.gh signoff failapplies NO clean-tree guard (extension source L1410 to L1415, "A red status is a warning, not an attestation, so no cleanliness check applies"). Onlycreatedoes (L1343 to L1351,is_clean || fail "$UNCLEAN_REASON", which printsError: repository has uncommitted changesorError: repository has unpushed changeson stderr and exits 1). So on a dirty or unpushed tree a red run still posts its red rows, and the green rows are refused with a warning.gh signoff failexists only since gh-signoff 0.4.0 (2026-08-21,CHANGELOG.mdin the extension directory), and so does the--end-of-options marker. The installed version here isgh-signoff 0.4.1.Design / approach
Decided plan, no open options.
Mechanism. GitHub renders one row per distinct commit-status
context.gh signoff(basecamp/gh-signoff, already the--signoffdependency) posts named contexts.gh signoff create -- <name>...posts a greensignoff/<name>per name, andgh signoff fail --description <text> -- <name>posts a red one. Rich check runs (logs, annotations) are NOT an option, because the Check Runs API only accepts a GitHub App token and a localghOAuth token is not one.What
--signoffdoes after this change.gh signoff failcall PER red row, then every green row in ONEgh signoff createcall.signoff(baregh signoff, through the runner exactly as today) only when the WHOLE list ran and was green. It stays the one contextgh signoff installrequires.--only <title> --signoffposts rows for what ran and NEVER the roll-up, which closes the hole described above.Row granularity (the depth-2 rule). A top-level plain step is one row. A top-level group contributes one row per DIRECT child, and a child that is itself a group rolls up into a single row. Nothing deeper gets a row. Per-top-level-group is too coarse for the scaffold (it would yield only
SetupandChecks), and per-leaf is too noisy for this monorepo (theBun runtime smoke + test matrixgroup alone has 32 leaves). This matches how hosted CI draws the same line: GitHub Actions and CircleCI post one status per JOB, not per workflow and not per shell command.Depth is measured against the FULL declared tree (
cfg.steps), never against the--onlyselection.selectSteps(ci-config.jsL172 to L195) returns a FLAT list of the matched nodes, so--only Conventionsturns a depth-2 child into a top-level node of the selection. Measuring there would rename or regroup rows between a whole run and a partial run. Measured against the full tree,signoff/Conventionsis the same context either way.Row verdict. For a row, take the leaves under it (the row itself for a plain step,
flattenSteps([group])for a rolled-up group) and look up each leaf's result.--fail-fastor--only, or the only non-green leaves were interrupted).Mapping a result to its row uses object identity, not titles.
selectStepspushes the SAME node objects it was given (verified:selectSteps(tree, ['tests']).steps[0] === tree[1].steps[4]istrue), so a leaf object is the same incfg.stepsand in the selection the runner executes. The runner gains one injectable hook,onResult(step, result), called fromrunOne, and the bin fills aMap<CiStep, StepResult>from it. Title matching was rejected: duplicate titles are legal today (readCiConfighas no duplicate check, exact or case-folded),groupis only the immediate parent, and a leaf titled the same under two different rows would redden both. The hook leavesStepResultand the--jsondocument untouched.Context name. The row's title verbatim, so the context is
signoff/<title>. gh-signoff accepts spaces and punctuation and refuses only an empty name, a double quote, a backslash, or a C0 control character (require_context_nameat extension L634 to L638,require_json_safeat L608 to L616, whose pattern is*[\"\\]* | *[$'\x01'-$'\x1f']*). The JS twin is/["\\\u0000-\u001f]/. A title matching it is skipped for the row with a warning, never a crash. The reader already trims titles and refuses empty ones.Two rows that fold to one context are MERGED, with a warning. GitHub compares contexts case-insensitively (gh-signoff
CHANGELOG.md, 0.4.0, "Context identity is case-insensitive, matching how GitHub compares status check contexts"), soTestsandtestsare one context. The fold key istitle.trim().toLowerCase(), the same foldselectStepsuses for--only. The rows merge into one row carrying the FIRST declared spelling and the union of the leaves, and the ordinary verdict rule applies. Skipping the later row was rejected because it can post a false green:Testsgreen plustestsred would show a greensignoff/Tests. Merging needs no new semantics, since "green only when every leaf under the row is green" is already the roll-up rule.Exact
ghinvocations, each settled from the extension source.gh signoff create -- <title1> <title2> ..., ONE call.cmd_createloops over every context (L1370 to L1400). The explicitcreatesubcommand is mandatory, because the top-level dispatch (L2634 to L2668) treats a first positional ofcreate,fail,install,uninstall,check,contexts,status,version, orcompletionas a COMMAND, so a step titledstatuspassed bare would rungh signoff statusand a step titledinstallwould edit the repository ruleset. The--is mandatory too, becausecmd_createfails on any-*argument (L1321 to L1323) unless it follows--(L1311 to L1320), and a title may begin with a hyphen.gh signoff fail --description "<text>" -- <title>, one call PER red row.cmd_failtakes ONE--descriptionfor the whole call (L1429 to L1435) and applies it to every context in its loop (L1496 to L1510), so a single call could not give each row its own text. The description is<leaf title> failed (exit <code or signal>), the runner's own wording (ci-runner.jsL161, L354, L407), with(+N more)appended when several leaves under one row failed, cut to 140 characters (gh-signoff cuts at 140 too, L1484 to L1487, and GitHub rejects longer).-f. Never--commit(with--committhe clean-tree check is replaced by an is-on-a-remote check, L1343 to L1351, which would let a dirty worktree attest green).git show 444e9e0^:gh-signoffin the extension clone) both calls die in ARGUMENT PARSING, before any POST:create -- Xhitsfail "unknown option: --", andfail --description ...falls to the implicit create, collectsfailas a context, then hitsfail "unknown option: --description". Nothing is posted and the exit is 1, so an old extension degrades to a warning. The warning appends an upgrade hint when stderr holdsunknown option.Argv spawn, never a shell string.
runCispawns withshell: true, and real titles contain spaces, parentheses,#,+, and colons (E2E (Puppeteer against the blog example),Postgres prod-engine round-trip (#563),Tests: node + browser). The row posts spawnghwith an argv array and noshelloption. Measured on Node 24 and Bun 1.3.14 with a fakeghfirst onPATH: a title ofE2E (Puppeteer) #1 + $(touch marker)arrived as one argv entry and the marker file was never created. A missingghsurfaces on BOTH runtimes as anerrorevent withcode: 'ENOENT'(no synchronous throw on either), which the poster turns into one warning and stops.Failure policy. The roll-up keeps today's contract: a missing
ghor a refused signoff is a failed step and a non-zero exit. The per-row posts are best-effort: a failure to post prints a warning and does not change the exit code, because the rows are informational and the roll-up is the gate. There is no timeout on a row call, matching the roll-up, which has none today.Required vs informational. Only the roll-up
signoffis required. The rows stay informational, because requiring each by name means renaming a step inwebjs.ci.stepsblocks every PR until the ruleset is updated. The docs say an app CAN require a row withgh signoff install "<title>"and name that rename hazard.Where the code lives. A new sibling module
packages/cli/lib/ci-signoff.js.ci-runner.jsis 499 lines, so size does not force this. Responsibility does:ci-runner.jsowns executing a step tree, and "which statuses a finished run reports to GitHub" is a second responsibility with its own external dependency. The bin (webjs.js, 1875 lines, the orchestration entry) gains only wiring.Prior art read.
~/Documents/Projects/frameworks/rails/activesupport/lib/active_support/continuous_integration.rbL18 to L21 and L56 to L59, andrailties/lib/rails/generators/rails/app/templates/config/ci.rb.ttL36 to L38. Rails models signoff as one morestep "Signoff: ...", "gh signoff"insideif success?, a single roll-up. It has no per-step rows, so this feature is a superset and the roll-up wording here stays Rails' own.~/.local/share/gh/extensions/gh-signoff/README.mdL154 to L215, "Advanced usage: Partial signoff", which names this exact use ("use partial signoff to reflect each CI step") and showsgh signoff statuslisting every context.~/.local/share/gh/extensions/gh-signoff/gh-signoff(0.4.1) and itsCHANGELOG.md, line anchors above.Alternatives rejected. Posting through
gh api repos/:owner/:repo/statuses/<sha>directly (it would bypass the clean-tree attestation that makes a green status trustworthy, and would duplicate gh-signoff). Slugifying titles into contexts (the verbatim title is the decided name, and gh-signoff accepts it). Onefailcall for all red rows (one shared description). Title-based result matching (ambiguous, see above). A version probe (an extra spawn for a failure that is already safe). Pinning--commit(changes what green attests).pendingrows (gh-signoff has no pending verb).Implementation plan
Work in a dedicated worktree (
git worktree add -b feat/ci-signoff-rows ../webjs-ci-signoff-rows origin/main, thennpm run worktree:link). NEVER run an install while thenode_modulessymlink stands (#1442). Exercise the branch's CLI by path (node packages/cli/bin/webjs.js ci ...), because the hoistedwebjsbin in a linked worktree resolves into the PRIMARY checkout.packages/is plain.jswith JSDoc, never.ts. No new dependency, no new flag, no new config key.Step 1. Add the
onResulthook to the runnerFile
packages/cli/lib/ci-runner.js.In the
runCiJSDoc options block (L177 to L189) add one line aftercloseGraceMs?: number,:In the
ctxliteral (L193 to L213) add aftercloseGraceMs: opts.closeGraceMs ?? CLOSE_GRACE_MS,:In
runOne, today (L401 to L402):After:
Also extend the module header comment (L40 to L44) with one sentence saying
onResulthands back the node each result belongs to, which is whatci-signoff.jskeys rows on. Nothing else in the runner changes, andStepResultkeeps its shape.Step 2. New module
packages/cli/lib/ci-signoff.jsThe code below was run as a prototype against the real
ci-config.json Node 24 and Bun 1.3.14 with a fakegh, and produced the row lists in the Tests section. Match the file's comment density toci-runner.js(a header comment stating what the module owns and why the posts are argv spawns).stdoutis drained and discarded on purpose. Under--jsonthe bin's stdout carries exactly one document, and the row report is WebJs's own text written throughout.Step 3. Wire the bin
File
packages/cli/bin/webjs.js,case 'ci'.3a. Collect results by node. Today (L846 to L853):
After:
3b. Replace the signoff block. Today (L876 to L892):
After:
3c. The
--jsondocument. Today the object literal ends with thestepsarray (L899 to L909). Add one spread after it so the key exists ONLY when--signoffwas passed:The exit-code line (L915) does not change.
signoffOkis only ever set by the roll-up, which is what keeps the row posts out of the exit code.3d. USAGE banner. Today (L97):
After (two lines, same indent):
3e.
HELP.ci. Replace the--jsonand--signoffoption descriptions (L191 to L192) and add onenotesentry (after L197, the last entry today):Add
'webjs ci --only Tests --signoff'toexamples.Step 4. Commit plan (three logical units, push after each)
The hooks match
packages/cli/lib/(NOTpackages/cli/bin/):require-docs-with-src.shL59 andrequire-tests-with-src.shL59 both BLOCK a commit that stagespackages/cli/lib/**without a doc surface and a test.require-bun-parity-with-runtime-src.shL60 to L63 will NOT fire (no file name here matches its keyword list), but the Bun proof below is still required by the workflow rules.feat(cli): compute and post per-row signoff statuses.lib/ci-runner.js,lib/ci-signoff.js,packages/cli/test/ci-signoff/ci-signoff.test.mjs, theonResulttest inpackages/cli/test/ci-runner/ci-runner.test.mjs,test/bun/ci-signoff.mjsplus its wrapper, and thepackages/cli/AGENTS.mdmodule-map entry (the doc surface the hook wants).feat(cli): post one signoff row per step from webjs ci --signoff.bin/webjs.js,test/cli/ci.test.mjs, the scaffold and repo-health assertions.docs: describe the per-row signoff statuses. Every surface in the Docs section.The PR title must be
feat:prefixed (it feeds the generated changelog), the body carriesCloses #1481, and no commit carries an AI-attribution trailer.Tests
Expected row lists (measured by running the prototype helper against the real trees at
cb073574).Scaffold (
packages/cli/lib/create.jsL586 to L612), 6 rows:Monorepo root (
package.jsonwebjs.ci.steps), 11 rows:In-repo apps:
gallerygivesSetup, Conventions, Health, Types, Tests: node + browser,examples/bloggivesSetup, Conventions, Health, Types, Tests: node, andwebsitegivesConventions, Health, Types, Tests: node + browser.Partial runs on the scaffold tree:
--only Testsgives green["Tests"], and--only "Tests: server"gives NO row (one of three leaves underTestsran).Unit, new file
packages/cli/test/ci-signoff/ci-signoff.test.mjsFollows the sibling naming (
test/ci-runner/ci-runner.test.mjs,test/ci-config/ci-config.test.mjs). Build trees withnormalizeStepsfrom../../lib/ci-config.jsand a resultMapfromflattenSteps. Each test states its counterfactual, the file's existing posture.greenhas length 6, does not includeChecks(a per-top-level-group mapping would yield 2 rows) and does not includeTests: server(a per-leaf mapping would yield 8).cb073574, including the depth-4 leaves underIn-repo app tests) yields exactly the 11 titles above. It is an inline fixture on purpose, so a later edit to the root list does not rot a CLI unit test.Tests: browser failed (exit 1). Two red leaves giveTests: server failed (exit 2) (+1 more). The sibling rows stay green.<title> failed (exit <code>), and a signal death readsfailed (exit SIGKILL)(the runner'scode ?? signalwording).ok: false, interrupted: true) gives no row. An interrupted leaf beside a genuinely failed leaf in the same row is red.Say "hi",a\b, a title holding\u0007, a title holding a tab) land inskippedwith one warning each and in neithergreennorred.déploiement,$(x) ; rm,-qa, andstatusare NOT skipped.Tests(green) andtests(red) merge into one RED row titledTests, with one warning. COUNTERFACTUAL:greendoes not containTests, which is what a skip-the-later-row policy would post.lintunder two different rolled-up rows and only one fails. Exactly one row is red. COUNTERFACTUAL: a title-keyed lookup reddens both.--onlyshape: results only for the leaves ofselectSteps(tree, ['tests']).stepsgive green["Tests"], and the selected node is===the node in the full tree.postSignoffRowswith a recording fakespawn(reuse thefakeChild/recordershape fromci-runner.test.mjs): the calls are['signoff','fail','--description',<desc>,'--',<title>]per red row FIRST, then one['signoff','create','--',...greens]. The command is'gh',opts.shellisundefined, andopts.stdiois['ignore','pipe','pipe']. Empty rows spawn nothing.postedomits the failed titles. Stderr holdingunknown option: --adds the 0.4.0 upgrade hint.errorevent withcode: 'ENOENT'yields exactly ONE warning naming the missingghand NO further spawn. A synchronous throw fromspawndoes the same.formatSignoffRowsprints a red row before green rows, prints(no row was posted)when both are empty, and prints every warning.Unit, extend
packages/cli/test/ci-runner/ci-runner.test.mjsOne test:
onResultfires exactly once per finished step with the SAME node objectnormalizeStepsproduced, for a sequential step, a pooled step, and a step inside a group nested in a pool. COUNTERFACTUAL: with noonResultthe run completes andresult.stepsis unchanged.CLI end to end, extend
test/cli/ci.test.mjsAdd a
fakeGh(t)helper: a temp directory holding an executablegh(chmod 0o755), returned with the env to pass (PATHwith that directory FIRST, joined bypath.delimiter, plusGH_LOG) and acalls()reader that splits the log on the::end::line into argv arrays. The script records ONE argv entry per line, so "a title arrives as one entry" is directly assertable (a title can never hold a newline, since a C0 control skips the row):The fixture needs no git repository, since the fake never calls git. This file also runs under the Bun matrix (
scripts/run-bun-tests.jswalkstest/for*.test.mjs, andbun testhas a 5 second per-test default), so keep everytest()to at most three CLI spawns and useexit 0/exit 3steps.Fixture list for these tests:
Setup(plain), then aChecksgroup withparallel: 2holdingE2E (Puppeteer against the blog example),Postgres prod-engine round-trip (#563), and a nestedTestsgroup withTests: serverandTests: node + browser.--signoff: exit 0, andcalls()deep-equals[['signoff','create','--','Setup','E2E (Puppeteer against the blog example)','Postgres prod-engine round-trip (#563)','Tests'], ['signoff']]. That one assertion pins the argv-entry integrity for spaces, parentheses,#, and+, the depth-2 rule, and rows-before-roll-up. Stdout matchessignoff/Tests. COUNTERFACTUAL: the same run WITHOUT--signoffnever creates the log file.Tests: serverisexit 3): exit 1,calls()deep-equals[['signoff','fail','--description','Tests: server failed (exit 3)','--','Tests'], ['signoff','create','--','Setup','E2E (Puppeteer against the blog example)','Postgres prod-engine round-trip (#563)']], there is NO['signoff']entry, and stdout holdsSignoff: CI failed. Do not merge or deploy..--only Tests --signoff, green: exit 0,calls()deep-equals[['signoff','create','--','Tests']], no['signoff']entry, and stdout names the partial run. This is the counterfactual for the hole: onmaintoday the same command calls baregh signoff.GH_FAIL=rowson a green full run exits 0, still calls bare['signoff'], and prints a warning holdingboom.GH_FAIL=rollupexits 1.--json --signoff: stdout parses as ONE document whosesignoffequals{ rollup: true, green: [...4 titles], red: [], skipped: [], warnings: [] }, and noghtext reached stdout.--jsonWITHOUT--signoff:Object.keys(doc)deep-equals['ok','seconds','interrupted','steps'], the byte-identity pin.Say "hi") in a green run: exit 0, the title is in no argv array, the warning is printed, and the roll-up still posts.signoff/<title>in thewebjs help cioutput.Bun parity, new pair
test/bun/ci-signoff.mjsandtest/bun/ci-signoff.test.mjsFollows the
ci-runner.mjs/ci-runner.test.mjspair exactly: the.mjsis plain assertions runnable asnode test/bun/ci-signoff.mjsANDbun test/bun/ci-signoff.mjs, and the.test.mjsis the three-linenode:testwrapper that imports it, which is hownode scripts/run-bun-tests.js(it walkstest/for*.test.mjsand runs each underbun test) andnpm testboth pick it up.packages/cli/test/**is NOT walked by the Bun matrix, which is why this lives undertest/bun/. No newbun proof:step in the rootwebjs.cilist is needed (ci-runner.mjshas none either).With the REAL
spawnand the fakeghscript above onPATH, assert on this runtime:E2E (Puppeteer) #1 + $(touch <marker>)arrives as ONE argv entry and<marker>does not exist afterwards (no shell ever saw it).statusand-qaarrive aftercreateand--.--descriptionand its text as two entries.GH_FAIL=rowsyields warnings and an emptyposted, never a throw.PATHwith noghyields exactly one warning (both runtimes emiterrorwithENOENT, measured).Run and report all of:
node --test packages/cli/test/ci-signoff/ci-signoff.test.mjs packages/cli/test/ci-runner/ci-runner.test.mjs test/cli/ci.test.mjs,node test/bun/ci-signoff.mjs,bun test/bun/ci-signoff.mjs,bun test test/cli/ci.test.mjs test/bun/ci-signoff.test.mjs, thennpm testonce.Scaffold and repo health
test/scaffolds/scaffold-integration.test.js, next to the existingwebjs.ciassertions (L296 to L305): normalize the generatedciPkg.webjs.ci.steps, mark every leaf green, and assertsignoffRowsreturns exactly the 6 scaffold titles with emptyskippedandwarnings. This is the "freshly generated app" proof, and it fails if a later scaffold edit adds a title gh-signoff refuses.test/repo-health/in-repo-ci-blocks.test.mjs: for the root and the three apps,signoffRowsover an all-green map returns emptyskippedand emptywarnings. It deliberately does NOT pin the 11 root titles, so adding a Gate slot stays a one-file change.Layers that do not apply
Browser (
npm run test:browser), e2e (WEBJS_E2E=1), and smoke (test/examples/*/smoke) do not apply. The change is CLI-only: it touches no rendered HTML, no client module, no server request path, and no example app. Do not runnpm run ciper fix (it takes many minutes). Run it at most once before marking the PR ready, from a real install, and NEVER with--signoffas a test of this feature against the real repository from a linked worktree.Manual verification (once, read-only afterwards)
On the pushed feature branch, from a real install,
node packages/cli/bin/webjs.js ci --only Conventions --signoffpostssignoff/Conventionsand no roll-up. Read it back over REST withgh api repos/webjsdev/webjs/commits/<sha>/statuses --jq '.[].context'(orgh signoff status). Do not run the full list with--signoffjust to see the roll-up.Docs
Invoke the
webjs-doc-syncskill. Everysignoffmention in the repo (grep atcb073574, excludingnode_modules) and what changes on each. The prose hook (.claude/hooks/block-prose-punctuation.sh) scans NEW doc content: backtick everywebjs cimention, no em-dashes, no pause hyphens or semicolons, no colon after a code-shaped left-hand side. Insidewebsite/**/page.tsthe text sits in anhtmltemplate, so use<code>tags and NO backtick characters (invariant 9).Every surface must state three things plainly: the rows are informational and the roll-up is the gate, how to require one row (
gh signoff install "<title>") with the rename hazard, and that an app on GitHub Actions is unaffected and splits rows by splitting jobs with--only..agents/skills/webjs/references/built-ins.mdL239 (the canonical text, under "Local CI"). Replace the tail of the Flags paragraph, from "and--signoffrunsgh signoffafter a green run" to "a red run posts nothing", with:--signoffposts commit statuses throughgh signoff(basecamp/gh-signoff 0.4.0 or newer). Each ROW gets its own status namedsignoff/<title>, green or red. A row is a top-level step or a direct child of a top-level group, and a child that is itself a group is one row, green only when every step under it passed. The roll-upsignoffis posted only when the WHOLE list ran green, never under--only, and it is the one status to require (gh signoff installonce, thennpm run ci -- --signoffafter pushing). Rows are informational and best-effort. Requiring one is possible withgh signoff install "Tests", at the price that renaming that step blocks every PR until the ruleset is updated. A red run posts red rows for what failed, green rows for what passed, and no roll-up.gh signoffrefuses a green status on a tree with uncommitted or unpushed changes, so push first. A title holding a double quote, a backslash, or a control character gets no row. An interrupted run posts nothing. An app that gates on GitHub Actions is unaffected and gets separate rows there by declaring separate jobs that each runnpm run ci -- --only "<title>". Also extend the--jsonshape in the same paragraph with thesignoff: { rollup, green, red, skipped, warnings }key, present only with--signoff. This file is whatscripts/sync-scaffold-skill.mjsships into every app, so there is no second copy to edit..agents/skills/webjs/references/testing.mdL201. The pointer sentence stays accurate. Add "per-step status rows" to the list of whatreferences/built-ins.mdcovers.AGENTS.mdL137 (code-workflow item 4): "additionally posts a greensignoffstatus on the pushed head" becomes a sentence saying it posts onesignoff/<title>status per step row, green or red, plus the roll-upsignoffafter a full green run. L573 (CLI reference line forwebjs ci): the--signoffclause gets the same wording plus "never the roll-up under --only". L89 needs no change.framework-dev.md, "Local CI" (L175, the gate paragraph at L211 to L228). State that a root run posts the 11 rows listed above, that they line up with the job names in.github/workflows/ci.yml, thatscripts/protect-main.shrequires only the roll-up, and that--only Gate --signoffposts rows and no roll-up.packages/cli/AGENTS.md. Add aci-signoff.jsentry to the module map afterci-runner.js(L122), and rewrite the--signoffsentence in thewebjs cirow (L192), addingtest/ci-signoff/andtest/bun/ci-signoff.mjsto its Tests list. Another change touched this file atcb073574, so re-grep the anchors.packages/cli/README.mdL53: the--signoffgloss becomes "per-step status rows plus the roll-up via gh signoff".packages/cli/templates/.agents/rules/workflow.mdL59 to L60: one added sentence on the rows and that onlysignoffshould be required.packages/cli/templates/.github/workflows/ci.ymlheader L18 to L21: say the local signoff posts a row per step plus the roll-upsignoff, and that this workflow is unaffected. CheckbunifyCiinpackages/cli/lib/runtime-rewrite.jsstill transforms the file (packages/cli/test/runtime-rewrite/).website/app/docs/configuration/page.tsL71 (thewebjs ci --signoffcomment in the code block) and L91 (the "Agents and cloud runners" paragraph): the same facts as item 1, including thesignoffJSON key.website/app/docs/testing/page.tsL215: the pointer stays. Add "and the per-step status rows" to it.scripts/protect-main.shheader (L7 to L12): one added comment sentence that--signoffalso posts informationalsignoff/<title>rows, that this script requires only the roll-up, and the rename hazard of requiring a row. No behaviour change to the script..github/workflows/ci.ymlheader L6: "posts a greensignoffstatus" gains "and onesignoff/<title>row per Gate slot"..claude/skills/webjs-start-work/SKILL.mdL301 to L303: mention the rows and thatgh signoff statuslists them. Edit the REPO copy only (~/.claude/skills/<name>is a symlink to it).packages/cli/bin/webjs.jsUSAGE andHELP.ci, covered in Step 3.No change: the
webjs.ciJSON Schema and theWebjsConfigtype (nosignoffmention inpackages/serverorpackages/core, confirmed by grep, and no config key is added),README.mdat the root, the marketing pages, the blog, and the gallery demos. After the docs land, run( cd website && node ../packages/cli/bin/webjs.js check )and the website doctor, sincewebsite/was touched.Acceptance criteria
npm run ci -- --signoffon a green run in a scaffolded app callsgh signoff create --with the six titlesSetup,Conventions,Health,Types,Security: dependency audit,Tests, then baregh signoff, so the PR's status list shows sixsignoff/<title>rows plussignoff<title> failed (exit <code>)description, green rows for the passed ones, and no roll-up, and still exits 1--only <title> --signoffposts only the rows that ran to a verdict and NEVER the roll-up, and a row keeps the same context name as in a whole run#,+, colons, a leading hyphen, or a gh-signoff command word (status,install) arrive as single argv entries aftercreate --/fail ... --, with no shell interpolation, on Node AND Bungh, missinggh, gh-signoff older than 0.4.0) warns and leaves the exit code alone, while a failed roll-up still fails the runwebjs ciwithout--signoffis byte-identical to today, including the--jsondocument keys, and--json --signoffadds exactly onesignoffkey while stdout stays one document--onlytest fails on today's binpackages/cli/test/ci-signoff/,ci-runner), CLI (test/cli/ci.test.mjswith a fakegh), Bun (test/bun/ci-signoff.mjsplus wrapper), scaffold, and repo-health layers, and every command in the Tests section was run and reported.agents/skills/webjs/references/built-ins.mddescribes the per-row behaviourOut of scope
.github/workflows/ci.ymland this repo's workflow are unchanged apart from their comment headers. An app that prefers cloud CI gets separate rows by declaring separate jobs that each runnpm run ci -- --only <title>.pendingrows while a run is in flight (gh-signoff has no pending verb), and rich check runs (the Check Runs API needs a GitHub App token).webjs.ci.signoff, no per-step opt-out), dependency, or version probe.ghplus the extension stay an optional, user-installed tool that only--signofftouches.--commit), a timeout onghcalls, and passing--url.git config signoff.urlalready works for an app that wants a Details link.readCiConfig. Duplicates stay legal. The helper handles them.scripts/protect-main.sh, changing which contextsmainrequires, or requiring any row. Only the maintainer runs that script.packages/cli/bin/webjs.js(1875 lines). The bin gains wiring only.