diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8decda7..f4f7613 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,6 +91,9 @@ jobs: - name: 🧪 Test independent Monorepo CI aggregate ruleset run: bash tests/ci-aggregate-ruleset.sh + - name: 🧪 Test retained signing-rule safety + run: bash tests/signing-rule-retirement.sh + # The drift check itself reads live GitHub state and runs on a schedule # (repository-drift-check.yaml); this only pins its comparison logic, # against fixtures, so it stays offline and PR-safe. diff --git a/AGENTS.md b/AGENTS.md index 6c0ae8c..25b0f74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,12 +102,13 @@ bash tests/admin-team-policy.sh # Admins policy invariants bash tests/declarative-coverage.sh # every repo declared in every rendered dimension bash tests/declarative-coverage-fail-closed.sh # rendered-label reads fail closed bash tests/repository-update-policy.sh # active Repository update invariants +bash tests/signing-rule-retirement.sh # retained signing-rule identity and safe lifecycle bash tests/release-contract.sh # deploy/ changes must trigger a release bash tests/deploy-deletions.sh # removed deploy/ resources must be acknowledged per resource bash tests/repository-drift.sh # declared-vs-live comparison logic ``` -Those eight commands are the baseline checks that `ci.yaml` runs. Pull requests additionally pass +Those nine commands are the baseline checks that `ci.yaml` runs. Pull requests additionally pass their changed paths and title through `scripts/validate-release-contract.sh` and their base/head renders plus the pull-request body through `scripts/validate-deploy-deletions.sh` (every managed resource that leaves the render needs its own `Deletion-Acknowledged: ./` body line, spelled the way the diff --git a/deploy/README.md b/deploy/README.md index 1c0a9d7..2ebac9e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -32,9 +32,10 @@ out-of-band changes made in the GitHub UI. out-of-band label drift is reverted. This is the Crossplane replacement for the old EndBug/label-sync workflow. - `organization-rulesets/` — one `OrganizationRuleset` per file (org-wide branch/tag - protection). 10 existing org rulesets are adopted **Observe-first** (read-only) + 2 - net-new rulesets are managed: `v*` tag protection and World at Ruin's trusted-regression - workflow. The 10 org rulesets the provider + protection). 9 existing org rulesets remain **Observe-only**; the existing signing + rule is retained disabled with only Observe/Update and its full observed fields. + Three net-new rulesets are managed: `v*` tag protection, World at Ruin's trusted-regression + workflow, and monorepo's independent CI aggregate check. The 10 org rulesets the provider can't yet express stay UI-managed — see [`organization-rulesets/README.md`](organization-rulesets/README.md) for the full importability matrix and the push/tag/Actions-policy analysis. diff --git a/deploy/organization-rulesets/README.md b/deploy/organization-rulesets/README.md index 0627c08..9e9775a 100644 --- a/deploy/organization-rulesets/README.md +++ b/deploy/organization-rulesets/README.md @@ -9,12 +9,13 @@ Reconciled by the platform `github-config` tenant like the rest of `deploy/`. ## How adoption works -- **Observe-first (read-only).** Existing rulesets are bound with +- **Observe-first (read-only).** The nine Observe-only org imports are bound with `managementPolicies: ["Observe"]` — Crossplane mirrors live GitHub state into `status.atProvider` and **never writes, reverts, or deletes**. This is pure GitOps *visibility*, with zero behaviour change (the same flow `repositories/` and `teams/` - used). `Delete` is omitted everywhere, so a CR/Flux prune can never delete a real - ruleset. + used). The [retained signing rule](#retained-signing-rule-record) is the exception: + it uses only Observe and Update to preserve its disabled record. `Delete` is + omitted everywhere, so a CR/Flux prune can never delete a real ruleset. - **external-name = the numeric ruleset id**, for both Kinds — `OrganizationRuleset` (`gh api orgs/devantler-tech/rulesets`) and `RepositoryRuleset` (`gh api repos/devantler-tech//rulesets`) alike. Terraform's @@ -36,7 +37,8 @@ verb — e.g. `require-pull-request.yaml`). Repo-scoped rulesets live next door | Files | Rulesets | Policy | |---|---|---| -| 10 `OrganizationRuleset` files | the 10 org rulesets below | Observe (read-only import) | +| 9 `OrganizationRuleset` files | the imported org rulesets below except Require signed commits | Observe (read-only import) | +| `require-signed-commits.yaml` | **Require signed commits** (existing, retired) | Observe + Update — retain the disabled record; never create or delete | | `protect-release-tags.yaml` | **Protect release tags** (net-new) | Managed (Create) — block tag delete + force-move + require `v` | | `require-world-at-ruin-trusted-regressions.yaml` | **Require workflow - World at Ruin trusted regressions** (net-new) | Managed (Create) — target only World at Ruin and require the central Actions workflow | | `require-monorepo-ci-aggregate-contract.yaml` | **Require workflow - Monorepo CI aggregate contract** (net-new) | Managed (Create) — target only monorepo and require the aggregate-execution control from its reviewed `main` | @@ -47,6 +49,24 @@ merging · Require conversation resolution before merging · Require linear hist Require signed commits · Require status checks to pass · Restrict deletions · Restrict branch names · Restrict commit metadata · Require workflows (DependencyReview). +### Retained signing-rule record + +`require-signed-commits.yaml` retains ruleset `5397812` with `enforcement: disabled`. +Its ref include list is empty, so it covers no branches. The complete observed +selectors, bypass list and rule fields are declared before allowing `Update`; +`Create`, `Delete` and `LateInitialize` remain excluded. The disabled record makes +the control's actual coverage clear without changing effective branch protection. + +The effective pull-request, required-status-check and linear-history controls +remain separate. GitHub-created signed squash commits are outcome evidence, not +native signature enforcement. GitHub's +[signed-commit rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#require-signed-commits) +restrict squash merging another author's pull request when signatures are required. +Any future enforcement proposal must first prove the bot and contributor merge +paths are compatible; changing the empty ref selector is not part of this retirement. +The retirement decision and evidence are tracked in +[#132](https://github.com/devantler-tech/.github/issues/132). + ## What stays UI-managed, and why `provider-upjet-github` v0.19.1 has a **narrower** ruleset schema than GitHub's API. diff --git a/deploy/organization-rulesets/kustomization.yaml b/deploy/organization-rulesets/kustomization.yaml index b6c85c9..11822b7 100644 --- a/deploy/organization-rulesets/kustomization.yaml +++ b/deploy/organization-rulesets/kustomization.yaml @@ -1,17 +1,18 @@ # `OrganizationRuleset` resources — one per file, named after the rule (an active verb). -# 10 existing org rulesets are adopted Observe-first (read-only) and 3 net-new rulesets -# are managed (Create). The 10 org rulesets the provider cannot express remain UI-managed — +# Nine imported org rulesets remain Observe-only; the retained signing rule is disabled +# with only Observe/Update. Three net-new rulesets are managed (Create). +# The 10 org rulesets the provider cannot express remain UI-managed — # see ./README.md for the importability matrix and the push/tag/Actions-policy analysis. # Repo-scoped rulesets live in ../repository-rulesets/. Included by ../kustomization.yaml. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - # Adopted Observe-first (read-only). + # Existing imports: nine Observe-only, with the retained signing exception marked below. - block-force-pushes.yaml - require-pull-request.yaml - require-conversation-resolution.yaml - require-linear-history.yaml - - require-signed-commits.yaml + - require-signed-commits.yaml # Retained disabled record: Observe + Update only. - require-status-checks.yaml - restrict-deletions.yaml - restrict-branch-names.yaml diff --git a/deploy/organization-rulesets/require-signed-commits.yaml b/deploy/organization-rulesets/require-signed-commits.yaml index 1eec790..4569f96 100644 --- a/deploy/organization-rulesets/require-signed-commits.yaml +++ b/deploy/organization-rulesets/require-signed-commits.yaml @@ -1,6 +1,6 @@ -# Existing org ruleset, adopted Observe-first (read-only). Identity-only -# forProvider — don't promote past Observe without backfilling from -# status.atProvider (lossy round-trip). See ./README.md for the convention. +# Retained disabled record: this rule's empty ref selector protects no branches. +# Backfilled from the observed ruleset before allowing Update; keep all fields +# and the existing identity. Never create or delete it. See ./README.md and #132. apiVersion: enterprise.github.m.upbound.io/v1alpha1 kind: OrganizationRuleset metadata: @@ -8,11 +8,27 @@ metadata: annotations: crossplane.io/external-name: "5397812" # numeric ruleset id spec: - managementPolicies: ["Observe"] + managementPolicies: ["Observe", "Update"] forProvider: name: Require signed commits target: branch - enforcement: active + enforcement: disabled + bypassActors: [] + conditions: + - refName: + - include: [] + exclude: [] + repositoryName: + - include: ["~ALL"] + exclude: [] + protected: false + rules: + - creation: false + deletion: false + nonFastForward: false + requiredLinearHistory: false + requiredSignatures: true + update: false providerConfigRef: kind: ProviderConfig name: default diff --git a/tests/signing-rule-retirement.sh b/tests/signing-rule-retirement.sh new file mode 100644 index 0000000..f32ecc5 --- /dev/null +++ b/tests/signing-rule-retirement.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Retain the approved disabled record without broadening its scope or lifecycle. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +render="$(mktemp)" +trap 'rm -f "$render"' EXIT +fail() { echo "signing-rule-retirement test: $*" >&2; exit 1; } + +kubectl kustomize "$root/deploy" >"$render" +selector='select(.apiVersion == "enterprise.github.m.upbound.io/v1alpha1" and .kind == "OrganizationRuleset" and .metadata.name == "require-signed-commits")' +count="$(yq -N "$selector | .metadata.name" "$render" | grep -c . || true)" +[[ "$count" == 1 ]] || fail "the existing signing ruleset must remain in the render" + +assert_json() { + local expected="$1" expression="$2" actual + actual="$(yq -o=json -I=0 "$selector | $expression" "$render")" + [[ "$actual" == "$expected" ]] || fail "$expression: expected $expected, got $actual" +} + +# Only this existing object may be updated. Prune or external disappearance must +# never authorize deletion or recreation, and late initialization stays disabled. +assert_json '"5397812"' '.metadata.annotations."crossplane.io/external-name"' +assert_json '["Observe","Update"]' '.spec.managementPolicies' +assert_json '{"kind":"ProviderConfig","name":"default"}' '.spec.providerConfigRef' +assert_json '{}' '(.spec.initProvider // {})' +assert_json '"disabled"' '.spec.forProvider.enforcement' +assert_json '"Require signed commits"' '.spec.forProvider.name' +assert_json '"branch"' '.spec.forProvider.target' + +# Update sends the full ruleset: preserve the observed selectors, empty bypass +# list and all rule booleans explicitly. Empty ref coverage must stay empty. +assert_json '[]' '.spec.forProvider.bypassActors' +assert_json '[{"refName":[{"exclude":[],"include":[]}],"repositoryName":[{"exclude":[],"include":["~ALL"],"protected":false}]}]' '.spec.forProvider.conditions | sort_keys(..)' +assert_json '[{"creation":false,"deletion":false,"nonFastForward":false,"requiredLinearHistory":false,"requiredSignatures":true,"update":false}]' '.spec.forProvider.rules | sort_keys(..)' +assert_json '["bypassActors","conditions","enforcement","name","rules","target"]' '.spec.forProvider | keys | sort' + +echo "signing-rule-retirement: OK"