From eb532e56ce33ef699306bd612d33f260bf38af45 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Wed, 1 Jul 2026 15:31:56 -0400 Subject: [PATCH 01/10] Publish GitHub release after release jobs --- .github/workflows/release.yml | 99 +++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5a2bcfbe0e..b4ee10e042b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -387,3 +387,102 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: us-east-1 run: aws s3 cp latest-version s3://spacetimedb-client-binaries/latest-version --acl public-read + + publish-github-release: + name: Publish GitHub release + needs: + - build-cargo-release + - release-crates + - release-csharp + - release-cpp + - release-npm + - release-docker + - update-mirror-latest-version + runs-on: ubuntu-latest + if: >- + ${{ + always() + && !inputs.dry_run + && needs.build-cargo-release.result == 'success' + && contains(fromJSON('["success", "skipped"]'), needs.release-crates.result) + && contains(fromJSON('["success", "skipped"]'), needs.release-csharp.result) + && contains(fromJSON('["success", "skipped"]'), needs.release-cpp.result) + && contains(fromJSON('["success", "skipped"]'), needs.release-npm.result) + && contains(fromJSON('["success", "skipped"]'), needs.release-docker.result) + && contains(fromJSON('["success", "skipped"]'), needs.update-mirror-latest-version.result) + }} + permissions: + actions: write + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.inputs.release_tag }} + steps: + - name: Verify release exists + id: release + run: | + set -euo pipefail + + echo "already_published=false" >> "$GITHUB_OUTPUT" + + release_json="$( + gh release view "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,url + )" + + is_draft="$(jq -r '.isDraft' <<<"${release_json}")" + url="$(jq -r '.url' <<<"${release_json}")" + if [ "${is_draft}" != "true" ]; then + echo "Release ${RELEASE_TAG} is already published: ${url}" + echo "already_published=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Found draft release ${RELEASE_TAG}: ${url}" + + - name: Attach release artifacts + if: ${{ steps.release.outputs.already_published != 'true' }} + run: | + set -euo pipefail + + started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + gh workflow run attach-artifacts.yml \ + --repo "${GITHUB_REPOSITORY}" \ + --ref master \ + -f "release_tag=${RELEASE_TAG}" + + run_id="" + for _ in $(seq 1 60); do + run_id="$( + gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/attach-artifacts.yml/runs?event=workflow_dispatch&per_page=20" \ + | jq -r --arg started_at "${started_at}" \ + '.workflow_runs + | map(select(.created_at >= $started_at)) + | sort_by(.created_at) + | reverse + | .[0].id // empty' + )" + if [ -n "${run_id}" ]; then + break + fi + sleep 5 + done + + if [ -z "${run_id}" ]; then + echo "Timed out waiting for attach-artifacts.yml workflow run to start." + exit 1 + fi + + gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --exit-status + + - name: Publish release + if: ${{ steps.release.outputs.already_published != 'true' }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .id)" + gh api \ + --method PATCH \ + "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -F draft=false From 18bf4deda84c20b04b71575ce014f920256f5a88 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Wed, 1 Jul 2026 19:46:42 -0400 Subject: [PATCH 02/10] Move GitHub release publish into release tool --- .github/workflows/release.yml | 76 ++------- tools/release/README.md | 18 +++ tools/release/src/main.rs | 20 ++- tools/release/src/targets/github_release.rs | 163 ++++++++++++++++++++ tools/release/src/targets/mod.rs | 1 + 5 files changed, 210 insertions(+), 68 deletions(-) create mode 100644 tools/release/src/targets/github_release.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 338734f3099..62a529f3a09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -416,73 +416,17 @@ jobs: contents: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.event.inputs.release_tag }} steps: - - name: Verify release exists - id: release - run: | - set -euo pipefail - - echo "already_published=false" >> "$GITHUB_OUTPUT" - - release_json="$( - gh release view "${RELEASE_TAG}" \ - --repo "${GITHUB_REPOSITORY}" \ - --json isDraft,url - )" - - is_draft="$(jq -r '.isDraft' <<<"${release_json}")" - url="$(jq -r '.url' <<<"${release_json}")" - if [ "${is_draft}" != "true" ]; then - echo "Release ${RELEASE_TAG} is already published: ${url}" - echo "already_published=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Found draft release ${RELEASE_TAG}: ${url}" + - name: Download cargo-release + uses: actions/download-artifact@v4 + with: + name: cargo-release-bin + path: ./shared-bin - - name: Attach release artifacts - if: ${{ steps.release.outputs.already_published != 'true' }} - run: | - set -euo pipefail - - started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - gh workflow run attach-artifacts.yml \ - --repo "${GITHUB_REPOSITORY}" \ - --ref master \ - -f "release_tag=${RELEASE_TAG}" - - run_id="" - for _ in $(seq 1 60); do - run_id="$( - gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/attach-artifacts.yml/runs?event=workflow_dispatch&per_page=20" \ - | jq -r --arg started_at "${started_at}" \ - '.workflow_runs - | map(select(.created_at >= $started_at)) - | sort_by(.created_at) - | reverse - | .[0].id // empty' - )" - if [ -n "${run_id}" ]; then - break - fi - sleep 5 - done - - if [ -z "${run_id}" ]; then - echo "Timed out waiting for attach-artifacts.yml workflow run to start." - exit 1 - fi - - gh run watch "${run_id}" --repo "${GITHUB_REPOSITORY}" --exit-status - - - name: Publish release - if: ${{ steps.release.outputs.already_published != 'true' }} + - name: Make binary executable and on PATH run: | - set -euo pipefail + chmod +x ./shared-bin/cargo-release + echo "$PWD/shared-bin" >> "$GITHUB_PATH" - release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .id)" - gh api \ - --method PATCH \ - "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ - -F draft=false + - name: Publish GitHub release + run: cargo-release release github-release ${{ github.event.inputs.release_tag }} --repo "$GITHUB_REPOSITORY" diff --git a/tools/release/README.md b/tools/release/README.md index 03075d4abfa..d6640ab16af 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -155,6 +155,24 @@ cargo release docker v1.2.0 --dry-run - You must be logged in to DockerHub (`docker login`) - You must have push access to the clockworklabs/spacetime repository +### GitHub Release + +Publish the GitHub release after all package and artifact release steps have completed. This will: +1. Verify the tag's GitHub release exists and is still a draft +2. Dispatch the `attach-artifacts.yml` workflow to upload client binaries +3. Wait for that workflow to finish successfully +4. Publish the draft GitHub release + +```bash +cargo release github-release v1.2.0 +``` + +If the GitHub release is already published, the command treats that as success and exits without re-uploading artifacts. + +**Prerequisites:** +- GitHub CLI must be installed +- `GH_TOKEN` must have permission to dispatch workflows and update releases + ## Full Release To perform a full release of all components: diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs index 00dd0065f01..7a489620a11 100644 --- a/tools/release/src/main.rs +++ b/tools/release/src/main.rs @@ -5,8 +5,8 @@ mod crates_resolver; mod targets; use targets::{ - cpp::CppRelease, crates::CratesRelease, csharp::CSharpRelease, docker::DockerRelease, npm::NpmRelease, - ReleaseTarget, + cpp::CppRelease, crates::CratesRelease, csharp::CSharpRelease, docker::DockerRelease, + github_release::GithubRelease, npm::NpmRelease, ReleaseTarget, }; #[derive(Parser)] @@ -64,6 +64,15 @@ enum Commands { #[arg(long)] dry_run: bool, }, + + /// Publish GitHub release after artifacts are available + #[command(name = "github-release")] + GithubRelease { + release_version: String, + #[arg(long, default_value = "clockworklabs/SpacetimeDB")] + repo: String, + }, + /// Perform a release for all targets #[command(name = "--all")] All { @@ -118,6 +127,13 @@ fn main() { let target = DockerRelease::new(version.clone(), *dry_run); target.release() } + Commands::GithubRelease { + release_version: version, + repo, + } => { + let target = GithubRelease::new(version.clone(), repo.clone()); + target.release() + } Commands::All { release_version: version, skip, diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs new file mode 100644 index 00000000000..97bb567263e --- /dev/null +++ b/tools/release/src/targets/github_release.rs @@ -0,0 +1,163 @@ +use crate::targets::{util, ReleaseTarget}; +use serde::Deserialize; +use std::process::Command; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Release { + is_draft: bool, + url: String, +} + +pub struct GithubRelease { + version: String, + repo: String, +} + +impl GithubRelease { + pub fn new(version: String, repo: String) -> Self { + Self { version, repo } + } + + fn gh(&self) -> Command { + Command::new("gh") + } + + fn run_output(&self, mut cmd: Command, label: &str) -> Result { + util::print_command(&cmd); + + let output = cmd + .output() + .map_err(|err| format!("Failed to execute {}: {}", label, err))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return Ok(stdout); + } + return Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + Err(format!( + "{} failed\n--- stdout ---\n{}\n--- stderr ---\n{}", + label, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } + + fn run_status(&self, mut cmd: Command, label: &str) -> Result<(), String> { + util::print_command(&cmd); + + let status = cmd + .status() + .map_err(|err| format!("Failed to execute {}: {}", label, err))?; + + if status.success() { + Ok(()) + } else { + Err(format!("{} failed with status {}", label, status)) + } + } + + fn release(&self) -> Result { + let mut cmd = self.gh(); + cmd.args([ + "release", + "view", + &self.version, + "--repo", + &self.repo, + "--json", + "isDraft,url", + ]); + + let output = self.run_output(cmd, "view GitHub release")?; + serde_json::from_str(&output).map_err(|err| format!("Failed to parse GitHub release JSON: {}", err)) + } + + fn dispatch_attach_artifacts(&self) -> Result { + let mut cmd = self.gh(); + cmd.args([ + "workflow", + "run", + "attach-artifacts.yml", + "--repo", + &self.repo, + "--ref", + "master", + "-f", + &format!("release_tag={}", self.version), + ]); + + self.run_output(cmd, "dispatch attach-artifacts.yml") + } + + fn run_id_from_output<'a>(&self, output: &'a str) -> Result<&'a str, String> { + let url = output + .split_whitespace() + .find(|word| word.starts_with("https://") && word.contains("/actions/runs/")) + .unwrap_or(output); + + url.trim_end_matches('/') + .rsplit('/') + .next() + .filter(|segment| !segment.is_empty() && segment.chars().all(|ch| ch.is_ascii_digit())) + .ok_or_else(|| { + format!( + "Could not parse workflow run id from gh workflow run output: {}", + output + ) + }) + } + + fn wait_for_artifacts(&self, workflow_output: &str) -> Result<(), String> { + let run_id = self.run_id_from_output(workflow_output)?; + let mut cmd = self.gh(); + cmd.args(["run", "watch", run_id, "--repo", &self.repo, "--exit-status"]); + self.run_status(cmd, "watch attach-artifacts.yml") + } + + fn publish_release(&self) -> Result<(), String> { + let mut id_cmd = self.gh(); + id_cmd.args([ + "api", + &format!("repos/{}/releases/tags/{}", self.repo, self.version), + "--jq", + ".id", + ]); + let release_id = self.run_output(id_cmd, "get GitHub release id")?; + + let mut publish_cmd = self.gh(); + publish_cmd.args([ + "api", + "--method", + "PATCH", + &format!("repos/{}/releases/{}", self.repo, release_id), + "-F", + "draft=false", + ]); + self.run_status(publish_cmd, "publish GitHub release") + } +} + +impl ReleaseTarget for GithubRelease { + fn release(&self) -> Result<(), String> { + let release = self.release()?; + if !release.is_draft { + println!("GitHub release {} is already published: {}", self.version, release.url); + return Ok(()); + } + + println!("Found draft GitHub release {}: {}", self.version, release.url); + let run_url = self.dispatch_attach_artifacts()?; + self.wait_for_artifacts(&run_url)?; + self.publish_release()?; + println!("Published GitHub release {}.", self.version); + Ok(()) + } + + fn name(&self) -> &'static str { + "github-release" + } +} diff --git a/tools/release/src/targets/mod.rs b/tools/release/src/targets/mod.rs index 2dc299ae357..1a80944abb1 100644 --- a/tools/release/src/targets/mod.rs +++ b/tools/release/src/targets/mod.rs @@ -2,6 +2,7 @@ pub mod cpp; pub mod crates; pub mod csharp; pub mod docker; +pub mod github_release; pub mod npm; pub mod util; From a4433a791fe345ed4f7fd01694ad0296e8ab212a Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 1 Jul 2026 16:51:48 -0700 Subject: [PATCH 03/10] [bot/publish-github-release-after-steps]: update --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62a529f3a09..65c3213eeec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,11 @@ on: required: true type: boolean default: false + release_github: + description: "Publish GitHub release (if nothing fails)" + required: true + type: boolean + default: false update_mirror_latest_version: description: "Update S3 mirror latest-version marker" required: true @@ -403,6 +408,7 @@ jobs: ${{ always() && !inputs.dry_run + && inputs.release_github && needs.build-cargo-release.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.release-crates.result) && contains(fromJSON('["success", "skipped"]'), needs.release-csharp.result) From 8b63ddaf49810f2cb8b97678f7f8a1e25ee39e11 Mon Sep 17 00:00:00 2001 From: Zeke Foppa Date: Wed, 1 Jul 2026 16:56:23 -0700 Subject: [PATCH 04/10] [bot/publish-github-release-after-steps]: consistency --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65c3213eeec..ede0dc8da6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -394,7 +394,6 @@ jobs: run: aws s3 cp latest-version s3://spacetimedb-client-binaries/latest-version --acl public-read publish-github-release: - name: Publish GitHub release needs: - build-cargo-release - release-crates From 3cfe10b00cc828a4605660e1fab07767c8581d1b Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:31:09 -0400 Subject: [PATCH 05/10] Remove GitHub release repo option --- .github/workflows/release.yml | 2 +- tools/release/src/main.rs | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ede0dc8da6c..df1427eb522 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -434,4 +434,4 @@ jobs: echo "$PWD/shared-bin" >> "$GITHUB_PATH" - name: Publish GitHub release - run: cargo-release release github-release ${{ github.event.inputs.release_tag }} --repo "$GITHUB_REPOSITORY" + run: cargo-release release github-release ${{ github.event.inputs.release_tag }} diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs index 7a489620a11..5078ad91ae7 100644 --- a/tools/release/src/main.rs +++ b/tools/release/src/main.rs @@ -67,11 +67,7 @@ enum Commands { /// Publish GitHub release after artifacts are available #[command(name = "github-release")] - GithubRelease { - release_version: String, - #[arg(long, default_value = "clockworklabs/SpacetimeDB")] - repo: String, - }, + GithubRelease { release_version: String }, /// Perform a release for all targets #[command(name = "--all")] @@ -129,9 +125,8 @@ fn main() { } Commands::GithubRelease { release_version: version, - repo, } => { - let target = GithubRelease::new(version.clone(), repo.clone()); + let target = GithubRelease::new(version.clone(), "clockworklabs/SpacetimeDB".to_string()); target.release() } Commands::All { From 6205542b7acdbd431b2e4a63a479f8ae5db21c06 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:33:06 -0400 Subject: [PATCH 06/10] Simplify GitHub release target repo handling --- tools/release/src/main.rs | 2 +- tools/release/src/targets/github_release.rs | 35 +++++++++++---------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/tools/release/src/main.rs b/tools/release/src/main.rs index 5078ad91ae7..6be4a47bb6b 100644 --- a/tools/release/src/main.rs +++ b/tools/release/src/main.rs @@ -126,7 +126,7 @@ fn main() { Commands::GithubRelease { release_version: version, } => { - let target = GithubRelease::new(version.clone(), "clockworklabs/SpacetimeDB".to_string()); + let target = GithubRelease::new(version.clone()); target.release() } Commands::All { diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs index 97bb567263e..2cc00391cab 100644 --- a/tools/release/src/targets/github_release.rs +++ b/tools/release/src/targets/github_release.rs @@ -2,6 +2,12 @@ use crate::targets::{util, ReleaseTarget}; use serde::Deserialize; use std::process::Command; +const REPO: &str = "clockworklabs/SpacetimeDB"; + +fn gh() -> Command { + Command::new("gh") +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Release { @@ -11,16 +17,11 @@ struct Release { pub struct GithubRelease { version: String, - repo: String, } impl GithubRelease { - pub fn new(version: String, repo: String) -> Self { - Self { version, repo } - } - - fn gh(&self) -> Command { - Command::new("gh") + pub fn new(version: String) -> Self { + Self { version } } fn run_output(&self, mut cmd: Command, label: &str) -> Result { @@ -61,13 +62,13 @@ impl GithubRelease { } fn release(&self) -> Result { - let mut cmd = self.gh(); + let mut cmd = gh(); cmd.args([ "release", "view", &self.version, "--repo", - &self.repo, + REPO, "--json", "isDraft,url", ]); @@ -77,13 +78,13 @@ impl GithubRelease { } fn dispatch_attach_artifacts(&self) -> Result { - let mut cmd = self.gh(); + let mut cmd = gh(); cmd.args([ "workflow", "run", "attach-artifacts.yml", "--repo", - &self.repo, + REPO, "--ref", "master", "-f", @@ -113,27 +114,27 @@ impl GithubRelease { fn wait_for_artifacts(&self, workflow_output: &str) -> Result<(), String> { let run_id = self.run_id_from_output(workflow_output)?; - let mut cmd = self.gh(); - cmd.args(["run", "watch", run_id, "--repo", &self.repo, "--exit-status"]); + let mut cmd = gh(); + cmd.args(["run", "watch", run_id, "--repo", REPO, "--exit-status"]); self.run_status(cmd, "watch attach-artifacts.yml") } fn publish_release(&self) -> Result<(), String> { - let mut id_cmd = self.gh(); + let mut id_cmd = gh(); id_cmd.args([ "api", - &format!("repos/{}/releases/tags/{}", self.repo, self.version), + &format!("repos/{}/releases/tags/{}", REPO, self.version), "--jq", ".id", ]); let release_id = self.run_output(id_cmd, "get GitHub release id")?; - let mut publish_cmd = self.gh(); + let mut publish_cmd = gh(); publish_cmd.args([ "api", "--method", "PATCH", - &format!("repos/{}/releases/{}", self.repo, release_id), + &format!("repos/{}/releases/{}", REPO, release_id), "-F", "draft=false", ]); From b26a96602d3c9f156e5c1a62ebc60eb8a99c1b96 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:37:03 -0400 Subject: [PATCH 07/10] Use duct for GitHub release commands --- Cargo.lock | 1 + tools/release/Cargo.toml | 1 + tools/release/src/targets/github_release.rs | 135 ++++++++++---------- 3 files changed, 69 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1db7622f17f..ccb629ff633 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8700,6 +8700,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap 4.5.50", + "duct", "serde", "serde_json", "toml 0.8.23", diff --git a/tools/release/Cargo.toml b/tools/release/Cargo.toml index 625fc485a56..f07167536e9 100644 --- a/tools/release/Cargo.toml +++ b/tools/release/Cargo.toml @@ -15,3 +15,4 @@ toml = "0.8.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" +duct.workspace = true diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs index 2cc00391cab..b2db1bcabbf 100644 --- a/tools/release/src/targets/github_release.rs +++ b/tools/release/src/targets/github_release.rs @@ -1,69 +1,81 @@ -use crate::targets::{util, ReleaseTarget}; +use crate::targets::ReleaseTarget; +use duct::Expression; use serde::Deserialize; -use std::process::Command; +use std::ffi::OsString; const REPO: &str = "clockworklabs/SpacetimeDB"; -fn gh() -> Command { - Command::new("gh") +fn gh(args: impl IntoIterator>) -> Expression { + duct::cmd("gh", args) } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct Release { - is_draft: bool, - url: String, -} +fn run_output(cmd: Expression, label: &str) -> Result { + println!("$> {:?}", cmd); -pub struct GithubRelease { - version: String, -} + let output = cmd + .unchecked() + .stdout_capture() + .stderr_capture() + .run() + .map_err(|err| format!("Failed to execute {}: {}", label, err))?; -impl GithubRelease { - pub fn new(version: String) -> Self { - Self { version } + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return Ok(stdout); + } + return Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()); } - fn run_output(&self, mut cmd: Command, label: &str) -> Result { - util::print_command(&cmd); + Err(format!( + "{} failed\n--- stdout ---\n{}\n--- stderr ---\n{}", + label, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) +} - let output = cmd - .output() - .map_err(|err| format!("Failed to execute {}: {}", label, err))?; +fn run_status(cmd: Expression, label: &str) -> Result<(), String> { + println!("$> {:?}", cmd); - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return Ok(stdout); - } - return Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } + let output = cmd + .unchecked() + .stdout_capture() + .stderr_capture() + .run() + .map_err(|err| format!("Failed to execute {}: {}", label, err))?; + if output.status.success() { + Ok(()) + } else { Err(format!( - "{} failed\n--- stdout ---\n{}\n--- stderr ---\n{}", + "{} failed with status {}\n--- stdout ---\n{}\n--- stderr ---\n{}", label, + output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) )) } +} - fn run_status(&self, mut cmd: Command, label: &str) -> Result<(), String> { - util::print_command(&cmd); +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Release { + is_draft: bool, + url: String, +} - let status = cmd - .status() - .map_err(|err| format!("Failed to execute {}: {}", label, err))?; +pub struct GithubRelease { + version: String, +} - if status.success() { - Ok(()) - } else { - Err(format!("{} failed with status {}", label, status)) - } +impl GithubRelease { + pub fn new(version: String) -> Self { + Self { version } } fn release(&self) -> Result { - let mut cmd = gh(); - cmd.args([ + let cmd = gh([ "release", "view", &self.version, @@ -73,13 +85,13 @@ impl GithubRelease { "isDraft,url", ]); - let output = self.run_output(cmd, "view GitHub release")?; + let output = run_output(cmd, "view GitHub release")?; serde_json::from_str(&output).map_err(|err| format!("Failed to parse GitHub release JSON: {}", err)) } fn dispatch_attach_artifacts(&self) -> Result { - let mut cmd = gh(); - cmd.args([ + let release_tag = format!("release_tag={}", self.version); + let cmd = gh([ "workflow", "run", "attach-artifacts.yml", @@ -88,10 +100,10 @@ impl GithubRelease { "--ref", "master", "-f", - &format!("release_tag={}", self.version), + &release_tag, ]); - self.run_output(cmd, "dispatch attach-artifacts.yml") + run_output(cmd, "dispatch attach-artifacts.yml") } fn run_id_from_output<'a>(&self, output: &'a str) -> Result<&'a str, String> { @@ -114,31 +126,18 @@ impl GithubRelease { fn wait_for_artifacts(&self, workflow_output: &str) -> Result<(), String> { let run_id = self.run_id_from_output(workflow_output)?; - let mut cmd = gh(); - cmd.args(["run", "watch", run_id, "--repo", REPO, "--exit-status"]); - self.run_status(cmd, "watch attach-artifacts.yml") + let cmd = gh(["run", "watch", run_id, "--repo", REPO, "--exit-status"]); + run_status(cmd, "watch attach-artifacts.yml") } fn publish_release(&self) -> Result<(), String> { - let mut id_cmd = gh(); - id_cmd.args([ - "api", - &format!("repos/{}/releases/tags/{}", REPO, self.version), - "--jq", - ".id", - ]); - let release_id = self.run_output(id_cmd, "get GitHub release id")?; - - let mut publish_cmd = gh(); - publish_cmd.args([ - "api", - "--method", - "PATCH", - &format!("repos/{}/releases/{}", REPO, release_id), - "-F", - "draft=false", - ]); - self.run_status(publish_cmd, "publish GitHub release") + let release_endpoint = format!("repos/{}/releases/tags/{}", REPO, self.version); + let id_cmd = gh(["api", &release_endpoint, "--jq", ".id"]); + let release_id = run_output(id_cmd, "get GitHub release id")?; + + let publish_endpoint = format!("repos/{}/releases/{}", REPO, release_id); + let publish_cmd = gh(["api", "--method", "PATCH", &publish_endpoint, "-F", "draft=false"]); + run_status(publish_cmd, "publish GitHub release") } } From 4c5401fb49826890d255147ee13afbde1717bce6 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:46:27 -0400 Subject: [PATCH 08/10] Inline GitHub release command helpers --- tools/release/src/targets/github_release.rs | 107 +++++++------------- 1 file changed, 36 insertions(+), 71 deletions(-) diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs index b2db1bcabbf..31bda0daa17 100644 --- a/tools/release/src/targets/github_release.rs +++ b/tools/release/src/targets/github_release.rs @@ -1,63 +1,9 @@ use crate::targets::ReleaseTarget; -use duct::Expression; +use duct::cmd; use serde::Deserialize; -use std::ffi::OsString; const REPO: &str = "clockworklabs/SpacetimeDB"; -fn gh(args: impl IntoIterator>) -> Expression { - duct::cmd("gh", args) -} - -fn run_output(cmd: Expression, label: &str) -> Result { - println!("$> {:?}", cmd); - - let output = cmd - .unchecked() - .stdout_capture() - .stderr_capture() - .run() - .map_err(|err| format!("Failed to execute {}: {}", label, err))?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return Ok(stdout); - } - return Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - - Err(format!( - "{} failed\n--- stdout ---\n{}\n--- stderr ---\n{}", - label, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )) -} - -fn run_status(cmd: Expression, label: &str) -> Result<(), String> { - println!("$> {:?}", cmd); - - let output = cmd - .unchecked() - .stdout_capture() - .stderr_capture() - .run() - .map_err(|err| format!("Failed to execute {}: {}", label, err))?; - - if output.status.success() { - Ok(()) - } else { - Err(format!( - "{} failed with status {}\n--- stdout ---\n{}\n--- stderr ---\n{}", - label, - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )) - } -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Release { @@ -74,24 +20,29 @@ impl GithubRelease { Self { version } } - fn release(&self) -> Result { - let cmd = gh([ + fn fetch_release(&self) -> Result { + let cmd = cmd!( + "gh", "release", "view", &self.version, "--repo", REPO, "--json", - "isDraft,url", - ]); - - let output = run_output(cmd, "view GitHub release")?; + "isDraft,url" + ); + println!("$> {:?}", cmd); + let output = cmd + .read() + .map(|s| s.trim().to_owned()) + .map_err(|e| format!("Failed to execute view GitHub release: {e}"))?; serde_json::from_str(&output).map_err(|err| format!("Failed to parse GitHub release JSON: {}", err)) } fn dispatch_attach_artifacts(&self) -> Result { let release_tag = format!("release_tag={}", self.version); - let cmd = gh([ + let cmd = cmd!( + "gh", "workflow", "run", "attach-artifacts.yml", @@ -101,9 +52,12 @@ impl GithubRelease { "master", "-f", &release_tag, - ]); + ); - run_output(cmd, "dispatch attach-artifacts.yml") + println!("$> {:?}", cmd); + cmd.read() + .map(|s| s.trim().to_owned()) + .map_err(|e| format!("Failed to execute dispatch attach-artifacts.yml: {e}")) } fn run_id_from_output<'a>(&self, output: &'a str) -> Result<&'a str, String> { @@ -126,24 +80,35 @@ impl GithubRelease { fn wait_for_artifacts(&self, workflow_output: &str) -> Result<(), String> { let run_id = self.run_id_from_output(workflow_output)?; - let cmd = gh(["run", "watch", run_id, "--repo", REPO, "--exit-status"]); - run_status(cmd, "watch attach-artifacts.yml") + let cmd = cmd!("gh", "run", "watch", run_id, "--repo", REPO, "--exit-status"); + println!("$> {:?}", cmd); + cmd.run() + .map(|_| ()) + .map_err(|e| format!("Failed to execute watch attach-artifacts.yml: {e}")) } fn publish_release(&self) -> Result<(), String> { let release_endpoint = format!("repos/{}/releases/tags/{}", REPO, self.version); - let id_cmd = gh(["api", &release_endpoint, "--jq", ".id"]); - let release_id = run_output(id_cmd, "get GitHub release id")?; + let id_cmd = cmd!("gh", "api", &release_endpoint, "--jq", ".id"); + println!("$> {:?}", id_cmd); + let release_id = id_cmd + .read() + .map(|s| s.trim().to_owned()) + .map_err(|e| format!("Failed to execute get GitHub release id: {e}"))?; let publish_endpoint = format!("repos/{}/releases/{}", REPO, release_id); - let publish_cmd = gh(["api", "--method", "PATCH", &publish_endpoint, "-F", "draft=false"]); - run_status(publish_cmd, "publish GitHub release") + let publish_cmd = cmd!("gh", "api", "--method", "PATCH", &publish_endpoint, "-F", "draft=false"); + println!("$> {:?}", publish_cmd); + publish_cmd + .run() + .map(|_| ()) + .map_err(|e| format!("Failed to execute publish GitHub release: {e}")) } } impl ReleaseTarget for GithubRelease { fn release(&self) -> Result<(), String> { - let release = self.release()?; + let release = self.fetch_release()?; if !release.is_draft { println!("GitHub release {} is already published: {}", self.version, release.url); return Ok(()); From 8da79d8a13d003b69ea57947960088a2e387612d Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:53:02 -0400 Subject: [PATCH 09/10] Simplify GitHub release publish flow --- tools/release/src/targets/github_release.rs | 23 +++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs index 31bda0daa17..457edcb9e0a 100644 --- a/tools/release/src/targets/github_release.rs +++ b/tools/release/src/targets/github_release.rs @@ -58,9 +58,10 @@ impl GithubRelease { cmd.read() .map(|s| s.trim().to_owned()) .map_err(|e| format!("Failed to execute dispatch attach-artifacts.yml: {e}")) + .and_then(|output| Self::run_id_from_output(&output).map(str::to_owned)) } - fn run_id_from_output<'a>(&self, output: &'a str) -> Result<&'a str, String> { + fn run_id_from_output(output: &str) -> Result<&str, String> { let url = output .split_whitespace() .find(|word| word.starts_with("https://") && word.contains("/actions/runs/")) @@ -78,26 +79,16 @@ impl GithubRelease { }) } - fn wait_for_artifacts(&self, workflow_output: &str) -> Result<(), String> { - let run_id = self.run_id_from_output(workflow_output)?; + fn wait_for_workflow(&self, run_id: &str) -> Result<(), String> { let cmd = cmd!("gh", "run", "watch", run_id, "--repo", REPO, "--exit-status"); println!("$> {:?}", cmd); cmd.run() .map(|_| ()) - .map_err(|e| format!("Failed to execute watch attach-artifacts.yml: {e}")) + .map_err(|e| format!("Failed to execute watch workflow: {e}")) } fn publish_release(&self) -> Result<(), String> { - let release_endpoint = format!("repos/{}/releases/tags/{}", REPO, self.version); - let id_cmd = cmd!("gh", "api", &release_endpoint, "--jq", ".id"); - println!("$> {:?}", id_cmd); - let release_id = id_cmd - .read() - .map(|s| s.trim().to_owned()) - .map_err(|e| format!("Failed to execute get GitHub release id: {e}"))?; - - let publish_endpoint = format!("repos/{}/releases/{}", REPO, release_id); - let publish_cmd = cmd!("gh", "api", "--method", "PATCH", &publish_endpoint, "-F", "draft=false"); + let publish_cmd = cmd!("gh", "release", "edit", &self.version, "--repo", REPO, "--draft=false"); println!("$> {:?}", publish_cmd); publish_cmd .run() @@ -115,8 +106,8 @@ impl ReleaseTarget for GithubRelease { } println!("Found draft GitHub release {}: {}", self.version, release.url); - let run_url = self.dispatch_attach_artifacts()?; - self.wait_for_artifacts(&run_url)?; + let run_id = self.dispatch_attach_artifacts()?; + self.wait_for_workflow(&run_id)?; self.publish_release()?; println!("Published GitHub release {}.", self.version); Ok(()) From f38edbaf7ad9995863f4c0790fbf931d396c6055 Mon Sep 17 00:00:00 2001 From: clockwork-labs-bot Date: Sun, 5 Jul 2026 16:55:14 -0400 Subject: [PATCH 10/10] Rename GitHub workflow run ID parser --- tools/release/src/targets/github_release.rs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs index 457edcb9e0a..aa135d87d2b 100644 --- a/tools/release/src/targets/github_release.rs +++ b/tools/release/src/targets/github_release.rs @@ -58,25 +58,15 @@ impl GithubRelease { cmd.read() .map(|s| s.trim().to_owned()) .map_err(|e| format!("Failed to execute dispatch attach-artifacts.yml: {e}")) - .and_then(|output| Self::run_id_from_output(&output).map(str::to_owned)) + .and_then(|url| Self::run_id_from_url(&url).map(str::to_owned)) } - fn run_id_from_output(output: &str) -> Result<&str, String> { - let url = output - .split_whitespace() - .find(|word| word.starts_with("https://") && word.contains("/actions/runs/")) - .unwrap_or(output); - + fn run_id_from_url(url: &str) -> Result<&str, String> { url.trim_end_matches('/') .rsplit('/') .next() .filter(|segment| !segment.is_empty() && segment.chars().all(|ch| ch.is_ascii_digit())) - .ok_or_else(|| { - format!( - "Could not parse workflow run id from gh workflow run output: {}", - output - ) - }) + .ok_or_else(|| format!("Could not parse workflow run id from URL: {}", url)) } fn wait_for_workflow(&self, run_id: &str) -> Result<(), String> {