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
42 changes: 42 additions & 0 deletions .github/ISSUE_TEMPLATE/change-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Change contract
description: Propose a pjson change with a repo-guard change contract.
title: "[change] "
body:
- type: markdown
attributes:
value: |
Describe the proposed change and include a YAML change contract block.
`authorized_governance_paths` is a privileged field: it belongs in the issue body, not the PR body.
- type: textarea
id: description
attributes:
label: Description
description: What does this change do and why?
validations:
required: true
- type: textarea
id: contract
attributes:
label: Change Contract
description: Paste a repo-guard YAML change contract block.
value: |
```repo-guard-yaml
change_type: feature
scope:
- repo-policy.json
budgets: {}
anchors:
affects: []
implements: []
verifies: []
# Uncomment and list governance files this issue sanctions changing.
# Only the contract in THIS issue body can unlock policy.paths.governance_paths.
# authorized_governance_paths:
# - repo-policy.json
must_touch: []
must_not_touch: []
expected_effects:
- Describe the expected effect
```
validations:
required: true
2 changes: 2 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
## Change Contract

<!-- Keep this block in the PR body so repo-guard can validate the change. -->
<!-- PR body stores change intent. authorized_governance_paths in this PR body is not trusted;
governance changes must be sanctioned from the linked issue body. -->

