Skip to main content

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

  1. Push to main on the admin repo triggers a webhook
  2. safe-settings compares the push diff — if there are changes to config files, it re-applies the entire configuration
  3. Config is applied hierarchically: org defaults → suborg overrides → repo overrides
  4. CRON sync every 6 hours re-applies all config to correct drift

Drift prevention

Change typeDetectionTiming
Manual change to a rulesetWebhook repository_rulesetReal-time (~seconds)
Manual change to branch protectionWebhook branch_protection_ruleReal-time (~seconds)
Manual change to repo settings (wiki, merge strategy, etc.)CRON syncUp to 6 hours
Manual change to labels/teamsCRON syncUp to 6 hours

The repository.edited webhook only covers name/description/visibility — NOT individual settings like has_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:

  1. Go to platform-settings → Issues → New Issue → select "Request new repository"

  2. Fill out the form: name, description, topics, extra labels (optional), and visibility

  3. The new-repo.yml workflow posts a summary table with the parsed data as a comment — verify that everything is correct

    The repo name is validated against ^[a-zA-Z0-9._-]+$. If it does not match, the workflow fails.

  4. Add the approve-repo label to the issue

  5. The approve-repo.yml workflow generates the YAML file at .github/repos/<name>.yml with force_create: true and pushes it directly to main

  6. safe-settings receives the push and creates the repo with the specified config

  7. The issue is closed automatically with a link to the created repo

Duplicate detection

There is double protection:

  1. new-repo.yml checks whether .github/repos/<name>.yml already exists — if it does, it closes the issue with an error before showing the summary table.
  2. approve-repo.yml checks again at approval time — if the config appeared between issue creation and approval, it removes the approve-repo label and comments with the error (without closing the issue).
Manual override

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:

  1. Create a file in .github/suborgs/<name>.yml
  2. List the repos in the suborgrepos field or use custom properties
  3. 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.

Concurrency

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

  1. Open an issue with the "Request repo deletion" template in platform-settings

  2. Fill in the repo name and the reason for deletion

  3. The notify-lifecycle-approvers.yml workflow automatically mentions @eigenoid/platform-lifecycle-approvers for review

  4. A member of @eigenoid/platform-lifecycle-approvers reviews and adds the approved-deletion label

    The archive-repo.yml workflow 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.

  5. 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.json with the archival date
    • The issue is closed automatically
  6. 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

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
bash

The next CRON run will detect that the repo is no longer archived and skip it automatically.

After 30 days

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 operationRule
Create reposrepository_create
Delete reposrepository_delete
Transfer reposrepository_transfer
Change visibilityrepository_visibility

Who can bypass

ActorTypeReason
OrgAdmin (role)Organization AdminEmergencies and manual overrides
eigenoid-settings-botGitHub AppGovernance 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.yml and managed by safe-settings (with push permission at the org level).
  • Authorization: the archive-repo.yml workflow verifies team membership before proceeding. If the person applying the approved-deletion label is not a member, the label is removed and the operation is cancelled.
  • Notification: the notify-lifecycle-approvers.yml workflow automatically mentions the team when a delete-repo issue is opened.

CODEOWNERS

The admin repo uses CODEOWNERS to require review from the platform-lifecycle-approvers team on changes to critical files:

PathOwner
.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 main requires a PR with at least 1 approval
  • Bypass actors: eigenoid-settings-bot and OrgAdmin
  • This is necessary because the admin repo is excluded from org-level rulesets (to prevent safe-settings lockout)

How to modify org defaults

  1. Edit .github/settings.yml in the admin repo
  2. Open a PR → safe-settings runs a dry-run and shows what would change as a check on the PR
  3. 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"
yaml

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
yaml

Cloud Run — Service management

Service details

FieldValue
Serviceeigenoid-safe-settings
Regioneurope-west1
GCP Projecteigenoid-prd
Min instances1 (avoids cold start — GitHub webhook timeout is 10s)
Max instances3
Port3000
URLhttps://eigenoid-safe-settings-{hash}.europe-west1.run.app
IaCeigenoid/iac-platform (Terraform)

Environment variables

VariableValueSource
NODE_ENVproductionInline
ADMIN_REPOplatform-settingsInline
LOG_LEVELinfoInline
CRON0 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_REPO must be just the repo name (platform-settings), NOT owner/repo.

View logs

gcloud run services logs read eigenoid-safe-settings \
--region europe-west1 \
--project eigenoid-prd \
--limit 50
bash

For detailed debugging (temporary):

gcloud run services update eigenoid-safe-settings \
--region europe-west1 --project eigenoid-prd \
--update-env-vars "LOG_LEVEL=debug"
bash

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
bash

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
bash

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

ComponentEstimated 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

  1. Docker multi-arch on Apple Silicon: --platform linux/amd64 may be ignored. Use an explicit digest.
  2. ADMIN_REPO is just the name: do not use owner/repo, only platform-settings.
  3. Rulesets with ~ALL include the admin repo: exclude platform-settings from rulesets to prevent lockout.
  4. Legacy branch protection persists: after removing branches: from the YAML, delete manually via API.
  5. Webhook path: must be /api/github/webhooks, not root /.
  6. Drift is push-triggered: safe-settings only processes pushes that modify config files. CRON covers the rest.
  7. PRIVATE_KEY must be raw PEM: Probot rejects base64. Use the PEM file directly.
  8. Rulesets with target: repository: rules for repository policies use specific types (repository_create, repository_delete, repository_transfer), not creation/deletion — those are for branch/tag targets.
  9. enforcement: "evaluate" is not available for repository policies: unlike branch/tag rulesets, repository rulesets do not support "evaluate" (audit-only) mode. Only active or disabled.
  10. Admin repo needs its own rulesets: since platform-settings is excluded from org-level rulesets, it needs repo-level rulesets to protect main (requires PR + approval).