From 5fceddf4877777178ab6727773bc17dcdf008372 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 22:19:14 +0000 Subject: [PATCH 1/2] feat(profile): govern organization identity and pins --- AGENTS.md | 6 +++ CONTRIBUTING.md | 9 +++++ README.md | 59 +++++++++++++++++++++++++-- bin/publish | 37 +++++++++++++++-- bin/validate | 59 +++++++++++++++++++++++++++ bin/verify-manual-settings | 44 +++++++++++++++++++- community-health/README.md | 7 ++-- community-health/profile/README.md | 34 ++++++++++++++++ config/community-health.json | 4 ++ config/manual-settings.json | 32 ++++++++++++++- config/organization.json | 2 + tests/publish-community-health.sh | 5 ++- tests/publish-maintenance-branch.sh | 63 +++++++++++++++++++++++++---- tests/validate-community-health.sh | 33 +++++++++++++++ tests/verify-manual-settings.sh | 54 ++++++++++++++++++++++++- 15 files changed, 428 insertions(+), 20 deletions(-) create mode 100644 community-health/profile/README.md diff --git a/AGENTS.md b/AGENTS.md index 23ed6b9..181884d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,12 @@ deployment target. Never hand-edit that repository, add independent release automation to it, or add its issue forms' `projects:` key: public contributors may not have the Project write permission that key requires. +- Keep the game-first organization description and canonical website in + `config/organization.json`, and publish them only on real drift. Keep the + generated public profile in `community-health/profile/README.md`. Record the + six ordered public pins with stable repository IDs in + `config/manual-settings.json`, verify them read-only through GraphQL, and use + the documented organization-owner UI step instead of an undocumented writer. - 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93e6d38..b085cee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,15 @@ material. Changes require credential-schema negative tests, the read-only `bin/verify-manual-settings` plan, and the documented manual and scheduled post-rotation checks. +Organization identity changes must preserve the exact public description and +canonical website in `config/organization.json`, remain delta-aware in plan and +apply modes, and include unchanged-state coverage. Keep the public profile in +`community-health/profile/README.md`; never edit `atrinik/.github` directly. +Public pins are stable-ID manual state in `config/manual-settings.json`. Verify +their exact count, public/active identity, and order read-only through GraphQL, +and document the organization-owner profile-settings step rather than using an +undocumented mutation interface. + Synchronization authentication tests must distinguish readable from writable credentials, preserve exact CLI failure statuses, reject missing advertised classic-PAT scopes and write capabilities before mutation, and fail closed on diff --git a/README.md b/README.md index 26862ea..afbf2f1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ organization issue types and fields, the public **Atrinik work** Project and shared views, scheduled item synchronization, repository custom properties, and the generated organization community-health repository. +The organization description and canonical website are also governed here. +The public profile README is generated from `community-health/profile/README.md`, +while the six public organization pins remain explicit, read-only-verified +manual state because GitHub exposes no supported pin mutation API. + Atrinik uses GitHub Team. The publisher detects the organization plan and uses organization-level rulesets on Team or Enterprise. On GitHub Free it installs equivalent repository-level rulesets instead. Archived repositories are @@ -100,9 +105,18 @@ read-only and are skipped on later runs. release policy. - `community-health/` is the source for organization-wide issue forms, pull-request guidance, contribution guidance, conduct policy, and security - reporting. `bin/publish-community-health` generates `atrinik/.github` - directly from this released source; the generated repository is not edited - or released independently. + reporting, including the public organization profile. + `bin/publish-community-health` generates `atrinik/.github` directly from this + released source; the generated repository is not edited or released + independently. +- `config/organization.json` owns the game-first organization description and + preserves `https://atrinik.org` as the canonical website. The publisher + compares every owned field and patches only when live state differs. +- `config/manual-settings.json` owns the exact public repository pin order: + `classic`, `atrinik`, `website`, `content`, `protocol`, and `playtester`, with + stable repository IDs. `bin/verify-manual-settings` reads the ordered pins + through GraphQL and fails closed on count, identity, visibility, archival, or + order drift; it never mutates them. The GitHub REST API does not expose every organization control. The desired values are recorded in `config/manual-settings.json` and must be confirmed in @@ -287,6 +301,45 @@ repository if needed and converges every file listed in `config/community-health.json`. Local community-health files in a component repository continue to take precedence over these defaults. +### Organization identity and public pins + +Review and deploy organization identity only after the governing pull request +is merged. First inspect both complete plans: + +```sh +bin/publish +bin/publish-community-health +``` + +With separate live-mutation authorization, `bin/publish --apply` converges the +description while preserving the canonical website and the other owned +organization defaults. `bin/publish-community-health --apply` publishes +`community-health/profile/README.md` as `.github/profile/README.md`; never edit +the generated repository directly. + +GitHub does not provide a supported public API for organization pins. An +organization owner must open +, select exactly six +repositories, and arrange them in this order: + +1. `classic` +2. `atrinik` +3. `website` +4. `content` +5. `protocol` +6. `playtester` + +After saving, verify the exact live order and stable identities without +mutation: + +```sh +bin/verify-manual-settings +``` + +Do not use browser automation or an undocumented endpoint to apply pins. A +change is complete only after the public organization view renders the profile +README and links correctly and the verifier reports the governed pin order. + `bin/publish-repository-properties` creates the organization property schema and assigns the complete desired value set to every repository. It runs after the generated `.github` repository exists so the inventory and live repository diff --git a/bin/publish b/bin/publish index cddc370..692f2fd 100755 --- a/bin/publish +++ b/bin/publish @@ -32,6 +32,7 @@ for command in gh git jq; do done root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +organization_config=${root}/config/organization.json 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 @@ -63,6 +64,30 @@ run_api() { fi } +converge_organization_settings() { + local current=$1 + local desired + + desired=$(jq -c . "${organization_config}") + if jq -e --argjson desired "${desired}" ' + . as $current | + all($desired | to_entries[]; $current[.key] == .value) + ' <<<"${current}" >/dev/null; then + echo "KEEP /orgs/${organization} metadata and repository defaults" + return + fi + + jq -r \ + --arg endpoint "/orgs/${organization}" \ + --argjson desired "${desired}" ' + . as $current | + $desired | to_entries[] | + select($current[.key] != .value) | + "DRIFT \($endpoint) \(.key) live=\($current[.key] | tojson) desired=\(.value | tojson)" + ' <<<"${current}" + run_api PATCH "orgs/${organization}" "${organization_config}" +} + repository_uses_advanced_codeql() { local repository=$1 @@ -1015,7 +1040,13 @@ converge_immutable_release_policy() { return 1 } -organization_plan=$(github_api "orgs/${organization}" --jq '.plan.name') +organization_metadata=$(github_api "orgs/${organization}") +if ! jq -e 'type == "object" and (.plan.name | type == "string")' \ + <<<"${organization_metadata}" >/dev/null; then + echo "error: GitHub returned invalid organization metadata" >&2 + exit 1 +fi +organization_plan=$(jq -r '.plan.name' <<<"${organization_metadata}") if [[ ${policy_scope} == auto ]]; then case ${organization_plan} in team | enterprise) policy_scope=organization ;; @@ -1089,7 +1120,7 @@ done < <( converge_immutable_release_policy -run_api PATCH "orgs/${organization}" "${root}/config/organization.json" +converge_organization_settings "${organization_metadata}" run_api PUT "orgs/${organization}/actions/permissions" \ "${root}/config/actions-permissions.json" run_api PUT "orgs/${organization}/actions/permissions/selected-actions" \ @@ -1129,7 +1160,7 @@ while IFS= read -r repository; do run_api PATCH "repos/${organization}/${repository}" "${archive_payload}" done < <(jq -r '.archive[]' "${repositories_config}") -if [[ $(github_api "orgs/${organization}" --jq '.members_can_create_teams') != false ]]; then +if [[ $(jq -r '.members_can_create_teams' <<<"${organization_metadata}") != false ]]; then echo "MANUAL members can still create teams; disable this in Member privileges." fi echo "Manual confirmation required for config/manual-settings.json." diff --git a/bin/validate b/bin/validate index a34ebf8..98b9a45 100755 --- a/bin/validate +++ b/bin/validate @@ -3,6 +3,7 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +organization_config=${root}/config/organization.json 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 @@ -38,6 +39,17 @@ while IFS= read -r -d '' config; do jq empty "${config}" >/dev/null done < <(find "${root}/config" -name '*.json' -print0 | sort -z) +jq -e ' + . == { + description: "A cooperative fantasy world of islands, ruins, and crystal-lit mysteries—open source, playable in Classic, and rebuilding for the future.", + blog: "https://atrinik.org", + default_repository_permission: "none", + members_can_create_repositories: false, + members_can_create_public_repositories: false, + members_can_create_private_repositories: false + } +' "${organization_config}" >/dev/null + jq -e ' .bypass_actors == [] and [.rules[].type] == ["deletion", "non_fast_forward"] @@ -221,6 +233,7 @@ jq -e ' "members_can_create_teams", "members_can_delete_or_transfer_repositories", "members_can_request_github_apps", + "organization_pins", "reason", "repository_admins_can_install_github_apps", "require_secure_two_factor_methods", @@ -228,6 +241,30 @@ jq -e ' ]) ' "${manual_settings_config}" >/dev/null +jq -e ' + .organization_pins as $pins | + ($pins | keys == ["apply_path", "repositories", "verification"]) and + $pins.apply_path == + "https://github.com/organizations/atrinik/settings/profile" and + $pins.verification == "read_only_graphql" and + ($pins.repositories | type == "array" and length == 6) and + [$pins.repositories[].name] == [ + "classic", + "atrinik", + "website", + "content", + "protocol", + "playtester" + ] and + ([ $pins.repositories[].repository_id ] | length == (unique | length)) and + all( + $pins.repositories[]; + (keys == ["name", "repository_id"]) and + (.name | type == "string" and length > 0) and + (.repository_id | type == "number" and floor == . and . > 0) + ) +' "${manual_settings_config}" >/dev/null + jq -e \ --arg today "${validation_today}" \ --slurpfile repositories "${repositories_config}" ' @@ -720,6 +757,10 @@ jq -e ' has_wiki: false }) and (.files | type == "array" and length > 0) and + any( + .files[]; + . == {source: "profile/README.md", target: "profile/README.md"} + ) and ([.files[].source] | length == (unique | length)) and ([.files[].target] | length == (unique | length)) and all( @@ -739,6 +780,24 @@ while IFS= read -r community_file; do fi done < <(jq -r '.files[].source' "${community_health_config}") +profile_readme=${root}/community-health/profile/README.md +for required_text in \ + '# Atrinik' \ + 'Atrinik is an open-source cooperative fantasy role-playing world' \ + 'GPL-licensed [Atrinik Classic](https://github.com/atrinik/classic)' \ + 'MIT-licensed Go, Rust,' \ + 'They do not yet form a complete game release.' \ + 'Game content and' \ + 'media remain separately licensed' \ + '[Play Atrinik Classic](https://atrinik.org/downloads/)' \ + '[Follow the next-generation roadmap](https://github.com/atrinik/atrinik/issues/168)' \ + 'Direct human contributions are welcome.'; do + if ! grep -Fq "${required_text}" "${profile_readme}"; then + echo "error: organization profile is missing required text: ${required_text}" >&2 + exit 1 + fi +done + validate_issue_form() { local form_name=$1 local inference_key=$2 diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 9263606..734af6c 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -400,4 +400,46 @@ while IFS= read -r environment_record; do echo "KEEP ${repository} environment ${environment} metadata" done < <(jq -c '.github_actions_environments[]' "${manual_settings}") -echo "Manual settings live credential and environment metadata is present." +# GraphQL variables must remain literal for GitHub to substitute. +# shellcheck disable=SC2016 +pins_response=$(github_api \ + "read ${organization} organization pins" \ + graphql \ + -f query='query($login:String!){organization(login:$login){login pinnedItems(first:10,types:REPOSITORY){totalCount nodes{... on Repository{name databaseId nameWithOwner isArchived visibility}}}}}' \ + -F login="${organization}") +if ! jq -e \ + --arg organization "${organization}" ' + .data.organization as $organization_data | + ($organization_data | type == "object") and + $organization_data.login == $organization and + ($organization_data.pinnedItems.totalCount | type == "number") and + ($organization_data.pinnedItems.nodes | type == "array") and + $organization_data.pinnedItems.totalCount == + ($organization_data.pinnedItems.nodes | length) and + all( + $organization_data.pinnedItems.nodes[]; + (.name | type == "string" and length > 0) and + (.databaseId | type == "number" and floor == . and . > 0) and + .nameWithOwner == "\($organization)/\(.name)" and + .isArchived == false and + .visibility == "PUBLIC" + ) + ' <<<"${pins_response}" >/dev/null; then + echo "error: GitHub returned invalid organization pin metadata for ${organization}" >&2 + exit 1 +fi + +desired_pins=$(jq -c '.organization_pins.repositories' "${manual_settings}") +live_pins=$(jq -c ' + [.data.organization.pinnedItems.nodes[] | + {name, repository_id: .databaseId}] +' <<<"${pins_response}") +if [[ ${live_pins} != "${desired_pins}" ]]; then + echo "error: organization pin order or identity drift for ${organization}" >&2 + echo "desired: ${desired_pins}" >&2 + echo "live: ${live_pins}" >&2 + exit 1 +fi +echo "KEEP ${organization} organization pins match the exact governed order" + +echo "Manual settings live credential, environment, and organization pin metadata is present." diff --git a/community-health/README.md b/community-health/README.md index 4706df8..785dd3b 100644 --- a/community-health/README.md +++ b/community-health/README.md @@ -1,8 +1,9 @@ # Atrinik community health defaults -This repository provides Atrinik's organization-wide issue forms, pull-request -template, contribution guidance, and security policy. A repository's local -file takes precedence when it needs more specific instructions. +This repository provides Atrinik's public organization profile, organization-wide +issue forms, pull-request template, contribution guidance, and security policy. +A repository's local file takes precedence when it needs more specific +instructions. The source of truth is [`atrinik/github-settings`](https://github.com/atrinik/github-settings). Do not diff --git a/community-health/profile/README.md b/community-health/profile/README.md new file mode 100644 index 0000000..8431c62 --- /dev/null +++ b/community-health/profile/README.md @@ -0,0 +1,34 @@ +# Atrinik + +> A world of crystal, memory, and unfinished paths. + +Atrinik is an open-source cooperative fantasy role-playing world of islands, +ruins, and crystal-lit mysteries. + +The maintained GPL-licensed [Atrinik Classic](https://github.com/atrinik/classic) +line keeps the world playable today. In parallel, fresh MIT-licensed Go, Rust, +and Protobuf foundations are being developed as a separate clean-room +implementation. They do not yet form a complete game release. Game content and +media remain separately licensed under the terms in their source repositories. + +## Start here + +- [Play Atrinik Classic](https://atrinik.org/downloads/) +- [Explore the development workspace](https://github.com/atrinik/atrinik) +- [Follow the next-generation roadmap](https://github.com/atrinik/atrinik/issues/168) +- [Discover the world](https://atrinik.org/) + +## The project + +| Repository | Purpose | +| --- | --- | +| [classic](https://github.com/atrinik/classic) | The playable, maintained C17 client, server, editor, libraries, and protocol | +| [content](https://github.com/atrinik/content) | Atrinik's authored maps, quests, archetypes, and world data | +| [client](https://github.com/atrinik/client) | Clean-room Rust and SDL3 next-generation client foundation | +| [server](https://github.com/atrinik/server) | Clean-room Go authoritative server foundation | +| [protocol](https://github.com/atrinik/protocol) | Game Protocol 1 schemas, QUIC specifications, and Go/Rust bindings | +| [renderer](https://github.com/atrinik/renderer) | Shared Rust GPU renderer for clients, editors, and tools | + +Human creators direct the game world. Software is developed publicly—primarily +through Codex-driven workflows under maintainer direction, review, provenance +controls, and repository validation. Direct human contributions are welcome. diff --git a/config/community-health.json b/config/community-health.json index 33f7be6..ea457cf 100644 --- a/config/community-health.json +++ b/config/community-health.json @@ -8,6 +8,10 @@ "has_wiki": false }, "files": [ + { + "source": "profile/README.md", + "target": "profile/README.md" + }, { "source": "README.md", "target": "README.md" diff --git a/config/manual-settings.json b/config/manual-settings.json index 8cdb7ea..cc0d3bb 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -4,6 +4,36 @@ "members_can_create_teams": false, "repository_admins_can_install_github_apps": false, "members_can_request_github_apps": true, + "organization_pins": { + "apply_path": "https://github.com/organizations/atrinik/settings/profile", + "repositories": [ + { + "name": "classic", + "repository_id": 1327289971 + }, + { + "name": "atrinik", + "repository_id": 15810595 + }, + { + "name": "website", + "repository_id": 1327107093 + }, + { + "name": "content", + "repository_id": 1325219730 + }, + { + "name": "protocol", + "repository_id": 1327106950 + }, + { + "name": "playtester", + "repository_id": 1329284051 + } + ], + "verification": "read_only_graphql" + }, "codecov_github_app_repositories": [ "atrinik", "classic", @@ -91,5 +121,5 @@ ], "require_two_factor_authentication": true, "require_secure_two_factor_methods": true, - "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, and external credential values must never be stored here. Confirm these settings, credential lifecycle metadata and live secret-name presence, Codecov GitHub App repository access, any listed GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI." + "reason": "The public GitHub API does not expose supported update parameters for every setting in this file, including organization pins, and external credential values must never be stored here. Confirm these settings, the exact ordered pin set, credential lifecycle metadata and live secret-name presence, Codecov GitHub App repository access, any listed GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI." } diff --git a/config/organization.json b/config/organization.json index 98094a1..6af9044 100644 --- a/config/organization.json +++ b/config/organization.json @@ -1,4 +1,6 @@ { + "description": "A cooperative fantasy world of islands, ruins, and crystal-lit mysteries—open source, playable in Classic, and rebuilding for the future.", + "blog": "https://atrinik.org", "default_repository_permission": "none", "members_can_create_repositories": false, "members_can_create_public_repositories": false, diff --git a/tests/publish-community-health.sh b/tests/publish-community-health.sh index 5fb0e13..61ae1f2 100755 --- a/tests/publish-community-health.sh +++ b/tests/publish-community-health.sh @@ -35,6 +35,9 @@ output=$(PATH="${temporary}/bin:${PATH}" \ "${root}/bin/publish-community-health") [[ $(grep -c '^PLAN POST /orgs/atrinik/repos ' <<<"${output}") == 1 ]] -[[ $(grep -c '^PLAN PUT /repos/atrinik/.github/contents/' <<<"${output}") == 9 ]] +[[ $(grep -c '^PLAN PUT /repos/atrinik/.github/contents/' <<<"${output}") == 10 ]] +grep -Fq \ + 'PLAN PUT /repos/atrinik/.github/contents/profile/README.md' \ + <<<"${output}" echo "Community-health publisher plans the repository and every default file." diff --git a/tests/publish-maintenance-branch.sh b/tests/publish-maintenance-branch.sh index 3a780bb..9533c15 100755 --- a/tests/publish-maintenance-branch.sh +++ b/tests/publish-maintenance-branch.sh @@ -226,11 +226,30 @@ if [[ ${method} != GET ]]; then fi case "${endpoint}|${jq_filter}" in -"orgs/atrinik|.plan.name") - printf 'team\n' - ;; -"orgs/atrinik|.members_can_create_teams") - printf 'false\n' +"orgs/atrinik|") + if [[ ${GH_ORGANIZATION_SCENARIO:-converged} == drifted ]]; then + jq -n '{ + plan: {name: "team"}, + description: "", + blog: "https://atrinik.org", + default_repository_permission: "none", + members_can_create_repositories: false, + members_can_create_public_repositories: false, + members_can_create_private_repositories: false, + members_can_create_teams: false + }' + else + jq -n '{ + plan: {name: "team"}, + description: "A cooperative fantasy world of islands, ruins, and crystal-lit mysteries—open source, playable in Classic, and rebuilding for the future.", + blog: "https://atrinik.org", + default_repository_permission: "none", + members_can_create_repositories: false, + members_can_create_public_repositories: false, + members_can_create_private_repositories: false, + members_can_create_teams: false + }' + fi ;; "orgs/atrinik/repos?per_page=100&type=all|"*) printf 'classic\t%s\tfalse\tpublic\tfalse\tfalse\ttrue\ttrue\ttrue\n' \ @@ -1083,6 +1102,7 @@ printf '{"enforced_repositories":"none"}\n' \ >"${organization_immutable_state}" GH_API_LOG=${organization_log} \ GH_IMMUTABLE_STATE=${organization_immutable_state} \ + GH_ORGANIZATION_SCENARIO=drifted \ GH_SECURITY_SCENARIO=drifted \ PATH="${temporary}/bin:${PATH}" \ ATRINIK_POLICY_SCOPE=organization \ @@ -1098,6 +1118,20 @@ jq -s -e ' ) ' "${organization_log}" >/dev/null assert_immutable_release_apply "${organization_log}" +jq -s -e ' + [ + .[] | + select(.method == "PATCH" and .endpoint == "orgs/atrinik") | + .payload + ] == [{ + description: "A cooperative fantasy world of islands, ruins, and crystal-lit mysteries—open source, playable in Classic, and rebuilding for the future.", + blog: "https://atrinik.org", + default_repository_permission: "none", + members_can_create_repositories: false, + members_can_create_public_repositories: false, + members_can_create_private_repositories: false + }] +' "${organization_log}" >/dev/null assert_maintenance_payload \ "${organization_log}" "orgs/atrinik/rulesets" true assert_default_branch_policy_payloads \ @@ -1140,7 +1174,16 @@ GH_API_LOG=${idempotent_log} \ ATRINIK_POLICY_SCOPE=organization \ "${root}/bin/publish" --apply >"${idempotent_output}" assert_idempotent_organization_security "${idempotent_log}" +jq -s -e ' + all( + .[]; + .method != "PATCH" or .endpoint != "orgs/atrinik" + ) +' "${idempotent_log}" >/dev/null [[ ! -e ${enablement_event} ]] +grep -F \ + 'KEEP /orgs/atrinik metadata and repository defaults' \ + "${idempotent_output}" >/dev/null grep -F \ 'KEEP /orgs/atrinik/code-security/configurations/265377 matches config/code-security-advanced.json' \ "${idempotent_output}" >/dev/null @@ -1343,10 +1386,17 @@ plan_immutable_state=${temporary}/plan-immutable.json printf '{"enforced_repositories":"none"}\n' >"${plan_immutable_state}" GH_API_LOG=${temporary}/plan.jsonl \ GH_IMMUTABLE_STATE=${plan_immutable_state} \ + GH_ORGANIZATION_SCENARIO=drifted \ GH_SECURITY_SCENARIO=drifted \ PATH="${temporary}/bin:${PATH}" \ ATRINIK_POLICY_SCOPE=organization \ "${root}/bin/publish" >"${plan_output}" +grep -F \ + 'DRIFT /orgs/atrinik description live="" desired="A cooperative fantasy world of islands, ruins, and crystal-lit mysteries—open source, playable in Classic, and rebuilding for the future."' \ + "${plan_output}" >/dev/null +grep -F \ + 'PLAN PATCH /orgs/atrinik <= config/organization.json' \ + "${plan_output}" >/dev/null grep -F \ 'PLAN POST /orgs/atrinik/code-security/configurations/265377/attach' \ "${plan_output}" >/dev/null @@ -1376,8 +1426,7 @@ jq -e '. == {enforced_repositories: "none"}' \ jq -s -e ' all( .[]; - .method != "PUT" or - .endpoint != "orgs/atrinik/settings/immutable-releases" + .method == "GET" ) ' "${temporary}/plan.jsonl" >/dev/null diff --git a/tests/validate-community-health.sh b/tests/validate-community-health.sh index 84da322..062db5d 100755 --- a/tests/validate-community-health.sh +++ b/tests/validate-community-health.sh @@ -8,6 +8,7 @@ trap 'rm -rf "${temporary}"' EXIT cp -R "${root}/." "${temporary}/repository" forms=${temporary}/repository/community-health/.github/ISSUE_TEMPLATE +profile=${temporary}/repository/community-health/profile/README.md assert_invalid() { local description=$1 @@ -25,6 +26,38 @@ reset_repository() { "${temporary}/repository/bin/validate" >/dev/null +rm "${profile}" +assert_invalid 'a missing organization profile source' +reset_repository + +sed -i 's/GPL-licensed/GPL-compatible/' "${profile}" +assert_invalid 'an organization profile without the Classic license boundary' +reset_repository + +jq 'del(.files[] | select(.target == "profile/README.md"))' \ + "${temporary}/repository/config/community-health.json" \ + >"${temporary}/community-health.json" +mv "${temporary}/community-health.json" \ + "${temporary}/repository/config/community-health.json" +assert_invalid 'an undeclared generated organization profile' +reset_repository + +jq '.description = "Developer tools for Atrinik"' \ + "${temporary}/repository/config/organization.json" \ + >"${temporary}/organization.json" +mv "${temporary}/organization.json" \ + "${temporary}/repository/config/organization.json" +assert_invalid 'organization identity drift' +reset_repository + +jq '.organization_pins.repositories |= reverse' \ + "${temporary}/repository/config/manual-settings.json" \ + >"${temporary}/manual-settings.json" +mv "${temporary}/manual-settings.json" \ + "${temporary}/repository/config/manual-settings.json" +assert_invalid 'organization pin order drift' +reset_repository + sed -i '2a title: ""' "${forms}/bug.yml" assert_invalid 'an empty issue-form title' reset_repository diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index aa9ca2f..3683fa8 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -20,7 +20,14 @@ while (($#)); do -H) shift 2 ;; + -f | -F) + shift 2 + ;; *) + if [[ -n ${endpoint} ]]; then + echo "unexpected gh api argument: $1" >&2 + exit 1 + fi endpoint=$1 shift ;; @@ -33,6 +40,37 @@ if [[ ${FAKE_GH_SCENARIO} == api-failure ]]; then exit 41 fi case ${endpoint} in +graphql) + if [[ ${FAKE_GH_SCENARIO} == malformed-pins ]]; then + jq -n '{data: {organization: {login: "atrinik", pinnedItems: {nodes: []}}}}' + elif [[ ${FAKE_GH_SCENARIO} == pin-drift ]]; then + jq -n '{data: {organization: { + login: "atrinik", + pinnedItems: { + totalCount: 2, + nodes: [ + {name: "atrinik", databaseId: 15810595, nameWithOwner: "atrinik/atrinik", isArchived: false, visibility: "PUBLIC"}, + {name: "classic", databaseId: 1327289971, nameWithOwner: "atrinik/classic", isArchived: false, visibility: "PUBLIC"} + ] + } + }}}' + else + jq -n '{data: {organization: { + login: "atrinik", + pinnedItems: { + totalCount: 6, + nodes: [ + {name: "classic", databaseId: 1327289971, nameWithOwner: "atrinik/classic", isArchived: false, visibility: "PUBLIC"}, + {name: "atrinik", databaseId: 15810595, nameWithOwner: "atrinik/atrinik", isArchived: false, visibility: "PUBLIC"}, + {name: "website", databaseId: 1327107093, nameWithOwner: "atrinik/website", isArchived: false, visibility: "PUBLIC"}, + {name: "content", databaseId: 1325219730, nameWithOwner: "atrinik/content", isArchived: false, visibility: "PUBLIC"}, + {name: "protocol", databaseId: 1327106950, nameWithOwner: "atrinik/protocol", isArchived: false, visibility: "PUBLIC"}, + {name: "playtester", databaseId: 1329284051, nameWithOwner: "atrinik/playtester", isArchived: false, visibility: "PUBLIC"} + ] + } + }}}' + fi + ;; repos/atrinik/github-settings) if [[ ${FAKE_GH_SCENARIO} == identity-drift ]]; then jq -n '{ @@ -284,7 +322,8 @@ output=$(run_verify present) grep -Fq 'KEEP atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ <<<"${output}" grep -Fq 'KEEP atrinik/classic environment discord-release metadata' <<<"${output}" -grep -Fq 'Manual settings live credential and environment metadata is present.' \ +grep -Fq 'KEEP atrinik organization pins match the exact governed order' <<<"${output}" +grep -Fq 'Manual settings live credential, environment, and organization pin metadata is present.' \ <<<"${output}" : >"${temporary}/gh.log" @@ -350,6 +389,19 @@ for scenario in missing identity-drift api-failure page2-failure; do exit 1 fi done + +for scenario in pin-drift malformed-pins; do + : >"${temporary}/gh.log" + if run_verify "${scenario}" \ + >"${temporary}/${scenario}.out" 2>"${temporary}/${scenario}.err"; then + echo "error: manual-settings verifier accepted ${scenario}" >&2 + exit 1 + fi +done +grep -Fq 'organization pin order or identity drift' \ + "${temporary}/pin-drift.err" +grep -Fq 'invalid organization pin metadata' \ + "${temporary}/malformed-pins.err" grep -Fq 'MISSING atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ "${temporary}/missing.err" grep -Fq 'repository identity or active-state drift' \ From 014d1e6cb832d34ebd0dfc67d168948189261b2c Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Mon, 10 Aug 2026 22:26:38 +0000 Subject: [PATCH 2/2] fix(profile): document public pin workflow --- README.md | 7 ++++--- bin/validate | 3 +-- config/manual-settings.json | 2 +- tests/validate-community-health.sh | 8 ++++++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index afbf2f1..2b8ef59 100644 --- a/README.md +++ b/README.md @@ -318,9 +318,10 @@ organization defaults. `bin/publish-community-health --apply` publishes the generated repository directly. GitHub does not provide a supported public API for organization pins. An -organization owner must open -, select exactly six -repositories, and arrange them in this order: +organization owner must open , choose **View as: +Public**, select **Customize pins** in the Pinned section (or **pin +repositories** when the section is empty), select exactly six repositories, +arrange them in this order, and select **Save pins**: 1. `classic` 2. `atrinik` diff --git a/bin/validate b/bin/validate index 98b9a45..5b1dfc3 100755 --- a/bin/validate +++ b/bin/validate @@ -244,8 +244,7 @@ jq -e ' jq -e ' .organization_pins as $pins | ($pins | keys == ["apply_path", "repositories", "verification"]) and - $pins.apply_path == - "https://github.com/organizations/atrinik/settings/profile" and + $pins.apply_path == "https://github.com/atrinik" and $pins.verification == "read_only_graphql" and ($pins.repositories | type == "array" and length == 6) and [$pins.repositories[].name] == [ diff --git a/config/manual-settings.json b/config/manual-settings.json index cc0d3bb..ca39635 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -5,7 +5,7 @@ "repository_admins_can_install_github_apps": false, "members_can_request_github_apps": true, "organization_pins": { - "apply_path": "https://github.com/organizations/atrinik/settings/profile", + "apply_path": "https://github.com/atrinik", "repositories": [ { "name": "classic", diff --git a/tests/validate-community-health.sh b/tests/validate-community-health.sh index 062db5d..15da1e2 100755 --- a/tests/validate-community-health.sh +++ b/tests/validate-community-health.sh @@ -58,6 +58,14 @@ mv "${temporary}/manual-settings.json" \ assert_invalid 'organization pin order drift' reset_repository +jq '.organization_pins.apply_path = "https://github.com/organizations/atrinik/settings/profile"' \ + "${temporary}/repository/config/manual-settings.json" \ + >"${temporary}/manual-settings.json" +mv "${temporary}/manual-settings.json" \ + "${temporary}/repository/config/manual-settings.json" +assert_invalid 'an organization pin path that omits the public profile flow' +reset_repository + sed -i '2a title: ""' "${forms}/bug.yml" assert_invalid 'an empty issue-form title' reset_repository