diff --git a/AGENTS.md b/AGENTS.md index 99e54b14e4..f7651b39a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,8 @@ This architecture separates concerns between execution logic (core), UI state ma release was requested, verify the tag explicitly with `gh release view v --repo cbusillo/code`. - If you already know the run ID (e.g., from webhook output), run `scripts/wait-for-gh-run.sh --run `. -- Adjust the poll cadence via `--interval ` (defaults to 8). The script exits 0 on success and 1 on failure, so it can gate local automation. +- Adjust the poll cadence via `--interval ` (defaults to 8). The script exits 0 on success and nonzero on failure, so it can gate local automation. +- The wait is bounded by `--timeout ` (defaults to 1800 and is capped at 7200). Timeout exits 124. +- If GitHub reports `status=waiting`, the script reads pending deployments for the exact run, prints protected-environment and current-identity approval diagnostics, and exits 2. It never approves a deployment; use the exact-run workflow babysitter with separate automation-dispatch and human-review identities. - Pass `--failure-logs` to automatically dump logs for any job that does not finish successfully. - Dependencies: GitHub CLI (`gh`) and `jq` must be available in `PATH`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8030d0bc31..f167331112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [Unreleased] -- (none) +- Core/GH: make `gh_run_wait` fail fast with exact-run protected-environment diagnostics, never auto-approve deployments, and enforce bounded polling timeouts. ## [0.6.116] - 2026-06-04 diff --git a/code-rs/core/src/tools/handlers/gh_run_wait.rs b/code-rs/core/src/tools/handlers/gh_run_wait.rs new file mode 100644 index 0000000000..a3f81df550 --- /dev/null +++ b/code-rs/core/src/tools/handlers/gh_run_wait.rs @@ -0,0 +1,979 @@ +use std::path::Path; +use std::process::ExitStatus; +use std::process::Stdio; +use std::time::Duration; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::gh_run_wait_spec::DEFAULT_POLL_INTERVAL_SECONDS; +use crate::tools::handlers::gh_run_wait_spec::DEFAULT_TIMEOUT_SECONDS; +use crate::tools::handlers::gh_run_wait_spec::GH_RUN_WAIT_TOOL_NAME; +use crate::tools::handlers::gh_run_wait_spec::MAX_TIMEOUT_SECONDS; +use crate::tools::handlers::gh_run_wait_spec::create_gh_run_wait_tool; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use codex_utils_pty::process_group::kill_child_process_group; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const MAX_DIAGNOSTIC_LENGTH: usize = 1_000; + +pub struct GhRunWaitHandler; + +#[derive(Debug, Deserialize)] +struct GhRunWaitArgs { + #[serde(default)] + run_id: Option, + #[serde(default)] + repo: Option, + #[serde(default)] + workflow: Option, + #[serde(default)] + branch: Option, + #[serde(default)] + head_sha: Option, + #[serde(default)] + interval_seconds: Option, + #[serde(default)] + timeout_seconds: Option, +} + +#[derive(Debug)] +struct PreparedWait { + run_id: Option, + repo: Option, + workflow: Option, + branch: Option, + head_sha: Option, + interval_seconds: u64, + timeout_seconds: u64, +} + +struct WaitOutcome { + text: String, + success: bool, +} + +struct CommandOutput { + status: ExitStatus, + stdout: String, + stderr: String, +} + +enum CommandFailure { + Cancelled, + TimedOut, + Execution(String), +} + +impl ToolHandler for GhRunWaitHandler { + type Output = FunctionToolOutput; + + fn tool_name(&self) -> ToolName { + ToolName::plain(GH_RUN_WAIT_TOOL_NAME) + } + + fn spec(&self) -> Option { + Some(create_gh_run_wait_tool()) + } + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + turn, + payload, + cancellation_token, + .. + } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait handler received unsupported payload".to_string(), + )); + } + }; + let prepared = prepare_wait(parse_arguments(&arguments)?)?; + let outcome = wait_for_github_run(&turn.cwd, prepared, &cancellation_token).await?; + Ok(FunctionToolOutput::from_text( + outcome.text, + Some(outcome.success), + )) + } +} + +async fn wait_for_github_run( + cwd: &Path, + prepared: PreparedWait, + cancellation_token: &CancellationToken, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(prepared.timeout_seconds); + let mut messages = Vec::new(); + let branch = if prepared.run_id.is_none() { + Some( + resolve_branch( + cwd, + prepared.branch.as_deref(), + deadline, + cancellation_token, + ) + .await?, + ) + } else { + prepared.branch.clone() + }; + let run_id = if let Some(run_id) = prepared.run_id.clone() { + run_id + } else { + match select_run( + cwd, + prepared.repo.as_deref(), + prepared.workflow.as_deref(), + branch.as_deref().unwrap_or("main"), + prepared.head_sha.as_deref(), + deadline, + cancellation_token, + ) + .await + { + Ok(run_id) => run_id, + Err(CommandFailure::TimedOut) => { + return Ok(timeout_outcome(prepared.timeout_seconds, None)); + } + Err(CommandFailure::Cancelled) => return Ok(cancelled_outcome()), + Err(CommandFailure::Execution(message)) => { + return Err(FunctionCallError::RespondToModel(message)); + } + } + }; + + messages.push(format!( + "Waiting for GitHub Actions run {run_id} (timeout: {} seconds).", + prepared.timeout_seconds + )); + if prepared.run_id.is_none() { + let workflow = prepared.workflow.as_deref().unwrap_or("latest workflow"); + messages.push(format!( + "Selected {workflow} on branch '{}'.", + branch.as_deref().unwrap_or("main") + )); + } + + let mut last_status = String::new(); + let mut last_jobs_snapshot = String::new(); + let mut last_run_url = String::new(); + + loop { + let run_view_args = vec![ + "run".to_string(), + "view".to_string(), + run_id.clone(), + "--json".to_string(), + "status,conclusion,displayTitle,workflowName,headBranch,url,jobs".to_string(), + ]; + let output = match run_gh( + cwd, + prepared.repo.as_deref(), + &run_view_args, + deadline, + cancellation_token, + ) + .await + { + Ok(output) => output, + Err(CommandFailure::TimedOut) => { + return Ok(timeout_outcome_with_url( + prepared.timeout_seconds, + Some(&run_id), + &last_run_url, + )); + } + Err(CommandFailure::Cancelled) => return Ok(cancelled_outcome()), + Err(CommandFailure::Execution(message)) => { + return Err(FunctionCallError::RespondToModel(message)); + } + }; + + if !output.status.success() { + messages.push(format!( + "Failed to fetch run {run_id}; retrying. {}", + bounded_diagnostic(&output.stderr) + )); + match sleep_until_next_poll( + deadline, + prepared.interval_seconds, + cancellation_token, + ) + .await + { + PollSleep::Continue => continue, + PollSleep::TimedOut => { + return Ok(timeout_outcome_with_url( + prepared.timeout_seconds, + Some(&run_id), + &last_run_url, + )); + } + PollSleep::Cancelled => return Ok(cancelled_outcome()), + } + } + + let run_payload: Value = serde_json::from_str(&output.stdout).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "gh_run_wait could not parse GitHub run {run_id}: {error}" + )) + })?; + let status = json_string(&run_payload, "status").unwrap_or_else(|| "unknown".to_string()); + let conclusion = json_string(&run_payload, "conclusion").unwrap_or_default(); + let workflow_name = json_string(&run_payload, "workflowName") + .unwrap_or_else(|| "unknown workflow".to_string()); + let display_title = json_string(&run_payload, "displayTitle") + .unwrap_or_else(|| "no title".to_string()); + let branch_name = json_string(&run_payload, "headBranch") + .unwrap_or_else(|| "unknown branch".to_string()); + let run_url = json_string(&run_payload, "url").unwrap_or_default(); + if !run_url.is_empty() { + last_run_url.clone_from(&run_url); + } + + if status != last_status { + let conclusion_suffix = if conclusion.is_empty() { + String::new() + } else { + format!(", conclusion: {conclusion}") + }; + messages.push(format!( + "[{workflow_name}] {display_title} on branch '{branch_name}' -> status: {status}{conclusion_suffix}" + )); + if !run_url.is_empty() { + messages.push(run_url.clone()); + } + last_status.clone_from(&status); + } + + if status == "waiting" { + append_waiting_diagnostics( + cwd, + prepared.repo.as_deref(), + &run_id, + &run_url, + deadline, + cancellation_token, + &mut messages, + ) + .await; + return Ok(WaitOutcome { + text: messages.join("\n"), + success: false, + }); + } + + let jobs = run_payload + .get("jobs") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + let jobs_snapshot = jobs + .iter() + .map(job_snapshot) + .collect::>() + .join("\n"); + if jobs_snapshot != last_jobs_snapshot { + if !jobs.is_empty() { + messages.push("Job summary:".to_string()); + messages.extend(jobs.iter().map(|job| format!(" - {}", job_summary(job)))); + } + last_jobs_snapshot = jobs_snapshot; + } + + let failing_jobs = jobs + .iter() + .filter(|job| job_failed(job)) + .map(|job| json_string(job, "name").unwrap_or_else(|| "unnamed job".to_string())) + .collect::>(); + if !failing_jobs.is_empty() { + messages.push(format!( + "Run {run_id} has failing job(s): {}.", + failing_jobs.join(", ") + )); + return Ok(WaitOutcome { + text: messages.join("\n"), + success: false, + }); + } + + if status == "completed" { + if conclusion == "success" { + messages.push(format!("Run {run_id} succeeded.")); + return Ok(WaitOutcome { + text: messages.join("\n"), + success: true, + }); + } + messages.push(format!( + "Run {run_id} finished with conclusion '{}'.", + if conclusion.is_empty() { + "unknown" + } else { + &conclusion + } + )); + return Ok(WaitOutcome { + text: messages.join("\n"), + success: false, + }); + } + + match sleep_until_next_poll(deadline, prepared.interval_seconds, cancellation_token).await { + PollSleep::Continue => {} + PollSleep::TimedOut => { + return Ok(timeout_outcome_with_url( + prepared.timeout_seconds, + Some(&run_id), + &last_run_url, + )); + } + PollSleep::Cancelled => return Ok(cancelled_outcome()), + } + } +} + +async fn select_run( + cwd: &Path, + repo: Option<&str>, + workflow: Option<&str>, + branch: &str, + head_sha: Option<&str>, + deadline: Instant, + cancellation_token: &CancellationToken, +) -> Result { + let args = build_run_list_args(workflow, branch, head_sha); + let output = run_gh(cwd, repo, &args, deadline, cancellation_token).await?; + if !output.status.success() { + return Err(CommandFailure::Execution(format!( + "gh_run_wait failed to list GitHub Actions runs: {}", + bounded_diagnostic(&output.stderr) + ))); + } + let payload: Value = serde_json::from_str(&output.stdout).map_err(|error| { + CommandFailure::Execution(format!( + "gh_run_wait could not parse the GitHub Actions run list: {error}" + )) + })?; + let run = payload + .as_array() + .and_then(|runs| runs.first()) + .ok_or_else(|| { + let workflow_description = workflow.unwrap_or("any workflow"); + let sha_description = head_sha + .map(|sha| format!(" at commit '{sha}'")) + .unwrap_or_default(); + CommandFailure::Execution(format!( + "gh_run_wait found no {workflow_description} runs on branch '{branch}'{sha_description}." + )) + })?; + if let Some(expected_sha) = head_sha { + let actual_sha = json_string(run, "headSha").unwrap_or_default(); + if !actual_sha.eq_ignore_ascii_case(expected_sha) { + return Err(CommandFailure::Execution(format!( + "gh_run_wait selected a run whose commit did not match '{expected_sha}'." + ))); + } + } + json_identifier(run.get("databaseId")).ok_or_else(|| { + CommandFailure::Execution( + "gh_run_wait selected a run without a valid numeric run id.".to_string(), + ) + }) +} + +fn build_run_list_args( + workflow: Option<&str>, + branch: &str, + head_sha: Option<&str>, +) -> Vec { + let mut args = vec!["run".to_string(), "list".to_string()]; + if let Some(workflow) = workflow { + args.extend(["--workflow".to_string(), workflow.to_string()]); + } + args.extend(["--branch".to_string(), branch.to_string()]); + if let Some(head_sha) = head_sha { + args.extend(["--commit".to_string(), head_sha.to_string()]); + } + args.extend([ + "--limit".to_string(), + "1".to_string(), + "--json".to_string(), + "databaseId,workflowName,displayTitle,headBranch,headSha".to_string(), + ]); + args +} + +async fn resolve_branch( + cwd: &Path, + requested_branch: Option<&str>, + deadline: Instant, + cancellation_token: &CancellationToken, +) -> Result { + if let Some(branch) = requested_branch { + return Ok(branch.to_string()); + } + for args in [ + vec![ + "rev-parse".to_string(), + "--abbrev-ref".to_string(), + "HEAD".to_string(), + ], + vec![ + "symbolic-ref".to_string(), + "--quiet".to_string(), + "--short".to_string(), + "refs/remotes/origin/HEAD".to_string(), + ], + ] { + let output = match run_command("git", &args, cwd, deadline, cancellation_token).await { + Ok(output) => output, + Err(CommandFailure::TimedOut) => { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait timed out while resolving the current branch.".to_string(), + )); + } + Err(CommandFailure::Cancelled) => { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait was cancelled while resolving the current branch.".to_string(), + )); + } + Err(CommandFailure::Execution(_)) => continue, + }; + if output.status.success() { + let branch = output.stdout.trim().trim_start_matches("origin/"); + if !branch.is_empty() && branch != "HEAD" { + return Ok(branch.to_string()); + } + } + } + Ok("main".to_string()) +} + +async fn append_waiting_diagnostics( + cwd: &Path, + requested_repo: Option<&str>, + run_id: &str, + run_url: &str, + deadline: Instant, + cancellation_token: &CancellationToken, + messages: &mut Vec, +) { + messages.push(format!( + "Run {run_id} requires attention: GitHub reports status 'waiting'." + )); + if !run_url.is_empty() { + messages.push(run_url.to_string()); + } + + let repo = if let Some(repo) = requested_repo { + Some(repo.to_string()) + } else { + let args = vec![ + "repo".to_string(), + "view".to_string(), + "--json".to_string(), + "nameWithOwner".to_string(), + "--jq".to_string(), + ".nameWithOwner".to_string(), + ]; + match run_command("gh", &args, cwd, deadline, cancellation_token).await { + Ok(output) if output.status.success() && !output.stdout.trim().is_empty() => { + Some(output.stdout.trim().to_string()) + } + _ => None, + } + }; + + if let Some(repo) = repo { + let args = vec![ + "api".to_string(), + format!("repos/{repo}/actions/runs/{run_id}/pending_deployments"), + ]; + match run_command("gh", &args, cwd, deadline, cancellation_token).await { + Ok(output) if output.status.success() => { + append_pending_deployments(&output.stdout, run_id, &repo, messages); + } + Ok(output) => messages.push(format!( + "Unable to read pending deployments for exact run {run_id} in {repo}: {}", + bounded_diagnostic(&output.stderr) + )), + Err(CommandFailure::TimedOut) => messages.push( + "Timed out while reading pending deployments for the exact run.".to_string(), + ), + Err(CommandFailure::Cancelled) => { + messages.push("Pending-deployment inspection was cancelled.".to_string()) + } + Err(CommandFailure::Execution(message)) => messages.push(message), + } + } else { + messages.push( + "Unable to resolve the repository, so pending deployments could not be inspected." + .to_string(), + ); + } + + messages.push("gh_run_wait does not approve protected environments.".to_string()); + messages.push(format!( + "Use the exact-run workflow babysitter for run {run_id} with separate automation-dispatch and human-review identities." + )); +} + +fn append_pending_deployments( + payload: &str, + run_id: &str, + repo: &str, + messages: &mut Vec, +) { + let Ok(Value::Array(pending_deployments)) = serde_json::from_str::(payload) else { + messages.push(format!( + "GitHub returned an unexpected pending-deployments response for exact run {run_id} in {repo}." + )); + return; + }; + if pending_deployments.is_empty() { + messages.push( + "GitHub reported no pending deployments; the wait may be a concurrency, queue, or custom protection-rule gate." + .to_string(), + ); + return; + } + messages.push("Pending protected environment deployment(s):".to_string()); + for pending in pending_deployments { + let environment_name = pending + .get("environment") + .and_then(|environment| json_string(environment, "name")) + .unwrap_or_else(|| "unnamed environment".to_string()); + let can_approve = match pending.get("current_user_can_approve").and_then(Value::as_bool) { + Some(true) => "yes", + Some(false) => "no", + None => "unknown", + }; + messages.push(format!( + " - {}: current GitHub CLI identity can approve: {can_approve}", + single_line(&environment_name) + )); + } +} + +async fn run_gh( + cwd: &Path, + repo: Option<&str>, + args: &[String], + deadline: Instant, + cancellation_token: &CancellationToken, +) -> Result { + let mut gh_args = Vec::new(); + if let Some(repo) = repo { + gh_args.extend(["-R".to_string(), repo.to_string()]); + } + gh_args.extend_from_slice(args); + run_command("gh", &gh_args, cwd, deadline, cancellation_token).await +} + +async fn run_command( + program: &str, + args: &[String], + cwd: &Path, + deadline: Instant, + cancellation_token: &CancellationToken, +) -> Result { + if Instant::now() >= deadline { + return Err(CommandFailure::TimedOut); + } + let mut command = Command::new(program); + command + .args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + command.process_group(0); + + let mut child = command.spawn().map_err(|error| { + CommandFailure::Execution(format!("gh_run_wait could not start {program}: {error}")) + })?; + let mut stdout = child.stdout.take().ok_or_else(|| { + CommandFailure::Execution(format!("gh_run_wait could not capture {program} stdout")) + })?; + let mut stderr = child.stderr.take().ok_or_else(|| { + CommandFailure::Execution(format!("gh_run_wait could not capture {program} stderr")) + })?; + let stdout_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).await.map(|_| bytes) + }); + let stderr_task = tokio::spawn(async move { + let mut bytes = Vec::new(); + stderr.read_to_end(&mut bytes).await.map(|_| bytes) + }); + + enum CommandExit { + Completed(Result), + TimedOut, + Cancelled, + } + + let command_exit = tokio::select! { + status = child.wait() => CommandExit::Completed(status), + _ = tokio::time::sleep_until(deadline) => CommandExit::TimedOut, + _ = cancellation_token.cancelled() => CommandExit::Cancelled, + }; + let interruption = match command_exit { + CommandExit::Completed(_) => None, + CommandExit::TimedOut => Some(CommandFailure::TimedOut), + CommandExit::Cancelled => Some(CommandFailure::Cancelled), + }; + if interruption.is_some() { + let _ = kill_child_process_group(&mut child); + let _ = child.start_kill(); + let _ = child.wait().await; + } + + let stdout = stdout_task + .await + .map_err(|error| CommandFailure::Execution(format!("stdout task failed: {error}")))? + .map_err(|error| CommandFailure::Execution(format!("stdout read failed: {error}")))?; + let stderr = stderr_task + .await + .map_err(|error| CommandFailure::Execution(format!("stderr task failed: {error}")))? + .map_err(|error| CommandFailure::Execution(format!("stderr read failed: {error}")))?; + if let Some(interruption) = interruption { + return Err(interruption); + } + let CommandExit::Completed(status) = command_exit else { + unreachable!("interrupted commands return before status handling"); + }; + let status = status.map_err(|error| { + CommandFailure::Execution(format!("gh_run_wait could not wait for {program}: {error}")) + })?; + Ok(CommandOutput { + status, + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }) +} + +enum PollSleep { + Continue, + TimedOut, + Cancelled, +} + +async fn sleep_until_next_poll( + deadline: Instant, + interval_seconds: u64, + cancellation_token: &CancellationToken, +) -> PollSleep { + let now = Instant::now(); + if now >= deadline { + return PollSleep::TimedOut; + } + let next_poll = std::cmp::min( + deadline, + now + Duration::from_secs(interval_seconds), + ); + tokio::select! { + _ = tokio::time::sleep_until(next_poll) => { + if Instant::now() >= deadline { + PollSleep::TimedOut + } else { + PollSleep::Continue + } + } + _ = cancellation_token.cancelled() => PollSleep::Cancelled, + } +} + +fn prepare_wait(args: GhRunWaitArgs) -> Result { + let interval_seconds = args + .interval_seconds + .unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS); + let timeout_seconds = args.timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS); + if interval_seconds == 0 { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait interval_seconds must be greater than zero".to_string(), + )); + } + if timeout_seconds == 0 || timeout_seconds > MAX_TIMEOUT_SECONDS { + return Err(FunctionCallError::RespondToModel(format!( + "gh_run_wait timeout_seconds must be greater than zero and at most {MAX_TIMEOUT_SECONDS}" + ))); + } + if interval_seconds > timeout_seconds { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait interval_seconds must not exceed timeout_seconds".to_string(), + )); + } + + let repo = normalize_optional_string(args.repo); + if let Some(repo) = repo.as_deref() + && !valid_repo(repo) + { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait repo must use OWNER/REPO format".to_string(), + )); + } + Ok(PreparedWait { + run_id: normalize_run_id(args.run_id)?, + repo, + workflow: normalize_optional_string(args.workflow), + branch: normalize_optional_string(args.branch), + head_sha: normalize_optional_string(args.head_sha), + interval_seconds, + timeout_seconds, + }) +} + +fn normalize_run_id(value: Option) -> Result, FunctionCallError> { + let Some(value) = value else { + return Ok(None); + }; + let run_id = match value { + Value::String(value) => value.trim().to_string(), + Value::Number(value) => value.as_u64().map(|value| value.to_string()).ok_or_else(|| { + FunctionCallError::RespondToModel( + "gh_run_wait run_id must be a positive integer".to_string(), + ) + })?, + Value::Null => return Ok(None), + _ => { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait run_id must be a string or positive integer".to_string(), + )); + } + }; + if run_id.is_empty() { + return Ok(None); + } + if !run_id.bytes().all(|byte| byte.is_ascii_digit()) || run_id.starts_with('0') { + return Err(FunctionCallError::RespondToModel( + "gh_run_wait run_id must be a positive integer".to_string(), + )); + } + Ok(Some(run_id)) +} + +fn normalize_optional_string(value: Option) -> Option { + value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) +} + +fn valid_repo(repo: &str) -> bool { + let mut parts = repo.split('/'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(owner), Some(name), None) + if !owner.is_empty() + && !name.is_empty() + && !owner.chars().any(char::is_whitespace) + && !name.chars().any(char::is_whitespace) + ) +} + +fn json_string(value: &Value, key: &str) -> Option { + value.get(key).and_then(|field| match field { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +fn json_identifier(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(value)) => value.as_u64().map(|value| value.to_string()), + Some(Value::String(value)) + if !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && !value.starts_with('0') => + { + Some(value.clone()) + } + _ => None, + } +} + +fn job_snapshot(job: &Value) -> String { + format!( + "{}|{}|{}", + json_string(job, "name").unwrap_or_else(|| "unnamed job".to_string()), + json_string(job, "status").unwrap_or_default(), + json_string(job, "conclusion").unwrap_or_default() + ) +} + +fn job_summary(job: &Value) -> String { + let name = json_string(job, "name").unwrap_or_else(|| "unnamed job".to_string()); + let status = json_string(job, "status").unwrap_or_else(|| "unknown".to_string()); + let conclusion = json_string(job, "conclusion").unwrap_or_default(); + if status == "completed" && !conclusion.is_empty() { + format!("{name}: {status} ({conclusion})") + } else { + format!("{name}: {status}") + } +} + +fn job_failed(job: &Value) -> bool { + if json_string(job, "status").as_deref() != Some("completed") { + return false; + } + !matches!( + json_string(job, "conclusion").as_deref(), + None | Some("") | Some("success") | Some("skipped") | Some("neutral") + ) +} + +fn timeout_outcome(timeout_seconds: u64, run_id: Option<&str>) -> WaitOutcome { + timeout_outcome_with_url(timeout_seconds, run_id, "") +} + +fn timeout_outcome_with_url( + timeout_seconds: u64, + run_id: Option<&str>, + run_url: &str, +) -> WaitOutcome { + let subject = run_id + .map(|run_id| format!("run {run_id}")) + .unwrap_or_else(|| "run selection".to_string()); + let mut text = format!( + "gh_run_wait timed out after {timeout_seconds} seconds while waiting for {subject}." + ); + if !run_url.is_empty() { + text.push('\n'); + text.push_str(run_url); + } + text.push_str("\nRetry the same exact run or use the exact-run workflow babysitter."); + WaitOutcome { + text, + success: false, + } +} + +fn cancelled_outcome() -> WaitOutcome { + WaitOutcome { + text: "gh_run_wait was cancelled; its active child process was terminated and reaped." + .to_string(), + success: false, + } +} + +fn bounded_diagnostic(value: &str) -> String { + let normalized = single_line(value.trim()); + if normalized.is_empty() { + return "No diagnostic was returned.".to_string(); + } + let mut characters = normalized.chars(); + let bounded = characters + .by_ref() + .take(MAX_DIAGNOSTIC_LENGTH) + .collect::(); + if characters.next().is_some() { + format!("{bounded}…") + } else { + bounded + } +} + +fn single_line(value: &str) -> String { + value + .chars() + .map(|character| { + if matches!(character, '\r' | '\n' | '\t') { + ' ' + } else { + character + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prepare_wait_preserves_exact_run_and_timeout() { + let args: GhRunWaitArgs = serde_json::from_value(json!({ + "run_id": 4242, + "repo": "example/repo", + "head_sha": "abc123", + "interval_seconds": 3, + "timeout_seconds": 30 + })) + .expect("valid args"); + let prepared = prepare_wait(args).expect("prepared args"); + assert_eq!(prepared.run_id.as_deref(), Some("4242")); + assert_eq!(prepared.repo.as_deref(), Some("example/repo")); + assert_eq!(prepared.head_sha.as_deref(), Some("abc123")); + assert_eq!(prepared.interval_seconds, 3); + assert_eq!(prepared.timeout_seconds, 30); + } + + #[test] + fn prepare_wait_rejects_unbounded_timeout() { + let args: GhRunWaitArgs = serde_json::from_value(json!({ + "timeout_seconds": MAX_TIMEOUT_SECONDS + 1 + })) + .expect("valid JSON"); + assert!(prepare_wait(args).is_err()); + } + + #[test] + fn exact_sha_selection_uses_github_commit_filter() { + let args = build_run_list_args(Some("CI"), "main", Some("abc123")); + assert_eq!( + args, + vec![ + "run", + "list", + "--workflow", + "CI", + "--branch", + "main", + "--commit", + "abc123", + "--limit", + "1", + "--json", + "databaseId,workflowName,displayTitle,headBranch,headSha", + ] + ); + } + + #[test] + fn pending_deployment_diagnostics_include_approval_capability() { + let mut messages = Vec::new(); + append_pending_deployments( + r#"[{"environment":{"name":"production"},"current_user_can_approve":false}]"#, + "4242", + "example/repo", + &mut messages, + ); + assert_eq!( + messages, + vec![ + "Pending protected environment deployment(s):", + " - production: current GitHub CLI identity can approve: no", + ] + ); + } +} diff --git a/code-rs/core/src/tools/handlers/gh_run_wait_spec.rs b/code-rs/core/src/tools/handlers/gh_run_wait_spec.rs new file mode 100644 index 0000000000..0a735c8be2 --- /dev/null +++ b/code-rs/core/src/tools/handlers/gh_run_wait_spec.rs @@ -0,0 +1,77 @@ +use codex_tools::AdditionalProperties; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use std::collections::BTreeMap; + +pub const GH_RUN_WAIT_TOOL_NAME: &str = "gh_run_wait"; +pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 8; +pub const DEFAULT_TIMEOUT_SECONDS: u64 = 1_800; +pub const MAX_TIMEOUT_SECONDS: u64 = 7_200; + +pub fn create_gh_run_wait_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "run_id".to_string(), + JsonSchema::string(Some("GitHub Actions run ID to wait for.".to_string())), + ), + ( + "repo".to_string(), + JsonSchema::string(Some( + "Repository in OWNER/REPO form. Defaults to the repository at the current working directory." + .to_string(), + )), + ), + ( + "workflow".to_string(), + JsonSchema::string(Some( + "Workflow name or filename used to select the latest run when run_id is omitted." + .to_string(), + )), + ), + ( + "branch".to_string(), + JsonSchema::string(Some( + "Branch used to select the latest run. Defaults to the current branch, then the repository default branch." + .to_string(), + )), + ), + ( + "head_sha".to_string(), + JsonSchema::string(Some( + "Exact commit SHA required when selecting a run by workflow and branch. Prefer this after a merge or push so a newer run is not selected accidentally." + .to_string(), + )), + ), + ( + "interval_seconds".to_string(), + JsonSchema::integer(Some(format!( + "Polling interval in seconds (default: {DEFAULT_POLL_INTERVAL_SECONDS})." + ))), + ), + ( + "timeout_seconds".to_string(), + JsonSchema::integer(Some(format!( + "Overall timeout in seconds (default: {DEFAULT_TIMEOUT_SECONDS}; maximum: {MAX_TIMEOUT_SECONDS})." + ))), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: GH_RUN_WAIT_TOOL_NAME.to_string(), + description: "Wait for a GitHub Actions run by explicit run ID or latest workflow/branch selection, optionally constrained by commit SHA. Protected-environment waits fail immediately with exact-run pending-deployment diagnostics and are never auto-approved; other runs poll until completion or the bounded timeout." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + None, + Some(AdditionalProperties::Boolean(false)), + ), + output_schema: None, + }) +} + +#[cfg(test)] +#[path = "gh_run_wait_spec_tests.rs"] +mod tests; diff --git a/code-rs/core/src/tools/handlers/gh_run_wait_spec_tests.rs b/code-rs/core/src/tools/handlers/gh_run_wait_spec_tests.rs new file mode 100644 index 0000000000..7d58fb6f7d --- /dev/null +++ b/code-rs/core/src/tools/handlers/gh_run_wait_spec_tests.rs @@ -0,0 +1,38 @@ +use super::*; +use codex_tools::JsonSchemaPrimitiveType; +use codex_tools::JsonSchemaType; + +#[test] +fn gh_run_wait_spec_exposes_bounded_timeout_and_exact_run_fields() { + let ToolSpec::Function(spec) = create_gh_run_wait_tool() else { + panic!("expected function tool spec"); + }; + assert_eq!(spec.name, GH_RUN_WAIT_TOOL_NAME); + assert!(!spec.strict); + let properties = spec + .parameters + .properties + .as_ref() + .expect("object properties"); + for name in [ + "run_id", + "repo", + "workflow", + "branch", + "head_sha", + "interval_seconds", + "timeout_seconds", + ] { + assert!(properties.contains_key(name), "missing {name} property"); + } + assert_eq!( + properties["timeout_seconds"].schema_type, + Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Integer)) + ); + assert!( + properties["timeout_seconds"] + .description + .as_deref() + .is_some_and(|description| description.contains("maximum: 7200")) + ); +} diff --git a/code-rs/core/src/tools/handlers/mod.rs b/code-rs/core/src/tools/handlers/mod.rs index c299dff167..140dc1def8 100644 --- a/code-rs/core/src/tools/handlers/mod.rs +++ b/code-rs/core/src/tools/handlers/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod apply_patch_spec; mod code_bridge; pub(crate) mod code_bridge_spec; mod dynamic; +mod gh_run_wait; +pub(crate) mod gh_run_wait_spec; mod goal; pub(crate) mod goal_spec; mod mcp; @@ -53,6 +55,7 @@ pub use code_bridge::CodeBridgeHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; +pub use gh_run_wait::GhRunWaitHandler; pub use goal::CreateGoalHandler; pub use goal::GetGoalHandler; pub use goal::UpdateGoalHandler; diff --git a/code-rs/core/src/tools/spec_plan.rs b/code-rs/core/src/tools/spec_plan.rs index 55fad9803a..78142e5218 100644 --- a/code-rs/core/src/tools/spec_plan.rs +++ b/code-rs/core/src/tools/spec_plan.rs @@ -9,6 +9,7 @@ use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; use crate::tools::handlers::GetGoalHandler; +use crate::tools::handlers::GhRunWaitHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; use crate::tools::handlers::LocalShellHandler; @@ -196,6 +197,7 @@ pub fn build_tool_registry_builder( } builder.register_handler(Arc::new(PlanHandler)); + builder.register_handler(Arc::new(GhRunWaitHandler)); if config.goal_tools { builder.register_handler(Arc::new(GetGoalHandler)); builder.register_handler(Arc::new(CreateGoalHandler)); diff --git a/code-rs/core/src/tools/spec_plan_tests.rs b/code-rs/core/src/tools/spec_plan_tests.rs index 99c2f1add0..e97d05a338 100644 --- a/code-rs/core/src/tools/spec_plan_tests.rs +++ b/code-rs/core/src/tools/spec_plan_tests.rs @@ -3,6 +3,7 @@ use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; use crate::tools::handlers::goal_spec::create_create_goal_tool; use crate::tools::handlers::goal_spec::create_get_goal_tool; use crate::tools::handlers::goal_spec::create_update_goal_tool; +use crate::tools::handlers::gh_run_wait_spec::create_gh_run_wait_tool; use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1; use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v2; @@ -117,6 +118,7 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { }), create_write_stdin_tool(), create_update_plan_tool(), + create_gh_run_wait_tool(), request_user_input_tool_spec(&request_user_input_available_modes(&features)), create_apply_patch_freeform_tool(), ToolSpec::WebSearch { diff --git a/scripts/tests/wait-for-gh-run-test.sh b/scripts/tests/wait-for-gh-run-test.sh new file mode 100644 index 0000000000..3dc63e4392 --- /dev/null +++ b/scripts/tests/wait-for-gh-run-test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +SCRIPT="$ROOT_DIR/scripts/wait-for-gh-run.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + [[ "$haystack" == *"$needle"* ]] || fail "expected output to contain: $needle" +} + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT + +mkdir -p "$tmp_dir/bin" +cat >"$tmp_dir/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >>"$GH_MOCK_LOG" + +if [[ "${1:-}" == "-R" ]]; then + shift 2 +fi + +case "$GH_MOCK_SCENARIO:${1:-} ${2:-}" in + "waiting:run view") + cat <<'JSON' +{"status":"waiting","conclusion":null,"displayTitle":"Deploy production","workflowName":"Deploy","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:40Z","jobs":[]} +JSON + ;; + "waiting:api repos/example/repo/actions/runs/4242/pending_deployments") + cat <<'JSON' +[{"environment":{"id":7,"name":"launchplane-authz-admin"},"current_user_can_approve":false,"wait_timer":0,"reviewers":[]}] +JSON + ;; + "timeout:run view") + cat <<'JSON' +{"status":"in_progress","conclusion":null,"displayTitle":"Build","workflowName":"CI","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:40Z","jobs":[{"databaseId":8,"name":"build","status":"in_progress","conclusion":null}]} +JSON + ;; + "lifecycle:run view") + count=0 + if [[ -f "$GH_MOCK_STATE" ]]; then + count=$(cat "$GH_MOCK_STATE") + fi + count=$((count + 1)) + printf '%s\n' "$count" >"$GH_MOCK_STATE" + if ((count == 1)); then + cat <<'JSON' +{"status":"queued","conclusion":null,"displayTitle":"Build","workflowName":"CI","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:40Z","jobs":[{"databaseId":8,"name":"build","status":"queued","conclusion":null}]} +JSON + elif ((count == 2)); then + cat <<'JSON' +{"status":"in_progress","conclusion":null,"displayTitle":"Build","workflowName":"CI","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:41Z","jobs":[{"databaseId":8,"name":"build","status":"in_progress","conclusion":null}]} +JSON + else + cat <<'JSON' +{"status":"completed","conclusion":"success","displayTitle":"Build","workflowName":"CI","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:42Z","jobs":[{"databaseId":8,"name":"build","status":"completed","conclusion":"success"}]} +JSON + fi + ;; + "select:run list") + cat <<'JSON' +[{"databaseId":4242,"workflowName":"CI","displayTitle":"Build","headBranch":"main","headSha":"abc123"}] +JSON + ;; + "select:run view") + cat <<'JSON' +{"status":"completed","conclusion":"success","displayTitle":"Build","workflowName":"CI","headBranch":"main","url":"https://github.com/example/repo/actions/runs/4242","startedAt":"2026-07-25T20:01:33Z","updatedAt":"2026-07-25T20:01:42Z","jobs":[{"databaseId":8,"name":"build","status":"completed","conclusion":"success"}]} +JSON + ;; + *) + echo "unexpected gh invocation: $*" >&2 + exit 64 + ;; +esac +EOF +chmod +x "$tmp_dir/bin/gh" + +run_wait() { + local scenario="$1" + shift + : >"$tmp_dir/gh.log" + rm -f "$tmp_dir/gh.state" + set +e + PATH="$tmp_dir/bin:$PATH" \ + GH_MOCK_LOG="$tmp_dir/gh.log" \ + GH_MOCK_SCENARIO="$scenario" \ + GH_MOCK_STATE="$tmp_dir/gh.state" \ + bash "$SCRIPT" "$@" >"$tmp_dir/output" 2>&1 + WAIT_STATUS=$? + set -e + WAIT_OUTPUT=$(cat "$tmp_dir/output") +} + +run_wait waiting \ + --run 4242 \ + --repo example/repo \ + --interval 1 \ + --timeout 30 +[[ $WAIT_STATUS -eq 2 ]] || fail "expected protected-environment exit 2, got $WAIT_STATUS; output: $WAIT_OUTPUT" +assert_contains "$WAIT_OUTPUT" "launchplane-authz-admin" +assert_contains "$WAIT_OUTPUT" "current GitHub CLI identity can approve: no" +assert_contains "$WAIT_OUTPUT" "exact-run babysitter" +assert_contains "$(cat "$tmp_dir/gh.log")" "api repos/example/repo/actions/runs/4242/pending_deployments" +if grep -Eq '(^| )(--method|-X) (POST|PUT|PATCH|DELETE)($| )' "$tmp_dir/gh.log"; then + fail "generic waiter must not mutate pending deployments" +fi + +run_wait timeout \ + --run 4242 \ + --repo example/repo \ + --interval 1 \ + --timeout 1 +[[ $WAIT_STATUS -eq 124 ]] || fail "expected timeout exit 124, got $WAIT_STATUS; output: $WAIT_OUTPUT" +assert_contains "$WAIT_OUTPUT" "Timed out after" +assert_contains "$WAIT_OUTPUT" "exact-run babysitter" + +run_wait lifecycle \ + --run 4242 \ + --repo example/repo \ + --interval 1 \ + --timeout 5 +[[ $WAIT_STATUS -eq 0 ]] || fail "expected queued/in_progress/completed lifecycle to succeed, got $WAIT_STATUS; output: $WAIT_OUTPUT" +assert_contains "$WAIT_OUTPUT" "status: queued" +assert_contains "$WAIT_OUTPUT" "status: in_progress" +assert_contains "$WAIT_OUTPUT" "Run 4242 succeeded" + +run_wait select \ + --repo example/repo \ + --workflow CI \ + --branch main \ + --head-sha abc123 \ + --interval 1 \ + --timeout 5 +[[ $WAIT_STATUS -eq 0 ]] || fail "expected exact commit selection to succeed, got $WAIT_STATUS; output: $WAIT_OUTPUT" +assert_contains "$(cat "$tmp_dir/gh.log")" "run list --workflow CI --branch main --limit 1" +assert_contains "$(cat "$tmp_dir/gh.log")" "--commit abc123" +if grep -q -- "--limit 20" "$tmp_dir/gh.log"; then + fail "exact commit selection must use GitHub's commit filter instead of a fixed 20-run window" +fi + +echo "PASS: waiting diagnostics, bounded timeout, and normal lifecycle behavior" diff --git a/scripts/wait-for-gh-run.sh b/scripts/wait-for-gh-run.sh index 599f1bb5d8..7ff01c2049 100755 --- a/scripts/wait-for-gh-run.sh +++ b/scripts/wait-for-gh-run.sh @@ -16,14 +16,19 @@ Usage: wait-for-gh-run.sh [OPTIONS] Options: -r, --run ID Run ID to monitor. + -R, --repo OWNER/REPO Repository to monitor (default: repository at the current directory). -w, --workflow NAME Workflow name or filename to pick the latest run. -b, --branch BRANCH Branch to filter when selecting a run (default: current branch). + -s, --head-sha SHA Commit SHA to match when selecting the latest run. -i, --interval SECONDS Polling interval in seconds (default: 8). + -t, --timeout SECONDS Overall timeout in seconds (default: 1800; maximum: 7200). -L, --failure-logs Print logs for any job that does not finish successfully. -h, --help Show this help message. If neither --run nor --workflow is provided, the latest run on the current -branch is selected automatically. +branch is selected automatically. A protected-environment wait exits 2 after +printing pending-deployment diagnostics. A timeout exits 124. This command +never approves a deployment. EOF } @@ -35,9 +40,13 @@ require_binary() { } RUN_ID="" +REPO="" WORKFLOW="" BRANCH="" +HEAD_SHA="" INTERVAL="8" +TIMEOUT="1800" +MAX_TIMEOUT=7200 PRINT_FAILURE_LOGS=false AUTO_SELECTED_RUN=false @@ -47,6 +56,10 @@ while [[ $# -gt 0 ]]; do RUN_ID="${2:-}" shift 2 ;; + -R|--repo) + REPO="${2:-}" + shift 2 + ;; -w|--workflow) WORKFLOW="${2:-}" shift 2 @@ -59,6 +72,14 @@ while [[ $# -gt 0 ]]; do INTERVAL="${2:-}" shift 2 ;; + -s|--head-sha) + HEAD_SHA="${2:-}" + shift 2 + ;; + -t|--timeout) + TIMEOUT="${2:-}" + shift 2 + ;; -L|--failure-logs) PRINT_FAILURE_LOGS=true shift @@ -78,6 +99,125 @@ done require_binary gh require_binary jq +if [[ ! "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then + echo "error: --interval must be a positive integer" >&2 + exit 1 +fi +if [[ ! "$TIMEOUT" =~ ^[1-9][0-9]*$ ]]; then + echo "error: --timeout must be a positive integer" >&2 + exit 1 +fi + +INTERVAL=$((10#$INTERVAL)) +TIMEOUT=$((10#$TIMEOUT)) + +if ((TIMEOUT > MAX_TIMEOUT)); then + echo "error: --timeout must be at most $MAX_TIMEOUT seconds" >&2 + exit 1 +fi +if ((INTERVAL > TIMEOUT)); then + echo "error: --interval must not exceed --timeout" >&2 + exit 1 +fi +if [[ -n "$REPO" && ! "$REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then + echo "error: --repo must use OWNER/REPO format" >&2 + exit 1 +fi + +last_run_url="" +wait_started_seconds=$SECONDS + +exit_on_timeout() { + local elapsed=$((SECONDS - wait_started_seconds)) + echo "Timed out after $(format_duration "$elapsed") waiting for GitHub Actions run ${RUN_ID:-selection} (limit: $(format_duration "$TIMEOUT"))." >&2 + [[ -n "$last_run_url" ]] && echo " $last_run_url" >&2 + echo "Retry gh_run_wait for the same exact run or use the exact-run babysitter for longer monitoring." >&2 + exit 124 +} + +check_timeout() { + if ((SECONDS - wait_started_seconds >= TIMEOUT)); then + exit_on_timeout + fi +} + +sleep_until_next_poll() { + local elapsed=$((SECONDS - wait_started_seconds)) + local remaining=$((TIMEOUT - elapsed)) + local sleep_seconds=$INTERVAL + if ((remaining <= 0)); then + exit_on_timeout + fi + if ((sleep_seconds > remaining)); then + sleep_seconds=$remaining + fi + sleep "$sleep_seconds" +} + +GH_CAPTURED_OUTPUT="" +GH_CAPTURED_ERROR="" +ACTIVE_COMMAND_PID="" + +stop_active_command() { + local command_pid="${ACTIVE_COMMAND_PID:-}" + if [[ -z "$command_pid" ]]; then + return + fi + kill -TERM -- "-$command_pid" 2>/dev/null || kill "$command_pid" 2>/dev/null || true + sleep 0.1 + kill -KILL -- "-$command_pid" 2>/dev/null || kill -9 "$command_pid" 2>/dev/null || true + wait "$command_pid" 2>/dev/null || true + ACTIVE_COMMAND_PID="" +} + +handle_interrupt() { + local exit_status="$1" + stop_active_command + exit "$exit_status" +} + +trap 'handle_interrupt 130' INT +trap 'handle_interrupt 143' TERM HUP +trap stop_active_command EXIT + +run_gh_capture() { + check_timeout + local stdout_file + local stderr_file + local command=(gh) + local command_pid + local command_status + stdout_file=$(mktemp "${TMPDIR:-/tmp}/gh-run-wait.stdout.XXXXXX") + stderr_file=$(mktemp "${TMPDIR:-/tmp}/gh-run-wait.stderr.XXXXXX") + if [[ -n "$REPO" ]]; then + command+=(-R "$REPO") + fi + command+=("$@") + set -m + "${command[@]}" >"$stdout_file" 2>"$stderr_file" & + command_pid=$! + ACTIVE_COMMAND_PID=$command_pid + set +m + while kill -0 "$command_pid" 2>/dev/null; do + if ((SECONDS - wait_started_seconds >= TIMEOUT)); then + stop_active_command + rm -f "$stdout_file" "$stderr_file" + exit_on_timeout + fi + sleep 0.1 + done + if wait "$command_pid"; then + command_status=0 + else + command_status=$? + fi + ACTIVE_COMMAND_PID="" + GH_CAPTURED_OUTPUT=$(cat "$stdout_file") + GH_CAPTURED_ERROR=$(cat "$stderr_file") + rm -f "$stdout_file" "$stderr_file" + return "$command_status" +} + default_branch() { local branch="" if command -v git >/dev/null 2>&1; then @@ -96,12 +236,6 @@ default_branch() { fi fi - if branch=$(git remote show origin 2>/dev/null | awk '/HEAD branch/ {print $NF}'); then - if [[ -n "$branch" ]]; then - echo "$branch" - return 0 - fi - fi fi echo "main" @@ -111,33 +245,72 @@ select_latest_run() { local workflow="$1" local branch="$2" local json - if ! json=$(gh run list --workflow "$workflow" --branch "$branch" --limit 1 --json databaseId,status,conclusion,displayTitle,workflowName,headBranch 2>/dev/null); then + local run_id + local args=(run list --workflow "$workflow" --branch "$branch" --limit 1 --json databaseId,status,conclusion,displayTitle,workflowName,headBranch,headSha) + if [[ -n "$HEAD_SHA" ]]; then + args+=(--commit "$HEAD_SHA") + fi + if ! run_gh_capture "${args[@]}"; then echo "error: failed to list runs for workflow '$workflow'" >&2 exit 1 fi + json=$GH_CAPTURED_OUTPUT if [[ $(jq 'length' <<<"$json") -eq 0 ]]; then echo "error: no runs found for workflow '$workflow' on branch '$branch'" >&2 exit 1 fi + if [[ -n "$HEAD_SHA" ]]; then + run_id=$(jq -r --arg head_sha "$HEAD_SHA" ' + .[0] + | select(((.headSha // "") | ascii_downcase) == ($head_sha | ascii_downcase)) + | .databaseId // empty + ' <<<"$json") + if [[ -z "$run_id" ]]; then + echo "error: no runs found for workflow '$workflow' on branch '$branch' at commit '$HEAD_SHA'" >&2 + exit 1 + fi + printf '%s\n' "$run_id" + return + fi + jq -r '.[0].databaseId' <<<"$json" } select_latest_run_any() { local branch="$1" local json - if ! json=$(gh run list --branch "$branch" --limit 1 --json databaseId,workflowName,displayTitle,headBranch 2>/dev/null); then + local run_id + local args=(run list --branch "$branch" --limit 1 --json databaseId,workflowName,displayTitle,headBranch,headSha) + if [[ -n "$HEAD_SHA" ]]; then + args+=(--commit "$HEAD_SHA") + fi + if ! run_gh_capture "${args[@]}"; then echo "error: failed to list runs on branch '$branch'" >&2 exit 1 fi + json=$GH_CAPTURED_OUTPUT if [[ $(jq 'length' <<<"$json") -eq 0 ]]; then echo "error: no runs found on branch '$branch'" >&2 exit 1 fi - WORKFLOW=$(jq -r '.[0].workflowName // ""' <<<"$json") + if [[ -n "$HEAD_SHA" ]]; then + run_id=$(jq -r --arg head_sha "$HEAD_SHA" ' + .[0] + | select(((.headSha // "") | ascii_downcase) == ($head_sha | ascii_downcase)) + | .databaseId // empty + ' <<<"$json") + if [[ -z "$run_id" ]]; then + echo "error: no runs found on branch '$branch' at commit '$HEAD_SHA'" >&2 + exit 1 + fi + printf '%s\n' "$run_id" + return + fi + jq -r '.[0].databaseId' <<<"$json" } @@ -155,6 +328,61 @@ format_duration() { fi } +resolve_repo_for_api() { + RESOLVED_REPO="" + if [[ -n "$REPO" ]]; then + RESOLVED_REPO=$REPO + return 0 + fi + if ! run_gh_capture repo view --json nameWithOwner --jq .nameWithOwner; then + return 1 + fi + RESOLVED_REPO=$GH_CAPTURED_OUTPUT + [[ -n "$RESOLVED_REPO" ]] +} + +diagnose_waiting_run() { + local run_url="$1" + local repo="" + local pending_json="" + local pending_count="0" + local pending_read=false + + echo "Run $RUN_ID requires attention: GitHub reports status 'waiting'." >&2 + [[ -n "$run_url" ]] && echo " $run_url" >&2 + + if ! resolve_repo_for_api; then + echo "Unable to resolve the repository, so pending deployments could not be inspected." >&2 + else + repo=$RESOLVED_REPO + fi + if [[ -n "$repo" ]] && ! run_gh_capture api "repos/$repo/actions/runs/$RUN_ID/pending_deployments"; then + echo "Unable to read pending deployments for exact run $RUN_ID in $repo." >&2 + elif [[ -n "$repo" ]]; then + pending_json=$GH_CAPTURED_OUTPUT + pending_read=true + fi + if [[ "$pending_read" == true ]] && ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"$pending_json"; then + echo "GitHub returned an unexpected pending-deployments response for exact run $RUN_ID." >&2 + elif [[ "$pending_read" == true ]]; then + pending_count=$(jq 'length' <<<"$pending_json") + if ((pending_count > 0)); then + echo "Pending protected environment deployment(s):" >&2 + jq -r ' + .[] + | ((.environment.name // "(unnamed environment)") | tostring | gsub("[\\r\\n\\t]"; " ")) as $name + | .current_user_can_approve as $can_approve + | " - \($name): current GitHub CLI identity can approve: \(if $can_approve == true then "yes" elif $can_approve == false then "no" else "unknown" end)" + ' <<<"$pending_json" >&2 + else + echo "GitHub reported no pending deployments; the wait may be a concurrency, queue, or custom protection-rule gate." >&2 + fi + fi + + echo "gh_run_wait does not approve protected environments." >&2 + echo "Recommendation: use the exact-run babysitter for run $RUN_ID with separate automation-dispatch and human-review identities." >&2 +} + if [[ -z "$BRANCH" ]]; then BRANCH=$(default_branch) fi @@ -169,12 +397,12 @@ if [[ -z "$RUN_ID" ]]; then fi fi -if [[ -z "$RUN_ID" ]]; then - echo "error: unable to determine run ID" >&2 +if [[ ! "$RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "error: unable to determine a valid numeric run ID" >&2 exit 1 fi -echo "Waiting for GitHub Actions run $RUN_ID..." >&2 +echo "Waiting for GitHub Actions run $RUN_ID (timeout: $(format_duration "$TIMEOUT"))..." >&2 if [[ "$AUTO_SELECTED_RUN" == true ]]; then if [[ -z "$WORKFLOW" ]]; then echo "Auto-selected latest run on branch '$BRANCH'." >&2 @@ -190,12 +418,14 @@ last_jobs_snapshot="" last_progress_snapshot="" while true; do + check_timeout json="" - if ! json=$(gh run view "$RUN_ID" --json status,conclusion,displayTitle,workflowName,headBranch,url,startedAt,updatedAt,jobs 2>/dev/null); then + if ! run_gh_capture run view "$RUN_ID" --json status,conclusion,displayTitle,workflowName,headBranch,url,startedAt,updatedAt,jobs; then echo "$(date '+%Y-%m-%d %H:%M:%S') failed to fetch run info; retrying in $INTERVAL s" >&2 - sleep "$INTERVAL" + sleep_until_next_poll continue fi + json=$GH_CAPTURED_OUTPUT status=$(jq -r '.status' <<<"$json") conclusion=$(jq -r '.conclusion // ""' <<<"$json") @@ -203,6 +433,9 @@ while true; do display_title=$(jq -r '.displayTitle // "(no title)"' <<<"$json") branch_name=$(jq -r '.headBranch // "(unknown branch)"' <<<"$json") run_url=$(jq -r '.url // ""' <<<"$json") + if [[ -n "$run_url" ]]; then + last_run_url="$run_url" + fi if [[ "$status" != "$last_status" ]]; then echo "$(date '+%Y-%m-%d %H:%M:%S') [$workflow_name] $display_title on branch '$branch_name' -> status: $status${conclusion:+, conclusion: $conclusion}" >&2 @@ -210,6 +443,11 @@ while true; do last_status="$status" fi + if [[ "$status" == "waiting" ]]; then + diagnose_waiting_run "$run_url" + exit 2 + fi + jobs_snapshot=$(jq -r '.jobs[]? | "\(.name // "(no name)")|\(.status)//\(.conclusion // "")"' <<<"$json" | sort) if [[ "$jobs_snapshot" != "$last_jobs_snapshot" ]]; then @@ -251,7 +489,9 @@ while true; do job_conclusion=$(jq -r '.conclusion // "unknown"' <<<"$job_json") echo "--- Logs for job: $job_name (ID $job_id, conclusion: $job_conclusion) ---" >&2 if [[ -n "$job_id" ]]; then - if ! gh run view "$RUN_ID" --log --job "$job_id" 2>&1; then + if run_gh_capture run view "$RUN_ID" --log --job "$job_id"; then + printf '%s\n' "$GH_CAPTURED_OUTPUT" + else echo "(failed to fetch logs for job $job_id)" >&2 fi else @@ -289,7 +529,9 @@ while true; do | while IFS=$'\t' read -r job_id job_name; do [[ -z "$job_id" ]] && continue echo "--- Logs for job: $job_name (ID $job_id) ---" >&2 - if ! gh run view "$RUN_ID" --log --job "$job_id" 2>&1; then + if run_gh_capture run view "$RUN_ID" --log --job "$job_id"; then + printf '%s\n' "$GH_CAPTURED_OUTPUT" + else echo "(failed to fetch logs for job $job_id)" >&2 fi echo "--- End logs for job: $job_name ---" >&2 @@ -304,5 +546,5 @@ while true; do fi fi - sleep "$INTERVAL" + sleep_until_next_poll done