Przejdź do głównej zawartości

Od zgłoszenia do pull requesta bez rąk na klawiaturze

Potok od zgłoszenia do PR zamienia zgłoszenie na GitHubie oznaczone etykietą w szkic pull requesta bez niczyjego pisania: agent (Claude Code, Codex lub Cursor) implementuje zgłoszenie w izolowanym przebiegu, CI ponownie uruchamia bramki, które agent deklaruje jako zaliczone, pętla poprawek z limitem trzech prób naprawia błędy, a pakiet dowodów przygotowuje pull request do merge’a przez człowieka.

Masz backlog dobrze opisanych zgłoszeń: zmienić nazwę pola, dodać filtr do endpointu, naprawić błąd ze znanym sposobem reprodukcji. Każde potrafi zjeść inżynierowi większość godziny na przygotowania, pisanie i czekanie na CI, a pisanie mógłby przejąć agent. Problemem jest wszystko wokół pisania. Ktoś musi uruchomić przebieg, pilnować go, wypchnąć gałąź, otworzyć pull request, ponowić niestabilny test i ocenić, czy zielony znaczek cokolwiek znaczy.

Ten tutorial buduje wersję, w której nikt nie robi tych rzeczy ręcznie. Jest dla dewelopera, który to podłącza, i dla tech leada, który decyduje, jakie zgłoszenia mogą do potoku trafić. Każdy plik jest kompletny i przeznaczony do skopiowania do repozytorium.

  • Etykietę agent:ready, która uruchamia agenta na runnerze GitHuba albo w Cloud Agencie Cursora, nigdy na laptopie.
  • Kontrakt zadania: formularz zgłoszenia, prompt, którego przebieg zawsze używa, i jeden skrypt definiujący „gotowe”.
  • Podział uprawnień: agent edytuje pliki, workflow commituje, CI ocenia, a człowiek robi merge.
  • Pętlę review i poprawek, która zatrzymuje się po trzech próbach i przekazuje pull request wskazanej osobie.
  • Komentarz z pakietem dowodów w każdym pull requeście agenta, wygenerowany przez CI, a nie przez agenta.
  • Pięć liczb, po których tech lead pozna, czy potok produkuje kod nadający się do merge’a.

Potok ma sześć etapów. Najważniejsza jest trzecia kolumna: na żadnym etapie komponent, który pisze kod, nie decyduje zarazem, że kod jest dobry.

EtapCo działaKto ma uprawnieniaCo go zatrzymuje
1. EtykietaMaintainer nadaje agent:readyCzłowiek z prawem zapisuBrak etykiety albo brak prawa zapisu u osoby, która ją nadała
2. Izolowany przebiegAgent na świeżym runnerze albo w maszynie wirtualnej Cloud AgentaAgent może tylko edytować plikiLimit tur, limit kwoty, timeout joba albo warunek stopu w prompcie
3. Commit i szkic PRJob workflow z tokenem aplikacji GitHubWorkflow, nigdy agentPusty patch oznacza zgłoszenie etykietą agent:needs-human
4. Bramkiscripts/agent-gates.sh z gałęzi bazowej, uruchomiony w CI na kodzie pull requestaCI, w jobie bez sekretów i bez tokenu z prawem zapisuKażda niezaliczona bramka, w tym zmiana w chronionej ścieżce
5. Pętla review i poprawekZnów agent, z logami bramek i komentarzami z review na wejściuMaksymalnie trzy commity Agent-Fix:Limit albo warunek stopu nadaje PR etykietę agent:needs-human
6. MergeCzłowiek czyta pakiet dowodów i zatwierdzaWłaściciel zgłoszenia oraz CODEOWNERS dla wrażliwych ścieżekRuleset wymagający jednej akceptacji i sprawdzenia evidence

Decyzję projektową stojącą za etapami 3 i 4 warto nazwać wprost: żaden job, który uruchamia kod napisany przez agenta, nie ma tokenu z prawem zapisu. Job agenta robi checkout bez zapisanych poświadczeń gita i przekazuje akcji Claude Code własny GITHUB_TOKEN joba, który przy contents: read służy tylko do odczytu. Przekazuje patch drugiemu jobowi i dopiero ten job tworzy token z prawem zapisu. W CI job, który uruchamia kod pull requesta, nie ma sekretów, a job, który komentuje i oznacza pull request jako gotowy, nigdy tego kodu nie pobiera ani nie uruchamia. Agent z wstrzykniętym promptem może więc zepsuć własną kopię roboczą i wydać swój budżet, ale nie wypchnie niczego na gałąź, nie napisze komentarza jako bot i nie dotknie innego repozytorium. Trzyma jednak klucz API modelu, gdy uruchamia kod, który przed chwilą napisał, więc daj potokowi osobny klucz z limitem wydatków.

Między trzema narzędziami różni się tylko krok agenta. Kontrakt zadania, bramki, dowody i pętla są identyczne, więc możesz zmienić narzędzie albo uruchomić dwa równolegle, żeby je porównać.

