Skip to content

Commit 9f51ae7

Browse files
runningcodeclaude
andcommitted
feat(build): Add --git-metadata flag with CI auto-detection (EME-604)
This change prevents local dev builds from triggering GitHub status checks by introducing intelligent git metadata collection control. Changes: - Add new CI detection module (src/utils/ci.rs) that checks common CI environment variables (CI, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL, etc.) - Add --git-metadata flag to build upload command with three modes: * --git-metadata or --git-metadata=true: Force enable git metadata collection * --git-metadata=false or --no-git-metadata: Force disable git metadata collection * Default (no flag): Auto-detect CI environment and enable only in CI - Refactor execute() function to conditionally collect git metadata based on flag and CI detection When git metadata collection is disabled, empty values are sent for VcsInfo fields, preventing status checks from being triggered on GitHub. This solves two issues: 1. Local dev builds no longer trigger unwanted status checks 2. Users with unsupported VCS providers can explicitly disable git metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 3ed4ada commit 9f51ae7

File tree

3 files changed

+288
-137
lines changed

3 files changed

+288
-137
lines changed

src/commands/build/upload.rs

Lines changed: 213 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::utils::build::{
2222
is_aab_file, is_apk_file, is_zip_file, normalize_directory, write_version_metadata,
2323
};
2424
use crate::utils::chunks::{upload_chunks, Chunk};
25+
use crate::utils::ci::is_ci;
2526
use crate::utils::fs::get_sha1_checksums;
2627
use crate::utils::fs::TempDir;
2728
use crate::utils::fs::TempFile;
@@ -106,6 +107,23 @@ pub fn make_command(command: Command) -> Command {
106107
.long("release-notes")
107108
.help("The release notes to use for the upload.")
108109
)
110+
.arg(
111+
Arg::new("git_metadata")
112+
.long("git-metadata")
113+
.num_args(0..=1)
114+
.default_missing_value("true")
115+
.value_parser(clap::value_parser!(bool))
116+
.help("Controls whether to collect and send git metadata (branch, commit, etc.). \
117+
Use --git-metadata to force enable, --git-metadata=false or --no-git-metadata to force disable. \
118+
If not specified, git metadata is automatically collected only when running in a CI environment.")
119+
)
120+
.arg(
121+
Arg::new("no_git_metadata")
122+
.long("no-git-metadata")
123+
.action(ArgAction::SetTrue)
124+
.conflicts_with("git_metadata")
125+
.help("Disable collection of git metadata. Equivalent to --git-metadata=false.")
126+
)
109127
}
110128

