octoops
v1.32.0
Published
Declarative GitHub repo configuration using the gh CLI
Downloads
1,116
Readme
octoops
Declarative GitHub repo configuration using the gh CLI.
Maintain a JSON config describing desired state for your org's repos. Running octoops reconciles actual state with desired state using gh api calls. Idempotent and safe to run repeatedly.
npm install -g octoopsUsage
octoops apply config.json
octoops apply --dry-run config.json
octoops apply --audit config.jsonImport an existing org into a config file:
octoops import my-org > config.json
octoops import my-org -o config.json
octoops import my-org --only members
octoops import my-org --only members,teamsSeed state from an existing config (skips GitHub API calls):
octoops seed config.jsonResync state from live GitHub (use this if your state file got out of sync):
octoops resync config.jsonApply a manifest that orchestrates multiple configs in one shot:
// org-manifest.json
{
"includes": [
"./team-a/config.json",
"./team-b/config.json",
"./tooling/config.json"
]
}octoops apply org-manifest.jsonEach include is resolved relative to the manifest file and applied in order. Each gets its own <name>.state.json next to it — manifests don't share state. Manifests can include other manifests recursively (cycles error out). A manifest is orchestration-only; it can't also declare org/repos/teams/etc. — octoops errors if both are present so the file's role stays clear.
Rename a repo on GitHub and rewrite the config + state file in one shot:
octoops rename config.json old-name new-name
octoops rename --dry-run config.json old-name new-nameThe repo entry must live in the file you pass — extends/shared files are not searched. The state key (<org>/<old>) is rewritten to <org>/<new> so subsequent applies see no diff.
Respects GitHub API rate limits automatically.
Configuration
{
"org": "my-org",
"presets": {
"standard-teams": [
{ "name": "backend", "permission": "write" },
{ "name": "devops", "permission": "admin" }
]
},
"repos": [
{
"name": "my-service",
"description": "Does the thing",
"private": true,
"merging": { "squashOnly": true, "deleteBranchOnMerge": true },
"topics": ["nodejs"],
"teams": "standard-teams",
"branchProtection": [
{ "branch": "main", "enforceAdmins": true, "requiredReviews": { "approvals": 1 } }
],
"environments": [{ "name": "production", "reviewers": [{ "team": "devops" }] }],
"rulesets": [
{
"name": "protect-workflows",
"include": ["~ALL"],
"filePathRestrictions": [".github/workflows/**"],
"bypassActors": [{ "team": "devops" }]
}
],
"npm": {
"trustedPublishing": { "workflow": "release.yml", "environment": "production" }
}
}
]
}Any repo field that accepts an object or array can be a string instead, referencing a key in presets. Supported fields: merging, teams, topics, branchProtection, environments, rulesets, npm. This lets you define a config once and reuse it across repos.
For array fields you can also mix preset references with inline objects element-by-element:
"presets": {
"integrity": { "name": "integrity", "preventForcePush": true },
"tags": { "name": "tags", "target": "tag", "preventCreation": true }
},
"repos": [
{ "name": "api", "rulesets": ["integrity", "tags", { "name": "ad-hoc", "preventDeletion": true }] }
]Each string element is looked up in presets (it can resolve to a single object or an array — arrays are spread). Inline objects pass through unchanged.
Repo settings
Top-level repo fields for basic settings:
description— repo descriptionhomepage— repo homepage urlprivate: true|false— visibilityinternal: true— internal visibility (Enterprise only, overridesprivate)defaultBranch— default branch name (e.g."main")wiki: true|false— enable/disable repo wikiprojects: true|false— enable/disable repo projectsarchived: true— archive the repo (skips further reconcile). Removing this from the config (when state has it) unarchives the repoinit: true— initialize the repo with a README so the default branch exists. On create, passes--add-readmetogh repo create. On an existing empty repo (no branches), createsREADME.mdretroactively. Once initialized, recorded in state and not re-checkedactionsAccess—"none"|"organization"|"enterprise". Controls which other repos' GitHub Actions workflows can access this repo's actions and reusable workflows (Settings → Actions → General → Access). Only relevant for private repos.template— overloaded by type:"owner/repo"— create the repo from a template repo. Only used at create time. Mutually exclusive withinit(templates already have content).true/false— mark this repo as a template (so others can "Use this template"). Settable on existing repos via the settings PATCH.
merging—{ squashOnly, deleteBranchOnMerge }
Omitting a field leaves the current GitHub value untouched. Setting it makes octoops reconcile it.
Sharing config across files
A config file can extends one or more other files. This lets multiple teams share a common base of defaults, presets, etc:
// shared.json
{
"defaults": {
"base": { "private": true, "wiki": false }
},
"presets": {
"default-rules": [
{ "name": "main", "preventForcePush": true, "requirePR": { "approvals": 1 } }
]
}
}// team-a.json
{
"extends": "../shared.json",
"org": "my-org",
"presets": {
"team-a-rules": [...]
},
"repos": [
{ "name": "api", "defaults": "base" }
]
}Resolution rules:
extendsis a string or array of paths. Paths are relative to the file declaring them.- Files are loaded recursively (extended files can extend further). Cycles error.
- Deep-merge: objects merge per key, arrays/scalars replace. So adding a preset in the leaf adds it to the inherited set; redefining a preset key replaces just that one.
- The leaf's own fields override the merged base.
- State files belong to the leaf config (
team-a.state.json), not the shared file.
Defaults
Define named defaults at the top level and let repos opt in via defaults: "name". Defaults can chain via extends:
{
"org": "my-org",
"defaults": {
"base": {
"private": true,
"merging": { "squashOnly": true, "deleteBranchOnMerge": true },
"wiki": false,
"projects": false
},
"service": {
"extends": "base",
"teams": [{ "name": "backend", "permission": "write" }],
"rulesets": "default-rules"
},
"oss-module": {
"extends": "base",
"private": false,
"rulesets": "oss-rules"
}
},
"repos": [
{ "name": "api", "defaults": "service" },
{ "name": "web", "defaults": "service", "topics": ["frontend"] },
{ "name": "lib-foo", "defaults": "oss-module" },
{ "name": "internal-thing" }
]
}Resolution rules:
extendsanddefaultsaccept a string or an array. Array form is left-to-right, rightmost wins:extends: ["a", "b"]starts fromaand layersbon top.- Each name is fully resolved (its own
extendswalked) before being used as a layer. - Objects deep-merge (so a repo can override
merging.squashOnlywithout clobberingmerging.deleteBranchOnMerge). - Arrays and scalars replace.
- Repo fields override the resolved defaults.
- Cycles or unknown names are an error.
- After defaults are applied, preset resolution runs as usual (so
rulesets: "default-rules"still resolves throughpresets).
Example with mixin composition:
{
"defaults": {
"private-base": { "private": true, "wiki": false },
"squash-merging": { "merging": { "squashOnly": true, "deleteBranchOnMerge": true } },
"service": {
"extends": ["private-base", "squash-merging"],
"teams": [{ "name": "backend", "permission": "write" }]
}
},
"repos": [
{ "name": "api", "defaults": "service" },
{ "name": "frontend", "defaults": ["service", "frontend-mixin"] }
]
}GitHub Advanced Security
Per-repo, under security:
{
"name": "my-repo",
"security": {
"advancedSecurity": true,
"secretScanning": true,
"secretScanningPushProtection": true,
"secretScanningValidityChecks": true,
"dependabotSecurityUpdates": true,
"codeScanningDefaultSetup": true
}
}Maps to the repo's security_and_analysis settings; codeScanningDefaultSetup toggles the code-scanning default setup via its own endpoint. If GHAS isn't available on the plan/repo (private without GHAS, forks, etc.) the relevant calls log skip-code-scanning and continue.
Org-level defaults for newly-created repos can be set at the top level:
{
"org": "my-org",
"security": {
"advancedSecurity": true,
"secretScanning": true,
"secretScanningPushProtection": true,
"dependabotAlerts": true,
"dependabotSecurityUpdates": true
}
}These map to GitHub's *_enabled_for_new_repositories fields on the org settings — they only affect freshly-created repos; existing repos need the per-repo block.
GitHub Packages visibility
Set the visibility of a repo's GitHub Packages by attaching githubPackages to the repo entry. Accepts a single object or an array:
{
"repos": [
{
"name": "my-tool",
"githubPackages": { "visibility": "private" }
},
{
"name": "foo",
"githubPackages": [
{ "name": "@my-org/foo", "visibility": "private" },
{ "name": "@my-org/foo-cli", "visibility": "private" }
]
}
]
}Fields:
name— optional, defaults to the repo name. Use the full package name (with@scope/prefix for scoped packages).visibility— required, one of"public"|"private"|"internal".type— optional, defaults to"npm". Other types (e.g."container") work the same way via the same API.
Behavior:
- Apply errors if the package isn't published yet on GitHub Packages — publish it first (e.g. via your trusted-publishing workflow), then octoops can set visibility.
- No-op when current visibility already matches.
- Tracked in state on the repo; removing an entry from config leaves the package's visibility untouched (octoops won't flip it back).
- Your gh auth needs
read:packagesandwrite:packagesscopes —gh auth refresh -h github.com -s read:packages,write:packagesif you hit a 403.
GitHub Apps installation scope
Declare which repos each org-installed GitHub App can access. Octoops looks up the installation by app slug, then adds/removes repos from its selected list to match:
{
"org": "my-org",
"apps": [
{ "name": "dependabot", "allRepos": true },
{ "name": "holepunchto", "repos": ["repo-a", "repo-b"] }
]
}Fields per entry:
name— required, the app's slug (the part aftergithub.com/apps/, same one used in rulesetbypassActors: [{ app: "..." }]).- Exactly one of:
allRepos: true— the installation grants access to all current and future repos.repos: ["..."]— explicit list of repo names in the org.
Behavior:
- Errors if the app isn't installed on the org (installation itself is UI-only).
- Errors if the config's scope (
allReposvsrepos) doesn't match the installation's current scope — that toggle is UI-only, with a link in the error to the settings page. - For
repos: [...], octoops state-tracks the applied list and PUTs/DELETEs individual repos to converge with the desired one. Idempotent PUTs; DELETE 404s are treated as already-removed. - Diffed against a normalized (repos sorted) shape, so a no-op apply skips the API calls entirely.
Token requirements — this feature has stricter auth than the rest of octoops:
- You must be the Organization Owner (not just admin/member).
- You must be the user who originally installed the app on the org — GitHub scopes installation-modification permissions to the installer, not to any org owner.
- You must supply a classic Personal Access Token (not the token
gh auth loginmints via OAuth). GitHub CLI's OAuth token is scoped to the "GitHub CLI" app and can't modify other apps' installations, even with correct org role and scopes.
Create the classic PAT at github.com/settings/tokens → Tokens (classic) → Generate new token, with scopes:
admin:orgreporead:user
If the org enforces SAML SSO, click Enable SSO → Authorize for the org on the token page.
Then run apply with GH_TOKEN set for that invocation so your regular gh auth stays untouched:
GH_TOKEN=ghp_yourpathere octoops apply your-config.jsonRunners
Manage self-hosted runner groups and GitHub-hosted larger runners at the org level. Octoops doesn't provision the underlying machines for self-hosted runners — those still register with GitHub the usual way — but it manages how they're grouped and which repos can use them.
{
"org": "my-org",
"runnerGroups": {
"ci": {
"visibility": "selected",
"repos": ["api", "web"],
"allowsPublicRepositories": false,
"restrictedToWorkflows": ["my-org/ci/.github/workflows/*"]
},
"release": {
"visibility": "private"
}
},
"hostedRunners": {
"build-8core": {
"size": "8-core",
"image": "ubuntu-22.04",
"runnerGroup": "ci",
"maximumRunners": 3
}
},
"pruneOfflineRunners": true
}Runner group fields:
visibility—"all"|"selected"|"private". Default"all".repos— whenvisibilityis"selected", the list of repo names that can use this group.allowsPublicRepositories: true|false— whether public repos in the org can use this group.restrictedToWorkflows: ["my-org/repo/.github/workflows/*"]— restrict to specific workflow refs.
Hosted runner fields:
size— required, e.g."4-core","8-core","16-core". Match what your plan exposes.image— required, image id or display name (e.g."ubuntu-22.04","windows-2022").runnerGroup— required, name of a runner group (octoops resolves to id).maximumRunners— optional cap on parallel runners.enableStaticIp: true— optional static IP allocation.
Other:
pruneOfflineRunners: true— remove offline/stale self-hosted runners at the org level on every apply.
Groups and hosted runners in state but not in config are deleted. Default runner groups (Default) are never deleted even if they're not in config.
Environments
Each entry in environments supports:
name— requiredreviewers: [{ team }]— required-reviewer teams (deployments are gated until one approves). On private repos this requires GitHub Enterprise; otherwise octoops printsskip-environmentsunlessenterprise: trueis setpreventSelfReview: true— block the actor who triggered a deployment from approving it themselvessecrets— see "Secrets" below
"environments": [
{ "name": "npm", "reviewers": [{ "team": "release" }], "preventSelfReview": true }
]Secrets
Reference a local dotenv-style file from a repo or environment:
{
"name": "my-repo",
"secrets": ".secrets",
"environments": [
{ "name": "production", "secrets": ".secrets.prod" }
]
}File format (KEY=value, # comments, optional quoting):
NPM_TOKEN=abc123
SLACK_WEBHOOK="https://hooks.slack.com/..."
# tokens
GH_TOKEN='ghp_...'Behavior:
- Paths resolve relative to the config file
- Missing file → octoops prints
skip-secretsand leaves existing secrets/state alone (so you can.gitignorethe secrets file and only run apply where it's present) - Each secret is hashed with a per-secret HMAC-SHA256 salt (
[salt, hmac]); state never holds plaintext, and hashes can't be correlated across secrets/repos/state files - Only secrets whose value changed are PUT to GitHub; secrets removed from the file are deleted from GitHub
- Values are sent to
gh secret setover stdin (never on the command line, never logged)
Ruleset fields
Each entry in rulesets supports:
name— requiredtarget—"branch"(default) or"tag"enforcement—"active"(default),"evaluate", or"disabled"include/exclude— branch/tag patterns. Defaults to["~DEFAULT_BRANCH"]. Use"~ALL"to match everythingpreventCreation: true— block creating matching branches/tagspreventUpdate: true— block updating matching branches/tagspreventDeletion: true— block branch/tag deletionpreventForcePush: true— block force pushesrequireLinearHistory: true— require linear commit history (no merge commits)requireSignedCommits: true— require signed commitsrequirePR: { approvals, dismissStale, codeOwners, lastPushApproval, resolveThreads, requiredReviewers }— require pull requestsrequirePR.requiredReviewers— see "Required reviewers" belowrequiredStatusChecks: { strict, checks: [...], doNotEnforceOnCreate }— required CI checks;checksis strings or{ context, integrationId };doNotEnforceOnCreate: trueskips enforcing the checks when a branch/tag is created (defaults tofalse)filePathRestrictions: ["..."]— glob restrictions on which file paths can changerequiredWorkflows: [{ path, repositoryId, ref }]— required GitHub Actions workflowsdoNotEnforceWorkflowsOnCreate: true— skip enforcingrequiredWorkflowswhen a branch/tag is created (defaults tofalse)bypassActors: [...]— entries:{ team },{ username },{ app }(GitHub App slug, e.g."dependabot"), or{ type: "OrganizationAdmin" }, each with optionalmode: "always"|"pull_request"
Required reviewers (beta)
requirePR.requiredReviewers lets you require specific teams to approve PRs that touch certain file paths. Each entry has:
team— name of an org team that must approvefilePatterns— array of fnmatch patterns; the team is required when a PR changes any matching fileminApprovals— minimum approvals from that team (default1;0adds the team as a reviewer without requiring approval)
Example: infra team must approve any change to Terraform files or infra/, with two approvals; security team must approve any change under auth/:
{
"name": "main-protection",
"include": ["~DEFAULT_BRANCH"],
"preventForcePush": true,
"requirePR": {
"approvals": 1,
"requiredReviewers": [
{ "team": "infra", "filePatterns": ["**/*.tf", "infra/**"], "minApprovals": 2 },
{ "team": "security", "filePatterns": ["auth/**"] }
]
}
}GitHub flags this API as beta — the parameter shape may change on their side.
{
"org": "my-org",
"presets": {
"default-rules": [
{
"name": "main",
"preventDeletion": true,
"preventForcePush": true,
"requirePR": { "approvals": 1 },
"bypassActors": [{ "type": "OrganizationAdmin" }]
}
]
},
"repos": [
{ "name": "api", "private": true, "rulesets": "default-rules" },
{ "name": "web", "private": true, "rulesets": "default-rules" },
{ "name": "docs", "private": false, "rulesets": "default-rules" }
]
}Lock down a set of public modules
{
"org": "my-org",
"presets": {
"oss-merging": { "squashOnly": true, "deleteBranchOnMerge": true },
"oss-protection": [
{
"name": "main-branch",
"include": ["~DEFAULT_BRANCH"],
"preventDeletion": true,
"preventForcePush": true,
"requirePR": { "approvals": 1, "dismissStale": true, "lastPushApproval": true }
}
]
},
"repos": [
{
"name": "module-a",
"private": false,
"merging": "oss-merging",
"rulesets": "oss-protection"
},
{
"name": "module-b",
"private": false,
"merging": "oss-merging",
"rulesets": "oss-protection"
},
{ "name": "module-c", "private": false, "merging": "oss-merging", "rulesets": "oss-protection" }
]
}Manage org members
{
"org": "my-org",
"admins": ["alice", "bob"],
"members": ["charlie", "dave", "eve"]
}admins and members are independent. If admins is present, only listed users will be admins. If members is present, only listed users will be members. Omit either to leave that role unmanaged.
Removing someone from the org also removes them from all teams. If that person is still listed in a team config, the next apply will re-add them. Make sure admins/members is the superset of everyone referenced in teams.
Manage org teams
{
"org": "my-org",
"teams": [
{
"name": "engineering",
"description": "All engineers",
"privacy": "closed",
"members": [
{ "username": "alice", "role": "maintainer" },
{ "username": "bob", "role": "member" }
]
},
{
"name": "backend",
"parent": "engineering",
"members": [
{ "username": "alice", "role": "maintainer" },
{ "username": "charlie", "role": "member" }
]
},
{
"name": "devops",
"parent": "engineering",
"members": [{ "username": "dave", "role": "maintainer" }]
}
],
"repos": [{ "name": "api", "teams": [{ "name": "backend", "permission": "write" }] }]
}Org teams are reconciled before repos. Parent teams should come before children in the array. Members not in the list are removed. Teams in state but not in config are deleted. Renaming a team will create the new team and delete the old one.
Add individual collaborators to a repo
{
"org": "my-org",
"repos": [
{
"name": "secret-project",
"private": true,
"collaborators": [
{ "username": "alice", "permission": "admin" },
{ "username": "bob", "permission": "write" }
]
}
]
}Only direct collaborators are managed. Org-level implicit access is ignored. Unlisted direct collaborators are removed.
Minimal repo with just teams and topics
{
"org": "my-org",
"repos": [
{
"name": "internal-tool",
"private": true,
"topics": ["internal", "tooling"],
"teams": [
{ "name": "platform", "permission": "admin" },
{ "name": "everyone", "permission": "read" }
]
}
]
}npm trusted publishing
{
"org": "my-org",
"repos": [
{
"name": "my-module",
"private": false,
"npm": {
"package": "my-module",
"trustedPublishing": {
"workflow": "publish.yml",
"environment": "npm"
}
}
}
]
}Sets up npm trusted publishing so GitHub Actions can publish via OIDC without npm tokens. If the package doesn't exist on npm yet, a placeholder 0.0.0 is published first. package defaults to the repo name if omitted, or to <scope>/<repo-name> if scope is set. Requires interactive npm authentication on first run.
{
"npm": { "scope": "@my-org", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } }
}…makes the package name default to @my-org/<repo-name>. The leading @ is optional; octoops adds it if missing. Defining package explicitly overrides scope.
For repos that publish multiple packages, use an array:
"npm": [
{ "package": "my-module", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } },
{ "package": "my-module-cli", "trustedPublishing": { "workflow": "publish.yml", "environment": "npm" } }
]You can also manage the package's npm maintainer list:
"npm": {
"package": "my-module",
"maintainers": ["alice", "bob", "charlie"],
"trustedPublishing": { "workflow": "publish.yml", "environment": "npm" }
}Octoops adds anyone in maintainers who isn't already an owner and removes anyone who is an owner but not in the list — except the caller (whoever ran octoops apply). The caller is never removed, even if they're not listed in maintainers; you'll see a npm-keep-self log line. This prevents you from locking yourself out by mistake.
Programmatic usage
const { apply, importOrg, seed } = require('octoops')
await apply(config, {
dry: false,
statePath: './config.state.json',
audit: true
})
const config = await importOrg('my-org')
const membersOnly = await importOrg('my-org', { only: ['members'] })
seed(config, { statePath: './config.state.json' })A state file is written next to the config to track what was last applied. On partial failure, completed steps are saved so the next run picks up where it left off.
Note
This was written by a silly robot so be aware of mistakes.
License
Apache-2.0
