| name: CI |
| |
| # Controls when the action will run |
| on: |
| # Triggers the workflow on push or pull request events but only for the main and 2.x branches |
| push: |
| branches: [main, 2.x] |
| pull_request: |
| branches: [main, 2.x] |
| |
| # Allows you to run this workflow manually from the Actions tab |
| workflow_dispatch: |
| |
| concurrency: |
| group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} |
| cancel-in-progress: true |
| |
| jobs: |
| semantic-pull-request: |
| if: github.event_name == 'pull_request' |
| runs-on: ubuntu-latest |
| steps: |
| - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 |
| env: |
| GITHUB_TOKEN: ${{ github.token }} |
| |
| format: |
| runs-on: ubuntu-latest |
| permissions: |
| contents: read |
| id-token: write |
| steps: |
| - uses: actions/checkout@v6 |
| - uses: aspect-build/setup-aspect@c22a8f64fb38f82f59ce809cd7eb9f8ae096da44 # v2026.23.2 |
| with: |
| aspect-api-token: ${{ secrets.ASPECT_API_TOKEN }} |
| - name: Format |
| run: aspect format --task-key=format |
| |
| buildifier: |
| runs-on: ubuntu-latest |
| permissions: |
| contents: read |
| id-token: write |
| steps: |
| - uses: actions/checkout@v6 |
| - uses: aspect-build/setup-aspect@c22a8f64fb38f82f59ce809cd7eb9f8ae096da44 # v2026.23.2 |
| with: |
| aspect-api-token: ${{ secrets.ASPECT_API_TOKEN }} |
| - name: Buildifier |
| run: aspect buildifier --task-key=buildifier |
| |
| # Builds the workspace matrix axis. e2e tests gated on secrets that forks don't have |
| # (ASPECT_GHTESTER_SSH_KEY, ASPECT_NPM_AUTH_TOKEN) are only appended when the secret is |
| # available, so PRs from forks skip them instead of failing. |
| e2e-tests-list: |
| runs-on: ubuntu-latest |
| steps: |
| - id: workspace |
| name: Prepare 'workspace' matrix axis |
| run: | |
| paths=( |
| . |
| e2e/bzlmod |
| e2e/gyp_no_install_script |
| e2e/js_binary_workspace |
| e2e/js_image_oci |
| e2e/js_run_devserver |
| e2e/nextjs |
| e2e/npm_link_package |
| e2e/npm_link_package-rerooted |
| e2e/npm_translate_lock |
| e2e/npm_translate_lock_replace_packages |
| e2e/npm_translate_lock_empty |
| e2e/npm_translate_lock_exclude_package_contents |
| e2e/npm_translate_lock_multi |
| e2e/npm_translate_lock_package_visibility |
| e2e/npm_translate_lock_partial_clone |
| e2e/npm_translate_lock_subdir_patch |
| e2e/npm_translate_lock_disable_hooks |
| e2e/npm_translate_package_lock |
| e2e/npm_translate_yarn_lock |
| e2e/patch_from_repo |
| e2e/pnpm_repo_install |
| e2e/pnpm_workspace |
| e2e/pnpm_workspace_deps |
| e2e/pnpm_workspace_rerooted |
| e2e/protobuf-es |
| e2e/protobuf-google |
| e2e/repo_mapping |
| e2e/output_paths |
| e2e/ts_version_from_rules_ts_3.8.10 |
| e2e/update_pnpm_lock |
| e2e/update_pnpm_lock_with_import |
| e2e/vendored_node |
| e2e/vendored_tarfile |
| e2e/verify_patches |
| e2e/webpack_devserver |
| e2e/webpack_devserver_esm |
| examples |
| ) |
| # e2e/git_dep_metadata and e2e/npm_translate_lock_git+ssh clone over ssh and |
| # require an SSH key not available on forks. e2e/pnpm_lockfiles is in this |
| # bucket too: one of its v110 lockfile entries (jquery-git-https-763ade6) |
| # resolves to a `type: git` repo at git@github.com:jquery/jquery.git, so |
| # rules_js does a real SSH clone. |
| if [[ "${{ env.ASPECT_GHTESTER_SSH_KEY }}" ]]; then |
| paths+=( |
| e2e/git_dep_metadata |
| e2e/npm_translate_lock_git+ssh |
| e2e/pnpm_lockfiles |
| ) |
| fi |
| # e2e/npm_translate_lock_auth requires an npm auth token not available on forks. |
| if [[ "${{ env.ASPECT_NPM_AUTH_TOKEN }}" ]]; then |
| paths+=( e2e/npm_translate_lock_auth ) |
| fi |
| |
| # Slug feeds aspect --task-key, the disk-cache key, and the job name, all |
| # of which require [A-Za-z0-9_-]. Map "." → "root"; replace "/" with "-" |
| # and "+" and "." with "_" so e.g. e2e/npm_translate_lock_git+ssh becomes |
| # a valid e2e-npm_translate_lock_git_ssh slug. |
| entries=() |
| for p in "${paths[@]}"; do |
| if [[ "$p" == "." ]]; then |
| slug=root |
| else |
| slug=${p//\//-} |
| slug=${slug//+/_} |
| slug=${slug//./_} |
| fi |
| entries+=( "{\"path\":\"$p\",\"slug\":\"$slug\"}" ) |
| done |
| printf -v j '%s,' "${entries[@]}" |
| echo "res=[${j%,}]" | tee -a $GITHUB_OUTPUT |
| env: |
| ASPECT_GHTESTER_SSH_KEY: ${{ secrets.ASPECT_GHTESTER_SSH_KEY }} |
| ASPECT_NPM_AUTH_TOKEN: ${{ secrets.ASPECT_NPM_AUTH_TOKEN }} |
| outputs: |
| workspace: ${{ steps.workspace.outputs.res }} |
| |
| test: |
| name: test (${{ matrix.workspace.path }}, ${{ matrix.bazel.id }}) |
| runs-on: ubuntu-latest |
| needs: e2e-tests-list |
| permissions: |
| contents: read |
| id-token: write |
| strategy: |
| fail-fast: false |
| matrix: |
| workspace: ${{ fromJSON(needs.e2e-tests-list.outputs.workspace) }} |
| bazel: |
| - { |
| id: 'bazel-7', |
| version: '7.x', |
| flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel7', |
| } |
| - { |
| id: 'bazel-8', |
| version: '8.x', |
| flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel8', |
| } |
| - { |
| id: 'bazel-9', |
| version: '9.x', |
| flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel9', |
| } |
| - { |
| id: 'bazel-9-no-execroot-entry-point', |
| version: '9.x', |
| flags: '--bazel-flag=--test_tag_filters=-skip-on-bazel9 --bazel-flag=--@aspect_rules_js//js:use_execroot_entry_point=False', |
| } |
| exclude: |
| # e2e/js_image_oci pulls in the `llvm` module, whose hermetic toolchain requires Bazel 8+. |
| - { |
| workspace: { slug: 'e2e-js_image_oci' }, |
| bazel: { id: 'bazel-7' }, |
| } |
| # e2e/patch_from_repo has a bazel7-specific absolute label in its MODULE.bazel. |
| - { |
| workspace: { slug: 'e2e-patch_from_repo' }, |
| bazel: { id: 'bazel-8' }, |
| } |
| - { |
| workspace: { slug: 'e2e-patch_from_repo' }, |
| bazel: { id: 'bazel-9' }, |
| } |
| - { |
| workspace: { slug: 'e2e-patch_from_repo' }, |
| bazel: { id: 'bazel-9-no-execroot-entry-point' }, |
| } |
| # e2e/repo_mapping renames aspect_rules_js, so a flag beginning with |
| # --@aspect_rules_js// (as used by the no-execroot-entry-point variant) doesn't resolve. |
| - { |
| workspace: { slug: 'e2e-repo_mapping' }, |
| bazel: { id: 'bazel-9-no-execroot-entry-point' }, |
| } |
| env: |
| USE_BAZEL_VERSION: ${{ matrix.bazel.version }} |
| ASPECT_GH_PACKAGES_AUTH_TOKEN: ${{ secrets.ASPECT_GH_PACKAGES_AUTH_TOKEN }} |
| ASPECT_NPM_AUTH_TOKEN: ${{ secrets.ASPECT_NPM_AUTH_TOKEN }} |
| steps: |
| - uses: actions/checkout@v6 |
| # Setup an ssh keypair for e2e tests that clone a git repository via ssh. |
| - uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 |
| if: matrix.workspace.slug == 'e2e-git_dep_metadata' || matrix.workspace.slug == 'e2e-npm_translate_lock_git_ssh' || matrix.workspace.slug == 'e2e-pnpm_lockfiles' |
| with: |
| ssh-private-key: ${{ secrets.ASPECT_GHTESTER_SSH_KEY }} |
| - uses: aspect-build/setup-aspect@c22a8f64fb38f82f59ce809cd7eb9f8ae096da44 # v2026.23.2 |
| with: |
| aspect-api-token: ${{ secrets.ASPECT_API_TOKEN }} |
| - name: Test |
| working-directory: ${{ matrix.workspace.path }} |
| run: aspect test --task-key=test-${{ matrix.workspace.slug }}-${{ matrix.bazel.id }} ${{ matrix.bazel.flags }} -- //... |
| |
| # The root workspace lockfile contains an intentionally-unused npm package |
| # (npm/private/test/package.json) to assert that rules_js fetches packages |
| # lazily. Its generated __links repo is expected; the package content repo |
| # must never be fetched. |
| - name: Check that unused npm packages were not fetched |
| if: matrix.workspace.path == '.' |
| # bazel rather than aspect: the aspect CLI task runner has no `info` subcommand. |
| # bazelisk resolves the same USE_BAZEL_VERSION and the same output base. |
| run: ls $(bazel info output_base)/external | grep -v __links | grep -vz unused |
| |
| # Smoke test that coverage collection works, once per CI run. Scoped to the |
| # coverage tests (js/private/test/coverage), whose custom lcov mergers assert |
| # coverage behavior but only exercise it when run in coverage mode. Not //...: |
| # instrumentation changes launcher env/output, breaking golden-output tests |
| # (js_binary_sh, fixed_args, ...) that were never run instrumented. |
| - name: Coverage |
| if: matrix.workspace.path == '.' && matrix.bazel.id == 'bazel-7' |
| run: aspect test --task-key=coverage-${{ matrix.workspace.slug }}-${{ matrix.bazel.id }} ${{ matrix.bazel.flags }} --bazel-flag=--collect_code_coverage --bazel-flag=--instrument_test_targets -- //js/private/test/coverage/... |
| |
| # Skipped on the no-execroot-entry-point variant: test.sh scripts invoke plain |
| # `bazel` without matrix.bazel.flags, so that leg wouldn't exercise the flag — |
| # it would only duplicate the bazel-9 run. |
| - name: Optional ./test.sh |
| if: matrix.bazel.id != 'bazel-9-no-execroot-entry-point' |
| working-directory: ${{ matrix.workspace.path }} |
| env: |
| ASPECT_RULES_JS_FROZEN_PNPM_LOCK: 1 |
| run: | |
| if [[ -f ./test.sh ]]; then |
| ./test.sh |
| else |
| echo "No test.sh in ${PWD}; skipping" |
| fi |
| |
| # Mac/Windows smoke tests - only e2e/bzlmod |
| # Only run on main branch (not PRs) to minimize minutes (billed at 10X and 2X respectively) |
| # https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#included-storage-and-minutes |
| smoke: |
| if: github.ref_name == 'main' || contains(github.head_ref, 'macos') || contains(github.head_ref, 'windows') |
| runs-on: ${{ matrix.os }} |
| defaults: |
| run: |
| working-directory: e2e/bzlmod |
| strategy: |
| fail-fast: false |
| matrix: |
| os: [macos-latest, windows-latest] |
| env: |
| ASPECT_RULES_JS_FROZEN_PNPM_LOCK: 1 |
| steps: |
| - uses: actions/checkout@v6 |
| |
| - uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 |
| with: |
| bazelisk-cache: true |
| disk-cache: ${{ matrix.os }}-bzlmod |
| repository-cache: true |
| |
| - name: bazel test //... |
| shell: bash |
| run: | |
| bazel test \ |
| --test_tag_filters=-skip-on-bazel7 \ |
| --build_tag_filters=-skip-on-bazel7 \ |
| //... |
| |
| # For branch protection settings, this job provides a "stable" name that can be used to gate PR merges |
| # on "all matrix jobs were successful". |
| conclusion: |
| needs: [format, buildifier, test, smoke] |
| runs-on: ubuntu-latest |
| if: always() |
| steps: |
| - run: | |
| if [[ "${{ needs.format.result }}" == "success" \ |
| && "${{ needs.buildifier.result }}" == "success" \ |
| && "${{ needs.test.result }}" == "success" \ |
| && ("${{ needs.smoke.result }}" == "success" || "${{ needs.smoke.result }}" == "skipped") ]]; then |
| exit 0 |
| else |
| exit 1 |
| fi |