Skip to content

Commit a11a412

Browse files
feat: Add workflow job support (#6)
* feat: Add workflow job support * Add changeset
1 parent d7de74c commit a11a412

File tree

5 files changed

+390
-16
lines changed

5 files changed

+390
-16
lines changed

.changeset/dull-boats-go.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 support for workflow jobs alongside workflow runs

docs/configuration.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,17 +291,46 @@ If neither `$global` nor the repo defines a `labels` key, the feature is a no-op
291291

292292
## Trigger
293293

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.
294+
By default Layne scans every pull request immediately when it is opened, synchronised, or reopened (`pull_request` trigger). This is the right choice for private or internal repositories where all contributors are trusted and every PR is worth scanning.
295+
296+
For public repositories, two problems arise:
297+
298+
**1. GitHub workflow approval gates.** GitHub requires maintainer approval before running Actions workflows for first-time external contributors. This means Layne's `pull_request` event fires and the scan starts running — spawning Semgrep processes, Trufflehog processes, and Anthropic API calls — on code that may never actually execute in CI because a maintainer hasn't approved it yet. You end up scanning throwaway spam PRs, bot noise, and low-effort contributions that will be closed without review.
299+
300+
**2. Wasted spend on failing code.** Even for trusted contributors, a PR that immediately breaks CI is unlikely to be merged. Scanning it early means burning Semgrep CPU time, Trufflehog I/O, and — most importantly — Anthropic API credits on code that will need to be revised anyway. If CI runs for 5 minutes and fails on a type error, the security scan result is moot.
301+
302+
The `workflow_run` and `workflow_job` triggers solve both problems by deferring the scan until after CI has already run. You only scan code that cleared your quality gate, which is almost always the only code that will ever land in your main branch.
303+
304+
### Cost impact
305+
306+
The Claude adapter makes Anthropic API calls charged per token. On a busy public repository, the difference between scanning every PR immediately and scanning only after CI passes can be significant:
307+
308+
- Repositories with high external contributor volume often receive many low-quality PRs (spam, trivial fixes, automated dependency bumps that fail tests). These will never merge and don't need security scanning.
309+
- PRs that fail CI within the first few minutes consume scan compute for a result no one will act on. A 30-second CI failure gate that rejects 40% of PRs saves 40% of scan costs immediately.
310+
- Semgrep and Trufflehog are cheap (CPU only), but the Claude adapter is billed per token at Anthropic API rates. On a repo with many PRs per day, this adds up quickly. Deferring to after CI passes is the single most effective cost control available.
311+
312+
The deferred triggers do not reduce security coverage for PRs that pass CI — the scan still runs on every commit that clears the gate, before merge.
313+
314+
### Choosing between `workflow_run` and `workflow_job`
315+
316+
| | `workflow_run` | `workflow_job` |
317+
|---|---|---|
318+
| Gates on | An entire workflow completing | A single named job completing |
319+
| Use when | You want CI fully done before scanning | You have a fast early gate (e.g. lint, approval job) and want to scan sooner |
320+
| Latency | Scan starts after the longest job in the workflow | Scan starts as soon as the named job finishes |
321+
| Typical setup | One CI workflow, wait for all of it | A dedicated `security-gate` job that runs approval checks early |
295322

296323
### Modes
297324

298325
| `on` | Behaviour |
299326
|---|---|
300327
| `pull_request` | *(default)* Scan fires immediately on `opened`, `synchronize`, and `reopened` events |
301328
| `workflow_run` | Scan fires when the named CI workflow completes with a matching conclusion |
329+
| `workflow_job` | Scan fires when the named CI job completes with a matching conclusion |
302330

303331
### Schema
304332

333+
**`workflow_run`:**
305334
```json
306335
{
307336
"owner/repo": {
@@ -314,24 +343,38 @@ By default Layne scans every pull request immediately when it is opened, synchro
314343
}
315344
```
316345

346+
**`workflow_job`:**
347+
```json
348+
{
349+
"owner/repo": {
350+
"trigger": {
351+
"on": "workflow_job",
352+
"job": "security-gate",
353+
"conclusions": ["success"]
354+
}
355+
}
356+
}
357+
```
358+
317359
| Key | Type | Default | Description |
318360
|---|---|---|---|
319-
| `on` | `"pull_request"` \| `"workflow_run"` | `"pull_request"` | When to trigger the scan |
361+
| `on` | `"pull_request"` \| `"workflow_run"` \| `"workflow_job"` | `"pull_request"` | When to trigger the scan |
320362
| `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` |
363+
| `job` | string || Name of the GitHub Actions job to watch. Required when `on` is `"workflow_job"` |
364+
| `conclusions` | string[] | `["success"]` | Conclusions that trigger the scan. Valid values: `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, `action_required` |
322365

323366
> **`trigger` can be set globally.** Set it under `$global` to apply to all repos, then override per-repo as needed.
324367
325-
### How `workflow_run` works
368+
### How deferred triggers work
326369

327-
When `on: workflow_run` is configured for a repo:
370+
Both `workflow_run` and `workflow_job` follow the same two-stage pattern:
328371

329372
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.
373+
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), Layne falls back to the GitHub API to find the associated PR.
331374

332375
### Failure mode
333376

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.
377+
If the watched workflow or job is renamed or removed, Layne never receives the 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.
335378

336379
### Examples
337380

@@ -347,6 +390,18 @@ If the watched workflow is renamed or removed, Layne never receives the `workflo
347390
}
348391
```
349392

393+
**Scan after a specific job completes (finer-grained than a whole workflow):**
394+
```json
395+
{
396+
"owner/repo": {
397+
"trigger": {
398+
"on": "workflow_job",
399+
"job": "security-gate"
400+
}
401+
}
402+
}
403+
```
404+
350405
**Scan regardless of whether CI passes or fails (workflow was approved, code is worth scanning):**
351406
```json
352407
{

src/__tests__/server.test.js

Lines changed: 242 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ const { loadScanConfig } = await import('../config.j
3030
const { webhooksTotal } = await import('../metrics.js');
3131
const { app, verifySignature, processWebhookRequest } = await import('../server.js');
3232

33-
const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request' } };
34-
const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'] } };
33+
const PR_TRIGGER_CONFIG = { trigger: { on: 'pull_request' } };
34+
const WORKFLOW_TRIGGER_CONFIG = { trigger: { on: 'workflow_run', workflow: 'Tests Done', conclusions: ['success'] } };
35+
const WORKFLOW_JOB_TRIGGER_CONFIG = { trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success'] } };
3536

3637
function sign(body) {
3738
return 'sha256=' + crypto
@@ -83,6 +84,29 @@ function workflowRunPayload({
8384
});
8485
}
8586

87+
function workflowJobPayload({
88+
action = 'completed',
89+
jobName = 'security-scan',
90+
conclusion = 'success',
91+
headSha = 'abc123',
92+
} = {}) {
93+
return JSON.stringify({
94+
action,
95+
workflow_job: {
96+
name: jobName,
97+
conclusion,
98+
head_sha: headSha,
99+
},
100+
repository: {
101+
name: 'my-repo',
102+
full_name: 'org/my-repo',
103+
clone_url: 'https://github.com/org/my-repo.git',
104+
owner: { login: 'org' },
105+
},
106+
installation: { id: 987 },
107+
});
108+
}
109+
86110
function webhookRequest(body, { event = 'pull_request', signature } = {}) {
87111
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
88112
return {
@@ -162,7 +186,7 @@ describe('processWebhookRequest()', () => {
162186
expect(createCheckRun).not.toHaveBeenCalled();
163187
});
164188

165-
it('ignores non-pull_request / non-workflow_run events', async () => {
189+
it('ignores events that are not pull_request, workflow_run, or workflow_job', async () => {
166190
const res = await processWebhookRequest(webhookRequest(JSON.stringify({ action: 'created' }), {
167191
event: 'push',
168192
}));
@@ -527,3 +551,218 @@ describe('workflow_run trigger — workflow_run event', () => {
527551
expect(scanQueue.add).toHaveBeenCalledOnce();
528552
});
529553
});
554+
555+
// ---------------------------------------------------------------------------
556+
// workflow_job trigger — pull_request events deferred
557+
// ---------------------------------------------------------------------------
558+
559+
describe('workflow_job trigger — pull_request event', () => {
560+
beforeEach(() => {
561+
loadScanConfig.mockResolvedValue(WORKFLOW_JOB_TRIGGER_CONFIG);
562+
});
563+
564+
it('does not enqueue a scan when the repo uses workflow_job trigger', async () => {
565+
const res = await processWebhookRequest(webhookRequest(prPayload('opened')));
566+
567+
expect(res).toEqual({ status: 200, body: 'Deferred' });
568+
expect(scanQueue.add).not.toHaveBeenCalled();
569+
expect(createCheckRun).not.toHaveBeenCalled();
570+
});
571+
572+
it('caches PR metadata in Redis with a 7-day TTL', async () => {
573+
await processWebhookRequest(webhookRequest(prPayload('opened')));
574+
575+
expect(redis.set).toHaveBeenCalledWith(
576+
'layne:pr:org/my-repo:abc123',
577+
expect.any(String),
578+
'EX',
579+
7 * 24 * 60 * 60
580+
);
581+
582+
const cached = JSON.parse(redis.set.mock.calls[0][1]);
583+
expect(cached).toMatchObject({
584+
prNumber: 42,
585+
headSha: 'abc123',
586+
headRef: 'feature/login',
587+
baseSha: 'def456',
588+
baseRef: 'main',
589+
labels: ['bug'],
590+
installationId: 987,
591+
});
592+
});
593+
594+
it('creates a skipped check run with the configured job name in the summary', async () => {
595+
await processWebhookRequest(webhookRequest(prPayload('opened')));
596+
597+
expect(skipCheckRun).toHaveBeenCalledWith(expect.objectContaining({
598+
installationId: 987,
599+
owner: 'org',
600+
repo: 'my-repo',
601+
headSha: 'abc123',
602+
summary: expect.stringContaining('security-scan'),
603+
}));
604+
});
605+
606+
it('still returns Deferred even if skipCheckRun throws', async () => {
607+
skipCheckRun.mockRejectedValueOnce(new Error('GitHub API error'));
608+
609+
const res = await processWebhookRequest(webhookRequest(prPayload('opened')));
610+
611+
expect(res).toEqual({ status: 200, body: 'Deferred' });
612+
});
613+
});
614+
615+
// ---------------------------------------------------------------------------
616+
// workflow_job trigger — workflow_job events
617+
// ---------------------------------------------------------------------------
618+
619+
describe('workflow_job trigger — workflow_job event', () => {
620+
const CACHED_PR = JSON.stringify({
621+
prNumber: 42,
622+
headSha: 'abc123',
623+
headRef: 'feature/login',
624+
baseSha: 'def456',
625+
baseRef: 'main',
626+
labels: ['bug'],
627+
installationId: 987,
628+
cloneUrl: 'https://github.com/org/my-repo.git',
629+
repoFullName: 'org/my-repo',
630+
});
631+
632+
beforeEach(() => {
633+
loadScanConfig.mockResolvedValue(WORKFLOW_JOB_TRIGGER_CONFIG);
634+
redis.get.mockResolvedValue(CACHED_PR);
635+
});
636+
637+
it('enqueues a scan when the workflow job matches config', async () => {
638+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
639+
640+
expect(res).toEqual({ status: 200, body: 'Accepted' });
641+
expect(createCheckRun).toHaveBeenCalledOnce();
642+
expect(scanQueue.add).toHaveBeenCalledOnce();
643+
});
644+
645+
it('enqueues with the correct job payload from the cached PR data', async () => {
646+
await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
647+
648+
const [, jobData] = scanQueue.add.mock.calls[0];
649+
expect(jobData).toMatchObject({
650+
owner: 'org',
651+
repo: 'my-repo',
652+
headSha: 'abc123',
653+
headRef: 'feature/login',
654+
baseSha: 'def456',
655+
baseRef: 'main',
656+
prNumber: 42,
657+
labels: ['bug'],
658+
installationId: 987,
659+
checkRunId: 99,
660+
});
661+
});
662+
663+
it('uses the correct job ID for deduplication', async () => {
664+
await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
665+
666+
const [, , opts] = scanQueue.add.mock.calls[0];
667+
expect(opts.jobId).toBe('org/my-repo#42@abc123');
668+
});
669+
670+
it('ignores workflow_job events where action is not "completed"', async () => {
671+
const res = await processWebhookRequest(webhookRequest(
672+
workflowJobPayload({ action: 'in_progress' }), { event: 'workflow_job' }
673+
));
674+
675+
expect(res).toEqual({ status: 200, body: 'Event ignored' });
676+
expect(scanQueue.add).not.toHaveBeenCalled();
677+
});
678+
679+
it('ignores workflow_job events for a different job name', async () => {
680+
const res = await processWebhookRequest(webhookRequest(
681+
workflowJobPayload({ jobName: 'lint' }), { event: 'workflow_job' }
682+
));
683+
684+
expect(res).toEqual({ status: 200, body: 'Event ignored' });
685+
expect(scanQueue.add).not.toHaveBeenCalled();
686+
});
687+
688+
it('ignores workflow_job events with a non-matching conclusion', async () => {
689+
const res = await processWebhookRequest(webhookRequest(
690+
workflowJobPayload({ conclusion: 'failure' }), { event: 'workflow_job' }
691+
));
692+
693+
expect(res).toEqual({ status: 200, body: 'Event ignored' });
694+
expect(scanQueue.add).not.toHaveBeenCalled();
695+
});
696+
697+
it('ignores workflow_job events when the repo uses pull_request trigger', async () => {
698+
loadScanConfig.mockResolvedValue(PR_TRIGGER_CONFIG);
699+
700+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
701+
702+
expect(res).toEqual({ status: 200, body: 'Event ignored' });
703+
expect(scanQueue.add).not.toHaveBeenCalled();
704+
});
705+
706+
it('deduplicates: does not enqueue when the job already exists', async () => {
707+
scanQueue.getJob.mockResolvedValueOnce({ id: 'org/my-repo#42@abc123' });
708+
709+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
710+
711+
expect(res).toEqual({ status: 200, body: 'Accepted' });
712+
expect(createCheckRun).not.toHaveBeenCalled();
713+
expect(scanQueue.add).not.toHaveBeenCalled();
714+
});
715+
716+
it('returns 200 with "PR not found" when cache is cold and GitHub API returns nothing', async () => {
717+
redis.get.mockResolvedValue(null);
718+
findPullRequestBySha.mockResolvedValue(null);
719+
720+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
721+
722+
expect(res).toEqual({ status: 200, body: 'PR not found' });
723+
expect(scanQueue.add).not.toHaveBeenCalled();
724+
});
725+
726+
it('falls back to GitHub API when cache is cold and enqueues from API data', async () => {
727+
redis.get.mockResolvedValue(null);
728+
findPullRequestBySha.mockResolvedValue({
729+
number: 42,
730+
head: { ref: 'feature/login', sha: 'abc123' },
731+
base: { ref: 'main', sha: 'def456' },
732+
labels: [{ name: 'bug' }],
733+
});
734+
735+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
736+
737+
expect(res).toEqual({ status: 200, body: 'Accepted' });
738+
expect(findPullRequestBySha).toHaveBeenCalledWith(expect.objectContaining({
739+
owner: 'org',
740+
repo: 'my-repo',
741+
headSha: 'abc123',
742+
}));
743+
expect(scanQueue.add).toHaveBeenCalledOnce();
744+
});
745+
746+
it('returns "PR not found" when cache is cold and GitHub API throws', async () => {
747+
redis.get.mockResolvedValue(null);
748+
findPullRequestBySha.mockRejectedValue(new Error('API error'));
749+
750+
const res = await processWebhookRequest(webhookRequest(workflowJobPayload(), { event: 'workflow_job' }));
751+
752+
expect(res).toEqual({ status: 200, body: 'PR not found' });
753+
expect(scanQueue.add).not.toHaveBeenCalled();
754+
});
755+
756+
it('enqueues with configured non-default conclusions', async () => {
757+
loadScanConfig.mockResolvedValue({
758+
trigger: { on: 'workflow_job', job: 'security-scan', conclusions: ['success', 'failure'] },
759+
});
760+
761+
const res = await processWebhookRequest(webhookRequest(
762+
workflowJobPayload({ conclusion: 'failure' }), { event: 'workflow_job' }
763+
));
764+
765+
expect(res).toEqual({ status: 200, body: 'Accepted' });
766+
expect(scanQueue.add).toHaveBeenCalledOnce();
767+
});
768+
});

0 commit comments

Comments
 (0)