A globally installable CLI for the GitHub operations the REST and GraphQL APIs do not expose: flipping container package visibility, deleting packages in bulk, managing package-level permissions, and switching a repository's code security settings, the dependency graph among them.
GitHub has no endpoint for any of those. The only place they exist is the web UI, so gh-manager drives a real browser for the writes, and uses the API for the reads and for verifying that a write actually landed. Actions secrets and branch protections use supported API writes.
npm i -g @link-foundation/gh-manager
gh-manager auth login
gh-manager package public box box-dind --org link-foundation-
Organization, repository and environment Actions secrets: encrypted storage, organization fallback and Actions-log health checks.
-
Protect every branch of an organization, user or repository against deletion and force pushes, with safe API fallbacks.
-
One login, reused: a dedicated Chrome profile in
~/.gh-manager/survives SSO and 2FA, which a personal access token cannot carry -
Hybrid strategy: API reads, browser writes, API re-read to verify
-
Whatever GitHub left out of its API: the dependency graph toggle has no REST or GraphQL endpoint at all, so the settings page is the only way to set it — and the SBOM endpoint is the only way to prove it is on
-
No false "no packages found": an empty API listing falls back to enumerating the packages the browser can see
-
Safe bulk operations: patterns are resolved against the packages that exist, the resolved list is always printed, and nothing destructive runs without a confirmation or an explicit
--yes -
Meaningful exit codes: a pipeline can branch on the reason, not the text
-
Loud failures: an unexpected page writes a screenshot and the HTML to
~/.gh-manager/logs/and exits non-zero
npm i -g @link-foundation/gh-manager
gh-manager --versionNode.js 20 or newer, plus a browser you already have. The session is an
installed Chrome started by
browser-commander with a
dedicated --user-data-dir; --channel msedge|brave|chromium picks another
one, and --engine picks the automation engine (playwright by default).
# Sign in once. A browser window opens; finish the login (SSO, 2FA, whatever
# your organization requires) and the session is stored in the managed profile.
gh-manager auth login
# Remember the organization so later commands do not repeat --org.
gh-manager config set org link-foundation
# What is there?
gh-manager package list
# Make two packages public, verified through the API afterwards.
gh-manager package public box box-dind
# Delete everything matching a glob, showing the plan first.
gh-manager package delete --pattern 'box-test-*' --dry-run
gh-manager package delete --pattern 'box-test-*'
# Turn on the dependency graph a dependency-review workflow needs.
gh-manager security dependency-graph link-foundation/gh-manager --enable| Operation | Read | Write | Verify |
|---|---|---|---|
| List packages | API → browser | — | — |
| Change visibility | API | Browser | API, then page |
| Delete a package | API | Browser | API, then page |
| List or change permissions | Browser | Browser | Page re-read |
| Read code security settings | API → browser | — | — |
| Set the dependency graph | SBOM probe | Browser | SBOM, then page |
| Set another security setting | API | API → browser | API, then page |
| Protect branches | API | API | Ruleset and effective branch rules |
GET /orgs/{org}/packages?package_type=container answers with an empty array
for a token without the package scopes. gh-manager treats an empty listing as
"the API cannot see them", not as "there are none", and enumerates the packages
page in the browser instead. The output says which source was used.
Verification is equally explicit. After a write, the API is re-read until it
agrees, for up to 15 seconds. If the API cannot see the package at all — no
token, or a token without scopes — that is reported as an absence of evidence
and the page itself is read instead. A change that cannot be confirmed either
way fails with VERIFICATION_FAILED (exit code 6); it is never reported as a
success.
The dependency graph is the one setting with no endpoint on either side. Its
state is probed with GET /repos/{owner}/{repo}/dependency-graph/sbom, which
answers with a document while the graph is on and 404 while it is off. GitHub
builds that document in the background, so a 404 right after the toggle was
flipped is not evidence that the flip failed; when the SBOM has not appeared
within the verification window, the settings page is read again and the command
says which of the two confirmed the change.
The grammar is gh-manager <domain> <verb> [targets...] [flags]. Run
gh-manager --help for the domain list, or gh-manager <domain> --help for a
domain.
gh-manager protect --org link-foundation
gh-manager protect --user konard
gh-manager protect link-foundation/gh-manager
gh-manager protect --org link-foundation --dry-run
gh-manager protect link-foundation/gh-manager --name guard --rule pull_request --yes
gh-manager protect link-foundation/gh-manager --from rules.jsonCreates an active branch ruleset named protection, covering ~ALL branches
with no exclusions or bypass actors. Its two default rules restrict deletions
and block force pushes. Normal pushes, branch creation, pull requests, and
release bots remain allowed. Stricter rules are additive and opt-in: repeat
--rule for pull_request (one approval), required_signatures,
required_linear_history, creation or update. Rules needing custom
parameters use --from with a JSON file:
{
"name": "protection",
"rules": [
{
"type": "required_status_checks",
"parameters": {
"required_status_checks": [{ "context": "build" }],
"strict_required_status_checks_policy": true
}
}
]
}The default rules are always retained. JSON accepts name, rules, and
optional target: "branch" and enforcement: "active"; coverage and bypass
actors stay fixed to protect all branches. --name overrides the JSON name.
For an organization, the command first tries one organization ruleset covering
all current and future repositories. GitHub's actual API response determines
availability; a permissions or plan denial is explained before falling back
to repository rulesets. Organization rulesets require org admin access and
admin:org for classic tokens (gh auth refresh -s admin:org). Repository
rulesets require repository admin access or Administration write permission;
private repositories require appropriate token access. The command uses an
API token, resolved in the same way as other commands.
For a user, it lists owned public repositories and, when the token belongs to that user, also enumerates owned private repositories through the authenticated API. To include private repositories, use that user's token. Archived repositories are skipped with a reason; forks are identified separately and protected when writable. Repository enumeration follows all pagination pages.
An existing active ruleset covering all branches with the requested rules and
no bypass actors reports already protected. A local ruleset with the selected
name is updated only after showing its before/after diff and confirming. Extra
rules, check requirements and review requirements are preserved. Conflicting
parameters fail with guidance to choose a separate name. Inherited rulesets
are never edited. A concurrent edit detected before writing stops that update.
If repository rulesets are unavailable, the command plans classic protection for every current branch, preserving its checks, reviews and restrictions. Classic protection cannot cover future branches; rerun to include new ones. If classic protection is also unavailable, the repository is reported as a failure and the bulk run continues. Extra rules that cannot be represented safely by this fallback are reported without writing.
Bulk changes and updates require confirmation, or --yes in noninteractive
runs. The resolved list and actions appear before consent; a fallback plan is
shown and confirmed separately. --dry-run only reads and prints the plan,
including each repository's create, update, already protected or skipped
status. A dry run cannot predict an error GitHub returns only on creation.
--json returns the plan/results as one JSON document. Writes are verified by
re-reading the ruleset and one branch's effective rules in each repository.
An empty repository verifies the ruleset and reports that no branch is available
to probe. Classic protection is re-read for every branch. --timeout <seconds>
sets the verification window (default 15); failed verification exits 6.
Library consumers can use createProtectionManager({ rest }) with
plan(target, options) or protect(target, options):
const manager = createProtectionManager({ rest: createRestClient({ token }) });
const plan = await manager.plan({ org: 'link-foundation' });
const result = await manager.protect(
{ repo: { owner: 'link-foundation', name: 'gh-manager' } },
{ dryRun: true }
);For bulk writes or updates, pass a confirm(plan) callback that returns consent
and an optional onPlan(plan) callback to display the reviewed plan. Inspect
repositories for per-repository failures and their exitCode, as one failure
does not cancel the rest. See the read-only organization preview.
gh-manager auth login # open a browser window and wait for the sign-in
gh-manager auth status # show the signed-in account and the API token in use
gh-manager auth logout # delete the managed browser profileauth login needs a visible window, so it refuses --headless. It waits up to
300 seconds by default; raise that with --timeout <seconds>.
gh-manager package list --org link-foundation
gh-manager package public box box-dind --org link-foundation
gh-manager package private --pattern 'box*' --org link-foundation
gh-manager package internal box --org link-foundation
gh-manager package delete box-test --org link-foundation --dry-run--account <login> targets a user account instead of an organization.
--package-type <type> selects the ecosystem (container by default).
delete always asks for a confirmation, even for a single explicitly named
package. The visibility verbs ask only when the list came from a pattern,
because a typed name is already a decision. --yes answers in advance, which
is what a non-interactive run needs; without a TTY and without --yes, the
command aborts rather than assuming consent.
gh-manager permissions list --pattern 'box*' --org link-foundation
gh-manager permissions grant box --team maintainers --role write --org link-foundation
gh-manager permissions revoke --pattern 'box*' --user someone --org link-foundation
gh-manager permissions sync --from policy.json --org link-foundation --dry-runRoles are read, write, and admin. --team and --user may be repeated.
revoke always asks; grant asks for pattern runs.
Because access is usually spoken about as a property of a package, the same
verbs are also reachable under package, and the two spellings are the same
command:
gh-manager package permissions list --pattern 'box*' --org link-foundationgh-manager security status link-foundation/gh-manager
gh-manager security dependency-graph link-foundation/gh-manager --enable
gh-manager security vulnerability-alerts link-foundation/gh-manager --enable --yes
gh-manager security automated-security-fixes link-foundation/gh-manager --enable
gh-manager security secret-scanning link-foundation/gh-manager --enable
gh-manager security push-protection link-foundation/gh-manager --disableThese are the toggles of /settings/security_analysis. Targets are
repositories, written as <owner>/<repo> or as <repo> together with --org
or --account; a pattern is refused, so every repository is named.
The dependency graph is why the domain exists. A repository whose graph is off
fails actions/dependency-review with "Dependency review is not supported on
this repository", and GitHub publishes no REST endpoint, no GraphQL mutation,
and no gh command for the toggle — only the page. gh-manager clicks it and
then asks for the SBOM the graph makes possible, because that document is the
only evidence the graph is on.
Every change asks for a confirmation, however the repository was named: turning
a protection off removes it, and turning one on can start alerting a whole
organization. --yes answers in advance, and --dry-run prints the plan and
stops.
A setting that already has the wanted state is reported as such and exits 0,
so re-running a command is never an error. --json reports which of the two
happened, along with what performed the change and what proved it:
$ gh-manager security dependency-graph link-foundation/gh-manager --enable --yes --json
[
{
"target": "link-foundation/gh-manager",
"ok": true,
"feature": "dependency-graph",
"state": "enabled",
"changed": true,
"changedBy": "browser",
"verifiedBy": "api"
}
]Two settings depend on another one, and GitHub refuses them with a bare 422
when the dependency is missing, so gh-manager names the missing one before
anything is touched: Dependabot security updates need Dependabot alerts, and
push protection needs secret scanning.
A toggle an organization or enterprise policy owns cannot be flipped from a
repository at all. gh-manager refuses it with the screenshot and the page HTML
in ~/.gh-manager/logs/ and a non-zero exit, rather than reporting a change it
did not make. To set these switches across an organization, GitHub does offer an
API: code security configurations.
Actions secrets use the authenticated GitHub REST API and libsodium sealed-box encryption. Values enter through stdin or caller callbacks and never appear in results, dry runs or debug logs. Secret names, reasons, validation and failure patterns belong to the caller.
# Try selected organization scope first; fall back to each repository on refusal.
gh-manager secret ensure CUSTOM_TOKEN --org link-foundation --repos gh-manager,project < token.txt
gh-manager secret list --org link-foundation --repo gh-manager --json
gh-manager secret get-metadata CUSTOM_TOKEN --org link-foundation
# Explicit repository or environment storage.
gh-manager secret set CUSTOM_TOKEN --repo link-foundation/project < token.txt
gh-manager secret set CUSTOM_TOKEN --repo link-foundation/project --env production < token.txt
# Inspect the latest default-branch runs of workflows referencing this name.
gh-manager secret health CUSTOM_TOKEN --org link-foundation --failure-pattern 'CUSTOM_AUTH_ERROR' --json
gh-manager secret health CUSTOM_TOKEN --repo link-foundation/project --json
# Record a replacement's registry expiry when storing it.
gh-manager secret set CUSTOM_TOKEN --org link-foundation --repos project --expires-at '<ISO-date>' < token.txt
# Dispatch a workflow, or rerun its latest run, wait, then classify the evidence.
gh-manager secret test CUSTOM_TOKEN --repo link-foundation/project --timeout 300
# A caller can explicitly report a broken secret after inspecting its health.
gh-manager secret ensure CUSTOM_TOKEN --repo link-foundation/project --broken --reason 'Actions authentication failure' < token.txt
gh-manager secret audit --org link-foundation --json
gh-manager secret delete CUSTOM_TOKEN --repo link-foundation/project --dry-run
# Cleanup takes explicit names and a reason; execution requires a verification callback.
gh-manager secret cleanup OLD_TOKEN --repo link-foundation/project --reason 'Caller migration' --validator ./validator.mjs --dry-runensure leaves existing secrets unchanged when health is ok or unknown, unless a caller validator or recorded expiry establishes that replacement is needed. Organization ensure uses selected visibility and --repos; on organization permission, plan or personal-account refusal it reports path: "repository", a fallback reason, and one result per repository. Explicit set overwrites the chosen scope. Repository secrets override organization secrets with the same name.
Health returns ok, auth-failing, expiring, expired or unknown, plus workflow/job/step evidence. It reads the companion NAME_EXPIRES_AT Actions variable and includes expiresAt and a separate expiry assessment with its source scope. A date within seven days reports expiring even when the last publish passed; a date at or before the current time reports expired. --rotate-before (seconds) or library option rotateBeforeMs changes the window. Expired metadata takes precedence over authentication failures; authentication failures take precedence over an upcoming expiry. Missing organization metadata emits an "Expiry unknown; record it with secret set ... --expires-at" warning to stderr and the JSON warnings array, including when repository health finds a visible organization secret. Malformed and unreadable metadata have distinct reasons; workflow evidence remains available. Repository health reads repository metadata first, then accessible organization variables when there is no repository secret override.
Only a failed step that uses the secret and has a matching log line is auth-failing; generic patterns include 401, 403, unauthorized, bad credentials, expired and invalid token. Successful secret steps can be ok even when unrelated steps fail. No runs, skipped steps, unresolved names, unreadable evidence and non-authentication failures are unknown. Static dot and bracket references and environment aliases are supported. Health follows local and remote reusable workflows, explicit secret aliases, secrets: inherit and nested calls. Local calls use the caller's run commit; remote calls use the SHA in the run's referenced_workflows or an explicit commit SHA. Missing pins, unreadable calls, cycles and ambiguous names remain unknown. Called jobs with environments are resolved when readable environment metadata confirms no same-name override; overrides, dynamic names and unreadable environment metadata remain unknown. Skipped reusable invocations report workflow-call-not-executed. Resolution is bounded to ten workflow levels and 100 visits. Dynamic references, secret values forwarded as inputs, and composite action internals require separate inspection. Logs are GitHub-masked and additionally redact common credential forms.
Health selects the latest run of each referencing workflow on the repository's default branch; --branch overrides that selection. Expired logs (HTTP 410) produce unknown evidence with reason: "logs-expired"; absent/deleted logs (HTTP 404) use reason: "logs-unavailable". Runs older than 90 days are reported as expired without fetching jobs or logs. Set --retention-days (or library option retentionDays) to your repository/organization retention period. A recent rerun uses its updated timestamp. Missing timestamps leave availability to the API. GitHub's log retention settings can differ between repositories.
Organization health and failure scans continue after repository errors. JSON results include each affected repository's error and unknown status, alongside available evidence. Progress (n/total repositories) and a repository-error count go to stderr, keeping stdout parseable under --json. An organization inventory failure still raises an error because there is no repository list to scan.
secret test uses workflow_dispatch when supported, otherwise reruns the last run. --input name=value supplies dispatch inputs. Polling is bounded by --timeout (seconds); a timeout returns unknown evidence. A rerun must have a newer attempt before its result is accepted. Health/testing operate on organization/repository scope, not environment-specific secret attribution.
All writes support --dry-run. Set/delete dry runs make no requests; ensure/cleanup read metadata but never read stdin or invoke callbacks. Delete/cleanup request confirmation unless --yes is supplied. --org plus --repo filters list/get-metadata/audit; storage uses one scope. --env requires --repo.
--validator ./validator.mjs is a generic local ES module with optional validate, acquire, revokePrevious, and cleanup verify exports. gh-manager runs these hooks without deciding what they check. Callbacks receive secret name, scope and metadata; candidate validation receives the value only in memory. Callback errors are sanitized. Optional caller expiry is recorded in NAME_EXPIRES_AT at matching scope/access; --rotate-before changes the seven-day rotation window. Secret and expiry writes are separate requests, so a metadata failure can leave the new ciphertext stored; revocation does not run after failed verification.
Audit parses default-branch workflow YAML, ignoring comments. Dynamic references, unreadable workflows and partial organization inventories make unmatched names only possibly unused; audit never deletes them. RELEASE_PR_TOKEN users can request a GitHub App plan:
gh-manager secret setup-app --org link-foundation --dry-run
gh-manager secret setup-app --org link-foundation --app-id 123 --repos gh-manager < app-key.pemOrganization secrets need admin:org (and repo for private repositories), or fine-grained Organization secrets permissions. Repository secrets need Secrets permissions; environment secrets need Environments permissions. Companion metadata needs Variables permissions. Private workflow data needs Actions read permission; dispatch/rerun needs Actions write.
For secret set, ensure and setup-app, an organization HTTP 403 with a token discovered from gh auth token starts gh auth refresh -h github.com -s admin:org once in an interactive terminal, reads the refreshed token and retries the denied request. Authentication prompts go to stderr so --json stays parseable. The user completes GitHub's authentication flow; no manual command restart is needed. Explicit tokens, environment tokens, unattended processes and dry runs retain permission recovery guidance. This route requests the required scope; it does not use the organization browser form.
Discover repositories, selected files, Actions runs and log evidence without cloning:
gh-manager repo list --org link-foundation --json
gh-manager repo list --user konard --include-archived --include-forks --json
gh-manager repo files link-foundation/project --match '**/manifest.json' --match '.github/workflows/*.yml' --content
gh-manager runs list link-foundation/project --workflow release.yml --branch main --status failure
gh-manager runs logs https://github.com/link-foundation/project/actions/runs/123 --grep 'CALLER_ERROR'
gh-manager runs logs 123 --repo link-foundation/project --grep 'CALLER_ERROR'
gh-manager runs failures --org link-foundation --grep 'CALLER_ERROR' --jsonRepository listing omits archived repositories and forks by default. File globs support *, ? and **; content is read by immutable blob SHA, with subtree traversal when GitHub truncates a recursive tree. Run listing follows pagination. runs failures inspects the latest default-branch run of each workflow before checking failure logs, so a recovered workflow is not reported from an older failure. It shares health's --retention-days cutoff.
runs failures outputs an object with matches (workflow failures with matching lines), unavailableLogs (counts grouped by repository and unavailable-log reason), errors (repository/workflow errors), and repositories (matching-line, unavailable-log and error counts for every scanned repository). Matching evidence is retained when another job's log is unavailable. Missing logs contribute counts without placeholder jobs or text. An unavailable/expired whole run counts as one; otherwise each missing job log counts as one. The library's runs.failures() keeps its detailed array of failure/unknown findings; runs.failureReport() returns the compact CLI report.
runs logs keeps its array of matched lines and includes typed { status: "logs-expired" | "logs-unavailable", runId, url, ... } entries for unavailable logs. Library rest.text and runs.jobLog return a string on success or { status, httpStatus, path } for HTTP 404/410; callers should narrow the result before parsing text. Authentication, rate-limit and server errors remain errors and are collected by bulk scans. Signed download URLs and API tokens never appear in unavailable-log results.
The package entry exports typed secrets, health, repos, and runs factory aliases, plus createSecretManager, createSecretHealth, createRepoManager, and createRunManager. These services use an injected REST client and are supported for programmatic use:
import {
createRestClient,
secrets,
health,
repos,
runs,
} from '@link-foundation/gh-manager';
const options = { rest: createRestClient({ token: process.env.GH_TOKEN }) };
const repositories = await repos(options).list({ org: 'link-foundation' });
const files = await repos(options).files('link-foundation/project', {
match: ['**/manifest.json', '.github/workflows/*.yml'],
content: true,
});
const state = await health(options).health('CUSTOM_TOKEN', {
scope: { repo: { owner: 'link-foundation', name: 'project' } },
failurePatterns: ['CALLER_AUTH_ERROR'],
});See the programmatic secret cycle, API types, and the requirement/research report. Factory options accept rest and optional debug logging; health additionally accepts injectable clock/sleep. CLI timeouts use seconds; library polling durations use milliseconds. API permission errors carry HTTP status; unreadable health evidence is unknown.
Migration: built-in publishing-policy maps/functions and --registry were removed. Replace policy queries in the caller, pass explicit cleanup names/reason and a generic verify hook, and use SecretCallbackContext instead of RegistrySecretContext. Existing GitHub Packages and browser APIs remain available. gh-manager never invokes package-registry-manager; callers own their publishing integrations.
gh-manager config list
gh-manager config set org link-foundation
gh-manager config get org
gh-manager config unset orgconfig list also prints the path of the file it read.
Stored keys: org, account, engine, channel, headless, packageType.
Flags always win over what is stored.
Targets are package names, or a --pattern. The two cannot be combined, and a
command that gets neither is a usage error.
--pattern 'box*'is a glob by default:*matches any run of characters,?matches exactly one, and every other character is literal. A glob is anchored, sobox*matchesbox-dindbutdindmatches nothing.--regexswitches the same flag to a JavaScript regular expression, which is not anchored:--pattern 'box' --regexmatchessandboxtoo. Anchor it yourself when that matters:--pattern '^box(-dind)?$' --regex.- Both forms match case-insensitively.
- A pattern is always expanded against the packages that actually exist, and the resolved list is printed before anything is touched.
- A pattern that matches nothing exits with
NO_MATCHES(4) and names the pattern and how many packages were considered. It never silently succeeds. - An over-broad pattern (
*,**,.*,.+,^.*$) is refused unless--allis passed, so a typo cannot expand to "everything". --dry-runprints the plan and stops before acting.
permissions sync --from policy.json converges package access onto a file:
{
"packages": [
{
"pattern": "box*",
"teams": { "maintainers": "admin", "reviewers": "read" },
"users": { "konard": "write" }
},
{
"names": ["gh-manager"],
"exclusive": false,
"teams": { "maintainers": "write" }
}
]
}Each entry selects packages by name, names, or pattern. Grantees the
entry omits are revoked, which is what makes the file a policy and not a wish
list; set "exclusive": false when an entry should only add. Roles are
case-insensitive. Combine with --dry-run to see the grants and revokes a file
implies before applying it.
| Code | Name | Meaning |
|---|---|---|
| 0 | SUCCESS |
Every requested operation completed and was verified |
| 1 | FAILURE |
An operation failed (browser, GitHub, unexpected UI) |
| 2 | USAGE |
The command line was wrong |
| 3 | AUTH |
No usable browser session or token |
| 4 | NO_MATCHES |
The targets or pattern resolved to nothing |
| 5 | ABORTED |
A confirmation was declined |
| 6 | VERIFICATION_FAILED |
The action ran but could not be confirmed |
In a bulk run one failure does not cancel the rest: the remaining packages are still processed, the failures are listed, and the exit code reports the first one.
~/.gh-manager/
├── config.json # stored defaults
├── chrome-profile/ # the persistent browser profile
└── logs/ # failure screenshots and page dumps
Override the location with --app-dir <path> or GH_MANAGER_HOME, which is
what a CI runner or a second account needs.
Two independent credentials, used for different things.
The browser session performs every write. It is created once by
gh-manager auth login and stored in the managed Chrome profile. gh-manager
never sees a password.
The API token is only used for reads and verification, and is optional — without one, gh-manager falls back to reading pages. It is resolved in this order:
--token <value>GH_MANAGER_TOKEN,GH_TOKEN,GITHUB_TOKENgh auth token, so an existing GitHub CLI login just works
gh-manager auth status prints which source was used.
--headless runs the browser without a window, and every verb except
auth login supports it. A CI run needs three things:
- An application directory holding a profile that is already signed in. Point
GH_MANAGER_HOMEat it, or restore it from a secret; the profile is the credential, so treat it as one. --yes, because a destructive verb refuses to assume consent when there is no TTY.- A token in the environment, so changes are verified through the API rather than through a page read.
GH_MANAGER_HOME=/secure/gh-manager \
gh-manager package public box --org link-foundation --headless --yesIf the profile has expired, the run fails with AUTH (3) and a screenshot,
which is the signal to refresh it with an interactive gh-manager auth login.
Every page interaction waits for a condition and gives up with a clear message
instead of sleeping and hoping. When a page does not look the way the driver
expects, the failure writes <label>.png and <label>.html to
~/.gh-manager/logs/ and names them in the error.
--verboseprints the navigation and page-state trace.--jsonprints machine-readable output forpackage list,permissions list,config list,security status, and everysecuritychange verb.- All the GitHub selectors live in
src/browser/selectors.js, so a UI change is a one-file fix.
- Package visibility is a container-registry concept; npm and other
ecosystems expose a different settings page, so
containeris both the default and the tested path. - The browser drivers target the current GitHub UI. A redesign needs
src/browser/selectors.jsupdated; the tests intests/browser-*.test.jsrun against fixture pages and will fail loudly when a driver stops matching. auth logincannot run headless, by design.- The dependency graph can only be confirmed through the SBOM endpoint, which
documents no
refparameter: it answers for the default branch, and only once GitHub has built the document. A change the SBOM has not caught up with is confirmed by re-reading the settings page, and the output says so. - Repository-level security toggles are refused when an organization or enterprise policy owns them. That is GitHub's rule, not a gh-manager limitation; the organization-wide equivalent is the code security configurations API.
The package is also importable, which is how the tests and
examples/basic-usage.js exercise the pure parts (pattern resolution, policy
parsing, access diffing) with no browser and no token:
import {
matchPackageNames,
parsePolicy,
runCli,
} from '@link-foundation/gh-manager';runCli(argv, options) returns an exit code and never calls process.exit, so
a whole command line can be run in process.
examples/enable-dependency-graph.js does exactly that, turning the dependency
graph on for a list of repositories and branching on the code each one returns.
# Install dependencies
bun install
# Run tests
bun test --timeout 30000
# Or with other runtimes:
npm test
deno test --allow-read --allow-write --allow-env
# Lint code
bun run lint
# Format code
bun run format
# Check all (lint + format + zero new duplication relative to origin/main)
bun run check
# Try the pure parts without a browser
node examples/basic-usage.js
# Turn the dependency graph on for one or more repositories
node examples/enable-dependency-graph.js owner/repo
# See what the API can say about a repository's security settings
GITHUB_TOKEN=$(gh auth token) node experiments/security-settings-endpoints.mjs owner/repo
# Build the universal React example app
npm install --prefix examples/universal-app
npm run example:web:build
npm run example:desktop:packageThe duplication check compares code with origin/main and rejects new clones of at least 50 tokens. Fetch origin/main before running it. Existing duplication is reported without blocking changes; an empty scan fails.
.
├── .changeset/ # Changeset configuration
├── .github/workflows/ # GitHub Actions CI/CD
├── .husky/ # Git hooks (pre-commit)
├── bin/gh-manager.js # CLI entry point
├── examples/ # Usage examples
│ └── universal-app/ # React + GitHub Pages + Electron + Capacitor app
├── scripts/ # Build and release scripts
├── src/
│ ├── browser/ # Page drivers and every GitHub selector
│ ├── cli/ # Argument parsing, prompts, entry point
│ ├── domains/ # Command domains: auth, package, permissions, security, config
│ ├── github/ # REST client and token discovery
│ ├── packages/ # The hybrid API/browser gateway
│ ├── permissions/ # Policy parsing and access diffing
│ ├── security/ # Code security settings: features, API, gateway
│ ├── index.js # Library entry point
│ └── index.d.ts # TypeScript definitions
├── tests/ # Test files
├── eslint.config.js # ESLint configuration
├── .prettierrc # Prettier configuration
├── bunfig.toml # Bun configuration
├── deno.json # Deno configuration
└── package.json # Node.js package manifest
gh-manager runs on all major JavaScript runtimes:
- Bun: Primary runtime with highest performance, uses native test support (
bun test) - Node.js: Alternative runtime, uses built-in test runner (
node --test) - Deno: Secure runtime with built-in TypeScript support (
deno test)
The test-anywhere framework provides a unified testing API that works identically across all runtimes.
While package.json is the source of truth for dependencies, any of these work:
- bun: Primary choice, uses
bun.lockb - npm: Uses
package-lock.json - yarn: Uses
yarn.lock - pnpm: Uses
pnpm-lock.yaml - deno: Uses
deno.jsonfor configuration
Note: package-lock.json is not committed by default to allow any package manager.
examples/universal-app is a Vite React app that imports matchPackageNames
and isOverBroadPattern from src/patterns.js and lets you try a pattern against
a list of package names, including the over-broad check that protects bulk
operations. The same static build is used by:
- GitHub Pages (
npm run example:web:build) - Electron desktop packaging (
npm run example:desktop:package) - Capacitor Android/iOS sync (
npm run example:mobile:sync)
The example app has its own package.json and lockfile, so React, Electron,
and Capacitor stay out of the published CLI package.
See examples/universal-app/README.md for local web, desktop, Android, and iOS testing instructions.
- ESLint: Configured with recommended rules + Prettier integration
- Prettier: Consistent code formatting
- Husky + lint-staged: Pre-commit hooks ensure code quality
- File size limit: Files must stay under 1500 lines for maintainability (enforced via ESLint and CI)
The release workflow uses Changesets for version management:
- Creating a changeset: Run
bun run changesetto document changes - PR validation: CI checks for valid changeset in each PR
- Automated versioning: Merging to
maintriggers version bump - npm publishing: Automated via OIDC trusted publishing (no tokens needed)
- Optional Docker Hub publishing: When configured, waits for the exact npm version and tags the Docker image with that version
- GitHub releases: Auto-created with formatted release notes
First publish: Use
package-registry-manager setup --registry npm --package @link-foundation/gh-manager --executeto sign in through the browser, publish the first version and attach trusted publishing. Release jobs use OIDC and require no stored npm token.
Two manual release modes are available via GitHub Actions:
- Instant release: Immediately bump version and publish
- Changeset PR: Create a PR with changeset for review
The GitHub Actions workflow (.github/workflows/release.yml) implements a fast-fail pipeline:
Fast checks (~7-30s each, run first for fastest feedback):
- Test compilation: Syntax-checks all
.mjsfiles withnode --check - Lint, format & secrets scan: ESLint, Prettier, jscpd, and secretlint for credential leak detection
- File line limits: Enforces the 1500-line limit on JavaScript (
.js,.mjs,.cjs) and Markdown (.md) files plusrelease.yml - Changeset check: Validates PR has exactly one changeset (added by that PR)
- Version check: Blocks manual version changes in
package.json - Documentation validation: Checks required doc files (doc line limits are enforced by the file line limits check)
Slow checks (only run after all fast checks pass):
- Test matrix: 3 runtimes × 3 OS = 9 test combinations
- Broken link checks: Validates all links in Markdown/HTML files (separate workflow)
Release (on merge to main):
- Changeset merge: Combines multiple pending changesets at release time
- Release: Automated versioning and npm publishing
- Optional Docker publish: Publishes Docker Hub
latestand npm-version tags after the npm package is visible
Every CI job declares an explicit timeout-minutes so hung steps fail
in minutes instead of reaching the GitHub Actions default of six hours.
Fast checks use 5-10 minute caps, release jobs use 30 minutes, and the
link checker uses 10 minutes for external network variance.
That cap is a backstop, never the deadline: GitHub reports a job it
kills as cancelled, not failed. Long steps therefore own an
explicit budget via scripts/run-with-budget-warning.sh, which warns at
70% of the budget and fails the step with exit code 124 when it expires.
See CI-TIMEOUT-BUDGETS.md.
Individual tests are also capped inside supported runners:
npm test runs node --test --test-timeout=30000, and the CI Bun
runner uses bun test --timeout 30000. Both bound a single test, not
the suite, which is why the suite budget above exists. Deno does not
provide a single global per-test timeout flag, so Deno tests are
protected by their step budget and the matrix job backstop.
See BEST-PRACTICES.md for detailed explanations of each practice.
The CI/CD pipeline is designed to handle concurrent PRs gracefully:
-
PR Validation: Only validates changesets added by the current PR, not pre-existing ones from other merged PRs. This prevents false failures when multiple PRs merge before a release cycle completes.
-
Release-time Merging: If multiple changesets exist when releasing, they are automatically merged into a single changeset with:
- The highest version bump type (major > minor > patch)
- All descriptions preserved in chronological order
This design decouples PR validation from the need to pull changes from the default branch, reducing conflicts and ensuring that even if CI/CD fails, all unpublished changesets will still get published when the error is resolved.
The example-app.yml workflow deploys the universal example app to GitHub
Pages on every push to main. Before the first run on main in a new
repository created from this template, open Settings → Pages and set
Source = GitHub Actions. This is a one-time manual step and cannot be
configured from a workflow because the Pages source defaults to
Deploy from a branch. Without it, the pages-deploy job fails on
actions/deploy-pages with Get Pages site failed /
Failed to create deployment. After flipping the source, the workflow
provisions the Pages site on its first run.
The same example-app.yml workflow contains a preview-regen job that boots
the built example app in a headless Chromium via
browser-commander +
Playwright and writes fresh screenshots to
docs/screenshots/example-app/example-app-{locale}-{theme}.png on every
push to main (and on workflow_dispatch). Any drift is committed back to
main with [skip ci] so README/site images never go stale between
releases. The job runs in the official Playwright container with the browser
already installed, avoiding CI stalls from live Chromium downloads.
The same script is available locally:
npm install --prefix examples/universal-app
npm run example:web:preview-images
# Verbose probe of <html data-theme>, <html lang>, and PNG signatures:
PREVIEW_VERBOSE=1 npm run example:web:preview-imagesThe matrix defaults to {en, ru} × {light, dark}. The shipped example app
has no localization or theme toggle yet, so every cell currently renders
the same UI — when a fork adds either, the matrix produces real per-cell
variants without script edits.
The link checker workflow (.github/workflows/links.yml) validates all links in Markdown and HTML files:
- Detection: Uses lychee to scan all
*.mdand*.htmlfiles - Web Archive fallback: For any broken links found, automatically checks the Wayback Machine for archived versions
- Actionable suggestions: Reports one of three outcomes for each broken link:
- Archived: Suggests the Web Archive URL as a replacement
- Not archived: Clearly reports the link is unrecoverable
- Scheduled checks: Runs weekly to catch links that break over time (even if no files changed)
- Issue creation: On scheduled runs, creates a GitHub Issue with the full broken links report
Add regex patterns to .lycheeignore to exclude URLs from checks (e.g., local dev URLs, example.com, known rate-limited sites).
The published name lives in package.json (@link-foundation/gh-manager) and
in .changeset/config.json. Release scripts read it from package.json at
runtime, so there are no package-name constants to keep in sync.
Docker publishing is disabled by default. To enable it for a project that ships
a Docker image, add a Dockerfile and configure these GitHub Actions settings:
| Setting | Type | Description |
|---|---|---|
DOCKERHUB_IMAGE |
Variable | Docker Hub image name, for example namespace/image. This enables Docker publishing. |
DOCKERHUB_USERNAME |
Variable | Docker Hub username used by docker/login-action. |
DOCKERHUB_TOKEN |
Secret | Docker Hub access token used for registry authentication. |
DOCKER_CONTEXT |
Variable, optional | Docker build context. Defaults to .. |
DOCKERFILE |
Variable, optional | Dockerfile path. Defaults to ./Dockerfile. |
When enabled, the release workflow waits until the exact published npm version
is visible in the npm registry, then publishes Docker Hub tags for latest and
that same version. The Docker build also receives NPM_PACKAGE_VERSION as a
build argument so Dockerfiles can install the matching published package.
Customize ESLint in eslint.config.js. Current configuration:
- ES Modules support
- Prettier integration
- No console restrictions (common in CLI tools)
- Strict equality enforcement
- Async/await best practices
- Strict unused variables rule: No exceptions - all unused variables, arguments, and caught errors must be removed (no
_prefix exceptions)
Configured in .prettierrc:
- Single quotes
- Semicolons
- 2-space indentation
- 80-character line width
- ES5 trailing commas
- LF line endings
| Script | Description |
|---|---|
bun test --timeout 30000 |
Run tests with Bun and a 30s per-test cap |
npm test |
Run tests with Node.js and a 30s per-test cap |
bun run lint |
Check code with ESLint |
bun run lint:fix |
Fix ESLint issues automatically |
bun run format |
Format code with Prettier |
bun run format:check |
Check formatting without changing files |
bun run check |
Run all checks (lint + format) |
npm run example:web:dev |
Start the universal app Vite dev server |
npm run example:web:build |
Build the universal app static web bundle |
npm run example:web:preview-images |
Regenerate preview screenshots via browser-commander |
npm run example:desktop:package |
Package the Electron desktop app locally |
npm run example:mobile:sync |
Build and sync the app bundle into Capacitor projects |
bun run changeset |
Create a new changeset |
See CONTRIBUTING.md for detailed contribution guidelines.
Quick steps:
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Make your changes
- Create a changeset:
bun run changeset - Commit your changes (pre-commit hooks will run automatically)
- Push and create a Pull Request
This template implements CI/CD best practices for AI-driven development. See BEST-PRACTICES.md for details on:
- File size limits for AI readability
- Automated formatting and linting
- Multi-runtime and cross-platform testing
- Changeset-based versioning
- Concurrency control for CI/CD pipelines
Unlicense - Public Domain