diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3691a013..d950244c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -7,12 +7,14 @@ on: description: Source commit to check required: false type: string + scope: + description: impact for PRs, full for explicit release verification + type: string + default: impact native-artifacts: description: Retain native installers for a release build type: boolean default: false - push: - branches: [main] pull_request: permissions: @@ -30,6 +32,7 @@ jobs: plan: ${{ steps.select.outputs.plan }} jobs: ${{ steps.select.outputs.jobs }} image: ${{ steps.select.outputs.image }} + attempt: ${{ steps.select.outputs.attempt }} steps: - uses: actions/checkout@v7 with: @@ -39,6 +42,7 @@ jobs: id: select env: REQUESTED_REF: ${{ inputs.ref }} + CHECK_SCOPE: ${{ inputs.scope || 'impact' }} run: python3 scripts/ci_plan.py plan hygiene: @@ -56,191 +60,44 @@ jobs: backend: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'backend') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 40 - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_DB: oac_core_ci_tests - POSTGRES_USER: agents_api - POSTGRES_PASSWORD: core_test_only - ports: - - 5432/tcp - options: >- - --health-cmd "pg_isready -U agents_api -d oac_core_ci_tests" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - name: Select Go caches - id: source - run: | - echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "GOCACHE=$HOME/.oac/cache/go-build" >> "$GITHUB_ENV" - echo "GOMODCACHE=$HOME/.oac/cache/go-mod" >> "$GITHUB_ENV" - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: false - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/go-build - ~/.oac/cache/go-mod - key: core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-backend-${{ steps.source.outputs.revision }} - restore-keys: | - core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-backend- - core-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}- - - uses: actions/setup-node@v6 - with: - node-version: '22' - - name: Install native Go prerequisites - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev - - name: Verify dedicated test database - env: - OAC_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_core_ci_tests?sslmode=disable - run: | - echo "OAC_TEST_DATABASE_URL=$OAC_TEST_DATABASE_URL" >> "$GITHUB_ENV" - make check-database - - name: Verify generated SQL queries - run: make check-sqlc - - name: Test Runtime and shared Go contracts - run: make check-go - - name: Test microsandbox provider and Linux helper - run: make check-microsandbox-provider - - name: Build and test standalone Core and persistence - run: make check-core - - name: Build execution daemon - run: make build-daemon + uses: ./.github/workflows/ci-backend.yml + with: + ref: ${{ inputs.ref || github.sha }} distribution: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'distribution') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - name: Select Go caches - id: source - run: | - echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "GOCACHE=$HOME/.oac/cache/go-build" >> "$GITHUB_ENV" - echo "GOMODCACHE=$HOME/.oac/cache/go-mod" >> "$GITHUB_ENV" - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - cache: false - - uses: actions/cache@v6 - with: - path: | - ~/.oac/cache/go-build - ~/.oac/cache/go-mod - key: core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-distribution-${{ steps.source.outputs.revision }} - restore-keys: | - core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-distribution- - core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-tooling- - core-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}- - - uses: actions/setup-node@v6 - with: - node-version: '22' - - name: Verify Harness catalog and installer schema - run: make check-harness-catalog - - name: Test distribution, installer and console packaging - run: make check-distribution + uses: ./.github/workflows/ci-distribution.yml + with: + ref: ${{ inputs.ref || github.sha }} harness: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'harness') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - uses: ./.github/actions/node - - name: Test and package Harness adapters - run: make check-claude-sdk check-mcode-harness + uses: ./.github/workflows/ci-harness.yml + with: + ref: ${{ inputs.ref || github.sha }} example: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'example') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - uses: ./.github/actions/node - - name: Install dependencies and example acceptance browser - run: | - make node-deps - pnpm exec playwright install --with-deps chrome - - name: Test and build optional example with browser acceptance - run: make check-example - - name: Upload browser failure evidence - if: failure() - uses: actions/upload-artifact@v6 - with: - name: example-acceptance-${{ github.run_attempt }} - path: | - example/*/playwright-report/ - example/*/test-results/ - retention-days: 7 - compression-level: 0 - if-no-files-found: ignore + uses: ./.github/workflows/ci-example.yml + with: + ref: ${{ inputs.ref || github.sha }} web: needs: plan if: needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web') - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - uses: ./.github/actions/node - - name: Typecheck, test and build Web and clients - run: make check-web-unit + uses: ./.github/workflows/ci-web.yml + with: + ref: ${{ inputs.ref || github.sha }} web-acceptance: needs: [plan, web] if: needs.web.result == 'success' && needs.plan.result == 'success' && contains(fromJSON(needs.plan.outputs.jobs || '[]'), 'web-acceptance') - strategy: - fail-fast: false - matrix: - shard: [1, 2] - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ inputs.ref || github.sha }} - - uses: ./.github/actions/node - - name: Install dependencies and Web acceptance browser - run: | - make node-deps - pnpm exec playwright install --with-deps chrome - - name: Verify Web workflows against isolated fixtures - run: make check-web-acceptance OAC_WEB_TEST_SHARD=${{ matrix.shard }}/2 - - name: Upload browser failure evidence - if: failure() - uses: actions/upload-artifact@v6 - with: - name: web-acceptance-${{ matrix.shard }}-${{ github.run_attempt }} - path: | - playwright-report/ - test-results/ - retention-days: 7 - compression-level: 0 - if-no-files-found: ignore + uses: ./.github/workflows/ci-web-acceptance.yml + with: + ref: ${{ inputs.ref || github.sha }} api: needs: plan @@ -278,5 +135,6 @@ jobs: - name: Require every selected check to succeed env: PLAN: ${{ needs.plan.outputs.plan }} + PLAN_ATTEMPT: ${{ needs.plan.outputs.attempt }} RESULTS: ${{ toJSON(needs) }} run: python3 scripts/ci_plan.py gate diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml new file mode 100644 index 00000000..961418d0 --- /dev/null +++ b/.github/workflows/ci-backend.yml @@ -0,0 +1,77 @@ +name: ci-backend + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + backend: + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 40 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: oac_core_ci_tests + POSTGRES_USER: agents_api + POSTGRES_PASSWORD: core_test_only + ports: + - 5432/tcp + options: >- + --health-cmd "pg_isready -U agents_api -d oac_core_ci_tests" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - name: Select Go caches + id: source + run: | + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "GOCACHE=$HOME/.oac/cache/go-build" >> "$GITHUB_ENV" + echo "GOMODCACHE=$HOME/.oac/cache/go-mod" >> "$GITHUB_ENV" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - uses: actions/cache@v6 + with: + path: | + ~/.oac/cache/go-build + ~/.oac/cache/go-mod + key: core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-backend-${{ steps.source.outputs.revision }} + restore-keys: | + core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-backend- + core-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}- + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install native Go prerequisites + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libssl-dev + - name: Verify dedicated test database + env: + OAC_TEST_DATABASE_URL: postgres://agents_api:core_test_only@127.0.0.1:${{ job.services.postgres.ports['5432'] }}/oac_core_ci_tests?sslmode=disable + run: | + echo "OAC_TEST_DATABASE_URL=$OAC_TEST_DATABASE_URL" >> "$GITHUB_ENV" + make check-database + - name: Verify generated SQL queries + run: make check-sqlc + - name: Test Runtime and shared Go contracts + run: make check-go + - name: Test microsandbox provider and Linux helper + run: make check-microsandbox-provider + - name: Build and test standalone Core and persistence + run: make check-core + - name: Build execution daemon + run: make build-daemon diff --git a/.github/workflows/ci-distribution.yml b/.github/workflows/ci-distribution.yml new file mode 100644 index 00000000..89bdd4c5 --- /dev/null +++ b/.github/workflows/ci-distribution.yml @@ -0,0 +1,48 @@ +name: ci-distribution + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + distribution: + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - name: Select Go caches + id: source + run: | + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "GOCACHE=$HOME/.oac/cache/go-build" >> "$GITHUB_ENV" + echo "GOMODCACHE=$HOME/.oac/cache/go-mod" >> "$GITHUB_ENV" + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + - uses: actions/cache@v6 + with: + path: | + ~/.oac/cache/go-build + ~/.oac/cache/go-mod + key: core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-distribution-${{ steps.source.outputs.revision }} + restore-keys: | + core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-distribution- + core-go-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}-tooling- + core-go-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/go.mod', '**/go.sum') }}- + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Verify Harness catalog and installer schema + run: make check-harness-catalog + - name: Test distribution, installer and console packaging + run: make check-distribution diff --git a/.github/workflows/ci-example.yml b/.github/workflows/ci-example.yml new file mode 100644 index 00000000..56abcfdd --- /dev/null +++ b/.github/workflows/ci-example.yml @@ -0,0 +1,39 @@ +name: ci-example + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + example: + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/node + - name: Install dependencies and example acceptance browser + run: | + make node-deps + pnpm exec playwright install --with-deps chrome + - name: Test and build optional example with browser acceptance + run: make check-example + - name: Upload browser failure evidence + if: failure() + uses: actions/upload-artifact@v6 + with: + name: example-acceptance-${{ github.run_attempt }} + path: | + example/*/playwright-report/ + example/*/test-results/ + retention-days: 7 + compression-level: 0 + if-no-files-found: ignore diff --git a/.github/workflows/ci-harness.yml b/.github/workflows/ci-harness.yml new file mode 100644 index 00000000..c6cb9c40 --- /dev/null +++ b/.github/workflows/ci-harness.yml @@ -0,0 +1,24 @@ +name: ci-harness + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + harness: + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/node + - name: Test and package Harness adapters + run: make check-claude-sdk check-mcode-harness diff --git a/.github/workflows/ci-web-acceptance.yml b/.github/workflows/ci-web-acceptance.yml new file mode 100644 index 00000000..c084cd1c --- /dev/null +++ b/.github/workflows/ci-web-acceptance.yml @@ -0,0 +1,43 @@ +name: ci-web-acceptance + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + web-acceptance: + strategy: + fail-fast: false + matrix: + shard: [1, 2] + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/node + - name: Install dependencies and Web acceptance browser + run: | + make node-deps + pnpm exec playwright install --with-deps chrome + - name: Verify Web workflows against isolated fixtures + run: make check-web-acceptance OAC_WEB_TEST_SHARD=${{ matrix.shard }}/2 + - name: Upload browser failure evidence + if: failure() + uses: actions/upload-artifact@v6 + with: + name: web-acceptance-${{ matrix.shard }}-${{ github.run_attempt }} + path: | + playwright-report/ + test-results/ + retention-days: 7 + compression-level: 0 + if-no-files-found: ignore diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml new file mode 100644 index 00000000..0b331899 --- /dev/null +++ b/.github/workflows/ci-web.yml @@ -0,0 +1,24 @@ +name: ci-web + +on: + workflow_call: + inputs: + ref: + description: Immutable source commit to check + type: string + required: true + +permissions: + contents: read + +jobs: + web: + runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/node + - name: Typecheck, test and build Web and clients + run: make check-web-unit diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index a094b874..83cd6590 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -145,6 +145,7 @@ jobs: if: inputs.upload-artifacts with: name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} + overwrite: true path: ${{ runner.temp }}/oac-native-installer-*.tar.gz if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97f3c14d..6934a16c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,7 @@ jobs: uses: ./.github/workflows/check.yml with: ref: ${{ inputs.ref || github.sha }} + scope: full native-artifacts: true build: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f61d4112..c966f13b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ Toolchain setup and focused commands are in [Develop OpenAgentCore](docs/develop ### Checks for a change -Run the checks for the changed behavior and its consumers before completion, including database migration and cross-component tests when affected. Use the [CI selection policy](docs/maintainers.md#continuous-integration) to determine the relevant groups; record what passed and any validation limits. A small follow-up needs its relevant checks, not another unrelated full run. The [Makefile](Makefile) retains `make check` as the complete local gate; main and release CI run all groups, and releases require the full gate. [Live acceptance](#live-acceptance) qualifies native execution beyond fixtures and builds. +Run the checks for the changed behavior and its consumers before completion, including database migration and cross-component tests when affected. Use the [CI selection policy](docs/maintainers.md#continuous-integration) to determine the relevant groups; record what passed and any validation limits. A small follow-up needs its relevant checks, not another unrelated full run. The [Makefile](Makefile) retains `make check` as the complete local gate; PR CI selects affected groups and must pass against the current main baseline before merging; main does not repeat those checks. Releases explicitly require the full gate. Retry PR checks with Re-run all jobs, not individual jobs or failed jobs only. [Live acceptance](#live-acceptance) qualifies native execution beyond fixtures and builds. ### Test database diff --git a/docs/maintainers.md b/docs/maintainers.md index 1091a55d..f8356ffd 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -144,8 +144,13 @@ With `draft_release=true` the result is an unpublished `build-` draft ## Continuous integration -Every PR runs `core-check` and reports the required status `check`. `scripts/ci_plan.py` owns the only path-to-check map. The planner compares the PR event's tested merge commit with its verified first parent, using NUL-delimited Git output with rename detection disabled so both old and new paths count. Its JSON plan and reasons appear in the run summary. Missing or inconsistent merge parents, unavailable diffs, empty changes, unknown files, CI changes and shared build/dependency inputs select the full gate. Deletions and mixed changes retain all affected groups. Main pushes and release calls always select every group. +Every PR runs `core-check` and reports the required status `check`. Normal pushes to main do not start CI: the PR is the merge verification boundary. Require the selected checks on the current main baseline before merging; a local pass or an older PR result is not a substitute. Do not bypass stale or failed checks expecting a post-merge run to cover them. There is no scheduled full run. +CI has three responsibilities. `scripts/ci_policy.py` owns the only path-to-check map and execution prerequisites (`image` requires `api`; `web-acceptance` requires `web`). `scripts/ci_plan.py` reads the event and Git diff, produces the plan, and verifies results. `.github/workflows/check.yml` orchestrates the plan and required gate; each reusable workflow owns its group's runner, environment and commands. Consumer propagation in the policy is distinct from job execution order in `needs`. + +The planner compares the PR event's tested merge commit with its verified first parent, using NUL-delimited Git output with rename detection disabled so both old and new paths count. Deletions and mixed changes retain all affected groups. Its JSON plan and reasons appear in the run summary. An empty verified diff runs hygiene only. An unclassified path, unavailable diff or inconsistent checkout fails planning with a diagnostic; it starts no expensive jobs and cannot produce a successful gate. Add the missing consumer mapping or repair the checkout rather than silently selecting everything. + +Release checks use the same entry point with explicit `scope: full` and an immutable source SHA. That mode selects every group and image acceptance. An ordinary event or a supplied ref alone never implies full verification. Broad shared changes can still select many groups when they affect those consumers. | Group | Checks and consumers | | --- | --- | | `hygiene` | Names, repository links, bundled documentation integrity, and CI planner/gate tests; runs for every change | @@ -159,11 +164,11 @@ Every PR runs `core-check` and reports the required status `check`. `scripts/ci_ | `native` | Reusable Linux, macOS and Windows builds, filesystem/process/Harness checks and native installation; at most two platforms run concurrently | | `lint` | Reusable actionlint check, including local composite actions | -Ordinary documentation runs hygiene only; generated catalog files and configuration reference sections retain their distribution freshness checks. Installer changes add distribution checks. Web changes add Web checks and both browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the planner. Generated catalog and protocol inputs include the installer, client and UI consumers. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. +Ordinary documentation runs hygiene only; generated catalog files and configuration reference sections retain their distribution freshness checks. Installer changes add distribution checks. Web changes add Web checks and both browser shards; Core/DB changes add backend and official-client acceptance. Shared contracts, SDKs, Runtime inputs and dependencies propagate to their consumers according to the policy. Generated catalog and protocol inputs include the installer, client and UI consumers. Go and Node dependency files select their own consumers. Changes to a group workflow select that group and lint; changes to the planner, policy or central orchestration select hygiene and lint. Do not duplicate path lists in reusable workflows or put a `paths` filter on the required workflow. -The final `check` runs even when planning or a dependency fails. It requires a successful, valid plan, every selected job to be successful, and every unselected job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API/native reusable workflows are direct dependencies of this gate. A newer run on the same PR cancels its predecessor. Release checks run at their requested immutable ref; native packaging executes once inside those checks, and the distribution build waits for them. +The final `check` runs even when planning or a dependency fails. It requires a successful, valid plan, every selected job to be successful, and every unselected job to be skipped. Failure, cancellation, a missing job, an unexpected skip or an unexpected execution fails the gate. API/native reusable workflows are direct dependencies of this gate. A new commit cancels the previous run on the same PR and starts a fresh affected-check plan. For a failed PR run, use GitHub’s **Re-run all jobs** or `gh run rerun RUN_ID --repo MiniMax-AI/OpenAgentCore`; this repeats the selected groups, not the full repository. Do not use `--failed` or `--job`. The plan records its run attempt and the final gate requires the current attempt, rejecting partial reruns that reuse an earlier plan. A rerun checks its original revision; push a new commit to validate updated code. Release checks run at their requested immutable ref; native packaging executes once inside those checks, and the distribution build waits for them. -Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. +Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Explicit native package uploads replace their same-named artifact when verification is retried, so release assembly still receives one archive per platform. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above. The local Node composite action installs the pinned pnpm and caches its package store by lockfile, OS, architecture, Node version and pnpm version. It caches downloaded packages, not `node_modules`; installs remain frozen. Go partitions retain the existing module/compiler caches described under [publication](#publish-a-version). Cache hits seed work and never replace tests. The native concurrency limit reduces peak demand; it does not imply a reduction in total machine time. @@ -174,9 +179,15 @@ python3 scripts/ci_plan.py plan --base origin/main --head HEAD make check-ci ``` -`make check` remains the full local entry point with an unsharded Web suite. `make check-web-unit` and `make check-web-acceptance OAC_WEB_TEST_SHARD=1/2` expose the Web parts. The selection tests cover mixed changes, shared consumers, renames/deletions, unknown inputs, shallow merge checkouts and failed/cancelled/missing results. Changes to the map or workflow graph also require actionlint and replay of representative PR diffs; exercise real documentation, installer, Web and Core runs before relying on new selection rules. +`make check` remains the full local entry point with an unsharded Web suite. `make check-web-unit` and `make check-web-acceptance OAC_WEB_TEST_SHARD=1/2` expose the Web parts. The selection tests cover mixed changes, shared consumers, renames/deletions, unclassified-input failures, shallow merge checkouts, execution prerequisites and failed/cancelled/missing or reused-attempt results. Workflow contract tests require PR-only daily triggers, callable adapters, gate dependencies and explicit full release checks. Changes to the map or workflow graph also require actionlint and replay of representative PR diffs; exercise real documentation, installer, Web and Core runs before relying on new selection rules. + +Measure completed runs with `python3 scripts/ci_metrics.py RUN_ID ...`. It reports the latest attempt's summed runner minutes, elapsed time and initial queue delay from that attempt's start, peak concurrent jobs, platform breakdown and job outcomes/failure fraction. Only jobs assigned a runner in that attempt contribute machine time and execution concurrency; jobs cancelled while queued retain their outcome and wall time. Earlier attempts are not included. Failed-job reruns can carry earlier successful results: their outcomes appear separately and their old execution time is excluded. A missing rerun start timestamp stops measurement because reused jobs cannot be separated reliably. Keep run/head/attempt identities with comparisons, and report cancellations and unfinished runs separately. Raw runner minutes are not billed minutes; use each platform's published conversion and allowance rules before estimating cost. A small successful sample is not a long-term failure-rate estimate. No post-merge or scheduled full checks are started. + +### Extending checks + +For a new component, add its paths and all affected consumers in `ci_policy.py`, then add representative selection tests, including its shared fixtures and generated outputs. The tracked-path coverage test rejects unclassified files. For a new check group, add its stable name to `JOBS`, any execution prerequisites, one reusable workflow, the central call and the final gate dependency; the workflow tests detect inconsistent registration. Keep runner setup and commands in that adapter so changing one group does not select unrelated groups. Update this table with the check's purpose. -Measure completed runs with `python3 scripts/ci_metrics.py RUN_ID ...`. It reports the latest attempt's summed runner minutes, elapsed time and initial queue delay from that attempt's start, peak concurrent jobs, platform breakdown and job outcomes/failure fraction. Only jobs assigned a runner in that attempt contribute machine time and execution concurrency; jobs cancelled while queued retain their outcome and wall time. Earlier attempts are not included. Failed-job reruns can carry earlier successful results: their outcomes appear separately and their old execution time is excluded. A missing rerun start timestamp stops measurement because reused jobs cannot be separated reliably. Keep run/head/attempt identities with comparisons, and report cancellations and unfinished runs separately. Raw runner minutes are not billed minutes; use each platform's published conversion and allowance rules before estimating cost. A small successful sample is not a long-term failure-rate estimate. Main impact selection or scheduled full runs are outside this policy. +For policy or orchestration changes, run `make check-ci check-docs check-names` and actionlint, replay representative diffs, and exercise the affected workflow behavior. A syntax check does not prove runtime behavior. No dependency database, result cache or custom retry scheduler is part of this contract. `python3 scripts/ci_plan.py plan --full` explicitly displays the complete local plan; it does not start Actions or publish anything. ### CI runners and free allowance diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 7b76da22..bfa45e7e 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -1,93 +1,14 @@ #!/usr/bin/env python3 -"""Select PR checks from the tested merge diff; unknown inputs select the full gate.""" +"""Plan checks for a verified PR diff or an explicit full run; enforce their results.""" import argparse import json import os -from pathlib import Path, PurePosixPath +from pathlib import Path +import re import subprocess -JOBS = ("hygiene", "distribution", "backend", "harness", "example", "web", "web-acceptance", "api", "native", "lint") -# Rules accumulate: shared inputs exercise every declared consumer. This is the -# only authored path map; workflows consume the resulting plan. -RULES = ( - ((".github/", "scripts/ci_"), JOBS), - (("apps/web/", "playwright.config.ts"), ("web", "web-acceptance")), - (("services/web/",), ("distribution", "web", "web-acceptance")), - (("example/",), ("example",)), - (("services/core/",), ("backend", "api")), - (("services/core/internal/sandbox/testdata/node-diagnostics.json",), ("web", "web-acceptance", "example")), - (("services/core/internal/sandbox/testdata/deployment-contract.json", - "services/core/internal/sandbox/e2b/testdata/configuration-selectors.json"), ("distribution",)), - (("services/core/internal/nativeinstaller/",), ("native", "distribution")), - (("services/core/deploy/", "services/core/tools/"), ("distribution",)), - (("apps/daemon/",), ("backend", "native")), - (("internal/",), ("backend", "api", "native", "distribution")), - (("internal/harnessconfig/",), ("web", "web-acceptance", "example", "harness")), - (("contracts/",), ("backend", "api", "native", "web", "web-acceptance", "example", "distribution")), - (("packages/agents-client/",), ("backend", "api", "web", "web-acceptance", "example")), - (("packages/claude-sdk-adapter/", "packages/mcode-harness/"), ("harness", "native", "backend", "distribution")), - (("packages/tsconfig/",), JOBS), - (("deploy/install/", "deploy/install-release.sh", "scripts/install-release.", "scripts/publish-core-release.", - "scripts/core-distribution-manifest.", "scripts/build-core-distribution.sh", "scripts/config-reference.py", - "scripts/build-web.sh"), ("distribution",)), - (("scripts/build-native-", "scripts/native-"), ("native", "backend", "distribution")), - (("scripts/build-core.sh", "scripts/build-core-image-context.sh", "deploy/distribution/"), ("backend", "api", "distribution", "native")), - (("scripts/build-e2b-provider.sh",), ("backend", "api", "distribution")), - (("scripts/build-claude", "scripts/check-claude", "scripts/build-mcode", "scripts/prepare-release-runtimes.sh"), - ("harness", "native", "backend", "distribution")), - (("scripts/build-agents-runtime.sh",), ("backend", "native", "distribution")), - (("scripts/generate-harness-catalog", "scripts/harness-catalog/", "scripts/openapi-split/", "scripts/patch-agents-openapi.py", - "scripts/extract-agents-api-upstream.py"), JOBS), - (("scripts/check-sqlc.py",), ("backend",)), - (("scripts/check-names", "scripts/name-allowlist.json"), ("hygiene",)), -) -FULL_INPUTS = {"Makefile", "go.mod", "go.sum", "go.work", "go.work.sum", "package.json", "pnpm-lock.yaml", - "pnpm-workspace.yaml", "tsconfig.base.json", ".npmrc", ".gitignore", ".gitattributes", ".dockerignore"} -IMAGE_INPUTS = ("scripts/build-core", "scripts/build-e2b-provider", "deploy/distribution/", "services/core/tools/e2b-provider/", - "services/core/deploy/e2b/") -# Generated outputs retain freshness checks even when the file is documentation. -GENERATED_OUTPUTS = {"contracts/agents-api/harness-catalog.md", "packages/agents-client/src/harness-catalog.ts", - "services/core/internal/engine/catalog_generated.go", "docs/configuration.md", - "docs/getting-started/install-options.md"} - - -def documentation(path): - p = PurePosixPath(path) - if p.name in {"README.md", "README.zh-CN.md", "AGENTS.md", "CONTRIBUTING.md", "LICENSE"}: - return True - return (path.startswith(("docs/", "contracts/")) and p.suffix in {".md", ".png", ".jpg", ".jpeg", ".svg", ".webp"}) or path in { - "apps/web/PRODUCT.md", "apps/web/DESIGN.md", "services/core/IMPLEMENTATION.md"} - - -def full(reason): - return {"version": 1, "jobs": list(JOBS), "image": True, "reasons": [reason]} - - -def select(paths): - if not paths: - return full("Empty diff; run the full gate") - jobs = {"hygiene"} - image = False - reasons = [] - for path in paths: - if not path or path.startswith("/") or ".." in PurePosixPath(path).parts: - return full("Invalid path in diff") - if path in FULL_INPUTS or path.startswith(".github/"): - return full(f"Shared build or CI input: {path}") - matches = {"hygiene"} if documentation(path) else { - job for prefixes, targets in RULES if path.startswith(prefixes) for job in targets} - if path in GENERATED_OUTPUTS: - matches.add("distribution") - if not matches: - return full(f"Unclassified input: {path}") - if not documentation(path) and path.startswith(IMAGE_INPUTS): - matches.add("api") - image = True - jobs.update(matches) - image = image or matches == set(JOBS) - reasons.append(f"{path}: {', '.join(sorted(matches))}") - return {"version": 1, "jobs": [job for job in JOBS if job in jobs], "image": image, "reasons": reasons} +from ci_policy import JOBS, expand, full, select def git(*args): @@ -104,22 +25,28 @@ def changed_paths(base, head): return fields[1::2] -def event_plan(event_name, event, requested_ref=""): - if event_name != "pull_request" or requested_ref: - return full("Main, manual or reusable run: full gate") +def event_plan(event_name, event, requested_ref="", scope="impact"): + if scope == "full": + if not re.fullmatch(r"[0-9a-f]{40}", requested_ref) or git("rev-parse", "HEAD").decode().strip() != requested_ref: + raise ValueError("Full checks require the checked-out immutable source SHA") + return full("Explicit full verification") + if scope != "impact" or event_name != "pull_request" or requested_ref: + raise ValueError("Impact checks require a pull_request merge checkout") try: pr = event["pull_request"] base, head = pr["base"]["sha"], pr["head"]["sha"] - # Checkout tests the event's merge commit, not an arbitrary head or ref. - # Its first-parent diff includes the integrated PR changes, with no API - # pagination/truncation or merge-base assumptions in a shallow clone. commit = git("cat-file", "-p", "HEAD").decode("utf-8") parents = [line[7:] for line in commit.split("\n\n", 1)[0].splitlines() if line.startswith("parent ")] if parents != [base, head]: raise ValueError("Checkout does not match the PR merge parents") return select(changed_paths(base, "HEAD")) except (KeyError, TypeError, ValueError, UnicodeError, subprocess.CalledProcessError) as err: - return full(f"Diff unavailable ({type(err).__name__}); full gate") + raise ValueError(f"Cannot plan affected checks: {err}. Repair the diff or policy; no full run was started.") from err + + +def check_attempt(planned, current): + if not planned or not planned.isdecimal() or int(planned) < 1 or planned != current: + raise ValueError("The plan belongs to another attempt. Use Re-run all jobs to repeat the selected checks together.") def validate_plan(plan): @@ -132,6 +59,9 @@ def validate_plan(plan): raise ValueError("Invalid selected jobs") if plan["image"] and "api" not in selected: raise ValueError("Image checks require API acceptance") + checks = set(selected) | ({"image"} if plan["image"] else set()) + if expand(checks) != checks: + raise ValueError("Plan omits an execution prerequisite") return set(selected) @@ -149,24 +79,29 @@ def main(): sub = parser.add_subparsers(dest="command", required=True) plan_parser = sub.add_parser("plan") plan_parser.add_argument("--base") + plan_parser.add_argument("--full", action="store_true", help="Explicitly select all checks locally") plan_parser.add_argument("--head", default="HEAD") sub.add_parser("gate") args = parser.parse_args() if args.command == "gate": + check_attempt(os.environ.get("PLAN_ATTEMPT"), os.environ.get("GITHUB_RUN_ATTEMPT")) check_results(json.loads(os.environ["PLAN"]), json.loads(os.environ["RESULTS"])) print("All checks selected by the plan passed.") return - if args.base: + if args.full: + plan = full("Explicit local full verification") + elif args.base: plan = select(changed_paths(args.base, args.head)) else: try: event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) except (OSError, ValueError, KeyError): event = {} - plan = event_plan(os.environ.get("GITHUB_EVENT_NAME"), event, os.environ.get("REQUESTED_REF", "")) + plan = event_plan(os.environ.get("GITHUB_EVENT_NAME"), event, os.environ.get("REQUESTED_REF", ""), os.environ.get("CHECK_SCOPE", "impact")) print(json.dumps(plan, indent=2)) if output := os.environ.get("GITHUB_OUTPUT"): with open(output, "a") as f: + f.write("attempt=" + os.environ.get("GITHUB_RUN_ATTEMPT", "1") + "\n") f.write("plan=" + json.dumps(plan, separators=(",", ":")) + "\n") f.write("jobs=" + json.dumps(plan["jobs"]) + "\n") f.write("image=" + json.dumps(plan["image"]) + "\n") diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index 6f52cdd6..009f42d5 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -69,11 +69,25 @@ def test_core_fixtures_retain_client_and_installer_consumers(self): with self.subTest(path=path): self.assertTrue({"backend", "api", "distribution"} <= self.jobs(path)) - def test_unknown_dependencies_ci_and_empty_diffs_are_full(self): - for paths in ([], ["new-component/source.rs"], ["pnpm-lock.yaml"], ["go.sum"], ["Makefile"], - [".github/actions/node/action.yml"], ["scripts/ci_plan.py"], ["../outside"], ["/outside"]): - self.assertEqual(set(ci.select(paths)["jobs"]), set(ci.JOBS)) - self.assertTrue(ci.select(paths)["image"]) + def test_unknown_inputs_fail_without_starting_a_full_run(self): + for paths in (["new-component/source.rs"], [".github/workflows/unmapped.yml"], ["../outside"], ["/outside"]): + with self.subTest(paths=paths), self.assertRaises(ValueError): + ci.select(paths) + self.assertEqual(self.jobs(), {"hygiene"}) + + def test_shared_dependencies_select_their_consumers(self): + self.assertEqual(self.jobs("go.sum"), {"hygiene", "backend", "api", "native", "distribution"}) + self.assertEqual(self.jobs("pnpm-lock.yaml"), {"hygiene", "web", "web-acceptance", "example", "harness", "native"}) + self.assertFalse(ci.select(["pnpm-lock.yaml"])["image"]) + + def test_ci_changes_select_owned_checks(self): + for path in ("scripts/ci_policy.py", "scripts/ci_plan.py", ".github/workflows/check.yml", ".github/workflows/release.yml"): + self.assertEqual(self.jobs(path), {"hygiene", "lint"}) + self.assertEqual(self.jobs(".github/workflows/ci-backend.yml"), {"hygiene", "backend", "lint"}) + self.assertEqual(self.jobs(".github/workflows/native.yml"), {"hygiene", "native", "lint"}) + self.assertEqual(self.jobs(".github/workflows/ci-web-acceptance.yml"), {"hygiene", "web", "web-acceptance", "lint"}) + self.assertEqual(self.jobs(".github/actions/node/action.yml"), + {"hygiene", "lint", "web", "web-acceptance", "example", "harness", "native"}) def test_mixed_changes_accumulate(self): self.assertEqual(self.jobs("docs/maintainers.md", "deploy/install/install.py", "apps/web/src/app.tsx"), @@ -94,20 +108,30 @@ def test_workflow_graph_cannot_silently_omit_or_add_a_gate_dependency(self): self.assertEqual(set(dependencies), set(ci.JOBS) | {"plan"}) def test_unknown_markdown_is_not_assumed_to_be_documentation(self): - self.assertEqual(self.jobs("new-engine/system-prompt.md"), set(ci.JOBS)) - - def test_non_pr_events_always_run_full(self): - for event in ("push", "workflow_dispatch", "workflow_call"): - self.assertEqual(ci.event_plan(event, {})["jobs"], list(ci.JOBS)) - self.assertEqual(ci.event_plan("pull_request", {}, "release-sha")["jobs"], list(ci.JOBS)) - - def test_unavailable_or_wrong_merge_diff_runs_full(self): - self.assertEqual(ci.event_plan("pull_request", {})["jobs"], list(ci.JOBS)) + with self.assertRaises(ValueError): + self.jobs("new-engine/system-prompt.md") + + def test_full_is_explicit_and_bound_to_an_immutable_checkout(self): + sha = "a" * 40 + with patch.object(ci, "git", return_value=(sha + "\n").encode()): + for event in ("push", "workflow_dispatch", "workflow_call"): + with self.assertRaises(ValueError): + ci.event_plan(event, {}) + self.assertEqual(ci.event_plan(event, {}, sha, "full")["jobs"], list(ci.JOBS)) + for ref in ("", "main", "b" * 40): + with self.assertRaises(ValueError): + ci.event_plan("push", {}, ref, "full") + with self.assertRaises(ValueError): + ci.event_plan("pull_request", {}, sha) + + def test_unavailable_or_wrong_merge_diff_stops_planning(self): + with self.assertRaises(ValueError): + ci.event_plan("pull_request", {}) event = {"pull_request": {"base": {"sha": "a"}, "head": {"sha": "b"}}} - with patch.object(ci, "git", return_value=b"parent wrong\n\nmessage"): - self.assertEqual(ci.event_plan("pull_request", event)["jobs"], list(ci.JOBS)) - with patch.object(ci, "git", side_effect=subprocess.CalledProcessError(1, "git")): - self.assertEqual(ci.event_plan("pull_request", event)["jobs"], list(ci.JOBS)) + with patch.object(ci, "git", return_value=b"parent wrong\n\nmessage"), self.assertRaises(ValueError): + ci.event_plan("pull_request", event) + with patch.object(ci, "git", side_effect=subprocess.CalledProcessError(1, "git")), self.assertRaises(ValueError): + ci.event_plan("pull_request", event) class GitDiffTests(unittest.TestCase): @@ -149,6 +173,18 @@ def git(*args): class GateTests(unittest.TestCase): + def test_partial_reruns_cannot_reuse_a_previous_plan(self): + ci.check_attempt("1", "1") + ci.check_attempt("2", "2") + for planned, current in (("1", "2"), (None, "2"), ("", "1"), ("0", "0")): + with self.subTest(planned=planned), self.assertRaises(ValueError): + ci.check_attempt(planned, current) + + def test_missing_execution_prerequisites_fail(self): + for jobs, image in ((["hygiene", "web-acceptance"], False), (["hygiene"], True)): + with self.assertRaises(ValueError): + ci.validate_plan({"version": 1, "jobs": jobs, "image": image}) + def fixture(self): plan = ci.select(["apps/web/src/app.tsx"]) needs = {job: {"result": "success" if job in plan["jobs"] else "skipped"} for job in ci.JOBS} diff --git a/scripts/ci_policy.py b/scripts/ci_policy.py new file mode 100644 index 00000000..cb5f0fbb --- /dev/null +++ b/scripts/ci_policy.py @@ -0,0 +1,115 @@ +"""Pure CI policy: affected consumers, execution prerequisites and plan shape.""" + +from pathlib import PurePosixPath + +JOBS = ("hygiene", "distribution", "backend", "harness", "example", "web", "web-acceptance", "api", "native", "lint") +# Execution prerequisites are separate from path-to-consumer rules. +PREREQUISITES = {"web-acceptance": ("web",), "image": ("api",)} + +# Rules accumulate: shared inputs exercise every declared consumer. This is the +# only authored path map; workflows consume the resulting plan. +RULES = ( + (("scripts/ci_",), ("hygiene", "lint")), + (("apps/web/", "playwright.config.ts", ".env.example"), ("web", "web-acceptance")), + (("services/web/",), ("distribution", "web", "web-acceptance")), + (("example/",), ("example",)), + (("services/core/",), ("backend", "api")), + (("services/core/internal/sandbox/testdata/node-diagnostics.json",), ("web", "web-acceptance", "example")), + (("services/core/internal/sandbox/testdata/deployment-contract.json", + "services/core/internal/sandbox/e2b/testdata/configuration-selectors.json"), ("distribution",)), + (("services/core/internal/nativeinstaller/",), ("native", "distribution")), + (("services/core/deploy/", "services/core/tools/"), ("distribution",)), + (("apps/daemon/",), ("backend", "native")), + (("internal/",), ("backend", "api", "native", "distribution")), + (("internal/harnessconfig/",), ("web", "web-acceptance", "example", "harness")), + (("contracts/",), ("backend", "api", "native", "web", "web-acceptance", "example", "distribution")), + (("packages/agents-client/",), ("backend", "api", "web", "web-acceptance", "example")), + (("packages/claude-sdk-adapter/", "packages/mcode-harness/"), ("harness", "native", "backend", "distribution")), + (("packages/tsconfig/",), ("web", "web-acceptance", "example", "harness", "native")), + (("deploy/install/", "deploy/install-release.sh", "scripts/install-release.", "scripts/publish-core-release.", + "scripts/core-distribution-manifest.", "scripts/build-core-distribution.sh", "scripts/config-reference.py", + "scripts/build-web.sh"), ("distribution",)), + (("scripts/build-native-", "scripts/native-"), ("native", "backend", "distribution")), + (("scripts/build-core.sh", "scripts/build-core-image-context.sh", "deploy/distribution/"), ("backend", "api", "distribution", "native")), + (("scripts/build-e2b-provider.sh",), ("backend", "api", "distribution")), + (("scripts/build-claude", "scripts/check-claude", "scripts/build-mcode", "scripts/prepare-release-runtimes.sh"), + ("harness", "native", "backend", "distribution")), + (("scripts/build-agents-runtime.sh",), ("backend", "native", "distribution")), + (("scripts/generate-harness-catalog", "scripts/harness-catalog/", "scripts/openapi-split/", "scripts/patch-agents-openapi.py", + "scripts/extract-agents-api-upstream.py"), tuple(job for job in JOBS if job != "lint")), + (("scripts/check-sqlc.py",), ("backend",)), + (("scripts/check-names", "scripts/name-allowlist.json"), ("hygiene",)), +) +# Exact root and workflow inputs are never inferred from a filename prefix. +ROOT_RULES = { + **{path: ("backend", "api", "native", "distribution") for path in ("go.mod", "go.sum", "go.work", "go.work.sum")}, + **{path: ("web", "web-acceptance", "example", "harness", "native") for path in + ("package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "tsconfig.base.json", ".npmrc")}, + "Makefile": tuple(job for job in JOBS if job != "lint") + ("image",), + ".gitignore": ("hygiene",), + ".gitattributes": ("backend", "distribution", "native", "web", "example", "harness"), + ".dockerignore": ("image", "distribution"), + ".github/workflows/check.yml": ("hygiene", "lint"), + ".github/workflows/release.yml": ("hygiene", "lint"), + ".github/workflows/actionlint.yml": ("lint",), + ".github/workflows/api-acceptance.yml": ("api", "image", "lint"), + ".github/workflows/native.yml": ("native", "lint"), + ".github/actions/node/action.yml": ("web", "web-acceptance", "example", "harness", "native", "lint"), + **{f".github/workflows/ci-{job}.yml": (job, "lint") for job in + ("backend", "distribution", "harness", "example", "web", "web-acceptance")}, +} +RULES += ((("scripts/build-core", "scripts/build-e2b-provider", "deploy/distribution/", + "services/core/tools/e2b-provider/", "services/core/deploy/e2b/"), ("image",)),) +# Generated outputs retain freshness checks even when the file is documentation. +GENERATED_OUTPUTS = {"contracts/agents-api/harness-catalog.md", "packages/agents-client/src/harness-catalog.ts", + "services/core/internal/engine/catalog_generated.go", "docs/configuration.md", + "docs/getting-started/install-options.md"} + + +def documentation(path): + p = PurePosixPath(path) + if p.name in {"README.md", "README.zh-CN.md", "AGENTS.md", "CONTRIBUTING.md", "LICENSE"} and (len(p.parts) == 1 or path.startswith(("docs/", "contracts/", "apps/", "services/", "internal/", "packages/", "deploy/", "example/"))): + return True + return (path.startswith(("docs/", "contracts/")) and p.suffix in {".md", ".png", ".jpg", ".jpeg", ".svg", ".webp"}) or path in { + "apps/web/PRODUCT.md", "apps/web/DESIGN.md", "services/core/IMPLEMENTATION.md"} + + +def expand(checks): + selected = set(checks) + if not selected <= set(JOBS) | {"image"}: + raise ValueError("Unknown CI check") + pending = list(selected) + while pending: + for dependency in PREREQUISITES.get(pending.pop(), ()): + if dependency not in selected: + selected.add(dependency) + pending.append(dependency) + return selected + + +def make_plan(checks, reasons): + selected = expand({"hygiene", *checks}) + return {"version": 1, "jobs": [job for job in JOBS if job in selected], "image": "image" in selected, "reasons": reasons} + + +def full(reason): + return make_plan((*JOBS, "image"), [reason]) + + +def select(paths): + checks, reasons = set(), [] + for path in paths: + if not path or path.startswith("/") or ".." in PurePosixPath(path).parts: + raise ValueError("Invalid path in diff") + matches = set(ROOT_RULES.get(path, ())) + if documentation(path): + matches.add("hygiene") + else: + matches.update(job for prefixes, targets in RULES if path.startswith(prefixes) for job in targets) + if path in GENERATED_OUTPUTS: + matches.add("distribution") + if not matches: + raise ValueError(f"Unclassified input: {path}; add its consumers to scripts/ci_policy.py") + checks.update(matches) + reasons.append(f"{path}: {', '.join(sorted(matches))}") + return make_plan(checks, reasons or ["Empty diff: repository integrity only"]) diff --git a/scripts/ci_workflow_test.py b/scripts/ci_workflow_test.py new file mode 100644 index 00000000..3441e49a --- /dev/null +++ b/scripts/ci_workflow_test.py @@ -0,0 +1,61 @@ +"""Keep trigger ownership and workflow adapters aligned with the CI policy.""" + +from pathlib import Path +import re +import unittest + +from ci_policy import JOBS, select + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github/workflows" + + +class WorkflowTests(unittest.TestCase): + def test_daily_entry_has_no_push_or_schedule_trigger(self): + text = (WORKFLOWS / "check.yml").read_text() + trigger = text.split("on:\n", 1)[1].split("\npermissions:", 1)[0] + self.assertEqual(set(re.findall(r"^ ([a-z_]+):", trigger, re.M)), {"pull_request", "workflow_call"}) + self.assertIn("cancel-in-progress: true", text) + self.assertIn("github.ref", text.split("concurrency:", 1)[1].split("jobs:", 1)[0]) + self.assertIn("attempt: ${{ steps.select.outputs.attempt }}", text) + self.assertIn("PLAN_ATTEMPT: ${{ needs.plan.outputs.attempt }}", text) + + def test_check_adapters_are_callable_and_select_their_own_group(self): + text = (WORKFLOWS / "check.yml").read_text() + for job in set(JOBS) - {"hygiene"}: + block = re.search(r"^ " + job + r":\n(.*?)(?=^ (?:[a-z-]+:|#)|\Z)", text, re.M | re.S).group(1) + self.assertIn(f"'{job}')", block) + target = re.search(r"uses: \./(\S+)", block).group(1) + self.assertIn("ref: ${{ inputs.ref || github.sha }}", block) + adapter = (ROOT / target).read_text() + trigger = adapter.split("on:\n", 1)[1].split("\npermissions:", 1)[0] + self.assertIn(" workflow_call:", trigger) + self.assertNotRegex(trigger, r"(?m)^ (push|pull_request|schedule):") + self.assertIn("ref: ${{ inputs.ref", adapter) + self.assertIn(job, select([target])["jobs"]) + + def test_release_requests_full_checks_before_build_and_publication(self): + text = (WORKFLOWS / "release.yml").read_text() + check = text.split(" check:\n", 1)[1].split(" build:\n", 1)[0] + self.assertIn("scope: full", check) + self.assertIn("native-artifacts: true", check) + self.assertIn("ref: ${{ inputs.ref || github.sha }}", check) + self.assertIn(" needs: check", text.split(" build:\n", 1)[1]) + self.assertIn("needs: [check, build]", text.split(" release:\n", 1)[1]) + native = (WORKFLOWS / "native.yml").read_text() + self.assertIn("overwrite: true", native) + self.assertIn("if: inputs.upload-artifacts", native) + + def test_browser_and_native_limits_remain_owned_by_their_adapters(self): + web = (WORKFLOWS / "ci-web-acceptance.yml").read_text() + self.assertIn("shard: [1, 2]", web) + self.assertIn("OAC_WEB_TEST_SHARD=${{ matrix.shard }}/2", web) + self.assertIn("workers: 1", (ROOT / "playwright.config.ts").read_text()) + native = (WORKFLOWS / "native.yml").read_text() + self.assertIn("max-parallel: 2", native) + for platform in ("linux-amd64", "darwin-arm64", "windows-amd64"): + self.assertIn(platform, native) + + +if __name__ == "__main__": + unittest.main()