diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..5cf295a --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,29 @@ +name: Governance drift audit + +on: + push: + branches: + - main + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: governance-drift-audit + cancel-in-progress: false + +jobs: + audit: + name: Governance drift audit + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Compare desired state with GitHub + env: + GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} + run: bin/audit diff --git a/.github/workflows/publish-planning.yml b/.github/workflows/publish-planning.yml index 94c9035..32ae3d6 100644 --- a/.github/workflows/publish-planning.yml +++ b/.github/workflows/publish-planning.yml @@ -42,3 +42,8 @@ jobs: env: GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} run: bin/publish-repository-properties --apply + + - name: Verify applied governance + env: + GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} + run: bin/audit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1e25c40..237c526 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,3 +21,8 @@ jobs: env: GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} run: bin/publish --apply + + - name: Verify applied governance + env: + GH_TOKEN: ${{ secrets.ATRINIK_SETTINGS_TOKEN }} + run: bin/audit diff --git a/AGENTS.md b/AGENTS.md index cc874a8..8b88e5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,15 @@ - 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. +- `bin/audit` is the read-only live drift check for the complete active + repository inventory. Run it after governance changes and on its scheduled + workflow; it must fail closed on an unregistered active repository, merge + policy drift, custom-property drift, missing inherited rulesets, or exposed + security-baseline drift. +- New repositories must be registered in the desired-state inventories before + they are treated as governed. The publishers fail closed when an active + repository is absent from those inventories; absent archived repositories + remain historical evidence and are skipped by the property publisher. - Keep default-branch deletion and non-fast-forward rules non-bypassable during normal operation. Isolate the organization-owner security-advisory exception to the explicitly authorized window, and use pull-request-only bypass mode diff --git a/README.md b/README.md index 9c59241..49b9819 100644 --- a/README.md +++ b/README.md @@ -612,6 +612,21 @@ and assigns the complete desired value set to every repository. It runs after the generated `.github` repository exists so the inventory and live repository set agree. +`bin/audit` is the read-only live drift check. It compares every active +repository with the merge defaults, custom-property inventory, inherited +organization rulesets, and exposed security baseline. It also fails when an +active repository is missing from the desired-state inventory or when a +governed active repository is absent or archived. The `Governance drift audit` +workflow runs it on the default branch and on a schedule; repository creation +must therefore be followed by a desired-state change before the new repository +can pass the audit. + +When creating a repository, register it in `config/repositories.json` and +`config/repository-properties.json` in the same pull request. Add required +status checks only after the repository emits the named workflow jobs. Review +`bin/publish` and `bin/publish-repository-properties` in plan mode, apply them +with the organization settings credential, and finish with `bin/audit`. + The manual `Publish planning` workflow performs those four apply steps in the same order. Both planning workflows use `ATRINIK_SETTINGS_TOKEN`; in addition to the existing organization and repository administration access, that token diff --git a/bin/audit b/bin/audit new file mode 100755 index 0000000..ebcb69b --- /dev/null +++ b/bin/audit @@ -0,0 +1,260 @@ +#!/usr/bin/env bash + +set -euo pipefail + +organization=${ATRINIK_ORGANIZATION:-atrinik} +api_version=2026-03-10 + +for command in gh grep jq sort; do + if ! command -v "${command}" >/dev/null 2>&1; then + echo "error: ${command} is required" >&2 + exit 1 + fi +done + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +repositories_config=${root}/config/repositories.json +repository_defaults=${root}/config/repository-defaults.json +repository_properties=${root}/config/repository-properties.json +advisory_windows=${root}/config/advisory-merge-windows.json +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT + +"${root}/bin/validate" >/dev/null + +github_api() { + gh api -H "X-GitHub-Api-Version: ${api_version}" "$@" +} + +declare -a findings=() + +record_finding() { + findings+=("$1") +} + +is_temporary_security_advisory_repository() { + local repository=$1 + local private=$2 + local visibility=$3 + local disabled=$4 + local fork=$5 + local has_issues=$6 + local has_projects=$7 + local has_wiki=$8 + local base_repository + + if [[ ${repository} =~ ^(.+)-ghsa-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$ ]]; then + base_repository=${BASH_REMATCH[1]} + else + return 1 + fi + + [[ ${private} == true ]] && + [[ ${visibility} == private ]] && + [[ ${disabled} == false ]] && + [[ ${fork} == false ]] && + [[ ${has_issues} == false ]] && + [[ ${has_projects} == false ]] && + [[ ${has_wiki} == false ]] && + jq -e --arg repository "${base_repository}" \ + '(.pull_request_gate | index($repository)) != null' \ + "${repositories_config}" >/dev/null +} + +declare -A live_archived=() +while IFS=$'\t' read -r \ + repository archived private visibility disabled fork has_issues has_projects has_wiki; do + [[ -n ${repository} ]] || continue + if is_temporary_security_advisory_repository \ + "${repository}" "${private}" "${visibility}" "${disabled}" "${fork}" \ + "${has_issues}" "${has_projects}" "${has_wiki}"; then + continue + fi + live_archived["${repository}"]=${archived} +done < <( + github_api --paginate "orgs/${organization}/repos?per_page=100&type=all" \ + --jq ' + .[] | + [ + .name, + (.archived | tostring), + (.private | tostring), + .visibility, + (.disabled | tostring), + (.fork | tostring), + (.has_issues | tostring), + (.has_projects | tostring), + (.has_wiki | tostring) + ] | + @tsv + ' +) + +declare -A expected_active=() +declare -A expected_archived=() +while IFS=$'\t' read -r repository lifecycle; do + if [[ ${lifecycle} == archived ]]; then + expected_archived["${repository}"]=true + else + expected_active["${repository}"]=true + fi +done < <( + jq -r '.repositories | to_entries[] | [.key, .value.lifecycle] | @tsv' \ + "${repository_properties}" +) + +desired_property_schema=$(jq -c ' + .definitions | + map({property_name, value_type, required, default_value, description, allowed_values}) | + sort_by(.property_name) +' "${repository_properties}") +if current_property_schema=$(github_api \ + "orgs/${organization}/properties/schema" | \ + jq -c 'map({property_name, value_type, required, default_value, description, allowed_values}) | sort_by(.property_name)'); then + if [[ ${current_property_schema} != "${desired_property_schema}" ]]; then + record_finding "organization custom-property schema drift: ${organization}" + fi +else + record_finding "organization custom-property schema could not be read: ${organization}" +fi + +for repository in "${!live_archived[@]}"; do + if [[ ${live_archived["${repository}"]} == false ]] && + [[ ! -v expected_active["${repository}"] ]]; then + record_finding "active repository is not in the desired-state inventory: ${organization}/${repository}" + fi +done + +for repository in "${!expected_active[@]}"; do + if [[ ! -v live_archived["${repository}"] ]]; then + record_finding "governed active repository is missing: ${organization}/${repository}" + elif [[ ${live_archived["${repository}"]} != false ]]; then + record_finding "governed active repository is archived: ${organization}/${repository}" + fi +done + +for repository in "${!expected_archived[@]}"; do + if [[ -v live_archived["${repository}"] ]] && + [[ ${live_archived["${repository}"]} != true ]]; then + record_finding "archived inventory repository is not archived: ${organization}/${repository}" + fi +done + +expected_rule_name() { + local repository=$1 + local base=$2 + + if jq -e --arg repository "${repository}" \ + '.repositories | index($repository) != null' "${advisory_windows}" \ + >/dev/null; then + printf '%s - classic\n' "${base}" + else + printf '%s\n' "${base}" + fi +} + +has_configured_repository() { + local property=$1 + local repository=$2 + + jq -e --arg repository "${repository}" \ + "${property} | index(\$repository) != null" "${repositories_config}" \ + >/dev/null +} + +has_required_ci() { + local repository=$1 + jq -e --arg repository "${repository}" \ + '.required_ci | has($repository)' "${repositories_config}" >/dev/null +} + +has_release_tags() { + local repository=$1 + jq -e --arg repository "${repository}" \ + '.release_tags | index($repository) != null' "${repositories_config}" \ + >/dev/null +} + +for repository in "${!expected_active[@]}"; do + [[ -v live_archived["${repository}"] ]] || continue + [[ ${live_archived["${repository}"]} == false ]] || continue + + metadata_file=${temporary}/${repository}.json + if ! github_api "repos/${organization}/${repository}" >"${metadata_file}"; then + record_finding "repository metadata could not be read: ${organization}/${repository}" + continue + fi + + if ! jq -e --slurpfile desired "${repository_defaults}" ' + .archived == false and + .default_branch == $desired[0].default_branch and + .allow_merge_commit == $desired[0].allow_merge_commit and + .allow_rebase_merge == $desired[0].allow_rebase_merge and + .allow_squash_merge == $desired[0].allow_squash_merge and + .delete_branch_on_merge == $desired[0].delete_branch_on_merge and + .squash_merge_commit_title == $desired[0].squash_merge_commit_title and + .squash_merge_commit_message == $desired[0].squash_merge_commit_message + ' "${metadata_file}" >/dev/null; then + record_finding "repository merge policy drift: ${organization}/${repository}" + fi + + desired_properties=$(jq -c --arg repository "${repository}" ' + .repositories[$repository] | + to_entries | + map({property_name: .key, value: .value}) | + sort_by(.property_name) + ' "${repository_properties}") + if current_properties=$(github_api \ + "repos/${organization}/${repository}/properties/values" | \ + jq -c 'map({property_name, value}) | sort_by(.property_name)'); then + if [[ ${current_properties} != "${desired_properties}" ]]; then + record_finding "repository custom-property drift: ${organization}/${repository}" + fi + else + record_finding "repository custom properties could not be read: ${organization}/${repository}" + fi + + if rulesets=$(github_api \ + "repos/${organization}/${repository}/rulesets?includes_parents=true" | \ + jq -r '.[].name' | sort); then + required_rulesets=( + "$(expected_rule_name "${repository}" "01 - Default branch integrity")" + "$(expected_rule_name "${repository}" "01 - Default branch linear history")" + ) + if has_configured_repository '.pull_request_gate' "${repository}"; then + required_rulesets+=( + "$(expected_rule_name "${repository}" "02 - Changes through pull requests")" + ) + fi + if has_required_ci "${repository}"; then + required_rulesets+=("03 - Required CI - ${repository}") + fi + if has_release_tags "${repository}"; then + required_rulesets+=("04 - Immutable release tags") + fi + for ruleset in "${required_rulesets[@]}"; do + if ! grep -Fxq -- "${ruleset}" <<<"${rulesets}"; then + record_finding "missing inherited ruleset '${ruleset}': ${organization}/${repository}" + fi + done + else + record_finding "inherited rulesets could not be read: ${organization}/${repository}" + fi + + if ! jq -e ' + .security_and_analysis.secret_scanning.status == "enabled" and + .security_and_analysis.secret_scanning_push_protection.status == "enabled" and + .security_and_analysis.secret_scanning_validity_checks.status == "enabled" and + .security_and_analysis.dependabot_security_updates.status == "enabled" + ' "${metadata_file}" >/dev/null; then + record_finding "exposed security baseline drift: ${organization}/${repository}" + fi +done + +if ((${#findings[@]})); then + printf 'DRIFT %s\n' "${findings[@]}" | sort -u + echo "Governance audit failed: ${#findings[@]} finding(s)." >&2 + exit 1 +fi + +echo "Governance audit passed: ${#expected_active[@]} active repositories checked." diff --git a/bin/publish b/bin/publish index e8856c0..a08b010 100755 --- a/bin/publish +++ b/bin/publish @@ -37,7 +37,7 @@ auto | organization | repository) ;; ;; esac -for command in gh git jq; do +for command in gh git jq sort; do if ! command -v "${command}" >/dev/null 2>&1; then echo "error: ${command} is required" >&2 exit 1 @@ -1291,6 +1291,20 @@ done < <( ' ) +declare -A governed_active_repositories=() +while IFS= read -r repository; do + governed_active_repositories["${repository}"]=true +done < <( + jq -r '[".github"] + .pull_request_gate | .[]' "${repositories_config}" +) + +while IFS= read -r repository; do + if [[ ! -v governed_active_repositories["${repository}"] ]]; then + echo "error: live active repository is not in the governance inventory: ${organization}/${repository}" >&2 + exit 1 + fi +done < <(printf '%s\n' "${active_repositories[@]}" | sort) + while IFS= read -r repository; do if [[ ! -v active_repository_ids["${repository}"] ]]; then echo "error: advanced CodeQL inventory repository is not active: ${repository}" >&2 diff --git a/bin/publish-repository-properties b/bin/publish-repository-properties index e4ee474..1655a9a 100755 --- a/bin/publish-repository-properties +++ b/bin/publish-repository-properties @@ -15,7 +15,7 @@ case ${1:-} in ;; esac -for command in gh jq; do +for command in gh jq sort; do if ! command -v "${command}" >/dev/null 2>&1; then echo "error: ${command} is required" >&2 exit 1 @@ -33,6 +33,35 @@ github_api() { gh api -H "X-GitHub-Api-Version: ${api_version}" "$@" } +is_temporary_security_advisory_repository() { + local repository=$1 + local private=$2 + local visibility=$3 + local disabled=$4 + local fork=$5 + local has_issues=$6 + local has_projects=$7 + local has_wiki=$8 + local base_repository + + if [[ ${repository} =~ ^(.+)-ghsa-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$ ]]; then + base_repository=${BASH_REMATCH[1]} + else + return 1 + fi + + [[ ${private} == true ]] && + [[ ${visibility} == private ]] && + [[ ${disabled} == false ]] && + [[ ${fork} == false ]] && + [[ ${has_issues} == false ]] && + [[ ${has_projects} == false ]] && + [[ ${has_wiki} == false ]] && + jq -e --arg repository "${base_repository}" \ + '(.pull_request_gate | index($repository)) != null' \ + "${root}/config/repositories.json" >/dev/null +} + run_api() { local method=$1 local endpoint=$2 @@ -85,6 +114,7 @@ while IFS= read -r definition; do done < <(jq -c '.definitions[]' "${properties_config}") declare -A live_repositories=() +declare -A live_active_repositories=() while IFS= read -r repository; do live_repositories["${repository}"]=true done < <( @@ -92,8 +122,60 @@ done < <( --jq '.[].name' ) +while IFS=$'\t' read -r \ + repository archived private visibility disabled fork has_issues has_projects has_wiki; do + if [[ ${archived} == false ]] && + ! is_temporary_security_advisory_repository \ + "${repository}" "${private}" "${visibility}" "${disabled}" "${fork}" \ + "${has_issues}" "${has_projects}" "${has_wiki}"; then + live_active_repositories["${repository}"]=true + fi +done < <( + github_api --paginate "orgs/${organization}/repos?per_page=100&type=all" \ + --jq ' + .[] | + [ + .name, + (.archived | tostring), + (.private | tostring), + .visibility, + (.disabled | tostring), + (.fork | tostring), + (.has_issues | tostring), + (.has_projects | tostring), + (.has_wiki | tostring) + ] | + @tsv + ' +) + +while IFS= read -r repository; do + if [[ ! -v live_active_repositories["${repository}"] ]]; then + echo "error: governed active repository is missing or archived: ${organization}/${repository}" >&2 + exit 1 + fi +done < <( + jq -r '.repositories | to_entries[] | select(.value.lifecycle != "archived") | .key' \ + "${properties_config}" +) + +while IFS= read -r repository; do + if [[ -v live_active_repositories["${repository}"] ]] && + ! jq -e --arg repository "${repository}" \ + '.repositories | has($repository)' "${properties_config}" >/dev/null; then + echo "error: live active repository is not in the property inventory: ${organization}/${repository}" >&2 + exit 1 + fi +done < <(printf '%s\n' "${!live_active_repositories[@]}" | sort) + while IFS= read -r repository; do if [[ ! -v live_repositories["${repository}"] ]]; then + lifecycle=$(jq -r --arg repository "${repository}" \ + '.repositories[$repository].lifecycle' "${properties_config}") + if [[ ${lifecycle} == archived ]]; then + echo "SKIP ${organization}/${repository} is an absent archived repository" + continue + fi echo "error: repository property inventory is not live: ${organization}/${repository}" >&2 exit 1 fi diff --git a/config/repositories.json b/config/repositories.json index a4e1071..1613d1b 100644 --- a/config/repositories.json +++ b/config/repositories.json @@ -18,6 +18,7 @@ "server", "sound", "tools", + "web-platform", "website" ], "required_ci": { diff --git a/config/repository-properties.json b/config/repository-properties.json index 0a8d518..7d45cd3 100644 --- a/config/repository-properties.json +++ b/config/repository-properties.json @@ -341,6 +341,16 @@ ], "lifecycle": "active", "release_policy": "semantic-release" + }, + "web-platform": { + "component_role": [ + "library" + ], + "provider_set": [ + "shared" + ], + "lifecycle": "seed", + "release_policy": "none" } } } diff --git a/tests/audit.sh b/tests/audit.sh new file mode 100755 index 0000000..c124b4d --- /dev/null +++ b/tests/audit.sh @@ -0,0 +1,232 @@ +#!/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 ]] || exit 1 +shift +jq_filter= +endpoint= +while (($#)); do + case $1 in + -H) + shift 2 + ;; + --paginate) + shift + ;; + --jq) + jq_filter=$2 + shift 2 + ;; + *) + endpoint=$1 + shift + ;; + esac +done + +emit() { + local payload=$1 + if [[ -n ${jq_filter} ]]; then + jq -r "${jq_filter}" <<<"${payload}" + else + printf '%s\n' "${payload}" + fi +} + +config_root=${AUDIT_CONFIG_ROOT:?} +scenario=${GH_TEST_SCENARIO:-pass} + +case ${endpoint} in +orgs/atrinik/properties/schema) + if [[ ${scenario} == schema-drift ]]; then + jq -n --slurpfile config \ + "${config_root}/config/repository-properties.json" \ + '$config[0].definitions | map(. + {description: "drift"})' + else + jq -n --slurpfile config \ + "${config_root}/config/repository-properties.json" \ + '$config[0].definitions' + fi + ;; +orgs/atrinik/repos\?*) + payload=$(jq -n --slurpfile config "${config_root}/config/repository-properties.json" ' + [ + $config[0].repositories | + to_entries[] | + { + name: .key, + archived: (.value.lifecycle == "archived"), + private: false, + visibility: "public", + disabled: false, + fork: false, + has_issues: true, + has_projects: true, + has_wiki: true + } + ] | + if $ENV.GH_TEST_SCENARIO == "unmanaged" then + . + [{ + name: "unmanaged", + archived: false, + private: false, + visibility: "public", + disabled: false, + fork: false, + has_issues: true, + has_projects: true, + has_wiki: true + }] + elif $ENV.GH_TEST_SCENARIO == "temporary" then + . + [{ + name: "classic-ghsa-8533-3vg8-r287", + archived: false, + private: true, + visibility: "private", + disabled: false, + fork: false, + has_issues: false, + has_projects: false, + has_wiki: false + }] + else + . + end + ') + emit "${payload}" + ;; +repos/atrinik/*) + path=${endpoint#repos/atrinik/} + case ${path} in + */properties/values) + repository=${path%/properties/values} + if [[ ${scenario} == properties-drift && ${repository} == observatory ]]; then + jq -n ' + [ + {property_name: "component_role", value: ["unclassified"]}, + {property_name: "lifecycle", value: "seed"}, + {property_name: "provider_set", value: ["unclassified"]}, + {property_name: "release_policy", value: "none"} + ] + ' + else + jq -n --slurpfile config "${config_root}/config/repository-properties.json" \ + --arg repository "${repository}" ' + $config[0].repositories[$repository] | + to_entries | + map({property_name: .key, value: .value}) + ' + fi + ;; + */rulesets\?includes_parents=true) + repository=${path%/rulesets?includes_parents=true} + jq -n --slurpfile repositories "${config_root}/config/repositories.json" \ + --arg repository "${repository}" ' + [ + "01 - Default branch integrity", + "01 - Default branch linear history" + ] + + (if ($repositories[0].pull_request_gate | index($repository)) != null then + ["02 - Changes through pull requests"] + else [] end) + + (if ($repositories[0].required_ci | has($repository)) then + ["03 - Required CI - " + $repository] + else [] end) + + (if ($repositories[0].release_tags | index($repository)) != null then + ["04 - Immutable release tags"] + else [] end) | + map({name: .}) + ' + ;; + *) + repository=${path} + allow_merge_commit=false + if [[ ${scenario} == merge-drift && ${repository} == observatory ]]; then + allow_merge_commit=true + fi + jq -n --slurpfile defaults "${config_root}/config/repository-defaults.json" \ + --arg repository "${repository}" --argjson allow_merge_commit ${allow_merge_commit} ' + { + id: 1, + name: $repository, + archived: false, + default_branch: $defaults[0].default_branch, + allow_merge_commit: $allow_merge_commit, + allow_rebase_merge: $defaults[0].allow_rebase_merge, + allow_squash_merge: $defaults[0].allow_squash_merge, + delete_branch_on_merge: $defaults[0].delete_branch_on_merge, + squash_merge_commit_title: $defaults[0].squash_merge_commit_title, + squash_merge_commit_message: $defaults[0].squash_merge_commit_message, + security_and_analysis: { + secret_scanning: {status: "enabled"}, + secret_scanning_push_protection: {status: "enabled"}, + secret_scanning_validity_checks: {status: "enabled"}, + dependabot_security_updates: {status: "enabled"} + } + } + ' + ;; + esac + ;; +*) + echo "unexpected endpoint: ${endpoint}" >&2 + exit 1 + ;; +esac +EOF +chmod +x "${temporary}/bin/gh" + +run_audit() { + local scenario=$1 + GH_TEST_SCENARIO=${scenario} \ + PATH="${temporary}/bin:${PATH}" \ + AUDIT_CONFIG_ROOT="${root}" \ + "${root}/bin/audit" +} + +output=$(run_audit pass) +grep -Fxq 'Governance audit passed: 21 active repositories checked.' <<<"${output}" + +temporary_output=$(run_audit temporary) +grep -Fxq 'Governance audit passed: 21 active repositories checked.' <<<"${temporary_output}" + +if run_audit unmanaged >"${temporary}/unmanaged.out" 2>"${temporary}/unmanaged.err"; then + echo "expected unmanaged repository audit to fail" >&2 + exit 1 +fi +grep -Fq 'active repository is not in the desired-state inventory: atrinik/unmanaged' \ + "${temporary}/unmanaged.out" + +if run_audit merge-drift >"${temporary}/merge.out" 2>"${temporary}/merge.err"; then + echo "expected merge drift audit to fail" >&2 + exit 1 +fi +grep -Fq 'repository merge policy drift: atrinik/observatory' \ + "${temporary}/merge.out" + +if run_audit properties-drift >"${temporary}/properties.out" 2>"${temporary}/properties.err"; then + echo "expected property drift audit to fail" >&2 + exit 1 +fi +grep -Fq 'repository custom-property drift: atrinik/observatory' \ + "${temporary}/properties.out" + +if run_audit schema-drift >"${temporary}/schema.out" 2>"${temporary}/schema.err"; then + echo "expected property schema drift audit to fail" >&2 + exit 1 +fi +grep -Fq 'organization custom-property schema drift: atrinik' \ + "${temporary}/schema.out" + +echo "Governance drift audit tests passed." diff --git a/tests/publish-repository-properties.sh b/tests/publish-repository-properties.sh index a107051..3d6d890 100755 --- a/tests/publish-repository-properties.sh +++ b/tests/publish-repository-properties.sh @@ -16,6 +16,7 @@ set -euo pipefail shift endpoint= jq_filter= +scenario=${GH_TEST_SCENARIO:-pass} while (($#)); do case $1 in -H) @@ -40,8 +41,51 @@ orgs/atrinik/properties/schema) printf '[]\n' ;; orgs/atrinik/repos?*) - [[ ${jq_filter} == '.[].name' ]] || exit 1 - jq -r '.repositories | keys[]' "${PROPERTIES_CONFIG:?}" + case ${jq_filter} in + '.[].name') + if [[ ${scenario} == extra-active ]]; then + jq -r '.repositories | keys[], "unmanaged"' "${PROPERTIES_CONFIG:?}" + elif [[ ${scenario} == missing-archived ]]; then + jq -r '.repositories | keys[] | select(. != "legacy-client")' \ + "${PROPERTIES_CONFIG:?}" + else + jq -r '.repositories | keys[]' "${PROPERTIES_CONFIG:?}" + fi + ;; + *'@tsv'*) + if [[ ${scenario} == extra-active ]]; then + jq -r '.repositories | to_entries[] | + [.key, (.value.lifecycle == "archived" | tostring), + "false", "public", "false", "false", "true", "true", "true"] | + @tsv' \ + "${PROPERTIES_CONFIG:?}" + printf 'unmanaged\tfalse\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' + elif [[ ${scenario} == missing-archived ]]; then + jq -r '.repositories | to_entries[] | + select(.key != "legacy-client") | + [.key, (.value.lifecycle == "archived" | tostring), + "false", "public", "false", "false", "true", "true", "true"] | + @tsv' \ + "${PROPERTIES_CONFIG:?}" + elif [[ ${scenario} == temporary ]]; then + jq -r '.repositories | to_entries[] | + [.key, (.value.lifecycle == "archived" | tostring), + "false", "public", "false", "false", "true", "true", "true"] | + @tsv' \ + "${PROPERTIES_CONFIG:?}" + printf 'classic-ghsa-8533-3vg8-r287\tfalse\ttrue\tprivate\tfalse\tfalse\tfalse\tfalse\tfalse\n' + else + jq -r '.repositories | to_entries[] | + [.key, (.value.lifecycle == "archived" | tostring), + "false", "public", "false", "false", "true", "true", "true"] | + @tsv' \ + "${PROPERTIES_CONFIG:?}" + fi + ;; + *) + exit 1 + ;; + esac ;; repos/atrinik/*/properties/values) printf '[]\n' @@ -61,6 +105,38 @@ output=$( ) [[ $(grep -c '^PLAN PUT /orgs/atrinik/properties/schema/' <<<"${output}") == 4 ]] -[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${output}") == 26 ]] +expected_repositories=$(jq '.repositories | length' \ + "${root}/config/repository-properties.json") +[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${output}") == "${expected_repositories}" ]] + +temporary_output=$( + GH_TEST_SCENARIO=temporary \ + PATH="${temporary}/bin:${PATH}" \ + PROPERTIES_CONFIG="${root}/config/repository-properties.json" \ + "${root}/bin/publish-repository-properties" +) +[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${temporary_output}") == "${expected_repositories}" ]] + +if GH_TEST_SCENARIO=extra-active \ + PATH="${temporary}/bin:${PATH}" \ + PROPERTIES_CONFIG="${root}/config/repository-properties.json" \ + "${root}/bin/publish-repository-properties" \ + >"${temporary}/extra.out" 2>"${temporary}/extra.err"; then + echo "expected extra active repository to fail closed" >&2 + exit 1 +fi +grep -Fq \ + 'live active repository is not in the property inventory: atrinik/unmanaged' \ + "${temporary}/extra.err" + +missing_output=$( + GH_TEST_SCENARIO=missing-archived \ + PATH="${temporary}/bin:${PATH}" \ + PROPERTIES_CONFIG="${root}/config/repository-properties.json" \ + "${root}/bin/publish-repository-properties" +) +grep -Fq 'SKIP atrinik/legacy-client is an absent archived repository' \ + <<<"${missing_output}" +[[ $(grep -c '^PLAN PATCH /orgs/atrinik/properties/values ' <<<"${missing_output}") == $((expected_repositories - 1)) ]] echo "Repository-property publisher plans every definition and repository value."