Skip to content
Back to skills

Bazel Github Actions Ci

BSecurity

Use when setting up or reviewing Bazel CI on GitHub Actions. Covers

  • 8 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 20, 2026
developmentpythongojavakotlingitsecurity

Security analysis

B85/100
  • highPerforms destructive filesystem operations

Pro shows the line behind each finding and how to fix it

Scanned September 20, 2026

npx -y skills add tstapler/dotfiles --skill bazel-github-actions-ci --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Bazel Github Actions Ci?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Bazel Github Actions Ci
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/tstapler-bazel-github-actions-ci/badge)](https://www.skillsdirectory.com/skills/tstapler-bazel-github-actions-ci)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: bazel-github-actions-ci
description: Use when setting up or reviewing Bazel CI on GitHub Actions. Covers
  setup-bazel caching strategy, per-job disk-cache isolation, Compose Desktop X11
  support, KMP test aggregation, and security hardening. Based on SteleKit migration.
---

# Bazel CI on GitHub Actions

Battle-tested patterns from migrating a Kotlin Multiplatform project to Bazel CI.
Replaces the outdated `bazelbuild/setup-bazelisk + actions/cache` approach.

---

## Core: use `bazel-contrib/setup-bazel` — not manual caching

`setup-bazel@0.19.0` replaces both `bazelbuild/setup-bazelisk` and manual
`actions/cache` for Bazel caches. It manages three orthogonal caches:

| Input | What it caches | Notes |
|---|---|---|
| `disk-cache: <name>` | Compiled build outputs (action result cache) | Isolate per job |
| `repository-cache: true` | Downloaded Maven/artifact files (CAS store) | Share across jobs |
| `external-cache: true` | Processed external repos (Compose Desktop, AndroidX) | Share across jobs |
| `cache-save: <bool>` | Whether to write the cache back | PRs read, pushes write |

```yaml
- name: Set up Bazel
  uses: bazel-contrib/setup-bazel@0.19.0
  with:
    bazelisk-version: "1.x"
    disk-cache: jvm              # unique per job — prevents cross-job collisions
    repository-cache: true       # shared across all jobs (keyed by MODULE.bazel)
    external-cache: true         # Compose Desktop etc. are large; cache separately
    cache-save: ${{ github.event_name != 'pull_request' }}
```

**Why `cache-save: ${{ github.event_name != 'pull_request' }}`**: PRs read the cache
seeded by the main branch but never write back. This prevents a poisoned PR from
corrupting the shared cache. Pushes to main write a fresh cache for the next PR.

**Why per-job `disk-cache`**: JVM and Android compiled outputs are incompatible.
Sharing a disk cache between jobs that build different targets causes cache misses and
occasional corruption. Use distinct names: `jvm`, `android`, `android-tests`, `web`.

**`repository-cache` and `external-cache` are complementary, not redundant**:
- `repository-cache` = `~/.cache/bazel/cache/repos` — raw downloaded files (content-addressed)
- `external-cache` = processed external repo directories (e.g. `external/rules_jvm_external~maven/`)
Both are useful; enabling both is correct.

---

## Security baseline

Always add `permissions: contents: read` at the workflow level. Without it, the default
`GITHUB_TOKEN` has write access to contents, packages, and deployments — far more than
a test workflow needs.

```yaml
name: Bazel CI

on:
  push:
    branches: [main]
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

permissions:
  contents: read     # minimum required to checkout code

concurrency:
  group: bazel-ci-${{ github.ref }}
  cancel-in-progress: true
```

---

## Skipping draft PRs

```yaml
jobs:
  bazel-jvm:
    if: github.event.pull_request.draft == false
```

**This is safe for both `push` and `pull_request` events.** On `push` events,
`github.event.pull_request` is `null`, so `github.event.pull_request.draft` is `null`.
GitHub Actions coerces `null` to `0` and `false` to `0` for `==`, so `null == false`
evaluates to `true` — the job runs. No special handling needed for push vs PR.

---

## JDK: JVM jobs don't need `setup-java`

Bazel downloads its own JDK toolchain via `--java_runtime_version=21` (from
`rules_java`). JVM/desktop jobs do **not** need `actions/setup-java`.

Android jobs **do** need `setup-java` because the Android SDK host tools (aapt2,
d8, etc.) run outside Bazel's hermetic environment and require a system JDK on `PATH`.

```yaml
# JVM job — NO setup-java needed (Bazel downloads the JDK itself)
bazel-jvm:
  steps:
    - uses: actions/checkout@v4
    - uses: bazel-contrib/setup-bazel@0.19.0
      with: { disk-cache: jvm, ... }
    - run: bazel test //kmp:jvm_tests

# Android job — setup-java IS needed for host tools
bazel-android:
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with: { java-version: "21", distribution: temurin }
    - uses: android-actions/setup-android@v3
    - uses: bazel-contrib/setup-bazel@0.19.0
      with: { disk-cache: android, ... }
    - run: bazel build //kmp:android_app --config=android
```

---

## Compose Desktop / X11 on Linux CI

Compose Desktop tests open real windows and require an X display. The Bazel sandbox
creates an isolated mount namespace that excludes `/tmp/.X11-unix` by default, so even
with `DISPLAY` set, the X socket is unreachable.

### .bazelrc

```
# Mount the X11 socket directory into every test sandbox (Linux only).
# Locally: uses whatever X server is running.
# CI: xvfb-run creates the socket before Bazel runs.
test --sandbox_add_mount_pair=/tmp/.X11-unix
```

### BUILD.bazel test target

```python
kt_jvm_test(
    name = "jvm_tests",
    ...
    jvm_flags = [
        "-Djava.awt.headless=false",          # Compose Desktop requires headless=false
        "--add-opens=java.base/java.lang=ALL-UNNAMED",
    ],
    env = {
        "LIBGL_ALWAYS_SOFTWARE": "1",         # software OpenGL — no GPU needed on CI
    },
    env_inherit = ["DISPLAY"],                # required with --incompatible_strict_action_env
    tags = ["requires-display"],
)
```

### CI workflow step

```yaml
- name: Install Xvfb
  run: |
    sudo apt-get install -y --no-install-recommends xvfb
    # Create the directory before Bazel validates the sandbox mount source.
    # xvfb-run creates the socket, but Bazel checks the directory at startup.
    mkdir -p /tmp/.X11-unix

- name: Run JVM tests
  run: |
    xvfb-run --auto-servernum bazel test //kmp:jvm_tests \
      --test_output=summary
```

**Why `mkdir -p /tmp/.X11-unix` before the test command**: Bazel validates
`--sandbox_add_mount_pair` mount sources at startup. If the directory doesn't exist,
Bazel refuses to start the test. `xvfb-run` creates the socket inside the directory
but the directory itself must pre-exist.

---

## Test aggregation: `test_suite` not `alias`

`alias()` does not forward the test provider — `bazel test //kmp:jvm_tests` will silently
build without running any tests if `jvm_tests` is an `alias`.

```python
# kmp/BUILD.bazel — CORRECT
test_suite(
    name = "jvm_tests",
    tests = ["//kmp/src/jvmTest/kotlin:jvm_tests"],
)

# WRONG — test provider is not forwarded
alias(
    name = "jvm_tests",
    actual = "//kmp/src/jvmTest/kotlin:jvm_tests",
)
```

---

## JUnit4 Suite aggregator (Bazel's `test_class` requirement)

Bazel's `kt_jvm_test` runner needs a real JUnit4 class as `test_class`. Kotlin test
with `@RunWith(Suite.class)` is the correct aggregator pattern.

```kotlin
@RunWith(Suite::class)
@Suite.SuiteClasses(
    FooTest::class,
    BarTest::class,
    // ... all test classes in the source set
)
class AllJvmTests
```

```python
kt_jvm_test(
    name = "jvm_tests",
    srcs = glob(["**/*.kt"]),
    test_class = "dev.stapler.stelekit.AllJvmTests",
    ...
)
```

Note: if screenshot tests use Roborazzi (no Bazel integration), exclude them:
```python
srcs = glob(
    ["**/*.kt"],
    exclude = ["**/*Screenshot*.kt", "**/screenshots/**"],
),
```

---

## `associates` vs `deps` for `internal` visibility

To access `internal` declarations from another module, use `associates`, not `deps`.
A target listed in `associates` **must not** also appear in `deps` — this is a rules_kotlin
constraint.

```python
kt_jvm_test(
    name = "business_tests",
    associates = ["//kmp/src/commonMain/kotlin:common_main_lib"],  # grants internal access
    deps = [
        # common_main_lib must NOT appear here too
        "@maven//:org_jetbrains_kotlin_kotlin_test_junit",
    ],
)
```

---

## JAR-safe test fixture extraction

Bazel packs `resources` into JARs. `File(url.toURI())` throws on `jar:` URIs.
Use `ClassLoader.getResource()` and extract to a temp dir with atomic rename:

```kotlin
fun getClasspathDirectory(loader: ClassLoader, resourceName: String): File {
    val url = loader.getResource(resourceName) ?: error("$resourceName not found")
    if (url.protocol == "file") return File(url.toURI())    // IDE / file:// path — no extraction needed
    val dest = File(
        System.getenv("TEST_TMPDIR") ?: System.getProperty("java.io.tmpdir"),
        resourceName.replace('/', '_'),
    )
    synchronized(extractionLock) {
        if (!dest.exists()) {
            val tmp = File(dest.parent, dest.name + ".tmp")
            tmp.deleteRecursively(); tmp.mkdirs()
            try {
                val conn = url.openConnection() as JarURLConnection
                conn.jarFile.use { jar ->
                    val prefix = conn.entryName + "/"
                    jar.entries().asSequence()
                        .filter { !it.isDirectory && it.name.startsWith(prefix) }
                        .forEach { entry ->
                            val target = File(tmp, entry.name.removePrefix(prefix))
                            target.parentFile?.mkdirs()
                            jar.getInputStream(entry).use { it.copyTo(target.outputStream()) }
                        }
                }
                tmp.renameTo(dest)              // atomic: dest appears fully-written or not at all
            } catch (e: Exception) {
                tmp.deleteRecursively(); throw e // clean up partial extractions
            }
        }
    }
    return dest
}
private val extractionLock = Any()
```

Use `TEST_TMPDIR` (Bazel's designated temp dir for tests) over `java.io.tmpdir` — Bazel
cleans it between runs. Do **not** use `user.home` — Bazel's sandbox blocks writes there.

---

## Sandbox restrictions: `user.home` writes fail

Inside the Bazel sandbox, the home directory (`$HOME`) is inaccessible for writes.
Replace `System.getProperty("user.home")` with `System.getProperty("java.io.tmpdir")`
or `System.getenv("TEST_TMPDIR")` in any test that creates temporary files.

---

## KMP: patching `rules_kotlin` for `common_srcs`

Kotlin Multiplatform requires `-Xcommon-sources` for expect/actual compilation.
`rules_kotlin` doesn't support this natively — patch it via `single_version_override`:

```starlark
# MODULE.bazel
single_version_override(
    module_name = "rules_kotlin",
    version = "2.3.20",
    patches = ["//third_party/patches:rules_kotlin_kmp.patch"],
    patch_strip = 1,
)
```

The patch adds a `common_srcs` attribute to `kt_jvm_library` and passes
`-Xcommon-sources=$(common_srcs)` to kotlinc. Maintain the patch in
`third_party/patches/rules_kotlin_kmp.patch`. When upgrading rules_kotlin,
re-apply and re-test.

---

## Gradle-delegating genrule (for WASM/JS)

When a Bazel target must delegate to Gradle (e.g., WASM until rules_kotlin#567 lands),
preserve Gradle's incremental cache with a separate `actions/cache` step keyed on
relevant Gradle files plus `${{ github.sha }}` (so each push seeds a fresh cache from
the previous run):

```yaml
- uses: actions/cache@v4
  with:
    path: ~/.gradle
    key: gradle-web-${{ runner.os }}-${{ hashFiles('build.gradle.kts', 'gradle.properties') }}-${{ github.sha }}
    restore-keys: |
      gradle-web-${{ runner.os }}-${{ hashFiles('build.gradle.kts', 'gradle.properties') }}-
      gradle-web-${{ runner.os }}-
```

---

## Artifact uploads: gate on push

CI artifacts on PRs add noise and consume storage quota without benefit. Gate uploads
on `github.event_name == 'push'`:

```yaml
- name: Upload web bundle
  if: github.event_name == 'push'
  uses: actions/upload-artifact@v4
  with:
    name: web-dist
    path: bazel-bin/kmp/web_dist.tar.gz
    retention-days: 7
```

---

## Local disk cache for developers

Document in `.bazelrc.user.example` (not committed):

```
# Persists build results between sessions and workspace switches.
# build --disk_cache=~/.cache/bazel-disk-cache
```

Grows unboundedly — users must manually prune with `bazel clean --expunge` or `rm -rf`.

---

## Anti-patterns

| Wrong | Correct |
|---|---|
| `bazelbuild/setup-bazelisk` + `actions/cache` for Bazel | `bazel-contrib/setup-bazel@0.19.0` |
| Single shared `disk-cache` across all jobs | One `disk-cache` name per job |
| `alias()` for test aggregation | `test_suite()` |
| `target` in both `associates` and `deps` | `associates` only — no `deps` duplication |
| `File(url.toURI())` for classpath resources | Extract via `JarURLConnection` |
| `System.getProperty("user.home")` in tests | `TEST_TMPDIR` or `java.io.tmpdir` |
| `sudo apt-get install -y <pkg>` | `sudo apt-get install -y --no-install-recommends <pkg>` |
| Uploading artifacts on every event | Gate with `if: github.event_name == 'push'` |
| No `permissions:` block in workflow | `permissions: contents: read` at minimum |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…