feat(ci): add automated release workflow with patch version bumping

Add a Release workflow that runs nightly (6 AM UTC) and supports manual
dispatch. It computes the next patch version from the latest v* semver
tag, builds CLI binaries for all platforms (Linux amd64/arm64, macOS
aarch64), creates a git tag, and publishes a GitHub Release.

Also adds a next-version subcommand to release.py to centralize version
computation, and updates install.sh to default to the latest GitHub
release instead of devel.

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-03-13 11:01:22 -07:00
parent 35037465c7
commit d623c286fa
3 changed files with 397 additions and 3 deletions
+307
View File
@@ -0,0 +1,307 @@
name: Release
on:
schedule:
- cron: "0 6 * * *" # 6:00 AM UTC daily
workflow_dispatch:
permissions:
contents: write
packages: read
defaults:
run:
shell: bash
jobs:
# ---------------------------------------------------------------------------
# Compute the next patch version from the latest v* tag
# ---------------------------------------------------------------------------
compute-version:
name: Compute Version
runs-on: build-amd64
timeout-minutes: 5
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
outputs:
next_version: ${{ steps.bump.outputs.next_version }}
next_tag: ${{ steps.bump.outputs.next_tag }}
should_release: ${{ steps.bump.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Fetch tags
run: git fetch --tags --force
- name: Compute next patch version
id: bump
run: |
set -euo pipefail
RESULT=$(uv run python tasks/scripts/release.py next-version)
echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
sr = str(d['should_release']).lower()
print(f'should_release={sr}')
print(f'next_version={d[\"next_version\"]}')
print(f'next_tag={d[\"next_tag\"]}')
" >> "$GITHUB_OUTPUT"
# Log for visibility in the workflow run.
echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if not d['should_release']:
reason = d.get('reason', 'No new commits')
print(f'::notice::{reason}')
else:
print(f'::notice::Next release: {d[\"next_tag\"]} (from {d[\"latest_tag\"]})')
"
# ---------------------------------------------------------------------------
# Build CLI binaries (Linux musl — static, native on each arch)
# ---------------------------------------------------------------------------
build-cli-linux:
name: Build CLI (Linux ${{ matrix.arch }})
needs: [compute-version]
if: needs.compute-version.outputs.should_release == 'true'
strategy:
matrix:
include:
- arch: amd64
runner: build-amd64
target: x86_64-unknown-linux-musl
- arch: arm64
runner: build-arm64
target: aarch64-unknown-linux-musl
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
options: --privileged
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Fetch tags
run: git fetch --tags --force
- name: Install tools
run: mise install
- name: Cache Rust target and registry
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
shared-key: cli-musl-${{ matrix.arch }}
cache-directories: .cache/sccache
cache-targets: "true"
- name: Install musl toolchain
run: |
set -euo pipefail
apt-get update
apt-get install -y --no-install-recommends musl-tools
rm -rf /var/lib/apt/lists/*
- name: Add Rust musl target
run: mise x -- rustup target add ${{ matrix.target }}
- name: Scope workspace to CLI crates
run: |
set -euo pipefail
sed -i 's|members = \["crates/\*"\]|members = ["crates/openshell-cli", "crates/openshell-core", "crates/openshell-bootstrap", "crates/openshell-policy", "crates/openshell-providers", "crates/openshell-tui"]|' Cargo.toml
- name: Patch workspace version
run: |
set -euo pipefail
sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "${{ needs.compute-version.outputs.next_version }}"/}' Cargo.toml
- name: Build ${{ matrix.target }}
run: mise x -- cargo build --release --target ${{ matrix.target }} -p openshell-cli
- name: sccache stats
if: always()
run: mise x -- sccache --show-stats
- name: Package binary
run: |
set -euo pipefail
mkdir -p artifacts
tar -czf artifacts/openshell-${{ matrix.target }}.tar.gz \
-C target/${{ matrix.target }}/release openshell
ls -lh artifacts/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-cli-linux-${{ matrix.arch }}
path: artifacts/*.tar.gz
retention-days: 5
# ---------------------------------------------------------------------------
# Build CLI binary (macOS aarch64 via osxcross)
# ---------------------------------------------------------------------------
build-cli-macos:
name: Build CLI (macOS)
needs: [compute-version]
if: needs.compute-version.outputs.should_release == 'true'
runs-on: build-amd64
timeout-minutes: 60
container:
image: ghcr.io/nvidia/openshell/ci:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
options: --privileged
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SCCACHE_MEMCACHED_ENDPOINT: ${{ vars.SCCACHE_MEMCACHED_ENDPOINT }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mark workspace safe for git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Fetch tags
run: git fetch --tags --force
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Set up Docker Buildx
uses: ./.github/actions/setup-buildx
- name: Build macOS binary via Docker
run: |
set -euo pipefail
docker buildx build \
--file deploy/docker/Dockerfile.cli-macos \
--build-arg OPENSHELL_CARGO_VERSION="${{ needs.compute-version.outputs.next_version }}" \
--build-arg CARGO_TARGET_CACHE_SCOPE="${{ github.sha }}" \
--target binary \
--output type=local,dest=out/ \
.
- name: Package binary
run: |
set -euo pipefail
mkdir -p artifacts
tar -czf artifacts/openshell-aarch64-apple-darwin.tar.gz \
-C out openshell
ls -lh artifacts/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-cli-macos
path: artifacts/*.tar.gz
retention-days: 5
# ---------------------------------------------------------------------------
# Create git tag and GitHub Release with CLI binaries
# ---------------------------------------------------------------------------
release:
name: Release
needs: [compute-version, build-cli-linux, build-cli-macos]
if: needs.compute-version.outputs.should_release == 'true'
runs-on: build-amd64
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Download all CLI artifacts
uses: actions/download-artifact@v4
with:
pattern: release-cli-*
path: release/
merge-multiple: true
- name: Generate checksums
run: |
set -euo pipefail
cd release
sha256sum *.tar.gz > openshell-checksums-sha256.txt
cat openshell-checksums-sha256.txt
- name: Create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${{ needs.compute-version.outputs.next_tag }}" -m "Release ${{ needs.compute-version.outputs.next_tag }}" "${GITHUB_SHA}"
git push origin "${{ needs.compute-version.outputs.next_tag }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: OpenShell ${{ needs.compute-version.outputs.next_tag }}
tag_name: ${{ needs.compute-version.outputs.next_tag }}
target_commitish: ${{ github.sha }}
body: |
## OpenShell ${{ needs.compute-version.outputs.next_tag }}
### Quick install
```bash
curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
```
Install a specific version:
```bash
OPENSHELL_VERSION="${{ needs.compute-version.outputs.next_tag }}" \
curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
```
### Assets
| File | Platform |
|------|----------|
| `openshell-x86_64-unknown-linux-musl.tar.gz` | Linux x86_64 |
| `openshell-aarch64-unknown-linux-musl.tar.gz` | Linux aarch64 / ARM64 |
| `openshell-aarch64-apple-darwin.tar.gz` | macOS Apple Silicon |
| `openshell-checksums-sha256.txt` | SHA256 checksums |
### Manual install
```bash
# Linux x86_64
curl -fsSL https://github.com/NVIDIA/OpenShell/releases/download/${{ needs.compute-version.outputs.next_tag }}/openshell-x86_64-unknown-linux-musl.tar.gz | tar xz
sudo mv openshell /usr/local/bin/
# Linux aarch64
curl -fsSL https://github.com/NVIDIA/OpenShell/releases/download/${{ needs.compute-version.outputs.next_tag }}/openshell-aarch64-unknown-linux-musl.tar.gz | tar xz
sudo mv openshell /usr/local/bin/
# macOS (Apple Silicon)
curl -fsSL https://github.com/NVIDIA/OpenShell/releases/download/${{ needs.compute-version.outputs.next_tag }}/openshell-aarch64-apple-darwin.tar.gz | tar xz
sudo mv openshell /usr/local/bin/
```
files: |
release/openshell-x86_64-unknown-linux-musl.tar.gz
release/openshell-aarch64-unknown-linux-musl.tar.gz
release/openshell-aarch64-apple-darwin.tar.gz
release/openshell-checksums-sha256.txt
+9 -3
View File
@@ -8,13 +8,13 @@
# curl -fsSL https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
#
# Environment variables:
# OPENSHELL_VERSION - Release tag to install (default: "devel")
# OPENSHELL_VERSION - Release tag to install (default: "latest")
# OPENSHELL_INSTALL_DIR - Directory to install into (default: /usr/local/bin)
#
set -eu
REPO="NVIDIA/OpenShell"
VERSION="${OPENSHELL_VERSION:-devel}"
VERSION="${OPENSHELL_VERSION:-latest}"
INSTALL_DIR="${OPENSHELL_INSTALL_DIR:-/usr/local/bin}"
info() {
@@ -84,7 +84,13 @@ verify_checksum() {
main() {
target="$(get_target)"
filename="openshell-${target}.tar.gz"
base_url="https://github.com/${REPO}/releases/download/${VERSION}"
# "latest" uses GitHub's redirect URL; explicit tags use the direct download path.
if [ "$VERSION" = "latest" ]; then
base_url="https://github.com/${REPO}/releases/latest/download"
else
base_url="https://github.com/${REPO}/releases/download/${VERSION}"
fi
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
+81
View File
@@ -7,9 +7,11 @@ from __future__ import annotations
import argparse
import fnmatch
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
@@ -132,6 +134,78 @@ def get_version(format: str) -> None:
print(f"GIT_SHA={versions.git_sha}")
def _latest_semver_tag() -> str | None:
"""Find the latest v* semver tag (e.g. v0.5.0), ignoring non-semver tags like 'devel'."""
try:
output = _git(["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"])
except subprocess.CalledProcessError:
return None
tags = output.strip().splitlines()
return tags[0] if tags else None
def _parse_semver(tag: str) -> tuple[int, int, int]:
"""Parse a 'vMAJOR.MINOR.PATCH' tag into (major, minor, patch)."""
version = tag.lstrip("v")
parts = version.split(".")
return int(parts[0]), int(parts[1]), int(parts[2])
def next_release_version() -> None:
"""Compute the next patch release version from the latest v* semver tag.
Prints JSON with keys: should_release, next_version, next_tag, latest_tag.
Exits 0 regardless — callers check should_release.
"""
latest_tag = _latest_semver_tag()
if latest_tag is None:
print(
json.dumps(
{
"error": "No v* semver tags found",
"should_release": False,
"next_version": "",
"next_tag": "",
"latest_tag": "",
}
)
)
sys.exit(1)
assert latest_tag is not None # unreachable; narrowing for type checkers
tag_sha = _git(["rev-list", "-n1", latest_tag])
head_sha = _git(["rev-parse", "HEAD"])
if tag_sha == head_sha:
print(
json.dumps(
{
"should_release": False,
"next_version": "",
"next_tag": "",
"latest_tag": latest_tag,
"reason": f"HEAD is already tagged as {latest_tag}",
}
)
)
return
major, minor, patch = _parse_semver(latest_tag)
next_version = f"{major}.{minor}.{patch + 1}"
next_tag = f"v{next_version}"
print(
json.dumps(
{
"should_release": True,
"next_version": next_version,
"next_tag": next_tag,
"latest_tag": latest_tag,
}
)
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="OpenClaw release tooling.")
sub = parser.add_subparsers(dest="command", required=True)
@@ -147,6 +221,11 @@ def build_parser() -> argparse.ArgumentParser:
"--docker", action="store_true", help="Print Docker version only."
)
sub.add_parser(
"next-version",
help="Compute the next patch release version (JSON output).",
)
python_publish_parser = sub.add_parser(
"python-publish", help="Publish python wheel."
)
@@ -175,6 +254,8 @@ def main() -> None:
get_version("docker")
else:
get_version("all")
elif args.command == "next-version":
next_release_version()
elif args.command == "python-publish":
python_publish(version=args.version, wheel_glob=args.wheel_glob)