diff --git a/AGENTS.md b/AGENTS.md index 2d0e883..b585669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,13 @@ branch policies and reviewer sets, and variable and secret names only. Never record credential values. Provision them only after the owning workflow is merged and reviewed, and do not imply that `bin/publish` applies them. +- Record GitHub Pages sites with stable repository identity, exact provider URL, + HTTPS enforcement, owning deployment environment, desired Actions source, + workflow path, and immutable deployment-action marker. Before that marker is + present on the default branch, the verifier permits only the documented + legacy source as pending transition state; once merged, anything except the + Actions source is drift. Switch the live source only after the consuming + workflow is merged and reviewed. - Treat `config/codeql-advanced-setup.json` as the exhaustive exception inventory for repositories whose component/path-aware CodeQL workflow cannot use repository-wide default setup. Keep the advanced security configuration diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c65b661..7a7100a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,10 @@ Actions environments belong in the same inventory with a stable repository ID, exact branch policy and reviewer set, and variable and secret names only. Never commit their values, and provision them only after the workflow that uses them is merged and reviewed. +GitHub Pages records likewise bind stable repository identity, the provider +URL, HTTPS, the deployment environment, and an immutable marker in the owning +workflow. Keep a pre-merge legacy source only as the verifier's explicit +pending state, then activate the Actions source after that exact workflow lands. Administrative Actions credentials require a value-free lifecycle entry in `config/manual-settings.json`. Record stable repository identity, secret scope diff --git a/README.md b/README.md index 625941b..5b7a49d 100644 --- a/README.md +++ b/README.md @@ -777,6 +777,66 @@ verification to succeed. If any identity, policy, reviewer, name, or count differs, stop: do not delete or overwrite unknown live settings. Reconcile the reviewed desired-state contract first, then repeat the complete verification. +## Activating the Classic performance Pages site + +`atrinik/classic` (repository ID `1327289971`) owns the provider-managed +`https://atrinik.github.io/classic/` site and the `github-pages` deployment +environment. The environment permits only the exact `main` branch and has no +required reviewers, secrets, or variables. The desired Pages build type is +`workflow`; the consuming workflow is +`.github/workflows/daily-client-performance.yml`, bound by the immutable +`actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e` marker. + +The live legacy `main`-root source is a bounded pre-activation state only while +that marker is absent from the default-branch workflow. +`bin/verify-manual-settings` reports it as `PENDING`. Once the reviewed Classic +workflow is merged, the same verifier requires the Actions source and fails on +any lingering legacy source. It never changes the source itself. + +Before activation, verify the repository, current Pages site, environment, and +the merged workflow without changing live state: + +```sh +gh api repos/atrinik/classic --jq '{id,full_name,archived,default_branch}' +gh api repos/atrinik/classic/pages \ + --jq '{html_url,build_type,source,public,https_enforced,cname}' +gh api repos/atrinik/classic/environments/github-pages \ + --jq '{name,deployment_branch_policy,protection_rule_types:[.protection_rules[].type]}' +gh api --paginate \ + 'repos/atrinik/classic/environments/github-pages/deployment-branch-policies?per_page=100' \ + --jq '[.branch_policies[] | {name,type}]' +gh api repos/atrinik/classic/environments/github-pages/secrets \ + --jq '{total_count,names:[.secrets[].name]}' +gh api repos/atrinik/classic/environments/github-pages/variables \ + --jq '{total_count,names:[.variables[].name]}' +gh api 'repos/atrinik/classic/contents/.github/workflows/daily-client-performance.yml?ref=main' \ + --jq -r .content | base64 --decode | \ + grep -F 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' +``` + +Require the stable repository identity and `main` default branch, exact site +URL, public HTTPS with no custom hostname, the exact custom `main` environment +branch policy, no other protection rule, and empty secret/variable lists. Stop +on any difference. Only after the workflow marker is present on `main`, an +organization owner with repository administration access may switch the exact +site to the Actions source: + +```sh +printf '%s\n' '{"build_type":"workflow"}' | gh api --method PUT \ + repos/atrinik/classic/pages --input - +``` + +Immediately run `bin/verify-manual-settings` and require `KEEP atrinik/classic +Pages uses the reviewed Actions workflow source`. Then dispatch `Daily Classic +client performance` from `main` with checkpoint source `final-benchmark-data`, +verify the attempt-qualified evidence/checkpoint artifacts and successful +`github-pages` deployment, and confirm `https://atrinik.github.io/classic/`, +`trend.json`, `v1/state.json`, and `v1/manifest.json`. Subsequent manual and +scheduled runs must use the default `pages` checkpoint. If activation or the +bootstrap run fails, leave or restore the last known-good Pages deployment, +preserve the final `benchmark-data` commit, and do not delete the historical +branch. + ## Retiring a manually inventoried Actions environment Removing an entry from `github_actions_environments` records the desired diff --git a/bin/validate b/bin/validate index aea4a12..86ee72e 100755 --- a/bin/validate +++ b/bin/validate @@ -290,6 +290,7 @@ jq -e ' "github_actions_credentials", "github_actions_environments", "github_packages_actions_access", + "github_pages_sites", "members_can_change_repository_visibility", "members_can_create_teams", "members_can_delete_or_transfer_repositories", @@ -664,10 +665,10 @@ jq -e \ ( .secret_names | type == "array" and - length > 0 and length == (unique | length) and all(.[]; type == "string" and test("^[A-Z][A-Z0-9_]*$")) ) and + ((.secret_names | length) > 0 or .environment == "github-pages") and ( .variable_names as $variables | all(.secret_names[]; . as $secret | ($variables | index($secret)) == null) @@ -684,6 +685,67 @@ jq -e \ ) ' "${manual_settings_config}" >/dev/null +jq -e \ + --slurpfile repositories "${repositories_config}" ' + .github_actions_environments as $environments | + .github_pages_sites as $sites | + ($sites | type == "array") and + ([$sites[].repository] | length == (unique | length)) and + all( + $sites[]; + (keys == [ + "activation_marker", + "build_type", + "environment", + "https_enforced", + "repository", + "repository_id", + "site_url", + "workflow_path" + ]) and + (.build_type == "workflow") and + (.https_enforced == true) and + (.environment == "github-pages") and + ( + .repository | + type == "string" and + test("^atrinik/[A-Za-z0-9_.-]+$") + ) and + ( + .repository as $full_name | + ($full_name | split("/")[1]) as $repository | + ($repositories[0].pull_request_gate | index($repository)) != null and + ($repositories[0].archive | index($repository)) == null + ) and + (.repository_id | type == "number" and . > 0 and floor == .) and + ( + .repository as $repository | + ($repository | split("/")) as $parts | + .site_url == ("https://" + $parts[0] + ".github.io/" + $parts[1] + "/") + ) and + ( + .workflow_path | + type == "string" and + test("^\\.github/workflows/[A-Za-z0-9_.-]+\\.ya?ml$") + ) and + ( + .activation_marker | + type == "string" and + test("^actions/deploy-pages@[0-9a-f]{40}$") + ) + ) and + all( + $sites[]; + . as $site | + any( + $environments[]; + .repository == $site.repository and + .repository_id == $site.repository_id and + .environment == $site.environment + ) + ) +' "${manual_settings_config}" >/dev/null + jq -e ' (keys == [ "issue_fields", diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 4f97339..b831425 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -10,7 +10,7 @@ if (($#)); then exit 2 fi -for command in gh jq; do +for command in base64 gh jq; do if ! command -v "${command}" >/dev/null 2>&1; then echo "error: ${command} is required" >&2 exit 1 @@ -332,6 +332,80 @@ if ((missing > 0)); then exit 1 fi +while IFS= read -r pages_record; do + repository=$(jq -r '.repository' <<<"${pages_record}") + repository_owner=${repository%%/*} + repository_id=$(jq -r '.repository_id' <<<"${pages_record}") + site_url=$(jq -r '.site_url' <<<"${pages_record}") + workflow_path=$(jq -r '.workflow_path' <<<"${pages_record}") + activation_marker=$(jq -r '.activation_marker' <<<"${pages_record}") + pages_key=${repository//\//-} + + if [[ ${repository_owner} != "${organization}" ]]; then + echo "error: Pages repository owner does not match ${organization}: ${repository}" >&2 + exit 1 + fi + if verify_repository_identity "${repository}" "${repository_id}"; then + : + else + status=$? + exit "${status}" + fi + + pages_file=${temporary}/${pages_key}-pages.json + workflow_file=${temporary}/${pages_key}-pages-workflow.json + if github_api "read ${repository} Pages metadata" \ + "repos/${repository}/pages" >"${pages_file}" && + github_api "read ${repository} Pages workflow from the default branch" \ + "repos/${repository}/contents/${workflow_path}?ref=main" >"${workflow_file}"; then + : + else + status=$? + exit "${status}" + fi + if ! jq -e --arg site_url "${site_url}" ' + .html_url == $site_url and + .public == true and + .https_enforced == true and + .cname == null and + (.build_type | IN("legacy", "workflow")) + ' "${pages_file}" >/dev/null; then + echo "error: Pages identity or HTTPS drift for ${repository}" >&2 + exit 1 + fi + if ! jq -e ' + .type == "file" and + .encoding == "base64" and + (.content | type == "string") + ' "${workflow_file}" >/dev/null; then + echo "error: Pages workflow metadata is invalid for ${repository}" >&2 + exit 1 + fi + if ! jq -r '.content' "${workflow_file}" | base64 --decode \ + >"${temporary}/${pages_key}-pages-workflow.yml"; then + echo "error: Pages workflow content is not valid base64 for ${repository}" >&2 + exit 1 + fi + + if grep -Fq -- "${activation_marker}" \ + "${temporary}/${pages_key}-pages-workflow.yml"; then + if [[ $(jq -r '.build_type' "${pages_file}") != workflow ]]; then + echo "error: Pages Actions source is not active for merged ${repository} workflow" >&2 + exit 1 + fi + echo "KEEP ${repository} Pages uses the reviewed Actions workflow source" + else + if ! jq -e ' + .build_type == "legacy" and + .source == {branch: "main", path: "/"} + ' "${pages_file}" >/dev/null; then + echo "error: pending Pages activation has unexpected live source for ${repository}" >&2 + exit 1 + fi + echo "PENDING ${repository} Pages remains on the exact legacy main-root source until the reviewed workflow merges" + fi +done < <(jq -c '.github_pages_sites[]' "${manual_settings}") + while IFS= read -r environment_record; do repository=$(jq -r '.repository' <<<"${environment_record}") repository_owner=${repository%%/*} @@ -605,4 +679,4 @@ if [[ ${live_pins} != "${desired_pins}" ]]; then fi echo "KEEP ${organization} organization pins match the exact governed order" -echo "Manual settings live credential, GitHub App, environment, and organization pin metadata is present." +echo "Manual settings live credential, GitHub App, Pages, environment, and organization pin metadata is present." diff --git a/config/manual-settings.json b/config/manual-settings.json index 0376796..0c34022 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -68,6 +68,36 @@ "DISCORD_APPLICATION_ID" ], "variable_names": [] + }, + { + "deployment_branch_policy": { + "custom_branch_policies": true, + "patterns": [ + { + "name": "main", + "type": "branch" + } + ], + "protected_branches": false + }, + "environment": "github-pages", + "repository": "atrinik/classic", + "repository_id": 1327289971, + "required_reviewers": [], + "secret_names": [], + "variable_names": [] + } + ], + "github_pages_sites": [ + { + "activation_marker": "actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e", + "build_type": "workflow", + "environment": "github-pages", + "https_enforced": true, + "repository": "atrinik/classic", + "repository_id": 1327289971, + "site_url": "https://atrinik.github.io/classic/", + "workflow_path": ".github/workflows/daily-client-performance.yml" } ], "github_actions_apps": [ @@ -131,5 +161,5 @@ "github_packages_actions_access": [], "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, including organization pins and the exact selected-repository set for an organization GitHub App installation to an administrative token, 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, GitHub App repository selection, 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 the exact selected-repository set for an organization GitHub App installation to an administrative token, 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, GitHub App repository selection, Codecov GitHub App repository access, any listed GitHub Pages sites and GitHub Actions environments, and GitHub Packages Actions access in the corresponding organization, repository, and package settings UI." } diff --git a/tests/validate-manual-environments.sh b/tests/validate-manual-environments.sh index d03aeb3..2f69db5 100755 --- a/tests/validate-manual-environments.sh +++ b/tests/validate-manual-environments.sh @@ -23,6 +23,7 @@ assert_invalid() { reset_manual_settings() { jq ' + .github_pages_sites = [] | .github_actions_environments = [ { deployment_branch_policy: { @@ -56,8 +57,8 @@ rewrite_manual_settings() { } jq -e ' - .github_actions_environments == [ - { + (.github_actions_environments | length) == 2 and + any(.github_actions_environments[]; . == { deployment_branch_policy: { custom_branch_policies: true, patterns: [ @@ -72,8 +73,30 @@ jq -e ' required_reviewers: [], secret_names: ["DISCORD_APPLICATION_ID"], variable_names: [] - } - ] + }) and + any(.github_actions_environments[]; . == { + deployment_branch_policy: { + custom_branch_policies: true, + patterns: [{name: "main", type: "branch"}], + protected_branches: false + }, + environment: "github-pages", + repository: "atrinik/classic", + repository_id: 1327289971, + required_reviewers: [], + secret_names: [], + variable_names: [] + }) and + .github_pages_sites == [{ + activation_marker: "actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e", + build_type: "workflow", + environment: "github-pages", + https_enforced: true, + repository: "atrinik/classic", + repository_id: 1327289971, + site_url: "https://atrinik.github.io/classic/", + workflow_path: ".github/workflows/daily-client-performance.yml" + }] ' "${root}/config/manual-settings.json" >/dev/null "${temporary}/bin/validate" >/dev/null @@ -175,4 +198,33 @@ rewrite_manual_settings \ '.github_actions_environments[0].secret_names = ["external_token"]' assert_invalid 'a malformed environment secret name' +reset_manual_settings +rewrite_manual_settings \ + '.github_actions_environments[0].secret_names = []' +assert_invalid 'an empty secret contract outside github-pages' + +cp "${root}/config/manual-settings.json" \ + "${temporary}/config/manual-settings.json" +rewrite_manual_settings \ + '.github_pages_sites[0].build_type = "legacy"' +assert_invalid 'a non-workflow desired Pages source' + +cp "${root}/config/manual-settings.json" \ + "${temporary}/config/manual-settings.json" +rewrite_manual_settings \ + '.github_pages_sites[0].site_url = "https://example.test/classic/"' +assert_invalid 'a Pages URL outside the repository identity' + +cp "${root}/config/manual-settings.json" \ + "${temporary}/config/manual-settings.json" +rewrite_manual_settings \ + '.github_pages_sites[0].activation_marker = "actions/deploy-pages@v4"' +assert_invalid 'a mutable Pages activation marker' + +cp "${root}/config/manual-settings.json" \ + "${temporary}/config/manual-settings.json" +rewrite_manual_settings \ + '.github_pages_sites[0].environment = "production"' +assert_invalid 'a Pages site without its exact environment' + echo "Manual GitHub Actions environment validation tests passed." diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 3b9c462..ed3c426 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -126,6 +126,39 @@ repos/atrinik/classic) }' fi ;; +repos/atrinik/classic/pages) + build_type=legacy + html_url=https://atrinik.github.io/classic/ + source='{"branch":"main","path":"/"}' + if [[ ${FAKE_GH_SCENARIO} == pages-active ]]; then + build_type=workflow + source=null + elif [[ ${FAKE_GH_SCENARIO} == pages-source-drift ]]; then + source='{"branch":"development","path":"/"}' + elif [[ ${FAKE_GH_SCENARIO} == pages-identity-drift ]]; then + html_url=https://example.test/classic/ + fi + jq -n \ + --arg build_type "${build_type}" \ + --arg html_url "${html_url}" \ + --argjson source "${source}" \ + '{html_url: $html_url, build_type: $build_type, source: $source, + public: true, https_enforced: true, cname: null}' + ;; +"repos/atrinik/classic/contents/.github/workflows/daily-client-performance.yml?ref=main") + workflow='name: Daily Classic client performance' + if [[ ${FAKE_GH_SCENARIO} == pages-active || + ${FAKE_GH_SCENARIO} == pages-activation-missing ]]; then + workflow='uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' + fi + if [[ ${FAKE_GH_SCENARIO} == pages-workflow-metadata-drift ]]; then + jq -n '{type: "dir", encoding: "none", content: null}' + else + content=$(printf '%s\n' "${workflow}" | base64 -w0) + jq -n --arg content "${content}" \ + '{type: "file", encoding: "base64", content: $content}' + fi + ;; "orgs/atrinik/installations?per_page=100&page=1") if [[ ${FAKE_GH_SCENARIO} == app-api-failure ]]; then echo "gh: organization installation administration denied" >&2 @@ -366,6 +399,16 @@ repos/atrinik/classic/environments/discord-release) }' fi ;; +repos/atrinik/classic/environments/github-pages) + jq -n '{ + name: "github-pages", + deployment_branch_policy: { + protected_branches: false, + custom_branch_policies: true + }, + protection_rules: [{type: "branch_policy"}] + }' + ;; "repos/atrinik/classic/environments/discord-release/deployment-branch-policies?per_page=100&page=1") if [[ ${FAKE_GH_SCENARIO} == branch-policy-drift ]]; then jq -n '{ @@ -427,6 +470,18 @@ repos/atrinik/classic/environments/discord-release) jq -n '{total_count: 0, variables: []}' fi ;; +"repos/atrinik/classic/environments/github-pages/deployment-branch-policies?per_page=100&page=1") + jq -n '{ + total_count: 1, + branch_policies: [{id: 3, name: "main", type: "branch"}] + }' + ;; +"repos/atrinik/classic/environments/github-pages/secrets?per_page=100&page=1") + jq -n '{total_count: 0, secrets: []}' + ;; +"repos/atrinik/classic/environments/github-pages/variables?per_page=100&page=1") + jq -n '{total_count: 0, variables: []}' + ;; *) exit 1 ;; esac EOF @@ -448,6 +503,9 @@ 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 'PENDING atrinik/classic Pages remains on the exact legacy main-root source' \ + <<<"${output}" +grep -Fq 'KEEP atrinik/classic environment github-pages metadata' <<<"${output}" grep -Fq 'KEEP atrinik-classic-dependency-updater installation metadata and exact permissions' \ <<<"${output}" grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_PRIVATE_KEY' \ @@ -455,9 +513,32 @@ grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_P grep -Fq 'KEEP atrinik/classic repository Actions variable DEPENDENCY_UPDATE_APP_ID' \ <<<"${output}" grep -Fq 'KEEP atrinik organization pins match the exact governed order' <<<"${output}" -grep -Fq 'Manual settings live credential, GitHub App, environment, and organization pin metadata is present.' \ +grep -Fq 'Manual settings live credential, GitHub App, Pages, environment, and organization pin metadata is present.' \ <<<"${output}" +: >"${temporary}/gh.log" +output=$(run_verify pages-active) +grep -Fq 'KEEP atrinik/classic Pages uses the reviewed Actions workflow source' \ + <<<"${output}" + +for scenario in pages-source-drift pages-identity-drift \ + pages-activation-missing pages-workflow-metadata-drift; 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 'pending Pages activation has unexpected live source' \ + "${temporary}/pages-source-drift.err" +grep -Fq 'Pages identity or HTTPS drift' \ + "${temporary}/pages-identity-drift.err" +grep -Fq 'Pages Actions source is not active' \ + "${temporary}/pages-activation-missing.err" +grep -Fq 'Pages workflow metadata is invalid' \ + "${temporary}/pages-workflow-metadata-drift.err" + : >"${temporary}/gh.log" output=$(run_verify page2) grep -Fq 'KEEP atrinik/github-settings repository Actions secret ATRINIK_SETTINGS_TOKEN' \ @@ -708,4 +789,4 @@ fi grep -Fq 'ATRINIK_SETTINGS_TOKEN is unavailable' "${temporary}/empty.err" [[ ! -s ${temporary}/gh.log ]] -echo "Manual settings live credential, GitHub App, and environment verification tests passed." +echo "Manual settings live credential, GitHub App, Pages, and environment verification tests passed."