diff --git a/.changelog/unreleased/104-docs-gate.md b/.changelog/unreleased/104-docs-gate.md
new file mode 100644
index 0000000..e7bc937
--- /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. 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 faa8594..e6ab378 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,69 @@ 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"
+ 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
+ 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 +810,7 @@ jobs:
- python-ci
- go-ci
- snapshot-validation
+ - docs-gate
if: always()
runs-on: ubuntu-latest
steps:
@@ -744,6 +826,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 +836,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 +891,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/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/docs/repo-update-log.md b/docs/repo-update-log.md
index 1cce8ad..fc31059 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. **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
- **Issue/PR:** #101 / (pending)
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-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/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/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..62b0fe6 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');
@@ -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,79 @@ 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 }}');
+ // 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));",
+ );
+ });
+
+ 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');
@@ -216,11 +290,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);
});
});