diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index a815f51..39cf4f9 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -10,7 +10,7 @@ bundle: attestation_identity_digest: "sha256:e1b787c63de916f750e2cbcbf664967669024e1062964515f612b2c826c9e10d" projection: - input_digest: "sha256:2b4778d4f725f958775501f9ea0a01e005ff452f426b088c3b78b9164d61deeb" + input_digest: "sha256:4aff802086959c163a94f212e2bc166d7f515577bfce4fc84343f6b07fe6130f" output_digest: "sha256:8e8a9aec59f3cb0c89cba52e37ab0015501f94aaa72d8e9dc3e03304c7244852" files: - path: ".gds/compiled-policy.json" diff --git a/.gds/repository.yaml b/.gds/repository.yaml index 4bf02f9..7dd32ea 100644 --- a/.gds/repository.yaml +++ b/.gds/repository.yaml @@ -48,7 +48,7 @@ verification: lint: ["actionlint"] test: ["scripts/validate_module.sh"] required: ["lint", "test"] - required_contexts: ["ci-gate"] + required_contexts: [] agent: context_profile: "project-default" diff --git a/.github/rulesets/branch-main.json b/.github/rulesets/branch-main.json index 06ac733..c07a49d 100644 --- a/.github/rulesets/branch-main.json +++ b/.github/rulesets/branch-main.json @@ -29,22 +29,11 @@ "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true, "allowed_merge_methods": [ "merge" ] } - }, - { - "type": "required_status_checks", - "parameters": { - "strict_required_status_checks_policy": true, - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "ci-gate" - } - ] - } } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70d81d1..7f016a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,10 @@ on: branches: [main] pull_request: branches: [main] - # ci-gate is this repository's required context. A required check is never - # reported to a merge queue unless its workflow subscribes to merge_group, - # so without this an enabled queue would wait forever for a status that can - # never arrive. + # ci-gate is truthful advisory evidence here, not a required merge context. + # A status check is never reported to a merge queue unless its workflow + # subscribes to merge_group, so this stays for consumers who require + # ci-gate and for a later queue enablement. merge_group: types: [checks_requested] workflow_dispatch: @@ -249,7 +249,7 @@ jobs: # notice one silently becoming a no-op. # # These live in ci.yml rather than in a workflow of their own because a - # required context must be caller-native: ci-gate's `needs` is this run's real + # aggregate must be caller-native: ci-gate's `needs` is this run's real # dependency graph, and nothing may cross a workflow boundary. They cannot be # a called reusable either — a self workflow must not be `on: workflow_call`, # since everything that is becomes part of the product consumers pin. @@ -282,7 +282,7 @@ jobs: go-version: '1.24' # setup-go caches the module and build cache with no input at all. # This job is in `ci-gate.needs`, so that entry would be an unreviewed - # input to a required check. Found by deriving the required surface + # input to the self-CI aggregate. Found by deriving that surface # from the gate's own graph rather than from a hand-written list. cache: false @@ -433,8 +433,8 @@ jobs: contents: read steps: # Caller-native: `needs` is this run's real dependency graph, so nothing - # crosses a workflow boundary and nothing can be substituted. This is the - # shape a required check must have — see docs/08 and gate.yml's header. + # crosses a workflow boundary and nothing can be substituted. Consumers + # who require `ci-gate` need this shape — see docs/08 and gate.yml. - name: Aggregate required results env: RESULTS: ${{ toJSON(needs) }} diff --git a/.github/workflows/dependabot-catalog-convergence.yml b/.github/workflows/dependabot-catalog-convergence.yml index 3043ad5..a7b8c49 100644 --- a/.github/workflows/dependabot-catalog-convergence.yml +++ b/.github/workflows/dependabot-catalog-convergence.yml @@ -80,7 +80,7 @@ jobs: persist-credentials: true path: candidate - name: Synchronize derived action declarations - run: python3 -I trusted/scripts/sync_action_catalog.py --root candidate + run: python3 -I trusted/scripts/sync_action_catalog.py --root candidate --catalog-only - name: Commit converged declarations id: commit working-directory: candidate @@ -88,12 +88,17 @@ jobs: HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | set -euo pipefail - if git diff --quiet -- .github/workflows catalog docs/generated; then + # GITHUB_TOKEN cannot push workflow-file updates without the + # `workflows` permission. Dependabot already owns those files; this + # job only commits catalog and generated docs derived from them. + # sync_action_catalog.py --catalog-only requires a unique pin per + # action across workflow files and fails closed on mixed identities. + if git diff --quiet -- catalog docs/generated; then echo "catalog already converged" else git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add .github/workflows catalog docs/generated + git add catalog docs/generated git commit -m "chore(deps): synchronize action catalog" fi git push origin "HEAD:${HEAD_BRANCH}" diff --git a/.github/workflows/zizmor-sarif.yml b/.github/workflows/zizmor-sarif.yml index 9635a29..b8cf1ce 100644 --- a/.github/workflows/zizmor-sarif.yml +++ b/.github/workflows/zizmor-sarif.yml @@ -61,7 +61,7 @@ jobs: with: version: 0.11.30 # This workflow backs the `zizmor` job, which is in `ci-gate.needs`. - # A required gate must not take an unreviewed input. + # The self-CI aggregate must not take an unreviewed input. enable-cache: false - name: Run zizmor (SARIF) diff --git a/AGENTS.md b/AGENTS.md index 6cc995d..c40c381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,12 +61,13 @@ Touch this → also do this: Failed, cancelled, skipped and missing results prove nothing. Preserve the scope of actual fixture execution; static validation is not runtime evidence. - **a gate's behaviour** → the `shell-gates` and `dockerfile-gate` jobs in - `ci.yml` are inside `ci-gate`'s `needs`, so they **block the merge**. They lift - each gate's real step out of its workflow with - `scripts/negative_gate_probe.py` and run it twice: the broken fixture under - `tests/fixtures/negative/` must be rejected and the clean one accepted, for - every covered gate, `zizmor-no-sarif` among them. A gate that never fails is - not a gate, and a probe that never passes is not a test. + `ci.yml` are inside `ci-gate`'s `needs`. They remain focused self-CI evidence + (ordinary merge does not wait on remote `ci-gate`). They lift each gate's + real step out of its workflow with `scripts/negative_gate_probe.py` and run + it twice: the broken fixture under `tests/fixtures/negative/` must be + rejected and the clean one accepted, for every covered gate, + `zizmor-no-sarif` among them. A gate that never fails is not a gate, and a + probe that never passes is not a test. - **a catalog file** → run `generate_docs.py` through the isolated launcher above. - **a skill** → run `sync_skills.py` through the isolated launcher above. - **a product fact** → re-read its `source_urls` and correct it. Bumping the @@ -101,6 +102,7 @@ the named script — its fixtures say what the contract is. | `capability.schema.yaml` is executed against `capabilities.yml`, not just shipped | `validate_catalog.py`, `_json_schema.py` | | Branch and tag rulesets are `active`, and release tags carry the immutability rules | `check_rulesets.py` | | A documented `zizmor`/`actionlint` command is the one `ci-gate` runs | `check_documented_commands.py` | +| This repository's default-branch ruleset has no required general CI context | `check_rulesets.py` | | Every `CHANGELOG.md` release heading is a real tag, and dates run newest first | `check_release_ledger.py` | Two rules no validator can catch for you: @@ -113,6 +115,8 @@ Two rules no validator can catch for you: Scorecard is the trap: it cannot run on a pull-request head, so it protects nothing while blocking every merge. The requirement can live in *classic* branch protection, where a ruleset-shaped investigation will not find it. + This repository does not require a general CI status check for ordinary + merge; consumers may still select `ci-gate` as theirs. ## Tier truth @@ -128,9 +132,18 @@ a declared lane or green source check is not proof it ran on every platform. ## Git Conventional Commits under 100 chars, `git commit -s -S`, no `Co-Authored-By`. -`main` is PR-only and takes **merge commits** (squash and rebase are disabled -live), behind the required `ci-gate`. Fill the PR template; workflow changes owe -a permissions diff and a threat-model note. +`main` is PR-only and takes **merge commits**. Ordinary merge does not wait on +a required general CI status check. `ci-gate` still runs (core validators, +changed-path validators, actionlint, zizmor, and the negative shell/Dockerfile +gates) and is truthful advisory evidence. Runtime fixtures, Scorecard, CodeQL, +language packs and other self-workflows stay truthful background evidence: a +failure remains a failure and opens an issue here. Prove a change with the +local Commands above. Fill the PR template; workflow changes owe a permissions +diff and a threat-model note. + +Source ruleset `.github/rulesets/branch-main.json` is merge-only, signed, and +has no required general CI context. Live GitHub settings are not applied from +this tree; a drift report belongs with the coordinator's plan→approve→apply. Releases are tag-driven and immutable: `VERSION` must equal the tag on one LF-terminated line, `CHANGELOG.md` must hold exactly one matching `## [X.Y.Z]` diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a058ee..b40599b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ The project follows Semantic Versioning. Consumer adoption resolves the programme from the immutable release being pinned, not from `main`. Private attestations stay an Enterprise Cloud plan gate, independent of the three add-ons. +- Dependabot catalog convergence commits only catalog and generated docs, so + the default `GITHUB_TOKEN` can push without `workflows` permission. + Catalog-only follows the unique workflow pin per action and fails closed + when identities are mixed, so the catalog cannot describe a pin the tree + does not share. Ordinary merge in this repository does not require a + general CI status check; `ci-gate` stays truthful advisory evidence. + Authored skill `metadata:` mappings stay mappings. - Re-verify four vendor allowance records with staggered review dates, correct Ubicloud's monthly credit and Harness's conditional CI credit semantics, and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4df9cf..bb1e2ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,8 +35,9 @@ license your contribution under [AGPL-3.0-or-later](LICENSE). ## Non-negotiables for every workflow PR -These are enforced by review, the self-CI `ci-gate`, `actionlint`, and `zizmor`. -A PR that misses any of them will not be merged. +These are enforced by review plus the focused local checks below (`validate_all`, +`actionlint`, and `zizmor`). `ci-gate` still runs them remotely as truthful +advisory evidence; ordinary merge does not wait on that remote context. 1. **Full-SHA action pins with version comments.** Every `uses:` of a third-party action pins a 40-character commit SHA followed by a version @@ -88,7 +89,7 @@ A PR that misses any of them will not be merged. ## Local checks -Run these before opening a PR (they mirror the self-CI `ci-gate`): +Run these before opening a PR (they are the focused checks `ci-gate` also runs): ```bash # Lint all workflow YAML @@ -154,12 +155,13 @@ one command ended up disagreeing. ## Branch protection and CI -`main` is protected: signed commits, required review plus code-owner review, -linear history, no force-push or deletion, and the required `ci-gate` status -check. All workflow files are owned by [@rldyourmnd](https://github.com/rldyourmnd) -via [CODEOWNERS](.github/CODEOWNERS), so a maintainer review is always required. -Open PRs against `main` from a topic branch; the `ci-gate` check (contract + -actionlint + zizmor) must be green before merge. +`main` is protected: signed commits, no force-push or deletion, merge commits +only in the source ruleset, and no required general CI status check. `ci-gate` +(core + touched validators, actionlint, zizmor, negative gates) stays truthful +advisory evidence. Background workflows must stay truthful; they are not merge +blockers. All workflow files are owned +by [@rldyourmnd](https://github.com/rldyourmnd) via +[CODEOWNERS](.github/CODEOWNERS). Open PRs against `main` from a topic branch. ## Releases diff --git a/README.md b/README.md index ad62e63..271f6a2 100644 --- a/README.md +++ b/README.md @@ -338,10 +338,14 @@ has `pre` and `post` entry points that GitHub can execute even when a step-level `main` and release tags are protected by **rulesets** in [`.github/rulesets/`](.github/rulesets/) (pull-request-only merge commits, -resolved review threads, signed commits, linear history, the strict `ci-gate` -status check, and tag protection). The repository's solo-maintainer rule does -not require an impossible self-approval; reusable projects with independent -reviewers should require approvals and CODEOWNERS review. See +resolved review threads, signed commits, no force-push or deletion, and tag +protection). Ordinary merge in this repository does not wait on a required +general CI status check. `ci-gate` still runs as truthful advisory evidence; +Scorecard, CodeQL and runtime fixtures stay background evidence. Consumers +may require `ci-gate` if they want a merge gate. The repository's +solo-maintainer rule does not require an impossible self-approval; reusable +projects with independent reviewers should require approvals and CODEOWNERS +review. See [`docs/08-governance-rulesets.md`](docs/08-governance-rulesets.md) for the rulesets-first model and a migration guide from classic branch protection. diff --git a/SECURITY.md b/SECURITY.md index 358119e..eab5bf2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,14 +26,15 @@ response as soon as reasonably possible. `timeout-minutes`. - `ci.yml` runs static validators (`scripts/validate_all.py`), `actionlint`, and `zizmor` (regular persona, SARIF) against this repository's own workflows on - every push and pull request; `ci-gate` aggregates them. + every push and pull request; `ci-gate` aggregates them as truthful advisory + evidence, not a required merge context. - `check_harden_runner_contract.py` rejects conditional Harden-Runner steps and any paid runtime-hardening reference in cross-tier/private-free workflows. - `main` is protected by a repository ruleset (`.github/rulesets/branch-main.json`): - pull-request-only merge commits, resolved review threads, signed commits, - linear history, no force-push or deletion, and the strict `ci-gate` status - check. The solo-maintainer repository does not require self-approval. Release - tags are + pull-request-only merge commits, resolved review threads, signed commits, and + no force-push or deletion. Ordinary merge does not wait on a required general + CI status check. The solo-maintainer repository does not require + self-approval. Release tags are protected by `.github/rulesets/tag-semver.json`. - Releases are tag-driven and immutable, and ship an SPDX SBOM, SHA256SUMS, a build-provenance attestation, and an SBOM attestation (SLSA v1 Build L3, diff --git a/catalog/cache-contract.yml b/catalog/cache-contract.yml index 8f8a8d9..bac2ed1 100644 --- a/catalog/cache-contract.yml +++ b/catalog/cache-contract.yml @@ -190,25 +190,25 @@ refusals: action: astral-sh/setup-uv input: enable-cache value: false - reason: The blocking gate must not depend on an unreviewed input. + reason: The self-CI aggregate must not depend on an unreviewed input. - workflow: .github/workflows/ci.yml job: validate-touched action: astral-sh/setup-uv input: enable-cache value: false - reason: The blocking gate must not depend on an unreviewed input. + reason: The self-CI aggregate must not depend on an unreviewed input. - workflow: .github/workflows/ci.yml job: shell-gates action: astral-sh/setup-uv input: enable-cache value: false - reason: The blocking gate must not depend on an unreviewed input. + reason: The self-CI aggregate must not depend on an unreviewed input. - workflow: .github/workflows/ci.yml job: dockerfile-gate action: astral-sh/setup-uv input: enable-cache value: false - reason: The blocking gate must not depend on an unreviewed input. + reason: The self-CI aggregate must not depend on an unreviewed input. - workflow: .github/workflows/ci.yml job: shell-gates action: actions/setup-go @@ -216,8 +216,8 @@ refusals: value: false reason: >- setup-go caches the module and build cache with no input at all, and this - job is in `ci-gate.needs`. Found by deriving the required surface from the - gate's own graph; the hand-written list had not named it. + job is in `ci-gate.needs`. Found by deriving the aggregate surface from + the gate's own graph; the hand-written list had not named it. - workflow: .github/workflows/zizmor-sarif.yml job: zizmor action: astral-sh/setup-uv @@ -226,7 +226,7 @@ refusals: reason: >- This workflow backs the `zizmor` job, which is in `ci-gate.needs`. The contract's own closing paragraph used to assert that the undeclared - remainder could not reach a required check; this is the counter-example. + remainder could not reach the aggregate; this is the counter-example. # Everything else is left to the producer's default on purpose, and there is now # exactly one such place: `python-ci.yml`, which sets `enable-cache: true` @@ -237,6 +237,6 @@ refusals: # where a poisoned entry cannot reach a release or a required check". That was # false: `zizmor-sarif.yml` is called by the `zizmor` job, which is in # `ci-gate.needs`, and it took setup-uv's default. The refusals below are no -# longer a hand-written list -- `check_cache_contract.py` derives the required +# longer a hand-written list -- `check_cache_contract.py` derives the aggregate # jobs from `ci-gate`'s own `needs` graph, follows local `uses:` into the called # workflow, and demands a refusal for every cache-capable step it finds there. diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml index 5b927ea..68fa983 100644 --- a/catalog/python-execution.yml +++ b/catalog/python-execution.yml @@ -472,6 +472,9 @@ "_workflow_yaml", "check_python_execution_contract" ], + "check_rulesets.py": [ + "_strict_yaml" + ], "check_runner_routing.py": [ "_strict_yaml", "_workflow_yaml", diff --git a/docs/08-governance-rulesets.md b/docs/08-governance-rulesets.md index 4bbfc30..72db21c 100644 --- a/docs/08-governance-rulesets.md +++ b/docs/08-governance-rulesets.md @@ -78,13 +78,15 @@ version-controlled and reviewable. | Block force-push (`non_fast_forward`) | No history rewrite | | Block deletion | Protect the branch | -This is the recommended multi-maintainer baseline. This repository's live -solo-maintainer variant still requires a pull request, merge-commit-only merge, -resolved review threads, signed commits, linear history, no -force-push/deletion, and the strict `ci-gate` check, but sets approvals to zero -because GitHub does not allow an author to approve their own pull request. -Projects with an independent reviewer should use the recommendation above. -`ci-gate` is the aggregate gate job in `ci.yml`. +This is the recommended multi-maintainer baseline for **consumers who want a +merge gate**. This repository's own ruleset still requires a pull request, +merge-commit-only merge, resolved review threads, signed commits, and no +force-push/deletion, but it does **not** require a general CI status check: +ordinary merge does not wait on remote `ci-gate`. `ci-gate` still runs here as +truthful advisory evidence. Approvals are zero because GitHub does not allow +an author to approve their own pull request. Projects with an independent +reviewer should use the recommendation above. `ci-gate` is the aggregate job +in `ci.yml`; requiring it remains a selectable consumer choice. ## Who owns which governance surface @@ -94,7 +96,7 @@ have to re-derive it: | Surface | Owner | Status | | --- | --- | --- | -| `.github/rulesets/branch-main.json` | this repository | Canonical desired state for ruleset `18506136`. Verified against the live API: `allowed_merge_methods: ["merge"]`, `ci-gate` strict, signed commits, thread resolution, zero approvals. | +| `.github/rulesets/branch-main.json` | this repository | Canonical desired state for ruleset `21104086`: `allowed_merge_methods: ["merge"]`, signed commits, thread resolution, zero approvals, extra approval for unattributed changes, **no** required general CI status check. Live settings are not applied from this tree. | | `.github/rulesets/tag-semver.json`, `push-hygiene.json` | this repository | Canonical desired state for the repository's own tag and push rules. | | `NDDev baseline: *` rulesets | the estate control plane | Applied on top, not tracked here. Deleting or editing them from this repository would fight the reconciler. | | `.gds/compiled-policy.json` | GDS, generated | Generated from the estate policy sources; agrees with live state since the repository-tier override landed. Never edit it here. | diff --git a/scripts/check_anchor_contexts.py b/scripts/check_anchor_contexts.py index ae95129..d4e15e6 100644 --- a/scripts/check_anchor_contexts.py +++ b/scripts/check_anchor_contexts.py @@ -17,9 +17,10 @@ * the branch loses one, so the anchor advertises assurance that no longer exists. -`check_rulesets.py` already asserts the *tracked* `branch-main.json` requires -`ci-gate`, but nothing compared the tracked ruleset to the live one, so a change -made through the API left every file in this repository unchanged and green. +`check_rulesets.py` asserts the tracked `branch-main.json` does not require a +general CI status check. This script still compares that desired set (from +`.gds/repository.yaml`) to the live ruleset, so a change made through the API +cannot leave every file in this repository unchanged and green. Advisory tier: what a live ruleset says is a property of the repository's settings at this moment, not of the tree, and `AGENTS.md` is explicit that such diff --git a/scripts/check_cache_contract.py b/scripts/check_cache_contract.py index a93b960..40bd2cc 100644 --- a/scripts/check_cache_contract.py +++ b/scripts/check_cache_contract.py @@ -20,12 +20,12 @@ job no longer exists are both findings, because a contract that names things that are gone stops being read. * **The required surface is derived, not listed.** `ci-gate`'s own `needs` graph - says which jobs a merge depends on, and a `uses:` job is followed into the - workflow it calls. Any step in that surface running an action that caches by - default must carry a declared refusal. The hand-written list missed exactly - this: `zizmor-sarif.yml` backs the required `zizmor` job and took setup-uv's - default, while the contract's own closing paragraph asserted that the - undeclared remainder could not reach a required check. + says which jobs the self-CI aggregate depends on, and a `uses:` job is + followed into the workflow it calls. Any step in that surface running an + action that caches by default must carry a declared refusal. The hand-written + list missed exactly this: `zizmor-sarif.yml` backs the `zizmor` job and took + setup-uv's default, while the contract's own closing paragraph asserted that + the undeclared remainder could not reach the aggregate. What this cannot see, stated plainly rather than implied: an action that caches by default and exposes no input at all is invisible to static analysis, and @@ -164,7 +164,7 @@ def _action(step: dict[str, Any]) -> str: def _required_surface() -> set[tuple[str, str]]: - """Every (workflow, job) a merge depends on, read from `ci-gate`'s own graph. + """Every (workflow, job) the self-CI aggregate depends on, from `ci-gate`. A `uses:` job is followed into the workflow it calls, because that is where its steps actually live -- and where the cache-capable step that started all diff --git a/scripts/check_merge_group.py b/scripts/check_merge_group.py index 4108248..240a2d8 100644 --- a/scripts/check_merge_group.py +++ b/scripts/check_merge_group.py @@ -3,7 +3,7 @@ GitHub merge queue evaluates required status checks on the synthetic `merge_group` event. If a ruleset enables merge queue, this script ensures the -self-CI workflow that publishes the required `ci-gate` check also listens for +self-CI workflow that publishes the `ci-gate` aggregate also listens for `merge_group`. """ from __future__ import annotations diff --git a/scripts/check_rulesets.py b/scripts/check_rulesets.py index 053ba3a..dc73607 100644 --- a/scripts/check_rulesets.py +++ b/scripts/check_rulesets.py @@ -6,7 +6,7 @@ Shape is not the same as force, and this check used to assert only shape. `enforcement` was validated against an enum that accepts `disabled`, so the default-branch ruleset could have been switched off entirely and this gate would -still have been green while the required `ci-gate` context protected nothing. +still have been green while signatures and deletion rules protected nothing. Tag validation was a single boolean — "a tag ruleset exists" — even though the consumer-adoption skill tells every consumer that tags in this library are immutable, a promise that rests on three specific rules none of which were @@ -19,8 +19,11 @@ import sys from pathlib import Path +from ci_workflows_tools._strict_yaml import strict_load + REPO_ROOT = Path(__file__).resolve().parent.parent RULESETS_DIR = REPO_ROOT / ".github" / "rulesets" +ANCHOR = REPO_ROOT / ".gds" / "repository.yaml" VALID_TARGETS = {"branch", "tag", "push"} VALID_ENFORCEMENT = {"active", "evaluate", "disabled"} @@ -131,14 +134,28 @@ def check() -> list[str]: if "~DEFAULT_BRANCH" in include or "~ALL" in include: saw_branch_default = True if "required_status_checks" in rule_types: - params = next( - (r.get("parameters", {}) for r in rules - if r.get("type") == "required_status_checks"), {}) - contexts = {c.get("context") for c in params.get("required_status_checks", [])} - if "ci-gate" not in contexts: - problems.append(f"{name}: default-branch ruleset must require the `ci-gate` status check") - else: - problems.append(f"{name}: default-branch ruleset must include a `required_status_checks` rule") + problems.append( + f"{name}: default-branch ruleset must not require a " + "general CI status check; ordinary merge does not wait " + "on remote ci-gate" + ) + try: + declared = { + str(item) + for item in ( + (strict_load(ANCHOR).get("verification") or {}) + .get("required_contexts") or [] + ) + } + except (OSError, ValueError) as exc: + problems.append(f"{ANCHOR.name}: {exc}") + declared = set() + if declared: + problems.append( + ".gds/repository.yaml verification.required_contexts " + f"must be empty; ordinary merge does not wait on " + f"{sorted(declared)}" + ) if target == "tag": saw_tag = True missing_tag_rules = REQUIRED_TAG_RULES - rule_types diff --git a/scripts/check_skills.py b/scripts/check_skills.py index 2ddea11..9e7cc47 100644 --- a/scripts/check_skills.py +++ b/scripts/check_skills.py @@ -6,6 +6,9 @@ size), the fixed skill set, and — like a generated-docs drift check — that the mirror is byte-identical to the source. It plugs into validate_all via `check()`. + +Authored extra frontmatter (`license`, `compatibility`, and a `metadata:` +mapping) is part of the product. Do not flatten `metadata` to a string. """ from __future__ import annotations diff --git a/scripts/check_workflow_contracts.py b/scripts/check_workflow_contracts.py index 95ff424..fceec66 100644 --- a/scripts/check_workflow_contracts.py +++ b/scripts/check_workflow_contracts.py @@ -2,7 +2,7 @@ """Reusable-workflow contract: every workflow except the self workflows (`ci.yml`, `release.yml`) must be reusable (`on: workflow_call`). The self workflows must NOT be reusable, and `ci.yml` must expose the `ci-gate` job that -branch protection requires as a status check. Caller-provided command runners +aggregates focused self-CI as truthful advisory evidence. Caller-provided command runners must also fail on the first failing command instead of returning the status of only the final command. The Go pack's history-depth input remains a typed, backward-compatible pass-through to checkout. @@ -267,7 +267,29 @@ def check() -> list[str]: ci = load_yaml((workflow_files()[0].parent / "ci.yml")) jobs = ci.get("jobs", {}) or {} if "ci-gate" not in jobs: - problems.append("ci.yml: missing required `ci-gate` job (branch-protection status check)") + problems.append("ci.yml: missing `ci-gate` aggregate job (advisory evidence, not a required merge context)") + + # Run 34091935173 failed because this job committed workflow files and + # GITHUB_TOKEN cannot push them without `workflows` permission. Catalog + # follow-up must not expand App permissions; it commits catalog only. + convergence = (workflow_files()[0].parent / "dependabot-catalog-convergence.yml").read_text( + encoding="utf-8" + ) + if "git add .github/workflows" in convergence: + problems.append( + "dependabot-catalog-convergence.yml: must not git-add workflow files; " + "GITHUB_TOKEN cannot push them without workflows permission" + ) + if "--catalog-only" not in convergence: + problems.append( + "dependabot-catalog-convergence.yml: must run sync_action_catalog.py " + "--catalog-only so it does not rewrite workflow files" + ) + if "git add catalog docs/generated" not in convergence: + problems.append( + "dependabot-catalog-convergence.yml: catalog convergence commit must " + "add only catalog and docs/generated" + ) go_ci = load_yaml((workflow_files()[0].parent / "go-ci.yml")) go_on = get_on(go_ci) diff --git a/scripts/sync_action_catalog.py b/scripts/sync_action_catalog.py index 582c0c8..83bef57 100644 --- a/scripts/sync_action_catalog.py +++ b/scripts/sync_action_catalog.py @@ -5,6 +5,7 @@ import collections import pathlib import re +import sys import urllib.error import urllib.request from collections.abc import Callable @@ -44,7 +45,9 @@ def resolve_action_image(action: str, sha: str) -> str: ) -def workflow_pins(root: pathlib.Path) -> dict[str, tuple[str, str]]: +def workflow_pins( + root: pathlib.Path, *, require_unique: bool = False +) -> dict[str, tuple[str, str]]: found: dict[str, collections.Counter[tuple[str, str]]] = {} for path in sorted((root / ".github/workflows").glob("*.yml")): for line in path.read_text(encoding="utf-8").splitlines(): @@ -57,8 +60,17 @@ def workflow_pins(root: pathlib.Path) -> dict[str, tuple[str, str]]: result: dict[str, tuple[str, str]] = {} for repository, identities in found.items(): ranked = identities.most_common() - if len(ranked) > 1 and ranked[0][1] == ranked[1][1]: - raise ValueError(f"{repository} has no unique majority identity: {ranked}") + if len(ranked) > 1: + if require_unique: + raise ValueError( + f"{repository} has mixed identities {ranked}; " + "catalog-only cannot rewrite workflow files, so the catalog " + "must not describe a pin the tree does not share" + ) + if ranked[0][1] == ranked[1][1]: + raise ValueError( + f"{repository} has no unique majority identity: {ranked}" + ) result[repository] = ranked[0][0] return result @@ -66,25 +78,34 @@ def workflow_pins(root: pathlib.Path) -> dict[str, tuple[str, str]]: def synchronize( root: pathlib.Path, image_resolver: Callable[[str, str], str] = resolve_action_image, + *, + catalog_only: bool = False, ) -> list[str]: - pins = workflow_pins(root) + pins = workflow_pins(root, require_unique=catalog_only) changed: list[str] = [] - for path in sorted((root / ".github/workflows").glob("*.yml")): - before = path.read_text(encoding="utf-8") - output: list[str] = [] - for line in before.splitlines(): - match = PIN.search(line) - if match is not None: - reference, sha, version = match.groups() - repository = "/".join(reference.split("/")[:2]) - expected = pins[repository] - if (sha, version) != expected: - line = line[:match.start(2)] + expected[0] + line[match.end(2):match.start(3)] + expected[1] + line[match.end(3):] - output.append(line) - after = "\n".join(output) + "\n" - if after != before: - path.write_text(after, encoding="utf-8") - changed.append(str(path.relative_to(root))) + if not catalog_only: + for path in sorted((root / ".github/workflows").glob("*.yml")): + before = path.read_text(encoding="utf-8") + output: list[str] = [] + for line in before.splitlines(): + match = PIN.search(line) + if match is not None: + reference, sha, version = match.groups() + repository = "/".join(reference.split("/")[:2]) + expected = pins[repository] + if (sha, version) != expected: + line = ( + line[:match.start(2)] + + expected[0] + + line[match.end(2):match.start(3)] + + expected[1] + + line[match.end(3):] + ) + output.append(line) + after = "\n".join(output) + "\n" + if after != before: + path.write_text(after, encoding="utf-8") + changed.append(str(path.relative_to(root))) tools = root / "catalog/tools.yml" lines = tools.read_text(encoding="utf-8").splitlines() @@ -155,8 +176,19 @@ def synchronize( def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd()) + parser.add_argument( + "--catalog-only", + action="store_true", + help="update catalog and generated docs from the unique workflow pin " + "per action; fail closed on mixed identities; do not rewrite " + "workflow files (GITHUB_TOKEN cannot push them)", + ) args = parser.parse_args() - changed = synchronize(args.root.resolve()) + try: + changed = synchronize(args.root.resolve(), catalog_only=args.catalog_only) + except ValueError as exc: + print(exc, file=sys.stderr) + return 1 print("\n".join(changed) if changed else "action-catalog-current") return 0 diff --git a/scripts/test_sync_action_catalog.py b/scripts/test_sync_action_catalog.py index 851b00b..c6f9d3d 100644 --- a/scripts/test_sync_action_catalog.py +++ b/scripts/test_sync_action_catalog.py @@ -7,6 +7,14 @@ from ci_workflows_tools.sync_action_catalog import synchronize +def _write_catalog(root: pathlib.Path, sha: str, version: str) -> None: + (root / "catalog/tools.yml").write_text( + "tools:\n - id: example\n kind: action\n" + f' current_version: "{version}"\n pin: "example/action@{sha}"\n', + encoding="utf-8", + ) + + def check() -> list[str]: problems: list[str] = [] with tempfile.TemporaryDirectory() as directory: @@ -20,11 +28,7 @@ def check() -> list[str]: f"jobs:\n x:\n steps:\n - uses: example/action@{new} # v2.0.0\n", encoding="utf-8", ) - (root / "catalog/tools.yml").write_text( - "tools:\n - id: example\n kind: action\n" - f' current_version: "v1.0.0"\n pin: "example/action@{old}"\n', - encoding="utf-8", - ) + _write_catalog(root, old, "v1.0.0") (root / "catalog/action-images.yml").write_text( "images:\n - action: example/action\n" " image: docker://example/action:1.0.0\n", @@ -55,6 +59,75 @@ def check() -> list[str]: problems.append("transitive Docker image did not converge") if synchronize(root, image_resolver=lambda action, sha: "unexpected"): problems.append("second synchronization was not idempotent") + + # PR92 shape: every workflow file already shares one pin; catalog lags. + # catalog-only must follow that unique pin and leave workflows untouched. + old = "c" * 40 + new = "d" * 40 + (root / ".github/workflows/ci.yml").write_text( + f"jobs:\n x:\n steps:\n - uses: example/action@{new} # v2.0.0\n", + encoding="utf-8", + ) + (root / ".github/workflows/second.yml").write_text( + f"jobs:\n z:\n steps:\n - uses: example/action@{new} # v2.0.0\n", + encoding="utf-8", + ) + if (root / ".github/workflows/other.yml").exists(): + (root / ".github/workflows/other.yml").unlink() + _write_catalog(root, old, "v1.0.0") + before_ci = (root / ".github/workflows/ci.yml").read_text(encoding="utf-8") + before_second = (root / ".github/workflows/second.yml").read_text(encoding="utf-8") + changed = synchronize( + root, + image_resolver=lambda action, sha: "docker://example/action:2.0.0", + catalog_only=True, + ) + if any(path.startswith(".github/workflows/") for path in changed): + problems.append("catalog-only unique-pin path rewrote a workflow file") + if (root / ".github/workflows/ci.yml").read_text(encoding="utf-8") != before_ci: + problems.append("catalog-only unique-pin path mutated ci.yml") + if (root / ".github/workflows/second.yml").read_text(encoding="utf-8") != before_second: + problems.append("catalog-only unique-pin path mutated second.yml") + tools = (root / "catalog/tools.yml").read_text(encoding="utf-8") + if f"example/action@{new}" not in tools or 'current_version: "v2.0.0"' not in tools: + problems.append("catalog-only unique-pin path did not follow the shared workflow pin") + + # Mixed identities: catalog-only must fail closed and write nothing. + (root / ".github/workflows/other.yml").write_text( + f"jobs:\n y:\n steps:\n - uses: example/action@{old} # v1.0.0\n", + encoding="utf-8", + ) + _write_catalog(root, old, "v1.0.0") + before_other = (root / ".github/workflows/other.yml").read_text(encoding="utf-8") + before_catalog = (root / "catalog/tools.yml").read_text(encoding="utf-8") + raised = None + try: + synchronize( + root, + image_resolver=lambda action, sha: "docker://example/action:2.0.0", + catalog_only=True, + ) + except ValueError as exc: + raised = exc + if raised is None: + problems.append("catalog-only mixed pins must fail closed") + elif "mixed identities" not in str(raised): + problems.append(f"catalog-only mixed pins raised unexpected: {raised}") + if (root / ".github/workflows/other.yml").read_text(encoding="utf-8") != before_other: + problems.append("catalog-only mixed pins mutated a workflow file") + if (root / "catalog/tools.yml").read_text(encoding="utf-8") != before_catalog: + problems.append("catalog-only mixed pins wrote a catalog pin the tree does not share") + + # Full sync still majority-rewrites stragglers for local/human use. + changed = synchronize( + root, + image_resolver=lambda action, sha: "docker://example/action:2.0.0", + ) + other = (root / ".github/workflows/other.yml").read_text(encoding="utf-8") + if f"example/action@{new} # v2.0.0" not in other: + problems.append("full synchronization did not rewrite the straggler workflow pin") + if ".github/workflows/other.yml" not in changed: + problems.append("full synchronization did not report the rewritten workflow") return problems