Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -192,6 +200,7 @@ runs:
run: ferrflow check --comment

- name: Run FerrFlow release
id: ferrflow
if: inputs.mode == 'release'
shell: bash
env:
Expand All @@ -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

Expand All @@ -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
Expand Down
34 changes: 27 additions & 7 deletions docs/site/docs-en/ci/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/site/docs-en/ci/gitlab-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 9 additions & 7 deletions docs/site/docs-en/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <VERSION>` | Force a specific version, skipping commit analysis. Format: `VERSION` (single repo) or `NAME@VERSION` (monorepo) |
| `--channel <NAME>` | 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 <VERSION>` | Force a specific version, skipping commit analysis. Format: `VERSION` (single repo) or `NAME@VERSION` (monorepo) |
| `--channel <NAME>` | 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 <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 <PATH>` | Destination for `--output-format`. Required for `dotenv` and `json`; `github` defaults to `$GITHUB_OUTPUT` |

**What it does:**

Expand Down
34 changes: 27 additions & 7 deletions docs/site/docs-fr/ci/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/site/docs-fr/ci/gitlab-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions docs/site/docs-fr/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ ferrflow release [OPTIONS]
| `--channel <NAME>` | 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 <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 <PATH>` | Destination de `--output-format`. Obligatoire pour `dotenv` et `json` ; `github` utilise `$GITHUB_OUTPUT` par défaut |

**Ce que ça fait :**

Expand Down
30 changes: 26 additions & 4 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
}

Expand Down Expand Up @@ -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();
Expand Down
77 changes: 64 additions & 13 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<ReleaseOutputFormat>,
/// Destination for --output-format. Defaults to $GITHUB_OUTPUT for `github`.
#[arg(long, value_name = "PATH", requires = "output_format")]
output_file: Option<PathBuf>,
},
#[command(
about = "Run the full release in a throwaway clone: files, commit and tags, never pushed or published"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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()
));
}
Expand Down Expand Up @@ -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"]);
Expand Down
Loading
Loading