111129
pub fn execute(matches: &ArgMatches) -> Result<()> {
@@ -114,58 +132,92 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
114132
.get_many::<String>("paths")
115133
.expect("paths argument is required");
116134

117-
let head_sha = matches
118-
.get_one::<Option<Digest>>("head_sha")
119-
.map(|d| d.as_ref().cloned())
120-
.or_else(|| Some(vcs::find_head_sha().ok()))
121-
.flatten();
122-
123-
let cached_remote = config.get_cached_vcs_remote();
124-
// Try to open the git repository and find the remote, but handle errors gracefully.
125-
let (vcs_provider, head_repo_name, head_ref, base_ref, base_repo_name) = {
126-
// Try to open the repo and get the remote URL, but don't fail if not in a repo.
127-
let repo = git2::Repository::open_from_env().ok();
128-
let repo_ref = repo.as_ref();
129-
let remote_url = repo_ref.and_then(|repo| git_repo_remote_url(repo, &cached_remote).ok());
130-
131-
let vcs_provider = matches
132-
.get_one("vcs_provider")
133-
.map(String::as_str)
134-
.map(Cow::Borrowed)
135-
.or_else(|| {
136-
remote_url
137-
.as_ref()
138-
.map(|url| get_provider_from_remote(url))
139-
.map(Cow::Owned)
140-
})
141-
.unwrap_or_default();
135+
// Determine if we should collect git metadata
136+
let should_collect_git_metadata = if matches.get_flag("no_git_metadata") {
137+
// --no-git-metadata was specified
138+
false
139+
} else if let Some(&git_metadata) = matches.get_one::<bool>("git_metadata") {
140+
// --git-metadata or --git-metadata=true/false was specified
141+
git_metadata
142+
} else {
143+
// Default behavior: auto-detect CI
144+
is_ci()
145+
};
142146

143-
let head_repo_name = matches
144-
.get_one("head_repo_name")
145-
.map(String::as_str)
146-
.map(Cow::Borrowed)
147-
.or_else(|| {
148-
remote_url
149-
.as_ref()
150-
.map(|url| get_repo_from_remote_preserve_case(url))
151-
.map(Cow::Owned)
152-
})
153-
.unwrap_or_default();
147+
debug!(
148+
"Git metadata collection: {}",
149+
if should_collect_git_metadata {
150+
"enabled"
151+
} else {
152+
"disabled (use --git-metadata to enable)"
153+
}
154+
);
154155

155-
let head_ref = matches
156-
.get_one("head_ref")
157-
.map(String::as_str)
158-
.map(Cow::Borrowed)
159-
.or_else(|| {
160-
// First try GitHub Actions environment variables
161-
get_github_head_ref().map(Cow::Owned)
162-
})
163-
.or_else(|| {
164-
// Fallback to git repository introspection
165-
// Note: git_repo_head_ref will return an error for detached HEAD states,
166-
// which the error handling converts to None - this prevents sending "HEAD" as a branch name
167-
// In that case, the user will need to provide a valid branch name.
168-
repo_ref
156+
// Collect git metadata based on the flag
157+
let (
158+
head_sha,
159+
vcs_provider,
160+
head_repo_name,
161+
head_ref,
162+
base_ref,
163+
base_repo_name,
164+
base_sha,
165+
pr_number,
166+
) = if should_collect_git_metadata {
167+
let head_sha = matches
168+
.get_one::<Option<Digest>>("head_sha")
169+
.map(|d| d.as_ref().cloned())
170+
.or_else(|| Some(vcs::find_head_sha().ok()))
171+
.flatten();
172+
173+
let cached_remote = config.get_cached_vcs_remote();
174+
// Try to open the git repository and find the remote, but handle errors gracefully.
175+
let (vcs_provider, head_repo_name, head_ref, base_ref, base_repo_name) = {
176+
// Try to open the repo and get the remote URL, but don't fail if not in a repo.
177+
let repo = git2::Repository::open_from_env().ok();
178+
let repo_ref = repo.as_ref();
179+
let remote_url =
180+
repo_ref.and_then(|repo| git_repo_remote_url(repo, &cached_remote).ok());
181+
182+
let vcs_provider = matches
183+
.get_one("vcs_provider")
184+
.map(String::as_str)
185+
.map(Cow::Borrowed)
186+
.or_else(|| {
187+
remote_url
188+
.as_ref()
189+
.map(|url| get_provider_from_remote(url))
190+
.map(Cow::Owned)
191+
})
192+
.unwrap_or_default();
193+
194+
let head_repo_name = matches
195+
.get_one("head_repo_name")
196+
.map(String::as_str)
197+
.map(Cow::Borrowed)
198+
.or_else(|| {
199+
remote_url
200+
.as_ref()
201+
.map(|url| get_repo_from_remote_preserve_case(url))
202+
.map(Cow::Owned)
203+
})
204+
.unwrap_or_default();
205+
206+
let head_ref =
207+
matches
208+
.get_one("head_ref")
209+
.map(String::as_str)
210+
.map(Cow::Borrowed)
211+
.or_else(|| {
212+
// First try GitHub Actions environment variables
213+
get_github_head_ref().map(Cow::Owned)
214+
})
215+
.or_else(|| {
216+
// Fallback to git repository introspection
217+
// Note: git_repo_head_ref will return an error for detached HEAD states,
218+
// which the error handling converts to None - this prevents sending "HEAD" as a branch name
219+
// In that case, the user will need to provide a valid branch name.
220+
repo_ref
169221
.and_then(|r| match git_repo_head_ref(r) {
170222
Ok(ref_name) => {
171223
debug!("Found current branch reference: {ref_name}");
@@ -177,107 +229,131 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
177229
}
178230
})
179231
.map(Cow::Owned)
180-
})
181-
.unwrap_or_default();
182-
183-
let base_ref = matches
184-
.get_one("base_ref")
185-
.map(String::as_str)
186-
.map(Cow::Borrowed)
187-
.or_else(|| {
188-
// First try GitHub Actions environment variables
189-
get_github_base_ref().map(Cow::Owned)
190-
})
191-
.or_else(|| {
192-
// Fallback to git repository introspection
193-
repo_ref
194-
.and_then(|r| match git_repo_base_ref(r, &cached_remote) {
195-
Ok(base_ref_name) => {
196-
debug!("Found base reference: {base_ref_name}");
197-
Some(base_ref_name)
198-
}
199-
Err(e) => {
200-
info!("Could not detect base branch reference: {e}");
201-
None
202-
}
203232
})
204-
.map(Cow::Owned)
205-
})
206-
.unwrap_or_default();
233+
.unwrap_or_default();
234+
235+
let base_ref = matches
236+
.get_one("base_ref")
237+
.map(String::as_str)
238+
.map(Cow::Borrowed)
239+
.or_else(|| {
240+
// First try GitHub Actions environment variables
241+
get_github_base_ref().map(Cow::Owned)
242+
})
243+
.or_else(|| {
244+
// Fallback to git repository introspection
245+
repo_ref
246+
.and_then(|r| match git_repo_base_ref(r, &cached_remote) {
247+
Ok(base_ref_name) => {
248+
debug!("Found base reference: {base_ref_name}");
249+
Some(base_ref_name)
250+
}
251+
Err(e) => {
252+
info!("Could not detect base branch reference: {e}");
253+
None
254+
}
255+
})
256+
.map(Cow::Owned)
257+
})
258+
.unwrap_or_default();
259+
260+
let base_repo_name = matches
261+
.get_one("base_repo_name")
262+
.map(String::as_str)
263+
.map(Cow::Borrowed)
264+
.or_else(|| {
265+
// Try to get the base repo name from the VCS if not provided
266+
repo_ref
267+
.and_then(|r| match git_repo_base_repo_name_preserve_case(r) {
268+
Ok(Some(base_repo_name)) => {
269+
debug!("Found base repository name: {base_repo_name}");
270+
Some(base_repo_name)
271+
}
272+
Ok(None) => {
273+
debug!("No base repository found - not a fork");
274+
None
275+
}
276+
Err(e) => {
277+
warn!("Could not detect base repository name: {e}");
278+
None
279+
}
280+
})
281+
.map(Cow::Owned)
282+
})
283+
.unwrap_or_default();
284+
285+
(
286+
vcs_provider,
287+
head_repo_name,
288+
head_ref,
289+
base_ref,
290+
base_repo_name,
291+
)
292+
};
293+
294+
// Track whether base_sha and base_ref were explicitly provided by the user
295+
let base_sha_from_user = matches.get_one::<Option<Digest>>("base_sha").is_some();
296+
let base_ref_from_user = matches.get_one::<String>("base_ref").is_some();
207297

208-
let base_repo_name = matches
209-
.get_one("base_repo_name")
210-
.map(String::as_str)
211-
.map(Cow::Borrowed)
298+
let mut base_sha = matches
299+
.get_one::<Option<Digest>>("base_sha")
300+
.map(|d| d.as_ref().cloned())
212301
.or_else(|| {
213-
// Try to get the base repo name from the VCS if not provided
214-
repo_ref
215-
.and_then(|r| match git_repo_base_repo_name_preserve_case(r) {
216-
Ok(Some(base_repo_name)) => {
217-
debug!("Found base repository name: {base_repo_name}");
218-
Some(base_repo_name)
219-
}
220-
Ok(None) => {
221-
debug!("No base repository found - not a fork");
222-
None
223-
}
224-
Err(e) => {
225-
warn!("Could not detect base repository name: {e}");
226-
None
227-
}
228-
})
229-
.map(Cow::Owned)
302+
Some(
303+
vcs::find_base_sha(&cached_remote)
304+
.inspect_err(|e| debug!("Error finding base SHA: {e}"))
305+
.ok()
306+
.flatten(),
307+
)
230308
})
231-
.unwrap_or_default();
309+
.flatten();
310+
311+
let mut base_ref = base_ref;
312+
313+
// If base_sha equals head_sha and both were auto-inferred, skip setting base_sha and base_ref
314+
// but keep head_sha (since comparing a commit to itself provides no meaningful baseline)
315+
if !base_sha_from_user
316+
&& !base_ref_from_user
317+
&& base_sha.is_some()
318+
&& head_sha.is_some()
319+
&& base_sha == head_sha
320+
{
321+
debug!(
322+
"Base SHA equals head SHA ({}), and both were auto-inferred. Skipping base_sha and base_ref, but keeping head_sha.",
323+
base_sha.expect("base_sha is Some at this point")
324+
);
325+
base_sha = None;
326+
base_ref = "".into();
327+
}
328+
let pr_number = matches
329+
.get_one("pr_number")
330+
.copied()
331+
.or_else(get_github_pr_number);
232332

233333
(
334+
head_sha,
234335
vcs_provider,
235336
head_repo_name,
236337
head_ref,
237338
base_ref,
238339
base_repo_name,
340+
base_sha,
341+
pr_number,
342+
)
343+
} else {
344+
// Git metadata collection is disabled
345+
(
346+
None,
347+
Cow::Borrowed(""),
348+
Cow::Borrowed(""),
349+
Cow::Borrowed(""),
350+
Cow::Borrowed(""),
351+
Cow::Borrowed(""),
352+
None,
353+
None,
239354
)
240355
};
241356

242-
// Track whether base_sha and base_ref were explicitly provided by the user
243-
let base_sha_from_user = matches.get_one::<Option<Digest>>("base_sha").is_some();
244-
let base_ref_from_user = matches.get_one::<String>("base_ref").is_some();
245-
246-
let mut base_sha = matches
247-
.get_one::<Option<Digest>>("base_sha")
248-
.map(|d| d.as_ref().cloned())
249-
.or_else(|| {
250-
Some(
251-
vcs::find_base_sha(&cached_remote)
252-
.inspect_err(|e| debug!("Error finding base SHA: {e}"))
253-
.ok()
254-
.flatten(),
255-
)
256-
})
257-
.flatten();
258-
259-
let mut base_ref = base_ref;
260-
261-
// If base_sha equals head_sha and both were auto-inferred, skip setting base_sha and base_ref
262-
// but keep head_sha (since comparing a commit to itself provides no meaningful baseline)
263-
if !base_sha_from_user
264-
&& !base_ref_from_user
265-
&& base_sha.is_some()
266-
&& head_sha.is_some()
267-
&& base_sha == head_sha
268-
{
269-
debug!(
270-
"Base SHA equals head SHA ({}), and both were auto-inferred. Skipping base_sha and base_ref, but keeping head_sha.",
271-
base_sha.expect("base_sha is Some at this point")
272-
);
273-
base_sha = None;
274-
base_ref = "".into();
275-
}
276-
let pr_number = matches
277-
.get_one("pr_number")
278-
.copied()
279-
.or_else(get_github_pr_number);
280-
281357
let build_configuration = matches.get_one("build_configuration").map(String::as_str);
282358
let release_notes = matches.get_one("release_notes").map(String::as_str);
283359

0 commit comments

Comments
 (0)