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
11 changes: 7 additions & 4 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Validate JSON
run: find config -name '*.json' -print0 | xargs -0 -n1 jq empty
- name: Validate configuration
run: bin/validate

- name: Check publisher
run: shellcheck bin/publish
- name: Check governance scripts
run: shellcheck bin/publish bin/publish-repositories bin/validate tests/*.sh

- name: Test publisher policy scopes
run: tests/publish-maintenance-branch.sh
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
- Keep required workflow job names synchronized with rulesets. Workflow or
permissions changes also require actionlint, least-privilege review, and
immutable action references according to policy.
- Declare protected non-default lines in `maintenance_branches`. Give each
repository/branch pair a unique entry, and require only status checks that
the branch's workflows already emit. Validate both Team organization rulesets
and the retained repository-level fallback when changing this contract.
- Preserve the selected-actions entry for Codecov and the manual GitHub App
repository-access inventory while coverage uploads use OIDC authentication.
- Never print or commit administrative tokens, live secrets, or API responses
containing credentials.
- Validate all JSON with `jq`, run `bash -n` and ShellCheck on changed publisher
scripts, run actionlint for workflows, inspect plan output, and finish with
`git diff --check`.
- Run `bin/validate`, `bash -n`, ShellCheck on governance scripts, the publisher
policy-scope tests, actionlint for workflows, inspect plan output, and finish
with `git diff --check`.
- Commits and pull-request titles use Conventional Commits. Every squash merge
is released by semantic-release.
- Update this `AGENTS.md` in the same change when major rework alters governance
Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Contributing

Use a Conventional Commits pull-request title. Validate every JSON policy,
run `shellcheck bin/publish`, and review the dry-run publisher output before
applying organization changes. Never commit the administrative token required
by the manual publishing workflow.
run `bin/validate`, ShellCheck on every governance script, the publisher
policy-scope tests, and actionlint when workflows change. Review the dry-run
publisher output before applying organization changes. Never commit the
administrative token required by the manual publishing workflow.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ read-only and are skipped on later runs.
Replacement repositories receive their required-check rules only after their
bootstrap workflows publish stable aggregate job names; renamed repositories
retain their established check contracts during the transition.
- Declared maintenance branches reject deletion and non-fast-forward updates,
require linear history and pull requests, and require only checks already
emitted for that branch.
- Release tags in release-producing repositories cannot be moved or deleted.
- Active repositories allow squash merges only. The squash commit uses the
pull-request title and description, and merged head branches are deleted.
Expand Down Expand Up @@ -46,7 +49,8 @@ their OIDC-authenticated coverage uploads and badges remain available.

## Usage

Requirements: `gh`, `jq`, and an authenticated organization-owner account.
Requirements: `git`, `gh`, `jq`, and an authenticated organization-owner
account.

```sh
gh auth refresh -h github.com -s admin:org
Expand Down Expand Up @@ -86,3 +90,32 @@ security configuration is also the default for newly created repositories.
Add repositories to the appropriate arrays in `config/repositories.json` when
they also need a pull-request gate, required CI, immutable release tags, or
archival.

## Protecting a maintenance branch

Add a unique repository/branch entry to `maintenance_branches` in
`config/repositories.json`. Branch names are repository-relative, so `1.x`
becomes the exact ruleset ref `refs/heads/1.x`. Every entry receives deletion,
non-fast-forward, linear-history, and pull-request rules. Its `required_ci`
array may contain only stable checks already declared for that repository and
emitted by workflows on the maintenance branch.

`content/1.x` initially requires `Conventional PR title`, whose unfiltered
pull-request policy already runs for that branch. `Content validation` remains
deliberately absent until the content workflow is enabled and proven on `1.x`;
add it to this entry in that implementation change before applying the tighter
policy.

On GitHub Team the publisher creates one organization ruleset per maintenance
branch. The repository-policy fallback creates the equivalent repository
ruleset. Both paths remove stale managed maintenance rulesets, and migration
creates the destination protection before deleting the previous scope.

Validate semantic configuration and both publisher scopes before reviewing the
live plan:

```sh
bin/validate
tests/publish-maintenance-branch.sh
bin/publish
```
166 changes: 148 additions & 18 deletions bin/publish
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ auto | organization | repository) ;;
;;
esac

for command in gh jq; do
for command in gh git jq; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "error: ${command} is required" >&2
exit 1
Expand All @@ -36,6 +36,8 @@ repositories_config=${root}/config/repositories.json
temporary_files=()
trap 'rm -f "${temporary_files[@]}"' EXIT

"${root}/bin/validate" >/dev/null

github_api() {
gh api -H "X-GitHub-Api-Version: ${api_version}" "$@"
}
Expand Down Expand Up @@ -112,18 +114,121 @@ make_organization_ruleset() {
echo "${output}"
}

delete_repository_ruleset() {
maintenance_ruleset_name() {
local repository=$1
local name=$2
local id
local branch=$2

id=$(
printf '05 - Maintenance branch - %s - %s' "${repository}" "${branch}"
}

make_maintenance_ruleset() {
local repository=$1
local branch=$2
local required_ci=$3
local scope=$4
local output name

name=$(maintenance_ruleset_name "${repository}" "${branch}")
output=$(mktemp)
temporary_files+=("${output}")
jq --arg name "${name}" \
--arg repository "${repository}" \
--arg branch "${branch}" \
--arg scope "${scope}" \
--argjson app_id 15368 \
--argjson required_ci "${required_ci}" '
.name = $name |
.conditions.ref_name.include = ["refs/heads/\($branch)"] |
.rules = (
.rules |
map(
if .type == "required_status_checks" then
.parameters.required_status_checks =
($required_ci | map({context: ., integration_id: $app_id}))
else
.
end
) |
if ($required_ci | length) == 0 then
map(select(.type != "required_status_checks"))
else
.
end
) |
if $scope == "organization" then
.conditions.repository_name = {
include: [$repository],
exclude: [],
protected: false
}
else
.
end
' "${root}/config/rulesets/maintenance-branch.json" >"${output}"
echo "${output}"
}

maintenance_ruleset_is_desired() {
local repository=$1
local candidate=$2
local branch expected

while IFS= read -r branch; do
expected=$(maintenance_ruleset_name "${repository}" "${branch}")
if [[ ${candidate} == "${expected}" ]]; then
return 0
fi
done < <(
jq -r --arg repository "${repository}" '
.maintenance_branches[] |
select(.repository == $repository) |
.branch
' "${repositories_config}"
)
return 1
}

delete_stale_repository_maintenance_rulesets() {
local repository ruleset name id

for repository in "${active_repositories[@]}"; do
while IFS= read -r ruleset; do
name=$(jq -r '.name' <<<"${ruleset}")
id=$(jq -r '.id' <<<"${ruleset}")
if [[ ${name} != "05 - Maintenance branch - "* ]]; then
continue
fi
if ! maintenance_ruleset_is_desired "${repository}" "${name}"; then
run_api DELETE "repos/${organization}/${repository}/rulesets/${id}"
fi
done < <(
github_api \
"repos/${organization}/${repository}/rulesets?includes_parents=false" |
jq -c '.[]'
)
done
}

delete_managed_repository_rulesets() {
local repository=$1
local ruleset name id

while IFS= read -r ruleset; do
name=$(jq -r '.name' <<<"${ruleset}")
id=$(jq -r '.id' <<<"${ruleset}")
case ${name} in
"01 - Default branch integrity" | \
"02 - Changes through pull requests" | \
"03 - Required CI" | \
"04 - Immutable release tags" | \
"05 - Maintenance branch - "*)
run_api DELETE "repos/${organization}/${repository}/rulesets/${id}"
;;
esac
done < <(
github_api "repos/${organization}/${repository}/rulesets?includes_parents=false" |
jq -r --arg name "${name}" 'map(select(.name == $name))[0].id // empty'
jq -c '.[]'
)
if [[ -n ${id} ]]; then
run_api DELETE "repos/${organization}/${repository}/rulesets/${id}"
fi
}

delete_managed_organization_rulesets() {
Expand All @@ -136,15 +241,16 @@ delete_managed_organization_rulesets() {
"01 - Default branch integrity" | \
"02 - Changes through pull requests" | \
"04 - Immutable release tags" | \
"03 - Required CI - "*)
"03 - Required CI - "* | \
"05 - Maintenance branch - "*)
run_api DELETE "orgs/${organization}/rulesets/${id}"
;;
esac
done < <(github_api "orgs/${organization}/rulesets" | jq -c '.[]')
}

publish_repository_rulesets() {
local repository ci_payload
local repository ci_payload maintenance branch required_ci input

for repository in "${active_repositories[@]}"; do
upsert_repository_ruleset "${repository}" \
Expand Down Expand Up @@ -174,13 +280,25 @@ publish_repository_rulesets() {
"${root}/config/rulesets/immutable-release-tags.json"
done < <(jq -r '.release_tags[]' "${repositories_config}")

while IFS= read -r maintenance; do
repository=$(jq -r '.repository' <<<"${maintenance}")
branch=$(jq -r '.branch' <<<"${maintenance}")
required_ci=$(jq -c '.required_ci' <<<"${maintenance}")
input=$(make_maintenance_ruleset \
"${repository}" "${branch}" "${required_ci}" repository)
temporary_files+=("${input}")
upsert_repository_ruleset "${repository}" "${input}"
done < <(jq -c '.maintenance_branches[]' "${repositories_config}")

delete_stale_repository_maintenance_rulesets

if [[ ${organization_plan} != free ]]; then
delete_managed_organization_rulesets
fi
}

publish_organization_rulesets() {
local input repository ci_payload
local input repository ci_payload maintenance branch required_ci
local -a desired_rulesets
local repositories ruleset name id desired

Expand Down Expand Up @@ -232,12 +350,27 @@ publish_organization_rulesets() {
upsert_organization_ruleset "${ci_payload}"
done < <(jq -r '.required_ci | keys[]' "${repositories_config}")

while IFS= read -r maintenance; do
repository=$(jq -r '.repository' <<<"${maintenance}")
branch=$(jq -r '.branch' <<<"${maintenance}")
required_ci=$(jq -c '.required_ci' <<<"${maintenance}")
name=$(maintenance_ruleset_name "${repository}" "${branch}")
desired_rulesets+=("${name}")
input=$(make_maintenance_ruleset \
"${repository}" "${branch}" "${required_ci}" organization)
temporary_files+=("${input}")
upsert_organization_ruleset "${input}"
done < <(jq -c '.maintenance_branches[]' "${repositories_config}")

while IFS= read -r ruleset; do
name=$(jq -r '.name' <<<"${ruleset}")
id=$(jq -r '.id' <<<"${ruleset}")
if [[ ${name} != "03 - Required CI - "* ]]; then
case ${name} in
"03 - Required CI - "* | "05 - Maintenance branch - "*) ;;
*)
continue
fi
;;
esac
desired=false
for repository in "${desired_rulesets[@]}"; do
if [[ ${repository} == "${name}" ]]; then
Expand All @@ -251,10 +384,7 @@ publish_organization_rulesets() {
done < <(github_api "orgs/${organization}/rulesets" | jq -c '.[]')

for repository in "${active_repositories[@]}"; do
delete_repository_ruleset "${repository}" "01 - Default branch integrity"
delete_repository_ruleset "${repository}" "02 - Changes through pull requests"
delete_repository_ruleset "${repository}" "03 - Required CI"
delete_repository_ruleset "${repository}" "04 - Immutable release tags"
delete_managed_repository_rulesets "${repository}"
done
}

Expand Down
51 changes: 51 additions & 0 deletions bin/validate
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash

set -euo pipefail

root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
repositories_config=${root}/config/repositories.json

for command in git jq; do
if ! command -v "${command}" >/dev/null 2>&1; then
echo "error: ${command} is required" >&2
exit 1
fi
done

while IFS= read -r -d '' config; do
jq empty "${config}" >/dev/null
done < <(find "${root}/config" -name '*.json' -print0 | sort -z)

jq -e '
. as $config |
(.maintenance_branches | type == "array") and
all(
.maintenance_branches[];
(keys == ["branch", "repository", "required_ci"]) and
(.repository | type == "string" and length > 0) and
(.branch | type == "string" and length > 0 and . != "main") and
(.required_ci | type == "array") and
((.required_ci | length) == (.required_ci | unique | length)) and
all(.required_ci[]; type == "string" and length > 0) and
(.repository as $repository |
($config.pull_request_gate | index($repository)) != null) and
(.repository as $repository |
.required_ci |
all(
.[];
. as $check |
(($config.required_ci[$repository] // []) | index($check)) != null
))
) and
([.maintenance_branches[] | [.repository, .branch]] as $branches |
($branches | length) == ($branches | unique | length))
' "${repositories_config}" >/dev/null

while IFS= read -r branch; do
if ! git check-ref-format --branch "${branch}" >/dev/null; then
echo "error: invalid maintenance branch name: ${branch}" >&2
exit 1
fi
done < <(jq -r '.maintenance_branches[].branch' "${repositories_config}")

echo "Governance configuration is valid."
Loading