Skip to content
singerbjPublic

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

gh-runner

Monorepo for @singerbj/gh-runner — a CLI that temporarily registers the machine you're sitting at as a GitHub Actions self-hosted runner for the repo you're standing in. It stays online for as long as the command runs, then deregisters and deletes itself.

cd ~/code/my-repo
npx @singerbj/gh-runner              # pick platforms from a menu
npx @singerbj/gh-runner mac linux    # or name them — one runner each, in parallel
npx @singerbj/gh-runner --all        # everything this machine can serve

Linux can come from a container, so any machine can serve it; macOS and Windows have to be native, so asking a Mac for a Windows runner is an error rather than a silent no-op.

It also audits .github/workflows first, so you find out that nothing targets runs-on: [self-hosted, gh-runner] before you sit waiting for a job that never arrives — and it offers to open a PR that fixes the YAML, prepared in a throwaway git worktree so your working tree is never touched.

That PR doesn't hand your CI to a laptop that might be closed. Every job stays on the GitHub-hosted runner it already had, and moves to your machine only while the repo can't start hosted jobs — out of Actions minutes, over a spending limit, a failed payment:

runs-on: ${{ vars.GH_RUNNER_LINUX || 'ubuntu-latest' }}

No job decides that — it couldn't, with no runner to start on. gh-runner notices GitHub refusing jobs, sets the variable while it's running, re-runs what was refused, and clears it when it stops or the minutes come back. The out-of-minutes simulation proves the whole cycle on every change, against a mocked GitHub.

What's in here

Path Package What it is
packages/gh-runner @singerbj/gh-runner The CLI and its programmatic API (TypeScript, ESM)
apps/web @gh-runner/web The docs site (VitePress, deployed to Pages)

Full CLI docs live in packages/gh-runner/README.md.

Setting up a new project is one command, npx @singerbj/gh-runner setup, which opens the workflow PR and registers nothing. Every release also ships:

The compose file and the prompt are in the npm package and attached to every GitHub release as docker-compose.yml and gh-runner-setup-prompt.md. The docs site embeds them straight from these files.

Development

Requires Node 20+.

npm install          # install the whole workspace (and the git hooks)
npm run dev          # watch-build the CLI and serve the docs site
npm run build        # turbo build every package
npm test             # turbo run the test suites
npm run typecheck    # turbo typecheck every package
npm run lint         # oxlint
npm run format       # oxfmt .
npm run audit        # better-npm-audit
npm run verify       # lint + format:check + typecheck + test, all of it

Checks

oxlint and oxfmt handle linting and formatting — both Rust, both fast enough to run on every commit. oxfmt owns every file type it supports (JS, TS, JSON); nothing else formats them, so there's no second formatter to disagree with.

A husky pre-commit hook runs all three checks:

pre-commit: oxlint
pre-commit: oxfmt
pre-commit: better-npm-audit

It's installed by npm install via the prepare script. The audit needs the network, so SKIP_AUDIT=1 git commit ... skips that one check when you're offline; git commit --no-verify skips everything.

better-npm-audit also runs every Monday at noon UTC. A scheduled run that fails quietly is worthless, so a failure opens a security-labelled issue (or comments on the open one) with the report attached.

The repo is a Turborepo; each task above fans out to the workspaces that define it, with caching between runs.

Every action is pinned to a commit SHA, with the version it came from in a trailing comment. A tag is a mutable pointer — @v5 is whatever actions/checkout last pushed there, and a compromised or repointed tag would run in a job that can push to main and publish to npm. A SHA can't move. Dependabot opens a weekly PR to move them deliberately, since a pin that nobody updates is its own kind of stale.

Trying the CLI locally

npm run build
node packages/gh-runner/dist/cli.js --help

# or link it onto your PATH
npm link --workspace @singerbj/gh-runner
gh-runner --repo owner/name

Releasing

