diff --git a/.github/actions/path-classifier/classify.js b/.github/actions/path-classifier/classify.js index dbc081a0f..cd890f3a1 100644 --- a/.github/actions/path-classifier/classify.js +++ b/.github/actions/path-classifier/classify.js @@ -25,6 +25,11 @@ const DEFAULT_CATEGORIES = { 'test-only': { paths: ['tests/**', '**/test_*.py', '**/*.test.js'], requireAll: true }, }; +const STABLE_SYNC_BRANCHES = new Set([ + 'sync/workflows-candidate', + 'sync/workflows-delivery', +]); + function normalizePath(value) { return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); } @@ -182,6 +187,50 @@ function parseGithubContext() { } } +function stableDeliverySealStatus(githubContext, { contract, now } = {}) { + const event = githubContext?.event || {}; + const pullRequest = event.pull_request; + const branch = pullRequest?.head?.ref || ''; + if (githubContext?.event_name !== 'pull_request' || !STABLE_SYNC_BRANCHES.has(branch)) { + return { required: false, valid: true, reason: '' }; + } + + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headRepository || !baseRepository || headRepository !== baseRepository) { + return { + required: true, + valid: false, + reason: 'stable delivery must originate from the base repository', + }; + } + if (!contract) { + return { required: true, valid: false, reason: 'delivery contract is unavailable' }; + } + + const record = contract.parseDeliveryRecord(pullRequest?.body || ''); + const eligibility = contract.mergeEligibility(record, { + now: now || new Date().toISOString(), + repository: baseRepository, + requireSealed: true, + headSha: pullRequest?.head?.sha || '', + }); + return { + required: true, + valid: Boolean(eligibility.eligible), + reason: eligibility.reason, + }; +} + +function loadDeliveryContract() { + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const contractPath = path.resolve(workspace, '.github/scripts/sync_pr_lease_contract.js'); + if (!fs.existsSync(contractPath)) { + return null; + } + return require(contractPath); +} + function runGit(args) { return execFileSync('git', args, { cwd: process.env.GITHUB_WORKSPACE || process.cwd(), @@ -312,6 +361,15 @@ function writeOutputs(outputs) { function main() { const githubContext = parseGithubContext(); + const seal = stableDeliverySealStatus(githubContext, { + contract: loadDeliveryContract(), + }); + if (seal.required && !seal.valid) { + throw new Error( + `Mutable generated delivery is not mergeable: ${seal.reason}. ` + + 'Maint 71 must seal this exact head after bounded reviewer settlement.', + ); + } const forceFull = String(process.env.INPUT_FORCE_FULL || '').toLowerCase() === 'true'; const configPath = process.env.INPUT_CONFIG_PATH || '.github/path-classification.yml'; const baseRef = resolveBaseRef(process.env.INPUT_BASE_REF || '', githubContext); @@ -345,4 +403,5 @@ module.exports = { matchesAny, normalizePath, parseClassificationConfig, + stableDeliverySealStatus, }; diff --git a/.github/scripts/__tests__/path-classifier.test.js b/.github/scripts/__tests__/path-classifier.test.js index f4eab7fe0..75dac7ff6 100644 --- a/.github/scripts/__tests__/path-classifier.test.js +++ b/.github/scripts/__tests__/path-classifier.test.js @@ -9,7 +9,9 @@ const { globToRegExp, matchesAny, parseClassificationConfig, + stableDeliverySealStatus, } = require('../../actions/path-classifier/classify.js'); +const deliveryContract = require('../sync_pr_lease_contract.js'); const CONFIG = { categories: DEFAULT_CATEGORIES }; @@ -115,3 +117,68 @@ categories: paths: ['**/*.py', 'pyproject.toml'], }); }); + +function deliveryContext(record, { branch = 'sync/workflows-delivery', fork = false } = {}) { + return { + event_name: 'pull_request', + event: { + pull_request: { + body: deliveryContract.formatDeliveryRecord(record), + head: { + ref: branch, + sha: 'head-abc', + repo: { full_name: fork ? 'attacker/Ready' : 'stranske/Ready' }, + }, + base: { repo: { full_name: 'stranske/Ready' } }, + }, + }, + }; +} + +const deliveryRecord = { + schema: 'sync-pr-delivery-record/v1', + durable_issue_url: 'https://github.com/stranske/Workflows/issues/1836', + plan_id: 'plan-abc', + generation: 'generation-abc', + repository: 'stranske/Ready', + desired_tree_hash: 'tree-abc', + source_commit: 'source-abc', + lease_expires_at: '2099-08-14T00:00:00Z', + predecessor_prs: [], + successor_prs: [], + delivery_state: 'staging', +}; + +test('custom Gate classifier rejects an unsealed stable delivery', () => { + assert.deepEqual( + stableDeliverySealStatus(deliveryContext(deliveryRecord), { + contract: deliveryContract, + now: '2026-08-12T00:00:00Z', + }), + { required: true, valid: false, reason: 'delivery_not_sealed:staging' }, + ); +}); + +test('custom Gate classifier accepts only the sealed exact head', () => { + const sealed = { + ...deliveryRecord, + delivery_state: 'sealed', + review_started_at: '2026-08-12T00:00:00Z', + sealed_at: '2026-08-12T00:15:00Z', + sealed_head_sha: 'head-abc', + }; + assert.deepEqual( + stableDeliverySealStatus(deliveryContext(sealed), { + contract: deliveryContract, + now: '2026-08-12T00:20:00Z', + }), + { required: true, valid: true, reason: 'current_unexpired' }, + ); + assert.equal( + stableDeliverySealStatus(deliveryContext(sealed, { fork: true }), { + contract: deliveryContract, + now: '2026-08-12T00:20:00Z', + }).valid, + false, + ); +}); diff --git a/.github/scripts/issue_format.py b/.github/scripts/issue_format.py index c86bfee1c..53e5af375 100644 --- a/.github/scripts/issue_format.py +++ b/.github/scripts/issue_format.py @@ -280,7 +280,7 @@ def _candidate_matches(text: str) -> list[tuple[int, int, str]]: _EXPLICIT_CREATE_PREFIX = re.compile( r"\b(?:create|add|introduce|scaffold|generate|write)\s+" - r"(?:(?:a|the)\s+)?(?:new\s+)?(?:file\s+)?(?:at\s+|named\s+)?$", + r"(?:(?:a|the)\s+)?(?:new\s+)?(?:files?\s+)?(?:at\s+|named\s+)?$", re.I, ) diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index f8a351d41..beb116685 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -47,6 +47,11 @@ Some repos cannot use the template `pr-00-gate.yml` because: For these repos: - The Gate workflow (`pr-00-gate.yml`) is maintained locally and excluded from sync. +- A custom Gate must invoke the exact-synced `.github/actions/path-classifier` + (or enforce the equivalent delivery-record check itself). The classifier + rejects stable candidate/delivery PRs until Maint 71 seals the exact head, so + a custom aggregate `Gate / gate` cannot report success while delivery is + still mutable. - `Trend_Model_Project` skips the synced `AGENTS.md` file and keeps its local `Agents.md`. - `trip-planner` skips the synced `.github/scripts/package.json` and vendored @@ -359,7 +364,9 @@ This is also run automatically as the first step of both `maint-68-sync-consumer (before any PR creation) and `health-70-validate-sync-manifest.yml` (on every PR that touches the manifest). -Custom Gate repos are a special-case skip: their `pr-00-gate.yml` stays local. +Custom Gate repos are a special-case skip: their `pr-00-gate.yml` stays local, +but their Gate must retain the exact-synced path classifier or an equivalent +exact-head delivery-seal check. The `Template` repository is the canonical source for new consumer repos, so it must not preserve stale copies of files that are `create_only` for real diff --git a/scripts/langchain/issue_formatter.py b/scripts/langchain/issue_formatter.py index 1c50c8ad6..b52aa13ad 100755 --- a/scripts/langchain/issue_formatter.py +++ b/scripts/langchain/issue_formatter.py @@ -449,14 +449,18 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: def _formatted_output_valid(text: str) -> bool: if not text: return False + # The archived Original Issue is provenance, not executable formatted + # content. Its stale or cross-repo path citations must not reverse a valid + # formatter result after the visible body has already passed validation. + visible_text = _strip_original_issue_blocks(text) try: workspace = os.environ.get("GITHUB_WORKSPACE", "").strip() repo_root = Path(workspace).resolve() if workspace else Path.cwd().resolve() - return bool(_issue_format_validator().validate(text, repo_root=repo_root).ok) + return bool(_issue_format_validator().validate(visible_text, repo_root=repo_root).ok) except (ImportError, OSError, RuntimeError, SyntaxError): # Preserve the former heading-only behavior until the copy-synced # validator becomes available again. - return all(section in text for section in ("## Tasks", "## Acceptance Criteria")) + return all(section in visible_text for section in ("## Tasks", "## Acceptance Criteria")) def _select_code_fence(text: str) -> str: diff --git a/templates/consumer-repo/.github/actions/path-classifier/classify.js b/templates/consumer-repo/.github/actions/path-classifier/classify.js index dbc081a0f..cd890f3a1 100644 --- a/templates/consumer-repo/.github/actions/path-classifier/classify.js +++ b/templates/consumer-repo/.github/actions/path-classifier/classify.js @@ -25,6 +25,11 @@ const DEFAULT_CATEGORIES = { 'test-only': { paths: ['tests/**', '**/test_*.py', '**/*.test.js'], requireAll: true }, }; +const STABLE_SYNC_BRANCHES = new Set([ + 'sync/workflows-candidate', + 'sync/workflows-delivery', +]); + function normalizePath(value) { return String(value || '').replace(/\\/g, '/').replace(/^\.\/+/, ''); } @@ -182,6 +187,50 @@ function parseGithubContext() { } } +function stableDeliverySealStatus(githubContext, { contract, now } = {}) { + const event = githubContext?.event || {}; + const pullRequest = event.pull_request; + const branch = pullRequest?.head?.ref || ''; + if (githubContext?.event_name !== 'pull_request' || !STABLE_SYNC_BRANCHES.has(branch)) { + return { required: false, valid: true, reason: '' }; + } + + const headRepository = pullRequest?.head?.repo?.full_name || ''; + const baseRepository = pullRequest?.base?.repo?.full_name || ''; + if (!headRepository || !baseRepository || headRepository !== baseRepository) { + return { + required: true, + valid: false, + reason: 'stable delivery must originate from the base repository', + }; + } + if (!contract) { + return { required: true, valid: false, reason: 'delivery contract is unavailable' }; + } + + const record = contract.parseDeliveryRecord(pullRequest?.body || ''); + const eligibility = contract.mergeEligibility(record, { + now: now || new Date().toISOString(), + repository: baseRepository, + requireSealed: true, + headSha: pullRequest?.head?.sha || '', + }); + return { + required: true, + valid: Boolean(eligibility.eligible), + reason: eligibility.reason, + }; +} + +function loadDeliveryContract() { + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const contractPath = path.resolve(workspace, '.github/scripts/sync_pr_lease_contract.js'); + if (!fs.existsSync(contractPath)) { + return null; + } + return require(contractPath); +} + function runGit(args) { return execFileSync('git', args, { cwd: process.env.GITHUB_WORKSPACE || process.cwd(), @@ -312,6 +361,15 @@ function writeOutputs(outputs) { function main() { const githubContext = parseGithubContext(); + const seal = stableDeliverySealStatus(githubContext, { + contract: loadDeliveryContract(), + }); + if (seal.required && !seal.valid) { + throw new Error( + `Mutable generated delivery is not mergeable: ${seal.reason}. ` + + 'Maint 71 must seal this exact head after bounded reviewer settlement.', + ); + } const forceFull = String(process.env.INPUT_FORCE_FULL || '').toLowerCase() === 'true'; const configPath = process.env.INPUT_CONFIG_PATH || '.github/path-classification.yml'; const baseRef = resolveBaseRef(process.env.INPUT_BASE_REF || '', githubContext); @@ -345,4 +403,5 @@ module.exports = { matchesAny, normalizePath, parseClassificationConfig, + stableDeliverySealStatus, }; diff --git a/templates/consumer-repo/.github/scripts/issue_format.py b/templates/consumer-repo/.github/scripts/issue_format.py index c86bfee1c..53e5af375 100644 --- a/templates/consumer-repo/.github/scripts/issue_format.py +++ b/templates/consumer-repo/.github/scripts/issue_format.py @@ -280,7 +280,7 @@ def _candidate_matches(text: str) -> list[tuple[int, int, str]]: _EXPLICIT_CREATE_PREFIX = re.compile( r"\b(?:create|add|introduce|scaffold|generate|write)\s+" - r"(?:(?:a|the)\s+)?(?:new\s+)?(?:file\s+)?(?:at\s+|named\s+)?$", + r"(?:(?:a|the)\s+)?(?:new\s+)?(?:files?\s+)?(?:at\s+|named\s+)?$", re.I, ) diff --git a/templates/consumer-repo/scripts/langchain/issue_formatter.py b/templates/consumer-repo/scripts/langchain/issue_formatter.py index 1c50c8ad6..b52aa13ad 100755 --- a/templates/consumer-repo/scripts/langchain/issue_formatter.py +++ b/templates/consumer-repo/scripts/langchain/issue_formatter.py @@ -449,14 +449,18 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str: def _formatted_output_valid(text: str) -> bool: if not text: return False + # The archived Original Issue is provenance, not executable formatted + # content. Its stale or cross-repo path citations must not reverse a valid + # formatter result after the visible body has already passed validation. + visible_text = _strip_original_issue_blocks(text) try: workspace = os.environ.get("GITHUB_WORKSPACE", "").strip() repo_root = Path(workspace).resolve() if workspace else Path.cwd().resolve() - return bool(_issue_format_validator().validate(text, repo_root=repo_root).ok) + return bool(_issue_format_validator().validate(visible_text, repo_root=repo_root).ok) except (ImportError, OSError, RuntimeError, SyntaxError): # Preserve the former heading-only behavior until the copy-synced # validator becomes available again. - return all(section in text for section in ("## Tasks", "## Acceptance Criteria")) + return all(section in visible_text for section in ("## Tasks", "## Acceptance Criteria")) def _select_code_fence(text: str) -> str: diff --git a/tests/scripts/test_issue_format.py b/tests/scripts/test_issue_format.py index 8717a37c3..8c29ebfd5 100644 --- a/tests/scripts/test_issue_format.py +++ b/tests/scripts/test_issue_format.py @@ -661,6 +661,25 @@ def test_one_create_phrase_governs_a_list_of_new_paths(tmp_path, validator_path: assert validator.validate(body, repo_root=tmp_path).ok +@pytest.mark.parametrize( + "validator_path", + [ + Path(".github/scripts/issue_format.py"), + Path("templates/consumer-repo/.github/scripts/issue_format.py"), + ], +) +def test_plural_files_create_phrase_governs_all_new_paths(tmp_path, validator_path: Path) -> None: + validator = _validator(validator_path) + body = ( + VALID_CONTEXT + + "## Tasks\n" + + "- [ ] Create files `src/a.py`, `src/b.py`, and `src/c.py`\n\n" + + "## Acceptance Criteria\n- pytest tests/test_x.py passes\n" + ) + assert validator._created_paths(body) == {"src/a.py", "src/b.py", "src/c.py"} + assert validator.validate(body, repo_root=tmp_path).ok + + def test_create_path_chain_stops_when_task_switches_to_modification(tmp_path) -> None: validator = _validator() body = ( diff --git a/tests/scripts/test_issue_formatter.py b/tests/scripts/test_issue_formatter.py index c48cb2b3c..13fd1e7dd 100644 --- a/tests/scripts/test_issue_formatter.py +++ b/tests/scripts/test_issue_formatter.py @@ -79,6 +79,34 @@ def validate(body: str, repo_root: Path | None = None) -> ValidationResult: assert seen["repo_root"] == tmp_path.resolve() +def test_formatted_output_validation_excludes_archived_original_issue( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + seen: dict[str, str] = {"body": ""} + + class ValidationResult: + ok = True + + class Validator: + @staticmethod + def validate(body: str, repo_root: Path | None = None) -> ValidationResult: + seen["body"] = body + return ValidationResult() + + visible = "## Tasks\n\n- [ ] Update `scripts/live.py`\n\n## Acceptance Criteria\n\n- pytest tests/test_live.py passes" + archived = issue_formatter._append_raw_issue_section( + visible, + "Old report cites `missing/a.py`, `missing/b.py`, and `missing/c.py`.", + ) + monkeypatch.setattr(issue_formatter, "_issue_format_validator", lambda: Validator()) + monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path)) + + assert issue_formatter._formatted_output_valid(archived) is True + assert "Original Issue" not in seen["body"] + assert "missing/a.py" not in seen["body"] + assert "scripts/live.py" in seen["body"] + + def _install_fake_langchain(monkeypatch: pytest.MonkeyPatch, mock_chain: mock.MagicMock) -> None: mock_template = mock.MagicMock() mock_template.__or__ = mock.MagicMock(return_value=mock_chain)