Skip to content
Open
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
30 changes: 30 additions & 0 deletions .github/actions/soldeer-package-build/action.yml
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions .github/workflows/rainix-sol-static.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
19 changes: 18 additions & 1 deletion rainix-static/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>] [--scratch <dir>]
// 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 <dir>] [--github-env <file>] [--samples N]
// [--timeout N] [--no-archive]
// Pick a working fork RPC endpoint per network and export it as
Expand All @@ -60,6 +70,7 @@ mod no_submodules;
mod prompt_cap;
mod rpc_preflight;
mod soldeer_gate;
mod soldeer_package_build;

use std::path::Path;

Expand Down Expand Up @@ -156,6 +167,11 @@ fn main() {
.unwrap_or_else(|| fail("soldeer-gate: --package <name> 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());
Expand Down Expand Up @@ -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);
}
Expand Down
53 changes: 32 additions & 21 deletions rainix-static/src/soldeer_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,21 @@ use std::process::Command;
/// A file entry pulled from a package zip: (name, bytes).
type Entry = (String, Vec<u8>);

/// 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 `^<key>[[: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<u8> {
Expand Down Expand Up @@ -68,7 +74,7 @@ fn norm_hash(entries: &mut Vec<Entry>) -> String {
}

/// Read a zip into (name, bytes) entries, skipping directory entries.
fn read_zip(path: &Path) -> Vec<Entry> {
pub(crate) fn read_zip(path: &Path) -> Vec<Entry> {
let file = std::fs::File::open(path)
.unwrap_or_else(|e| fail(&format!("open {}: {e}", path.display())));
let mut archive = zip::ZipArchive::new(file)
Expand Down Expand Up @@ -135,12 +141,12 @@ fn parse_registry(json: &str) -> (Option<String>, Option<String>) {
(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<String> {
/// First `[package].<key>` 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<String> {
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('"')?;
Expand All @@ -150,6 +156,11 @@ fn read_local_version(dir: &Path) -> Option<String> {
None
}

/// The in-dev, unpublished `[package].version` from foundry.toml.
fn read_local_version(dir: &Path) -> Option<String> {
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(".");
Expand Down Expand Up @@ -183,16 +194,16 @@ pub(crate) fn run(pkg: &str, gh_out: Option<&str>) {

// Local package content: `forge soldeer push --dry-run` writes
// <cwd-basename>.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()) {
Expand Down Expand Up @@ -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}")));
Expand All @@ -256,10 +267,10 @@ fn curl_stdout(url: &str) -> Option<String> {
.then(|| String::from_utf8_lossy(&out.stdout).to_string())
}

/// Paths of `*.zip` files in the cwd.
fn cwd_zips() -> Vec<PathBuf> {
/// Paths of `*.zip` files directly in `dir`.
fn zips_in(dir: &Path) -> Vec<PathBuf> {
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") {
Expand All @@ -270,15 +281,15 @@ fn cwd_zips() -> Vec<PathBuf> {
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<PathBuf> {
cwd_zips()
/// Most recently modified `*.zip` in `dir` (the dry-run output).
pub(crate) fn newest_zip(dir: &Path) -> Option<PathBuf> {
zips_in(dir)
.into_iter()
.max_by_key(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
}
Expand Down
Loading
Loading