main is the release branch, and the version bumps itself. Merge a change to packages/gh-runner and the Release workflow:

  1. typechecks, tests, and builds every package;
  2. asks npm what version is published and bumps the patch digit past it;
  3. commits that bump to main as Release vX.Y.Z;
  4. publishes with npm provenance;
  5. cuts a vX.Y.Z GitHub release with generated notes;
  6. builds ghcr.io/singerbj/gh-runner on a native amd64 and a native arm64 runner, from the version just published to npm, and tags the pair as X.Y.Z and latest;
  7. attaches docker-compose.yml (pinned to the X.Y.Z image) and gh-runner-setup-prompt.md to the release.

Merging never moves the major or minor. Those stay where you put them, and there are two ways to put them:

  • Set the version in packages/gh-runner/package.json. A version npm has never had is published exactly as written, with no bump commit — the way to pick a specific number. That includes one below what npm has: the version line was started over at 0.0.1 that way, with latest moved back to it explicitly (npm won't move it backwards on its own). A version npm already has is bumped past as usual.
  • Run the workflow from the Actions tab and choose minor or major. Both reset the patch to zero: from 0.0.7, minor gives 0.1.0 and major gives 1.0.0.

Either way, merges afterwards resume at the patch: 0.1.0, then 0.1.1, 0.1.2.

Nothing reads commit messages. An earlier version of this workflow looked for a [major] keyword, and the very commit that documented the keyword tripped it — 0.1.0 published as 1.0.0. A release trigger you can't write about is a bad trigger.

It runs as separate jobs, and the split is the point. npm ci and the test suite execute a few hundred third-party packages' code, and NPM_TOKEN would be readable by any one of them if it shared a job with them. So build has no secrets, and publish installs nothing — it publishes the tarball build already packed, which runs no lifecycle scripts. token goes first and does nothing but check the secret isn't empty, so a missing one fails in seconds instead of after a full build. The image jobs come after publish and never see NPM_TOKEN: they hold only packages: write, and build the image before they log in to the registry, so nothing the build installs sees that either.

Only changes under packages/gh-runner (plus package-lock.json) trigger a release. That includes the Dockerfile, the compose file and the setup prompt, so editing any of them ships a new release, so landing work on the docs site or the root README mints nothing. The bump commit is pushed with GITHUB_TOKEN, which by design starts no further workflow runs — a release can't set off another release.

If main is protected, allow the GitHub Actions bot to push to it, or the workflow stops before publishing and says so. Bumping the manifest by hand in the PR is the way through otherwise.

One-time setup for the image: GHCR creates every new package private, and a workflow token can't change that. After the first release, make it public at github.com/users/singerbj/packages/container/gh-runner/settings → Danger Zone → Change visibility. Until then the release run ends with a warning saying so.

Required repository secret: NPM_TOKEN, added at Settings → Secrets and variables → Actions.

Use a classic Automation token, or a granular token with Read and write on all packages. A granular token limited to selected packages can't publish @singerbj/gh-runner until it exists — npm only lets you select packages that are already there, so the very first publish of a new name fails with a 403 Forbidden that says nothing about scopes. (The other way through that chicken-and-egg: publish the first version by hand once, then scope a token — or Trusted Publishing — to the package that now exists.)

The workflow checks the secret exists before building the tarball, and translates a 403 into the explanation above rather than leaving you with npm's wording.

The docs site

apps/web is a VitePress site: a short home page plus the docs, one Markdown file per page under apps/web/docs/. The sidebar and nav live in apps/web/.vitepress/config.mts.

It deploys to GitHub Pages through the Pages workflow on every push to main that touches it, plus on demand from the Actions tab.

One-time setup: switch Pages on at Settings → Pages → Build and deployment → Source: GitHub Actions. A workflow can't do this for itself — creating a Pages site needs admin rights, and GITHUB_TOKEN doesn't have them — so the workflow checks first and tells you to click that if it's missing, rather than failing deep inside configure-pages.

The site is served at https://gh-runner.benjaminjsinger.com/, the custom domain set under Settings → Pages. The workflow builds with whatever base path configure-pages reports, so dropping the custom domain (and serving from singerbj.github.io/gh-runner/) needs no config change.

Security

gh-runner hands a CI job the machine it runs on, so it has a threat model worth reading: SECURITY.md covers what it guarantees, what it deliberately doesn't, and where to report a vulnerability.

License

MIT

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages