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 trusted deploy/ guards ruleset
run: bash tests/deploy-guards-ruleset.sh

- name: 🧪 Test retained signing-rule safety
run: bash tests/signing-rule-retirement.sh

Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/deploy-guards.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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.
#
# 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]

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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Kind>.<group>/<name>` 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
Expand Down
5 changes: 3 additions & 2 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion deploy/organization-rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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` |
| `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
Expand Down Expand Up @@ -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 |
|---|---|
Expand Down
3 changes: 2 additions & 1 deletion deploy/organization-rulesets/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
42 changes: 42 additions & 0 deletions deploy/organization-rulesets/require-dotgithub-deploy-guards.yaml
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions tests/deploy-guards-ruleset.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/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"
# 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
# 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"
15 changes: 13 additions & 2 deletions tests/world-at-ruin-regression-ruleset.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 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"
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"
Loading