Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/repo-required-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions scripts/pr-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
86 changes: 85 additions & 1 deletion scripts/pr-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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');
});
});
Loading