Claude CodeCodexCursor
Gdzie działaanthropics/claude-code-action@v1 na runnerze GitHubaopenai/codex-action@v1 (tag v1.12) na runnerze GitHubaW Cloud Agencie, który według Cursora działa „in isolated VMs in the cloud with full development environments” (sprawdzone 2026-08-28)
Kto uruchamiaWorkflow, w trybie automatyzacji (wejście prompt)WorkflowWorkflow, przez @cursor/sdk 1.0.32
OgraniczeniaLista dozwolonych narzędzi --allowedTools, --max-turns, --max-budget-usdpermission-profile: ":workspace" bez dostępu do sieci oraz safety-strategy: drop-sudoMaszyna wirtualna Cursora; SDK nie udostępnia listy dozwolonych narzędzi dla agentów w chmurze
Kto otwiera PRTwój workflowTwój workflowCursor (autoCreatePR: true); twój workflow nadaje etykietę
RozliczenieMinuty Actions plus tokeny API albo subskrypcja przez CLAUDE_CODE_OAUTH_TOKENMinuty Actions plus tokeny API (sekret OPENAI_API_KEY)Zużycie Cursora na koncie, do którego należy CURSOR_API_KEY

Wszystkie trzy używają domyślnego modelu narzędzia, chyba że ustawisz inny. Aktualne modele domyślne i ceny są w przeglądzie modeli; zacznij od nich, zamiast przypinać model w workflow.

  1. Utwórz trzy etykiety. agent:ready uruchamia przebieg, agent-pr oznacza pull requesty obsługiwane przez pętlę, a agent:needs-human zatrzymuje pętlę.

  2. Utwórz małą aplikację GitHub do wypychania zmian. Nadaj jej Contents: read and write, Pull requests: read and write i Issues: read and write, zainstaluj ją w repozytorium, zapisz jej client ID jako zmienną Actions AGENT_APP_CLIENT_ID, a klucz prywatny jako sekret AGENT_APP_PRIVATE_KEY. Aplikacja jest potrzebna, bo GitHub nie uruchamia workflow ze zdarzeń utworzonych domyślnym GITHUB_TOKEN: pull request otwarty tym tokenem nigdy nie uruchomiłby CI. Traktuj aplikację jak tożsamość agenta z własnym właścicielem; rotację i zakres opisuje tożsamość agentów i sekrety.

  3. Dodaj sekret swojego narzędzia. ANTHROPIC_API_KEY (albo CLAUDE_CODE_OAUTH_TOKEN, wygenerowany poleceniem claude setup-token), OPENAI_API_KEY lub CURSOR_API_KEY. Dla Claude Code ten potok nie potrzebuje aplikacji Claude GitHub: workflow przekazują akcji tylko do odczytu GITHUB_TOKEN joba jako github_token. Bez tego wejścia akcja uwierzytelnia się przez aplikację, której token może zapisywać zawartość repozytorium.

  4. Zabezpiecz domyślną gałąź rulesetem. Wymagaj pull requesta, jednej akceptacji, review od właścicieli kodu oraz sprawdzeń evidence i twojego zwykłego CI. Zablokuj force push. Potem skieruj przez CODEOWNERS klasy zmian, które człowiek musi przeczytać, łącznie z każdym plikiem, który decyduje, co znaczy „przechodzi”:

    # .github/CODEOWNERS: paths the pipeline may change but never merge alone
    /src/auth/ @acme/security
    /src/billing/ @acme/payments
    /migrations/ @acme/data
    /tests/contract/ @acme/tech-leads
    /.github/ @acme/tech-leads
    # The rules the agent is judged by, and the settings its runs load
    /scripts/agent-gates.sh @acme/tech-leads
    /scripts/evidence.sh @acme/tech-leads
    /package.json @acme/tech-leads
    /package-lock.json @acme/tech-leads
    /tsconfig*.json @acme/tech-leads
    /vite.config.* @acme/tech-leads
    /vitest.config.* @acme/tech-leads
    /eslint.config.* @acme/tech-leads
    /.claude/ @acme/tech-leads
    /.codex/ @acme/tech-leads
    /.cursor/ @acme/tech-leads
    /.mcp.json @acme/tech-leads
    /AGENTS.md @acme/tech-leads
    /CLAUDE.md @acme/tech-leads
  5. Wyklucz katalog roboczy agenta. Dodaj .agent/ do .gitignore. Plik zadania, logi bramek i raport agenta żyją tam i nigdy nie mogą trafić do commita.

Napisz kontrakt zadania: formularz zgłoszenia, skrypt bramek i schemat wyniku

Dział zatytułowany „Napisz kontrakt zadania: formularz zgłoszenia, skrypt bramek i schemat wyniku”

Trzy pliki definiują, o co agent jest proszony i co znaczy „gotowe”. Zmieniają się rzadko i to je tech lead przegląda, kiedy potok działa źle.

Formularz zgłoszenia wymusza pola, od których zależy prompt. Rada DORA o małych partiach pasuje tu wprost: „AI can easily generate massive blocks of code, which are hard to review and test. Enforcing the discipline of small batches counteracts this risk” (DORA, blog Google Cloud, 2025-12-10). Jeden rezultat na zgłoszenie sprawia, że każdy pull request da się ocenić na podstawie samych dowodów.

