blob: 6ab7bb3f58f788f81b25880a60caa239272466b4 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
name: Release
on:
workflow_dispatch:
inputs:
source_reference_tag:
description: Exact approved ASF source candidate tag (v<version>-incubating-rc<positive-integer>)
required: true
type: string
permissions:
contents: read
concurrency:
group: product-release
cancel-in-progress: false
jobs:
release-identity:
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.identity.outputs.version }}
tag: ${{ steps.identity.outputs.tag }}
source_commit: ${{ steps.identity.outputs.source_commit }}
source_reference_tag: ${{ steps.identity.outputs.source_reference_tag }}
cli_archive: ${{ steps.identity.outputs.cli_archive }}
node_version: ${{ steps.identity.outputs.node_version }}
npm_version: ${{ steps.identity.outputs.npm_version }}
node_archive: ${{ steps.identity.outputs.node_archive }}
node_archive_sha256: ${{ steps.identity.outputs.node_archive_sha256 }}
node_source_url: ${{ steps.identity.outputs.node_source_url }}
steps:
- name: Check out the dispatched commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Resolve product release identity
id: identity
env:
GITHUB_SHA: ${{ github.sha }}
SOURCE_REFERENCE_TAG: ${{ inputs.source_reference_tag }}
run: node scripts/product-release-identity.mjs
- name: Require the exact ASF source reference
env:
RELEASE_REF: ${{ github.ref }}
RELEASE_REPOSITORY: ${{ github.repository }}
SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }}
SOURCE_REFERENCE_TAG: ${{ steps.identity.outputs.source_reference_tag }}
run: |
if [[ "$RELEASE_REPOSITORY" != "apache/maka" ]]; then
echo "Product releases must run in apache/maka; found $RELEASE_REPOSITORY" >&2
exit 1
fi
if [[ "$RELEASE_REF" != "refs/tags/$SOURCE_REFERENCE_TAG" ]]; then
echo "Product releases must be dispatched from $SOURCE_REFERENCE_TAG; found $RELEASE_REF" >&2
exit 1
fi
git fetch --force --no-tags origin \
"refs/tags/$SOURCE_REFERENCE_TAG:refs/tags/$SOURCE_REFERENCE_TAG"
test "$(git cat-file -t "refs/tags/$SOURCE_REFERENCE_TAG")" = tag
test "$(git rev-parse "refs/tags/$SOURCE_REFERENCE_TAG^{commit}")" = "$SOURCE_COMMIT"
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main
desktop:
needs: release-identity
strategy:
# Every target ships from one dispatch, so a failure on one still leaves
# the other artifacts available for diagnosis. Each architecture builds on
# a runner of its own: the native Runtime Host peer and the packaged smoke
# probes are host binaries.
fail-fast: false
matrix:
include:
- platform: macos
arch: arm64
runner: macos-15
- platform: macos
arch: x64
runner: macos-15-intel
- platform: windows
arch: x64
runner: windows-2025
- platform: linux
arch: x64
runner: ubuntu-24.04
- platform: linux
arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
environment: release
timeout-minutes: 75
defaults:
run:
# Windows runners default to pwsh; the release steps are written once,
# in bash, for both platforms.
shell: bash
steps:
- name: Check out the dispatched commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-identity.outputs.source_commit }}
fetch-depth: 0
persist-credentials: false
- name: Set up the pinned release Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ needs.release-identity.outputs.node_version }}
cache: npm
- name: Select and verify the pinned npm release toolchain
run: |
npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}"
test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}"
- name: Install dependencies with the pinned npm
run: npm ci
- name: Audit shipped product closure
run: node scripts/audit-shipped-dependencies.mjs
- name: Update stable Rust for Desktop native artifacts
run: rustup update stable --no-self-update
- name: Write App Store Connect API key
if: matrix.platform == 'macos'
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8
run: |
umask 077
printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH"
- name: Package notarized app and signed DMG
if: matrix.platform == 'macos'
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: npm run package:macos-${{ matrix.arch }}
- name: Notarize and staple the signed final DMG
if: matrix.platform == 'macos'
env:
APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
# Discovered, not spelled out: this runner builds one architecture, so
# exactly one DMG exists, and signing does not need to know its name.
# The name itself is the descriptor's to decide and `verify:macos`
# below resolves it from there.
shopt -s nullglob
dmgs=(apps/desktop/release/*.dmg)
if [[ ${#dmgs[@]} -ne 1 ]]; then
echo "Expected exactly one DMG to sign, found ${#dmgs[@]}" >&2
exit 1
fi
DMG_PATH="${dmgs[0]}"
codesign --verify --verbose=4 "$DMG_PATH"
xcrun notarytool submit "$DMG_PATH" \
--key "$APPLE_API_KEY" \
--key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER" \
--wait
xcrun stapler staple "$DMG_PATH"
codesign --verify --verbose=4 "$DMG_PATH"
xcrun stapler validate "$DMG_PATH"
spctl --assess \
--type open \
--context context:primary-signature \
--verbose=4 \
"$DMG_PATH"
- name: Verify the final DMG
if: matrix.platform == 'macos'
run: npm run verify:macos -- "${{ matrix.arch }}"
# The updater is one code path across both macOS architectures, so the
# end-to-end update proof runs once, on the architecture most installs
# use. package-macos-autoupdate-next.mjs enforces the same restriction.
- name: Build the version-bumped macOS update
if: matrix.platform == 'macos' && matrix.arch == 'arm64'
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run package:macos-autoupdate-next
- name: Verify macOS automatic update end to end
if: matrix.platform == 'macos' && matrix.arch == 'arm64'
run: npm run verify:macos-autoupdate -- apps/desktop/release-autoupdate-next
# Windows has no Authenticode certificate yet, so this build is unsigned
# and there is nothing to notarize between packaging and verification.
- name: Package the Windows installer and ZIP
if: matrix.platform == 'windows'
run: npm run package:windows-x64
- name: Verify the Windows release
if: matrix.platform == 'windows'
run: npm run verify:windows-x64 -- "${{ matrix.arch }}"
# Discovered, not spelled out, for the same reason as the DMG above: this
# runner builds one installer, and the name is the descriptor's to decide.
# The installer steps below are handed a path rather than a target, so
# unlike `verify:windows-x64` they cannot resolve it themselves.
- name: Locate the packaged Windows installer
id: installer
if: matrix.platform == 'windows'
run: |
shopt -s nullglob
exes=(apps/desktop/release/*.exe)
if [[ ${#exes[@]} -ne 1 ]]; then
echo "Expected exactly one Windows installer, found ${#exes[@]}" >&2
exit 1
fi
echo "exe=${exes[0]}" >> "$GITHUB_OUTPUT"
- name: Download and verify the pinned Windows upgrade baseline
id: previous
if: matrix.platform == 'windows'
env:
GH_TOKEN: ${{ github.token }}
run: |
previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \
"${{ needs.release-identity.outputs.version }}" artifacts/windows-upgrade-baseline)"
echo "exe=$previous_exe" >> "$GITHUB_OUTPUT"
- name: Exercise pinned Windows upgrade and uninstall
if: matrix.platform == 'windows'
run: |
npm run verify:windows-installer -- \
"${{ steps.installer.outputs.exe }}" \
"${{ steps.previous.outputs.exe }}"
- name: Build the version-bumped autoupdate installer
if: matrix.platform == 'windows'
run: npm run package:windows-autoupdate-next
- name: Verify automatic update end to end
if: matrix.platform == 'windows'
run: |
npm run verify:windows-autoupdate -- \
"${{ steps.installer.outputs.exe }}" \
apps/desktop/release-autoupdate-next
- name: Prove deterministic mid-install failure rollback
if: matrix.platform == 'windows'
run: |
npm run verify:windows-installer-rollback -- \
"${{ steps.installer.outputs.exe }}" \
apps/desktop/release-autoupdate-next
# Linux ships unsigned for the same reason Windows does: there is no
# certificate yet, so packaging and verification are adjacent.
- name: Package the Linux AppImage and deb
if: matrix.platform == 'linux'
run: npm run package:linux
- name: Ensure xvfb
if: matrix.platform == 'linux'
run: command -v xvfb-run >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y xvfb; }
# `xvfb-run` because the verification launches the extracted AppImage's
# renderer over CDP, the way the macOS and Windows verifications do.
- name: Verify the Linux release
if: matrix.platform == 'linux'
run: xvfb-run -a npm run verify:linux -- "${{ matrix.arch }}"
- name: Stage the exact product artifact group
run: >-
node scripts/product-release-artifacts.mjs stage
"desktop-${{ matrix.platform }}-${{ matrix.arch }}"
apps/desktop/release
"${{ runner.temp }}/release-assets"
- name: Upload the verified release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-desktop-${{ matrix.platform }}-${{ matrix.arch }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-assets
if-no-files-found: error
retention-days: 30
- name: Remove temporary release credentials
if: always() && matrix.platform == 'macos'
run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8"
cli-macos-arm64:
needs: release-identity
runs-on: macos-15
environment: release
timeout-minutes: 45
defaults:
run:
shell: bash
steps:
- name: Check out the dispatched commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-identity.outputs.source_commit }}
fetch-depth: 0
persist-credentials: false
- name: Set up the pinned release Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ needs.release-identity.outputs.node_version }}
cache: npm
- name: Select and verify the pinned npm release toolchain
run: |
npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}"
test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}"
- name: Install dependencies with the pinned npm
run: npm ci
- name: Build the CLI production workspace closure
run: |
npm --workspace maka-agent run build:workspace-deps
npm --workspace maka-agent run build
npm run check:cli-third-party-notices
- name: Download and verify the official Node runtime
env:
NODE_ARCHIVE: ${{ runner.temp }}/${{ needs.release-identity.outputs.node_archive }}
NODE_ARCHIVE_SHA256: ${{ needs.release-identity.outputs.node_archive_sha256 }}
NODE_SOURCE_URL: ${{ needs.release-identity.outputs.node_source_url }}
run: |
curl --proto '=https' --tlsv1.2 --fail --location \
--output "$NODE_ARCHIVE" "$NODE_SOURCE_URL"
actual="$(shasum -a 256 "$NODE_ARCHIVE" | awk '{print $1}')"
test "$actual" = "$NODE_ARCHIVE_SHA256"
- name: Write App Store Connect API key
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8
run: |
umask 077
printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH"
- name: Package signed and notarized CLI and TUI
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
MAKA_CLI_NODE_ARCHIVE: ${{ runner.temp }}/${{ needs.release-identity.outputs.node_archive }}
MAKA_CLI_RELEASE_SIGNING: '1'
run: npm run package:cli:macos-arm64
- name: Verify the final CLI and TUI artifact
env:
MAKA_CLI_REQUIRE_RELEASE_SIGNING: '1'
run: >-
npm run verify:cli:macos-arm64 --
"apps/desktop/release/${{ needs.release-identity.outputs.cli_archive }}"
- name: Stage the exact product artifact group
run: >-
node scripts/product-release-artifacts.mjs stage
"cli-macos-arm64"
apps/desktop/release
"${{ runner.temp }}/release-assets"
- name: Upload the verified CLI release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-cli-macos-arm64-${{ github.run_attempt }}
path: ${{ runner.temp }}/release-assets
if-no-files-found: error
retention-days: 30
- name: Remove temporary release credentials
if: always()
run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8"
publish:
# One draft release carries both platforms, so it is created once, after
# every platform has been packaged and verified.
needs: [release-identity, desktop, cli-macos-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write
defaults:
run:
shell: bash
steps:
- name: Check out the dispatched commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-identity.outputs.source_commit }}
fetch-depth: 0
persist-credentials: false
- name: Download the verified release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: release-*-${{ github.run_attempt }}
path: release-assets
merge-multiple: true
- name: Set up the pinned release Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ needs.release-identity.outputs.node_version }}
package-manager-cache: false
- name: Select the pinned npm release toolchain
run: |
npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}"
test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}"
- name: Install the release verifier dependencies
run: npm ci --ignore-scripts --no-audit --no-fund
# Each macOS runner uploaded a feed naming only its own architecture. The
# feed a client reads carries both, so it only exists once they are here.
- name: Merge the per-architecture update feeds
run: node scripts/product-release-artifacts.mjs merge-feeds release-assets
- name: Verify the exact product artifact manifest
run: node scripts/product-release-artifacts.mjs verify release-assets
- name: Record the immutable publication evidence
env:
GITHUB_SHA: ${{ needs.release-identity.outputs.source_commit }}
SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }}
run: |
node scripts/product-release-artifacts.mjs record \
release-assets \
"$RUNNER_TEMP/product-release.json" \
"$GITHUB_REPOSITORY" \
"$GITHUB_RUN_ID" \
"$GITHUB_RUN_ATTEMPT"
- name: Upload the immutable publication evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: product-release-record-${{ github.run_attempt }}
path: ${{ runner.temp }}/product-release.json
if-no-files-found: error
compression-level: 0
retention-days: 30
# The runner uploads still carry the per-architecture feeds this job
# merged away. Finalize must publish and attest the published set, so it
# is handed these normalized bytes rather than the raw runner groups. The
# name deliberately avoids the `release-*` glob this job downloads.
- name: Upload the verified release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: product-release-assets-${{ github.run_attempt }}
path: release-assets
if-no-files-found: error
compression-level: 0
retention-days: 30
- name: Revalidate the live ASF source reference
env:
SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }}
SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }}
run: |
git fetch --force --no-tags origin \
"refs/tags/$SOURCE_REFERENCE_TAG:refs/tags/$SOURCE_REFERENCE_TAG"
test "$(git cat-file -t "refs/tags/$SOURCE_REFERENCE_TAG")" = tag
test "$(git rev-parse "refs/tags/$SOURCE_REFERENCE_TAG^{commit}")" = "$SOURCE_COMMIT"
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main
- name: Ensure the exact product tag
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.release-identity.outputs.tag }}
SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }}
run: |
gh auth setup-git
node scripts/product-release-tag.mjs ensure "$TAG" "$SOURCE_COMMIT"
- name: Create or update the draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.release-identity.outputs.version }}
TAG: ${{ needs.release-identity.outputs.tag }}
SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }}
SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }}
run: |
notes="Maka Desktop and the required Apple Silicon CLI/TUI artifact built from approved ASF source reference ${SOURCE_REFERENCE_TAG} at commit ${SOURCE_COMMIT}.
Before publishing, download these draft assets on an Apple Silicon Mac, an Intel Mac, a Windows x64 machine, and an x64 and an arm64 Linux machine, and complete .github/RELEASE_CHECKLIST.md.
The Windows and Linux builds are unsigned: SmartScreen warns on first launch, no Linux package is signed either, and every download has to be checked against its .sha256 file.
These files are convenience binaries built from the approved ASF source release; they are not ASF release artifacts.
Known limitations: Computer Use and Gitoxide-backed workspace execution are not included in this release. The Desktop convenience artifacts do not distribute a Git runtime."
if gh release view "$TAG" >/dev/null 2>&1; then
is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)"
if [[ "$is_draft" != "true" ]]; then
echo "Release ${TAG} is already published and cannot be replaced." >&2
exit 1
fi
else
gh release create "$TAG" \
--draft \
--verify-tag \
--prerelease=false \
--latest=false \
--title "Maka ${VERSION}" \
--notes "$notes"
fi
if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then
echo "Release ${TAG} was published while recovery was running." >&2
exit 1
fi
gh release edit "$TAG" \
--prerelease=false \
--latest=false \
--title "Maka ${VERSION}" \
--notes "$notes"
actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)"
if [[ "$actual_prerelease" != "false" ]]; then
echo "Release ${TAG} must not be a prerelease." >&2
exit 1
fi
asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')"
declare -A expected_assets=()
declare -A existing_assets=()
mapfile -t local_assets < <(find release-assets -maxdepth 1 -type f -printf '%f\n' | sort)
for asset in "${local_assets[@]}"; do
expected_assets["$asset"]=1
done
existing_dir="$RUNNER_TEMP/existing-release-assets"
mkdir -p "$existing_dir"
if [[ -n "$asset_names" ]]; then
while IFS= read -r asset; do
if [[ -z "${expected_assets[$asset]+present}" ]]; then
echo "Draft ${TAG} contains unexpected asset ${asset}; inspect and remove it manually before retrying." >&2
exit 1
fi
gh release download "$TAG" --pattern "$asset" --dir "$existing_dir"
if ! cmp -s "release-assets/$asset" "$existing_dir/$asset"; then
echo "Draft ${TAG} asset ${asset} differs from the verified artifact." >&2
exit 1
fi
existing_assets["$asset"]=1
done <<< "$asset_names"
fi
missing_assets=()
for asset in "${local_assets[@]}"; do
if [[ -z "${existing_assets[$asset]+present}" ]]; then
missing_assets+=("release-assets/$asset")
fi
done
release_state="$(gh release view "$TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | map(tostring) | join(" ")')"
if [[ "$release_state" != "true false" ]]; then
echo "Release ${TAG} changed state while assets were checked: ${release_state}." >&2
exit 1
fi
if (( ${#missing_assets[@]} > 0 )); then
gh release upload "$TAG" "${missing_assets[@]}"
fi
{
echo "Draft release ${TAG} created from ${SOURCE_COMMIT}."
echo "Release workflow run: ${GITHUB_RUN_ID}, attempt: ${GITHUB_RUN_ATTEMPT}."
} >> "$GITHUB_STEP_SUMMARY"