From 54f31498551604c44cf9575405e3df5e6bed1db2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Thu, 13 Aug 2026 03:59:09 +0000 Subject: [PATCH] chore(content)!: plan 1.x governance retirement --- AGENTS.md | 8 + CONTRIBUTING.md | 11 ++ README.md | 46 ++++- bin/publish | 212 ++++++++++++++++++++++- bin/validate | 60 +++++++ config/repositories.json | 11 +- config/retired-maintenance-branches.json | 50 ++++++ tests/publish-maintenance-branch.sh | 8 + tests/publish-maintenance-retirement.sh | 207 ++++++++++++++++++++++ tests/validate-retired-maintenance.sh | 77 ++++++++ 10 files changed, 665 insertions(+), 25 deletions(-) create mode 100644 config/retired-maintenance-branches.json create mode 100755 tests/publish-maintenance-retirement.sh create mode 100755 tests/validate-retired-maintenance.sh diff --git a/AGENTS.md b/AGENTS.md index 29c121a..2d0e883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,14 @@ 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. +- Record a completed maintenance-line retirement in + `config/retired-maintenance-branches.json` with stable repository/ruleset + identities, exact final branch and release commits, recovery assets, and the + rulesets that must remain active. Use the targeted + `bin/publish --retire-maintenance REPOSITORY/BRANCH` plan so the authorized + apply can remove only that exact ruleset. Branch deletion is a later, + separately authorized operation after a second live preflight; the publisher + never deletes a branch, tag, release, or asset. - Preserve the selected-actions entry for Codecov and the manual GitHub App repository-access inventory while coverage uploads use OIDC authentication. - Record cross-repository private-package consumption in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e13b56..c65b661 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,3 +101,14 @@ landed and passed on the protected branch. In particular, add Classic's `CodeQL validation` requirement only after its advanced workflow is merged; otherwise the required check would prevent the workflow pull request itself from merging. + +Maintenance-branch retirement removes the active entry from +`config/repositories.json` and adds an immutable recovery record to +`config/retired-maintenance-branches.json`. Validate the complete repository, +then review the targeted `bin/publish --retire-maintenance +REPOSITORY/BRANCH` output. It must contain exactly one planned mutation: the +declared ruleset deletion. Merge the desired state before requesting explicit +organization-owner authorization for `--apply`. Re-run the complete preflight +after apply and again before separately authorized exact branch deletion; +never combine either operation with tag, release, asset, default-branch, or +unrelated policy changes. diff --git a/README.md b/README.md index 7b11ff8..625941b 100644 --- a/README.md +++ b/README.md @@ -528,7 +528,7 @@ confirm that remaining pull requests and branches have been preserved at their new location. For `atrinik/classic`, also complete any intentional tag rebuild before applying immutable release-tag policy. -## Protecting a maintenance branch +## Protecting and retiring a maintenance branch Add a unique repository/branch entry to `maintenance_branches` in `config/repositories.json`. Branch names are repository-relative, so `1.x` @@ -537,18 +537,50 @@ 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` requires `Content validation` and `Conventional PR title`. Both -contexts were emitted successfully by the branch-aware maintenance-line pull -request before the desired state added them; neither context is inferred from -the default branch. +Retired maintenance lines move out of that active array and into +`config/retired-maintenance-branches.json`. The immutable record binds the +stable repository and ruleset IDs, exact final branch commit, final rollback +tag/commit and asset names, and every default-branch/tag ruleset that must stay +active. The targeted publisher refuses missing, ambiguous, or drifted live +state and emits only the exact maintenance-ruleset deletion: + +```sh +bin/validate +bin/publish --retire-maintenance content/1.x +``` + +For the completed content cutover, the final rollback anchor is +`v1.8.19@566bd25f78b80b08d5f75f4b02017ab2429204db`; the preserved retirement +branch tip is `080a9ea41741e4e67adc7b09b3ccb51475d93d3a`. The source archive, +Classic runtime archive, and `SHA256SUMS` must remain accessible and checksum +clean. `content@main` is the sole authored and released source, and the Classic +updater remains justified only for proposing locks to verified main-built +Classic artifacts. + +After the desired-state pull request merges, an organization owner may +explicitly authorize this one policy mutation: + +```sh +bin/publish --apply --retire-maintenance content/1.x +``` + +The targeted apply verifies that only ruleset `20571870` is absent and that +the recorded main and immutable-tag protections remain active. It does not +delete the branch. Before deleting `refs/heads/1.x`, repeat the live repository, +ruleset, open-PR, consumer, tag, release, asset, checksum, and reachability +preflight and obtain a second explicit organization-owner authorization for +that exact Git-reference deletion. After deletion, verify the branch is absent +and every preserved tag/release asset and commit remains reachable. Recreating +`1.x` is a new organization-owner recovery decision, not automatic rollback. 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: +Validate semantic configuration and both publisher scopes when adding or +changing active maintenance protection. For a recorded retirement, additionally +review the targeted plan above before any live authorization: ```sh bin/validate diff --git a/bin/publish b/bin/publish index 692f2fd..169193f 100755 --- a/bin/publish +++ b/bin/publish @@ -6,15 +6,28 @@ organization=${ATRINIK_ORGANIZATION:-atrinik} policy_scope=${ATRINIK_POLICY_SCOPE:-auto} api_version=2026-03-10 apply=false +retire_maintenance= -case "${1:-}" in -"") ;; ---apply) apply=true ;; -*) - echo "usage: $0 [--apply]" >&2 - exit 2 - ;; -esac +while (($#)); do + case $1 in + --apply) + apply=true + shift + ;; + --retire-maintenance) + if (($# < 2)) || [[ -z $2 ]]; then + echo "error: --retire-maintenance requires REPOSITORY/BRANCH" >&2 + exit 2 + fi + retire_maintenance=$2 + shift 2 + ;; + *) + echo "usage: $0 [--apply] [--retire-maintenance REPOSITORY/BRANCH]" >&2 + exit 2 + ;; + esac +done case ${policy_scope} in auto | organization | repository) ;; @@ -37,6 +50,7 @@ repositories_config=${root}/config/repositories.json advisory_merge_windows_config=${root}/config/advisory-merge-windows.json codeql_advanced_config=${root}/config/codeql-advanced-setup.json immutable_releases_config=${root}/config/immutable-releases.json +retired_maintenance_config=${root}/config/retired-maintenance-branches.json temporary_files=() trap 'rm -f "${temporary_files[@]}"' EXIT @@ -64,6 +78,188 @@ run_api() { fi } +retire_maintenance_branch_ruleset() { + local coordinate=$1 + local retirement repository branch repository_id default_branch + local branch_commit release_tag release_commit ruleset_id ruleset_name + local repository_metadata ruleset branch_ref tag_ref release open_pulls + local rulesets preserved_id preserved_name + + retirement=$(jq -c --arg coordinate "${coordinate}" ' + [ + .retirements[] | + select("\(.repository)/\(.branch)" == $coordinate) + ] | + if length == 1 then .[0] else empty end + ' "${retired_maintenance_config}") + if [[ -z ${retirement} ]]; then + echo "error: retirement coordinate is absent or ambiguous: ${coordinate}" >&2 + exit 1 + fi + + repository=$(jq -r '.repository' <<<"${retirement}") + branch=$(jq -r '.branch' <<<"${retirement}") + repository_id=$(jq -r '.repository_id' <<<"${retirement}") + default_branch=$(jq -r '.default_branch' <<<"${retirement}") + branch_commit=$(jq -r '.final_branch_commit' <<<"${retirement}") + release_tag=$(jq -r '.final_release.tag' <<<"${retirement}") + release_commit=$(jq -r '.final_release.commit' <<<"${retirement}") + ruleset_id=$(jq -r '.ruleset.id' <<<"${retirement}") + ruleset_name=$(jq -r '.ruleset.name' <<<"${retirement}") + + if jq -e --arg repository "${repository}" --arg branch "${branch}" ' + any( + .maintenance_branches[]; + .repository == $repository and .branch == $branch + ) + ' "${repositories_config}" >/dev/null; then + echo "error: retirement target remains in desired maintenance branches" >&2 + exit 1 + fi + + repository_metadata=$(github_api "repos/${organization}/${repository}") + if ! jq -e --argjson id "${repository_id}" \ + --arg default_branch "${default_branch}" ' + .id == $id and + .archived == false and + .default_branch == $default_branch + ' <<<"${repository_metadata}" >/dev/null; then + echo "error: retirement repository identity or default branch drift" >&2 + exit 1 + fi + + branch_ref=$(github_api \ + "repos/${organization}/${repository}/git/ref/heads/${branch}") + if [[ $(jq -r '.object.sha // empty' <<<"${branch_ref}") != \ + "${branch_commit}" ]]; then + echo "error: retirement branch commit drift" >&2 + exit 1 + fi + + tag_ref=$(github_api \ + "repos/${organization}/${repository}/git/ref/tags/${release_tag}") + if [[ $(jq -r '.object.sha // empty' <<<"${tag_ref}") != \ + "${release_commit}" ]]; then + echo "error: final rollback tag commit drift" >&2 + exit 1 + fi + + release=$(github_api \ + "repos/${organization}/${repository}/releases/tags/${release_tag}") + if ! jq -e --arg tag "${release_tag}" --argjson retirement "${retirement}" ' + .tag_name == $tag and + .draft == false and + .prerelease == false and + ([.assets[].name] | sort) == ($retirement.final_release.assets | sort) + ' <<<"${release}" >/dev/null; then + echo "error: final rollback release or asset inventory drift" >&2 + exit 1 + fi + + open_pulls=$(github_api \ + "repos/${organization}/${repository}/pulls?state=open&base=${branch}&per_page=100") + if [[ $(jq 'length' <<<"${open_pulls}") != 0 ]]; then + echo "error: open pull requests still target the retirement branch" >&2 + exit 1 + fi + + rulesets=$(github_api "orgs/${organization}/rulesets") + while IFS=$'\t' read -r preserved_id preserved_name; do + if ! jq -e --argjson id "${preserved_id}" --arg name "${preserved_name}" ' + any(.[]; .id == $id and .name == $name and .enforcement == "active") + ' <<<"${rulesets}" >/dev/null; then + echo "error: preserved ruleset identity or enforcement drift: ${preserved_id}" >&2 + exit 1 + fi + done < <(jq -r '.preserved_rulesets[] | [.id, .name] | @tsv' \ + <<<"${retirement}") + + if ! jq -e --argjson id "${ruleset_id}" --arg name "${ruleset_name}" ' + [.[] | select(.id == $id and .name == $name)] | length == 1 + ' <<<"${rulesets}" >/dev/null; then + echo "error: exact retirement ruleset is missing or ambiguous" >&2 + exit 1 + fi + + ruleset=$(github_api "orgs/${organization}/rulesets/${ruleset_id}") + if ! jq -e --argjson id "${ruleset_id}" --arg name "${ruleset_name}" \ + --arg repository "${repository}" --arg branch "${branch}" \ + --argjson retirement "${retirement}" ' + .id == $id and + .name == $name and + .target == "branch" and + .enforcement == "active" and + .bypass_actors == [{ + actor_id: null, + actor_type: "OrganizationAdmin", + bypass_mode: "pull_request" + }] and + .conditions.repository_name.include == [$repository] and + .conditions.repository_name.exclude == [] and + .conditions.ref_name.include == ["refs/heads/\($branch)"] and + .conditions.ref_name.exclude == [] and + ([.rules[].type] | sort) == ([ + "deletion", + "non_fast_forward", + "pull_request", + "required_linear_history", + "required_status_checks" + ] | sort) and + ([.rules[] | select(.type == "pull_request")] | length == 1) and + ([.rules[] | select(.type == "pull_request")][0].parameters == { + allowed_merge_methods: ["merge", "squash", "rebase"], + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_approving_review_count: 0, + required_review_thread_resolution: true, + required_reviewers: [] + }) and + ([.rules[] | select(.type == "required_status_checks")] | length == 1) and + ([.rules[] | select(.type == "required_status_checks")][0].parameters == { + do_not_enforce_on_create: false, + required_status_checks: ( + $retirement.ruleset.required_ci | + map({context: ., integration_id: 15368}) + ), + strict_required_status_checks_policy: true + }) + ' <<<"${ruleset}" >/dev/null; then + echo "error: exact retirement ruleset payload drift" >&2 + exit 1 + fi + + echo "KEEP ${organization}/${repository} default branch is ${default_branch}" + echo "KEEP refs/heads/${branch} is ${branch_commit}" + echo "KEEP ${release_tag} is ${release_commit} with exact recovery assets" + echo "KEEP no open pull request targets ${branch}" + run_api DELETE "orgs/${organization}/rulesets/${ruleset_id}" + + if ${apply}; then + rulesets=$(github_api "orgs/${organization}/rulesets") + if jq -e --argjson id "${ruleset_id}" 'any(.[]; .id == $id)' \ + <<<"${rulesets}" >/dev/null; then + echo "error: retirement ruleset still exists after apply" >&2 + exit 1 + fi + while IFS=$'\t' read -r preserved_id preserved_name; do + if ! jq -e --argjson id "${preserved_id}" --arg name "${preserved_name}" ' + any(.[]; .id == $id and .name == $name and .enforcement == "active") + ' <<<"${rulesets}" >/dev/null; then + echo "error: preserved ruleset changed during retirement: ${preserved_id}" >&2 + exit 1 + fi + done < <(jq -r '.preserved_rulesets[] | [.id, .name] | @tsv' \ + <<<"${retirement}") + echo "APPLY verified only ruleset ${ruleset_id} is absent" + fi +} + +if [[ -n ${retire_maintenance} ]]; then + retire_maintenance_branch_ruleset "${retire_maintenance}" + exit 0 +fi + converge_organization_settings() { local current=$1 local desired diff --git a/bin/validate b/bin/validate index a61227b..8cd395f 100755 --- a/bin/validate +++ b/bin/validate @@ -8,6 +8,7 @@ repositories_config=${root}/config/repositories.json advisory_merge_windows_config=${root}/config/advisory-merge-windows.json advanced_codeql_config=${root}/config/codeql-advanced-setup.json immutable_releases_config=${root}/config/immutable-releases.json +retired_maintenance_config=${root}/config/retired-maintenance-branches.json manual_settings_config=${root}/config/manual-settings.json planning_config=${root}/config/planning.json planning_health_config=${root}/config/planning-health.json @@ -159,6 +160,65 @@ jq -e ' ($branches | length) == ($branches | unique | length)) ' "${repositories_config}" >/dev/null +jq -e \ + --slurpfile repositories "${repositories_config}" ' + (keys == ["retirements"]) and + (.retirements | type == "array") and + all( + .retirements[]; + (keys == [ + "branch", + "default_branch", + "final_branch_commit", + "final_release", + "preserved_rulesets", + "repository", + "repository_id", + "ruleset" + ]) and + (.repository | type == "string" and length > 0) and + (.repository_id | type == "number" and floor == . and . > 0) and + .default_branch == "main" and + (. as $retirement | + ($retirement.branch | type == "string" and length > 0) and + $retirement.branch != $retirement.default_branch) and + (.final_branch_commit | test("^[0-9a-f]{40}$")) and + (.final_release | keys == ["assets", "commit", "tag"]) and + (.final_release.tag | test("^v[0-9]+\\.[0-9]+\\.[0-9]+$")) and + (.final_release.commit | test("^[0-9a-f]{40}$")) and + (.final_release.assets | type == "array" and length > 0) and + ((.final_release.assets | length) == + (.final_release.assets | unique | length)) and + all(.final_release.assets[]; type == "string" and length > 0) and + (.ruleset | keys == ["id", "name", "required_ci"]) and + (.ruleset.id | type == "number" and floor == . and . > 0) and + (.ruleset.name == + "05 - Maintenance branch - \(.repository) - \(.branch)") and + (.ruleset.required_ci | type == "array" and length > 0) and + ((.ruleset.required_ci | length) == + (.ruleset.required_ci | unique | length)) and + all(.ruleset.required_ci[]; type == "string" and length > 0) and + (.preserved_rulesets | type == "array" and length > 0) and + all( + .preserved_rulesets[]; + (keys == ["id", "name"]) and + (.id | type == "number" and floor == . and . > 0) and + (.name | type == "string" and length > 0) + ) and + ((.preserved_rulesets | map(.id) | length) == + (.preserved_rulesets | map(.id) | unique | length)) and + (.repository as $repository | .branch as $branch | + all( + $repositories[0].maintenance_branches[]; + .repository != $repository or .branch != $branch + )) + ) and + ((.retirements | map([.repository, .branch]) | length) == + (.retirements | map([.repository, .branch]) | unique | length)) and + ((.retirements | map(.ruleset.id) | length) == + (.retirements | map(.ruleset.id) | unique | length)) + ' "${retired_maintenance_config}" >/dev/null + jq -e \ --slurpfile repositories "${repositories_config}" ' (keys == ["repositories"]) and diff --git a/config/repositories.json b/config/repositories.json index 61efbc5..1cdaf59 100644 --- a/config/repositories.json +++ b/config/repositories.json @@ -89,16 +89,7 @@ "Conventional PR title" ] }, - "maintenance_branches": [ - { - "repository": "content", - "branch": "1.x", - "required_ci": [ - "Content validation", - "Conventional PR title" - ] - } - ], + "maintenance_branches": [], "release_tags": [ "atrinik", "classic", diff --git a/config/retired-maintenance-branches.json b/config/retired-maintenance-branches.json new file mode 100644 index 0000000..47f1354 --- /dev/null +++ b/config/retired-maintenance-branches.json @@ -0,0 +1,50 @@ +{ + "retirements": [ + { + "repository": "content", + "repository_id": 1325219730, + "default_branch": "main", + "branch": "1.x", + "final_branch_commit": "080a9ea41741e4e67adc7b09b3ccb51475d93d3a", + "final_release": { + "tag": "v1.8.19", + "commit": "566bd25f78b80b08d5f75f4b02017ab2429204db", + "assets": [ + "SHA256SUMS", + "atrinik-content-1.8.19-runtime.tar.gz", + "atrinik-content-1.8.19.tar.gz" + ] + }, + "ruleset": { + "id": 20571870, + "name": "05 - Maintenance branch - content - 1.x", + "required_ci": [ + "Content validation", + "Conventional PR title" + ] + }, + "preserved_rulesets": [ + { + "id": 20522685, + "name": "01 - Default branch integrity" + }, + { + "id": 20593745, + "name": "01 - Default branch linear history" + }, + { + "id": 20522686, + "name": "02 - Changes through pull requests" + }, + { + "id": 20522693, + "name": "03 - Required CI - content" + }, + { + "id": 20522688, + "name": "04 - Immutable release tags" + } + ] + } + ] +} diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index 9533c15..ebaa0bc 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -1095,6 +1095,14 @@ cp -R "${root}/.github/workflows" "${advisory_active_root}/.github/workflows" jq '.repositories = ["classic"]' \ "${root}/config/advisory-merge-windows.json" \ >"${advisory_active_root}/config/advisory-merge-windows.json" +jq '.maintenance_branches = [{ + repository: "content", + branch: "1.x", + required_ci: ["Content validation", "Conventional PR title"] +}]' "${root}/config/repositories.json" \ + >"${advisory_active_root}/config/repositories.json" +jq '.retirements = []' "${root}/config/retired-maintenance-branches.json" \ + >"${advisory_active_root}/config/retired-maintenance-branches.json" organization_log=${temporary}/organization.jsonl organization_immutable_state=${temporary}/organization-immutable.json diff --git a/tests/publish-maintenance-retirement.sh b/tests/publish-maintenance-retirement.sh new file mode 100755 index 0000000..587504c --- /dev/null +++ b/tests/publish-maintenance-retirement.sh @@ -0,0 +1,207 @@ +#!/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 + +[[ ${1:-} == api ]] +shift +method=GET +endpoint= +while (($#)); do + case $1 in + -H | --header) + shift 2 + ;; + --method) + method=$2 + shift 2 + ;; + *) + endpoint=$1 + shift + ;; + esac +done + +jq -cn --arg method "${method}" --arg endpoint "${endpoint}" \ + '{method: $method, endpoint: $endpoint}' >>"${GH_API_LOG}" + +if [[ ${method} == DELETE ]] && + [[ ${endpoint} == orgs/atrinik/rulesets/20571870 ]]; then + printf 'false\n' >"${GH_RULESET_PRESENT}" + printf '{}\n' + exit 0 +fi +[[ ${method} == GET ]] + +rulesets() { + jq -n --argjson present "$(<"${GH_RULESET_PRESENT}")" ' + [ + {id: 20522685, name: "01 - Default branch integrity", enforcement: "active"}, + {id: 20593745, name: "01 - Default branch linear history", enforcement: "active"}, + {id: 20522686, name: "02 - Changes through pull requests", enforcement: "active"}, + {id: 20522693, name: "03 - Required CI - content", enforcement: "active"}, + {id: 20522688, name: "04 - Immutable release tags", enforcement: "active"} + ] + + (if $present then [{ + id: 20571870, + name: "05 - Maintenance branch - content - 1.x", + enforcement: "active" + }] else [] end) + ' +} + +case ${endpoint} in +repos/atrinik/content) + printf '%s\n' \ + '{"id":1325219730,"archived":false,"default_branch":"main"}' + ;; +repos/atrinik/content/git/ref/heads/1.x) + printf '%s\n' \ + '{"object":{"sha":"080a9ea41741e4e67adc7b09b3ccb51475d93d3a"}}' + ;; +repos/atrinik/content/git/ref/tags/v1.8.19) + printf '%s\n' \ + '{"object":{"sha":"566bd25f78b80b08d5f75f4b02017ab2429204db"}}' + ;; +repos/atrinik/content/releases/tags/v1.8.19) + printf '%s\n' '{ + "tag_name":"v1.8.19", + "draft":false, + "prerelease":false, + "assets":[ + {"name":"atrinik-content-1.8.19-runtime.tar.gz"}, + {"name":"atrinik-content-1.8.19.tar.gz"}, + {"name":"SHA256SUMS"} + ] + }' + ;; +'repos/atrinik/content/pulls?state=open&base=1.x&per_page=100') + if [[ ${GH_OPEN_PULL:-false} == true ]]; then + printf '[{"number":1}]\n' + else + printf '[]\n' + fi + ;; +orgs/atrinik/rulesets) + rulesets + ;; +orgs/atrinik/rulesets/20571870) + if [[ ${GH_RULESET_DRIFT:-false} == true ]]; then + branch=retired + else + branch=1.x + fi + jq -n --arg branch "${branch}" '{ + id: 20571870, + name: "05 - Maintenance branch - content - 1.x", + target: "branch", + enforcement: "active", + bypass_actors: [{ + actor_id: null, + actor_type: "OrganizationAdmin", + bypass_mode: "pull_request" + }], + conditions: { + repository_name: {include: ["content"], exclude: []}, + ref_name: {include: ["refs/heads/\($branch)"], exclude: []} + }, + rules: [ + {type: "deletion"}, + {type: "non_fast_forward"}, + {type: "required_linear_history"}, + { + type: "pull_request", + parameters: { + allowed_merge_methods: ["merge", "squash", "rebase"], + dismiss_stale_reviews_on_push: false, + require_code_owner_review: false, + require_last_push_approval: false, + required_approving_review_count: 0, + required_review_thread_resolution: true, + required_reviewers: [] + } + }, + { + type: "required_status_checks", + parameters: { + do_not_enforce_on_create: false, + required_status_checks: [ + {context: "Content validation", integration_id: 15368}, + {context: "Conventional PR title", integration_id: 15368} + ], + strict_required_status_checks_policy: true + } + } + ] + }' + ;; +*) + echo "unexpected endpoint: ${endpoint}" >&2 + exit 1 + ;; +esac +EOF +chmod +x "${temporary}/bin/gh" + +present=${temporary}/ruleset-present +printf 'true\n' >"${present}" +plan_log=${temporary}/plan.jsonl +plan_output=${temporary}/plan.txt +GH_API_LOG=${plan_log} GH_RULESET_PRESENT=${present} \ + PATH="${temporary}/bin:${PATH}" \ + "${root}/bin/publish" --retire-maintenance content/1.x >"${plan_output}" + +[[ $(grep -c '^PLAN ' "${plan_output}") == 1 ]] +grep -Fx 'PLAN DELETE /orgs/atrinik/rulesets/20571870' \ + "${plan_output}" >/dev/null +jq -s -e 'all(.[]; .method == "GET")' "${plan_log}" >/dev/null +[[ $(<"${present}") == true ]] + +apply_log=${temporary}/apply.jsonl +apply_output=${temporary}/apply.txt +GH_API_LOG=${apply_log} GH_RULESET_PRESENT=${present} \ + PATH="${temporary}/bin:${PATH}" \ + "${root}/bin/publish" --apply \ + --retire-maintenance content/1.x >"${apply_output}" +jq -s -e ' + [.[] | select(.method != "GET")] == [{ + method: "DELETE", + endpoint: "orgs/atrinik/rulesets/20571870" + }] +' "${apply_log}" >/dev/null +grep -Fx 'APPLY verified only ruleset 20571870 is absent' \ + "${apply_output}" >/dev/null +[[ $(<"${present}") == false ]] + +printf 'true\n' >"${present}" +if GH_API_LOG=${temporary}/open-pr.jsonl GH_RULESET_PRESENT=${present} \ + GH_OPEN_PULL=true PATH="${temporary}/bin:${PATH}" \ + "${root}/bin/publish" --retire-maintenance content/1.x \ + >"${temporary}/open-pr.out" 2>"${temporary}/open-pr.err"; then + echo "retirement plan accepted an open target-branch pull request" >&2 + exit 1 +fi +grep -F 'open pull requests still target the retirement branch' \ + "${temporary}/open-pr.err" >/dev/null + +if GH_API_LOG=${temporary}/drift.jsonl GH_RULESET_PRESENT=${present} \ + GH_RULESET_DRIFT=true PATH="${temporary}/bin:${PATH}" \ + "${root}/bin/publish" --retire-maintenance content/1.x \ + >"${temporary}/drift.out" 2>"${temporary}/drift.err"; then + echo "retirement plan accepted ruleset scope drift" >&2 + exit 1 +fi +grep -F 'exact retirement ruleset payload drift' \ + "${temporary}/drift.err" >/dev/null + +echo "Targeted maintenance retirement publisher tests passed." diff --git a/tests/validate-retired-maintenance.sh b/tests/validate-retired-maintenance.sh new file mode 100755 index 0000000..866c901 --- /dev/null +++ b/tests/validate-retired-maintenance.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT + +make_case() { + local name=$1 + local case_root=${temporary}/${name} + + mkdir -p "${case_root}/bin" + cp "${root}/bin/validate" "${case_root}/bin/validate" + cp -R "${root}/config" "${case_root}/config" + cp -R "${root}/community-health" "${case_root}/community-health" + printf '%s\n' "${case_root}" +} + +expect_invalid_retirement() { + local name=$1 + local filter=$2 + local case_root output + + case_root=$(make_case "${name}") + output=${case_root}/config/retired-maintenance-branches.next.json + jq "${filter}" \ + "${case_root}/config/retired-maintenance-branches.json" >"${output}" + mv "${output}" "${case_root}/config/retired-maintenance-branches.json" + if "${case_root}/bin/validate" >/dev/null 2>&1; then + echo "expected retired-maintenance validation failure: ${name}" >&2 + exit 1 + fi +} + +"${root}/bin/validate" >/dev/null + +expect_invalid_retirement invalid-commit \ + '.retirements[0].final_branch_commit = "not-a-commit"' +expect_invalid_retirement duplicate-asset \ + '.retirements[0].final_release.assets += ["SHA256SUMS"]' +expect_invalid_retirement duplicate-ci \ + '.retirements[0].ruleset.required_ci += ["Content validation"]' +expect_invalid_retirement duplicate-preserved-id ' + .retirements[0].preserved_rulesets += + [{id: 20522685, name: "duplicate identity"}] +' +expect_invalid_retirement duplicate-coordinate ' + .retirements += [ + .retirements[0] | + .ruleset.id = 99999999 + ] +' +expect_invalid_retirement duplicate-ruleset-id ' + .retirements += [ + .retirements[0] | + .repository = "other" | + .repository_id = 99999999 | + .ruleset.name = "05 - Maintenance branch - other - 1.x" + ] +' + +overlap_root=$(make_case active-overlap) +jq '.maintenance_branches = [{ + repository: "content", + branch: "1.x", + required_ci: ["Content validation", "Conventional PR title"] +}]' "${overlap_root}/config/repositories.json" \ + >"${overlap_root}/config/repositories.next.json" +mv "${overlap_root}/config/repositories.next.json" \ + "${overlap_root}/config/repositories.json" +if "${overlap_root}/bin/validate" >/dev/null 2>&1; then + echo "expected active and retired maintenance overlap to fail" >&2 + exit 1 +fi + +echo "Retired-maintenance validation tests passed."