Skip to content

Release

Release #15

Workflow file for this run

name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version X.Y.Z (required for release and dry-run)"
required: false
type: string
mode:
description: "What to run"
type: choice
default: dry-run
options:
- dry-run
- release
- manifest-only
- transparency-only
permissions:
contents: read
concurrency:
group: grove-release-${{ inputs.mode }}
cancel-in-progress: false
jobs:
validate:
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Validate inputs
run: |
set -euo pipefail
echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || {
echo "version must match X.Y.Z, got: $VERSION"
exit 1
}
build:
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
needs: validate
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, target: linux-x64, ext: "" }
- { os: macos-latest, target: macos-arm64, ext: "" }
- { os: macos-15-intel, target: macos-x64, ext: "" }
- { os: windows-latest, target: windows-x64, ext: ".exe" }
runs-on: ${{ matrix.os }}
env:
VERSION: ${{ inputs.version }}
MODE: ${{ inputs.mode }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.mode == 'release' && format('refs/tags/v{0}', inputs.version) || '' }}
- name: Assert checkout matches the tag
if: inputs.mode == 'release'
shell: bash
run: |
set -euo pipefail
tag_commit=$(git ls-remote origin "refs/tags/v$VERSION" "refs/tags/v$VERSION^{}" | tail -1 | cut -f1)
[ -n "$tag_commit" ] || { echo "tag v$VERSION not found on origin"; exit 1; }
[ "$(git rev-parse HEAD)" = "$tag_commit" ] || { echo "checkout does not match tag v$VERSION"; exit 1; }
- name: Build grove and grove-mcp
run: cargo build --release --locked -p grove-core -p grove-mcp
shell: bash
- name: Test
run: cargo test --workspace --exclude grove-desktop --locked
shell: bash
- name: Package archives
shell: bash
run: |
set -euo pipefail
mkdir -p stage out
for comp in grove grove-mcp; do
cp "target/release/$comp${{ matrix.ext }}" stage/
tar -czf "out/$comp-v$VERSION-${{ matrix.target }}.tar.gz" -C stage "$comp${{ matrix.ext }}"
rm "stage/$comp${{ matrix.ext }}"
done
ls -la out/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-${{ matrix.target }}
path: out/*.tar.gz
build-desktop:
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
needs: validate
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, target: linux-x64, ext: "", bundles: "deb,appimage" }
- { os: macos-latest, target: macos-arm64, ext: "", bundles: "dmg" }
- { os: macos-15-intel, target: macos-x64, ext: "", bundles: "dmg" }
- { os: windows-latest, target: windows-x64, ext: ".exe", bundles: "nsis,msi" }
runs-on: ${{ matrix.os }}
env:
VERSION: ${{ inputs.version }}
MODE: ${{ inputs.mode }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.mode == 'release' && format('refs/tags/v{0}', inputs.version) || '' }}
- name: Assert checkout matches the tag
if: inputs.mode == 'release'
shell: bash
run: |
set -euo pipefail
tag_commit=$(git ls-remote origin "refs/tags/v$VERSION" "refs/tags/v$VERSION^{}" | tail -1 | cut -f1)
[ -n "$tag_commit" ] || { echo "tag v$VERSION not found on origin"; exit 1; }
[ "$(git rev-parse HEAD)" = "$tag_commit" ] || { echo "checkout does not match tag v$VERSION"; exit 1; }
- name: Install Tauri system dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libxdo-dev libappindicator3-dev librsvg2-dev
- name: Install tauri-cli 2.11.4
run: cargo install tauri-cli --locked --version 2.11.4
shell: bash
- name: Build desktop bundles
run: cargo tauri build --bundles "${{ matrix.bundles }}"
shell: bash
working-directory: packages/desktop/src-tauri
- name: Desktop test suite
run: cargo test -p grove-desktop --locked
shell: bash
- name: Package portable archive and bundles
shell: bash
run: |
set -euo pipefail
mkdir -p stage/ui out
cp "target/release/grove-desktop${{ matrix.ext }}" stage/
cp packages/desktop/src-tauri/icons/128x128.png stage/icon.png
cp packages/desktop/src-tauri/icons/icon.icns stage/icon.icns
cp -r packages/desktop/ui/views stage/ui/views
cp -r packages/desktop/ui/icons stage/ui/icons
tar -czf "out/grove-desktop-v$VERSION-${{ matrix.target }}.tar.gz" -C stage "grove-desktop${{ matrix.ext }}" icon.png icon.icns ui
cd target/release/bundle
case "${{ matrix.target }}" in
linux-x64)
cp deb/*.deb "$OLDPWD/out/grove-desktop-v$VERSION-linux-x64.deb"
cp appimage/*.AppImage "$OLDPWD/out/grove-desktop-v$VERSION-linux-x64.AppImage"
;;
macos-*)
cp dmg/*.dmg "$OLDPWD/out/grove-desktop-v$VERSION-${{ matrix.target }}.dmg"
;;
windows-x64)
cp nsis/*-setup.exe "$OLDPWD/out/grove-desktop-v$VERSION-windows-x64-setup.exe"
cp msi/*.msi "$OLDPWD/out/grove-desktop-v$VERSION-windows-x64.msi"
;;
esac
ls -la "$OLDPWD/out/"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist-desktop-${{ matrix.target }}
path: out/*
audit:
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install trivy 0.73.0
run: |
set -euo pipefail
curl -fsSL -o /tmp/trivy.tar.gz https://github.com/aquasecurity/trivy/releases/download/v0.73.0/trivy_0.73.0_Linux-64bit.tar.gz
curl -fsSL -o /tmp/trivy-checksums.txt https://github.com/aquasecurity/trivy/releases/download/v0.73.0/trivy_0.73.0_checksums.txt
expected=$(grep "Linux-64bit.tar.gz$" /tmp/trivy-checksums.txt | cut -d' ' -f1)
actual=$(sha256sum /tmp/trivy.tar.gz | cut -d' ' -f1)
[ "$actual" = "$expected" ] || { echo "trivy checksum mismatch"; exit 1; }
sudo tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy
- uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2
with:
version: "1.12"
- name: Instantiate Julia project
run: julia --project=packages/grove -e 'using Pkg; Pkg.instantiate()'
- name: Audit gate (fail-closed)
run: bin/audit.sh --trivy-bin trivy --fail-closed
sbom:
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2
with:
version: "1.12"
- name: Instantiate Julia project
run: julia --project=packages/grove -e 'using Pkg; Pkg.instantiate()'
- name: Install trivy 0.73.0
run: |
set -euo pipefail
curl -fsSL -o /tmp/trivy.tar.gz https://github.com/aquasecurity/trivy/releases/download/v0.73.0/trivy_0.73.0_Linux-64bit.tar.gz
curl -fsSL -o /tmp/trivy-checksums.txt https://github.com/aquasecurity/trivy/releases/download/v0.73.0/trivy_0.73.0_checksums.txt
expected=$(grep "Linux-64bit.tar.gz$" /tmp/trivy-checksums.txt | cut -d' ' -f1)
actual=$(sha256sum /tmp/trivy.tar.gz | cut -d' ' -f1)
[ "$actual" = "$expected" ] || { echo "trivy checksum mismatch"; exit 1; }
sudo tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy
- name: Install cargo-cyclonedx 0.5.9
run: cargo install cargo-cyclonedx --locked --version 0.5.9
- name: Generate SBOM
run: bin/sbom.sh --output sbom.cdx.json
- name: Validate VEX
run: julia --project=packages/grove bin/validate-vex.jl docs/security/artifacts/vex.json
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: sbom.cdx.json
sign-and-publish:
needs: [build, build-desktop, audit, sbom]
if: always() && !failure() && !cancelled()
runs-on: ubuntu-latest
environment: ${{ inputs.mode == 'dry-run' && 'release-dry' || 'release' }}
permissions:
contents: write
id-token: write
attestations: write
pull-requests: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
MODE: ${{ inputs.mode }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.mode == 'release' && format('refs/tags/v{0}', inputs.version) || '' }}
- name: Download build artifacts
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-*
path: out
merge-multiple: true
- name: Download SBOM
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom
path: sbom
- name: Prepare signing key
run: |
set -euo pipefail
umask 077
if [ "$MODE" = "dry-run" ]; then
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out key.pem 2>/dev/null
openssl pkey -in key.pem -pubout -out trusted.pem 2>/dev/null
echo "dry-run: using an ephemeral in-job key; the release secret is not touched"
else
printf '%s' "$GROVE_MANIFEST_SIGNING_KEY" > key.pem
cp docs/security/artifacts/public-keys/grove-manifest-2026-08.pem trusted.pem
chmod 600 key.pem
openssl rsa -in key.pem -pubout -outform DER 2>/dev/null | sha256sum | cut -c1-16
openssl pkey -pubin -in trusted.pem -outform DER 2>/dev/null | sha256sum | cut -c1-16
fi
env:
GROVE_MANIFEST_SIGNING_KEY: ${{ secrets.GROVE_MANIFEST_SIGNING_KEY }}
- name: Check release preconditions
if: inputs.mode == 'release'
run: |
set -euo pipefail
echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "bad version"; exit 1; }
gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/v$VERSION" > /dev/null || {
echo "tag v$VERSION does not exist; create and push it before dispatching (see publish-release runbook)"
exit 1
}
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/v$VERSION" > /dev/null 2>&1; then
echo "release v$VERSION already exists"
exit 1
fi
- name: Sign release metadata
if: inputs.mode == 'release' || inputs.mode == 'dry-run'
run: |
set -euo pipefail
ls out/*.tar.gz
(cd out && ls | grep -v '^SHA256SUMS' | xargs sha256sum > SHA256SUMS)
bin/sign.sh key.pem out/SHA256SUMS
bin/manifest.sh --version "$VERSION" --sums out/SHA256SUMS --artifacts-dir out \
--previous manifest.json --output manifest.json
bin/sign.sh key.pem manifest.json
cp sbom/sbom.cdx.json docs/security/artifacts/sbom.cdx.json
bin/sign.sh key.pem docs/security/artifacts/sbom.cdx.json
bin/sign.sh key.pem docs/security/artifacts/vex.json
bin/skill-bundle.sh --output out/grove-skill.md
bin/sign.sh key.pem out/grove-skill.md
if ! bin/verify.sh trusted.pem install.sh install.sh.sig 2>/dev/null; then
bin/sign.sh key.pem install.sh
fi
if ! bin/verify.sh trusted.pem install.ps1 install.ps1.sig 2>/dev/null; then
bin/sign.sh key.pem install.ps1
fi
bin/verify.sh trusted.pem manifest.json manifest.json.sig
cat > out/notes.md <<EOF
## grove v$VERSION
### Install
bash (macOS / Linux / Git Bash on Windows):
curl -fsSL https://raw.githubusercontent.com/alxshelepenok/grove/main/install.sh | bash
Windows PowerShell 5.1+:
iwr https://raw.githubusercontent.com/alxshelepenok/grove/main/install.ps1 -UseBasicParsing | iex
The installer verifies the signed manifest (RSA-2048/PSS) before parsing it and checks SHA-256 + size of every artifact before installing. Anti-rollback state lives in ~/.grove/.sequence.
### Verify manually
Download SHA256SUMS and SHA256SUMS.sig from this release, then:
bin/verify.sh docs/security/artifacts/public-keys/grove-manifest-2026-08.pem SHA256SUMS SHA256SUMS.sig
sha256sum -c SHA256SUMS
### Desktop bundles are unsigned
Browser downloads of the .msi / .dmg / .deb / .AppImage carry no publisher signature: Windows SmartScreen and macOS Gatekeeper will warn. The signed manifest and SHA256SUMS.sig above are the verification path; the script installers never trigger these prompts (command-line downloads carry no Mark-of-the-Web / quarantine attribute).
### Contents
- grove and grove-mcp CLI binaries + grove-desktop portable archives: linux-x64, macos-arm64, macos-x64, windows-x64.
- Desktop OS bundles: msi + nsis (windows), dmg (macOS arm64 + x64), deb + AppImage (linux).
- Signed manifest, SHA256SUMS, CycloneDX SBOM, VEX, signed installers.
EOF
cat manifest.json
- name: Dry-run summary
if: inputs.mode == 'dry-run'
run: |
set -euo pipefail
echo "dry-run complete; nothing was published"
echo "would attach to release v$VERSION:"
ls out/*.tar.gz out/SHA256SUMS* manifest.json* docs/security/artifacts/sbom.cdx.json* docs/security/artifacts/vex.json.sig
- name: Refresh manifest only
if: inputs.mode == 'manifest-only'
run: |
set -euo pipefail
bin/manifest.sh --refresh --previous manifest.json --output manifest.json
bin/sign.sh key.pem manifest.json
bin/verify.sh trusted.pem manifest.json manifest.json.sig
tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
[ -n "$tag" ] || { echo "no release to attach the refreshed manifest to"; exit 1; }
gh release upload "$tag" manifest.json manifest.json.sig --clobber
- name: Re-sign transparency artifacts only
if: inputs.mode == 'transparency-only'
run: |
set -euo pipefail
curl -fsSL -o /tmp/julia.tar.gz https://julialang-s3.julialang.org/bin/linux/x64/1.12/julia-1.12.6-linux-x86_64.tar.gz
echo "bbabf3bef19421a9dbd24a767d807606ab85e444323b5a1c73ffe293fa3d079a /tmp/julia.tar.gz" | sha256sum -c -
sudo tar -xzf /tmp/julia.tar.gz -C /opt
export PATH="/opt/julia-1.12.6/bin:$PATH"
julia --project=packages/grove -e 'using Pkg; Pkg.instantiate()'
julia --project=packages/grove bin/validate-vex.jl docs/security/artifacts/vex.json
bin/sign.sh key.pem docs/security/artifacts/vex.json
[ -f docs/security/artifacts/sbom.cdx.json ] && bin/sign.sh key.pem docs/security/artifacts/sbom.cdx.json
tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')
[ -n "$tag" ] || { echo "no release to attach transparency artifacts to"; exit 1; }
gh release upload "$tag" docs/security/artifacts/vex.json docs/security/artifacts/vex.json.sig --clobber
[ -f docs/security/artifacts/sbom.cdx.json ] && gh release upload "$tag" docs/security/artifacts/sbom.cdx.json docs/security/artifacts/sbom.cdx.json.sig --clobber
- name: Publish release
if: inputs.mode == 'release'
run: |
set -euo pipefail
gh release create "v$VERSION" \
--title "v$VERSION" \
--notes-file out/notes.md \
--verify-tag \
out/*.tar.gz out/*.deb out/*.AppImage out/*.dmg out/*.msi out/*-setup.exe \
out/SHA256SUMS out/SHA256SUMS.sig \
out/grove-skill.md out/grove-skill.md.sig \
manifest.json manifest.json.sig \
docs/security/artifacts/sbom.cdx.json docs/security/artifacts/sbom.cdx.json.sig \
docs/security/artifacts/vex.json docs/security/artifacts/vex.json.sig \
install.sh install.sh.sig install.ps1 install.ps1.sig
- name: Attest build provenance
if: inputs.mode == 'release'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
out/*.tar.gz
out/*.deb
out/*.AppImage
out/*.dmg
out/*.msi
out/*-setup.exe
manifest.json
- name: Post-release smoke test
if: inputs.mode == 'release'
run: |
set -euo pipefail
base="https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION"
mkdir -p smoke
curl -fsSL -o smoke/manifest.json "$base/manifest.json"
curl -fsSL -o smoke/manifest.json.sig "$base/manifest.json.sig"
bin/verify.sh trusted.pem smoke/manifest.json smoke/manifest.json.sig
for f in install.sh install.ps1; do
curl -fsSL -o "smoke/$f" "$base/$f"
curl -fsSL -o "smoke/$f.sig" "$base/$f.sig"
bin/verify.sh trusted.pem "smoke/$f" "smoke/$f.sig"
done
expect=$(sed -n '/^ "grove_mcp_linux_x64": {$/,/^ }/p' smoke/manifest.json | sed -n 's/^ "sha256": "\([0-9a-f]*\)",$/\1/p')
curl -fsSL -o smoke/grove-mcp.tar.gz "$base/grove-mcp-v$VERSION-linux-x64.tar.gz"
actual=$(sha256sum smoke/grove-mcp.tar.gz | cut -d' ' -f1)
[ "$actual" = "$expect" ] || { echo "smoke hash mismatch"; exit 1; }
expect_dt=$(sed -n '/^ "grove_desktop_linux_x64": {$/,/^ }/p' smoke/manifest.json | sed -n 's/^ "sha256": "\([0-9a-f]*\)",$/\1/p')
curl -fsSL -o smoke/grove-desktop.tar.gz "$base/grove-desktop-v$VERSION-linux-x64.tar.gz"
actual_dt=$(sha256sum smoke/grove-desktop.tar.gz | cut -d' ' -f1)
[ "$actual_dt" = "$expect_dt" ] || { echo "smoke desktop hash mismatch"; exit 1; }
echo "smoke ok: published manifest, install.sh, install.ps1 verify; grove-mcp and grove-desktop linux-x64 match their signed hashes"
- name: Commit transparency log
if: inputs.mode != 'dry-run'
run: |
set -euo pipefail
git config user.name "grove-compass[bot]"
git config user.email "324460858+grove-compass[bot]@users.noreply.github.com"
git add manifest.json manifest.json.sig docs/security/artifacts/ install.sh.sig install.ps1.sig
if git diff --cached --quiet; then
echo "nothing to commit"
exit 0
fi
git commit -m "release: transparency artifacts ($MODE $VERSION)"
if ! git push origin HEAD:main; then
if [ -n "$VERSION" ]; then
branch="release/v$VERSION"
else
branch="release/$MODE-$(date -u +%Y%m%d%H%M%S)"
fi
git push origin "HEAD:refs/heads/$branch"
if ! gh pr create --base main --head "$branch" \
--title "release: transparency artifacts ($MODE $VERSION)" \
--body "Opened by the release workflow because direct push to main is protected. Merge to sync the root manifest and docs/security/artifacts."; then
echo "::warning::could not open the PR automatically; allow GitHub Actions to create pull requests in repo settings or open it manually: https://github.com/$GITHUB_REPOSITORY/pull/new/$branch"
fi
fi