From fc853d8ea0c58ad798fa2062c30b922ee674ffb7 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 14 Sep 2026 01:10:29 +0200 Subject: [PATCH 1/2] feat(rulesets): enforce the deploy/ guards from reviewed main A pull request could edit or remove the release-contract and deletion validators from its own ci.yaml and still pass CI - Required Checks. An organization ruleset now requires .github/workflows/deploy-guards.yaml from this repository's main; it checks the validators out at github.workflow_sha and reads the candidate only as data. tests/deploy-guards-ruleset.sh pins the ruleset and the trusted-checkout wiring. Fixes #183 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yaml | 3 + .github/workflows/deploy-guards.yaml | 64 ++++++++++++++ AGENTS.md | 11 ++- deploy/README.md | 5 +- deploy/organization-rulesets/README.md | 3 +- .../organization-rulesets/kustomization.yaml | 3 +- .../require-dotgithub-deploy-guards.yaml | 42 +++++++++ tests/deploy-guards-ruleset.sh | 86 +++++++++++++++++++ tests/world-at-ruin-regression-ruleset.sh | 4 +- 9 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/deploy-guards.yaml create mode 100644 deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml create mode 100644 tests/deploy-guards-ruleset.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e13fd03..a9e0fa3 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 trusted deploy/ guards ruleset + run: bash tests/deploy-guards-ruleset.sh + - name: ๐Ÿงช Test retained signing-rule safety run: bash tests/signing-rule-retirement.sh diff --git a/.github/workflows/deploy-guards.yaml b/.github/workflows/deploy-guards.yaml new file mode 100644 index 0000000..391c191 --- /dev/null +++ b/.github/workflows/deploy-guards.yaml @@ -0,0 +1,64 @@ +name: ๐Ÿ›ก๏ธ Deploy guards + +# An organization ruleset (deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml) +# requires this workflow from this repository's reviewed main. github.workflow_sha +# names that reviewed revision, and the validators are checked out from it, so a +# pull request cannot remove or neuter the deploy/ guards by editing ci.yaml, this +# file, or the scripts. The candidate supplies only its deploy/ tree, history and +# pull-request text, all read as data. +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + merge_group: + +permissions: {} + +jobs: + deploy-guards: + name: ๐Ÿ›ก๏ธ Enforce deploy/ guards from reviewed main + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: ๐Ÿ“ฅ Check out the reviewed validators + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devantler-tech/.github + ref: ${{ github.workflow_sha }} + path: trusted + persist-credentials: false + + - name: ๐Ÿ“ฅ Check out the candidate as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: candidate + fetch-depth: 0 + persist-credentials: false + + - name: ๐Ÿšฆ Validate release contract + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: | + first_subject="$(git -C candidate log --format=%s "$BASE_SHA..$HEAD_SHA" | tail -1)" + git -C candidate diff --name-only -z "$BASE_SHA" "$HEAD_SHA" | + bash trusted/scripts/validate-release-contract.sh "$PR_TITLE" "$COMMIT_COUNT" "$first_subject" + + - name: ๐Ÿ—‘๏ธ Validate deploy/ deletions are acknowledged + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + git -C candidate worktree add --detach "$RUNNER_TEMP/base" "$BASE_SHA" + kubectl kustomize "$RUNNER_TEMP/base/deploy" > "$RUNNER_TEMP/base-render.yaml" || + { echo "deploy-deletions: UNKNOWN โ€” the base deploy/ tree does not render; repair main first" >&2; exit 2; } + kubectl kustomize candidate/deploy > "$RUNNER_TEMP/head-render.yaml" || + { echo "deploy-deletions: UNKNOWN โ€” this pull request's deploy/ tree does not render" >&2; exit 2; } + printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.txt" + bash trusted/scripts/validate-deploy-deletions.sh \ + "$RUNNER_TEMP/base-render.yaml" "$RUNNER_TEMP/head-render.yaml" "$RUNNER_TEMP/pr-body.txt" diff --git a/AGENTS.md b/AGENTS.md index 25b0f74..23e814a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,16 @@ renders plus the pull-request body through `scripts/validate-deploy-deletions.sh resource that leaves the render needs its own `Deletion-Acknowledged: ./` body line, spelled the way the failure prints it); merge groups skip those event-specific checks. The deletion check also renders `deploy/` at the base, so a pull request whose base `main` does not build fails there with kubectl's -exit status โ€” the pull request that repairs the build is expected to edit `ci.yaml` in the same change. +exit status. + +Both validators also run in `.github/workflows/deploy-guards.yaml`, which the +`require-dotgithub-deploy-guards` organization ruleset requires from reviewed `main`. That copy +checks the validators out at `github.workflow_sha` and reads the pull request only as data, so +editing `ci.yaml`, the workflow, or a validator in a pull request does not change the check that +judges it; such edits take effect only after they merge. Keep `tests/deploy-guards-ruleset.sh` +passing when either file changes. Because the repairing pull request cannot edit that check, a +`main` whose `deploy/` no longer renders needs an organization owner to set the ruleset's +enforcement to `evaluate` while the `github-config` reconciliation is suspended, then restore both. `kubectl` (with built-in kustomize) is preinstalled on CI runners. A clean build proves the manifests are well-formed; the Crossplane CRDs themselves are applied/validated **on-cluster** (the diff --git a/deploy/README.md b/deploy/README.md index 2ebac9e..c479dd0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -34,8 +34,9 @@ out-of-band changes made in the GitHub UI. - `organization-rulesets/` โ€” one `OrganizationRuleset` per file (org-wide branch/tag 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 + Four net-new rulesets are managed: `v*` tag protection, World at Ruin's trusted-regression + workflow, monorepo's independent CI aggregate check, and this repository's `deploy/` guards + run from its reviewed `main`. 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 9e9775a..a9dba74 100644 --- a/deploy/organization-rulesets/README.md +++ b/deploy/organization-rulesets/README.md @@ -42,6 +42,7 @@ verb โ€” e.g. `require-pull-request.yaml`). Repo-scoped rulesets live next door | `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` | +| `require-dotgithub-deploy-guards.yaml` | **Require workflow - .github deploy guards** (net-new) | Managed (Create) โ€” target only this repository and run the `deploy/` release-contract and deletion validators from its reviewed `main` | | (in `../repository-rulesets/`) `require-merge-queue-on-platform.yaml` | `platform` "Require merge queue" | Observe (read-only import) | The 10 imported org rulesets: Block force pushes ยท Require a pull request before @@ -80,7 +81,7 @@ Verified against the live CRDs, it does **not** support: - **Target `repository`** (only `branch`, `tag`, `push`). - **Bypass actor `EnterpriseOwner`**. -So **10 of the 23 org rulesets cannot be faithfully expressed** and remain UI-managed: +So **10 of the 24 org rulesets cannot be faithfully expressed** and remain UI-managed: | Ruleset (org) | Blocked by | |---|---| diff --git a/deploy/organization-rulesets/kustomization.yaml b/deploy/organization-rulesets/kustomization.yaml index 11822b7..b969a47 100644 --- a/deploy/organization-rulesets/kustomization.yaml +++ b/deploy/organization-rulesets/kustomization.yaml @@ -1,6 +1,6 @@ # `OrganizationRuleset` resources โ€” one per file, named after the rule (an active verb). # Nine imported org rulesets remain Observe-only; the retained signing rule is disabled -# with only Observe/Update. Three net-new rulesets are managed (Create). +# with only Observe/Update. Four 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. @@ -22,3 +22,4 @@ resources: - protect-release-tags.yaml - require-world-at-ruin-trusted-regressions.yaml - require-monorepo-ci-aggregate-contract.yaml + - require-dotgithub-deploy-guards.yaml diff --git a/deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml b/deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml new file mode 100644 index 0000000..8f143f5 --- /dev/null +++ b/deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml @@ -0,0 +1,42 @@ +# Require the deploy/ guards from this repository's reviewed main branch. +# ci.yaml runs the release-contract and deletion validators from the pull +# request's own checkout, so a candidate can edit or remove them and still pass +# `CI - Required Checks`. This rule selects .github/workflows/deploy-guards.yaml +# from refs/heads/main; that workflow runs the validators from the same reviewed +# revision and reads the candidate only as data. +# +# provider-upjet-github v0.19.1 exposes a branch/tag ref but not GitHub's +# workflow SHA selector, so refs/heads/main is the strongest declarative source +# binding the deployed provider can express. The source workflow reaches main in +# the merge that adds this rule, and deploy/ publishes only on a later v* tag. +# +# Net-new and managed Observe + Create + Update + LateInitialize, never Delete. +# No bypassActors are declared. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-dotgithub-deploy-guards +spec: + managementPolicies: + - Observe + - Create + - Update + - LateInitialize + forProvider: + name: Require workflow - .github deploy guards + target: branch + enforcement: active + conditions: + - refName: + - include: ["~DEFAULT_BRANCH"] + exclude: [] + repositoryId: [933213756] + rules: + - requiredWorkflows: + - requiredWorkflow: + - repositoryId: 933213756 + path: .github/workflows/deploy-guards.yaml + ref: refs/heads/main + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/tests/deploy-guards-ruleset.sh b/tests/deploy-guards-ruleset.sh new file mode 100644 index 0000000..031a69d --- /dev/null +++ b/tests/deploy-guards-ruleset.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Pins the trusted enforcement of the deploy/ guards (#183): an organization +# ruleset requires .github/workflows/deploy-guards.yaml from this repository's +# reviewed main, and that workflow runs the validators from the ruleset-selected +# source revision, never from the candidate checkout it judges. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflow="${DEPLOY_GUARDS_WORKFLOW:-${repo_root}/.github/workflows/deploy-guards.yaml}" +render="$(mktemp)" +trap 'rm -f "${render}"' EXIT + +fail() { + echo "deploy-guards-ruleset test: $*" >&2 + exit 1 +} + +for tool in kubectl yq; do + command -v "${tool}" >/dev/null || fail "required tool '${tool}' not found" +done + +kubectl kustomize "${repo_root}/deploy" >"${render}" || + fail "kubectl kustomize deploy/ failed" + +selector='select(.kind == "OrganizationRuleset" and .metadata.name == "require-dotgithub-deploy-guards")' +count="$(yq -N "${selector} | .metadata.name" "${render}" | grep -c . || true)" +[[ "${count}" == "1" ]] || fail "expected exactly one rendered deploy-guards ruleset, got ${count}" + +assert_value() { + local label="$1" expected="$2" expression="$3" actual + actual="$(yq -r "${selector} | ${expression}" "${render}")" + [[ "${actual}" == "${expected}" ]] || fail "${label}: expected '${expected}', got '${actual}'" +} + +assert_json() { + local label="$1" expected="$2" expression="$3" actual + actual="$(yq -o=json -I=0 "${selector} | ${expression}" "${render}")" + [[ "${actual}" == "${expected}" ]] || fail "${label}: expected '${expected}', got '${actual}'" +} + +# 933213756 is devantler-tech/.github: the rule targets only this repository +# and takes its workflow from this repository's reviewed main. +assert_value "ruleset name" "Require workflow - .github deploy guards" '.spec.forProvider.name' +assert_value "ruleset target" "branch" '.spec.forProvider.target' +assert_value "ruleset enforcement" "active" '.spec.forProvider.enforcement' +assert_json "management policy" '["Observe","Create","Update","LateInitialize"]' '.spec.managementPolicies' +assert_json "target repository" '[933213756]' '.spec.forProvider.conditions[0].repositoryId' +assert_json "target branch" '["~DEFAULT_BRANCH"]' '.spec.forProvider.conditions[0].refName[0].include' +assert_json "target exclusions" '[]' '.spec.forProvider.conditions[0].refName[0].exclude' +assert_value "bypass actor count" "0" '(.spec.forProvider.bypassActors // []) | length' +assert_value "rule count" "1" '.spec.forProvider.rules | length' +assert_value "required workflow count" "1" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow | length' +assert_value "source repository" "933213756" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].repositoryId' +assert_value "source path" ".github/workflows/deploy-guards.yaml" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].path' +assert_value "source ref" "refs/heads/main" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].ref' + +[[ -f "${workflow}" ]] || fail "required workflow ${workflow#"${repo_root}"/} does not exist" + +wf() { yq -r "$1" "${workflow}"; } + +[[ "$(wf '.on | has("pull_request")')" == "true" ]] || fail "workflow must run on pull_request" +[[ "$(wf '.on | has("merge_group")')" == "true" ]] || fail "workflow must run on merge_group" +[[ "$(wf '.permissions | tojson')" == "{}" ]] || fail "workflow-level permissions must be {}" + +# The validators must come from the ruleset-selected source revision. A +# candidate cannot change github.workflow_sha, so this checkout is the trusted one. +# shellcheck disable=SC2016 +trusted_checkouts="$(wf '[.jobs[].steps[] | select(.uses // "" | test("^actions/checkout@")) | select(.with.repository == "devantler-tech/.github" and .with.ref == "${{ github.workflow_sha }}" and .with.path == "trusted")] | length')" +[[ "${trusted_checkouts}" == "1" ]] || fail "expected one checkout of the workflow source revision into trusted/, got ${trusted_checkouts}" + +unsafe_checkouts="$(wf '[.jobs[].steps[] | select(.uses // "" | test("^actions/checkout@")) | select(.with["persist-credentials"] != false)] | length')" +[[ "${unsafe_checkouts}" == "0" ]] || fail "every checkout must set persist-credentials: false (${unsafe_checkouts} do not)" + +for validator in validate-release-contract.sh validate-deploy-deletions.sh; do + runs="$(wf "[.jobs[].steps[] | select(.run // \"\" | contains(\"${validator}\"))] | length")" + [[ "${runs}" == "1" ]] || fail "expected exactly one step running ${validator}, got ${runs}" + trusted="$(wf "[.jobs[].steps[] | select(.run // \"\" | contains(\"trusted/scripts/${validator}\"))] | length")" + [[ "${trusted}" == "1" ]] || fail "${validator} must run from trusted/scripts, not the candidate checkout" +done + +candidate_scripts="$(wf '[.jobs[].steps[] | select(.run // "" | test("(^|[^/[:alnum:]_])(candidate/)?scripts/"))] | length')" +[[ "${candidate_scripts}" == "0" ]] || fail "no step may run a script outside trusted/ (${candidate_scripts} do)" + +echo "deploy-guards-ruleset: OK" diff --git a/tests/world-at-ruin-regression-ruleset.sh b/tests/world-at-ruin-regression-ruleset.sh index 8e9d8f8..7391107 100755 --- a/tests/world-at-ruin-regression-ruleset.sh +++ b/tests/world-at-ruin-regression-ruleset.sh @@ -57,7 +57,7 @@ assert_value "source repository" "948529001" '.spec.forProvider.rules[0].require assert_value "source path" ".github/workflows/world-at-ruin-required-regressions.yaml" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].path' assert_value "source ref" "refs/heads/main" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].ref' -grep -Fq '10 of the 23 org rulesets' "${repo_root}/deploy/organization-rulesets/README.md" || - fail "organization ruleset inventory must account for 10 imported, 3 managed, and 10 UI-managed rulesets" +grep -Fq '10 of the 24 org rulesets' "${repo_root}/deploy/organization-rulesets/README.md" || + fail "organization ruleset inventory must account for 10 imported, 4 managed, and 10 UI-managed rulesets" echo "world-at-ruin-regression-ruleset: OK" From be9d6474b8dd56d5c6f8009772e6160b56d8423f Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 14 Sep 2026 01:21:40 +0200 Subject: [PATCH 2/2] fix(rulesets): keep merge groups from passing the deploy/ guards unjudged A merge-group event carries no pull-request title, commits or body, so a merge_group run of deploy-guards.yaml skipped both validators and passed. Drop the trigger so a merge queue waits on the required check instead, and assert that. The ruleset inventory test now checks the imported and managed counts its failure message claims, not only the total. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy-guards.yaml | 6 +++++- tests/deploy-guards-ruleset.sh | 7 ++++++- tests/world-at-ruin-regression-ruleset.sh | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-guards.yaml b/.github/workflows/deploy-guards.yaml index 391c191..8b56379 100644 --- a/.github/workflows/deploy-guards.yaml +++ b/.github/workflows/deploy-guards.yaml @@ -6,10 +6,14 @@ name: ๐Ÿ›ก๏ธ Deploy guards # pull request cannot remove or neuter the deploy/ guards by editing ci.yaml, this # file, or the scripts. The candidate supplies only its deploy/ tree, history and # pull-request text, all read as data. +# +# It deliberately has no merge_group trigger. Both guards need the pull request's +# title, commits and body, which a merge-group event does not carry, so a +# merge-group run could only pass without judging anything. Without the trigger, +# a merge queue added to this repository waits on this required check instead. on: pull_request: types: [opened, synchronize, reopened, edited, ready_for_review] - merge_group: permissions: {} diff --git a/tests/deploy-guards-ruleset.sh b/tests/deploy-guards-ruleset.sh index 031a69d..fbbc5a3 100644 --- a/tests/deploy-guards-ruleset.sh +++ b/tests/deploy-guards-ruleset.sh @@ -61,7 +61,12 @@ assert_value "source ref" "refs/heads/main" '.spec.forProvider.rules[0].required wf() { yq -r "$1" "${workflow}"; } [[ "$(wf '.on | has("pull_request")')" == "true" ]] || fail "workflow must run on pull_request" -[[ "$(wf '.on | has("merge_group")')" == "true" ]] || fail "workflow must run on merge_group" +# A merge-group event carries no pull-request title, commits or body, so a +# merge_group run could only skip both guards and pass. Leaving the trigger out +# makes a merge queue wait on this required check instead. +[[ "$(wf '.on | has("merge_group")')" == "false" ]] || fail "workflow must not run on merge_group, where both guards would be skipped" +guarded_steps="$(wf '[.jobs[].steps[] | select(.run // "" | test("validate-(release-contract|deploy-deletions)\\.sh")) | select(.if // "" | test("pull_request"))] | length')" +[[ "${guarded_steps}" == "2" ]] || fail "both validator steps must run on pull_request, got ${guarded_steps}" [[ "$(wf '.permissions | tojson')" == "{}" ]] || fail "workflow-level permissions must be {}" # The validators must come from the ruleset-selected source revision. A diff --git a/tests/world-at-ruin-regression-ruleset.sh b/tests/world-at-ruin-regression-ruleset.sh index 7391107..ce4e8ad 100755 --- a/tests/world-at-ruin-regression-ruleset.sh +++ b/tests/world-at-ruin-regression-ruleset.sh @@ -57,7 +57,18 @@ assert_value "source repository" "948529001" '.spec.forProvider.rules[0].require assert_value "source path" ".github/workflows/world-at-ruin-required-regressions.yaml" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].path' assert_value "source ref" "refs/heads/main" '.spec.forProvider.rules[0].requiredWorkflows[0].requiredWorkflow[0].ref' -grep -Fq '10 of the 24 org rulesets' "${repo_root}/deploy/organization-rulesets/README.md" || - fail "organization ruleset inventory must account for 10 imported, 4 managed, and 10 UI-managed rulesets" +inventory="${repo_root}/deploy/organization-rulesets/README.md" +grep -Fq 'The 10 imported org rulesets' "${inventory}" || + fail "organization ruleset inventory must account for 10 imported rulesets" +# The backticks are literal Markdown table cell delimiters, not command substitution. +# shellcheck disable=SC2016 +managed_rows="$(grep -c '^| `[a-z-]*\.yaml` | .*(net-new) | Managed (Create)' "${inventory}" || true)" +[[ "${managed_rows}" == "4" ]] || + fail "organization ruleset inventory must list 4 managed rulesets, got ${managed_rows}" +managed_rendered="$(yq -N 'select(.kind == "OrganizationRuleset" and (.spec.managementPolicies | contains(["Create"]))) | .metadata.name' "${render}" | grep -c . || true)" +[[ "${managed_rendered}" == "4" ]] || + fail "expected 4 rendered managed (Create) organization rulesets, got ${managed_rendered}" +grep -Fq '10 of the 24 org rulesets' "${inventory}" || + fail "organization ruleset inventory must account for 10 UI-managed of 24 org rulesets" echo "world-at-ruin-regression-ruleset: OK"