```repo-guard-yaml
change_type: feature
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/repo-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

- name: Run repo-guard
id: repo_guard
uses: netkeep80/repo-guard@88fdc275cbc9bd835cc20c638d83d832027182c7
uses: netkeep80/repo-guard@99bf716da62c5d01070aa0d7e4d4f8031b43a351
with:
mode: check-pr
enforcement: blocking
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'repo-policy.json'
- '.github/workflows/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/ISSUE_TEMPLATE/**'
pull_request:
paths:
- 'requirements/**'
Expand All @@ -19,6 +20,7 @@ on:
- 'repo-policy.json'
- '.github/workflows/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/ISSUE_TEMPLATE/**'

permissions:
contents: read
Expand All @@ -42,7 +44,7 @@ jobs:
run: node scripts/validate-repo-guard-workflow.js

- name: Валидация repo-guard policy
uses: netkeep80/repo-guard@88fdc275cbc9bd835cc20c638d83d832027182c7
uses: netkeep80/repo-guard@99bf716da62c5d01070aa0d7e4d4f8031b43a351
with:
mode: check-diff
enforcement: blocking
1 change: 1 addition & 0 deletions .gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# .gitkeep file auto-generated at 2026-04-20T23:12:54.984Z for PR creation at branch issue-24-4c32bd4f9a73 for issue https://github.com/netkeep80/pjson/issues/24
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,20 @@ governance, requirements, docs, scripts и CI изменений.
- `anchors.verifies` — требования, для которых PR добавляет тест или другой
исполняемый check.

Governance trust model разделяет intent и authorization. PR body остаётся
каналом change intent: `change_type`, `scope`, `budgets`, `must_touch`,
`must_not_touch`, `expected_effects` и `anchors.*`. Поле
`authorized_governance_paths` в PR body игнорируется как untrusted source.
Governance edits санкционируются только через linked issue body: в issue должен
быть блок `repo-guard-yaml` с `authorized_governance_paths`, покрывающим
изменяемые файлы из `paths.governance_paths`.

Для `check-pr` governance boundary читается из base branch repo-policy.json, а
не из версии policy внутри PR. Если trusted base boundary невозможно прочитать
или разобрать, gate должен fail closed и не принимать governance decision на
основе mutable head policy. Requirements-aware anchors, trace rules и evidence
rules остаются обязательным слоем поверх этой модели доверия.

Заявленные в `anchors.affects` требования должны сопровождаться evidence-файлом
из списков `must_touch_any` в `repo-policy.json`. То же правило действует для
`anchors.implements` и `anchors.verifies` со своими evidence surfaces; иначе
Expand Down
17 changes: 17 additions & 0 deletions docs/requirements-strict-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,23 @@ pjson усиливает базовый профиль локальными пр
| `anchors.verifies` принимают verification evidence в `tests/**`, `experiments/**`, `scripts/**`, `.github/workflows/**` | До появления C++ test tree исполняемые проверки живут в scripts и experiments |
| Запрещены временные файлы, build outputs, logs и `node_modules/**` | Строгий профиль не должен пропускать operational мусор в PR diff |

## Governance trust model [FR-006](../requirements/functional/FR-006.json)

`requirements-strict` остаётся requirements-aware policy: anchors, trace rules
и evidence rules не заменяются governance authorization, а выполняются вместе с
ней. Governance changes добавляют отдельную модель доверия:

- PR body описывает только change intent (`change_type`, `scope`, `budgets`,
`must_touch`, `must_not_touch`, `expected_effects`, `anchors.*`);
- linked issue body является trusted channel для privileged field
`authorized_governance_paths`;
- `authorized_governance_paths` в PR body считается untrusted source и не
открывает правку файлов из `paths.governance_paths`;
- `check-pr` читает `paths.governance_paths` из trusted base policy, то есть из
`repo-policy.json` базовой ветки, а не из head policy текущего PR;
- если trusted base policy нельзя прочитать или распарсить, gate должен
fail closed вместо fallback на mutable policy из PR.

## Оставшиеся bespoke проверки [FR-005](../requirements/functional/FR-005.json)

`scripts/validate-requirements.js` остается нужным для проверок, которые
Expand Down
1 change: 1 addition & 0 deletions repo-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"governance_paths": [
"repo-policy.json",
".github/PULL_REQUEST_TEMPLATE.md",
".github/ISSUE_TEMPLATE/change-contract.yml",
".github/workflows/",
"docs/requirements-strict-profile.md",
"requirements/README.md",
Expand Down
5 changes: 5 additions & 0 deletions requirements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,8 @@ repo-guard check-diff
3. Ссылки на требования в Markdown разрешаются
4. Строгие архитектурные заголовки содержат bracketed requirement link
5. Измененные требования и declared PR anchors имеют evidence-файлы

Governance-файлы проверяются дополнительным trust model слоем: PR body содержит
change intent, а `authorized_governance_paths` принимается только из linked
issue body. В `check-pr` список `paths.governance_paths` читается из policy
базовой ветки; если эта trusted boundary недоступна, gate должен fail closed.
82 changes: 81 additions & 1 deletion scripts/validate-repo-guard-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ const fs = require('fs');
const path = require('path');

const PROJECT_ROOT = path.join(__dirname, '..');
const EXPECTED_ACTION = 'netkeep80/repo-guard@88fdc275cbc9bd835cc20c638d83d832027182c7';
const EXPECTED_ACTION_REF = '99bf716da62c5d01070aa0d7e4d4f8031b43a351';
const EXPECTED_ACTION = `netkeep80/repo-guard@${EXPECTED_ACTION_REF}`;
const EXPECTED_ENFORCEMENT = 'blocking';

let errors = 0;
let repoGuardWorkflowRefs = [];
let requirementsWorkflowRefs = [];

function error(msg) {
console.error(`ОШИБКА: ${msg}`);
Expand Down Expand Up @@ -73,6 +76,32 @@ function requireArrayNotIncludes(label, values, forbidden) {
}
}

function extractRepoGuardActionRefs(text) {
const refs = [];
const pattern = /uses:\s+netkeep80\/repo-guard@([^\s#]+)/g;
let match;
while ((match = pattern.exec(text)) !== null) {
refs.push(match[1].replace(/^['"]|['"]$/g, ''));
}
return refs;
}

function requireSingleExpectedRepoGuardAction(label, text) {
const refs = extractRepoGuardActionRefs(text);
if (refs.length !== 1) {
error(`${label}: должен быть ровно один uses: ${EXPECTED_ACTION}, найдено ${refs.length}`);
return refs;
}
const ref = refs[0];
if (!/^[0-9a-f]{40}$/.test(ref)) {
error(`${label}: repo-guard Action должен быть pinned на commit SHA, получено '${ref}'`);
}
if (ref !== EXPECTED_ACTION_REF) {
error(`${label}: repo-guard Action должен использовать ${EXPECTED_ACTION_REF}, получено '${ref}'`);
}
return refs;
}

info('Проверка repo-guard workflow...');

const workflow = readText('.github/workflows/repo-guard.yml');
Expand Down Expand Up @@ -119,6 +148,7 @@ if (workflow !== null) {
`uses: ${EXPECTED_ACTION}`,
`workflow должен использовать pinned Action ${EXPECTED_ACTION}`
);
repoGuardWorkflowRefs = requireSingleExpectedRepoGuardAction('.github/workflows/repo-guard.yml', workflow);
requireContains(
'.github/workflows/repo-guard.yml',
workflow,
Expand Down Expand Up @@ -181,7 +211,10 @@ if (policy !== null) {
const governancePaths = policy.paths && policy.paths.governance_paths;
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, 'repo-policy.json');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, '.github/PULL_REQUEST_TEMPLATE.md');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, '.github/ISSUE_TEMPLATE/change-contract.yml');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, '.github/workflows/');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, 'requirements/README.md');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, 'requirements/schemas/');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, 'scripts/validate-repo-guard-workflow.js');
requireArrayIncludes('repo-policy.json paths.governance_paths', governancePaths, 'docs/requirements-strict-profile.md');
requireArrayNotIncludes('repo-policy.json paths.governance_paths', governancePaths, 'scripts/validate-docs-headings.js');
Expand Down Expand Up @@ -250,12 +283,23 @@ info('Проверка PR template и README...');
const prTemplate = readText('.github/PULL_REQUEST_TEMPLATE.md');
if (prTemplate !== null) {
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, '```repo-guard-yaml', 'шаблон должен содержать repo-guard YAML block');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'change intent', 'шаблон должен объяснять, что PR body хранит только change intent');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'authorized_governance_paths in this PR body is not trusted', 'шаблон должен запрещать доверять governance authorization из PR body');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'linked issue body', 'шаблон должен направлять governance authorization в linked issue body');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'anchors:', 'шаблон должен содержать секцию anchors');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'affects:', 'шаблон должен позволять объявлять affected anchors');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'implements:', 'шаблон должен позволять объявлять implemented anchors');
requireContains('.github/PULL_REQUEST_TEMPLATE.md', prTemplate, 'verifies:', 'шаблон должен позволять объявлять verified anchors');
}

const issueTemplate = readText('.github/ISSUE_TEMPLATE/change-contract.yml');
if (issueTemplate !== null) {
requireContains('.github/ISSUE_TEMPLATE/change-contract.yml', issueTemplate, '```repo-guard-yaml', 'issue form должен содержать repo-guard YAML block');
requireContains('.github/ISSUE_TEMPLATE/change-contract.yml', issueTemplate, 'authorized_governance_paths', 'issue form должен документировать privileged authorization field');
requireContains('.github/ISSUE_TEMPLATE/change-contract.yml', issueTemplate, 'privileged field', 'issue form должен явно называть authorized_governance_paths privileged field');
requireContains('.github/ISSUE_TEMPLATE/change-contract.yml', issueTemplate, 'Only the contract in THIS issue body can unlock policy.paths.governance_paths', 'issue form должен фиксировать trusted issue channel');
}

const readme = readText('README.md');
if (readme !== null) {
requireContains('README.md', readme, '.github/workflows/repo-guard.yml', 'документация должна упоминать PR workflow repo-guard');
Expand All @@ -265,13 +309,20 @@ if (readme !== null) {
requireContains('README.md', readme, 'anchors.affects', 'документация должна объяснять affected anchors в PR body');
requireContains('README.md', readme, 'anchors.implements', 'документация должна объяснять implemented anchors в PR body');
requireContains('README.md', readme, 'anchors.verifies', 'документация должна объяснять verified anchors в PR body');
requireContains('README.md', readme, 'authorized_governance_paths', 'документация должна описывать issue-sourced governance authorization');
requireContains('README.md', readme, 'linked issue body', 'документация должна фиксировать trusted issue channel');
requireContains('README.md', readme, 'base branch repo-policy.json', 'документация должна фиксировать trusted base policy boundary');
requireContains('README.md', readme, 'fail closed', 'документация должна описывать fail-closed поведение при недоступной trusted boundary');
}

const strictProfileDoc = readText('docs/requirements-strict-profile.md');
if (strictProfileDoc !== null) {
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'requirements-strict', 'документ должен называть strict profile');
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'requirement-json-req-refs-must-resolve', 'документ должен описывать перенос JSON trace ref resolution в repo-guard');
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'scripts/validate-requirements.js', 'документ должен описывать оставшийся legacy validator');
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'authorized_governance_paths', 'документ должен описывать issue-sourced governance authorization');
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'trusted base policy', 'документ должен описывать trusted base branch policy boundary');
requireContains('docs/requirements-strict-profile.md', strictProfileDoc, 'fail closed', 'документ должен описывать fail-closed behavior');
}

const requirementsValidator = readText('scripts/validate-requirements.js');
Expand Down Expand Up @@ -306,6 +357,7 @@ if (requirementsWorkflow !== null) {
`uses: ${EXPECTED_ACTION}`,
`CI должен запускать pinned Action ${EXPECTED_ACTION} для repo-guard policy`
);
requirementsWorkflowRefs = requireSingleExpectedRepoGuardAction('.github/workflows/requirements.yml', requirementsWorkflow);
requireContains(
'.github/workflows/requirements.yml',
requirementsWorkflow,
Expand Down Expand Up @@ -336,12 +388,40 @@ if (requirementsWorkflow !== null) {
'.github/PULL_REQUEST_TEMPLATE.md',
'CI должен перезапускаться при изменении PR template'
);
requireContains(
'.github/workflows/requirements.yml',
requirementsWorkflow,
'.github/ISSUE_TEMPLATE/**',
'CI должен перезапускаться при изменении issue template'
);
requireContains(
'.github/workflows/requirements.yml',
requirementsWorkflow,
'repo-policy.json',
'CI должен перезапускаться при изменении repo-policy.json'
);
requireNotContains(
'.github/workflows/requirements.yml',
requirementsWorkflow,
'git clone https://github.com/netkeep80/repo-guard.git',
'CI должен использовать pinned Action, а не ручной clone'
);
requireNotContains(
'.github/workflows/requirements.yml',
requirementsWorkflow,
'node /tmp/repo-guard/src/repo-guard.mjs',
'CI не должен запускать временно клонированный CLI напрямую'
);
}

if (
repoGuardWorkflowRefs.length === 1 &&
requirementsWorkflowRefs.length === 1 &&
repoGuardWorkflowRefs[0] !== requirementsWorkflowRefs[0]
) {
error(
`.github/workflows/repo-guard.yml и .github/workflows/requirements.yml должны использовать один и тот же repo-guard pin: ${repoGuardWorkflowRefs[0]} != ${requirementsWorkflowRefs[0]}`
);
}

console.log('');
Expand Down
Loading