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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 1 addition & 25 deletions bin/audit
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 66 additions & 6 deletions bin/publish
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions lib/ruleset-policy.jq
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion tests/audit-fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down
4 changes: 2 additions & 2 deletions tests/audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion tests/publish-maintenance-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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|")
Expand Down Expand Up @@ -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'
;;
Expand Down
Loading
Loading