diff --git a/.github/workflows/factory.yml b/.github/workflows/factory.yml index 8438851..0270669 100644 --- a/.github/workflows/factory.yml +++ b/.github/workflows/factory.yml @@ -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] @@ -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 @@ -42,7 +41,6 @@ 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 @@ -50,15 +48,13 @@ permissions: # the job token reads code and Actions; every write goes through th # 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/')) || @@ -66,14 +62,9 @@ jobs: 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: @@ -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' @@ -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 @@ -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 @@ -166,7 +127,7 @@ 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 @@ -174,7 +135,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: review @@ -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