From 7d03e9817af8c1aefca18e005aa8d73b601f1985 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sat, 4 Jul 2026 11:01:23 -0500 Subject: [PATCH 1/5] feat(gate): add opt-in docs-system input and docs-gate lane (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New reusable boolean input docs-system (default false, same opt-in pattern as stack). When true, a docs-gate job runs on pull_request events after detect in the consumer checkout: npm run docs:render -- --check (rendered-block drift) and node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json (blocking findings; the checkout uses fetch-depth: 0 to reach the merge base). The job conditions on the input directly, not on a detect output, so a caller whose workflow-library-ref lags this body cannot silently no-op the gate. decision aggregates it like every other optional lane — skipped is not failed for non-opted consumers, and the require_success call carries the same pull_request event guard as pr-contract so opted-in consumers stay green on push/merge_group runs. classify-pr.mjs threads docsSystem through to a run-docs-gate output and the lane summary (docs-gate rides along every PR lane incl. docs-only). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019b2W87gyzLPaHBbpuU9Zxt --- .github/workflows/repo-required-gate.yml | 84 ++++++++++++++++++++++++ examples/repo-required-gate.yml | 13 ++++ scripts/classify-pr.mjs | 15 +++++ scripts/classify-pr.test.mjs | 55 ++++++++++++++++ scripts/workflow-structure.test.mjs | 68 +++++++++++++++++++ 5 files changed, 235 insertions(+) diff --git a/.github/workflows/repo-required-gate.yml b/.github/workflows/repo-required-gate.yml index faa8594..f9e28ec 100644 --- a/.github/workflows/repo-required-gate.yml +++ b/.github/workflows/repo-required-gate.yml @@ -202,6 +202,20 @@ on: description: "Fail if go-generate-command leaves a dirty tree (stale committed generated code)." type: boolean default: false + # ---- Docs-system gate (#104) ----------------------------------------- + docs-system: + description: | + Opt-in docs gate for repo-template docs-system consumers (same + opt-in pattern as `stack`). When true, a docs-gate job runs on + pull_request events after detect, in the consumer checkout: + `npm run docs:render -- --check` (fails on stale committed rendered + blocks) and `node scripts/doc-health/health.mjs --repo . + --changed-from origin/ --json` (fails on blocking findings). + Requires the repo-template docs-system scripts. Consumers that + leave this false are unaffected: the job is skipped and the + decision job does not require it (skipped != failed). + type: boolean + default: false workflow-library-ref: description: | Git ref (tag, branch, or SHA) of ArchonVII/github-workflows used @@ -230,6 +244,7 @@ jobs: run-policy-validation: ${{ steps.detect.outputs.run-policy-validation }} snapshot-only: ${{ steps.detect.outputs.snapshot-only }} run-snapshot-validation: ${{ steps.detect.outputs.run-snapshot-validation }} + run-docs-gate: ${{ steps.detect.outputs.run-docs-gate }} forced-full: ${{ steps.detect.outputs.forced-full }} files-summary: ${{ steps.detect.outputs.files-summary }} steps: @@ -256,6 +271,7 @@ jobs: SNAPSHOT_PATHS: ${{ inputs.snapshot-paths }} SNAPSHOT_TEST_COMMAND: ${{ inputs.snapshot-test-command }} FORCE_FULL_CI_LABEL: ${{ inputs.force-full-ci-label }} + DOCS_SYSTEM: ${{ inputs.docs-system }} WORKFLOW_LIBRARY_REF: ${{ inputs.workflow-library-ref }} with: script: | @@ -303,6 +319,7 @@ jobs: snapshotPaths: process.env.SNAPSHOT_PATHS, snapshotTestCommand: process.env.SNAPSHOT_TEST_COMMAND, forceFullCiLabel: process.env.FORCE_FULL_CI_LABEL, + docsSystem: process.env.DOCS_SYSTEM === 'true', isPullRequest, }); @@ -319,6 +336,7 @@ jobs: core.setOutput('run-policy-validation', String(result.outputs.runPolicyValidation)); core.setOutput('snapshot-only', String(result.outputs.snapshotOnly)); core.setOutput('run-snapshot-validation', String(result.outputs.runSnapshotValidation)); + core.setOutput('run-docs-gate', String(result.outputs.runDocsGate)); core.setOutput('forced-full', String(result.outputs.forcedFull)); core.setOutput('files-summary', result.filesSummary); @@ -717,6 +735,61 @@ jobs: shell: bash run: ${{ inputs.snapshot-test-command }} + # Docs-system gate (#104): opt-in via the docs-system input, PR events only. + # Runs in the CONSUMER checkout on every lane — doc drift is precisely a + # docs-only concern, so the job is deliberately NOT path-routed. Conditioned + # on the input directly rather than on `needs.detect.outputs.run-docs-gate`: + # a caller whose workflow-library-ref lags this workflow body would import a + # classifier without that output, and an empty-string comparison would + # silently no-op the gate for an opted-in consumer. The detect output still + # exists so the lane summary reports docs-gate as required/skipped honestly. + docs-gate: + name: docs gate + needs: + - detect + if: always() && inputs.docs-system && github.event_name == 'pull_request' && needs.detect.outputs.ok == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + # health.mjs --changed-from resolves the merge base with the PR base + # branch; the default depth-1 checkout cannot reach it, so fetch full + # history (also brings in origin/). + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ fromJSON(inputs.node-versions)[0] }} + + - name: Detect package manager + id: pm + shell: bash + run: | + if [ -f pnpm-lock.yaml ]; then + npm install -g pnpm + echo "install=pnpm install --frozen-lockfile" >> "$GITHUB_OUTPUT" + elif [ -f yarn.lock ]; then + echo "install=yarn install --frozen-lockfile" >> "$GITHUB_OUTPUT" + else + echo "install=npm ci" >> "$GITHUB_OUTPUT" + fi + + - name: Install + shell: bash + run: ${{ steps.pm.outputs.install }} + + - name: Rendered docs drift check + shell: bash + run: npm run docs:render -- --check + + - name: Doc health blocking findings + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: node scripts/doc-health/health.mjs --repo . --changed-from "origin/${BASE_REF}" --json + decision: name: decision needs: @@ -729,6 +802,7 @@ jobs: - python-ci - go-ci - snapshot-validation + - docs-gate if: always() runs-on: ubuntu-latest steps: @@ -744,6 +818,7 @@ jobs: RUN_WORKFLOW_VALIDATION: ${{ inputs.run-workflow-validation && needs.detect.outputs.run-workflow-validation == 'true' }} RUN_POLICY_VALIDATION: ${{ inputs.run-policy-validation && needs.detect.outputs.run-policy-validation == 'true' }} RUN_SNAPSHOT_VALIDATION: ${{ needs.detect.outputs.run-snapshot-validation }} + RUN_DOCS_GATE: ${{ inputs.docs-system }} DETECT_RESULT: ${{ needs.detect.result }} CONTRACT_RESULT: ${{ needs.pr-contract.result }} WORKFLOW_RESULT: ${{ needs.workflow-validation.result }} @@ -753,6 +828,7 @@ jobs: PYTHON_RESULT: ${{ needs.python-ci.result }} GO_RESULT: ${{ needs.go-ci.result }} SNAPSHOT_RESULT: ${{ needs.snapshot-validation.result }} + DOCS_GATE_RESULT: ${{ needs.docs-gate.result }} EVENT_NAME: ${{ github.event_name }} RUN_PR_CONTRACT: ${{ inputs.run-pr-contract }} run: | @@ -807,6 +883,14 @@ jobs: require_success "snapshot validation" "$SNAPSHOT_RESULT" fi + # Docs gate is PR-only (mirrors pr-contract): the job itself skips + # on push/merge_group even for opted-in consumers, so the event + # guard here is load-bearing — without it every non-PR run of an + # opted-in consumer would fail on a skipped-but-required job. + if [ "$RUN_DOCS_GATE" = "true" ] && [ "$EVENT_NAME" = "pull_request" ]; then + require_success "docs gate" "$DOCS_GATE_RESULT" + fi + if [ "$failed" -ne 0 ]; then exit 1 fi diff --git a/examples/repo-required-gate.yml b/examples/repo-required-gate.yml index 3e2dffe..1217142 100644 --- a/examples/repo-required-gate.yml +++ b/examples/repo-required-gate.yml @@ -137,6 +137,19 @@ jobs: # src/snapshots/ # snapshot-test-command: npm run test:snapshots + # ------ Docs-system gate (repo-template docs-system repos) ---------- + # Opt-in, PR events only, runs on EVERY lane (doc drift is precisely a + # docs-only concern). When true, a docs-gate job runs after detect: + # npm run docs:render -- --check (stale committed rendered blocks) + # node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json + # (blocking doc-health findings) + # Requires the repo-template docs-system scripts (docs:render npm + # script + scripts/doc-health/health.mjs). Leave false/absent + # otherwise: the job is skipped and the decision check does not + # require it. + # + # docs-system: true + # ------ Force-full escape hatch (any stack) ------------------------- # Default label is `ci:full`. Apply it to a PR to bypass path-based # routing and run the full language CI surface — e.g. a workflow-only diff --git a/scripts/classify-pr.mjs b/scripts/classify-pr.mjs index a064a3e..5274b83 100644 --- a/scripts/classify-pr.mjs +++ b/scripts/classify-pr.mjs @@ -56,6 +56,7 @@ const VALID_STACKS = new Set(['node', 'python', 'go', 'minimal', 'polyglot']); * @property {string} [snapshotPaths] Newline-separated patterns. Empty disables the snapshot-refresh lane. * @property {string} [snapshotTestCommand] Non-empty when consumer wants the snapshot-validation job to actually run a test. * @property {string} [forceFullCiLabel] Label that forces full CI (default 'ci:full'). + * @property {boolean} [docsSystem] True when the caller opted into the docs-gate lane (#104). PR events only; not path-routed. * @property {string} [docExtensions] Pipe-separated extensions (no dots). * @property {string} [docPrefixes] Newline-separated prefixes (e.g. 'docs/', '.changelog/'). * @property {boolean} [isPullRequest] False for push events; classifier widens routing accordingly. @@ -77,6 +78,7 @@ const VALID_STACKS = new Set(['node', 'python', 'go', 'minimal', 'polyglot']); * @property {boolean} outputs.runPolicyValidation * @property {boolean} outputs.snapshotOnly * @property {boolean} outputs.runSnapshotValidation + * @property {boolean} outputs.runDocsGate True on every PR lane when docsSystem is set (doc drift is a docs concern, so docs-only PRs run it too); always false on non-PR events. * @property {boolean} outputs.forcedFull * @property {string[]} jobsRequired Job ids required for this lane. * @property {string[]} jobsSkipped Job ids deliberately skipped. @@ -98,6 +100,7 @@ export function classifyPR(input) { snapshotPaths = '', snapshotTestCommand = '', forceFullCiLabel = 'ci:full', + docsSystem = false, docExtensions = DEFAULT_DOC_EXTENSIONS, docPrefixes = DEFAULT_DOC_PREFIXES.join('\n'), isPullRequest = true, @@ -155,6 +158,7 @@ export function classifyPR(input) { runPolicyValidation: true, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: false, // docs-gate job is PR-only; the decision job guards on the event too forcedFull: false, }; return { @@ -274,6 +278,7 @@ export function classifyPR(input) { runPolicyValidation: true, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: docsSystem, forcedFull: true, }; return { @@ -300,6 +305,7 @@ export function classifyPR(input) { runPolicyValidation: false, snapshotOnly: true, runSnapshotValidation: snapshotTestCommand.trim().length > 0, + runDocsGate: docsSystem, forcedFull: false, }; return { @@ -326,6 +332,9 @@ export function classifyPR(input) { runPolicyValidation: false, snapshotOnly: false, runSnapshotValidation: false, + // Doc drift is precisely a docs-only concern — the gate rides along + // even on the smallest lane when the consumer opted in. + runDocsGate: docsSystem, forcedFull: false, }; return { @@ -352,6 +361,7 @@ export function classifyPR(input) { runPolicyValidation: false, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: docsSystem, forcedFull: false, }; return { @@ -378,6 +388,7 @@ export function classifyPR(input) { runPolicyValidation: true, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: docsSystem, forcedFull: false, }; return { @@ -442,6 +453,7 @@ export function classifyPR(input) { runPolicyValidation: policy, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: docsSystem, forcedFull: false, }; @@ -481,6 +493,7 @@ function requiredJobs(outputs, runPrContract) { if (outputs.runPythonCi) jobs.push('python-ci'); if (outputs.runGoCi) jobs.push('go-ci'); if (outputs.runSnapshotValidation) jobs.push('snapshot-validation'); + if (outputs.runDocsGate) jobs.push('docs-gate'); jobs.push('decision'); return jobs; } @@ -494,6 +507,7 @@ function skippedJobs(outputs, isPullRequest) { 'python-ci', 'go-ci', 'snapshot-validation', + 'docs-gate', ]; const required = new Set(requiredJobs(outputs, isPullRequest)); return all.filter((j) => !required.has(j)); @@ -519,6 +533,7 @@ function failedResult(errors) { runPolicyValidation: false, snapshotOnly: false, runSnapshotValidation: false, + runDocsGate: false, forcedFull: false, }, jobsRequired: ['detect'], diff --git a/scripts/classify-pr.test.mjs b/scripts/classify-pr.test.mjs index f74b52f..1554009 100644 --- a/scripts/classify-pr.test.mjs +++ b/scripts/classify-pr.test.mjs @@ -498,6 +498,61 @@ describe('classifyPR — stack=go (first-class Go lane, #51)', () => { }); }); +describe('classifyPR — docs-system gate (#104)', () => { + it('50. default (docsSystem unset) → runDocsGate false, docs-gate skipped', () => { + const r = classifyPR(input({ files: ['src/foo.ts'] })); + expect(r.outputs.runDocsGate).toBe(false); + expect(r.jobsRequired).not.toContain('docs-gate'); + expect(r.jobsSkipped).toContain('docs-gate'); + }); + + it('51. docsSystem=true on a code PR → docs-gate required', () => { + const r = classifyPR(input({ files: ['src/foo.ts'], docsSystem: true })); + expect(r.outputs.runDocsGate).toBe(true); + expect(r.jobsRequired).toContain('docs-gate'); + expect(r.jobsSkipped).not.toContain('docs-gate'); + }); + + it('52. docsSystem=true on a docs-only PR → docs-gate still required (drift IS a docs concern)', () => { + const r = classifyPR(input({ files: ['README.md'], docsSystem: true })); + expect(r.lane).toBe('docs-only'); + expect(r.outputs.runDocsGate).toBe(true); + expect(r.jobsRequired).toContain('docs-gate'); + }); + + it('53. docsSystem=true on push events → runDocsGate false (the job is PR-only)', () => { + const r = classifyPR(input({ isPullRequest: false, docsSystem: true })); + expect(r.outputs.runDocsGate).toBe(false); + expect(r.jobsRequired).not.toContain('docs-gate'); + }); + + it('54. docsSystem=true rides along forced-full and snapshot-refresh lanes', () => { + const forced = classifyPR( + input({ files: ['README.md'], labels: ['ci:full'], docsSystem: true }), + ); + expect(forced.outputs.runDocsGate).toBe(true); + expect(forced.jobsRequired).toContain('docs-gate'); + + const snap = classifyPR( + input({ + files: ['src/snapshots/foo.yml'], + snapshotPaths: 'src/snapshots/**', + snapshotTestCommand: 'npm test', + docsSystem: true, + }), + ); + expect(snap.lane).toBe('snapshot-refresh'); + expect(snap.outputs.runDocsGate).toBe(true); + expect(snap.jobsRequired).toContain('docs-gate'); + }); + + it('55. config-error result keeps runDocsGate false', () => { + const r = classifyPR(input({ stack: 'kotlin', docsSystem: true })); + expect(r.ok).toBe(false); + expect(r.outputs.runDocsGate).toBe(false); + }); +}); + describe('classifyPR — polyglot with Go (node+go, #51)', () => { // archon's real shape: Electron/React frontend + Go backend. const NODE = 'frontend/**\nemain/**\npackage.json\npackage-lock.json'; diff --git a/scripts/workflow-structure.test.mjs b/scripts/workflow-structure.test.mjs index 94f6276..eb887c1 100644 --- a/scripts/workflow-structure.test.mjs +++ b/scripts/workflow-structure.test.mjs @@ -65,6 +65,7 @@ describe('repo-required-gate workflow node delegation', () => { 'python-ci', 'go-ci', 'snapshot-validation', + 'docs-gate', ]) { const block = workflowJobBlock(body, job); @@ -112,6 +113,73 @@ describe('repo-required-gate workflow node delegation', () => { }); }); +describe('repo-required-gate docs-gate lane (#104)', () => { + it('declares the opt-in docs-system input defaulting to false', () => { + const body = readWorkflow('repo-required-gate'); + + const start = body.indexOf(' docs-system:'); + const end = body.indexOf(' workflow-library-ref:'); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + + const inputBlock = body.slice(start, end); + expect(inputBlock).toContain('type: boolean'); + expect(inputBlock).toContain('default: false'); + }); + + it('runs the docs gate only for opted-in consumers on pull_request events', () => { + const body = readWorkflow('repo-required-gate'); + const block = workflowJobBlock(body, 'docs-gate'); + + expect(block, 'docs-gate job exists').not.toBe(''); + // Conditioned on the INPUT directly (not a detect output) so a caller + // whose workflow-library-ref lags this workflow body can never silently + // no-op the gate via a missing classifier output. + expect(block).toContain( + "if: always() && inputs.docs-system && github.event_name == 'pull_request' && needs.detect.outputs.ok == 'true'", + ); + // health.mjs --changed-from needs the merge base with the PR base branch; + // the default depth-1 checkout cannot reach it. + expect(block).toContain('fetch-depth: 0'); + expect(block).toContain('npm run docs:render -- --check'); + expect(block).toContain( + 'node scripts/doc-health/health.mjs --repo . --changed-from', + ); + }); + + it('emits the run-docs-gate detect output from the classifier', () => { + const body = readWorkflow('repo-required-gate'); + + expect(body).toContain('run-docs-gate: ${{ steps.detect.outputs.run-docs-gate }}'); + expect(body).toContain('DOCS_SYSTEM: ${{ inputs.docs-system }}'); + expect(body).toContain( + "core.setOutput('run-docs-gate', String(result.outputs.runDocsGate));", + ); + }); + + it('aggregates the docs gate in decision with skipped != failed for non-opted consumers', () => { + const body = readWorkflow('repo-required-gate'); + const decision = workflowJobBlock(body, 'decision'); + + expect(decision, 'decision waits for docs-gate').toContain('- docs-gate'); + expect(decision).toContain('DOCS_GATE_RESULT: ${{ needs.docs-gate.result }}'); + expect(decision).toContain('RUN_DOCS_GATE: ${{ inputs.docs-system }}'); + expect(decision).toContain('require_success "docs gate" "$DOCS_GATE_RESULT"'); + // PR-only guard mirrors pr-contract: the docs-gate job itself skips on + // push/merge_group even for opted-in consumers, so requiring it without + // the event guard would fail every non-PR run of an opted-in consumer. + expect(decision).toContain( + 'if [ "$RUN_DOCS_GATE" = "true" ] && [ "$EVENT_NAME" = "pull_request" ]; then', + ); + }); + + it('documents the docs-system opt-in in the example caller', () => { + const body = readExample('repo-required-gate'); + + expect(body).toContain('# docs-system: true'); + }); +}); + describe('repo-required-gate caller example', () => { it('runs the required gate only for ci:full label changes', () => { const body = readExample('repo-required-gate'); From a86abb221e103576922195ed3349d25618b40642 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sat, 4 Jul 2026 11:02:07 -0500 Subject: [PATCH 2/5] chore(examples): retire fragment caller example stubs (#104) Drop examples/changelog-fragment.yml and examples/repo-update-log-fragment.yml so new-workflow guidance no longer points agents at fragment callers. Guidance cleanup only: the reusable workflow bodies stay because existing consumers still call them; the installed-baseline/registry migration belongs to the later S3/C1/T1 cleanup. README inventory rows keep the reusable bodies and mark the example cells retired; a shape test now locks in stubs-gone / bodies-present. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019b2W87gyzLPaHBbpuU9Zxt --- README.md | 4 ++-- examples/changelog-fragment.yml | 25 ------------------------- examples/repo-update-log-fragment.yml | 24 ------------------------ scripts/workflow-structure.test.mjs | 16 +++++++++------- 4 files changed, 11 insertions(+), 58 deletions(-) delete mode 100644 examples/changelog-fragment.yml delete mode 100644 examples/repo-update-log-fragment.yml diff --git a/README.md b/README.md index 11bebde..c5f1a5a 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ provider self-test workflows. | Workflow | Purpose | Caller | | --- | --- | --- | -| [`changelog-fragment.yml`](.github/workflows/changelog-fragment.yml) | Requires an added `.changelog/unreleased/*.md` fragment when configured source paths change, with a label-based opt-out. | [`examples/changelog-fragment.yml`](examples/changelog-fragment.yml) | -| [`repo-update-log-fragment.yml`](.github/workflows/repo-update-log-fragment.yml) | Requires an added `docs/repo-update-log/*.md` fragment for code/config/behavior/protected-doc/workflow/policy PRs, with a body-recorded skip only for unprotected doc-only fixes. | [`examples/repo-update-log-fragment.yml`](examples/repo-update-log-fragment.yml) | +| [`changelog-fragment.yml`](.github/workflows/changelog-fragment.yml) | Requires an added `.changelog/unreleased/*.md` fragment when configured source paths change, with a label-based opt-out. | Example retired (#104); existing installed callers keep working. | +| [`repo-update-log-fragment.yml`](.github/workflows/repo-update-log-fragment.yml) | Requires an added `docs/repo-update-log/*.md` fragment for code/config/behavior/protected-doc/workflow/policy PRs, with a body-recorded skip only for unprotected doc-only fixes. | Example retired (#104); existing installed callers keep working. | | [`doc-orphan-detector.yml`](.github/workflows/doc-orphan-detector.yml) | Scheduled backstop for committed docs stranded on pushed branches with no open PR. Reports path-only tracking issues and never commits or pushes. | [`examples/doc-orphan-detector.yml`](examples/doc-orphan-detector.yml) | | [`doc-policy-lint.yml`](.github/workflows/doc-policy-lint.yml) | Warning-only document-policy lint for durable docs: status headers, charter budgets, supersession links, active-doc placeholders, index coherence, and stale active-doc terms. | [`examples/doc-policy-lint.yml`](examples/doc-policy-lint.yml) | | [`anomaly-triage.yml`](.github/workflows/anomaly-triage.yml) | Reads a per-PR anomalies file, classifies entries as PR-related or unrelated, posts sticky review comments, and can open downstream issues. | [`examples/anomaly-triage.yml`](examples/anomaly-triage.yml) | diff --git a/examples/changelog-fragment.yml b/examples/changelog-fragment.yml deleted file mode 100644 index 7464b9d..0000000 --- a/examples/changelog-fragment.yml +++ /dev/null @@ -1,25 +0,0 @@ -# Copy this file to `.github/workflows/changelog-fragment.yml` in any -# consumer repo that uses the per-PR CHANGELOG fragment convention. - -name: Changelog Fragment - -on: - pull_request: - types: [opened, synchronize, edited, reopened] - -# Superseded runs on the same PR are pure spend — cancel them (#101). -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - pull-requests: read - contents: read - -jobs: - check: - uses: ArchonVII/github-workflows/.github/workflows/changelog-fragment.yml@v1 - # with: - # src-path-prefix: 'src/' - # fragment-dir: '.changelog/unreleased/' - # opt-out-label: 'no-changelog' diff --git a/examples/repo-update-log-fragment.yml b/examples/repo-update-log-fragment.yml deleted file mode 100644 index 2461646..0000000 --- a/examples/repo-update-log-fragment.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Copy this file to `.github/workflows/repo-update-log-fragment.yml` in repos -# that use the per-PR `docs/repo-update-log/` fragment convention. - -name: Repo Update Log Fragment - -on: - pull_request: - types: [opened, synchronize, edited, reopened, ready_for_review] - -# Superseded runs on the same PR are pure spend — cancel them (#101). -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - pull-requests: read - contents: read - -jobs: - check: - uses: ArchonVII/github-workflows/.github/workflows/repo-update-log-fragment.yml@v1 - with: - workflow-library-ref: v1 - # fragment-dir: "docs/repo-update-log/" diff --git a/scripts/workflow-structure.test.mjs b/scripts/workflow-structure.test.mjs index eb887c1..059691e 100644 --- a/scripts/workflow-structure.test.mjs +++ b/scripts/workflow-structure.test.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; const readWorkflow = (name) => readFileSync(`.github/workflows/${name}.yml`, 'utf8'); @@ -284,11 +284,13 @@ describe('repo-update-log-fragment workflow contract', () => { expect(body).toContain('evaluateRepoUpdateLogFragment'); }); - it('ships a caller example pinned to the reusable workflow and helper refs', () => { - const body = readExample('repo-update-log-fragment'); - - expect(body).toContain('uses: ArchonVII/github-workflows/.github/workflows/repo-update-log-fragment.yml@v1'); - expect(body).toContain('workflow-library-ref: v1'); - expect(body).toContain('types: [opened, synchronize, edited, reopened, ready_for_review]'); + it('no longer ships the retired fragment caller example stubs (#104)', () => { + // Guidance cleanup only: the reusable workflow BODIES stay (existing + // consumers still call them); only the examples/ stubs are retired so + // new-workflow guidance stops pointing agents at fragment callers. + expect(existsSync('examples/changelog-fragment.yml')).toBe(false); + expect(existsSync('examples/repo-update-log-fragment.yml')).toBe(false); + expect(existsSync('.github/workflows/changelog-fragment.yml')).toBe(true); + expect(existsSync('.github/workflows/repo-update-log-fragment.yml')).toBe(true); }); }); From 42ee1336ab1c2e7b9eed9a6b9e1cd587b58f5d70 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sat, 4 Jul 2026 11:02:44 -0500 Subject: [PATCH 3/5] docs(ledger): log docs-gate lane + fragment example cleanup (#104) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019b2W87gyzLPaHBbpuU9Zxt --- .changelog/unreleased/104-docs-gate.md | 1 + docs/repo-update-log.md | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 .changelog/unreleased/104-docs-gate.md diff --git a/.changelog/unreleased/104-docs-gate.md b/.changelog/unreleased/104-docs-gate.md new file mode 100644 index 0000000..f518fe3 --- /dev/null +++ b/.changelog/unreleased/104-docs-gate.md @@ -0,0 +1 @@ +- **repo-required-gate gains an opt-in `docs-system` docs gate.** New boolean input (default false, same opt-in pattern as `stack`): when true, pull_request runs add a `docs-gate` job (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`) aggregated by `decision` with skipped != failed for non-opted consumers. The `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs are retired (guidance cleanup only — the reusable workflow bodies stay). (#104) diff --git a/docs/repo-update-log.md b/docs/repo-update-log.md index 1cce8ad..27533b6 100644 --- a/docs/repo-update-log.md +++ b/docs/repo-update-log.md @@ -15,6 +15,15 @@ This log records agent-visible repository changes that should be easy to audit l - **Propagation:** none | pending | completed ``` +## 2026-07-04 - Docs-gate lane (docs-system input) + fragment example cleanup + +- **Issue/PR:** #104 / (pending) +- **Branch:** agent/claude/104-docs-gate +- **Changed paths:** .github/workflows/repo-required-gate.yml, scripts/classify-pr.mjs, scripts/classify-pr.test.mjs, scripts/workflow-structure.test.mjs, examples/repo-required-gate.yml, examples/changelog-fragment.yml (deleted), examples/repo-update-log-fragment.yml (deleted), README.md, .changelog/unreleased/104-docs-gate.md, docs/repo-update-log.md +- **What changed:** Added an opt-in `docs-system` boolean input (default false) to the reusable `repo-required-gate`. When true, a `docs-gate` job runs on pull_request events after `detect` in the consumer checkout (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`, fetch-depth 0 for the merge base); `decision` requires it only for opted-in consumers on pull_request events (skipped != failed, event guard mirrors pr-contract), and the job conditions on the input directly so a lagging `workflow-library-ref` cannot silently no-op the gate. `classify-pr.mjs` threads `docsSystem` → `run-docs-gate` output + lane-summary jobs lists. Retired the `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs (guidance cleanup only — the reusable workflow bodies stay for existing consumers; registry/baseline migration is deferred to the S3/C1/T1 cleanup). P1 of the docs-system epic (ArchonVII/repo-template#124). +- **Verification:** TDD RED: `npx vitest run scripts/classify-pr.test.mjs scripts/workflow-structure.test.mjs` → 12 failed / 67 passed before implementation. GREEN: same command → 79/79; full `npm test` (vitest 4.1.7) → 9 files / 179 tests / 179 pass on the lane worktree (baseline on `origin/main` was 168; net +11). `actionlint .github/workflows/repo-required-gate.yml examples/repo-required-gate.yml` exited 0 locally (actionlint 1.7.7 binary). `git diff --check` exited 0. +- **Propagation:** pending — `@v1` retag is owner-gated (retag ships the gate fleet-wide); after retag, the repo-template caller flips `docs-system: true` with a live-red scratch-branch proof (ArchonVII/repo-template#124 P1, issue #104 item 3); archon-setup snapshot refresh picks up the retired example stubs. + ## 2026-07-01 - Example-caller concurrency + anomaly path filter - **Issue/PR:** #101 / (pending) From 3ea0c30cd80656fd23995c37e84e445b42c51af2 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sat, 4 Jul 2026 11:20:27 -0500 Subject: [PATCH 4/5] test(gate): assert detect passes docsSystem env to classifier (#104) Adversarial review (mutation testing) found the one docs-gate wire with no failing test: deleting the detect job's `docsSystem: process.env.DOCS_SYSTEM === 'true'` classifier argument left all 179 tests green while run-docs-gate silently became 'false' for opted-in consumers, making the lane summary report docs-gate as skipped even though decision (which reads inputs.docs-system directly) still requires it. Pin the env -> classifyPR hand-off with a substring assertion; verified the mutation now fails 1/179 and the restored workflow passes 179/179. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019b2W87gyzLPaHBbpuU9Zxt --- scripts/workflow-structure.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/workflow-structure.test.mjs b/scripts/workflow-structure.test.mjs index 059691e..62b0fe6 100644 --- a/scripts/workflow-structure.test.mjs +++ b/scripts/workflow-structure.test.mjs @@ -152,6 +152,12 @@ describe('repo-required-gate docs-gate lane (#104)', () => { expect(body).toContain('run-docs-gate: ${{ steps.detect.outputs.run-docs-gate }}'); expect(body).toContain('DOCS_SYSTEM: ${{ inputs.docs-system }}'); + // The env -> classifyPR hand-off is the one wire the env/output assertions + // above cannot see: without it, classifyPR gets docsSystem=undefined and + // run-docs-gate is silently 'false', so the lane summary reports docs-gate + // as skipped while decision (which reads inputs.docs-system directly) + // still requires it. + expect(body).toContain("docsSystem: process.env.DOCS_SYSTEM === 'true',"); expect(body).toContain( "core.setOutput('run-docs-gate', String(result.outputs.runDocsGate));", ); From 24168115c59e609c00663718dc2c7fe8215b0b1c Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sat, 4 Jul 2026 13:18:23 -0500 Subject: [PATCH 5/5] fix(gate): lockfile-aware install in the docs-gate lane (#104) The docs-gate "Detect package manager" step fell through to `npm ci` whenever no pnpm/yarn lockfile was present. `npm ci` REQUIRES a package-lock.json and hard-fails without one, so an opted-in docs-system consumer that ships package.json + docs scripts but no committed lockfile would go red before either docs check ran. Add the same fallback the shared node-ci.yml already uses: `npm ci` only when package-lock.json exists, else `npm install --no-package-lock`. actionlint exit 0; vitest 179/179. Resolves the PR #105 review thread (no-lockfile docs consumers). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Eiuy6Jd6Pf372zhG2od66m --- .changelog/unreleased/104-docs-gate.md | 2 +- .github/workflows/repo-required-gate.yml | 10 +++++++++- docs/repo-update-log.md | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.changelog/unreleased/104-docs-gate.md b/.changelog/unreleased/104-docs-gate.md index f518fe3..e7bc937 100644 --- a/.changelog/unreleased/104-docs-gate.md +++ b/.changelog/unreleased/104-docs-gate.md @@ -1 +1 @@ -- **repo-required-gate gains an opt-in `docs-system` docs gate.** New boolean input (default false, same opt-in pattern as `stack`): when true, pull_request runs add a `docs-gate` job (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`) aggregated by `decision` with skipped != failed for non-opted consumers. The `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs are retired (guidance cleanup only — the reusable workflow bodies stay). (#104) +- **repo-required-gate gains an opt-in `docs-system` docs gate.** New boolean input (default false, same opt-in pattern as `stack`): when true, pull_request runs add a `docs-gate` job (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`) aggregated by `decision` with skipped != failed for non-opted consumers. Its package-manager detection is lockfile-aware — `npm ci` only when a `package-lock.json` exists, else `npm install --no-package-lock` — mirroring `node-ci.yml`, so a docs consumer that ships `package.json` + docs scripts without a committed lockfile isn't failed before either check runs. The `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs are retired (guidance cleanup only — the reusable workflow bodies stay). (#104) diff --git a/.github/workflows/repo-required-gate.yml b/.github/workflows/repo-required-gate.yml index f9e28ec..e6ab378 100644 --- a/.github/workflows/repo-required-gate.yml +++ b/.github/workflows/repo-required-gate.yml @@ -772,8 +772,16 @@ jobs: echo "install=pnpm install --frozen-lockfile" >> "$GITHUB_OUTPUT" elif [ -f yarn.lock ]; then echo "install=yarn install --frozen-lockfile" >> "$GITHUB_OUTPUT" - else + elif [ -f package-lock.json ]; then echo "install=npm ci" >> "$GITHUB_OUTPUT" + else + # No committed lockfile: a docs-system consumer may ship + # package.json + docs scripts without one. `npm ci` REQUIRES a + # lockfile and would fail the docs gate before either check runs, + # so fall back to a non-lockfile install — mirrors node-ci.yml's + # "Detect package manager" step (node-ci.yml, no-lockfile branch). + # --no-package-lock keeps CI from fabricating a stray lockfile. + echo "install=npm install --no-package-lock" >> "$GITHUB_OUTPUT" fi - name: Install diff --git a/docs/repo-update-log.md b/docs/repo-update-log.md index 27533b6..fc31059 100644 --- a/docs/repo-update-log.md +++ b/docs/repo-update-log.md @@ -20,8 +20,8 @@ This log records agent-visible repository changes that should be easy to audit l - **Issue/PR:** #104 / (pending) - **Branch:** agent/claude/104-docs-gate - **Changed paths:** .github/workflows/repo-required-gate.yml, scripts/classify-pr.mjs, scripts/classify-pr.test.mjs, scripts/workflow-structure.test.mjs, examples/repo-required-gate.yml, examples/changelog-fragment.yml (deleted), examples/repo-update-log-fragment.yml (deleted), README.md, .changelog/unreleased/104-docs-gate.md, docs/repo-update-log.md -- **What changed:** Added an opt-in `docs-system` boolean input (default false) to the reusable `repo-required-gate`. When true, a `docs-gate` job runs on pull_request events after `detect` in the consumer checkout (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`, fetch-depth 0 for the merge base); `decision` requires it only for opted-in consumers on pull_request events (skipped != failed, event guard mirrors pr-contract), and the job conditions on the input directly so a lagging `workflow-library-ref` cannot silently no-op the gate. `classify-pr.mjs` threads `docsSystem` → `run-docs-gate` output + lane-summary jobs lists. Retired the `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs (guidance cleanup only — the reusable workflow bodies stay for existing consumers; registry/baseline migration is deferred to the S3/C1/T1 cleanup). P1 of the docs-system epic (ArchonVII/repo-template#124). -- **Verification:** TDD RED: `npx vitest run scripts/classify-pr.test.mjs scripts/workflow-structure.test.mjs` → 12 failed / 67 passed before implementation. GREEN: same command → 79/79; full `npm test` (vitest 4.1.7) → 9 files / 179 tests / 179 pass on the lane worktree (baseline on `origin/main` was 168; net +11). `actionlint .github/workflows/repo-required-gate.yml examples/repo-required-gate.yml` exited 0 locally (actionlint 1.7.7 binary). `git diff --check` exited 0. +- **What changed:** Added an opt-in `docs-system` boolean input (default false) to the reusable `repo-required-gate`. When true, a `docs-gate` job runs on pull_request events after `detect` in the consumer checkout (`npm run docs:render -- --check` + `node scripts/doc-health/health.mjs --repo . --changed-from origin/ --json`, fetch-depth 0 for the merge base); `decision` requires it only for opted-in consumers on pull_request events (skipped != failed, event guard mirrors pr-contract), and the job conditions on the input directly so a lagging `workflow-library-ref` cannot silently no-op the gate. `classify-pr.mjs` threads `docsSystem` → `run-docs-gate` output + lane-summary jobs lists. **Review round (PR #105 thread):** the docs-gate's "Detect package manager" step fell to `npm ci` with no lockfile guard; `npm ci` REQUIRES a `package-lock.json`, so an opted-in docs consumer that ships `package.json` + docs scripts without a committed lockfile would go red before either docs check ran. Added the same `elif [ -f package-lock.json ]` → `npm ci`, else `npm install --no-package-lock` fallback the shared `node-ci.yml` already uses (node-ci.yml "Detect package manager", no-lockfile branch). Retired the `examples/changelog-fragment.yml` and `examples/repo-update-log-fragment.yml` caller stubs (guidance cleanup only — the reusable workflow bodies stay for existing consumers; registry/baseline migration is deferred to the S3/C1/T1 cleanup). P1 of the docs-system epic (ArchonVII/repo-template#124). +- **Verification:** TDD RED: `npx vitest run scripts/classify-pr.test.mjs scripts/workflow-structure.test.mjs` → 12 failed / 67 passed before implementation. GREEN: same command → 79/79; full `npm test` (vitest 4.1.7) → 9 files / 179 tests / 179 pass on the lane worktree (baseline on `origin/main` was 168; net +11). `actionlint .github/workflows/repo-required-gate.yml examples/repo-required-gate.yml` exited 0 locally (actionlint 1.7.7 binary). `git diff --check` exited 0. Review-round re-verify after the lockfile fallback: `actionlint .github/workflows/repo-required-gate.yml` exit 0, `npx vitest run` → 179/179. - **Propagation:** pending — `@v1` retag is owner-gated (retag ships the gate fleet-wide); after retag, the repo-template caller flips `docs-system: true` with a live-red scratch-branch proof (ArchonVII/repo-template#124 P1, issue #104 item 3); archon-setup snapshot refresh picks up the retired example stubs. ## 2026-07-01 - Example-caller concurrency + anomaly path filter