Run workflows without a container: line, share one workflow file across a team, let only maintainers decide where jobs run, run LLM checks on the author’s own tokens, and serve several repositories from one machine.
Container-runner mode
Run every job in a container, no container: needed
Workflows written for GitHub-hosted runners usually have no container: line. In container-runner mode they run unchanged. To switch an existing runner: ownci stop, then ownci setup --force --mode container-runner.
jobs:
build:
runs-on: [ownci] # no container: needed
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y jq && make test
Jobs without container: run inside the runner container, as your user ID, with passwordless sudo and the Docker CLI.
Jobs with container: behave exactly as in host mode: the job container is a sibling on your Docker daemon.
Every job gets a fresh container. Anything a job changes outside the workspace (apt-get install, /tmp, $HOME) is gone for the next one, and the workspace is wiped as in host mode. Expect a few seconds between jobs while the new container connects.
The default is the official ghcr.io/actions/actions-runner (Ubuntu 24.04), pinned by digest: about 2 GB, pulled once at setup. It has git, curl and the Docker CLI, but not the hosted runners’ preinstalled toolchains; actions/setup-* actions install what you need.
--image picks another one, at setup or for one session at start. It must contain bash, setpriv, useradd/groupadd/usermod, getent, stat, the docker CLI and ICU, and should contain git and sudo. setup refuses an image that lacks a required tool.
shell
docker build -t my/runner:22.04 -f e2e/images/ubuntu22.Dockerfile . # in a clone of the OwnCI repository
ownci setup --repo owner/name --mode container-runner --image my/runner:22.04
The runner directory must be mountable at its own path: a plain Linux path without symlinks, not under /mnt/c. The default data directory is fine.
Give each developer’s runner their own label (dev- plus their GitHub username) and let the workflow pick the runner of whoever triggered it:
shell
ownci setup --repo owner/name --labels dev-<your-github-username> # each developer, once
.github/workflows/ci.yml
jobs:
build:
runs-on: [ownci, "dev-${{ github.actor }}"] # the user who opened or pushed to the PR
container: node:22
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
If a developer’s runner is offline, their job waits in the queue. It never lands on someone else’s runner.
Bots such as Dependabot match no runner.
Each developer needs admin access to the repository to run ownci setup.
Use github.event.pull_request.user.login instead of github.actor to target the PR author rather than whoever pushed last.
Keep the dev- prefix. Labels are case-insensitive, and without a prefix a username can equal another runner label: a real GitHub account called ownci exists, and its jobs would become runs-on: [ownci, ownci], which every OwnCI runner on the repository matches.
The label routes jobs; it does not restrict who can reach a runner. Anyone who can change the workflow can point runs-on at another developer’s label. To let only maintainers decide, see the next section.
With on: pull_request, GitHub runs the workflow file from the PR, so a PR can change runs-on to any developer’s label. And anyone with write access can push a branch with a workflow of its own (on: push) that targets any runner on the repository. If maintainers should have full control over which jobs reach which machine:
Run CI from main’s workflow file with pull_request_target.It always uses the workflow from the base branch, so a PR cannot change runs-on; it still tests the PR’s code..github/workflows/ci.yml
# .github/workflows/ci.yml, on main
on:
pull_request_target:
branches: [main]
permissions:
contents: read # pull_request_target can otherwise get a write token
jobs:
build:
runs-on: [ownci, "dev-${{ github.event.pull_request.user.login }}"] # the PR author's machine
container: node:22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # the PR's code, not main's
persist-credentials: false
- run: npm ci && npm run build
Protect the workflow files on main. Require pull request reviews on main (branch protection or a branch ruleset) with Require review from Code Owners, and a CODEOWNERS entry such as/.github/workflows/ @owner/maintainers.
Block workflow changes on every other branch. A push ruleset withRestrict file paths set to .github/workflows/**, with maintainers on the bypass list, stops anyone from pushing a branch whose own workflow targets a runner. Push rulesets need GitHub Team or Enterprise and a private or internal repository. Without this step, anyone with write access can still reach every runner on the repository.
Don’t use secrets or caches in this workflow. The PR’s code runs in the same job, so anything the job can read, the PR can read, and a cache it saves is visible to main’s workflows.
pull_request_target ignores the fork pull request approval setting: it always runs. Keep the author’s dev- label in runs-on; a contributor without a runner of their own gets a job that waits in the queue and reaches no one’s machine. Never combine this trigger with runs-on: [ownci] alone.
Maintainers decide where a job runs, not what it runs. The PR’s scripts (npm ci, its tests) still run on the author’s machine. Anyone with write access who pushes to someone else’s PR branch runs their code on that author’s machine.
A PR that changes CI is tested with main’s workflow; the change applies once it is merged, and then reaches every developer’s machine. Review workflow changes like code.
A job can run an AI coding CLI headless: Claude Code (claude -p), Codex (codex exec) or GitHub Copilot CLI (copilot -p). With the author routing above, an LLM review of a pull request runs on its author’s machine and spends its author’s tokens. This is a recipe, not an OwnCI feature: OwnCI does nothing LLM-specific, and its end-to-end tests do not cover it.
Repository secrets are the wrong place for the key: they hold one shared key for everyone, and this workflow must not use secrets (see above). Keep each developer’s key on their own machine and mount it into the job container instead.
Each developer, once: put the key in a file at a path the whole team agrees on.The path must be absolute and the same on every machine, so not under your home directory.shell
Create it before the first job runs: if the file is missing, Docker creates an empty directory in its place and the job fails reading it (sudo rmdir /etc/ownci/llm.env, then create the file). To use a Claude subscription instead of an API key,claude setup-token prints a long-lived token: store it asCLAUDE_CODE_OAUTH_TOKEN=…. For Codex and Copilot, see their documentation for headless authentication. Check your provider’s terms before using a personal subscription in automation.
Run the check from main with pull_request_target, routed to the author’s runner as in the previous section:.github/workflows/ai-review.yml
# .github/workflows/ai-review.yml, on main
on:
pull_request_target:
branches: [main]
permissions:
contents: read
jobs:
ai-review:
if: contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: [ownci, "dev-${{ github.event.pull_request.user.login }}"] # the PR author's machine
container:
image: node:22
volumes:
- /etc/ownci/llm.env:/run/llm.env:ro # the author's key, from their machine
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 # for the diff against the base branch
persist-credentials: false
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- run: npm install -g @anthropic-ai/claude-code@2.1.316
- run: |
set -a; . /run/llm.env; set +a
git diff "origin/${{ github.base_ref }}...HEAD" \
| claude -p "Review this diff for bugs. Be brief." >> "$GITHUB_STEP_SUMMARY"
The review appears in the run’s summary, which needs no extra permission. Don’t give the jobpull-requests: write to post comments: the PR’s code runs in the same job and would get that token too. In container-runner mode, keep the container: line, because only a job container can mount a file from your machine.
The check is advisory, not a gate. It runs from the PR’s checkout on the author’s machine, so the author controls the repository’s prompts and skills (.claude/, CLAUDE.md) and, in the end, the result. Use it to help authors, not to approve merges.
The job can read the key. So can the PR’s code, and a prompt hidden in the diff can ask the agent to leak it. Anyone with write access who pushes to someone else’s PR branch runs their code next to that author’s key. Leave the agent’s tools restricted: in -p mode Claude Code refuses tools that need approval unless you allow them, so don’t add --dangerously-skip-permissions. Prefer a key with a spending limit.
Claude Code is installed on every run. The job container starts fresh, so nothing installed on your machine is in it. To skip the install, build an image FROM node:22 with Claude Code installed, push it to a registry and use it as image:; each machine then downloads it once.
Your own Claude setup stays on your machine. The job sees the repository’s .claude/ and CLAUDE.md from the checkout and the key from the mounted file, not your ~/.claude (login, settings, personal skills).
Every push spends tokens. Limit the check with paths: filters, a label, or by diffing only the files that matter. LLM output varies from run to run.