diff --git a/.github/actions/soldeer-package-build/action.yml b/.github/actions/soldeer-package-build/action.yml
new file mode 100644
index 0000000..2ffe3ed
--- /dev/null
+++ b/.github/actions/soldeer-package-build/action.yml
@@ -0,0 +1,30 @@
+name: soldeer-package-build
+description: >-
+ Builds the tree `.soldeerignore` would publish rather than the repo tree. The two are separate, hand-maintained definitions of what the library is, and the repo tree is always complete: a source the filter drops, or a shipped file whose import the filter drops, resolves in every repo-side check and fails only in a consumer's `forge build` after `soldeer install`. No-op for a repo whose foundry.toml declares no `[package]` name and version, since nothing publishes from it.
+inputs:
+ rainix-sha:
+ description: >-
+ The rainix commit whose `sol-shell` supplies `forge`. Pass the calling workflow's `env.RAINIX_SHA` so the package is built with the same toolchain the job's other steps use.
+ required: true
+runs:
+ using: composite
+ steps:
+ - name: Build the package as published
+ shell: bash
+ # The interpolation goes through env rather than into the script body:
+ # any repo can call this composite, so its input is untrusted text that
+ # must never be spliced into a shell command.
+ env:
+ RAINIX_SHA: ${{ inputs.rainix-sha }}
+ run: |
+ set -euo pipefail
+ # `forge` comes from the sol-shell the caller pins and has already
+ # realised for its other steps, so the package builds on the same
+ # toolchain as the repo tree. The check itself runs from this
+ # composite's own checkout via a path: ref, so its version tracks the
+ # action version rather than that pin — a subcommand added here works
+ # on a consumer's next push, and no api.github.com HEAD lookup is
+ # involved either way.
+ nix develop "github:rainlanguage/rainix/$RAINIX_SHA#sol-shell" \
+ -c nix run "path:$(cd "$GITHUB_ACTION_PATH/../../.." && pwd)#rainix-static" \
+ -- soldeer-package-build
diff --git a/.github/workflows/rainix-sol-static.yaml b/.github/workflows/rainix-sol-static.yaml
index e893b6c..22e7de8 100644
--- a/.github/workflows/rainix-sol-static.yaml
+++ b/.github/workflows/rainix-sol-static.yaml
@@ -48,6 +48,19 @@ jobs:
- name: Install soldeer dependencies
if: hashFiles('soldeer.lock') != ''
run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge soldeer install
+ # Build the tree `.soldeerignore` would publish rather than the repo tree.
+ # The two are separate, hand-maintained definitions of what the library
+ # is, and the repo tree is always complete: a source the filter drops, or
+ # a shipped file whose import the filter drops, resolves in every other
+ # check here and only fails in a consumer's build after `soldeer install`.
+ # No-op for a repo whose foundry.toml declares no `[package]` name and
+ # version, since nothing publishes from it. A composite rather than a
+ # `run:`, because the check has to come from the action's own checkout:
+ # `rainix-static` inside the RAINIX_SHA-pinned shell is whatever that
+ # commit built, and carries no subcommand added after it.
+ - uses: rainlanguage/rainix/.github/actions/soldeer-package-build@main
+ with:
+ rainix-sha: ${{ env.RAINIX_SHA }}
- run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c slither .
- run: nix develop github:rainlanguage/rainix/${{ env.RAINIX_SHA }}#sol-shell -c forge fmt --check
# Enforce Rain's one-contract-per-.sol-file convention (rainix#214).
diff --git a/flake.nix b/flake.nix
index c390c0b..3f5fdf9 100644
--- a/flake.nix
+++ b/flake.nix
@@ -469,6 +469,7 @@
bats test/bats/devshell/sol-shell/sol-tasks.test.bats
bats test/bats/devshell/sol-shell/slim.test.bats
bats test/bats/devshell/sol-shell/closure.test.bats
+ bats test/bats/task/soldeer-package-build.test.bats
'';
additionalBuildInputs = [ pkgs.bats ] ++ sol-build-inputs;
};
diff --git a/rainix-static/src/main.rs b/rainix-static/src/main.rs
index af1910b..bc74881 100644
--- a/rainix-static/src/main.rs
+++ b/rainix-static/src/main.rs
@@ -45,6 +45,16 @@
// `forge soldeer push --dry-run` would upload against the latest published
// revision, and emit changed / version / next. Runs inside sol-shell, so
// `forge` and `curl` are on PATH.
+// soldeer-package-build [--root
] [--scratch ]
+// build the package exactly as it publishes: unpack what
+// `forge soldeer push --dry-run` would upload into a scratch project,
+// give it the build config, remappings, lockfile and dependencies a
+// consumer supplies, and `forge build` it. `.soldeerignore` is a second
+// definition of the library, disjoint from the source graph a repo-side
+// build walks, so a shipped file whose import the filter drops is
+// invisible to every other check. A repo whose foundry.toml declares no
+// `[package]` name and version publishes nothing, and is skipped. Runs
+// inside sol-shell, so `forge` is on PATH.
// rpc-preflight [--root ] [--github-env ] [--samples N]
// [--timeout N] [--no-archive]
// Pick a working fork RPC endpoint per network and export it as
@@ -60,6 +70,7 @@ mod no_submodules;
mod prompt_cap;
mod rpc_preflight;
mod soldeer_gate;
+mod soldeer_package_build;
use std::path::Path;
@@ -156,6 +167,11 @@ fn main() {
.unwrap_or_else(|| fail("soldeer-gate: --package required"));
soldeer_gate::run(&pkg, flag(&args, "--github-output").as_deref());
}
+ "soldeer-package-build" => {
+ let root = flag(&args, "--root").unwrap_or_else(|| ".".to_string());
+ let scratch = flag(&args, "--scratch");
+ soldeer_package_build::run(Path::new(&root), scratch.as_deref().map(Path::new));
+ }
"snapshots-append-only" => {
let base = flag(&args, "--base").unwrap_or_else(|| "origin/main".to_string());
let root = flag(&args, "--root").unwrap_or_else(|| "src/generated".to_string());
@@ -197,7 +213,8 @@ fn main() {
eprintln!(
"rainix-static: unknown subcommand {other:?} \
(available: no-submodules, agent-context-cap, prompt-cap, \
- snapshots-append-only, soldeer-gate, rpc-preflight)"
+ snapshots-append-only, soldeer-gate, soldeer-package-build, \
+ rpc-preflight)"
);
std::process::exit(2);
}
diff --git a/rainix-static/src/soldeer_gate.rs b/rainix-static/src/soldeer_gate.rs
index 57c708e..a4718a6 100644
--- a/rainix-static/src/soldeer_gate.rs
+++ b/rainix-static/src/soldeer_gate.rs
@@ -14,15 +14,21 @@ use std::process::Command;
/// A file entry pulled from a package zip: (name, bytes).
type Entry = (String, Vec);
-/// A foundry.toml `[package].version` line starts with `version`, then optional
-/// spaces/tabs, then `=`. Matches the old `^version[[:space:]]*=` sed anchor.
-fn is_version_line(line: &str) -> bool {
- match line.strip_prefix("version") {
+/// A foundry.toml `[package]` field line starts with the key at column zero,
+/// then optional spaces/tabs, then `=`. Matches the `^[[:space:]]*=` sed
+/// anchor.
+fn is_key_line(line: &str, key: &str) -> bool {
+ match line.strip_prefix(key) {
Some(rest) => rest.trim_start_matches([' ', '\t']).starts_with('='),
None => false,
}
}
+/// `is_key_line` for the `version` key.
+fn is_version_line(line: &str) -> bool {
+ is_key_line(line, "version")
+}
+
/// Blank foundry.toml's version line to `version = "0.0.0"` so a bump alone is
/// never seen as a content change. Every other line is preserved verbatim.
fn blank_foundry_version(content: &[u8]) -> Vec {
@@ -68,7 +74,7 @@ fn norm_hash(entries: &mut Vec) -> String {
}
/// Read a zip into (name, bytes) entries, skipping directory entries.
-fn read_zip(path: &Path) -> Vec {
+pub(crate) fn read_zip(path: &Path) -> Vec {
let file = std::fs::File::open(path)
.unwrap_or_else(|e| fail(&format!("open {}: {e}", path.display())));
let mut archive = zip::ZipArchive::new(file)
@@ -135,12 +141,12 @@ fn parse_registry(json: &str) -> (Option, Option) {
(ver, url)
}
-/// First `[package].version` value in foundry.toml (the in-dev, unpublished
-/// version). Reads the value between the first pair of quotes on that line.
-fn read_local_version(dir: &Path) -> Option {
+/// First `[package].` value in foundry.toml. Reads the value between the
+/// first pair of quotes on that line.
+pub(crate) fn read_local_field(dir: &Path, key: &str) -> Option {
let content = std::fs::read_to_string(dir.join("foundry.toml")).ok()?;
for line in content.lines() {
- if is_version_line(line) {
+ if is_key_line(line, key) {
let q1 = line.find('"')?;
let rest = &line[q1 + 1..];
let q2 = rest.find('"')?;
@@ -150,6 +156,11 @@ fn read_local_version(dir: &Path) -> Option {
None
}
+/// The in-dev, unpublished `[package].version` from foundry.toml.
+fn read_local_version(dir: &Path) -> Option {
+ read_local_field(dir, "version")
+}
+
/// Run the Soldeer content gate for `pkg` and emit changed / version / next.
pub(crate) fn run(pkg: &str, gh_out: Option<&str>) {
let dir = Path::new(".");
@@ -183,16 +194,16 @@ pub(crate) fn run(pkg: &str, gh_out: Option<&str>) {
// Local package content: `forge soldeer push --dry-run` writes
// .zip into the cwd.
- remove_cwd_zips();
+ remove_zips(dir);
let spec = format!("{pkg}~{local}");
run_cmd(
Command::new("forge").args(["soldeer", "push", &spec, "--dry-run"]),
"forge soldeer push --dry-run",
);
- let local_zip = newest_cwd_zip().unwrap_or_else(|| fail("forge dry-run produced no .zip"));
+ let local_zip = newest_zip(dir).unwrap_or_else(|| fail("forge dry-run produced no .zip"));
let mut local_entries = read_zip(&local_zip);
let new_hash = norm_hash(&mut local_entries);
- remove_cwd_zips();
+ remove_zips(dir);
// Published content, hashed the same way; "none" when nothing is published.
let old_hash = match (&remote, url.as_deref()) {
@@ -239,7 +250,7 @@ fn emit(gh_out: Option<&str>, lines: &str) {
}
/// Run a subprocess, inheriting stdio; fail loud on spawn error or nonzero exit.
-fn run_cmd(cmd: &mut Command, what: &str) {
+pub(crate) fn run_cmd(cmd: &mut Command, what: &str) {
let status = cmd
.status()
.unwrap_or_else(|e| fail(&format!("{what}: failed to spawn: {e}")));
@@ -256,10 +267,10 @@ fn curl_stdout(url: &str) -> Option {
.then(|| String::from_utf8_lossy(&out.stdout).to_string())
}
-/// Paths of `*.zip` files in the cwd.
-fn cwd_zips() -> Vec {
+/// Paths of `*.zip` files directly in `dir`.
+fn zips_in(dir: &Path) -> Vec {
let mut v = Vec::new();
- if let Ok(rd) = std::fs::read_dir(".") {
+ if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "zip") {
@@ -270,15 +281,15 @@ fn cwd_zips() -> Vec {
v
}
-fn remove_cwd_zips() {
- for p in cwd_zips() {
+pub(crate) fn remove_zips(dir: &Path) {
+ for p in zips_in(dir) {
let _ = std::fs::remove_file(p);
}
}
-/// Most recently modified `*.zip` in the cwd (the dry-run output).
-fn newest_cwd_zip() -> Option {
- cwd_zips()
+/// Most recently modified `*.zip` in `dir` (the dry-run output).
+pub(crate) fn newest_zip(dir: &Path) -> Option {
+ zips_in(dir)
.into_iter()
.max_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
}
diff --git a/rainix-static/src/soldeer_package_build.rs b/rainix-static/src/soldeer_package_build.rs
new file mode 100644
index 0000000..bd3f264
--- /dev/null
+++ b/rainix-static/src/soldeer_package_build.rs
@@ -0,0 +1,259 @@
+//! `soldeer-package-build` — build the package exactly as it publishes.
+//!
+//! `.soldeerignore` is a second, hand-maintained definition of what a library
+//! is, disjoint from the source graph `forge build` walks in the repo: the repo
+//! tree is complete, so a file the filter drops, or a shipped file whose import
+//! the filter drops, is invisible to every check that runs against the repo.
+//! This subcommand takes what `forge soldeer push --dry-run` would upload,
+//! unpacks it into a scratch project with the build config and dependencies a
+//! consumer supplies, and builds it — so an unresolvable import in the
+//! published tree is red here instead of in a consumer's `forge build` after
+//! `soldeer install`.
+
+use crate::fail;
+use crate::soldeer_gate::{newest_zip, read_local_field, read_zip, remove_zips, run_cmd};
+use std::path::{Component, Path, PathBuf};
+use std::process::Command;
+
+/// Files a consumer supplies and `.soldeerignore` commonly excludes: the build
+/// config, its remappings, and its dependency lock. Taken from the repo when
+/// the package does not ship them, so the published tree has something to build
+/// with.
+pub(crate) const SCAFFOLD_FILES: [&str; 3] = ["foundry.toml", "remappings.txt", "soldeer.lock"];
+
+/// A zip entry name as a path under the scratch project, or None when it
+/// escapes that root. Absolute paths, drive prefixes, `..` and `.` are rejected
+/// rather than normalized, so a hostile or malformed entry name cannot write
+/// outside the scratch directory.
+pub(crate) fn safe_entry_path(name: &str) -> Option {
+ let mut out = PathBuf::new();
+ for c in Path::new(name).components() {
+ match c {
+ Component::Normal(part) => out.push(part),
+ _ => return None,
+ }
+ }
+ (!out.as_os_str().is_empty()).then_some(out)
+}
+
+/// Copy each of `SCAFFOLD_FILES` the package does not ship from `root` into
+/// `scratch`, and return the names copied in `SCAFFOLD_FILES` order. A file the
+/// package ships is left alone — it is what a consumer would get. A file
+/// neither side has is simply absent.
+pub(crate) fn scaffold_missing(root: &Path, scratch: &Path) -> Vec<&'static str> {
+ let mut copied = Vec::new();
+ for f in SCAFFOLD_FILES {
+ let dest = scratch.join(f);
+ let src = root.join(f);
+ if dest.exists() || !src.exists() {
+ continue;
+ }
+ std::fs::copy(&src, &dest).unwrap_or_else(|e| {
+ fail(&format!(
+ "copy {} to {}: {e}",
+ src.display(),
+ dest.display()
+ ))
+ });
+ copied.push(f);
+ }
+ copied
+}
+
+/// True when `foundry.toml` content opens a `[dependencies]` table, in either
+/// the inline (`[dependencies]`) or per-dependency (`[dependencies.forge-std]`)
+/// form, i.e. `forge soldeer install` has something to resolve.
+pub(crate) fn declares_dependencies(toml: &str) -> bool {
+ toml.lines().any(|l| {
+ let l = l.trim();
+ l == "[dependencies]" || l.starts_with("[dependencies.")
+ })
+}
+
+/// Write one package entry under `scratch`, creating its parent directories.
+/// Returns true when the entry is a Solidity source.
+fn write_entry(scratch: &Path, name: &str, content: &[u8]) -> bool {
+ let rel = safe_entry_path(name)
+ .unwrap_or_else(|| fail(&format!("package entry {name:?} escapes the package root")));
+ let dest = scratch.join(&rel);
+ if let Some(parent) = dest.parent() {
+ std::fs::create_dir_all(parent)
+ .unwrap_or_else(|e| fail(&format!("create {}: {e}", parent.display())));
+ }
+ std::fs::write(&dest, content)
+ .unwrap_or_else(|e| fail(&format!("write {}: {e}", dest.display())));
+ rel.extension().is_some_and(|x| x == "sol")
+}
+
+/// Build the package `root` publishes, in `scratch` (a temp directory when
+/// None). Runs inside sol-shell, so `forge` is on PATH.
+pub(crate) fn run(root: &Path, scratch: Option<&Path>) {
+ let toml_path = root.join("foundry.toml");
+ let (name, version) = match (
+ read_local_field(root, "name"),
+ read_local_field(root, "version"),
+ ) {
+ (Some(n), Some(v)) => (n, v),
+ _ => {
+ println!(
+ "soldeer-package-build: {} declares no [package] name and version, so no package publishes from it — skipping",
+ toml_path.display()
+ );
+ return;
+ }
+ };
+
+ // `forge soldeer push --dry-run` writes the package zip into `root` under a
+ // name derived from the directory, so clear any stale zip first and take the
+ // newest one afterwards. The scratch tree is created only after the zip has
+ // been read and removed, so a scratch directory under `root` is not itself
+ // part of what gets packaged.
+ remove_zips(root);
+ let spec = format!("{name}~{version}");
+ run_cmd(
+ Command::new("forge")
+ .current_dir(root)
+ .args(["soldeer", "push", &spec, "--dry-run"]),
+ "forge soldeer push --dry-run",
+ );
+ let zip = newest_zip(root).unwrap_or_else(|| fail("forge dry-run produced no .zip"));
+ let entries = read_zip(&zip);
+ remove_zips(root);
+
+ let scratch = match scratch {
+ Some(p) => p.to_path_buf(),
+ None => std::env::temp_dir().join(format!(
+ "rainix-soldeer-package-build-{}",
+ std::process::id()
+ )),
+ };
+ let _ = std::fs::remove_dir_all(&scratch);
+ std::fs::create_dir_all(&scratch)
+ .unwrap_or_else(|e| fail(&format!("create {}: {e}", scratch.display())));
+
+ let mut sol = 0usize;
+ for (entry, content) in &entries {
+ if write_entry(&scratch, entry, content) {
+ sol += 1;
+ }
+ }
+ scaffold_missing(root, &scratch);
+
+ let toml = match std::fs::read_to_string(scratch.join("foundry.toml")) {
+ Ok(t) => t,
+ Err(e) => fail(&format!(
+ "{spec} ships no foundry.toml and {} could not be read ({e}), so the published tree cannot be built",
+ toml_path.display()
+ )),
+ };
+ // Dependencies never ship inside a package; a consumer resolves them from
+ // the declared `[dependencies]`, and so does this build.
+ if declares_dependencies(&toml) {
+ run_cmd(
+ Command::new("forge")
+ .current_dir(&scratch)
+ .args(["soldeer", "install"]),
+ "forge soldeer install",
+ );
+ }
+
+ let status = Command::new("forge")
+ .current_dir(&scratch)
+ .arg("build")
+ .status()
+ .unwrap_or_else(|e| fail(&format!("forge build: failed to spawn: {e}")));
+ if !status.success() {
+ fail(&format!(
+ "{spec} does not build as published — the unpacked tree is at {}. \
+ Every source it needs must either be in the package or come from a declared dependency; \
+ a path that resolves in the repo but not there is excluded by .soldeerignore.",
+ scratch.display()
+ ));
+ }
+ let _ = std::fs::remove_dir_all(&scratch);
+ println!("soldeer-package-build: clean — {spec} builds as published ({sol} Solidity files)");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+
+ static N: AtomicUsize = AtomicUsize::new(0);
+
+ fn tmp_dir() -> PathBuf {
+ let d = std::env::temp_dir().join(format!(
+ "rainix-static-package-build-test-{}-{}",
+ std::process::id(),
+ N.fetch_add(1, Ordering::SeqCst)
+ ));
+ std::fs::create_dir_all(&d).unwrap();
+ d
+ }
+
+ #[test]
+ fn entry_paths_stay_inside_the_package() {
+ assert_eq!(
+ safe_entry_path("src/lib/LibFs.sol"),
+ Some(PathBuf::from("src/lib/LibFs.sol"))
+ );
+ assert_eq!(
+ safe_entry_path("README.md"),
+ Some(PathBuf::from("README.md"))
+ );
+ }
+
+ #[test]
+ fn escaping_entry_paths_are_rejected() {
+ assert_eq!(safe_entry_path(""), None);
+ assert_eq!(safe_entry_path("/etc/passwd"), None);
+ assert_eq!(safe_entry_path("../outside.sol"), None);
+ assert_eq!(safe_entry_path("src/../../outside.sol"), None);
+ assert_eq!(safe_entry_path("./src/A.sol"), None);
+ }
+
+ #[test]
+ fn a_solidity_entry_is_counted_and_written_with_its_parents() {
+ let d = tmp_dir();
+ assert!(write_entry(&d, "src/lib/A.sol", b"contract A {}"));
+ assert_eq!(
+ std::fs::read_to_string(d.join("src/lib/A.sol")).unwrap(),
+ "contract A {}"
+ );
+ assert!(!write_entry(&d, "README.md", b"hi"));
+ }
+
+ #[test]
+ fn scaffolding_takes_only_what_the_package_omits() {
+ let root = tmp_dir();
+ let scratch = tmp_dir();
+ std::fs::write(root.join("foundry.toml"), "root toml").unwrap();
+ std::fs::write(root.join("remappings.txt"), "root remappings").unwrap();
+ // soldeer.lock exists in neither; foundry.toml ships in the package.
+ std::fs::write(scratch.join("foundry.toml"), "package toml").unwrap();
+
+ assert_eq!(scaffold_missing(&root, &scratch), vec!["remappings.txt"]);
+ assert_eq!(
+ std::fs::read_to_string(scratch.join("foundry.toml")).unwrap(),
+ "package toml"
+ );
+ assert_eq!(
+ std::fs::read_to_string(scratch.join("remappings.txt")).unwrap(),
+ "root remappings"
+ );
+ assert!(!scratch.join("soldeer.lock").exists());
+ }
+
+ #[test]
+ fn dependencies_table_detection() {
+ assert!(declares_dependencies(
+ "[profile.default]\n\n[dependencies]\nforge-std = \"1\"\n"
+ ));
+ assert!(declares_dependencies(" [dependencies] \n"));
+ assert!(declares_dependencies(
+ "[dependencies.forge-std]\nversion = \"1\"\n"
+ ));
+ assert!(!declares_dependencies("[profile.default]\nsrc = \"src\"\n"));
+ assert!(!declares_dependencies("[dependencies_notreally]\n"));
+ }
+}
diff --git a/test/bats/task/soldeer-package-build.test.bats b/test/bats/task/soldeer-package-build.test.bats
new file mode 100644
index 0000000..f7fdd80
--- /dev/null
+++ b/test/bats/task/soldeer-package-build.test.bats
@@ -0,0 +1,146 @@
+setup() {
+ work="$(mktemp -d)"
+ scratch="$(mktemp -d)"
+ # An absolute path pins the compiler to the one in this shell, so the check is
+ # not resolving a version over the network.
+ solc="$(command -v solc-0.8.25)"
+
+ mkdir -p "$work/src/lib" "$work/script" "$work/test/concrete"
+
+ cat > "$work/foundry.toml" < "$work/.soldeerignore" <<'EOF'
+/foundry.toml
+/remappings.txt
+/test
+EOF
+
+ cat > "$work/src/lib/LibThing.sol" <<'EOF'
+// SPDX-License-Identifier: LicenseRef-DCL-1.0
+pragma solidity ^0.8.25;
+
+library LibThing {
+ function one() internal pure returns (uint256) {
+ return 1;
+ }
+}
+EOF
+
+ cat > "$work/test/concrete/Helper.sol" <<'EOF'
+// SPDX-License-Identifier: LicenseRef-DCL-1.0
+pragma solidity =0.8.25;
+
+contract Helper {}
+EOF
+
+ # The worked example a consumer copies. It publishes; the tree it imports from
+ # does not.
+ cat > "$work/script/Build.sol" <<'EOF'
+// SPDX-License-Identifier: LicenseRef-DCL-1.0
+pragma solidity =0.8.25;
+
+import {LibThing} from "../src/lib/LibThing.sol";
+import {Helper} from "../test/concrete/Helper.sol";
+
+contract Build {
+ function run() external returns (uint256) {
+ new Helper();
+ return LibThing.one();
+ }
+}
+EOF
+}
+
+teardown() {
+ rm -rf "$work" "$scratch"
+}
+
+@test "a published file importing an excluded path fails the build" {
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"rain-test-package~0.1.0 does not build as published"* ]]
+ # The repo tree is complete, so this import is only unresolvable in the
+ # package — which is the whole reason a repo-side build cannot see it.
+ [[ "$output" == *"test/concrete/Helper.sol"* ]]
+ [ -f "$work/src/lib/LibThing.sol" ]
+}
+
+@test "the same package builds once the imported file publishes too" {
+ mkdir -p "$work/src/concrete"
+ mv "$work/test/concrete/Helper.sol" "$work/src/concrete/Helper.sol"
+ sed -i 's#"../test/concrete/Helper.sol"#"../src/concrete/Helper.sol"#' "$work/script/Build.sol"
+
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"clean — rain-test-package~0.1.0 builds as published"* ]]
+ [[ "$output" == *"3 Solidity files"* ]]
+}
+
+@test "the scratch tree carries the build config the package does not ship" {
+ mkdir -p "$work/src/concrete"
+ mv "$work/test/concrete/Helper.sol" "$work/src/concrete/Helper.sol"
+ sed -i 's#"../test/concrete/Helper.sol"#"../src/concrete/Helper.sol"#' "$work/script/Build.sol"
+ printf 'some-remapping/=dependencies/some-remapping/\n' > "$work/remappings.txt"
+
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 0 ]
+ # A clean run removes the scratch tree, so re-run it against a build that
+ # cannot succeed to inspect what the tree was given.
+ printf 'import {Nope} from "./Nope.sol";\n' >> "$work/src/lib/LibThing.sol"
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+ [ "$status" -eq 1 ]
+ [ -f "$scratch/src/lib/LibThing.sol" ]
+ [ ! -d "$scratch/test" ]
+ run cat "$scratch/foundry.toml"
+ [[ "$output" == *"rain-test-package"* ]]
+ run cat "$scratch/remappings.txt"
+ [[ "$output" == *"some-remapping/=dependencies/some-remapping/"* ]]
+}
+
+@test "a repo that publishes no package is skipped rather than built" {
+ cat > "$work/foundry.toml" <<'EOF'
+[profile.default]
+src = 'src'
+EOF
+
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"declares no [package] name and version"* ]]
+ [[ "$output" == *"skipping"* ]]
+}
+
+@test "a half-declared package is skipped rather than built" {
+ # Both fields are required to name what publishes. The fixture's tree fails to
+ # build as published, so either half alone reaching the build is exit 1 here.
+ sed -i '/^version = /d' "$work/foundry.toml"
+
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"declares no [package] name and version"* ]]
+ [[ "$output" == *"skipping"* ]]
+
+ sed -i 's/^name = .*/version = "0.1.0"/' "$work/foundry.toml"
+
+ run rainix-static soldeer-package-build --root "$work" --scratch "$scratch"
+
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"declares no [package] name and version"* ]]
+ [[ "$output" == *"skipping"* ]]
+}