Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -387,3 +392,46 @@ 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:
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
&& 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)
&& 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 }}
steps:
- name: Download cargo-release
uses: actions/download-artifact@v4
with:
name: cargo-release-bin
path: ./shared-bin

- name: Make binary executable and on PATH
run: |
chmod +x ./shared-bin/cargo-release
echo "$PWD/shared-bin" >> "$GITHUB_PATH"

- name: Publish GitHub release
run: cargo-release release github-release ${{ github.event.inputs.release_tag }}
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tools/release/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ toml = "0.8.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
duct.workspace = true
18 changes: 18 additions & 0 deletions tools/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 13 additions & 2 deletions tools/release/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -64,6 +64,11 @@ enum Commands {
#[arg(long)]
dry_run: bool,
},

/// Publish GitHub release after artifacts are available
#[command(name = "github-release")]
GithubRelease { release_version: String },

/// Perform a release for all targets
#[command(name = "--all")]
All {
Expand Down Expand Up @@ -118,6 +123,12 @@ fn main() {
let target = DockerRelease::new(version.clone(), *dry_run);
target.release()
}
Commands::GithubRelease {
release_version: version,
} => {
let target = GithubRelease::new(version.clone());
target.release()
}
Commands::All {
release_version: version,
skip,
Expand Down
163 changes: 163 additions & 0 deletions tools/release/src/targets/github_release.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
use crate::targets::ReleaseTarget;
use duct::Expression;
use serde::Deserialize;
use std::ffi::OsString;

const REPO: &str = "clockworklabs/SpacetimeDB";

fn gh(args: impl IntoIterator<Item = impl Into<OsString>>) -> Expression {
Comment thread
bfops marked this conversation as resolved.
Outdated
duct::cmd("gh", args)
}

fn run_output(cmd: Expression, label: &str) -> Result<String, String> {
Comment thread
bfops marked this conversation as resolved.
Outdated
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> {
Comment thread
bfops marked this conversation as resolved.
Outdated
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 {
is_draft: bool,
url: String,
}

pub struct GithubRelease {
version: String,
}

impl GithubRelease {
pub fn new(version: String) -> Self {
Self { version }
}

fn release(&self) -> Result<Release, String> {
Comment thread
bfops marked this conversation as resolved.
Outdated
let cmd = gh([
"release",
"view",
&self.version,
"--repo",
REPO,
"--json",
"isDraft,url",
]);

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<String, String> {
let release_tag = format!("release_tag={}", self.version);
let cmd = gh([
"workflow",
"run",
"attach-artifacts.yml",
"--repo",
REPO,
"--ref",
"master",
"-f",
&release_tag,
]);

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> {
Comment thread
bfops marked this conversation as resolved.
Outdated
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")
}

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 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")
}
}

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"
}
}
1 change: 1 addition & 0 deletions tools/release/src/targets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading