Files
openclaw/.github/workflows/linux-app-release.yml

443 lines
18 KiB
YAML

name: Linux App Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing OpenClaw release tag to receive Linux companion bundles, for example v2026.7.1
required: true
type: string
desktop-test-bundles:
description: Also build unsigned macOS/Windows test bundles
default: false
type: boolean
permissions:
contents: write
concurrency:
group: linux-app-release-${{ inputs.tag }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
validate_release:
name: Validate release tag
runs-on: ubuntu-22.04
timeout-minutes: 10
# Build jobs check out this exact SHA so a tag force-moved mid-run cannot
# swap in code the ancestry guard never validated.
outputs:
tag_sha: ${{ steps.ancestry.outputs.tag_sha }}
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*)?$ ]]; then
# Alpha/beta semver suffixes map to a Debian revision that sorts
# AFTER the plain stable version, breaking beta-to-stable upgrades;
# numeric stable revisions (-N) order correctly and stay allowed.
echo "Linux bundles ship for stable release tags only (vYYYY.M.PATCH or vYYYY.M.PATCH-N); got: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Ensure tag commit is reachable from main
id: ancestry
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
timeout --signal=TERM --kill-after=10s 120s git fetch --quiet origin main
tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")
if ! git merge-base --is-ancestor "${tag_sha}" origin/main; then
echo "Tag ${RELEASE_TAG} (${tag_sha}) is not reachable from main; Linux bundles ship for main-based releases only."
exit 1
fi
echo "tag_sha=${tag_sha}" >> "$GITHUB_OUTPUT"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName
build_linux:
name: Build Linux companion bundles
needs: validate_release
# Oldest supported build base: bundles link against this glibc, so newer
# runners would silently drop Ubuntu 22.04/Debian 12 users.
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_release.outputs.tag_sha }}
persist-credentials: false
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
curl \
file \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
libxdo-dev \
wget
- name: Install Rust
run: rustup toolchain install stable --profile minimal
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build Linux companion bundles
working-directory: apps/linux/src-tauri
env:
# appimagetool strips fail on CI runners; Tauri documents NO_STRIP for Actions.
NO_STRIP: "true"
RELEASE_TAG: ${{ inputs.tag }}
# Sign the AppImage updater artifact so the companion can verify updates.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
# Stamp the release version into the bundles; the committed manifests
# keep a placeholder 0.1.0 so releases stay tag-driven.
pnpm dlx @tauri-apps/cli@2.11.4 build --bundles deb,appimage \
--config "{\"version\":\"${version}\"}"
- name: Verify and rename Linux bundles
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
version="${RELEASE_TAG#v}"
debs=(apps/linux/src-tauri/target/release/bundle/deb/*.deb)
appimages=(apps/linux/src-tauri/target/release/bundle/appimage/*.AppImage)
if [[ ${#debs[@]} -ne 1 || ${#appimages[@]} -ne 1 || ! -f "${appimages[0]}.sig" ]]; then
echo "Expected one deb, one AppImage, and its updater signature"
exit 1
fi
deb_version=$(dpkg-deb -f "${debs[0]}" Version)
if [[ "${deb_version}" != "${version}"* ]]; then
echo "Debian package version '${deb_version}' does not match release version '${version}'"
exit 1
fi
mkdir -p dist/linux-app/release dist/linux-app/signatures
cp "${debs[0]}" "dist/linux-app/release/OpenClaw-${version}-amd64.deb"
cp "${appimages[0]}" "dist/linux-app/release/OpenClaw-${version}-amd64.AppImage"
cp "${appimages[0]}.sig" \
"dist/linux-app/signatures/OpenClaw-${version}-amd64.AppImage.sig"
- name: Upload Linux bundles
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: linux-app-release
path: dist/linux-app
if-no-files-found: error
# TEST-ONLY bundles: no Apple codesigning/notarization or Authenticode.
# Users must bypass Gatekeeper or SmartScreen before running them.
build_macos:
name: Build unsigned macOS test bundles
if: ${{ inputs['desktop-test-bundles'] }}
needs: validate_release
runs-on: macos-14
timeout-minutes: 45
steps:
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_release.outputs.tag_sha }}
persist-credentials: false
- name: Install Rust
run: rustup toolchain install stable --profile minimal
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build macOS test bundles
working-directory: apps/linux/src-tauri
env:
RELEASE_TAG: ${{ inputs.tag }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
pnpm dlx @tauri-apps/cli@2.11.4 build --bundles app,dmg \
--config "{\"version\":\"${version}\"}"
- name: Verify and rename macOS bundles
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
version="${RELEASE_TAG#v}"
apps=(apps/linux/src-tauri/target/release/bundle/macos/*.app)
archives=(apps/linux/src-tauri/target/release/bundle/macos/*.app.tar.gz)
dmgs=(apps/linux/src-tauri/target/release/bundle/dmg/*.dmg)
if [[ ${#apps[@]} -ne 1 || ${#archives[@]} -ne 1 || ${#dmgs[@]} -ne 1 || ! -f "${archives[0]}.sig" ]]; then
echo "Expected one app, updater archive, updater signature, and dmg"
exit 1
fi
bundle_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"${apps[0]}/Contents/Info.plist")
if [[ "${bundle_version}" != "${version}" ]]; then
echo "macOS bundle version '${bundle_version}' does not match release version '${version}'"
exit 1
fi
mkdir -p dist/macos-app/release dist/macos-app/signatures
cp "${dmgs[0]}" "dist/macos-app/release/OpenClaw-${version}-darwin-aarch64.dmg"
cp "${archives[0]}" \
"dist/macos-app/release/OpenClaw-${version}-darwin-aarch64.app.tar.gz"
cp "${archives[0]}.sig" \
"dist/macos-app/signatures/OpenClaw-${version}-darwin-aarch64.app.tar.gz.sig"
- name: Upload macOS test bundles
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: macos-app-release
path: dist/macos-app
if-no-files-found: error
build_windows:
name: Build unsigned Windows test bundle
if: ${{ inputs['desktop-test-bundles'] }}
needs: validate_release
runs-on: windows-2022
timeout-minutes: 45
steps:
- name: Checkout selected tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.validate_release.outputs.tag_sha }}
persist-credentials: false
- name: Install Rust
run: rustup toolchain install stable --profile minimal
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build Windows test bundle
working-directory: apps/linux/src-tauri
shell: bash
env:
RELEASE_TAG: ${{ inputs.tag }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
pnpm dlx @tauri-apps/cli@2.11.4 build --bundles nsis \
--config "{\"version\":\"${version}\"}"
- name: Verify and rename Windows bundle
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:RELEASE_TAG.Substring(1)
$installers = @(Get-ChildItem "apps/linux/src-tauri/target/release/bundle/nsis/*.exe")
if ($installers.Count -ne 1) {
throw "Expected one NSIS installer; found $($installers.Count)"
}
$installer = $installers[0]
$signature = "$($installer.FullName).sig"
if (-not (Test-Path -LiteralPath $signature)) {
throw "Missing NSIS updater signature: $signature"
}
if (-not $installer.VersionInfo.ProductVersion.StartsWith($version)) {
throw "Windows bundle version '$($installer.VersionInfo.ProductVersion)' does not match release version '$version'"
}
New-Item -ItemType Directory -Force -Path "dist/windows-app/release", "dist/windows-app/signatures" | Out-Null
Copy-Item -LiteralPath $installer.FullName -Destination "dist/windows-app/release/OpenClaw-$version-windows-x86_64.exe"
Copy-Item -LiteralPath $signature -Destination "dist/windows-app/signatures/OpenClaw-$version-windows-x86_64.exe.sig"
- name: Upload Windows test bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-app-release
path: dist/windows-app
if-no-files-found: error
publish:
name: Publish companion bundles and updater manifest
if: >-
${{
always() &&
needs.build_linux.result == 'success' &&
(!inputs['desktop-test-bundles'] ||
(needs.build_macos.result == 'success' && needs.build_windows.result == 'success'))
}}
needs:
- validate_release
- build_linux
- build_macos
- build_windows
# One shared desktop-test channel asset must not race across release tags.
concurrency:
group: linux-app-release-publish
cancel-in-progress: false
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Download Linux bundles
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: linux-app-release
path: dist/input/linux
- name: Download macOS test bundles
if: ${{ inputs['desktop-test-bundles'] }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: macos-app-release
path: dist/input/macos
- name: Download Windows test bundle
if: ${{ inputs['desktop-test-bundles'] }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: windows-app-release
path: dist/input/windows
- name: Assemble release assets and updater manifest
env:
DESKTOP_TEST_BUNDLES: ${{ inputs['desktop-test-bundles'] }}
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
mkdir -p dist/release
cp dist/input/linux/release/* dist/release/
if [[ "${DESKTOP_TEST_BUNDLES}" == "true" ]]; then
cp dist/input/macos/release/* dist/release/
cp dist/input/windows/release/* dist/release/
fi
# Generate this before latest.json so it covers only downloadable bundles.
(cd dist/release && sha256sum ./* > SHA256SUMS.linux-app.txt)
cat dist/release/SHA256SUMS.linux-app.txt
linux_signature=$(cat "dist/input/linux/signatures/OpenClaw-${version}-amd64.AppImage.sig")
url_base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}"
pub_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Capture the full body (no early-closing pipe under pipefail), then
# truncate to 2000 Unicode chars inside jq so we never split a
# multibyte character or SIGPIPE the release-view command.
notes=$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json body --jq '.body // ""')
# latest.json is always the stable Linux channel. Desktop test builds
# use a separate manifest that Linux-only releases leave untouched.
jq -n \
--arg version "${version}" \
--arg notes "${notes}" \
--arg pub_date "${pub_date}" \
--arg linux_signature "${linux_signature}" \
--arg linux_url "${url_base}/OpenClaw-${version}-amd64.AppImage" \
'{version: $version, notes: ($notes | .[0:2000]), pub_date: $pub_date, platforms: {"linux-x86_64": {signature: $linux_signature, url: $linux_url}}}' \
> dist/release/latest.json
cat dist/release/latest.json
if [[ "${DESKTOP_TEST_BUNDLES}" == "true" ]]; then
macos_signature=$(cat "dist/input/macos/signatures/OpenClaw-${version}-darwin-aarch64.app.tar.gz.sig")
windows_signature=$(cat "dist/input/windows/signatures/OpenClaw-${version}-windows-x86_64.exe.sig")
jq -n \
--arg version "${version}" \
--arg notes "${notes}" \
--arg pub_date "${pub_date}" \
--arg macos_signature "${macos_signature}" \
--arg macos_url "${url_base}/OpenClaw-${version}-darwin-aarch64.app.tar.gz" \
--arg windows_signature "${windows_signature}" \
--arg windows_url "${url_base}/OpenClaw-${version}-windows-x86_64.exe" \
'{version: $version, notes: ($notes | .[0:2000]), pub_date: $pub_date, platforms: {"darwin-aarch64": {signature: $macos_signature, url: $macos_url}, "windows-x86_64": {signature: $windows_signature, url: $windows_url}}}' \
> dist/release/latest-desktop-test.json
cat dist/release/latest-desktop-test.json
fi
- name: Attach bundles to the release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh release upload "${RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber \
dist/release/*
- name: Publish desktop test update channel
if: ${{ inputs['desktop-test-bundles'] }}
env:
DESKTOP_TEST_CHANNEL_TAG: desktop-test
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
TAG_SHA: ${{ needs.validate_release.outputs.tag_sha }}
run: |
set -euo pipefail
channel_dir="${RUNNER_TEMP}/desktop-test-channel"
candidate_version="${RELEASE_TAG#v}"
mkdir -p "${channel_dir}"
if gh release view "${DESKTOP_TEST_CHANNEL_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then
has_manifest=$(gh release view "${DESKTOP_TEST_CHANNEL_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--json assets \
--jq '[.assets[].name] | index("latest-desktop-test.json") != null')
if [[ "${has_manifest}" == "true" ]]; then
gh release download "${DESKTOP_TEST_CHANNEL_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern latest-desktop-test.json \
--dir "${channel_dir}"
current_version=$(jq -er '.version | strings' "${channel_dir}/latest-desktop-test.json")
newest_version=$(printf '%s\n' "${current_version}" "${candidate_version}" | LC_ALL=C sort -V | tail -n 1)
if [[ "${newest_version}" != "${candidate_version}" ]]; then
echo "Desktop test channel is already newer (${current_version}); leaving it unchanged."
exit 0
fi
fi
else
gh release create "${DESKTOP_TEST_CHANNEL_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--target "${TAG_SHA}" \
--prerelease \
--title "OpenClaw desktop test update channel" \
--notes "Opt-in updater manifest for unsigned macOS and Windows Tauri test builds."
fi
gh release upload "${DESKTOP_TEST_CHANNEL_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber \
dist/release/latest-desktop-test.json