octoops
v1.27.0
Published
Declarative GitHub repo configuration using the gh CLI
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.jsonRespects 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-checkedtemplate: "owner/repo"— create the repo from a template repo. Only used at create time. Mutually exclusive withinit(templates already have content)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 an org's GitHub Packages by package name:
{
"org": "my-org",
"githubPackages": [
{ "name": "@my-org/foo", "visibility": "private" },
{ "name": "my-tool", "visibility": "public" }
]
}Fields:
name— required, the package name (with@scope/prefix if scoped).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; removing an entry from config leaves the package's visibility untouched (octoops won't flip it back).
Runners
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: [...] }— required CI checks;checksis strings or{ context, integrationId }filePathRestrictions: ["..."]— glob restrictions on which file paths can changerequiredWorkflows: [{ path, repositoryId, ref }]— required GitHub Actions workflowsbypassActors: [...]— 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
