From cb1067df01137d0965a4152c57fb0fc33026265d Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 18:15:07 +0100 Subject: [PATCH 1/4] fix(cache): keep cache file names under the 255-byte limit The file name joined four hashes and the variant (about 245 bytes), and the staging file appended ..tmp. With a seven-digit PID the staging name passed the 255-byte filename limit, the write failed silently, and check never hit its cache. Name entries by a hash of the key instead; old entries age out through the existing prune. Signed-off-by: BryanFRD --- src/cache.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 7281a9c5..925c2933 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -32,10 +32,15 @@ pub struct CacheKey { impl CacheKey { fn filename(&self) -> String { - format!( - "{}-{}-{}-{}-{}.json", - self.head, self.tags_hash, self.config_hash, self.files_hash, self.variant - ) + let joined = [ + self.head.as_str(), + &self.tags_hash, + &self.config_hash, + &self.files_hash, + self.variant, + ] + .join("\n"); + format!("{}.json", sha256_hex(joined.as_bytes())) } } @@ -305,6 +310,23 @@ mod tests { assert_ne!(base, json_variant.filename()); } + #[test] + fn a_full_size_key_round_trips_with_a_seven_digit_pid_suffix() { + let hash = "a".repeat(64); + let k = CacheKey { + head: hash.clone(), + tags_hash: hash.clone(), + config_hash: hash.clone(), + files_hash: hash, + variant: "text", + }; + assert!(k.filename().len() + ".4194304.tmp".len() <= 255); + + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), &k, &sample()); + assert!(read(dir.path(), &k).is_some()); + } + #[test] fn prune_caps_total_entries() { let dir = tempfile::tempdir().unwrap(); From cb48c688f7c05acd90d52d498afc2134eee1d7e6 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 18:15:07 +0100 Subject: [PATCH 2/4] feat(action): expose release outputs Add --output-format github|dotenv|json and --output-file to ferrflow release. They report whether the run created tags and which packages it released, so later CI steps can gate on it. Dry runs and PR-mode runs that only open the release PR report released=false. The action passes --output-format github and exposes the released and releases outputs. Closes #205 Signed-off-by: BryanFRD --- README.md | 10 ++ action.yml | 16 ++++ docs/site/docs-en/ci/github-actions.md | 34 +++++-- docs/site/docs-en/ci/gitlab-ci.md | 21 +++++ docs/site/docs-en/reference/cli.md | 16 ++-- docs/site/docs-fr/ci/github-actions.md | 34 +++++-- docs/site/docs-fr/ci/gitlab-ci.md | 21 +++++ docs/site/docs-fr/reference/cli.md | 2 + src/cli.rs | 77 +++++++++++++--- src/monorepo/check.rs | 10 +- src/monorepo/mod.rs | 1 + src/monorepo/release.rs | 23 +++-- src/monorepo/release_output.rs | 89 ++++++++++++++++++ src/monorepo/release_output/tests.rs | 122 +++++++++++++++++++++++++ src/monorepo/run/execute.rs | 6 ++ src/monorepo/run/mod.rs | 31 +++---- src/monorepo/run/output.rs | 6 ++ src/monorepo/run/publish.rs | 11 ++- src/monorepo/run/state.rs | 2 + src/monorepo/run/summary.rs | 12 +++ src/monorepo/tests.rs | 104 ++++++++++++++++++++- src/monorepo/types.rs | 2 + src/monorepo/version_command_tests.rs | 2 +- 23 files changed, 576 insertions(+), 76 deletions(-) create mode 100644 src/monorepo/release_output.rs create mode 100644 src/monorepo/release_output/tests.rs diff --git a/README.md b/README.md index d0652a35..8319cfa4 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,16 @@ release: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` +With the action, gate post-release steps on its `released` output (`releases` holds a JSON array of the tagged packages): + +```yaml +- uses: FerrLabs/FerrFlow@v7 + id: release +- name: Publish + if: steps.release.outputs.released == 'true' + run: npm publish +``` + ### Using the hosted bot (ferrflow[bot]) Install the [FerrFlow GitHub App](https://github.com/apps/ferrflow) on your repo or org, then opt in with `bot: true`. Release commits, tags, and GitHub Releases are authored by `ferrflow[bot]` and downstream workflows triggered by those events run normally (unlike the default `GITHUB_TOKEN`, which suppresses them). diff --git a/action.yml b/action.yml index 48e1504e..82b0978d 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,14 @@ inputs: required: false default: '' +outputs: + released: + description: '"true" when mode "release" tagged at least one package, otherwise "false". Always "false" on a dry run.' + value: ${{ steps.ferrflow.outputs.released }} + releases: + description: 'JSON array of the packages tagged by mode "release", each with name, version and tag. "[]" when nothing was released.' + value: ${{ steps.ferrflow.outputs.releases }} + runs: using: composite steps: @@ -192,6 +200,7 @@ runs: run: ferrflow check --comment - name: Run FerrFlow release + id: ferrflow if: inputs.mode == 'release' shell: bash env: @@ -200,6 +209,7 @@ runs: FERRFLOW_BOT_AUDIENCE: ${{ inputs.bot_audience }} INPUT_DRY_RUN: ${{ inputs.dry_run }} INPUT_FORCE_VERSION: ${{ inputs.force_version }} + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail @@ -215,6 +225,12 @@ runs: args+=(--force-version "$INPUT_FORCE_VERSION") fi + if [[ "$(ferrflow release --help)" == *--output-format* ]]; then + args+=(--output-format github) + else + echo "::warning::FerrFlow ${INPUT_VERSION} cannot report release outputs; the released and releases outputs stay empty." + fi + ferrflow "${args[@]}" - name: Run FerrFlow publishers diff --git a/docs/site/docs-en/ci/github-actions.md b/docs/site/docs-en/ci/github-actions.md index c9c02919..e262f13b 100644 --- a/docs/site/docs-en/ci/github-actions.md +++ b/docs/site/docs-en/ci/github-actions.md @@ -75,21 +75,41 @@ The action writes the key under `RUNNER_TEMP` for the job only, and it never rea ## Accessing the release output -The action exposes the new version as an output you can use in downstream steps: +In `release` mode the action exposes two outputs: + +| Output | Value | +| ---------- | -------------------------------------------------------------------------------------------------------- | +| `released` | `"true"` when at least one package was tagged, otherwise `"false"`. Always `"false"` on a dry run | +| `releases` | JSON array of the tagged packages, e.g. `[{"name":"api","version":"1.3.0","tag":"api@v1.3.0"}]`, or `[]` | + +Gate post-release steps on `released`: ```yaml title=".github/workflows/release.yml" -- uses: FerrLabs/ferrflow@v5 - id: ferrflow +- uses: FerrLabs/ferrflow@v7 + id: release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- name: Build Docker image - if: steps.ferrflow.outputs.version != '' +- name: Publish + if: steps.release.outputs.released == 'true' + run: npm publish +``` + +In a monorepo, read `releases` to act on each package that went out: + +```yaml title=".github/workflows/release.yml" +- name: Build released packages + if: steps.release.outputs.released == 'true' + env: + RELEASES: ${{ steps.release.outputs.releases }} run: | - docker build -t myimage:${{ steps.ferrflow.outputs.version }} . - docker push myimage:${{ steps.ferrflow.outputs.version }} + echo "$RELEASES" | jq -r '.[].name' | while read -r pkg; do + pnpm --filter "$pkg" build + done ``` +With `releaseCommitMode: "pr"`, the run that only opens the release pull request tags nothing and reports `released=false`. The run that tags the merged release reports it. + ## Skip CI on release commits FerrFlow commits version bumps with `[skip ci]` in the message by default to prevent infinite loops. No extra configuration needed. diff --git a/docs/site/docs-en/ci/gitlab-ci.md b/docs/site/docs-en/ci/gitlab-ci.md index 92750d1d..68bf9019 100644 --- a/docs/site/docs-en/ci/gitlab-ci.md +++ b/docs/site/docs-en/ci/gitlab-ci.md @@ -108,3 +108,24 @@ If you store that token as a **protected** variable, GitLab only exposes it to p ## GitLab Releases When `GITLAB_TOKEN` is set, FerrFlow creates a GitLab Release with the generated changelog as release notes, matching the behaviour of the GitHub integration. + +## Gating later jobs on a release + +`--output-format dotenv` writes `FERRFLOW_RELEASED` and `FERRFLOW_RELEASES` (a JSON array of `name`, `version`, `tag`) to a file GitLab can pass to later jobs: + +```yaml title=".gitlab-ci.yml" +release: + script: + - ferrflow release --output-format dotenv --output-file ferrflow.env + artifacts: + reports: + dotenv: ferrflow.env + +build: + needs: [release] + script: + - if [ "$FERRFLOW_RELEASED" != "true" ]; then exit 0; fi + - pnpm build +``` + +Dotenv variables reach the script of a later job, not its `rules:`, which GitLab evaluates when the pipeline is created. diff --git a/docs/site/docs-en/reference/cli.md b/docs/site/docs-en/reference/cli.md index 5104efce..79428b1c 100644 --- a/docs/site/docs-en/reference/cli.md +++ b/docs/site/docs-en/reference/cli.md @@ -11,13 +11,15 @@ Run the full release pipeline: bump versions, update changelogs, commit, tag, pu ferrflow release [OPTIONS] ``` -| Flag | Description | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--force` | Allow floating tags to move backward to a lower version | -| `--force-version ` | Force a specific version, skipping commit analysis. Format: `VERSION` (single repo) or `NAME@VERSION` (monorepo) | -| `--channel ` | Pre-release channel override (e.g. `beta`, `rc`, `dev`) | -| `--draft` | Create releases as drafts (GitHub only). A later `ferrflow release` without `--draft` detects and publishes existing drafts automatically | -| `--force-unlock` | Break an existing `.git/ferrflow.lock` before acquiring it. Use only when no other `ferrflow release` is running — e.g. after a crash left the lockfile behind | +| Flag | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--force` | Allow floating tags to move backward to a lower version | +| `--force-version ` | Force a specific version, skipping commit analysis. Format: `VERSION` (single repo) or `NAME@VERSION` (monorepo) | +| `--channel ` | Pre-release channel override (e.g. `beta`, `rc`, `dev`) | +| `--draft` | Create releases as drafts (GitHub only). A later `ferrflow release` without `--draft` detects and publishes existing drafts automatically | +| `--force-unlock` | Break an existing `.git/ferrflow.lock` before acquiring it. Use only when no other `ferrflow release` is running — e.g. after a crash left the lockfile behind | +| `--output-format ` | After the run, write whether a release happened and which packages were tagged: `github` (appends to `$GITHUB_OUTPUT`), `dotenv` (`FERRFLOW_RELEASED` / `FERRFLOW_RELEASES`), or `json` | +| `--output-file ` | Destination for `--output-format`. Required for `dotenv` and `json`; `github` defaults to `$GITHUB_OUTPUT` | **What it does:** diff --git a/docs/site/docs-fr/ci/github-actions.md b/docs/site/docs-fr/ci/github-actions.md index 34ebbdad..34f911c2 100644 --- a/docs/site/docs-fr/ci/github-actions.md +++ b/docs/site/docs-fr/ci/github-actions.md @@ -75,21 +75,41 @@ L'action écrit la clé sous `RUNNER_TEMP`, le temps du job, et elle n'atteint n ## Accéder à la sortie de la release -L'action expose la nouvelle version en output que vous pouvez utiliser dans les étapes suivantes : +En mode `release`, l'action expose deux outputs : + +| Output | Valeur | +| ---------- | -------------------------------------------------------------------------------------------------------------- | +| `released` | `"true"` si au moins un package a été tagué, sinon `"false"`. Toujours `"false"` en dry run | +| `releases` | Tableau JSON des packages tagués, par exemple `[{"name":"api","version":"1.3.0","tag":"api@v1.3.0"}]`, ou `[]` | + +Conditionnez les étapes post-release sur `released` : ```yaml -- uses: FerrLabs/ferrflow@v5 - id: ferrflow +- uses: FerrLabs/ferrflow@v7 + id: release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -- name: Build Docker image - if: steps.ferrflow.outputs.version != '' +- name: Publish + if: steps.release.outputs.released == 'true' + run: npm publish +``` + +Dans un monorepo, lisez `releases` pour agir sur chaque package publié : + +```yaml +- name: Build released packages + if: steps.release.outputs.released == 'true' + env: + RELEASES: ${{ steps.release.outputs.releases }} run: | - docker build -t myimage:${{ steps.ferrflow.outputs.version }} . - docker push myimage:${{ steps.ferrflow.outputs.version }} + echo "$RELEASES" | jq -r '.[].name' | while read -r pkg; do + pnpm --filter "$pkg" build + done ``` +Avec `releaseCommitMode: "pr"`, le run qui ouvre seulement la pull request de release ne tague rien et renvoie `released=false`. C'est le run qui tague la release mergée qui la signale. + ## Skip CI sur les commits de release FerrFlow ajoute `[skip ci]` dans le message des commits de version par défaut pour éviter les boucles infinies. Aucune configuration supplémentaire nécessaire. diff --git a/docs/site/docs-fr/ci/gitlab-ci.md b/docs/site/docs-fr/ci/gitlab-ci.md index 4ea04a8c..36222442 100644 --- a/docs/site/docs-fr/ci/gitlab-ci.md +++ b/docs/site/docs-fr/ci/gitlab-ci.md @@ -95,3 +95,24 @@ Si vous stockez ce token dans une variable **protégée**, GitLab ne l'expose qu ## GitLab Releases Lorsque `GITLAB_TOKEN` est défini, FerrFlow crée une GitLab Release avec le changelog généré comme notes de release, de la même manière que l'intégration GitHub. + +## Conditionner les jobs suivants à une release + +`--output-format dotenv` écrit `FERRFLOW_RELEASED` et `FERRFLOW_RELEASES` (un tableau JSON de `name`, `version`, `tag`) dans un fichier que GitLab transmet aux jobs suivants : + +```yaml +release: + script: + - ferrflow release --output-format dotenv --output-file ferrflow.env + artifacts: + reports: + dotenv: ferrflow.env + +build: + needs: [release] + script: + - if [ "$FERRFLOW_RELEASED" != "true" ]; then exit 0; fi + - pnpm build +``` + +Les variables dotenv arrivent dans le script d'un job suivant, pas dans ses `rules:`, que GitLab évalue à la création du pipeline. diff --git a/docs/site/docs-fr/reference/cli.md b/docs/site/docs-fr/reference/cli.md index d88aec6c..8ce68a87 100644 --- a/docs/site/docs-fr/reference/cli.md +++ b/docs/site/docs-fr/reference/cli.md @@ -18,6 +18,8 @@ ferrflow release [OPTIONS] | `--channel ` | Canal de pré-release à utiliser (ex. `beta`, `rc`, `dev`) | | `--draft` | Créer les releases en brouillon (GitHub uniquement). Un `ferrflow release` ultérieur sans `--draft` détecte et publie automatiquement les brouillons existants | | `--force-unlock` | Forcer la levée d'un verrou `.git/ferrflow.lock` existant. À n'utiliser que si aucun autre `ferrflow release` n'est en cours : par exemple après un crash ayant laissé le fichier de verrou | +| `--output-format ` | Après le run, écrire si une release a eu lieu et quels packages ont été tagués : `github` (ajouté à `$GITHUB_OUTPUT`), `dotenv` (`FERRFLOW_RELEASED` / `FERRFLOW_RELEASES`) ou `json` | +| `--output-file ` | Destination de `--output-format`. Obligatoire pour `dotenv` et `json` ; `github` utilise `$GITHUB_OUTPUT` par défaut | **Ce que ça fait :** diff --git a/src/cli.rs b/src/cli.rs index 51b4cdf4..72952db2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,6 +7,7 @@ use clap_complete::Shell; use crate::config::ConfigFileFormat; use crate::doctor::DoctorFormat; use crate::logging::LogFormat; +use crate::monorepo::release_output::{ReleaseOutputFormat, ReleaseOutputTarget}; use crate::status::OutputFormat; use crate::timing::Timing; @@ -69,6 +70,12 @@ pub enum Commands { draft: bool, #[arg(long)] force_unlock: bool, + /// Write whether a release happened, and what was tagged, for the next CI step. + #[arg(long, value_enum, value_name = "FORMAT")] + output_format: Option, + /// Destination for --output-format. Defaults to $GITHUB_OUTPUT for `github`. + #[arg(long, value_name = "PATH", requires = "output_format")] + output_file: Option, }, #[command( about = "Run the full release in a throwaway clone: files, commit and tags, never pushed or published" @@ -280,19 +287,33 @@ impl Cli { channel, draft, force_unlock, - } => crate::monorepo::release( - self.config.as_deref(), - self.dry_run, - self.verbose, - json, - force, - &force_version, - &exclude, - channel.as_deref(), - draft, - force_unlock, - timing, - ), + output_format, + output_file, + } => { + let output = output_format + .map(|format| { + ReleaseOutputTarget::resolve( + format, + output_file, + std::env::var_os("GITHUB_OUTPUT"), + ) + }) + .transpose()?; + crate::monorepo::release( + self.config.as_deref(), + self.dry_run, + self.verbose, + json, + force, + &force_version, + &exclude, + channel.as_deref(), + draft, + force_unlock, + output.as_ref(), + timing, + ) + } Commands::ShadowRelease { keep } => { crate::shadow::run(self.config.as_deref(), self.verbose, keep) } @@ -462,6 +483,8 @@ mod tests { channel: None, draft: false, force_unlock: false, + output_format: None, + output_file: None, } if v.is_empty() && e.is_empty() )); } @@ -509,6 +532,34 @@ mod tests { } } + #[test] + fn parse_release_output_format() { + let cli = parse(&[ + "ferrflow", + "release", + "--output-format", + "dotenv", + "--output-file", + "ferrflow.env", + ]); + match cli.command { + Commands::Release { + output_format, + output_file, + .. + } => { + assert_eq!(output_format, Some(ReleaseOutputFormat::Dotenv)); + assert_eq!(output_file, Some(PathBuf::from("ferrflow.env"))); + } + _ => panic!("expected Release"), + } + } + + #[test] + fn output_file_requires_output_format() { + assert!(Cli::try_parse_from(["ferrflow", "release", "--output-file", "out.env"]).is_err()); + } + #[test] fn parse_release_force_version() { let cli = parse(&["ferrflow", "release", "--force-version", "api@2.0.0"]); diff --git a/src/monorepo/check.rs b/src/monorepo/check.rs index f8d774dc..7390f4fd 100644 --- a/src/monorepo/check.rs +++ b/src/monorepo/check.rs @@ -64,7 +64,7 @@ pub fn check( crate::git::write_commit_graph_if_absent(&repo); - if let (Some(key), Some(out)) = (&cache_key, &out) { + if let Some(key) = &cache_key { cache::write( &cache_dir, key, @@ -75,9 +75,7 @@ pub fn check( ); } - if let Some(out) = out { - out.print(); - } + out.print(); if comment { post_preview_comment(&repo, &config, &root); @@ -122,7 +120,5 @@ pub(super) fn plan_json(config_path: Option<&Path>, channel: Option<&str>) -> Re &mut timing, )?; - Ok(out - .and_then(|o| o.json) - .unwrap_or_else(|| r#"{"packages":[]}"#.to_string())) + Ok(out.json.unwrap_or_else(|| r#"{"packages":[]}"#.to_string())) } diff --git a/src/monorepo/mod.rs b/src/monorepo/mod.rs index a54734ed..bcc4f603 100644 --- a/src/monorepo/mod.rs +++ b/src/monorepo/mod.rs @@ -1,6 +1,7 @@ mod check; pub(crate) mod preview; mod release; +pub(crate) mod release_output; pub(crate) mod run; mod types; mod util; diff --git a/src/monorepo/release.rs b/src/monorepo/release.rs index c9edd4cc..f9ff4894 100644 --- a/src/monorepo/release.rs +++ b/src/monorepo/release.rs @@ -6,8 +6,10 @@ use crate::config::Config; use crate::git::{get_repo_root, open_repo}; use crate::timing::Timing; +use super::release_output::ReleaseOutputTarget; use super::run::checkpoint::Checkpoint; use super::run::run_release_logic; +use super::types::RunOutput; const MAX_RELEASE_REGENERATE_ATTEMPTS: usize = 3; @@ -23,6 +25,7 @@ pub fn release( channel: Option<&str>, draft: bool, force_unlock: bool, + output: Option<&ReleaseOutputTarget>, timing: &mut Timing, ) -> Result<()> { let repo = timing.stage("open_repo", || open_repo(&std::env::current_dir()?))?; @@ -56,10 +59,7 @@ pub fn release( true, timing, )?; - if let Some(out) = out { - out.print(); - } - return Ok(()); + return emit(out, output); } let branch = crate::git::resolve_current_branch(&open_repo(&root)?, &config.workspace.branch); @@ -88,12 +88,7 @@ pub fn release( ); match result { - Ok(out) => { - if let Some(out) = out { - out.print(); - } - return Ok(()); - } + Ok(out) => return emit(out, output), Err(e) if attempt < MAX_RELEASE_REGENERATE_ATTEMPTS && crate::git::is_push_rejected_error(&e) => @@ -113,6 +108,14 @@ pub fn release( })) } +fn emit(out: RunOutput, output: Option<&ReleaseOutputTarget>) -> Result<()> { + out.print(); + match output { + Some(target) => target.write(&out.releases), + None => Ok(()), + } +} + fn print_banner(dry_run: bool) { if dry_run { tracing::info!("{}", "FerrFlow — Release (dry run)".bold().blue()); diff --git a/src/monorepo/release_output.rs b/src/monorepo/release_output.rs new file mode 100644 index 00000000..58002459 --- /dev/null +++ b/src/monorepo/release_output.rs @@ -0,0 +1,89 @@ +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use std::ffi::OsString; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReleaseOutputFormat { + Github, + Dotenv, + Json, +} + +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +pub struct PublishedRelease { + pub name: String, + pub version: String, + pub tag: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ReleaseOutputTarget { + format: ReleaseOutputFormat, + path: PathBuf, +} + +#[derive(Serialize)] +struct Payload<'a> { + released: bool, + releases: &'a [PublishedRelease], +} + +impl ReleaseOutputTarget { + pub fn resolve( + format: ReleaseOutputFormat, + file: Option, + github_output: Option, + ) -> Result { + let path = match (file, format) { + (Some(path), _) => path, + (None, ReleaseOutputFormat::Github) => match github_output { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => bail!("--output-format github needs --output-file or GITHUB_OUTPUT"), + }, + (None, ReleaseOutputFormat::Dotenv) => { + bail!("--output-format dotenv needs --output-file") + } + (None, ReleaseOutputFormat::Json) => bail!("--output-format json needs --output-file"), + }; + Ok(Self { format, path }) + } + + pub fn write(&self, releases: &[PublishedRelease]) -> Result<()> { + let content = render(self.format, releases)?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .append(self.format == ReleaseOutputFormat::Github) + .truncate(self.format != ReleaseOutputFormat::Github) + .open(&self.path) + .with_context(|| format!("opening release output file {}", self.path.display()))?; + file.write_all(content.as_bytes()) + .with_context(|| format!("writing release output file {}", self.path.display())) + } +} + +fn render(format: ReleaseOutputFormat, releases: &[PublishedRelease]) -> Result { + let released = !releases.is_empty(); + Ok(match format { + ReleaseOutputFormat::Github => format!( + "released={released}\nreleases={}\n", + serde_json::to_string(releases)? + ), + ReleaseOutputFormat::Dotenv => format!( + "FERRFLOW_RELEASED={released}\nFERRFLOW_RELEASES={}\n", + serde_json::to_string(releases)? + ), + ReleaseOutputFormat::Json => { + format!( + "{}\n", + serde_json::to_string(&Payload { released, releases })? + ) + } + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/monorepo/release_output/tests.rs b/src/monorepo/release_output/tests.rs new file mode 100644 index 00000000..fc496531 --- /dev/null +++ b/src/monorepo/release_output/tests.rs @@ -0,0 +1,122 @@ +use std::path::PathBuf; + +use super::{PublishedRelease, ReleaseOutputFormat, ReleaseOutputTarget, render}; + +fn release(name: &str, version: &str) -> PublishedRelease { + PublishedRelease { + name: name.to_string(), + version: version.to_string(), + tag: format!("{name}@v{version}"), + } +} + +#[test] +fn github_format_lists_releases() { + let out = render(ReleaseOutputFormat::Github, &[release("mcp", "3.0.0")]).unwrap(); + assert_eq!( + out, + "released=true\nreleases=[{\"name\":\"mcp\",\"version\":\"3.0.0\",\"tag\":\"mcp@v3.0.0\"}]\n" + ); +} + +#[test] +fn dotenv_format_prefixes_keys() { + let out = render(ReleaseOutputFormat::Dotenv, &[release("api", "1.2.0")]).unwrap(); + assert_eq!( + out, + "FERRFLOW_RELEASED=true\nFERRFLOW_RELEASES=[{\"name\":\"api\",\"version\":\"1.2.0\",\"tag\":\"api@v1.2.0\"}]\n" + ); +} + +#[test] +fn json_format_is_one_object() { + let out = render( + ReleaseOutputFormat::Json, + &[release("api", "1.2.0"), release("web", "0.4.1")], + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(value["released"], true); + assert_eq!(value["releases"][1]["name"], "web"); + assert_eq!(value["releases"][1]["tag"], "web@v0.4.1"); +} + +#[test] +fn nothing_released_reports_false_and_empty_list() { + assert_eq!( + render(ReleaseOutputFormat::Github, &[]).unwrap(), + "released=false\nreleases=[]\n" + ); + assert_eq!( + render(ReleaseOutputFormat::Dotenv, &[]).unwrap(), + "FERRFLOW_RELEASED=false\nFERRFLOW_RELEASES=[]\n" + ); + assert_eq!( + render(ReleaseOutputFormat::Json, &[]).unwrap(), + "{\"released\":false,\"releases\":[]}\n" + ); +} + +#[test] +fn github_falls_back_to_github_output() { + let target = + ReleaseOutputTarget::resolve(ReleaseOutputFormat::Github, None, Some("/gh/out".into())) + .unwrap(); + assert_eq!(target.path, PathBuf::from("/gh/out")); +} + +#[test] +fn explicit_file_wins_over_github_output() { + let target = ReleaseOutputTarget::resolve( + ReleaseOutputFormat::Github, + Some(PathBuf::from("out.txt")), + Some("/gh/out".into()), + ) + .unwrap(); + assert_eq!(target.path, PathBuf::from("out.txt")); +} + +#[test] +fn github_without_destination_is_an_error() { + assert!(ReleaseOutputTarget::resolve(ReleaseOutputFormat::Github, None, None).is_err()); + assert!( + ReleaseOutputTarget::resolve(ReleaseOutputFormat::Github, None, Some("".into())).is_err() + ); +} + +#[test] +fn dotenv_and_json_require_a_file() { + let gh = Some("/gh/out".into()); + assert!(ReleaseOutputTarget::resolve(ReleaseOutputFormat::Dotenv, None, gh.clone()).is_err()); + assert!(ReleaseOutputTarget::resolve(ReleaseOutputFormat::Json, None, gh).is_err()); +} + +#[test] +fn github_appends_to_existing_outputs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("github_output"); + std::fs::write(&path, "other=1\n").unwrap(); + let target = + ReleaseOutputTarget::resolve(ReleaseOutputFormat::Github, Some(path.clone()), None) + .unwrap(); + target.write(&[]).unwrap(); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "other=1\nreleased=false\nreleases=[]\n" + ); +} + +#[test] +fn dotenv_overwrites_previous_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ferrflow.env"); + std::fs::write(&path, "FERRFLOW_RELEASED=true\nSTALE=1\n").unwrap(); + let target = + ReleaseOutputTarget::resolve(ReleaseOutputFormat::Dotenv, Some(path.clone()), None) + .unwrap(); + target.write(&[]).unwrap(); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "FERRFLOW_RELEASED=false\nFERRFLOW_RELEASES=[]\n" + ); +} diff --git a/src/monorepo/run/execute.rs b/src/monorepo/run/execute.rs index ee0a32b3..fd756625 100644 --- a/src/monorepo/run/execute.rs +++ b/src/monorepo/run/execute.rs @@ -42,6 +42,12 @@ pub(super) struct ReleasePlan<'a> { pub forge: Option<&'a dyn Forge>, } +impl ReleasePlan<'_> { + pub fn creates_tags(&self) -> bool { + !self.dry_run && !self.shadow && release_commit_mode(self) != ReleaseCommitMode::Pr + } +} + pub(super) fn execute_release(plan: &mut ReleasePlan<'_>) -> Result<()> { run_pre_commit_hooks(plan)?; diff --git a/src/monorepo/run/mod.rs b/src/monorepo/run/mod.rs index 6542e6cf..8dc35c21 100644 --- a/src/monorepo/run/mod.rs +++ b/src/monorepo/run/mod.rs @@ -63,7 +63,7 @@ pub(super) fn run_release_logic( force_unlock: bool, version_commands: bool, timing: &mut Timing, -) -> Result> { +) -> Result { let flags = RunFlags { dry_run, verbose, @@ -116,9 +116,9 @@ fn run_with_flags( force_versions: &[String], excluded: &[String], timing: &mut Timing, -) -> Result> { +) -> Result { if config.packages.is_empty() { - return finish(flags.dry_run, output::empty_config(flags)?); + return output::empty_config(flags); } if let Err(errors) = config.validate_groups() { @@ -181,7 +181,7 @@ fn run_with_flags( timing.record("per-package compute", compute_start.elapsed()); if flags.json { - return finish(flags.dry_run, output::check(state)?); + return output::check(state); } let publisher = Publisher { @@ -195,12 +195,14 @@ fn run_with_flags( }; publisher.publish(&mut state, timing)?; - if flags.release_json { - let out = output::release_json(&inputs.repo, state, &inputs.target_branch, flags.dry_run)?; - return finish(flags.dry_run, out); - } - - finish(flags.dry_run, output::text(state, config, flags)) + let published = std::mem::take(&mut state.published); + let mut out = if flags.release_json { + output::release_json(&inputs.repo, state, &inputs.target_branch, flags.dry_run)? + } else { + output::text(state, config, flags) + }; + out.releases = published; + Ok(out) } fn version_pass<'a>( @@ -331,15 +333,6 @@ pub(super) fn collect_dry_run_diffs( diffs } -fn finish(dry_run: bool, out: RunOutput) -> Result> { - if dry_run { - Ok(Some(out)) - } else { - out.print(); - Ok(None) - } -} - pub(super) fn refresh_lockfiles( pkg: &PackageConfig, root: &Path, diff --git a/src/monorepo/run/output.rs b/src/monorepo/run/output.rs index 9c0825bb..d8639d2a 100644 --- a/src/monorepo/run/output.rs +++ b/src/monorepo/run/output.rs @@ -22,16 +22,19 @@ pub(super) fn empty_config(flags: RunFlags) -> Result { .to_json()?, ), text_lines: Vec::new(), + releases: Vec::new(), }); } if flags.json { return Ok(RunOutput { json: Some(serde_json::to_string(&CheckResult { packages: vec![] })?), text_lines: Vec::new(), + releases: Vec::new(), }); } Ok(RunOutput { json: None, + releases: Vec::new(), text_lines: vec![ "No packages configured. Run `ferrflow init` to create a ferrflow config." .yellow() @@ -46,6 +49,7 @@ pub(super) fn check(state: ReleaseState) -> Result { packages: state.json_packages, })?), text_lines: Vec::new(), + releases: Vec::new(), }) } @@ -80,6 +84,7 @@ pub(super) fn release_json( Ok(RunOutput { json: Some(payload.to_json()?), text_lines: Vec::new(), + releases: Vec::new(), }) } @@ -105,6 +110,7 @@ pub(super) fn text(mut state: ReleaseState, config: &Config, flags: RunFlags) -> RunOutput { json: None, text_lines, + releases: Vec::new(), } } diff --git a/src/monorepo/run/publish.rs b/src/monorepo/run/publish.rs index 73528663..79bc3684 100644 --- a/src/monorepo/run/publish.rs +++ b/src/monorepo/run/publish.rs @@ -84,7 +84,9 @@ impl Publisher<'_> { let mut checkpoint = self.checkpoint(&state.tags_to_create)?; let release_start = std::time::Instant::now(); - let release_result = execute_release(&mut self.release_plan(state, checkpoint.as_mut())); + let mut plan = self.release_plan(state, checkpoint.as_mut()); + let creates_tags = plan.creates_tags(); + let release_result = execute_release(&mut plan); timing.record("release commit phase", release_start.elapsed()); if self.flags.shadow { @@ -98,6 +100,13 @@ impl Publisher<'_> { if !self.flags.dry_run { Checkpoint::delete(self.root)?; } + if creates_tags { + state.published = state + .tags_to_create + .iter() + .map(PlannedTag::published) + .collect(); + } self.run_success_hook(&released_tags) } Err(err) => { diff --git a/src/monorepo/run/state.rs b/src/monorepo/run/state.rs index 4fa0f573..dfef4da2 100644 --- a/src/monorepo/run/state.rs +++ b/src/monorepo/run/state.rs @@ -5,6 +5,7 @@ use crate::conventional_commits::BumpType; use crate::forge::ReleaseResult; use crate::hooks::HookContext; +use super::super::release_output::PublishedRelease; use super::super::types::CheckPackage; use super::cascade::CascadeSink; use super::context::RunFlags; @@ -28,6 +29,7 @@ pub(super) struct ReleaseState { pub shared_outputs: Vec, pub untouched_skipped: usize, pub forge_results: Vec<(String, ReleaseResult)>, + pub published: Vec, } impl ReleaseState { diff --git a/src/monorepo/run/summary.rs b/src/monorepo/run/summary.rs index bc845f2f..5891fa9d 100644 --- a/src/monorepo/run/summary.rs +++ b/src/monorepo/run/summary.rs @@ -1,4 +1,6 @@ use colored::Colorize; + +use super::super::release_output::PublishedRelease; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub(crate) struct PlannedTag { pub tag: String, @@ -10,6 +12,16 @@ pub(crate) struct PlannedTag { pub is_prerelease: bool, } +impl PlannedTag { + pub fn published(&self) -> PublishedRelease { + PublishedRelease { + name: self.package.clone(), + version: self.version.clone(), + tag: self.tag.clone(), + } + } +} + pub(super) fn untouched_hint(skipped: usize, recover_missed_releases: bool) -> Option { if skipped == 0 || recover_missed_releases { return None; diff --git a/src/monorepo/tests.rs b/src/monorepo/tests.rs index ea0045d1..c92d1c55 100644 --- a/src/monorepo/tests.rs +++ b/src/monorepo/tests.rs @@ -103,7 +103,7 @@ mod manifest_flow { use std::collections::BTreeMap; use std::path::Path; - fn add_bare_remote(dir: &Path) -> tempfile::TempDir { + pub(super) fn add_bare_remote(dir: &Path) -> tempfile::TempDir { let bare = tempfile::tempdir().unwrap(); git(dir, &["init", "--bare", &bare.path().to_string_lossy()]); git( @@ -113,7 +113,7 @@ mod manifest_flow { bare } - fn write_config(dir: &Path, manifest_mode: bool, commit_mode: &str) { + pub(super) fn write_config(dir: &Path, manifest_mode: bool, commit_mode: &str) { let manifest_line = if manifest_mode { r#""manifestFile": ".ferrflow.manifest.json", "# } else { @@ -131,7 +131,7 @@ mod manifest_flow { .unwrap(); } - fn write_pkg(dir: &Path, pkg: &str, version: &str) { + pub(super) fn write_pkg(dir: &Path, pkg: &str, version: &str) { std::fs::create_dir_all(dir.join(pkg)).unwrap(); std::fs::write( dir.join(pkg).join("version.json"), @@ -154,6 +154,7 @@ mod manifest_flow { None, false, false, + None, &mut Timing::new(false), ) }) @@ -328,6 +329,99 @@ mod manifest_flow { } } +mod release_outputs { + use super::manifest_flow::{add_bare_remote, write_config, write_pkg}; + use crate::monorepo::release_output::{ReleaseOutputFormat, ReleaseOutputTarget}; + use crate::test_utils::{commit_file, git, init_repo, with_cwd}; + use crate::timing::Timing; + use std::path::Path; + + fn release_with_output(dir: &Path, dry_run: bool) -> serde_json::Value { + let config = dir.join(".ferrflow"); + let out_file = dir.join("ferrflow-out.json"); + let target = + ReleaseOutputTarget::resolve(ReleaseOutputFormat::Json, Some(out_file.clone()), None) + .unwrap(); + with_cwd(dir, || { + crate::monorepo::release( + Some(&config), + dry_run, + false, + false, + false, + &[], + &[], + None, + false, + false, + Some(&target), + &mut Timing::new(false), + ) + }) + .unwrap(); + serde_json::from_str(&std::fs::read_to_string(out_file).unwrap()).unwrap() + } + + fn repo_with_api_feature(commit_mode: &str) -> tempfile::TempDir { + let (dir, _repo) = init_repo(); + write_pkg(dir.path(), "api", "1.0.0"); + write_pkg(dir.path(), "web", "2.0.0"); + write_config(dir.path(), false, commit_mode); + commit_file(dir.path(), "seed.txt", "x", "chore: seed", 1_962_000_000); + commit_file( + dir.path(), + "api/feature.txt", + "y", + "feat: api change", + 1_962_000_001, + ); + dir + } + + #[test] + fn a_release_reports_the_tagged_packages() { + let dir = repo_with_api_feature("commit"); + let _remote = add_bare_remote(dir.path()); + + let out = release_with_output(dir.path(), false); + + assert_eq!(out["released"], true); + let releases = out["releases"].as_array().unwrap(); + assert_eq!(releases.len(), 1); + assert_eq!(releases[0]["name"], "api"); + assert_eq!(releases[0]["version"], "1.1.0"); + let tag = releases[0]["tag"].as_str().unwrap(); + assert_eq!(git(dir.path(), &["tag", "--list", tag]).trim(), tag); + } + + #[test] + fn a_dry_run_reports_nothing_released() { + let dir = repo_with_api_feature("commit"); + + let out = release_with_output(dir.path(), true); + + assert_eq!(out["released"], false); + assert_eq!(out["releases"], serde_json::json!([])); + } + + #[test] + fn nothing_to_release_still_writes_the_outputs() { + let (dir, _repo) = init_repo(); + write_pkg(dir.path(), "api", "1.0.0"); + write_pkg(dir.path(), "web", "2.0.0"); + write_config(dir.path(), false, "commit"); + commit_file(dir.path(), "seed.txt", "x", "chore: seed", 1_963_000_000); + git(dir.path(), &["tag", "api@v1.0.0"]); + git(dir.path(), &["tag", "web@v2.0.0"]); + let _remote = add_bare_remote(dir.path()); + + let out = release_with_output(dir.path(), false); + + assert_eq!(out["released"], false); + assert_eq!(out["releases"], serde_json::json!([])); + } +} + mod release_json_diff { use crate::changelog::{ChangelogRender, GitLog}; use crate::config::Config; @@ -378,7 +472,7 @@ mod release_json_diff { true, &mut Timing::new(false), )?; - captured = out.expect("dry-run returns output").json; + captured = out.json; Ok(()) }) .unwrap(); @@ -742,6 +836,7 @@ mod lockfile_flow { None, false, false, + None, &mut Timing::new(false), ) }) @@ -1057,6 +1152,7 @@ mod bump_raising { None, false, false, + None, &mut Timing::new(false), ) }); diff --git a/src/monorepo/types.rs b/src/monorepo/types.rs index 2595b21b..d19c1d64 100644 --- a/src/monorepo/types.rs +++ b/src/monorepo/types.rs @@ -1,3 +1,4 @@ +use super::release_output::PublishedRelease; use super::version_source::VersionSource; #[derive(serde::Serialize, serde::Deserialize)] @@ -29,6 +30,7 @@ pub(super) struct CheckResult { pub(super) struct RunOutput { pub json: Option, pub text_lines: Vec, + pub releases: Vec, } impl RunOutput { diff --git a/src/monorepo/version_command_tests.rs b/src/monorepo/version_command_tests.rs index 00594ccc..06ee916e 100644 --- a/src/monorepo/version_command_tests.rs +++ b/src/monorepo/version_command_tests.rs @@ -44,7 +44,7 @@ fn run(dir: &Path, json: bool, run_version_hooks: bool) -> anyhow::Result Date: Sun, 25 Oct 2026 18:24:10 +0100 Subject: [PATCH 3/4] fix(release): print shadow-release output again run_release_logic no longer prints on non-dry runs, and run_shadow_release discarded the returned output, so shadow-release lost its per-package lines. Signed-off-by: BryanFRD --- src/monorepo/run/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/monorepo/run/mod.rs b/src/monorepo/run/mod.rs index 8dc35c21..c9c3b768 100644 --- a/src/monorepo/run/mod.rs +++ b/src/monorepo/run/mod.rs @@ -105,7 +105,7 @@ pub(crate) fn run_shadow_release(root: &Path, config: &Config, verbose: bool) -> force_unlock: false, }; let mut timing = Timing::new(false); - run_with_flags(root, config, flags, access, &[], &[], &mut timing).map(|_| ()) + run_with_flags(root, config, flags, access, &[], &[], &mut timing).map(|out| out.print()) } fn run_with_flags( From 20a608950840c3f1376bee4c2a7d88123ed89ba7 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 18:24:42 +0100 Subject: [PATCH 4/4] chore(deps): exempt json5 1.3.4 from cargo vet Signed-off-by: BryanFRD --- supply-chain/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supply-chain/config.toml b/supply-chain/config.toml index b5bd1065..f600219a 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -260,7 +260,7 @@ version = "0.3.106" criteria = "safe-to-deploy" [[exemptions.json5]] -version = "1.3.3" +version = "1.3.4" criteria = "safe-to-deploy" [[exemptions.lazy_static]]