CI Versions¶
This project runs in two CI flavors. Detect which one you are in via environment
variables ($GITLAB_CI vs $GITHUB_ACTIONS) and behave accordingly.
This repo: GitHub source, GitLab mirror
The repository is developed on GitHub — its own CI (tests, image build,
advisor/agent) runs in .github/workflows/.
It is mirrored read-only to GitLab, whose only pipeline is a tagged
release that publishes the claude-agent component to the CI/CD Catalog. So
on GitLab you consume the component — you don't run this project's pipeline.
The table below describes how the agent behaves in each CI once you wire it
into your project.
| Aspect | Value |
|---|---|
| Runner | Rootless, unprivileged Podman on an OpenShift GitLab Runner |
| Pipeline config | include: the claude-agent component in your .gitlab-ci.yml |
| Detection | $GITLAB_CI == "true" |
| Telemetry | OTel Collector sidecar at http://localhost:4318 (mandatory) |
| Builds | Podman (podman build -t app-test .)— never Docker |
| Credentials | Zero-credential; no global Git/SSH keys or Elastic token |
| Aspect | Value |
|---|---|
| Runner | GitHub-hosted or self-hosted runner |
| Pipeline config | .github/workflows/*.yml |
| Detection | $GITHUB_ACTIONS == "true" |
| Telemetry | OTel Collector via workflow service/sidecar at http://localhost:4318 when configured |
| Builds | Prefer Podman to mirror GitLab; fall back to Docker only if the workflow provisions it |
| Credentials | Workflow-scoped GITHUB_TOKEN/secrets— do not read host OS env vars |
Setting the Anthropic API key¶
The agent needs an ANTHROPIC_API_KEY. Store it in the platform's secret store—
never in the repo, the pipeline YAML, or a component/action input.
UI — repo Settings → Secrets and variables → Actions → New repository secret (or an organization secret to share across repos):
| Field | Value |
|---|---|
| Name | ANTHROPIC_API_KEY |
| Secret | your sk-ant-… key |
CLI:
gh secret set ANTHROPIC_API_KEY # prompts for the value
# or: gh secret set ANTHROPIC_API_KEY --body "sk-ant-..." --repo bigg01/your-repo
Use it — pass it into the action via the secrets context (it's masked in
logs and only exposed to steps that reference it):
UI — project (or group, to share across a team) Settings → CI/CD → Variables → Add variable:
| Field | Value |
|---|---|
| Key | ANTHROPIC_API_KEY |
| Value | your sk-ant-… key |
| Flags | ✔ Masked, ✔ Protected, Expand variable reference off |
CLI (glab):
Use it — the component reads the variable by name (don't pass the key as an input); just include it:
Protected variable ⇒ protected ref
A GitLab Protected variable is only injected on protected branches/tags.
If a pipeline runs on an unprotected branch, ANTHROPIC_API_KEY is empty and the
job fails fast. Protect the branch, or drop the Protected flag for testing.
Zero-credential environment¶
The sandbox image ships with no ambient credentials— no global Git config,
no SSH keys, no cloud profiles, and no long-lived tokens baked in. The agent is
also instructed never to read host OS environment variables (see
CLAUDE.MD). Every
secret it needs is injected per run, scoped to that run by the CI platform's
secret store, and gone when the job ends. The mechanism differs by platform:
- Source— masked, protected CI/CD variables. The team's
ANTHROPIC_API_KEYand the write-scopedGIT_PUSH_TOKENare provided as CI/CD variables at the project or group level (one key shared across a team). Masked redacts them from job logs; protected restricts them to protected branches and tags. - Fail-fast, not silent. Both personalities guard on the key in
before_scriptand exit with instructions if it is missing— the team is explicitly asked to set it rather than hitting a cryptic mid-run error. - No host credentials. The job runs as an arbitrary non-root UID in a
rootless OpenShift runner pod; the only platform credentials present are
the ones GitLab grants the job (e.g.
$CI_REGISTRY_*for image push). - Boundary by absence. The read-only Advisor is issued no
GIT_PUSH_TOKENat all, so mutation is impossible even if attempted. - Optional— no stored secret at all. With the
OpenBao addon, the job mints a short-lived OIDC JWT
(
id_tokens:→BAO_JWT) and exchanges it for a lease-bound secret at runtime— nothing long-lived is stored in GitLab.
- Source— the
secretscontext.${{ secrets.ANTHROPIC_API_KEY }}is injected as an env var only into the steps that reference it and is auto-masked in logs. - Repo token is ephemeral. Writes use the per-job
GITHUB_TOKEN, freshly minted each run and expired when the job ends— never a personal token. - Least privilege per personality. The job's
permissions:block sets scope: the Advisor declarescontents: read(it literally cannot push), the Agent declarescontents: writeand only ever opens a new branch. - Optional— no stored secret at all. With
permissions: id-token: write, the OpenBao addon uses GitHub OIDC to mint a JWT exchanged for a vault secret at runtime— the same model as GitLab.
Same guarantee, two stores
On both platforms the secret is short-lived, scoped to the run, masked in logs, and never written to the image. Because the agent cannot reach the host OS environment, an escaped or hallucinated credential lookup finds nothing— and every action it does take is OTel-audited. This is what makes bypass-permissions ("YOLO") mode safe here.
Prefer short-lived Anthropic keys
Give CI a short-lived, rotated Anthropic API key rather than a permanent one— scope it to a dedicated workspace with a spend limit, rotate it on a schedule, and revoke it the moment a run leaks it. Better still, store no static key at all: have the OpenBao addon mint one with a short lease, or federate via OIDC so each run receives an ephemeral credential that expires on its own.
Why it matters — the API key is the one credential a leak actually monetizes: unlike the repo token (scoped, ephemeral, useless off this repo) it bills straight to your Anthropic account. A CI variable is exposed to every job, every log line, the build cache, and— on fork merge requests— to code you did not write; masking reduces but never eliminates that exposure. A key that is short-lived, workspace-scoped, and spend-capped shrinks both the window an exfiltrated key stays valid and the blast radius of spend if it is used before you notice.
Detecting the environment¶
if [ "$GITLAB_CI" = "true" ]; then
echo "Running under GitLab CI"
elif [ "$GITHUB_ACTIONS" = "true" ]; then
echo "Running under GitHub Actions"
fi
Personalities & triggers¶
The same image runs as one of two personalities, selected by the CI trigger. On
GitLab both are shipped by the component as two jobs — claude-advisor
(gated on merge_request_event) and claude-agent (gated on a non-empty
$CLAUDE_TASK) — so a single include: wires up the full flow; you don't
hand-write them (see Example pipelines).
On GitHub the analog is the event-driven workflow.
| Aspect | Value |
|---|---|
| Trigger | A @claude … comment on a PR/issue (GitHub issue_comment; GitLab via a comment-driven pipeline passing $CLAUDE_TASK) |
| Does | Applies a fix, commits, opens a new PR/MR branch |
| Token | Repository write— confined to new branches, never the default branch |
| Aspect | Value |
|---|---|
| Trigger | A PR/MR open or synchronize event (GitHub pull_request; GitLab merge_request_event) |
| Does | Lints, tests, flags bugs, posts a review comment— never mutates |
| Token | Repository read + permission to post review comments |
Run creation and review on different models or vendors
Because the two personalities do opposite jobs against a shared written spec, it makes sense to give them different models— or different vendors: the Agent creates on the strongest coding model, while the Advisor reviews with an independent one that doesn't share the creator's blind spots. See Different models for creation vs review.
Identity & attribution— who did what¶
Give every agent its own GitLab identity— a dedicated service account (bot user) whose access token it authenticates with. Never reuse a human's Personal Access Token, and never share one token across personalities or teams: if two agents push with the same credential, the audit log cannot tell them apart. The read-write Agent gets a write-scoped token; the read-only Advisor gets no push token at all (but still its own bot, so the review comments it posts are attributable).
Attribution then lands in three independent places that should always agree— if one disagrees, you have a misconfiguration:
- GitLab audit log + MR author → the bot user the access token belongs to.
git logauthor/committer → the git identity (set it to match the bot).- Commit trailers + OTel attributes (
claude.personality,ci.pipeline.id) → the specific run.
Naming example¶
One identity per (personality × team). For a read-write Agent on a payments
team:
| Thing | Convention | Example |
|---|---|---|
| GitLab service account (bot user) | claude-<personality>[-<team>] |
claude-agent-payments |
| Access token name (so you revoke the right one) | <bot>-<scope>-<created> |
claude-agent-payments-write-2026-06 |
| Git author name | Claude <Personality> · <team> |
Claude Agent · payments |
| Git author email (plus-addressed: unique yet routable) | claude-<personality>+<team>@<org> |
claude-agent+payments@acme.dev |
| Branch / MR | claude/<personality>/<pipeline-id>-<short-sha> |
claude/agent/84213-1a2b3c4 |
| Commit trailers (pin a change to one run) | Claude-Run, Claude-Model, Claude-Bot |
Claude-Run: gitlab/pipeline/84213/job/99001 |
So a commit the agent makes carries, for example:
feat: handle null customer in checkout
Claude-Personality: agent
Claude-Bot: claude-agent-payments
Claude-Run: gitlab/pipeline/84213/job/99001
Claude-Model: claude-sonnet-4-6
Map the git email to the bot
In GitLab, add the git author email as a (verified or noreply) email on the
bot user, so git log entries link back to the same account the audit log and
MR list show. Otherwise commits render as an unattributed author even though
the push itself was the bot— and your three sources no longer agree.
GitHub equivalent
The per-job GITHUB_TOKEN always authors as github-actions[bot]— shared
across every workflow, so it can't distinguish agents. For unique identities,
install a GitHub App per agent (app slug e.g. claude-agent[bot]) or issue
distinct fine-grained PATs, and set the same user.name / user.email and
commit-trailer convention. Attribution then comes from the App identity (audit
log + PR author) plus the trailers.
Example pipelines¶
GitLab CI— using the claude-agent component¶
This section has two halves: the reusable component you reference
(templates/claude-agent.yml),
and a runnable example you copy
(examples/gitlab/claude-ci-agent-test/).
Read the reference to know what the component does, then copy the example to get a
working pipeline.
1. Reference — the claude-agent component¶
A reusable GitLab CI/CD component
ships with this project at
templates/claude-agent.yml.
A single include: adds both personalities as jobs — at parity with the
GitHub workflow:
claude-advisor(read-only) runs automatically on everymerge_request_event: it reviews the diff and posts the verdict as an MR note.claude-agent(read-write) runs when$CLAUDE_TASKis non-empty (from thepromptinput, or a pipeline variable supplied by a manual run / trigger token / webhook — GitLab has no native "MR comment" trigger): it applies the change on a new branch (branch_prefix+ pipeline id) and opens a new MR, never the default branch.
Both jobs start an OTel Collector sidecar (nested Podman) when
ELASTIC_OTLP_ENDPOINT is set, emit per-run cost, optionally fetch secrets via
the OpenBao addon, and default to bypass-permissions
("YOLO") mode — safe here because the agent is fully contained.
Each job also publishes claude-result.json (the raw run output — usage, cost,
result) as a job artifact.
Claude runs in the job container; nested Podman is optional
claude runs directly in the job's container, which is the rootless
sandbox image (the runner starts the job in it via image:) — there is no
second podman run wrapping Claude. Nested Podman is used only when needed:
to start the OTel sidecar, and by the agent itself when a task builds or tests
app images. So a plain run needs no nested Podman. If your runner can't do
nested rootless Podman, leave ELASTIC_OTLP_ENDPOINT unset to skip the sidecar
— the agent still runs; only the exported audit trail is lost.
Inputs¶
| Input | Default | Description |
|---|---|---|
stage |
test |
Pipeline stage both jobs run in. |
image |
ghcr.io/bigg01/claude-ci-agent/claude-agent:0.1.0-alpha.13 |
Published sandbox image providing the Claude Code CLI + baked CI helpers. |
prompt |
"" |
Task handed to the agent personality. Leave empty for advisor-only use; a CLAUDE_TASK pipeline variable overrides it for ad-hoc agent runs. |
branch_prefix |
claude/task- |
Prefix for the branch the agent pushes (the pipeline id is appended, e.g. claude/task-1234). Keep it in sync with any advisor rules: that match on the branch name. |
model |
claude-sonnet-4-6 |
Claude model id passed to claude --model. |
api_key_variable |
ANTHROPIC_API_KEY |
Name of the masked, protected CI/CD variable holding your team's Anthropic key— never the key itself. The job fails fast if it is unset. |
token_variable |
GITLAB_TOKEN |
Name of the variable holding a GitLab token used to push the branch and open/comment on MRs. Needs write_repository and api scopes and at least the Developer role (a read-only token gets a 403 on push). CI_JOB_TOKEN is not sufficient. |
claude_args |
--dangerously-skip-permissions |
Extra flags for the claude CLI; set empty to require approvals. |
otel_endpoint |
http://localhost:4318 |
OTLP endpoint the agent exports to (the sidecar listens here). |
team |
default |
team.name resource attribute, for per-team cost attribution. |
bao_audience |
$CI_SERVER_URL |
Audience (aud) of the OIDC JWT minted for the optional OpenBao addon. |
Provide the key as a variable, not an input
Pass the variable name via api_key_variable, then create that masked,
protected CI/CD variable with your team's key. Component inputs are
interpolated as plaintext into the compiled pipeline, so passing the key
directly would leak it.
2. Usage — minimal examples/gitlab pipeline¶
This is the minimal way to use the component: set one variable and write a
single include:. The component ships both personalities, so the advisor
auto-runs on merge requests and the agent runs whenever you hand it a task — no
hand-written jobs.
Minimal here, advanced in spec-driven
This page shows the bare include. For the advanced version of the same
example — gating the agent behind a manual click, a spec-graded custom advisor
on a cheaper model, artifacts, and the full implement → review → merge loop —
see Spec-driven development. The runnable
examples/gitlab/claude-ci-agent-test/
project is that advanced form.
Step 1 — set the Anthropic key as a CI/CD variable (this is how the key is provided; it is never a component input). In the consuming project (or group): Settings → CI/CD → Variables → Add variable:
| Field | Value |
|---|---|
| Key | ANTHROPIC_API_KEY |
| Value | your sk-ant-… key |
| Flags | ✔ Masked, ✔ Protected, Expand variable reference off |
Step 2 — include the component in your .gitlab-ci.yml:
stages:
- test
include:
- component: $CI_SERVER_FQDN/<group>/claude-ci-agent/claude-agent@v0.1.0-alpha.13
inputs:
prompt: "Fix the failing unit tests and commit the change."
# api_key_variable: MY_KEY_NAME # only if your variable isn't ANTHROPIC_API_KEY
The component reads the variable by name at runtime and exports it for the
claude CLI — fail-fast if it's unset.
Replace the component path and pin a version
<group> must point at the GitLab project that hosts this component. Pin
@v0.1.0-alpha.13 to a released tag (or a commit SHA) for reproducible pipelines.
Protected variable ⇒ protected ref
A Protected variable is only injected on protected branches/tags. If the
pipeline runs on an unprotected branch, ANTHROPIC_API_KEY is empty and the job
fails fast — either protect the branch or drop the Protected flag.
GitHub Actions— using the claude-ci-agent action¶
The GitHub analog of the GitLab component is a reusable Docker container action
at action.yml.
It runs the agent inside the same sandbox image and takes the same kind of inputs.
Reference it from any workflow with uses::
name: Claude Agent
on: [workflow_dispatch]
jobs:
agent:
runs-on: ubuntu-latest
steps:
- uses: bigg01/claude-ci-agent@v1
with:
prompt: "Fix the failing unit tests and commit the change."
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
Inputs¶
| Input | Default | Description |
|---|---|---|
prompt |
(required) | The task prompt handed to the agent. |
anthropic_api_key |
(required) | Anthropic API key— pass from ${{ secrets.* }}, never inline. The action fails fast if empty. |
claude_args |
--dangerously-skip-permissions |
Extra flags for the claude CLI; set empty to require approvals. |
model |
claude-sonnet-4-6 |
Claude model id. |
otel_endpoint |
http://localhost:4318 |
OTLP endpoint of the OTel Collector sidecar. |
Two GitHub surfaces
action.yml— the reusable component above, for other repos..github/workflows/claude-agent.yml— this repo's event-driven workflow (build image → advisor on PRs → agent on@claudecomments), the analog of the GitLab pipeline.
For the reusable action, the secret is passed via the secrets context (auto-masked
in logs); the action itself never sees it as a plaintext input.
Complete scenario— GitLab + Jira, spec-driven¶
This ties the pieces together: a Jira issue is the spec, a status change kicks off GitLab, the read-write Agent implements it, the read-only Advisor grades the MR against that same spec, and the verdict flows back onto the ticket. The deeper rationale lives in Spec-driven development; this is the concrete GitLab wiring.
Timeline¶
- A PO writes acceptance criteria on
PROJ-142and moves it to AI-Ready. - A Jira Automation rule fires a web request to GitLab's pipeline-trigger API,
passing
JIRA_ISSUE_KEY=PROJ-142. - The
implementjob pulls the issue →spec/PROJ-142.md, the Agent implements to spec, opens MRclaude/PROJ-142, comments on Jira, and moves it to In Review. - The MR-open event runs the
advisorjob: it grades each criterion against the spec, posts PASS/FAIL on the MR and on Jira, and transitions the issue. - PASS → a human merges; a Smart Commit
(
PROJ-142 #close) closes the ticket. FAIL → a reviewer re-triggers the Agent on the same branch. The agent never self-merges.
Jira side— the trigger¶
A Jira Automation rule, When issue transitions to AI-Ready, Then Send web
request (store the GitLab trigger token in Jira's secret vault, not inline):
POST https://gitlab.example.com/api/v4/projects/<PROJECT_ID>/trigger/pipeline
form-encoded:
token = {{ GitLab trigger token }}
ref = main
variables[JIRA_ISSUE_KEY] = {{ issue.key }}
GitLab side— the complete .gitlab-ci.yml¶
Set as CI/CD variables (masked/protected, or minted at runtime by the
OpenBao addon): ANTHROPIC_API_KEY, JIRA_URL, JIRA_TOKEN,
GIT_PUSH_TOKEN (the Agent bot's write token), and the Jira transition IDs.
stages: [implement, review]
variables:
AGENT_IMAGE: ghcr.io/bigg01/claude-ci-agent/claude-agent:0.1.0-alpha.13
CLAUDE_MODEL: "claude-sonnet-4-6"
CLAUDE_CODE_ENABLE_TELEMETRY: "1"
OTEL_LOG_TOOL_CONTENT: "1"
OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4318
# Shared Jira helpers, injected into each job's before_script as shell functions.
.jira_fns: &jira_fns |
jira_to_spec() { # Jira issue → spec/<KEY>.md (Jira Cloud returns ADF JSON)
mkdir -p spec
curl -sS -H "Authorization: Bearer $JIRA_TOKEN" \
"$JIRA_URL/rest/api/3/issue/$JIRA_ISSUE_KEY?fields=summary,description" \
| python3 -c '
import sys, json
d = json.load(sys.stdin)["fields"]
def t(n): return (n.get("text","")+"".join(t(c) for c in n.get("content",[]))) if isinstance(n,dict) else "".join(t(c) for c in n) if isinstance(n,list) else ""
print("# %s\n\n%s" % (d["summary"], t(d.get("description") or {})))' > "spec/$JIRA_ISSUE_KEY.md"
}
jira_comment() { # post the contents of $1 as a comment on the issue
jq -Rs '{body:{type:"doc",version:1,content:[{type:"paragraph",content:[{type:"text",text:.}]}]}}' "$1" \
| curl -sS -X POST -H "Authorization: Bearer $JIRA_TOKEN" -H "Content-Type: application/json" \
"$JIRA_URL/rest/api/3/issue/$JIRA_ISSUE_KEY/comment" -d @-
}
jira_transition() { # move the issue to transition id $1
curl -sS -X POST -H "Authorization: Bearer $JIRA_TOKEN" -H "Content-Type: application/json" \
"$JIRA_URL/rest/api/3/issue/$JIRA_ISSUE_KEY/transitions" -d "{\"transition\":{\"id\":\"$1\"}}"
}
# ---- AGENT (read-write): Jira-triggered implementation -----------------------
implement:
stage: implement
image: $AGENT_IMAGE
rules:
- if: '$JIRA_ISSUE_KEY' # only on the Jira-triggered pipeline
before_script:
- *jira_fns
- git config user.name "Claude Agent · payments"
- git config user.email "claude-agent+payments@acme.dev"
script:
- jira_to_spec
- |
claude -p "Implement spec/$JIRA_ISSUE_KEY.md exactly. Satisfy every acceptance \
criterion, add the tests it requires, follow CLAUDE.MD. Nothing out of scope." \
--model "$CLAUDE_MODEL" --permission-mode bypassPermissions \
--dangerously-skip-permissions
# New branch named with the Jira key → GitLab/Jira auto-link the MR to the issue.
- |
git checkout -b "claude/$JIRA_ISSUE_KEY"
git add -A
git commit -m "feat($JIRA_ISSUE_KEY): implement from spec" \
-m "Claude-Personality: agent" \
-m "Claude-Run: gitlab/pipeline/$CI_PIPELINE_ID"
git push "https://oauth2:${GIT_PUSH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" \
"HEAD:claude/$JIRA_ISSUE_KEY" \
-o merge_request.create \
-o merge_request.title="$JIRA_ISSUE_KEY implement from spec"
- echo "Claude opened an implementation MR for review." > _msg.txt && jira_comment _msg.txt
- jira_transition "$JIRA_IN_REVIEW_ID"
# ---- ADVISOR (read-only): grade the MR against the spec ----------------------
advisor:
stage: review
image: $AGENT_IMAGE
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^claude\//'
variables:
JIRA_ISSUE_KEY: "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" # claude/PROJ-142 → stripped below
before_script:
- *jira_fns
- 'export JIRA_ISSUE_KEY="${JIRA_ISSUE_KEY#claude/}"'
script:
- |
claude -p "You are the ADVISOR (read-only). Review this MR against \
spec/$JIRA_ISSUE_KEY.md. For EACH acceptance criterion, state PASS or FAIL with \
file:line evidence. Run the tests and linters. Note any out-of-scope work or \
CLAUDE.MD violations. Write the verdict to review.md. Do NOT modify files." \
--model "$CLAUDE_MODEL" --permission-mode bypassPermissions \
--dangerously-skip-permissions
# Post the verdict to the MR (bot API token) and to Jira, then transition.
- |
jq -Rs '{body: .}' review.md | curl -sS -X POST \
-H "PRIVATE-TOKEN: $GIT_PUSH_TOKEN" -H "Content-Type: application/json" \
"$CI_API_V4_URL/projects/$CI_MERGE_REQUEST_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" -d @-
- jira_comment review.md
- |
grep -q 'FAIL' review.md && jira_transition "$JIRA_CHANGES_REQUESTED_ID" \
|| jira_transition "$JIRA_IN_REVIEW_ID"
artifacts:
paths: [review.md]
expire_in: 1 week
Why the Advisor stays safe here
The Advisor gets the Jira token (to comment + transition) and a token to post
the MR note, but no GIT_PUSH_TOKEN for code— it cannot change the branch it
is reviewing. Both jobs are
fully contained, and every step streams secret-scrubbed OTLP to
Elastic, tagged with JIRA_ISSUE_KEY so the whole feature is auditable end to end.