diff --git a/docs/site/docs-en/reference/cli.md b/docs/site/docs-en/reference/cli.md index 79428b1c..11feb453 100644 --- a/docs/site/docs-en/reference/cli.md +++ b/docs/site/docs-en/reference/cli.md @@ -84,6 +84,47 @@ At the end it lists the commits, the files written and the tags created, then de --- +## `ferrflow snapshot` + +Stamp every package with pending changes with a throwaway pre-release version, for a PR preview build. + +```bash +ferrflow snapshot --tag [--from-zero] [--no-git] [--run-hooks] +``` + +| Flag | Description | +| ---------------- | ------------------------------------------------------------------------ | +| `--tag ` | Pre-release suffix, e.g. `pr-123`. Must be a valid semver pre-release | +| `--from-zero` | Use `0.0.0-.` instead of the computed next version | +| `--no-git` | Skip the clean working tree check, for checkouts CI already modified | +| `--run-hooks` | Run the `pre_publish` and `post_publish` hooks with the snapshot version | + +The next version is computed from every commit since each package's last release, then its pre-release part is replaced: `1.3.0` becomes `1.3.0-pr-123.abc1234`, where `abc1234` is the short HEAD commit. Only versioned files are written, plus lockfiles when `updateLockfiles` is on. There is no changelog, commit, tag or push, and configured publishers do not run, so nothing can move a `latest` dist-tag. With `--dry-run` nothing is written. + +It prints one JSON object on stdout: + +```json +{ + "tag": "pr-123", + "commit": "abc1234", + "dry_run": false, + "packages": [ + { + "name": "cli", + "version": "1.3.0-pr-123.abc1234", + "previous_version": "1.2.0" + } + ] +} +``` + +```bash +ferrflow snapshot --tag "pr-$PR_NUMBER" > snapshot.json +npm publish --tag "pr-$PR_NUMBER" +``` + +--- + ## `ferrflow check` Preview what `ferrflow release` would do without making any changes. diff --git a/docs/site/docs-fr/reference/cli.md b/docs/site/docs-fr/reference/cli.md index 8ce68a87..401556be 100644 --- a/docs/site/docs-fr/reference/cli.md +++ b/docs/site/docs-fr/reference/cli.md @@ -53,6 +53,47 @@ Avec `releaseCommitMode: "pr"`, le clone reçoit le commit et les tags qui arriv --- +## `ferrflow snapshot` + +Donner à chaque package qui a des changements en attente une version de pré-release jetable, pour un build de preview de PR. + +```bash +ferrflow snapshot --tag [--from-zero] [--no-git] [--run-hooks] +``` + +| Option | Description | +| ----------------- | ------------------------------------------------------------------------------------- | +| `--tag ` | Suffixe de pré-release, par exemple `pr-123`. Doit être une pré-release semver valide | +| `--from-zero` | Utiliser `0.0.0-.` au lieu de la prochaine version calculée | +| `--no-git` | Ne pas exiger un working tree propre, pour un checkout déjà modifié par la CI | +| `--run-hooks` | Exécuter les hooks `pre_publish` et `post_publish` avec la version snapshot | + +La prochaine version est calculée à partir de tous les commits depuis la dernière release de chaque package, puis sa partie pré-release est remplacée : `1.3.0` devient `1.3.0-pr-123.abc1234`, où `abc1234` est le commit HEAD abrégé. Seuls les fichiers versionnés sont écrits, ainsi que les lockfiles quand `updateLockfiles` est activé. Pas de changelog, de commit, de tag ni de push, et les publishers configurés ne tournent pas, donc rien ne peut déplacer un dist-tag `latest`. Avec `--dry-run`, rien n'est écrit. + +La commande affiche un objet JSON sur stdout : + +```json +{ + "tag": "pr-123", + "commit": "abc1234", + "dry_run": false, + "packages": [ + { + "name": "cli", + "version": "1.3.0-pr-123.abc1234", + "previous_version": "1.2.0" + } + ] +} +``` + +```bash +ferrflow snapshot --tag "pr-$PR_NUMBER" > snapshot.json +npm publish --tag "pr-$PR_NUMBER" +``` + +--- + ## `ferrflow check` Prévisualiser ce que `ferrflow release` ferait sans effectuer de changements. diff --git a/src/cli.rs b/src/cli.rs index 72952db2..3e5af78d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -84,6 +84,22 @@ pub enum Commands { #[arg(long, help = "Keep the clone and print its path")] keep: bool, }, + /// Stamp pending packages with a throwaway `-.` version for a PR preview. + /// Writes versioned files only: no changelog, commit, tag or push. + Snapshot { + /// Pre-release suffix, e.g. `pr-123`. + #[arg(long, value_name = "SUFFIX")] + tag: String, + /// Use `0.0.0-.` instead of the computed next version. + #[arg(long)] + from_zero: bool, + /// Skip the clean working tree check. + #[arg(long)] + no_git: bool, + /// Run the pre_publish and post_publish hooks. Configured publishers are not run. + #[arg(long)] + run_hooks: bool, + }, Publish { packages: Vec, #[arg(short = 'a', long)] @@ -226,6 +242,7 @@ impl Commands { Commands::Check { .. } => "check", Commands::Release { .. } => "release", Commands::ShadowRelease { .. } => "shadow-release", + Commands::Snapshot { .. } => "snapshot", Commands::Publish { .. } => "publish", Commands::Changelog => "changelog", Commands::Init { .. } => "init", @@ -317,6 +334,22 @@ impl Cli { Commands::ShadowRelease { keep } => { crate::shadow::run(self.config.as_deref(), self.verbose, keep) } + Commands::Snapshot { + tag, + from_zero, + no_git, + run_hooks, + } => crate::monorepo::snapshot( + self.config.as_deref(), + &crate::monorepo::SnapshotOptions { + tag: &tag, + from_zero, + no_git, + run_hooks, + dry_run: self.dry_run, + verbose: self.verbose, + }, + ), Commands::Publish { packages, all } => crate::publish::run( self.config.as_deref(), &packages, diff --git a/src/monorepo/check.rs b/src/monorepo/check.rs index 7390f4fd..dea0fda0 100644 --- a/src/monorepo/check.rs +++ b/src/monorepo/check.rs @@ -101,11 +101,13 @@ pub(super) fn plan_json(config_path: Option<&Path>, channel: Option<&str>) -> Re let repo = open_repo(&std::env::current_dir()?)?; let root = get_repo_root(&repo)?; let config = Config::load(&root, config_path)?; - let mut timing = Timing::new(false); + plan_json_at(&root, &config, channel) +} +pub(super) fn plan_json_at(root: &Path, config: &Config, channel: Option<&str>) -> Result { let out = run_release_logic( - &root, - &config, + root, + config, true, false, true, @@ -117,7 +119,7 @@ pub(super) fn plan_json(config_path: Option<&Path>, channel: Option<&str>) -> Re false, false, false, - &mut timing, + &mut Timing::new(false), )?; Ok(out.json.unwrap_or_else(|| r#"{"packages":[]}"#.to_string())) diff --git a/src/monorepo/mod.rs b/src/monorepo/mod.rs index bcc4f603..e3fb4be1 100644 --- a/src/monorepo/mod.rs +++ b/src/monorepo/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod preview; mod release; pub(crate) mod release_output; pub(crate) mod run; +mod snapshot; mod types; mod util; mod version_source; @@ -18,6 +19,7 @@ pub use graph_report::run as graph; pub use plan_interactive::run as plan_interactive; pub use release::release; pub use run::why; +pub use snapshot::{SnapshotOptions, snapshot}; #[cfg(test)] mod tests; diff --git a/src/monorepo/snapshot.rs b/src/monorepo/snapshot.rs new file mode 100644 index 00000000..a4fa99df --- /dev/null +++ b/src/monorepo/snapshot.rs @@ -0,0 +1,221 @@ +use anyhow::{Context, Result, bail}; +use semver::{BuildMetadata, Prerelease, Version}; +use serde::Serialize; +use std::path::Path; + +use crate::config::{Config, PackageConfig}; +use crate::formats::write_version; +use crate::git::{get_repo_root, open_repo}; +use crate::hooks::{HookContext, HookPackage, HookPoint, resolve_hook, resolve_policy, run_hook}; + +use super::check::plan_json_at; +use super::run::refresh_lockfiles; +use super::types::{CheckPackage, CheckResult}; +use super::util::collect_dirty_files; + +pub struct SnapshotOptions<'a> { + pub tag: &'a str, + pub from_zero: bool, + pub no_git: bool, + pub run_hooks: bool, + pub dry_run: bool, + pub verbose: bool, +} + +#[derive(Debug, Serialize)] +struct SnapshotReport { + tag: String, + commit: String, + dry_run: bool, + packages: Vec, +} + +#[derive(Debug, Serialize)] +struct SnapshotPackage { + name: String, + version: String, + previous_version: String, + #[serde(skip)] + bump_type: String, +} + +pub fn snapshot(config_path: Option<&Path>, opts: &SnapshotOptions<'_>) -> Result<()> { + let repo = open_repo(&std::env::current_dir()?)?; + let root = get_repo_root(&repo)?; + let config = Config::load(&root, config_path)?; + let report = run(&root, config, opts)?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) +} + +fn run(root: &Path, mut config: Config, opts: &SnapshotOptions<'_>) -> Result { + config.workspace.recover_missed_releases = true; + let suffix = parse_suffix(opts.tag)?; + let repo = open_repo(root)?; + + if !opts.no_git { + let mut dirty: Vec = collect_dirty_files(&repo).into_iter().collect(); + if !dirty.is_empty() { + dirty.sort(); + bail!( + "working tree has uncommitted changes ({}); commit them or pass --no-git", + dirty.join(", ") + ); + } + } + + let commit = repo + .head_id() + .context("snapshot needs a commit to stamp the version with")? + .to_string()[..7] + .to_string(); + + let plan: CheckResult = serde_json::from_str(&plan_json_at(root, &config, None)?)?; + let packages = plan + .packages + .into_iter() + .filter_map(|pkg| snapshot_package(pkg, &suffix, &commit, opts.from_zero).transpose()) + .collect::>>()?; + + if !opts.dry_run { + for pkg in &packages { + let pkg_config = package_config(&config, &pkg.name)?; + for vf in &pkg_config.versioned_files { + write_version(vf, root, &pkg.version)?; + } + if pkg_config.effective_update_lockfiles(&config.workspace) { + refresh_lockfiles(pkg_config, root, &mut Vec::new(), &mut Vec::new()); + } + } + } + + if opts.run_hooks { + for point in [HookPoint::PrePublish, HookPoint::PostPublish] { + run_publish_hooks(root, &config, &packages, opts, point)?; + } + } + + Ok(SnapshotReport { + tag: opts.tag.to_string(), + commit, + dry_run: opts.dry_run, + packages, + }) +} + +fn parse_suffix(tag: &str) -> Result { + if tag.is_empty() { + bail!("--tag must not be empty"); + } + Prerelease::new(tag).with_context(|| { + format!("--tag `{tag}` is not a valid semver pre-release (use [0-9A-Za-z-], dot-separated)") + }) +} + +fn snapshot_package( + pkg: CheckPackage, + suffix: &Prerelease, + commit: &str, + from_zero: bool, +) -> Result> { + let Some(next) = pkg.next_version else { + return Ok(None); + }; + let version = snapshot_version(&next, suffix, commit, from_zero) + .with_context(|| format!("package '{}': cannot snapshot version `{next}`", pkg.name))?; + Ok(Some(SnapshotPackage { + name: pkg.name, + version, + previous_version: pkg.current_version, + bump_type: pkg.bump_type, + })) +} + +fn snapshot_version( + next: &str, + suffix: &Prerelease, + commit: &str, + from_zero: bool, +) -> Result { + let mut version = if from_zero { + Version::new(0, 0, 0) + } else { + Version::parse(next)? + }; + let commit_id = if commit.starts_with('0') && commit.bytes().all(|b| b.is_ascii_digit()) { + format!("g{commit}") + } else { + commit.to_string() + }; + version.pre = Prerelease::new(&format!("{suffix}.{commit_id}"))?; + version.build = BuildMetadata::EMPTY; + Ok(version.to_string()) +} + +fn package_config<'a>(config: &'a Config, name: &str) -> Result<&'a PackageConfig> { + config + .packages + .iter() + .find(|p| p.name == name) + .with_context(|| format!("package '{name}' not found in config")) +} + +fn run_publish_hooks( + root: &Path, + config: &Config, + packages: &[SnapshotPackage], + opts: &SnapshotOptions<'_>, + point: HookPoint, +) -> Result<()> { + let all_packages: Vec = packages + .iter() + .map(|p| HookPackage { + name: p.name.clone(), + version: p.version.clone(), + bump: p.bump_type.clone(), + }) + .collect(); + let ws_hooks = config.workspace.hooks.as_ref(); + + for pkg in packages { + let pkg_config = package_config(config, &pkg.name)?; + let pkg_hooks = pkg_config.hooks.as_ref(); + let Some(cmd) = resolve_hook(pkg_hooks, ws_hooks, point) else { + continue; + }; + let ctx = HookContext { + package: pkg.name.clone(), + old_version: pkg.previous_version.clone(), + new_version: pkg.version.clone(), + bump_type: pkg.bump_type.clone(), + tag: String::new(), + dry_run: opts.dry_run, + package_path: root + .join(pkg_config.path.trim_start_matches("./")) + .to_string_lossy() + .into_owned(), + channel: Some(opts.tag.to_string()), + error_code: None, + monorepo: config.is_monorepo(), + is_prerelease: true, + changelog: String::new(), + commits: Vec::new(), + bumped_files: Vec::new(), + all_packages: all_packages.clone(), + release_url: None, + }; + run_hook( + point, + &cmd, + &ctx, + resolve_policy(pkg_hooks, ws_hooks), + opts.dry_run, + opts.verbose, + root, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/monorepo/snapshot/tests.rs b/src/monorepo/snapshot/tests.rs new file mode 100644 index 00000000..b67ed7c7 --- /dev/null +++ b/src/monorepo/snapshot/tests.rs @@ -0,0 +1,331 @@ +use super::*; +use crate::test_utils::{commit_file, git, init_repo}; + +fn suffix(s: &str) -> Prerelease { + Prerelease::new(s).unwrap() +} + +fn opts(tag: &str) -> SnapshotOptions<'_> { + SnapshotOptions { + tag, + from_zero: false, + no_git: false, + run_hooks: false, + dry_run: false, + verbose: false, + } +} + +#[test] +fn version_appends_suffix_and_commit_to_next_version() { + assert_eq!( + snapshot_version("1.2.3", &suffix("pr-123"), "abc1234", false).unwrap(), + "1.2.3-pr-123.abc1234" + ); +} + +#[test] +fn version_replaces_existing_prerelease_and_build_metadata() { + assert_eq!( + snapshot_version("2.0.0-beta.4+build.7", &suffix("pr-9"), "abc1234", false).unwrap(), + "2.0.0-pr-9.abc1234" + ); +} + +#[test] +fn from_zero_ignores_the_computed_version() { + assert_eq!( + snapshot_version("3.4.5", &suffix("pr-1"), "abc1234", true).unwrap(), + "0.0.0-pr-1.abc1234" + ); +} + +#[test] +fn numeric_commit_with_leading_zero_stays_valid_semver() { + let v = snapshot_version("1.0.0", &suffix("pr-1"), "0123456", false).unwrap(); + assert_eq!(v, "1.0.0-pr-1.g0123456"); + Version::parse(&v).unwrap(); +} + +#[test] +fn non_semver_next_version_is_an_error() { + assert!(snapshot_version("not-a-version", &suffix("pr-1"), "abc1234", false).is_err()); +} + +#[test] +fn suffix_must_be_a_valid_prerelease() { + assert!(parse_suffix("pr-123").is_ok()); + assert!(parse_suffix("pr.123").is_ok()); + assert!(parse_suffix("").is_err()); + assert!(parse_suffix("pr_123").is_err()); + assert!(parse_suffix("pr..1").is_err()); + assert!(parse_suffix("pr/1").is_err()); +} + +fn package_json(version: &str) -> String { + format!("{{\n \"name\": \"x\",\n \"version\": \"{version}\"\n}}\n") +} + +fn monorepo(extra_ws: &str) -> (tempfile::TempDir, Config) { + let (dir, _repo) = init_repo(); + let root = dir.path(); + std::fs::write( + root.join(".ferrflow"), + format!( + r#"{{"workspace": {{"releaseCommitMode": "none"{extra_ws}}}, "package": [ + {{"name": "api", "path": "api", "versionedFiles": [{{"path": "api/package.json", "format": "json"}}]}}, + {{"name": "web", "path": "web", "versionedFiles": [{{"path": "web/package.json", "format": "json"}}]}} + ]}}"# + ), + ) + .unwrap(); + for pkg in ["api", "web"] { + std::fs::create_dir_all(root.join(pkg)).unwrap(); + std::fs::write(root.join(pkg).join("package.json"), package_json("1.0.0")).unwrap(); + } + git(root, &["add", "."]); + git(root, &["commit", "-m", "chore: init"]); + git(root, &["tag", "api@v1.0.0"]); + git(root, &["tag", "web@v1.0.0"]); + commit_file(root, "api/index.js", "x", "feat(api): add x", 1_900_000_000); + + let config = Config::load(root, Some(&root.join(".ferrflow"))).unwrap(); + (dir, config) +} + +fn head(root: &Path) -> String { + git(root, &["rev-parse", "HEAD"]).trim().to_string() +} + +fn version_in(root: &Path, pkg: &str) -> String { + let raw = std::fs::read_to_string(root.join(pkg).join("package.json")).unwrap(); + serde_json::from_str::(&raw).unwrap()["version"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn snapshots_only_packages_with_pending_changes() { + let (dir, config) = monorepo(""); + let root = dir.path(); + let sha = head(root)[..7].to_string(); + + let report = run(root, config, &opts("pr-123")).unwrap(); + + assert_eq!(report.commit, sha); + assert_eq!(report.packages.len(), 1); + assert_eq!(report.packages[0].name, "api"); + assert_eq!(report.packages[0].previous_version, "1.0.0"); + assert_eq!(report.packages[0].version, format!("1.1.0-pr-123.{sha}")); + assert_eq!(version_in(root, "api"), format!("1.1.0-pr-123.{sha}")); + assert_eq!(version_in(root, "web"), "1.0.0"); +} + +#[test] +fn counts_every_commit_since_the_last_release_not_just_head() { + let (dir, config) = monorepo(""); + let root = dir.path(); + commit_file(root, "README.md", "docs", "docs: readme", 1_900_000_100); + + let report = run(root, config, &opts("pr-2")).unwrap(); + + assert_eq!(report.packages.len(), 1); + assert_eq!(report.packages[0].name, "api"); +} + +#[test] +fn leaves_history_and_tags_untouched() { + let (dir, config) = monorepo(""); + let root = dir.path(); + let before_head = head(root); + let before_tags = git(root, &["tag", "--list"]); + + run(root, config, &opts("pr-1")).unwrap(); + + assert_eq!(head(root), before_head); + assert_eq!(git(root, &["tag", "--list"]), before_tags); +} + +#[test] +fn dry_run_writes_nothing() { + let (dir, config) = monorepo(""); + let root = dir.path(); + + let report = run( + root, + config, + &SnapshotOptions { + dry_run: true, + ..opts("pr-1") + }, + ) + .unwrap(); + + assert!(report.dry_run); + assert!(report.packages[0].version.starts_with("1.1.0-pr-1.")); + assert_eq!(version_in(root, "api"), "1.0.0"); +} + +#[test] +fn dirty_tree_is_refused_without_no_git() { + let (dir, config) = monorepo(""); + let root = dir.path(); + std::fs::write(root.join("api/index.js"), "edited").unwrap(); + + let err = run(root, config, &opts("pr-1")).unwrap_err(); + + assert!(format!("{err:#}").contains("--no-git"), "{err:#}"); + assert_eq!(version_in(root, "api"), "1.0.0"); +} + +#[test] +fn no_git_runs_on_a_dirty_tree() { + let (dir, config) = monorepo(""); + let root = dir.path(); + std::fs::write(root.join("api/index.js"), "edited").unwrap(); + + let report = run( + root, + config, + &SnapshotOptions { + no_git: true, + ..opts("pr-1") + }, + ) + .unwrap(); + + assert_eq!(report.packages.len(), 1); + assert!(version_in(root, "api").starts_with("1.1.0-pr-1.")); +} + +#[test] +fn invalid_tag_fails_before_touching_files() { + let (dir, config) = monorepo(""); + let root = dir.path(); + + assert!(run(root, config, &opts("pr_1")).is_err()); + assert_eq!(version_in(root, "api"), "1.0.0"); +} + +#[test] +fn json_report_shape_is_stable() { + let (dir, config) = monorepo(""); + let report = run(dir.path(), config, &opts("pr-7")).unwrap(); + + let json = serde_json::to_value(&report).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort(); + assert_eq!(keys, ["commit", "dry_run", "packages", "tag"]); + assert_eq!(json["tag"], "pr-7"); + + let mut pkg_keys: Vec<&str> = json["packages"][0] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + pkg_keys.sort(); + assert_eq!(pkg_keys, ["name", "previous_version", "version"]); +} + +#[test] +fn publish_hooks_see_the_snapshot_version() { + let (dir, config) = monorepo( + r#", "hooks": {"prePublish": "echo pre $FERRFLOW_PACKAGE $FERRFLOW_NEW_VERSION $FERRFLOW_CHANNEL >> hooks.log", "postPublish": "echo post $FERRFLOW_PACKAGE >> hooks.log"}"#, + ); + let root = dir.path(); + let sha = head(root)[..7].to_string(); + + run( + root, + config, + &SnapshotOptions { + run_hooks: true, + no_git: true, + ..opts("pr-5") + }, + ) + .unwrap(); + let log = std::fs::read_to_string(root.join("hooks.log")).unwrap(); + assert_eq!( + log, + format!("pre api 1.1.0-pr-5.{sha} pr-5\npost api\n"), + "pre_publish must fire for every package before any post_publish" + ); +} + +#[test] +fn hooks_do_not_run_unless_asked() { + let (dir, config) = monorepo(r#", "hooks": {"prePublish": "touch hooks.log"}"#); + let root = dir.path(); + + run( + root, + config, + &SnapshotOptions { + no_git: true, + ..opts("pr-5") + }, + ) + .unwrap(); + + assert!(!root.join("hooks.log").exists()); +} + +#[test] +fn lockfile_follows_the_snapshot_version_when_update_lockfiles_is_on() { + if std::process::Command::new("cargo") + .arg("--version") + .output() + .is_err() + { + return; + } + let (dir, _repo) = init_repo(); + let root = dir.path(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), "").unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"alpha\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\n", + ) + .unwrap(); + std::fs::write( + root.join(".ferrflow"), + r#"{"workspace": {"releaseCommitMode": "none", "updateLockfiles": true}, "package": [ + {"name": "alpha", "path": ".", "versionedFiles": [{"path": "Cargo.toml", "format": "toml"}]} + ]}"#, + ) + .unwrap(); + let gen_lock = std::process::Command::new("cargo") + .current_dir(root) + .args(["generate-lockfile", "--offline"]) + .output() + .unwrap(); + assert!(gen_lock.status.success()); + git(root, &["add", "."]); + git(root, &["commit", "-m", "chore: init"]); + git(root, &["tag", "v1.0.0"]); + commit_file( + root, + "src/lib.rs", + "pub fn x() {}", + "feat: x", + 1_900_000_000, + ); + let config = Config::load(root, Some(&root.join(".ferrflow"))).unwrap(); + + let report = run(root, config, &opts("pr-3")).unwrap(); + + let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); + assert!( + lock.contains(&format!("version = \"{}\"", report.packages[0].version)), + "Cargo.lock must carry the snapshot version:\n{lock}" + ); +}