Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Kind>.<group>/<name>` body line, spelled the way the
Expand Down
7 changes: 4 additions & 3 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 24 additions & 4 deletions deploy/organization-rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>/rulesets`) alike. Terraform's
Expand All @@ -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 |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `protect-release-tags.yaml` | **Protect release tags** (net-new) | Managed (Create) — block tag delete + force-move + require `v<semver>` |
| `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` |
Expand All @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions deploy/organization-rulesets/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
26 changes: 21 additions & 5 deletions deploy/organization-rulesets/require-signed-commits.yaml
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
# 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:
name: require-signed-commits
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
38 changes: 38 additions & 0 deletions tests/signing-rule-retirement.sh
Original file line number Diff line number Diff line change
@@ -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"
Loading