Skip to content

Commit f2de34f

Browse files
feat: Add a new trigger - workflow_run (#4)
* feat: add a new trigger for Layne - workflow_run * Add changeset
1 parent e0b5410 commit f2de34f

File tree

17 files changed

+765
-66
lines changed

17 files changed

+765
-66
lines changed

.changeset/rich-socks-say.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"layne": minor
3+
---
4+
5+
Adds a new trigger for Layne: workflow_run

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ Two separate Node.js processes:
5454
**`src/server.js` — Webhook receiver**
5555
- Express app with `POST /webhook`, `GET /health`, `GET /metrics` (when enabled), `GET /assets/layne-logo.png`
5656
- Verifies GitHub HMAC signature before processing
57-
- On a qualifying `pull_request` event (opened/synchronize/reopened), creates a GitHub Check Run in `queued` state, then enqueues a BullMQ job and returns 200
57+
- Handles two event types: `pull_request` and `workflow_run`
58+
- **`pull_request` trigger (default):** on opened/synchronize/reopened, creates a Check Run in `queued` state, enqueues a BullMQ job, returns 200
59+
- **`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
5860
- Job ID is deduplicated by `{repo}#{pr}@{sha}` — duplicate webhook deliveries are no-ops (Redis lock + queue check)
5961
- Exported `app` and `processWebhookRequest` for use in tests
6062

@@ -103,26 +105,27 @@ Two separate Node.js processes:
103105

104106
| Module | Purpose |
105107
|--------|---------|
106-
| `src/config.js` | Loads and merges `config/repos.json`; cached after first read |
108+
| `src/config.js` | Loads and merges `config/layne.json`; cached after first read |
107109
| `src/github.js` | Check Run CRUD + label management (`ensureLabelsExist`, `setLabels`) |
108110
| `src/metrics.js` | Prometheus metric definitions; exports no-op stubs when `METRICS_ENABLED` is not `true` |
109111
| `src/notifiers/index.js` | Notification orchestrator; iterates registered notifiers |
110112
| `src/notifiers/rocketchat.js` | Rocket.Chat incoming webhook notifier |
111113
| `src/queue.js` | Shared Redis + BullMQ queue instance |
112114
| `src/debug.js` | Conditional debug logging via `DEBUG_MODE` |
113115

114-
## Per-repo configuration (`config/repos.json`)
116+
## Per-repo configuration (`config/layne.json`)
115117

116118
See [docs/configuration.md](docs/configuration.md) for the full schema and examples.
117119

118120
Key points for code navigation:
119-
- Read once at worker startup — **restart the worker to pick up changes**
121+
- Read once per process startup — **restart both server and worker to pick up changes**
120122
- Loaded and merged by `src/config.js``loadScanConfig`
121123
- Supports `$global` key for defaults inherited by all repos
122124
- Scanner blocks: per-repo spread over defaults (`{ ...DEFAULT_CONFIG.semgrep, ...repoOverrides.semgrep }`)
125+
- `trigger`: controls when scanning fires — `pull_request` (default, immediate) or `workflow_run` (deferred until a named CI workflow completes); global default → per-repo override
123126
- `notifications` and `labels`: per-repo notifier/key wins over global; per-repo absence = inherit global entirely
124127
- `extraArgs` fully replaces the default (not extended)
125-
- `config/repos.json` must be present in the Docker image (`COPY config/ ./config/`)
128+
- `config/layne.json` must be present in the Docker image (`COPY config/ ./config/`)
126129
- Notifier contract: `async function notify({ findings, owner, repo, prNumber, toolConfig })` — must never throw
127130
- Notification dedup key: `layne:scan:count:{owner}/{repo}#{prNumber}` (Redis, 30-day TTL)
128131
- `webhookUrl` values starting with `$` are resolved from `process.env` at runtime

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Closes #42
9191
npm test # run the full test suite
9292
npm run test:watch # watch mode during development
9393
npm run lint # ESLint
94-
npm run validate-config # validate config/repos.json schema
94+
npm run validate-config # validate config/layne.json schema
9595
```
9696

9797
All four must pass before a PR can be merged.
File renamed without changes.

docs/configuration.md

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Configuration
22

3-
Scanner behaviour, labels, and notifications are all configured in `config/repos.json`. Layne reads this file once at worker startup — **restart the worker to pick up changes**.
3+
Scanner behaviour, labels, notifications, and trigger conditions are all configured in `config/layne.json`. Layne reads this file once per process startup — **restart both server and worker to pick up changes** (the automated deploy pipeline does this automatically).
44

55
---
66

@@ -119,7 +119,7 @@ skill = Anthropic().beta.skills.create(
119119
files=files_from_dir("/path/to/skill-folder"), # must contain SKILL.md
120120
betas=["skills-2025-10-02"],
121121
)
122-
print(skill.id) # paste this into repos.json
122+
print(skill.id) # paste this into layne.json
123123
```
124124

125125
### How args are assembled
@@ -236,7 +236,7 @@ Labels are applied **after** the Check Run is completed. Errors never affect the
236236

237237
### Configuration
238238

239-
Add a `labels` key to `$global` or to any repo entry in `config/repos.json`:
239+
Add a `labels` key to `$global` or to any repo entry in `config/layne.json`:
240240

241241
```json
242242
{
@@ -289,6 +289,91 @@ If neither `$global` nor the repo defines a `labels` key, the feature is a no-op
289289

290290
---
291291

292+
## Trigger
293+
294+
By default Layne scans every pull request immediately when it is opened, synchronised, or reopened. The `trigger` block lets you defer scanning until a specific CI workflow completes — useful for open-source repositories where workflows from external contributors require maintainer approval before they run.
295+
296+
### Modes
297+
298+
| `on` | Behaviour |
299+
|---|---|
300+
| `pull_request` | *(default)* Scan fires immediately on `opened`, `synchronize`, and `reopened` events |
301+
| `workflow_run` | Scan fires when the named CI workflow completes with a matching conclusion |
302+
303+
### Schema
304+
305+
```json
306+
{
307+
"owner/repo": {
308+
"trigger": {
309+
"on": "workflow_run",
310+
"workflow": "Tests Done",
311+
"conclusions": ["success"]
312+
}
313+
}
314+
}
315+
```
316+
317+
| Key | Type | Default | Description |
318+
|---|---|---|---|
319+
| `on` | `"pull_request"` \| `"workflow_run"` | `"pull_request"` | When to trigger the scan |
320+
| `workflow` | string || Name of the GitHub Actions workflow to watch. Required when `on` is `"workflow_run"` |
321+
| `conclusions` | string[] | `["success"]` | Workflow conclusions that trigger the scan. Valid values: `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, `action_required` |
322+
323+
> **`trigger` can be set globally.** Set it under `$global` to apply to all repos, then override per-repo as needed.
324+
325+
### How `workflow_run` works
326+
327+
When `on: workflow_run` is configured for a repo:
328+
329+
1. **On `pull_request`** — Layne caches the 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.
330+
2. **On `workflow_run completed`** — When the named workflow 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), Layne falls back to the GitHub API to find the associated PR.
331+
332+
### Failure mode
333+
334+
If the watched workflow is renamed or removed, Layne never receives the `workflow_run` event and the scan never runs. To fail **closed** (safe) rather than **open** (silent), make Layne's Check Run a **required status check** in branch protection — then a missing check blocks merging and the absence is immediately visible.
335+
336+
### Examples
337+
338+
**Scan only after CI passes (mirrors GitHub's "require approval for workflows" gate):**
339+
```json
340+
{
341+
"owner/repo": {
342+
"trigger": {
343+
"on": "workflow_run",
344+
"workflow": "Tests Done"
345+
}
346+
}
347+
}
348+
```
349+
350+
**Scan regardless of whether CI passes or fails (workflow was approved, code is worth scanning):**
351+
```json
352+
{
353+
"owner/repo": {
354+
"trigger": {
355+
"on": "workflow_run",
356+
"workflow": "Tests Done",
357+
"conclusions": ["success", "failure"]
358+
}
359+
}
360+
}
361+
```
362+
363+
**Apply workflow_run trigger to all repos globally:**
364+
```json
365+
{
366+
"$global": {
367+
"trigger": {
368+
"on": "workflow_run",
369+
"workflow": "CI"
370+
}
371+
}
372+
}
373+
```
374+
375+
---
376+
292377
## Notifications
293378

294379
Layne can send a notification to a chat webhook when a scan finds new issues. Notifications fire after the GitHub Check Run is fully posted — engineers see the check result first, then receive the alert.

docs/extending.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ const NOTIFIERS = {
158158
};
159159
```
160160
161-
The notifier key (`pagerduty`) is what operators use in `config/repos.json` under `notifications`.
161+
The notifier key (`pagerduty`) is what operators use in `config/layne.json` under `notifications`.
162162
163163
### 3. Write tests
164164

docs/local-development.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,17 @@ Everything else in `.env.example` is commented out with sensible defaults — yo
130130

131131
### 3.3 Add your test repo to the scanner config
132132

133-
Open `config/repos.json`. Add an entry for your test repository:
133+
Open `config/layne.json`. Add an entry for your test repository:
134134

135135
```json
136136
{
137137
"your-org/your-repo": {}
138138
}
139139
```
140140

141-
An empty object uses the global defaults: Semgrep and Trufflehog enabled, Claude disabled, plus any global notifications and labels defined in `config/repos.json`. In the checked-in example config, Rocket.Chat and Slack notifications are enabled globally, but local development still works fine if you leave the webhook env vars unset because the notifiers will log and skip delivery.
141+
An empty object uses the global defaults: Semgrep and Trufflehog enabled, Claude disabled, plus any global notifications and labels defined in `config/layne.json`. In the checked-in example config, Rocket.Chat notifications are enabled globally, but local development still works fine if you leave the webhook env vars unset because the notifier will log and skip delivery.
142142

143-
> **Important:** The worker reads `config/repos.json` once at startup. Restart it after any changes.
143+
> **Important:** Both the server and worker read `config/layne.json` once at startup. Restart both after any changes.
144144
145145
---
146146

@@ -446,4 +446,4 @@ npm run dev:stop
446446
| `[replay] Error: could not reach the server` | Server is not running | `npm start` in another terminal |
447447
| `[replay] response → 401 Invalid signature` | `GITHUB_WEBHOOK_SECRET` in `.env` does not match what the server expects | They must be identical — both processes load the same `.env` |
448448
| Check Run never appears on GitHub | The worker failed to authenticate | Check worker logs for `getInstallationToken` errors; verify `GITHUB_APP_PRIVATE_KEY` is a valid single-line PEM |
449-
| Config changes not picked up by the worker | Worker caches `config/repos.json` at startup | Restart the worker: `Ctrl-C` then `npm run worker` |
449+
| Config changes not picked up | Server and worker each cache `config/layne.json` at startup | Restart both: `Ctrl-C` on each, then `npm start` and `npm run worker` |

docs/reference.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
| `DEBUG_MODE` | No | off | Set to `true` or `1` to enable verbose debug logging |
1616
| `METRICS_ENABLED` | No | `false` | Set to `true` to enable Prometheus metrics endpoints |
1717
| `METRICS_PORT` | No | `9091` | Port for the worker Prometheus metrics server |
18-
| `ROCKETCHAT_WEBHOOK_URL` | No || Global Rocket.Chat webhook URL, referenced as `"$ROCKETCHAT_WEBHOOK_URL"` in `config/repos.json`. Add additional vars (e.g. `PAYMENTS_ROCKETCHAT_WEBHOOK_URL`) for per-repo webhooks. |
19-
| `SLACK_WEBHOOK_URL` | No || Global Slack incoming webhook URL, referenced as `"$SLACK_WEBHOOK_URL"` in `config/repos.json`. Add additional vars (e.g. `PAYMENTS_SLACK_WEBHOOK_URL`) for per-repo webhooks. |
18+
| `ROCKETCHAT_WEBHOOK_URL` | No || Global Rocket.Chat webhook URL, referenced as `"$ROCKETCHAT_WEBHOOK_URL"` in `config/layne.json`. Add additional vars (e.g. `PAYMENTS_ROCKETCHAT_WEBHOOK_URL`) for per-repo webhooks. |
2019

2120
## Finding Shape
2221

scripts/validate-config.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22
/**
3-
* Validates config/repos.json and exits with code 1 if there are errors.
3+
* Validates config/layne.json and exits with code 1 if there are errors.
44
* Run via: npm run validate-config
55
*/
66

@@ -10,18 +10,18 @@ import { fileURLToPath } from 'url';
1010
import { validateConfig } from '../src/config-validator.js';
1111

1212
const __dirname = dirname(fileURLToPath(import.meta.url));
13-
const configPath = join(__dirname, '..', 'config', 'repos.json');
13+
const configPath = join(__dirname, '..', 'config', 'layne.json');
1414

1515
let raw;
1616
try {
1717
raw = JSON.parse(await readFile(configPath, 'utf8'));
1818
} catch (err) {
1919
if (err.code === 'ENOENT') {
20-
console.error(`✗ config/repos.json not found at ${configPath}`);
20+
console.error(`✗ config/layne.json not found at ${configPath}`);
2121
} else if (err instanceof SyntaxError) {
22-
console.error(`✗ config/repos.json is not valid JSON: ${err.message}`);
22+
console.error(`✗ config/layne.json is not valid JSON: ${err.message}`);
2323
} else {
24-
console.error(`✗ Failed to read config/repos.json: ${err.message}`);
24+
console.error(`✗ Failed to read config/layne.json: ${err.message}`);
2525
}
2626
process.exit(1);
2727
}
@@ -30,10 +30,10 @@ const result = validateConfig(raw);
3030

3131
if (result.valid) {
3232
const repoCount = Object.keys(raw).filter(k => k !== '$global').length;
33-
console.log(`✓ config/repos.json is valid (${repoCount} repo(s) configured)`);
33+
console.log(`✓ config/layne.json is valid (${repoCount} repo(s) configured)`);
3434
process.exit(0);
3535
} else {
36-
console.error(`✗ config/repos.json has ${result.errors.length} error(s):\n`);
36+
console.error(`✗ config/layne.json has ${result.errors.length} error(s):\n`);
3737
for (const err of result.errors) {
3838
console.error(` • ${err}`);
3939
}

src/__tests__/config.test.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,28 @@ describe('loadScanConfig()', () => {
1818
DEFAULT_CONFIG = configMod.DEFAULT_CONFIG;
1919
});
2020

21-
it('returns defaults when repos.json is missing (readFile throws)', async () => {
21+
it('returns defaults when layne.json is missing (readFile throws)', async () => {
2222
vi.mocked(readFile).mockRejectedValueOnce(new Error('ENOENT'));
2323
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
2424
expect(config.semgrep).toEqual(DEFAULT_CONFIG.semgrep);
2525
expect(config.trufflehog).toEqual(DEFAULT_CONFIG.trufflehog);
2626
});
2727

28-
it('returns defaults when repos.json contains malformed JSON', async () => {
28+
it('returns defaults when layne.json contains malformed JSON', async () => {
2929
vi.mocked(readFile).mockResolvedValueOnce('not valid json {{{');
3030
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
3131
expect(config.semgrep).toEqual(DEFAULT_CONFIG.semgrep);
3232
expect(config.trufflehog).toEqual(DEFAULT_CONFIG.trufflehog);
3333
});
3434

35-
it('returns defaults when repos.json top-level value is an array', async () => {
35+
it('returns defaults when layne.json top-level value is an array', async () => {
3636
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify([{ foo: 'bar' }]));
3737
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
3838
expect(config.semgrep).toEqual(DEFAULT_CONFIG.semgrep);
3939
expect(config.trufflehog).toEqual(DEFAULT_CONFIG.trufflehog);
4040
});
4141

42-
it('returns defaults when repos.json top-level value is a number', async () => {
42+
it('returns defaults when layne.json top-level value is a number', async () => {
4343
vi.mocked(readFile).mockResolvedValueOnce('42');
4444
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
4545
expect(config.semgrep).toEqual(DEFAULT_CONFIG.semgrep);
@@ -100,7 +100,7 @@ describe('loadScanConfig()', () => {
100100
expect(config.trufflehog.extraArgs).toEqual([]);
101101
});
102102

103-
it('reads repos.json only once across two loadScanConfig calls (cache)', async () => {
103+
it('reads layne.json only once across two loadScanConfig calls (cache)', async () => {
104104
vi.mocked(readFile).mockResolvedValue(JSON.stringify({}));
105105
await loadScanConfig({ owner: 'org', repo: 'a' });
106106
await loadScanConfig({ owner: 'org', repo: 'b' });
@@ -217,4 +217,50 @@ describe('loadScanConfig()', () => {
217217
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
218218
expect(config.labels).toEqual({});
219219
});
220+
221+
// --- trigger ---
222+
223+
it('returns the default pull_request trigger when no trigger is configured', async () => {
224+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({}));
225+
const config = await loadScanConfig({ owner: 'org', repo: 'repo' });
226+
expect(config.trigger).toEqual({ on: 'pull_request' });
227+
});
228+
229+
it('returns a workflow_run trigger configured at the repo level', async () => {
230+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
231+
'acme/frontend': {
232+
trigger: { on: 'workflow_run', workflow: 'Tests Done' },
233+
},
234+
}));
235+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
236+
expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'Tests Done' });
237+
});
238+
239+
it('inherits $global trigger when the repo has no trigger block', async () => {
240+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
241+
'$global': { trigger: { on: 'workflow_run', workflow: 'CI' } },
242+
'acme/frontend': { semgrep: { extraArgs: ['--config', 'auto'] } },
243+
}));
244+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
245+
expect(config.trigger).toEqual({ on: 'workflow_run', workflow: 'CI' });
246+
});
247+
248+
it('repo-level trigger overrides $global trigger', async () => {
249+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
250+
'$global': { trigger: { on: 'workflow_run', workflow: 'CI' } },
251+
'acme/frontend': { trigger: { on: 'pull_request' } },
252+
}));
253+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
254+
expect(config.trigger.on).toBe('pull_request');
255+
});
256+
257+
it('preserves custom conclusions in the trigger', async () => {
258+
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify({
259+
'acme/frontend': {
260+
trigger: { on: 'workflow_run', workflow: 'CI', conclusions: ['success', 'failure'] },
261+
},
262+
}));
263+
const config = await loadScanConfig({ owner: 'acme', repo: 'frontend' });
264+
expect(config.trigger.conclusions).toEqual(['success', 'failure']);
265+
});
220266
});

0 commit comments

Comments
 (0)