GitHub Governance
The eigenoid org configuration is managed declaratively via safe-settings. Repo settings, labels, rulesets, teams, and permissions are defined in YAML — any manual change is automatically reverted.
Architecture
eigenoid/platform-settings (ADMIN_REPO)
├── .github/
│ ├── settings.yml ← org-wide defaults
│ ├── repos/
│ │ └── platform-actions.yml ← per-repo overrides
│ ├── suborgs/
│ │ └── iac.yml ← shared config for a group of repos
│ ├── archived-repos/ ← config YAML for deleted repos (created on first archival)
│ ├── archived-repos.json ← registry of repos pending deletion
│ ├── ISSUE_TEMPLATE/
│ │ ├── new-repo.yml ← form to create a repo
│ │ └── delete-repo.yml ← form to delete a repo
│ └── workflows/
│ ├── new-repo.yml ← validates and posts summary table (trigger: label new-repo)
│ ├── approve-repo.yml ← generates YAML and pushes on approval (trigger: label approve-repo)
│ ├── archive-repo.yml ← Phase 1: archives repo + moves config
│ ├── delete-archived-repos.yml ← Phase 2: CRON deletes repos after 30 days
│ └── notify-lifecycle-approvers.yml ← notifies the approver team
├── CODEOWNERS ← requires review from platform-lifecycle-approvers
└── README.md
push to main issue with label
│ │
▼ ▼
GitHub webhook ──▶ Cloud Run (safe-settings) GitHub Actions (governance workflows)
│ │
▼ ▼
GitHub API: applies config GitHub API: push config / archive /
(settings, rulesets, labels) delete repos
Bots used:
┌─────────────────────────────────────────────────────┐
│ eigenoid-settings-bot → commits, push, repo API │
│ eigenoid-automation-bot → comments, labels, issues │
└─────────────────────────────────────────────────────┘
How it works
- Push to main on the admin repo triggers a webhook
- safe-settings compares the push diff — if there are changes to config files, it re-applies the entire configuration
- Config is applied hierarchically: org defaults → suborg overrides → repo overrides
- CRON sync every 6 hours re-applies all config to correct drift
Drift prevention
| Change type | Detection | Timing |
|---|---|---|
| Manual change to a ruleset | Webhook repository_ruleset | Real-time (~seconds) |
| Manual change to branch protection | Webhook branch_protection_rule | Real-time (~seconds) |
| Manual change to repo settings (wiki, merge strategy, etc.) | CRON sync | Up to 6 hours |
| Manual change to labels/teams | CRON sync | Up to 6 hours |
The
repository.editedwebhook only covers name/description/visibility — NOT individual settings likehas_wiki. That is why the CRON is necessary.
How to add a new repo
The standard way to create a repo is via issue template:
-
Go to
platform-settings→ Issues → New Issue → select "Request new repository" -
Fill out the form: name, description, topics, extra labels (optional), and visibility
-
The
new-repo.ymlworkflow posts a summary table with the parsed data as a comment — verify that everything is correctThe repo name is validated against
^[a-zA-Z0-9._-]+$. If it does not match, the workflow fails. -
Add the
approve-repolabel to the issue -
The
approve-repo.ymlworkflow generates the YAML file at.github/repos/<name>.ymlwithforce_create: trueand pushes it directly tomain -
safe-settings receives the push and creates the repo with the specified config
-
The issue is closed automatically with a link to the created repo
There is double protection:
new-repo.ymlchecks whether.github/repos/<name>.ymlalready exists — if it does, it closes the issue with an error before showing the summary table.approve-repo.ymlchecks again at approval time — if the config appeared between issue creation and approval, it removes theapprove-repolabel and comments with the error (without closing the issue).
If you need full control, you can create the YAML in .github/repos/ directly via PR. The issue template is the recommended flow because it validates inputs and leaves an audit trail.
If a group of repos shares config:
- Create a file in
.github/suborgs/<name>.yml - List the repos in the
suborgreposfield or use custom properties - Repos in the suborg inherit this config in addition to the org defaults
How to delete a repo
Deletion uses a two-phase soft-delete system with a 30-day retention period.
The three lifecycle workflows (approve-repo, archive-repo, delete-archived-repos) share the concurrency group repo-lifecycle with cancel-in-progress: false. This serializes operations and prevents race conditions when multiple requests are submitted simultaneously.
Full flow
Issue (delete-repo) ──▶ label approved-deletion ──▶ Phase 1: archives repo
│
30 days
│
▼
Phase 2: CRON deletes repo
Procedure
-
Open an issue with the "Request repo deletion" template in
platform-settings -
Fill in the repo name and the reason for deletion
-
The
notify-lifecycle-approvers.ymlworkflow automatically mentions@eigenoid/platform-lifecycle-approversfor review -
A member of
@eigenoid/platform-lifecycle-approversreviews and adds theapproved-deletionlabelThe
archive-repo.ymlworkflow verifies that the person adding the label is a member of the team. If they are not, the label is removed and the operation is cancelled. -
Phase 1 (immediate):
- The workflow verifies the repo exists and is not already archived
- If there are open issues or PRs, a warning is posted (archiving continues)
- The repo is archived (
archived: true) - The config YAML is moved from
.github/repos/to.github/archived-repos/ - It is registered in
.github/archived-repos.jsonwith the archival date - The issue is closed automatically
-
Phase 2 (daily CRON at 06:00 UTC, can also be triggered manually via
workflow_dispatch):- The workflow checks
archived-repos.json - Repos older than 30 days are permanently deleted
- The entry is removed from the registry
- Safety checks: if the repo was un-archived manually → deletion is cancelled; if the repo ID does not match (name recycled) → skipped; if the repo no longer exists (404) → the registry is cleaned up
- The workflow checks
Recovery (before the 30-day mark)
If a repo was archived by mistake, it can be recovered:
# 1. Un-archive the repo
gh api -X PATCH repos/eigenoid/NAME -f archived=false
# 2. Move the YAML back
git mv .github/archived-repos/NAME.yml .github/repos/NAME.yml
# 3. Remove the entry from archived-repos.json and commit
git commit -am "fix: restore NAME from soft-delete" && git push
The next CRON run will detect that the repo is no longer archived and skip it automatically.
If the repo has already been permanently deleted, the only option is to contact GitHub Support. Restoration is not guaranteed.
Repository lifecycle governance
An org-level ruleset with target: repository prevents org members from performing destructive operations on repos:
| Blocked operation | Rule |
|---|---|
| Create repos | repository_create |
| Delete repos | repository_delete |
| Transfer repos | repository_transfer |
| Change visibility | repository_visibility |
Who can bypass
| Actor | Type | Reason |
|---|---|---|
OrgAdmin (role) | Organization Admin | Emergencies and manual overrides |
eigenoid-settings-bot | GitHub App | Governance workflows operate with the bot's token |
How it interacts with the workflows
The repo creation and deletion workflows use a settings bot token (via actions/create-github-app-token). Since the bot is a bypass actor on the ruleset, operations go through without issue. Members can only create/delete repos via the issue templates — never directly.
platform-lifecycle-approvers team
The @eigenoid/platform-lifecycle-approvers team controls who can approve repo deletions:
- Management: the team is defined in
settings.ymland managed by safe-settings (withpushpermission at the org level). - Authorization: the
archive-repo.ymlworkflow verifies team membership before proceeding. If the person applying theapproved-deletionlabel is not a member, the label is removed and the operation is cancelled. - Notification: the
notify-lifecycle-approvers.ymlworkflow automatically mentions the team when adelete-repoissue is opened.
CODEOWNERS
The admin repo uses CODEOWNERS to require review from the platform-lifecycle-approvers team on changes to critical files:
| Path | Owner |
|---|---|
.github/workflows/ | @eigenoid/platform-lifecycle-approvers |
.github/settings.yml | @eigenoid/platform-lifecycle-approvers |
.github/repos/ | @eigenoid/platform-lifecycle-approvers |
.github/ISSUE_TEMPLATE/ | @eigenoid/platform-lifecycle-approvers |
CODEOWNERS | @eigenoid/platform-lifecycle-approvers |
This ensures that changes to workflows, safe-settings configuration, and issue templates require approval from the governance team.
Admin repo protection
The admin repo (platform-settings) has additional repo-level rulesets:
- Branch
mainrequires a PR with at least 1 approval - Bypass actors:
eigenoid-settings-botandOrgAdmin - This is necessary because the admin repo is excluded from org-level rulesets (to prevent safe-settings lockout)
How to modify org defaults
- Edit
.github/settings.ymlin the admin repo - Open a PR → safe-settings runs a dry-run and shows what would change as a check on the PR
- Merge the PR → safe-settings applies the changes to all repos
Example: add a new label
labels:
# ... existing labels ...
- name: security
color: "b60205"
description: "Security-related issue"
Example: modify a ruleset
rulesets:
- name: require-pr
# ... existing config ...
rules:
- type: pull_request
parameters:
required_approving_review_count: 2 # change from 1 to 2
Cloud Run — Service management
Service details
| Field | Value |
|---|---|
| Service | eigenoid-safe-settings |
| Region | europe-west1 |
| GCP Project | eigenoid-prd |
| Min instances | 1 (avoids cold start — GitHub webhook timeout is 10s) |
| Max instances | 3 |
| Port | 3000 |
| URL | https://eigenoid-safe-settings-{hash}.europe-west1.run.app |
| IaC | eigenoid/iac-platform (Terraform) |
Environment variables
| Variable | Value | Source |
|---|---|---|
NODE_ENV | production | Inline |
ADMIN_REPO | platform-settings | Inline |
LOG_LEVEL | info | Inline |
CRON | 0 0 */6 * * * | Inline |
APP_ID | (secret) | Secret Manager: safe-settings-app-id:latest |
PRIVATE_KEY | (secret) | Secret Manager: safe-settings-private-key:latest |
WEBHOOK_SECRET | (secret) | Secret Manager: safe-settings-webhook-secret:latest |
Warning:
ADMIN_REPOmust be just the repo name (platform-settings), NOTowner/repo.
View logs
gcloud run services logs read eigenoid-safe-settings \
--region europe-west1 \
--project eigenoid-prd \
--limit 50
For detailed debugging (temporary):
gcloud run services update eigenoid-safe-settings \
--region europe-west1 --project eigenoid-prd \
--update-env-vars "LOG_LEVEL=debug"
Force a full sync
Push any change to a file in .github/ of the admin repo. safe-settings only processes pushes that modify config files.
cd platform-settings
# Edit settings.yml (a comment is enough)
git commit -am "fix: trigger config sync" && git push
Update the container version
The safe-settings version is managed as IaC in iac-platform (ADR-0013). The file safe-settings/IMAGE_TAG is the source of truth.
# 1. Check the latest upstream version
gh api repos/github/safe-settings/releases/latest --jq '.tag_name'
# 2. Update IMAGE_TAG in iac-platform
cd iac-platform
echo "NEW_VERSION" > safe-settings/IMAGE_TAG
# 3. Open PR → plan shows image change → merge → auto-deploy
The mirroring workflow copies the image from GHCR to Artifact Registry automatically. Cloud Run is updated via Terraform apply.
For the full procedure with verification, see the safe-settings operations runbook.
Costs
| Component | Estimated cost |
|---|---|
| Cloud Run (min-instances=1, ~idle) | ~$5-10/month |
| Secret Manager (3 secrets) | ~$0.06/month |
| Artifact Registry (1 image) | ~$0.10/month |
| Total | ~$5-10/month |
Known gotchas
- Docker multi-arch on Apple Silicon:
--platform linux/amd64may be ignored. Use an explicit digest. ADMIN_REPOis just the name: do not useowner/repo, onlyplatform-settings.- Rulesets with
~ALLinclude the admin repo: excludeplatform-settingsfrom rulesets to prevent lockout. - Legacy branch protection persists: after removing
branches:from the YAML, delete manually via API. - Webhook path: must be
/api/github/webhooks, not root/. - Drift is push-triggered: safe-settings only processes pushes that modify config files. CRON covers the rest.
- PRIVATE_KEY must be raw PEM: Probot rejects base64. Use the PEM file directly.
- Rulesets with
target: repository: rules for repository policies use specific types (repository_create,repository_delete,repository_transfer), notcreation/deletion— those are for branch/tag targets. enforcement: "evaluate"is not available for repository policies: unlike branch/tag rulesets, repository rulesets do not support "evaluate" (audit-only) mode. Onlyactiveordisabled.- Admin repo needs its own rulesets: since
platform-settingsis excluded from org-level rulesets, it needs repo-level rulesets to protectmain(requires PR + approval).