From 5e30d11b2ccd3cb4fcd3c26e34e688811886833f Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sun, 7 Jun 2026 17:20:46 -0500 Subject: [PATCH 1/2] feat(policy): add validatePrTemplate structure check Add a structure-only validator (and formatter) that checks a committed PR template has the contract's required headings in the required order, without the body-fill checks (checked boxes, placeholders, substantive content) that a template legitimately lacks. Catches the drift class where a repo's own PULL_REQUEST_TEMPLATE.md cannot itself pass the gate it is subject to. Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/pr-contract.mjs | 49 ++++++++++++++++++++ scripts/pr-contract.test.mjs | 86 +++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/scripts/pr-contract.mjs b/scripts/pr-contract.mjs index 709d210..95128dc 100644 --- a/scripts/pr-contract.mjs +++ b/scripts/pr-contract.mjs @@ -98,6 +98,55 @@ export function formatPrContractResult(result) { return lines.join('\n'); } +/** + * Validate that a committed PR template can satisfy the contract's required + * heading structure (all required headings present, in the required order). + * + * Unlike validatePrContract, this checks STRUCTURE ONLY — not checked boxes, + * placeholders, or substantive content — because a template legitimately ships + * with unchecked boxes and TODO/comment placeholders. It catches the drift + * class where a repo's own .github/PULL_REQUEST_TEMPLATE.md cannot itself pass + * the gate it is subject to (e.g. a pre-strict template using `## Changelog` + * instead of `## Docs / Changelog`, or missing `### Verification Notes`). + * Source: /page-gm incident 2026-06-07 (ArchonVII/hudson-bend#43; + * ArchonVII/github-workflows#53). + * + * @param {string} templateBody Raw PULL_REQUEST_TEMPLATE.md contents. + * @param {object} [options] + * @param {Array} [options.requiredHeadings] Defaults to DEFAULT_REQUIRED_HEADINGS. + * @returns {{ok:boolean, errors:Array<{code:string,message:string,path:string}>, warnings:Array, facts:object}} + */ +export function validatePrTemplate(templateBody, options = {}) { + const required = normalizeHeadings(options.requiredHeadings || DEFAULT_REQUIRED_HEADINGS); + const errors = []; + const headings = parseHeadings(templateBody || ''); + validateHeadingOrder(headings, required, errors); + return { + ok: errors.length === 0, + errors, + warnings: [], + facts: { headingCount: headings.length }, + }; +} + +export function formatPrTemplateResult(result) { + if (result.ok) { + return 'PR template conforms to the required contract structure.'; + } + + const lines = [ + 'PR template does NOT conform to the strict PR contract structure.', + 'Filling this template out verbatim would fail `repo-required-gate / pr contract`.', + 'Sync `.github/PULL_REQUEST_TEMPLATE.md` from ArchonVII/repo-template.', + '', + 'Structure issues:', + ]; + for (const item of result.errors) { + lines.push(`- [${item.code}] ${item.message}`); + } + return lines.join('\n'); +} + export function loadPrFromGh({ repo, pr }) { if (!repo) throw new Error('Missing required --repo owner/name argument.'); if (!pr) throw new Error('Missing required --pr number argument.'); diff --git a/scripts/pr-contract.test.mjs b/scripts/pr-contract.test.mjs index 6469155..5a9984b 100644 --- a/scripts/pr-contract.test.mjs +++ b/scripts/pr-contract.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validatePrContract } from './pr-contract.mjs'; +import { validatePrContract, validatePrTemplate, formatPrTemplateResult } from './pr-contract.mjs'; const validBody = [ '## Summary', @@ -115,3 +115,87 @@ describe('validatePrContract', () => { expect(result.facts.docsOnly).toBe(true); }); }); + +const canonicalTemplate = [ + '## Summary', + '', + 'TODO: What changed and why?', + '', + '## Verification', + '', + '- [ ] TODO', + '', + '### Verification Notes', + '', + 'TODO: Summarize verification.', + '', + '## Docs / Changelog', + '', + 'TODO: changelog fragment or no-changelog label.', + '', + '## Linked Issue', + '', + 'Closes #', +].join('\n'); + +// The pre-strict template that caused ArchonVII/hudson-bend#43: `## Changelog` +// in the wrong position, no `## Docs / Changelog`. +const staleTemplate = [ + '## Summary', + '', + '## Linked Issue', + '', + 'Closes #', + '', + '## Scope', + '', + '## Changelog', + '', + '## Verification', + '', + '### Verification Notes', + '', + '## Risks', +].join('\n'); + +describe('validatePrTemplate', () => { + it('accepts the canonical template structure (unchecked boxes + placeholders are fine)', () => { + const result = validatePrTemplate(canonicalTemplate); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('flags a pre-strict template missing `## Docs / Changelog`', () => { + const result = validatePrTemplate(staleTemplate); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'missing_heading')).toBe(true); + }); + + it('flags a template missing `### Verification Notes`', () => { + const noNotes = [ + '## Summary', + '', + '## Verification', + '', + '- [ ] x', + '', + '## Docs / Changelog', + '', + '## Linked Issue', + ].join('\n'); + const result = validatePrTemplate(noNotes); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.code === 'missing_heading')).toBe(true); + }); + + it('formats a failing result with a sync hint', () => { + const report = formatPrTemplateResult(validatePrTemplate(staleTemplate)); + expect(report).toContain('does NOT conform'); + expect(report).toContain('Sync'); + }); + + it('formats a passing result', () => { + expect(formatPrTemplateResult(validatePrTemplate(canonicalTemplate))) + .toContain('conforms'); + }); +}); From 7d35955bb227189b3cbcc4493c418a20fa6320a4 Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Sun, 7 Jun 2026 17:20:46 -0500 Subject: [PATCH 2/2] ci(repo-required-gate): warn on consumer PR-template drift Add a warning-only step to the pr-contract job that fetches the caller repo's .github/PULL_REQUEST_TEMPLATE.md and validates its structure against the contract, surfacing drift directly instead of via a confusing body-check failure. Phase 1 = warning-only (never hard-fails), matching the evidence parser. Reaches consumers only after a @v1 retag. Refs #53 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/repo-required-gate.yml | 61 ++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.github/workflows/repo-required-gate.yml b/.github/workflows/repo-required-gate.yml index 4c22969..67ac85e 100644 --- a/.github/workflows/repo-required-gate.yml +++ b/.github/workflows/repo-required-gate.yml @@ -462,6 +462,67 @@ jobs: core.setFailed(report); } + # Warning-first guard against consumer PR-template drift. A repo's own + # .github/PULL_REQUEST_TEMPLATE.md is copied at onboarding and tracked by + # nothing afterward, so it silently rots out of sync with the strict + # contract (e.g. pre-strict `## Changelog` instead of `## Docs / Changelog`, + # or missing `### Verification Notes`). Filling such a template out + # verbatim then fails the body check above with a confusing error. This + # step surfaces the drift directly. Phase 1 = warning-only (never hard- + # fails), matching the evidence parser. Source: /page-gm incident + # 2026-06-07 (ArchonVII/hudson-bend#43; #53). + - name: Warn if the repo PR template can't pass the contract + if: ${{ !cancelled() && github.event.pull_request.draft == false }} + uses: actions/github-script@v7 + env: + WORKFLOW_LIBRARY_REF: ${{ inputs.workflow-library-ref }} + with: + script: | + const path = require('path'); + const url = require('url'); + + let templateBody = null; + try { + const resp = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/PULL_REQUEST_TEMPLATE.md', + ref: context.payload.pull_request.base.ref, + }); + templateBody = Buffer.from(resp.data.content, resp.data.encoding || 'base64').toString('utf8'); + } catch (err) { + if (err && err.status === 404) { + core.info('No .github/PULL_REQUEST_TEMPLATE.md; template conformance check skipped.'); + return; + } + core.warning(`Could not read PR template: ${err && err.message ? err.message : err}. Conformance check skipped.`); + return; + } + + const validatorPath = path.resolve( + process.env.GITHUB_WORKSPACE, + '__github-workflows__/scripts/pr-contract.mjs', + ); + const ref = process.env.WORKFLOW_LIBRARY_REF || '(unset)'; + let validatePrTemplate; + let formatPrTemplateResult; + try { + ({ validatePrTemplate, formatPrTemplateResult } = await import(url.pathToFileURL(validatorPath).href)); + } catch (err) { + core.warning(`Could not load PR template validator at ${validatorPath} (workflow-library-ref=${ref}): ${err && err.message ? err.message : err}. Conformance check skipped.`); + return; + } + + const result = validatePrTemplate(templateBody); + const report = formatPrTemplateResult(result); + await core.summary + .addHeading(result.ok ? 'PR template conformance' : 'PR template drift (warning)', 3) + .addCodeBlock(report, 'text') + .write(); + if (!result.ok) { + core.warning(`pr-template: ${report}`); + } + workflow-validation: name: workflow and hook validation needs: