diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a87f3a0..bc41579 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,68 @@ jobs: Write-Host "::error::CheckForUpdates outcomes: v1 updatesAvailable '$($env:UNCHANGED)' (expected false), v2 '$($env:MOVED)' (expected true), update without token '$($env:NOTOKEN)' (expected failure/token)" exit 1 + # The change workflow on a copy of valid-minimal: no token, nothing pushed. The token guard runs after the local + # plan, so a valid change fails on the token while a no-op and an invalid id need none. + changerule-action: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Prepare the fixture + id: fixture + shell: pwsh + run: | + . ./tests/Helpers/RepoFixture.ps1 + $org = New-FixtureRepo -Name 'valid-minimal' -Destination (Join-Path $env:RUNNER_TEMP 'change-org') + Add-Content -LiteralPath $env:GITHUB_OUTPUT -Value "org=$org" + + - name: Change AA0001 to None without the token (must fail) + id: notoken + continue-on-error: true + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: AA0001 + action: None + levels: '*' + stages: '*' + + # valid-minimal already has AA0072 Info for every level and stage; an empty justification keeps its text. + - name: No-op change + id: noop + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: AA0072 + action: Info + levels: '*' + stages: '*' + + - name: Invalid id (must fail) + id: invalid + continue-on-error: true + uses: ./actions/ChangeRule + with: + repositoryRoot: ${{ steps.fixture.outputs.org }} + ruleId: LC9999 + action: Warning + + # The outputs name the outcome, so a crash or another error does not pass. + - name: Require the three outcomes + if: steps.notoken.outcome != 'failure' || steps.notoken.outputs.failure != 'token' || steps.noop.outputs.noop != 'true' || steps.invalid.outcome != 'failure' || steps.invalid.outputs.failure != 'validation' + shell: pwsh + env: + NOTOKEN: ${{ steps.notoken.outcome }}/${{ steps.notoken.outputs.failure }} + NOOP: ${{ steps.noop.outputs.noop }} + INVALID: ${{ steps.invalid.outcome }}/${{ steps.invalid.outputs.failure }} + run: | + Write-Host "::error::ChangeRule outcomes: without token '$($env:NOTOKEN)' (expected failure/token), no-op '$($env:NOOP)' (expected true), invalid id '$($env:INVALID)' (expected failure/validation)" + exit 1 + # The scan against the real packages on nuget.org (the unit suites use stub packages). Every run is a dry run: no # token, nothing pushed. nuget.org moves, so the checks are lower bounds and the log prints the numbers. scan-action: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53a97de..60fd8ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,28 +22,29 @@ What exists today, and the work package that adds the rest. A folder is created | Path | Content | Added by | |---|---|---| -| `.github/workflows/ci.yml` | PSScriptAnalyzer, the matrix checks V1 to V14 and Pester on every pull request and every push to `main` and to a release branch (`v*`); the Validate action on two fixtures and on `template/`; the Publish action on `template/` without deploying, and the init script against the staged site served over HTTP; the CheckForUpdates action on `tests/fixtures/repos/update-org` against the local templates; the ScanDiagnostics action in dry runs against nuget.org. | WP00, WP03, WP04, WP05, WP06, WP07, WP08 | +| `.github/workflows/ci.yml` | PSScriptAnalyzer, the matrix checks V1 to V14 and Pester on every pull request and every push to `main` and to a release branch (`v*`); the Validate action on two fixtures and on `template/`; the Publish action on `template/` without deploying, and the init script against the staged site served over HTTP; the CheckForUpdates action on `tests/fixtures/repos/update-org` against the local templates; the ScanDiagnostics action in dry runs against nuget.org; the ChangeRule action on a copy of `tests/fixtures/repos/valid-minimal` without a token. | WP00, WP03, WP04, WP05, WP06, WP07, WP08, WP09 | | `.github/workflows/` (deploy) | Copies `template/` into `ALCops/rulebook` and pins action references to `@v1`. | WP13 ([#15](https://github.com/ALCops/rulebook-engine/issues/15)) | | `.github/release.yml` | Maps pull request labels to release-note sections ([D38](docs/adr/0038-release-notes-are-generated-from-pull-request-labels.md)). | WP00 | | `.github/dependabot.yml` | Weekly, grouped updates of the GitHub Actions used by the workflows. | WP00 | | `.github/ISSUE_TEMPLATE/` | Issue forms: work package, task or spin-off. | written | | `PSScriptAnalyzerSettings.psd1` | Analyzer settings: errors and warnings, default rules, justified exclusions only. | WP00 | | `schemas/` | JSON schemas for every file in an organization rulebook repository, draft 2020-12, no `$id`; names and URLs in [docs/reference/naming.md](docs/reference/naming.md). | WP02 ([#4](https://github.com/ALCops/rulebook-engine/issues/4)) | -| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1`, `ScanDiagnostics.Action.Tests.ps1` and `Rulebook.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | +| `tests/` | `Smoke.Tests.ps1`, `Schemas.Tests.ps1`, `Rulebook.Generate.Tests.ps1`, `Rulebook.Validate.Tests.ps1`, `Validate.Action.Tests.ps1`, `Rulebook.Template.Tests.ps1`, `Rulebook.Publish.Tests.ps1`, `Publish.Action.Tests.ps1`, `Get-RulebookSkeletons.Tests.ps1`, `Rulebook.GitHub.Tests.ps1`, `Rulebook.Update.Tests.ps1`, `CheckForUpdates.Action.Tests.ps1`, `Rulebook.NuGet.Tests.ps1`, `Rulebook.Extract.Tests.ps1`, `Rulebook.Catalog.Tests.ps1`, `Rulebook.Quarantine.Tests.ps1`, `Rulebook.Scan.Tests.ps1`, `ScanDiagnostics.Action.Tests.ps1`, `Rulebook.Action.Tests.ps1`, `Rulebook.Edit.Tests.ps1` and `ChangeRule.Action.Tests.ps1` now; later one suite per module and action, with fixtures under `tests/fixtures/`. | WP00, WP02, WP03, WP04, WP05, WP06, WP07, WP08, WP09; WP12 ([#14](https://github.com/ALCops/rulebook-engine/issues/14)) and every module work package | | `tests/fixtures/schemas/` | One file per case: `//.json`, each invalid file a one-change mutation of a valid one. | WP02 | | `tests/fixtures/repos/` | Organization rulebook repositories for the module suites. `valid-minimal` (30-id catalog, four levels, three stages), `stale-endpoints` and `update-org` (WP07) are complete on disk, their `rulesets/` written by `Update-RulebookEndpoints`; every other folder is an overlay holding only the files it changes, copied over `valid-minimal` by `New-FixtureRepo`. | WP03 | | `tests/fixtures/templates/` | `v1` and `v2`: two versions of a mini template for the update suites, with the exact difference list and the derivation of `repos/update-org` in its README. | WP07 | | `tests/fixtures/stub-analyzers/` | C# stubs of the compiler and the twelve cop assemblies and `Build-StubPackage.ps1`, which compiles them with the Roslyn of pwsh into fixture nupkgs (variants tools 18.0.43.1464 and 30.0.42.60748-beta, ALCops 1.3.1, 1.4.0-beta.1 and 1.4.0) for the scan suites; `tests/Helpers/StubFeed.ps1` builds them once per source hash. | WP08 | | `tests/fixtures/matrix/` | `tiny`: a six-id level content in the layout of `docs/rulebook/` (inventory, matrix, resolved cells, levels, stages, twins) for the unit cases of the Template suite. | WP04 | | `tests/Helpers/` | Helpers the suites dot-source in `BeforeAll`: `RepoFixture.ps1` copies a fixture into `TestDrive`, edits its JSON, creates git repositories, bare origins with a push-refusing hook and the synthetic performance rulebook; `StubFeed.ps1` builds NuGet flat containers of the stub analyzer packages. | WP03, WP08 | -| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine`, `Rulebook.Scan` and `Rulebook.Action` (written). | WP03 to WP09 | +| `modules/` | PowerShell modules shared by the actions, each a `.psm1` with a `.psd1` manifest: `Rulebook.Generate`, `Rulebook.Validate`, `Rulebook.Template`, `Rulebook.Publish`, `Rulebook.GitHub`, `Rulebook.Update`, `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine`, `Rulebook.Scan`, `Rulebook.Action` and `Rulebook.Edit` (written). | WP03 to WP09 | | `actions/` | Composite actions, one folder each with `action.yaml` and its entry script. | WP03 to WP09 | | `actions/Validate/` | `action.yaml` and `Validate.ps1`: checks C1 to C16, annotations, the job summary with the effective diff ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 5.5). | WP03 | | `actions/Publish/` | `action.yaml` and `Publish.ps1`: stage the endpoints, the rendered skeletons and `index.html`, the Pages preflight, deploy to GitHub Pages, the reachability check ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 7.2, [docs/reference/publish-targets.md](docs/reference/publish-targets.md)). | WP05 | | `actions/CheckForUpdates/` | `action.yaml` and `CheckForUpdates.ps1`: the template update check (`update` other than `'Y'`) and the update pull request or direct commit (`update: 'Y'`), with the token from `GHTOKENWORKFLOW` ([docs/reference/update-mechanics.md](docs/reference/update-mechanics.md)). | WP07 | | `actions/ScanDiagnostics/` | `action.yaml` and `ScanDiagnostics.ps1`: the diagnostic scan, its living pull request on `scan-diagnostics/` or a direct commit, and the dry run ([docs/reference/scan-mechanics.md](docs/reference/scan-mechanics.md)). | WP08 | +| `actions/ChangeRule/` | `action.yaml` and `ChangeRule.ps1`: set or remove one override entry for a level and stage selection, regenerate and validate, and open the pull request on `change-rule//` or push a direct commit ([docs/reference/change-mechanics.md](docs/reference/change-mechanics.md)). | WP09 | | `scripts/` | User-facing scripts an AL project downloads and runs, self-contained (no engine module, PowerShell 7): `Get-RulebookSkeletons.ps1` downloads the published skeletons of a level into `.rulebook/`, one per stage ([ARCHITECTURE.md](docs/ARCHITECTURE.md) section 6.3). | WP06 ([#8](https://github.com/ALCops/rulebook-engine/issues/8)) | -| `template/` | Source of the template content deployed to `ALCops/rulebook`, 43 files: 11 hand-written files (settings, `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml`, `README.md`, `skeletons/README.md`, `overrides.json`, three quarantine files) and 32 files generated by `tools/rulebook/Build-Template.ps1` (`base/`, `stages/`, `catalog/`, `skeletons/`, `rulesets/`), see [docs/reference/template-content.md](docs/reference/template-content.md). | WP03, WP04, WP05, WP06, WP07, WP08, WP13 | +| `template/` | Source of the template content deployed to `ALCops/rulebook`, 44 files: 12 hand-written files (settings, `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml`, `ChangeRule.yaml`, `README.md`, `skeletons/README.md`, `overrides.json`, three quarantine files) and 32 files generated by `tools/rulebook/Build-Template.ps1` (`base/`, `stages/`, `catalog/`, `skeletons/`, `rulesets/`), see [docs/reference/template-content.md](docs/reference/template-content.md). | WP03, WP04, WP05, WP06, WP07, WP08, WP09, WP13 | | `docs/`, `tools/rulebook/` | Architecture, decision records, level content and the scripts that build it and the template content (`Build-Template.ps1`, WP04). | written | ## 2. Conventions @@ -74,7 +75,7 @@ After any change to `docs/rulebook/`, `modules/Rulebook.Template.psm1` or `templ ## 4. CI -`.github/workflows/ci.yml` has five jobs on `ubuntu-latest`, `test`, `validate-action`, `publish-action` and `update-action` with a 15-minute timeout and `scan-action` with 20 minutes. All five run on every pull request and on every push to `main` or a release branch (`v*`). `test` installs PSScriptAnalyzer and Pester 6, runs the analyzer over the whole repository and fails on any finding (after printing the findings table), runs the matrix checks V1 to V14 (`tools/rulebook/Test-Rulebook.ps1`, which exits 1 on a failed check), runs Pester from `tests/`, and uploads `testResults.xml` (NUnit) as the `testResults` artifact, also when a step failed. The workflow token is read-only, and a new push to a pull request cancels its running job; pushes to `main` and release branches always finish. +`.github/workflows/ci.yml` has six jobs on `ubuntu-latest`, `test`, `validate-action`, `publish-action`, `update-action` and `changerule-action` with a 15-minute timeout and `scan-action` with 20 minutes. All six run on every pull request and on every push to `main` or a release branch (`v*`). `test` installs PSScriptAnalyzer and Pester 6, runs the analyzer over the whole repository and fails on any finding (after printing the findings table), runs the matrix checks V1 to V14 (`tools/rulebook/Test-Rulebook.ps1`, which exits 1 on a failed check), runs Pester from `tests/`, and uploads `testResults.xml` (NUnit) as the `testResults` artifact, also when a step failed. The workflow token is read-only, and a new push to a pull request cancels its running job; pushes to `main` and release branches always finish. `validate-action` runs the composite action from the checkout (`uses: ./actions/Validate`) the way an organization workflow does: it must pass on `tests/fixtures/repos/valid-minimal`, pass on `template/` with `failOnWarning` (no warning either), and fail on `tests/fixtures/repos/stale-endpoints`, and the job fails otherwise. Its three steps pass `checkForUpdates: 'false'`, so the fixtures are not compared with `ALCops/rulebook`. On a pull request its `template/` step shows the effective diff of the template endpoints against the base branch. Its step on `stale-endpoints` prints one expected C12 error annotation. @@ -82,6 +83,8 @@ After any change to `docs/rulebook/`, `modules/Rulebook.Template.psm1` or `templ `update-action` runs `uses: ./actions/CheckForUpdates` in check mode on `tests/fixtures/repos/update-org` with the local templates of `tests/fixtures/templates/` (no download, no token): against `v1` it must report `updatesAvailable` `false`, against `v2` `true`, and in update mode without a token it must fail with the `failure` output `token`; a guard step fails the job on any other outcome (it prints one expected error annotation, the missing secret). +`changerule-action` runs `uses: ./actions/ChangeRule` on a copy of `tests/fixtures/repos/valid-minimal` in `RUNNER_TEMP` (no token, nothing pushed): `AA0001` `None` for every level and stage must fail with the `failure` output `token` (the change is valid and changes nine endpoints, so the token guard, which runs after the local plan, fires); `AA0072` `Info` for every level and stage without a justification must succeed with `noop` `true` (the fixture has exactly that entry, and an empty justification keeps its text); `LC9999` must fail with `validation`. A guard step fails the job on any other outcome (the run prints two expected error annotations). + `scan-action` runs `uses: ./actions/ScanDiagnostics` with `dryRun: 'true'` against the real packages on nuget.org (no token, nothing pushed; the unit suites use stub packages): on `template/` without a policy it must fail with the `failure` output `policy` (one expected error annotation); on a copy of `template/` with a policy it must scan both stable versions, give at least 600 catalog entries a package, write a valid `catalog/scan-state.json` and leave a candidate without validation errors, and it prints the new ids, the refreshed titles and docs links and the ids without a package; on `tests/fixtures/repos/scan-org` (30 catalog ids) it must quarantine at least 500 new ids in `default` and `ci` and leave `quarantine.vnext.json` alone; a second dry run on the scanned template must give `nothing-new` in under 60 s. nuget.org moves, so the checks are lower bounds. The ruleset requires the checks of section 6. @@ -100,7 +103,7 @@ Applied on 2026-10-03 by the WP00 pull request ([#2](https://github.com/ALCops/r | Setting | Value | Command | |---|---|---| -| Ruleset `protect-main` on the default branch | Pull request required, 0 approvals, deletion and force-push blocked, checks `test`, `validate-action`, `publish-action`, `update-action` and `scan-action` from GitHub Actions required, no bypass actors. `validate-action` was added on 2026-10-06 with the WP03 pull request ([#46](https://github.com/ALCops/rulebook-engine/pull/46)) through a PUT of the same JSON. `publish-action` was added the same way on 2026-10-06, after the first CI run of the WP05 pull request ([#59](https://github.com/ALCops/rulebook-engine/pull/59)) had reported the check; the PUT kept the other rules as the GET returned them. `update-action` is added the same way after the first CI run of the WP07 pull request ([#9](https://github.com/ALCops/rulebook-engine/issues/9)) has reported the check, and `scan-action` after the first CI run of the WP08 pull request ([#10](https://github.com/ALCops/rulebook-engine/issues/10)). | `gh api --method POST ... rulesets --input ruleset-engine.json`, later `gh api --method PUT ... rulesets/24420852 --input ruleset-engine.json` | +| Ruleset `protect-main` on the default branch | Pull request required, 0 approvals, deletion and force-push blocked, checks `test`, `validate-action`, `publish-action`, `update-action`, `scan-action` and `changerule-action` from GitHub Actions required, no bypass actors. `validate-action` was added on 2026-10-06 with the WP03 pull request ([#46](https://github.com/ALCops/rulebook-engine/pull/46)) through a PUT of the same JSON. `publish-action` was added the same way on 2026-10-06, after the first CI run of the WP05 pull request ([#59](https://github.com/ALCops/rulebook-engine/pull/59)) had reported the check; the PUT kept the other rules as the GET returned them. `update-action` is added the same way after the first CI run of the WP07 pull request ([#9](https://github.com/ALCops/rulebook-engine/issues/9)) has reported the check, and `scan-action` after the first CI run of the WP08 pull request ([#10](https://github.com/ALCops/rulebook-engine/issues/10)), `changerule-action` after the first CI run of the WP09 pull request ([#11](https://github.com/ALCops/rulebook-engine/issues/11)). | `gh api --method POST ... rulesets --input ruleset-engine.json`, later `gh api --method PUT ... rulesets/24420852 --input ruleset-engine.json` | | Workflow permissions | Read-only `GITHUB_TOKEN` (applied). "Actions may create and approve pull requests" is not applied yet: the repository-level PUT is refused (409) while the organization policy "Allow GitHub Actions to create and approve pull requests" is off. An org admin enables it under Org Settings > Actions > General; then the PUT below applies. | `gh api --method PUT ... actions/permissions/workflow` | | Labels | `dependencies` and `skip-changelog`, next to the defaults (`enhancement`, `bug`, `documentation`). | `gh label create` | @@ -130,7 +133,8 @@ The ruleset, saved as `ruleset-engine.json` outside the repository (`integration { "context": "validate-action", "integration_id": 15368 }, { "context": "publish-action", "integration_id": 15368 }, { "context": "update-action", "integration_id": 15368 }, - { "context": "scan-action", "integration_id": 15368 } ] } } + { "context": "scan-action", "integration_id": 15368 }, + { "context": "changerule-action", "integration_id": 15368 } ] } } ] } ``` diff --git a/README.md b/README.md index 2d46f2a..117adbe 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,15 @@ The engine behind [ALCops/rulebook](https://github.com/ALCops/rulebook): the com | Path | Content | Status | |---|---|---| -| `actions//action.yaml` | Composite actions, each running a PowerShell 7 entry script on `ubuntu-latest`: `Validate` (checks, effective diff, update check), `Publish` (gate, stage, deploy, verify), `CheckForUpdates` (update from the template), `ScanDiagnostics` (the daily diagnostic scan). | `Validate` (WP03), `Publish` (WP05), `CheckForUpdates` (WP07) and `ScanDiagnostics` (WP08) written; `ChangeRule` planned (WP09) | -| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`, and `Rulebook.Action` (the helpers every action entry script shares: annotations, the failure kind, the job summary and `GITHUB_OUTPUT`). | written (WP03 to WP09) | -| `template/` | Source of the template content that a deploy workflow copies into `ALCops/rulebook`, pinning action references from `@main` to `@v1`. Its `base/`, `stages/` and `rulesets/` are generated from `docs/rulebook/`. | written (WP03 to WP08), 43 files: settings, the workflows `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml` and `ScanDiagnostics.yaml`, the README files, empty overrides and quarantine files, and the 32 generated files; the other workflows come with their work packages, the deploy with WP13 | +| `actions//action.yaml` | Composite actions, each running a PowerShell 7 entry script on `ubuntu-latest`: `Validate` (checks, effective diff, update check), `Publish` (gate, stage, deploy, verify), `CheckForUpdates` (update from the template), `ScanDiagnostics` (the daily diagnostic scan), `ChangeRule` (one override entry through a form, as a pull request). | `Validate` (WP03), `Publish` (WP05), `CheckForUpdates` (WP07), `ScanDiagnostics` (WP08) and `ChangeRule` (WP09) written | +| `modules/Rulebook.*.psm1` | PowerShell modules shared by the actions, each with a `.psd1` manifest: `Rulebook.Generate` (level chain + stage deltas + twins setting + overrides + quarantine to sparse flat endpoints, effective diff), `Rulebook.Validate` (checks C1 to C16), `Rulebook.Template` (level, stage, twins, catalog and skeleton files of `template/` from `docs/rulebook/`), `Rulebook.Publish` (staging, Pages deploy, reachability), `Rulebook.Update` and `Rulebook.GitHub` (the update and the GitHub plumbing), for the scan `Rulebook.NuGet`, `Rulebook.Extract`, `Rulebook.Catalog`, `Rulebook.Quarantine` and `Rulebook.Scan`, `Rulebook.Edit` (overrides.json and the change set of the ChangeRule action) and `Rulebook.Action` (the helpers every action entry script shares: annotations, the failure kind, the job summary and `GITHUB_OUTPUT`). | written (WP03 to WP09) | +| `template/` | Source of the template content that a deploy workflow copies into `ALCops/rulebook`, pinning action references from `@main` to `@v1`. Its `base/`, `stages/` and `rulesets/` are generated from `docs/rulebook/`. | written (WP03 to WP09), 44 files: settings, the workflows `Validate.yaml`, `Publish.yaml`, `UpdateRulebookSystemFiles.yaml`, `ScanDiagnostics.yaml` and `ChangeRule.yaml`, the README files, empty overrides and quarantine files, and the 32 generated files; the other workflows come with their work packages, the deploy with WP13 | | `schemas/` | JSON schemas for every file in an organization rulebook repository (ruleset profiles, overrides, quarantine, twins, catalog, scan state, settings), served from the `v1` branch over raw URLs, which go live with WP13 ([#15](https://github.com/ALCops/rulebook-engine/issues/15)) and return 404 until then; the tests use the local files. See [docs/reference/naming.md](docs/reference/naming.md). | written (WP02) | -| `tests/` | Pester 6 suites, one per module and action, with fixtures under `tests/fixtures/`: the schema suite and its fixtures under `tests/fixtures/schemas/`; the Generate, Validate and Validate action suites with organization rulebook fixtures under `tests/fixtures/repos/` and helpers in `tests/Helpers/`. | a suite per module and action (WP00 to WP08); the scan suites build stub analyzer packages from `tests/fixtures/stub-analyzers/` at test time | +| `tests/` | Pester 6 suites, one per module and action, with fixtures under `tests/fixtures/`: the schema suite and its fixtures under `tests/fixtures/schemas/`; the Generate, Validate and Validate action suites with organization rulebook fixtures under `tests/fixtures/repos/` and helpers in `tests/Helpers/`. | a suite per module and action (WP00 to WP09); the scan suites build stub analyzer packages from `tests/fixtures/stub-analyzers/` at test time | | `docs/` | Architecture, decision records (`adr/`), references, and `docs/rulebook/` with the level content (inventory, matrix, composition spec). | written | | `tools/rulebook/` | PowerShell scripts that extract the inventory from the analyzer sources, build the matrix (ladders, stage columns, twin pairs, counts) and verify it (`Extract-Inventory.ps1`, `Build-Matrix.ps1`, `Test-Rulebook.ps1`). They import `Rulebook.Generate` for the diagnostic sort key and build portable paths; CI runs `Test-Rulebook.ps1`. `Build-Template.ps1` regenerates `template/` from `docs/rulebook/`. | written | | `scripts/` | User-facing scripts that an AL project downloads from the engine and runs, self-contained (PowerShell 7, no engine module): `Get-RulebookSkeletons.ps1` reads `/rulebook.json` and downloads the published skeletons of one level into `.rulebook/`, one file per stage. Linked from the index page of every published rulebook. | written (WP06) | -| `.github/workflows/` | `ci.yml`: the job `test` (PSScriptAnalyzer, the matrix checks V1 to V14, Pester) and one job per action, `validate-action`, `publish-action`, `update-action` and `scan-action` (dry runs against nuget.org); the deploy workflow comes with WP13. | CI written (WP00); deploy planned (WP13) | +| `.github/workflows/` | `ci.yml`: the job `test` (PSScriptAnalyzer, the matrix checks V1 to V14, Pester) and one job per action, `validate-action`, `publish-action`, `update-action`, `scan-action` (dry runs against nuget.org) and `changerule-action`; the deploy workflow comes with WP13. | CI written (WP00); deploy planned (WP13) | | `CONTRIBUTING.md` | Conventions, running the checks locally, CI, branches, repository settings and pull request rules. | written | ## 2. Relation to the template diff --git a/actions/ChangeRule/ChangeRule.ps1 b/actions/ChangeRule/ChangeRule.ps1 new file mode 100644 index 0000000..bb45e7a --- /dev/null +++ b/actions/ChangeRule/ChangeRule.ps1 @@ -0,0 +1,218 @@ +#requires -Version 7.4 +<# +.SYNOPSIS +Entry script of the ChangeRule action: set or remove one override entry for a level and stage selection and land it +as a pull request (or a direct commit). +.DESCRIPTION +1. Reads the settings: the secret name (ghTokenWorkflowSecretName) and the labels (commitOptions.pullRequestLabels). +2. Builds a one-item change set (ConvertTo-RulebookChangeSet; -Action Remove deletes the matching entry) and plans it + on a candidate copy (Invoke-RulebookChangeSet; the repository itself is never written). A finding (rule id, + action, selectors, a remove without a matching entry) is one error annotation each, failure validation; a + candidate that does not validate gives one error annotation per finding, "The changed rulebook would not validate:". +3. A no-op (no endpoint changes, overrides.json would not change, D48) is one notice, result no-op, noop true and exit + code 0; it needs no token. +4. The token guard: an invalid ghTokenWorkflowSecretName or an empty -Token is failure token; the token is exchanged + (Get-GitHubAccessToken) and masked before anything else runs. +5. Publish-RulebookChange (or -PublishCommand) on change-rule//: pull-request, or + direct-commit with -DirectCommit (the workflow derives it from commitOptions.createPullRequest, D47; a refused + push falls back to the pull request). Failures are push or pull-request with the token hint. +6. The job summary (ConvertTo-ChangeSummary, capped at -SummaryLimit) and GITHUB_OUTPUT: result (pull-request, + direct-commit, no-op), noop, changedEndpoints (comma separated), pullRequestUrl, branch and failure (validation, + token, push, pull-request, error; empty on success). +Returns { ExitCode, Result, NoOp, Failure, Plan, Publish, Annotations, Summary, Outputs } and never calls exit, so tests +run it in-process; action.yaml exits with ExitCode. -RemoteUrl, -ApiUrl, -WorkPath, -SummaryLimit, -Now and +-PublishCommand are test seams; a -WorkPath the caller passes is left in place, the temporary work folder the script +names itself is removed at the end. +#> +[CmdletBinding()] +param( + [string]$RepositoryRoot = '.', + [AllowEmptyString()][string]$RuleId, + [AllowEmptyString()][string]$Action, + [AllowEmptyString()][string]$Levels = '*', + [AllowEmptyString()][string]$Stages = '*', + [AllowEmptyString()][string]$Justification, + [AllowEmptyString()][string]$Token, + [switch]$DirectCommit, + [AllowEmptyString()][string]$BaseBranch = $env:GITHUB_REF_NAME, + [AllowEmptyString()][string]$Actor = $env:GITHUB_ACTOR, + [AllowEmptyString()][string]$Repository = $env:GITHUB_REPOSITORY, + [AllowEmptyString()][string]$RemoteUrl, + [string]$ApiUrl = $(if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { 'https://api.github.com' }), + [string]$WorkPath, + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY, + [string]$WorkspaceRoot = $env:GITHUB_WORKSPACE, + [int]$SummaryLimit = 900KB, + [System.DateTimeOffset]$Now = [System.DateTimeOffset]::UtcNow, + # Test seam: runs instead of Publish-RulebookChange with the same parameters (the script re-imports the modules, + # so a Pester mock of the function does not reach it). + [scriptblock]$PublishCommand +) + +Set-StrictMode -Version 3.0 +$modules = Join-Path $PSScriptRoot '..' '..' 'modules' +Import-Module (Join-Path $modules 'Rulebook.Generate.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.GitHub.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Update.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Edit.psd1') -Force +Import-Module (Join-Path $modules 'Rulebook.Action.psd1') -Force + +$docsUrl = 'https://github.com/ALCops/rulebook/blob/main/docs/ghtokenworkflow.md' + +$ownWork = [string]::IsNullOrEmpty($WorkPath) +if ($ownWork) { + $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $WorkPath = Join-Path $tempRoot ('rulebook-change-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) +} +$WorkPath = Resolve-ActionPath $WorkPath +$ctx = New-ActionContext -Title 'ChangeRule' + +# Every token obtained by an exchange is masked before anything can print it. +$maskToken = { param([string]$Value) if (-not [string]::IsNullOrEmpty($Value)) { Write-Host "::add-mask::$Value" } } +$plan = $null +$publish = $null +$result = $null +$summaryMessage = $null +$failed = $false +$secretName = 'GHTOKENWORKFLOW' +# A comma-separated selector; ConvertTo-RulebookChangeSet trims the values and drops empty ones. +$split = { param([string]$Value) , [string[]]$Value.Split(',') } + +try { + # 1. Settings: the secret name and the labels. + $root = (Resolve-Path -LiteralPath $RepositoryRoot -ErrorAction Stop).ProviderPath + $workspace = if ([string]::IsNullOrEmpty($WorkspaceRoot)) { $root } else { (Resolve-Path -LiteralPath $WorkspaceRoot -ErrorAction Stop).ProviderPath } + $separator = [System.IO.Path]::DirectorySeparatorChar + $settingsFile = Join-Path $root '.github' 'Rulebook-Settings.json' + $settings = $null + if (Test-Path -LiteralPath $settingsFile -PathType Leaf) { + try { $settings = Get-Content -LiteralPath $settingsFile -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop } catch { $settings = $null } + } + $setting = { param([string[]]$Path) $value = $settings; foreach ($key in $Path) { if ($value -isnot [System.Collections.IDictionary] -or -not $value.Contains($key)) { return $null }; $value = $value[$key] }; return $value } + $name = [string](& $setting 'ghTokenWorkflowSecretName') + $nameValid = $name -cmatch '^[A-Za-z_][A-Za-z0-9_]*$' -and $name -inotmatch '^GITHUB_' + if (-not [string]::IsNullOrEmpty($name) -and $nameValid) { $secretName = $name } + $labels = [string[]]@(& $setting 'commitOptions', 'pullRequestLabels' | Where-Object { $_ -is [string] -and $_ -ne '' }) + + # 2. The change set and the plan, on a candidate copy. + $changeSet = ConvertTo-RulebookChangeSet -RuleId ([string]$RuleId) -Action ([string]$Action) -Levels (& $split ([string]$Levels)) -Stages (& $split ([string]$Stages)) -Justification $Justification + $plan = Invoke-RulebookChangeSet -RepositoryRoot $root -ChangeSet $changeSet -WorkPath (Join-Path $WorkPath 'plan') -Now $Now + if ($plan.Failure -or -not $plan.Valid) { + $prefix = if ($null -ne $plan.CandidatePath) { 'The changed rulebook would not validate: ' } else { '' } + foreach ($finding in @($plan.Findings | Where-Object Severity -EQ 'error')) { + $message = if ($finding.Id -and $finding.Rule -cne 'change') { "$($finding.Id): $($finding.Message)" } else { $finding.Message } + $file = if ($finding.File) { [System.IO.Path]::GetRelativePath($workspace, (Join-Path $root $finding.File)).Replace($separator, '/') } else { $null } + Add-Annotation -Context $ctx -File $file -Title $(if ($finding.Rule -ceq 'change') { 'ChangeRule' } else { $finding.Rule }) -Message "$prefix$message" + } + Add-Failure -Context $ctx -Kind 'validation' + $summaryMessage = if ($prefix) { 'The changed rulebook does not validate; nothing was pushed.' } else { 'The change is not valid; nothing was written.' } + $failed = $true + } elseif ($plan.NoOp) { + # 3. Nothing would change (D48). + $item = @($plan.Items)[0] + # Worded from the rows: an endpoint can keep another action, decided by a more specific entry or input. + $same = @($item.Rows | Where-Object { $_.After -ceq $item.Action }) + $other = @($item.Rows | Where-Object { $_.After -cne $item.Action }) + if ($other.Count -eq 0) { + $summaryMessage = "No change: $($item.Id) is already $($item.Action) on every matching endpoint ($(@($same | ForEach-Object Endpoint) -join ', ')); overrides.json was not written" + } else { + $kept = @($other | ForEach-Object { '{0} {1} ({2})' -f $_.Endpoint, $_.After, $_.AfterSource }) -join ', ' + $summaryMessage = "No change: $($item.Id) keeps its effective action on every matching endpoint ($($same.Count) at $($item.Action); $($other.Count) decided by a more specific entry or input: $kept); overrides.json was not written" + } + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage + $result = 'no-op' + } else { + # 4. The token guard, after the local plan: a validation error or a no-op needs no secret. + if (-not [string]::IsNullOrEmpty($name) -and -not $nameValid) { + Add-Failure -Context $ctx -Kind 'token' + throw "ghTokenWorkflowSecretName '$name' in .github/Rulebook-Settings.json is not a valid secret name (letters, digits and underscores, not starting with a digit or GITHUB_). Read $docsUrl" + } + if ([string]::IsNullOrWhiteSpace($Token)) { + Add-Failure -Context $ctx -Kind 'token' + throw "The $secretName secret is needed to change a rule. Read $docsUrl" + } + if ([string]::IsNullOrEmpty($Repository)) { throw 'The change needs the repository (GITHUB_REPOSITORY) as owner/name.' } + if ([string]::IsNullOrEmpty($BaseBranch)) { throw 'The change needs baseBranch.' } + try { + $access = Get-GitHubAccessToken -Token $Token -Repository $Repository -ApiUrl $ApiUrl + } catch { + Add-Failure -Context $ctx -Kind 'token' + throw "The $secretName secret could not be used: $($_.Exception.Message)" + } + if (-not [string]::IsNullOrEmpty($access.Token)) { & $maskToken $access.Token } + Write-Host "Write token: $($access.Kind)" + + # 5. Publish. + if (-not $plan.HeadSha) { Add-Annotation -Context $ctx -Command warning -Message 'base-move guard inactive: the checkout HEAD could not be read' } + try { + $publishParameters = @{ + Plan = $plan; RepositoryRoot = $root; Repository = $Repository; RemoteUrl = $RemoteUrl; Token = $access.Token; BaseBranch = $BaseBranch + BranchPrefix = "change-rule/$($plan.Items[0].Id)"; DirectCommit = [bool]$DirectCommit; Actor = $Actor; Labels = $labels + WorkPath = (Join-Path $WorkPath 'publish'); ApiUrl = $ApiUrl; Now = $Now + } + $publish = if ($null -ne $PublishCommand) { & $PublishCommand @publishParameters } else { Publish-RulebookChange @publishParameters } + } catch { + $stage = [string]$_.Exception.Data['Stage'] + if ($stage -cnotin 'push', 'pull-request') { $stage = 'push' } + Add-Failure -Context $ctx -Kind $stage + # The base branch moved between plan and publish: nothing is wrong with the token; run the workflow again. + if ([string]$_.Exception.Data['Reason'] -ceq 'base-moved') { throw $_.Exception.Message } + $what = if ($stage -eq 'pull-request') { 'Failed to create the pull request for the rule change' } else { 'Failed to push the rule change' } + throw "$what. Make sure that the token in the secret $secretName is not expired and may write contents and pull requests of $Repository. Read $docsUrl (Error was: $($_.Exception.Message))" + } + $result = $publish.Result + switch ($publish.Result) { + 'direct-commit' { $summaryMessage = "Rule change committed to $($publish.Branch) ($(Get-ShortSha $publish.Sha))" } + 'no-changes' { + # The plan saw a change but the clone of the base branch holds it already (pushed in between): a no-op. + $result = 'no-op' + $summaryMessage = "No change: $BaseBranch already holds this change; nothing was pushed" + } + default { $summaryMessage = "Pull request: $($publish.PullRequestUrl)" + $(if ($publish.Fallback) { ' (the direct commit was refused)' } else { '' }) } + } + Add-Annotation -Context $ctx -Command notice -Message $summaryMessage + } +} catch { + Add-Annotation -Context $ctx -Message $_.Exception.Message + Add-Failure -Context $ctx -Kind 'error' + $summaryMessage = $_.Exception.Message + $failed = $true +} finally { + if ($ownWork -and (Test-Path -LiteralPath $WorkPath)) { Remove-Item -LiteralPath $WorkPath -Recurse -Force -ErrorAction SilentlyContinue } +} + +# 6. Summary and outputs. +$summary = "## Rule change`n`n$(ConvertTo-SingleLine $summaryMessage)`n`n" +if ($null -ne $plan) { + try { + $summary = ConvertTo-ChangeSummary -Plan $plan -Result $publish -Message $summaryMessage + } catch { + Write-Host "The summary could not be written in full: $($_.Exception.Message)" + } +} +$summary = $summary.Replace("`r`n", "`n") +# The runner caps a step summary at 1 MiB; stay well below it (-SummaryLimit, 900 KiB). +$where = if ($null -ne $publish -and $publish.PSObject.Properties['Body'] -and $publish.Body) { 'the pull request body' } else { 'the job log' } +$summary = Limit-SummaryText -Text $summary -MaxBytes $SummaryLimit -Footer "The summary was cut at $([math]::Round($SummaryLimit / 1KB)) KiB; the full tables are in $where." +Write-Text -Path $SummaryPath -Text $summary +$changed = if ($null -ne $plan -and -not $failed -and $result -ne 'no-op') { @($plan.Items | ForEach-Object { $_.ChangedEndpoints } | Select-Object -Unique) -join ',' } else { '' } +$outputs = [ordered]@{ + result = $(if ($failed -or -not $result) { '' } else { $result }) + noop = $(if (-not $failed -and $result -eq 'no-op') { 'true' } else { 'false' }) + changedEndpoints = $changed + pullRequestUrl = $(if ($null -ne $publish -and $publish.PullRequestUrl) { $publish.PullRequestUrl } else { '' }) + branch = $(if ($null -ne $publish -and $publish.Branch) { $publish.Branch } else { '' }) + failure = $ctx.Failure +} +Write-ActionOutput -Outputs $outputs +[pscustomobject]@{ + ExitCode = $(if ($failed) { 1 } else { 0 }) + Result = $outputs.result + NoOp = $outputs.noop -eq 'true' + Failure = $ctx.Failure + Plan = $plan + Publish = $publish + Annotations = $ctx.Annotations.ToArray() + Summary = $summary + Outputs = $outputs +} diff --git a/actions/ChangeRule/action.yaml b/actions/ChangeRule/action.yaml new file mode 100644 index 0000000..0f72737 --- /dev/null +++ b/actions/ChangeRule/action.yaml @@ -0,0 +1,99 @@ +name: Rulebook ChangeRule +description: Sets or removes one override entry of an organization rulebook repository for a level and stage selection, regenerates and validates the endpoints, and lands the change as a pull request (or a direct commit). +author: ALCops + +inputs: + ruleId: + description: The diagnostic id, for example LC0015. Checked against ^[A-Z]{2,3}\d{4}i?$ and the catalog. + required: true + action: + description: Error, Warning, Info, Hidden or None sets the entry; Remove deletes the entry with these selectors. + required: true + levels: + description: "A level slug from the settings, or '*' for every level." + required: false + default: '*' + stages: + description: "A stage slug from the settings, or '*' for every stage." + required: false + default: '*' + justification: + description: Why the organization changes the rule; written into the entry. Optional; empty keeps the existing justification of the entry; ignored for Remove. + required: false + default: '' + token: + description: The GHTOKENWORKFLOW value (a personal access token or GitHub App JSON). Not needed for a change that fails validation or changes nothing. + required: false + default: '' + directCommit: + description: "'true' pushes to baseBranch instead of opening a pull request (falls back to a pull request when the push is refused)." + required: false + default: 'false' + baseBranch: + description: The branch to change. + required: false + default: ${{ github.ref_name }} + repositoryRoot: + description: Path of the rulebook repository, relative to the workspace. + required: false + default: '.' + actor: + description: The git author name of the change commit. + required: false + default: ${{ github.actor }} + +outputs: + result: + description: "pull-request, direct-commit or no-op (nothing written); empty on failure." + value: ${{ steps.change.outputs.result }} + noop: + description: "'true' when nothing would change (no endpoint and not overrides.json); nothing was written." + value: ${{ steps.change.outputs.noop }} + changedEndpoints: + description: The endpoints whose effective action changes, comma separated (for example strict.ci). + value: ${{ steps.change.outputs.changedEndpoints }} + pullRequestUrl: + description: The URL of the pull request. + value: ${{ steps.change.outputs.pullRequestUrl }} + branch: + description: The pushed branch (change-rule//) or the base branch of a direct commit. + value: ${{ steps.change.outputs.branch }} + failure: + description: "Why the step failed, empty on success: validation, token, push, pull-request or error." + value: ${{ steps.change.outputs.failure }} + +runs: + using: composite + steps: + - name: Change the rule + id: change + shell: pwsh + # Inputs reach the script through the environment, never interpolated into the script (script injection). + env: + INPUT_RULEID: ${{ inputs.ruleId }} + INPUT_ACTION: ${{ inputs.action }} + INPUT_LEVELS: ${{ inputs.levels }} + INPUT_STAGES: ${{ inputs.stages }} + INPUT_JUSTIFICATION: ${{ inputs.justification }} + INPUT_TOKEN: ${{ inputs.token }} + INPUT_DIRECTCOMMIT: ${{ inputs.directCommit }} + INPUT_BASEBRANCH: ${{ inputs.baseBranch }} + INPUT_REPOSITORYROOT: ${{ inputs.repositoryRoot }} + INPUT_ACTOR: ${{ inputs.actor }} + run: | + $parameters = @{ + RepositoryRoot = $env:INPUT_REPOSITORYROOT + RuleId = $env:INPUT_RULEID + Action = $env:INPUT_ACTION + Levels = $env:INPUT_LEVELS + Stages = $env:INPUT_STAGES + Justification = $env:INPUT_JUSTIFICATION + Token = $env:INPUT_TOKEN + DirectCommit = $env:INPUT_DIRECTCOMMIT -eq 'true' + BaseBranch = $env:INPUT_BASEBRANCH + Actor = $env:INPUT_ACTOR + } + # The result object is the last pipeline output; anything a module emits before it is ignored. + $result = @(& (Join-Path $env:GITHUB_ACTION_PATH 'ChangeRule.ps1') @parameters)[-1] + if ($null -eq $result -or $null -eq $result.PSObject.Properties['ExitCode']) { Write-Host '::error::ChangeRule.ps1 returned no result'; exit 1 } + exit $result.ExitCode diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7bfbde9..8e69ace 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,7 +97,7 @@ Everything an org repo contains after "Use this template". The **class** column | `.github/workflows/Publish.yaml` | On push to the default branch and on demand: validate, refuse stale endpoints, deploy `rulesets/`, the rendered skeletons and `index.html` to the configured target, verify reachability. Never commits (D42). | system | | `.github/workflows/UpdateRulebookSystemFiles.yaml` | Manual, or scheduled when `update.schedule` is set (the update writes the `schedule:` trigger): pull the new template version into a PR (or a direct commit) with the regenerated endpoints and skeletons, after validating the result (section 7.3). | system | | `.github/workflows/ScanDiagnostics.yaml` | Daily (the `schedule:` comes from `scan.schedule`, shipped `17 4 * * *`) and manual: scan the newest stable and prerelease versions of the two analyzer packages on NuGet, record new ids and changed defaults in the catalog, quarantine new ids by the policy, release the ids a level file adopted, regenerate the endpoints and keep one living pull request on `scan-diagnostics/`, or push a direct commit (section 7.4). | system | -| `.github/workflows/ChangeRule.yaml` | Manual form: one override entry, regenerate, open a PR. | system | +| `.github/workflows/ChangeRule.yaml` | Manual form: set or remove one override entry, regenerate, open a PR or push a direct commit per `commitOptions.createPullRequest` (section 7.5). | system | | `.github/workflows/ApplyRulebookChange.yaml` | On an issue with the `rulebook-change` label: gate on collaborator association, apply the change set, regenerate, open a PR or commit (section 7.6). | system | | `.github/ISSUE_TEMPLATE/rulebook-change.yml`, `config.yml` | The issue form the dashboard prefills; blank issues stay enabled. | system | | `.github/Rulebook-Settings.json` | Template URL and sha, base URL, publish target, quarantine policy, twins setting, the ordered `levels` (name, `basedOn`, description) and `stages` (name, description). Carries the settings schema URL in `$schema`. | settings (kept, `$schema` refreshed) | @@ -265,7 +265,7 @@ A rule is `id` (`^[A-Z]{2,3}[0-9]{4}i?$`), `action` (`Error`, `Warning`, `Info`, Common to all four: entries in inventory order with the matrix row justification; the level and stage files carry the delta profile URL in `$schema`; UTF-8 without BOM, LF; a file is written only when its bytes differ, files of the folder that no input produces are deleted first; one `{ File, Path, Change }` object per change (`created`, `modified`, `deleted`); `-WhatIf` writes nothing and returns the same list. The functions throw, as engine tools, on matrix input they cannot use (rows out of inventory order, an id without resolved cells, a level that is not a slug, an unresolved `basedOn` or a cycle, no `default` stage, a stage without a matrix column, a value that is not an action). -`tools/rulebook/Build-Template.ps1 [-RulebookDir] [-TemplateDir] [-WhatIf]` runs the four functions and then `Update-RulebookEndpoints` on `template/`, prints one line per change or `template: current`, and returns the change objects. `tests/Rulebook.Template.Tests.ps1` regenerates `template/` from its 11 hand-written files and compares the bytes, so a matrix change without a template regeneration fails CI. +`tools/rulebook/Build-Template.ps1 [-RulebookDir] [-TemplateDir] [-WhatIf]` runs the four functions and then `Update-RulebookEndpoints` on `template/`, prints one line per change or `template: current`, and returns the change objects. `tests/Rulebook.Template.Tests.ps1` regenerates `template/` from its 12 hand-written files and compares the bytes, so a matrix change without a template regeneration fails CI. ### 5.7 Scan modules @@ -279,6 +279,13 @@ The scan of section 7.4 (WP08) is five modules, each a `.psm1` with a `.psd1` ma | `Rulebook.Quarantine` | `Get-QuarantinePolicy`, `Read-QuarantineFile`, `Write-QuarantineFile`, `New-QuarantineJustification`, `Add-QuarantineEntry`, `Invoke-QuarantineHousekeeping`, `Update-QuarantineFromScan` | The policy (no default, D14), the quarantine files in the template layout, new ids into the policy stages, housekeeping by the rule of C13. | | `Rulebook.Scan` | `Get-RulebookScanPlan`, `Get-ScanTitle`, `ConvertTo-ScanPullRequestBody`, `ConvertTo-ScanSummary`, `Publish-RulebookScan` | The run of section 7.4 on a candidate tree, its title and body, and the living pull request (D45). | +### 5.8 Edit and action helper modules + +| Module | Functions | Role | +|---|---|---| +| `Rulebook.Edit` (WP09) | `Read-OverridesFile`, `ConvertTo-OverridesJson`, `Write-OverridesFile`, `Set-RulebookOverride`, `Remove-RulebookOverride`, `ConvertTo-RulebookChangeSet`, `Test-RulebookChangeSet`, `Invoke-RulebookChangeSet`, `Get-RulebookChangeTitle`, `ConvertTo-ChangeTable`, `ConvertTo-ChangePullRequestBody`, `ConvertTo-ChangeSummary`, `Publish-RulebookChange` | `overrides.json` in the template layout, the change set of sections 7.5 and 7.6 (`set` and `remove`; `release` arrives with WP15) applied all or nothing on a candidate copy, the per-endpoint table and the no-op rule (D48), and the pull request or direct commit (D47). Contract: [reference/change-mechanics.md](reference/change-mechanics.md). | +| `Rulebook.Action` (#58) | `Format-AnnotationText`, `ConvertTo-SingleLine`, `Format-TableCell`, `New-ActionContext`, `Add-Annotation`, `Add-Failure`, `Write-Text`, `Resolve-ActionPath`, `Write-ActionOutput`, `Limit-SummaryText` | The helpers every action entry script shares: workflow command escaping and annotations, the failure kind, the job summary and `GITHUB_OUTPUT`. No engine imports. | + ## 6. Endpoints and skeletons ### 6.1 URL scheme @@ -450,17 +457,17 @@ flowchart LR ### 7.5 Change rule (R10) -Trigger: `workflow_dispatch` with a form. +Trigger: `workflow_dispatch` with a form (implemented with WP09). | Input | Type | Values | |---|---|---| -| `ruleId` | string | `AA0001`, `LC0029`, ... validated against the catalog | +| `ruleId` | string | `AA0001`, `LC0029`, ... checked against `^[A-Z]{2,3}\d{4}i?$` and the catalog | | `action` | choice | Error, Warning, Info, Hidden, None, Remove | -| `levels` | choice | the level slugs from the settings and `*`; the choice list is rewritten by the update workflow (D30) | -| `stages` | choice | the stage slugs from the settings and `*`; same mechanism | -| `justification` | string | stored with the override entry | +| `levels` | choice | one level slug from the settings or `*`; the choice list is rewritten by the update workflow (D30) | +| `stages` | choice | one stage slug from the settings or `*`; same mechanism | +| `justification` | string | optional (D37); stored with the override entry; empty keeps the entry's existing justification | -The action writes or removes one entry in `overrides.json`, regenerates `rulesets/`, runs validation, and opens a PR whose body shows the effective change per endpoint (before and after). Changing the shipped level content itself is done in the engine, in the matrix, and reaches orgs through the update workflow; an org edits its own level and stage files by hand. The same action is callable from a future web UI or VS Code extension because its inputs are plain strings. +The workflow builds a one-item change set and calls the `ChangeRule` action (`Rulebook.Edit`): it writes or removes one entry in `overrides.json` on a candidate copy, regenerates `rulesets/`, validates, and lands the result per `commitOptions.createPullRequest` as a pull request on `change-rule//` or a direct commit (D47; there is no `directCommit` input). The pull request body shows the effective change per endpoint with provenance. A change that alters no endpoint (the entry is unchanged, or a new entry would only repeat what the base already gives) is reported and never written (D48). Changing the shipped level content itself is done in the engine, in the matrix, and reaches orgs through the update workflow; an org edits its own level and stage files by hand. Mechanics, messages and tests: [reference/change-mechanics.md](reference/change-mechanics.md). ### 7.6 Apply change set (R12) @@ -479,7 +486,7 @@ flowchart LR apply -->|direct commit| commit[commit, comment table, close] ``` -A change set is `{ version, note?, changes[] }` with `set` (write an override entry), `remove` (delete one) and `release` (remove an id from the quarantine of the listed stages). It is applied all or nothing, regenerated once, and lands per `commitOptions.createPullRequest` as one PR or one commit (D33). The gate is collaborator association (D34); justification is optional (D37). ChangeRule (section 7.5) builds a one-item change set and calls the same module function. Details, schema and the comment protocol are in [dashboard.md](dashboard.md) sections 6 to 8. +A change set is `{ version, note?, changes[] }` with `set` (write an override entry), `remove` (delete one) and `release` (remove an id from the quarantine of the listed stages). It is applied all or nothing, regenerated once, and lands per `commitOptions.createPullRequest` as one PR or one commit (D33). The gate is collaborator association (D34); justification is optional (D37). ChangeRule (section 7.5) builds a one-item change set and calls the same module function: `Invoke-RulebookChangeSet` with `set` and `remove` landed with WP09 ([reference/change-mechanics.md](reference/change-mechanics.md)); `release`, the change set schema and the issue parsing arrive with WP15. Details, schema and the comment protocol are in [dashboard.md](dashboard.md) sections 6 to 8. ## 8. Settings @@ -553,7 +560,7 @@ Every target ends with the same reachability check: `GET` each endpoint, skeleto | Endpoint unreachable, invalid JSON, invalid enum, timeout | Whole ruleset discarded, one diagnostic AL1033. `alc` aborts the compile (exit 1, no `.app`; timeout: not observed), whether the endpoint is the root path or the skeleton's include ([spike c](reference/spikes/c-alc-on-ubuntu.md), [spike a](reference/spikes/a-hosts-and-skeleton-include.md)), so on raw `alc` the build fails by itself (AL-Go and BcContainerHelper run the same compiler but were not observed). The VS Code language server continues with compiler defaults and shows AL1033 on `app.json` ([spike e](reference/spikes/e-vscode-refetch.md)), so the editor shows default severities: because the matrix follows the analyzer defaults for most rules (D21) and the endpoint only lists deviations (D22), that is close to the intended ruleset; what is lost is every `None` the level set, every downgrade, and the org's overrides. | Validate before publish, reachability check after publish, and as a backstop treat AL1033 as a failure in pipelines (documented in WP11). One fetch per compile keeps the exposure to one request. | | External rulesets disabled in the consumer | AL0767 when the root path is a URL, AL1033 when a local skeleton includes the URL; `alc` aborts with exit 1 in both cases. | Walkthroughs set `enableExternalRulesets` in every consumer. `alc` defaults to disabled. | | Endpoint committed but stale (inputs changed, not regenerated) | Consumers get yesterday's decision. | Regeneration check in Validate; Publish refuses to deploy stale endpoints and keeps the last good site (D42); every writing workflow regenerates in its pull request. | -| Override selector typo | Silent no-op. | Selector validation; the ChangeRule PR body shows before and after per endpoint. | +| Override selector typo | Silent no-op. | Selector validation (C10, and the ChangeRule action refuses an unknown slug before writing); the ChangeRule PR body shows before and after per endpoint, and a change that alters nothing is reported, not written (D48). | | Update PR overwrites an org edit in a system file | Edit lost. | File classes; org decisions live only in `overrides.json`; docs say which files are system files. | | Scan adds an id the org wanted to see | Rule hidden until adopted. | Policy is explicit per org; the PR lists every new id with its default severity and docs link; adopting it in a level file releases it on the next run. | | Someone pushes to the scan branch by hand | The push is replaced by the next run. | The branch is rebuilt from the base on every run with a lease push (D45): the lease covers the window between `ls-remote` and the push only (a push in that window is rejected, an earlier push is replaced), and a base branch that moved while the scan was planning fails the run before the push; the PR body says not to push to the branch. | @@ -609,7 +616,9 @@ See the open decisions table in [adr/README.md](adr/README.md): O3 engine pinnin | Update PR title | `[@] Update Rulebook System Files from ALCops/rulebook - ` | | | Scan branch | `scan-diagnostics/`, rebuilt by every run (D45) | `scan-diagnostics/main` | | Scan PR title | `Scan diagnostics: (