From 9eea855f7e36c70cb3e3cb74c52a73b0e10cb6cf Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 9 Aug 2026 02:37:45 +0000 Subject: [PATCH 1/2] chore: record website preview environment --- AGENTS.md | 5 ++ CONTRIBUTING.md | 6 +- README.md | 86 ++++++++++++++++++- bin/validate | 91 ++++++++++++++++++++ config/manual-settings.json | 27 +++++- tests/validate-manual-environments.sh | 119 ++++++++++++++++++++++++++ 6 files changed, 329 insertions(+), 5 deletions(-) create mode 100755 tests/validate-manual-environments.sh diff --git a/AGENTS.md b/AGENTS.md index 37e6ccb..1f6a5e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,11 @@ Actions access** UI. Do not change package visibility, permission inheritance, or source-repository association, and do not automate the grant through an undocumented API. +- Record manually provisioned GitHub Actions environments in + `config/manual-settings.json` with stable repository IDs, exact deployment + 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. - 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 481ee57..7ff13f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,11 @@ administrative token required by the manual publishing workflow. Package Actions-access grants that lack a documented public API belong in `config/manual-settings.json`; record stable package and repository IDs, grant only read access to consumer workflows, and preserve the package's existing -visibility and source-repository association. +visibility and source-repository association. Manually provisioned GitHub +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. Keep default-branch deletion and non-fast-forward rules non-bypassable during normal operation. Any organization-owner exception needed for GitHub's diff --git a/README.md b/README.md index 10035ba..fd884fe 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ read-only and are skipped on later runs. read-only workflow grants recorded in `config/manual-settings.json`. The package's visibility, permission inheritance, and source-repository link are not changed by these grants. +- GitHub Actions environments that depend on externally issued credentials + have name-only contracts in `config/manual-settings.json`. The inventory + records the stable repository identity, exact deployment branch policy and + reviewer set, and variable and secret names; credential values remain manual + and are never committed. - GitHub Actions defaults to read-only, cannot approve pull requests, and may use only Atrinik, GitHub, Codecov coverage, and explicitly allowed Docker actions. @@ -90,9 +95,10 @@ read-only and are skipped on later runs. 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 the organization UI under **Member privileges**, **Authentication security**, -**GitHub Apps**, and each listed package's **Manage Actions access** section. -Codecov must be installed for the listed repositories so their -OIDC-authenticated coverage uploads and badges remain available. +**GitHub Apps**, each listed repository's **Environments** settings, and each +listed package's **Manage Actions access** section. Codecov must be installed +for the listed repositories so their OIDC-authenticated coverage uploads and +badges remain available. ## Usage @@ -354,6 +360,80 @@ payload. For a later intentional rollback, review and merge a desired-state change first, then run the normal plan and explicitly authorized apply flow so the previous selection is not guessed or partially overwritten. +## Provisioning the website preview-domain environment + +The public `atrinik/website` repository (repository ID `1327107093`) uses the +`cloudflare-preview-domains` environment for its reviewed preview-domain +automation. That workflow uses `pull_request_target`, for which GitHub resolves +the workflow, `GITHUB_REF`, and `GITHUB_SHA` from the base repository's default +branch rather than from the pull-request head. Restricting this environment to +the exact `main` branch therefore admits the trusted default-branch workflow +without admitting ordinary pull-request merge or head refs. This remains a +valid security boundary only while the website workflow never checks out, +downloads, or executes pull-request-controlled code or artifacts. + +The environment deliberately has no required reviewers. Its custom deployment +branch policy allows only the `main` branch and no tags. The inventory records +only names: the environment variables are `CLOUDFLARE_ACCOUNT_ID` and +`CLOUDFLARE_ZONE_ID`, and the environment secret is +`CLOUDFLARE_PREVIEW_TOKEN`. The external values are never stored in this +repository, fixtures, commands, or logs, and `bin/publish` does not create, +update, or delete this environment. + +Do not provision any value until the exact website workflow that references +this environment has been merged to `atrinik/website@main` and reviewed for +the `pull_request_target` trust boundary. Then an organization owner or +repository administrator must: + +1. Confirm the repository identity without reading any credential value: + + ```sh + gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/atrinik/website \ + --jq '{id, full_name, visibility, archived, default_branch}' + ``` + +2. Open **atrinik/website → Settings → Environments**, create + `cloudflare-preview-domains`, select custom deployment branches and tags, + add only the `main` branch pattern, add no tag pattern, and leave required + reviewers disabled. +3. Add the two exact environment variable names and the one exact environment + secret name from `config/manual-settings.json`, entering their values only + through the protected GitHub settings UI from the owning Cloudflare account. + +The identity check must report ID `1327107093`, `atrinik/website`, `public`, +`archived: false`, and default branch `main`. Verify the resulting environment +without printing variable values or attempting to retrieve the secret value: + +```sh +gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/atrinik/website/environments/cloudflare-preview-domains \ + --jq '{name, deployment_branch_policy, protection_rule_types: [.protection_rules[].type]}' +gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/atrinik/website/environments/cloudflare-preview-domains/deployment-branch-policies \ + --jq '[.branch_policies[] | {name, type}]' +gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/atrinik/website/environments/cloudflare-preview-domains/variables \ + --jq '[.variables[].name] | sort' +gh api -H 'X-GitHub-Api-Version: 2026-03-10' \ + repos/atrinik/website/environments/cloudflare-preview-domains/secrets \ + --jq '[.secrets[].name] | sort' +``` + +The environment must report custom branch policies enabled, protected-branch +mode disabled, no `required_reviewers` protection rule, exactly one branch +policy named `main`, exactly the two recorded variable names, and exactly the +recorded secret name. If any identity or name differs, stop and reconcile it in +a reviewed desired-state change; never copy a live credential into an issue, +pull request, log, or configuration file. + +To retire the environment, first merge a reviewed website change that removes +all workflow references to it and a reviewed governance change that removes +its manual inventory entry. Then delete only +`cloudflare-preview-domains` through the website's Environment settings and +confirm that no other repository environment, variable, or secret changed. No +`bin/publish --apply` run is involved in provisioning or rollback. + ## Granting Classic access to the Windows build image The private `ghcr.io/atrinik/windows-build` image is owned by the diff --git a/bin/validate b/bin/validate index 6cd862e..0fac3e8 100755 --- a/bin/validate +++ b/bin/validate @@ -238,6 +238,97 @@ jq -e ' ) ' "${manual_settings_config}" >/dev/null +jq -e \ + --slurpfile repositories "${repositories_config}" ' + .github_actions_environments as $environments | + ($environments | type == "array" and length > 0) and + ( + [$environments[] | [.repository, .environment]] | + length == (unique | length) + ) and + all( + $environments[]; + (keys == [ + "deployment_branch_policy", + "environment", + "repository", + "repository_id", + "required_reviewers", + "secret_names", + "variable_names" + ]) 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 + ( + .environment | + type == "string" and + length <= 255 and + test("^[a-z0-9][a-z0-9-]*[a-z0-9]$") + ) and + (.required_reviewers == []) and + ( + .deployment_branch_policy | + (keys == [ + "custom_branch_policies", + "patterns", + "protected_branches" + ]) and + .custom_branch_policies == true and + .protected_branches == false and + (.patterns | type == "array" and length > 0) and + ([.patterns[] | [.type, .name]] | length == (unique | length)) and + all( + .patterns[]; + (keys == ["name", "type"]) and + .type == "branch" and + ( + .name | + type == "string" and + length > 0 and + test("^[A-Za-z0-9._/-]+$") + ) + ) + ) and + ( + .variable_names | + type == "array" and + length > 0 and + length == (unique | length) and + all(.[]; type == "string" and test("^[A-Z][A-Z0-9_]*$")) + ) and + ( + .secret_names | + type == "array" and + length > 0 and + length == (unique | length) and + all(.[]; type == "string" and test("^[A-Z][A-Z0-9_]*$")) + ) and + ( + .variable_names as $variables | + all(.secret_names[]; . as $secret | ($variables | index($secret)) == null) + ) + ) and + all( + $environments[]; + . as $environment | + all( + $environments[]; + .repository != $environment.repository or + .repository_id == $environment.repository_id + ) + ) +' "${manual_settings_config}" >/dev/null + jq -e ' (keys == [ "issue_fields", diff --git a/config/manual-settings.json b/config/manual-settings.json index 35e4545..8801b65 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -14,6 +14,31 @@ "renderer", "server" ], + "github_actions_environments": [ + { + "deployment_branch_policy": { + "custom_branch_policies": true, + "patterns": [ + { + "name": "main", + "type": "branch" + } + ], + "protected_branches": false + }, + "environment": "cloudflare-preview-domains", + "repository": "atrinik/website", + "repository_id": 1327107093, + "required_reviewers": [], + "secret_names": [ + "CLOUDFLARE_PREVIEW_TOKEN" + ], + "variable_names": [ + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_ZONE_ID" + ] + } + ], "github_packages_actions_access": [ { "package_owner": "atrinik", @@ -28,5 +53,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. Confirm them, Codecov GitHub App repository access, and GitHub Packages Actions access in the corresponding organization and package settings UI." + "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, Codecov GitHub App repository access, 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 new file mode 100755 index 0000000..3838f2b --- /dev/null +++ b/tests/validate-manual-environments.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +temporary=$(mktemp -d) +trap 'rm -rf "${temporary}"' EXIT + +mkdir -p "${temporary}/bin" +cp "${root}/bin/validate" "${temporary}/bin/validate" +cp -R "${root}/config" "${temporary}/config" +cp -R "${root}/community-health" "${temporary}/community-health" + +assert_invalid() { + local description=$1 + + if "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: validator accepted ${description}" >&2 + exit 1 + fi +} + +reset_manual_settings() { + cp "${root}/config/manual-settings.json" \ + "${temporary}/config/manual-settings.json" +} + +rewrite_manual_settings() { + local filter=$1 + local output + + output=$(mktemp) + jq "${filter}" \ + "${temporary}/config/manual-settings.json" >"${output}" + mv "${output}" "${temporary}/config/manual-settings.json" +} + +jq -e ' + .github_actions_environments == [ + { + deployment_branch_policy: { + custom_branch_policies: true, + patterns: [{name: "main", type: "branch"}], + protected_branches: false + }, + environment: "cloudflare-preview-domains", + repository: "atrinik/website", + repository_id: 1327107093, + required_reviewers: [], + secret_names: ["CLOUDFLARE_PREVIEW_TOKEN"], + variable_names: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ZONE_ID"] + } + ] +' "${root}/config/manual-settings.json" >/dev/null + +rewrite_manual_settings \ + '.github_actions_environments += [.github_actions_environments[0]]' +assert_invalid 'a duplicate repository environment' +reset_manual_settings + +rewrite_manual_settings \ + 'del(.github_actions_environments[0].repository_id)' +assert_invalid 'an environment without a stable repository ID' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].repository = "atrinik/unknown"' +assert_invalid 'an environment for an ungoverned repository' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].required_reviewers = [{type: "User", id: 1}]' +assert_invalid 'a required reviewer outside the declared empty set' +reset_manual_settings + +rewrite_manual_settings ' + .github_actions_environments[0].deployment_branch_policy = { + custom_branch_policies: false, + patterns: [], + protected_branches: true + } +' +assert_invalid 'a protected-branches policy instead of an exact custom policy' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].deployment_branch_policy.patterns[0].name = "*"' +assert_invalid 'a wildcard deployment branch policy' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].deployment_branch_policy.patterns[0].type = "tag"' +assert_invalid 'a tag deployment policy' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].secret_values = {}' +assert_invalid 'an environment secret-value field' +reset_manual_settings + +rewrite_manual_settings ' + .github_actions_environments[0].variable_names += + [.github_actions_environments[0].variable_names[0]] +' +assert_invalid 'a duplicate environment variable name' +reset_manual_settings + +rewrite_manual_settings ' + .github_actions_environments[0].secret_names = + [.github_actions_environments[0].variable_names[0]] +' +assert_invalid 'a name declared as both a variable and a secret' +reset_manual_settings + +rewrite_manual_settings \ + '.github_actions_environments[0].secret_names = ["cloudflare_preview_token"]' +assert_invalid 'a malformed environment secret name' + +echo "Manual GitHub Actions environment validation tests passed." From 924001fb045aebbaa37f108961c30bd51810606d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 9 Aug 2026 02:46:18 +0000 Subject: [PATCH 2/2] fix: reject unrecognized manual settings --- bin/validate | 16 ++++++++++++++++ tests/validate-manual-environments.sh | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/bin/validate b/bin/validate index 0fac3e8..504a966 100755 --- a/bin/validate +++ b/bin/validate @@ -196,6 +196,22 @@ jq -e '. == {state: "configured", query_suite: "default"}' \ jq -e '. == {state: "not-configured"}' \ "${root}/config/codeql-default-setup-advanced.json" >/dev/null +jq -e ' + (keys == [ + "codecov_github_app_repositories", + "github_actions_environments", + "github_packages_actions_access", + "members_can_change_repository_visibility", + "members_can_create_teams", + "members_can_delete_or_transfer_repositories", + "members_can_request_github_apps", + "reason", + "repository_admins_can_install_github_apps", + "require_secure_two_factor_methods", + "require_two_factor_authentication" + ]) +' "${manual_settings_config}" >/dev/null + jq -e ' .github_packages_actions_access as $access | ($access | type == "array") and diff --git a/tests/validate-manual-environments.sh b/tests/validate-manual-environments.sh index 3838f2b..b4dc90d 100755 --- a/tests/validate-manual-environments.sh +++ b/tests/validate-manual-environments.sh @@ -98,6 +98,11 @@ rewrite_manual_settings \ assert_invalid 'an environment secret-value field' reset_manual_settings +rewrite_manual_settings \ + '.github_actions_environment_secret_values = {CLOUDFLARE_PREVIEW_TOKEN: "secret"}' +assert_invalid 'a top-level environment secret-value field' +reset_manual_settings + rewrite_manual_settings ' .github_actions_environments[0].variable_names += [.github_actions_environments[0].variable_names[0]]