diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f87f965271..660c63b32c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,10 @@ jobs: runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} timeout-minutes: 5 outputs: - kilocode_backend: ${{ steps.filter.outputs.kilocode_backend }} - cloud_agent_next: ${{ steps.filter.outputs.cloud_agent_next }} + # Fall back to the retry attempt when the first Detect changes step died + # on a transient GitHub API failure (it sets no outputs when it fails). + kilocode_backend: ${{ steps.filter.outputs.kilocode_backend || steps.filter_retry.outputs.kilocode_backend }} + cloud_agent_next: ${{ steps.filter.outputs.cloud_agent_next || steps.filter_retry.outputs.cloud_agent_next }} workspace_matrix: ${{ steps.workspaces.outputs.matrix }} steps: - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 @@ -47,8 +49,47 @@ jobs: - name: Test dependency change detection run: node --test scripts/changed-dependencies.test.mjs + - name: Test changes-filter retry wiring + run: node --test scripts/changes-filter-retry.test.mjs + + # dorny/paths-filter has no built-in retry: one dropped connection to + # api.github.com ("other side closed" while listing the PR's changed + # files) fails the step and, with it, every check that needs this job. + # The first attempt is allowed to fail so the retry below absorbs + # transient resets; a persistent failure still fails the job. - name: Detect changes id: filter + continue-on-error: true + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + kilocode_backend: + - 'apps/web/src/**' + - 'apps/web/.env' + - 'apps/web/.env.test' + - 'apps/web/.env.development.local.example' + - '.env.local.example' + - 'apps/web/package.json' + - 'apps/web/tsconfig.json' + - 'apps/web/tsconfig.*.json' + - 'apps/web/next.config.mjs' + - 'apps/web/jest.config.ts' + - 'apps/web/postcss.config.mjs' + - 'apps/web/sentry.*' + - 'packages/db/**' + - 'packages/encryption/**' + - 'packages/trpc/**' + - 'packages/worker-utils/**' + - 'services/deploy-infra/builder/**' + - 'package.json' + - 'pnpm-lock.yaml' + cloud_agent_next: + - 'services/cloud-agent-next/**' + - name: Detect changes (retry) + id: filter_retry + # Keep the filters byte-identical to the attempt above: + # scripts/changes-filter-retry.test.mjs fails the job when they drift. + if: steps.filter.outcome == 'failure' uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 with: filters: | diff --git a/scripts/changes-filter-retry.test.mjs b/scripts/changes-filter-retry.test.mjs new file mode 100644 index 0000000000..b9957bd98a --- /dev/null +++ b/scripts/changes-filter-retry.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { load } from 'js-yaml'; + +// The "Detect changes" step in ci.yml runs dorny/paths-filter, which calls the +// GitHub API to list the PR's changed files. That call fails without retry on +// transient connection resets ("other side closed"), so the workflow keeps a +// second attempt whose outputs the job falls back to. This suite pins that +// wiring: without it a single dropped connection fails every check gated on +// this job, and drifted filters between the two attempts would let the retry +// evaluate different paths than the first attempt reported. + +const actionRef = 'dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d'; +const filterNames = ['kilocode_backend', 'cloud_agent_next']; +const fallbackOutput = name => + `\${{ steps.filter.outputs.${name} || steps.filter_retry.outputs.${name} }}`; + +function readChangesJob() { + const workflow = load( + readFileSync(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8') + ); + return workflow.jobs.changes; +} + +function firstAttempt(job) { + const step = job.steps.find(item => item.id === 'filter'); + assert.ok(step, 'changes: first Detect changes step (id filter) is missing'); + return step; +} + +function retryStep(job) { + const step = job.steps.find(item => item.id === 'filter_retry'); + assert.ok(step, 'changes: Detect changes retry step (id filter_retry) is missing'); + return step; +} + +function validate(job) { + const first = firstAttempt(job); + const retry = retryStep(job); + assert.ok( + job.steps.indexOf(retry) > job.steps.indexOf(first), + 'changes: retry must run after the first Detect changes attempt' + ); + assert.equal(first.uses, actionRef, 'changes: first attempt must use the pinned paths-filter'); + assert.equal(retry.uses, actionRef, 'changes: retry must use the same pinned paths-filter'); + assert.equal( + first['continue-on-error'], + true, + 'changes: first attempt must tolerate failure so the retry can run' + ); + assert.equal( + retry['continue-on-error'], + undefined, + 'changes: a persistent filter failure must fail the job, never be ignored' + ); + assert.equal( + retry.if, + "steps.filter.outcome == 'failure'", + 'changes: retry must run only after a failed first attempt' + ); + assert.equal( + retry.with.filters, + first.with.filters, + 'changes: retry filters must be byte-identical to the first attempt' + ); + assert.deepEqual( + Object.keys(load(first.with.filters)).sort(), + [...filterNames].sort(), + 'changes: filters must keep the expected filter names' + ); + for (const name of filterNames) { + assert.equal( + job.outputs[name], + fallbackOutput(name), + `changes: output ${name} must fall back to the retry attempt` + ); + } +} + +test('ci.yml retries the changes filter after a transient GitHub API failure', () => { + validate(readChangesJob()); +}); + +for (const defect of [ + 'missing-retry', + 'fatal-first-attempt', + 'ignored-retry', + 'unconditional-retry', + 'drifted-filters', + 'missing-fallback', +]) { + test(`changes rejects ${defect}`, () => { + const job = readChangesJob(); + const first = job.steps.find(item => item.id === 'filter'); + const retry = job.steps.find(item => item.id === 'filter_retry'); + if (defect === 'missing-retry') job.steps.splice(job.steps.indexOf(retry), 1); + if (defect === 'fatal-first-attempt') delete first['continue-on-error']; + if (defect === 'ignored-retry') retry['continue-on-error'] = true; + if (defect === 'unconditional-retry') delete retry.if; + if (defect === 'drifted-filters') + retry.with.filters = retry.with.filters.replace("'apps/web/src/**'", "'apps/web/src2/**'"); + if (defect === 'missing-fallback') + job.outputs.kilocode_backend = '${{ steps.filter.outputs.kilocode_backend }}'; + assert.throws(() => validate(job), assert.AssertionError); + }); +}