diff --git a/deploy/README.md b/deploy/README.md index bfa9433..0c86169 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -20,6 +20,14 @@ out-of-band changes made in the GitHub UI. every repo); each `.yaml` adds only that repo's Dependabot/Renovate ecosystem extras. Authoritative — 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) + 1 + net-new `v*` tag-protection ruleset is managed. 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. +- `repository-rulesets/` — one `RepositoryRuleset` per file (`-on-.yaml`), + each a repo-scoped rule adopted Observe-first. See the platform repo's [`docs/github-management.md`](https://github.com/devantler-tech/platform/blob/main/docs/github-management.md) diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index f5511ca..975451b 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -18,3 +18,5 @@ resources: - repositories/ - teams/ - labels/ + - organization-rulesets/ + - repository-rulesets/ diff --git a/deploy/organization-rulesets/README.md b/deploy/organization-rulesets/README.md new file mode 100644 index 0000000..90cd3ff --- /dev/null +++ b/deploy/organization-rulesets/README.md @@ -0,0 +1,90 @@ +# `organization-rulesets/` — org rulesets as code + +Declarative management of the devantler-tech org's **`OrganizationRuleset`** resources +(org-wide branch/tag protection and policies) as Crossplane managed resources, via +[provider-upjet-github](https://github.com/crossplane-contrib/provider-upjet-github) +(from the `integrations/github` Terraform provider). Repo-scoped `RepositoryRuleset` +resources live in the sibling [`../repository-rulesets/`](../repository-rulesets/). +Reconciled by the platform `github-config` tenant like the rest of `deploy/`. + +## How adoption works + +- **Observe-first (read-only).** Existing rulesets 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. +- **external-name = the import id.** `OrganizationRuleset` → the numeric ruleset id + (`gh api orgs/devantler-tech/rulesets`); `RepositoryRuleset` → `:`. +- **forProvider is identity-only** on the Observe imports (`name`/`target`/ + `enforcement`). ⚠️ **Do not** promote an import past `Observe` without first + backfilling its full `rules`/`conditions`/`bypassActors` from the observed + `status.atProvider` — the provider's round-trip is lossy, so a partial `forProvider` + under `Update` would wipe live rules. + +## What is managed here + +**One `OrganizationRuleset` per file**, named after the rule it enforces (an active +verb — e.g. `require-pull-request.yaml`). Repo-scoped rulesets live next door in +[`../repository-rulesets/`](../repository-rulesets/) as `-on-.yaml`. + +| Files | Rulesets | Policy | +|---|---|---| +| 10 `OrganizationRuleset` files | the 10 org rulesets below | Observe (read-only import) | +| `protect-release-tags.yaml` | **Protect release tags** (net-new) | Managed (Create) — block tag delete + force-move + require `v` | +| (in `../repository-rulesets/`) `restrict-deletions-on-ksail.yaml`, `require-merge-queue-on-platform.yaml` | `ksail` "Restrict deletions", `platform` "Require merge queue" | Observe (read-only import) | + +The 10 imported org rulesets: Block force pushes · Require a pull request before +merging · Require conversation resolution before merging · Require linear history · +Require signed commits · Require status checks to pass · Restrict deletions · Restrict +branch names · Restrict commit metadata · Require workflows (DependencyReview). + +## What stays UI-managed, and why + +`provider-upjet-github` v0.19.1 has a **narrower** ruleset schema than GitHub's API. +Verified against the live CRDs, it does **not** support: + +- **`repository_property` conditions** (custom-property scoping) — only `refName`, + `repositoryName`, `repositoryId`. +- **Rule types** `code_quality`, `copilot_code_review`, `repository_transfer`, + `repository_name`, and the push-file rules (`file_path_restriction`, `max_file_size`, + `file_extension_restriction`, `max_file_path_length`). +- **Target `repository`** (only `branch`, `tag`, `push`). +- **Bypass actor `EnterpriseOwner`**. + +So **10 of the 20 org rulesets cannot be faithfully expressed** and remain UI-managed: + +| Ruleset (org) | Blocked by | +|---|---| +| Require code scanning results | `repository_property` condition (custom property `Type`) | +| Require workflows … EnableAutoMerge | `repository_property` condition | +| Require workflows … LintDocumentation | `repository_property` condition | +| Require workflows … ScanGitHubActions | `repository_property` condition | +| Require workflows for .NET | `repository_property` condition (`language`) | +| Require workflows for Go | `repository_property` condition (`language`) | +| Require code quality results | rule type `code_quality` unsupported | +| Automatically request Copilot code review | rule type `copilot_code_review` unsupported (also disabled) | +| restrict-names | target `repository` unsupported | +| restrict-transfers | target `repository` unsupported (+ `EnterpriseOwner` bypass) | + +These are tracked for re-adoption as the provider gains support in +[#69](https://github.com/devantler-tech/.github/issues/69) (`roadmap`). Re-home each +here Observe-first once expressible. + +## Push / tag / Actions-policy considerations + +- **Push rulesets** — none exist, and **not adoptable**: the provider supports + `target: push` (beta) but none of the push-file rule types above, so a push ruleset + can't be expressed. Tracked in [#69](https://github.com/devantler-tech/.github/issues/69). +- **Tag rulesets** — none existed; **added** here (`protect-release-tags.yaml`). Makes + release tags immutable (block delete + force-move) and well-formed (`v`). See + that file's header for the team-vs-enterprise tier caveat on the name-pattern rule and + its fallback. +- **Actions policies** — the 2026-06-18 + [workflow execution protections](https://github.blog/changelog/2026-06-18-control-who-and-what-triggers-github-actions-workflows/) + (actor + event allow-lists controlling who/what triggers workflows, delivered as org + rulesets scoped by **custom properties**) are **not adoptable**: the new rule types + aren't in the provider and the feature relies on the `repository_property` scoping the + provider lacks. Tracked in [#69](https://github.com/devantler-tech/.github/issues/69); + revisit when the provider catches up. diff --git a/deploy/organization-rulesets/block-force-pushes.yaml b/deploy/organization-rulesets/block-force-pushes.yaml new file mode 100644 index 0000000..bd5f19d --- /dev/null +++ b/deploy/organization-rulesets/block-force-pushes.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: block-force-pushes + annotations: + crossplane.io/external-name: "6986186" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Block force pushes + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/kustomization.yaml b/deploy/organization-rulesets/kustomization.yaml new file mode 100644 index 0000000..2e9bffa --- /dev/null +++ b/deploy/organization-rulesets/kustomization.yaml @@ -0,0 +1,21 @@ +# `OrganizationRuleset` resources — one per file, named after the rule (an active verb). +# 10 existing org rulesets are adopted Observe-first (read-only) and 1 net-new tag ruleset +# is 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). + - block-force-pushes.yaml + - require-pull-request.yaml + - require-conversation-resolution.yaml + - require-linear-history.yaml + - require-signed-commits.yaml + - require-status-checks.yaml + - restrict-deletions.yaml + - restrict-branch-names.yaml + - restrict-commit-metadata.yaml + - require-workflows-dependency-review.yaml + # Net-new, managed (Create). + - protect-release-tags.yaml diff --git a/deploy/organization-rulesets/protect-release-tags.yaml b/deploy/organization-rulesets/protect-release-tags.yaml new file mode 100644 index 0000000..300a3fd --- /dev/null +++ b/deploy/organization-rulesets/protect-release-tags.yaml @@ -0,0 +1,62 @@ +# Protect release tags — the ONE net-new, actually-managed ruleset in deploy/rulesets/. +# +# The org ships every product by tagging a `v*` release (each repo's cd.yaml triggers +# on `v*`), yet had NO tag ruleset — release tags could be deleted or force-moved. +# This `OrganizationRuleset` (target: tag, ~ALL repos, ~ALL tags) makes release tags +# immutable and well-formed: +# - deletion: block deleting a tag +# - nonFastForward: block force-updating (re-pointing) a tag +# - tagNamePattern: require `v` (e.g. v1.2.3, v1.2.3-rc.1) +# Creating a NEW v-semver tag is still allowed (deletion/nonFastForward only restrict +# delete/move) so normal releases are unaffected; only tampering is blocked. +# +# Unlike everything else in this directory this is NOT an import — it has no +# external-name and is managed (Observe + Create + Update + LateInitialize, never +# Delete), so Crossplane CREATES it on first reconcile and then keeps it in sync. +# +# No bypassActors on purpose: clean immutability, consistent with the org's other +# protective rulesets ("Block force pushes", "Restrict branch names", which also have +# none), and it avoids the OrganizationAdmin bypass actor_id quirk. An org admin can +# still edit/disable the ruleset itself. If release automation ever needs to move or +# delete a tag, add a bypassActors entry here (declaratively) rather than in the UI. +# +# TIER CAVEAT (tagNamePattern): provider/GitHub docs historically flag the tag name +# pattern rule as "enterprise only". devantler-tech is on the `team` plan, NOT an +# enterprise — BUT this same org already runs ACTIVE org-level branch_name_pattern +# ("Restrict branch names") and commit_message_pattern ("Restrict commit metadata") +# rulesets, so pattern rules demonstrably work here and tagNamePattern is expected to +# as well. FALLBACK: if GitHub rejects this ruleset on apply because of the tag name +# pattern, delete just the `tagNamePattern` block below — `deletion` + `nonFastForward` +# still deliver immutable release tags and have no tier requirement. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: protect-release-tags +spec: + managementPolicies: + - Observe + - Create + - Update + - LateInitialize + forProvider: + name: Protect release tags + target: tag + enforcement: active + conditions: + - refName: + - include: ["~ALL"] + exclude: [] + repositoryName: + - include: ["~ALL"] + exclude: [] + rules: + - deletion: true + nonFastForward: true + tagNamePattern: + - operator: regex + pattern: '^v\d+\.\d+\.\d+(-.+)?$' + negate: false + name: "Tags must be v (e.g. v1.2.3 or v1.2.3-rc.1)" + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-conversation-resolution.yaml b/deploy/organization-rulesets/require-conversation-resolution.yaml new file mode 100644 index 0000000..cd9c45b --- /dev/null +++ b/deploy/organization-rulesets/require-conversation-resolution.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-conversation-resolution + annotations: + crossplane.io/external-name: "6986190" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Require conversation resolution before merging + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-linear-history.yaml b/deploy/organization-rulesets/require-linear-history.yaml new file mode 100644 index 0000000..6d19b45 --- /dev/null +++ b/deploy/organization-rulesets/require-linear-history.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-linear-history + annotations: + crossplane.io/external-name: "6986179" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Require linear history + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-pull-request.yaml b/deploy/organization-rulesets/require-pull-request.yaml new file mode 100644 index 0000000..1465f94 --- /dev/null +++ b/deploy/organization-rulesets/require-pull-request.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-pull-request + annotations: + crossplane.io/external-name: "4134020" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Require a pull request before merging + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-signed-commits.yaml b/deploy/organization-rulesets/require-signed-commits.yaml new file mode 100644 index 0000000..1eec790 --- /dev/null +++ b/deploy/organization-rulesets/require-signed-commits.yaml @@ -0,0 +1,18 @@ +# 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. +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"] + forProvider: + name: Require signed commits + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-status-checks.yaml b/deploy/organization-rulesets/require-status-checks.yaml new file mode 100644 index 0000000..438074f --- /dev/null +++ b/deploy/organization-rulesets/require-status-checks.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-status-checks + annotations: + crossplane.io/external-name: "14974342" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Require status checks to pass + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/require-workflows-dependency-review.yaml b/deploy/organization-rulesets/require-workflows-dependency-review.yaml new file mode 100644 index 0000000..441f876 --- /dev/null +++ b/deploy/organization-rulesets/require-workflows-dependency-review.yaml @@ -0,0 +1,23 @@ +# 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. +# +# Newer "Require workflows to pass" rule (API type `workflows`, exposed by the +# provider as `requiredWorkflows`). Only this DependencyReview variant is +# importable — it conditions on repository_name `~ALL`. The other "Require +# workflows …" rulesets condition on repository_property and stay UI-managed. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: require-workflows-dependency-review + annotations: + crossplane.io/external-name: "17213449" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Require workflows to pass before merging - DependencyReview + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/restrict-branch-names.yaml b/deploy/organization-rulesets/restrict-branch-names.yaml new file mode 100644 index 0000000..04fc667 --- /dev/null +++ b/deploy/organization-rulesets/restrict-branch-names.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: restrict-branch-names + annotations: + crossplane.io/external-name: "4133993" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Restrict branch names + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/restrict-commit-metadata.yaml b/deploy/organization-rulesets/restrict-commit-metadata.yaml new file mode 100644 index 0000000..a4a4193 --- /dev/null +++ b/deploy/organization-rulesets/restrict-commit-metadata.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: restrict-commit-metadata + annotations: + crossplane.io/external-name: "4134015" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Restrict commit metadata + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/organization-rulesets/restrict-deletions.yaml b/deploy/organization-rulesets/restrict-deletions.yaml new file mode 100644 index 0000000..da27886 --- /dev/null +++ b/deploy/organization-rulesets/restrict-deletions.yaml @@ -0,0 +1,18 @@ +# 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. +apiVersion: enterprise.github.m.upbound.io/v1alpha1 +kind: OrganizationRuleset +metadata: + name: restrict-deletions + annotations: + crossplane.io/external-name: "6986171" # numeric ruleset id +spec: + managementPolicies: ["Observe"] + forProvider: + name: Restrict deletions + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/repository-rulesets/README.md b/deploy/repository-rulesets/README.md new file mode 100644 index 0000000..a005e1b --- /dev/null +++ b/deploy/repository-rulesets/README.md @@ -0,0 +1,17 @@ +# `repository-rulesets/` — repo-scoped rulesets as code + +Declarative management of individual repositories' **`RepositoryRuleset`** resources as +Crossplane managed resources, via +[provider-upjet-github](https://github.com/crossplane-contrib/provider-upjet-github). +Org-wide `OrganizationRuleset` resources live in the sibling +[`../organization-rulesets/`](../organization-rulesets/), whose +[`README.md`](../organization-rulesets/README.md) documents the shared **Observe-first** +adoption convention and the provider importability matrix. + +**One `RepositoryRuleset` per file**, named `-on-.yaml` so the rule and its +target repo are clear from the filename. external-name = `:`. + +| File | Repo | Ruleset | Policy | +|---|---|---|---| +| `restrict-deletions-on-ksail.yaml` | `ksail` | Restrict deletions (scoped to `refs/heads/benchmark-data`) | Observe (read-only import) | +| `require-merge-queue-on-platform.yaml` | `platform` | Require merge queue | Observe (read-only import) | diff --git a/deploy/repository-rulesets/kustomization.yaml b/deploy/repository-rulesets/kustomization.yaml new file mode 100644 index 0000000..4e18c1f --- /dev/null +++ b/deploy/repository-rulesets/kustomization.yaml @@ -0,0 +1,9 @@ +# `RepositoryRuleset` resources — one per file, named `-on-.yaml`, each a +# repo-scoped rule adopted Observe-first (read-only). external-name = `:`. See +# ../organization-rulesets/README.md for the shared adoption convention and the provider +# importability matrix. Included by ../kustomization.yaml. +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - restrict-deletions-on-ksail.yaml + - require-merge-queue-on-platform.yaml diff --git a/deploy/repository-rulesets/require-merge-queue-on-platform.yaml b/deploy/repository-rulesets/require-merge-queue-on-platform.yaml new file mode 100644 index 0000000..3d3a296 --- /dev/null +++ b/deploy/repository-rulesets/require-merge-queue-on-platform.yaml @@ -0,0 +1,22 @@ +# Existing repository ruleset, adopted Observe-first (read-only). external-name = +# `:` (cf. the `maintainers:` convention in +# ../teams/). Identity-only forProvider — see ./README.md for the convention. +# +# platform's "Require merge queue" rule on the default branch (merge_queue is a +# repo-only rule type; org rulesets cannot carry it). +apiVersion: repo.github.m.upbound.io/v1alpha1 +kind: RepositoryRuleset +metadata: + name: platform-require-merge-queue + annotations: + crossplane.io/external-name: "platform:5275020" +spec: + managementPolicies: ["Observe"] + forProvider: + repository: platform + name: Require merge queue + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/deploy/repository-rulesets/restrict-deletions-on-ksail.yaml b/deploy/repository-rulesets/restrict-deletions-on-ksail.yaml new file mode 100644 index 0000000..68555df --- /dev/null +++ b/deploy/repository-rulesets/restrict-deletions-on-ksail.yaml @@ -0,0 +1,22 @@ +# Existing repository ruleset, adopted Observe-first (read-only). external-name = +# `:` (cf. the `maintainers:` convention in +# ../teams/). Identity-only forProvider — see ./README.md for the convention. +# +# ksail's own "Restrict deletions" rule, scoped to refs/heads/benchmark-data (the +# long-lived benchmark-data branch) — narrower than the org-wide rule. +apiVersion: repo.github.m.upbound.io/v1alpha1 +kind: RepositoryRuleset +metadata: + name: ksail-restrict-deletions + annotations: + crossplane.io/external-name: "ksail:14699634" +spec: + managementPolicies: ["Observe"] + forProvider: + repository: ksail + name: Restrict deletions + target: branch + enforcement: active + providerConfigRef: + kind: ProviderConfig + name: default