Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 20 additions & 61 deletions .github/workflows/factory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@
name: factory
on:
schedule:
- cron: "*/10 * * * *" # the dispatcher's backstop: GitHub fires a schedule late or not at all under load
- cron: "*/10 * * * *"
workflow_dispatch:
inputs:
issue: { description: Objective issue number (empty runs the dispatcher), required: false }
harness: { description: "Override FACTORY_HARNESS for this run (claude | codex | copilot)", required: false }
wake: { description: "Set by a wake job only (the deadline it slept to); leave empty", required: false }
issues: # a new Objective dispatches at once (labelled at creation, or labelled later); closed: an Objective blocked by that issue is ready now
types: [opened, labeled, closed]
pull_request: # fires because the App (not the job token) applies factory:review, a human marks a Question draft ready (the App's own ready mark starts no job), a Factory PR merged (siblings may be stale now), or the merge queue dropped one (the dispatcher re-enqueues it once, then Blocks)
issues: # a new Objective dispatches at once (labelled at creation, or labelled later); the cron is only the sweep
types: [opened, labeled]
pull_request: # fires because the App (not the job token) applies factory:review, a human marks a Question draft ready, a Factory PR merged (siblings may be stale now), or the merge queue dropped one (the dispatcher re-enqueues it once, then Blocks)
types: [labeled, ready_for_review, closed, dequeued]
check_suite: # CI from a GitHub App outside Actions finished on a factory/ branch: a stage waiting on it resumes, or the dispatcher's merge pass runs
types: [completed]
Expand All @@ -24,10 +23,10 @@ on:
- Quality
types: [completed]
env:
FACTORY_HARNESS: claude # claude | codex | copilot
FACTORY_MODEL: claude-opus-5-5
FACTORY_SUBAGENT_MODEL: claude-sonnet-5-5
FACTORY_REVIEWER_MODEL: claude-fable-5-2
FACTORY_HARNESS: copilot # claude | codex | copilot
FACTORY_MODEL: ""
FACTORY_SUBAGENT_MODEL: ""
FACTORY_REVIEWER_MODEL: ""
FACTORY_CREDENTIAL: plan # api; plan only for your own repository
FACTORY_MAX_CONCURRENT: 5
FACTORY_DAILY_USD: 25
Expand All @@ -42,38 +41,30 @@ env:
FACTORY_PROTECTED_PATHS: /scripts/ci/setup.sh
FACTORY_CI_CHECKS: quality
FACTORY_NODE_VERSION: 22 # the Node.js your CI uses
FACTORY_WORKER_SECRETS: AI_GATEWAY_API_KEY=WORKER_AI_GATEWAY_API_KEY # gate v0.9.23 relay leg: relayed, metered against max_usd
permissions: # the job token reads code and Actions; every write goes through the App's token
contents: read
actions: read # the dispatcher sums today's usage artifacts; doctor lists the CI workflow's triggers
issues: write
# id-token: write # Claude workload identity federation only
jobs:
dispatch:
# Ticks: the */10 cron only (a backstop: GitHub fires schedules late or not at all; other schedules are other
# jobs'), a manual run, a new Objective's label, any issue closing (it may block an Objective), Factory's own jobs'
# last steps (a slot freed, a failed or requeued start), a merged Factory PR, the merge queue dropping one
# (only GitHub and people with write access dequeue), the queue watch, and, without a sandbox, CI finishing on a
# factory/ branch (the merge pass merges a PR whose checks just went green; with a sandbox the gate job takes those
# events and ticks this job itself).
# Ticks: the cron, a manual run, a new Objective's label, the review job's last step (a worker slot freed), a merged
# Factory PR, the merge queue dropping one (only GitHub and people with write access dequeue), the queue watch, and,
# without a sandbox, CI finishing on a factory/ branch (the merge pass merges a PR whose checks just went green; with
# a sandbox the gate job takes those events and ticks this job itself).
if: >-
(github.event_name == 'schedule' && github.event.schedule == '*/10 * * * *') || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && (github.event.action == 'closed' || github.event.label.name == 'factory:objective' || (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'factory:objective')))) ||
github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && (github.event.label.name == 'factory:objective' || (github.event.action == 'opened' && contains(join(github.event.issue.labels.*.name, ','), 'factory:objective')))) ||
(github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'factory/')) ||
(github.event_name == 'pull_request' && github.event.action == 'dequeued' && startsWith(github.head_ref, 'factory/') && github.event.pull_request.head.repo.full_name == github.repository)
|| (github.event_name == 'check_suite' && startsWith(github.event.check_suite.head_branch, 'factory/')) ||
(github.event_name == 'workflow_run' && startsWith(github.event.workflow_run.head_branch, 'factory/') && github.event.workflow_run.head_repository.full_name == github.repository)
concurrency: { group: factory-dispatch, cancel-in-progress: false } # ticks run one at a time
runs-on: ubuntu-latest
timeout-minutes: 15 # a tick takes seconds; a hung one would hold every later tick for the runner's six hours
outputs:
issues: "${{ steps.f.outputs.issues }}"
count: "${{ steps.f.outputs.count }}"
queue_watch: "${{ steps.f.outputs.queue_watch }}"
wake_at: "${{ steps.f.outputs.wake_at }}"
wake_kind: "${{ steps.f.outputs.wake_kind }}"
outputs: { issues: "${{ steps.f.outputs.issues }}", count: "${{ steps.f.outputs.count }}", queue_watch: "${{ steps.f.outputs.queue_watch }}" }
steps:
- uses: clockgrove/factory@6b76ddac5eec8503b20473eb087dc147e318546e
- uses: clockgrove/factory@v0.9.4
id: f
with: { mode: dispatch, issue: "${{ inputs.issue }}", app_id: "${{ secrets.FACTORY_APP_ID }}", app_private_key: "${{ secrets.FACTORY_APP_PRIVATE_KEY }}" }
queue-watch:
Expand All @@ -93,41 +84,13 @@ jobs:
permissions: { contents: read, actions: write } # actions: write only for the dispatcher tick
concurrency: { group: factory-queue-watch, cancel-in-progress: false }
steps:
- uses: clockgrove/factory@6b76ddac5eec8503b20473eb087dc147e318546e
- uses: clockgrove/factory@v0.9.4
id: w
with: { mode: watch, pr: "${{ needs.dispatch.outputs.queue_watch }}", app_id: "${{ secrets.FACTORY_APP_ID }}", app_private_key: "${{ secrets.FACTORY_APP_PRIVATE_KEY }}" }
- name: Dispatch tick (a watched PR left the queue or stalled there, or the watch's time is up)
if: steps.w.outputs.tick == 'true'
env: { GH_TOKEN: "${{ github.token }}", GH_REPO: "${{ github.repository }}" } # no checkout here: gh names the repository from GH_REPO
run: gh workflow run factory.yml
wake:
# The dispatcher's own next tick, for a wait only time ends (GitHub's cron fires late or not at all): a sandbox
# stage's lease end (a stage that died silently), a retry the sandbox vendor's quota set, the next try of a start
# that failed another way (three failures Block), the UTC day's turn when daily_usd held work back, or one retry
# of a read that failed. The dispatcher arms one only when no wake already queued or sleeping ticks before it;
# none is ever cancelled, and a second one only ticks twice. It sleeps on a hosted runner and holds one of the
# account's concurrent-job slots while it sleeps. Cost: a public repository pays nothing and sleeps to every wait
# (a deadline past 350 minutes in hops, each a plain tick that arms the rest). A private one pays about $0.36 a
# sleeper-hour (a 2-core Linux runner at $0.006 a minute), so it sleeps only to the short waits (a quota retry, a
# failed start's retry, a read retry: minutes each); a lease end and the day's turn wait for events and the cron.
name: wake (${{ needs.dispatch.outputs.wake_kind }} ${{ needs.dispatch.outputs.wake_at }})
needs: dispatch
if: needs.dispatch.outputs.wake_at != ''
runs-on: ubuntu-latest
timeout-minutes: 355
permissions: { actions: write } # the dispatcher tick only
steps:
- name: Sleep to the deadline, then tick the dispatcher
env: { GH_TOKEN: "${{ github.token }}", GH_REPO: "${{ github.repository }}", WAKE_AT: "${{ needs.dispatch.outputs.wake_at }}", WAKE_KIND: "${{ needs.dispatch.outputs.wake_kind }}" }
run: |
left=$(( $(date -u -d "$WAKE_AT" +%s) - $(date -u +%s) ))
if [ "$left" -gt 21000 ]; then
sleep 21000
gh workflow run factory.yml || { sleep 30; gh workflow run factory.yml; }
exit 0
fi
if [ "$left" -gt 0 ]; then sleep "$left"; fi
gh workflow run factory.yml -f wake="$WAKE_KIND $WAKE_AT" || { sleep 30; gh workflow run factory.yml -f wake="$WAKE_KIND $WAKE_AT"; }
work:
needs: dispatch
if: needs.dispatch.outputs.count != '' && needs.dispatch.outputs.count != '0'
Expand All @@ -139,7 +102,7 @@ jobs:
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0, persist-credentials: false }
- uses: clockgrove/factory@6b76ddac5eec8503b20473eb087dc147e318546e
- uses: clockgrove/factory@v0.9.4
id: f
with:
mode: work
Expand All @@ -149,10 +112,8 @@ jobs:
app_private_key: ${{ secrets.FACTORY_APP_PRIVATE_KEY }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # Claude; empty with federation
codex_api_key: ${{ secrets.CODEX_API_KEY }} # Codex; reaches the harness step only
codex_gateway_key: ${{ secrets.FACTORY_CODEX_GATEWAY_KEY }} # Codex through the Vercel AI Gateway; wins over CODEX_API_KEY when set
copilot_github_token: ${{ secrets.COPILOT_GITHUB_TOKEN }} # Copilot; a token with only the Copilot Requests permission (required)
ai_gateway_api_key: ${{ secrets.AI_GATEWAY_API_KEY }} # optional: red-check triage reruns flaky CI jobs instead of a fix pass, and the drift advisory flags a drifting worker on its progress comment; empty turns both off
worker_secret_values: ${{ secrets.FACTORY_WORKER_SECRET_VALUES }} # optional, with FACTORY_WORKER_SECRETS in the env block: SOURCE=value lines for the worker's shell only; a dedicated key with a gateway-side budget (docs/PLAN.md, Worker secrets)
# anthropic_oidc_exchange_url: ${{ vars.ANTHROPIC_OIDC_EXCHANGE_URL }} # Claude federation (with id-token: write above)
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # credential: plan only; your own repository only
- uses: actions/upload-artifact@v7
Expand All @@ -166,15 +127,15 @@ jobs:
name: review (${{ github.head_ref }})
if: >-
github.event_name == 'pull_request' && startsWith(github.head_ref, 'factory/') && github.event.pull_request.head.repo.full_name == github.repository &&
((github.event.action == 'labeled' && github.event.label.name == 'factory:review') || (github.event.action == 'ready_for_review' && github.event.sender.type != 'Bot'))
((github.event.action == 'labeled' && github.event.label.name == 'factory:review') || github.event.action == 'ready_for_review')
runs-on: ubuntu-24.04
timeout-minutes: 240
permissions: { contents: read, issues: write, actions: write } # actions: write here, for the tick below and triage's rerun of failed CI jobs
concurrency: { group: "factory-review-${{ github.event.pull_request.number }}", cancel-in-progress: false }
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0, persist-credentials: false }
- uses: clockgrove/factory@6b76ddac5eec8503b20473eb087dc147e318546e
- uses: clockgrove/factory@v0.9.4
id: f
with:
mode: review
Expand All @@ -184,10 +145,8 @@ jobs:
app_private_key: ${{ secrets.FACTORY_APP_PRIVATE_KEY }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
codex_api_key: ${{ secrets.CODEX_API_KEY }}
codex_gateway_key: ${{ secrets.FACTORY_CODEX_GATEWAY_KEY }}
copilot_github_token: ${{ secrets.COPILOT_GITHUB_TOKEN }}
ai_gateway_api_key: ${{ secrets.AI_GATEWAY_API_KEY }}
worker_secret_values: ${{ secrets.FACTORY_WORKER_SECRET_VALUES }} # with FACTORY_WORKER_SECRETS: the fix and answer passes only, never the reviewer
# anthropic_oidc_exchange_url: ${{ vars.ANTHROPIC_OIDC_EXCHANGE_URL }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
- uses: actions/upload-artifact@v7
Expand Down
Loading