Add a third builder shape for archives with no wrapping directory
All checks were successful
CI / build (pull_request) Successful in 1m13s
CI / test (pull_request) Successful in 3m46s
CI / audit (pull_request) Successful in 15s
CI / coverage (pull_request) Successful in 7m27s

Merging master's claude-code.toml onto this branch surfaced a real gap:
builder.rs only knew "bare binary download" and "tarball extracting into
a same-named directory" (uv). claude-code's tarball extracts a bare
`claude` file with no wrapping directory, and that inner filename
doesn't match the package name either — makepkg's package() failed with
"cannot stat .../claude-linux-x64/claude-code" (confirmed by actually
running the build).

Add Package::archive_binary_path, an explicit override for the
in-archive path builder.rs installs from, used verbatim when present
instead of the stem/binary_name convention. Set binary_name = "claude"
too, matching the box's actual command name (/opt/claude-code/bin/claude)
rather than the claude-code package name. Also added a sanity_check
block (claude --version), matching uv's pattern, confirmed against the
real built binary's output ("2.1.276 (Claude Code)").

Verified end to end against the real repo with PKGWATCH_REPO_DIR
pointed at a scratch dir: check -> fetch -> verify -> review --approve
-> build -> sanity-check -> publish all pass for claude-code v2.1.276.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Austin Schaefer 2026-09-18 12:46:28 +02:00
parent 14e1cee36f
commit 0044eda532
3 changed files with 101 additions and 6 deletions

View file

@ -16,11 +16,25 @@
# no `{version}` placeholder is needed, same as uv's config. Tracking the # no `{version}` placeholder is needed, same as uv's config. Tracking the
# glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl # glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl
# variant, to match this machine. # variant, to match this machine.
#
# Archive shape doesn't match uv's or scaleway-cli's: the tarball extracts a
# bare `claude` file with no wrapping directory (confirmed via `tar tzvf`
# against the real v2.1.276 asset) — hence archive_binary_path below (see
# builder.rs's third shape). binary_name is also set explicitly to `claude`
# (the real upstream command name, not the `claude-code` package name) so
# the installed binary matches what this box already invokes as `claude`
# (see /opt/claude-code/bin/claude).
[package.claude-code] [package.claude-code]
repo = "anthropics/claude-code" repo = "anthropics/claude-code"
asset_pattern = "claude-linux-x64.tar.gz" asset_pattern = "claude-linux-x64.tar.gz"
binary_name = "claude"
archive_binary_path = "claude"
[package.claude-code.verification] [package.claude-code.verification]
method = "same-origin-sha256" method = "same-origin-sha256"
checksum_asset_pattern = "SHASUMS256.txt" checksum_asset_pattern = "SHASUMS256.txt"
[package.claude-code.sanity_check]
command = "claude --version"
version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)'

View file

