diff --git a/AGENTS.md b/AGENTS.md index 40ab76f..ae4858a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,6 +204,14 @@ must explicitly converge inventory repositories to default-setup `not-configured` and all other active repositories to `configured` without weakening dependency, secret-scanning, or vulnerability-reporting features. +- Keep ordinary ruleset publication delta-aware using the shared audit/publisher + comparison contract. Normalize only documented actor-ID equivalence and + reviewed compatible PR/CI extension values; preserve existing writable + extension fields in every replacement payload. Reject unknown or conflicting + restrictions, including additional deployment gates, instead of discarding + them. Verify exact existing ruleset identity and re-read full state immediately + before PUT, refusing observed concurrent edits. Repository merge defaults and + organization workflow permissions write only their real managed-field drift. - Keep organization security publication delta-aware. Normalize GitHub's legacy aggregate security response before comparing desired configuration, update only real configuration/default drift, and never re-attach a diff --git a/README.md b/README.md index 77c3f52..0ccd5a7 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,26 @@ migration. Before changing immutable releases, it snapshots the exact current organization mode and selected repository IDs. A failed update or verification automatically restores and verifies that snapshot. +Repository merge defaults and organization workflow permissions are written only +when their declared fields differ. Ordinary ruleset updates compare normalized +policy, treating the ignored `OrganizationAdmin.actor_id` and array ordering as +semantically equivalent. Publisher and audit share the comparison contract in +`lib/ruleset-policy.jq`. + +GitHub's additional PR/CI parameters are writable controls. Existing empty +`required_reviewers`, all three `allowed_merge_methods`, +`require_extra_approval_for_unattributed_changes: true`, and +`do_not_enforce_on_create: false` are compatible with this policy and are copied +unchanged into replacement payloads. Other values, unknown parameters, extra +rules (including deployment gates), and unknown condition fields require manual +review; the publisher stops instead of dropping them. Managed fields still +converge on real drift. Existing ruleset updates verify the listed identity and +re-read the full document before PUT, refusing observed concurrent changes. +These reads are not an atomic API transaction. Review the complete plan before +any authorized apply; normalization does not authorize rule retirement or a +live policy change. Queue installation and verification still precede relaxed +required CI. + The organization security phase compares GitHub's normalized configuration, default, and repository-association state before writing. It updates only drifted configurations and attaches only repositories that are not already diff --git a/bin/audit b/bin/audit index 8aa9e3c..78bf624 100755 --- a/bin/audit +++ b/bin/audit @@ -234,24 +234,7 @@ organization_ruleset_scope() { else error("unknown managed ruleset scope") end' } -ruleset_normalization=' -# Omitted request fields are expanded by GitHub to these exact defaults. -# Preserve nondefault, malformed and unknown fields as observable drift. -def normalize_rules: - map(if .type == "pull_request" then - .parameters |= ( - if .required_reviewers == [] then del(.required_reviewers) else . end | - if .require_extra_approval_for_unattributed_changes == true then - del(.require_extra_approval_for_unattributed_changes) else . end | - if (.allowed_merge_methods | type) == "array" and - (.allowed_merge_methods | sort) == ["merge", "rebase", "squash"] then - del(.allowed_merge_methods) else . end) - elif .type == "required_status_checks" then - .parameters |= ( - if .do_not_enforce_on_create == false then del(.do_not_enforce_on_create) else . end | - .required_status_checks |= sort_by(.context, .integration_id)) - else . end) | sort_by(.type); -' +ruleset_normalization=$(cat "${root}/lib/ruleset-policy.jq") read_effective_rules() { local repository=$1 branch=$2 encoded @@ -345,13 +328,6 @@ check_ruleset() { wanted=$(jq --argjson scope "${scope}" '.conditions.repository_name = $scope' <<<"${wanted}") fi if ! jq -e --argjson wanted "${wanted}" "${ruleset_normalization}"' - # GitHub returns null for OrganizationAdmin, whose desired actor ID is 1. - def normalize_bypass: - map(if .actor_type == "OrganizationAdmin" and .actor_id == null then - .actor_id = 1 else . end) | sort_by(.actor_type, .actor_id, .bypass_mode); - def normalize_conditions: - with_entries(if .key == "ref_name" or .key == "repository_name" then - .value.include |= sort | .value.exclude |= sort else . end); .enforcement == $wanted.enforcement and .target == $wanted.target and (.bypass_actors | normalize_bypass) == ($wanted.bypass_actors | normalize_bypass) and (.conditions | normalize_conditions) == ($wanted.conditions | normalize_conditions) and diff --git a/bin/publish b/bin/publish index d1dcd8a..f1b5950 100755 --- a/bin/publish +++ b/bin/publish @@ -369,6 +369,59 @@ is_temporary_security_advisory_repository() { "${repositories_config}" >/dev/null } +# Compare only declared metadata keys; never PATCH already-converged defaults. +converge_settings() { + local method=$1 endpoint=$2 input=$3 current + current=$(github_api "${endpoint}") || return 1 + if ! jq -e 'type == "object"' <<<"${current}" >/dev/null; then + echo "error: unreadable settings: ${endpoint}" >&2 + return 1 + fi + if jq -e --slurpfile desired "${input}" ' + . as $current | all($desired[0] | to_entries[]; $current[.key] == .value) + ' <<<"${current}" >/dev/null; then + echo "KEEP /${endpoint}" + else + run_api "${method}" "${endpoint}" "${input}" + fi +} + +# Preserve compatible writable API extensions on a managed-policy update. +# Unknown restrictions fail closed rather than disappearing in a replacement PUT. +update_managed_ruleset() { + local endpoint=$1 input=$2 id=$3 source_type=$4 source=$5 + local current fresh output + current=$(github_api "${endpoint}") || return 1 + if ! jq -e --argjson id "${id}" --arg source_type "${source_type}" \ + --arg source "${source}" --slurpfile desired "${input}" ' + .id == $id and .source_type == $source_type and .source == $source and + .name == $desired[0].name + ' <<<"${current}" >/dev/null; then + echo "error: managed ruleset identity changed; retained: ${endpoint}" >&2 + return 1 + fi + output=$(mktemp) + temporary_files+=("${output}") + jq -L "${root}/lib" --slurpfile desired "${input}" ' + include "ruleset-policy"; prepare_policy($desired[0]) + ' <<<"${current}" >"${output}" || return 1 + if jq -e -L "${root}/lib" --slurpfile desired "${output}" ' + include "ruleset-policy"; + (policy_body | normalize_policy) == ($desired[0] | normalize_policy) + ' <<<"${current}" >/dev/null; then + echo "KEEP /${endpoint} $(jq -r .name "${input}")" + return + fi + # Re-read the full document immediately before a replacement and refuse any + # observed concurrent edit, including changes in unmanaged fields. + fresh=$(github_api "${endpoint}") || return 1 + if ! jq -e --argjson prior "${current}" '. == $prior' <<<"${fresh}" >/dev/null; then + echo "error: managed ruleset changed during planning; retained: ${endpoint}" >&2 + return 1 + fi + run_api PUT "${endpoint}" "${output}" +} + upsert_repository_ruleset() { local repository=$1 local input=$2 @@ -379,7 +432,8 @@ upsert_repository_ruleset() { id=$(jq -r --arg name "${name}" 'map(select(.name == $name))[0].id // empty' <<<"${rulesets}") if [[ -n ${id} ]]; then - run_api PUT "repos/${organization}/${repository}/rulesets/${id}" "${input}" + update_managed_ruleset "repos/${organization}/${repository}/rulesets/${id}" \ + "${input}" "${id}" Repository "${organization}/${repository}" else run_api POST "repos/${organization}/${repository}/rulesets" "${input}" fi @@ -394,7 +448,8 @@ upsert_organization_ruleset() { id=$(jq -r --arg name "${name}" 'map(select(.name == $name))[0].id // empty' <<<"${rulesets}") if [[ -n ${id} ]]; then - run_api PUT "orgs/${organization}/rulesets/${id}" "${input}" + update_managed_ruleset "orgs/${organization}/rulesets/${id}" \ + "${input}" "${id}" Organization "${organization}" else run_api POST "orgs/${organization}/rulesets" "${input}" fi @@ -1273,7 +1328,7 @@ delete_removed_security_merge_rulesets() { # These repository rules remain repository-owned even in organization scope: # GitHub Team does not support organization-level code-scanning rules. publish_security_merge_rulesets() { - local repository kind template name existing id current reason status readiness_mode phase metadata effective_branch proven_current + local repository kind template name existing id current reason status readiness_mode phase metadata effective_branch proven_current preserved_payload delete_removed_security_merge_rulesets || return 1 for kind in code_scanning dependency_review; do if [[ ${kind} == code_scanning ]]; then @@ -1375,7 +1430,12 @@ publish_security_merge_rulesets() { echo "KEEP /repos/${organization}/${repository}/rulesets/${id} ${name}" continue fi - run_api PUT "repos/${organization}/${repository}/rulesets/${id}" "${template}" + preserved_payload=$(mktemp) + temporary_files+=("${preserved_payload}") + jq -L "${root}/lib" --slurpfile desired "${template}" ' + include "ruleset-policy"; prepare_policy($desired[0]) + ' <<<"${current}" >"${preserved_payload}" || return 1 + run_api PUT "repos/${organization}/${repository}/rulesets/${id}" "${preserved_payload}" else run_api POST "repos/${organization}/${repository}/rulesets" "${template}" fi @@ -2084,11 +2144,11 @@ converge_immutable_release_policy converge_organization_settings "${organization_metadata}" converge_actions_permissions -run_api PUT "orgs/${organization}/actions/permissions/workflow" \ +converge_settings PUT "orgs/${organization}/actions/permissions/workflow" \ "${root}/config/actions-workflow.json" for repository in "${active_repositories[@]}"; do - run_api PATCH "repos/${organization}/${repository}" \ + converge_settings PATCH "repos/${organization}/${repository}" \ "${root}/config/repository-defaults.json" done diff --git a/lib/ruleset-policy.jq b/lib/ruleset-policy.jq new file mode 100644 index 0000000..f430cae --- /dev/null +++ b/lib/ruleset-policy.jq @@ -0,0 +1,76 @@ +# Reviewed compatible values for writable API extensions, not response metadata. +# Comparison may omit them; update payloads must preserve the live values. +# Nondefault, malformed and unknown fields remain observable drift. +def normalize_rules: + map(if .type == "pull_request" then + .parameters |= ( + if .required_reviewers == [] then del(.required_reviewers) else . end | + if .require_extra_approval_for_unattributed_changes == true then + del(.require_extra_approval_for_unattributed_changes) else . end | + if (.allowed_merge_methods | type) == "array" and + (.allowed_merge_methods | sort) == ["merge", "rebase", "squash"] then + del(.allowed_merge_methods) else . end) + elif .type == "required_status_checks" then + .parameters |= ( + if .do_not_enforce_on_create == false then del(.do_not_enforce_on_create) else . end | + .required_status_checks |= sort_by(.context, .integration_id)) + else . end) | sort_by(.type); + +# OrganizationAdmin.actor_id is ignored by the REST API. +# https://docs.github.com/en/rest/orgs/rules?apiVersion=2026-03-10 +def normalize_bypass: + map(if .actor_type == "OrganizationAdmin" then .actor_id = 1 else . end) | + sort_by(.actor_type, .actor_id, .bypass_mode); +def normalize_conditions: + with_entries(if .key == "ref_name" or .key == "repository_name" then + .value.include |= sort | .value.exclude |= sort else . end); +def normalize_policy: + .bypass_actors |= normalize_bypass | + .conditions |= normalize_conditions | + .rules |= normalize_rules; +def policy_body: + del(.id, .node_id, .source, .source_type, .created_at, .updated_at, ._links, + .current_user_can_bypass); + +# Validate keys recursively before replacing managed values. Arrays other than +# rules are atomic policy values. New restrictions require source review. +def recognized_keys($wanted): + if type == "object" then + ($wanted | type == "object") and + all(to_entries[]; . as $entry | + ($wanted | has($entry.key)) and + ($entry.value | recognized_keys($wanted[$entry.key]))) + elif type == "array" then + ($wanted | type == "array") and + all(.[]; . as $item | + if type == "object" or type == "array" then + any($wanted[]; . as $shape | $item | recognized_keys($shape)) + else true end) + else true end; +def prepare_policy($wanted): + policy_body as $live | + ($live | normalize_policy) as $normalized | + ($wanted | normalize_policy) as $normalized_wanted | + if ($live | type) != "object" or + ($live.rules | type) != "array" or + ($live.rules | map(.type) | length != (unique | length)) or + (($normalized | del(.rules) | recognized_keys($normalized_wanted | del(.rules))) | not) or + (all($normalized.rules[]; . as $rule | + any($normalized_wanted.rules[]; + . as $candidate | .type == $rule.type and ($rule | recognized_keys($candidate)))) | not) + then error("unrecognized ruleset fields or restrictions require manual review") + else + # A known extension is approved only at its reviewed compatible value. + # normalize_rules removed exactly those values; leftover extension keys + # therefore failed recognized_keys above. Copy actual values, never defaults. + $wanted | .rules |= map(. as $rule | + ([$live.rules[] | select(.type == $rule.type)][0] // {}) as $prior | + if .type == "pull_request" then + .parameters = (($prior.parameters // {} | with_entries(select( + .key == "required_reviewers" or .key == "allowed_merge_methods" or + .key == "require_extra_approval_for_unattributed_changes"))) + .parameters) + elif .type == "required_status_checks" then + .parameters = (($prior.parameters // {} | with_entries(select( + .key == "do_not_enforce_on_create"))) + .parameters) + else . end) + end; diff --git a/tests/audit-fixtures.py b/tests/audit-fixtures.py index db3b6fd..9aef19e 100755 --- a/tests/audit-fixtures.py +++ b/tests/audit-fixtures.py @@ -159,7 +159,7 @@ def rulesets(repository): for actor in item['bypass_actors']: if actor['actor_type'] == 'OrganizationAdmin': actor['actor_id'] = None - if scenario == 'owner-id-drift' and repository == 'observatory' and template == 'pull-request-gate': + if scenario == 'owner-id-ignored' and repository == 'observatory' and template == 'pull-request-gate': item['bypass_actors'][0]['actor_id'] = 2 if item['source_type'] == 'Organization': if template in ('default-branch-integrity', 'default-branch-linear-history'): diff --git a/tests/audit.sh b/tests/audit.sh index c1b0d0b..36b7ea1 100755 --- a/tests/audit.sh +++ b/tests/audit.sh @@ -7,6 +7,7 @@ temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT mkdir -p "${temporary}/mock" "${temporary}/full/bin" cp -R "${root}/config" "${temporary}/full/config" +cp -R "${root}/lib" "${temporary}/full/lib" cp "${root}/bin/audit" "${temporary}/full/bin/audit" # Schema validation has its own policy tests; these isolate live API evidence. printf '#!/usr/bin/env bash\nexit 0\n' >"${temporary}/full/bin/validate" @@ -141,7 +142,7 @@ output=$(run_audit legacy-security) grep -Fq 'Governance audit passed:' <<<"${output}" output=$(run_audit repository-rulesets) grep -Fq 'Governance audit passed:' <<<"${output}" -for scenario in api-defaults-omitted api-default-order scope-order inherited-tag-unaffected foreign-tag sha-enforced-on languages-readable languages-empty advanced-null-languages; do +for scenario in owner-id-ignored api-defaults-omitted api-default-order scope-order inherited-tag-unaffected foreign-tag sha-enforced-on languages-readable languages-empty advanced-null-languages; do output=$(run_audit "${scenario}") grep -Fq 'Governance audit passed:' <<<"${output}" done @@ -265,7 +266,6 @@ assert_drift organization-codeql-owner "managed security ruleset must be reposit assert_drift organization-dependency-owner "managed security ruleset must be repository-owned '07 - Dependency review'" assert_drift integrity-bypass "ruleset scope or policy drift '01 - Default branch integrity'" assert_drift pr-bypass "ruleset scope or policy drift '02 - Changes through pull requests'" -assert_drift owner-id-drift "ruleset scope or policy drift '02 - Changes through pull requests'" assert_drift pending-wrong-dependency-scope "ruleset scope or policy drift '07 - Dependency review'" for scenario in wrong-app wrong-context strict-ci; do assert_drift "${scenario}" 'effective required CI contexts or app drift' diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index 4465348..addeaf3 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -292,7 +292,7 @@ if [[ ${method} != GET ]]; then fi case "${endpoint}|${jq_filter}" in -repos/atrinik/*/actions/permissions/workflow\|*) +repos/atrinik/*/actions/permissions/workflow\|* | orgs/atrinik/actions/permissions/workflow\|*) printf '{"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}\n' ;; "orgs/atrinik/actions/permissions/selected-actions|") @@ -544,6 +544,11 @@ repos/atrinik/*/rulesets\?includes_parents=false\|*) printf '[{"id":900,"name":"05 - Maintenance branch - content - retired"}]\n' fi ;; +repos/atrinik/*\|) + # Default-settings reads added by the delta-aware publisher. Other metadata + # endpoints with identity-specific contracts are handled above. + printf '{"default_branch":"main","allow_merge_commit":false,"allow_rebase_merge":false,"allow_squash_merge":true,"delete_branch_on_merge":true,"squash_merge_commit_title":"PR_TITLE","squash_merge_commit_message":"PR_BODY"}\n' + ;; *) printf '[]\n' ;; diff --git a/tests/publish-policy-comparison.sh b/tests/publish-policy-comparison.sh new file mode 100755 index 0000000..cfdc2ba --- /dev/null +++ b/tests/publish-policy-comparison.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# Production functions consume the fixture globals. +# shellcheck disable=SC2034 +set -euo pipefail +source_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT +sed -n '/^converge_organization_settings() {/,/^organization_metadata=/p' \ + "${source_root}/bin/publish" | sed '$d' >"${temporary}/functions.sh" +sed -n '/^list_rulesets() {/,/^}/p' "${source_root}/bin/publish" >>"${temporary}/functions.sh" +# shellcheck source=/dev/null +source "${temporary}/functions.sh" +root=${source_root} +organization=atrinik +temporary_files=() +log=${temporary}/writes +reads=${temporary}/reads +input=${temporary}/desired.json +live=${temporary}/live.json +later=${temporary}/later.json +scope=organization + +github_api() { + printf '%s\n' "$1" >>"${reads}" + case $1 in + orgs/atrinik/rulesets | 'repos/atrinik/example/rulesets?includes_parents=false') + jq '[{id:42,name:.name}]' "${input}" ;; + */rulesets/42) + if [[ -f ${later} ]] && (($(grep -c '/rulesets/42' "${reads}") > 1)); then + cat "${later}" + else cat "${live}"; fi ;; + *) cat "${live}" ;; + esac +} +run_api() { + jq -cn --arg method "$1" --arg endpoint "$2" --slurpfile body "$3" \ + '{method:$method,endpoint:$endpoint,body:$body[0]}' >>"${log}" +} +reset_case() { + : >"${log}" + : >"${reads}" + rm -f "${later}" + cp "${root}/config/rulesets/$1.json" "${input}" + local source_type=Organization source=atrinik + if [[ ${scope} == repository ]]; then source_type=Repository; source=atrinik/example; fi + jq --arg source_type "${source_type}" --arg source "${source}" ' + . + {id:42,source_type:$source_type,source:$source,updated_at:"before"} | + if $source_type == "Repository" then .current_user_can_bypass = "pull_requests_only" else . end | + .bypass_actors |= map(.actor_id = null) | + .rules |= map(if .type == "pull_request" then + .parameters += {required_reviewers:[],allowed_merge_methods:["rebase","squash","merge"], + require_extra_approval_for_unattributed_changes:true} + elif .type == "required_status_checks" then .parameters.do_not_enforce_on_create = false + else . end) + ' "${input}" >"${live}" +} +change_live() { + jq "$1" "${live}" >"${temporary}/changed" + mv "${temporary}/changed" "${live}" +} +upsert() { + if [[ ${scope} == organization ]]; then upsert_organization_ruleset "${input}" + else upsert_repository_ruleset example "${input}"; fi +} +for scope in organization repository; do + for template in pull-request-gate required-ci; do + reset_case "${template}" + upsert >"${temporary}/output" + test ! -s "${log}" + change_live '.bypass_actors |= map(.actor_id = 1) | .rules[].parameters |= del(.required_reviewers, .allowed_merge_methods, .require_extra_approval_for_unattributed_changes, .do_not_enforce_on_create)' + upsert >"${temporary}/output" + test ! -s "${log}" + reset_case "${template}" + # Real scope drift retains the exact live extension fields on the PUT. + change_live '.conditions.ref_name.include = ["refs/heads/old"]' + upsert >"${temporary}/output" + jq -se --slurpfile live "${live}" --slurpfile desired "${input}" ' + length == 1 and .[0].method == "PUT" and + .[0].body.conditions == $desired[0].conditions and + .[0].body.rules == $live[0].rules and + (.[0].body | has("id") or has("updated_at") or has("current_user_can_bypass") | not) + ' "${log}" >/dev/null + done + for mutation in \ + '.rules[0].parameters.future_gate = true' \ + '.rules[0].parameters.required_reviewers = [{reviewer:{id:42,type:"Team"},minimum_approvals:1}]' \ + '.rules[0].parameters.require_extra_approval_for_unattributed_changes = false' \ + '.rules[0].parameters.allowed_merge_methods = ["squash"]' \ + '.rules[0].parameters.required_reviewers = null' \ + '.rules += [{type:"required_deployments",parameters:{required_deployment_environments:["production"]}}]' \ + '.rules += [.rules[0]]' \ + '.conditions.repository_property = {future:true}' \ + '.bypass_actors[0].future_permission = true' \ + '.future_control = true' \ + '.source = "foreign"' \ + '.id = 99'; do + reset_case pull-request-gate + change_live "${mutation}" + if upsert >"${temporary}/output" 2>&1; then + echo "unexpectedly accepted ${scope}: ${mutation}" >&2; exit 1 + fi + test ! -s "${log}" + done + for mutation in \ + '.rules[0].parameters.do_not_enforce_on_create = true' \ + '.rules[0].parameters.required_status_checks = [{context:"Unknown", integration_id:15368, future_control:true}]'; do + reset_case required-ci + change_live "${mutation}" + if upsert >"${temporary}/output" 2>&1; then exit 1; fi + test ! -s "${log}" + done + # A concurrent restriction appears between the initial read and PUT. + reset_case pull-request-gate + change_live '.enforcement = "disabled"' + jq '.rules[0].parameters.required_reviewers = [{reviewer:{id:42,type:"Team"},minimum_approvals:1}]' \ + "${live}" >"${later}" + if upsert >"${temporary}/output" 2>&1; then exit 1; fi + test ! -s "${log}" + # Known read-only metadata remains part of the complete concurrency fence. + reset_case pull-request-gate + change_live '.enforcement = "disabled"' + jq '.current_user_can_bypass = "never"' "${live}" >"${later}" + if upsert >"${temporary}/output" 2>&1; then exit 1; fi + test ! -s "${log}" +done +# Repository defaults and org workflow permissions ignore unrelated metadata, +# avoid converged writes, and still report real drift. +for settings in repository-defaults actions-workflow; do + : >"${log}" + jq '. + {id:42, unrelated:"metadata"}' "${root}/config/${settings}.json" >"${live}" + converge_settings PATCH settings "${root}/config/${settings}.json" >"${temporary}/output" + test ! -s "${log}" + jq 'del(.[keys[0]])' "${root}/config/${settings}.json" >"${live}" + converge_settings PATCH settings "${root}/config/${settings}.json" >"${temporary}/output" + test "$(wc -l <"${log}")" = 1 +done +echo 'Policy comparison and preservation tests passed.' diff --git a/tests/publish-ruleset-pagination.sh b/tests/publish-ruleset-pagination.sh index 610f85b..9e49a31 100755 --- a/tests/publish-ruleset-pagination.sh +++ b/tests/publish-ruleset-pagination.sh @@ -10,6 +10,7 @@ trap 'rm -rf "${temporary}"' EXIT root=${temporary}/fixture mkdir -p "${root}/config" cp -R "${source_root}/config/." "${root}/config/" +cp -R "${source_root}/lib" "${root}/lib" sed -n '/^converge_organization_settings() {/,/^organization_metadata=/p' \ "${source_root}/bin/publish" | sed '$d' >"${temporary}/functions.sh" sed -n '/^list_rulesets() {/,/^}/p' "${source_root}/bin/publish" >>"${temporary}/functions.sh" @@ -55,6 +56,24 @@ github_api() { *) return 99 ;; esac ;; + orgs/atrinik/rulesets/1 | orgs/atrinik/rulesets/2 | orgs/atrinik/rulesets/4 | orgs/atrinik/rulesets/5) + local template + case ${endpoint##*/} in + 1) template=default-branch-integrity ;; + 2) template=default-branch-linear-history ;; + 4) template=pull-request-gate ;; + 5) template=immutable-release-tags ;; + esac + jq --argjson id "${endpoint##*/}" ' + . + {id:$id,source_type:"Organization",source:"atrinik",enforcement:"disabled"} | + .conditions.repository_name = {include:[],exclude:[],protected:false} + ' "${root}/config/rulesets/${template}.json" ;; + orgs/atrinik/rulesets/31 | repos/atrinik/content-toolkit/rulesets/31) + local source_type=Repository source=atrinik/content-toolkit + if [[ ${endpoint} == orgs/* ]]; then source_type=Organization; source=atrinik; fi + jq --arg source_type "${source_type}" --arg source "${source}" ' + . + {id:31,source_type:$source_type,source:$source,enforcement:"disabled"} + ' "${root}/config/rulesets/default-branch-integrity.json" ;; repos/atrinik/content-toolkit) echo '{"id":123,"full_name":"atrinik/content-toolkit","archived":false,"default_branch":"main"}' ;; repos/atrinik/content-toolkit/rulesets/3 | \ diff --git a/tests/publish-security-merge.sh b/tests/publish-security-merge.sh index 96f8ba2..26d2d27 100755 --- a/tests/publish-security-merge.sh +++ b/tests/publish-security-merge.sh @@ -12,6 +12,7 @@ trap 'rm -rf "${temporary}"' EXIT root=${temporary}/fixture mkdir -p "${root}/bin" "${root}/config/rulesets" cp "${source_root}/config/"*.json "${root}/config/" +cp -R "${source_root}/lib" "${root}/lib" cp "${source_root}/config/rulesets/"*.json "${root}/config/rulesets/" # Source production functions without executing validation, authentication or @@ -485,6 +486,7 @@ for policy_scope in organization repository; do # Unchanged ownership, explicit API defaults, and allowed enforcement/ref # restoration still converge; a concurrent completed repair causes KEEP. for filter in '.' \ + '.current_user_can_bypass = "pull_requests_only"' \ '.bypass_actors[0].actor_id = null | if .rules[0].type == "required_status_checks" then .rules[0].parameters.do_not_enforce_on_create = false else . end' \ '.enforcement = "evaluate" | .conditions.ref_name.include = ["refs/heads/other"]' \ '.enforcement = "active"'; do @@ -503,6 +505,13 @@ for policy_scope in organization repository; do jq -se --arg endpoint "repos/atrinik/content-toolkit/rulesets/${gate_update_id}" ' [.[] | select(.method != "GET")] | length == 1 and .[0].method == "PUT" and .[0].endpoint == $endpoint' "${api_log}" >/dev/null + jq -se '[.[] | select(.method == "PUT")][0].payload | + has("current_user_can_bypass") | not' "${api_log}" >/dev/null + if [[ ${gate_update_id} == 7 && ${filter} == *do_not_enforce_on_create* ]]; then + jq -se '[.[] | select(.method == "PUT")][0].payload.rules[0].parameters | + has("do_not_enforce_on_create") and .do_not_enforce_on_create == false' \ + "${api_log}" >/dev/null + fi fi done for filter in '.archived = true' '.id = 999' '.full_name = "foreign/content-toolkit"' unreadable; do