Skip to content
Draft
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
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"
173 changes: 169 additions & 4 deletions crates/aspect-launcher/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,172 @@
# 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 uses its own compiled-in
version and the default GitHub source. This means the `aspect-cli` version
floats with the installed launcher version.

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

While the parser technically allows `version()` with no positional argument
(falling back to the launcher's built-in version), this is equivalent to not
having a `version.axl` at all. 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, defaults to the launcher's own version. |
| `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. Launcher queries the GitHub releases API
(`/repos/{org}/{repo}/releases?per_page=10`)
2. Scans the most recent releases to find the first one that contains the
matching artifact — this gives us a concrete tag (e.g. `v2026.11.5`)
3. Direct download from
`https://github.com/{org}/{repo}/releases/download/{resolved_tag}/{artifact}`
4. The downloaded binary is cached for future runs

This means the unpinned case always gets the latest *available* release — it
gracefully handles the window during a new release where assets haven't finished
uploading by using the most recent release that has them.

## 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.
45 changes: 45 additions & 0 deletions crates/aspect-launcher/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,48 @@ impl AspectCache {
self.root.join(format!("bin/{0}/{1}/{0}", tool_name, hash))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_tool_path_structure() {
let cache = AspectCache::from(PathBuf::from("/tmp/cache"));
let path = cache.tool_path(
&"aspect-cli".to_string(),
&"https://github.com/aspect-build/aspect-cli/releases/tags/v1.0.0".to_string(),
);
// Path should be: /tmp/cache/bin/{tool_name}/{hash}/{tool_name}
let components: Vec<_> = path.components().collect();
let path_str = path.to_str().unwrap();
assert!(path_str.starts_with("/tmp/cache/bin/aspect-cli/"));
assert!(path_str.ends_with("/aspect-cli"));
// Should have the structure: root/bin/name/hash/name
assert_eq!(components.len(), 7); // /tmp/cache/bin/aspect-cli/{hash}/aspect-cli
}

#[test]
fn test_tool_path_deterministic() {
let cache = AspectCache::from(PathBuf::from("/tmp/cache"));
let path1 = cache.tool_path(&"tool".to_string(), &"source-a".to_string());
let path2 = cache.tool_path(&"tool".to_string(), &"source-a".to_string());
assert_eq!(path1, path2);
}

#[test]
fn test_tool_path_different_sources_differ() {
let cache = AspectCache::from(PathBuf::from("/tmp/cache"));
let path1 = cache.tool_path(&"tool".to_string(), &"source-a".to_string());
let path2 = cache.tool_path(&"tool".to_string(), &"source-b".to_string());
assert_ne!(path1, path2);
}

#[test]
fn test_tool_path_different_names_differ() {
let cache = AspectCache::from(PathBuf::from("/tmp/cache"));
let path1 = cache.tool_path(&"tool-a".to_string(), &"source".to_string());
let path2 = cache.tool_path(&"tool-b".to_string(), &"source".to_string());
assert_ne!(path1, path2);
}
}
Loading