@ -36,10 +36,12 @@ pub struct BuildResult {
/// artifact, then runs `makepkg` in `build_dir`. /// artifact, then runs `makepkg` in `build_dir`.
/// ///
/// Deliberately one fixed "prebuilt binary" shape, not a templating engine /// Deliberately one fixed "prebuilt binary" shape, not a templating engine
/// — see docs/SPEC.md > Scaling > Template reuse. Covers the two shapes the two /// — see docs/SPEC.md > Scaling > Template reuse. Covers the shapes
/// currently-tracked packages actually need: a bare-binary download /// currently-tracked packages actually need: a bare-binary download
/// (scaleway-cli) and a tarball containing a same-named directory (uv). /// (scaleway-cli), a tarball containing a same-named directory (uv), and a
/// Extend when a third real shape shows up rather than guessing at /// tarball with no wrapping directory at all whose inner filename doesn't
/// match the package name (claude-code — see `Package::archive_binary_path`).
/// Extend when a fourth real shape shows up rather than guessing at
/// generality now. /// generality now.
pub fn build(req: &BuildRequest, build_dir: &Path) -> Result<BuildResult> { pub fn build(req: &BuildRequest, build_dir: &Path) -> Result<BuildResult> {
let pkgbuild = generate_pkgbuild(req)?; let pkgbuild = generate_pkgbuild(req)?;
@ -86,9 +88,14 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
let sha256 = hash::sha256_hex_file(req.artifact_path)?; let sha256 = hash::sha256_hex_file(req.artifact_path)?;
let install_source = match archive_stem(req.asset_name) { let install_source = if let Some(path) = &req.pkg.archive_binary_path {
validate_shell_safe("archive binary path", path)?;
path.clone()
} else {
match archive_stem(req.asset_name) {
Some(stem) => format!("{stem}/{binary_name}"), Some(stem) => format!("{stem}/{binary_name}"),
None => req.asset_name.to_string(), None => req.asset_name.to_string(),
}
}; };
Ok(format!( Ok(format!(
@ -328,6 +335,23 @@ mod tests {
toml::from_str(&toml_text).unwrap() toml::from_str(&toml_text).unwrap()
} }
fn make_package_with_archive_binary_path(
binary_name: &str,
archive_binary_path: &str,
) -> Package {
let toml_text = format!(
r#"
repo = "o/r"
asset_pattern = "x"
binary_name = "{binary_name}"
archive_binary_path = "{archive_binary_path}"
[verification]
method = "github-attestation"
"#
);
toml::from_str(&toml_text).unwrap()
}
#[test] #[test]
fn build_rejects_unsafe_version() { fn build_rejects_unsafe_version() {
let pkg = make_package(None); let pkg = make_package(None);
@ -402,6 +426,53 @@ mod tests {
)); ));
} }
#[test]
fn generate_pkgbuild_flat_archive_installs_from_archive_binary_path_override() {
// claude-code's shape: a tarball with no wrapping directory, whose
// inner filename ("claude") doesn't match the package name
// ("claude-code") — neither existing shape (stem/binary_name, or
// bare-binary-no-archive) fits, hence the explicit override.
let pkg = make_package_with_archive_binary_path("claude-code", "claude");
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
let req = BuildRequest {
pkg_name: "claude-code",
pkg: &pkg,
version: "2.1.276",
repo: "anthropics/claude-code",
asset_name: "claude-linux-x64.tar.gz",
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(
pkgbuild
.contains("install -Dm755 \"${srcdir}/claude\" \"${pkgdir}/usr/bin/claude-code\"")
);
}
#[test]
fn generate_pkgbuild_rejects_archive_binary_path_with_command_substitution() {
let pkg = make_package_with_archive_binary_path("claude-code", "claude$(touch pwned)");
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "claude-code",
pkg: &pkg,
version: "2.1.276",
repo: "anthropics/claude-code",
asset_name: "claude-linux-x64.tar.gz",
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test] #[test]
fn generate_pkgbuild_rejects_download_url_with_single_quote() { fn generate_pkgbuild_rejects_download_url_with_single_quote() {
let pkg = make_package(None); let pkg = make_package(None);

View file

@ -28,6 +28,16 @@ pub struct Package {
/// checking the currently-installed extra package, not guessable from /// checking the currently-installed extra package, not guessable from
/// the repo name). Defaults to the package name when omitted. /// the repo name). Defaults to the package name when omitted.
pub binary_name: Option<String>, pub binary_name: Option<String>,
/// Explicit path to the binary inside the extracted archive, relative
/// to `srcdir`, for archive layouts that don't match the "extracts into
/// a directory named after the archive stem" convention `builder.rs`
/// otherwise assumes (e.g. claude-code's tarball extracts a bare
/// `claude` file with no wrapping directory, and that inner filename
/// doesn't match the package name either). Only meaningful when
/// `asset_pattern` names an archive; ignored for bare-binary downloads,
/// where the downloaded file *is* the source path already. Defaults to
/// the stem/`binary_name` convention when omitted.
pub archive_binary_path: Option<String>,
/// Post-build correctness check (not a security control — see /// Post-build correctness check (not a security control — see
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the /// docs/SPEC.md > Verification trust tiers). Runs `command` against the
/// freshly built binary and confirms `version_regex`'s capture group /// freshly built binary and confirms `version_regex`'s capture group