From 23e210da1961dc9022baf15890c52a90c272443b Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Fri, 7 Aug 2026 13:50:26 -0400 Subject: [PATCH 1/4] Replace gh run watch in workflow wrapper --- .github/workflows/internal-tests.yml | 13 +--- tools/ci/README.md | 16 +++++ tools/ci/src/main.rs | 104 +++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/.github/workflows/internal-tests.yml b/.github/workflows/internal-tests.yml index ba4cb230da6..0d38053977d 100644 --- a/.github/workflows/internal-tests.yml +++ b/.github/workflows/internal-tests.yml @@ -93,17 +93,10 @@ jobs: RUN_URL: ${{ steps.dispatch.outputs.run_url }} run: | set -euo pipefail - echo "Waiting for workflow result... ${RUN_URL}" - set +e - # Due to our limited-scope token permissions, `gh run watch` spams errors about not being able to get annotations. They look worrying but they're benign, so we filter them out. - gh run watch "$RUN_ID" \ + cargo ci other-workflows watch \ --repo "$TARGET_OWNER/$TARGET_REPO" \ - --exit-status \ - --interval 30 2>&1 \ - | grep -Fv "requesting annotations returned 403 Forbidden as the token does not have sufficient permissions" - watch_status="${PIPESTATUS[0]}" - set -e - exit "$watch_status" + --run-id "$RUN_ID" \ + --run-url "$RUN_URL" - name: Cancel invoked run if workflow cancelled if: ${{ cancelled() && steps.dispatch.outputs.run_id }} diff --git a/tools/ci/README.md b/tools/ci/README.md index 46eefb9cadc..156c876ca9f 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -333,6 +333,22 @@ Usage: help [COMMAND]... - `subcommand `: Print help for the subcommand(s) +#### `watch` + +**Usage:** +```bash +Usage: watch [OPTIONS] --repo --run-id +``` + +**Options:** + +- `--repo `: Repository containing the workflow run, in owner/repo form +- `--run-id `: GitHub Actions workflow run ID +- `--run-url `: Optional URL printed while waiting for the run +- `--interval-seconds `: Seconds to sleep between polls +- `--max-attempts `: Maximum number of polls before timing out +- `--help`: Print help + #### `help` **Usage:** diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 41d91e19a8d..34a29e8c561 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -3,6 +3,7 @@ use anyhow::{bail, Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use duct::{cmd, Expression}; +use serde::Deserialize; use serde_json::Value; use std::collections::BTreeSet; use std::ffi::OsString; @@ -457,6 +458,96 @@ enum OtherWorkflowsCmd { #[command(subcommand)] cmd: cla_assistant::ClaAssistantCmd, }, + /// Waits for a GitHub Actions workflow run to complete. + Watch { + /// Repository containing the workflow run, in owner/repo form. + #[arg(long)] + repo: String, + /// GitHub Actions workflow run ID. + #[arg(long)] + run_id: u64, + /// Optional URL printed while waiting for the run. + #[arg(long)] + run_url: Option, + /// Seconds to sleep between polls. + #[arg(long, default_value_t = 30)] + interval_seconds: u64, + /// Maximum number of polls before timing out. + #[arg(long, default_value_t = 240)] + max_attempts: u64, + }, +} + +#[derive(Deserialize)] +struct WorkflowRunView { + status: String, + conclusion: Option, + url: Option, + jobs: Vec, +} + +#[derive(Deserialize)] +struct WorkflowJobView { + name: String, + status: String, + conclusion: Option, +} + +fn get_workflow_run(repo: &str, run_id: u64) -> Result { + let raw = cmd!( + "gh", + "run", + "view", + run_id.to_string(), + "--repo", + repo, + "--json", + "status,conclusion,url,jobs", + ) + .read() + .with_context(|| format!("failed to read workflow run {run_id} in {repo}"))?; + serde_json::from_str(&raw).with_context(|| format!("failed to parse workflow run {run_id} in {repo}")) +} + +fn print_workflow_job_summary(run: &WorkflowRunView) { + println!("Job summary:"); + for job in &run.jobs { + let result = job.conclusion.as_deref().unwrap_or(&job.status); + println!(" {result:>11} {}", job.name); + } +} + +fn watch_workflow_run( + repo: &str, + run_id: u64, + run_url: Option, + interval_seconds: u64, + max_attempts: u64, +) -> Result<()> { + let run_url = run_url.or_else(|| get_workflow_run(repo, run_id).ok().and_then(|run| run.url)); + + if let Some(run_url) = run_url { + println!("Waiting for workflow result... {run_url}"); + } else { + println!("Waiting for workflow result: {repo}/actions/runs/{run_id}"); + } + + for _ in 0..max_attempts { + let run = get_workflow_run(repo, run_id)?; + if run.status == "completed" { + print_workflow_job_summary(&run); + let conclusion = run.conclusion.as_deref().unwrap_or("success"); + if conclusion == "success" { + return Ok(()); + } + bail!("workflow run {run_id} completed with conclusion: {conclusion}"); + } + + println!("workflow run {run_id} status: {}", run.status); + std::thread::sleep(std::time::Duration::from_secs(interval_seconds)); + } + + bail!("timed out waiting for workflow run {run_id} to complete") } fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { @@ -900,6 +991,19 @@ fn main() -> Result<()> { cla_assistant::run(cmd)?; } + Some(CiCmd::OtherWorkflows { + cmd: + OtherWorkflowsCmd::Watch { + repo, + run_id, + run_url, + interval_seconds, + max_attempts, + }, + }) => { + watch_workflow_run(&repo, run_id, run_url, interval_seconds, max_attempts)?; + } + None => run_all_clap_subcommands(&cli.skip)?, } From 5dbd7f729ba24662e5d5d35164d13c5f15e53300 Mon Sep 17 00:00:00 2001 From: Zeke Foppa <196249+bfops@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:58:29 -0700 Subject: [PATCH 2/4] Apply suggestion from @bfops Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> --- tools/ci/src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 34a29e8c561..5aa22736760 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -543,7 +543,6 @@ fn watch_workflow_run( bail!("workflow run {run_id} completed with conclusion: {conclusion}"); } - println!("workflow run {run_id} status: {}", run.status); std::thread::sleep(std::time::Duration::from_secs(interval_seconds)); } From 3923f82893586636bc1243e92f4e3b7cf6af6be9 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Mon, 10 Aug 2026 20:02:56 -0400 Subject: [PATCH 3/4] Address workflow watch review --- .github/workflows/internal-tests.yml | 4 +-- tools/ci/README.md | 3 +- tools/ci/src/main.rs | 41 ++++++++++------------------ 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/.github/workflows/internal-tests.yml b/.github/workflows/internal-tests.yml index 0d38053977d..af2cc14b69b 100644 --- a/.github/workflows/internal-tests.yml +++ b/.github/workflows/internal-tests.yml @@ -90,13 +90,11 @@ jobs: env: GH_TOKEN: ${{ secrets.SPACETIMEDB_PRIVATE_TOKEN }} RUN_ID: ${{ steps.dispatch.outputs.run_id }} - RUN_URL: ${{ steps.dispatch.outputs.run_url }} run: | set -euo pipefail cargo ci other-workflows watch \ --repo "$TARGET_OWNER/$TARGET_REPO" \ - --run-id "$RUN_ID" \ - --run-url "$RUN_URL" + --run-id "$RUN_ID" - name: Cancel invoked run if workflow cancelled if: ${{ cancelled() && steps.dispatch.outputs.run_id }} diff --git a/tools/ci/README.md b/tools/ci/README.md index 156c876ca9f..c520424ee54 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -344,9 +344,8 @@ Usage: watch [OPTIONS] --repo --run-id - `--repo `: Repository containing the workflow run, in owner/repo form - `--run-id `: GitHub Actions workflow run ID -- `--run-url `: Optional URL printed while waiting for the run - `--interval-seconds `: Seconds to sleep between polls -- `--max-attempts `: Maximum number of polls before timing out +- `--max-attempts `: Maximum number of polls before timing out. Polls forever by default - `--help`: Print help #### `help` diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 5aa22736760..5c80f3b0867 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -466,15 +466,12 @@ enum OtherWorkflowsCmd { /// GitHub Actions workflow run ID. #[arg(long)] run_id: u64, - /// Optional URL printed while waiting for the run. - #[arg(long)] - run_url: Option, /// Seconds to sleep between polls. #[arg(long, default_value_t = 30)] interval_seconds: u64, - /// Maximum number of polls before timing out. - #[arg(long, default_value_t = 240)] - max_attempts: u64, + /// Maximum number of polls before timing out. Polls forever by default. + #[arg(long)] + max_attempts: Option, }, } @@ -482,7 +479,6 @@ enum OtherWorkflowsCmd { struct WorkflowRunView { status: String, conclusion: Option, - url: Option, jobs: Vec, } @@ -502,7 +498,7 @@ fn get_workflow_run(repo: &str, run_id: u64) -> Result { "--repo", repo, "--json", - "status,conclusion,url,jobs", + "status,conclusion,jobs", ) .read() .with_context(|| format!("failed to read workflow run {run_id} in {repo}"))?; @@ -517,22 +513,12 @@ fn print_workflow_job_summary(run: &WorkflowRunView) { } } -fn watch_workflow_run( - repo: &str, - run_id: u64, - run_url: Option, - interval_seconds: u64, - max_attempts: u64, -) -> Result<()> { - let run_url = run_url.or_else(|| get_workflow_run(repo, run_id).ok().and_then(|run| run.url)); - - if let Some(run_url) = run_url { - println!("Waiting for workflow result... {run_url}"); - } else { - println!("Waiting for workflow result: {repo}/actions/runs/{run_id}"); - } +fn watch_workflow_run(repo: &str, run_id: u64, interval_seconds: u64, max_attempts: Option) -> Result<()> { + println!("Waiting for workflow result... https://github.com/{repo}/actions/runs/{run_id}"); - for _ in 0..max_attempts { + let mut attempts = 0; + loop { + attempts += 1; let run = get_workflow_run(repo, run_id)?; if run.status == "completed" { print_workflow_job_summary(&run); @@ -543,10 +529,12 @@ fn watch_workflow_run( bail!("workflow run {run_id} completed with conclusion: {conclusion}"); } + if max_attempts.is_some_and(|max| attempts >= max) { + bail!("timed out waiting for workflow run {run_id} to complete") + } + std::thread::sleep(std::time::Duration::from_secs(interval_seconds)); } - - bail!("timed out waiting for workflow run {run_id} to complete") } fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { @@ -995,12 +983,11 @@ fn main() -> Result<()> { OtherWorkflowsCmd::Watch { repo, run_id, - run_url, interval_seconds, max_attempts, }, }) => { - watch_workflow_run(&repo, run_id, run_url, interval_seconds, max_attempts)?; + watch_workflow_run(&repo, run_id, interval_seconds, max_attempts)?; } None => run_all_clap_subcommands(&cli.skip)?, From daa9871951516e5438d4f538934011121a9263a6 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Mon, 10 Aug 2026 20:04:18 -0400 Subject: [PATCH 4/4] Move workflow watch helpers to module --- tools/ci/src/main.rs | 66 +------------------------------- tools/ci/src/workflow_watch.rs | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 64 deletions(-) create mode 100644 tools/ci/src/workflow_watch.rs diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 5c80f3b0867..527b99961d8 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -3,7 +3,6 @@ use anyhow::{bail, Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use duct::{cmd, Expression}; -use serde::Deserialize; use serde_json::Value; use std::collections::BTreeSet; use std::ffi::OsString; @@ -19,6 +18,7 @@ mod codeowners_check; mod keynote_bench; mod smoketest; mod util; +mod workflow_watch; use util::ensure_repo_root; @@ -475,68 +475,6 @@ enum OtherWorkflowsCmd { }, } -#[derive(Deserialize)] -struct WorkflowRunView { - status: String, - conclusion: Option, - jobs: Vec, -} - -#[derive(Deserialize)] -struct WorkflowJobView { - name: String, - status: String, - conclusion: Option, -} - -fn get_workflow_run(repo: &str, run_id: u64) -> Result { - let raw = cmd!( - "gh", - "run", - "view", - run_id.to_string(), - "--repo", - repo, - "--json", - "status,conclusion,jobs", - ) - .read() - .with_context(|| format!("failed to read workflow run {run_id} in {repo}"))?; - serde_json::from_str(&raw).with_context(|| format!("failed to parse workflow run {run_id} in {repo}")) -} - -fn print_workflow_job_summary(run: &WorkflowRunView) { - println!("Job summary:"); - for job in &run.jobs { - let result = job.conclusion.as_deref().unwrap_or(&job.status); - println!(" {result:>11} {}", job.name); - } -} - -fn watch_workflow_run(repo: &str, run_id: u64, interval_seconds: u64, max_attempts: Option) -> Result<()> { - println!("Waiting for workflow result... https://github.com/{repo}/actions/runs/{run_id}"); - - let mut attempts = 0; - loop { - attempts += 1; - let run = get_workflow_run(repo, run_id)?; - if run.status == "completed" { - print_workflow_job_summary(&run); - let conclusion = run.conclusion.as_deref().unwrap_or("success"); - if conclusion == "success" { - return Ok(()); - } - bail!("workflow run {run_id} completed with conclusion: {conclusion}"); - } - - if max_attempts.is_some_and(|max| attempts >= max) { - bail!("timed out waiting for workflow run {run_id} to complete") - } - - std::thread::sleep(std::time::Duration::from_secs(interval_seconds)); - } -} - fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { let subcmds = Cli::command() .get_subcommands() @@ -987,7 +925,7 @@ fn main() -> Result<()> { max_attempts, }, }) => { - watch_workflow_run(&repo, run_id, interval_seconds, max_attempts)?; + workflow_watch::watch_workflow_run(&repo, run_id, interval_seconds, max_attempts)?; } None => run_all_clap_subcommands(&cli.skip)?, diff --git a/tools/ci/src/workflow_watch.rs b/tools/ci/src/workflow_watch.rs new file mode 100644 index 00000000000..38ac3b59d5d --- /dev/null +++ b/tools/ci/src/workflow_watch.rs @@ -0,0 +1,70 @@ +use anyhow::{bail, Context, Result}; +use duct::cmd; +use serde::Deserialize; + +#[derive(Deserialize)] +struct WorkflowRunView { + status: String, + conclusion: Option, + jobs: Vec, +} + +#[derive(Deserialize)] +struct WorkflowJobView { + name: String, + status: String, + conclusion: Option, +} + +fn get_workflow_run(repo: &str, run_id: u64) -> Result { + let raw = cmd!( + "gh", + "run", + "view", + run_id.to_string(), + "--repo", + repo, + "--json", + "status,conclusion,jobs", + ) + .read() + .with_context(|| format!("failed to read workflow run {run_id} in {repo}"))?; + serde_json::from_str(&raw).with_context(|| format!("failed to parse workflow run {run_id} in {repo}")) +} + +fn print_workflow_job_summary(run: &WorkflowRunView) { + println!("Job summary:"); + for job in &run.jobs { + let result = job.conclusion.as_deref().unwrap_or(&job.status); + println!(" {result:>11} {}", job.name); + } +} + +pub(crate) fn watch_workflow_run( + repo: &str, + run_id: u64, + interval_seconds: u64, + max_attempts: Option, +) -> Result<()> { + println!("Waiting for workflow result... https://github.com/{repo}/actions/runs/{run_id}"); + + let mut attempts = 0; + loop { + attempts += 1; + let run = get_workflow_run(repo, run_id)?; + if run.status == "completed" { + print_workflow_job_summary(&run); + let conclusion = run.conclusion.as_deref().unwrap_or("success"); + if conclusion == "success" { + return Ok(()); + } + bail!("workflow run {run_id} completed with conclusion: {conclusion}"); + } + + if max_attempts.is_some_and(|max| attempts >= max) { + bail!("timed out waiting for workflow run {run_id} to complete") + } + + std::thread::sleep(std::time::Duration::from_secs(interval_seconds)); + } +}