diff --git a/.github/workflows/internal-tests.yml b/.github/workflows/internal-tests.yml index d621b50c4eb..e2301d3da96 100644 --- a/.github/workflows/internal-tests.yml +++ b/.github/workflows/internal-tests.yml @@ -70,20 +70,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 - 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" - name: Cancel invoked run if workflow cancelled if: ${{ cancelled() && steps.dispatch.outputs.run_id && steps.dispatch.outputs.did_start == 'true' }} diff --git a/tools/ci/README.md b/tools/ci/README.md index f0563733ee2..7e4ba972ab3 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -346,6 +346,21 @@ 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 +- `--interval-seconds `: Seconds to sleep between polls +- `--max-attempts `: Maximum number of polls before timing out. Polls forever by default +- `--help`: Print help + #### `help` **Usage:** diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 469d085bf5e..cffa793dd16 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -19,6 +19,7 @@ mod internal_tests; mod keynote_bench; mod smoketest; mod util; +mod workflow_watch; use util::ensure_repo_root; @@ -460,6 +461,21 @@ 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, + /// Seconds to sleep between polls. + #[arg(long, default_value_t = 30)] + interval_seconds: u64, + /// Maximum number of polls before timing out. Polls forever by default. + #[arg(long)] + max_attempts: Option, + }, } fn run_all_clap_subcommands(skips: &[String]) -> Result<()> { @@ -908,6 +924,18 @@ fn main() -> Result<()> { cla_assistant::run(cmd)?; } + Some(CiCmd::OtherWorkflows { + cmd: + OtherWorkflowsCmd::Watch { + 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)); + } +}