Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ material. Changes require credential-schema negative tests, the read-only
`bin/verify-manual-settings` plan, and the documented manual and scheduled
post-rotation checks.

Actions GitHub Apps require their own value-free inventory entry with stable
App, installation, and repository IDs; exact permissions and event set;
selected-repository verification; consumer paths; secret and variable names;
accountable ownership; verification and rotation dates; and a runbook. Never
store a private key, installation token, token response, or credential value.
Changes require negative schema tests, read-only installation and metadata-name
verification, and organization-owner confirmation of the exact selected
repository set in GitHub's installation UI. Keep the App out of bypass lists,
preserve read-only Actions defaults, and prove the consumer's disposable pull
request receives ordinary checks without self-approval or self-merge.

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
Expand Down
113 changes: 113 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ read-only and are skipped on later runs.
secret-name presence plus inventoried environment identity, deployment
branches, reviewer rules, and exact secret and variable name sets without
reading or proving any value.
- Repository-scoped GitHub Apps used by Actions have separate value-free
lifecycle contracts in `config/manual-settings.json`. The inventory binds
the stable App and installation IDs, exact permissions and event set,
intended repository and consumer, credential metadata names, accountable
owner, verification date, rotation deadline, and runbook. The App private
key and installation tokens never belong in this repository.
- GitHub Actions defaults to read-only, cannot approve pull requests, and may
use only Atrinik, GitHub, Codecov coverage, and explicitly allowed Docker
actions.
Expand Down Expand Up @@ -225,6 +231,113 @@ functionality proof: apply mode verifies the classic PAT's advertised scopes,
Project update capability, and write access for every repository needing an
issue-type change before the first mutation.

### Classic dependency update App

`atrinik-classic-dependency-updater` is an organization-owned GitHub App used
only by the planned `atrinik/classic` consumer workflow
`.github/workflows/update-content.yml`. App ID `4564008` and installation ID
`153045686` are public stable identifiers. The installation selects only
`atrinik/classic`, repository ID `1327289971`, and has exactly these repository
permissions:

- metadata: read;
- contents: write;
- pull requests: write.

The App subscribes to no webhook events and has no Actions, checks,
deployments, environments, issues, packages, secrets, organization
administration, or ruleset-bypass permission. Organization Actions defaults
remain read-only and Actions cannot approve pull-request reviews. The App ID
is stored as the repository Actions variable `DEPENDENCY_UPDATE_APP_ID`; its
private key is stored as the repository Actions secret
`DEPENDENCY_UPDATE_APP_PRIVATE_KEY`. Never commit, print, cache, artifact, or
place either the private key or an installation token in a pull-request body,
step output, command line, fixture, or log.

The public REST response available to the settings administration credential
proves the organization owner, stable App and installation IDs, selected-mode
installation, exact effective permissions, empty event set, and unsuspended
state. GitHub does not expose the installation's exact selected-repository list
to that credential. An organization owner must therefore also inspect
<https://github.com/organizations/atrinik/settings/installations/153045686>
and confirm that the only selected repository is `classic` whenever the record
is created, rotated, or reviewed. `repository_scope_verification` records this
manual boundary; `bin/verify-manual-settings` additionally verifies Classic's
stable repository identity and both credential metadata names without reading
or logging their values.

GitHub App installation access tokens expire after one hour. The consumer must
mint a token only in the branch/PR mutation job, scope it to `classic`, avoid
exporting it beyond the necessary steps, and allow the pinned token action to
revoke it when the job finishes. The automation boundary is the single stable
branch `automation/content-update` and one open pull request from that branch.
Use one non-cancelling concurrency group, `classic-content-update`; a queued
run re-evaluates current state after the prior run completes. A run updates the
existing App-owned branch and PR only when their base, head owner, author, and
changed-path contract are intact. Zero matching PRs permits creation, one
permits refresh, and multiple matches or unexpected commits fail closed.

GitHub has no branch-only App permission: `contents: write`, which is required
to update the automation branch, also authorizes Git-reference and release API
operations. GitHub likewise has no separate create-or-update-PR permission that
excludes review APIs: `pull requests: write` covers both. The App credential
therefore cannot by itself prove that tag, release, or review calls are
technically impossible. Because the App owns its generated pull request, it
cannot provide the distinct human approval required by the ordinary gate, and
it has no bypass. The consumer must never review, approve, merge, tag, publish,
dispatch a release, write the default branch, or change repository settings.
Its reviewed code, exact branch and changed-path checks, ordinary pull-request
gate, protected release-tag rules, workflow contract tests, and audit trail
jointly enforce that operational boundary. Do not describe the credential as
release- or review-incapable; treat any use outside the exact automation branch
and pull-request operations as an incident.

Provision or rotate the key as an Atrinik organization owner:

1. Prepare a reviewed change that advances `last_verified_on` and `rotate_by`
by no more than the recorded 90-day cadence. Reconfirm the App owner,
installation ID, Classic-only selection, empty event set, and exact three
permissions in the GitHub UI before changing credentials.
2. Generate a new App private key while the prior key remains valid. Store the
complete new PEM in the approved private credential manager and replace the
`DEPENDENCY_UPDATE_APP_PRIVATE_KEY` repository secret without echoing it.
Keep `DEPENDENCY_UPDATE_APP_ID` equal to the public numeric App ID.
3. Run `bin/verify-manual-settings`. This proves metadata and name presence,
not the key value. After the Classic consumer exists, dispatch only
`update-content.yml` and require one App-authored disposable pull request to
receive ordinary `Classic validation`, `CodeQL validation`, and
`Conventional PR title` checks.
4. Confirm the App-authored pull request cannot satisfy its own human approval
or merge gate, close the disposable pull request, remove its branch, and
only then delete the previous private key. A later scheduled run must also
succeed before rotation is considered complete.

