diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index ddbf73c2c..38e06521b 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -15,6 +15,8 @@ name: Integration — Drizzle (EQL v3) on: push: branches: [main] + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: - 'languages/typescript/packages/stack/src/eql/v3/**' - 'languages/typescript/packages/stack-drizzle/**' @@ -106,102 +108,108 @@ on: # load. Same argument as the two entries above, same guard. - 'packages/eql/crates/**' - 'packages/eql/Cargo.toml' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: ['**'] - # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. - paths: - - 'languages/typescript/packages/stack/src/eql/v3/**' - - 'languages/typescript/packages/stack-drizzle/**' - # Source layers the adapter's encoding/round-trip rests on: a break here - # (not just under src/eql/v3) can produce wrong rows, so trigger the live - # suite that would catch it. - - 'languages/typescript/packages/stack/src/encryption/**' - - 'languages/typescript/packages/stack/src/schema/**' - - 'languages/typescript/packages/stack/integration/**' - # The WASM family suite (integration/wasm/**) exercises this entry: - - 'languages/typescript/packages/stack/src/wasm-inline.ts' - # The DynamoDB adapter, and the entry/type modules the suites import - # directly. `integration/shared/v2-decrypt-compat` and its - # `integration/wasm/` twin (both selected below) are the repo's only live - # EQL v2 read coverage, and they exercise the DynamoDB legacy path, so a - # change here must run them. Pinned by - # scripts/__tests__/integration-workflow-paths.test.mjs. - - 'languages/typescript/packages/stack/src/dynamodb/**' - - 'languages/typescript/packages/stack/src/index.ts' - - 'languages/typescript/packages/stack/src/types.ts' - # Those same v2 suites mint their fixtures by importing - # `@cipherstash/protect-ffi` directly. A native-module bump is the change - # most able to break v2 payload deserialization and it touches NO source - # directory, so without these two entries the only suites that would catch - # it never start. They are the files a bump actually edits: exact pins - # (`protect-ffi`, `@cipherstash/eql`) live in the package manifest, - # `catalog:` ones in the workspace catalog. - # - # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every - # dependency bump in the monorepo — far more often than either file here — - # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi version change cannot reach the lockfile without editing - # one of the two manifests below first, and an auth one edits its own - # manifest, under the auth paths below. - - 'languages/typescript/packages/stack/package.json' - - 'pnpm-workspace.yaml' - - 'languages/typescript/packages/test-kit/**' - - 'languages/typescript/packages/cli/src/installer/**' - - 'local/docker-compose.postgres.yml' - - 'local/docker-compose.supabase.yml' - - 'local/supabase-init.sql' - - '.github/workflows/integration-drizzle.yml' - - '.github/actions/integration-setup/**' - - '.github/actions/integration-db/**' - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - # `@cipherstash/auth` is a workspace package, and the SDK loads its napi - # module (and, for `wasm-inline`, its wasm) on import, so a change to what - # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of - # require-auth-npm-changeset.yml. - - 'packages/stack-auth/Cargo.toml' - - 'packages/stack-auth/src/**' - - 'languages/typescript/packages/auth/**' - - 'languages/typescript/packages/stack-auth-wasm/**' - # The Rust that produces every EQL payload these suites round-trip. - # Absorbing protect-ffi put it in-tree, so a crate change can now - # break them in a PR that touches no TypeScript at all. - # - # The manifests are here for the same reason as the sources: a - # dependency bump in Cargo.lock changes the compiled encryption core - # without touching a .rs file, and package.json / mise.toml carry the - # build scripts and the toolchain pin. Matching the filter in - # tests-rust.yml, which already covers all four. - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # The non-cargo half of `build:wasm`, and the reason this job passes - # `wasm: 'true'`: `tsc -p tsconfig.wasm-errors.json`, then a - # `postbuild:wasm` hook running `scripts/inline-wasm.mjs`, which is what - # emits `dist/wasm/protect_ffi_inline.js` — the bundle the - # `integration/wasm/**` suites selected below actually load. Both files - # are in the WASM cache key of `.github/actions/build-ffi-binding`, so an - # edit to either misses that cache and REBUILDS the bundle. Without these - # two entries the workflow does not trigger at all, so neither the rebuild - # nor the only suite that loads it ever runs. - # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'languages/typescript/packages/protect-ffi/tsconfig.wasm-errors.json' - - 'languages/typescript/packages/protect-ffi/scripts/inline-wasm.mjs' - # Out of that package, and compiled into the same two artifacts: the - # cdylib crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into `index.node` and into - # `protect_ffi_inline.js`. Both cache keys in - # `.github/actions/build-ffi-binding` hash them, so an edit here misses - # that cache and rebuilds the bundle the `integration/wasm/**` suites - # load. Same argument as the two entries above, same guard. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + drizzle: ${{ steps.decide.outputs.drizzle }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + drizzle: + - "languages/typescript/packages/stack/src/eql/v3/**" + - "languages/typescript/packages/stack-drizzle/**" + - "languages/typescript/packages/stack/src/encryption/**" + - "languages/typescript/packages/stack/src/schema/**" + - "languages/typescript/packages/stack/integration/**" + - "languages/typescript/packages/stack/src/wasm-inline.ts" + - "languages/typescript/packages/stack/src/dynamodb/**" + - "languages/typescript/packages/stack/src/index.ts" + - "languages/typescript/packages/stack/src/types.ts" + - "languages/typescript/packages/stack/package.json" + - "pnpm-workspace.yaml" + - "languages/typescript/packages/test-kit/**" + - "languages/typescript/packages/cli/src/installer/**" + - "local/docker-compose.postgres.yml" + - "local/docker-compose.supabase.yml" + - "local/supabase-init.sql" + - ".github/workflows/integration-drizzle.yml" + - ".github/actions/integration-setup/**" + - ".github/actions/integration-db/**" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "packages/stack-auth/Cargo.toml" + - "packages/stack-auth/src/**" + - "languages/typescript/packages/auth/**" + - "languages/typescript/packages/stack-auth-wasm/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "languages/typescript/packages/protect-ffi/tsconfig.wasm-errors.json" + - "languages/typescript/packages/protect-ffi/scripts/inline-wasm.mjs" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + DRIZZLE: ${{ steps.filter.outputs.drizzle }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + DRIZZLE=true + fi + answers=( + "drizzle=$DRIZZLE" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + integration: name: Drizzle v3 integration (db=${{ matrix.db }}) runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -218,7 +226,14 @@ jobs: # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a # declared manual trigger do nothing. Enforced by # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.drizzle == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) strategy: fail-fast: false @@ -344,3 +359,31 @@ jobs: - name: Stop ${{ matrix.db }} if: always() && env.CS_COMPOSE_PROJECT != '' run: docker compose -p "$CS_COMPOSE_PROJECT" -f "$CS_COMPOSE_FILE" down -v + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Integration (Drizzle) summary + needs: [selector, integration] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index ed74631f2..0644f304d 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -17,6 +17,8 @@ name: Integration — prisma-next (EQL v3) on: push: branches: [main] + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: - 'languages/typescript/packages/stack/src/eql/v3/**' - 'languages/typescript/packages/stack-prisma/**' @@ -62,56 +64,95 @@ on: # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - 'packages/eql/crates/**' - 'packages/eql/Cargo.toml' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: ['**'] - # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. - paths: - - 'languages/typescript/packages/stack/src/eql/v3/**' - - 'languages/typescript/packages/stack-prisma/**' - # Source layers the adapter's encoding/round-trip rests on: a break here - # (not just under src/eql/v3) can produce wrong rows, so trigger the live - # suite that would catch it. - - 'languages/typescript/packages/stack/src/encryption/**' - - 'languages/typescript/packages/stack/src/schema/**' - - 'languages/typescript/packages/test-kit/**' - - 'languages/typescript/packages/cli/src/installer/**' - - 'local/docker-compose.postgres.yml' - - 'local/docker-compose.supabase.yml' - - 'local/supabase-init.sql' - - '.github/workflows/integration-prisma-next.yml' - - '.github/actions/integration-setup/**' - - '.github/actions/integration-db/**' - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - # The Rust that produces every EQL payload these suites round-trip. - # Absorbing protect-ffi put it in-tree, so a crate change can now - # break them in a PR that touches no TypeScript at all. - # - # The manifests are here for the same reason as the sources: a - # dependency bump in Cargo.lock changes the compiled encryption core - # without touching a .rs file, and package.json / mise.toml carry the - # build scripts and the toolchain pin. Matching the filter in - # tests-rust.yml, which already covers all four. - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # Out of that package, and a compile input all the same: the cdylib - # crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into the `index.node` this job encrypts - # through. The native cache key in `.github/actions/build-ffi-binding` - # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, - # because the workflow does not trigger at all: a payload-encoding change - # under packages/eql/crates merges with this suite green by absence. - # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + prisma_next: ${{ steps.decide.outputs.prisma_next }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + prisma_next: + - "languages/typescript/packages/stack/src/eql/v3/**" + - "languages/typescript/packages/stack-prisma/**" + - "languages/typescript/packages/stack/src/encryption/**" + - "languages/typescript/packages/stack/src/schema/**" + - "languages/typescript/packages/test-kit/**" + - "languages/typescript/packages/cli/src/installer/**" + - "local/docker-compose.postgres.yml" + - "local/docker-compose.supabase.yml" + - "local/supabase-init.sql" + - ".github/workflows/integration-prisma-next.yml" + - ".github/actions/integration-setup/**" + - ".github/actions/integration-db/**" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + PRISMA_NEXT: ${{ steps.filter.outputs.prisma_next }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + PRISMA_NEXT=true + fi + answers=( + "prisma_next=$PRISMA_NEXT" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + integration: name: prisma-next v3 integration (db=${{ matrix.db }}) runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -128,7 +169,14 @@ jobs: # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a # declared manual trigger do nothing. Enforced by # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.prisma_next == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) strategy: fail-fast: false @@ -199,3 +247,31 @@ jobs: - name: Stop ${{ matrix.db }} if: always() && env.CS_COMPOSE_PROJECT != '' run: docker compose -p "$CS_COMPOSE_PROJECT" -f "$CS_COMPOSE_FILE" down -v + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Integration (prisma-next) summary + needs: [selector, integration] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml index 8b8f4ff03..2d4d1c490 100644 --- a/.github/workflows/integration-protect-ffi.yml +++ b/.github/workflows/integration-protect-ffi.yml @@ -34,6 +34,8 @@ name: Integration — protect-ffi (native + WASM) on: push: branches: [main] + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: # The suite itself: tests, fixtures, its compose file, its `tasks.toml` # and its manifest. @@ -99,45 +101,11 @@ on: - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' - '.github/actions/require-cs-secrets/**' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: ['**'] - # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. - # A one-sided edit disables the job on pull requests while leaving it green - # on `main` — the exact inversion of what you want. - paths: - - 'languages/typescript/packages/protect-ffi/integration-tests/**' - - 'pnpm-workspace.yaml' - - 'packages/eql/packages/eql/**' - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/scripts/**' - - 'languages/typescript/packages/protect-ffi/tsconfig.wasm-errors.json' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # Out of this package, and a real compile input: the cdylib crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into `index.node` AND into the wasm32 build - # this suite's `wasm-round-trip` test loads. Both cache keys in - # `.github/actions/build-ffi-binding` hash these, so an edit misses the - # cache and rebuilds the binding — without these entries it would be - # rebuilt and no suite that loads it would start. Pinned by - # scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' - - '.github/workflows/integration-protect-ffi.yml' - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - # `@cipherstash/auth` is a workspace package, and the SDK loads its napi - # module (and, for `wasm-inline`, its wasm) on import, so a change to what - # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of - # require-auth-npm-changeset.yml. - - 'packages/stack-auth/Cargo.toml' - - 'packages/stack-auth/src/**' - - 'languages/typescript/packages/auth/**' - - 'languages/typescript/packages/stack-auth-wasm/**' - - '.github/actions/require-cs-secrets/**' workflow_dispatch: {} permissions: @@ -158,6 +126,87 @@ concurrency: cancel-in-progress: true jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + protect_ffi: ${{ steps.decide.outputs.protect_ffi }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + protect_ffi: + - "languages/typescript/packages/protect-ffi/integration-tests/**" + - "pnpm-workspace.yaml" + - "packages/eql/packages/eql/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/scripts/**" + - "languages/typescript/packages/protect-ffi/tsconfig.wasm-errors.json" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + - ".github/workflows/integration-protect-ffi.yml" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "packages/stack-auth/Cargo.toml" + - "packages/stack-auth/src/**" + - "languages/typescript/packages/auth/**" + - "languages/typescript/packages/stack-auth-wasm/**" + - ".github/actions/require-cs-secrets/**" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + PROTECT_FFI: ${{ steps.filter.outputs.protect_ffi }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + PROTECT_FFI=true + fi + answers=( + "protect_ffi=$PROTECT_FFI" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + integration: name: protect-ffi integration suite runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -181,7 +230,14 @@ jobs: # unrelated commit will not start it either. An allowlist of event names # would break again the day someone adds `schedule:`. # Enforced by scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.protect_ffi == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) env: # Same split as the other integration workflows: the workspace CRN and @@ -389,3 +445,31 @@ jobs: if: always() working-directory: languages/typescript/packages/protect-ffi/integration-tests run: docker compose down -v + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Integration (protect-ffi) summary + needs: [selector, integration] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 16d4da21e..6814a4379 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -11,6 +11,8 @@ name: Integration — Supabase (EQL v3) on: push: branches: [main] + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: - 'languages/typescript/packages/stack-supabase/**' - 'languages/typescript/packages/stack/src/eql/v3/**' @@ -89,88 +91,104 @@ on: # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - 'packages/eql/crates/**' - 'packages/eql/Cargo.toml' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: ['**'] - paths: - - 'languages/typescript/packages/stack-supabase/**' - - 'languages/typescript/packages/stack/src/eql/v3/**' - # Source layers the adapter's encoding/round-trip rests on: a break here - # (not just under src/supabase) can produce wrong wire output or rows, so - # trigger the live suite that would catch it. - - 'languages/typescript/packages/stack/src/encryption/**' - - 'languages/typescript/packages/stack/src/schema/**' - - 'languages/typescript/packages/stack/integration/**' - # The DynamoDB adapter, and the entry/type modules the suites import - # directly. `integration/shared/v2-decrypt-compat` is the repo's only live - # EQL v2 read coverage for the native entry (the `integration/wasm/` twin - # runs on the Drizzle job) and it exercises the DynamoDB legacy path, so a - # change here must run it. Pinned by - # scripts/__tests__/integration-workflow-paths.test.mjs. - - 'languages/typescript/packages/stack/src/dynamodb/**' - - 'languages/typescript/packages/stack/src/index.ts' - - 'languages/typescript/packages/stack/src/types.ts' - # That v2 suite mints its fixtures by importing `@cipherstash/protect-ffi` - # directly. A native-module bump is the change most able to break v2 - # payload deserialization and it touches NO source directory, so without - # these two entries the only suites that would catch it never start. They - # are the files a bump actually edits: exact pins (`protect-ffi`, - # `@cipherstash/eql`) live in the package manifest, `catalog:` ones in - # the workspace catalog. - # - # `pnpm-lock.yaml` is deliberately NOT listed. It changes on roughly every - # dependency bump in the monorepo — far more often than either file here — - # and these are credentialed, database-backed jobs. Nothing is lost: a - # protect-ffi version change cannot reach the lockfile without editing - # one of the two manifests below first, and an auth one edits its own - # manifest, under the auth paths below. - - 'languages/typescript/packages/stack/package.json' - - 'pnpm-workspace.yaml' - - 'languages/typescript/packages/test-kit/**' - - 'languages/typescript/packages/cli/src/installer/**' - - 'local/docker-compose.supabase.yml' - - 'local/supabase-init.sql' - - '.github/workflows/integration-supabase.yml' - - '.github/actions/integration-setup/**' - - '.github/actions/integration-db/**' - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - # `@cipherstash/auth` is a workspace package, and the SDK loads its napi - # module (and, for `wasm-inline`, its wasm) on import, so a change to what - # it ships arrives here rather than in `pnpm-workspace.yaml`. The paths of - # require-auth-npm-changeset.yml. - - 'packages/stack-auth/Cargo.toml' - - 'packages/stack-auth/src/**' - - 'languages/typescript/packages/auth/**' - - 'languages/typescript/packages/stack-auth-wasm/**' - # The Rust that produces every EQL payload these suites round-trip. - # Absorbing protect-ffi put it in-tree, so a crate change can now - # break them in a PR that touches no TypeScript at all. - # - # The manifests are here for the same reason as the sources: a - # dependency bump in Cargo.lock changes the compiled encryption core - # without touching a .rs file, and package.json / mise.toml carry the - # build scripts and the toolchain pin. Matching the filter in - # tests-rust.yml, which already covers all four. - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # Out of that package, and a compile input all the same: the cdylib - # crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into the `index.node` this job encrypts - # through. The native cache key in `.github/actions/build-ffi-binding` - # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, - # because the workflow does not trigger at all: a payload-encoding change - # under packages/eql/crates merges with this suite green by absence. - # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + supabase: ${{ steps.decide.outputs.supabase }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + supabase: + - "languages/typescript/packages/stack-supabase/**" + - "languages/typescript/packages/stack/src/eql/v3/**" + - "languages/typescript/packages/stack/src/encryption/**" + - "languages/typescript/packages/stack/src/schema/**" + - "languages/typescript/packages/stack/integration/**" + - "languages/typescript/packages/stack/src/dynamodb/**" + - "languages/typescript/packages/stack/src/index.ts" + - "languages/typescript/packages/stack/src/types.ts" + - "languages/typescript/packages/stack/package.json" + - "pnpm-workspace.yaml" + - "languages/typescript/packages/test-kit/**" + - "languages/typescript/packages/cli/src/installer/**" + - "local/docker-compose.supabase.yml" + - "local/supabase-init.sql" + - ".github/workflows/integration-supabase.yml" + - ".github/actions/integration-setup/**" + - ".github/actions/integration-db/**" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "packages/stack-auth/Cargo.toml" + - "packages/stack-auth/src/**" + - "languages/typescript/packages/auth/**" + - "languages/typescript/packages/stack-auth-wasm/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + SUPABASE: ${{ steps.filter.outputs.supabase }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + SUPABASE=true + fi + answers=( + "supabase=$SUPABASE" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + integration: name: Supabase v3 integration (db=${{ matrix.db }}) runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -187,7 +205,14 @@ jobs: # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a # declared manual trigger do nothing. Enforced by # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.supabase == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) strategy: # A one-element matrix, not a cross-product: the Supabase adapter only ever @@ -289,3 +314,31 @@ jobs: - name: Stop ${{ matrix.db }} if: always() && env.CS_COMPOSE_PROJECT != '' run: docker compose -p "$CS_COMPOSE_PROJECT" -f "$CS_COMPOSE_FILE" down -v + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Integration (Supabase) summary + needs: [selector, integration] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/.github/workflows/prisma-example-readme-e2e.yml b/.github/workflows/prisma-example-readme-e2e.yml index 9869d8470..0851818ad 100644 --- a/.github/workflows/prisma-example-readme-e2e.yml +++ b/.github/workflows/prisma-example-readme-e2e.yml @@ -15,14 +15,16 @@ on: push: branches: - main + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: - 'languages/typescript/examples/prisma/**' - '.github/workflows/prisma-example-readme-e2e.yml' # The walkthrough encrypts against the live service, so the native # binding and the action that builds it are inputs to this suite. Added # with that build step: without them a protect-ffi change would reach main - # having never run the walkthrough. They are repeated verbatim under - # `pull_request` below, which is what makes that a gate rather than a + # having never run the walkthrough. The `selector` job's filter repeats + # them for `pull_request`, which is what makes that a gate rather than a # report — a PR touching any of these runs the walkthrough before the # change lands, not after. - '.github/actions/build-ffi-binding/**' @@ -45,41 +47,86 @@ on: # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - 'packages/eql/crates/**' - 'packages/eql/Cargo.toml' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: - '**' - paths: - - 'languages/typescript/examples/prisma/**' - - '.github/workflows/prisma-example-readme-e2e.yml' - # The same entries as under `push` above, and the copy that does the - # work: a PR touching the native binding or the action that builds it - # runs the walkthrough on the PR, so a protect-ffi change is gated by - # this suite rather than reaching main unexercised. (GitHub Actions has - # no YAML anchors, so the list is written twice; - # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares the - # two copies.) - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # Out of that package, and a compile input all the same: the cdylib - # crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into the `index.node` this job encrypts - # through. The native cache key in `.github/actions/build-ffi-binding` - # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, - # because the workflow does not trigger at all: a payload-encoding change - # under packages/eql/crates merges with this walkthrough green by absence. - # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + readme: ${{ steps.decide.outputs.readme }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + readme: + - "languages/typescript/examples/prisma/**" + - ".github/workflows/prisma-example-readme-e2e.yml" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + README: ${{ steps.filter.outputs.readme }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + README=true + fi + answers=( + "readme=$README" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + walkthrough: name: Run README walkthrough runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -92,7 +139,14 @@ jobs: # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a # declared manual trigger do nothing. Enforced by # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.readme == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) env: CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} @@ -164,3 +218,31 @@ jobs: if: always() working-directory: languages/typescript/examples/prisma run: docker compose down -v || true + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Prisma example README summary + needs: [selector, walkthrough] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index 01c68f928..781ef0bd5 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -14,6 +14,8 @@ on: push: branches: - main + # The same files as the `selector` job's filter. + # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares them. paths: - 'languages/typescript/packages/stack-prisma/**' - 'languages/typescript/examples/prisma/**' @@ -21,9 +23,9 @@ on: # This suite encrypts against the live service, so the native binding and # the action that builds it are inputs to it. Added with that build step: # without them a protect-ffi change would reach main having never run this - # suite. They are repeated verbatim under `pull_request` below, which is - # what makes that a gate rather than a report — a PR touching any of these - # runs the suite before the change lands, not after. + # suite. The `selector` job's filter repeats them for `pull_request`, + # which is what makes that a gate rather than a report — a PR touching + # any of these runs the suite before the change lands, not after. - '.github/actions/build-ffi-binding/**' - '.github/actions/build-auth-binding/**' - 'languages/typescript/packages/protect-ffi/crates/**' @@ -44,42 +46,87 @@ on: # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - 'packages/eql/crates/**' - 'packages/eql/Cargo.toml' + # No path filter: a workflow that a path filter skips leaves its required + # checks pending, and GitHub's documentation on required status checks says + # that blocks a merge. The trigger paths are in the `selector` job instead. pull_request: branches: - '**' - paths: - - 'languages/typescript/packages/stack-prisma/**' - - 'languages/typescript/examples/prisma/**' - - '.github/workflows/prisma-next-e2e.yml' - # The same entries as under `push` above, and the copy that does the - # work: a PR touching the native binding or the action that builds it - # runs this suite on the PR, so a protect-ffi change is gated by it - # rather than reaching main unexercised. (GitHub Actions has no YAML - # anchors, so the list is written twice; - # scripts/__tests__/workflow-paths-filter-parity.test.mjs compares the - # two copies.) - - '.github/actions/build-ffi-binding/**' - - '.github/actions/build-auth-binding/**' - - 'languages/typescript/packages/protect-ffi/crates/**' - - 'languages/typescript/packages/protect-ffi/src/**' - - 'languages/typescript/packages/protect-ffi/Cargo.toml' - - 'languages/typescript/packages/protect-ffi/Cargo.lock' - - 'languages/typescript/packages/protect-ffi/package.json' - - 'languages/typescript/packages/protect-ffi/mise.toml' - # Out of that package, and a compile input all the same: the cdylib - # crate carries - # `eql-bindings = { path = "../../../../../../packages/eql/crates/eql-bindings" }`, so the - # EQL wire types are linked into the `index.node` this job encrypts - # through. The native cache key in `.github/actions/build-ffi-binding` - # hashes both, so an edit here misses that cache and the binding gets - # rebuilt — correctly. Without these two entries the miss never happens, - # because the workflow does not trigger at all: a payload-encoding change - # under packages/eql/crates merges with this suite green by absence. - # Pinned by scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs. - - 'packages/eql/crates/**' - - 'packages/eql/Cargo.toml' jobs: + # The selector job: it decides which jobs a pull request runs. It runs on + # every event and is never skipped, because the gated jobs need it. When it + # cannot decide it fails, and that fails the summary job: a selector that + # answered nothing would skip every gated job, and the summary job counts a + # skipped job as passed (ADR 0003). + # + # On a GitHub-hosted runner because the gated jobs wait for it. No checkout: + # on a pull request, dorny/paths-filter lists the changed files through the + # GitHub API. + selector: + name: Selector + runs-on: ubuntu-latest + timeout-minutes: 10 + # Each output is named after the filter that decides it. + # `scripts/__tests__/workflow-selectors.test.mjs` follows a job's `if:` to + # its filter by that name. + outputs: + prisma_next_e2e: ${{ steps.decide.outputs.prisma_next_e2e }} + steps: + # Only on a pull request, which has a base to compare with. Other events + # select every job in the step below. + - id: filter + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3 + with: + filters: | + # The same list as `on.push.paths`, which says why each entry + # is there. + prisma_next_e2e: + - "languages/typescript/packages/stack-prisma/**" + - "languages/typescript/examples/prisma/**" + - ".github/workflows/prisma-next-e2e.yml" + - ".github/actions/build-ffi-binding/**" + - ".github/actions/build-auth-binding/**" + - "languages/typescript/packages/protect-ffi/crates/**" + - "languages/typescript/packages/protect-ffi/src/**" + - "languages/typescript/packages/protect-ffi/Cargo.toml" + - "languages/typescript/packages/protect-ffi/Cargo.lock" + - "languages/typescript/packages/protect-ffi/package.json" + - "languages/typescript/packages/protect-ffi/mise.toml" + - "packages/eql/crates/**" + - "packages/eql/Cargo.toml" + + # Explicit default, not `|| 'true'`, which trips GitHub's inconsistent + # treatment of the string 'false'. On a pull request, an answer other than + # `true` or `false` means the filter did not decide, so the step fails + # rather than write it. + # + # The event and the answers arrive through `env:`, not through a `${{ }}` + # in the script. That lets `scripts/__tests__/workflow-selectors.test.mjs` + # run this script and read back what it wrote. + - id: decide + env: + EVENT_NAME: ${{ github.event_name }} + PRISMA_NEXT_E2E: ${{ steps.filter.outputs.prisma_next_e2e }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + PRISMA_NEXT_E2E=true + fi + answers=( + "prisma_next_e2e=$PRISMA_NEXT_E2E" + ) + for answer in "${answers[@]}"; do + case "${answer#*=}" in + true|false) echo "$answer" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::The path filter answered '${answer#*=}' for ${answer%%=*}. The selector cannot decide which jobs to run." + exit 1 + ;; + esac + done + e2e: name: Run Prisma Next E2E runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -93,7 +140,14 @@ jobs: # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a # declared manual trigger do nothing. Enforced by # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + needs: [selector] + # The first clause is the selector's answer; both are exclusions of + # pull_request, not lists of events. + if: >- + (github.event_name != 'pull_request' + || needs.selector.outputs.prisma_next_e2e == 'true') + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) env: CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} @@ -201,3 +255,31 @@ jobs: if: always() working-directory: languages/typescript/examples/prisma run: docker compose -f test/e2e/docker-compose.yml down -v + + # The summary job: the one job of this workflow that branch rules require. + # It has the same name on every event. It passes when every needed job + # passed or was skipped. Skipped counts as passed because the selector skips + # a job whose trigger paths a change does not touch. A failed job still + # fails this one, because it is in `needs` and reports its failure. + summary: + name: Prisma Next E2E summary + needs: [selector, e2e] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check that every needed job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + set -euo pipefail + echo "Needed results: $RESULTS" + for result in $RESULTS; do + case "$result" in + success|skipped) ;; + *) + echo "::error::A needed job reported '$result'." + exit 1 + ;; + esac + done diff --git a/languages/typescript/packages/protect-ffi/src/integrationSuiteCi.test.ts b/languages/typescript/packages/protect-ffi/src/integrationSuiteCi.test.ts index 41af6b68e..f1cefe743 100644 --- a/languages/typescript/packages/protect-ffi/src/integrationSuiteCi.test.ts +++ b/languages/typescript/packages/protect-ffi/src/integrationSuiteCi.test.ts @@ -80,7 +80,8 @@ function stripJsonComments(raw: string): string { * * Cutting at `jobs:` is what removes the `on:` / `paths:` block, and that is * also what makes the discovery filter below mean what its comment says: a - * `paths:` filter naming the suite is a trigger, not a run. + * `paths:` filter naming the suite is a trigger, not a run. A selector job's + * `filters:` block is the same kind of list inside `jobs:`, so it is cut too. * * `nativeLoading.test.ts` strips comments from the emitted entry for the same * reason — a comment describing a thing is indistinguishable from the thing. @@ -89,7 +90,22 @@ function executablePart(body: string): string { const jobsAt = body.search(/^jobs:/m) // No `jobs:` key means nothing runs, so nothing can match. Returning the // whole body here would hand every assertion the header comments instead. - return jobsAt === -1 ? '' : body.slice(jobsAt).replace(/^[ \t]*#.*$/gm, '') + if (jobsAt === -1) return '' + const kept: string[] = [] + let filtersIndent = -1 + for (const line of body.slice(jobsAt).split('\n')) { + const indent = line.length - line.trimStart().length + if (filtersIndent >= 0) { + if (line.trim() === '' || indent > filtersIndent) continue + filtersIndent = -1 + } + if (/^\s*filters:\s*\|/.test(line)) { + filtersIndent = indent + continue + } + kept.push(line) + } + return kept.join('\n').replace(/^[ \t]*#.*$/gm, '') } /** Every workflow GitHub actually executes, read from the repository root. */ diff --git a/scripts/__tests__/auth-binding-step-order.test.mjs b/scripts/__tests__/auth-binding-step-order.test.mjs index aa1b604ab..82dd4317e 100644 --- a/scripts/__tests__/auth-binding-step-order.test.mjs +++ b/scripts/__tests__/auth-binding-step-order.test.mjs @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { filterCovers } from './lib/paths-filter.mjs' +import { jobTriggerLists } from './lib/selector.mjs' import { readWorkflow, workflowFiles } from './lib/workflows.mjs' /** @@ -84,13 +85,20 @@ describe('the @cipherstash/auth binding is built wherever the SDK runs', () => { const same = (entry, input) => entry === input -/** The `paths:` lists of a workflow's filtered events. */ -const filters = (relPath) => { - const wf = readWorkflow(relPath) - const on = wf?.on ?? wf?.[true] - return ['push', 'pull_request'] - .map((event) => on?.[event]?.paths) - .filter(Array.isArray) +/** + * The path lists that decide whether the given jobs run, once each: the + * workflow's `push` and `pull_request` `paths:` lists, and the selector filter + * each job reads. Every job of the workflow when none are given. + */ +const filters = (relPath, jobNames) => { + const lists = new Map() + for (const jobName of jobNames ?? + Object.keys(readWorkflow(relPath)?.jobs ?? {})) { + for (const { source, paths } of jobTriggerLists(relPath, jobName)) { + lists.set(source, paths) + } + } + return [...lists.values()] } /** @@ -105,14 +113,19 @@ const AUTH_SOURCE_WORKFLOWS = [ ] describe('a change to the auth binding starts the jobs that load it', () => { - const users = [ - ...new Set( - JOBS.filter((job) => job.auth).map((job) => job.id.split(' / ')[0]), - ), - ] + /** Each workflow with a job that builds the binding, and those jobs. */ + const usersByWorkflow = new Map() + for (const job of JOBS.filter((entry) => entry.auth)) { + const [relPath, jobName] = job.id.split(' / ') + usersByWorkflow.set(relPath, [ + ...(usersByWorkflow.get(relPath) ?? []), + jobName, + ]) + } + const users = [...usersByWorkflow.keys()] it.each(users)('%s filters on the action', (relPath) => { - for (const paths of filters(relPath)) { + for (const paths of filters(relPath, usersByWorkflow.get(relPath))) { expect( filterCovers(paths, '.github/actions/build-auth-binding/**', same), ).toBe(true) @@ -133,7 +146,7 @@ describe('a change to the auth binding starts the jobs that load it', () => { '%s filters on the auth sources', (relPath) => { expect(shipped.length).toBeGreaterThanOrEqual(4) - const lists = filters(relPath) + const lists = filters(relPath, usersByWorkflow.get(relPath)) expect(lists.length).toBe(2) for (const paths of lists) { expect( diff --git a/scripts/__tests__/integration-selectors.test.mjs b/scripts/__tests__/integration-selectors.test.mjs new file mode 100644 index 000000000..fec8e7e59 --- /dev/null +++ b/scripts/__tests__/integration-selectors.test.mjs @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { runsWhen } from './lib/expressions.mjs' +import { + readSelectorJob, + SELECTOR_JOB, + selectedJobs, + selectorOutputsRead, +} from './lib/selector.mjs' + +/** + * What the selector jobs of the credentialed integration and E2E workflows + * decide for a pull request. Each filter keeps the list it replaced, which + * integration-workflow-paths, wasm-build-inputs-paths-filter and + * auth-binding-step-order check entry by entry. These pin the decisions and + * that the fork guard still holds. + */ + +const WORKFLOWS = { + '.github/workflows/integration-drizzle.yml': 'integration', + '.github/workflows/integration-prisma-next.yml': 'integration', + '.github/workflows/integration-protect-ffi.yml': 'integration', + '.github/workflows/integration-supabase.yml': 'integration', + '.github/workflows/prisma-example-readme-e2e.yml': 'walkthrough', + '.github/workflows/prisma-next-e2e.yml': 'e2e', +} + +const REPO = 'cipherstash/stack' + +for (const [relPath, job] of Object.entries(WORKFLOWS)) { + describe(relPath, () => { + const runs = (files) => selectedJobs(relPath, files).includes(job) + + it('has a selector job that decides the suite', () => { + const selector = readSelectorJob(relPath) + expect(selector).not.toBeNull() + expect(selectorOutputsRead(selector.workflow.jobs[job])).toHaveLength(1) + }) + + it('runs the suite for a change to the binding it encrypts through', () => { + expect( + runs(['languages/typescript/packages/protect-ffi/crates/a/src/lib.rs']), + ).toBe(true) + expect(runs(['packages/eql/Cargo.toml'])).toBe(true) + expect(runs([relPath])).toBe(true) + }) + + it('skips the suite for an unrelated change', () => { + expect(runs(['languages/golang/encrypt/encrypt.go'])).toBe(false) + expect(runs(['docs/sdk-design-principles.md'])).toBe(false) + }) + + it('still skips a fork pull request, whatever the selector says', () => { + // The suite holds live credentials, which a fork run does not get. + const output = selectorOutputsRead( + readSelectorJob(relPath).workflow.jobs[job], + )[0] + const verdict = runsWhen(readSelectorJob(relPath).workflow.jobs[job].if, { + github: { + event_name: 'pull_request', + repository: REPO, + event: { + pull_request: { head: { repo: { full_name: 'fork/stack' } } }, + }, + }, + needs: { [SELECTOR_JOB]: { outputs: { [output]: 'true' } } }, + vars: {}, + }) + expect(verdict).toBe(false) + }) + }) +} + +describe('suite-specific trigger paths', () => { + it('runs the protect-ffi suite for its own tests, and not the others', () => { + const file = + 'languages/typescript/packages/protect-ffi/integration-tests/a.ts' + expect( + selectedJobs('.github/workflows/integration-protect-ffi.yml', [file]), + ).toContain('integration') + expect( + selectedJobs('.github/workflows/integration-drizzle.yml', [file]), + ).not.toContain('integration') + }) + + it('runs the Drizzle suite for stack-drizzle, and not the Prisma suites', () => { + const file = 'languages/typescript/packages/stack-drizzle/src/index.ts' + expect( + selectedJobs('.github/workflows/integration-drizzle.yml', [file]), + ).toContain('integration') + expect( + selectedJobs('.github/workflows/prisma-next-e2e.yml', [file]), + ).not.toContain('e2e') + }) +}) diff --git a/scripts/__tests__/integration-workflow-paths.test.mjs b/scripts/__tests__/integration-workflow-paths.test.mjs index c3b1e812a..a44f65539 100644 --- a/scripts/__tests__/integration-workflow-paths.test.mjs +++ b/scripts/__tests__/integration-workflow-paths.test.mjs @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' +import { jobTriggerLists } from './lib/selector.mjs' import { readWorkflow, workflowFiles } from './lib/workflows.mjs' /** @@ -40,9 +41,6 @@ const STACK_SRC = 'languages/typescript/packages/stack/src' const STACK_MANIFEST = 'languages/typescript/packages/stack/package.json' const CATALOG_MANIFEST = 'pnpm-workspace.yaml' -/** Both trigger events, in the order GitHub evaluates them. */ -const TRIGGER_EVENTS = ['push', 'pull_request'] - /** * The integration workflows, discovered rather than listed: any workflow whose * `CS_IT_SUITE` globs select suites out of `languages/typescript/packages/stack/` is in scope. A @@ -56,14 +54,21 @@ function discoverWorkflows() { } /** - * `on:` parses as the boolean `true` under YAML 1.1 (the "Norway problem"), - * which is why this reads both keys rather than `wf.on`. + * The path lists that decide whether the jobs running a suite run, once each: + * the workflow's `push` and `pull_request` `paths:` lists, and the selector + * filter each such job reads. */ -function triggerBlocks(wf) { - const on = wf.on ?? wf[true] - return TRIGGER_EVENTS.map((event) => on?.[event]).filter( - (block) => block && Array.isArray(block.paths), - ) +function triggerBlocks(relPath) { + const lists = new Map() + for (const [jobId, job] of Object.entries( + readWorkflow(relPath)?.jobs ?? {}, + )) { + if (!job?.env?.CS_IT_SUITE) continue + for (const { source, paths } of jobTriggerLists(relPath, jobId)) { + lists.set(source, { source, paths }) + } + } + return [...lists.values()] } /** Every `CS_IT_SUITE` glob set declared anywhere in the workflow. */ @@ -190,8 +195,9 @@ describe('integration workflow paths filters', () => { for (const relPath of WORKFLOWS) { it(`${relPath} triggers on the manifests pinning its suites' dependencies`, () => { const wf = readWorkflow(relPath) - const blocks = triggerBlocks(wf) - expect(blocks.length).toBe(TRIGGER_EVENTS.length) + const blocks = triggerBlocks(relPath) + // A `push` list, and a `pull_request` list or a selector filter. + expect(blocks.length).toBe(2) const deps = stackDependencies() const files = suiteFiles(suiteGlobs(wf)) @@ -215,7 +221,7 @@ describe('integration workflow paths filters', () => { it(`${relPath} triggers on every source path its suites import`, () => { const wf = readWorkflow(relPath) - const blocks = triggerBlocks(wf) + const blocks = triggerBlocks(relPath) expect(blocks.length).toBeGreaterThan(0) const files = suiteFiles(suiteGlobs(wf)) diff --git a/scripts/__tests__/lib/selector.mjs b/scripts/__tests__/lib/selector.mjs new file mode 100644 index 000000000..ec2f910bb --- /dev/null +++ b/scripts/__tests__/lib/selector.mjs @@ -0,0 +1,361 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import yaml from 'js-yaml' +import { runsWhen } from './expressions.mjs' +import { pathsGlobToRegExp } from './paths-filter.mjs' +import { readWorkflow } from './workflows.mjs' + +/** + * Reading and running a workflow's selector job, for the guards that pin what + * it decides. + * + * The selector job is the first job of a workflow. It compares the files a + * pull request changes with each check's trigger paths, using + * dorny/paths-filter, and writes one output per filter. Each gated job's `if:` + * reads the output named after its filter. ADR 0003 and `.github/CONTEXT.md` + * describe the design. + * + * Everything here runs the workflow's own text: the filter rules as written, + * the decision script as written, and each job's real `if:`. A guard that + * reimplemented the decision would agree with a rewritten one by luck. + */ + +export const SELECTOR_JOB = 'selector' + +/** The events the selector decides nothing for: each one selects every job. */ +export const NON_PULL_REQUEST_EVENTS = [ + 'push', + 'schedule', + 'merge_group', + 'workflow_dispatch', +] + +const DORNY = 'dorny/paths-filter@' + +const stepOutputRef = (expression) => + /steps\.([A-Za-z0-9_-]+)\.outputs\.([A-Za-z0-9_-]+)/.exec( + String(expression ?? ''), + ) + +const selectorOutputRefs = (expression) => + [ + ...String(expression ?? '').matchAll( + new RegExp(`needs\\.${SELECTOR_JOB}\\.outputs\\.([A-Za-z0-9_-]+)`, 'g'), + ), + ].map((match) => match[1]) + +/** + * `{a,b}` expanded into its alternatives, as picomatch reads a brace pattern. + * One level only: a nested brace throws, rather than being read wrongly. + */ +export function expandBraces(pattern) { + const open = pattern.indexOf('{') + if (open === -1) return [pattern] + const close = pattern.indexOf('}', open) + const inner = pattern.slice(open + 1, close) + if (close === -1 || inner.includes('{')) { + throw new Error(`Cannot expand the brace pattern ${pattern}`) + } + const head = pattern.slice(0, open) + return inner + .split(',') + .flatMap((alternative) => + expandBraces(`${head}${alternative}${pattern.slice(close + 1)}`), + ) +} + +/** One dorny/paths-filter rule against one file. A `!` rule negates. */ +export function dornyRuleMatches(rule, file) { + if (rule.startsWith('!')) return !dornyRuleMatches(rule.slice(1), file) + return expandBraces(rule).some((glob) => pathsGlobToRegExp(glob).test(file)) +} + +/** + * Whether a file counts for a filter. Under dorny's default quantifier, `some`, + * one matching rule is enough; under `every`, all of them must match. + */ +export function dornyFilterMatches(rules, quantifier, file) { + return quantifier === 'every' + ? rules.every((rule) => dornyRuleMatches(rule, file)) + : rules.some((rule) => dornyRuleMatches(rule, file)) +} + +/** + * A workflow's selector job, parsed, or `null` when it has none. + * + * `filters` maps each filter name to its rules. A filter that includes an + * anchored list (`- *name`) parses to a nested list, and dorny flattens it; + * so does this. `outputs` maps each selector output to the decision step + * output it reads. + */ +export function readSelectorJob(relPath) { + const workflow = readWorkflow(relPath) + const job = workflow?.jobs?.[SELECTOR_JOB] + if (!job) return null + const steps = Array.isArray(job.steps) ? job.steps : [] + const filterSteps = steps.filter((step) => + String(step?.uses ?? '').startsWith(DORNY), + ) + const filterStep = filterSteps.length === 1 ? filterSteps[0] : null + const filters = Object.fromEntries( + Object.entries(yaml.load(filterStep?.with?.filters ?? '') ?? {}).map( + ([name, rules]) => [name, [rules].flat(Infinity).map(String)], + ), + ) + const outputs = Object.fromEntries( + Object.entries(job.outputs ?? {}).map(([name, expression]) => [ + name, + stepOutputRef(expression), + ]), + ) + const decideIds = [ + ...new Set(Object.values(outputs).map((ref) => ref?.[1])), + ].filter(Boolean) + const decideStep = + decideIds.length === 1 + ? steps.find((step) => step?.id === decideIds[0]) + : null + return { + workflow, + job, + filterSteps, + filterStep, + quantifier: filterStep?.with?.['predicate-quantifier'] ?? 'some', + filters, + outputs: Object.fromEntries( + Object.entries(outputs).map(([name, ref]) => [name, ref?.[2]]), + ), + decideStep, + } +} + +/** The selector outputs a job's `if:` reads. */ +export function selectorOutputsRead(job) { + return [...new Set(selectorOutputRefs(job?.if))] +} + +/** + * Runs the selector's decision script the way GitHub would: `answers` stands + * in for the filter step's outputs, keyed by filter name. An env entry that + * names no answer gets the empty string, as GitHub substitutes for an + * expression that resolves to nothing. + * + * Returns the exit status, the log, and the selector job's outputs. + */ +export function runSelectorDecision(relPath, eventName, answers) { + const selector = readSelectorJob(relPath) + const scratch = mkdtempSync(join(tmpdir(), 'selector-')) + try { + const outputFile = join(scratch, 'outputs.txt') + writeFileSync(outputFile, '') + const env = { PATH: process.env.PATH, GITHUB_OUTPUT: outputFile } + for (const [key, value] of Object.entries( + selector?.decideStep?.env ?? {}, + )) { + const text = String(value) + if (/github\.event_name/.test(text)) env[key] = eventName + else env[key] = answers[stepOutputRef(text)?.[2]] ?? '' + } + const run = spawnSync( + 'bash', + ['-c', String(selector?.decideStep?.run ?? 'exit 99')], + { cwd: scratch, env, encoding: 'utf8' }, + ) + const written = Object.fromEntries( + readFileSync(outputFile, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => [ + line.slice(0, line.indexOf('=')), + line.slice(line.indexOf('=') + 1), + ]), + ) + return { + status: run.status, + log: `${run.stdout}${run.stderr}`, + outputs: Object.fromEntries( + Object.entries(selector?.outputs ?? {}).map(([name, stepOutput]) => [ + name, + written[stepOutput], + ]), + ), + } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +/** What each filter answers for a pull request that changes `files`. */ +export function filterAnswers(relPath, files) { + const selector = readSelectorJob(relPath) + return Object.fromEntries( + Object.entries(selector?.filters ?? {}).map(([name, rules]) => [ + name, + String( + files.some((file) => + dornyFilterMatches(rules, selector.quantifier, file), + ), + ), + ]), + ) +} + +const needsOf = (job) => [job?.needs ?? []].flat() + +/** + * The jobs that run for a same-repository pull request changing `files`, + * sorted. The selector job's outputs come from its own script; each job's + * `if:` is evaluated as GitHub does, including the implicit `success()` that + * skips a job when one it needs did not succeed. + * + * `github` adds to the context, for a job whose `if:` reads more than the + * event and the selector (such as `head_ref`). + */ +export function selectedJobs(relPath, files, { github = {} } = {}) { + const selector = readSelectorJob(relPath) + const decision = runSelectorDecision( + relPath, + 'pull_request', + filterAnswers(relPath, files), + ) + if (decision.status !== 0) { + throw new Error(`The selector failed:\n${decision.log}`) + } + const repository = 'cipherstash/stack' + const context = { + github: { + event_name: 'pull_request', + repository, + head_ref: 'feature', + event: { + pull_request: { + draft: false, + head: { repo: { full_name: repository } }, + user: { type: 'User' }, + }, + }, + ...github, + }, + needs: {}, + vars: {}, + } + const jobs = selector.workflow.jobs + const results = { + [SELECTOR_JOB]: { result: 'success', outputs: decision.outputs }, + } + const resolve = (id) => { + if (results[id]) return results[id].result + const job = jobs[id] + const needs = needsOf(job) + const needResults = needs.map(resolve) + const condition = String(job?.if ?? '') + const statusFunction = /\b(always|failure|cancelled|success)\(\)/.test( + condition, + ) + let runs + if (!statusFunction && needResults.some((result) => result !== 'success')) { + runs = false + } else if (!condition) { + runs = true + } else { + runs = runsWhen(condition, { + ...context, + needs: Object.fromEntries(needs.map((need) => [need, results[need]])), + }) + } + results[id] = { result: runs ? 'success' : 'skipped', outputs: {} } + return results[id].result + } + return Object.keys(jobs) + .filter((id) => resolve(id) === 'success') + .sort() +} + +// GitHub reads a `paths:` list in order, and the last matching entry decides. +export function pathsListSelects(paths, file) { + let selected = false + for (const entry of paths) { + if (entry.startsWith('!')) { + if (pathsGlobToRegExp(entry.slice(1)).test(file)) selected = false + } else if (pathsGlobToRegExp(entry).test(file)) { + selected = true + } + } + return selected +} + +/** + * A filter as a GitHub `paths:` list: entries in order, a `!` entry excluding + * what came before it. Lets the guards that check a workflow's `paths:` + * coverage read a selector filter with the same code. + * + * Under `every`, the one positive rule's brace alternatives come first and the + * `!` rules after, which selects exactly the files dorny selects. Under + * `some`, a filter has no `!` rule (`workflow-selectors.test.mjs`), so it is + * already that list. + */ +export function filterAsPathsList(rules, quantifier) { + if (quantifier !== 'every') return rules.flatMap(expandBraces) + const positive = rules.filter((rule) => !rule.startsWith('!')) + const negative = rules.filter((rule) => rule.startsWith('!')) + return [...positive.flatMap(expandBraces), ...negative] +} + +/** + * The path lists that decide whether a job runs on a change, as GitHub + * `paths:` lists: the workflow's `on.push.paths` and `on.pull_request.paths` + * where they exist, and each selector filter the job's `if:` reads. + * + * A job in a selector workflow that reads no filter runs on every pull + * request, so only the `push` list, if any, decides it. + */ +export function jobTriggerLists(relPath, jobId) { + const workflow = readWorkflow(relPath) + const on = workflow?.on ?? workflow?.[true] + const lists = ['push', 'pull_request'] + .filter((event) => Array.isArray(on?.[event]?.paths)) + .map((event) => ({ + source: `on.${event}.paths`, + paths: on[event].paths.map(String), + })) + const selector = readSelectorJob(relPath) + for (const name of selectorOutputsRead(workflow?.jobs?.[jobId])) { + const rules = selector?.filters?.[name] + if (!rules) continue + lists.push({ + source: `selector filter "${name}"`, + paths: filterAsPathsList(rules, selector.quantifier), + }) + } + return lists +} + +/** + * Runs the summary job's check with the given results of the jobs it needs, + * and returns its exit status. The results stand in for + * `join(needs.*.result, ' ')`, wherever the step reads it. + */ +export function runSummaryCheck(relPath, summaryId, results) { + const workflow = readWorkflow(relPath) + const steps = workflow?.jobs?.[summaryId]?.steps ?? [] + const joined = results.join(' ') + const resultsExpression = /\$\{\{\s*join\(needs\.\*\.result,\s*' '\)\s*\}\}/g + let status = 0 + for (const step of steps) { + if (typeof step?.run !== 'string') continue + const env = { PATH: process.env.PATH } + for (const [key, value] of Object.entries(step.env ?? {})) { + env[key] = String(value).replace(resultsExpression, joined) + } + const run = spawnSync( + 'bash', + ['-c', step.run.replace(resultsExpression, joined)], + { env, encoding: 'utf8' }, + ) + status = run.status + if (status !== 0) break + } + return status +} diff --git a/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs b/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs index 28d96ad94..8f28725ad 100644 --- a/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs +++ b/scripts/__tests__/wasm-build-inputs-paths-filter.test.mjs @@ -4,6 +4,7 @@ import yaml from 'js-yaml' import { describe, expect, it } from 'vitest' import { filterCovers } from './lib/paths-filter.mjs' import { REPO_ROOT } from './lib/repo-root.mjs' +import { jobTriggerLists } from './lib/selector.mjs' import { readWorkflow, workflowFiles } from './lib/workflows.mjs' /** @@ -57,9 +58,6 @@ const ACTION = '.github/actions/build-ffi-binding/action.yml' const ACTION_USES = './.github/actions/build-ffi-binding' const CACHE_ACTION = /^actions\/cache(\/(restore|save))?@/ -/** Both trigger events that carry a `paths:` filter, in GitHub's own order. */ -const FILTERED_EVENTS = ['push', 'pull_request'] - /** The package whose Rust both cache keys are about. */ const FFI_PACKAGE = 'languages/typescript/packages/protect-ffi' const FFI_PACKAGE_NAME = '@cipherstash/protect-ffi' @@ -276,21 +274,34 @@ function stepDir(wf, job, step) { /** Every job step of a workflow, paired with the job that owns it. */ function steps(wf) { - return Object.values(wf?.jobs ?? {}).flatMap((job) => - (Array.isArray(job?.steps) ? job.steps : []).map((step) => ({ job, step })), + return Object.entries(wf?.jobs ?? {}).flatMap(([jobId, job]) => + (Array.isArray(job?.steps) ? job.steps : []).map((step) => ({ + jobId, + job, + step, + })), ) } -/** Runs `./.github/actions/build-ffi-binding`, optionally with `wasm: 'true'`. */ -function usesAction(relPath, { wasmOnly = false } = {}) { - return steps(readWorkflow(relPath)).some( - ({ step }) => - typeof step?.uses === 'string' && - step.uses.trim() === ACTION_USES && - (!wasmOnly || String(step?.with?.wasm) === 'true'), +const jobIdsOf = (entries) => [...new Set(entries.map(({ jobId }) => jobId))] + +/** + * The jobs that run `./.github/actions/build-ffi-binding`, optionally with + * `wasm: 'true'`. + */ +function actionJobs(relPath, { wasmOnly = false } = {}) { + return jobIdsOf( + steps(readWorkflow(relPath)).filter( + ({ step }) => + typeof step?.uses === 'string' && + step.uses.trim() === ACTION_USES && + (!wasmOnly || String(step?.with?.wasm) === 'true'), + ), ) } +const usesAction = (relPath, options) => actionJobs(relPath, options).length > 0 + /** * Compiles this package's Rust — through the action, or directly. * @@ -300,10 +311,9 @@ function usesAction(relPath, { wasmOnly = false } = {}) { * EQL workspace has its own, with its own workflows — is not mistaken for this * one. */ -function compilesFfiRust(relPath) { - if (usesAction(relPath)) return true +function ffiRustJobs(relPath) { const wf = readWorkflow(relPath) - return steps(wf).some(({ job, step }) => { + const direct = steps(wf).filter(({ job, step }) => { const run = String(step?.run ?? '') if (!run) return false const targetsPackage = @@ -316,24 +326,28 @@ function compilesFfiRust(relPath) { (inPackage && mentionsAny(run, CARGO_TASKS)) ) }) + return [...new Set([...actionJobs(relPath), ...jobIdsOf(direct)])] } +const compilesFfiRust = (relPath) => ffiRustJobs(relPath).length > 0 + /** - * The `paths:` filters a workflow declares, one entry per filtered event. + * The path lists that decide whether the given jobs run: the workflow's `push` + * and `pull_request` `paths:` lists, and the selector filter each job reads. + * One entry per list, labelled with where it comes from. * - * `on:` parses as the boolean `true` under YAML 1.1 (the "Norway problem"), - * hence `wf.on ?? wf[true]`. An event with no `paths:` is UNFILTERED and is - * skipped rather than failed — it already runs on every diff, so it cannot miss - * one. `tests.yml` and `tests-rust.yml` are both half-cases: `push` is - * `branches: [main]` with no filter, so only their `pull_request` lists are - * here. `tests.yml`'s is `'**'` with one negation, which `filterCovers` reads. + * A job that no list decides is skipped rather than failed: it runs on every + * diff, so it cannot miss one. A selector filter is read as a `paths:` list, + * so a `'**'` with negations is read the same way in either. */ -function filteredEvents(relPath) { - const wf = readWorkflow(relPath) - const on = wf?.on ?? wf?.[true] - return FILTERED_EVENTS.filter((event) => - Array.isArray(on?.[event]?.paths), - ).map((event) => ({ relPath, event, paths: on[event].paths })) +function filteredEvents(relPath, jobIds) { + const lists = new Map() + for (const jobId of jobIds) { + for (const { source, paths } of jobTriggerLists(relPath, jobId)) { + lists.set(source, { relPath, event: source, paths }) + } + } + return [...lists.values()] } const ALL_WORKFLOWS = workflowFiles() @@ -381,6 +395,7 @@ const KEYS = [ what: '`index.node`, the binding every credentialed suite encrypts through', reaches: 'compiles the crate graph behind `index.node`', workflows: ALL_WORKFLOWS.filter(compilesFfiRust), + jobs: ffiRustJobs, expected: [ '.github/workflows/_build-ffi-artifacts.yml', '.github/workflows/integration-drizzle.yml', @@ -401,6 +416,7 @@ const KEYS = [ workflows: ALL_WORKFLOWS.filter((relPath) => usesAction(relPath, { wasmOnly: true }), ), + jobs: (relPath) => actionJobs(relPath, { wasmOnly: true }), expected: [ '.github/workflows/integration-drizzle.yml', '.github/workflows/integration-protect-ffi.yml', @@ -447,7 +463,9 @@ describe('the FFI cache keys and the filters that trigger them agree', () => { for (const key of KEYS) { const inputs = buildInputs(key.step) - const filtered = key.workflows.flatMap(filteredEvents) + const filtered = key.workflows.flatMap((relPath) => + filteredEvents(relPath, key.jobs(relPath)), + ) describe(`the ${key.id} key`, () => { it('hashes build inputs that name real files', () => { @@ -506,7 +524,7 @@ describe('the FFI cache keys and the filters that trigger them agree', () => { ) expect( uncovered, - `These files are hashed into the ${key.id} cache key of \`${ACTION}\`, so an edit to one MISSES the cache and rebuilds ${key.what}. ${relPath} ${key.reaches}, but its \`${event}\` \`paths:\` filter does not list them, so a PR touching only one of them rebuilds the artifact and starts no suite that exercises it. Nothing goes red; nothing ran.\nAdd each to BOTH copies of the filter — GitHub Actions has no YAML anchors, and \`workflow-paths-filter-parity.test.mjs\` fails a one-sided edit.`, + `These files are hashed into the ${key.id} cache key of \`${ACTION}\`, so an edit to one MISSES the cache and rebuilds ${key.what}. ${relPath} ${key.reaches}, but its \`${event}\` \`paths:\` filter does not list them, so a PR touching only one of them rebuilds the artifact and starts no suite that exercises it. Nothing goes red; nothing ran.\nAdd each to every copy of the list — GitHub Actions has no YAML anchors, and \`workflow-paths-filter-parity.test.mjs\` fails a one-sided edit.`, ).toEqual([]) }) } @@ -521,7 +539,7 @@ describe('the FFI cache keys and the filters that trigger them agree', () => { KEYS.flatMap((key) => { const inputs = buildInputs(key.step) return key.workflows - .flatMap(filteredEvents) + .flatMap((relPath) => filteredEvents(relPath, key.jobs(relPath))) .flatMap(({ relPath, paths }) => inputs .filter((pattern) => !filterCovers(paths, pattern, entryCovers)) diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index e727e71fe..d7a6ea95e 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -180,7 +180,11 @@ function extractForkClause(condition) { * run. */ const PERMISSIVE_NEEDS = { - selector: { outputs: { eql: 'true', rust_crates: 'true' } }, + // Every selector output, whatever its name: each workflow's selector names + // its outputs after its own filters. + selector: { + outputs: new Proxy({}, { has: () => true, get: () => 'true' }), + }, // The release workflows' gates, none of which is about how the run was // triggered. Held open, or "skips because there is nothing to publish" would // be indistinguishable from "skips on a dispatch". diff --git a/scripts/__tests__/workflow-paths-filter-parity.test.mjs b/scripts/__tests__/workflow-paths-filter-parity.test.mjs index 74ab3af62..376cd937f 100644 --- a/scripts/__tests__/workflow-paths-filter-parity.test.mjs +++ b/scripts/__tests__/workflow-paths-filter-parity.test.mjs @@ -1,4 +1,11 @@ +import { execFileSync } from 'node:child_process' import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' +import { + dornyFilterMatches, + pathsListSelects, + readSelectorJob, +} from './lib/selector.mjs' import { readWorkflow, workflowFiles } from './lib/workflows.mjs' /** @@ -57,6 +64,14 @@ function pathsFilters(relPath) { ) } +/** Every tracked file: what a `paths:` list or a filter can select. */ +const TRACKED = execFileSync('git', ['ls-files', '-z'], { + cwd: REPO_ROOT, + encoding: 'utf8', +}) + .split('\0') + .filter(Boolean) + const CLASSIFIED = workflowFiles().map((relPath) => { const filters = pathsFilters(relPath) const declared = FILTERED_EVENTS.filter((event) => filters[event] !== null) @@ -66,8 +81,23 @@ const CLASSIFIED = workflowFiles().map((relPath) => { /** Workflows writing the list twice — the ones the parity check applies to. */ const PARITY = CLASSIFIED.filter((entry) => entry.declared.length === 2) +/** + * Workflows whose selector job decides what a pull request runs, and whose + * `push` keeps a `paths:` list. Their `pull_request` has no filter, by design + * (ADR 0003), so the second copy of the list is the selector's filters. The + * check below compares the two by what they select. + */ +const SELECTOR_PUSH = CLASSIFIED.filter( + (entry) => + entry.filters.push !== null && + entry.filters.pull_request === null && + readSelectorJob(entry.relPath) !== null, +) + /** Workflows filtering one event and not the other. */ -const ASYMMETRIC = CLASSIFIED.filter((entry) => entry.declared.length === 1) +const ASYMMETRIC = CLASSIFIED.filter( + (entry) => entry.declared.length === 1 && !SELECTOR_PUSH.includes(entry), +) /** * The guard on the scan, in the shape the rest of this directory uses. Every @@ -81,12 +111,6 @@ const ASYMMETRIC = CLASSIFIED.filter((entry) => entry.declared.length === 1) */ const EXPECTED_PARITY_WORKFLOWS = [ '.github/workflows/fta-v3.yml', - '.github/workflows/integration-drizzle.yml', - '.github/workflows/integration-prisma-next.yml', - '.github/workflows/integration-protect-ffi.yml', - '.github/workflows/integration-supabase.yml', - '.github/workflows/prisma-example-readme-e2e.yml', - '.github/workflows/prisma-next-e2e.yml', '.github/workflows/tests-bench.yml', '.github/workflows/tests-supply-chain.yml', ] @@ -145,24 +169,6 @@ const EXPECTED_ASYMMETRIES = new Map([ // check-auth-npm-changeset.test.mjs, which is the second copy here. 'pull_request is the only trigger; the check diffs a pull request against its base, which a push does not have', ], - [ - '.github/workflows/test-eql.yml', - // `pull_request` is deliberately UNFILTERED and `push: branches: [main]` - // carries the list, which is the same shape as tests-rust.yml with the two - // events swapped. Unfiltered on the PR side because this workflow's - // `ci-required` is the check branch protection is meant to reference, and a - // workflow skipped by a path filter leaves a required check stuck Pending; - // relevance is applied per-job via `if:` instead. Filtered on the push side - // because nothing waits on a push run, so skipping one costs nothing. - // - // The direction that would hurt — a `pull_request` filter narrower than the - // `push` one, so the PR introducing a change never runs the suite — is - // unreachable when `pull_request` has no filter at all. The push list is - // not unchecked: `eql-workflow-filters.test.mjs` compares it against the - // union of the selector job's filters, which is the second copy of the - // same list and the one this file cannot see. - 'pull_request is deliberately unfiltered (per-job relevance instead), so PRs run a superset of what push runs; the push list is compared against the selector job filters by eql-workflow-filters.test.mjs', - ], [ '.github/workflows/bench-eql.yml', // The mirror image of lint-release.yml: `push: branches: [main]` with a @@ -235,6 +241,27 @@ describe('paths filters are written twice, identically', () => { ).toEqual([]) }) + for (const { relPath, filters } of SELECTOR_PUSH) { + it(`${relPath} selects on push what its selector selects on a pull request`, () => { + // A selector workflow writes its list twice as well: as the `push` + // `paths:` list, and split across the selector job's filters. The two + // are written in different syntaxes, so they are compared by what they + // select from the tracked files, not by their text. + const selector = readSelectorJob(relPath) + const differ = TRACKED.filter( + (file) => + pathsListSelects(filters.push, file) !== + Object.values(selector.filters).some((rules) => + dornyFilterMatches(rules, selector.quantifier, file), + ), + ) + expect( + differ, + `In ${relPath}, the \`push\` \`paths:\` list and the union of the selector job's filters disagree about these files. Change both in the same edit.`, + ).toEqual([]) + }) + } + for (const { relPath } of PARITY) { it(`${relPath} filters push and pull_request identically`, () => { const filters = pathsFilters(relPath) diff --git a/scripts/__tests__/workflow-selectors.test.mjs b/scripts/__tests__/workflow-selectors.test.mjs new file mode 100644 index 000000000..341b95f8d --- /dev/null +++ b/scripts/__tests__/workflow-selectors.test.mjs @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import { runsWhen } from './lib/expressions.mjs' +import { + NON_PULL_REQUEST_EVENTS, + readSelectorJob, + runSelectorDecision, + runSummaryCheck, + SELECTOR_JOB, + selectorOutputsRead, +} from './lib/selector.mjs' +import { workflowFiles } from './lib/workflows.mjs' + +/** + * Every workflow with a selector job follows the same design (ADR 0003): the + * selector job decides which checks a pull request runs, each gated job reads + * the selector's answer in its `if:`, and a summary job ends the workflow. + * `.github/CONTEXT.md` defines the terms. + * + * The workflows are discovered, not listed, so a workflow that gains a + * selector job is held to this the day it lands. What each selector decides + * for particular changes is pinned per workflow, in the `*-selector*.test.mjs` + * files. + * + * Whether every job is decided by the selector, or has a written reason to run + * on every change, is the failsafe's question, not this file's. + */ + +const SELECTOR_WORKFLOWS = workflowFiles().filter( + (relPath) => readSelectorJob(relPath) !== null, +) + +/** + * The guard on the scan. A minimum: a workflow that gains a selector job must + * not fail this. Each workflow's own `*-selector*.test.mjs` file checks that + * its selector job exists. + */ +const EXPECTED_SELECTOR_WORKFLOWS = ['.github/workflows/test-eql.yml'] + +const triggersOf = (workflow) => workflow?.on ?? workflow?.[true] + +const needsOf = (job) => [job?.needs ?? []].flat() + +/** The jobs whose `if:` reads a selector output. */ +function gatedJobs(workflow) { + return Object.entries(workflow?.jobs ?? {}) + .map(([id, job]) => ({ id, job, reads: selectorOutputsRead(job) })) + .filter((entry) => entry.reads.length > 0) +} + +describe('the workflows with a selector job', () => { + it('include the converted workflows', () => { + expect(SELECTOR_WORKFLOWS).toEqual( + expect.arrayContaining(EXPECTED_SELECTOR_WORKFLOWS), + ) + }) +}) + +for (const relPath of SELECTOR_WORKFLOWS) { + const selector = readSelectorJob(relPath) + const { workflow } = selector + const jobIds = Object.keys(workflow.jobs) + const summaryId = jobIds.at(-1) + const filterNames = Object.keys(selector.filters) + const outputNames = Object.keys(selector.outputs) + + describe(relPath, () => { + it('has no path filter on pull_request', () => { + // A workflow that a path filter skips leaves its required checks + // pending, and GitHub's documentation on required status checks says + // that blocks a merge. The selector job decides instead. + const pullRequest = triggersOf(workflow)?.pull_request ?? {} + expect(Object.keys(pullRequest)).not.toContain('paths') + expect(Object.keys(pullRequest)).not.toContain('paths-ignore') + }) + + it('starts with the selector job, on a GitHub-hosted runner', () => { + // ADR 0003: every gated job waits for it, and a self-provisioned + // machine takes longer to start. + expect(jobIds[0]).toBe(SELECTOR_JOB) + expect(String(selector.job['runs-on'])).toMatch(/^ubuntu-/) + expect(selector.job.needs).toBeUndefined() + expect(selector.job.if).toBeUndefined() + }) + + it('has one path filter step, run only on a pull request', () => { + expect(selector.filterSteps).toHaveLength(1) + expect(String(selector.filterStep?.if)).toBe( + "github.event_name == 'pull_request'", + ) + expect(filterNames).not.toEqual([]) + }) + + it('writes no `!` rule that dorny would read as matching everything', () => { + // Under dorny's default quantifier, `some`, a file counts when ANY rule + // matches, so `!**/*.md` matches every file that is not Markdown. A `!` + // rule only excludes under `every`, where a file must match every rule, + // and there a second positive rule would select nothing at all. + expect(['some', 'every']).toContain(selector.quantifier) + for (const [name, rules] of Object.entries(selector.filters)) { + const positive = rules.filter((rule) => !rule.startsWith('!')) + const negative = rules.filter((rule) => rule.startsWith('!')) + if (selector.quantifier === 'every') { + expect(positive, `filter "${name}"`).toHaveLength(1) + } else { + expect(negative, `filter "${name}"`).toEqual([]) + } + } + }) + + it('names each output after the filter that decides it', () => { + // The guards follow a job's `if:` to its filter by this name. + expect(outputNames.sort()).toEqual([...filterNames].sort()) + for (const [name, stepOutput] of Object.entries(selector.outputs)) { + expect(stepOutput, `output "${name}"`).toBe(name) + } + expect( + selector.decideStep, + 'No single step writes the selector outputs.', + ).toBeTruthy() + }) + + it('selects every job on events other than pull_request', () => { + for (const event of NON_PULL_REQUEST_EVENTS) { + const { status, log, outputs } = runSelectorDecision(relPath, event, {}) + expect(status, log).toBe(0) + for (const name of outputNames) { + expect(outputs[name], `${name} on ${event}`).toBe('true') + } + } + }) + + it('passes the filter answers through on a pull request', () => { + for (const answer of ['true', 'false']) { + const answers = Object.fromEntries( + filterNames.map((name) => [name, answer]), + ) + const { status, log, outputs } = runSelectorDecision( + relPath, + 'pull_request', + answers, + ) + expect(status, log).toBe(0) + expect(outputs).toEqual(answers) + } + }) + + it('fails when a pull request filter gives no answer', () => { + // A missing answer would skip every gated job, and the summary job + // counts skipped as passed: a green run that ran nothing. + for (const name of filterNames) { + for (const bad of ['', 'maybe']) { + const answers = Object.fromEntries( + filterNames.map((other) => [other, other === name ? bad : 'true']), + ) + const { status } = runSelectorDecision( + relPath, + 'pull_request', + answers, + ) + expect(status, `filter "${name}" answering '${bad}'`).not.toBe(0) + } + } + }) + + it('gates jobs only on outputs the selector writes, and needs it', () => { + const gated = gatedJobs(workflow) + expect(gated).not.toEqual([]) + for (const { id, job, reads } of gated) { + expect(needsOf(job), `job "${id}"`).toContain(SELECTOR_JOB) + for (const name of reads) { + expect(outputNames, `job "${id}" reads "${name}"`).toContain(name) + } + } + }) + + it("writes each gated job's `if:` as an exclusion of pull_request", () => { + // Outside a pull request the selector's answers must not matter: a job + // written as a list of allowed events skips on the event nobody listed. + for (const { id, job } of gatedJobs(workflow)) { + for (const event of NON_PULL_REQUEST_EVENTS) { + const verdict = (answer) => + runsWhen(job.if, { + github: { + event_name: event, + repository: 'cipherstash/stack', + event: {}, + }, + needs: { + [SELECTOR_JOB]: { + result: 'success', + outputs: Object.fromEntries( + outputNames.map((name) => [name, answer]), + ), + }, + }, + vars: {}, + }) + expect(verdict('false'), `job "${id}" on ${event}`).toBe( + verdict('true'), + ) + } + } + }) + + it('ends with a summary job that needs every other job', () => { + const summary = workflow.jobs[summaryId] + expect(String(summary.if)).toBe('always()') + expect([...needsOf(summary)].sort()).toEqual( + jobIds.filter((id) => id !== summaryId).sort(), + ) + }) + + it('passes the summary only when every needed job passed or skipped', () => { + const status = (results) => runSummaryCheck(relPath, summaryId, results) + expect(status(['success', 'skipped', 'success'])).toBe(0) + expect(status(['success', 'failure'])).not.toBe(0) + expect(status(['skipped', 'cancelled'])).not.toBe(0) + }) + }) +}