Skip to content

release

release #116

Workflow file for this run

name: release
on:
push:
tags: ["v*.*.*"]
workflow_dispatch:
inputs:
version:
description: Semver (with or without leading v)
required: true
permissions:
contents: read
concurrency:
# A tag push and a manual dispatch can target the same immutable release tag
# from different refs. Serialize releases so those paths cannot publish twice.
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && (startsWith(inputs.version, 'v') && inputs.version || format('v{0}', inputs.version)) || github.ref_name }}
cancel-in-progress: false
jobs:
prepare:
runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
contents: read
outputs:
package_name: ${{ steps.release.outputs.package_name }}
release_sha: ${{ steps.release.outputs.release_sha }}
release_tag: ${{ steps.release.outputs.release_tag }}
release_version: ${{ steps.release.outputs.release_version }}
release_channel: ${{ steps.release.outputs.release_channel }}
npm_tag: ${{ steps.release.outputs.npm_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 1
- id: release
name: Resolve immutable release tag
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED: ${{ github.event.inputs.version || github.ref_name }}
TRIGGER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
release_version="${REQUESTED#v}"
if [[ ! "$release_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Requested release version '$REQUESTED' is not semver."
exit 1
fi
release_tag="v${release_version}"
release_channel="$(node scripts/resolve-release-channel.mjs --version "$release_version")"
npm_tag="${release_channel}"
if [[ "$release_channel" == "stable" ]]; then
npm_tag=latest
fi
release_sha="$TRIGGER_SHA"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
for attempt in 1 2 3; do
if timeout 60s git \
-c http.lowSpeedLimit=1000 \
-c http.lowSpeedTime=30 \
fetch --force --no-tags origin "refs/tags/${release_tag}:refs/tags/${release_tag}"; then
break
fi
if [[ "$attempt" -eq 3 ]]; then
echo "::error::Unable to fetch ${release_tag} after ${attempt} bounded attempts."
exit 1
fi
sleep 2
done
release_sha="$(git rev-list -n 1 "$release_tag")"
elif [[ "$EVENT_NAME" != "push" ]]; then
echo "::error::Unsupported release event ${EVENT_NAME}."
exit 1
fi
if [[ ! "$release_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Release source ${release_sha} is not an immutable commit SHA."
exit 1
fi
git checkout --detach "$release_sha"
package_version="$(node -p "require('./package.json').version")"
package_name="$(node -p "require('./package.json').name")"
if [[ "$package_version" != "$release_version" ]]; then
echo "::error::${release_tag} contains package version ${package_version}, not ${release_version}."
exit 1
fi
{
echo "package_name=$package_name"
echo "release_sha=$release_sha"
echo "release_tag=$release_tag"
echo "release_version=$release_version"
echo "release_channel=$release_channel"
echo "npm_tag=$npm_tag"
} >> "$GITHUB_OUTPUT"
binaries:
needs: prepare
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- { platform: linux-x64, target: x86_64-unknown-linux-gnu, os: ubuntu-latest }
- { platform: linux-arm64, target: aarch64-unknown-linux-gnu, os: ubuntu-24.04-arm }
- { platform: darwin-x64, target: x86_64-apple-darwin, os: macos-15 }
- { platform: darwin-arm64, target: aarch64-apple-darwin, os: macos-15 }
runs-on: ${{ matrix.os }}
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.prepare.outputs.release_sha }}
- uses: ./.github/actions/setup-rust
with:
toolchain: stable
targets: ${{ matrix.target }}
workspaces: . -> target
- name: Build canonical native binary
env:
RELEASE_PLATFORM: ${{ matrix.platform }}
run: node scripts/build-release-binary.mjs --platform "$RELEASE_PLATFORM"
- name: Smoke host binary
env:
RELEASE_PLATFORM: ${{ matrix.platform }}
run: node scripts/smoke-release-native-only.mjs "dist/release/maestro-${RELEASE_PLATFORM}"
- name: Record smoke marker
env:
RELEASE_PLATFORM: ${{ matrix.platform }}
run: |
binary="dist/release/maestro-${RELEASE_PLATFORM}"
if command -v sha256sum >/dev/null 2>&1; then
digest="$(sha256sum "$binary" | awk '{print $1}')"
else
digest="$(shasum -a 256 "$binary" | awk '{print $1}')"
fi
printf '%s maestro-%s\n' "$digest" "$RELEASE_PLATFORM" > "dist/release/smoked-${RELEASE_PLATFORM}.txt"
- name: Verify macOS release signature
if: ${{ startsWith(matrix.platform, 'darwin-') }}
env:
RELEASE_PLATFORM: ${{ matrix.platform }}
run: |
node scripts/check-macos-release-signature.mjs \
"dist/release/maestro-${RELEASE_PLATFORM}" \
--marker "dist/release/signed-${RELEASE_PLATFORM}.json"
- name: Record release toolchain
env:
RELEASE_PLATFORM: ${{ matrix.platform }}
run: rustc -Vv > "dist/release/rustc-${RELEASE_PLATFORM}.txt"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: maestro-${{ matrix.platform }}
path: |
dist/release/maestro-${{ matrix.platform }}
dist/release/smoked-${{ matrix.platform }}.txt
dist/release/signed-${{ matrix.platform }}.json
dist/release/rustc-${{ matrix.platform }}.txt
publish:
needs:
- prepare
- binaries
# Public free hosted minutes by default. Do not point this at private
# self-hosted labels unless the runner group allows public repos.
runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 60
environment: npm-release
permissions:
contents: read
# Required for npm trusted publishing (OIDC). Last successful
# @evalops/maestro publish used GitHub OIDC, not classic NPM_TOKEN.
id-token: write
outputs:
package_name: ${{ needs.prepare.outputs.package_name }}
release_tag: ${{ needs.prepare.outputs.release_tag }}
release_version: ${{ needs.prepare.outputs.release_version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.prepare.outputs.release_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
package-manager-cache: false
registry-url: https://registry.npmjs.org
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
with:
pattern: maestro-*
path: release-binaries
merge-multiple: true
- name: Verify native package inputs
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
run: |
set -euo pipefail
test "$(node -p "require('./package.json').version")" = "$RELEASE_VERSION"
test -f packages/web/dist/index.html
npm run check:rust-only-runtime
- name: Materialize native npm package
run: node scripts/materialize-native-package.mjs --input-dir release-binaries
- name: Package browser assets for native installs
run: tar -czf maestro-web-dist.tar.gz -C packages/web/dist .
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
retention-days: 14
name: release-web-dist-${{ needs.prepare.outputs.release_tag }}
overwrite: true
path: maestro-web-dist.tar.gz
- id: pack
run: |
PACK_JSON=$(npm pack --json)
echo "$PACK_JSON"
node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); p.name="@evalops/maestro"; fs.writeFileSync("package.json", JSON.stringify(p, null, "\t")+"\n")'
ALIAS_PACK_JSON=$(npm pack --json)
echo "$ALIAS_PACK_JSON"
git checkout -- package.json
{
echo "tarball=$(echo "$PACK_JSON" | jq -r '.[0].filename')"
echo "integrity=$(echo "$PACK_JSON" | jq -r '.[0].integrity')"
echo "alias_tarball=$(echo "$ALIAS_PACK_JSON" | jq -r '.[0].filename')"
echo "alias_integrity=$(echo "$ALIAS_PACK_JSON" | jq -r '.[0].integrity')"
} >> "$GITHUB_OUTPUT"
- name: Smoke packed package without JS runtime
env:
MAESTRO_REQUIRE_PACKAGED_TUI: "1"
NPM_CONFIG_FETCH_RETRIES: "1"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"
NPM_CONFIG_FETCH_TIMEOUT: "30000"
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
PACKED_TARBALL: ${{ steps.pack.outputs.tarball }}
run: node scripts/smoke-packed-cli.js "$PACKED_TARBALL"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
retention-days: 14
name: npm-tarball-${{ needs.prepare.outputs.release_tag }}
overwrite: true
path: |
${{ steps.pack.outputs.tarball }}
${{ steps.pack.outputs.alias_tarball }}
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PACKAGE_NAME: ${{ needs.prepare.outputs.package_name }}
ALIAS_PACKAGE_NAME: "@evalops/maestro"
PACKED_INTEGRITY: ${{ steps.pack.outputs.integrity }}
ALIAS_PACKED_INTEGRITY: ${{ steps.pack.outputs.alias_integrity }}
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
NPM_TAG: ${{ needs.prepare.outputs.npm_tag }}
TARBALL: ${{ steps.pack.outputs.tarball }}
ALIAS_TARBALL: ${{ steps.pack.outputs.alias_tarball }}
NPM_CONFIG_FETCH_RETRIES: "1"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"
NPM_CONFIG_FETCH_TIMEOUT: "30000"
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
run: |
set -euo pipefail
# Trusted publishing (OIDC) is the primary path for @evalops/maestro.
# Classic NPM_TOKEN is fallback only; a dead token returns E404 on PUT.
publish_with_oidc() {
local tarball="$1"
# npm ≥ 11.5.1 required for OIDC; pin via npx for stable behavior.
npx --yes npm@11.10.0 publish "$tarball" --access public --tag "$NPM_TAG" --registry "$NPM_CONFIG_REGISTRY"
}
publish_with_token() {
local tarball="$1"
if [[ -z "${NODE_AUTH_TOKEN:-}" ]]; then
echo "::error::OIDC publish failed and secrets.NPM_TOKEN is empty. Configure npm trusted publishing for evalops/maestro release.yml (npm-release env) and ensure this job has id-token: write, or set a granular NPM_TOKEN with write on @evalops/maestro."
return 1
fi
printf "//registry.npmjs.org/:_authToken=%s\n" "$NODE_AUTH_TOKEN" > "$RUNNER_TEMP/npmrc"
NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npmrc" \
NODE_AUTH_TOKEN="$NODE_AUTH_TOKEN" \
npx --yes npm@11.10.0 publish "$tarball" --access public --tag "$NPM_TAG" --registry "$NPM_CONFIG_REGISTRY"
}
verify_published_tarball() {
local package_name="$1"
local packed_integrity="$2"
local registry_integrity
registry_integrity="$(
command npm view "${package_name}@${RELEASE_VERSION}" --registry "$NPM_CONFIG_REGISTRY" dist.integrity 2>/dev/null
)" || return 1
if [[ -z "$registry_integrity" ]]; then
return 1
fi
if [[ "$registry_integrity" != "$packed_integrity" ]]; then
echo "::error::npm already contains ${package_name}@${RELEASE_VERSION} with integrity ${registry_integrity}, not ${packed_integrity}."
return 2
fi
echo "Verified ${package_name}@${RELEASE_VERSION} already contains the packed tarball."
}
publish_or_verify() {
local publisher="$1"
local package_name="$2"
local tarball="$3"
local packed_integrity="$4"
local publish_status=0
local registry_status=0
"$publisher" "$tarball" || publish_status=$?
if [[ "$publish_status" -eq 0 ]]; then
return 0
fi
verify_published_tarball "$package_name" "$packed_integrity" || registry_status=$?
if [[ "$registry_status" -eq 0 ]]; then
return 0
fi
if [[ "$registry_status" -eq 2 ]]; then
return 2
fi
return "$publish_status"
}
publish_package() {
local package_name="$1"
local tarball="$2"
local packed_integrity="$3"
local registry_status=0
verify_published_tarball "$package_name" "$packed_integrity" || registry_status=$?
if [[ "$registry_status" -eq 0 ]]; then
return 0
fi
if [[ "$registry_status" -eq 2 ]]; then
return 2
fi
if publish_or_verify publish_with_oidc "$package_name" "$tarball" "$packed_integrity"; then
return 0
fi
echo "::warning::OIDC trusted publish failed for ${package_name}; trying secrets.NPM_TOKEN fallback."
publish_or_verify publish_with_token "$package_name" "$tarball" "$packed_integrity"
}
# shellcheck disable=SC2153 # Values are supplied by the step env block.
publish_package "$PACKAGE_NAME" "$TARBALL" "$PACKED_INTEGRITY"
publish_package "$ALIAS_PACKAGE_NAME" "$ALIAS_TARBALL" "$ALIAS_PACKED_INTEGRITY"
github-release:
needs:
- prepare
- binaries
- publish
runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 15
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.prepare.outputs.release_sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
with:
name: npm-tarball-${{ needs.prepare.outputs.release_tag }}
path: release-assets
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
with:
pattern: maestro-*
path: release-assets
merge-multiple: true
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0
with:
name: release-web-dist-${{ needs.prepare.outputs.release_tag }}
path: release-assets
- name: Install cosign
run: |
set -euo pipefail
cosign_version="2.6.1"
cosign_sha256="064954c5d8c7e3b28188eee5b1727b31c411550bc5fefd41aa672d3c761d103a"
mkdir -p "$RUNNER_TEMP/cosignbin"
curl -fsSL --max-time 180 --retry 2 --retry-delay 5 \
-o "$RUNNER_TEMP/cosignbin/cosign" \
"https://github.com/sigstore/cosign/releases/download/v${cosign_version}/cosign-linux-amd64"
echo "${cosign_sha256} $RUNNER_TEMP/cosignbin/cosign" | sha256sum -c -
chmod +x "$RUNNER_TEMP/cosignbin/cosign"
echo "$RUNNER_TEMP/cosignbin" >> "$GITHUB_PATH"
- name: Create signed channel metadata
env:
CHANNEL_KEY_ID: ${{ needs.prepare.outputs.release_channel == 'stable' && 'stable-2026-08-0c3df2ac' || 'preview-2026-08-912a0dab' }}
MAESTRO_CHANNEL_PRIVATE_KEY: ${{ needs.prepare.outputs.release_channel == 'stable' && secrets.MAESTRO_STABLE_CHANNEL_PRIVATE_KEY || secrets.MAESTRO_PREVIEW_CHANNEL_PRIVATE_KEY }}
RELEASE_CHANNEL: ${{ needs.prepare.outputs.release_channel }}
RELEASE_SHA: ${{ needs.prepare.outputs.release_sha }}
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
run: |
set -euo pipefail
compatibility_digest="$(jq -r '.compatibilityDigest' proto/maestro/v1/protocol-compatibility-manifest.json)"
for platform in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do
case "$platform" in
linux-x64) target=x86_64-unknown-linux-gnu ;;
linux-arm64) target=aarch64-unknown-linux-gnu ;;
darwin-x64) target=x86_64-apple-darwin ;;
darwin-arm64) target=aarch64-apple-darwin ;;
esac
digest="sha256:$(sha256sum "release-assets/maestro-${platform}" | awk '{print $1}')"
rustc_identity="$(cat "release-assets/rustc-${platform}.txt")"
node scripts/generate-runtime-passport.mjs \
--artifact-kind native_binary \
--artifact-name "maestro-${platform}" \
--artifact-digest "$digest" \
--source-sha "$RELEASE_SHA" \
--compatibility-digest "$compatibility_digest" \
--launch-spec-version evalops.maestro.hosted-launch-spec.v1 \
--receipt-version evalops.maestro.runtime-receipt.v1 \
--rustc "$rustc_identity" \
--target "$target" \
--behavior-profile hosted-http-sse-v1 \
--out "runtime-passport-maestro-${platform}.json"
done
node scripts/create-release-metadata.mjs \
--version "$RELEASE_VERSION" \
--release-tag "$RELEASE_TAG" \
--source-sha "$RELEASE_SHA" \
--out release-assets/release-metadata.json
cp release-assets/release-metadata.json release-assets/version.json
node scripts/create-release-channel-manifest.mjs \
--version "$RELEASE_VERSION" \
--channel "$RELEASE_CHANNEL" \
--key-id "$CHANNEL_KEY_ID" \
--release-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}" \
--metadata-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/release-metadata.json" \
--metadata-file release-assets/release-metadata.json \
--source-sha "$RELEASE_SHA" \
--out release-assets/channel-manifest.json
cp release-assets/channel-manifest.json release-assets/manifest.json
mv runtime-passport-maestro-*.json release-assets/
- name: Sign native release assets
working-directory: release-assets
run: |
set -euo pipefail
files=(
maestro-darwin-arm64
maestro-darwin-x64
maestro-linux-arm64
maestro-linux-x64
maestro-web-dist.tar.gz
)
files+=(release-metadata.json version.json channel-manifest.json manifest.json)
files+=(runtime-passport-maestro-*.json)
sha256sum "${files[@]}" > SHA256SUMS
cosign sign-blob --yes --bundle SHA256SUMS.cosign.bundle SHA256SUMS
for binary in maestro-darwin-arm64 maestro-darwin-x64 maestro-linux-arm64 maestro-linux-x64; do
cosign sign-blob --yes --bundle "${binary}.cosign.bundle" "$binary"
done
for passport in runtime-passport-maestro-*.json; do
cosign sign-blob --yes --bundle "${passport}.cosign.bundle" "$passport"
done
- name: Verify release tag has not moved
env:
EXPECTED_RELEASE_SHA: ${{ needs.prepare.outputs.release_sha }}
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
run: |
set -euo pipefail
for attempt in 1 2 3; do
if timeout 60s git \
-c http.lowSpeedLimit=1000 \
-c http.lowSpeedTime=30 \
fetch --force --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"; then
break
fi
if [[ "$attempt" -eq 3 ]]; then
echo "::error::Unable to verify ${RELEASE_TAG} after ${attempt} bounded attempts."
exit 1
fi
sleep 2
done
current_release_sha="$(git rev-list -n 1 "$RELEASE_TAG")"
if [[ "$current_release_sha" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "::error::${RELEASE_TAG} moved from ${EXPECTED_RELEASE_SHA} to ${current_release_sha}; refusing to create a mismatched release."
exit 1
fi
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228
with:
tag_name: ${{ needs.prepare.outputs.release_tag }}
name: Maestro ${{ needs.prepare.outputs.release_version }}
generate_release_notes: true
prerelease: ${{ needs.prepare.outputs.release_channel != 'stable' }}
make_latest: ${{ needs.prepare.outputs.release_channel == 'stable' }}
files: release-assets/*
post-publish-canary:
needs:
- prepare
- publish
runs-on: ${{ vars.PUBLIC_RELEASE_RUNNER || 'ubuntu-latest' }}
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.prepare.outputs.release_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
package-manager-cache: false
registry-url: https://registry.npmjs.org
- name: Verify published package from npm
env:
CLI_COMMAND: maestro
MAESTRO_INSTALL_AUDIT_LEVEL: critical
MAESTRO_PUBLISHED_REPLAY_SANDBOX_MODE: local
MAESTRO_REGISTRY_SMOKE_EVIDENCE_DIR: published-replay-evidence
MAESTRO_REGISTRY_POLL_ATTEMPTS: "120"
MAESTRO_REGISTRY_POLL_DELAY_MS: "5000"
NPM_CONFIG_FETCH_RETRIES: "1"
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "2000"
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "1000"
NPM_CONFIG_FETCH_TIMEOUT: "30000"
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
PACKAGE_NAME: ${{ needs.prepare.outputs.package_name }}
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
run: |
set -euo pipefail
node scripts/smoke-registry-install.js \
--package "$PACKAGE_NAME" \
--version "$RELEASE_VERSION" \
--cli-command "$CLI_COMMAND"
- name: Validate published replay evidence
run: node scripts/verify-published-replay-evidence.js --evidence-dir published-replay-evidence
- name: Upload published replay evidence
if: ${{ always() && hashFiles('published-replay-evidence/*.json') != '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
retention-days: 14
name: published-replay-evidence-${{ needs.prepare.outputs.release_tag }}
overwrite: true
path: published-replay-evidence/*.json