diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0921c8b1c9..1a8de3fd18b 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 @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index 3187ad56920..4d37bc9aee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8710,6 +8710,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/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..6be4a47bb6b 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,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 { @@ -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, diff --git a/tools/release/src/targets/github_release.rs b/tools/release/src/targets/github_release.rs new file mode 100644 index 00000000000..aa135d87d2b --- /dev/null +++ b/tools/release/src/targets/github_release.rs @@ -0,0 +1,109 @@ +use crate::targets::ReleaseTarget; +use duct::cmd; +use serde::Deserialize; + +const REPO: &str = "clockworklabs/SpacetimeDB"; + +#[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 fetch_release(&self) -> Result { + let cmd = cmd!( + "gh", + "release", + "view", + &self.version, + "--repo", + REPO, + "--json", + "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 = cmd!( + "gh", + "workflow", + "run", + "attach-artifacts.yml", + "--repo", + REPO, + "--ref", + "master", + "-f", + &release_tag, + ); + + println!("$> {:?}", cmd); + cmd.read() + .map(|s| s.trim().to_owned()) + .map_err(|e| format!("Failed to execute dispatch attach-artifacts.yml: {e}")) + .and_then(|url| Self::run_id_from_url(&url).map(str::to_owned)) + } + + 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 URL: {}", url)) + } + + 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 workflow: {e}")) + } + + fn publish_release(&self) -> Result<(), String> { + let publish_cmd = cmd!("gh", "release", "edit", &self.version, "--repo", REPO, "--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.fetch_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_id = self.dispatch_attach_artifacts()?; + self.wait_for_workflow(&run_id)?; + 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;