Skip to content
Merged
Show file tree
Hide file tree
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
45 changes: 43 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
108 changes: 108 additions & 0 deletions scripts/changes-filter-retry.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}