.github/ISSUE_TEMPLATE/agent-task.yml
name: Agent-ready task
description: One outcome, provable by checks. Label agent:ready only after review.
body:
- type: textarea
id: outcome
attributes:
label: Outcome
description: One sentence a reviewer can verify.
validations: { required: true }
- type: textarea
id: criteria
attributes:
label: Acceptance criteria
description: "One per line: Given / when / then -> check: <test file or command>"
validations: { required: true }
- type: textarea
id: scope
attributes:
label: Allowed paths
description: Globs the agent may change, for example src/invoices/**
validations: { required: true }
- type: textarea
id: out-of-scope
attributes:
label: Out of scope
description: What must not change, even if it looks related.

Skrypt bramek to jedyna definicja „gotowe”. Agent uruchamia go w swoim przebiegu, a CI jeszcze raz, niezależnie; liczy się tylko wynik z CI. CI zawsze uruchamia kopię skryptu z gałęzi bazowej na kodzie pull requesta, więc agent, który zmieni skrypt, package.json (którego skrypty lint, typecheck i test wywołują bramki) albo konfigurację testów, nie zmieni sposobu, w jaki jest oceniany, a za samą próbę obleje strażnika wyroczni. Tak potok chroni wyrocznię przed agentem, którego ta wyrocznia ocenia.

#!/usr/bin/env bash
# scripts/agent-gates.sh <base-ref>
# Writes .agent/gates.md and exits 0 only when every gate passes.
set -uo pipefail
BASE="${1:?usage: agent-gates.sh <base-ref>}"
# The protected-path pattern lives next to the prompts, in .github/agent/, which is itself protected.
PROTECTED=$(cat "$(dirname "$0")/../.github/agent/protected-paths.txt")
mkdir -p .agent
out=.agent/gates.md
printf '| Gate | Result | Command |\n| --- | --- | --- |\n' > "$out"
fail=0
gate() {
local name="$1"; shift
if "$@" > ".agent/$name.log" 2>&1; then
echo "| $name | pass | \`$*\` |" >> "$out"
else
echo "| $name | **fail (exit $?)** | \`$*\` |" >> "$out"; fail=1
fi
}
# Diff from the merge base, not the base tip: commits that landed on the base
# branch after the PR forked are not the agent's. No merge base fails closed.
if ! mb=$(git merge-base "$BASE" HEAD); then
echo "| oracle-guard | **fail** | no merge base with $BASE |" >> "$out"; exit 1
fi
# Committed, uncommitted and new files, so it works before and after the commit.
changed=$( { git diff --name-only "$mb"; git ls-files --others --exclude-standard; } | sort -u)
protected=$(printf '%s\n' "$changed" | grep -E "$PROTECTED" || true)
if [ -n "$protected" ]; then
echo "| oracle-guard | **fail** | touched: $(echo $protected) |" >> "$out"; fail=1
else
echo "| oracle-guard | pass | no protected path touched |" >> "$out"
fi
gate lint npm run lint
gate typecheck npm run typecheck
gate test npm test
exit $fail

Wzorzec chronionych ścieżek to jedno rozszerzone wyrażenie regularne w .github/agent/protected-paths.txt, czyli w pliku leżącym w chronionym katalogu. Czytają go skrypt bramek i pętla poprawek, więc lista żyje w jednym miejscu:

^(\.github/|\.claude/|\.codex/|\.cursor/|\.mcp\.json$|AGENTS\.md$|CLAUDE\.md$|tests/contract/|migrations/|scripts/(agent-gates|evidence)\.sh$|package(-lock)?\.json$|tsconfig[^/]*\.json$|(vite|vitest|eslint)\.config\.[^/]+$)

Dopisz do niego własne pliki konfiguracji testów i lintera i trzymaj go w zgodzie z blokiem CODEOWNERS powyżej.

Schemat wyniku daje każdemu przebiegowi ten sam, czytelny maszynowo raport własny. Codex wymusza go przez --output-schema; Claude Code i Cursor dostają prośbę o jego zachowanie w prompcie. Workflow wypisuje raport agenta w opisie pull requesta pod nagłówkiem, który mówi wprost, że to deklaracja, a nie dowód.

{
"type": "object",
"additionalProperties": false,
"required": ["status", "stop_reason", "items"],
"properties": {
"status": { "enum": ["done", "stopped"] },
"stop_reason": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["claim", "check", "result"],
"properties": {
"claim": { "type": "string" },
"check": { "type": "string" },
"result": { "enum": ["pass", "fail", "not-run"] }
}
}
}
}
}

Zapisz go jako .github/agent/result.schema.json.

Potok używa dwóch promptów trzymanych w repozytorium, więc każda ich zmiana przechodzi review jak zwykły kod. Oba traktują zgłoszenie i uwagi z review jako dane, bo każdy, kto może edytować zgłoszenie, może też podsunąć agentowi dowolny tekst.

Trzeci prompt jest dla osoby, która nadaje etykietę. Uruchom go lokalnie w dowolnym z trzech narzędzi, zanim oznaczysz zgłoszenie. Wyłapuje zgłoszenia, które zużyłyby trzy próby poprawek i i tak skończyły z etykietą agent:needs-human.

Poniższy workflow jest kompletny dla Claude Code. Wersje dla Codeksa i Cursora zastępują tylko krok oznaczony AGENT STEP, jak pokazują zakładki pod nim.

.github/workflows/agent-issue.yml
name: agent-issue
on:
issues:
types: [labeled]
concurrency:
group: agent-issue-${{ github.event.issue.number }}
cancel-in-progress: false
jobs:
agent:
if: github.event.label.name == 'agent:ready'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read # the only token in this job is read-only
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false # the agent gets no git credentials
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci # install before the agent runs; Codex's :workspace has no network
- name: Build the task file (issue text goes through env, never through the shell)
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
BASE_REF: ${{ github.event.repository.default_branch }}
run: |
mkdir -p .agent
{ cat .github/agent/implement.md
printf '\nBase ref for scripts/agent-gates.sh: origin/%s\n' "$BASE_REF"
printf '\n<issue number="%s">\n# %s\n\n%s\n</issue>\n' \
"$ISSUE_NUMBER" "$ISSUE_TITLE" "$ISSUE_BODY"
} > .agent/task.md
# AGENT STEP
- name: Run Claude Code
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ github.token }} # read-only here; without it the action mints a Claude App token that can push
prompt: "Read .agent/task.md and follow it exactly."
claude_args: >-
--max-turns 60 --max-budget-usd 10
--allowedTools "Read,Edit,Write,Grep,Glob,Bash(npm run *),Bash(npm test *),Bash(scripts/agent-gates.sh *),Bash(git diff *),Bash(git status *)"
- name: Hand the change to the next job as a patch
run: |
git add -A
git diff --cached --binary > agent.patch
- uses: actions/upload-artifact@v7
with:
name: agent-output
path: |
agent.patch
.agent/result.json
include-hidden-files: true
if-no-files-found: warn
open-pr:
needs: agent
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/create-github-app-token@v3
id: app
with:
client-id: ${{ vars.AGENT_APP_CLIENT_ID }}
private-key: ${{ secrets.AGENT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
token: ${{ steps.app.outputs.token }}
- uses: actions/download-artifact@v8
with: { name: agent-output, path: /tmp/agent }
- name: Commit, push and open a draft pull request
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
ISSUE: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ ! -s /tmp/agent/agent.patch ]; then
gh issue comment "$ISSUE" --body "The agent produced no change. Run: $RUN_URL"
gh issue edit "$ISSUE" --add-label agent:needs-human --remove-label agent:ready
exit 1
fi
branch="agent/issue-$ISSUE"
git switch -c "$branch"
git apply --index /tmp/agent/agent.patch
git -c user.name="agent-pipeline" -c user.email="agent-pipeline@users.noreply.github.com" \
commit -m "Implement #$ISSUE: $TITLE" -m "Agent-Run: $RUN_URL"
git push -u origin "$branch"
{
echo "Closes #$ISSUE"
echo
echo "### Agent's own report (a claim, not evidence)"
jq -r '"Status: \(.status). \(.stop_reason)", (.items[] | "- \(.claim) -> `\(.check)`: \(.result)")' \
/tmp/agent/.agent/result.json 2>/dev/null || echo "No self-report was written."
echo
echo "CI posts the evidence bundle as a comment. Run: $RUN_URL"
} > body.md
gh pr create --draft --head "$branch" --title "$TITLE (#$ISSUE)" \
--body-file body.md --label agent-pr
gh issue edit "$ISSUE" --remove-label agent:ready
report-failure:
needs: agent
if: always() && (needs.agent.result == 'failure' || needs.agent.result == 'cancelled')
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Hand the issue to a human instead of stalling silently
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue comment "$ISSUE" --body "The agent run did not finish (budget, timeout or an action error). Run: $RUN_URL"
gh issue edit "$ISSUE" --add-label agent:needs-human --remove-label agent:ready

Usunięcie agent:ready na końcu sprawia, że kolejne zdarzenie z etykietą nie uruchomi duplikatu na tym samym zgłoszeniu. Żeby powtórzyć przebieg, usuń gałąź i nadaj etykietę ponownie. Job report-failure obsługuje drugie wyjście: gdy job agenta się wyłoży albo przekroczy limit czasu, open-pr w ogóle nie rusza, więc bez niego zgłoszenie zostałoby z agent:ready i bez żadnego śladu. Używa domyślnego GITHUB_TOKEN, bo komentarz i zmiana etykiety na zgłoszeniu nie muszą uruchamiać żadnego workflow.

Workflow powyżej to wersja dla Claude Code. Akcja działa w trybie automatyzacji, bo ma wejście prompt, więc nie czeka na wzmiankę @claude. Zanim Claude wystartuje, akcja sprawdza, czy użytkownik, który wywołał zdarzenie, ma prawo zapisu i nie jest botem. Etykieta nadana przez kogoś z uprawnieniem tylko do triage kończy więc przebieg błędem.

--max-budget-usd figuruje w claude --help (2.1.283) i działa w trybie print, a właśnie tak akcja uruchamia Claude’a. --max-turns jest akceptowana, ale nie ma jej już w --help (sprawdzone 2026-09-26), więc jako twarde limity traktuj budżet i timeout-minutes joba.

Lista dozwolonych narzędzi nie daje Claude’owi git commit, git push ani narzędzi GitHuba, a job nie daje akcji niczego, czym mogłaby wypchnąć zmiany: github_token: ${{ github.token }} przy contents: read służy tylko do odczytu, a bez id-token: write akcja nie wymieni tokenu OIDC na token aplikacji Claude GitHub. Nie zmieniaj tego; jeśli usuniesz github_token, akcja wróci do tokenu aplikacji, który może zapisywać zawartość. Jeden wpis na liście zasługuje na drugie spojrzenie: Bash(npm run *) uruchamia to, co mówi package.json, łącznie ze skryptem, który agent właśnie dopisał. Dlatego package.json jest na liście chronionych ścieżek i w CODEOWNERS.

Jeśli wolisz rozmowę zamiast potoku, tryb tagowania akcji też reaguje na etykietę: wejście label_trigger wskazuje etykietę, która go uruchamia. W tym trybie Claude wypycha gałąź claude/ i odpowiada linkiem do wstępnie wypełnionej strony tworzenia pull requesta, zamiast samemu go otworzyć — tak mówi FAQ akcji (sprawdzone 2026-09-26). To zostawia w ścieżce kliknięcie człowieka, czyli odwrotność tego, co buduje ta strona.

Routines w Claude Code nie zastąpią tego workflow: ich wyzwalacze GitHub przyjmują tylko zdarzenia pull requestów i wydań, a nie zgłoszeń (dokumentacja routines, sprawdzone 2026-09-26). Routine pasuje za to do strony review; zobacz routines w Claude Code.

Workflow pętli obsługuje każdy pull request z etykietą agent-pr. Uruchamia bramki od zera i publikuje wynik jako pakiet dowodów. Deklaracja agenta, że bramki przeszły, nigdy nie jest dowodem.

Praca jest podzielona na dwa joby, bo kod pull requesta to wynik pracy agenta i trzeba go traktować jako niezaufany:

  • gates (zgłaszany jako sprawdzenie evidence) pobiera głowę pull requesta do pr/ z persist-credentials: false, pobiera scripts/ i .github/agent/ z gałęzi bazowej do trusted/ i uruchamia trusted/scripts/agent-gates.sh wewnątrz pr/. Ma tylko uprawnienie contents: read i nie odwołuje się do żadnego sekretu, więc skrypty cyklu życia npm ci i testy napisane przez agenta nie mają czego ukraść ani żadnego późniejszego uprzywilejowanego kroku do przejęcia.
  • publish rusza po nim i nigdy nie pobiera ani nie uruchamia kodu pull requesta. Ściąga log bramek, buduje pakiet skryptem evidence.sh z gałęzi bazowej na podstawie API GitHuba, komentuje przez GITHUB_TOKEN i dopiero wtedy tworzy token aplikacji dla gh pr ready.

Sam log bramek powstał w jobie, który uruchamiał kod agenta, więc pakiet traktuje go jak log: wynik „pass” albo „fail” pochodzi z rezultatu joba gates, a nie z pliku.

#!/usr/bin/env bash
# scripts/evidence.sh <pr-number> <gates-result> <gates-md>: prints the evidence bundle as Markdown.
# Reads the pull request through the GitHub API only; it never checks out or runs the PR's code.
set -euo pipefail
PR="$1"; RESULT="$2"; GATES="$3"
pr=$(gh pr view "$PR" --json headRefOid,additions,deletions,changedFiles,files,closingIssuesReferences)
issues=$(jq -r '[.closingIssuesReferences[].number | "#\(.)"] | join(", ")' <<<"$pr")
attempts=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate \
--jq '.[] | select(.commit.message | test("(^|\n)Agent-Fix:")) | .sha' | wc -l | tr -d ' ')
sensitive=$(jq -r '.files[].path | select(test("^(src/auth/|src/billing/|migrations/)"))' <<<"$pr")
cat <<EOF
## Evidence bundle
**Closes:** ${issues:-none} · **Head:** \`$(jq -r '.headRefOid[0:7]' <<<"$pr")\` · **Gates:** $RESULT · **Fix attempts:** $attempts of 3
### Gate log (written by the job that ran the PR's code; the check result above is what counts)
$(cat "$GATES" 2>/dev/null || echo "No gate log was uploaded.")
### Size
$(jq -r '"\(.changedFiles) files, +\(.additions) -\(.deletions)"' <<<"$pr")
### Sensitive paths (a named human reads these)
${sensitive:-none}
EOF

To minimalny pakiet. Pełny kontrakt, ze zmianą specyfikacji, wynikami akceptacji dla każdego kryterium, zrzutami ekranu i pochodzeniem zmiany, opisuje pakiet dowodów; rozbuduj ten skrypt w jego stronę, kiedy potok już działa.

.github/workflows/agent-loop.yml
name: agent-loop
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
pull_request_review:
types: [submitted]
concurrency:
group: agent-loop-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
gates:
name: evidence # the required check: PR code runs here, with no secrets and no write token
if: >-
contains(github.event.pull_request.labels.*.name, 'agent-pr') &&
(github.event.action != 'labeled' || github.event.label.name == 'agent-pr')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Check out the PR's code (untrusted, it is the agent's output)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
path: pr
fetch-depth: 0
persist-credentials: false
- name: Check out the gate rules from the base branch (trusted)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.ref }}
path: trusted
persist-credentials: false
sparse-checkout: |
scripts
.github/agent
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm, cache-dependency-path: pr/package-lock.json }
- name: Run the base branch's gates against the PR's code
working-directory: pr
env:
BASE: origin/${{ github.event.pull_request.base.ref }}
run: |
npm ci
../trusted/scripts/agent-gates.sh "$BASE"
- uses: actions/upload-artifact@v7
if: always()
with:
name: gate-logs
path: pr/.agent/
include-hidden-files: true
if-no-files-found: warn
publish:
needs: gates
if: always() && (needs.gates.result == 'success' || needs.gates.result == 'failure')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check out trusted scripts only (the PR's code is never checked out here)
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.ref }}
persist-credentials: false
sparse-checkout: scripts
- uses: actions/download-artifact@v8
continue-on-error: true
with: { name: gate-logs, path: /tmp/gates }
- name: Post the evidence bundle
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
RESULT: ${{ needs.gates.result }}
run: |
scripts/evidence.sh "$PR" "$RESULT" /tmp/gates/gates.md > evidence.md
gh pr comment "$PR" --body-file evidence.md --edit-last --create-if-none
- uses: actions/create-github-app-token@v3
id: app
if: needs.gates.result == 'success' && github.event.pull_request.draft
with:
client-id: ${{ vars.AGENT_APP_CLIENT_ID }}
private-key: ${{ secrets.AGENT_APP_PRIVATE_KEY }}
- name: Mark ready for review (App token, so review workflows start)
if: needs.gates.result == 'success' && github.event.pull_request.draft
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: gh pr ready "${{ github.event.pull_request.number }}"

Job gates zgłasza swoje sprawdzenie pod nazwą evidence; tę nazwę wpisz do wymaganych sprawdzeń w rulesecie. Oznaczenie pull requesta jako gotowego tokenem aplikacji ma znaczenie: boty do review, które startują na ready_for_review, na przykład workflow review Claude Code, pomijają szkice i nigdy nie zobaczą zdarzenia utworzonego przez GITHUB_TOKEN.

Joby poprawek uruchamiają się, gdy bramki nie przejdą albo człowiek zgłosi review ze statusem „changes requested”. Liczą wcześniejsze stopki Agent-Fix: w commitach na gałęzi, więc licznik prób przetrwa ponowne uruchomienia i nie potrzebuje zapisanego stanu. Przy czwartej próbie workflow nadaje pull requestowi etykietę agent:needs-human i się zatrzymuje.

Obowiązuje ten sam podział zaufania. fix-plan nigdy nie pobiera kodu pull requesta: liczy próby przez API, zatrzymuje pętlę, gdy pull request dotyka chronionej ścieżki, i buduje zadanie poprawki z fix.md z gałęzi bazowej. Tylko fix uruchamia agenta na kodzie pull requesta, z tokenem tylko do odczytu, a token aplikacji ma wyłącznie push-fix, który niczego z gałęzi nie uruchamia. Ponieważ fix-plan zatrzymuje się przy każdej zmianie package.json albo lockfile’a, npm ci w fix instaluje dokładnie to, co zainstalowałaby gałąź bazowa. Tę samą maszynę stanów, z tabelą uprawnień, opisuje ograniczona pętla review i poprawek w PR.

Dodaj te joby do agent-loop.yml:

fix-plan:
needs: gates
if: >-
always() &&
contains(github.event.pull_request.labels.*.name, 'agent-pr') &&
!contains(github.event.pull_request.labels.*.name, 'agent:needs-human') &&
(needs.gates.result == 'failure' || github.event.review.state == 'changes_requested')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
attempt: ${{ steps.plan.outputs.attempt }}
steps:
- name: Check out the fix prompt and the protected-path list from the base branch
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.ref }}
persist-credentials: false
sparse-checkout: .github/agent
- id: plan
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
n=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" --paginate \
--jq '.[] | select(.commit.message | test("(^|\n)Agent-Fix:")) | .sha' | wc -l | tr -d ' ')
touched=$(gh pr view "$PR" --json files --jq '.files[].path' \
| grep -E -f .github/agent/protected-paths.txt || true)
if [ -n "$touched" ]; then
gh pr edit "$PR" --add-label agent:needs-human
gh pr comment "$PR" --body "Protected paths changed: $(echo $touched). The loop stops here; a human decides."
echo "attempt=stop" >> "$GITHUB_OUTPUT"
elif [ "$n" -ge 3 ]; then
gh pr edit "$PR" --add-label agent:needs-human
gh pr comment "$PR" --body "Fix limit reached (3 attempts). Handing over to the issue owner."
echo "attempt=stop" >> "$GITHUB_OUTPUT"
else
echo "attempt=$((n + 1))" >> "$GITHUB_OUTPUT"
fi
- uses: actions/download-artifact@v8
if: steps.plan.outputs.attempt != 'stop' && needs.gates.result == 'failure'
with: { name: gate-logs, path: /tmp/gates }
- name: Build the fix task (feedback is data)
if: steps.plan.outputs.attempt != 'stop'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
mkdir -p task
{ cat .github/agent/fix.md
printf '\nBase ref for scripts/agent-gates.sh: origin/%s\n' "$BASE_REF"
echo '<feedback>'
cat /tmp/gates/gates.md 2>/dev/null
for f in /tmp/gates/*.log; do [ -f "$f" ] && { echo "## $f"; tail -n 80 "$f"; }; done
gh pr view "$PR" --json reviews \
--jq '.reviews[] | select(.state == "CHANGES_REQUESTED") | "- review by \(.author.login): \(.body)"'
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/comments" \
--jq '.[] | "- \(.path):\(.line // .original_line) \(.user.login): \(.body)"'
echo '</feedback>'
} > task/task.md
- uses: actions/upload-artifact@v7
if: steps.plan.outputs.attempt != 'stop'
with: { name: fix-task, path: task/task.md }
fix:
needs: fix-plan
if: always() && needs.fix-plan.result == 'success' && needs.fix-plan.outputs.attempt != 'stop'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read # the agent runs PR code here, so the job holds no write token
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
persist-credentials: false
- uses: actions/download-artifact@v8
with: { name: fix-task, path: .agent }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci # safe to run only because fix-plan stops on any change to package.json or the lockfile
# AGENT STEP: the same step as in agent-issue.yml
- run: git add -A && git diff --cached --binary > agent.patch
- uses: actions/upload-artifact@v7
with: { name: fix-output, path: agent.patch }
push-fix:
needs: [fix-plan, fix]
if: always() && needs.fix.result == 'success'
runs-on: ubuntu-latest
permissions: { contents: read }
steps:
- uses: actions/create-github-app-token@v3
id: app
with:
client-id: ${{ vars.AGENT_APP_CLIENT_ID }}
private-key: ${{ secrets.AGENT_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.app.outputs.token }}
- uses: actions/download-artifact@v8
with: { name: fix-output, path: /tmp/fix }
- env:
GH_TOKEN: ${{ steps.app.outputs.token }}
PR: ${{ github.event.pull_request.number }}
N: ${{ needs.fix-plan.outputs.attempt }}
run: |
if [ ! -s /tmp/fix/agent.patch ]; then
gh pr edit "$PR" --add-label agent:needs-human
gh pr comment "$PR" --body "Fix attempt $N produced no change. Handing over."
exit 0
fi
git apply --index /tmp/fix/agent.patch
git -c user.name="agent-pipeline" -c user.email="agent-pipeline@users.noreply.github.com" \
commit -m "Address review and gate feedback" -m "Agent-Fix: $N"
git push

Push uruchamia nowy przebieg synchronize, który ponownie wykonuje bramki, więc każda próba jest oceniana dokładnie tak samo jak pierwsza.

W jobie poprawek użyj tego samego kroku Run Claude Code. Pętla potrzebuje oprócz bramek także recenzenta: workflow z pluginem code-review z dokumentacji Claude Code GitHub Actions publikuje komentarze w kodzie, gdy pull request zostaje otwarty, zaktualizowany lub oznaczony jako gotowy, i pomija szkice. Te komentarze trafiają do promptu poprawek przez wywołanie pulls/…/comments powyżej. Zarządzane Code Review to opcja bez workflow (research preview, Team i Enterprise); jego check run „always completes with a neutral conclusion”, więc informuje pętlę, ale nigdy jej nie blokuje.

Sesje w chmurze mają też auto-fix (/autofix-pr z terminala na gałęzi pull requesta): Claude obserwuje pull request, wypycha poprawkę, gdy jest oczywista, i pyta cię, gdy komentarz jest niejednoznaczny. Jego dokumentacja nie opisuje limitu prób (sprawdzone 2026-09-26) i zaznacza, że auto-fix nie reaguje na konflikty merge’a, więc pętlą obowiązującą zostaje job z limitem powyżej.

Skąd wiesz, że potok produkuje kod nadający się do merge’a?

Dział zatytułowany „Skąd wiesz, że potok produkuje kod nadający się do merge’a?”

Bramki dowodzą poprawności każdego pull requesta. Poniższe pięć liczb dowodzi, że działa cały potok, i pochodzi z etykiet oraz stopek commitów, które już masz. Przez pierwszy miesiąc tech lead przegląda je co tydzień.

MiaraDefinicjaDobry kierunek
Odsetek bramek zaliczonych za pierwszym razemPull requesty agenta, których pierwszy przebieg evidence przeszedł, podzielone przez wszystkie pull requesty agentaRośnie; niski odsetek oznacza, że poprawić trzeba zgłoszenia albo prompt, a nie model
Próby poprawek na zmergowany PRLiczba stopek Agent-Fix: w zmergowanych pull requestach agentaNajczęściej 0 lub 1
Odsetek przekazańPull requesty, które dostały agent:needs-human, podzielone przez wszystkie pull requesty agentaSpada, ale nigdy do zera; zero oznacza zbyt luźne warunki stopu
Odsetek ręcznych poprawekPull requesty agenta, do których człowiek wypchnął commit przed merge’emSpada
Odsetek revertów po merge’uPull requesty agenta cofnięte albo poprawiane w ciągu 14 dniNa poziomie twojej bazy dla kodu pisanego przez ludzi lub niżej

Zatwierdzenie zostaje przy ludziach. Właściciel zgłoszenia zatwierdza merge po przeczytaniu pakietu dowodów, a nie diffa; czytanie dowodów zamiast kodu pokazuje, co czytać i w jakiej kolejności. CODEOWNERS wymusza wskazanego czytelnika dla uwierzytelniania, pieniędzy, schematów, migracji i samej wyroczni. Tech lead odpowiada za limity (trzy próby poprawek, limit kwoty, limity czasu jobów) i za to, które klasy zgłoszeń mogą dostać agent:ready.

Zacznij od dziesięciu zgłoszeń oznaczanych przez jedną osobę. Porównaj czas od etykiety do merge’a każdego pull requesta i odsetek przekazań człowiekowi z tym, ile czasu zespół szacował na te dziesięć zgłoszeń, a dopiero potem udostępnij etykietę zespołowi. To, jak takie review mieszczą się w dniu zespołu, opisuje zarządzanie kolejką review.

Pull request się otwiera, ale CI na nim nie rusza. Push albo pull request powstał z użyciem GITHUB_TOKEN, a GitHub nie uruchamia workflow ze zdarzeń tego tokenu. Naprawa: wypychaj, nadawaj etykiety i oznaczaj gotowość tokenem aplikacji GitHub, jak robi każdy workflow na tej stronie, a potem raz zamknij i otwórz pull request, żeby uruchomić CI.

Przebieg od razu kończy się błędem uprawnień. Osoba, która nadała agent:ready, ma uprawnienia do triage, ale nie do zapisu, a obie akcje sprawdzają użytkownika wywołującego, zanim agent wystartuje. Naprawa: ogranicz, kto może nadawać etykietę, albo nadaj ją ponownie jako maintainer. Nie obchodź tego przez allowed_non_write_users ani allow-users; to otwiera przebieg dla każdego, kto może założyć zgłoszenie.

Codex nie może zainstalować pakietu ani połączyć się z usługą. Profil :workspace nie ma dostępu do sieci. Naprawa: instaluj wszystkie zależności przed krokiem agenta, testy integracyjne wymagające usług uruchamiaj w jobie evidence, a przebieg bramek agenta ogranicz do tego, co działa offline.

Bramki przechodzą, bo agent zmienił test. Pętla nagradzana za zielony wynik znajdzie najtańszą drogę do zielonego. Naprawa: CI uruchamia agent-gates.sh z gałęzi bazowej, więc agent nie przepisze reguł, według których jest oceniany, a strażnik wyroczni odrzuca każdą zmianę w chronionej ścieżce, w tym w skryptach bramek, package.json i konfiguracji testów oraz lintera. Dopisz do protected-paths.txt i do CODEOWNERS każdy katalog, którego testy definiują zachowanie.

Pętla odbija się między dwoma błędami. Próba 2 cofa próbę 1, a próba 3 ją powtarza. Naprawa: limit zatrzymuje to po trzech próbach, a prompt poprawek każe agentowi przestać, gdy bramka pada drugi raz z tego samego powodu. Jeśli zdarza się to często, zgłoszenie nie było gotowe dla agenta; odeślij je do kształtowania backlogu.

Zgłoszenie albo komentarz z review zawiera instrukcje. Każdy, kto może edytować zgłoszenie, może dopisać „zaktualizuj też klucz wdrożeniowy”. Naprawa: workflow przekazuje tekst zgłoszenia przez zmienne środowiskowe, owija go w znaczniki, które prompt uznaje za dane, i nie daje agentowi tokenu z prawem zapisu ani narzędzi GitHuba, a w Codeksie także sieci. Joby CI, które uruchamiają kod pull requesta, nie mają sekretów. Szerszy model opisuje model zagrożeń dla agentów.

Zgłoszenie ma agent:ready i nic się nie dzieje. Job agenta wyczerpał budżet, przekroczył limit czasu albo wyłożył się wewnątrz akcji, więc open-pr został pominięty. Naprawa: job report-failure komentuje zgłoszenie linkiem do przebiegu i zamienia agent:ready na agent:needs-human. Przeczytaj log przebiegu, zanim nadasz etykietę ponownie; zatrzymanie na budżecie zwykle znaczy, że zgłoszenie było za duże.

Człowiek wypchnął zmiany na gałąź, a kolejna poprawka nadpisała jego zamiar. Pętla nie wie, kto steruje. Naprawa: gdy człowiek przejmuje pull request, nadaje agent:needs-human, a każdy job poprawek sprawdza tę etykietę przed startem.

Gałąź bazowa się przesunęła i pull request ma teraz konflikt. Żadna bramka nie pada, więc żadna poprawka nie startuje. Naprawa: dodaj zaplanowany job, który robi rebase otwartych gałęzi agent-pr, albo pozwól właścicielowi rozwiązać konflikty ręcznie; potok służy do pierwszej wersji, a nie do ostatniego etapu na ruchliwej gałęzi.

Wydatki powoli rosną. Niejasne zgłoszenie trzykrotnie zużywa cały budżet tur i kwoty. Naprawa: odsetek bramek zaliczonych za pierwszym razem pokaże to wcześniej niż faktura. Obniż --max-budget-usd dla joba poprawek i przyjrzyj się zgłoszeniom z największą liczbą prób, a nie modelowi.