
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
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 octoops
octoops apply config.json
octoops apply --dry-run config.json
octoops apply --audit config.json
Import 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,teams
Seed state from an existing config (skips GitHub API calls):
octoops seed config.json
Resync state from live GitHub (use this if your state file got out of sync):
octoops resync config.json
Apply 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.json
Each 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-name
The 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.
{
"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.
Top-level repo fields for basic settings:
description — repo descriptionhomepage — repo homepage urlprivate: true|false — visibilityinternal: true — internal visibility (Enterprise only, overrides private)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-readme to gh repo create. On an existing empty repo (no branches), creates README.md retroactively. 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 with init (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.
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:
extends is a string or array of paths. Paths are relative to the file declaring them.team-a.state.json), not the shared file.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:
extends and defaults accept a string or an array. Array form is left-to-right, rightmost wins: extends: ["a", "b"] starts from a and layers b on top.extends walked) before being used as a layer.merging.squashOnly without clobbering merging.deleteBranchOnMerge).rulesets: "default-rules" still resolves through presets).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"] }
]
}
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.
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:
read:packages and write:packages scopes — gh auth refresh -h github.com -s read:packages,write:packages if you hit a 403.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 after github.com/apps/, same one used in ruleset bypassActors: [{ app: "..." }]).allRepos: true — the installation grants access to all current and future repos.repos: ["..."] — explicit list of repo names in the org.Behavior:
allRepos vs repos) doesn't match the installation's current scope — that toggle is UI-only, with a link in the error to the settings page.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.Token requirements — this feature has stricter auth than the rest of octoops:
gh auth login mints 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:userIf 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.json
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 — when visibility is "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.
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 prints skip-environments unless enterprise: true is setpreventSelfReview: true — block the actor who triggered a deployment from approving it themselvessecrets — see "Secrets" below"environments": [
{ "name": "npm", "reviewers": [{ "team": "release" }], "preventSelfReview": true }
]
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:
skip-secrets and leaves existing secrets/state alone (so you can .gitignore the secrets file and only run apply where it's present)[salt, hmac]); state never holds plaintext, and hashes can't be correlated across secrets/repos/state filesgh secret set over stdin (never on the command line, never logged)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; checks is strings or { context, integrationId }; doNotEnforceOnCreate: true skips enforcing the checks when a branch/tag is created (defaults to false)filePathRestrictions: ["..."] — glob restrictions on which file paths can changerequiredWorkflows: [{ path, repositoryId, ref }] — required GitHub Actions workflowsdoNotEnforceWorkflowsOnCreate: true — skip enforcing requiredWorkflows when a branch/tag is created (defaults to false)bypassActors: [...] — entries: { team }, { username }, { app } (GitHub App slug, e.g. "dependabot"), or { type: "OrganizationAdmin" }, each with optional mode: "always"|"pull_request"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 (default 1; 0 adds 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" }
]
}
{
"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" }
]
}
{
"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.
{
"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.
{
"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.
{
"org": "my-org",
"repos": [
{
"name": "internal-tool",
"private": true,
"topics": ["internal", "tooling"],
"teams": [
{ "name": "platform", "permission": "admin" },
{ "name": "everyone", "permission": "read" }
]
}
]
}
{
"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.
You can also deprecate a package:
"npm": {
"package": "my-module",
"deprecated": "Use @my-org/my-module instead"
}
deprecated — optional. A string is used as the deprecation message. true uses the default message This package is deprecated. false (or "") clears an existing deprecation. Omit it entirely to leave deprecation unmanaged.Octoops only runs npm deprecate when the live message differs from the config, so re-applies stay quiet (npm-deprecate / npm-undeprecate log lines). If the package isn't on npm yet you'll see a skip-deprecate line.
{
"org": "my-org",
"repos": [
{
"name": "my-lib",
"pypi": {
"package": "my-lib",
"trustedPublishing": {
"workflow": "publish.yml",
"environment": "pypi"
}
}
}
]
}
Fields:
package — optional, defaults to the repo name. The PyPI project name.trustedPublishing.workflow — required. The GitHub Actions workflow file that publishes (e.g. publish.yml).trustedPublishing.environment — optional. The deployment environment the workflow uses. Referenced only — octoops does not create it. If you want a protected environment (reviewers etc.), declare it under environments as usual; the pypi block just names it.Behavior:
Unlike npm, PyPI has no API token or CLI to create a trusted publisher — it's configured through a logged-in browser session only. So octoops can't reconcile the PyPI side. Instead, apply prints the exact settings to enter once on PyPI (a pypi-manual log line) and records the config in state so subsequent applies stay quiet:
pypi-manual my-lib (configure trusted publisher on PyPI)
Owner: my-org
Repository: my-lib
Workflow: publish.yml
Environment: pypi
Existing project: https://pypi.org/manage/project/my-lib/settings/publishing/
New project: https://pypi.org/manage/account/publishing/ (add as a "pending publisher")
For a project that already exists on PyPI, add the publisher on its own publishing settings page. For a project that doesn't exist yet, add it as a "pending publisher" on the account page — the first publish then creates the project (no placeholder release needed). For array form (multiple packages from one repo), pass a list of objects like npm.
Your workflow needs permissions: id-token: write and should use pypa/gh-action-pypi-publish. octoops does not manage the workflow file.
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.
This was written by a silly robot so be aware of mistakes.
Apache-2.0
FAQs
Declarative GitHub repo configuration using the gh CLI
The npm package octoops receives a total of 453 weekly downloads. As such, octoops popularity was classified as not popular.
We found that octoops demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.