Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

600 lines · 20.2 KBRaw History
1import { describe, expect, it } from "vitest";
2import { expandMatrix, isWarning, jobOrder, matchesTrigger, parseWorkflow, type Workflow, type WorkflowError } from "../src/index.js";
3
4function ok(src: string, path = ".github/workflows/test.yml"): Workflow {
5 const { workflow, errors } = parseWorkflow(src, path);
6 const fatal = errors.filter((e) => !isWarning(e));
7 if (fatal.length > 0 || !workflow) throw new Error(`unexpected errors: ${JSON.stringify(errors, null, 2)}`);
8 return workflow;
9}
10
11function errs(src: string): WorkflowError[] {
12 return parseWorkflow(src, "w.yml").errors;
13}
14
15function fatal(src: string): WorkflowError[] {
16 const res = parseWorkflow(src, "w.yml");
17 const out = res.errors.filter((e) => !isWarning(e));
18 expect(out.length).toBeGreaterThan(0);
19 expect(res.workflow).toBeUndefined();
20 return out;
21}
22
23const minimalJob = `jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`;
24
25describe("real-world workflows", () => {
26 it("Node CI with matrix and cache", () => {
27 const wf = ok(`
28name: Node CI
29on:
30 push:
31 branches: [ main ]
32 pull_request:
33 branches: [ main ]
34env:
35 CI: true
36 FORCE_COLOR: 1
37concurrency:
38 group: ci-\${{ github.ref }}
39 cancel-in-progress: true
40permissions:
41 contents: read
42jobs:
43 test:
44 name: Test (node \${{ matrix.node-version }}, \${{ matrix.os }})
45 runs-on: \${{ matrix.os }}
46 timeout-minutes: 20
47 strategy:
48 fail-fast: false
49 max-parallel: 4
50 matrix:
51 os: [ubuntu-latest, windows-latest]
52 node-version: [18.x, 20.x, 22.x]
53 exclude:
54 - os: windows-latest
55 node-version: 18.x
56 include:
57 - os: ubuntu-latest
58 node-version: 22.x
59 coverage: true
60 steps:
61 - uses: actions/checkout@v4
62 with:
63 fetch-depth: 0
64 - name: Use Node.js \${{ matrix.node-version }}
65 uses: actions/setup-node@v4
66 with:
67 node-version: \${{ matrix.node-version }}
68 cache: npm
69 - name: Cache node modules
70 uses: actions/cache@v4
71 with:
72 path: ~/.npm
73 key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}
74 restore-keys: |
75 \${{ runner.os }}-node-
76 - run: npm ci
77 - run: npm test -- --coverage
78 if: matrix.coverage
79 env:
80 NODE_OPTIONS: --max-old-space-size=4096
81 - run: npm test
82 if: \${{ !matrix.coverage }}
83 continue-on-error: \${{ matrix.os == 'windows-latest' }}
84 timeout-minutes: 10
85 working-directory: ./app
86 shell: bash
87`);
88 expect(wf.name).toBe("Node CI");
89 expect(wf.path).toBe(".github/workflows/test.yml");
90 expect(wf.env).toEqual({ CI: "true", FORCE_COLOR: "1" });
91 expect(wf.concurrency).toEqual({ group: "ci-${{ github.ref }}", cancelInProgress: true });
92 expect(wf.permissions).toEqual({ contents: "read" });
93 expect(Object.keys(wf.on)).toEqual(["push", "pull_request"]);
94 expect(wf.on.push).toEqual({ branches: ["main"] });
95
96 const job = wf.jobs.test!;
97 expect(job.key).toBe("test");
98 expect(job.runsOn).toEqual(["${{ matrix.os }}"]);
99 expect(job.needs).toEqual([]);
100 expect(job.timeoutMinutes).toBe(20);
101 expect(job.continueOnError).toBe(false);
102 expect(job.strategy?.failFast).toBe(false);
103 expect(job.strategy?.maxParallel).toBe(4);
104 expect(job.steps).toHaveLength(6);
105 expect(job.steps[0]).toEqual({ uses: "actions/checkout@v4", with: { "fetch-depth": "0" } });
106 expect(job.steps[1]?.with).toEqual({ "node-version": "${{ matrix.node-version }}", cache: "npm" });
107 expect(job.steps[2]?.with?.["restore-keys"]).toBe("${{ runner.os }}-node-\n");
108 expect(job.steps[4]).toEqual({
109 run: "npm test -- --coverage",
110 if: "matrix.coverage",
111 env: { NODE_OPTIONS: "--max-old-space-size=4096" },
112 });
113 expect(job.steps[5]).toEqual({
114 run: "npm test",
115 if: "${{ !matrix.coverage }}",
116 "continue-on-error": "${{ matrix.os == 'windows-latest' }}",
117 "timeout-minutes": 10,
118 "working-directory": "./app",
119 shell: "bash",
120 });
121
122 const combos = expandMatrix(job.strategy?.matrix);
123 expect(combos).toHaveLength(5);
124 expect(combos).toContainEqual({ os: "ubuntu-latest", "node-version": "22.x", coverage: true });
125
126 expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(true);
127 expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/feature" })).toBe(false);
128 expect(matchesTrigger(wf, { name: "pull_request", action: "synchronize", baseRef: "main" })).toBe(true);
129 });
130
131 it("release workflow on tags", () => {
132 const wf = ok(`
133name: Release
134on:
135 push:
136 tags:
137 - 'v[0-9]+.[0-9]+.[0-9]+'
138 - '!v*-rc*'
139permissions:
140 contents: write
141 id-token: write
142jobs:
143 build:
144 runs-on: ubuntu-latest
145 outputs:
146 version: \${{ steps.meta.outputs.version }}
147 steps:
148 - uses: actions/checkout@v4
149 - id: meta
150 run: echo "version=\${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
151 publish:
152 needs: build
153 runs-on: [self-hosted, linux, x64]
154 environment:
155 name: production
156 url: https://example.com/releases/\${{ needs.build.outputs.version }}
157 if: startsWith(github.ref, 'refs/tags/')
158 steps:
159 - run: echo publishing \${{ needs.build.outputs.version }}
160 announce:
161 needs: [build, publish]
162 if: always()
163 runs-on: ubuntu-latest
164 steps:
165 - run: echo done
166`);
167 expect(wf.on.push).toEqual({ tags: ["v[0-9]+.[0-9]+.[0-9]+", "!v*-rc*"] });
168 expect(wf.permissions).toEqual({ contents: "write", "id-token": "write" });
169 expect(wf.jobs.build?.outputs).toEqual({ version: "${{ steps.meta.outputs.version }}" });
170 expect(wf.jobs.build?.steps[1]?.id).toBe("meta");
171 expect(wf.jobs.publish?.needs).toEqual(["build"]);
172 expect(wf.jobs.publish?.runsOn).toEqual(["self-hosted", "linux", "x64"]);
173 expect(wf.jobs.publish?.environment).toEqual({ name: "production", url: "https://example.com/releases/${{ needs.build.outputs.version }}" });
174 expect(wf.jobs.publish?.if).toBe("startsWith(github.ref, 'refs/tags/')");
175 expect(wf.jobs.publish?.timeoutMinutes).toBe(360);
176 expect(wf.jobs.announce?.needs).toEqual(["build", "publish"]);
177 expect(jobOrder(wf)).toEqual({ order: [["build"], ["publish"], ["announce"]], errors: [] });
178
179 expect(matchesTrigger(wf, { name: "push", ref: "refs/tags/v1.2.3" })).toBe(true);
180 expect(matchesTrigger(wf, { name: "push", ref: "refs/tags/v1.2.3-rc1" })).toBe(false);
181 expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(false);
182 });
183
184 it("workflow_dispatch with inputs", () => {
185 const wf = ok(`
186on:
187 workflow_dispatch:
188 inputs:
189 logLevel:
190 description: 'Log level'
191 required: true
192 default: 'warning'
193 type: choice
194 options:
195 - info
196 - warning
197 - debug
198 print_tags:
199 description: 'True to print to STDOUT'
200 required: true
201 type: boolean
202 environment:
203 description: 'Environment to run tests against'
204 type: environment
205 required: true
206 count:
207 type: number
208 default: 3
209 free:
210run-name: Deploy by @\${{ github.actor }}
211jobs:
212 print-tag:
213 runs-on: ubuntu-latest
214 if: \${{ inputs.print_tags }}
215 steps:
216 - name: Print the input tag to STDOUT
217 run: echo The tags are \${{ inputs.tags }}
218`);
219 expect(wf.runName).toBe("Deploy by @${{ github.actor }}");
220 expect(wf.on.workflow_dispatch?.inputs).toEqual({
221 logLevel: { description: "Log level", required: true, default: "warning", type: "choice", options: ["info", "warning", "debug"] },
222 print_tags: { description: "True to print to STDOUT", required: true, type: "boolean" },
223 environment: { description: "Environment to run tests against", type: "environment", required: true },
224 count: { type: "number", default: 3 },
225 free: {},
226 });
227 expect(matchesTrigger(wf, { name: "workflow_dispatch" })).toBe(true);
228 expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(false);
229 });
230
231 it("reusable workflow call and definition", () => {
232 const caller = ok(`
233on: push
234jobs:
235 call-workflow-passing-data:
236 uses: octo-org/example-repo/.github/workflows/reusable-workflow.yml@main
237 with:
238 config-path: .github/labeler.yml
239 debug: true
240 retries: 2
241 secrets: inherit
242 local:
243 uses: ./.github/workflows/deploy.yml
244 needs: call-workflow-passing-data
245 secrets:
246 token: \${{ secrets.DEPLOY_TOKEN }}
247 strategy:
248 matrix:
249 target: [dev, prod]
250`);
251 const j = caller.jobs["call-workflow-passing-data"]!;
252 expect(j.uses).toBe("octo-org/example-repo/.github/workflows/reusable-workflow.yml@main");
253 expect(j.runsOn).toEqual([]);
254 expect(j.steps).toEqual([]);
255 expect(j.with).toEqual({ "config-path": ".github/labeler.yml", debug: true, retries: 2 });
256 expect(j.secrets).toBe("inherit");
257 expect(caller.jobs.local?.secrets).toEqual({ token: "${{ secrets.DEPLOY_TOKEN }}" });
258 expect(caller.jobs.local?.needs).toEqual(["call-workflow-passing-data"]);
259
260 const callee = ok(`
261on:
262 workflow_call:
263 inputs:
264 config-path:
265 required: true
266 type: string
267 debug:
268 type: boolean
269 default: false
270 outputs:
271 firstword:
272 description: "The first output string"
273 value: \${{ jobs.example_job.outputs.output1 }}
274 secrets:
275 token:
276 required: true
277jobs:
278 example_job:
279 runs-on: ubuntu-latest
280 outputs:
281 output1: \${{ steps.step1.outputs.firstword }}
282 steps:
283 - id: step1
284 run: echo "firstword=hello" >> $GITHUB_OUTPUT
285`);
286 expect(callee.on.workflow_call).toEqual({
287 inputs: { "config-path": { required: true, type: "string" }, debug: { type: "boolean", default: false } },
288 outputs: { firstword: { description: "The first output string", value: "${{ jobs.example_job.outputs.output1 }}" } },
289 secrets: { token: { required: true } },
290 });
291 });
292
293 it("services, container, defaults, runs-on group, concurrency string", () => {
294 const wf = ok(`
295on: push
296defaults:
297 run:
298 shell: bash
299 working-directory: scripts
300concurrency: deploy
301jobs:
302 db:
303 runs-on:
304 group: big-runners
305 labels: [linux]
306 container: node:20
307 services:
308 postgres:
309 image: postgres:16
310 env:
311 POSTGRES_PASSWORD: postgres
312 ports: ['5432:5432']
313 options: --health-cmd pg_isready
314 redis: redis:7
315 permissions: read-all
316 concurrency:
317 group: db-\${{ github.ref }}
318 cancel-in-progress: \${{ github.ref != 'refs/heads/main' }}
319 continue-on-error: true
320 steps:
321 - run: psql -h postgres
322`);
323 expect(wf.defaults).toEqual({ run: { shell: "bash", "working-directory": "scripts" } });
324 expect(wf.concurrency).toEqual({ group: "deploy", cancelInProgress: false });
325 const j = wf.jobs.db!;
326 expect(j.runsOn).toEqual(["linux"]);
327 expect(j.runsOnGroup).toBe("big-runners");
328 expect(j.container).toEqual({ image: "node:20" });
329 expect(j.services).toEqual({
330 postgres: { image: "postgres:16", env: { POSTGRES_PASSWORD: "postgres" }, ports: ["5432:5432"], options: "--health-cmd pg_isready" },
331 redis: { image: "redis:7" },
332 });
333 expect(j.permissions).toBe("read-all");
334 expect(j.concurrency).toEqual({ group: "db-${{ github.ref }}", cancelInProgress: "${{ github.ref != 'refs/heads/main' }}" });
335 expect(j.continueOnError).toBe(true);
336 });
337
338 it("supports YAML anchors and merge keys", () => {
339 const wf = ok(`
340on: push
341jobs:
342 a: &base
343 runs-on: ubuntu-latest
344 env: &env
345 A: 1
346 steps:
347 - run: echo a
348 b:
349 <<: *base
350 env:
351 <<: *env
352 B: 2
353`);
354 expect(wf.jobs.b?.runsOn).toEqual(["ubuntu-latest"]);
355 expect(wf.jobs.b?.env).toEqual({ A: "1", B: "2" });
356 });
357});
358
359describe("`on` forms", () => {
360 it("string", () => {
361 expect(ok(`on: push\n${minimalJob}`).on).toEqual({ push: {} });
362 });
363 it("list", () => {
364 expect(ok(`on: [push, pull_request, workflow_dispatch]\n${minimalJob}`).on).toEqual({
365 push: {},
366 pull_request: {},
367 workflow_dispatch: {},
368 });
369 });
370 it("map with null values", () => {
371 expect(ok(`on:\n push:\n issues:\n types: opened\n${minimalJob}`).on).toEqual({ push: {}, issues: { types: ["opened"] } });
372 });
373 it("map with all push/pull_request filters", () => {
374 const wf = ok(`on:
375 push:
376 branches-ignore: [wip/**]
377 tags-ignore: [nightly]
378 paths-ignore: [docs/**]
379 pull_request:
380 types: [opened]
381 branches-ignore: [gh-pages]
382 paths: [src/**]
383 schedule:
384 - cron: '0 0 * * *'
385 - cron: '*/30 9-17 * * MON-FRI'
386 workflow_run:
387 workflows: ["CI"]
388 types: [completed]
389 release:
390 types: [published]
391${minimalJob}`);
392 expect(wf.on).toEqual({
393 push: { branchesIgnore: ["wip/**"], tagsIgnore: ["nightly"], pathsIgnore: ["docs/**"] },
394 pull_request: { types: ["opened"], branchesIgnore: ["gh-pages"], paths: ["src/**"] },
395 schedule: [{ cron: "0 0 * * *" }, { cron: "*/30 9-17 * * MON-FRI" }],
396 workflow_run: { workflows: ["CI"], types: ["completed"] },
397 release: { types: ["published"] },
398 });
399 });
400 it("'on' is not treated as a YAML 1.1 boolean", () => {
401 const res = parseWorkflow(`"on": push\n${minimalJob}`, "w.yml");
402 expect(res.workflow?.on).toEqual({ push: {} });
403 });
404});
405
406describe("validation errors", () => {
407 it("YAML syntax errors carry a line", () => {
408 const e = fatal(`on: push\njobs:\n build:\n runs-on: [ubuntu\n steps: x: y\n`);
409 expect(e[0]?.message).toMatch(/YAML syntax error/);
410 expect(e[0]?.line).toBeGreaterThan(0);
411 });
412
413 it("empty file / non-mapping", () => {
414 expect(fatal("")[0]?.message).toMatch(/empty/);
415 expect(fatal("- a\n- b\n")[0]?.message).toMatch(/must be a mapping/);
416 });
417
418 it("missing on", () => {
419 const e = fatal(minimalJob);
420 expect(e).toEqual([expect.objectContaining({ path: "on", message: "workflow is missing the 'on' trigger section" })]);
421 });
422
423 it("missing jobs", () => {
424 const e = fatal("name: x\non: push\n");
425 expect(e).toEqual([expect.objectContaining({ path: "jobs", message: "workflow is missing the 'jobs' section" })]);
426 expect(fatal("on: push\njobs: {}\n")[0]?.message).toMatch(/at least one job/);
427 });
428
429 it("job missing runs-on, with line number of the job key", () => {
430 const e = fatal(`on: push\njobs:\n ok:\n runs-on: x\n steps: [{run: a}]\n build:\n steps:\n - run: echo\n`);
431 expect(e).toEqual([{ path: "jobs.build", line: 6, col: 3, message: "job 'build' is missing 'runs-on'" }]);
432 });
433
434 it("reusable workflow job does not need runs-on but cannot have steps", () => {
435 expect(errs(`on: push\njobs:\n call:\n uses: ./.github/workflows/x.yml\n`)).toEqual([]);
436 const e = fatal(`on: push\njobs:\n call:\n uses: ./.github/workflows/x.yml\n steps:\n - run: a\n`);
437 expect(e[0]?.message).toMatch(/cannot have 'steps'/);
438 expect(fatal(`on: push\njobs:\n call:\n uses: not-a-workflow\n`)[0]?.message).toMatch(/reusable workflow reference/);
439 });
440
441 it("step with both or neither run and uses", () => {
442 const e = fatal(`on: push
443jobs:
444 build:
445 runs-on: ubuntu-latest
446 steps:
447 - run: echo
448 uses: actions/checkout@v4
449 - name: nothing
450`);
451 expect(e).toEqual([
452 { path: "jobs.build.steps[0]", line: 6, col: 9, message: "step cannot have both 'run' and 'uses'" },
453 { path: "jobs.build.steps[1]", line: 8, col: 9, message: "step must have either 'run' or 'uses'" },
454 ]);
455 });
456
457 it("job without steps", () => {
458 expect(fatal(`on: push\njobs:\n b:\n runs-on: x\n`)[0]?.message).toMatch(/must have a list of 'steps'/);
459 });
460
461 it("duplicate step ids and invalid job ids", () => {
462 const e = fatal(`on: push\njobs:\n 1bad:\n runs-on: x\n steps:\n - id: a\n run: x\n - id: a\n run: y\n`);
463 expect(e.map((x) => x.message)).toEqual([expect.stringMatching(/job id '1bad'/), expect.stringMatching(/duplicate step id 'a'/)]);
464 });
465
466 it("unknown needs", () => {
467 const e = fatal(`on: push\njobs:\n a:\n runs-on: x\n needs: [nope]\n steps: [{run: a}]\n`);
468 expect(e).toEqual([{ path: "jobs.a.needs", line: 5, col: 5, message: "Job 'a' depends on unknown job 'nope'" }]);
469 });
470
471 it("dependency cycles", () => {
472 const e = fatal(`on: push
473jobs:
474 a:
475 runs-on: x
476 needs: c
477 steps: [{run: a}]
478 b:
479 runs-on: x
480 needs: a
481 steps: [{run: a}]
482 c:
483 runs-on: x
484 needs: b
485 steps: [{run: a}]
486`);
487 expect(e).toHaveLength(1);
488 expect(e[0]?.message).toBe("Dependency cycle between jobs: a -> c -> b -> a");
489 expect(e[0]?.line).toBe(5);
490 });
491
492 it("invalid cron", () => {
493 const e = fatal(`on:\n schedule:\n - cron: '0 0 * *'\n${minimalJob}`);
494 expect(e[0]).toMatchObject({ path: "on.schedule[0].cron", line: 3 });
495 expect(e[0]?.message).toMatch(/Invalid cron: .*5 fields/);
496 expect(fatal(`on:\n schedule:\n - cron: '61 * * * *'\n${minimalJob}`)[0]?.message).toMatch(/out of range/);
497 });
498
499 it("unclosed ${{", () => {
500 const e = fatal(`on: push
501jobs:
502 build:
503 runs-on: ubuntu-latest
504 steps:
505 - run: echo \${{ github.ref
506`);
507 expect(e).toEqual([
508 expect.objectContaining({ path: "jobs.build.steps[0].run", line: 6, message: expect.stringMatching(/Unclosed expression/) }),
509 ]);
510 });
511
512 it("expression syntax errors, including bare if conditions", () => {
513 const e = fatal(`on: push
514jobs:
515 build:
516 if: github.event_name ==
517 runs-on: ubuntu-latest
518 steps:
519 - run: echo \${{ nosuch(1) }}
520 - run: echo ok
521 if: \${{ (1 }}
522`);
523 expect(e.map((x) => x.path)).toEqual(["jobs.build.if", "jobs.build.steps[0].run", "jobs.build.steps[1].if"]);
524 expect(e[1]?.message).toMatch(/Unrecognized function 'nosuch'/);
525 });
526
527 it("invalid dispatch inputs and push filter conflicts", () => {
528 const e = fatal(`on:
529 push:
530 branches: [main]
531 branches-ignore: [dev]
532 workflow_dispatch:
533 inputs:
534 a:
535 type: chioce
536 b:
537 type: choice
538${minimalJob}`);
539 expect(e.map((x) => x.message)).toEqual([
540 "'branches' and 'branches-ignore' cannot be used together for the same event",
541 expect.stringMatching(/invalid type 'chioce'/),
542 expect.stringMatching(/must define 'options'/),
543 ]);
544 });
545
546 it("invalid static matrix", () => {
547 const e = fatal(`on: push\njobs:\n b:\n runs-on: x\n strategy:\n matrix:\n os: ubuntu\n steps: [{run: a}]\n`);
548 expect(e[0]?.message).toMatch(/matrix.os must be a list/);
549 });
550
551 it("unknown keys are warnings and do not block the workflow", () => {
552 const res = parseWorkflow(
553 `on:\n push:\n pushh:\nfoo: 1\njobs:\n b:\n runs-on: x\n bar: 1\n steps:\n - run: a\n baz: 1\n`,
554 "w.yml",
555 );
556 expect(res.workflow).toBeDefined();
557 expect(res.errors.map((e) => [e.path, e.line, e.message])).toEqual([
558 ["on.pushh", 3, "warning: unknown event 'pushh'"],
559 ["foo", 4, "warning: unknown top-level key 'foo'"],
560 ["jobs.b.bar", 8, "warning: unknown job key 'bar'"],
561 ["jobs.b.steps[0].baz", 11, "warning: unknown step key 'baz'"],
562 ]);
563 expect(res.errors.every(isWarning)).toBe(true);
564 });
565
566 it("type errors in env and with", () => {
567 const e = fatal(`on: push\nenv:\n A: [1]\n${minimalJob}`);
568 expect(e[0]).toMatchObject({ path: "env.A", message: "env.A must be a string, number or boolean" });
569 });
570});
571
572describe("jobOrder", () => {
573 it("groups jobs into levels in file order", () => {
574 const wf = ok(`on: push
575jobs:
576 lint: {runs-on: x, steps: [{run: a}]}
577 test: {runs-on: x, steps: [{run: a}]}
578 build: {runs-on: x, needs: [lint, test], steps: [{run: a}]}
579 docs: {runs-on: x, needs: lint, steps: [{run: a}]}
580 deploy: {runs-on: x, needs: [build, docs], steps: [{run: a}]}
581`);
582 expect(jobOrder(wf)).toEqual({ order: [["lint", "test"], ["build", "docs"], ["deploy"]], errors: [] });
583 });
584
585 it("reports unknown needs and cycles on a constructed workflow", () => {
586 const wf = ok(`on: push\n${minimalJob}`);
587 const job = wf.jobs.build!;
588 wf.jobs = {
589 a: { ...job, key: "a", needs: ["b"] },
590 b: { ...job, key: "b", needs: ["a"] },
591 c: { ...job, key: "c", needs: ["zzz"] },
592 };
593 const res = jobOrder(wf);
594 expect(res.order).toEqual([["c"]]);
595 expect(res.errors.map((e) => e.message)).toEqual([
596 "Job 'c' depends on unknown job 'zzz'",
597 "Dependency cycle between jobs: a -> b -> a",
598 ]);
599 });
600});