diff --git a/apps/docs/content/docs/reference/configuration.mdx b/apps/docs/content/docs/reference/configuration.mdx index f5b1b9b527b37..1cd98e21a072f 100644 --- a/apps/docs/content/docs/reference/configuration.mdx +++ b/apps/docs/content/docs/reference/configuration.mdx @@ -362,6 +362,22 @@ In this example, if only `README.md` changed in a package, `build` would not run selected, regardless of individual task `inputs`. +#### `githubActionsRemoteBaseRefFallback` + +Default: `false` + +When GitHub Actions reports a pull request's base branch but that branch is not available as a local ref, fall back to `origin/`. This supports detached checkouts created by `actions/checkout`, where the base branch may only exist as a remote-tracking ref. + +Local refs continue to take precedence. This flag only affects base refs inferred from GitHub Actions. Explicit refs provided through `TURBO_SCM_BASE` and default `main` or `master` resolution are unchanged. + +```jsonc title="./turbo.json" +{ + "futureFlags": { + "githubActionsRemoteBaseRefFallback": true, + }, +} +``` + #### `watchUsingTaskInputs` Default: `false` diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index b64cb50dbb383..1421ba40755bd 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -479,11 +479,18 @@ impl RunBuilder { let repo_index_task = { let repo_root = self.repo_root.clone(); let git_root = self.opts.git_root.clone(); + let github_actions_remote_base_ref_fallback = self + .opts + .future_flags + .github_actions_remote_base_ref_fallback; tokio::task::spawn_blocking(move || { let scm = match git_root { Some(root) => SCM::new_with_git_root(&repo_root, root), None => SCM::new(&repo_root), - }; + } + .with_github_actions_remote_base_ref_fallback( + github_actions_remote_base_ref_fallback, + ); // The tracked half of the repo index only needs `.git/index`. let tracked_index = { let _span = tracing::info_span!("build_tracked_repo_index_gix").entered(); diff --git a/crates/turborepo-schema-gen/src/main.rs b/crates/turborepo-schema-gen/src/main.rs index 1681cf89ea421..ee8ec66d34a48 100644 --- a/crates/turborepo-schema-gen/src/main.rs +++ b/crates/turborepo-schema-gen/src/main.rs @@ -768,6 +768,14 @@ export interface FutureFlags { * @defaultValue `false` */ affectedUsingTaskInputs?: boolean; + /** + * When GitHub Actions reports a base branch that is not available as a + * local ref, fall back to `origin/`. This supports detached + * checkouts where only remote-tracking refs are present. + * + * @defaultValue `false` + */ + githubActionsRemoteBaseRefFallback?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared diff --git a/crates/turborepo-scm/src/git.rs b/crates/turborepo-scm/src/git.rs index 0953b8f2dfbaf..fd8fba29def96 100644 --- a/crates/turborepo-scm/src/git.rs +++ b/crates/turborepo-scm/src/git.rs @@ -491,21 +491,36 @@ impl GitRepo { // because at this point we know we're in a GITHUB CI environment // and we should really know by now what the base ref is // so it's better to just error if something went wrong - return match self - .execute_git_command(&["rev-parse", "--end-of-options", &github_base_ref], "") - { + let local_result = + self.execute_git_command(&["rev-parse", "--end-of-options", &github_base_ref], ""); + match local_result { Ok(_) => { eprintln!("Resolved base ref from GitHub Actions event: {github_base_ref}"); - Ok(github_base_ref) + return Ok(github_base_ref); } - Err(e) => { + Err(local_error) if self.github_actions_remote_base_ref_fallback => { + let remote_ref = format!("origin/{github_base_ref}"); + match self + .execute_git_command(&["rev-parse", "--end-of-options", &remote_ref], "") + { + Ok(_) => { + eprintln!("Resolved base ref from GitHub Actions event: {remote_ref}"); + return Ok(remote_ref); + } + Err(remote_error) => eprintln!( + "Failed to resolve base ref '{github_base_ref}' ({local_error}) or \ + '{remote_ref}' ({remote_error}) from GitHub Actions event" + ), + } + } + Err(error) => { eprintln!( "Failed to resolve base ref '{github_base_ref}' from GitHub Actions \ - event: {e}" + event: {error}" ); - Err(Error::UnableToResolveRef) } - }; + } + return Err(Error::UnableToResolveRef); } default_base_ref(|branch| self.execute_git_command(&["rev-parse", branch], "").is_ok()) @@ -1252,6 +1267,7 @@ mod tests { for (branches, expected) in [ (vec!["main"], Some("main")), (vec!["master"], Some("master")), + (vec!["origin/main"], None), (vec!["ziltoid"], None), (vec!["ziltoid", "main"], Some("main")), (vec!["ziltoid", "master"], Some("master")), @@ -1264,6 +1280,65 @@ mod tests { } } + #[test] + fn test_github_base_ref_remote_fallback_respects_flag() -> Result<(), Error> { + let (repo_root, repo_path) = setup_repository(Some("main"))?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + let file = root.join_component("todo.txt"); + file.create_with_contents("test remote base ref fallback")?; + let commit = commit_file(&repo_path, Path::new("todo.txt"), None); + + run_git(&repo_path, &["checkout", "--detach", &commit]); + run_git(&repo_path, &["branch", "-D", "main"]); + run_git( + &repo_path, + &["update-ref", "refs/remotes/origin/main", &commit], + ); + + let github_env = || CIEnv { + is_github_actions: true, + github_base_ref: Ok("main".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + let mut git = GitRepo::find(&root).unwrap(); + + assert_matches!( + git.resolve_base(None, github_env()), + Err(Error::UnableToResolveRef) + ); + git.github_actions_remote_base_ref_fallback = true; + assert_matches!( + git.resolve_base(None, CIEnv::none()), + Err(Error::UnableToResolveRef) + ); + assert_eq!( + git.resolve_base(None, github_env())?, + "origin/main".to_string() + ); + + Ok(()) + } + + #[test] + fn test_github_base_ref_remote_fallback_errors_when_refs_are_missing() -> Result<(), Error> { + let (repo_root, repo_path) = setup_repository(Some("main"))?; + let root = AbsoluteSystemPathBuf::try_from(repo_root.path()).unwrap(); + let file = root.join_component("todo.txt"); + file.create_with_contents("test missing base refs")?; + commit_file(&repo_path, Path::new("todo.txt"), None); + + let mut git = GitRepo::find(&root).unwrap(); + git.github_actions_remote_base_ref_fallback = true; + let env = CIEnv { + is_github_actions: true, + github_base_ref: Ok("missing".to_string()), + github_event_path: Err(VarError::NotPresent), + }; + + assert_matches!(git.resolve_base(None, env), Err(Error::UnableToResolveRef)); + Ok(()) + } + #[test] fn test_base_resolution_uses_override_before_default_branches() -> Result<(), Error> { let (repo_root, repo_path) = setup_repository(Some("main"))?; @@ -2351,6 +2426,7 @@ mod tests { root: root.to_owned(), bin, attrs: std::sync::OnceLock::new(), + github_actions_remote_base_ref_fallback: false, slowest_files: None, } } diff --git a/crates/turborepo-scm/src/lib.rs b/crates/turborepo-scm/src/lib.rs index 78a1ef129a348..5eb4894366a99 100644 --- a/crates/turborepo-scm/src/lib.rs +++ b/crates/turborepo-scm/src/lib.rs @@ -231,6 +231,7 @@ pub struct GitRepo { root: AbsoluteSystemPathBuf, bin: AbsoluteSystemPathBuf, attrs: OnceLock>, + github_actions_remote_base_ref_fallback: bool, /// Optional recorder for the slowest-to-hash files. Set by long-running /// consumers (the file watcher) so they can diagnose a stalled startup. slowest_files: Option>, @@ -250,6 +251,7 @@ impl Clone for GitRepo { root: self.root.clone(), bin: self.bin.clone(), attrs: OnceLock::new(), + github_actions_remote_base_ref_fallback: self.github_actions_remote_base_ref_fallback, slowest_files: self.slowest_files.clone(), } } @@ -284,6 +286,7 @@ impl GitRepo { root, bin, attrs: OnceLock::new(), + github_actions_remote_base_ref_fallback: false, slowest_files: None, }) } @@ -355,6 +358,7 @@ impl SCM { root: git_root, bin, attrs: OnceLock::new(), + github_actions_remote_base_ref_fallback: false, slowest_files: None, }), Err(e) => { @@ -367,6 +371,13 @@ impl SCM { } } + pub fn with_github_actions_remote_base_ref_fallback(mut self, enabled: bool) -> Self { + if let SCM::Git(git) = &mut self { + git.github_actions_remote_base_ref_fallback = enabled; + } + self + } + /// Attach a recorder that tracks the slowest-to-hash files. Long-running /// consumers (the file watcher) use this to diagnose a stalled startup /// caused by hashing a very large file. No-op for `SCM::Manual`. diff --git a/crates/turborepo-scm/src/repo_index.rs b/crates/turborepo-scm/src/repo_index.rs index 2127a306e487e..8c6872f3692be 100644 --- a/crates/turborepo-scm/src/repo_index.rs +++ b/crates/turborepo-scm/src/repo_index.rs @@ -1691,6 +1691,7 @@ mod tests { root: root.clone(), bin: root, attrs: OnceLock::new(), + github_actions_remote_base_ref_fallback: false, slowest_files: None, } } diff --git a/crates/turborepo-turbo-json/src/future_flags.rs b/crates/turborepo-turbo-json/src/future_flags.rs index 041a139cd60f7..4ebd48aa3b589 100644 --- a/crates/turborepo-turbo-json/src/future_flags.rs +++ b/crates/turborepo-turbo-json/src/future_flags.rs @@ -57,6 +57,11 @@ pub struct FutureFlags { /// selecting all tasks in changed packages. #[serde(default)] pub affected_using_task_inputs: bool, + /// When GitHub Actions reports a base branch that is not available as a + /// local ref, fall back to `origin/`. This supports detached + /// checkouts where only remote-tracking refs are present. + #[serde(default)] + pub github_actions_remote_base_ref_fallback: bool, /// Use task-level `inputs` globs to determine which tasks to re-run when /// files change in `turbo watch`. When enabled, only tasks whose declared /// inputs match the changed files are re-executed, rather than re-running @@ -158,18 +163,18 @@ impl TS for FutureFlags { fn inline() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: boolean, \ - pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, githubActionsRemoteBaseRefFallback?: boolean, \ + watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ + boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" .to_string() } fn inline_flattened() -> String { "{ errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, longerSignatureKey?: \ - boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: boolean, \ - pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ + boolean, affectedUsingTaskInputs?: boolean, githubActionsRemoteBaseRefFallback?: boolean, \ + watchUsingTaskInputs?: boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: \ + boolean, strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean }" .to_string() } @@ -177,18 +182,20 @@ impl TS for FutureFlags { fn decl() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ - experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean };" + boolean, githubActionsRemoteBaseRefFallback?: boolean, pruneIncludesGlobalFiles?: \ + boolean, filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ + experimentalTaskCommand?: boolean };" .to_string() } fn decl_concrete() -> String { "type FutureFlags = { errorsOnlyShowHash?: boolean, experimentalObservability?: boolean, \ longerSignatureKey?: boolean, affectedUsingTaskInputs?: boolean, watchUsingTaskInputs?: \ - boolean, pruneIncludesGlobalFiles?: boolean, filterUsingTasks?: boolean, \ - strictTaskEntrypointSelection?: boolean, globalConfiguration?: boolean, \ - experimentalCargoWorkspaces?: boolean, experimentalTaskCommand?: boolean };" + boolean, githubActionsRemoteBaseRefFallback?: boolean, pruneIncludesGlobalFiles?: \ + boolean, filterUsingTasks?: boolean, strictTaskEntrypointSelection?: boolean, \ + globalConfiguration?: boolean, experimentalCargoWorkspaces?: boolean, \ + experimentalTaskCommand?: boolean };" .to_string() } diff --git a/crates/turborepo-turbo-json/src/lib.rs b/crates/turborepo-turbo-json/src/lib.rs index 5a756821e0089..374c14d4227d9 100644 --- a/crates/turborepo-turbo-json/src/lib.rs +++ b/crates/turborepo-turbo-json/src/lib.rs @@ -782,6 +782,39 @@ mod tests { assert!(turbo_json.unwrap().future_flags.longer_signature_key); } + #[test] + fn test_deserialize_future_flags_github_actions_remote_base_ref_fallback() { + let json = r#"{ + "tasks": {}, + "futureFlags": { + "githubActionsRemoteBaseRefFallback": true + } + }"#; + + let (deserialized, diagnostics) = deserialize_from_json_str( + json, + JsonParserOptions::default().with_allow_comments(), + "turbo.json", + ); + assert!(diagnostics.is_empty()); + let raw_turbo_json: RawTurboJson = deserialized.unwrap(); + assert!( + raw_turbo_json + .future_flags + .as_ref() + .unwrap() + .as_inner() + .github_actions_remote_base_ref_fallback + ); + + let turbo_json = TurboJson::try_from(raw_turbo_json).unwrap(); + assert!( + turbo_json + .future_flags + .github_actions_remote_base_ref_fallback + ); + } + #[test] fn test_is_root_config_with_root_path() { let turbo_json = TurboJson { diff --git a/packages/turbo-types/schemas/schema.json b/packages/turbo-types/schemas/schema.json index 3e907fba56a12..78b260597ed15 100644 --- a/packages/turbo-types/schemas/schema.json +++ b/packages/turbo-types/schemas/schema.json @@ -330,6 +330,11 @@ "default": false, "type": "boolean" }, + "githubActionsRemoteBaseRefFallback": { + "description": "When GitHub Actions reports a base branch that is not available as a local ref, fall back to `origin/`. This supports detached checkouts where only remote-tracking refs are present.", + "default": false, + "type": "boolean" + }, "strictTaskEntrypointSelection": { "description": "Select requested task entrypoints according to whether the task resolves a command in the repository. When any package can run a requested task, packages without a command are not used as entrypoints. Tasks with no command anywhere remain available for graph-only orchestration, and missing tasks reached as dependencies remain in the Task Graph.", "default": false, diff --git a/packages/turbo-types/schemas/schema.v2.json b/packages/turbo-types/schemas/schema.v2.json index ffdd75200c473..ca48ee804c989 100644 --- a/packages/turbo-types/schemas/schema.v2.json +++ b/packages/turbo-types/schemas/schema.v2.json @@ -286,6 +286,11 @@ "default": false, "type": "boolean" }, + "githubActionsRemoteBaseRefFallback": { + "description": "When GitHub Actions reports a base branch that is not available as a local ref, fall back to `origin/`. This supports detached checkouts where only remote-tracking refs are present.", + "default": false, + "type": "boolean" + }, "errorsOnlyShowHash": { "description": "When using `outputLogs: \"errors-only\"`, show task hashes when tasks complete successfully. This provides visibility into which tasks are running without showing full output logs.", "default": false, diff --git a/packages/turbo-types/src/types/config-v2.ts b/packages/turbo-types/src/types/config-v2.ts index eaec1d34facee..b55e8e99b2cb5 100644 --- a/packages/turbo-types/src/types/config-v2.ts +++ b/packages/turbo-types/src/types/config-v2.ts @@ -266,6 +266,14 @@ export interface FutureFlags { * @defaultValue `false` */ affectedUsingTaskInputs?: boolean; + /** + * When GitHub Actions reports a base branch that is not available as a + * local ref, fall back to `origin/`. This supports detached + * checkouts where only remote-tracking refs are present. + * + * @defaultValue `false` + */ + githubActionsRemoteBaseRefFallback?: boolean; /** * Use task-level `inputs` globs to determine which tasks to re-run when * files change in `turbo watch`. When enabled, only tasks whose declared