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
59 changes: 59 additions & 0 deletions .github/actions/path-classifier/classify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^\.\/+/, '');
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -345,4 +403,5 @@ module.exports = {
matchesAny,
normalizePath,
parseClassificationConfig,
stableDeliverySealStatus,
};
67 changes: 67 additions & 0 deletions .github/scripts/__tests__/path-classifier.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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,
);
});
2 changes: 1 addition & 1 deletion .github/scripts/issue_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
9 changes: 8 additions & 1 deletion docs/ops/CONSUMER_REPO_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions scripts/langchain/issue_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^\.\/+/, '');
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -345,4 +403,5 @@ module.exports = {
matchesAny,
normalizePath,
parseClassificationConfig,
stableDeliverySealStatus,
};
2 changes: 1 addition & 1 deletion templates/consumer-repo/.github/scripts/issue_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
8 changes: 6 additions & 2 deletions templates/consumer-repo/scripts/langchain/issue_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions tests/scripts/test_issue_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
28 changes: 28 additions & 0 deletions tests/scripts/test_issue_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading