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"* ]] +}