diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dc743b7..cb73bc9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ad0d81c..8d41142 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 793076f..83621ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index a36896c..bd05ae4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 +``` diff --git a/bin/publish b/bin/publish index 935fa45..839857a 100755 --- a/bin/publish +++ b/bin/publish @@ -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 @@ -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}" "$@" } @@ -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() { @@ -136,7 +241,8 @@ 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 @@ -144,7 +250,7 @@ delete_managed_organization_rulesets() { } 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}" \ @@ -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 @@ -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 @@ -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 } diff --git a/bin/validate b/bin/validate new file mode 100755 index 0000000..d834fab --- /dev/null +++ b/bin/validate @@ -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." diff --git a/config/repositories.json b/config/repositories.json index e50a87e..0f8217f 100644 --- a/config/repositories.json +++ b/config/repositories.json @@ -75,6 +75,15 @@ "Conventional PR title" ] }, + "maintenance_branches": [ + { + "repository": "content", + "branch": "1.x", + "required_ci": [ + "Conventional PR title" + ] + } + ], "release_tags": [ "atrinik", "client", diff --git a/config/rulesets/maintenance-branch.json b/config/rulesets/maintenance-branch.json new file mode 100644 index 0000000..844f9bf --- /dev/null +++ b/config/rulesets/maintenance-branch.json @@ -0,0 +1,46 @@ +{ + "name": "05 - Maintenance branch", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 1, + "actor_type": "OrganizationAdmin", + "bypass_mode": "pull_request" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [] + } + }, + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "type": "pull_request", + "parameters": { + "require_code_owner_review": false, + "require_last_push_approval": false, + "dismiss_stale_reviews_on_push": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [], + "strict_required_status_checks_policy": true + } + } + ] +} diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh new file mode 100755 index 0000000..6b812ee --- /dev/null +++ b/tests/publish-maintenance-branch.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT +mkdir "${temporary}/bin" + +cat >"${temporary}/bin/gh" <<'EOF' +#!/usr/bin/env bash + +set -euo pipefail + +if [[ ${1:-} != api ]]; then + echo "unexpected gh command" >&2 + exit 1 +fi +shift + +method=GET +endpoint= +input= +jq_filter= +while (($#)); do + case $1 in + -H | --header) + shift 2 + ;; + --method) + method=$2 + shift 2 + ;; + --input) + input=$2 + shift 2 + ;; + --jq) + jq_filter=$2 + shift 2 + ;; + --paginate) + shift + ;; + *) + if [[ -n ${endpoint} ]]; then + echo "unexpected gh api argument: $1" >&2 + exit 1 + fi + endpoint=$1 + shift + ;; + esac +done + +payload=null +if [[ -n ${input} ]]; then + payload=$(jq -c . "${input}") +fi +jq -cn \ + --arg method "${method}" \ + --arg endpoint "${endpoint}" \ + --argjson payload "${payload}" \ + '{method: $method, endpoint: $endpoint, payload: $payload}' \ + >>"${GH_API_LOG}" + +if [[ ${method} != GET ]]; then + printf '{}\n' + exit 0 +fi + +case "${endpoint}|${jq_filter}" in +"orgs/atrinik|.plan.name") + printf 'team\n' + ;; +"orgs/atrinik|.members_can_create_teams") + printf 'false\n' + ;; +"orgs/atrinik/repos?per_page=100&type=all|"*) + printf 'content\n' + ;; +"orgs/atrinik/rulesets|"*) + printf '[{"id":900,"name":"05 - Maintenance branch - content - retired"}]\n' + ;; +"orgs/atrinik/code-security/configurations|"*) + printf '[{"id":265376,"name":"Atrinik security baseline"}]\n' + ;; +"repos/atrinik/nawerhals|.archived") + printf 'true\n' + ;; +repos/atrinik/*/rulesets\?includes_parents=false\|*) + printf '[{"id":900,"name":"05 - Maintenance branch - content - retired"}]\n' + ;; +*) + printf '[]\n' + ;; +esac +EOF +chmod +x "${temporary}/bin/gh" + +assert_maintenance_payload() { + local log=$1 + local endpoint=$2 + local organization_scope=$3 + + jq -s -e \ + --arg endpoint "${endpoint}" \ + --argjson organization_scope "${organization_scope}" ' + [ + .[] | + select( + .method == "POST" and + .endpoint == $endpoint and + .payload.name == "05 - Maintenance branch - content - 1.x" + ) + ] as $matches | + ($matches | length) == 1 and + ($matches[0].payload.target == "branch") and + ($matches[0].payload.enforcement == "active") and + ($matches[0].payload.conditions.ref_name.include == ["refs/heads/1.x"]) and + ( + ($matches[0].payload.conditions | has("repository_name")) == + $organization_scope + ) and + ( + ($organization_scope | not) or + ($matches[0].payload.conditions.repository_name.include == ["content"]) + ) and + ( + [$matches[0].payload.rules[].type] == [ + "deletion", + "non_fast_forward", + "required_linear_history", + "pull_request", + "required_status_checks" + ] + ) and + ( + [ + $matches[0].payload.rules[] | + select(.type == "required_status_checks") | + .parameters.required_status_checks[] + ] == [ + {context: "Conventional PR title", integration_id: 15368} + ] + ) and + all( + .[]; + .payload.name != "03 - Required CI - client" and + .payload.name != "03 - Required CI - editor" and + .payload.name != "03 - Required CI - protocol" and + .payload.name != "03 - Required CI - renderer" and + .payload.name != "03 - Required CI - server" + ) + ' "${log}" >/dev/null +} + +organization_log=${temporary}/organization.jsonl +GH_API_LOG=${organization_log} \ + PATH="${temporary}/bin:${PATH}" \ + ATRINIK_POLICY_SCOPE=organization \ + "${root}/bin/publish" --apply >/dev/null +assert_maintenance_payload \ + "${organization_log}" "orgs/atrinik/rulesets" true +jq -s -e ' + any( + .[]; + .method == "DELETE" and + .endpoint == "orgs/atrinik/rulesets/900" + ) +' "${organization_log}" >/dev/null + +repository_log=${temporary}/repository.jsonl +GH_API_LOG=${repository_log} \ + PATH="${temporary}/bin:${PATH}" \ + ATRINIK_POLICY_SCOPE=repository \ + "${root}/bin/publish" --apply >/dev/null +assert_maintenance_payload \ + "${repository_log}" "repos/atrinik/content/rulesets" false +jq -s -e ' + any( + .[]; + .method == "DELETE" and + .endpoint == "repos/atrinik/content/rulesets/900" + ) and + any( + .[]; + .method == "DELETE" and + .endpoint == "orgs/atrinik/rulesets/900" + ) +' "${repository_log}" >/dev/null + +echo "Maintenance branch publisher tests passed."