For suspected disclosure or misuse, disable the Classic updater workflow,
suspend installation `153045686`, delete the repository secret, and revoke the
affected private key immediately. Review Actions logs, App and organization
audit events, open pull requests, branches, tags, and releases; remove only
verified App-owned disposable state. Rotate to a new key and repeat the full
proof before re-enabling. To revoke permanently, uninstall the App from
Classic, delete its keys plus the repository secret and variable, and reconcile
`config/manual-settings.json` through a reviewed rollback. Do not leave a
credential inventory entry claiming a revoked installation is active.

Read non-secret live metadata and run the complete verifier with:

```sh
gh api orgs/atrinik/installations \
--jq '[.installations[] | {id, app_id, app_slug, repository_selection, permissions, events, suspended_at}]'
gh api repos/atrinik/classic/actions/secrets \
--jq '{total_count, names: [.secrets[].name]}'
gh api repos/atrinik/classic/actions/variables \
--jq '{total_count, names: [.variables[].name]}'
bin/verify-manual-settings
```

If any ID, permission, event, suspension state, selected repository, or
credential name differs, stop. Disable the consumer and reconcile reviewed
desired state before minting another installation token.

## Cross-repository planning

Review and apply the planning layers in their dependency order:
Expand Down
122 changes: 122 additions & 0 deletions bin/validate
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ jq -e '. == {state: "not-configured"}' \
jq -e '
(keys == [
"codecov_github_app_repositories",
"github_actions_apps",
"github_actions_credentials",
"github_actions_environments",
"github_packages_actions_access",
Expand All @@ -241,6 +242,127 @@ jq -e '
])
' "${manual_settings_config}" >/dev/null

jq -e \
--arg today "${validation_today}" \
--slurpfile repositories "${repositories_config}" '
def day_epoch($value):
($value + "T00:00:00Z" | fromdateiso8601);
def valid_day($value):
($value | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and
(try ((day_epoch($value) | strftime("%Y-%m-%d")) == $value) catch false);
.github_actions_apps as $apps |
($apps | type == "array" and length > 0) and
([$apps[].app_id] | length == (unique | length)) and
([$apps[].installation_id] | length == (unique | length)) and
(
[$apps[] | [.repository, .secret_scope, .secret_name]] |
length == (unique | length)
) and
(
[$apps[] | [.repository, .variable_scope, .variable_name]] |
length == (unique | length)
) and
all(
$apps[];
(keys == [
"app_id",
"app_slug",
"consumers",
"events",
"installation_id",
"last_verified_on",
"owner",
"permissions",
"purpose",
"repository",
"repository_id",
"repository_scope_verification",
"repository_selection",
"rotate_by",
"rotation_cadence_days",
"rotation_owner",
"runbook",
"secret_name",
"secret_scope",
"variable_name",
"variable_scope"
]) and
(.app_id | type == "number" and floor == . and . > 0) and
(
.app_slug |
type == "string" and
test("^[a-z0-9]+(?:-[a-z0-9]+)*$")
) and
(.installation_id | type == "number" and floor == . and . > 0) and
.repository_selection == "selected" and
.repository_scope_verification == "organization_owner_ui" and
.events == [] and
.permissions == {
contents: "write",
metadata: "read",
pull_requests: "write"
} 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 floor == . and . > 0) and
.secret_scope == "repository" and
.variable_scope == "repository" and
(.secret_name | type == "string" and test("^[A-Z][A-Z0-9_]*$")) and
(.variable_name | type == "string" and test("^[A-Z][A-Z0-9_]*$")) and
.secret_name != .variable_name and
(
.consumers |
type == "array" and
length > 0 and
. == (sort | unique) and
all(
.[];
type == "string" and
test("^\\.github/workflows/[A-Za-z0-9_.-]+\\.ya?ml$")
)
) and
(.purpose | type == "string" and length > 0) and
(.owner | type == "string" and length > 0) and
(.rotation_owner | type == "string" and length > 0) and
(
.rotation_cadence_days |
type == "number" and floor == . and . >= 1 and . <= 365
) and
valid_day(.last_verified_on) and
valid_day(.rotate_by) and
(day_epoch(.last_verified_on) <= day_epoch($today)) and
(day_epoch(.rotate_by) >= day_epoch($today)) and
(day_epoch(.rotate_by) > day_epoch(.last_verified_on)) and
(
(day_epoch(.rotate_by) - day_epoch(.last_verified_on)) / 86400 <=
.rotation_cadence_days
) and
(
.runbook |
type == "string" and
test("^[A-Za-z0-9._/-]+\\.md#[a-z0-9-]+$")
)
) and
all(
$apps[];
. as $app |
all(
$apps[];
.repository != $app.repository or
.repository_id == $app.repository_id
)
)
' "${manual_settings_config}" >/dev/null

jq -e '
.organization_pins as $pins |
($pins | keys == ["apply_path", "repositories", "verification"]) and
Expand Down
Loading