name: Release
on:
push:
tags: ["v*"]
env:
CARGO_TERM_COLOR: always
jobs:
verify-provenance:
name: Verify release provenance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Verify tag provenance
env:
RELEASE_TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ github.token }}
run: |
TAG_VERSION="${RELEASE_TAG#v}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
PYPROJECT_VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
if [[ "$TAG_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then
PYPI_VERSION="${BASH_REMATCH[1]}rc${BASH_REMATCH[2]}"
RELEASE_KIND="rc"
elif [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
PYPI_VERSION="$TAG_VERSION"
RELEASE_KIND="stable"
else
echo "::error::Tag version ($TAG_VERSION) must be stable X.Y.Z or RC X.Y.Z-rc.N"
exit 1
fi
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | awk '{print $NF}')
if [[ "$TAG_VERSION" != "$CARGO_VERSION" ]]; then
echo "::error::Tag version ($TAG_VERSION) != Cargo.toml ($CARGO_VERSION)"
exit 1
fi
if [[ "$PYPI_VERSION" != "$PYPROJECT_VERSION" ]]; then
echo "::error::Expected pyproject.toml version $PYPI_VERSION for $RELEASE_KIND tag $TAG_VERSION, got $PYPROJECT_VERSION"
exit 1
fi
shopt -s nullglob
npm_packages=(npm/@meridian-flow/mars-agents*/package.json)
shopt -u nullglob
if [[ "${#npm_packages[@]}" -eq 0 ]]; then
echo "::error::No npm package metadata found under npm/@meridian-flow/mars-agents*/package.json"
exit 1
fi
for pkg in "${npm_packages[@]}"; do
pkg_version="$(jq -r '.version // empty' "${pkg}")"
if [[ "${pkg_version}" != "${TAG_VERSION}" ]]; then
echo "::error::${pkg} version (${pkg_version}) does not match release tag version (${TAG_VERSION})"
exit 1
fi
while IFS=$'\t' read -r dep_name dep_version; do
[[ -n "${dep_name}" ]] || continue
if [[ "${dep_version}" != "${TAG_VERSION}" ]]; then
echo "::error::${pkg} optional dependency ${dep_name} version (${dep_version}) does not match release tag version (${TAG_VERSION})"
exit 1
fi
done < <(jq -r '.optionalDependencies // {} | to_entries[] | "\(.key)\t\(.value)"' "${pkg}")
done
escaped_tag_version="$(sed 's/[][\\.^$*+?(){}|/-]/\\&/g' <<<"${TAG_VERSION}")"
if ! grep -qE "^## \\[${escaped_tag_version}\\] - " CHANGELOG.md; then
echo "::error::CHANGELOG.md missing release section for ${TAG_VERSION} (expected '## [${TAG_VERSION}] - YYYY-MM-DD')"
exit 1
fi
RELEASE_SHA="$(git rev-parse HEAD)"
if ! git merge-base --is-ancestor "$RELEASE_SHA" "origin/$DEFAULT_BRANCH"; then
echo "::error::Tagged commit is not reachable from $DEFAULT_BRANCH"
exit 1
fi
COMMIT_MSG=$(git log -1 --format=%s "$RELEASE_SHA")
if [[ "$COMMIT_MSG" != "release: v$TAG_VERSION" ]]; then
echo "::error::Release commit message doesn't match expected format: release: v$TAG_VERSION"
exit 1
fi
echo "Provenance verified for v$TAG_VERSION ($RELEASE_KIND)"
build:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
artifact: mars-linux-x64
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
artifact: mars-linux-arm64
- target: aarch64-apple-darwin
runner: macos-latest
artifact: mars-darwin-arm64
- target: x86_64-apple-darwin
runner: macos-15-intel
artifact: mars-darwin-x64
- target: x86_64-pc-windows-msvc
runner: windows-latest
artifact: mars-windows-x64.exe
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Install Linux ARM64 cross toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
- name: Build
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }}
- name: Build (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: cargo build --release --target ${{ matrix.target }}
- name: Strip binary
if: matrix.target != 'aarch64-unknown-linux-gnu' && !contains(matrix.target, 'windows')
run: strip target/${{ matrix.target }}/release/mars
- name: Strip binary (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: aarch64-linux-gnu-strip target/${{ matrix.target }}/release/mars
- name: Rename binary
shell: bash
run: |
if [[ "${{ matrix.artifact }}" == *.exe ]]; then
cp target/${{ matrix.target }}/release/mars.exe ${{ matrix.artifact }}
else
cp target/${{ matrix.target }}/release/mars ${{ matrix.artifact }}
fi
- name: Smoke test (Windows)
if: contains(matrix.target, 'windows')
shell: pwsh
run: |
.\target\${{ matrix.target }}\release\mars.exe --version
$tmp = Join-Path $env:TEMP "mars-smoke-$([guid]::NewGuid())"
New-Item -ItemType Directory -Path $tmp | Out-Null
.\target\${{ matrix.target }}\release\mars.exe init --root $tmp
.\target\${{ matrix.target }}\release\mars.exe doctor --root $tmp
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}
pypi-wheels:
name: Build PyPI wheels (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
artifact: wheels-linux-x64
- target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
artifact: wheels-linux-arm64
- target: aarch64-apple-darwin
runner: macos-latest
artifact: wheels-darwin-arm64
- target: x86_64-apple-darwin
runner: macos-15-intel
artifact: wheels-darwin-x64
- target: x86_64-pc-windows-msvc
runner: windows-latest
artifact: wheels-windows-x64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- name: Build wheels
uses: PyO3/maturin-action@v1
with:
command: build
target: ${{ matrix.target }}
manylinux: auto
args: --release --out dist
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: dist/*.whl
pypi-sdist:
name: Build PyPI sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- name: Build sdist
uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist
- uses: actions/upload-artifact@v4
with:
name: sdist
path: dist/*.tar.gz
pypi-publish:
name: Publish PyPI
needs: [pypi-wheels, pypi-sdist, verify-provenance]
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
pattern: wheels-*
merge-multiple: true
- uses: actions/download-artifact@v4
with:
path: dist
name: sdist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
github-release:
name: GitHub Release
needs: [build, verify-provenance]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- name: Derive release metadata
id: release_meta
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
TAG_VERSION="${RELEASE_TAG#v}"
if [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
elif [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
else
echo "::error::Unsupported tag format: ${RELEASE_TAG}"
exit 1
fi
- uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
prerelease: ${{ steps.release_meta.outputs.prerelease }}
generate_release_notes: true
files: artifacts/mars-*
npm-publish:
name: Publish npm packages
needs: [build, verify-provenance]
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Ensure npm supports trusted publishing
run: npm install -g npm@latest
- uses: actions/download-artifact@v4
with:
path: artifacts/
pattern: mars-*
merge-multiple: true
- name: Derive release metadata
id: release_meta
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
TAG_VERSION="${RELEASE_TAG#v}"
echo "version=${TAG_VERSION}" >> "$GITHUB_OUTPUT"
if [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "npm_dist_tag=rc" >> "$GITHUB_OUTPUT"
echo "release_kind=rc" >> "$GITHUB_OUTPUT"
elif [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "npm_dist_tag=latest" >> "$GITHUB_OUTPUT"
echo "release_kind=stable" >> "$GITHUB_OUTPUT"
else
echo "::error::Unsupported tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Verify tag matches Cargo.toml
run: |
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
TAG_VERSION=${{ steps.release_meta.outputs.version }}
if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then
echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION)"
exit 1
fi
- name: Prepare and publish platform packages
run: |
VERSION=${{ steps.release_meta.outputs.version }}
NPM_DIST_TAG=${{ steps.release_meta.outputs.npm_dist_tag }}
publish_platform() {
local pkg_dir=$1
local binary=$2
local pkg_name
pkg_name=$(node -e "console.log(require('./$pkg_dir/package.json').name)")
# Skip if already published (idempotent on re-run)
if npm view "$pkg_name@$VERSION" version >/dev/null 2>&1; then
echo "Skipping $pkg_name@$VERSION — already published"
return 0
fi
(
cd "$pkg_dir"
npm version "$VERSION" --no-git-tag-version --allow-same-version
if [[ "$binary" == *.exe ]]; then
cp "$GITHUB_WORKSPACE/artifacts/$binary" mars.exe
else
cp "$GITHUB_WORKSPACE/artifacts/$binary" mars
chmod +x mars
fi
npm publish --provenance --access public --tag "$NPM_DIST_TAG"
)
}
publish_platform npm/@meridian-flow/mars-agents-linux-x64 mars-linux-x64
publish_platform npm/@meridian-flow/mars-agents-linux-arm64 mars-linux-arm64
publish_platform npm/@meridian-flow/mars-agents-darwin-arm64 mars-darwin-arm64
publish_platform npm/@meridian-flow/mars-agents-darwin-x64 mars-darwin-x64
publish_platform npm/@meridian-flow/mars-agents-win32-x64 mars-windows-x64.exe
- name: Publish CLI stub package
run: |
VERSION=${{ steps.release_meta.outputs.version }}
NPM_DIST_TAG=${{ steps.release_meta.outputs.npm_dist_tag }}
# Skip if already published
if npm view "@meridian-flow/mars-agents@$VERSION" version >/dev/null 2>&1; then
echo "Skipping @meridian-flow/mars-agents@$VERSION — already published"
exit 0
fi
(
cd npm/@meridian-flow/mars-agents
node -e "
const pkg = require('./package.json');
pkg.version = '$VERSION';
for (const dep of Object.keys(pkg.optionalDependencies || {})) {
pkg.optionalDependencies[dep] = '$VERSION';
}
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
npm publish --provenance --access public --tag "$NPM_DIST_TAG"
)
cargo-publish:
name: Publish crate
needs: [build, verify-provenance]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
publish_stderr="$(mktemp)"
trap 'rm -f "${publish_stderr}"' EXIT
if cargo publish 2> >(tee "${publish_stderr}" >&2); then
exit 0
fi
if grep -Eiq 'already (uploaded|published)|already exists' "${publish_stderr}"; then
echo "::notice::Crate version already published on crates.io; continuing."
exit 0
fi
echo "::error::cargo publish failed with an unexpected error."
cat "${publish_stderr}" >&2
exit 1