Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/launcher-integration-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
name: Launcher Integration Tests

on:
push:
branches: [main]
pull_request:
branches: [main]
paths:
- crates/aspect-launcher/**
- crates/aspect-telemetry/**
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
integration-tests:
name: Integration tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]

env:
ASPECT_CLI_DOWNLOADER_CACHE: /tmp/aspect-launcher-ci-cache
CARGO_TERM_COLOR: always
# Use the workflow's GITHUB_TOKEN to avoid rate-limiting on the releases API
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

steps:
- uses: actions/checkout@v6

- name: Cache cargo registry and build artifacts
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/
key: ${{ runner.os }}-cargo-launcher-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-launcher-

# The published aspect-cli artifacts are named after the Rust target triple
# (e.g. aspect-cli-x86_64-unknown-linux-musl). LLVM_TRIPLE is baked into
# the launcher at compile time via build.rs. In production the launcher is
# always cross-compiled to musl by Bazel, so LLVM_TRIPLE is correct. Here
# we build with plain cargo on a gnu runner, which would bake in
# x86_64-unknown-linux-gnu and cause the artifact lookup to fail. Building
# with --target x86_64-unknown-linux-musl replicates the production value.
- name: Install musl toolchain (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt-get install -y musl-tools
rustup target add x86_64-unknown-linux-musl
echo "CARGO_TARGET_ARGS=--target x86_64-unknown-linux-musl" >> "$GITHUB_ENV"

- name: Build launcher
run: cargo build -p aspect-launcher $CARGO_TARGET_ARGS

# ── Test 1: Unpinned first run ────────────────────────────────────────────
# Fresh cache, no version.axl → must query the releases API and download
# the latest stable (non-prerelease) release.

- name: "Test 1: Unpinned first run queries releases API"
run: |
mkdir -p /tmp/axl-test
output=$(cd /tmp/axl-test && ASPECT_DEBUG=1 \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q "querying releases API (no hint cached)" \
|| { echo "FAIL: expected 'no hint cached' API query"; exit 1; }
# Must resolve to a stable vYYYY.WW.P tag, not a prerelease
echo "$output" | grep -q 'downloading aspect cli version v[0-9]' \
|| { echo "FAIL: expected stable version download"; exit 1; }

# ── Test 2: Warm cache ────────────────────────────────────────────────────
# Hint is fresh, binary is present → no network call.

- name: "Test 2: Warm cache skips API"
run: |
output=$(cd /tmp/axl-test && ASPECT_DEBUG=1 \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q "found in cache" \
|| { echo "FAIL: expected cache hit"; exit 1; }
if echo "$output" | grep -q "querying releases"; then
echo "FAIL: should not query API on warm cache"; exit 1
fi

# ── Test 3: Stale hint ────────────────────────────────────────────────────
# Backdate hint mtime → must re-query API, then reuse the already-cached binary.

- name: "Test 3: Stale hint triggers API re-query"
run: |
python3 -c "
import os, time
hint_dir = '$ASPECT_CLI_DOWNLOADER_CACHE/launcher/latest'
for f in os.listdir(hint_dir):
p = os.path.join(hint_dir, f)
os.utime(p, (0, 0)) # epoch = definitely stale
"
output=$(cd /tmp/axl-test && ASPECT_DEBUG=1 \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q "querying releases API (hint is stale)" \
|| { echo "FAIL: expected stale hint message"; exit 1; }
# Binary was already cached — should not re-download
if echo "$output" | grep -q "^downloading "; then
echo "FAIL: should not re-download when binary is cached"; exit 1
fi

# ── Test 4: API failure fallback ──────────────────────────────────────────
# Stale hint + bad token → must fall back to cached binary and reset expiry.

- name: "Test 4: API failure falls back to stale cached binary"
run: |
python3 -c "
import os
hint_dir = '$ASPECT_CLI_DOWNLOADER_CACHE/launcher/latest'
for f in os.listdir(hint_dir):
os.utime(os.path.join(hint_dir, f), (0, 0))
"
output=$(cd /tmp/axl-test && ASPECT_DEBUG=1 GITHUB_TOKEN=invalid \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q "API error, falling back to stale cached tag" \
|| { echo "FAIL: expected stale fallback message"; exit 1; }

# ── Test 5: Pinned version (first run) ────────────────────────────────────
# version.axl with an explicit version → resolve tag directly, no API call.

- name: "Test 5: Pinned version downloads directly without API call"
run: |
mkdir -p /tmp/axl-test-pinned/.aspect
printf 'version("2026.15.2")\n' > /tmp/axl-test-pinned/.aspect/version.axl
touch /tmp/axl-test-pinned/MODULE.bazel
output=$(cd /tmp/axl-test-pinned && ASPECT_DEBUG=1 \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q 'pinned to tag "v2026.15.2", skipping API' \
|| { echo "FAIL: expected pinned tag message"; exit 1; }
if echo "$output" | grep -q "querying releases"; then
echo "FAIL: pinned version should not query API"; exit 1
fi

# ── Test 6: Pinned version (cache hit) ────────────────────────────────────
# Same pinned version, second run → cache hit, no download, no API call.

- name: "Test 6: Pinned version cache hit"
run: |
output=$(cd /tmp/axl-test-pinned && ASPECT_DEBUG=1 \
cargo run --manifest-path "$GITHUB_WORKSPACE/Cargo.toml" -p aspect-launcher $CARGO_TARGET_ARGS -- version 2>&1) || true
echo "$output"
echo "$output" | grep -q "found in cache" \
|| { echo "FAIL: expected cache hit"; exit 1; }
if echo "$output" | grep -q "^downloading "; then
echo "FAIL: should not re-download on cache hit"; exit 1
fi
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion crates/aspect-launcher/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
load("@aspect_bazel_lib//lib:expand_template.bzl", "expand_template")
load("//bazel/release:release.bzl", "release")
load("//bazel/release/homebrew:multi_platform_brew_artifacts.bzl", "multi_platform_brew_artifacts")
load("//bazel/rust:defs.bzl", "rust_binary")
load("//bazel/rust:defs.bzl", "rust_binary", "rust_test")
load("//bazel/rust:multi_platform_rust_binaries.bzl", "multi_platform_rust_binaries")

rust_binary(
Expand All @@ -25,6 +25,15 @@ rust_binary(
visibility = ["//:__pkg__"],
)

rust_test(
name = "test",
size = "small",
crate = ":aspect-launcher",
deps = [
"@crates//:serde_json",
],
)

release(
name = "release",
targets = [":bins", ":brew"],
Expand Down
3 changes: 3 additions & 0 deletions crates/aspect-launcher/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ sha2 = "0.10.9"
starlark_syntax = "0.13.0"
tempfile = "3.20.0"
tokio = { version = "1.45.1", features = ["fs", "macros", "rt", "rt-multi-thread"] }

[dev-dependencies]
serde_json = "1.0"
181 changes: 177 additions & 4 deletions crates/aspect-launcher/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,180 @@
# aspect-launcher

With a bare minimum of code, perform the following.
The aspect-launcher is a thin bootstrap binary that provisions and executes the
full `aspect-cli`. It is distributed as the `aspect` binary that users install
(e.g. via Homebrew). When a user runs `aspect build //...`, the launcher:

- Look for an `.aspect/config.toml`
- Read `.aspect_cli.version`
- ...
1. Locates the project root (walks up from cwd looking for `MODULE.aspect`,
`MODULE.bazel`, `WORKSPACE`, etc.)
2. Reads `.aspect/version.axl` (if present) to determine which version of
`aspect-cli` to use and where to download it from
3. Downloads (or retrieves from cache) the correct `aspect-cli` binary
4. `exec`s the real `aspect-cli`, forwarding all arguments

The launcher also forks a child process to report anonymous usage telemetry
(honoring `DO_NOT_TRACK`).

## version.axl

The file `.aspect/version.axl` controls which `aspect-cli` version the launcher
provisions. It uses Starlark syntax and contains a single `version()` call.

### Pinned version (recommended)

```starlark
version("2026.11.6")
```

This pins the project to a specific `aspect-cli` release. The launcher downloads
directly from `https://github.com/aspect-build/aspect-cli/releases/download/v2026.11.6/<artifact>`
with no GitHub API call needed.

### Pinned version with custom sources

```starlark
version(
"2026.11.6",
sources = [
local("bazel-bin/cli/aspect"),
github(
org = "aspect-build",
repo = "aspect-cli",
),
],
)
```

Sources are tried in order. This example first checks for a local build, then
falls back to GitHub.

### No version.axl

When no `.aspect/version.axl` file exists, the launcher queries the GitHub
releases API to find the latest available `aspect-cli` release and downloads
that. This is the unpinned (floating) mode — you always get the most recent
release that has a binary for your platform.

### Can you have a version.axl without pinning?

While the parser technically allows `version()` with no positional argument,
this is equivalent to not having a `version.axl` at all — the launcher will
query the releases API to find the latest available release. If you create a
`version.axl`, you should specify a version string. The only reason to have a
`version.axl` without a pinned version would be to customize the `sources`
list, e.g.:

```starlark
version(
sources = [
local("bazel-bin/cli/aspect"),
github(org = "my-fork", repo = "aspect-cli"),
],
)
```

This is a niche use case. In general, if `version.axl` exists, pin a version.

### version() reference

```
version(<version_string>?, sources = [...]?)
```

**Arguments:**

| Argument | Required | Description |
|----------|----------|-------------|
| *(positional)* | No | Version string (e.g. `"2026.11.6"`). If omitted, the GitHub releases API is queried to find the latest available release. |
| `sources` | No | List of source specifiers, tried in order. If omitted, defaults to `[github(org = "aspect-build", repo = "aspect-cli")]`. |

### Source types

#### github()

```starlark
github(
org = "aspect-build", # required
repo = "aspect-cli", # required
tag = "v{version}", # optional, default: "v{version}"
artifact = "{repo}-{target}", # optional, default: "{repo}-{target}"
)
```

#### http()

```starlark
http(
url = "https://example.com/aspect-cli-{version}-{target}", # required
)
```

#### local()

```starlark
local("bazel-bin/cli/aspect") # path relative to project root
```

### Template variables

The `tag`, `artifact`, and `url` fields support these placeholders:

| Variable | Description | Example |
|----------|-------------|---------|
| `{version}` | The version string from `version()` | `2026.11.6` |
| `{os}` | Operating system | `darwin`, `linux` |
| `{arch}` | CPU architecture (Bazel naming) | `aarch64`, `x86_64` |
| `{target}` | LLVM target triple | `aarch64-apple-darwin`, `x86_64-unknown-linux-musl` |

## Download flow

### Pinned version (version specified in version.axl)

```
version.axl: version("2026.11.6", sources = [github(org = "aspect-build", repo = "aspect-cli")])
```

1. Tag is computed: `v2026.11.6`
2. Cache is checked — if the binary is already cached, it is used immediately
3. Direct download from
`https://github.com/aspect-build/aspect-cli/releases/download/v2026.11.6/aspect-cli-{target}`
4. If the download fails, the error is reported — **no fallback to a different
version**. When you pin, you are guaranteed to get exactly that version or
an error.

### Unpinned version (no version.axl, or version.axl without a version string)

```
(no .aspect/version.axl file)
```

1. Check the tag hint cache — if a previous run already resolved a tag, its
binary is cached, and the hint is less than 24 hours old, use it immediately
with **no network call**
2. Otherwise, query the GitHub releases API
(`/repos/{org}/{repo}/releases?per_page=10`) and scan the most recent
non-prerelease releases to find the first one that contains the matching artifact
3. If the API call fails but a stale hint and cached binary exist, fall back to
the cached version and reset the hint's expiry so we don't hammer a down API
4. Write the resolved tag to the hint cache for future runs
5. Direct download from
`https://github.com/{org}/{repo}/releases/download/{resolved_tag}/{artifact}`

This means the unpinned case gets the latest *available* release on the first
run and after the 24-hour hint expiry, gracefully handles the window during a
new release where assets haven't finished uploading, avoids any network
dependency on warm-cache runs, and degrades gracefully when the GitHub API is
unavailable.

## Caching

Downloaded binaries are cached under the system cache directory
(`~/Library/Caches/aspect/launcher/` on macOS, `~/.cache/aspect/launcher/` on
Linux). The cache path is derived from a SHA-256 hash of the tool name and
source URL, so different versions coexist without conflict.

The cache location can be overridden with the `ASPECT_CLI_DOWNLOADER_CACHE`
environment variable.

## Debugging

Set `ASPECT_DEBUG=1` to enable verbose logging of the download and caching flow.
Loading
Loading