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..8b56379 --- /dev/null +++ b/.github/workflows/deploy-guards.yaml @@ -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' + 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..fbbc5a3 --- /dev/null +++ b/tests/deploy-guards-ruleset.sh @@ -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" diff --git a/tests/world-at-ruin-regression-ruleset.sh b/tests/world-at-ruin-regression-ruleset.sh index 8e9d8f8..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 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"