| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { expandMatrix, isWarning, jobOrder, matchesTrigger, parseWorkflow, type Workflow, type WorkflowError } from "../src/index.js"; |
| 3 | |
| 4 | function 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 | |
| 11 | function errs(src: string): WorkflowError[] { |
| 12 | return parseWorkflow(src, "w.yml").errors; |
| 13 | } |
| 14 | |
| 15 | function 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 | |
| 23 | const minimalJob = `jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`; |
| 24 | |
| 25 | describe("real-world workflows", () => { |
| 26 | it("Node CI with matrix and cache", () => { |
| 27 | const wf = ok(` |
| 28 | name: Node CI |
| 29 | on: |
| 30 | push: |
| 31 | branches: [ main ] |
| 32 | pull_request: |
| 33 | branches: [ main ] |
| 34 | env: |
| 35 | CI: true |
| 36 | FORCE_COLOR: 1 |
| 37 | concurrency: |
| 38 | group: ci-\${{ github.ref }} |
| 39 | cancel-in-progress: true |
| 40 | permissions: |
| 41 | contents: read |
| 42 | jobs: |
| 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(` |
| 133 | name: Release |
| 134 | on: |
| 135 | push: |
| 136 | tags: |
| 137 | - 'v[0-9]+.[0-9]+.[0-9]+' |
| 138 | - '!v*-rc*' |
| 139 | permissions: |
| 140 | contents: write |
| 141 | id-token: write |
| 142 | jobs: |
| 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(` |
| 186 | on: |
| 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: |
| 210 | run-name: Deploy by @\${{ github.actor }} |
| 211 | jobs: |
| 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(` |
| 233 | on: push |
| 234 | jobs: |
| 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(` |
| 261 | on: |
| 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 |
| 277 | jobs: |
| 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(` |
| 295 | on: push |
| 296 | defaults: |
| 297 | run: |
| 298 | shell: bash |
| 299 | working-directory: scripts |
| 300 | concurrency: deploy |
| 301 | jobs: |
| 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(` |
| 340 | on: push |
| 341 | jobs: |
| 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 | |
| 359 | describe("`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 | |
| 406 | describe("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 |
| 443 | jobs: |
| 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 |
| 473 | jobs: |
| 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 |
| 501 | jobs: |
| 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 |
| 514 | jobs: |
| 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 | |
| 572 | describe("jobOrder", () => { |
| 573 | it("groups jobs into levels in file order", () => { |
| 574 | const wf = ok(`on: push |
| 575 | jobs: |
| 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 | }); |