From c22c9e6d9c7f6c0c2e9db112f2b13fa9da256429 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Sat, 26 Sep 2026 03:12:22 +0200 Subject: [PATCH 1/2] feat: configure draft pull request scanning --- .changeset/draft-scan-policy.md | 5 ++ CLAUDE.md | 3 +- src/__tests__/config-validator.test.ts | 15 ++++ src/__tests__/config.test.ts | 20 ++++- src/__tests__/server.test.ts | 117 +++++++++++++++++++++++-- src/config-validator.ts | 3 + src/config.ts | 2 +- src/server.ts | 33 +++++-- src/types.ts | 1 + website/docs/configuration.md | 15 +++- 10 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 .changeset/draft-scan-policy.md create mode 100644 src/__tests__/config-validator.test.ts diff --git a/.changeset/draft-scan-policy.md b/.changeset/draft-scan-policy.md new file mode 100644 index 0000000..5b28fd1 --- /dev/null +++ b/.changeset/draft-scan-policy.md @@ -0,0 +1,5 @@ +--- +"layne": major +--- + +Skip draft pull requests by default across direct and deferred triggers, and handle ready_for_review events. Set trigger.scanOnDraft to true globally or per repository to preserve scanning drafts. Deferred CI workflows should subscribe to ready_for_review. diff --git a/CLAUDE.md b/CLAUDE.md index 13ac618..7e99977 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,7 +73,7 @@ Two separate Node.js processes: - Express app with `POST /webhook`, `GET /health`, `GET /metrics` (when enabled), `GET /assets/layne-logo.png` - Verifies GitHub HMAC signature before processing - Handles four event types: `pull_request`, `workflow_run`, `workflow_job`, and `issue_comment` -- **`pull_request` trigger (default):** on opened/synchronize/reopened, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200 +- **`pull_request` trigger (default):** ignores drafts by default; on opened/synchronize/reopened/ready_for_review for eligible PRs, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200 - **`workflow_run` trigger:** on `pull_request` events, caches PR metadata in Redis (TTL 7 days) and creates a `skipped` Check Run; on `workflow_run completed` events matching the configured workflow name and conclusion, looks up cached PR metadata (falls back to GitHub API if cache is cold) then enqueues the scan - **`workflow_job` trigger:** same two-stage pattern as `workflow_run` but gates on a single named job completing rather than the whole workflow - **`issue_comment` trigger:** parses `/layne exception-approve` commands from PR comments; validates the commenter is an authorized exception approver; stores exceptions in Redis scoped to the PR (not the commit SHA); re-enqueues the scan if the current check run is in `failure` state @@ -158,6 +158,7 @@ Key points for code navigation: - Supports `$global` key for defaults inherited by all repos - Scanner blocks: per-repo spread over defaults (`{ ...DEFAULT_CONFIG.semgrep, ...repoOverrides.semgrep }`) - `trigger`: controls when scanning fires - `pull_request` (default, immediate) or `workflow_run` (deferred until a named CI workflow completes); global default → per-repo override +- `trigger.scanOnDraft`: defaults to `false` and applies to pull request, workflow run, and workflow job triggers - `notifications` and `labels`: per-repo notifier/key wins over global; per-repo absence = inherit global entirely - `extraArgs` fully replaces the default (not extended) - `config/layne.json` must be present in the Docker image (`COPY config/ ./config/`) diff --git a/src/__tests__/config-validator.test.ts b/src/__tests__/config-validator.test.ts new file mode 100644 index 0000000..38801fe --- /dev/null +++ b/src/__tests__/config-validator.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { validateConfig } from '../config-validator.js'; + +describe('trigger draft config validation', () => { + it('accepts scanOnDraft for every trigger mode', () => { + expect(validateConfig({ '$global': { trigger: { scanOnDraft: false } } })).toEqual({ valid: true }); + expect(validateConfig({ 'acme/run': { trigger: { on: 'workflow_run', workflow: 'CI', scanOnDraft: true } } })).toEqual({ valid: true }); + expect(validateConfig({ 'acme/job': { trigger: { on: 'workflow_job', job: 'test', scanOnDraft: true } } })).toEqual({ valid: true }); + }); + + it('rejects non-boolean scanOnDraft values', () => { + expect(validateConfig({ '$global': { trigger: { scanOnDraft: 'false' } } }).valid).toBe(false); + expect(validateConfig({ 'acme/repo': { trigger: { scanOnDraft: null } } }).valid).toBe(false); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 3067d9c..0d6b76d 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -223,7 +223,7 @@ describe('loadScanConfig()', () => { it('returns the default pull_request trigger when no trigger is configured', async () => { vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({})); const config = await loadScanConfig({ owner: 'org', repo: 'repo' }); - expect(config.trigger).toEqual({ on: 'pull_request' }); + expect(config.trigger).toEqual({ on: 'pull_request', scanOnDraft: false }); }); it('returns a workflow_run trigger configured at the repo level', async () => { @@ -233,7 +233,7 @@ describe('loadScanConfig()', () => { }, })); const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' }); - expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'Tests Done' }); + expect(config.trigger).toEqual({ on: 'workflow_run', scanOnDraft: false, workflow: 'Tests Done' }); }); it('inherits $global trigger when the repo has no trigger block', async () => { @@ -242,7 +242,7 @@ describe('loadScanConfig()', () => { 'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } }, })); const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' }); - expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'CI' }); + expect(config.trigger).toEqual({ on: 'workflow_run', scanOnDraft: false, workflow: 'CI' }); }); it('repo-level trigger overrides $global trigger', async () => { @@ -264,6 +264,20 @@ describe('loadScanConfig()', () => { expect((config.trigger as { conclusions: string[] }).conclusions).toEqual(['success', 'failure']); }); + it('supports global scanOnDraft with a repository override', async () => { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({ + '$global': { trigger: { scanOnDraft: true } }, + 'acme/frontend': { trigger: { scanOnDraft: false } }, + 'acme/backend': {}, + })); + + const frontend = await loadScanConfig({ owner: 'acme', repo: 'frontend' }); + const backend = await loadScanConfig({ owner: 'acme', repo: 'backend' }); + + expect(frontend.trigger).toEqual({ on: 'pull_request', scanOnDraft: false }); + expect(backend.trigger).toEqual({ on: 'pull_request', scanOnDraft: true }); + }); + // --- comment --- it('returns comment.enabled=false by default', async () => { diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts index bfcdcae..9854742 100644 --- a/src/__tests__/server.test.ts +++ b/src/__tests__/server.test.ts @@ -43,10 +43,10 @@ const { isReviewerAuthorized, parseExceptionCommand, storeExceptions } = await import('../exception-approvals.js'); const { app, verifySignature, processWebhookRequest } = await import('../server.js'); -const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request' } }; -const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'] } }; -const WORKFLOW_JOB_TRIGGER_CONFIG = { trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success'] } }; -const EXCEPTION_CONFIG = { trigger: { on: 'pull_request' }, exceptionApprovers: { users: ['alice'], teams: [] } }; +const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request', scanOnDraft: false } }; +const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'], scanOnDraft: false } }; +const WORKFLOW_JOB_TRIGGER_CONFIG = { trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success'], scanOnDraft: false } }; +const EXCEPTION_CONFIG = { trigger: { on: 'pull_request', scanOnDraft: false }, exceptionApprovers: { users: ['alice'], teams: [] } }; function sign(body: Buffer | string): string { return 'sha256=' + crypto @@ -55,12 +55,13 @@ function sign(body: Buffer | string): string { .digest('hex'); } -function prPayload(action = 'opened'): string { +function prPayload(action = 'opened', metadata: { draft?: boolean } = {}): string { return JSON.stringify({ action, number: 42, pull_request: { number: 42, + draft: metadata.draft ?? false, head: { sha: 'abc123', ref: 'feature/login', repo: {} }, base: { sha: 'def456', ref: 'main' }, labels: [{ name: 'bug' }], @@ -149,6 +150,7 @@ beforeEach(() => { (getLatestCheckRun as ReturnType).mockResolvedValue({ conclusion: 'failure' }); (getPullRequest as ReturnType).mockResolvedValue({ state: 'open', + draft: false, head: { sha: 'abc123', ref: 'feature/login' }, base: { sha: 'def456', ref: 'main' }, labels: [], @@ -227,7 +229,7 @@ describe('processWebhookRequest()', () => { expect(createCheckRun).not.toHaveBeenCalled(); }); - it.each(['opened', 'synchronize', 'reopened'])( + it.each(['opened', 'synchronize', 'reopened', 'ready_for_review'])( 'accepts action "%s" only after the check run and queue job are created', async (action) => { const res = await processWebhookRequest(webhookRequest(prPayload(action))); @@ -238,6 +240,37 @@ describe('processWebhookRequest()', () => { } ); + it.each(['opened', 'synchronize', 'reopened'])( + 'ignores draft action "%s" by default', + async (action) => { + const res = await processWebhookRequest(webhookRequest(prPayload(action, { draft: true }))); + + expect(res).toEqual({ status: 200, body: 'Draft ignored' }); + expect(createCheckRun).not.toHaveBeenCalled(); + expect(scanQueue.add).not.toHaveBeenCalled(); + } + ); + + it('scans drafts and ready_for_review events when scanOnDraft is enabled', async () => { + (loadScanConfig as ReturnType).mockResolvedValue({ + trigger: { on: 'pull_request', scanOnDraft: true }, + }); + + const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true }))); + expect(draftRes).toEqual({ status: 200, body: 'Accepted' }); + expect(scanQueue.add).toHaveBeenCalledOnce(); + + vi.clearAllMocks(); + (loadScanConfig as ReturnType).mockResolvedValue({ + trigger: { on: 'pull_request', scanOnDraft: true }, + }); + + const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review'))); + expect(readyRes).toEqual({ status: 200, body: 'Accepted' }); + expect(createCheckRun).toHaveBeenCalledOnce(); + expect(scanQueue.add).toHaveBeenCalledOnce(); + }); + it('does not resolve before both persistence steps succeed', async () => { const checkRun = deferred(); const enqueue = deferred(); @@ -403,6 +436,20 @@ describe('workflow_run trigger — pull_request event', () => { expect(createCheckRun).not.toHaveBeenCalled(); }); + it('ignores draft PRs and starts deferral when they become ready', async () => { + const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true }))); + + expect(draftRes).toEqual({ status: 200, body: 'Draft ignored' }); + expect(redis.set).not.toHaveBeenCalled(); + expect(skipCheckRun).not.toHaveBeenCalled(); + + const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review'))); + + expect(readyRes).toEqual({ status: 200, body: 'Deferred' }); + expect(redis.set).toHaveBeenCalledOnce(); + expect(skipCheckRun).toHaveBeenCalledOnce(); + }); + it('caches PR metadata in Redis with a 7-day TTL', async () => { await processWebhookRequest(webhookRequest(prPayload('opened'))); @@ -476,6 +523,28 @@ describe('workflow_run trigger — workflow_run event', () => { expect(scanQueue.add).toHaveBeenCalledOnce(); }); + it('does not enqueue when the live PR is a draft by default', async () => { + (getPullRequest as ReturnType).mockResolvedValueOnce({ state: 'open', draft: true }); + + const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' })); + + expect(res).toEqual({ status: 200, body: 'PR not found' }); + expect(createCheckRun).not.toHaveBeenCalled(); + expect(scanQueue.add).not.toHaveBeenCalled(); + }); + + it('enqueues a draft when scanOnDraft is enabled', async () => { + (loadScanConfig as ReturnType).mockResolvedValue({ + trigger: { ...WORKFLOW_TRIGGER_CONFIG.trigger, scanOnDraft: true }, + }); + (getPullRequest as ReturnType).mockResolvedValueOnce({ state: 'open', draft: true }); + + const res = await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' })); + + expect(res).toEqual({ status: 200, body: 'Accepted' }); + expect(scanQueue.add).toHaveBeenCalledOnce(); + }); + it('enqueues with the correct job payload from the cached PR data', async () => { await processWebhookRequest(webhookRequest(workflowRunPayload(), { event: 'workflow_run' })); @@ -665,6 +734,20 @@ describe('workflow_job trigger — pull_request event', () => { expect(createCheckRun).not.toHaveBeenCalled(); }); + it('ignores draft PRs and starts deferral when they become ready', async () => { + const draftRes = await processWebhookRequest(webhookRequest(prPayload('opened', { draft: true }))); + + expect(draftRes).toEqual({ status: 200, body: 'Draft ignored' }); + expect(redis.set).not.toHaveBeenCalled(); + expect(skipCheckRun).not.toHaveBeenCalled(); + + const readyRes = await processWebhookRequest(webhookRequest(prPayload('ready_for_review'))); + + expect(readyRes).toEqual({ status: 200, body: 'Deferred' }); + expect(redis.set).toHaveBeenCalledOnce(); + expect(skipCheckRun).toHaveBeenCalledOnce(); + }); + it('caches PR metadata in Redis with a 7-day TTL', async () => { await processWebhookRequest(webhookRequest(prPayload('opened'))); @@ -738,6 +821,28 @@ describe('workflow_job trigger — workflow_job event', () => { expect(scanQueue.add).toHaveBeenCalledOnce(); }); + it('does not enqueue when the live PR is a draft by default', async () => { + (getPullRequest as ReturnType).mockResolvedValueOnce({ state: 'open', draft: true }); + + const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' })); + + expect(res).toEqual({ status: 200, body: 'PR not found' }); + expect(createCheckRun).not.toHaveBeenCalled(); + expect(scanQueue.add).not.toHaveBeenCalled(); + }); + + it('enqueues a draft when scanOnDraft is enabled', async () => { + (loadScanConfig as ReturnType).mockResolvedValue({ + trigger: { ...WORKFLOW_JOB_TRIGGER_CONFIG.trigger, scanOnDraft: true }, + }); + (getPullRequest as ReturnType).mockResolvedValueOnce({ state: 'open', draft: true }); + + const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' })); + + expect(res).toEqual({ status: 200, body: 'Accepted' }); + expect(scanQueue.add).toHaveBeenCalledOnce(); + }); + it('enqueues with the correct job payload from the cached PR data', async () => { await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' })); diff --git a/src/config-validator.ts b/src/config-validator.ts index beff65d..993142d 100644 --- a/src/config-validator.ts +++ b/src/config-validator.ts @@ -242,6 +242,9 @@ function validateTrigger(block: unknown, ctx: string, errors: string[]): void { const on = (b['on'] as string | undefined) ?? 'pull_request'; + if (b['scanOnDraft'] !== undefined && typeof b['scanOnDraft'] !== 'boolean') + errors.push(`${ctx}.scanOnDraft: must be a boolean`); + if (on === 'workflow_run') { if (b['workflow'] === undefined || b['workflow'] === null) errors.push(`${ctx}.workflow: required when "on" is "workflow_run"`); diff --git a/src/config.ts b/src/config.ts index f1f3a3d..cda9ef1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,7 +48,7 @@ export const DEFAULT_CONFIG: Readonly = Object.freeze({ extraArgs: [], } as DepDoctorConfig), labels: Object.freeze({} as LabelConfig), - trigger: Object.freeze({ on: 'pull_request' } as TriggerConfig), + trigger: Object.freeze({ on: 'pull_request', scanOnDraft: false } as TriggerConfig), comment: Object.freeze({ enabled: false, template: null } as CommentConfig), exceptionApprovers: Object.freeze({ users: [], teams: [] } as ExceptionApproversConfig), notifications: Object.freeze({} as Record), diff --git a/src/server.ts b/src/server.ts index 4d19075..aacd2fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -21,7 +21,7 @@ const app = express(); const PORT = process.env.PORT ?? 3000; const ACCEPTED_RESPONSE = { status: 200, body: 'Accepted' }; -const HANDLED_PR_ACTIONS = new Set(['opened', 'synchronize', 'reopened']); +const HANDLED_PR_ACTIONS = new Set(['opened', 'synchronize', 'reopened', 'ready_for_review']); const WEBHOOK_LOCK_TTL_SECONDS = 30; const PR_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days @@ -131,7 +131,7 @@ async function handlePullRequest(payload: Record): Promise<{ st repository: Record; installation: Record; }; - const pr = pull_request as { number: number; head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> }; + const pr = pull_request as { number: number; draft?: boolean; head: { sha: string; ref: string }; base: { sha: string; ref: string }; labels?: Array<{ name: string }> }; const prNumber = pr.number; const headSha = pr.head.sha; const repo = repository as { full_name: string; owner: { login: string }; name: string }; @@ -146,6 +146,11 @@ async function handlePullRequest(payload: Record): Promise<{ st const config = await loadScanConfig({ owner: repo.owner.login, repo: repo.name }); + if (pr.draft === true && !config.trigger.scanOnDraft) { + debug('server', `ignoring draft PR: ${repo.full_name} PR #${prNumber}`); + return { status: 200, body: 'Draft ignored' }; + } + if (config.trigger.on === 'workflow_run' || config.trigger.on === 'workflow_job') { return deferPullRequest({ pull_request: pr, repository: repo, installation, config }); } @@ -375,7 +380,12 @@ async function handleWorkflowRun(payload: Record): Promise<{ st } const headSha = run.head_sha; - const prData = await resolvePrData({ installation, repository: repo, headSha }); + const prData = await resolvePrData({ + installation, + repository: repo, + headSha, + scanOnDraft: config.trigger.scanOnDraft, + }); if (!prData) { console.warn(`[server] workflow_run: could not find PR for ${repo.full_name}@${headSha} — scan skipped`); @@ -438,7 +448,12 @@ async function handleWorkflowJob(payload: Record): Promise<{ st } const headSha = job.head_sha; - const prData = await resolvePrData({ installation, repository: repo, headSha }); + const prData = await resolvePrData({ + installation, + repository: repo, + headSha, + scanOnDraft: config.trigger.scanOnDraft, + }); if (!prData) { console.warn(`[server] workflow_job: could not find PR for ${repo.full_name}@${headSha} — scan skipped`); @@ -461,10 +476,11 @@ async function handleWorkflowJob(payload: Record): Promise<{ st }); } -async function resolvePrData({ installation, repository, headSha }: { +async function resolvePrData({ installation, repository, headSha, scanOnDraft }: { installation: Record; repository: { full_name: string; owner: { login: string }; name: string; clone_url?: string }; headSha: string; + scanOnDraft: boolean; }): Promise { const cacheKey = prCacheKey(repository.full_name, headSha); const cached = await redis.get(cacheKey); @@ -516,10 +532,15 @@ async function resolvePrData({ installation, repository, headSha }: { repo: repository.name, prNumber: prData.prNumber, }); - if ((livePr as { state?: string }).state !== 'open') { + const liveState = livePr as { state?: string; draft?: boolean }; + if (liveState.state !== 'open') { debug('server', `PR #${prData.prNumber} is no longer open, skipping scan`); return null; } + if (liveState.draft === true && !scanOnDraft) { + debug('server', `PR #${prData.prNumber} is a draft, skipping scan`); + return null; + } } catch (err) { console.error(`[server] Failed to verify PR state: ${(err as Error).message}`); return null; diff --git a/src/types.ts b/src/types.ts index b3554c2..67b5644 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,6 +176,7 @@ export interface LabelConfig { export interface TriggerConfig { on: TriggerOn; + scanOnDraft: boolean; workflow?: string; job?: string; conclusions?: string[]; diff --git a/website/docs/configuration.md b/website/docs/configuration.md index 4bd76f2..d6e0f51 100644 --- a/website/docs/configuration.md +++ b/website/docs/configuration.md @@ -172,7 +172,7 @@ Raise this for large monorepos where scanners may take a long time, or lower it ## Trigger -By default Layne scans every pull request immediately when it is opened, synchronised, or reopened (`pull_request` trigger). This may be the right choice for private or internal repositories where all contributors are trusted and every PR is worth scanning. +By default Layne scans non-draft pull requests when they are opened, synchronised, reopened, or marked ready for review (`pull_request` trigger). Draft pull requests are ignored unless `trigger.scanOnDraft` is enabled. For public repositories, two problems arise: @@ -186,7 +186,7 @@ Long story short, you can choose between the following: | `on` | Behavior | |---|---| -| `pull_request` | *(default)* Scan fires immediately on `opened`, `synchronize`, and `reopened` | +| `pull_request` | *(default)* Scan fires immediately on `opened`, `synchronize`, `reopened`, and `ready_for_review` for eligible PRs | | `workflow_run` | Scan fires when the named CI workflow completes with a matching conclusion | | `workflow_job` | Scan fires when the named CI job completes with a matching conclusion | @@ -221,6 +221,7 @@ Long story short, you can choose between the following: | Key | Type | Default | Description | |---|---|---|---| | `on` | `"pull_request"` \| `"workflow_run"` \| `"workflow_job"` | `"pull_request"` | When to trigger the scan | +| `scanOnDraft` | boolean | `false` | Allow draft PRs to scan; applies to all trigger modes | | `workflow` | string | (none) | Workflow name to watch. Required when `on` is `"workflow_run"` | | `job` | string | (none) | Job name to watch. Required when `on` is `"workflow_job"` | | `conclusions` | string[] | `["success"]` | Workflow/job conclusions that trigger the scan | @@ -232,6 +233,16 @@ Both `workflow_run` and `workflow_job` follow the same two-stage pattern: 1. **On `pull_request`** - Layne caches PR metadata in Redis (7-day TTL) and creates a `skipped` Check Run so the deferral is visible in the PR status UI. No scan is enqueued yet. 2. **On the trigger event completing** - When the named workflow or job finishes with a matching conclusion, Layne looks up the cached PR metadata and enqueues the scan. If the cache is cold (e.g. Layne was offline when the PR was opened), it falls back to the GitHub API. +With the default `scanOnDraft: false`, draft PR events are not cached or deferred. The watched GitHub Actions workflow must include `ready_for_review` in its `pull_request.types` so marking the PR ready starts a new workflow or job: + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] +``` + +Setting `scanOnDraft: true` allows matching `pull_request`, `workflow_run`, and `workflow_job` triggers to scan drafts. Deferred triggers still require the configured workflow or job conclusion. + ### Failure mode :::warning From 88633cc4bd24106485b72191ede3130590b4dab2 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Sat, 26 Sep 2026 03:36:20 +0200 Subject: [PATCH 2/2] ci: install the committed dependency lockfile --- .github/workflows/ci-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 561fdf8..053242e 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '22' - - run: rm -f package-lock.json && npm install + - run: npm ci - run: npm run build - run: npm run lint - run: npm run validate-config