diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af2bf411e0..5ef5b47637 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -575,7 +575,29 @@ jobs: - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build - run: bun run --cwd packages/core build - run: bun run --cwd packages/core build:hyperframes-runtime - - run: bun run --filter '@hyperframes/{cli,core,engine}' test + - run: bun run --filter '@hyperframes/{core,engine}' test + + test-cli: + name: Test (cli) + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + lfs: true + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - uses: ./.github/actions/install-ffmpeg-linux + - uses: ./.github/actions/prepare-ffmpeg-bin + - run: bash scripts/ci/install-workspace-dependencies.sh + - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build + - run: bun run --cwd packages/core build + - run: bun run --cwd packages/core build:hyperframes-runtime + - run: bun run --filter '@hyperframes/cli' test test-support: name: Test (support) @@ -612,7 +634,16 @@ jobs: test: name: Test - needs: [changes, producer-source-tests, test-studio, test-runtime, test-support, test-skills] + needs: + [ + changes, + producer-source-tests, + test-studio, + test-runtime, + test-cli, + test-support, + test-skills, + ] # Keep the existing required `Test` context authoritative for producer # failures too. It also fails closed if any package shard fails or is cancelled. if: always() && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true' || needs.changes.outputs.skills == 'true') @@ -630,10 +661,10 @@ jobs: echo "::error::Producer unit/integration tests did not succeed." exit 1 - name: Require package test shards - if: needs.changes.outputs.code == 'true' && (needs.test-studio.result != 'success' || needs.test-runtime.result != 'success' || needs.test-support.result != 'success') + if: needs.changes.outputs.code == 'true' && (needs.test-studio.result != 'success' || needs.test-runtime.result != 'success' || needs.test-cli.result != 'success' || needs.test-support.result != 'success') run: | echo "::error::A package test shard did not succeed." - echo "studio=${{ needs.test-studio.result }} runtime=${{ needs.test-runtime.result }} support=${{ needs.test-support.result }}" + echo "studio=${{ needs.test-studio.result }} runtime=${{ needs.test-runtime.result }} cli=${{ needs.test-cli.result }} support=${{ needs.test-support.result }}" exit 1 - name: Require skills tests if: needs.changes.outputs.skills == 'true' && needs.test-skills.result != 'success' diff --git a/scripts/check-test-reachability.mjs b/scripts/check-test-reachability.mjs index 0c70d04c59..74259b0266 100644 --- a/scripts/check-test-reachability.mjs +++ b/scripts/check-test-reachability.mjs @@ -58,24 +58,29 @@ function jobField(body, key) { return body.match(new RegExp(`^ ${key}: (.*)$`, "m"))?.[1] ?? ""; } +// `needs` as a one-line list, a multi-line list or a block list, whichever the formatter wrote. +function jobNeeds(body) { + const value = body.match(/^ needs:(.*(?:\n(?: .*| *))*)/m)?.[1] ?? ""; + return value + .replace(/#.*$/gm, "") + .replace(/^\s*- /gm, "") + .split(/[\s,[\]]+/) + .filter(Boolean); +} + function readJob(block) { const name = block.match(/^ ([\w-]+):/)[1]; const body = block.slice(block.indexOf("\n") + 1); const condition = jobField(body, "if"); - const needs = jobField(body, "needs"); - return { - name, - body, - condition, - needs: needs.replace(/[[\]]/g, "").split(/,\s*/).filter(Boolean), - }; + const needs = jobNeeds(body); + return { name, body, condition, needs }; } // Reject unsupported control syntax instead of treating it as an unconditional job. export function readWorkflow(source) { if (/^\s+(?:paths|paths-ignore|branches-ignore):/m.test(source.split("jobs:\n")[0])) throw new Error("Workflow trigger restrictions need an explicit reachability model"); - if (/^ (?:needs|if):\s*$/m.test(source)) + if (/^ if:\s*$/m.test(source) || /^ needs:\s*\n (?![-[])/m.test(source)) throw new Error("Block job conditions and dependencies need an explicit reachability model"); if (/^\s+(?:- )?working-directory:/m.test(source)) throw new Error("Working-directory overrides need an explicit reachability model"); diff --git a/scripts/check-test-reachability.test.mjs b/scripts/check-test-reachability.test.mjs index 7e2ed4f706..f5b2819453 100644 --- a/scripts/check-test-reachability.test.mjs +++ b/scripts/check-test-reachability.test.mjs @@ -137,11 +137,21 @@ test("conditions in the first step key are never credited", () => { assert.match(check(tree)["scripts/parity.test.mjs"][0], /no CI runner/); }); -test("block dependency syntax fails closed instead of losing the dependency", () => { +test("block and wrapped dependency lists gate a job like the one-line list", () => { + for (const needs of ["\n - disabled", "\n [\n disabled,\n ]"]) { + const tree = fixture("node --test scripts/parity.test.mjs", '"**"'); + tree[".github/workflows/ci.yml"] = + tree[".github/workflows/ci.yml"].replace(" needs: changes", ` needs:${needs}`) + + " disabled:\n if: false\n steps: []\n"; + assert.match(check(tree)["scripts/parity.test.mjs"].join("\n"), /CI filters exclude/); + } +}); + +test("a dependency mapping fails closed instead of losing the dependency", () => { const tree = fixture(); tree[".github/workflows/ci.yml"] = tree[".github/workflows/ci.yml"].replace( "needs: changes", - "needs:\n - disabled", + "needs:\n disabled: x", ); assert.throws(() => check(tree), /Block job/); });