Skip to content

Commit 0109c7b

Browse files
authored
fix(release): fall back to github-actions[bot] when no git identity is set (#1128)
1 parent cfe3ef9 commit 0109c7b

10 files changed

Lines changed: 244 additions & 8 deletions

File tree

‎action.yml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ runs:
128128
rm -rf "$DOWNLOAD_DIR"
129129
echo "$INSTALL_DIR" >> "$GITHUB_PATH"
130130
131-
# Git identity (`user.name` / `user.email`) is now set by the
132-
# binary itself in `ensure_bot_token`, so the action no longer
133-
# configures it. Self-hosters running their own App can override
134-
# via `FERRFLOW_BOT_LOGIN` and `FERRFLOW_BOT_USER_ID` env vars on
135-
# the calling job.
131+
# Git identity (`user.name` / `user.email`) is set by the binary
132+
# itself: `ensure_bot_token` in bot mode, otherwise
133+
# `ensure_commit_identity` falls back to github-actions[bot]. Self-hosters
134+
# running their own App can override via `FERRFLOW_BOT_LOGIN` and
135+
# `FERRFLOW_BOT_USER_ID` env vars on the calling job.
136136

137137
- name: Optimize commit-graph
138138
if: inputs.mode != 'publish'

‎docs/site/docs-en/ci/github-actions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ FerrFlow needs `contents: write` to:
4343

4444
If your repository has branch protection rules, create a dedicated token with the necessary permissions and pass it as `FERRFLOW_TOKEN` or configure the action's `token` input.
4545

46+
Release commits and tags use the repository's git identity. If the job sets none, FerrFlow commits as `github-actions[bot]`, the account a `GITHUB_TOKEN` push is attributed to anyway. Outside GitHub Actions, a release with no `user.name` and `user.email` stops before bumping anything.
47+
4648
<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Prefer not to manage a token? Set <code>bot: true</code> to author releases as <code>ferrflow[bot]</code> with zero secrets: see the <a href="/docs/ci/hosted-bot">Hosted bot</a> guide.</p>
4749
</div></aside>
4850

‎docs/site/docs-en/reference/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ ferrflow doctor [OPTIONS]
395395
| `--format <FMT>` | `human` (default) or `json` |
396396
| `--online` | Also probe the forge API (GitHub rate limit / auth); requires a token |
397397

398-
The report groups checks into five sections — **Repo** (git repository, commit history, clean working tree, remote, tags), **Config** (which config file wins, whether it parses, plus the full `ferrflow validate` check suite), **Versioning** (strategy, each package's on-disk version, and whether its lockfile agrees with it), **Forge** (detected forge and whether an auth token is present in the environment), and **CI** (workflow files, and whether a workflow pins the `FerrLabs/FerrFlow` action). Every check reports green, a warning, or an error.
398+
The report groups checks into five sections — **Repo** (git repository, commit history, clean working tree, git identity, remote, tags), **Config** (which config file wins, whether it parses, plus the full `ferrflow validate` check suite), **Versioning** (strategy, each package's on-disk version, and whether its lockfile agrees with it), **Forge** (detected forge and whether an auth token is present in the environment), and **CI** (workflow files, and whether a workflow pins the `FerrLabs/FerrFlow` action). Every check reports green, a warning, or an error.
399399

400400
Two of the **Versioning** checks are about lockfiles, and both matter for anything that builds with `--locked`. A lockfile recording a different version than the manifest beside it is reported outright, since that combination makes `cargo build --locked` refuse to run. A lockfile that is not covered by [`updateLockfiles`](/docs/configuration/config-file/) is flagged before it drifts, because nothing will keep it in step on the next release. Version comparison applies to `Cargo.lock`: pnpm, yarn, poetry, uv, bundler and mix lock dependencies without restating the version of the package that owns them, so there is nothing to compare, and those get the `updateLockfiles` check only.
401401

‎docs/site/docs-fr/ci/github-actions.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ FerrFlow a besoin de `contents: write` pour :
4343

4444
Si votre repository a des règles de protection de branche, créez un token dédié avec les permissions nécessaires et passez-le via `FERRFLOW_TOKEN` ou configurez l'input `token` de l'action.
4545

46+
Les commits et tags de release utilisent l'identité git du dépôt. Si le job n'en définit aucune, FerrFlow commite en tant que `github-actions[bot]`, le compte auquel un push via `GITHUB_TOKEN` est de toute façon attribué. Hors de GitHub Actions, une release sans `user.name` ni `user.email` s'arrête avant de modifier quoi que ce soit.
47+
4648
<aside class="ferr-aside ferr-aside--tip"><div class="ferr-aside__body"><p>Vous préférez ne pas gérer de token ? Mettez <code>bot: true</code> pour attribuer les releases à <code>ferrflow[bot]</code> sans aucun secret : voir le guide <a href="/fr/docs/ci/hosted-bot">Bot hébergé</a>.</p>
4749
</div></aside>
4850

‎docs/site/docs-fr/reference/cli.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ ferrflow doctor [OPTIONS]
319319
| `--format <FMT>` | `human` (défaut) ou `json` |
320320
| `--online` | Sonder aussi l'API de la forge (rate limit / auth GitHub) ; nécessite un token |
321321

322-
Le rapport groupe les vérifications en cinq sections : **Repo** (dépôt git, historique de commits, arbre de travail propre, remote, tags), **Config** (quel fichier de config l'emporte, s'il parse, plus toute la suite de vérifications de `ferrflow validate`), **Versioning** (stratégie, version sur disque de chaque package, et si son lockfile est d'accord avec elle), **Forge** (forge détectée et présence d'un token d'auth dans l'environnement) et **CI** (fichiers de workflow, et si un workflow épingle l'action `FerrLabs/FerrFlow`). Chaque vérification est verte, un avertissement, ou une erreur.
322+
Le rapport groupe les vérifications en cinq sections : **Repo** (dépôt git, historique de commits, arbre de travail propre, identité git, remote, tags), **Config** (quel fichier de config l'emporte, s'il parse, plus toute la suite de vérifications de `ferrflow validate`), **Versioning** (stratégie, version sur disque de chaque package, et si son lockfile est d'accord avec elle), **Forge** (forge détectée et présence d'un token d'auth dans l'environnement) et **CI** (fichiers de workflow, et si un workflow épingle l'action `FerrLabs/FerrFlow`). Chaque vérification est verte, un avertissement, ou une erreur.
323323

324324
Deux des vérifications **Versioning** portent sur les lockfiles, et les deux comptent pour tout ce qui compile avec `--locked`. Un lockfile qui enregistre une version différente du manifeste voisin est signalé directement, cette combinaison faisant échouer `cargo build --locked`. Un lockfile que [`updateLockfiles`](/fr/docs/configuration/config-file/) ne couvre pas est signalé avant qu'il ne dérive, puisque rien ne le maintiendra à jour à la prochaine release. La comparaison de version s'applique à `Cargo.lock` : pnpm, yarn, poetry, uv, bundler et mix verrouillent les dépendances sans redonner la version du package qui les possède, il n'y a donc rien à comparer, et ceux-là n'ont que la vérification `updateLockfiles`.
325325

‎src/doctor/checks.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use std::process::Command;
44
use crate::config::{Config, ForgeKind};
55
use crate::formats::lockfiles::{LockfileState, inspect_for_manifest};
66
use crate::formats::read_version;
7-
use crate::git::{Repository, collect_all_tags, get_remote_url};
7+
use crate::git::{
8+
Repository, collect_all_tags, commit_identity, get_remote_url, on_github_actions,
9+
};
810
use crate::validate::ValidationLevel;
911

1012
use super::report::{Check, Section, Status};
@@ -57,6 +59,8 @@ pub(super) fn repo_section(
5759
)),
5860
}
5961

62+
checks.push(identity_check(commit_identity(root), on_github_actions()));
63+
6064
let remote = remote_name(config);
6165
match get_remote_url(repo, remote) {
6266
Some(url) => checks.push(Check::ok(
@@ -423,6 +427,23 @@ fn forge_label(kind: ForgeKind) -> &'static str {
423427
}
424428
}
425429

430+
pub(super) fn identity_check(identity: Option<String>, on_actions: bool) -> Check {
431+
match identity {
432+
Some(identity) => Check::ok("git identity", Some(identity)),
433+
None if on_actions => Check::info(
434+
"git identity",
435+
Some("none configured. A release commits as github-actions[bot]".into()),
436+
),
437+
None => Check::warn(
438+
"git identity",
439+
Some(
440+
"none configured, so a release cannot commit. Set `git config user.name` and `git config user.email`"
441+
.into(),
442+
),
443+
),
444+
}
445+
}
446+
426447
fn dirty_count(root: &Path) -> Option<usize> {
427448
let output = Command::new("git")
428449
.current_dir(root)

‎src/doctor/tests.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,32 @@ mod end_to_end {
173173
}
174174
}
175175

176+
mod identity {
177+
use super::super::checks::identity_check;
178+
use super::Status;
179+
180+
#[test]
181+
fn a_resolved_identity_is_shown() {
182+
let check = identity_check(Some("Jane Dev <jane@example.com>".into()), false);
183+
assert_eq!(check.status, Status::Ok);
184+
assert_eq!(check.detail.as_deref(), Some("Jane Dev <jane@example.com>"));
185+
}
186+
187+
#[test]
188+
fn a_missing_identity_warns_off_actions() {
189+
let check = identity_check(None, false);
190+
assert_eq!(check.status, Status::Warn);
191+
assert!(check.detail.unwrap().contains("git config user.email"));
192+
}
193+
194+
#[test]
195+
fn a_missing_identity_on_actions_is_covered_by_the_fallback() {
196+
let check = identity_check(None, true);
197+
assert_eq!(check.status, Status::Info);
198+
assert!(check.detail.unwrap().contains("github-actions[bot]"));
199+
}
200+
}
201+
176202
mod lockfiles {
177203
use super::super::checks::versioning_section;
178204
use super::{Check, Status};

‎src/git/identity.rs‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
use anyhow::{Result, bail};
2+
use std::path::Path;
3+
4+
use super::shell::run_git_with_env;
5+
6+
const ACTIONS_BOT_NAME: &str = "github-actions[bot]";
7+
const ACTIONS_BOT_EMAIL: &str = "41898282+github-actions[bot]@users.noreply.github.com";
8+
9+
pub fn on_github_actions() -> bool {
10+
std::env::var("GITHUB_ACTIONS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("true"))
11+
}
12+
13+
pub fn commit_identity(workdir: &Path) -> Option<String> {
14+
commit_identity_with_env(workdir, &[])
15+
}
16+
17+
pub fn ensure_commit_identity(workdir: &Path) -> Result<()> {
18+
ensure_commit_identity_with_env(workdir, on_github_actions(), &[])
19+
}
20+
21+
fn commit_identity_with_env(workdir: &Path, env: &[(&str, &str)]) -> Option<String> {
22+
let author = run_git_with_env(workdir, &["var", "GIT_AUTHOR_IDENT"], env).ok()?;
23+
run_git_with_env(workdir, &["var", "GIT_COMMITTER_IDENT"], env).ok()?;
24+
let ident = author.trim();
25+
let name_and_email = ident.rsplitn(3, ' ').nth(2).unwrap_or(ident);
26+
Some(name_and_email.to_string())
27+
}
28+
29+
fn ensure_commit_identity_with_env(
30+
workdir: &Path,
31+
on_actions: bool,
32+
env: &[(&str, &str)],
33+
) -> Result<()> {
34+
if commit_identity_with_env(workdir, env).is_some() {
35+
return Ok(());
36+
}
37+
38+
if !on_actions {
39+
bail!(
40+
"no git identity to author the release commit and tags.\n\
41+
Set one before releasing:\n \
42+
git config user.name \"Your Name\"\n \
43+
git config user.email \"you@example.com\""
44+
);
45+
}
46+
47+
tracing::info!("No git identity configured, committing as {ACTIONS_BOT_NAME}.");
48+
run_git_with_env(
49+
workdir,
50+
&["config", "--local", "user.name", ACTIONS_BOT_NAME],
51+
env,
52+
)?;
53+
run_git_with_env(
54+
workdir,
55+
&["config", "--local", "user.email", ACTIONS_BOT_EMAIL],
56+
env,
57+
)?;
58+
Ok(())
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
struct IsolatedRepo {
66+
dir: tempfile::TempDir,
67+
global_config: String,
68+
}
69+
70+
impl IsolatedRepo {
71+
fn new() -> Self {
72+
let dir = tempfile::tempdir().unwrap();
73+
let global = dir.path().join("empty-global.gitconfig");
74+
std::fs::write(&global, "").unwrap();
75+
let repo = Self {
76+
global_config: global.to_string_lossy().into_owned(),
77+
dir,
78+
};
79+
repo.git(&["init", "-q"]);
80+
repo.git(&["config", "--local", "user.useConfigOnly", "true"]);
81+
repo
82+
}
83+
84+
fn env(&self) -> [(&str, &str); 2] {
85+
[
86+
("GIT_CONFIG_GLOBAL", self.global_config.as_str()),
87+
("GIT_CONFIG_NOSYSTEM", "1"),
88+
]
89+
}
90+
91+
fn git(&self, args: &[&str]) -> String {
92+
run_git_with_env(self.dir.path(), args, &self.env()).unwrap()
93+
}
94+
95+
fn local_config(&self, key: &str) -> Option<String> {
96+
run_git_with_env(
97+
self.dir.path(),
98+
&["config", "--local", "--get", key],
99+
&self.env(),
100+
)
101+
.ok()
102+
.map(|v| v.trim().to_string())
103+
}
104+
}
105+
106+
#[test]
107+
fn an_unconfigured_repo_has_no_identity() {
108+
let repo = IsolatedRepo::new();
109+
assert_eq!(commit_identity_with_env(repo.dir.path(), &repo.env()), None);
110+
}
111+
112+
#[test]
113+
fn a_configured_identity_is_reported_without_the_timestamp() {
114+
let repo = IsolatedRepo::new();
115+
repo.git(&["config", "--local", "user.name", "Jane Dev"]);
116+
repo.git(&["config", "--local", "user.email", "jane@example.com"]);
117+
assert_eq!(
118+
commit_identity_with_env(repo.dir.path(), &repo.env()).as_deref(),
119+
Some("Jane Dev <jane@example.com>")
120+
);
121+
}
122+
123+
#[test]
124+
fn a_name_without_an_email_is_not_an_identity() {
125+
let repo = IsolatedRepo::new();
126+
repo.git(&["config", "--local", "user.name", "Jane Dev"]);
127+
assert_eq!(commit_identity_with_env(repo.dir.path(), &repo.env()), None);
128+
}
129+
130+
#[test]
131+
fn on_actions_a_missing_identity_falls_back_to_github_actions_bot() {
132+
let repo = IsolatedRepo::new();
133+
ensure_commit_identity_with_env(repo.dir.path(), true, &repo.env()).unwrap();
134+
135+
assert_eq!(
136+
repo.local_config("user.name").as_deref(),
137+
Some("github-actions[bot]")
138+
);
139+
assert_eq!(
140+
repo.local_config("user.email").as_deref(),
141+
Some("41898282+github-actions[bot]@users.noreply.github.com")
142+
);
143+
repo.git(&[
144+
"commit",
145+
"-q",
146+
"--allow-empty",
147+
"-m",
148+
"chore(release): v1.0.0",
149+
]);
150+
}
151+
152+
#[test]
153+
fn on_actions_an_existing_identity_is_left_alone() {
154+
let repo = IsolatedRepo::new();
155+
repo.git(&["config", "--local", "user.name", "ferrflow[bot]"]);
156+
repo.git(&[
157+
"config",
158+
"--local",
159+
"user.email",
160+
"278126555+ferrflow[bot]@users.noreply.github.com",
161+
]);
162+
163+
ensure_commit_identity_with_env(repo.dir.path(), true, &repo.env()).unwrap();
164+
165+
assert_eq!(
166+
repo.local_config("user.name").as_deref(),
167+
Some("ferrflow[bot]")
168+
);
169+
}
170+
171+
#[test]
172+
fn off_actions_a_missing_identity_is_an_actionable_error_and_writes_nothing() {
173+
let repo = IsolatedRepo::new();
174+
let err = ensure_commit_identity_with_env(repo.dir.path(), false, &repo.env())
175+
.unwrap_err()
176+
.to_string();
177+
178+
assert!(err.contains("git config user.email"), "{err}");
179+
assert_eq!(repo.local_config("user.name"), None);
180+
assert_eq!(repo.local_config("user.email"), None);
181+
}
182+
}

‎src/git/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod commit_walk;
44
mod commits;
55
mod diff;
66
mod fetch;
7+
mod identity;
78
mod push;
89
mod remote;
910
mod repo;
@@ -28,6 +29,7 @@ pub use diff::{
2829
get_changed_files_since_oid, get_changed_files_since_tag,
2930
};
3031
pub use fetch::fetch_tags;
32+
pub use identity::{commit_identity, ensure_commit_identity, on_github_actions};
3133
#[allow(unused_imports)]
3234
pub use push::{
3335
delete_tag_if_unchanged, force_push_branch, force_push_tags, push, push_tags,

‎src/monorepo/release.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub fn release(
3232

3333
if !dry_run {
3434
crate::manifest::validate_in_sync(&config, &root)?;
35+
crate::git::ensure_commit_identity(&root)?;
3536
}
3637

3738
if !json {

0 commit comments

Comments
 (0)