From b43f58e1bd0898269164e84f9d6a717f68df79ef Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 18 Aug 2026 23:28:04 +0800 Subject: [PATCH 01/31] feat(release): unify Desktop and CLI product releases Make the root product version, one main commit, one tag, and one draft GitHub Release authoritative for Desktop, the standalone macOS arm64 CLI/TUI, and bundled source. Keep the staged OIDC npm workflow as a verified install channel without a competing tag or release. Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 91 +- .github/workflows/ci.yml | 4 + .github/workflows/release-cli-finalize.yml | 175 +--- .github/workflows/release-desktop.yml | 290 ------ .github/workflows/release.yml | 422 +++++++++ docs/cli-distribution.md | 65 ++ docs/cli-npm-release.md | 25 +- docs/cli-npm-release.zh-CN.md | 23 +- package-lock.json | 8 +- package.json | 14 +- packages/cli/package.json | 5 +- packages/cli/src/__tests__/cli.test.ts | 3 +- packages/cli/src/cli-core.ts | 1 - packages/eval/package.json | 15 + scripts/ci-test-plan.mjs | 1 + scripts/package-macos-arm64-cli.mjs | 876 +++++++++++++++++++ scripts/product-release-identity.mjs | 123 +++ scripts/product-release.test.mjs | 220 +++++ scripts/release-cli-package.mjs | 17 +- scripts/release-cli-publication.mjs | 72 +- scripts/release-cli-publication.test.mjs | 74 +- scripts/release-cli-workflow-policy.test.mjs | 29 +- scripts/release-eval-smoke-sitecustomize.py | 190 ++++ scripts/smoke-release-cli-package.mjs | 6 - scripts/verify-macos-arm64-cli.mjs | 712 +++++++++++++++ 25 files changed, 2763 insertions(+), 698 deletions(-) delete mode 100644 .github/workflows/release-desktop.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/cli-distribution.md create mode 100644 scripts/package-macos-arm64-cli.mjs create mode 100644 scripts/product-release-identity.mjs create mode 100644 scripts/product-release.test.mjs create mode 100644 scripts/release-eval-smoke-sitecustomize.py create mode 100644 scripts/verify-macos-arm64-cli.mjs diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 3496be3b9d..75d8d5f0e8 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -1,10 +1,21 @@ -# Desktop release checklist +# Product release checklist -The `Release desktop` workflow is the single release entry point. It packages, verifies, and creates one draft GitHub Release carrying the Apple Silicon macOS and Windows x64 builds; it never publishes the release. The macOS build is signed, notarized, and stapled. The Windows build is unsigned. +The `Release` workflow is Maka's single release entry point. Desktop, CLI/TUI, and source +materials share one source commit, root product version, tag, GitHub Release, Draft decision, +and release gate. The workflow creates no Draft until every required artifact job succeeds. + +Phase 1 requires: + +- signed and notarized Apple Silicon macOS Desktop artifacts; +- the unsigned Windows x64 Desktop installer and ZIP; +- the signed, notarized, relocatable Apple Silicon CLI/TUI ZIP; +- bundled Git source materials; +- checksums generated after each artifact reaches its final form. ## One-time repository setup -Create a GitHub Environment named `release`. Add required reviewers if the repository needs a release approval gate, then configure these environment secrets: +Create a protected GitHub Environment named `release`, require the appropriate reviewers, and +configure: - `CSC_LINK`: base64-encoded Developer ID Application `.p12`; - `CSC_KEY_PASSWORD`: password for that `.p12`; @@ -12,39 +23,63 @@ Create a GitHub Environment named `release`. Add required reviewers if the repos - `APPLE_API_KEY_ID`: App Store Connect API key ID; - `APPLE_API_ISSUER`: App Store Connect API issuer ID. -Windows needs no secrets while the build is unsigned: electron-builder skips signing when no certificate is configured. Adding an Authenticode certificate later means configuring it in `apps/desktop/electron-builder.config.mjs`, and nothing else: electron-builder derives the publisher name that authenticates updates from the certificate itself. +Windows remains unsigned until an Authenticode policy and certificate are added. Release secrets +must never be exposed to fork or ordinary pull-request jobs. -## Create the draft +## Create the complete Draft -1. Confirm the intended commit is on `main`, CI is green, `apps/desktop/package.json` contains a version that has never been released, and the exact `maka-agent` version in `packages/cli/package.json` is public on npm. -2. In GitHub Actions, run `Release desktop` against `main`. -3. Confirm every workflow step passes on both platforms and a draft release named `v` exists. -4. Confirm the draft records the intended commit SHA and contains the macOS DMG, ZIP, `latest-mac.yml`, the Windows `.exe`, ZIP, `latest.yml`, the bundled Git source-materials archive, and matching `.sha256` files. -5. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json` and `README.txt` are present, every manifest archive is present, and the manifest pins the expected Dugite native release. -6. Confirm the packaged applications contain `licenses/git/LICENSE.txt`, `NOTICE.txt`, and `SOURCE_OFFER.txt`. +1. Confirm the intended commit is on `main`, required CI is green, and root `package.json` + contains a product version that has never been released. +2. Confirm `apps/desktop/package.json` and `packages/cli/package.json` exactly match the root + version, and the CLI manifest exposes only the `maka` command. +3. In GitHub Actions, run `Release` against `main`. +4. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, `source`, and + `publish` pass. A skipped or failed required job must prevent Draft creation. +5. Confirm one Draft named `v` targets the intended source SHA and contains at least: + - `Maka--mac-arm64.dmg` and checksum; + - `Maka--win-x64.exe` and checksum; + - `Maka--cli-mac-arm64.zip` and checksum; + - `Maka--bundled-git-source.tar.gz` and checksum; + - the platform update metadata and Desktop ZIPs produced by electron-builder. +6. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `LICENSE`, `NOTICE`, + `THIRD_PARTY_NOTICES.txt`, the pinned Node license, and no `bin/maka-agent`. +7. Confirm `RELEASE.json` records the Draft's product version and source SHA, the official Node + URL/archive/digest, npm version, workspace and production dependency closures, dependency + patches, Mach-O inventory, and `developer-id-notarized` signing state. +8. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, + all manifest archives, and the expected Dugite native release are present. ## Acceptance on another Apple Silicon Mac -Download the DMG and its `.sha256` file through the GitHub UI. This download path applies the real browser quarantine metadata that CI intentionally does not simulate. +Download the DMG, CLI ZIP, and their checksum files through a browser from the Draft. Do not move +artifacts directly from the workflow runner; the browser path supplies the real quarantine +boundary. -1. From the download directory, run `shasum -a 256 -c Maka--mac-arm64.dmg.sha256`. -2. Open the DMG in Finder, drag Maka to Applications, and launch it from Finder. -3. Confirm macOS opens Maka without an unidentified-developer or damaged-app warning. -4. Run `spctl --assess --type execute --verbose=4 /Applications/Maka.app` and confirm it is accepted with a Developer ID origin. -5. Configure a model connection, send one basic prompt, and run one representative file-tool task. -6. Install `ripgrep` with `brew install ripgrep`, then confirm a task using `Grep` works. -7. Confirm the known limitation is accurate: Computer Use is not included. +1. Run `shasum -a 256 -c` for the DMG and CLI ZIP. +2. Install and launch the Desktop app from Finder. Confirm there is no unidentified-developer or + damaged-app warning. +3. Run `spctl --assess --type execute --verbose=4 /Applications/Maka.app` and confirm a Developer + ID origin. +4. Extract the CLI ZIP without clearing quarantine. Run `bin/maka --version` and `bin/maka --help`. +5. Create an external link, for example `ln -s "$PWD/bin/maka" /tmp/maka-release-acceptance`, and + confirm the linked command reports the same version and help output. +6. Start `bin/maka` with no arguments and confirm the TUI renders, accepts input, and exits cleanly. +7. Exercise one non-interactive `bin/maka run`, one deterministic `bin/maka eval run`, and one streaming + tool-call path against the packaged artifact. +8. Configure a Desktop model connection, send one prompt, and run one representative file-tool + task. Confirm the documented Computer Use limitation remains accurate. ## Acceptance on a Windows x64 machine -Download the `.exe` installer and its `.sha256` file through the GitHub UI. The build is unsigned, so this pass is about confirming the expected warnings and that the app still runs. +Download the installer and checksum through a browser from the same Draft. -1. From the download directory, run `Get-FileHash Maka--win-x64.exe -Algorithm SHA256` in PowerShell and confirm the hash matches the `.sha256` file. -2. Run the installer. Confirm SmartScreen shows the expected unrecognized-publisher warning, and that continuing through **More info → Run anyway** completes the install. -3. Launch Maka from the Start menu. -4. Configure a model connection, send one basic prompt, and run one representative file-tool task. -5. Install `ripgrep` with `winget install BurntSushi.ripgrep.MSVC`, restart Maka so the new `PATH` applies, then confirm a task using `Grep` works. -6. Run one terminal task and confirm the shell integration works against the packaged `node-pty`. -7. Confirm the known limitation is accurate: Computer Use is not included. +1. Verify the SHA-256 in PowerShell. +2. Run the installer and confirm the expected unsigned-publisher SmartScreen flow. +3. Launch Maka from the Start menu, configure a model connection, send one prompt, and run one + representative file-tool task. +4. Run one terminal task and confirm packaged `node-pty` behavior. +5. Confirm the documented Computer Use limitation remains accurate. -Publish the draft only after all checks pass on both platforms. If acceptance fails, keep the draft unpublished, fix the issue, increment the desktop version, and run the workflow again; do not replace an existing release identity. +Publish only after both independent-machine acceptance passes. If any required artifact or +acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product +version, and run the full workflow again. Never replace an existing release identity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5a4d79d4..56e73a08ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,10 @@ jobs: if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' run: npm ci + - name: Release contracts + if: steps.plan.outputs.cli_package == 'true' + run: npm run check:release + - name: Lint if: steps.plan.outputs.code == 'true' run: npm run lint diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 825a8fe049..58536225b7 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -1,4 +1,4 @@ -name: Finalize CLI npm release +name: Finalize CLI npm channel on: workflow_dispatch: @@ -12,7 +12,7 @@ on: required: true type: string version: - description: Exact staged maka-agent version + description: Exact staged maka-agent product version required: true type: string @@ -26,25 +26,19 @@ concurrency: jobs: inspect: - name: Verify the public npm release + name: Verify the public npm channel runs-on: ubuntu-24.04 timeout-minutes: 20 - outputs: - dist_tag: ${{ steps.release.outputs.dist_tag }} - git_tag: ${{ steps.release.outputs.git_tag }} - public_release_artifact_id: ${{ steps.public-release.outputs.artifact-id }} - source_sha: ${{ steps.release.outputs.source_sha }} - tarball: ${{ steps.release.outputs.tarball }} - version: ${{ steps.release.outputs.version }} steps: - name: Require main env: RELEASE_REF: ${{ github.ref }} run: | if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + echo "CLI npm finalization must be dispatched from main; found $RELEASE_REF" >&2 exit 1 fi + - name: Load the exact stage workflow run id: stage-run env: @@ -70,25 +64,28 @@ jobs: run.path !== ".github/workflows/release-cli-stage.yml" || run.event !== "workflow_dispatch" || run.head_branch !== "main" || + !/^[0-9a-f]{40}$/.test(run.head_sha) || run.conclusion !== "success" || run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY ) { throw new Error("Stage run is not an exact successful main CLI stage attempt"); } - if (!/^[0-9a-f]{40}$/.test(run.head_sha)) throw new Error("Stage run has no valid source SHA"); - fs.appendFileSync(process.env.GITHUB_OUTPUT, "source_sha=" + run.head_sha + "\n"); ' "$RUNNER_TEMP/stage-run.json" + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Select the release npm toolchain run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Download the exact staged candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -97,21 +94,22 @@ jobs: github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} + - name: Verify the stage run and release record - id: release env: EXPECTED_VERSION: ${{ inputs.version }} run: | node scripts/release-cli-publication.mjs validate-stage-run \ packages/cli/release \ "$RUNNER_TEMP/stage-run.json" \ - "$EXPECTED_VERSION" \ - "$GITHUB_OUTPUT" + "$EXPECTED_VERSION" + - name: Fetch and verify the public registry bytes run: | node scripts/release-cli-publication.mjs fetch-registry \ packages/cli/release \ "$RUNNER_TEMP/registry-release" + - name: Verify npm signatures and provenance run: | node scripts/release-cli-publication.mjs prepare-audit \ @@ -122,151 +120,12 @@ jobs: node "$GITHUB_WORKSPACE/scripts/release-cli-publication.mjs" validate-audit \ "$GITHUB_WORKSPACE/packages/cli/release" \ "$RUNNER_TEMP/signature-audit/audit.json" - - name: Preserve the verified public release - id: public-release + + - name: Preserve the verified public npm package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-public-release-${{ github.run_attempt }} + name: cli-public-package-${{ github.run_attempt }} path: ${{ runner.temp }}/registry-release if-no-files-found: error compression-level: 0 retention-days: 30 - - publish: - name: Create the GitHub CLI release - needs: inspect - runs-on: ubuntu-24.04 - timeout-minutes: 10 - environment: - name: npm-release - url: https://github.com/maka-agent/maka-agent/releases/tag/${{ needs.inspect.outputs.git_tag }} - permissions: - contents: write - steps: - - name: Check out the current release finalizer - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Download the verified public release - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - artifact-ids: ${{ needs.inspect.outputs.public_release_artifact_id }} - path: ${{ runner.temp }}/registry-release - - name: Create the Git tag and GitHub Release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_DIST_TAG: ${{ needs.inspect.outputs.dist_tag }} - RELEASE_DIRECTORY: ${{ runner.temp }}/registry-release - RELEASE_SHA: ${{ needs.inspect.outputs.source_sha }} - RELEASE_TAG: ${{ needs.inspect.outputs.git_tag }} - RELEASE_TARBALL_NAME: ${{ needs.inspect.outputs.tarball }} - RELEASE_VERSION: ${{ needs.inspect.outputs.version }} - run: | - tag_json="$RUNNER_TEMP/release-tag.json" - tag_ref="repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" - if ! gh api "$tag_ref" > "$tag_json" 2>/dev/null; then - if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$RELEASE_TAG" \ - -f sha="$RELEASE_SHA" > "$tag_json"; then - gh api "$tag_ref" > "$tag_json" - fi - fi - TAG_JSON="$tag_json" node -e ' - const fs = require("node:fs"); - const tag = JSON.parse(fs.readFileSync(process.env.TAG_JSON, "utf8")); - if ( - tag.ref !== "refs/tags/" + process.env.RELEASE_TAG || - tag.object?.type !== "commit" || - tag.object.sha !== process.env.RELEASE_SHA - ) { - throw new Error("Git tag does not point to the verified CLI release commit"); - } - ' - - release_flags=(--latest=false) - if [[ "$RELEASE_DIST_TAG" == "next" ]]; then - release_flags+=(--prerelease) - elif [[ "$RELEASE_DIST_TAG" == "latest" ]]; then - release_flags+=(--prerelease=false) - else - echo "Unsupported CLI release dist-tag: $RELEASE_DIST_TAG" >&2 - exit 1 - fi - - release_json="$RUNNER_TEMP/github-release.json" - release_assets=( - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME" - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.sha256" - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.files.json" - "$RELEASE_DIRECTORY/release.json" - ) - - # The REST release-by-tag endpoint does not return drafts. Resolve both draft and - # published releases through gh, then use the immutable API URL for exact reads. - if ! release_api_url="$(gh release view "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json apiUrl \ - --jq .apiUrl 2>/dev/null)"; then - if ! gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md"; then - echo "GitHub Release creation did not confirm success; inspecting remote state" >&2 - fi - release_api_url="$(gh release view "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json apiUrl \ - --jq .apiUrl)" - fi - if [[ ! "$release_api_url" =~ ^https://api\.github\.com/repos/$GITHUB_REPOSITORY/releases/[1-9][0-9]*$ ]]; then - echo "GitHub Release API URL is not bound to the expected repository: $release_api_url" >&2 - exit 1 - fi - gh api "$release_api_url" > "$release_json" - - release_draft="$(RELEASE_JSON="$release_json" node -e ' - const fs = require("node:fs"); - const release = JSON.parse(fs.readFileSync(process.env.RELEASE_JSON, "utf8")); - if (typeof release.draft !== "boolean") throw new Error("GitHub Release draft state is invalid"); - process.stdout.write(String(release.draft)); - ')" - if [[ "$release_draft" == "true" ]]; then - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft=true \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md" - gh release upload "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --clobber \ - "${release_assets[@]}" - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft=false \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md" - fi - - gh api "$release_api_url" > "$release_json" - node scripts/release-cli-publication.mjs validate-github-release \ - "$RELEASE_DIRECTORY" \ - "$release_json" - - latest_json="$RUNNER_TEMP/latest-release.json" - if gh api "repos/$GITHUB_REPOSITORY/releases/latest" > "$latest_json" 2>/dev/null; then - LATEST_JSON="$latest_json" node -e ' - const fs = require("node:fs"); - const latest = JSON.parse(fs.readFileSync(process.env.LATEST_JSON, "utf8")); - if (latest.tag_name === process.env.RELEASE_TAG) { - throw new Error("CLI release must not become the repository GitHub Latest release"); - } - ' - fi diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml deleted file mode 100644 index d0fc2fc248..0000000000 --- a/.github/workflows/release-desktop.yml +++ /dev/null @@ -1,290 +0,0 @@ -name: Release desktop - -on: - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: release-desktop - cancel-in-progress: false - -jobs: - build: - if: github.ref == 'refs/heads/main' - strategy: - # Both platforms ship from one dispatch, so a failure on one still leaves - # the other artifact available for diagnosis. - fail-fast: false - matrix: - include: - - platform: macos - runner: macos-15 - - platform: windows - runner: windows-2025 - runs-on: ${{ matrix.runner }} - environment: release - timeout-minutes: 60 - 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: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Verify Runtime Host setup package - run: | - requested_version="$(node -p "require('./packages/cli/package.json').version")" - setup_package="maka-agent@${requested_version}" - published_version="$(npm view "$setup_package" version)" - if [ "$published_version" != "$requested_version" ]; then - echo "Published package resolved to ${published_version}, expected ${requested_version}." >&2 - exit 1 - fi - echo "MAKA_RUNTIME_HOST_SETUP_PACKAGE=$setup_package" >> "$GITHUB_ENV" - - - name: Audit production dependencies - run: npm audit --omit=dev --audit-level=moderate - - - name: Audit shipped desktop closure - run: node scripts/audit-shipped-dependencies.mjs - - - 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: Resolve and reserve release identity - id: release - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - tag="v${version}" - dmg="apps/desktop/release/Maka-${version}-mac-arm64.dmg" - exe="apps/desktop/release/Maka-${version}-win-x64.exe" - - if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then - echo "Tag ${tag} already exists." >&2 - exit 1 - fi - if gh release view "$tag" >/dev/null 2>&1; then - echo "Release ${tag} already exists." >&2 - exit 1 - fi - - { - echo "version=${version}" - echo "tag=${tag}" - echo "dmg=${dmg}" - echo "exe=${exe}" - } >> "$GITHUB_OUTPUT" - - - 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-arm64 - - - 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 }} - DMG_PATH: ${{ steps.release.outputs.dmg }} - run: | - 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-arm64 -- "${{ steps.release.outputs.dmg }}" - - # 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 -- "${{ steps.release.outputs.exe }}" - - - name: Download and verify the pinned Windows upgrade baseline - id: previous - if: matrix.platform == 'windows' - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \ - "$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.release.outputs.exe }}" \ - "${{ steps.previous.outputs.exe }}" - - - name: Build the version-bumped autoupdate installer - if: matrix.platform == 'windows' - run: npm run package:windows-autoupdate-next - - # The fake-versioned artifacts live outside apps/desktop/release, so the - # upload globs below can never pick them up. - - name: Verify automatic update end to end - if: matrix.platform == 'windows' - run: | - npm run verify:windows-autoupdate -- \ - "${{ steps.release.outputs.exe }}" \ - apps/desktop/release-autoupdate-next - - - name: Upload the verified release assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-${{ matrix.platform }} - path: | - apps/desktop/release/Maka-* - apps/desktop/release/latest*.yml - if-no-files-found: error - retention-days: 7 - - - name: Remove temporary release credentials - if: always() && matrix.platform == 'macos' - run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8" - - source: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: - shell: bash - - steps: - - name: Check out the dispatched commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: npm - - - name: Install pinned dependency metadata - run: npm ci --ignore-scripts - - - name: Materialize bundled Git source materials - run: npm run prepare:bundled-git-source - - - name: Archive and hash source materials - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - archive_dir="apps/desktop/release-sources" - archive_name="Maka-${version}-bundled-git-source.tar.gz" - archive="${archive_dir}/${archive_name}" - tar -C apps/desktop/release-sources/bundled-git -czf "$archive" . - (cd "$archive_dir" && sha256sum "$archive_name" > "${archive_name}.sha256") - - - name: Upload verified source assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-source - path: | - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz.sha256 - if-no-files-found: error - retention-days: 7 - - publish: - # One draft release carries both platforms, so it is created once, after - # every platform has been packaged and verified. - needs: [build, source] - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - shell: bash - - steps: - - name: Check out the dispatched commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Download the verified release assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: release-* - path: release-assets - merge-multiple: true - - - name: Create draft GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - tag="v${version}" - - notes="Apple Silicon macOS and Windows x64 release built from commit ${GITHUB_SHA}. - - Before publishing, download these draft assets on another Apple Silicon Mac and on a Windows x64 machine, and complete .github/RELEASE_CHECKLIST.md. - - The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. - - Bundled Git source materials and their checksum are attached to this draft. The packaged applications also carry the GPLv2 license and written source offer. - - Known limitation: Computer Use is not included in this release." - - gh release create "$tag" release-assets/* \ - --draft \ - --target "$GITHUB_SHA" \ - --title "Maka ${version}" \ - --notes "$notes" - - echo "Draft release ${tag} created from ${GITHUB_SHA}." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..48f3c815bf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,422 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release-identity: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.identity.outputs.version }} + tag: ${{ steps.identity.outputs.tag }} + source_commit: ${{ steps.identity.outputs.source_commit }} + dmg: ${{ steps.identity.outputs.dmg }} + exe: ${{ steps.identity.outputs.exe }} + cli_archive: ${{ steps.identity.outputs.cli_archive }} + cli_checksum: ${{ steps.identity.outputs.cli_checksum }} + 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 + run: node scripts/product-release-identity.mjs + + - name: Assert the tag and release are unused + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.identity.outputs.tag }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists." >&2 + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release ${TAG} already exists." >&2 + exit 1 + fi + + desktop: + needs: release-identity + if: github.ref == 'refs/heads/main' + strategy: + # Both platforms ship from one dispatch, so a failure on one still leaves + # the other artifact available for diagnosis. + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + environment: release + timeout-minutes: 60 + 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: ${{ github.sha }} + 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 production dependencies + run: npm audit --omit=dev --audit-level=moderate + + - name: Audit shipped desktop closure + run: node scripts/audit-shipped-dependencies.mjs + + - 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-arm64 + + - 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 }} + DMG_PATH: apps/desktop/release/${{ needs.release-identity.outputs.dmg }} + run: | + 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-arm64 -- "apps/desktop/release/${{ needs.release-identity.outputs.dmg }}" + + # 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 -- "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" + + - 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 -- \ + "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ + "${{ steps.previous.outputs.exe }}" + + - name: Build the version-bumped autoupdate installer + if: matrix.platform == 'windows' + run: npm run package:windows-autoupdate-next + + # The fake-versioned artifacts live outside apps/desktop/release, so the + # upload globs below can never pick them up. + - name: Verify automatic update end to end + if: matrix.platform == 'windows' + run: | + npm run verify:windows-autoupdate -- \ + "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ + apps/desktop/release-autoupdate-next + + - name: Upload the verified release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.platform }} + path: | + apps/desktop/release/Maka-* + apps/desktop/release/latest*.yml + if-no-files-found: error + retention-days: 7 + + - name: Remove temporary release credentials + if: always() && matrix.platform == 'macos' + run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8" + + cli-macos-arm64: + needs: release-identity + if: github.ref == 'refs/heads/main' + 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: ${{ github.sha }} + 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 production dependencies + run: npm audit --omit=dev --audit-level=moderate + + - name: Build the CLI production workspace closure + run: | + npm --workspace maka-agent run pretest + 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: Upload the verified CLI release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-cli-macos-arm64 + path: | + apps/desktop/release/${{ needs.release-identity.outputs.cli_archive }} + apps/desktop/release/${{ needs.release-identity.outputs.cli_checksum }} + if-no-files-found: error + retention-days: 7 + + - name: Remove temporary release credentials + if: always() + run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8" + + source: + needs: release-identity + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + shell: bash + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + 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 pinned dependency metadata + run: npm ci --ignore-scripts + + - name: Materialize bundled Git source materials + run: npm run prepare:bundled-git-source + + - name: Archive and hash source materials + run: | + version="${{ needs.release-identity.outputs.version }}" + archive_dir="apps/desktop/release-sources" + archive_name="Maka-${version}-bundled-git-source.tar.gz" + archive="${archive_dir}/${archive_name}" + tar -C apps/desktop/release-sources/bundled-git -czf "$archive" . + (cd "$archive_dir" && sha256sum "$archive_name" > "${archive_name}.sha256") + + - name: Upload verified source assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-source + path: | + apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz + apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz.sha256 + if-no-files-found: error + retention-days: 7 + + 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, source] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + shell: bash + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Download the verified release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-* + path: release-assets + merge-multiple: true + + - name: Verify the complete required artifact set + env: + VERSION: ${{ needs.release-identity.outputs.version }} + run: | + required=( + "Maka-${VERSION}-mac-arm64.dmg" + "Maka-${VERSION}-mac-arm64.dmg.sha256" + "Maka-${VERSION}-win-x64.exe" + "Maka-${VERSION}-win-x64.exe.sha256" + "Maka-${VERSION}-cli-mac-arm64.zip" + "Maka-${VERSION}-cli-mac-arm64.zip.sha256" + "Maka-${VERSION}-bundled-git-source.tar.gz" + "Maka-${VERSION}-bundled-git-source.tar.gz.sha256" + ) + for name in "${required[@]}"; do + test -f "release-assets/${name}" + done + while IFS= read -r -d '' checksum; do + (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") + done < <(find release-assets -type f -name '*.sha256' -print0) + + - name: Create 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 }} + run: | + notes="Maka Desktop and the required Apple Silicon CLI/TUI artifact built from commit ${SOURCE_COMMIT}. + + Before publishing, download these draft assets on another Apple Silicon Mac and on a Windows x64 machine, and complete .github/RELEASE_CHECKLIST.md. + + The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. + + Bundled Git source materials and their checksum are attached to this draft. The packaged applications also carry the GPLv2 license and written source offer. + + Known limitation: Computer Use is not included in this release." + + gh release create "$TAG" release-assets/* \ + --draft \ + --target "$SOURCE_COMMIT" \ + --title "Maka ${VERSION}" \ + --notes "$notes" + + echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md new file mode 100644 index 0000000000..fa5f7e9ae9 --- /dev/null +++ b/docs/cli-distribution.md @@ -0,0 +1,65 @@ +# CLI/TUI distribution contract + +Maka ships its CLI/TUI as a required artifact of the same product release as Desktop. Phase 1 +publishes one signed and notarized Apple Silicon artifact: + +`Maka--cli-mac-arm64.zip` + +The ZIP contains an exactly pinned official Node runtime and the production workspace/npm +dependency closure derived from repository manifests and `package-lock.json`. It does not use a +system Node installation or a single-file/SEA build. + +## Public contract + +Only these surfaces are stable: + +- `bin/maka`, including invocation through a symlink outside the extracted archive; +- the documented `RELEASE.json` fields below. + +`libexec/**` is private and may change between releases. There is no public `maka-agent` launcher. +The TUI is the default interactive mode of `maka`, not a separate artifact. + +`RELEASE.json` fields: + +| Field | Meaning | +| --- | --- | +| `schemaVersion` | Metadata schema version, initially `1` | +| `product` | Product name, `Maka` | +| `version` | Root `package.json` product version | +| `sourceCommit` | Exact source commit shared by every release artifact | +| `platform` / `architecture` | Artifact target, `macos` / `arm64` | +| `publicCommands` | Public command list; exactly `["maka"]` in Phase 1 | +| `node` | Official Node version, source URL, archive name, and archive SHA-256 | +| `npmVersion` | Exact npm version used to materialize the production closure | +| `dependencyPatches` | Sorted repository patches applied to the staged dependencies | +| `productionDependencies` | Sorted external `name@version` production closure | +| `thirdPartyNoticesSha256` | Digest binding notices to this artifact | +| `workspacePackages` | Sorted manifest-derived production workspace closure | +| `machOBinaries` | Sorted paths of every Mach-O file that must be signed and verified | +| `signing` | `developer-id-notarized` for release artifacts; `development` for local checks | + +The CLI-specific `THIRD_PARTY_NOTICES.txt` must enumerate exactly the external production +dependencies recorded in `RELEASE.json`. The archive checksum is generated only after signing and +notarization complete. + +## Release and installation boundary + +Root `package.json` is the sole version authority. Desktop and CLI manifests must match before +packaging. Desktop, CLI/TUI, and source jobs build independently from one commit; one publish job +collects their verified outputs and creates one Draft GitHub Release. + +The GitHub Release ZIP is the immutable standalone distribution source. npm keeps its +installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but derives the same +version, runtime closure, file policy, notices, and source identity. It does not create a tag or +GitHub Release and does not block the product Draft. Homebrew must consume the standalone ZIP. + +## Decision ledger + +| Question | Decision | Enforced by | +| --- | --- | --- | +| Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | +| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. | `release.yml` identity and publish jobs | +| Which artifacts are required? | macOS and Windows Desktop installers, the macOS arm64 standalone CLI ZIP, and bundled source. | The publish job's explicit artifact allowlist | +| Is npm another release authority? | No. It is an optional install channel with its own OIDC, staging, verification, and 2FA boundaries. | Read-only npm finalize workflow; no tag or GitHub Release mutation | +| Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | +| Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 37964e6833..daae46b16a 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -2,9 +2,7 @@ [简体中文](./cli-npm-release.zh-CN.md) -This runbook is the operational authority for publishing `maka-agent`. The package version is -independent of the Desktop release line. Every public version must come from the exact tarball -validated by the Stage workflow. +This runbook is the operational authority for publishing the `maka-agent` npm installation channel. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from the exact tarball validated by the Stage workflow. ## Release invariants @@ -12,7 +10,7 @@ validated by the Stage workflow. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. -- Use the Git tag `cli-v`; CLI releases never replace the Desktop GitHub Latest release. +- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package maintainer approves the staged package with npm 2FA. - Do not rebuild between validation, staging, approval, and finalization. @@ -23,9 +21,7 @@ The two workflow boundaries are: 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) builds and validates one immutable tarball, records its source identity, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. -2. [Finalize CLI npm release](../.github/workflows/release-cli-finalize.yml) accepts only the exact - successful Stage run and attempt, verifies the public registry bytes, signature, provenance, and - dist-tag, then creates the exact Git tag and a non-Latest GitHub Release. +2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. ## One-time control-plane configuration @@ -64,8 +60,7 @@ package owner or recovery access as part of that change. ## Prepare a release 1. Merge all intended package, documentation, and release changes to `main`. -2. Set `packages/cli/package.json` to the unused target version and merge that change. The release - tool maps prerelease versions to `next` and stable versions to `latest`. +2. Set the root product version, `apps/desktop/package.json`, and `packages/cli/package.json` to the same unused target version and merge that change. The npm channel maps prerelease versions to `next` and stable versions to `latest`. 3. Confirm the target version is absent from both public and staged package state: ```sh @@ -138,7 +133,7 @@ Do not change `next` when it already points to a newer version such as `0.2.0-be intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, not dist-tag mutations, and the release workflows must not gain a long-lived npm token. -## Finalize the public release +## Finalize the public npm channel After npm reports the version as public: @@ -146,9 +141,7 @@ After npm reports the version as public: 2. Enter the successful Stage run ID, its exact run attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. -4. Review and approve the `npm-release` Environment deployment for the Git tag and GitHub Release. -5. Confirm the workflow created `cli-v` at the Stage source commit. A prerelease must be - marked prerelease; no CLI release may become the repository's GitHub Latest release. +4. Confirm the workflow preserved the verified public package as an Actions artifact and did not create or modify any Git tag or GitHub Release. Check the resulting registry state: @@ -196,11 +189,7 @@ The npm version is already immutable. Do not publish or approve it again. Preser attempt, version, and artifacts. If the package bytes and provenance are valid, fix the current Finalize verifier on `main` and rerun Finalize against that same successful Stage identity. -Finalize is idempotent across partial GitHub Release creation: it resumes an exact draft and accepts -an already-published release only after verifying its metadata and asset digests. If an existing -`cli-v` tag points anywhere other than the recorded Stage source commit, or an existing -published release differs from the verified candidate, stop and investigate. Do not move or delete -it to make the workflow pass. +Finalize is read-only with respect to product release state. If the npm package version, bytes, dist-tag, signature, provenance, or recorded Stage identity differ, stop and investigate; do not modify the product tag or GitHub Release to make npm verification pass. ### The public version is defective diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index b0ba7425f1..c28d520bd4 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -2,15 +2,14 @@ [English](./cli-npm-release.md) -本文档是发布 `maka-agent` 的操作权威。CLI 包版本独立于 Desktop 发布线。每个公开版本都必须 -来自 Stage workflow 验证过的同一个精确 tarball。 +本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自 Stage workflow 验证过的同一个精确 tarball。 ## 发布不变量 - 只从 `main` dispatch 发布 workflow; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; -- Git tag 使用 `cli-v`;CLI release 不得替换 Desktop 的 GitHub Latest release; +- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准 staged package; - validation、staging、approval 和 finalization 之间不得重新构建; @@ -21,9 +20,7 @@ 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 构建并验证一个 immutable tarball,记录其 source identity,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; -2. [Finalize CLI npm release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 - Stage run 和 attempt,验证公共 registry 字节、signature、provenance 和 dist-tag,然后创建 - 精确 Git tag 和非 Latest 的 GitHub Release。 +2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 ## 一次性控制面配置 @@ -62,8 +59,7 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ## 准备发布 1. 将本次包、文档和发布变更全部合并到 `main`; -2. 把 `packages/cli/package.json` 更新为尚未使用的目标版本并合并。release tool 会把 - prerelease 映射到 `next`,stable 映射到 `latest`; +2. 将根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 设置为同一个尚未使用的目标版本并合并。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; 3. 确认目标版本既不在公共 registry,也不在 staged package 中: ```sh @@ -133,7 +129,7 @@ npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.or npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 release workflow 不得获得长期 npm token。 -## Finalize 公共发布 +## Finalize 公共 npm 渠道 npm 显示该版本已经公开后: @@ -141,9 +137,7 @@ npm 显示该版本已经公开后: 2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; -4. 审查并批准用于 Git tag 和 GitHub Release 的 `npm-release` Environment deployment; -5. 确认 workflow 在 Stage source commit 上创建了 `cli-v`。预发布版本必须标记为 - prerelease;任何 CLI release 都不得成为仓库的 GitHub Latest release。 +4. 确认 workflow 将验证后的公开包保存为 Actions artifact,且没有创建或修改任何 Git tag 或 GitHub Release。 检查最终 registry 状态: @@ -188,10 +182,7 @@ npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Sta 和 artifacts。如果 package 字节与 provenance 有效,在 `main` 修复当前 Finalize verifier, 然后针对同一个成功 Stage identity 重新运行 Finalize。 -Finalize 可以幂等地恢复部分完成的 GitHub Release 创建:它会继续处理精确匹配的 -draft,并且只会在验证 metadata 和 asset digest 后接受已经发布的 release。如果已经 -存在的 `cli-v` 指向的不是记录的 Stage source commit,或已经发布的 release -与验证过的 candidate 不一致,立即停止并调查。不要通过移动或删除 tag 让 workflow 通过。 +Finalize 对产品发布状态只读。如果 npm 包版本、字节、dist-tag、签名、provenance 或记录的 Stage identity 不一致,立即停止并调查;不要修改产品 tag 或 GitHub Release 来让 npm 验证通过。 ### 公共版本存在缺陷 diff --git a/package-lock.json b/package-lock.json index 126ccdfd89..eabeb432e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "dugite": "3.2.2", "knip": "^6.26.0", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "engines": { "node": ">=22.19.0" @@ -13682,7 +13683,7 @@ }, "packages/cli": { "name": "maka-agent", - "version": "0.1.0-beta.2", + "version": "0.1.11", "license": "Apache-2.0", "dependencies": { "@earendil-works/pi-tui": "0.83.0", @@ -13694,8 +13695,7 @@ "@maka/storage": "0.1.0" }, "bin": { - "maka": "dist/cli.js", - "maka-agent": "dist/cli.js" + "maka": "dist/cli.js" } }, "packages/computer-use": { diff --git a/package.json b/package.json index 754c1da14f..f3e60025b8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,11 @@ "node": ">=22.19.0" }, "packageManager": "npm@11.19.0", + "releaseToolchain": { + "node": "24.18.1", + "nodeDarwinArm64Archive": "node-v24.18.1-darwin-arm64.tar.xz", + "nodeDarwinArm64Sha256": "1d60b703fe5d7e7072489be8187f430f1a095a658c31e5e1e281331a5873fac3" + }, "type": "module", "workspaces": [ "packages/core", @@ -48,9 +53,13 @@ "release:asf:verify": "node scripts/asf-source-release.mjs verify", "release:asf:sign": "node scripts/asf-source-release.mjs sign", "check:asf-source": "node --test scripts/asf-source-release.test.mjs scripts/asf-source-workflow-policy.test.mjs", + "check:product-release-identity": "node scripts/product-release-identity.mjs", + "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", + "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", + "test:product-release": "node --test scripts/product-release.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/product-release.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", @@ -81,7 +90,8 @@ "dugite": "3.2.2", "knip": "^6.26.0", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "allowScripts": { "esbuild@0.27.7": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index 5c6f097aa5..34333fc396 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,13 +1,12 @@ { "name": "maka-agent", - "version": "0.1.0-beta.2", + "version": "0.1.11", "license": "Apache-2.0", "private": true, "type": "module", "exports": {}, "bin": { - "maka": "./dist/cli.js", - "maka-agent": "./dist/cli.js" + "maka": "./dist/cli.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index b7c5b47426..9d9fc613ab 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -16,7 +16,6 @@ describe('Maka CLI args', () => { ) as Record; assert.deepEqual(manifest.bin, { maka: './dist/cli.js', - 'maka-agent': './dist/cli.js', }); assert.deepEqual(manifest.exports, {}); assert.equal(Object.hasOwn(manifest, 'main'), false); @@ -29,7 +28,7 @@ describe('Maka CLI args', () => { assert.equal(help.kind, 'help'); if (help.kind !== 'help') return; assert.match(help.text, /^ maka Start the TUI$/m); - assert.match(help.text, /^ maka-agent Start the TUI$/m); + assert.doesNotMatch(help.text, /maka-agent/); assert.match(help.text, /^ maka run /m); assert.match(help.text, /^ maka activate /m); assert.match(help.text, /^ maka eval /m); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index a87ee4d57e..812d38c21e 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -94,7 +94,6 @@ function helpText(cliCommand: string): string { '', 'Commands:', ` ${cliCommand} Start the TUI`, - ...(cliCommand === 'maka' ? [' maka-agent Start the TUI'] : []), ` ${cliCommand} run ... Run one non-interactive model turn`, ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, ` ${cliCommand} -p ... Alias for ${cliCommand} run`, diff --git a/packages/eval/package.json b/packages/eval/package.json index 68c4534e79..67e03a827a 100644 --- a/packages/eval/package.json +++ b/packages/eval/package.json @@ -4,6 +4,21 @@ "license": "Apache-2.0", "description": "Minimal experiment semantics for Maka evaluation.", "type": "module", + "releaseFiles": [ + "dist", + "harbor/deepseek-codex-models.json", + "harbor/deepseek-harness-profile/cordis.patch.yml", + "harbor/deepseek-harness-profile/cordis.yml", + "harbor/deepseek-harness-profile/package.json", + "harbor/docker-compose-egress-proxy.yaml", + "harbor/egress-proxy/Dockerfile", + "harbor/egress-proxy/entrypoint.sh", + "harbor/egress-proxy/network-policy", + "harbor/egress_filter.py", + "harbor/eval_framework.py", + "harbor/relay_agent.py", + "harbor/run_trial.py" + ], "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index aede3c79fc..fcb5a22a7f 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -10,6 +10,7 @@ const defaultRepoRoot = dirname(dirname(scriptPath)); const FULL_SUITE_FILES = new Set([ '.github/workflows/ci.yml', + '.github/workflows/release.yml', 'package-lock.json', 'package.json', 'scripts/ci-test-plan.mjs', diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs new file mode 100644 index 0000000000..38917a8a03 --- /dev/null +++ b/scripts/package-macos-arm64-cli.mjs @@ -0,0 +1,876 @@ +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + access, + chmod, + copyFile, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { + releaseToolchainFromManifest, + resolveProductReleaseIdentity, +} from './product-release-identity.mjs'; +import { isThirdPartyDevelopmentArtifact } from './release-cli-file-policy.mjs'; + +export { releaseToolchainFromManifest } from './product-release-identity.mjs'; + +const execFileAsync = promisify(execFile); +const requireFromHere = createRequire(import.meta.url); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const releaseDirectory = join(repoRoot, 'apps', 'desktop', 'release'); +const dependencyPatchesDirectory = join(repoRoot, 'patches'); +const cliPackageName = 'maka-agent'; +const localPackagePrefix = '@maka/'; +const requiredSigningEnvironment = [ + 'CSC_LINK', + 'CSC_KEY_PASSWORD', + 'APPLE_API_KEY', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', +]; + +function runCommand(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + stdio: 'inherit', + }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise(); + return; + } + reject( + new Error( + `${command} ${args.join(' ')} failed with ${ + signal ? `signal ${signal}` : `exit code ${code}` + }`, + ), + ); + }); + }); +} + +function inspectCommand(command, args, options = {}) { + return execFileAsync(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, + timeout: options.timeout ?? 30_000, + }); +} + +export function assertMacosArm64CliHost(platform = process.platform, arch = process.arch) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('CLI release packaging requires an Apple Silicon macOS host.'); + } +} + +export function resolveMacosArm64CliArtifactPaths(version) { + const archiveName = `Maka-${version}-cli-mac-arm64.zip`; + return { + archiveRootName: `Maka-${version}-cli-mac-arm64`, + archivePath: join(releaseDirectory, archiveName), + checksumPath: join(releaseDirectory, `${archiveName}.sha256`), + }; +} + +export function macosArm64CliWrapper() { + return `#!/bin/sh +set -eu +launcher=$0 +while [ -L "$launcher" ]; do + link_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) + link_target=$(readlink "$launcher") + case "$link_target" in + /*) launcher=$link_target ;; + *) launcher=$link_dir/$link_target ;; + esac +done +bin_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) +exec "$bin_dir/../libexec/node/bin/node" "$bin_dir/../libexec/node_modules/maka-agent/dist/cli.js" "$@" +`; +} + +export function macosArm64CliInstallArgs() { + return [ + 'ci', + '--omit=dev', + '--workspace', + cliPackageName, + '--include-workspace-root=false', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ]; +} + +export function standaloneInstallEnvironment(environment) { + return Object.fromEntries( + Object.entries(environment).filter( + ([name]) => name.toLowerCase() !== 'npm_config_allow_scripts', + ), + ); +} + +function manifestFromEntry(entry) { + return entry?.manifest ?? entry; +} + +export function collectWorkspaceDependencyClosure(entryName, manifestsByName) { + const closure = new Set(); + const visiting = new Set(); + + function visit(packageName) { + if (closure.has(packageName)) return; + if (visiting.has(packageName)) { + throw new Error(`Workspace dependency cycle reached ${packageName}.`); + } + const entry = manifestsByName.get(packageName); + if (!entry) { + throw new Error(`Workspace package ${packageName} is missing.`); + } + visiting.add(packageName); + const manifest = manifestFromEntry(entry); + for (const dependencyName of Object.keys(manifest.dependencies ?? {}).sort()) { + if (manifestsByName.has(dependencyName)) { + visit(dependencyName); + } else if (dependencyName.startsWith(localPackagePrefix)) { + throw new Error( + `${packageName} depends on local package ${dependencyName}, but it is not in workspaces.`, + ); + } + } + visiting.delete(packageName); + closure.add(packageName); + } + + visit(entryName); + return [...closure].sort(); +} + +function assertInsideRepo(path) { + const pathFromRepo = relative(repoRoot, path); + if (pathFromRepo === '..' || pathFromRepo.startsWith(`..${sep}`) || isAbsolute(pathFromRepo)) { + throw new Error(`Workspace path escapes the repository: ${path}`); + } +} + +export async function resolveCliWorkspacePackages() { + const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const manifestsByName = new Map(); + for (const workspacePath of rootManifest.workspaces ?? []) { + if (typeof workspacePath !== 'string' || /[*?[\]{}]/.test(workspacePath)) { + throw new Error(`CLI release requires explicit workspace paths, found ${workspacePath}.`); + } + const directory = resolve(repoRoot, workspacePath); + assertInsideRepo(directory); + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (typeof manifest.name !== 'string' || !manifest.name) { + throw new Error(`${workspacePath}/package.json is missing a package name.`); + } + if (manifestsByName.has(manifest.name)) { + throw new Error(`Duplicate workspace package name ${manifest.name}.`); + } + manifestsByName.set(manifest.name, { directory, manifest, workspacePath }); + } + + return collectWorkspaceDependencyClosure(cliPackageName, manifestsByName).map((name) => ({ + name, + ...manifestsByName.get(name), + })); +} + +function isTestArtifactName(name) { + return /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/.test(name); +} + +export async function pruneTestArtifacts(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory() && entry.name === '__tests__') { + await rm(path, { recursive: true, force: true }); + } else if (entry.isDirectory()) { + await pruneTestArtifacts(path); + } else if (entry.isFile() && isTestArtifactName(entry.name)) { + await rm(path, { force: true }); + } + } +} + +export async function pruneThirdPartyDevelopmentArtifacts(directory, root = directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const relativePath = relative(root, path); + if (isThirdPartyDevelopmentArtifact(relativePath)) { + await rm(path, { recursive: entry.isDirectory(), force: true }); + } else if (entry.isDirectory()) { + await pruneThirdPartyDevelopmentArtifacts(path, root); + } + } +} + +export function workspaceReleaseFiles(manifest) { + const declared = Object.hasOwn(manifest, 'releaseFiles') ? manifest.releaseFiles : ['dist']; + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be non-empty.`); + } + const releaseFiles = declared.map((path) => { + if (typeof path !== 'string' || path.length === 0) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be paths.`); + } + if ( + isAbsolute(path) || + path.includes('\\') || + /[*?[\]{}]/u.test(path) || + path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error(`${manifest.name ?? 'Workspace package'} has unsafe release file ${path}.`); + } + return path; + }); + if (new Set(releaseFiles).size !== releaseFiles.length) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles contain duplicates.`); + } + for (const [index, path] of releaseFiles.entries()) { + const overlap = releaseFiles + .slice(index + 1) + .find((candidate) => path.startsWith(`${candidate}/`) || candidate.startsWith(`${path}/`)); + if (overlap) { + throw new Error( + `${manifest.name ?? 'Workspace package'} releaseFiles overlap at ${path} and ${overlap}.`, + ); + } + } + if (!releaseFiles.includes('dist')) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must include dist.`); + } + return releaseFiles; +} + +export function standaloneInstallRootManifest(rootManifest, workspacePackages) { + const { allowScripts: _allowScripts, ...staged } = rootManifest; + return { + ...staged, + workspaces: workspacePackages.map(({ workspacePath }) => workspacePath), + }; +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export async function stageWorkspacePackages(installRoot, workspacePackages) { + const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + await Promise.all([ + writeFile( + join(installRoot, 'package.json'), + `${JSON.stringify(standaloneInstallRootManifest(rootManifest, workspacePackages), null, 2)}\n`, + 'utf8', + ), + copyFile(join(repoRoot, 'package-lock.json'), join(installRoot, 'package-lock.json')), + ...workspacePackages.map(async ({ directory, manifest, workspacePath }) => { + const targetDirectory = join(installRoot, workspacePath); + await mkdir(targetDirectory, { recursive: true }); + await copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')); + await Promise.all( + workspaceReleaseFiles(manifest).map(async (releaseFile) => { + const source = join(directory, ...releaseFile.split('/')); + const target = join(targetDirectory, ...releaseFile.split('/')); + await mkdir(dirname(target), { recursive: true }); + await cp(source, target, { recursive: true }); + }), + ); + await pruneTestArtifacts(join(targetDirectory, 'dist')); + }), + ]); +} + +export async function listDependencyPatchNames() { + let entries; + try { + entries = await readdir(dependencyPatchesDirectory, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.patch')) + .map((entry) => entry.name) + .sort(); +} + +export async function dependencyPatchPackageName(patchName) { + const patch = await readFile(join(dependencyPatchesDirectory, patchName), 'utf8'); + const target = patch.match(/^diff --git a\/node_modules\/((?:@[^/]+\/)?[^/]+)\//m)?.[1]; + if (!target) throw new Error(`Cannot resolve the target package for patch ${patchName}.`); + return target; +} + +export async function listApplicableDependencyPatchNames(nodeModulesDirectory) { + const applicable = []; + for (const patchName of await listDependencyPatchNames()) { + const packageName = await dependencyPatchPackageName(patchName); + try { + await access(packageModulePath(nodeModulesDirectory, packageName)); + applicable.push(patchName); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + return applicable; +} + +export async function applyDependencyPatches( + installRoot, + { env = process.env, run = runCommand, patchPackageEntry } = {}, +) { + const patchNames = await listApplicableDependencyPatchNames(join(installRoot, 'node_modules')); + if (patchNames.length === 0) return patchNames; + + const stagedPatchesDirectory = join(installRoot, 'patches'); + await mkdir(stagedPatchesDirectory, { recursive: true }); + await Promise.all( + patchNames.map((name) => + copyFile(join(dependencyPatchesDirectory, name), join(stagedPatchesDirectory, name)), + ), + ); + + const entry = patchPackageEntry ?? requireFromHere.resolve('patch-package/index.js'); + await run(process.execPath, [entry, '--error-on-fail'], { cwd: installRoot, env }); + return patchNames; +} + +function packageModulePath(nodeModulesDirectory, packageName) { + return join(nodeModulesDirectory, ...packageName.split('/')); +} + +export async function assertNoDanglingSymlinks(directory, rootDirectory = directory) { + const resolvedRoot = await realpath(rootDirectory); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + try { + const target = await realpath(path); + const targetFromRoot = relative(resolvedRoot, target); + if ( + targetFromRoot === '..' || + targetFromRoot.startsWith(`..${sep}`) || + isAbsolute(targetFromRoot) + ) { + throw new Error(`Symlink escapes the CLI artifact: ${path}`); + } + } catch (error) { + if (error?.code === 'ENOENT') throw new Error(`Dangling symlink in CLI artifact: ${path}`); + throw error; + } + } else if (entry.isDirectory()) { + await assertNoDanglingSymlinks(path, rootDirectory); + } + } +} + +async function assertWorkspaceLinks(archiveRoot, workspacePackages) { + const nodeModulesDirectory = join(archiveRoot, 'libexec', 'node_modules'); + for (const { name, workspacePath } of workspacePackages) { + const linkTarget = await realpath(packageModulePath(nodeModulesDirectory, name)); + const packageTarget = await realpath(join(archiveRoot, 'libexec', workspacePath)); + if (linkTarget !== packageTarget) { + throw new Error(`${name} does not resolve to its staged workspace package.`); + } + } + await assertNoDanglingSymlinks(join(archiveRoot, 'libexec')); +} + +function parseLinkedLibraries(output) { + return output + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)[0]) + .filter(Boolean); +} + +export function assertOfficialNodeRuntime({ + actualVersion, + expectedVersion, + architectures, + signature, + linkedLibraries, +}) { + if (actualVersion !== expectedVersion) { + throw new Error(`CLI release requires Node ${expectedVersion}, found ${actualVersion}.`); + } + const architectureList = architectures.trim().split(/\s+/).filter(Boolean); + if (architectureList.length !== 1 || architectureList[0] !== 'arm64') { + throw new Error( + `CLI Node runtime must contain only arm64, found ${architectureList.join(', ')}.`, + ); + } + if (!signature.includes('Authority=Developer ID Application: Node.js Foundation (HX7739G8FX)')) { + throw new Error('CLI release requires the official Node.js Foundation runtime.'); + } + if (!signature.includes('flags=0x10000(runtime)')) { + throw new Error('CLI Node runtime must use the hardened runtime signature.'); + } + const nonSystemLibraries = linkedLibraries.filter( + (path) => !path.startsWith('/usr/lib/') && !path.startsWith('/System/Library/'), + ); + if (nonSystemLibraries.length > 0) { + throw new Error( + `CLI Node runtime is not self-contained; non-system libraries: ${nonSystemLibraries.join(', ')}`, + ); + } +} + +async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { + const [nodeVersion, npmVersion, architectures, signature, dependencies] = await Promise.all([ + inspect(execPath, ['-p', 'process.versions.node'], { env }), + inspect('npm', ['--version'], { env }), + inspect('lipo', ['-archs', execPath], { env }), + inspect('codesign', ['-d', '--verbose=4', execPath], { env }), + inspect('otool', ['-L', execPath], { env }), + ]); + assertOfficialNodeRuntime({ + actualVersion: nodeVersion.stdout.trim(), + expectedVersion: toolchain.nodeVersion, + architectures: architectures.stdout, + signature: `${signature.stdout}\n${signature.stderr}`, + linkedLibraries: parseLinkedLibraries(dependencies.stdout), + }); + if (npmVersion.stdout.trim() !== toolchain.npmVersion) { + throw new Error( + `CLI release requires npm ${toolchain.npmVersion}, found ${npmVersion.stdout.trim()}.`, + ); + } +} + +export async function assertOfficialNodeArchive( + archivePath, + toolchain, + { hashFile = sha256File } = {}, +) { + if (basename(archivePath) !== toolchain.nodeArchive) { + throw new Error(`CLI Node archive must be named ${toolchain.nodeArchive}.`); + } + const actualSha256 = await hashFile(archivePath); + if (actualSha256 !== toolchain.nodeArchiveSha256) { + throw new Error( + `CLI Node archive digest mismatch: expected ${toolchain.nodeArchiveSha256}, found ${actualSha256}.`, + ); + } + return actualSha256; +} + +async function extractOfficialNodeRuntime(stagingRoot, archivePath, toolchain, { env, run }) { + await access(archivePath); + await assertOfficialNodeArchive(archivePath, toolchain); + const extractionRoot = join(stagingRoot, 'official-node'); + await mkdir(extractionRoot, { recursive: true }); + await run('tar', ['-xJf', archivePath, '-C', extractionRoot], { env }); + const distributionRoot = join(extractionRoot, `node-v${toolchain.nodeVersion}-darwin-arm64`); + const execPath = join(distributionRoot, 'bin', 'node'); + const licensePath = join(distributionRoot, 'LICENSE'); + await Promise.all([access(execPath), access(licensePath)]); + return { execPath, licensePath }; +} + +export async function collectPackagedProductionDependencies( + nodeModulesDirectory, + workspaceNames = new Set(), +) { + const dependencies = new Set(); + + async function visitNodeModules(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + const packageDirectories = []; + for (const entry of entries) { + if (entry.name === '.bin') continue; + const path = join(directory, entry.name); + if (entry.name.startsWith('@') && entry.isDirectory()) { + for (const scopedEntry of await readdir(path, { withFileTypes: true })) { + packageDirectories.push(join(path, scopedEntry.name)); + } + } else if (entry.isDirectory() || entry.isSymbolicLink()) { + packageDirectories.push(path); + } + } + for (const packageDirectory of packageDirectories) { + let manifest; + try { + manifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') continue; + throw error; + } + if ( + !workspaceNames.has(manifest.name) && + typeof manifest.name === 'string' && + typeof manifest.version === 'string' + ) { + dependencies.add(`${manifest.name}@${manifest.version}`); + } + await visitNodeModules(join(packageDirectory, 'node_modules')); + } + } + + await visitNodeModules(nodeModulesDirectory); + return [...dependencies].sort(); +} + +async function findFiles(directory, predicate) { + const matches = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) matches.push(...(await findFiles(path, predicate))); + else if (entry.isFile() && predicate(path)) matches.push(path); + } + return matches; +} + +export async function inspectNativeArtifacts( + directory, + { inspect = inspectCommand, concurrency = 16 } = {}, +) { + const files = await findFiles(directory, () => true); + const foreignBinaries = []; + const machOBinaries = []; + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(Math.max(1, concurrency), files.length) }, + async () => { + while (nextIndex < files.length) { + const path = files[nextIndex++]; + const result = await inspect('file', ['-b', path]); + if (/\bMach-O\b/.test(result.stdout)) machOBinaries.push(path); + else if (/^(?:ELF\b|PE32\b|MS-DOS executable\b)/.test(result.stdout)) { + foreignBinaries.push(path); + } + } + }, + ); + await Promise.all(workers); + return { foreignBinaries: foreignBinaries.sort(), machOBinaries: machOBinaries.sort() }; +} + +export async function findMachOBinaries(directory, options = {}) { + return (await inspectNativeArtifacts(directory, options)).machOBinaries; +} + +export function isMacosArm64MachO(architectures, buildVersion) { + return architectures.trim() === 'arm64' && /^\s*platform MACOS\s*$/m.test(buildVersion); +} + +async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect }) { + const { foreignBinaries, machOBinaries } = await inspectNativeArtifacts(nodeModulesDirectory, { + inspect, + }); + await Promise.all(foreignBinaries.map((path) => rm(path, { force: true }))); + for (const binaryPath of machOBinaries) { + const [architectures, buildVersion] = await Promise.all([ + inspect('lipo', ['-archs', binaryPath]), + inspect('xcrun', ['vtool', '-show-build', binaryPath]), + ]); + if (!isMacosArm64MachO(architectures.stdout, buildVersion.stdout)) { + await rm(binaryPath, { force: true }); + } + } +} + +export function assertReleaseSigningEnvironment(env) { + for (const name of requiredSigningEnvironment) { + if (!env[name]?.trim()) throw new Error(`CLI release signing requires ${name}.`); + } +} + +export function assertAcceptedNotarization(output) { + let result; + try { + result = JSON.parse(output); + } catch { + throw new Error('notarytool did not return valid JSON.'); + } + if (result.status !== 'Accepted') { + throw new Error(`CLI notarization failed with status ${result.status ?? 'unknown'}.`); + } +} + +async function signCliBinaries(machOBinaries, { env, run }) { + assertReleaseSigningEnvironment(env); + const { createKeychain, findIdentity, removeKeychain } = requireFromHere( + 'app-builder-lib/out/codeSign/macCodeSign', + ); + const { TmpDir } = requireFromHere('temp-file'); + const temporaryFiles = new TmpDir('maka-cli-signing'); + let keychainFile; + try { + ({ keychainFile } = await createKeychain({ + tmpDir: temporaryFiles, + cscLink: env.CSC_LINK, + cscKeyPassword: env.CSC_KEY_PASSWORD, + currentDir: repoRoot, + })); + const identity = await findIdentity('Developer ID Application', null, keychainFile); + if (!identity) throw new Error('Could not resolve a Developer ID Application identity.'); + if (!identity.hash) throw new Error('Developer ID Application identity is missing its hash.'); + + for (const binaryPath of machOBinaries) { + await run( + 'codesign', + [ + '--force', + '--options', + 'runtime', + '--timestamp', + '--sign', + identity.hash, + '--keychain', + keychainFile, + binaryPath, + ], + { env }, + ); + await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); + } + return { identityName: identity.name, machOBinaryCount: machOBinaries.length }; + } finally { + if (keychainFile) await removeKeychain(keychainFile, false); + await temporaryFiles.cleanup(); + } +} + +async function createCliZip(archiveRoot, archivePath, { env, run }) { + await rm(archivePath, { force: true }); + await run( + 'ditto', + [ + '-c', + '-k', + '--keepParent', + '--norsrc', + '--noextattr', + '--noqtn', + '--noacl', + archiveRoot, + archivePath, + ], + { env }, + ); +} + +async function notarizeCliZip(archivePath, { env, inspect }) { + const result = await inspect( + 'xcrun', + [ + 'notarytool', + 'submit', + archivePath, + '--key', + env.APPLE_API_KEY, + '--key-id', + env.APPLE_API_KEY_ID, + '--issuer', + env.APPLE_API_ISSUER, + '--wait', + '--output-format', + 'json', + ], + { env, timeout: 20 * 60_000 }, + ); + assertAcceptedNotarization(result.stdout); +} + +export async function packageMacosArm64Cli({ + platform = process.platform, + arch = process.arch, + env = process.env, + run = runCommand, + inspect = inspectCommand, + releaseSigning = env.MAKA_CLI_RELEASE_SIGNING === '1', + nodeArchivePath = env.MAKA_CLI_NODE_ARCHIVE, +} = {}) { + assertMacosArm64CliHost(platform, arch); + + const [rootManifest, desktopManifest, cliManifest, workspacePackages, sourceCommitResult] = + await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8').then(JSON.parse), + resolveCliWorkspacePackages(), + inspect('git', ['rev-parse', 'HEAD']), + ]); + const sourceCommit = sourceCommitResult.stdout.trim(); + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + ref: 'refs/heads/main', + sha: sourceCommit, + }); + const toolchain = releaseToolchainFromManifest(rootManifest); + if (releaseSigning) assertReleaseSigningEnvironment(env); + if (!nodeArchivePath) { + throw new Error(`Set MAKA_CLI_NODE_ARCHIVE to the verified ${toolchain.nodeArchive} path.`); + } + + const version = identity.version; + await Promise.all([ + access(join(repoRoot, 'LICENSE')), + access(join(repoRoot, 'NOTICE')), + ...workspacePackages.map(({ directory }) => access(join(directory, 'dist'))), + ]); + + const { archiveRootName, archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version); + await mkdir(releaseDirectory, { recursive: true }); + const stagingRoot = await mkdtemp(join(tmpdir(), 'maka-cli-')); + let complete = false; + + try { + const officialNode = await extractOfficialNodeRuntime( + stagingRoot, + resolve(nodeArchivePath), + toolchain, + { env, run }, + ); + await inspectReleaseToolchain({ execPath: officialNode.execPath, env, inspect, toolchain }); + + const installRoot = join(stagingRoot, 'install'); + await mkdir(installRoot, { recursive: true }); + await stageWorkspacePackages(installRoot, workspacePackages); + await run('npm', macosArm64CliInstallArgs(), { + cwd: installRoot, + env: standaloneInstallEnvironment(env), + }); + const dependencyPatches = await applyDependencyPatches(installRoot, { env, run }); + const productionDependencies = await collectPackagedProductionDependencies( + join(installRoot, 'node_modules'), + new Set(workspacePackages.map(({ name }) => name)), + ); + + const nodeModulesDirectory = join(installRoot, 'node_modules'); + await pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect }); + await pruneThirdPartyDevelopmentArtifacts(nodeModulesDirectory); + + const archiveRoot = join(stagingRoot, archiveRootName); + const binDirectory = join(archiveRoot, 'bin'); + const embeddedNodeDirectory = join(archiveRoot, 'libexec', 'node'); + await Promise.all([ + mkdir(binDirectory, { recursive: true }), + mkdir(join(embeddedNodeDirectory, 'bin'), { recursive: true }), + ]); + await rename(nodeModulesDirectory, join(archiveRoot, 'libexec', 'node_modules')); + for (const { workspacePath } of workspacePackages) { + const source = join(installRoot, workspacePath); + const target = join(archiveRoot, 'libexec', workspacePath); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target); + } + await Promise.all([ + copyFile(officialNode.execPath, join(embeddedNodeDirectory, 'bin', 'node')), + copyFile(officialNode.licensePath, join(embeddedNodeDirectory, 'LICENSE')), + copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')), + copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')), + writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), 'utf8'), + writeFile( + join(archiveRoot, 'README.txt'), + [ + `Maka CLI/TUI ${version} for Apple Silicon macOS`, + '', + "Add this directory's bin folder to PATH, then run:", + ' maka --help', + '', + 'The archive includes its own Node.js runtime and does not require the Maka desktop app.', + '', + ].join('\n'), + 'utf8', + ), + ]); + await Promise.all([ + chmod(join(embeddedNodeDirectory, 'bin', 'node'), 0o755), + chmod(join(binDirectory, 'maka'), 0o755), + ]); + await assertWorkspaceLinks(archiveRoot, workspacePackages); + + const thirdPartyNoticesPath = join(archiveRoot, 'THIRD_PARTY_NOTICES.txt'); + await copyFile( + join(repoRoot, 'packages', 'cli', 'THIRD_PARTY_NOTICES.txt'), + thirdPartyNoticesPath, + ); + const thirdPartyNoticesSha256 = await sha256File(thirdPartyNoticesPath); + + const machOBinaries = await findMachOBinaries(archiveRoot, { inspect }); + if (machOBinaries.length === 0) throw new Error('CLI artifact contains no Mach-O binaries.'); + const releaseMetadata = { + schemaVersion: 1, + product: 'Maka', + version, + sourceCommit, + platform: 'macos', + architecture: 'arm64', + publicCommands: ['maka'], + node: { + version: toolchain.nodeVersion, + sourceUrl: toolchain.nodeSourceUrl, + archive: toolchain.nodeArchive, + archiveSha256: toolchain.nodeArchiveSha256, + }, + npmVersion: toolchain.npmVersion, + dependencyPatches, + productionDependencies, + thirdPartyNoticesSha256, + workspacePackages: workspacePackages.map(({ name }) => name).sort(), + machOBinaries: machOBinaries.map((path) => relative(archiveRoot, path)).sort(), + signing: releaseSigning ? 'developer-id-notarized' : 'development', + }; + await writeFile( + join(archiveRoot, 'RELEASE.json'), + `${JSON.stringify(releaseMetadata, null, 2)}\n`, + 'utf8', + ); + + let signing; + if (releaseSigning) signing = await signCliBinaries(machOBinaries, { env, run }); + await createCliZip(archiveRoot, archivePath, { env, run }); + if (releaseSigning) await notarizeCliZip(archivePath, { env, inspect }); + + const sha256 = await sha256File(archivePath); + await writeFile(checksumPath, `${sha256} ${basename(archivePath)}\n`, 'utf8'); + complete = true; + return { archivePath, checksumPath, dependencyPatches, sha256, signing, version }; + } finally { + await rm(stagingRoot, { recursive: true, force: true }); + if (!complete) { + const { archivePath, checksumPath } = resolveMacosArm64CliArtifactPaths(version); + await Promise.all([rm(archivePath, { force: true }), rm(checksumPath, { force: true })]); + } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await packageMacosArm64Cli(); + console.log(`Created ${result.archivePath}`); + console.log(`SHA-256 ${result.sha256}`); +} diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs new file mode 100644 index 0000000000..815ad06103 --- /dev/null +++ b/scripts/product-release-identity.mjs @@ -0,0 +1,123 @@ +import { appendFile, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function exactVersion(value, label) { + if (typeof value !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value)) { + throw new Error(`${label} must be an exact product version`); + } + return value; +} + +export function releaseToolchainFromManifest(rootManifest) { + const nodeVersion = rootManifest.releaseToolchain?.node; + const nodeArchive = rootManifest.releaseToolchain?.nodeDarwinArm64Archive; + const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; + const npmMatch = /^npm@(\d+\.\d+\.\d+)$/u.exec(rootManifest.packageManager ?? ''); + if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { + throw new Error('package.json must define an exact releaseToolchain.node version'); + } + const expectedArchive = `node-v${nodeVersion}-darwin-arm64.tar.xz`; + if (nodeArchive !== expectedArchive) { + throw new Error(`releaseToolchain.nodeDarwinArm64Archive must be ${expectedArchive}`); + } + if (typeof nodeArchiveSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(nodeArchiveSha256)) { + throw new Error('releaseToolchain.nodeDarwinArm64Sha256 must be an exact SHA-256 digest'); + } + if (!npmMatch) throw new Error('package.json packageManager must pin an exact npm version'); + return { + nodeVersion, + nodeArchive, + nodeArchiveSha256, + nodeSourceUrl: `https://nodejs.org/download/release/v${nodeVersion}/${nodeArchive}`, + npmVersion: npmMatch[1], + }; +} + +export function resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + ref, + sha, +}) { + const version = exactVersion(rootManifest.version, 'Root package version'); + for (const [label, manifest] of [ + ['Desktop', desktopManifest], + ['CLI', cliManifest], + ]) { + if (manifest.version !== version) { + throw new Error( + `${label} version ${manifest.version ?? 'missing'} does not match root ${version}`, + ); + } + } + if (JSON.stringify(cliManifest.bin) !== JSON.stringify({ maka: './dist/cli.js' })) { + throw new Error('The only public CLI command must be maka'); + } + if (ref !== 'refs/heads/main') { + throw new Error(`Product releases require refs/heads/main, found ${ref ?? 'missing'}`); + } + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { + throw new Error('Product releases require an exact 40-character source commit SHA'); + } + + const toolchain = releaseToolchainFromManifest(rootManifest); + const cliArchive = `Maka-${version}-cli-mac-arm64.zip`; + + return { + ...toolchain, + version, + tag: `v${version}`, + sourceCommit: sha, + dmg: `Maka-${version}-mac-arm64.dmg`, + exe: `Maka-${version}-win-x64.exe`, + cliArchive, + cliChecksum: `${cliArchive}.sha256`, + sourceArchive: `Maka-${version}-bundled-git-source.tar.gz`, + publicCommands: ['maka'], + }; +} + +export async function readProductReleaseIdentity({ + ref = process.env.GITHUB_REF, + sha = process.env.GITHUB_SHA, +} = {}) { + const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), + ]); + return resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, ref, sha }); +} + +function githubOutputEntries(identity) { + return { + version: identity.version, + tag: identity.tag, + source_commit: identity.sourceCommit, + dmg: identity.dmg, + exe: identity.exe, + cli_archive: identity.cliArchive, + cli_checksum: identity.cliChecksum, + source_archive: identity.sourceArchive, + node_version: identity.nodeVersion, + node_archive: identity.nodeArchive, + node_archive_sha256: identity.nodeArchiveSha256, + node_source_url: identity.nodeSourceUrl, + npm_version: identity.npmVersion, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const identity = await readProductReleaseIdentity(); + if (process.env.GITHUB_OUTPUT) { + const output = Object.entries(githubOutputEntries(identity)) + .map(([name, value]) => `${name}=${value}`) + .join('\n'); + await appendFile(process.env.GITHUB_OUTPUT, `${output}\n`, 'utf8'); + } + console.log(`Product release ${identity.tag} from ${identity.sourceCommit}`); +} diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs new file mode 100644 index 0000000000..99ade18a5c --- /dev/null +++ b/scripts/product-release.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { parse as parseYaml } from 'yaml'; +import { parseCliReleaseVersion } from './release-cli-publication.mjs'; +import { planTests } from './ci-test-plan.mjs'; +import { + readProductReleaseIdentity, + releaseToolchainFromManifest, + resolveProductReleaseIdentity, +} from './product-release-identity.mjs'; +import { + macosArm64CliWrapper, + pruneThirdPartyDevelopmentArtifacts, + resolveCliWorkspacePackages, + resolveMacosArm64CliArtifactPaths, + standaloneInstallEnvironment, + standaloneInstallRootManifest, + workspaceReleaseFiles, +} from './package-macos-arm64-cli.mjs'; +import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; + +const rootManifest = { + version: '1.2.3', + packageManager: 'npm@11.19.0', + releaseToolchain: { + node: '24.18.1', + nodeDarwinArm64Archive: 'node-v24.18.1-darwin-arm64.tar.xz', + nodeDarwinArm64Sha256: '1'.repeat(64), + }, +}; + +test('one root version defines every product artifact from one main commit', () => { + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + ref: 'refs/heads/main', + sha: 'a'.repeat(40), + }); + + assert.equal(identity.version, '1.2.3'); + assert.equal(identity.tag, 'v1.2.3'); + assert.equal(identity.sourceCommit, 'a'.repeat(40)); + assert.equal(identity.dmg, 'Maka-1.2.3-mac-arm64.dmg'); + assert.equal(identity.exe, 'Maka-1.2.3-win-x64.exe'); + assert.equal(identity.cliArchive, 'Maka-1.2.3-cli-mac-arm64.zip'); + assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); +}); + +test('the root manifest pins the Node archive and npm used by release jobs', () => { + assert.deepEqual(releaseToolchainFromManifest(rootManifest), { + nodeVersion: '24.18.1', + nodeArchive: 'node-v24.18.1-darwin-arm64.tar.xz', + nodeArchiveSha256: '1'.repeat(64), + nodeSourceUrl: 'https://nodejs.org/download/release/v24.18.1/node-v24.18.1-darwin-arm64.tar.xz', + npmVersion: '11.19.0', + }); +}); + +test('the checked-in manifests resolve to one releasable product identity', async () => { + const identity = await readProductReleaseIdentity({ + ref: 'refs/heads/main', + sha: 'b'.repeat(40), + }); + + assert.equal(identity.version, '0.1.11'); + assert.equal(identity.npmVersion, '11.19.0'); + assert.deepEqual(identity.publicCommands, ['maka']); +}); + +test('standalone verification recognizes the current TUI status line through ANSI output', () => { + assert.equal( + isTuiReadyOutput( + '\u001b[1mMaka\u001b[22m\u001b[2m · \u001b[22m\u001b[2mAuto\u001b[22m\u001b[2m · model · provider\u001b[0m', + ), + true, + ); +}); + +test('the standalone maka launcher is relocatable and uses the embedded runtime', () => { + const paths = resolveMacosArm64CliArtifactPaths('1.2.3'); + assert.equal(paths.archiveRootName, 'Maka-1.2.3-cli-mac-arm64'); + assert.equal(paths.archivePath.endsWith('Maka-1.2.3-cli-mac-arm64.zip'), true); + + const wrapper = macosArm64CliWrapper(); + assert.match(wrapper, /while \[ -L "\$launcher" \]/u); + assert.match(wrapper, /libexec\/node\/bin\/node/u); + assert.match(wrapper, /libexec\/node_modules\/maka-agent\/dist\/cli\.js/u); + assert.doesNotMatch(wrapper, /maka-agent.*launcher/u); +}); + +test('the Eval workspace owns the complete runtime asset declaration', async () => { + const workspaces = await resolveCliWorkspacePackages(); + const evalWorkspace = workspaces.find(({ name }) => name === '@maka/eval'); + assert.ok(evalWorkspace); + assert.deepEqual(workspaceReleaseFiles(evalWorkspace.manifest), [ + 'dist', + 'harbor/deepseek-codex-models.json', + 'harbor/deepseek-harness-profile/cordis.patch.yml', + 'harbor/deepseek-harness-profile/cordis.yml', + 'harbor/deepseek-harness-profile/package.json', + 'harbor/docker-compose-egress-proxy.yaml', + 'harbor/egress-proxy/Dockerfile', + 'harbor/egress-proxy/entrypoint.sh', + 'harbor/egress-proxy/network-policy', + 'harbor/egress_filter.py', + 'harbor/eval_framework.py', + 'harbor/relay_agent.py', + 'harbor/run_trial.py', + ]); +}); + +test('standalone packaging applies the shared CLI file policy to dependencies', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-standalone-policy-')); + try { + await mkdir(join(root, 'test'), { recursive: true }); + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'test/fixture.js'), 'development'); + await writeFile(join(root, 'src/index.js'), 'runtime'); + await writeFile(join(root, 'src/index.ts'), 'development'); + + await pruneThirdPartyDevelopmentArtifacts(root); + + assert.equal(await readFile(join(root, 'src/index.js'), 'utf8'), 'runtime'); + await assert.rejects(readFile(join(root, 'test/fixture.js')), { code: 'ENOENT' }); + await assert.rejects(readFile(join(root, 'src/index.ts')), { code: 'ENOENT' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('standalone dependency installation removes host script policy and unrelated workspaces', () => { + const staged = standaloneInstallRootManifest( + { + private: true, + workspaces: ['packages/cli', 'apps/desktop'], + allowScripts: { electron: true }, + overrides: { dependency: '1.0.0' }, + }, + [{ workspacePath: 'packages/cli' }], + ); + assert.deepEqual(staged.workspaces, ['packages/cli']); + assert.equal(Object.hasOwn(staged, 'allowScripts'), false); + assert.deepEqual(staged.overrides, { dependency: '1.0.0' }); +}); + +test('standalone dependency installation ignores caller-specific npm script policy', () => { + const environment = standaloneInstallEnvironment({ + PATH: '/usr/bin', + npm_config_allow_scripts: '@opencode-ai/cli', + }); + assert.deepEqual(environment, { PATH: '/usr/bin' }); +}); + +test('one product workflow gates one draft release on every required artifact', async () => { + const source = await readFile( + new URL('../.github/workflows/release.yml', import.meta.url), + 'utf8', + ); + const workflow = parseYaml(source); + const jobs = workflow.jobs; + + assert.deepEqual(jobs.publish.needs, [ + 'release-identity', + 'desktop', + 'cli-macos-arm64', + 'source', + ]); + assert.equal(jobs.publish.if, "github.ref == 'refs/heads/main'"); + assert.equal(Object.hasOwn(jobs, 'npm'), false); + + const commands = Object.values(jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.run) + .filter((run) => typeof run === 'string') + .join('\n'); + assert.equal((commands.match(/gh release create/gu) ?? []).length, 1); + assert.equal(jobs.desktop['timeout-minutes'], 60); + assert.match(commands, /npm run package:windows-autoupdate-next/u); + assert.match(commands, /npm run verify:windows-autoupdate/u); + assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); + await assert.rejects( + readFile(new URL('../.github/workflows/release-desktop.yml', import.meta.url)), + { code: 'ENOENT' }, + ); +}); + +test('npm finalization verifies the channel without creating a tag or GitHub Release', async () => { + const source = await readFile( + new URL('../.github/workflows/release-cli-finalize.yml', import.meta.url), + 'utf8', + ); + const workflow = parseYaml(source); + + assert.deepEqual(Object.keys(workflow.jobs), ['inspect']); + assert.equal(workflow.permissions.contents, 'read'); + assert.doesNotMatch(source, /cli-v|gh release|git\/refs|contents: write/u); +}); + +test('npm channel identity has no independent product tag', () => { + assert.deepEqual(parseCliReleaseVersion('1.2.3'), { + version: '1.2.3', + distTag: 'latest', + tarball: 'maka-agent-1.2.3.tgz', + }); +}); + +test('product workflow changes select the release contracts in CI', () => { + const graph = { + dirs: [], + testDirs: new Set(), + dependents: new Map(), + }; + const plan = planTests(['.github/workflows/release.yml'], { graph }); + assert.equal(plan.code, true); + assert.equal(plan.full, true); +}); diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 6df3042a66..ca13052376 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -54,20 +54,9 @@ const buildOrder = [ '@maka/eval', 'maka-agent', ]; -const evalAssets = [ - 'harbor/deepseek-codex-models.json', - 'harbor/deepseek-harness-profile/cordis.patch.yml', - 'harbor/deepseek-harness-profile/cordis.yml', - 'harbor/deepseek-harness-profile/package.json', - 'harbor/docker-compose-egress-proxy.yaml', - 'harbor/egress-proxy/Dockerfile', - 'harbor/egress-proxy/entrypoint.sh', - 'harbor/egress-proxy/network-policy', - 'harbor/egress_filter.py', - 'harbor/eval_framework.py', - 'harbor/relay_agent.py', - 'harbor/run_trial.py', -]; +const evalAssets = readJson(join(repoRoot, 'packages/eval/package.json')).releaseFiles.filter( + (path) => path !== 'dist', +); const strippedInstallScripts = new Map([ // The clean repository install has already produced every generated file and // platform prebuild copied below. Do not run advisory postinstalls on an end diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index e1f9c4f694..ed1e321a0b 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -13,7 +13,6 @@ const RELEASE_RECORD_KEYS = [ 'packageName', 'version', 'distTag', - 'gitTag', 'tarball', 'sha256', 'checksum', @@ -26,7 +25,6 @@ export function parseCliReleaseVersion(version) { return { version, distTag: prerelease.length > 0 ? 'next' : 'latest', - gitTag: `cli-v${version}`, tarball: `${PACKAGE_NAME}-${version}.tgz`, }; } @@ -169,10 +167,6 @@ export async function fetchRegistryRelease({ for (const name of [record.checksum, record.inventory, 'release.json']) { copyFileSync(join(releaseDirectory, name), join(registryDirectory, name)); } - writeFileSync(join(registryDirectory, 'release-notes.md'), releaseNotes(record), { - flag: 'wx', - mode: 0o644, - }); return { ...record, tarballPath, sha256 }; } @@ -196,37 +190,6 @@ export function validateSignatureAudit({ releaseDirectory, audit }) { return record; } -export function validateGitHubRelease({ releaseDirectory, release }) { - const record = loadReleaseRecord(releaseDirectory); - const expectedAssets = [record.tarball, record.checksum, record.inventory, 'release.json']; - const expectedPrerelease = record.distTag === 'next'; - if ( - release?.tag_name !== record.gitTag || - release.name !== `Maka CLI ${record.version}` || - release.body !== readFileSync(join(releaseDirectory, 'release-notes.md'), 'utf8') || - release.draft !== false || - release.prerelease !== expectedPrerelease || - !Array.isArray(release.assets) || - release.assets.length !== expectedAssets.length - ) { - throw new Error('GitHub Release metadata does not match the verified CLI release'); - } - - const assets = new Map(release.assets.map((asset) => [asset?.name, asset])); - for (const name of expectedAssets) { - const bytes = readFileSync(join(releaseDirectory, name)); - const asset = assets.get(name); - if ( - asset?.state !== 'uploaded' || - asset.size !== bytes.length || - asset.digest !== `sha256:${digest('sha256', bytes, 'hex')}` - ) { - throw new Error(`GitHub Release asset does not match the verified file: ${name}`); - } - } - return record; -} - export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) { const record = loadReleaseRecord(releaseDirectory); const packageDirectory = join(auditDirectory, 'node_modules', PACKAGE_NAME); @@ -255,7 +218,7 @@ function loadReleaseRecord(releaseDirectory) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); - for (const key of ['distTag', 'gitTag', 'tarball']) { + for (const key of ['distTag', 'tarball']) { if (record[key] !== identity[key]) throw new Error(`Release record ${key} is inconsistent`); } if (!/^[0-9a-f]{64}$/u.test(record.sha256)) { @@ -438,11 +401,6 @@ function parseRegistryTarballUrl(value, expectedName) { return url.href; } -function releaseNotes(record) { - const install = record.distTag === 'next' ? `${PACKAGE_NAME}@next` : PACKAGE_NAME; - return `Maka CLI ${record.version}\n\nInstall with:\n\n\`\`\`sh\nnpm install --global ${install}\n\`\`\`\n\nSource commit: ${record.source.commit}\nStage workflow run: https://github.com/${record.source.repository}/actions/runs/${record.source.runId} (attempt ${record.source.runAttempt})\nSHA-256: \`${record.sha256}\`\n`; -} - function exactKeys(value, keys, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must be an object`); @@ -506,25 +464,25 @@ async function main() { appendOutputs(output, { version: result.record.version, dist_tag: result.record.distTag, - git_tag: result.record.gitTag, tarball: result.tarballPath, }); return; } - if (command === 'validate-stage-run' && args.length === 4) { + if (command === 'validate-stage-run' && (args.length === 3 || args.length === 4)) { const [releaseDirectory, runPath, expectedVersion, output] = args; const record = validateStageRun({ releaseDirectory: resolve(releaseDirectory), expectedVersion, run: readJson(resolve(runPath), 'stage workflow run'), }); - appendOutputs(output, { - version: record.version, - dist_tag: record.distTag, - git_tag: record.gitTag, - source_sha: record.source.commit, - tarball: record.tarball, - }); + if (output) { + appendOutputs(output, { + version: record.version, + dist_tag: record.distTag, + source_sha: record.source.commit, + tarball: record.tarball, + }); + } return; } if (command === 'prepare-audit' && args.length === 2) { @@ -551,16 +509,8 @@ async function main() { }); return; } - if (command === 'validate-github-release' && args.length === 2) { - const [releaseDirectory, releasePath] = args; - validateGitHubRelease({ - releaseDirectory: resolve(releaseDirectory), - release: readJson(resolve(releasePath), 'GitHub Release'), - }); - return; - } throw new Error( - `Usage: release-cli-publication.mjs ...`, + `Usage: release-cli-publication.mjs ...`, ); } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index afe576a43d..9c2d217d13 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -10,7 +10,6 @@ import { parseCliReleaseVersion, prepareSignatureAuditTree, prepareStageRelease, - validateGitHubRelease, validateRegistryChannels, validateSignatureAudit, validateStageRun, @@ -26,7 +25,6 @@ test('release versions map prereleases and stable versions to distinct channels' assert.deepEqual(parseCliReleaseVersion('0.1.0-beta.1'), { version: '0.1.0-beta.1', distTag: 'next', - gitTag: 'cli-v0.1.0-beta.1', tarball: 'maka-agent-0.1.0-beta.1.tgz', }); assert.equal(parseCliReleaseVersion('0.1.0').distTag, 'latest'); @@ -192,10 +190,9 @@ test('registry finalization requires the exact staged bytes and dist-tag', async readFileSync(`${result.tarballPath}.files.json`), readFileSync(`${fixture.tarballPath}.files.json`), ); - assert.match( - readFileSync(join(registryDirectory, 'release-notes.md'), 'utf8'), - /Stage workflow run: .* \(attempt 1\)/u, - ); + const registryRecord = JSON.parse(readFileSync(join(registryDirectory, 'release.json'), 'utf8')); + assert.equal(registryRecord.version, result.version); + assert.equal(Object.hasOwn(registryRecord, 'gitTag'), false); await assert.rejects( fetchRegistryRelease({ @@ -293,42 +290,6 @@ test('signature audit tree exposes only the top-level registry package', () => { ); }); -test('GitHub finalization accepts only the exact published metadata and asset digests', async () => { - const fixture = createPreparedCandidate(); - const registryDirectory = mkdtempSync(join(tmpdir(), 'maka-cli-github-release-')); - await fetchRegistryRelease({ - releaseDirectory: fixture.releaseDirectory, - registryDirectory, - fetchImpl: registryFetch({ fixture }), - }); - const release = createGitHubReleaseFixture(fixture, registryDirectory); - - assert.doesNotThrow(() => - validateGitHubRelease({ releaseDirectory: registryDirectory, release }), - ); - assert.throws( - () => - validateGitHubRelease({ - releaseDirectory: registryDirectory, - release: { ...release, draft: true }, - }), - /metadata does not match/u, - ); - assert.throws( - () => - validateGitHubRelease({ - releaseDirectory: registryDirectory, - release: { - ...release, - assets: release.assets.map((asset, index) => - index === 0 ? { ...asset, digest: `sha256:${'0'.repeat(64)}` } : asset, - ), - }, - }), - /asset does not match/u, - ); -}); - test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { const fixture = createCandidate(CURRENT_CLI_VERSION); const output = join(fixture.root, 'github-output.txt'); @@ -352,8 +313,7 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { assert.equal(result.status, 0, result.stderr); assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ `version=${fixture.version}`, - 'dist_tag=next', - `git_tag=cli-v${fixture.version}`, + 'dist_tag=latest', `tarball=${fixture.tarballPath}`, ]); }); @@ -393,7 +353,6 @@ test('validate-stage-run CLI emits the canonical cross-job release identity', () assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ `version=${fixture.version}`, 'dist_tag=next', - `git_tag=cli-v${fixture.version}`, `source_sha=${SOURCE_SHA}`, `tarball=${fixture.tarball}`, ]); @@ -456,31 +415,6 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { }; } -function createGitHubReleaseFixture(fixture, releaseDirectory) { - const names = [ - fixture.tarball, - `${fixture.tarball}.sha256`, - `${fixture.tarball}.files.json`, - 'release.json', - ]; - return { - tag_name: `cli-v${fixture.version}`, - name: `Maka CLI ${fixture.version}`, - body: readFileSync(join(releaseDirectory, 'release-notes.md'), 'utf8'), - draft: false, - prerelease: true, - assets: names.map((name) => { - const bytes = readFileSync(join(releaseDirectory, name)); - return { - name, - state: 'uploaded', - size: bytes.length, - digest: `sha256:${digest('sha256', bytes, 'hex')}`, - }; - }), - }; -} - function digest(algorithm, bytes, encoding) { return createHash(algorithm).update(bytes).digest(encoding); } diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index a0aa552a66..a88b812864 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -70,32 +70,11 @@ test('finalize validates one exact stage attempt before running the current veri assert.doesNotMatch(checkout, /steps\.stage-run\.outputs\.source_sha/u); }); -test('finalize propagates verified artifacts and idempotently publishes an exact-tag release', () => { +test('finalize preserves verified npm bytes without creating another product release', () => { const workflow = readWorkflow('release-cli-finalize.yml'); - assert.match( - workflow, - /public_release_artifact_id: \$\{\{ steps\.public-release\.outputs\.artifact-id \}\}/u, - ); - assert.match(workflow, /tarball: \$\{\{ steps\.release\.outputs\.tarball \}\}/u); - assert.doesNotMatch(workflow, /steps\.registry\.outputs\.tarball/u); - const publish = workflow.slice(workflow.indexOf('\n publish:')); - assert.match( - publish, - /artifact-ids: \$\{\{ needs\.inspect\.outputs\.public_release_artifact_id \}\}/u, - ); - assert.match(publish, /--verify-tag/u); - assert.match(publish, /gh release create[\s\S]*?--draft/u); - assert.match(publish, /gh release upload[\s\S]*?--clobber/u); - assert.match(publish, /gh release edit[\s\S]*?--draft=false/u); - assert.match(publish, /gh release view[\s\S]*?--json apiUrl/u); - assert.match(publish, /gh api "\$release_api_url"/u); - assert.doesNotMatch(publish, /releases\/tags\/\$RELEASE_TAG/u); - assert.match(publish, /--prerelease/u); - assert.match(publish, /--latest=false/u); - assert.match(publish, /validate-github-release/u); - const checkout = namedStep(workflowSteps(publish), 'Check out the current release finalizer'); - assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); - assert.match(checkout, /persist-credentials: false/u); + assert.match(workflow, /name: Preserve the verified public npm package/u); + assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); + assert.doesNotMatch(workflow, /cli-v|gh release|contents: write|validate-github-release/u); }); test('release workflows select npm from the root packageManager authority', () => { diff --git a/scripts/release-eval-smoke-sitecustomize.py b/scripts/release-eval-smoke-sitecustomize.py new file mode 100644 index 0000000000..fb1f4fa2cc --- /dev/null +++ b/scripts/release-eval-smoke-sitecustomize.py @@ -0,0 +1,190 @@ +"""Minimal offline Harbor API used by the extracted CLI release smoke.""" + +import asyncio +import importlib.metadata +import json +import os +import sys +import tempfile +import types +from pathlib import Path +from types import SimpleNamespace + + +def register(name: str, package: bool = False) -> types.ModuleType: + module = types.ModuleType(name) + if package: + module.__path__ = [] + sys.modules[name] = module + parent_name, _, child_name = name.rpartition(".") + if parent_name: + setattr(sys.modules[parent_name], child_name, module) + return module + + +harbor = register("harbor", package=True) +agents = register("harbor.agents", package=True) +base = register("harbor.agents.base") +models = register("harbor.models", package=True) +trial_models = register("harbor.models.trial", package=True) +trial_config = register("harbor.models.trial.config") +trial_package = register("harbor.trial", package=True) +trial_module = register("harbor.trial.trial") +single_step = register("harbor.trial.single_step") +constants = register("harbor.constants") + + +class BaseAgent: + def __init__(self, *args, **kwargs): + pass + + +base.BaseAgent = BaseAgent +constants.TASK_CACHE_DIR = Path(os.environ["MAKA_CLI_EVAL_SMOKE_CACHE"]) + + +class TaskIdentity: + def __init__(self, value): + self.value = value + + def model_dump_json(self): + return json.dumps(self.value, sort_keys=True) + + +class TaskConfig(SimpleNamespace): + def get_task_id(self): + return TaskIdentity(self.__dict__) + + +def namespace(value, task=False): + if isinstance(value, dict): + values = {key: namespace(child) for key, child in value.items()} + return TaskConfig(**values) if task else SimpleNamespace(**values) + if isinstance(value, list): + return [namespace(child) for child in value] + return value + + +class TrialConfig: + @classmethod + def model_validate_json(cls, raw): + value = json.loads(raw) + config = namespace(value) + config.task = namespace(value["task"], task=True) + return config + + +trial_config.TrialConfig = TrialConfig + + +class Trial: + @staticmethod + def _resolve_agent_skills(config): + return None + + @staticmethod + async def _load_task(config): + task_dir = Path(config.task.download_dir) / "release-smoke" + task_dir.mkdir(parents=True, exist_ok=True) + (task_dir / "task.txt").write_text("release smoke\n") + task = SimpleNamespace( + has_steps=False, + config=SimpleNamespace(agent=config.agent), + task_dir=str(task_dir), + ) + return task, None + + +trial_module.Trial = Trial + + +class SingleStepTrial: + def __init__(self, config, _task, _task_download_result): + self.config = config + self.task = _task + + async def run(self): + from relay_agent import RelayAgent + + kwargs = self.config.agent.kwargs + reader, writer = await asyncio.open_connection( + kwargs.relay_host, kwargs.relay_port + ) + + def send(value): + writer.write((json.dumps(value, separators=(",", ":")) + "\n").encode()) + + send( + { + "token": kwargs.relay_token, + "kind": "ready", + "instruction": "release smoke", + "cwd": tempfile.gettempdir(), + } + ) + await writer.drain() + execute = json.loads(await reader.readline()) + if ( + execute.get("token") != kwargs.relay_token + or execute.get("kind") != "execute" + ): + raise RuntimeError("invalid execute request") + if execute.get("command") != "/usr/bin/true" or execute.get("args") != []: + raise RuntimeError("unexpected release smoke command") + send( + { + "token": kwargs.relay_token, + "kind": "executed", + "termination": "exited", + "exitCode": 0, + "stdout": "", + "diagnostic": {"category": "none"}, + } + ) + await writer.drain() + decision = json.loads(await reader.readline()) + if ( + decision.get("token") != kwargs.relay_token + or decision.get("kind") != "verify" + ): + raise RuntimeError("invalid verify decision") + + trial_dir = Path(self.config.trials_dir) / self.config.trial_name + trial_dir.mkdir(parents=True, exist_ok=True) + (trial_dir / "result.json").write_text( + json.dumps( + { + "exception_info": None, + "verifier_result": {"rewards": {"reward": 1}}, + } + ) + + "\n" + ) + Path(os.environ["MAKA_CLI_EVAL_SMOKE_MARKER"]).write_text( + json.dumps( + { + "runTrial": str(Path(sys.argv[0]).resolve()), + "relayAgent": str( + Path(sys.modules["relay_agent"].__file__).resolve() + ), + "relayName": RelayAgent.name(), + } + ) + + "\n" + ) + writer.close() + await writer.wait_closed() + + +single_step.SingleStepTrial = SingleStepTrial + +metadata_version = importlib.metadata.version + + +def fixture_version(distribution_name): + if distribution_name == "harbor": + return "0.20.0" + return metadata_version(distribution_name) + + +importlib.metadata.version = fixture_version diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index 65a7e9a5b5..84692e8d19 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -122,8 +122,6 @@ async function validateInstalledProduct(root) { : join(prefix, 'lib/node_modules/maka-agent'); const baseEnvironment = isolatedEnvironment(join(root, 'home')); const maka = process.platform === 'win32' ? join(prefix, 'maka.cmd') : join(prefix, 'bin/maka'); - const makaAgent = - process.platform === 'win32' ? join(prefix, 'maka-agent.cmd') : join(prefix, 'bin/maka-agent'); const cliEntrypoint = join(packageRoot, 'dist/cli.js'); const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); const crossSpawnModule = await importInstalled(packageRoot, 'node_modules/cross-spawn/index.js'); @@ -138,10 +136,6 @@ async function validateInstalledProduct(root) { throw new Error(`Installed CLI reports ${version}; package manifest is ${manifest.version}`); } assertOutput(runSync(crossSpawn.sync, maka, ['--help'], baseEnvironment, root), 'Usage: maka'); - assertOutput( - runSync(crossSpawn.sync, makaAgent, ['--help'], baseEnvironment, root), - 'Usage: maka', - ); assertOutput( runSync(crossSpawn.sync, maka, ['eval', '--help'], baseEnvironment, root), 'usage: maka eval run', diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs new file mode 100644 index 0000000000..5881f7c71a --- /dev/null +++ b/scripts/verify-macos-arm64-cli.mjs @@ -0,0 +1,712 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { + access, + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify, stripVTControlCharacters } from 'node:util'; +import { + assertMacosArm64CliHost, + assertNoDanglingSymlinks, + inspectNativeArtifacts, + isMacosArm64MachO, + listApplicableDependencyPatchNames, + resolveCliWorkspacePackages, + resolveMacosArm64CliArtifactPaths, +} from './package-macos-arm64-cli.mjs'; +import { + releaseToolchainFromManifest, + resolveProductReleaseIdentity, +} from './product-release-identity.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const tuiReadyPattern = /Maka\s*·\s*Auto\s*·/u; + +async function runCommand(command, args, options = {}) { + return execFileAsync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + maxBuffer: 20 * 1024 * 1024, + timeout: options.timeout ?? 30_000, + }); +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export function dependencyKeysFromNotice(notice) { + return [...notice.matchAll(/^Package: (.+)$/gm)].map((match) => match[1]).sort(); +} + +export function assertCliThirdPartyNotices(notice, metadata, actualSha256) { + if (actualSha256 !== metadata.thirdPartyNoticesSha256) { + throw new Error('CLI third-party notice digest does not match RELEASE.json.'); + } + const noticeDependencies = dependencyKeysFromNotice(notice); + if (JSON.stringify(noticeDependencies) !== JSON.stringify(metadata.productionDependencies)) { + throw new Error('CLI third-party notices do not match the packaged production closure.'); + } +} + +async function assertMissing(path) { + try { + await access(path); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw error; + } + throw new Error(`CLI artifact contains forbidden path: ${path}`); +} + +export function isTuiReadyOutput(output) { + return tuiReadyPattern.test(stripVTControlCharacters(output)); +} + +export function assertExpectedTuiExit({ ready, stopRequested, exitCode, signal, output }) { + if (!ready) { + throw new Error( + `TUI exited before rendering in a PTY (exit ${exitCode}, signal ${signal}). Output: ${output.slice(-1000)}`, + ); + } + if (!stopRequested || (exitCode !== 0 && exitCode !== 130)) { + throw new Error( + `TUI crashed after startup (exit ${exitCode}, signal ${signal}). Output: ${output.slice(-1000)}`, + ); + } +} + +export function assertSafeCliArchiveEntries(entries, archiveRootName) { + if (entries.length === 0) throw new Error('CLI archive is empty.'); + for (const entry of entries) { + const normalized = entry.replace(/\\/g, '/'); + const segments = normalized.split('/').filter(Boolean); + if ( + normalized.startsWith('/') || + segments.includes('..') || + segments.some((segment) => segment.startsWith('._')) || + segments[0] !== archiveRootName + ) { + throw new Error(`Unsafe CLI archive entry: ${entry}`); + } + } +} + +async function smokeTuiInPty(archiveRoot, environment) { + const cliManifestPath = join( + archiveRoot, + 'libexec', + 'node_modules', + 'maka-agent', + 'package.json', + ); + const requireFromCli = createRequire(cliManifestPath); + const pty = requireFromCli('node-pty'); + const executable = join(archiveRoot, 'bin', 'maka'); + await new Promise((resolvePromise, reject) => { + let output = ''; + let ready = false; + let stopRequested = false; + let closeTimer; + const child = pty.spawn(executable, [], { + cols: 100, + rows: 30, + cwd: archiveRoot, + env: { ...environment, TERM: 'xterm-256color' }, + }); + const timeout = setTimeout(() => { + child.kill(); + reject(new Error(`TUI did not start in a PTY. Output: ${output.slice(-1000)}`)); + }, 10_000); + + child.onData((data) => { + output += data; + if (!ready && isTuiReadyOutput(output)) { + ready = true; + stopRequested = true; + child.write('\u0003'); + closeTimer = setTimeout(() => child.write('\u0003'), 250); + } + }); + child.onExit(({ exitCode, signal }) => { + clearTimeout(timeout); + clearTimeout(closeTimer); + try { + assertExpectedTuiExit({ ready, stopRequested, exitCode, signal, output }); + resolvePromise(); + } catch (error) { + reject(error); + } + }); + }); +} + +function parseLinkedLibraries(output) { + return output + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)[0]) + .filter(Boolean); +} + +function assertSelfContainedNode(output) { + const nonSystemLibraries = parseLinkedLibraries(output).filter( + (path) => !path.startsWith('/usr/lib/') && !path.startsWith('/System/Library/'), + ); + if (nonSystemLibraries.length > 0) { + throw new Error(`Embedded Node links non-system libraries: ${nonSystemLibraries.join(', ')}`); + } +} + +function parseSignatureDetails(output) { + const authority = output.match(/^Authority=(Developer ID Application: .+)$/m)?.[1]; + const teamIdentifier = output.match(/^TeamIdentifier=(.+)$/m)?.[1]; + const hardenedRuntime = output.includes('flags=0x10000(runtime)'); + return { authority, hardenedRuntime, teamIdentifier }; +} + +async function findFiles(directory, predicate) { + const matches = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) matches.push(...(await findFiles(path, predicate))); + else if (entry.isFile() && predicate(path)) matches.push(path); + } + return matches; +} + +async function assertNoTestArtifacts(archiveRoot) { + const libexecRoot = join(archiveRoot, 'libexec'); + const forbidden = await findFiles(libexecRoot, (path) => { + const pathFromLibexec = relative(libexecRoot, path); + return ( + pathFromLibexec.split(sep).includes('__tests__') || + /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/.test(path) || + /^test_.*\.py$/u.test(basename(path)) + ); + }); + if (forbidden.length > 0) { + throw new Error(`CLI artifact contains test files: ${forbidden.slice(0, 5).join(', ')}`); + } +} + +async function assertWorkspaceClosure(archiveRoot, metadata) { + const workspacePackages = await resolveCliWorkspacePackages(); + const expectedNames = workspacePackages.map(({ name }) => name).sort(); + if (JSON.stringify(metadata.workspacePackages) !== JSON.stringify(expectedNames)) { + throw new Error('CLI artifact workspace closure does not match package manifests.'); + } + for (const { name, workspacePath } of workspacePackages) { + const linkPath = join(archiveRoot, 'libexec', 'node_modules', ...name.split('/')); + const packagePath = join(archiveRoot, 'libexec', workspacePath); + const [resolvedLink, resolvedPackage] = await Promise.all([ + import('node:fs/promises').then(({ realpath }) => realpath(linkPath)), + import('node:fs/promises').then(({ realpath }) => realpath(packagePath)), + ]); + if (resolvedLink !== resolvedPackage) { + throw new Error(`${name} does not resolve to the packaged workspace directory.`); + } + } + await assertNoDanglingSymlinks(join(archiveRoot, 'libexec')); +} + +async function smokePackagedEval(archiveRoot, sourceCommit, environment, run) { + // Exercise the staged runner and relay offline against the narrow Harbor API + // they consume, so release verification stays deterministic and provider-free. + const smokeRoot = await mkdtemp(join(environment.TMPDIR, 'eval-smoke-')); + try { + const python = await run( + 'python3', + [ + '-c', + 'import json, sys; print(json.dumps({"executable": sys.executable, "version": list(sys.version_info[:3])}))', + ], + { env: process.env }, + ); + const pythonIdentity = JSON.parse(python.stdout); + if ( + !isAbsolute(pythonIdentity.executable) || + !Array.isArray(pythonIdentity.version) || + pythonIdentity.version[0] !== 3 || + pythonIdentity.version[1] < 10 + ) { + throw new Error('Packaged eval smoke requires Python 3.10 or newer.'); + } + + const fixtureRoot = join(smokeRoot, 'python'); + const trialsRoot = join(smokeRoot, 'trials'); + const taskCache = join(smokeRoot, 'task-cache'); + const markerPath = join(smokeRoot, 'marker.json'); + const specPath = join(smokeRoot, 'experiment.json'); + const outputPath = join(smokeRoot, 'output'); + await mkdir(fixtureRoot, { recursive: true }); + await copyFile( + join(repoRoot, 'scripts', 'release-eval-smoke-sitecustomize.py'), + join(fixtureRoot, 'sitecustomize.py'), + ); + + const spec = { + schemaVersion: 'maka.eval.v1', + id: 'release-artifact-smoke', + benchmark: { + id: 'release-smoke', + version: sourceCommit, + config: { repository: 'https://github.com/maka-agent/maka-agent.git' }, + }, + executor: { + kind: 'harbor', + config: { + frameworkVersion: '0.20.0', + pythonPathEnv: 'MAKA_CLI_EVAL_SMOKE_PYTHON', + trialsRootEnv: 'MAKA_CLI_EVAL_SMOKE_TRIALS', + environment: {}, + preparationEnvironment: [ + 'PYTHONPATH', + 'MAKA_CLI_EVAL_SMOKE_CACHE', + 'MAKA_CLI_EVAL_SMOKE_MARKER', + ], + mounts: [], + }, + }, + execution: { maxConcurrentTaskGroups: 1 }, + subjects: [ + { + id: 'external', + kind: 'external', + credentials: [], + config: { command: '/usr/bin/true', args: [], result: 'exit-code' }, + }, + ], + tasks: [ + { + id: 'smoke', + input: 'release smoke', + config: { harbor: { path: 'tasks/release-smoke' } }, + }, + ], + repetitions: 1, + budget: { timeoutMultiplier: 1 }, + verifier: { reward: 'reward' }, + }; + await writeFile(specPath, `${JSON.stringify(spec)}\n`, 'utf8'); + const smokeEnvironment = { + ...environment, + MAKA_CLI_EVAL_SMOKE_CACHE: taskCache, + MAKA_CLI_EVAL_SMOKE_MARKER: markerPath, + MAKA_CLI_EVAL_SMOKE_PYTHON: pythonIdentity.executable, + MAKA_CLI_EVAL_SMOKE_TRIALS: trialsRoot, + PYTHONPATH: fixtureRoot, + }; + const makaPath = join(archiveRoot, 'bin', 'maka'); + const result = await run(makaPath, ['eval', 'run', specPath, '--out', outputPath], { + cwd: smokeRoot, + env: smokeEnvironment, + timeout: 30_000, + }); + const summary = JSON.parse(result.stdout); + if (summary.experimentId !== spec.id || summary.cells !== 1 || summary.incomplete !== 0) { + throw new Error('Packaged eval smoke did not complete its one deterministic cell.'); + } + + const cellId = 'smoke::1::external'; + const attemptPath = join( + outputPath, + 'attempts', + createHash('sha256').update(cellId).digest('hex'), + '000001.json', + ); + const attempt = JSON.parse(await readFile(attemptPath, 'utf8')); + if ( + attempt.cellId !== cellId || + attempt.result?.status !== 'completed' || + attempt.result.score !== 1 + ) { + throw new Error('Packaged eval smoke did not record a completed, verified attempt.'); + } + + const marker = JSON.parse(await readFile(markerPath, 'utf8')); + const expectedRunTrial = await import('node:fs/promises').then(({ realpath }) => + realpath(join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'run_trial.py')), + ); + const expectedRelayAgent = await import('node:fs/promises').then(({ realpath }) => + realpath(join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'relay_agent.py')), + ); + if ( + marker.runTrial !== expectedRunTrial || + marker.relayAgent !== expectedRelayAgent || + marker.relayName !== 'maka-eval-relay' + ) { + throw new Error('Packaged eval smoke did not execute the staged Harbor runtime assets.'); + } + } finally { + await rm(smokeRoot, { recursive: true, force: true }); + } +} + +function streamingChunk(delta, finishReason = null) { + return { + id: 'chatcmpl-release-smoke', + object: 'chat.completion.chunk', + created: 0, + model: 'release-smoke-model', + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +export function assertPatchedStreamingToolCalls(parts) { + const errors = parts.filter((part) => part.type === 'error'); + if (errors.length > 0 || parts.at(-1)?.type !== 'finish') { + throw new Error('Packaged provider-utils failed to finish streamed tool calls.'); + } + const actualCalls = parts + .filter((part) => part.type === 'tool-call') + .map(({ toolCallId, toolName, input }) => ({ toolCallId, toolName, input })); + const expectedCalls = [ + { toolCallId: 'call_1', toolName: 'read_file', input: '{"path":"a.txt"}' }, + { toolCallId: 'call_2', toolName: 'read_file', input: '{"path":"b.txt"}' }, + ]; + if (JSON.stringify(actualCalls) !== JSON.stringify(expectedCalls)) { + throw new Error('Packaged provider-utils reordered or dropped streamed tool calls.'); + } +} + +export function resolvePackagedRuntimeModelFactory(archiveRoot) { + const cliManifestPath = join( + archiveRoot, + 'libexec', + 'node_modules', + 'maka-agent', + 'package.json', + ); + const requireFromCli = createRequire(cliManifestPath); + return requireFromCli.resolve('@maka/runtime/model-factory'); +} + +async function smokePatchedStreamingToolCalls(archiveRoot) { + const runtimeEntry = resolvePackagedRuntimeModelFactory(archiveRoot); + const { getAIModel } = await import(pathToFileURL(runtimeEntry).href); + const payloads = [ + streamingChunk({ role: 'assistant', content: 'Reading both files.' }), + streamingChunk({ + tool_calls: [ + { + index: 1, + id: 'call_1', + type: 'function', + function: { name: 'read_file', arguments: '' }, + }, + ], + }), + streamingChunk({ tool_calls: [{ index: 1, function: { arguments: '{"path":"a.txt"}' } }] }), + streamingChunk({ + tool_calls: [ + { + index: 2, + id: 'call_2', + type: 'function', + function: { name: 'read_file', arguments: '' }, + }, + ], + }), + streamingChunk({ tool_calls: [{ index: 2, function: { arguments: '{"path":"b.txt"}' } }] }), + streamingChunk({}, 'tool_calls'), + ]; + const body = `${payloads.map((payload) => `data: ${JSON.stringify(payload)}\n\n`).join('')}data: [DONE]\n\n`; + const model = getAIModel({ + connection: { + slug: 'release-smoke', + providerType: 'openai-compatible', + baseUrl: 'https://release-smoke.invalid/v1', + defaultModel: 'release-smoke-model', + }, + apiKey: 'release-smoke-key', + modelId: 'release-smoke-model', + fetch: async () => + new Response(body, { headers: { 'content-type': 'text/event-stream' }, status: 200 }), + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'read a.txt and b.txt' }] }], + tools: [ + { + type: 'function', + name: 'read_file', + inputSchema: { type: 'object', properties: { path: { type: 'string' } } }, + }, + ], + }); + const parts = []; + for await (const part of stream) parts.push(part); + assertPatchedStreamingToolCalls(parts); +} + +async function verifyBinarySignatures(binaryPaths, { requireReleaseSigning, run }) { + let expectedTeamIdentifier; + for (const binaryPath of binaryPaths) { + await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath]); + if (!requireReleaseSigning) continue; + const signature = await run('codesign', ['-d', '--verbose=4', binaryPath]); + const details = parseSignatureDetails(`${signature.stdout}\n${signature.stderr}`); + if (!details.authority || !details.hardenedRuntime || !details.teamIdentifier) { + throw new Error(`${binaryPath} is not signed with a hardened Developer ID identity.`); + } + expectedTeamIdentifier ??= details.teamIdentifier; + if (details.teamIdentifier !== expectedTeamIdentifier) { + throw new Error(`${binaryPath} is signed by a different Developer ID team.`); + } + } + return expectedTeamIdentifier; +} + +export async function verifyMacosArm64Cli( + archivePath, + { + platform = process.platform, + arch = process.arch, + run = runCommand, + smokeTui = smokeTuiInPty, + requireReleaseSigning = process.env.MAKA_CLI_REQUIRE_RELEASE_SIGNING === '1', + } = {}, +) { + assertMacosArm64CliHost(platform, arch); + const [rootManifest, desktopManifest, cliManifest, sourceCommitResult] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8').then(JSON.parse), + run('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }), + ]); + const toolchain = releaseToolchainFromManifest(rootManifest); + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + ref: 'refs/heads/main', + sha: sourceCommitResult.stdout.trim(), + }); + const version = identity.version; + const expectedPaths = resolveMacosArm64CliArtifactPaths(version); + const resolvedArchivePath = resolve(archivePath ?? expectedPaths.archivePath); + const checksumPath = `${resolvedArchivePath}.sha256`; + await Promise.all([access(resolvedArchivePath), access(checksumPath)]); + + const sha256 = await sha256File(resolvedArchivePath); + const expectedChecksum = `${sha256} ${basename(resolvedArchivePath)}\n`; + const actualChecksum = await readFile(checksumPath, 'utf8'); + if (actualChecksum !== expectedChecksum) { + throw new Error(`CLI checksum does not match ${basename(resolvedArchivePath)}.`); + } + + const archiveEntries = await run('unzip', ['-Z1', resolvedArchivePath], { + timeout: 120_000, + }); + assertSafeCliArchiveEntries( + archiveEntries.stdout.split('\n').filter(Boolean), + expectedPaths.archiveRootName, + ); + + const extractionRoot = await mkdtemp(join(dirname(resolvedArchivePath), '.verify-cli-')); + try { + await run('ditto', ['-x', '-k', resolvedArchivePath, extractionRoot], { + timeout: 300_000, + }); + const archiveRoot = join(extractionRoot, expectedPaths.archiveRootName); + const nodePath = join(archiveRoot, 'libexec', 'node', 'bin', 'node'); + const makaPath = join(archiveRoot, 'bin', 'maka'); + const metadataPath = join(archiveRoot, 'RELEASE.json'); + const thirdPartyNoticesPath = join(archiveRoot, 'THIRD_PARTY_NOTICES.txt'); + const requiredPaths = [ + nodePath, + makaPath, + metadataPath, + thirdPartyNoticesPath, + join(archiveRoot, 'LICENSE'), + join(archiveRoot, 'NOTICE'), + join(archiveRoot, 'libexec', 'node', 'LICENSE'), + ]; + await Promise.all([ + ...requiredPaths.map((path) => access(path)), + assertMissing(join(archiveRoot, 'bin', 'maka-agent')), + ]); + + const [metadata, expectedDependencyPatches, thirdPartyNotices] = await Promise.all([ + readFile(metadataPath, 'utf8').then(JSON.parse), + listApplicableDependencyPatchNames(join(archiveRoot, 'libexec', 'node_modules')), + readFile(thirdPartyNoticesPath, 'utf8'), + ]); + if ( + metadata.schemaVersion !== 1 || + metadata.product !== 'Maka' || + metadata.version !== version || + metadata.sourceCommit !== identity.sourceCommit || + metadata.platform !== 'macos' || + metadata.architecture !== 'arm64' || + metadata.node?.version !== toolchain.nodeVersion || + metadata.node?.sourceUrl !== toolchain.nodeSourceUrl || + metadata.node?.archive !== toolchain.nodeArchive || + metadata.node?.archiveSha256 !== toolchain.nodeArchiveSha256 || + metadata.npmVersion !== toolchain.npmVersion || + JSON.stringify(metadata.publicCommands) !== JSON.stringify(['maka']) + ) { + throw new Error('CLI release metadata does not match the product release identity.'); + } + if (JSON.stringify(metadata.dependencyPatches) !== JSON.stringify(expectedDependencyPatches)) { + throw new Error('CLI release metadata does not match the repository dependency patches.'); + } + if (requireReleaseSigning && metadata.signing !== 'developer-id-notarized') { + throw new Error('Release CLI artifact is not marked as Developer ID signed and notarized.'); + } + await Promise.all([ + assertWorkspaceClosure(archiveRoot, metadata), + assertNoTestArtifacts(archiveRoot), + sha256File(thirdPartyNoticesPath).then((digest) => + assertCliThirdPartyNotices(thirdPartyNotices, metadata, digest), + ), + ]); + + const { foreignBinaries, machOBinaries } = await inspectNativeArtifacts(archiveRoot, { + inspect: run, + }); + if (foreignBinaries.length > 0) { + throw new Error( + `CLI artifact contains foreign native binaries: ${foreignBinaries.join(', ')}`, + ); + } + if (machOBinaries.length === 0) throw new Error('CLI artifact contains no Mach-O binaries.'); + const relativeMachOBinaries = machOBinaries.map((path) => relative(archiveRoot, path)).sort(); + if (JSON.stringify(relativeMachOBinaries) !== JSON.stringify(metadata.machOBinaries)) { + throw new Error('CLI Mach-O inventory does not match RELEASE.json.'); + } + for (const binaryPath of machOBinaries) { + const [architectures, buildVersion] = await Promise.all([ + run('lipo', ['-archs', binaryPath]), + run('xcrun', ['vtool', '-show-build', binaryPath]), + ]); + if (!isMacosArm64MachO(architectures.stdout, buildVersion.stdout)) { + throw new Error(`${binaryPath} must target only Apple Silicon macOS.`); + } + } + const nodeDependencies = await run('otool', ['-L', nodePath]); + assertSelfContainedNode(nodeDependencies.stdout); + const signingTeamIdentifier = await verifyBinarySignatures(machOBinaries, { + requireReleaseSigning, + run, + }); + + if (requireReleaseSigning) { + await run('spctl', ['--assess', '--type', 'execute', '--verbose=4', nodePath]); + await run('xattr', [ + '-w', + '-r', + 'com.apple.quarantine', + '0083;00000000;GitHub;MakaReleaseVerification', + archiveRoot, + ]); + } + + const isolatedHome = join(extractionRoot, 'home'); + const commandWorkspace = join(extractionRoot, 'workspace'); + await Promise.all([mkdir(isolatedHome), mkdir(commandWorkspace)]); + const environment = { + HOME: isolatedHome, + LANG: 'en_US.UTF-8', + MAKA_DISABLE_DEFERRED_TOOLS: '1', + PATH: '/usr/bin:/bin:/usr/sbin:/sbin', + SHELL: '/bin/zsh', + TMPDIR: extractionRoot, + }; + + const embeddedNodeVersion = await run(nodePath, ['-p', 'process.versions.node'], { + cwd: commandWorkspace, + env: environment, + }); + if (embeddedNodeVersion.stdout.trim() !== toolchain.nodeVersion) { + throw new Error('Embedded Node version does not match the pinned release toolchain.'); + } + const versionResult = await run(makaPath, ['--version'], { + cwd: commandWorkspace, + env: environment, + }); + if (versionResult.stdout.trim() !== version) { + throw new Error( + `CLI version ${versionResult.stdout.trim()} does not match desktop ${version}.`, + ); + } + const externalBin = join(extractionRoot, 'external-bin'); + const externalMakaPath = join(externalBin, 'maka'); + await mkdir(externalBin); + await symlink(makaPath, externalMakaPath); + const externalVersionResult = await run(externalMakaPath, ['--version'], { + cwd: commandWorkspace, + env: environment, + }); + if (externalVersionResult.stdout.trim() !== version) { + throw new Error('The maka launcher is not relocatable through an external symlink.'); + } + const helpResult = await run(makaPath, ['--help'], { + cwd: commandWorkspace, + env: environment, + }); + for (const command of ['run', 'eval']) { + if (!helpResult.stdout.includes(command)) { + throw new Error(`CLI help does not list ${command}.`); + } + } + await smokePatchedStreamingToolCalls(archiveRoot); + + const profileListResult = await run(makaPath, ['runtime-host', 'profile', 'list'], { + cwd: commandWorkspace, + env: environment, + }); + const profiles = JSON.parse(profileListResult.stdout); + if ( + !Array.isArray(profiles) || + !profiles.some( + (profile) => + profile?.id === 'local' && profile.name === 'Local' && profile.kind === 'local', + ) + ) { + throw new Error('Packaged non-interactive profile command did not return the local profile.'); + } + const evalHelpResult = await run(makaPath, ['eval', '--help'], { + cwd: commandWorkspace, + env: environment, + }); + if (!evalHelpResult.stdout.includes('maka eval run ')) { + throw new Error('Packaged eval command did not load its public CLI contract.'); + } + await smokePackagedEval(archiveRoot, identity.sourceCommit, environment, run); + await smokeTui(archiveRoot, environment); + + return { + archivePath: resolvedArchivePath, + checksumPath, + machOBinaryCount: machOBinaries.length, + sha256, + signingTeamIdentifier, + evalSmokeVerified: true, + streamingPatchVerified: true, + version, + }; + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await verifyMacosArm64Cli(process.argv[2]); + console.log(`Verified ${result.archivePath}`); + console.log(`SHA-256 ${result.sha256}`); +} From cd030b6272f0d498975eb030e158ed898fac1537 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:14:09 +0800 Subject: [PATCH 02/31] fix(release): bind npm to the product release Generated-by: Codex --- .github/workflows/cli-package-validation.yml | 9 +++ .github/workflows/release-cli-stage.yml | 54 ++++++++++++- .github/workflows/release.yml | 51 +++++++----- scripts/product-release-identity.mjs | 24 ++++++ scripts/product-release-tag.mjs | 70 +++++++++++++++++ scripts/product-release.test.mjs | 82 ++++++++++++++++++++ scripts/release-cli-publication.mjs | 56 ++++++++----- scripts/release-cli-publication.test.mjs | 30 ++++--- scripts/release-cli-workflow-policy.test.mjs | 16 +++- 9 files changed, 337 insertions(+), 55 deletions(-) create mode 100644 scripts/product-release-tag.mjs diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index a66e8ecbf7..1f9a749afb 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -2,6 +2,12 @@ name: CLI package validation on: workflow_call: + inputs: + source_commit: + description: Exact product release commit to build; defaults to the triggering commit + required: false + type: string + default: '' outputs: release_candidate_artifact_id: description: Immutable artifact produced by the build job @@ -24,6 +30,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -77,6 +84,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -105,6 +113,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 7e09cc1a45..012cd63c33 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -17,10 +17,17 @@ concurrency: jobs: authorize: - name: Require main + name: Resolve product release runs-on: ubuntu-24.04 timeout-minutes: 5 + outputs: + source_commit: ${{ steps.product.outputs.source_commit }} + product_tag: ${{ steps.product.outputs.product_tag }} steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false - name: Reject non-main dispatches env: RELEASE_REF: ${{ github.ref }} @@ -30,14 +37,51 @@ jobs: exit 1 fi + - name: Resolve the exact product release + id: product + env: + EXPECTED_VERSION: ${{ inputs.version }} + PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} + GH_TOKEN: ${{ github.token }} + PRODUCT_TAG: v${{ inputs.version }} + run: | + if [[ ! "$EXPECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Expected an exact product version; found $EXPECTED_VERSION" >&2 + exit 1 + fi + release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" + test "$release_tag" = "$PRODUCT_TAG" + git fetch --no-tags origin "refs/tags/$PRODUCT_TAG" + source_commit="$(git rev-list -n 1 FETCH_HEAD)" + test "$(git rev-parse FETCH_HEAD^{commit})" = "$source_commit" + echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" + echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" + + - name: Check out the product release source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.product.outputs.source_commit }} + persist-credentials: false + + - name: Verify the product release identity + env: + GITHUB_REF: refs/heads/main + GITHUB_SHA: ${{ steps.product.outputs.source_commit }} + EXPECTED_PRODUCT_VERSION: ${{ inputs.version }} + EXPECTED_PRODUCT_TAG: ${{ steps.product.outputs.product_tag }} + EXPECTED_PRODUCT_SOURCE_COMMIT: ${{ steps.product.outputs.source_commit }} + run: node scripts/product-release-identity.mjs + validate: name: Validate immutable candidate needs: authorize uses: ./.github/workflows/cli-package-validation.yml + with: + source_commit: ${{ needs.authorize.outputs.source_commit }} stage: name: Stage maka-agent on npm - needs: validate + needs: [authorize, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: @@ -49,6 +93,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.authorize.outputs.source_commit }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -69,13 +114,16 @@ jobs: RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} - RELEASE_SHA: ${{ github.sha }} + RELEASE_SHA: ${{ needs.authorize.outputs.source_commit }} + RELEASE_WORKFLOW_SHA: ${{ github.sha }} RELEASE_WORKFLOW: .github/workflows/release-cli-stage.yml run: | node scripts/release-cli-publication.mjs prepare-stage \ packages/cli/release \ "$EXPECTED_VERSION" \ + "$PRODUCT_TAG" \ "$RELEASE_SHA" \ + "$RELEASE_WORKFLOW_SHA" \ "$RELEASE_RUN_ID" \ "$RELEASE_RUN_ATTEMPT" \ "$RELEASE_REPOSITORY" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48f3c815bf..59f1581aa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read concurrency: group: release @@ -41,20 +41,6 @@ jobs: id: identity run: node scripts/product-release-identity.mjs - - name: Assert the tag and release are unused - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.identity.outputs.tag }} - run: | - if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then - echo "Tag ${TAG} already exists." >&2 - exit 1 - fi - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release ${TAG} already exists." >&2 - exit 1 - fi - desktop: needs: release-identity if: github.ref == 'refs/heads/main' @@ -357,6 +343,8 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: write defaults: run: shell: bash @@ -366,6 +354,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} + fetch-depth: 0 persist-credentials: false - name: Download the verified release assets @@ -396,7 +385,16 @@ jobs: (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") done < <(find release-assets -type f -name '*.sha256' -print0) - - name: Create draft GitHub Release + - 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 }} @@ -413,10 +411,21 @@ jobs: Known limitation: Computer Use is not included in this release." - gh release create "$TAG" release-assets/* \ - --draft \ - --target "$SOURCE_COMMIT" \ - --title "Maka ${VERSION}" \ - --notes "$notes" + 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 \ + --title "Maka ${VERSION}" \ + --notes "$notes" + fi + + gh release edit "$TAG" --title "Maka ${VERSION}" --notes "$notes" + gh release upload "$TAG" release-assets/* --clobber echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 815ad06103..d2cda59614 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -81,6 +81,19 @@ export function resolveProductReleaseIdentity({ }; } +export function assertProductReleaseExpectation(identity, { version, tag, sourceCommit }) { + if ( + identity.version !== version || + identity.tag !== tag || + identity.sourceCommit !== sourceCommit + ) { + throw new Error( + `Checked source ${identity.tag} at ${identity.sourceCommit} does not match product release ${tag} at ${sourceCommit}`, + ); + } + return identity; +} + export async function readProductReleaseIdentity({ ref = process.env.GITHUB_REF, sha = process.env.GITHUB_SHA, @@ -113,6 +126,17 @@ function githubOutputEntries(identity) { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const identity = await readProductReleaseIdentity(); + if ( + process.env.EXPECTED_PRODUCT_VERSION || + process.env.EXPECTED_PRODUCT_TAG || + process.env.EXPECTED_PRODUCT_SOURCE_COMMIT + ) { + assertProductReleaseExpectation(identity, { + version: process.env.EXPECTED_PRODUCT_VERSION, + tag: process.env.EXPECTED_PRODUCT_TAG, + sourceCommit: process.env.EXPECTED_PRODUCT_SOURCE_COMMIT, + }); + } if (process.env.GITHUB_OUTPUT) { const output = Object.entries(githubOutputEntries(identity)) .map(([name, value]) => `${name}=${value}`) diff --git a/scripts/product-release-tag.mjs b/scripts/product-release-tag.mjs new file mode 100644 index 0000000000..bf82e959a3 --- /dev/null +++ b/scripts/product-release-tag.mjs @@ -0,0 +1,70 @@ +import { execFile } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +function validateInputs(tag, source) { + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(tag)) { + throw new Error(`Product tag must be an exact version tag; found ${tag}`); + } + if (!/^[0-9a-f]{40}$/u.test(source)) { + throw new Error(`Product tag source must be an exact commit SHA; found ${source}`); + } +} + +async function remoteTagCommit({ cwd, remote, tag }) { + const { stdout } = await execFileAsync( + 'git', + ['ls-remote', '--tags', '--refs', remote, `refs/tags/${tag}`], + { cwd }, + ); + const line = stdout.trim(); + if (!line) return undefined; + const [commit, ref, ...extra] = line.split(/\s+/u); + if (extra.length > 0 || ref !== `refs/tags/${tag}` || !/^[0-9a-f]{40}$/u.test(commit)) { + throw new Error(`Remote returned an invalid product tag reference for ${tag}`); + } + return commit; +} + +export async function ensureProductTag({ cwd = process.cwd(), remote = 'origin', tag, source }) { + validateInputs(tag, source); + const { stdout: resolvedSource } = await execFileAsync( + 'git', + ['rev-parse', `${source}^{commit}`], + { + cwd, + }, + ); + if (resolvedSource.trim() !== source) { + throw new Error(`Product source ${source} is not the exact checked-out commit`); + } + + const existing = await remoteTagCommit({ cwd, remote, tag }); + if (existing) { + if (existing !== source) { + throw new Error(`Product tag ${tag} points to ${existing} instead of ${source}`); + } + return 'existing'; + } + + try { + await execFileAsync('git', ['push', remote, `${source}:refs/tags/${tag}`], { cwd }); + return 'created'; + } catch (error) { + const raced = await remoteTagCommit({ cwd, remote, tag }); + if (raced === source) return 'existing'; + if (raced) throw new Error(`Product tag ${tag} points to ${raced} instead of ${source}`); + throw new Error(`Could not create product tag ${tag}`, { cause: error }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, tag, source, remote = 'origin'] = process.argv.slice(2); + if (command !== 'ensure' || !tag || !source) { + throw new Error('usage: product-release-tag.mjs ensure [remote]'); + } + const result = await ensureProductTag({ tag, source, remote }); + console.log(`Product tag ${tag}: ${result} at ${source}`); +} diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 99ade18a5c..4c49dbff36 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -1,12 +1,15 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; import { parseCliReleaseVersion } from './release-cli-publication.mjs'; import { planTests } from './ci-test-plan.mjs'; import { + assertProductReleaseExpectation, readProductReleaseIdentity, releaseToolchainFromManifest, resolveProductReleaseIdentity, @@ -21,6 +24,9 @@ import { workspaceReleaseFiles, } from './package-macos-arm64-cli.mjs'; import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; +import { ensureProductTag } from './product-release-tag.mjs'; + +const execFileAsync = promisify(execFile); const rootManifest = { version: '1.2.3', @@ -50,6 +56,76 @@ test('one root version defines every product artifact from one main commit', () assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('an npm candidate must name the exact product tag, version, and source commit', () => { + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + ref: 'refs/heads/main', + sha: 'a'.repeat(40), + }); + + assert.doesNotThrow(() => + assertProductReleaseExpectation(identity, { + version: '1.2.3', + tag: 'v1.2.3', + sourceCommit: 'a'.repeat(40), + }), + ); + for (const expected of [ + { version: '1.2.4', tag: 'v1.2.3', sourceCommit: 'a'.repeat(40) }, + { version: '1.2.3', tag: 'v1.2.4', sourceCommit: 'a'.repeat(40) }, + { version: '1.2.3', tag: 'v1.2.3', sourceCommit: 'b'.repeat(40) }, + ]) { + assert.throws( + () => assertProductReleaseExpectation(identity, expected), + /does not match product release/u, + ); + } +}); + +test('product tag creation is exact and idempotent but rejects a conflicting commit', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-product-tag-')); + const remote = join(root, 'remote.git'); + const source = join(root, 'source'); + try { + await execFileAsync('git', ['init', '--bare', remote]); + await mkdir(source); + await execFileAsync('git', ['init'], { cwd: source }); + await execFileAsync('git', ['config', 'user.name', 'Maka release test'], { cwd: source }); + await execFileAsync('git', ['config', 'user.email', 'release-test@example.invalid'], { + cwd: source, + }); + await writeFile(join(source, 'source.txt'), 'one\n'); + await execFileAsync('git', ['add', 'source.txt'], { cwd: source }); + await execFileAsync('git', ['commit', '-m', 'first'], { cwd: source }); + const first = ( + await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: source }) + ).stdout.trim(); + + assert.equal( + await ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: first }), + 'created', + ); + assert.equal( + await ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: first }), + 'existing', + ); + + await writeFile(join(source, 'source.txt'), 'two\n'); + await execFileAsync('git', ['commit', '-am', 'second'], { cwd: source }); + const second = ( + await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: source }) + ).stdout.trim(); + await assert.rejects( + ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: second }), + /points to .* instead of/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('the root manifest pins the Node archive and npm used by release jobs', () => { assert.deepEqual(releaseToolchainFromManifest(rootManifest), { nodeVersion: '24.18.1', @@ -163,6 +239,8 @@ test('one product workflow gates one draft release on every required artifact', const workflow = parseYaml(source); const jobs = workflow.jobs; + assert.equal(workflow.permissions.contents, 'read'); + assert.equal(jobs.publish.permissions.contents, 'write'); assert.deepEqual(jobs.publish.needs, [ 'release-identity', 'desktop', @@ -181,6 +259,10 @@ test('one product workflow gates one draft release on every required artifact', assert.equal(jobs.desktop['timeout-minutes'], 60); assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); + assert.match(commands, /product-release-tag\.mjs ensure/u); + assert.match(commands, /gh release create[\s\S]*--verify-tag/u); + assert.match(commands, /gh release upload[\s\S]*--clobber/u); + assert.doesNotMatch(commands, /gh release create[\s\S]*--target/u); assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); await assert.rejects( readFile(new URL('../.github/workflows/release-desktop.yml', import.meta.url)), diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index ed1e321a0b..aa450c0c37 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -12,6 +12,7 @@ const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', 'version', + 'productTag', 'distTag', 'tarball', 'sha256', @@ -53,7 +54,9 @@ export function prepareStageRelease({ repoRoot, releaseDirectory, expectedVersion, + productTag, sourceSha, + workflowSha, runId, runAttempt, repository, @@ -69,12 +72,23 @@ export function prepareStageRelease({ `Release version confirmation ${expectedVersion} does not match ${identity.version}`, ); } - validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }); + if (productTag !== `v${identity.version}`) { + throw new Error(`Product tag ${productTag} does not match ${identity.version}`); + } + validateSourceIdentity({ + sourceSha, + workflowSha, + runId, + runAttempt, + repository, + workflowPath, + }); const candidate = validateCandidateFiles(releaseDirectory, identity); const record = { - schemaVersion: 1, + schemaVersion: 2, packageName: PACKAGE_NAME, ...identity, + productTag, sha256: candidate.sha256, checksum: `${identity.tarball}.sha256`, inventory: `${identity.tarball}.files.json`, @@ -82,6 +96,7 @@ export function prepareStageRelease({ repository, workflow: workflowPath, commit: sourceSha, + workflowCommit: workflowSha, runId, runAttempt, }, @@ -107,7 +122,7 @@ export function validateStageRun({ releaseDirectory, expectedVersion, run }) { run.path !== record.source.workflow || run.event !== 'workflow_dispatch' || run.head_branch !== 'main' || - run.head_sha !== record.source.commit || + run.head_sha !== record.source.workflowCommit || run.conclusion !== 'success' || run.head_repository?.full_name !== record.source.repository ) { @@ -214,7 +229,7 @@ export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) function loadReleaseRecord(releaseDirectory) { const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); - if (record.schemaVersion !== 1 || record.packageName !== PACKAGE_NAME) { + if (record.schemaVersion !== 2 || record.packageName !== PACKAGE_NAME) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); @@ -232,11 +247,12 @@ function loadReleaseRecord(releaseDirectory) { } exactKeys( record.source, - ['repository', 'workflow', 'commit', 'runId', 'runAttempt'], + ['repository', 'workflow', 'commit', 'workflowCommit', 'runId', 'runAttempt'], 'release source', ); validateSourceIdentity({ sourceSha: record.source.commit, + workflowSha: record.source.workflowCommit, runId: record.source.runId, runAttempt: record.source.runAttempt, repository: record.source.repository, @@ -269,8 +285,16 @@ function validateCandidateFiles(releaseDirectory, identity) { return { tarballPath, sha256 }; } -function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }) { +function validateSourceIdentity({ + sourceSha, + workflowSha, + runId, + runAttempt, + repository, + workflowPath, +}) { if (!/^[0-9a-f]{40}$/u.test(sourceSha)) throw new Error('Release source SHA is invalid'); + if (!/^[0-9a-f]{40}$/u.test(workflowSha)) throw new Error('Release workflow SHA is invalid'); if (!/^[1-9]\d*$/u.test(runId)) throw new Error('Release workflow run ID is invalid'); if (!/^[1-9]\d*$/u.test(runAttempt)) throw new Error('Release workflow run attempt is invalid'); if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); @@ -440,11 +464,13 @@ function appendOutputs(path, values) { async function main() { const [command, ...args] = process.argv.slice(2); - if (command === 'prepare-stage' && args.length === 8) { + if (command === 'prepare-stage' && args.length === 10) { const [ releaseDirectory, expectedVersion, + productTag, sourceSha, + workflowSha, runId, runAttempt, repository, @@ -455,7 +481,9 @@ async function main() { repoRoot: resolve(import.meta.dirname, '..'), releaseDirectory: resolve(releaseDirectory), expectedVersion, + productTag, sourceSha, + workflowSha, runId, runAttempt, repository, @@ -468,21 +496,13 @@ async function main() { }); return; } - if (command === 'validate-stage-run' && (args.length === 3 || args.length === 4)) { - const [releaseDirectory, runPath, expectedVersion, output] = args; - const record = validateStageRun({ + if (command === 'validate-stage-run' && args.length === 3) { + const [releaseDirectory, runPath, expectedVersion] = args; + validateStageRun({ releaseDirectory: resolve(releaseDirectory), expectedVersion, run: readJson(resolve(runPath), 'stage workflow run'), }); - if (output) { - appendOutputs(output, { - version: record.version, - dist_tag: record.distTag, - source_sha: record.source.commit, - tarball: record.tarball, - }); - } return; } if (command === 'prepare-audit' && args.length === 2) { diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 9c2d217d13..af7ecce332 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -16,10 +16,12 @@ import { } from './release-cli-publication.mjs'; const SOURCE_SHA = 'a'.repeat(40); +const WORKFLOW_SHA = 'b'.repeat(40); const WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; const CURRENT_CLI_VERSION = JSON.parse( readFileSync(resolve(import.meta.dirname, '../packages/cli/package.json'), 'utf8'), ).version; +const PRODUCT_TAG = 'v0.1.0-beta.1'; test('release versions map prereleases and stable versions to distinct channels', () => { assert.deepEqual(parseCliReleaseVersion('0.1.0-beta.1'), { @@ -80,7 +82,9 @@ test('stage records bind the checked candidate to one source workflow run', () = repoRoot: fixture.root, releaseDirectory: fixture.releaseDirectory, expectedVersion: fixture.version, + productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -88,7 +92,9 @@ test('stage records bind the checked candidate to one source workflow run', () = }); assert.equal(prepared.record.sha256, fixture.sha256); + assert.equal(prepared.record.productTag, PRODUCT_TAG); assert.equal(prepared.record.source.commit, SOURCE_SHA); + assert.equal(prepared.record.source.workflowCommit, WORKFLOW_SHA); assert.equal(prepared.record.source.runId, '321'); assert.equal(prepared.record.source.runAttempt, '1'); assert.deepEqual( @@ -105,7 +111,9 @@ test('stage preparation rejects confirmation and checksum drift', () => { repoRoot: fixture.root, releaseDirectory: fixture.releaseDirectory, expectedVersion: '0.1.0-beta.2', + productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -121,7 +129,9 @@ test('stage preparation rejects confirmation and checksum drift', () => { repoRoot: fixture.root, releaseDirectory: fixture.releaseDirectory, expectedVersion: fixture.version, + productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -139,7 +149,7 @@ test('finalization accepts only the exact successful main stage run', () => { path: WORKFLOW_PATH, event: 'workflow_dispatch', head_branch: 'main', - head_sha: SOURCE_SHA, + head_sha: WORKFLOW_SHA, conclusion: 'success', head_repository: { full_name: 'maka-agent/maka-agent' }, }; @@ -158,7 +168,7 @@ test('finalization accepts only the exact successful main stage run', () => { { event: 'pull_request' }, { head_branch: 'feature' }, { conclusion: 'failure' }, - { head_sha: 'b'.repeat(40) }, + { head_sha: 'c'.repeat(40) }, { run_attempt: 2 }, ]) { assert.throws( @@ -300,7 +310,9 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { 'prepare-stage', fixture.releaseDirectory, fixture.version, + `v${fixture.version}`, SOURCE_SHA, + WORKFLOW_SHA, '321', '1', 'maka-agent/maka-agent', @@ -318,10 +330,9 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { ]); }); -test('validate-stage-run CLI emits the canonical cross-job release identity', () => { +test('validate-stage-run CLI accepts the canonical staged release identity', () => { const fixture = createPreparedCandidate(); const runPath = join(fixture.root, 'stage-run.json'); - const output = join(fixture.root, 'github-output.txt'); writeFileSync( runPath, JSON.stringify({ @@ -330,7 +341,7 @@ test('validate-stage-run CLI emits the canonical cross-job release identity', () path: WORKFLOW_PATH, event: 'workflow_dispatch', head_branch: 'main', - head_sha: SOURCE_SHA, + head_sha: WORKFLOW_SHA, conclusion: 'success', head_repository: { full_name: 'maka-agent/maka-agent' }, }), @@ -344,18 +355,11 @@ test('validate-stage-run CLI emits the canonical cross-job release identity', () fixture.releaseDirectory, runPath, fixture.version, - output, ], { encoding: 'utf8' }, ); assert.equal(result.status, 0, result.stderr); - assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ - `version=${fixture.version}`, - 'dist_tag=next', - `source_sha=${SOURCE_SHA}`, - `tarball=${fixture.tarball}`, - ]); }); function createPreparedCandidate() { @@ -364,7 +368,9 @@ function createPreparedCandidate() { repoRoot: fixture.root, releaseDirectory: fixture.releaseDirectory, expectedVersion: fixture.version, + productTag: `v${fixture.version}`, sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index a88b812864..a99745c66d 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -9,7 +9,7 @@ test('validation consumers download the artifact produced by the build job', () const workflow = readWorkflow('cli-package-validation.yml'); assert.match( workflow, - /workflow_call:\n\s+outputs:\n\s+release_candidate_artifact_id:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_candidate_artifact_id \}\}/u, + /workflow_call:[\s\S]*?\n\s+outputs:\n\s+release_candidate_artifact_id:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_candidate_artifact_id \}\}/u, ); assert.match( workflow, @@ -45,6 +45,20 @@ test('stage consumes the validated artifact and makes provenance staging the fin assert.match(submit, /--provenance/u); }); +test('stage builds the npm candidate from the exact product release commit', () => { + const workflow = readWorkflow('release-cli-stage.yml'); + assert.match(workflow, /source_commit: \$\{\{ steps\.product\.outputs\.source_commit \}\}/u); + assert.match( + workflow, + /needs: authorize\n\s+uses: \.\/\.github\/workflows\/cli-package-validation\.yml/u, + ); + assert.match(workflow, /source_commit: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); + assert.match(workflow, /ref: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); + assert.match(workflow, /gh release view "\$PRODUCT_TAG"/u); + assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); + assert.doesNotMatch(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); +}); + test('finalize validates one exact stage attempt before running the current verifier', () => { const workflow = readWorkflow('release-cli-finalize.yml'); const steps = workflowSteps(workflow); From 032c2e1979c0fc02159ff66b236c0d438f786f1d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:24:11 +0800 Subject: [PATCH 03/31] refactor(release): consolidate CLI packaging policy Generated-by: Codex --- package.json | 1 - scripts/ci-test-plan.mjs | 7 +- scripts/package-macos-arm64-cli.mjs | 308 +++++++++++------------ scripts/product-release-identity.mjs | 6 +- scripts/product-release.test.mjs | 80 +++++- scripts/release-cli-file-policy.mjs | 118 +++++++++ scripts/release-cli-file-policy.test.mjs | 28 +++ scripts/release-cli-package.mjs | 82 +++--- 8 files changed, 418 insertions(+), 212 deletions(-) diff --git a/package.json b/package.json index f3e60025b8..076b35c613 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "packageManager": "npm@11.19.0", "releaseToolchain": { "node": "24.18.1", - "nodeDarwinArm64Archive": "node-v24.18.1-darwin-arm64.tar.xz", "nodeDarwinArm64Sha256": "1d60b703fe5d7e7072489be8187f430f1a095a658c31e5e1e281331a5873fac3" }, "type": "module", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index fcb5a22a7f..7dbc69611b 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -10,13 +10,14 @@ const defaultRepoRoot = dirname(dirname(scriptPath)); const FULL_SUITE_FILES = new Set([ '.github/workflows/ci.yml', - '.github/workflows/release.yml', 'package-lock.json', 'package.json', 'scripts/ci-test-plan.mjs', 'scripts/run-workspace-tests-parallel.mjs', ]); +const RELEASE_CONTRACT_FILES = new Set(['.github/workflows/release.yml']); + const TYPECHECK_ONLY_FILES = new Set([ 'biome.jsonc', 'components.json', @@ -295,6 +296,10 @@ export function planTests(changedFiles, options = {}) { code = true; continue; } + if (RELEASE_CONTRACT_FILES.has(path)) { + code = true; + continue; + } if (path.startsWith('.github/') || isDocumentation(path)) continue; code = true; unknownCode = true; diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index 38917a8a03..aa8cca36cd 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -1,5 +1,5 @@ import { execFile, spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { access, @@ -24,7 +24,13 @@ import { releaseToolchainFromManifest, resolveProductReleaseIdentity, } from './product-release-identity.mjs'; -import { isThirdPartyDevelopmentArtifact } from './release-cli-file-policy.mjs'; +import { + isMakaDevelopmentArtifact, + isThirdPartyDevelopmentArtifact, + releaseNpmEnvironment, + resolveReleaseWorkspacePackages, + workspaceReleaseFiles, +} from './release-cli-file-policy.mjs'; export { releaseToolchainFromManifest } from './product-release-identity.mjs'; @@ -34,7 +40,6 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const releaseDirectory = join(repoRoot, 'apps', 'desktop', 'release'); const dependencyPatchesDirectory = join(repoRoot, 'patches'); const cliPackageName = 'maka-agent'; -const localPackagePrefix = '@maka/'; const requiredSigningEnvironment = [ 'CSC_LINK', 'CSC_KEY_PASSWORD', @@ -122,96 +127,11 @@ export function macosArm64CliInstallArgs() { } export function standaloneInstallEnvironment(environment) { - return Object.fromEntries( - Object.entries(environment).filter( - ([name]) => name.toLowerCase() !== 'npm_config_allow_scripts', - ), - ); -} - -function manifestFromEntry(entry) { - return entry?.manifest ?? entry; -} - -export function collectWorkspaceDependencyClosure(entryName, manifestsByName) { - const closure = new Set(); - const visiting = new Set(); - - function visit(packageName) { - if (closure.has(packageName)) return; - if (visiting.has(packageName)) { - throw new Error(`Workspace dependency cycle reached ${packageName}.`); - } - const entry = manifestsByName.get(packageName); - if (!entry) { - throw new Error(`Workspace package ${packageName} is missing.`); - } - visiting.add(packageName); - const manifest = manifestFromEntry(entry); - for (const dependencyName of Object.keys(manifest.dependencies ?? {}).sort()) { - if (manifestsByName.has(dependencyName)) { - visit(dependencyName); - } else if (dependencyName.startsWith(localPackagePrefix)) { - throw new Error( - `${packageName} depends on local package ${dependencyName}, but it is not in workspaces.`, - ); - } - } - visiting.delete(packageName); - closure.add(packageName); - } - - visit(entryName); - return [...closure].sort(); -} - -function assertInsideRepo(path) { - const pathFromRepo = relative(repoRoot, path); - if (pathFromRepo === '..' || pathFromRepo.startsWith(`..${sep}`) || isAbsolute(pathFromRepo)) { - throw new Error(`Workspace path escapes the repository: ${path}`); - } + return releaseNpmEnvironment(environment, join(repoRoot, '.npmrc')); } export async function resolveCliWorkspacePackages() { - const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); - const manifestsByName = new Map(); - for (const workspacePath of rootManifest.workspaces ?? []) { - if (typeof workspacePath !== 'string' || /[*?[\]{}]/.test(workspacePath)) { - throw new Error(`CLI release requires explicit workspace paths, found ${workspacePath}.`); - } - const directory = resolve(repoRoot, workspacePath); - assertInsideRepo(directory); - const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); - if (typeof manifest.name !== 'string' || !manifest.name) { - throw new Error(`${workspacePath}/package.json is missing a package name.`); - } - if (manifestsByName.has(manifest.name)) { - throw new Error(`Duplicate workspace package name ${manifest.name}.`); - } - manifestsByName.set(manifest.name, { directory, manifest, workspacePath }); - } - - return collectWorkspaceDependencyClosure(cliPackageName, manifestsByName).map((name) => ({ - name, - ...manifestsByName.get(name), - })); -} - -function isTestArtifactName(name) { - return /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/.test(name); -} - -export async function pruneTestArtifacts(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory() && entry.name === '__tests__') { - await rm(path, { recursive: true, force: true }); - } else if (entry.isDirectory()) { - await pruneTestArtifacts(path); - } else if (entry.isFile() && isTestArtifactName(entry.name)) { - await rm(path, { force: true }); - } - } + return resolveReleaseWorkspacePackages(repoRoot, cliPackageName); } export async function pruneThirdPartyDevelopmentArtifacts(directory, root = directory) { @@ -226,44 +146,6 @@ export async function pruneThirdPartyDevelopmentArtifacts(directory, root = dire } } -export function workspaceReleaseFiles(manifest) { - const declared = Object.hasOwn(manifest, 'releaseFiles') ? manifest.releaseFiles : ['dist']; - if (!Array.isArray(declared) || declared.length === 0) { - throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be non-empty.`); - } - const releaseFiles = declared.map((path) => { - if (typeof path !== 'string' || path.length === 0) { - throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be paths.`); - } - if ( - isAbsolute(path) || - path.includes('\\') || - /[*?[\]{}]/u.test(path) || - path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') - ) { - throw new Error(`${manifest.name ?? 'Workspace package'} has unsafe release file ${path}.`); - } - return path; - }); - if (new Set(releaseFiles).size !== releaseFiles.length) { - throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles contain duplicates.`); - } - for (const [index, path] of releaseFiles.entries()) { - const overlap = releaseFiles - .slice(index + 1) - .find((candidate) => path.startsWith(`${candidate}/`) || candidate.startsWith(`${path}/`)); - if (overlap) { - throw new Error( - `${manifest.name ?? 'Workspace package'} releaseFiles overlap at ${path} and ${overlap}.`, - ); - } - } - if (!releaseFiles.includes('dist')) { - throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must include dist.`); - } - return releaseFiles; -} - export function standaloneInstallRootManifest(rootManifest, workspacePackages) { const { allowScripts: _allowScripts, ...staged } = rootManifest; return { @@ -299,11 +181,22 @@ export async function stageWorkspacePackages(installRoot, workspacePackages) { await cp(source, target, { recursive: true }); }), ); - await pruneTestArtifacts(join(targetDirectory, 'dist')); + await pruneMakaDevelopmentArtifacts(targetDirectory); }), ]); } +async function pruneMakaDevelopmentArtifacts(directory, root = directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (isMakaDevelopmentArtifact(relative(root, path))) { + await rm(path, { recursive: entry.isDirectory(), force: true }); + } else if (entry.isDirectory()) { + await pruneMakaDevelopmentArtifacts(path, root); + } + } +} + export async function listDependencyPatchNames() { let entries; try { @@ -584,7 +477,14 @@ export function isMacosArm64MachO(architectures, buildVersion) { return architectures.trim() === 'arm64' && /^\s*platform MACOS\s*$/m.test(buildVersion); } -async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect }) { +export function macosArm64MachOAction(architectures, buildVersion) { + const architectureList = architectures.trim().split(/\s+/u).filter(Boolean); + if (!architectureList.includes('arm64')) return 'remove'; + if (!/^\s*platform MACOS\s*$/m.test(buildVersion)) return 'reject'; + return architectureList.length === 1 ? 'keep' : 'thin'; +} + +async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }) { const { foreignBinaries, machOBinaries } = await inspectNativeArtifacts(nodeModulesDirectory, { inspect, }); @@ -594,8 +494,26 @@ async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect }) { inspect('lipo', ['-archs', binaryPath]), inspect('xcrun', ['vtool', '-show-build', binaryPath]), ]); - if (!isMacosArm64MachO(architectures.stdout, buildVersion.stdout)) { + const action = macosArm64MachOAction(architectures.stdout, buildVersion.stdout); + if (action === 'remove') { await rm(binaryPath, { force: true }); + } else if (action === 'reject') { + throw new Error(`Mach-O file does not target macOS arm64: ${binaryPath}`); + } else if (action === 'thin') { + const thinnedPath = `${binaryPath}.arm64`; + try { + await run('lipo', [binaryPath, '-thin', 'arm64', '-output', thinnedPath]); + const [thinnedArchitectures, thinnedBuildVersion] = await Promise.all([ + inspect('lipo', ['-archs', thinnedPath]), + inspect('xcrun', ['vtool', '-show-build', thinnedPath]), + ]); + if (!isMacosArm64MachO(thinnedArchitectures.stdout, thinnedBuildVersion.stdout)) { + throw new Error(`Could not thin Mach-O file to macOS arm64: ${binaryPath}`); + } + await rename(thinnedPath, binaryPath); + } finally { + await rm(thinnedPath, { force: true }); + } } } } @@ -618,25 +536,103 @@ export function assertAcceptedNotarization(output) { } } -async function signCliBinaries(machOBinaries, { env, run }) { - assertReleaseSigningEnvironment(env); - const { createKeychain, findIdentity, removeKeychain } = requireFromHere( - 'app-builder-lib/out/codeSign/macCodeSign', - ); - const { TmpDir } = requireFromHere('temp-file'); - const temporaryFiles = new TmpDir('maka-cli-signing'); - let keychainFile; +export function decodeSigningCertificate(value) { + const encoded = value?.trim(); + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { + throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length === 0) { + throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); + } + return { bytes }; +} + +export function parseDeveloperIdApplicationIdentity(output) { + const identities = [...output.matchAll(/^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"([^"]+)"\s*$/gmu)] + .map((match) => ({ hash: match[1].toUpperCase(), name: match[2] })) + .filter(({ name }) => name.startsWith('Developer ID Application:')); + if (identities.length !== 1) { + throw new Error('CLI signing keychain must contain one Developer ID Application identity.'); + } + return identities[0]; +} + +async function createSigningKeychain({ env, run, inspect }) { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-cli-signing-')); + const certificatePath = join(temporaryRoot, 'identity.p12'); + const pemPath = join(temporaryRoot, 'identity.pem'); + const keychainFile = join(temporaryRoot, 'signing.keychain-db'); + const keychainPassword = randomBytes(32).toString('hex'); + let created = false; + const cleanup = async () => { + if (created) { + try { + await run('security', ['delete-keychain', keychainFile], { env }); + } finally { + created = false; + } + } + await rm(temporaryRoot, { recursive: true, force: true }); + }; + try { - ({ keychainFile } = await createKeychain({ - tmpDir: temporaryFiles, - cscLink: env.CSC_LINK, - cscKeyPassword: env.CSC_KEY_PASSWORD, - currentDir: repoRoot, - })); - const identity = await findIdentity('Developer ID Application', null, keychainFile); - if (!identity) throw new Error('Could not resolve a Developer ID Application identity.'); - if (!identity.hash) throw new Error('Developer ID Application identity is missing its hash.'); + await writeFile(certificatePath, decodeSigningCertificate(env.CSC_LINK).bytes, { mode: 0o600 }); + await run('security', ['create-keychain', '-p', keychainPassword, keychainFile], { env }); + created = true; + await run('security', ['unlock-keychain', '-p', keychainPassword, keychainFile], { env }); + await run('security', ['set-keychain-settings', '-lut', '21600', keychainFile], { env }); + await run( + 'openssl', + [ + 'pkcs12', + '-in', + certificatePath, + '-nodes', + '-passin', + 'env:CSC_KEY_PASSWORD', + '-out', + pemPath, + ], + { env }, + ); + await chmod(pemPath, 0o600); + await run('security', ['import', pemPath, '-k', keychainFile, '-T', '/usr/bin/codesign'], { + env, + }); + await run( + 'security', + [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:', + '-s', + '-k', + keychainPassword, + keychainFile, + ], + { env }, + ); + const identityOutput = await inspect( + 'security', + ['find-identity', '-v', '-p', 'codesigning', keychainFile], + { env }, + ); + return { + cleanup, + identity: parseDeveloperIdApplicationIdentity(identityOutput.stdout), + keychainFile, + }; + } catch (error) { + await cleanup(); + throw error; + } +} +async function signCliBinaries(machOBinaries, { env, run, inspect }) { + assertReleaseSigningEnvironment(env); + const signing = await createSigningKeychain({ env, run, inspect }); + try { for (const binaryPath of machOBinaries) { await run( 'codesign', @@ -646,19 +642,21 @@ async function signCliBinaries(machOBinaries, { env, run }) { 'runtime', '--timestamp', '--sign', - identity.hash, + signing.identity.hash, '--keychain', - keychainFile, + signing.keychainFile, binaryPath, ], { env }, ); await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); } - return { identityName: identity.name, machOBinaryCount: machOBinaries.length }; + return { + identityName: signing.identity.name, + machOBinaryCount: machOBinaries.length, + }; } finally { - if (keychainFile) await removeKeychain(keychainFile, false); - await temporaryFiles.cleanup(); + await signing.cleanup(); } } @@ -771,7 +769,7 @@ export async function packageMacosArm64Cli({ ); const nodeModulesDirectory = join(installRoot, 'node_modules'); - await pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect }); + await pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }); await pruneThirdPartyDevelopmentArtifacts(nodeModulesDirectory); const archiveRoot = join(stagingRoot, archiveRootName); @@ -852,7 +850,7 @@ export async function packageMacosArm64Cli({ ); let signing; - if (releaseSigning) signing = await signCliBinaries(machOBinaries, { env, run }); + if (releaseSigning) signing = await signCliBinaries(machOBinaries, { env, run, inspect }); await createCliZip(archiveRoot, archivePath, { env, run }); if (releaseSigning) await notarizeCliZip(archivePath, { env, inspect }); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index d2cda59614..6b5f6ca56b 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -13,16 +13,12 @@ function exactVersion(value, label) { export function releaseToolchainFromManifest(rootManifest) { const nodeVersion = rootManifest.releaseToolchain?.node; - const nodeArchive = rootManifest.releaseToolchain?.nodeDarwinArm64Archive; const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; const npmMatch = /^npm@(\d+\.\d+\.\d+)$/u.exec(rootManifest.packageManager ?? ''); if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { throw new Error('package.json must define an exact releaseToolchain.node version'); } - const expectedArchive = `node-v${nodeVersion}-darwin-arm64.tar.xz`; - if (nodeArchive !== expectedArchive) { - throw new Error(`releaseToolchain.nodeDarwinArm64Archive must be ${expectedArchive}`); - } + const nodeArchive = `node-v${nodeVersion}-darwin-arm64.tar.xz`; if (typeof nodeArchiveSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(nodeArchiveSha256)) { throw new Error('releaseToolchain.nodeDarwinArm64Sha256 must be an exact SHA-256 digest'); } diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 4c49dbff36..56055d2ef4 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -15,14 +15,18 @@ import { resolveProductReleaseIdentity, } from './product-release-identity.mjs'; import { + decodeSigningCertificate, macosArm64CliWrapper, + macosArm64MachOAction, + parseDeveloperIdApplicationIdentity, pruneThirdPartyDevelopmentArtifacts, resolveCliWorkspacePackages, resolveMacosArm64CliArtifactPaths, + stageWorkspacePackages, standaloneInstallEnvironment, standaloneInstallRootManifest, - workspaceReleaseFiles, } from './package-macos-arm64-cli.mjs'; +import { workspaceReleaseFiles } from './release-cli-file-policy.mjs'; import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; import { ensureProductTag } from './product-release-tag.mjs'; @@ -33,7 +37,6 @@ const rootManifest = { packageManager: 'npm@11.19.0', releaseToolchain: { node: '24.18.1', - nodeDarwinArm64Archive: 'node-v24.18.1-darwin-arm64.tar.xz', nodeDarwinArm64Sha256: '1'.repeat(64), }, }; @@ -156,6 +159,37 @@ test('standalone verification recognizes the current TUI status line through ANS ); }); +test('standalone packaging keeps or thins arm64 Mach-O files instead of deleting them', () => { + const macos = 'platform MACOS\n'; + assert.equal(macosArm64MachOAction('arm64', macos), 'keep'); + assert.equal(macosArm64MachOAction('x86_64 arm64', macos), 'thin'); + assert.equal(macosArm64MachOAction('x86_64', macos), 'remove'); + assert.equal(macosArm64MachOAction('arm64', 'platform IOS\n'), 'reject'); +}); + +test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identity', () => { + assert.deepEqual(decodeSigningCertificate(Buffer.from('pkcs12').toString('base64')), { + bytes: Buffer.from('pkcs12'), + }); + assert.throws(() => decodeSigningCertificate('not base64!'), /base64-encoded/u); + assert.deepEqual( + parseDeveloperIdApplicationIdentity( + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (TEAMID)"\n 1 valid identities found\n', + ), + { + hash: 'ABCDEF0123456789ABCDEF0123456789ABCDEF01', + name: 'Developer ID Application: Maka Test (TEAMID)', + }, + ); + assert.throws( + () => + parseDeveloperIdApplicationIdentity( + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Apple Development: Test"\n', + ), + /one Developer ID Application/u, + ); +}); + test('the standalone maka launcher is relocatable and uses the embedded runtime', () => { const paths = resolveMacosArm64CliArtifactPaths('1.2.3'); assert.equal(paths.archiveRootName, 'Maka-1.2.3-cli-mac-arm64'); @@ -208,6 +242,38 @@ test('standalone packaging applies the shared CLI file policy to dependencies', } }); +test('standalone workspace staging keeps runtime files and removes Maka development output', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-standalone-workspace-')); + const workspace = join(root, 'workspace'); + const install = join(root, 'install'); + try { + await mkdir(join(workspace, 'dist', '__tests__'), { recursive: true }); + await mkdir(install); + await writeFile(join(workspace, 'package.json'), '{}\n'); + await writeFile(join(workspace, 'dist', 'index.js'), 'runtime\n'); + await writeFile(join(workspace, 'dist', 'dev-cli.js'), 'development\n'); + await writeFile(join(workspace, 'dist', 'index.d.ts'), 'development\n'); + await writeFile(join(workspace, 'dist', 'index.js.map'), 'development\n'); + await writeFile(join(workspace, 'dist', '__tests__', 'fixture.js'), 'development\n'); + + await stageWorkspacePackages(install, [ + { + directory: workspace, + manifest: { name: '@maka/example' }, + workspacePath: 'packages/example', + }, + ]); + + const staged = join(install, 'packages', 'example', 'dist'); + assert.equal(await readFile(join(staged, 'index.js'), 'utf8'), 'runtime\n'); + for (const path of ['dev-cli.js', 'index.d.ts', 'index.js.map', '__tests__/fixture.js']) { + await assert.rejects(readFile(join(staged, path)), { code: 'ENOENT' }); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('standalone dependency installation removes host script policy and unrelated workspaces', () => { const staged = standaloneInstallRootManifest( { @@ -228,7 +294,10 @@ test('standalone dependency installation ignores caller-specific npm script poli PATH: '/usr/bin', npm_config_allow_scripts: '@opencode-ai/cli', }); - assert.deepEqual(environment, { PATH: '/usr/bin' }); + assert.deepEqual(environment, { + PATH: '/usr/bin', + npm_config_userconfig: join(process.cwd(), '.npmrc'), + }); }); test('one product workflow gates one draft release on every required artifact', async () => { @@ -298,5 +367,8 @@ test('product workflow changes select the release contracts in CI', () => { }; const plan = planTests(['.github/workflows/release.yml'], { graph }); assert.equal(plan.code, true); - assert.equal(plan.full, true); + assert.equal(plan.full, false); + assert.equal(plan.e2e, false); + assert.equal(plan.storybook, false); + assert.deepEqual(plan.workspaces, []); }); diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index 3252b5d4b3..bb464283ae 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -1,3 +1,6 @@ +import { readFileSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + const DEVELOPMENT_DIRECTORIES = new Set([ '.nyc_output', '__fixtures__', @@ -8,6 +11,121 @@ const DEVELOPMENT_DIRECTORIES = new Set([ 'test', 'tests', ]); +const LOCAL_PACKAGE_PREFIX = '@maka/'; + +function manifestFromEntry(entry) { + return entry?.manifest ?? entry; +} + +export function collectWorkspaceDependencyClosure(entryName, manifestsByName) { + const closure = []; + const complete = new Set(); + const visiting = new Set(); + + function visit(packageName) { + if (complete.has(packageName)) return; + if (visiting.has(packageName)) { + throw new Error(`Workspace dependency cycle reached ${packageName}.`); + } + const entry = manifestsByName.get(packageName); + if (!entry) throw new Error(`Workspace package ${packageName} is missing.`); + visiting.add(packageName); + const manifest = manifestFromEntry(entry); + for (const dependencyName of Object.keys(manifest.dependencies ?? {}).sort()) { + if (manifestsByName.has(dependencyName)) visit(dependencyName); + else if (dependencyName.startsWith(LOCAL_PACKAGE_PREFIX)) { + throw new Error( + `${packageName} depends on local package ${dependencyName}, but it is not in workspaces.`, + ); + } + } + visiting.delete(packageName); + complete.add(packageName); + closure.push(packageName); + } + + visit(entryName); + return closure; +} + +export function workspaceReleaseFiles(manifest) { + const declared = Object.hasOwn(manifest, 'releaseFiles') ? manifest.releaseFiles : ['dist']; + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be non-empty.`); + } + const releaseFiles = declared.map((path) => { + if (typeof path !== 'string' || path.length === 0) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must be paths.`); + } + if ( + isAbsolute(path) || + path.includes('\\') || + /[*?[\]{}]/u.test(path) || + path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw new Error(`${manifest.name ?? 'Workspace package'} has unsafe release file ${path}.`); + } + return path; + }); + if (new Set(releaseFiles).size !== releaseFiles.length) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles contain duplicates.`); + } + for (const [index, path] of releaseFiles.entries()) { + const overlap = releaseFiles + .slice(index + 1) + .find((candidate) => path.startsWith(`${candidate}/`) || candidate.startsWith(`${path}/`)); + if (overlap) { + throw new Error( + `${manifest.name ?? 'Workspace package'} releaseFiles overlap at ${path} and ${overlap}.`, + ); + } + } + if (!releaseFiles.includes('dist')) { + throw new Error(`${manifest.name ?? 'Workspace package'} releaseFiles must include dist.`); + } + return releaseFiles; +} + +export function resolveReleaseWorkspacePackages(repoRoot, entryName = 'maka-agent') { + const resolvedRepoRoot = realpathSync(repoRoot); + const rootManifest = JSON.parse(readFileSync(join(resolvedRepoRoot, 'package.json'), 'utf8')); + const manifestsByName = new Map(); + for (const workspacePath of rootManifest.workspaces ?? []) { + if (typeof workspacePath !== 'string' || /[*?[\]{}]/u.test(workspacePath)) { + throw new Error(`CLI release requires explicit workspace paths, found ${workspacePath}.`); + } + const directory = realpathSync(resolve(resolvedRepoRoot, workspacePath)); + const pathFromRepo = relative(resolvedRepoRoot, directory); + if (pathFromRepo === '..' || pathFromRepo.startsWith(`..${sep}`) || isAbsolute(pathFromRepo)) { + throw new Error(`Workspace path escapes the repository: ${workspacePath}`); + } + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + if (typeof manifest.name !== 'string' || !manifest.name) { + throw new Error(`${workspacePath}/package.json is missing a package name.`); + } + if (manifestsByName.has(manifest.name)) { + throw new Error(`Duplicate workspace package name ${manifest.name}.`); + } + manifestsByName.set(manifest.name, { directory, manifest, workspacePath }); + } + return collectWorkspaceDependencyClosure(entryName, manifestsByName).map((name) => ({ + name, + ...manifestsByName.get(name), + })); +} + +export function releaseNpmEnvironment(environment, userConfigPath) { + return { + ...Object.fromEntries( + Object.entries(environment).filter( + ([name]) => + name.toLowerCase() !== 'npm_config_allow_scripts' && + name.toLowerCase() !== 'npm_config_userconfig', + ), + ), + npm_config_userconfig: userConfigPath, + }; +} export function isThirdPartyDevelopmentArtifact(relativePath) { const segments = relativePath.split(/[\\/]/).filter(Boolean); diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 53ea3d6f3b..46459771c7 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -1,11 +1,39 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { + collectWorkspaceDependencyClosure, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, + workspaceReleaseFiles, } from './release-cli-file-policy.mjs'; describe('CLI release file policy', () => { + test('derives the runtime workspace build order from production dependencies', () => { + const manifests = new Map([ + ['maka-agent', { name: 'maka-agent', dependencies: { '@maka/eval': '0.1.0' } }], + ['@maka/eval', { name: '@maka/eval', dependencies: { '@maka/core': '0.1.0' } }], + ['@maka/core', { name: '@maka/core' }], + ]); + + assert.deepEqual(collectWorkspaceDependencyClosure('maka-agent', manifests), [ + '@maka/core', + '@maka/eval', + 'maka-agent', + ]); + }); + + test('release file declarations cannot escape or overlap their workspace', () => { + for (const releaseFiles of [ + ['dist', '../secret'], + ['dist', 'dist/runtime'], + ]) { + assert.throws( + () => workspaceReleaseFiles({ name: '@maka/example', releaseFiles }), + /unsafe|overlap/u, + ); + } + }); + test('rejects third-party development artifacts on every platform', () => { for (const path of [ 'coverage/lcov.info', diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index ca13052376..05567c2ae7 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -22,6 +22,9 @@ import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy import { isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, + releaseNpmEnvironment, + resolveReleaseWorkspacePackages, + workspaceReleaseFiles, } from './release-cli-file-policy.mjs'; const repoRoot = resolve(import.meta.dirname, '..'); @@ -36,27 +39,12 @@ const unsupportedArguments = process.argv if (unsupportedArguments.length > 0) { throw new Error(`Unsupported release argument: ${unsupportedArguments.join(', ')}`); } -const internalPackageNames = [ - '@maka/core', - '@maka/eval', - '@maka/mcp', - '@maka/runtime', - '@maka/runtime-host', - '@maka/storage', -]; +const workspacePackages = resolveReleaseWorkspacePackages(repoRoot); +const internalPackageNames = workspacePackages + .map(({ name }) => name) + .filter((name) => name !== 'maka-agent'); const internalPackageSet = new Set(internalPackageNames); -const buildOrder = [ - '@maka/core', - '@maka/storage', - '@maka/mcp', - '@maka/runtime', - '@maka/runtime-host', - '@maka/eval', - 'maka-agent', -]; -const evalAssets = readJson(join(repoRoot, 'packages/eval/package.json')).releaseFiles.filter( - (path) => path !== 'dist', -); +const buildOrder = workspacePackages.map(({ name }) => name); const strippedInstallScripts = new Map([ // The clean repository install has already produced every generated file and // platform prebuild copied below. Do not run advisory postinstalls on an end @@ -145,10 +133,15 @@ function buildFromCleanDependencyTree() { }); execFileSync('tar', ['-xf', archivePath, '-C', cleanRoot], { stdio: 'inherit' }); console.log('[release-cli] installing the committed dependency tree with npm ci'); - execFileSync('npm', ['ci'], npmSpawnOptions({ cwd: cleanRoot, stdio: 'inherit' })); + const cleanEnvironment = releaseNpmEnvironment(process.env, join(cleanRoot, '.npmrc')); + execFileSync( + 'npm', + ['ci'], + npmSpawnOptions({ cwd: cleanRoot, env: cleanEnvironment, stdio: 'inherit' }), + ); execFileSync(process.execPath, [join(cleanRoot, 'scripts/release-cli-package.mjs')], { cwd: cleanRoot, - env: { ...process.env, MAKA_CLI_RELEASE_PREPARED_TREE: '1' }, + env: { ...cleanEnvironment, MAKA_CLI_RELEASE_PREPARED_TREE: '1' }, stdio: 'inherit', }); @@ -201,7 +194,12 @@ function checkProductionAudit() { const audit = spawnSync( 'npm', ['audit', '--omit=dev', '--workspace', 'maka-agent', '--json'], - npmSpawnOptions({ cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }), + npmSpawnOptions({ + cwd: repoRoot, + encoding: 'utf8', + env: releaseNpmEnvironment(process.env, join(repoRoot, '.npmrc')), + maxBuffer: 64 * 1024 * 1024, + }), ); const report = JSON.parse(audit.stdout || '{}'); const vulnerabilities = report.metadata?.vulnerabilities; @@ -222,7 +220,7 @@ function readCliDependencyTree() { } function copyCliRuntime() { - copyRuntimeDist(cliSource, stageRoot, { excludeDevCli: true }); + copyRuntimeDist(cliSource, stageRoot); chmodSync(join(stageRoot, 'dist/cli.js'), 0o755); } @@ -244,7 +242,7 @@ function copyDependencyClosure(cli) { } if (!previous) { if (internalPackageSet.has(dependency.name)) { - copyInternalPackage(source, destination, dependency.name === '@maka/eval'); + copyInternalPackage(source, destination); } else { copyThirdPartyPackage(source, destination); } @@ -319,7 +317,7 @@ function dependencyDestination(dependency) { throw new Error(`Dependency path is outside the supported installed tree: ${sourcePath}`); } -function copyInternalPackage(source, destination, includeEvalAssets) { +function copyInternalPackage(source, destination) { mkdirSync(destination, { recursive: true, mode: 0o755 }); const manifest = readJson(join(source, 'package.json')); const allowedFields = [ @@ -344,31 +342,17 @@ function copyInternalPackage(source, destination, includeEvalAssets) { .map((field) => [field, manifest[field]]), ); writeFileSync(join(destination, 'package.json'), `${JSON.stringify(releaseManifest, null, 2)}\n`); - copyRuntimeDist(source, destination); - if (includeEvalAssets) { - for (const asset of evalAssets) copyDeclaredFile(source, destination, asset); + for (const releaseFile of workspaceReleaseFiles(manifest)) { + if (releaseFile === 'dist') copyRuntimeDist(source, destination); + else copyDeclaredFile(source, destination, releaseFile); } } -function copyRuntimeDist(source, destination, options = {}) { +function copyRuntimeDist(source, destination) { const sourceDist = join(source, 'dist'); if (!existsSync(sourceDist)) throw new Error(`Missing build output: ${sourceDist}`); copyTreeFiles(sourceDist, join(destination, 'dist'), (relativePath) => { - const segments = relativePath.split(sep); - const file = segments.at(-1) ?? ''; - if ( - segments.some( - (segment) => - segment === '__tests__' || segment === '__fixtures__' || segment === 'test-only', - ) - ) { - return false; - } - if (/(?:^|\.)test\.js$/.test(file) || file.endsWith('.d.ts') || file.endsWith('.map')) { - return false; - } - if (options.excludeDevCli && file === 'dev-cli.js') return false; - return file.endsWith('.js') || file.endsWith('.json'); + return !isMakaDevelopmentArtifact(join('dist', relativePath)); }); } @@ -707,10 +691,16 @@ function readJson(path) { } function runNpm(args, options = {}) { + const environment = releaseNpmEnvironment(options.env ?? process.env, join(repoRoot, '.npmrc')); return execFileSync( 'npm', args, - npmSpawnOptions({ cwd: repoRoot, stdio: options.encoding ? undefined : 'inherit', ...options }), + npmSpawnOptions({ + cwd: repoRoot, + stdio: options.encoding ? undefined : 'inherit', + ...options, + env: environment, + }), ); } From 7f3594f41a2954539880da06712a2fdb04483ee4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:27:30 +0800 Subject: [PATCH 04/31] fix(release): define exact draft recovery Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 8 +++ .github/workflows/release-cli-stage.yml | 2 +- .github/workflows/release.yml | 73 ++++++++++++++++++------- docs/README.md | 1 + docs/cli-distribution.md | 16 ++++-- docs/cli-npm-release.md | 20 ++++--- docs/cli-npm-release.zh-CN.md | 19 ++++--- scripts/product-release.test.mjs | 37 ++++--------- 8 files changed, 109 insertions(+), 67 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 75d8d5f0e8..b06dc01d22 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -49,6 +49,12 @@ must never be exposed to fork or ordinary pull-request jobs. 8. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, all manifest archives, and the expected Dugite native release are present. +If the publish job created the product tag or Draft but failed before every asset was uploaded, rerun +`Release` from `main` with `source_commit` set to the exact commit already named by the tag. This +input is recovery-only: the workflow requires it to remain an ancestor of `main`, rejects a tag that +points elsewhere, refuses to replace a published Release, and overwrites only assets on the existing +Draft. + ## Acceptance on another Apple Silicon Mac Download the DMG, CLI ZIP, and their checksum files through a browser from the Draft. Do not move @@ -61,6 +67,8 @@ boundary. 3. Run `spctl --assess --type execute --verbose=4 /Applications/Maka.app` and confirm a Developer ID origin. 4. Extract the CLI ZIP without clearing quarantine. Run `bin/maka --version` and `bin/maka --help`. + Keep the Mac online for this first Gatekeeper assessment: the notarized ZIP cannot carry a + stapled ticket, so macOS may retrieve it from Apple. 5. Create an external link, for example `ln -s "$PWD/bin/maka" /tmp/maka-release-acceptance`, and confirm the linked command reports the same version and help output. 6. Start `bin/maka` with no arguments and confirm the TUI renders, accepts input, and exits cleanly. diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 012cd63c33..681a972feb 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -152,7 +152,7 @@ jobs: echo "## maka-agent@$RELEASE_VERSION staging" echo echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." - echo "After the package becomes public, run **Finalize CLI npm release** with:" + echo "After the package becomes public, run **Finalize CLI npm channel** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59f1581aa5..4cec6dd7d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,11 @@ name: Release on: workflow_dispatch: + inputs: + source_commit: + description: Exact main ancestor used only to retry an interrupted Draft publication + required: false + type: string permissions: contents: read @@ -23,6 +28,7 @@ jobs: exe: ${{ steps.identity.outputs.exe }} cli_archive: ${{ steps.identity.outputs.cli_archive }} cli_checksum: ${{ steps.identity.outputs.cli_checksum }} + source_archive: ${{ steps.identity.outputs.source_archive }} node_version: ${{ steps.identity.outputs.node_version }} npm_version: ${{ steps.identity.outputs.npm_version }} node_archive: ${{ steps.identity.outputs.node_archive }} @@ -33,14 +39,41 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ inputs.source_commit || github.sha }} fetch-depth: 0 persist-credentials: false - name: Resolve product release identity id: identity + env: + GITHUB_SHA: ${{ inputs.source_commit || github.sha }} run: node scripts/product-release-identity.mjs + - name: Require the release source on main + env: + SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }} + run: git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main + + - name: Restrict an explicit source to Draft recovery + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_SOURCE: ${{ inputs.source_commit }} + SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }} + TAG: ${{ steps.identity.outputs.tag }} + run: | + if [[ -z "$RECOVERY_SOURCE" ]]; then + exit 0 + fi + tagged_source="$(git ls-remote --tags --refs origin "refs/tags/$TAG" | awk '{print $1}')" + if [[ "$tagged_source" != "$SOURCE_COMMIT" ]]; then + echo "Recovery tag ${TAG} does not point to ${SOURCE_COMMIT}." >&2 + exit 1 + fi + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then + echo "Recovery requires an existing Draft release ${TAG}." >&2 + exit 1 + fi + desktop: needs: release-identity if: github.ref == 'refs/heads/main' @@ -67,7 +100,7 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ needs.release-identity.outputs.source_commit }} fetch-depth: 0 persist-credentials: false @@ -206,7 +239,7 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ needs.release-identity.outputs.source_commit }} fetch-depth: 0 persist-credentials: false @@ -297,7 +330,7 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ needs.release-identity.outputs.source_commit }} persist-credentials: false - name: Set up the pinned release Node.js @@ -318,21 +351,21 @@ jobs: run: npm run prepare:bundled-git-source - name: Archive and hash source materials + env: + SOURCE_ARCHIVE: ${{ needs.release-identity.outputs.source_archive }} run: | - version="${{ needs.release-identity.outputs.version }}" archive_dir="apps/desktop/release-sources" - archive_name="Maka-${version}-bundled-git-source.tar.gz" - archive="${archive_dir}/${archive_name}" + archive="${archive_dir}/${SOURCE_ARCHIVE}" tar -C apps/desktop/release-sources/bundled-git -czf "$archive" . - (cd "$archive_dir" && sha256sum "$archive_name" > "${archive_name}.sha256") + (cd "$archive_dir" && sha256sum "$SOURCE_ARCHIVE" > "${SOURCE_ARCHIVE}.sha256") - name: Upload verified source assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-source path: | - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz.sha256 + apps/desktop/release-sources/${{ needs.release-identity.outputs.source_archive }} + apps/desktop/release-sources/${{ needs.release-identity.outputs.source_archive }}.sha256 if-no-files-found: error retention-days: 7 @@ -353,7 +386,7 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ needs.release-identity.outputs.source_commit }} fetch-depth: 0 persist-credentials: false @@ -365,18 +398,16 @@ jobs: merge-multiple: true - name: Verify the complete required artifact set - env: - VERSION: ${{ needs.release-identity.outputs.version }} run: | required=( - "Maka-${VERSION}-mac-arm64.dmg" - "Maka-${VERSION}-mac-arm64.dmg.sha256" - "Maka-${VERSION}-win-x64.exe" - "Maka-${VERSION}-win-x64.exe.sha256" - "Maka-${VERSION}-cli-mac-arm64.zip" - "Maka-${VERSION}-cli-mac-arm64.zip.sha256" - "Maka-${VERSION}-bundled-git-source.tar.gz" - "Maka-${VERSION}-bundled-git-source.tar.gz.sha256" + "${{ needs.release-identity.outputs.dmg }}" + "${{ needs.release-identity.outputs.dmg }}.sha256" + "${{ needs.release-identity.outputs.exe }}" + "${{ needs.release-identity.outputs.exe }}.sha256" + "${{ needs.release-identity.outputs.cli_archive }}" + "${{ needs.release-identity.outputs.cli_checksum }}" + "${{ needs.release-identity.outputs.source_archive }}" + "${{ needs.release-identity.outputs.source_archive }}.sha256" ) for name in "${required[@]}"; do test -f "release-assets/${name}" diff --git a/docs/README.md b/docs/README.md index 5bad119f49..1c8d34a9da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ This page is the authority map for Maka documentation. Code and contract tests r ### Release operations +- [CLI/TUI distribution contract](./cli-distribution.md) - [CLI npm release](./cli-npm-release.md) ([中文](./cli-npm-release.zh-CN.md)) ### Security and privacy diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index fa5f7e9ae9..f6042f7437 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -42,6 +42,12 @@ The CLI-specific `THIRD_PARTY_NOTICES.txt` must enumerate exactly the external p dependencies recorded in `RELEASE.json`. The archive checksum is generated only after signing and notarization complete. +Every Mach-O file inside the archive is signed and the ZIP is submitted to Apple's notary service. +ZIP files cannot carry a stapled notarization ticket, so the first Gatekeeper assessment on another +Mac may require network access to retrieve the ticket from Apple. The embedded code signatures and +published SHA-256 remain available for offline verification; do not describe the ZIP itself as +stapled. + ## Release and installation boundary Root `package.json` is the sole version authority. Desktop and CLI manifests must match before @@ -49,16 +55,18 @@ packaging. Desktop, CLI/TUI, and source jobs build independently from one commit collects their verified outputs and creates one Draft GitHub Release. The GitHub Release ZIP is the immutable standalone distribution source. npm keeps its -installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but derives the same -version, runtime closure, file policy, notices, and source identity. It does not create a tag or -GitHub Release and does not block the product Draft. Homebrew must consume the standalone ZIP. +installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but may start only after +the product `v` tag and GitHub Release exist. It checks out that tag's exact commit and +derives the same version, runtime closure, file policy, notices, and source identity. It does not +create a tag or GitHub Release and does not block creation of the product Draft. Homebrew must +consume the standalone ZIP. ## Decision ledger | Question | Decision | Enforced by | | --- | --- | --- | | Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | -| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. | `release.yml` identity and publish jobs | +| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | | Which artifacts are required? | macOS and Windows Desktop installers, the macOS arm64 standalone CLI ZIP, and bundled source. | The publish job's explicit artifact allowlist | | Is npm another release authority? | No. It is an optional install channel with its own OIDC, staging, verification, and 2FA boundaries. | Read-only npm finalize workflow; no tag or GitHub Release mutation | | Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index daae46b16a..7cab61c881 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -10,7 +10,7 @@ This runbook is the operational authority for publishing the `maka-agent` npm in - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. -- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow. +- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow, and must already exist before npm staging. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package maintainer approves the staged package with npm 2FA. - Do not rebuild between validation, staging, approval, and finalization. @@ -18,9 +18,10 @@ This runbook is the operational authority for publishing the `maka-agent` npm in The two workflow boundaries are: -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) builds and validates one - immutable tarball, records its source identity, enters the protected `npm-release` Environment, - and submits it to npm staging through OIDC. +1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) resolves the existing product + tag and GitHub Release, checks out that exact product commit, builds and validates one immutable + tarball, records both product and workflow identity, enters the protected `npm-release` + Environment, and submits it to npm staging through OIDC. 2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. ## One-time control-plane configuration @@ -61,7 +62,9 @@ package owner or recovery access as part of that change. 1. Merge all intended package, documentation, and release changes to `main`. 2. Set the root product version, `apps/desktop/package.json`, and `packages/cli/package.json` to the same unused target version and merge that change. The npm channel maps prerelease versions to `next` and stable versions to `latest`. -3. Confirm the target version is absent from both public and staged package state: +3. Run the product `Release` workflow and confirm its Draft `v` Release points to the + intended source commit. npm staging consumes this identity and cannot precede it. +4. Confirm the target version is absent from both public and staged package state: ```sh version=0.1.0-beta.1 @@ -71,13 +74,14 @@ package owner or recovery access as part of that change. The first command should report that the target version is not present. Resolve any existing stage instead of submitting the same version again. -4. Confirm the `npm-release` Environment and Trusted Publisher still match the values above and the +5. Confirm the `npm-release` Environment and Trusted Publisher still match the values above and the approving npm account has 2FA enabled. ## Stage the candidate 1. Open **Actions → Stage CLI npm release → Run workflow**. -2. Select `main` and enter the exact version from `packages/cli/package.json`. +2. Select `main` and enter the exact product version. The workflow resolves `v` and builds + its exact commit even if `main` has advanced since the Draft was created. 3. Wait for the reusable package validation jobs to pass. They build one tarball and validate the installed CLI on Linux x64, macOS arm64, and Windows x64, plus real Harbor and Pier Docker cells on Linux x64. @@ -137,7 +141,7 @@ not dist-tag mutations, and the release workflows must not gain a long-lived npm After npm reports the version as public: -1. Open **Actions → Finalize CLI npm release → Run workflow** on `main`. +1. Open **Actions → Finalize CLI npm channel → Run workflow** on `main`. 2. Enter the successful Stage run ID, its exact run attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index c28d520bd4..3c5dc56fe0 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -9,7 +9,7 @@ - 只从 `main` dispatch 发布 workflow; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; -- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理; +- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准 staged package; - validation、staging、approval 和 finalization 之间不得重新构建; @@ -17,9 +17,9 @@ 两个 workflow 边界分别是: -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 构建并验证一个 immutable - tarball,记录其 source identity,进入受保护的 `npm-release` Environment,然后通过 OIDC - 提交到 npm staging; +1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub + Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,分别记录产品与 + workflow identity,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; 2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 ## 一次性控制面配置 @@ -60,7 +60,9 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token 1. 将本次包、文档和发布变更全部合并到 `main`; 2. 将根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 设置为同一个尚未使用的目标版本并合并。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; -3. 确认目标版本既不在公共 registry,也不在 staged package 中: +3. 运行产品 `Release` workflow,确认其 Draft `v` Release 指向预期 source commit;npm + staging 消费这个身份,不能先于它运行; +4. 确认目标版本既不在公共 registry,也不在 staged package 中: ```sh version=0.1.0-beta.1 @@ -69,13 +71,14 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ``` 第一个命令应报告目标版本不存在。如果已经存在同版本 stage,先处理它,不要再次提交; -4. 确认 `npm-release` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 +5. 确认 `npm-release` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 npm 账号已经启用 2FA。 ## Stage 候选包 1. 打开 **Actions → Stage CLI npm release → Run workflow**; -2. 选择 `main`,输入 `packages/cli/package.json` 中的精确版本; +2. 选择 `main`,输入精确产品版本;即使 Draft 创建后 `main` 已前进,workflow 仍会解析 + `v` 并构建它的精确 commit; 3. 等待可复用 package validation jobs 全部通过。它们只构建一个 tarball,并在 Linux x64、 macOS arm64、Windows x64 上验证安装态 CLI,在 Linux x64 上运行真实 Harbor 和 Pier Docker cell; @@ -133,7 +136,7 @@ release workflow 不得获得长期 npm token。 npm 显示该版本已经公开后: -1. 在 `main` 上打开 **Actions → Finalize CLI npm release → Run workflow**; +1. 在 `main` 上打开 **Actions → Finalize CLI npm channel → Run workflow**; 2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 56055d2ef4..3533f0d878 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -10,7 +10,6 @@ import { parseCliReleaseVersion } from './release-cli-publication.mjs'; import { planTests } from './ci-test-plan.mjs'; import { assertProductReleaseExpectation, - readProductReleaseIdentity, releaseToolchainFromManifest, resolveProductReleaseIdentity, } from './product-release-identity.mjs'; @@ -139,17 +138,6 @@ test('the root manifest pins the Node archive and npm used by release jobs', () }); }); -test('the checked-in manifests resolve to one releasable product identity', async () => { - const identity = await readProductReleaseIdentity({ - ref: 'refs/heads/main', - sha: 'b'.repeat(40), - }); - - assert.equal(identity.version, '0.1.11'); - assert.equal(identity.npmVersion, '11.19.0'); - assert.deepEqual(identity.publicCommands, ['maka']); -}); - test('standalone verification recognizes the current TUI status line through ANSI output', () => { assert.equal( isTuiReadyOutput( @@ -199,7 +187,6 @@ test('the standalone maka launcher is relocatable and uses the embedded runtime' assert.match(wrapper, /while \[ -L "\$launcher" \]/u); assert.match(wrapper, /libexec\/node\/bin\/node/u); assert.match(wrapper, /libexec\/node_modules\/maka-agent\/dist\/cli\.js/u); - assert.doesNotMatch(wrapper, /maka-agent.*launcher/u); }); test('the Eval workspace owns the complete runtime asset declaration', async () => { @@ -318,6 +305,16 @@ test('one product workflow gates one draft release on every required artifact', ]); assert.equal(jobs.publish.if, "github.ref == 'refs/heads/main'"); assert.equal(Object.hasOwn(jobs, 'npm'), false); + assert.equal( + jobs['release-identity'].steps[0].with.ref, + '${{ inputs.source_commit || github.sha }}', + ); + for (const name of ['desktop', 'cli-macos-arm64', 'source', 'publish']) { + const checkout = jobs[name].steps.find((step) => + String(step.uses).startsWith('actions/checkout@'), + ); + assert.equal(checkout.with.ref, '${{ needs.release-identity.outputs.source_commit }}'); + } const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) @@ -329,6 +326,8 @@ test('one product workflow gates one draft release on every required artifact', assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); assert.match(commands, /product-release-tag\.mjs ensure/u); + assert.match(commands, /RECOVERY_SOURCE/u); + assert.match(commands, /--json isDraft/u); assert.match(commands, /gh release create[\s\S]*--verify-tag/u); assert.match(commands, /gh release upload[\s\S]*--clobber/u); assert.doesNotMatch(commands, /gh release create[\s\S]*--target/u); @@ -339,18 +338,6 @@ test('one product workflow gates one draft release on every required artifact', ); }); -test('npm finalization verifies the channel without creating a tag or GitHub Release', async () => { - const source = await readFile( - new URL('../.github/workflows/release-cli-finalize.yml', import.meta.url), - 'utf8', - ); - const workflow = parseYaml(source); - - assert.deepEqual(Object.keys(workflow.jobs), ['inspect']); - assert.equal(workflow.permissions.contents, 'read'); - assert.doesNotMatch(source, /cli-v|gh release|git\/refs|contents: write/u); -}); - test('npm channel identity has no independent product tag', () => { assert.deepEqual(parseCliReleaseVersion('1.2.3'), { version: '1.2.3', From c34585fd22a41132c29b1162fa3d0983dd5fd36c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:29:56 +0800 Subject: [PATCH 05/31] fix(release): prune non-macOS native artifacts Generated-by: Codex --- scripts/package-macos-arm64-cli.mjs | 7 +++---- scripts/product-release.test.mjs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index aa8cca36cd..10c54600ea 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -479,8 +479,9 @@ export function isMacosArm64MachO(architectures, buildVersion) { export function macosArm64MachOAction(architectures, buildVersion) { const architectureList = architectures.trim().split(/\s+/u).filter(Boolean); - if (!architectureList.includes('arm64')) return 'remove'; - if (!/^\s*platform MACOS\s*$/m.test(buildVersion)) return 'reject'; + if (!architectureList.includes('arm64') || !/^\s*platform MACOS\s*$/m.test(buildVersion)) { + return 'remove'; + } return architectureList.length === 1 ? 'keep' : 'thin'; } @@ -497,8 +498,6 @@ async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run const action = macosArm64MachOAction(architectures.stdout, buildVersion.stdout); if (action === 'remove') { await rm(binaryPath, { force: true }); - } else if (action === 'reject') { - throw new Error(`Mach-O file does not target macOS arm64: ${binaryPath}`); } else if (action === 'thin') { const thinnedPath = `${binaryPath}.arm64`; try { diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 3533f0d878..725e39c4eb 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -152,7 +152,7 @@ test('standalone packaging keeps or thins arm64 Mach-O files instead of deleting assert.equal(macosArm64MachOAction('arm64', macos), 'keep'); assert.equal(macosArm64MachOAction('x86_64 arm64', macos), 'thin'); assert.equal(macosArm64MachOAction('x86_64', macos), 'remove'); - assert.equal(macosArm64MachOAction('arm64', 'platform IOS\n'), 'reject'); + assert.equal(macosArm64MachOAction('arm64', 'platform IOS\n'), 'remove'); }); test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identity', () => { From 76dcec3118fb43a7f68c5297576c50617183d237 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:31:50 +0800 Subject: [PATCH 06/31] fix(release): pass the product tag into staging Generated-by: Codex --- .github/workflows/release-cli-stage.yml | 4 ++-- scripts/release-cli-workflow-policy.test.mjs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 681a972feb..117ce997b3 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -41,7 +41,6 @@ jobs: id: product env: EXPECTED_VERSION: ${{ inputs.version }} - PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} GH_TOKEN: ${{ github.token }} PRODUCT_TAG: v${{ inputs.version }} run: | @@ -53,7 +52,7 @@ jobs: test "$release_tag" = "$PRODUCT_TAG" git fetch --no-tags origin "refs/tags/$PRODUCT_TAG" source_commit="$(git rev-list -n 1 FETCH_HEAD)" - test "$(git rev-parse FETCH_HEAD^{commit})" = "$source_commit" + test "$(git rev-parse "FETCH_HEAD^{commit}")" = "$source_commit" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" @@ -111,6 +110,7 @@ jobs: id: release env: EXPECTED_VERSION: ${{ inputs.version }} + PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index a99745c66d..c605e18331 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -57,6 +57,8 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(workflow, /gh release view "\$PRODUCT_TAG"/u); assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); assert.doesNotMatch(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); + const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); + assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); test('finalize validates one exact stage attempt before running the current verifier', () => { From ce9edeeae9a2376661cfd1528c9c4bd022d4ede4 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:33:56 +0800 Subject: [PATCH 07/31] fix(release): carry the incubator disclaimer in CLI ZIPs Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 2 +- docs/cli-distribution.md | 3 ++- scripts/package-macos-arm64-cli.mjs | 2 ++ scripts/verify-macos-arm64-cli.mjs | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index b06dc01d22..e98ffb1c27 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -41,7 +41,7 @@ must never be exposed to fork or ordinary pull-request jobs. - `Maka--cli-mac-arm64.zip` and checksum; - `Maka--bundled-git-source.tar.gz` and checksum; - the platform update metadata and Desktop ZIPs produced by electron-builder. -6. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `LICENSE`, `NOTICE`, +6. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.txt`, the pinned Node license, and no `bin/maka-agent`. 7. Confirm `RELEASE.json` records the Draft's product version and source SHA, the official Node URL/archive/digest, npm version, workspace and production dependency closures, dependency diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index f6042f7437..3b65bf5ff9 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -39,7 +39,8 @@ The TUI is the default interactive mode of `maka`, not a separate artifact. | `signing` | `developer-id-notarized` for release artifacts; `development` for local checks | The CLI-specific `THIRD_PARTY_NOTICES.txt` must enumerate exactly the external production -dependencies recorded in `RELEASE.json`. The archive checksum is generated only after signing and +dependencies recorded in `RELEASE.json`. The archive also carries the repository's +`DISCLAIMER-WIP`, `LICENSE`, and `NOTICE`. The archive checksum is generated only after signing and notarization complete. Every Mach-O file inside the archive is signed and the ZIP is submitted to Apple's notary service. diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index 10c54600ea..a9f5201545 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -735,6 +735,7 @@ export async function packageMacosArm64Cli({ const version = identity.version; await Promise.all([ + access(join(repoRoot, 'DISCLAIMER-WIP')), access(join(repoRoot, 'LICENSE')), access(join(repoRoot, 'NOTICE')), ...workspacePackages.map(({ directory }) => access(join(directory, 'dist'))), @@ -788,6 +789,7 @@ export async function packageMacosArm64Cli({ await Promise.all([ copyFile(officialNode.execPath, join(embeddedNodeDirectory, 'bin', 'node')), copyFile(officialNode.licensePath, join(embeddedNodeDirectory, 'LICENSE')), + copyFile(join(repoRoot, 'DISCLAIMER-WIP'), join(archiveRoot, 'DISCLAIMER-WIP')), copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')), copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')), writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), 'utf8'), diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index 5881f7c71a..88724399b0 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -532,6 +532,7 @@ export async function verifyMacosArm64Cli( makaPath, metadataPath, thirdPartyNoticesPath, + join(archiveRoot, 'DISCLAIMER-WIP'), join(archiveRoot, 'LICENSE'), join(archiveRoot, 'NOTICE'), join(archiveRoot, 'libexec', 'node', 'LICENSE'), From 18591b4204e1efd589fb38d975b527ab9e449351 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:40:04 +0800 Subject: [PATCH 08/31] fix(release): order workspace builds by build dependencies Generated-by: Codex --- scripts/release-cli-file-policy.mjs | 31 ++++++++++++++++++++++++ scripts/release-cli-file-policy.test.mjs | 15 ++++++++++++ scripts/release-cli-package.mjs | 3 ++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index bb464283ae..96ecac0fbe 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -48,6 +48,37 @@ export function collectWorkspaceDependencyClosure(entryName, manifestsByName) { return closure; } +export function orderWorkspaceBuilds(workspaces) { + const workspacesByName = new Map(workspaces.map((workspace) => [workspace.name, workspace])); + const order = []; + const complete = new Set(); + const visiting = new Set(); + + function visit(packageName) { + if (complete.has(packageName)) return; + if (visiting.has(packageName)) { + throw new Error(`Workspace build dependency cycle reached ${packageName}.`); + } + const workspace = workspacesByName.get(packageName); + if (!workspace) throw new Error(`Selected workspace package ${packageName} is missing.`); + visiting.add(packageName); + const manifest = manifestFromEntry(workspace); + const dependencies = { + ...manifest.dependencies, + ...manifest.devDependencies, + }; + for (const dependencyName of Object.keys(dependencies).sort()) { + if (workspacesByName.has(dependencyName)) visit(dependencyName); + } + visiting.delete(packageName); + complete.add(packageName); + order.push(packageName); + } + + for (const { name } of workspaces) visit(name); + return order; +} + export function workspaceReleaseFiles(manifest) { const declared = Object.hasOwn(manifest, 'releaseFiles') ? manifest.releaseFiles : ['dist']; if (!Array.isArray(declared) || declared.length === 0) { diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 46459771c7..d5051f41f2 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -4,6 +4,7 @@ import { collectWorkspaceDependencyClosure, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, + orderWorkspaceBuilds, workspaceReleaseFiles, } from './release-cli-file-policy.mjs'; @@ -22,6 +23,20 @@ describe('CLI release file policy', () => { ]); }); + test('orders selected runtime workspaces by local build-time dependencies', () => { + const selected = [ + { name: '@maka/runtime', manifest: { devDependencies: { '@maka/storage': '0.1.0' } } }, + { name: '@maka/storage', manifest: {} }, + { name: 'maka-agent', manifest: { dependencies: { '@maka/runtime': '0.1.0' } } }, + ]; + + assert.deepEqual(orderWorkspaceBuilds(selected), [ + '@maka/storage', + '@maka/runtime', + 'maka-agent', + ]); + }); + test('release file declarations cannot escape or overlap their workspace', () => { for (const releaseFiles of [ ['dist', '../secret'], diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 05567c2ae7..cee2c7def9 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -22,6 +22,7 @@ import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy import { isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, + orderWorkspaceBuilds, releaseNpmEnvironment, resolveReleaseWorkspacePackages, workspaceReleaseFiles, @@ -44,7 +45,7 @@ const internalPackageNames = workspacePackages .map(({ name }) => name) .filter((name) => name !== 'maka-agent'); const internalPackageSet = new Set(internalPackageNames); -const buildOrder = workspacePackages.map(({ name }) => name); +const buildOrder = orderWorkspaceBuilds(workspacePackages); const strippedInstallScripts = new Map([ // The clean repository install has already produced every generated file and // platform prebuild copied below. Do not run advisory postinstalls on an end From 0190b71d703cb6cdc1016a0aa91cc271f5cef576 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:46:12 +0800 Subject: [PATCH 09/31] fix(release): close product authority races Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 4 +-- .github/workflows/release-cli-stage.yml | 26 ++++++++++------ .github/workflows/release.yml | 24 ++++++++++++--- scripts/product-release-identity.mjs | 10 ++----- scripts/product-release-tag.mjs | 8 ++++- scripts/product-release.test.mjs | 24 ++++++++++++++- scripts/release-cli-publication.mjs | 28 +++--------------- scripts/release-cli-publication.test.mjs | 2 +- scripts/release-cli-workflow-policy.test.mjs | 6 ++++ scripts/release-version.mjs | 31 ++++++++++++++++++++ 10 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 scripts/release-version.mjs diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index e98ffb1c27..dcf2b52117 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -52,8 +52,8 @@ must never be exposed to fork or ordinary pull-request jobs. If the publish job created the product tag or Draft but failed before every asset was uploaded, rerun `Release` from `main` with `source_commit` set to the exact commit already named by the tag. This input is recovery-only: the workflow requires it to remain an ancestor of `main`, rejects a tag that -points elsewhere, refuses to replace a published Release, and overwrites only assets on the existing -Draft. +points elsewhere, refuses to replace a published Release, and replaces the Draft's asset set with the +newly verified artifacts. If only the tag exists, the retry creates the missing Draft. ## Acceptance on another Apple Silicon Mac diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 117ce997b3..31a929faf0 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -44,10 +44,7 @@ jobs: GH_TOKEN: ${{ github.token }} PRODUCT_TAG: v${{ inputs.version }} run: | - if [[ ! "$EXPECTED_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Expected an exact product version; found $EXPECTED_VERSION" >&2 - exit 1 - fi + node scripts/release-version.mjs "$EXPECTED_VERSION" release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" test "$release_tag" = "$PRODUCT_TAG" git fetch --no-tags origin "refs/tags/$PRODUCT_TAG" @@ -166,10 +163,21 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Submit the candidate to npm staging env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.authorize.outputs.source_commit }} + PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} - run: >- - npm stage publish "$RELEASE_TARBALL" - --tag "$RELEASE_DIST_TAG" - --registry https://registry.npmjs.org/ - --provenance + run: | + current_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" + if [[ "$current_source" != "$PRODUCT_SOURCE_COMMIT" ]]; then + echo "Product tag ${PRODUCT_TAG} no longer points to ${PRODUCT_SOURCE_COMMIT}." >&2 + exit 1 + fi + release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" + test "$release_tag" = "$PRODUCT_TAG" + + npm stage publish "$RELEASE_TARBALL" \ + --tag "$RELEASE_DIST_TAG" \ + --registry https://registry.npmjs.org/ \ + --provenance diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cec6dd7d5..7b09b5a2b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,9 +69,11 @@ jobs: echo "Recovery tag ${TAG} does not point to ${SOURCE_COMMIT}." >&2 exit 1 fi - if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then - echo "Recovery requires an existing Draft release ${TAG}." >&2 - exit 1 + if gh release view "$TAG" >/dev/null 2>&1; then + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then + echo "Recovery cannot replace published Release ${TAG}." >&2 + exit 1 + fi fi desktop: @@ -456,7 +458,21 @@ jobs: --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" --title "Maka ${VERSION}" --notes "$notes" - gh release upload "$TAG" release-assets/* --clobber + asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" + if [[ -n "$asset_names" ]]; then + while IFS= read -r asset; do + gh release delete-asset "$TAG" "$asset" --yes + done <<< "$asset_names" + fi + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then + echo "Release ${TAG} was published while assets were replaced." >&2 + exit 1 + fi + gh release upload "$TAG" release-assets/* echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 6b5f6ca56b..ef77864e07 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -1,16 +1,10 @@ import { appendFile, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parseProductReleaseVersion } from './release-version.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); -function exactVersion(value, label) { - if (typeof value !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value)) { - throw new Error(`${label} must be an exact product version`); - } - return value; -} - export function releaseToolchainFromManifest(rootManifest) { const nodeVersion = rootManifest.releaseToolchain?.node; const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; @@ -39,7 +33,7 @@ export function resolveProductReleaseIdentity({ ref, sha, }) { - const version = exactVersion(rootManifest.version, 'Root package version'); + const { version } = parseProductReleaseVersion(rootManifest.version); for (const [label, manifest] of [ ['Desktop', desktopManifest], ['CLI', cliManifest], diff --git a/scripts/product-release-tag.mjs b/scripts/product-release-tag.mjs index bf82e959a3..527f459536 100644 --- a/scripts/product-release-tag.mjs +++ b/scripts/product-release-tag.mjs @@ -1,13 +1,19 @@ import { execFile } from 'node:child_process'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { parseProductReleaseVersion } from './release-version.mjs'; const execFileAsync = promisify(execFile); function validateInputs(tag, source) { - if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(tag)) { + if (!tag.startsWith('v')) { throw new Error(`Product tag must be an exact version tag; found ${tag}`); } + try { + parseProductReleaseVersion(tag.slice(1)); + } catch (error) { + throw new Error(`Product tag must be an exact version tag; found ${tag}`, { cause: error }); + } if (!/^[0-9a-f]{40}$/u.test(source)) { throw new Error(`Product tag source must be an exact commit SHA; found ${source}`); } diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 725e39c4eb..b87d06df33 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -58,6 +58,20 @@ test('one root version defines every product artifact from one main commit', () assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('product and npm release identities reject the same non-canonical versions', () => { + for (const version of ['01.2.3', '1.2.3-beta..1', '1.2.3-.', '1.2.3-beta.01']) { + const manifests = { + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + ref: 'refs/heads/main', + sha: 'a'.repeat(40), + }; + assert.throws(() => resolveProductReleaseIdentity(manifests), /valid product release version/u); + assert.throws(() => parseCliReleaseVersion(version), /valid product release version/u); + } +}); + test('an npm candidate must name the exact product tag, version, and source commit', () => { const identity = resolveProductReleaseIdentity({ rootManifest, @@ -327,9 +341,17 @@ test('one product workflow gates one draft release on every required artifact', assert.match(commands, /npm run verify:windows-autoupdate/u); assert.match(commands, /product-release-tag\.mjs ensure/u); assert.match(commands, /RECOVERY_SOURCE/u); + assert.match(commands, /if gh release view "\$TAG"/u); + assert.doesNotMatch(commands, /requires an existing Draft release/u); assert.match(commands, /--json isDraft/u); assert.match(commands, /gh release create[\s\S]*--verify-tag/u); - assert.match(commands, /gh release upload[\s\S]*--clobber/u); + assert.match(commands, /gh release delete-asset/u); + assert.match(commands, /gh release upload/u); + assert.doesNotMatch(commands, /gh release upload[\s\S]*--clobber/u); + const listAssets = commands.indexOf('asset_names="$(gh release view'); + const deleteAssets = commands.indexOf('gh release delete-asset'); + const uploadAssets = commands.indexOf('gh release upload'); + assert.ok(listAssets >= 0 && listAssets < deleteAssets && deleteAssets < uploadAssets); assert.doesNotMatch(commands, /gh release create[\s\S]*--target/u); assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); await assert.rejects( diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index aa450c0c37..d9cdd69022 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -3,6 +3,7 @@ import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; +import { parseProductReleaseVersion } from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; @@ -22,7 +23,7 @@ const RELEASE_RECORD_KEYS = [ ]; export function parseCliReleaseVersion(version) { - const { prerelease } = parseReleaseSemver(version); + const { prerelease } = parseProductReleaseVersion(version); return { version, distTag: prerelease.length > 0 ? 'next' : 'latest', @@ -303,30 +304,9 @@ function validateSourceIdentity({ } } -function parseReleaseSemver(version) { - if (typeof version !== 'string') throw new Error('Expected a valid CLI release version'); - const match = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u.exec( - version, - ); - if (!match) throw new Error(`Expected a valid CLI release version; found ${version}`); - const prerelease = match[4]?.split('.') ?? []; - if ( - prerelease.some( - (identifier) => /^\d+$/u.test(identifier) && identifier.length > 1 && identifier[0] === '0', - ) - ) { - throw new Error(`Expected a valid CLI release version; found ${version}`); - } - return { - core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], - prerelease, - }; -} - function compareReleaseSemver(left, right) { - const a = parseReleaseSemver(left); - const b = parseReleaseSemver(right); + const a = parseProductReleaseVersion(left); + const b = parseProductReleaseVersion(right); for (let index = 0; index < a.core.length; index += 1) { if (a.core[index] < b.core[index]) return -1; if (a.core[index] > b.core[index]) return 1; diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index af7ecce332..d9def68c19 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -31,7 +31,7 @@ test('release versions map prereleases and stable versions to distinct channels' }); assert.equal(parseCliReleaseVersion('0.1.0').distTag, 'latest'); for (const version of ['01.0.0', '0.1', '0.1.0+local', '0.1.0-beta..1', '../0.1.0']) { - assert.throws(() => parseCliReleaseVersion(version), /valid CLI release version/u); + assert.throws(() => parseCliReleaseVersion(version), /valid product release version/u); } }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index c605e18331..5f051890dd 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -41,6 +41,10 @@ test('stage consumes the validated artifact and makes provenance staging the fin assert.match(guidance, /npm dist-tag add/u); const submit = namedStep(steps, 'Submit the candidate to npm staging'); assert.equal(steps.at(-1), submit); + assert.match(submit, /git ls-remote --tags --refs origin/u); + assert.match(submit, /gh release view "\$PRODUCT_TAG"/u); + assert.ok(submit.indexOf('git ls-remote') < submit.indexOf('npm stage publish')); + assert.ok(submit.indexOf('gh release view') < submit.indexOf('npm stage publish')); assert.match(submit, /npm stage publish/u); assert.match(submit, /--provenance/u); }); @@ -55,6 +59,8 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(workflow, /source_commit: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); assert.match(workflow, /ref: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); assert.match(workflow, /gh release view "\$PRODUCT_TAG"/u); + assert.match(workflow, /node scripts\/release-version\.mjs "\$EXPECTED_VERSION"/u); + assert.doesNotMatch(workflow, /EXPECTED_VERSION.*=~/u); assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); assert.doesNotMatch(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000000..8021c6ef34 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,31 @@ +import { pathToFileURL } from 'node:url'; + +export function parseProductReleaseVersion(version) { + if (typeof version !== 'string') throw new Error('Expected a valid product release version'); + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u.exec( + version, + ); + if (!match) throw new Error(`Expected a valid product release version; found ${version}`); + const prerelease = match[4]?.split('.') ?? []; + if ( + prerelease.some( + (identifier) => /^\d+$/u.test(identifier) && identifier.length > 1 && identifier[0] === '0', + ) + ) { + throw new Error(`Expected a valid product release version; found ${version}`); + } + return { + version, + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [version] = process.argv.slice(2); + if (!version || process.argv.length !== 3) { + throw new Error('usage: release-version.mjs '); + } + parseProductReleaseVersion(version); +} From c1a163edbacf0b7b9029db3cc2331079bc948057 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 00:49:24 +0800 Subject: [PATCH 10/31] docs(release): describe artifact validation precisely Generated-by: Codex --- docs/cli-distribution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 3b65bf5ff9..0fbb491f1a 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -68,7 +68,7 @@ consume the standalone ZIP. | --- | --- | --- | | Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | | Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | -| Which artifacts are required? | macOS and Windows Desktop installers, the macOS arm64 standalone CLI ZIP, and bundled source. | The publish job's explicit artifact allowlist | +| Which artifacts are required? | macOS and Windows Desktop installers, the macOS arm64 standalone CLI ZIP, and bundled source. | The publish job's required-file checks and trusted artifact-job outputs | | Is npm another release authority? | No. It is an optional install channel with its own OIDC, staging, verification, and 2FA boundaries. | Read-only npm finalize workflow; no tag or GitHub Release mutation | | Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | | Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | From fe396b214dea6f119b42e5304de025454680b832 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 15:24:45 +0800 Subject: [PATCH 11/31] docs(cli): keep managed setup on the maka command Generated-by: Codex --- docs/runtime-host-remote-access.md | 4 ++-- docs/runtime-host-remote-access.zh-CN.md | 4 ++-- packages/cli/README.md | 8 ++++---- packages/cli/README.zh-CN.md | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md index 5db1f698ed..d1f48baaa9 100644 --- a/docs/runtime-host-remote-access.md +++ b/docs/runtime-host-remote-access.md @@ -10,7 +10,7 @@ On a Linux machine with Node.js 22.19 or newer and a working systemd user manage can install and verify a persistent Runtime Host in one command: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root /srv/maka \ @@ -22,7 +22,7 @@ instead of accumulating credentials. The command installs its exact Maka package directory, starts a loopback-only service, verifies the new credential, and then prints the connection details once. Use `terminal-client` for TUI or CLI. -Run `npx --yes maka-agent@next runtime-host service uninstall` on the Host to remove the service and +Run `npx --yes --package maka-agent@next maka runtime-host service uninstall` on the Host to remove the service and managed package. The State Root and Project data are retained. ## Manual Host setup diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md index 0cc46f9c46..626960f0a7 100644 --- a/docs/runtime-host-remote-access.zh-CN.md +++ b/docs/runtime-host-remote-access.zh-CN.md @@ -9,7 +9,7 @@ Maka Desktop、TUI 和 CLI 可以通过 TLS、SSH 或明确启用的明文 WebSo 在具备 Node.js 22.19 或更新版本以及可用 systemd user manager 的 Linux 机器上,发布版 CLI 可以用一个命令安装并验证持久 Runtime Host: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root /srv/maka \ @@ -18,7 +18,7 @@ npx --yes maka-agent@next runtime-host setup \ `--principal` 应使用稳定标识;重复执行会替换该 Client 的 credential,不会不断累积 credential。命令会把当前精确版本的 Maka 安装到托管目录,启动仅监听 loopback 的服务,验证新 credential,然后只显示一次连接信息。TUI 或 CLI 使用 `terminal-client`。 -在 Host 上运行 `npx --yes maka-agent@next runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 +在 Host 上运行 `npx --yes --package maka-agent@next maka runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 ## 手动设置 Host diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a41242a01..9ff6e0adc1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -37,8 +37,8 @@ maka --version maka --help ``` -`maka-agent` is an alias for `maka`. For a one-off invocation, use -`npx --yes maka-agent@next`; the unrelated `maka` package on npm is not this project. +The public command is `maka`. For a one-off invocation, use +`npx --yes --package maka-agent@next maka`; the unrelated `maka` package on npm is not this project. `runtime-host service install` uses the persistent global installation above; `runtime-host setup` creates its own managed copy from the exact package invoked by `npx`. @@ -90,7 +90,7 @@ with `npm install --global maka-agent@latest`. To set up a persistent remote Runtime Host from an exact released package on Linux: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-client \ --preset terminal-client ``` @@ -102,7 +102,7 @@ Rerunning setup replaces that Client credential. The service no longer depends o ```sh # Linux only, when a managed Runtime Host service was installed -npx --yes maka-agent@next runtime-host service uninstall +npx --yes --package maka-agent@next maka runtime-host service uninstall # If Maka was installed globally npm uninstall --global maka-agent diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index f75bb431b0..6f2f37fbcc 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -35,7 +35,7 @@ maka --version maka --help ``` -`maka-agent` 是 `maka` 的别名。一次性运行请使用 `npx --yes maka-agent@next`;npm 上与本项目 +公开命令只有 `maka`。一次性运行请使用 `npx --yes --package maka-agent@next maka`;npm 上与本项目 无关的 `maka` 包不是本项目。`runtime-host service install` 使用上面的持久全局安装; `runtime-host setup` 会从 `npx` 调用的精确 package 创建自己的托管副本。 @@ -84,7 +84,7 @@ Beta 升级不要使用不带 tag 的 `npm update --global maka-agent`:npm 的 在 Linux 上从精确的发布 package 设置持久 remote Runtime Host: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-client \ --preset terminal-client ``` @@ -95,7 +95,7 @@ npx --yes maka-agent@next runtime-host setup \ ```sh # 仅限安装过 managed Runtime Host service 的 Linux -npx --yes maka-agent@next runtime-host service uninstall +npx --yes --package maka-agent@next maka runtime-host service uninstall # 如果曾全局安装 Maka npm uninstall --global maka-agent From 58492882c7575441d2ce3bb5ae1aa80909719532 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 15:55:06 +0800 Subject: [PATCH 12/31] fix(release): close artifact integrity gaps Generated-by: Codex --- .github/workflows/release-cli-stage.yml | 1 - docs/cli-distribution.md | 4 +- scripts/package-macos-arm64-cli.mjs | 6 +-- scripts/package-macos-arm64.mjs | 2 + scripts/package-windows-x64.mjs | 2 + scripts/product-release.test.mjs | 58 +++++++++++++++------- scripts/release-checksum.mjs | 12 +++++ scripts/release-cli-file-policy.mjs | 26 +++++++++- scripts/release-cli-file-policy.test.mjs | 22 +++++++++ scripts/release-cli-package.mjs | 4 +- scripts/release-cli-publication.mjs | 17 +++---- scripts/release-cli-publication.test.mjs | 61 +++++++++++++++++++----- scripts/verify-macos-arm64-cli.mjs | 13 ++--- 13 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 scripts/release-checksum.mjs diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 31a929faf0..c0deae78a2 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -49,7 +49,6 @@ jobs: test "$release_tag" = "$PRODUCT_TAG" git fetch --no-tags origin "refs/tags/$PRODUCT_TAG" source_commit="$(git rev-list -n 1 FETCH_HEAD)" - test "$(git rev-parse "FETCH_HEAD^{commit}")" = "$source_commit" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 0fbb491f1a..86e6b631d7 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -40,8 +40,8 @@ The TUI is the default interactive mode of `maka`, not a separate artifact. The CLI-specific `THIRD_PARTY_NOTICES.txt` must enumerate exactly the external production dependencies recorded in `RELEASE.json`. The archive also carries the repository's -`DISCLAIMER-WIP`, `LICENSE`, and `NOTICE`. The archive checksum is generated only after signing and -notarization complete. +`DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, and the pinned Node runtime license. The archive checksum is +generated only after signing and notarization complete. Every Mach-O file inside the archive is signed and the ZIP is submitted to Apple's notary service. ZIP files cannot carry a stapled notarization ticket, so the first Gatekeeper assessment on another diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index a9f5201545..e507202421 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -29,7 +29,7 @@ import { isThirdPartyDevelopmentArtifact, releaseNpmEnvironment, resolveReleaseWorkspacePackages, - workspaceReleaseFiles, + resolveWorkspaceReleaseFiles, } from './release-cli-file-policy.mjs'; export { releaseToolchainFromManifest } from './product-release-identity.mjs'; @@ -174,7 +174,7 @@ export async function stageWorkspacePackages(installRoot, workspacePackages) { await mkdir(targetDirectory, { recursive: true }); await copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')); await Promise.all( - workspaceReleaseFiles(manifest).map(async (releaseFile) => { + resolveWorkspaceReleaseFiles(directory, manifest).map(async (releaseFile) => { const source = join(directory, ...releaseFile.split('/')); const target = join(targetDirectory, ...releaseFile.split('/')); await mkdir(dirname(target), { recursive: true }); @@ -536,7 +536,7 @@ export function assertAcceptedNotarization(output) { } export function decodeSigningCertificate(value) { - const encoded = value?.trim(); + const encoded = value?.replace(/[\t\n\r ]+/gu, ''); if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); } diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 5f08206447..a059cd632a 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { writeSha256Sidecar } from './release-checksum.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -79,6 +80,7 @@ export async function packageMacosArm64({ await assertFile(dmgPath); await assertFile(zipPath); await assertFile(updateMetadataPath); + await writeSha256Sidecar(dmgPath); await remove(join(releaseDirectory, 'mac-arm64'), { recursive: true, force: true }); return dmgPath; diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 3856d354c0..5638d9aaa6 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -3,6 +3,7 @@ import { access, copyFile, mkdir, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { npmSpawnOptions } from './npm-spawn.mjs'; +import { writeSha256Sidecar } from './release-checksum.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -95,6 +96,7 @@ export async function packageWindowsX64({ await assertFile(exePath); await assertFile(zipPath); await assertFile(updateMetadataPath); + await writeSha256Sidecar(exePath); // win-unpacked stays: the ZIP is an archive of exactly this directory, so it // is what the verifier inspects. Extracting the ZIP would only rebuild a copy // of it, and writing tens of thousands of small files on Windows costs more diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index b87d06df33..13bfd6bbb4 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -25,9 +25,10 @@ import { standaloneInstallEnvironment, standaloneInstallRootManifest, } from './package-macos-arm64-cli.mjs'; -import { workspaceReleaseFiles } from './release-cli-file-policy.mjs'; +import { resolveWorkspaceReleaseFiles } from './release-cli-file-policy.mjs'; import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; import { ensureProductTag } from './product-release-tag.mjs'; +import { writeSha256Sidecar } from './release-checksum.mjs'; const execFileAsync = promisify(execFile); @@ -152,6 +153,24 @@ test('the root manifest pins the Node archive and npm used by release jobs', () }); }); +test('release artifacts get a checksum sidecar for their final bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-checksum-')); + try { + const artifact = join(root, 'Maka-1.2.3.dmg'); + await writeFile(artifact, 'final installer\n'); + + const checksum = await writeSha256Sidecar(artifact); + + assert.equal(checksum, `${artifact}.sha256`); + assert.equal( + await readFile(checksum, 'utf8'), + 'ddc9faf55279d297f3fd55a04de0e66c4d7906512677c2e3a05b657a19ccdf92 Maka-1.2.3.dmg\n', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('standalone verification recognizes the current TUI status line through ANSI output', () => { assert.equal( isTuiReadyOutput( @@ -170,9 +189,17 @@ test('standalone packaging keeps or thins arm64 Mach-O files instead of deleting }); test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identity', () => { - assert.deepEqual(decodeSigningCertificate(Buffer.from('pkcs12').toString('base64')), { + const certificate = Buffer.from('pkcs12'); + const encodedCertificate = certificate.toString('base64'); + assert.deepEqual(decodeSigningCertificate(encodedCertificate), { bytes: Buffer.from('pkcs12'), }); + assert.deepEqual( + decodeSigningCertificate(`${encodedCertificate.slice(0, 4)}\n${encodedCertificate.slice(4)}`), + { + bytes: certificate, + }, + ); assert.throws(() => decodeSigningCertificate('not base64!'), /base64-encoded/u); assert.deepEqual( parseDeveloperIdApplicationIdentity( @@ -198,7 +225,6 @@ test('the standalone maka launcher is relocatable and uses the embedded runtime' assert.equal(paths.archivePath.endsWith('Maka-1.2.3-cli-mac-arm64.zip'), true); const wrapper = macosArm64CliWrapper(); - assert.match(wrapper, /while \[ -L "\$launcher" \]/u); assert.match(wrapper, /libexec\/node\/bin\/node/u); assert.match(wrapper, /libexec\/node_modules\/maka-agent\/dist\/cli\.js/u); }); @@ -207,21 +233,17 @@ test('the Eval workspace owns the complete runtime asset declaration', async () const workspaces = await resolveCliWorkspacePackages(); const evalWorkspace = workspaces.find(({ name }) => name === '@maka/eval'); assert.ok(evalWorkspace); - assert.deepEqual(workspaceReleaseFiles(evalWorkspace.manifest), [ - 'dist', - 'harbor/deepseek-codex-models.json', - 'harbor/deepseek-harness-profile/cordis.patch.yml', - 'harbor/deepseek-harness-profile/cordis.yml', - 'harbor/deepseek-harness-profile/package.json', - 'harbor/docker-compose-egress-proxy.yaml', - 'harbor/egress-proxy/Dockerfile', - 'harbor/egress-proxy/entrypoint.sh', - 'harbor/egress-proxy/network-policy', - 'harbor/egress_filter.py', - 'harbor/eval_framework.py', - 'harbor/relay_agent.py', - 'harbor/run_trial.py', - ]); + const releaseFiles = resolveWorkspaceReleaseFiles( + evalWorkspace.directory, + evalWorkspace.manifest, + ); + assert.equal(releaseFiles.includes('dist'), true); + assert.equal( + releaseFiles.some((path) => + path.split('/').some((segment) => ['src', 'test', 'tests', '__tests__'].includes(segment)), + ), + false, + ); }); test('standalone packaging applies the shared CLI file policy to dependencies', async () => { diff --git a/scripts/release-checksum.mjs b/scripts/release-checksum.mjs new file mode 100644 index 0000000000..681f9e6716 --- /dev/null +++ b/scripts/release-checksum.mjs @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; + +export async function writeSha256Sidecar(artifactPath) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(artifactPath)) hash.update(chunk); + const checksumPath = `${artifactPath}.sha256`; + await writeFile(checksumPath, `${hash.digest('hex')} ${basename(artifactPath)}\n`, 'utf8'); + return checksumPath; +} diff --git a/scripts/release-cli-file-policy.mjs b/scripts/release-cli-file-policy.mjs index 96ecac0fbe..8321bad619 100644 --- a/scripts/release-cli-file-policy.mjs +++ b/scripts/release-cli-file-policy.mjs @@ -1,4 +1,4 @@ -import { readFileSync, realpathSync } from 'node:fs'; +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; const DEVELOPMENT_DIRECTORIES = new Set([ @@ -117,6 +117,30 @@ export function workspaceReleaseFiles(manifest) { return releaseFiles; } +export function resolveWorkspaceReleaseFiles(directory, manifest) { + const releaseFiles = workspaceReleaseFiles(manifest); + for (const releaseFile of releaseFiles) { + const entry = lstatSync(join(directory, ...releaseFile.split('/')), { throwIfNoEntry: false }); + if (!entry) { + throw new Error( + `${manifest.name ?? 'Workspace package'} release file is missing: ${releaseFile}`, + ); + } + if (releaseFile === 'dist') { + if (!entry.isDirectory()) { + throw new Error( + `${manifest.name ?? 'Workspace package'} release dist must be a directory.`, + ); + } + } else if (!entry.isFile()) { + throw new Error( + `${manifest.name ?? 'Workspace package'} release asset must be a regular file: ${releaseFile}`, + ); + } + } + return releaseFiles; +} + export function resolveReleaseWorkspacePackages(repoRoot, entryName = 'maka-agent') { const resolvedRepoRoot = realpathSync(repoRoot); const rootManifest = JSON.parse(readFileSync(join(resolvedRepoRoot, 'package.json'), 'utf8')); diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index d5051f41f2..2787b563dd 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -1,10 +1,14 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, test } from 'node:test'; import { collectWorkspaceDependencyClosure, isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, orderWorkspaceBuilds, + resolveWorkspaceReleaseFiles, workspaceReleaseFiles, } from './release-cli-file-policy.mjs'; @@ -49,6 +53,24 @@ describe('CLI release file policy', () => { } }); + test('release file declarations reject directories other than dist', () => { + const workspace = mkdtempSync(join(tmpdir(), 'maka-release-files-')); + try { + mkdirSync(join(workspace, 'dist')); + mkdirSync(join(workspace, 'runtime-assets')); + assert.throws( + () => + resolveWorkspaceReleaseFiles(workspace, { + name: '@maka/example', + releaseFiles: ['dist', 'runtime-assets'], + }), + /must be a regular file/u, + ); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + test('rejects third-party development artifacts on every platform', () => { for (const path of [ 'coverage/lcov.info', diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index cee2c7def9..57bfc9f9f8 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -25,7 +25,7 @@ import { orderWorkspaceBuilds, releaseNpmEnvironment, resolveReleaseWorkspacePackages, - workspaceReleaseFiles, + resolveWorkspaceReleaseFiles, } from './release-cli-file-policy.mjs'; const repoRoot = resolve(import.meta.dirname, '..'); @@ -343,7 +343,7 @@ function copyInternalPackage(source, destination) { .map((field) => [field, manifest[field]]), ); writeFileSync(join(destination, 'package.json'), `${JSON.stringify(releaseManifest, null, 2)}\n`); - for (const releaseFile of workspaceReleaseFiles(manifest)) { + for (const releaseFile of resolveWorkspaceReleaseFiles(source, manifest)) { if (releaseFile === 'dist') copyRuntimeDist(source, destination); else copyDeclaredFile(source, destination, releaseFile); } diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index d9cdd69022..b87c2969ac 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -234,18 +234,19 @@ function loadReleaseRecord(releaseDirectory) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); - for (const key of ['distTag', 'tarball']) { - if (record[key] !== identity[key]) throw new Error(`Release record ${key} is inconsistent`); + const derivedFields = { + productTag: `v${identity.version}`, + distTag: identity.distTag, + tarball: identity.tarball, + checksum: `${identity.tarball}.sha256`, + inventory: `${identity.tarball}.files.json`, + }; + for (const [key, expected] of Object.entries(derivedFields)) { + if (record[key] !== expected) throw new Error(`Release record ${key} is inconsistent`); } if (!/^[0-9a-f]{64}$/u.test(record.sha256)) { throw new Error('Release record sha256 is invalid'); } - if ( - record.checksum !== `${identity.tarball}.sha256` || - record.inventory !== `${identity.tarball}.files.json` - ) { - throw new Error('Release record sidecar names are inconsistent'); - } exactKeys( record.source, ['repository', 'workflow', 'commit', 'workflowCommit', 'runId', 'runAttempt'], diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index d9def68c19..af497b86b1 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -22,6 +22,16 @@ const CURRENT_CLI_VERSION = JSON.parse( readFileSync(resolve(import.meta.dirname, '../packages/cli/package.json'), 'utf8'), ).version; const PRODUCT_TAG = 'v0.1.0-beta.1'; +const STAGE_RUN = { + id: 321, + run_attempt: 1, + path: WORKFLOW_PATH, + event: 'workflow_dispatch', + head_branch: 'main', + head_sha: WORKFLOW_SHA, + conclusion: 'success', + head_repository: { full_name: 'maka-agent/maka-agent' }, +}; test('release versions map prereleases and stable versions to distinct channels', () => { assert.deepEqual(parseCliReleaseVersion('0.1.0-beta.1'), { @@ -103,6 +113,26 @@ test('stage records bind the checked candidate to one source workflow run', () = ); }); +test('stage preparation rejects a product tag that does not match the version', () => { + const fixture = createCandidate(); + assert.throws( + () => + prepareStageRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + productTag: 'v9.9.9', + sourceSha: SOURCE_SHA, + workflowSha: WORKFLOW_SHA, + runId: '321', + runAttempt: '1', + repository: 'maka-agent/maka-agent', + workflowPath: WORKFLOW_PATH, + }), + /Product tag .* does not match/u, + ); +}); + test('stage preparation rejects confirmation and checksum drift', () => { const fixture = createCandidate(); assert.throws( @@ -143,22 +173,12 @@ test('stage preparation rejects confirmation and checksum drift', () => { test('finalization accepts only the exact successful main stage run', () => { const fixture = createPreparedCandidate(); - const run = { - id: 321, - run_attempt: 1, - path: WORKFLOW_PATH, - event: 'workflow_dispatch', - head_branch: 'main', - head_sha: WORKFLOW_SHA, - conclusion: 'success', - head_repository: { full_name: 'maka-agent/maka-agent' }, - }; assert.equal( validateStageRun({ releaseDirectory: fixture.releaseDirectory, expectedVersion: fixture.version, - run, + run: STAGE_RUN, }).source.commit, SOURCE_SHA, ); @@ -176,13 +196,30 @@ test('finalization accepts only the exact successful main stage run', () => { validateStageRun({ releaseDirectory: fixture.releaseDirectory, expectedVersion: fixture.version, - run: { ...run, ...drift }, + run: { ...STAGE_RUN, ...drift }, }), /stage workflow run/u, ); } }); +test('finalization rejects a release record whose product tag does not match its version', () => { + const fixture = createPreparedCandidate(); + const recordPath = join(fixture.releaseDirectory, 'release.json'); + const record = JSON.parse(readFileSync(recordPath, 'utf8')); + writeFileSync(recordPath, `${JSON.stringify({ ...record, productTag: 'v9.9.9' }, null, 2)}\n`); + + assert.throws( + () => + validateStageRun({ + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + run: STAGE_RUN, + }), + /productTag is inconsistent/u, + ); +}); + test('registry finalization requires the exact staged bytes and dist-tag', async () => { const fixture = createPreparedCandidate(); const registryDirectory = mkdtempSync(join(tmpdir(), 'maka-cli-registry-release-')); diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index 88724399b0..c61e2e7f3c 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -8,6 +8,7 @@ import { mkdtemp, readFile, readdir, + realpath, rm, symlink, writeFile, @@ -214,8 +215,8 @@ async function assertWorkspaceClosure(archiveRoot, metadata) { const linkPath = join(archiveRoot, 'libexec', 'node_modules', ...name.split('/')); const packagePath = join(archiveRoot, 'libexec', workspacePath); const [resolvedLink, resolvedPackage] = await Promise.all([ - import('node:fs/promises').then(({ realpath }) => realpath(linkPath)), - import('node:fs/promises').then(({ realpath }) => realpath(packagePath)), + realpath(linkPath), + realpath(packagePath), ]); if (resolvedLink !== resolvedPackage) { throw new Error(`${name} does not resolve to the packaged workspace directory.`); @@ -339,11 +340,11 @@ async function smokePackagedEval(archiveRoot, sourceCommit, environment, run) { } const marker = JSON.parse(await readFile(markerPath, 'utf8')); - const expectedRunTrial = await import('node:fs/promises').then(({ realpath }) => - realpath(join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'run_trial.py')), + const expectedRunTrial = await realpath( + join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'run_trial.py'), ); - const expectedRelayAgent = await import('node:fs/promises').then(({ realpath }) => - realpath(join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'relay_agent.py')), + const expectedRelayAgent = await realpath( + join(archiveRoot, 'libexec', 'packages', 'eval', 'harbor', 'relay_agent.py'), ); if ( marker.runTrial !== expectedRunTrial || From b67176efb6b8c05bf2d4e1fd9a1f23d6f9c52fab Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 15:56:56 +0800 Subject: [PATCH 13/31] fix(release): hash finalized desktop installers Generated-by: Codex --- .github/workflows/release.yml | 5 +++++ scripts/package-macos-arm64.mjs | 2 -- scripts/package-windows-x64.mjs | 2 -- scripts/product-release.test.mjs | 18 ++++++++++++++++++ scripts/release-checksum.mjs | 9 +++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b09b5a2b7..ba6c6849d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -213,6 +213,11 @@ jobs: "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ apps/desktop/release-autoupdate-next + - name: Hash the final Desktop installer + env: + INSTALLER: apps/desktop/release/${{ matrix.platform == 'macos' && needs.release-identity.outputs.dmg || needs.release-identity.outputs.exe }} + run: node scripts/release-checksum.mjs "$INSTALLER" + - name: Upload the verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index a059cd632a..5f08206447 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -2,7 +2,6 @@ import { spawn } from 'node:child_process'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { writeSha256Sidecar } from './release-checksum.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -80,7 +79,6 @@ export async function packageMacosArm64({ await assertFile(dmgPath); await assertFile(zipPath); await assertFile(updateMetadataPath); - await writeSha256Sidecar(dmgPath); await remove(join(releaseDirectory, 'mac-arm64'), { recursive: true, force: true }); return dmgPath; diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 5638d9aaa6..3856d354c0 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -3,7 +3,6 @@ import { access, copyFile, mkdir, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { npmSpawnOptions } from './npm-spawn.mjs'; -import { writeSha256Sidecar } from './release-checksum.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -96,7 +95,6 @@ export async function packageWindowsX64({ await assertFile(exePath); await assertFile(zipPath); await assertFile(updateMetadataPath); - await writeSha256Sidecar(exePath); // win-unpacked stays: the ZIP is an archive of exactly this directory, so it // is what the verifier inspects. Extracting the ZIP would only rebuild a copy // of it, and writing tens of thousands of small files on Windows costs more diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 13bfd6bbb4..07e1ab43f2 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -352,6 +352,24 @@ test('one product workflow gates one draft release on every required artifact', assert.equal(checkout.with.ref, '${{ needs.release-identity.outputs.source_commit }}'); } + const desktopStepNames = jobs.desktop.steps.map((step) => step.name); + const checksumStep = jobs.desktop.steps.find( + (step) => step.name === 'Hash the final Desktop installer', + ); + assert.ok(checksumStep); + assert.ok( + desktopStepNames.indexOf('Hash the final Desktop installer') > + desktopStepNames.indexOf('Verify the final DMG'), + ); + assert.ok( + desktopStepNames.indexOf('Hash the final Desktop installer') > + desktopStepNames.indexOf('Exercise pinned Windows upgrade and uninstall'), + ); + assert.ok( + desktopStepNames.indexOf('Hash the final Desktop installer') < + desktopStepNames.indexOf('Upload the verified release assets'), + ); + const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) .map((step) => step.run) diff --git a/scripts/release-checksum.mjs b/scripts/release-checksum.mjs index 681f9e6716..18f8d0bb46 100644 --- a/scripts/release-checksum.mjs +++ b/scripts/release-checksum.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; +import { pathToFileURL } from 'node:url'; export async function writeSha256Sidecar(artifactPath) { const hash = createHash('sha256'); @@ -10,3 +11,11 @@ export async function writeSha256Sidecar(artifactPath) { await writeFile(checksumPath, `${hash.digest('hex')} ${basename(artifactPath)}\n`, 'utf8'); return checksumPath; } + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [artifactPath, ...unsupported] = process.argv.slice(2); + if (!artifactPath || unsupported.length > 0) { + throw new Error('Usage: node scripts/release-checksum.mjs '); + } + console.log(await writeSha256Sidecar(artifactPath)); +} From b37e01b6d3734cbedda85e4da511ea760c794f2c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 16:49:44 +0800 Subject: [PATCH 14/31] fix(release): bind npm staging to product tags Generated-by: Codex --- .github/workflows/release-cli-finalize.yml | 26 ++++- .github/workflows/release-cli-stage.yml | 30 ++--- docs/cli-distribution.md | 2 +- docs/cli-npm-release.md | 26 +++-- docs/cli-npm-release.zh-CN.md | 26 +++-- scripts/package-macos-arm64-cli.mjs | 3 - scripts/product-release-identity.mjs | 18 +-- scripts/product-release.test.mjs | 17 +-- scripts/release-cli-publication.mjs | 90 +++++++++++---- scripts/release-cli-publication.test.mjs | 114 ++++++++++++++++--- scripts/release-cli-workflow-policy.test.mjs | 27 ++++- scripts/verify-macos-arm64-cli.mjs | 1 - 12 files changed, 267 insertions(+), 113 deletions(-) diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 58536225b7..a326ddde7f 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -42,6 +42,7 @@ jobs: - name: Load the exact stage workflow run id: stage-run env: + EXPECTED_VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} STAGE_RUN_ID: ${{ inputs.stage_run_id }} STAGE_RUN_ATTEMPT: ${{ inputs.stage_run_attempt }} @@ -63,12 +64,12 @@ jobs: String(run.run_attempt) !== process.env.STAGE_RUN_ATTEMPT || run.path !== ".github/workflows/release-cli-stage.yml" || run.event !== "workflow_dispatch" || - run.head_branch !== "main" || + run.head_branch !== "v" + process.env.EXPECTED_VERSION || !/^[0-9a-f]{40}$/.test(run.head_sha) || run.conclusion !== "success" || run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY ) { - throw new Error("Stage run is not an exact successful main CLI stage attempt"); + throw new Error("Stage run is not an exact successful product-tag CLI stage attempt"); } ' "$RUNNER_TEMP/stage-run.json" @@ -76,6 +77,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -96,13 +98,31 @@ jobs: run-id: ${{ inputs.stage_run_id }} - name: Verify the stage run and release record + id: release env: EXPECTED_VERSION: ${{ inputs.version }} run: | node scripts/release-cli-publication.mjs validate-stage-run \ packages/cli/release \ "$RUNNER_TEMP/stage-run.json" \ - "$EXPECTED_VERSION" + "$EXPECTED_VERSION" \ + "$GITHUB_OUTPUT" + + - name: Revalidate the product release authority + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} + PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} + run: | + current_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" + if [[ "$current_source" != "$PRODUCT_SOURCE_COMMIT" ]]; then + echo "Product tag ${PRODUCT_TAG} no longer points to ${PRODUCT_SOURCE_COMMIT}." >&2 + exit 1 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$PRODUCT_SOURCE_COMMIT" origin/main + release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" + test "$release_tag" = "$PRODUCT_TAG" - name: Fetch and verify the public registry bytes run: | diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index c0deae78a2..a8d6addd20 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -26,14 +26,16 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: v${{ inputs.version }} fetch-depth: 0 persist-credentials: false - - name: Reject non-main dispatches + - name: Require the exact product tag env: RELEASE_REF: ${{ github.ref }} + PRODUCT_TAG: v${{ inputs.version }} run: | - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then + echo "CLI releases must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 exit 1 fi @@ -45,22 +47,19 @@ jobs: PRODUCT_TAG: v${{ inputs.version }} run: | node scripts/release-version.mjs "$EXPECTED_VERSION" + source_commit="$(git rev-parse HEAD)" + test "$source_commit" = "$GITHUB_SHA" release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" test "$release_tag" = "$PRODUCT_TAG" - git fetch --no-tags origin "refs/tags/$PRODUCT_TAG" - source_commit="$(git rev-list -n 1 FETCH_HEAD)" + remote_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" + test "$remote_source" = "$source_commit" + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$source_commit" origin/main echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" - - name: Check out the product release source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ steps.product.outputs.source_commit }} - persist-credentials: false - - name: Verify the product release identity env: - GITHUB_REF: refs/heads/main GITHUB_SHA: ${{ steps.product.outputs.source_commit }} EXPECTED_PRODUCT_VERSION: ${{ inputs.version }} EXPECTED_PRODUCT_TAG: ${{ steps.product.outputs.product_tag }} @@ -89,6 +88,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.authorize.outputs.source_commit }} + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -110,8 +110,7 @@ jobs: RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} - RELEASE_SHA: ${{ needs.authorize.outputs.source_commit }} - RELEASE_WORKFLOW_SHA: ${{ github.sha }} + RELEASE_SHA: ${{ github.sha }} RELEASE_WORKFLOW: .github/workflows/release-cli-stage.yml run: | node scripts/release-cli-publication.mjs prepare-stage \ @@ -119,7 +118,6 @@ jobs: "$EXPECTED_VERSION" \ "$PRODUCT_TAG" \ "$RELEASE_SHA" \ - "$RELEASE_WORKFLOW_SHA" \ "$RELEASE_RUN_ID" \ "$RELEASE_RUN_ATTEMPT" \ "$RELEASE_REPOSITORY" \ @@ -175,6 +173,8 @@ jobs: fi release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" test "$release_tag" = "$PRODUCT_TAG" + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$PRODUCT_SOURCE_COMMIT" origin/main npm stage publish "$RELEASE_TARBALL" \ --tag "$RELEASE_DIST_TAG" \ diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 86e6b631d7..002d3b1ecd 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -69,6 +69,6 @@ consume the standalone ZIP. | Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | | Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | | Which artifacts are required? | macOS and Windows Desktop installers, the macOS arm64 standalone CLI ZIP, and bundled source. | The publish job's required-file checks and trusted artifact-job outputs | -| Is npm another release authority? | No. It is an optional install channel with its own OIDC, staging, verification, and 2FA boundaries. | Read-only npm finalize workflow; no tag or GitHub Release mutation | +| Is npm another release authority? | No. It is an optional install channel whose Stage ref, source, workflow identity, and provenance all resolve to the existing product tag commit. | Tag-dispatched OIDC staging and read-only finalization; no npm-specific tag or GitHub Release | | Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | | Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 7cab61c881..bbb97300da 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -6,7 +6,7 @@ This runbook is the operational authority for publishing the `maka-agent` npm in ## Release invariants -- Dispatch release workflows only from `main`. +- Dispatch the product Release and npm Finalize workflows only from `main`. Dispatch npm Stage only from the existing product `v` tag. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. @@ -20,7 +20,7 @@ The two workflow boundaries are: 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) resolves the existing product tag and GitHub Release, checks out that exact product commit, builds and validates one immutable - tarball, records both product and workflow identity, enters the protected `npm-release` + tarball, records that single tag commit and workflow run, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. 2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. @@ -30,7 +30,7 @@ The two workflow boundaries are: Create an Environment named `npm-release` with: -- `main` as the only allowed deployment branch; +- a selected deployment tag rule matching `v*`, with no branch rule; - the active CLI release maintainer as a required reviewer; - self-review allowed while one person is the sole release maintainer; - administrator bypass disabled where repository policy permits it; @@ -80,8 +80,7 @@ package owner or recovery access as part of that change. ## Stage the candidate 1. Open **Actions → Stage CLI npm release → Run workflow**. -2. Select `main` and enter the exact product version. The workflow resolves `v` and builds - its exact commit even if `main` has advanced since the Draft was created. +2. Select `v` under **Use workflow from** and enter the same exact product version. The workflow requires its GitHub ref, checkout, product tag, Release, source commit, and npm provenance to identify that one tag commit, and requires the commit to remain an ancestor of `main`. 3. Wait for the reusable package validation jobs to pass. They build one tarball and validate the installed CLI on Linux x64, macOS arm64, and Windows x64, plus real Harbor and Pier Docker cells on Linux x64. @@ -110,6 +109,17 @@ Before approval: - inspect the file inventory and the packaged `README.md`; - confirm the tarball belongs to the recorded Stage run and source commit. +Immediately before approval, recheck the live product authority recorded by the Stage run: + +```sh +git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" +source_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +git merge-base --is-ancestor "$source_commit" origin/main +gh release view "v$version" --json tagName --jq .tagName +``` + +The last command must print `v`. Stop if the tag is absent, moved, no longer on `main`, or no matching GitHub Release exists. + Approve only that stage ID. npm requires 2FA and makes the package public as part of approval: ```sh @@ -163,8 +173,7 @@ usage, cost, and artifacts. ### Before npm staging -If validation or Environment approval fails before `npm stage publish`, fix the problem on `main` -and start a new Stage run. No npm version has been consumed. +If a transient failure occurs before `npm stage publish`, rerun Stage from the same product tag. If code or workflow changes are required, fix them on `main`, increment the product version, create a new product tag and Draft, and Stage that new version. No npm version has been consumed. ### Stage workflow failed but npm contains a stage @@ -184,8 +193,7 @@ Never reject a stage based only on version text; bind the action to the inspecte ### Stage succeeded but review found a problem -Reject the stage, fix the problem on `main`, and stage again. Do not approve a candidate merely to -clear the staging area. +Reject the stage, fix the problem on `main`, increment the product version, create a new product tag and Draft, and Stage that new version. Do not approve a candidate merely to clear the staging area. ### npm approval succeeded but Finalize failed diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 3c5dc56fe0..968e2e4498 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -6,7 +6,7 @@ ## 发布不变量 -- 只从 `main` dispatch 发布 workflow; +- 产品 Release 和 npm Finalize workflow 只从 `main` dispatch;npm Stage 只从已有的产品 `v` tag dispatch; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; - 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; @@ -18,8 +18,7 @@ 两个 workflow 边界分别是: 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub - Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,分别记录产品与 - workflow identity,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; + Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; 2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 ## 一次性控制面配置 @@ -28,7 +27,7 @@ 创建名为 `npm-release` 的 Environment,并设置: -- 只允许 `main` 部署; +- 使用匹配 `v*` 的 selected deployment tag rule,不配置 branch rule; - 将当前 CLI 发布维护者设为 required reviewer; - 只有一名发布维护者期间允许 self-review; - 仓库策略允许时禁用 administrator bypass; @@ -77,8 +76,7 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ## Stage 候选包 1. 打开 **Actions → Stage CLI npm release → Run workflow**; -2. 选择 `main`,输入精确产品版本;即使 Draft 创建后 `main` 已前进,workflow 仍会解析 - `v` 并构建它的精确 commit; +2. 在 **Use workflow from** 选择 `v`,并输入同一个精确产品版本;workflow 要求其 GitHub ref、checkout、产品 tag、Release、source commit 和 npm provenance 全部指向这一个 tag commit,并要求该 commit 仍是 `main` 的 ancestor; 3. 等待可复用 package validation jobs 全部通过。它们只构建一个 tarball,并在 Linux x64、 macOS arm64、Windows x64 上验证安装态 CLI,在 Linux x64 上运行真实 Harbor 和 Pier Docker cell; @@ -106,6 +104,17 @@ npm stage download "$stage_id" --registry https://registry.npmjs.org/ - 检查文件清单和包内 `README.md`; - 确认 tarball 属于所记录的 Stage run 和 source commit。 +批准前的最后一步,重新检查 Stage run 记录的 live 产品权威: + +```sh +git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" +source_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +git merge-base --is-ancestor "$source_commit" origin/main +gh release view "v$version" --json tagName --jq .tagName +``` + +最后一条命令必须输出 `v`。tag 不存在、已移动、不再位于 `main`,或不存在匹配的 GitHub Release 时都必须停止。 + 只批准这个 stage ID。npm 会要求 2FA,并在批准时将 package 公开: ```sh @@ -157,8 +166,7 @@ host 上完成至少一个真实 experiment cell,检查 score、usage、cost ### npm staging 之前失败 -如果 validation 或 Environment approval 在 `npm stage publish` 前失败,在 `main` 修复后启动 -新的 Stage run。此时没有消耗 npm 版本。 +如果在 `npm stage publish` 前发生瞬时失败,从同一个产品 tag 重新运行 Stage。如果必须修改代码或 workflow,则在 `main` 修复、递增产品版本、创建新的产品 tag 和 Draft,再 Stage 新版本。此时没有消耗 npm 版本。 ### Stage workflow 失败,但 npm 中存在 stage @@ -177,7 +185,7 @@ npm stage reject "$stage_id" --registry https://registry.npmjs.org/ ### Stage 成功,但人工检查发现问题 -拒绝该 stage,在 `main` 修复后重新 Stage。不要为了清空 staging area 而批准有问题的候选。 +拒绝该 stage,在 `main` 修复、递增产品版本、创建新的产品 tag 和 Draft,再 Stage 新版本。不要为了清空 staging area 而批准有问题的候选。 ### npm approval 成功,但 Finalize 失败 diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index e507202421..f9e44e11d3 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -32,8 +32,6 @@ import { resolveWorkspaceReleaseFiles, } from './release-cli-file-policy.mjs'; -export { releaseToolchainFromManifest } from './product-release-identity.mjs'; - const execFileAsync = promisify(execFile); const requireFromHere = createRequire(import.meta.url); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -724,7 +722,6 @@ export async function packageMacosArm64Cli({ rootManifest, desktopManifest, cliManifest, - ref: 'refs/heads/main', sha: sourceCommit, }); const toolchain = releaseToolchainFromManifest(rootManifest); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index ef77864e07..143ab7e36e 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -26,13 +26,7 @@ export function releaseToolchainFromManifest(rootManifest) { }; } -export function resolveProductReleaseIdentity({ - rootManifest, - desktopManifest, - cliManifest, - ref, - sha, -}) { +export function resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, sha }) { const { version } = parseProductReleaseVersion(rootManifest.version); for (const [label, manifest] of [ ['Desktop', desktopManifest], @@ -47,9 +41,6 @@ export function resolveProductReleaseIdentity({ if (JSON.stringify(cliManifest.bin) !== JSON.stringify({ maka: './dist/cli.js' })) { throw new Error('The only public CLI command must be maka'); } - if (ref !== 'refs/heads/main') { - throw new Error(`Product releases require refs/heads/main, found ${ref ?? 'missing'}`); - } if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { throw new Error('Product releases require an exact 40-character source commit SHA'); } @@ -84,16 +75,13 @@ export function assertProductReleaseExpectation(identity, { version, tag, source return identity; } -export async function readProductReleaseIdentity({ - ref = process.env.GITHUB_REF, - sha = process.env.GITHUB_SHA, -} = {}) { +export async function readProductReleaseIdentity({ sha = process.env.GITHUB_SHA } = {}) { const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), ]); - return resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, ref, sha }); + return resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, sha }); } function githubOutputEntries(identity) { diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 07e1ab43f2..9e8615ad2f 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -15,12 +15,10 @@ import { } from './product-release-identity.mjs'; import { decodeSigningCertificate, - macosArm64CliWrapper, macosArm64MachOAction, parseDeveloperIdApplicationIdentity, pruneThirdPartyDevelopmentArtifacts, resolveCliWorkspacePackages, - resolveMacosArm64CliArtifactPaths, stageWorkspacePackages, standaloneInstallEnvironment, standaloneInstallRootManifest, @@ -41,12 +39,11 @@ const rootManifest = { }, }; -test('one root version defines every product artifact from one main commit', () => { +test('one root version defines every product artifact from one source commit', () => { const identity = resolveProductReleaseIdentity({ rootManifest, desktopManifest: { version: '1.2.3' }, cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, - ref: 'refs/heads/main', sha: 'a'.repeat(40), }); @@ -65,7 +62,6 @@ test('product and npm release identities reject the same non-canonical versions' rootManifest: { ...rootManifest, version }, desktopManifest: { version }, cliManifest: { version, bin: { maka: './dist/cli.js' } }, - ref: 'refs/heads/main', sha: 'a'.repeat(40), }; assert.throws(() => resolveProductReleaseIdentity(manifests), /valid product release version/u); @@ -78,7 +74,6 @@ test('an npm candidate must name the exact product tag, version, and source comm rootManifest, desktopManifest: { version: '1.2.3' }, cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, - ref: 'refs/heads/main', sha: 'a'.repeat(40), }); @@ -219,16 +214,6 @@ test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identi ); }); -test('the standalone maka launcher is relocatable and uses the embedded runtime', () => { - const paths = resolveMacosArm64CliArtifactPaths('1.2.3'); - assert.equal(paths.archiveRootName, 'Maka-1.2.3-cli-mac-arm64'); - assert.equal(paths.archivePath.endsWith('Maka-1.2.3-cli-mac-arm64.zip'), true); - - const wrapper = macosArm64CliWrapper(); - assert.match(wrapper, /libexec\/node\/bin\/node/u); - assert.match(wrapper, /libexec\/node_modules\/maka-agent\/dist\/cli\.js/u); -}); - test('the Eval workspace owns the complete runtime asset declaration', async () => { const workspaces = await resolveCliWorkspacePackages(); const evalWorkspace = workspaces.find(({ name }) => name === '@maka/eval'); diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index b87c2969ac..6ca48dd7d1 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -57,7 +57,6 @@ export function prepareStageRelease({ expectedVersion, productTag, sourceSha, - workflowSha, runId, runAttempt, repository, @@ -78,7 +77,6 @@ export function prepareStageRelease({ } validateSourceIdentity({ sourceSha, - workflowSha, runId, runAttempt, repository, @@ -86,7 +84,7 @@ export function prepareStageRelease({ }); const candidate = validateCandidateFiles(releaseDirectory, identity); const record = { - schemaVersion: 2, + schemaVersion: 3, packageName: PACKAGE_NAME, ...identity, productTag, @@ -97,7 +95,6 @@ export function prepareStageRelease({ repository, workflow: workflowPath, commit: sourceSha, - workflowCommit: workflowSha, runId, runAttempt, }, @@ -122,13 +119,13 @@ export function validateStageRun({ releaseDirectory, expectedVersion, run }) { String(run.run_attempt) !== record.source.runAttempt || run.path !== record.source.workflow || run.event !== 'workflow_dispatch' || - run.head_branch !== 'main' || - run.head_sha !== record.source.workflowCommit || + run.head_branch !== record.productTag || + run.head_sha !== record.source.commit || run.conclusion !== 'success' || run.head_repository?.full_name !== record.source.repository ) { throw new Error( - 'Release record does not belong to the exact successful main stage workflow run', + 'Release record does not belong to the exact successful product-tag stage workflow run', ); } return record; @@ -203,6 +200,10 @@ export function validateSignatureAudit({ releaseDirectory, audit }) { `npm signature audit did not include verified provenance for ${record.version}`, ); } + const statements = (own.attestationBundles ?? []).map(parseProvenanceStatement); + if (!statements.some((statement) => matchesReleaseProvenance(statement, record))) { + throw new Error(`npm signature audit provenance does not match ${record.productTag}`); + } return record; } @@ -230,7 +231,7 @@ export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) function loadReleaseRecord(releaseDirectory) { const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); - if (record.schemaVersion !== 2 || record.packageName !== PACKAGE_NAME) { + if (record.schemaVersion !== 3 || record.packageName !== PACKAGE_NAME) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); @@ -249,12 +250,11 @@ function loadReleaseRecord(releaseDirectory) { } exactKeys( record.source, - ['repository', 'workflow', 'commit', 'workflowCommit', 'runId', 'runAttempt'], + ['repository', 'workflow', 'commit', 'runId', 'runAttempt'], 'release source', ); validateSourceIdentity({ sourceSha: record.source.commit, - workflowSha: record.source.workflowCommit, runId: record.source.runId, runAttempt: record.source.runAttempt, repository: record.source.repository, @@ -287,16 +287,8 @@ function validateCandidateFiles(releaseDirectory, identity) { return { tarballPath, sha256 }; } -function validateSourceIdentity({ - sourceSha, - workflowSha, - runId, - runAttempt, - repository, - workflowPath, -}) { +function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }) { if (!/^[0-9a-f]{40}$/u.test(sourceSha)) throw new Error('Release source SHA is invalid'); - if (!/^[0-9a-f]{40}$/u.test(workflowSha)) throw new Error('Release workflow SHA is invalid'); if (!/^[1-9]\d*$/u.test(runId)) throw new Error('Release workflow run ID is invalid'); if (!/^[1-9]\d*$/u.test(runAttempt)) throw new Error('Release workflow run attempt is invalid'); if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); @@ -305,6 +297,50 @@ function validateSourceIdentity({ } } +function parseProvenanceStatement(bundle) { + const envelope = bundle?.dsseEnvelope; + if ( + envelope?.payloadType !== 'application/vnd.in-toto+json' || + typeof envelope.payload !== 'string' + ) { + return null; + } + try { + return JSON.parse(Buffer.from(envelope.payload, 'base64').toString('utf8')); + } catch { + return null; + } +} + +function matchesReleaseProvenance(statement, record) { + const repository = `https://github.com/${record.source.repository}`; + const ref = `refs/tags/${record.productTag}`; + const definition = statement?.predicate?.buildDefinition; + const workflow = definition?.externalParameters?.workflow; + const dependencies = definition?.resolvedDependencies; + const invocationId = `${repository}/actions/runs/${record.source.runId}/attempts/${record.source.runAttempt}`; + return ( + statement?._type === 'https://in-toto.io/Statement/v1' && + statement?.predicateType === 'https://slsa.dev/provenance/v1' && + definition?.buildType === + 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1' && + workflow?.repository === repository && + workflow?.ref === ref && + workflow?.path === record.source.workflow && + Array.isArray(dependencies) && + dependencies.some( + (dependency) => + dependency?.uri === `git+${repository}@${ref}` && + dependency?.digest?.gitCommit === record.source.commit, + ) && + definition?.internalParameters?.github?.event_name === 'workflow_dispatch' && + statement?.predicate?.runDetails?.builder?.id?.startsWith( + 'https://github.com/actions/runner/', + ) && + statement?.predicate?.runDetails?.metadata?.invocationId === invocationId + ); +} + function compareReleaseSemver(left, right) { const a = parseProductReleaseVersion(left); const b = parseProductReleaseVersion(right); @@ -445,13 +481,12 @@ function appendOutputs(path, values) { async function main() { const [command, ...args] = process.argv.slice(2); - if (command === 'prepare-stage' && args.length === 10) { + if (command === 'prepare-stage' && args.length === 9) { const [ releaseDirectory, expectedVersion, productTag, sourceSha, - workflowSha, runId, runAttempt, repository, @@ -464,7 +499,6 @@ async function main() { expectedVersion, productTag, sourceSha, - workflowSha, runId, runAttempt, repository, @@ -477,13 +511,19 @@ async function main() { }); return; } - if (command === 'validate-stage-run' && args.length === 3) { - const [releaseDirectory, runPath, expectedVersion] = args; - validateStageRun({ + if (command === 'validate-stage-run' && (args.length === 3 || args.length === 4)) { + const [releaseDirectory, runPath, expectedVersion, output] = args; + const record = validateStageRun({ releaseDirectory: resolve(releaseDirectory), expectedVersion, run: readJson(resolve(runPath), 'stage workflow run'), }); + if (output) { + appendOutputs(output, { + product_tag: record.productTag, + source_commit: record.source.commit, + }); + } return; } if (command === 'prepare-audit' && args.length === 2) { diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index af497b86b1..833b981dc8 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -16,7 +16,6 @@ import { } from './release-cli-publication.mjs'; const SOURCE_SHA = 'a'.repeat(40); -const WORKFLOW_SHA = 'b'.repeat(40); const WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; const CURRENT_CLI_VERSION = JSON.parse( readFileSync(resolve(import.meta.dirname, '../packages/cli/package.json'), 'utf8'), @@ -27,8 +26,8 @@ const STAGE_RUN = { run_attempt: 1, path: WORKFLOW_PATH, event: 'workflow_dispatch', - head_branch: 'main', - head_sha: WORKFLOW_SHA, + head_branch: PRODUCT_TAG, + head_sha: SOURCE_SHA, conclusion: 'success', head_repository: { full_name: 'maka-agent/maka-agent' }, }; @@ -94,7 +93,6 @@ test('stage records bind the checked candidate to one source workflow run', () = expectedVersion: fixture.version, productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, - workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -102,9 +100,10 @@ test('stage records bind the checked candidate to one source workflow run', () = }); assert.equal(prepared.record.sha256, fixture.sha256); + assert.equal(prepared.record.schemaVersion, 3); assert.equal(prepared.record.productTag, PRODUCT_TAG); assert.equal(prepared.record.source.commit, SOURCE_SHA); - assert.equal(prepared.record.source.workflowCommit, WORKFLOW_SHA); + assert.equal(Object.hasOwn(prepared.record.source, 'workflowCommit'), false); assert.equal(prepared.record.source.runId, '321'); assert.equal(prepared.record.source.runAttempt, '1'); assert.deepEqual( @@ -123,7 +122,6 @@ test('stage preparation rejects a product tag that does not match the version', expectedVersion: fixture.version, productTag: 'v9.9.9', sourceSha: SOURCE_SHA, - workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -143,7 +141,6 @@ test('stage preparation rejects confirmation and checksum drift', () => { expectedVersion: '0.1.0-beta.2', productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, - workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -161,7 +158,6 @@ test('stage preparation rejects confirmation and checksum drift', () => { expectedVersion: fixture.version, productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, - workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -171,7 +167,7 @@ test('stage preparation rejects confirmation and checksum drift', () => { ); }); -test('finalization accepts only the exact successful main stage run', () => { +test('finalization accepts only the exact successful product-tag stage run', () => { const fixture = createPreparedCandidate(); assert.equal( @@ -186,7 +182,7 @@ test('finalization accepts only the exact successful main stage run', () => { for (const drift of [ { path: '.github/workflows/other.yml' }, { event: 'pull_request' }, - { head_branch: 'feature' }, + { head_branch: 'v0.1.0-beta.2' }, { conclusion: 'failure' }, { head_sha: 'c'.repeat(40) }, { run_attempt: 2 }, @@ -290,6 +286,7 @@ test('signature audit must contain Maka provenance for the finalized version', ( name: 'maka-agent', version: fixture.version, attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + attestationBundles: [provenanceBundle()], }, ], }; @@ -317,6 +314,51 @@ test('signature audit must contain Maka provenance for the finalized version', ( ); }); +test('signature audit binds provenance to the exact tag, source, workflow, and run', () => { + const fixture = createPreparedCandidate(); + const audit = (mutate) => ({ + invalid: [], + missing: [], + verified: [ + { + name: 'maka-agent', + version: fixture.version, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + attestationBundles: [provenanceBundle(mutate)], + }, + ], + }); + for (const mutate of [ + (statement) => { + statement.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = 'b'.repeat(40); + }, + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.ref = 'refs/heads/main'; + }, + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.path = + '.github/workflows/other.yml'; + }, + (statement) => { + statement.predicate.runDetails.metadata.invocationId = + 'https://github.com/maka-agent/maka-agent/actions/runs/999/attempts/1'; + }, + (statement) => { + statement.predicate.buildDefinition.externalParameters.workflow.repository = + 'https://github.com/other/repository'; + }, + ]) { + assert.throws( + () => + validateSignatureAudit({ + releaseDirectory: fixture.releaseDirectory, + audit: audit(mutate), + }), + /provenance does not match/u, + ); + } +}); + test('signature audit tree exposes only the top-level registry package', () => { const fixture = createPreparedCandidate(); const auditDirectory = mkdtempSync(join(tmpdir(), 'maka-cli-signature-audit-')); @@ -349,7 +391,6 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { fixture.version, `v${fixture.version}`, SOURCE_SHA, - WORKFLOW_SHA, '321', '1', 'maka-agent/maka-agent', @@ -370,6 +411,7 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { test('validate-stage-run CLI accepts the canonical staged release identity', () => { const fixture = createPreparedCandidate(); const runPath = join(fixture.root, 'stage-run.json'); + const output = join(fixture.root, 'github-output.txt'); writeFileSync( runPath, JSON.stringify({ @@ -377,8 +419,8 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () run_attempt: 1, path: WORKFLOW_PATH, event: 'workflow_dispatch', - head_branch: 'main', - head_sha: WORKFLOW_SHA, + head_branch: PRODUCT_TAG, + head_sha: SOURCE_SHA, conclusion: 'success', head_repository: { full_name: 'maka-agent/maka-agent' }, }), @@ -392,11 +434,16 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () fixture.releaseDirectory, runPath, fixture.version, + output, ], { encoding: 'utf8' }, ); assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ + `product_tag=${PRODUCT_TAG}`, + `source_commit=${SOURCE_SHA}`, + ]); }); function createPreparedCandidate() { @@ -407,7 +454,6 @@ function createPreparedCandidate() { expectedVersion: fixture.version, productTag: `v${fixture.version}`, sourceSha: SOURCE_SHA, - workflowSha: WORKFLOW_SHA, runId: '321', runAttempt: '1', repository: 'maka-agent/maka-agent', @@ -416,6 +462,46 @@ function createPreparedCandidate() { return fixture; } +function provenanceBundle(mutate = () => {}) { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + predicateType: 'https://slsa.dev/provenance/v1', + predicate: { + buildDefinition: { + buildType: 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1', + externalParameters: { + workflow: { + repository: 'https://github.com/maka-agent/maka-agent', + ref: `refs/tags/${PRODUCT_TAG}`, + path: WORKFLOW_PATH, + }, + }, + resolvedDependencies: [ + { + uri: `git+https://github.com/maka-agent/maka-agent@refs/tags/${PRODUCT_TAG}`, + digest: { gitCommit: SOURCE_SHA }, + }, + ], + internalParameters: { github: { event_name: 'workflow_dispatch' } }, + }, + runDetails: { + builder: { id: 'https://github.com/actions/runner/github-hosted' }, + metadata: { + invocationId: 'https://github.com/maka-agent/maka-agent/actions/runs/321/attempts/1', + }, + }, + }, + }; + mutate(statement); + return { + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)).toString('base64'), + signatures: [{ keyid: '', sig: 'verified-by-npm' }], + }, + }; +} + function createCandidate(version = '0.1.0-beta.1') { const root = mkdtempSync(join(tmpdir(), 'maka-cli-publication-')); const releaseDirectory = join(root, 'packages/cli/release'); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 5f051890dd..b233bbc0ee 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -42,6 +42,7 @@ test('stage consumes the validated artifact and makes provenance staging the fin const submit = namedStep(steps, 'Submit the candidate to npm staging'); assert.equal(steps.at(-1), submit); assert.match(submit, /git ls-remote --tags --refs origin/u); + assert.match(submit, /git merge-base --is-ancestor "\$PRODUCT_SOURCE_COMMIT" origin\/main/u); assert.match(submit, /gh release view "\$PRODUCT_TAG"/u); assert.ok(submit.indexOf('git ls-remote') < submit.indexOf('npm stage publish')); assert.ok(submit.indexOf('gh release view') < submit.indexOf('npm stage publish')); @@ -51,6 +52,10 @@ test('stage consumes the validated artifact and makes provenance staging the fin test('stage builds the npm candidate from the exact product release commit', () => { const workflow = readWorkflow('release-cli-stage.yml'); + const authorizeSteps = workflowSteps(workflow); + const checkout = authorizeSteps.find((step) => step.includes('uses: actions/checkout@')); + assert.match(checkout, /ref: v\$\{\{ inputs\.version \}\}/u); + assert.match(workflow, /RELEASE_REF.*refs\/tags\/\$PRODUCT_TAG/su); assert.match(workflow, /source_commit: \$\{\{ steps\.product\.outputs\.source_commit \}\}/u); assert.match( workflow, @@ -62,7 +67,9 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(workflow, /node scripts\/release-version\.mjs "\$EXPECTED_VERSION"/u); assert.doesNotMatch(workflow, /EXPECTED_VERSION.*=~/u); assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); - assert.doesNotMatch(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); + assert.match(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); + assert.doesNotMatch(workflow, /RELEASE_WORKFLOW_SHA/u); + assert.match(workflow, /git merge-base --is-ancestor "\$source_commit" origin\/main/u); const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); @@ -75,6 +82,7 @@ test('finalize validates one exact stage attempt before running the current veri const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); + assert.match(workflow, /run\.head_branch !== "v" \+ process\.env\.EXPECTED_VERSION/u); for (const field of [ 'run.id', 'run.run_attempt', @@ -92,11 +100,26 @@ test('finalize validates one exact stage attempt before running the current veri assert.doesNotMatch(checkout, /steps\.stage-run\.outputs\.source_sha/u); }); +test('finalize revalidates the live product release before trusting public npm bytes', () => { + const workflow = readWorkflow('release-cli-finalize.yml'); + const steps = workflowSteps(workflow); + const record = namedStep(steps, 'Verify the stage run and release record'); + assert.match(record, /id: release/u); + assert.match(record, /"\$GITHUB_OUTPUT"/u); + const authority = namedStep(steps, 'Revalidate the product release authority'); + assert.match(authority, /git ls-remote --tags --refs origin/u); + assert.match(authority, /git merge-base --is-ancestor "\$PRODUCT_SOURCE_COMMIT" origin\/main/u); + assert.match(authority, /gh release view "\$PRODUCT_TAG"/u); + assert.ok( + workflow.indexOf(authority) < workflow.indexOf('Fetch and verify the public registry bytes'), + ); +}); + test('finalize preserves verified npm bytes without creating another product release', () => { const workflow = readWorkflow('release-cli-finalize.yml'); assert.match(workflow, /name: Preserve the verified public npm package/u); assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); - assert.doesNotMatch(workflow, /cli-v|gh release|contents: write|validate-github-release/u); + assert.doesNotMatch(workflow, /cli-v|contents: write|validate-github-release/u); }); test('release workflows select npm from the root packageManager authority', () => { diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index c61e2e7f3c..edcc514a03 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -494,7 +494,6 @@ export async function verifyMacosArm64Cli( rootManifest, desktopManifest, cliManifest, - ref: 'refs/heads/main', sha: sourceCommitResult.stdout.trim(), }); const version = identity.version; From e4cb2ca127664fb8111efaee75f819ab73c75a75 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 16:49:50 +0800 Subject: [PATCH 15/31] fix(release): recover draft assets without replacement Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 6 ++++-- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++++--- scripts/product-release.test.mjs | 19 ++++++++++++------- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index dcf2b52117..ee8e20d6ae 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -52,8 +52,10 @@ must never be exposed to fork or ordinary pull-request jobs. If the publish job created the product tag or Draft but failed before every asset was uploaded, rerun `Release` from `main` with `source_commit` set to the exact commit already named by the tag. This input is recovery-only: the workflow requires it to remain an ancestor of `main`, rejects a tag that -points elsewhere, refuses to replace a published Release, and replaces the Draft's asset set with the -newly verified artifacts. If only the tag exists, the retry creates the missing Draft. +points elsewhere, and refuses to replace a published Release. Existing Draft assets must exactly +match the newly verified bytes; the retry keeps matching assets and uploads only missing ones. If an +asset conflicts or is unexpected, inspect and remove it manually while the Release is still a Draft, +then rerun. If only the tag exists, the retry creates the missing Draft. ## Acceptance on another Apple Silicon Mac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba6c6849d3..ab9a3cd0a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -469,15 +469,40 @@ jobs: fi gh release edit "$TAG" --title "Maka ${VERSION}" --notes "$notes" 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 - gh release delete-asset "$TAG" "$asset" --yes + 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 if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then - echo "Release ${TAG} was published while assets were replaced." >&2 + echo "Release ${TAG} was published while assets were checked." >&2 exit 1 fi - gh release upload "$TAG" release-assets/* + if (( ${#missing_assets[@]} > 0 )); then + gh release upload "$TAG" "${missing_assets[@]}" + fi echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 9e8615ad2f..11868eb3f7 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -370,13 +370,18 @@ test('one product workflow gates one draft release on every required artifact', assert.doesNotMatch(commands, /requires an existing Draft release/u); assert.match(commands, /--json isDraft/u); assert.match(commands, /gh release create[\s\S]*--verify-tag/u); - assert.match(commands, /gh release delete-asset/u); - assert.match(commands, /gh release upload/u); - assert.doesNotMatch(commands, /gh release upload[\s\S]*--clobber/u); - const listAssets = commands.indexOf('asset_names="$(gh release view'); - const deleteAssets = commands.indexOf('gh release delete-asset'); - const uploadAssets = commands.indexOf('gh release upload'); - assert.ok(listAssets >= 0 && listAssets < deleteAssets && deleteAssets < uploadAssets); + const publishRelease = jobs.publish.steps.find( + (step) => step.name === 'Create or update the draft GitHub Release', + ).run; + assert.doesNotMatch(publishRelease, /gh release delete-asset/u); + assert.match(publishRelease, /gh release download/u); + assert.match(publishRelease, /cmp -s/u); + assert.match(publishRelease, /gh release upload/u); + assert.doesNotMatch(publishRelease, /--clobber/u); + const listAssets = publishRelease.indexOf('asset_names="$(gh release view'); + const compareAssets = publishRelease.indexOf('cmp -s'); + const uploadAssets = publishRelease.indexOf('gh release upload'); + assert.ok(listAssets >= 0 && listAssets < compareAssets && compareAssets < uploadAssets); assert.doesNotMatch(commands, /gh release create[\s\S]*--target/u); assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); await assert.rejects( From d0838eb25d63ef4007887ed3513f5541b16889c7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 16:56:01 +0800 Subject: [PATCH 16/31] docs(release): dispatch npm staging from product tags Generated-by: Codex --- docs/cli-npm-release.md | 10 ++++++++-- docs/cli-npm-release.zh-CN.md | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index bbb97300da..223ca15f9b 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -79,8 +79,14 @@ package owner or recovery access as part of that change. ## Stage the candidate -1. Open **Actions → Stage CLI npm release → Run workflow**. -2. Select `v` under **Use workflow from** and enter the same exact product version. The workflow requires its GitHub ref, checkout, product tag, Release, source commit, and npm provenance to identify that one tag commit, and requires the commit to remain an ancestor of `main`. +1. Dispatch the workflow with the exact product tag as its GitHub ref: + + ```sh + version=0.1.0-beta.1 + gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + ``` + +2. Confirm the created run uses `v`. The workflow requires its GitHub ref, checkout, product tag, Release, source commit, and npm provenance to identify that one tag commit, and requires the commit to remain an ancestor of `main`. 3. Wait for the reusable package validation jobs to pass. They build one tarball and validate the installed CLI on Linux x64, macOS arm64, and Windows x64, plus real Harbor and Pier Docker cells on Linux x64. diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 968e2e4498..0f8d7b15ae 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -75,8 +75,14 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ## Stage 候选包 -1. 打开 **Actions → Stage CLI npm release → Run workflow**; -2. 在 **Use workflow from** 选择 `v`,并输入同一个精确产品版本;workflow 要求其 GitHub ref、checkout、产品 tag、Release、source commit 和 npm provenance 全部指向这一个 tag commit,并要求该 commit 仍是 `main` 的 ancestor; +1. 使用精确产品 tag 作为 GitHub ref dispatch workflow: + + ```sh + version=0.1.0-beta.1 + gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + ``` + +2. 确认新建的 run 使用 `v`。workflow 要求其 GitHub ref、checkout、产品 tag、Release、source commit 和 npm provenance 全部指向这一个 tag commit,并要求该 commit 仍是 `main` 的 ancestor; 3. 等待可复用 package validation jobs 全部通过。它们只构建一个 tarball,并在 Linux x64、 macOS arm64、Windows x64 上验证安装态 CLI,在 Linux x64 上运行真实 Harbor 和 Pier Docker cell; From ed7fa2b4a2ed5402521bbb6fcf3226882e53db45 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 16:57:05 +0800 Subject: [PATCH 17/31] fix(release): parse npm provenance attestation bundles Generated-by: Codex --- scripts/release-cli-publication.mjs | 5 ++-- scripts/release-cli-publication.test.mjs | 32 +++++++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 6ca48dd7d1..665a768b8b 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -297,8 +297,9 @@ function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, work } } -function parseProvenanceStatement(bundle) { - const envelope = bundle?.dsseEnvelope; +function parseProvenanceStatement(attestation) { + if (attestation?.predicateType !== 'https://slsa.dev/provenance/v1') return null; + const envelope = attestation.bundle?.dsseEnvelope; if ( envelope?.payloadType !== 'application/vnd.in-toto+json' || typeof envelope.payload !== 'string' diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 833b981dc8..be29f93a64 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -357,6 +357,27 @@ test('signature audit binds provenance to the exact tag, source, workflow, and r /provenance does not match/u, ); } + const wrongPredicate = provenanceBundle(); + wrongPredicate.predicateType = 'https://example.invalid/provenance'; + assert.throws( + () => + validateSignatureAudit({ + releaseDirectory: fixture.releaseDirectory, + audit: { + invalid: [], + missing: [], + verified: [ + { + name: 'maka-agent', + version: fixture.version, + attestations: { provenance: {} }, + attestationBundles: [wrongPredicate], + }, + ], + }, + }), + /provenance does not match/u, + ); }); test('signature audit tree exposes only the top-level registry package', () => { @@ -494,10 +515,13 @@ function provenanceBundle(mutate = () => {}) { }; mutate(statement); return { - dsseEnvelope: { - payloadType: 'application/vnd.in-toto+json', - payload: Buffer.from(JSON.stringify(statement)).toString('base64'), - signatures: [{ keyid: '', sig: 'verified-by-npm' }], + predicateType: 'https://slsa.dev/provenance/v1', + bundle: { + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)).toString('base64'), + signatures: [{ keyid: '', sig: 'verified-by-npm' }], + }, }, }; } From 551a17003b4fd3bb2b945c34627f1d4db43d246a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 17:02:33 +0800 Subject: [PATCH 18/31] docs(release): require immutable product identity Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 7 +++++++ docs/cli-npm-release.md | 4 +++- docs/cli-npm-release.zh-CN.md | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index ee8e20d6ae..7c5f34cd84 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -26,6 +26,13 @@ configure: Windows remains unsigned until an Authenticode policy and certificate are added. Release secrets must never be exposed to fork or ordinary pull-request jobs. +Before the first product release, configure repository release immutability: + +- add a `v*` tag ruleset that blocks updates, force-pushes, and deletions after creation, with bypass limited to the release authority required by `product-release-tag.mjs`; +- enable immutable releases so assets and the associated tag cannot change after publication. + +These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. + ## Create the complete Draft 1. Confirm the intended commit is on `main`, required CI is green, and root `package.json` diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 223ca15f9b..664acae91f 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -119,7 +119,9 @@ Immediately before approval, recheck the live product authority recorded by the ```sh git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" -source_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +source_commit=replace-with-stage-recorded-commit +tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +test "$tag_commit" = "$source_commit" git merge-base --is-ancestor "$source_commit" origin/main gh release view "v$version" --json tagName --jq .tagName ``` diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 0f8d7b15ae..0750c8de2d 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -114,7 +114,9 @@ npm stage download "$stage_id" --registry https://registry.npmjs.org/ ```sh git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" -source_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +source_commit=replace-with-stage-recorded-commit +tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" +test "$tag_commit" = "$source_commit" git merge-base --is-ancestor "$source_commit" origin/main gh release view "v$version" --json tagName --jq .tagName ``` From 1ff0c1d85cba4dcae857e17f1f5ee650bc618137 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 20:09:07 +0800 Subject: [PATCH 19/31] fix(release): validate the live product identity locally Generated-by: Codex --- package.json | 2 +- scripts/product-release-identity.mjs | 16 ++++++++++++++-- scripts/product-release.test.mjs | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 076b35c613..20218e846a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:product-release": "node --test scripts/product-release.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/product-release.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:product-release-identity && node --test scripts/product-release.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 143ab7e36e..7a3ea5f1ac 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -1,9 +1,12 @@ +import { execFile } from 'node:child_process'; import { appendFile, readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; import { parseProductReleaseVersion } from './release-version.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const execFileAsync = promisify(execFile); export function releaseToolchainFromManifest(rootManifest) { const nodeVersion = rootManifest.releaseToolchain?.node; @@ -75,13 +78,22 @@ export function assertProductReleaseExpectation(identity, { version, tag, source return identity; } -export async function readProductReleaseIdentity({ sha = process.env.GITHUB_SHA } = {}) { +export async function readProductReleaseIdentity({ sha } = {}) { const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), ]); - return resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, sha }); + const sourceCommit = + sha ?? + process.env.GITHUB_SHA ?? + (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot })).stdout.trim(); + return resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha: sourceCommit, + }); } function githubOutputEntries(identity) { diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 11868eb3f7..e144d33914 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -29,6 +29,7 @@ import { ensureProductTag } from './product-release-tag.mjs'; import { writeSha256Sidecar } from './release-checksum.mjs'; const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, '..'); const rootManifest = { version: '1.2.3', @@ -56,6 +57,22 @@ test('one root version defines every product artifact from one source commit', ( assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('the product identity CLI uses the checked-out commit outside GitHub Actions', async () => { + const env = { ...process.env }; + delete env.GITHUB_SHA; + delete env.GITHUB_OUTPUT; + const [{ stdout }, { stdout: head }, manifest] = await Promise.all([ + execFileAsync(process.execPath, [join(repoRoot, 'scripts/product-release-identity.mjs')], { + cwd: repoRoot, + env, + }), + execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }), + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + ]); + + assert.equal(stdout.trim(), `Product release v${manifest.version} from ${head.trim()}`); +}); + test('product and npm release identities reject the same non-canonical versions', () => { for (const version of ['01.2.3', '1.2.3-beta..1', '1.2.3-.', '1.2.3-beta.01']) { const manifests = { From 9669a4557fc236aa2eb79bf7edb6ebc119c4f357 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 19 Aug 2026 20:09:07 +0800 Subject: [PATCH 20/31] docs(release): close manual validation gaps Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 16 ++++++++-------- docs/cli-npm-release.md | 1 + docs/cli-npm-release.zh-CN.md | 1 + 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 7c5f34cd84..f5f5517df7 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -88,14 +88,14 @@ boundary. ## Acceptance on a Windows x64 machine -Download the installer and checksum through a browser from the same Draft. - -1. Verify the SHA-256 in PowerShell. -2. Run the installer and confirm the expected unsigned-publisher SmartScreen flow. -3. Launch Maka from the Start menu, configure a model connection, send one prompt, and run one - representative file-tool task. -4. Run one terminal task and confirm packaged `node-pty` behavior. -5. Confirm the documented Computer Use limitation remains accurate. +Download the installer, Windows Desktop ZIP, and both checksum files through a browser from the same Draft. + +1. Verify both SHA-256 checksums in PowerShell. +2. Expand the ZIP and launch its Maka executable once to confirm the portable artifact starts. +3. Run the installer and confirm the expected unsigned-publisher SmartScreen flow. +4. Launch Maka from the Start menu, configure a model connection, send one prompt, and run one representative file-tool task. +5. Run one terminal task and confirm packaged `node-pty` behavior. +6. Confirm the documented Computer Use limitation remains accurate. Publish only after both independent-machine acceptance passes. If any required artifact or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 664acae91f..6111021c2b 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -118,6 +118,7 @@ Before approval: Immediately before approval, recheck the live product authority recorded by the Stage run: ```sh +set -eu git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" source_commit=replace-with-stage-recorded-commit tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 0750c8de2d..1e489891c1 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -113,6 +113,7 @@ npm stage download "$stage_id" --registry https://registry.npmjs.org/ 批准前的最后一步,重新检查 Stage run 记录的 live 产品权威: ```sh +set -eu git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" source_commit=replace-with-stage-recorded-commit tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" From 9ffea1a9b48b3e57ca5c4bf7003172a27f566e89 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 20 Aug 2026 15:32:35 +0800 Subject: [PATCH 21/31] fix(ci): build before validating release contracts Generated-by: Codex --- .github/workflows/ci.yml | 10 +++++----- scripts/ci-test-plan.test.mjs | 11 +++++++++++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e73a08ca..35871c642b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,10 +103,6 @@ jobs: if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' run: npm ci - - name: Release contracts - if: steps.plan.outputs.cli_package == 'true' - run: npm run check:release - - name: Lint if: steps.plan.outputs.code == 'true' run: npm run lint @@ -116,9 +112,13 @@ jobs: run: npm run format:check - name: Build - if: steps.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' run: npm run build + - name: Release contracts + if: steps.plan.outputs.cli_package == 'true' + run: npm run check:release + - name: Typecheck if: steps.plan.outputs.code == 'true' run: npm run typecheck diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index ab48fa3b91..cf545ef561 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -190,6 +190,17 @@ test('core CI validates affected installed CLI packages on its existing runner', assert.match(workflow, /run: npm run release:cli:smoke/u); }); +test('release contracts run against built CLI outputs', () => { + const workflow = readWorkflow('ci.yml'); + const buildIndex = workflow.indexOf(' - name: Build\n'); + const buildEnd = workflow.indexOf('\n - ', buildIndex + 1); + const releaseIndex = workflow.indexOf(' - name: Release contracts\n'); + + assert.ok(buildIndex >= 0); + assert.match(workflow.slice(buildIndex, buildEnd), /cli_package == 'true'/u); + assert.ok(buildIndex < releaseIndex); +}); + test('pull request triggers stay on an explicit allowlist', () => { // Naming the lanes that must not run on pull requests only covers the ones // someone remembered to name; W0 kept an unbounded trigger that way. From a3f4ea77292d58eac93ebe5363c96138118556a2 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 15:13:44 +0800 Subject: [PATCH 22/31] fix(release): bind products to ASF source authority Require product artifacts to come from the exact approved ASF source tag, and keep canonical repository identity across release metadata. Preserve Node's reviewed runtime entitlements during signing, pin the Apple signing team, and route every release authority change through its dedicated CI contract gate. Generated-by: Codex --- .github/RELEASE_CHECKLIST.md | 47 +++++--- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 67 ++++++----- docs/cli-npm-release.md | 20 ++-- docs/cli-npm-release.zh-CN.md | 13 +-- package.json | 1 + scripts/ci-test-plan.mjs | 30 ++++- scripts/ci-test-plan.test.mjs | 29 ++++- scripts/package-macos-arm64-cli.mjs | 113 ++++++++++++++++--- scripts/prepare-windows-upgrade-baseline.mjs | 2 +- scripts/product-release-identity.mjs | 43 ++++++- scripts/product-release.test.mjs | 90 +++++++++++++-- scripts/release-cli-package.mjs | 6 +- scripts/release-cli-publication.mjs | 2 +- scripts/release-cli-publication.test.mjs | 24 ++-- scripts/verify-macos-arm64-cli.mjs | 23 +++- 16 files changed, 398 insertions(+), 120 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index f5f5517df7..a13a33b764 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -1,8 +1,10 @@ # Product release checklist -The `Release` workflow is Maka's single release entry point. Desktop, CLI/TUI, and source -materials share one source commit, root product version, tag, GitHub Release, Draft decision, -and release gate. The workflow creates no Draft until every required artifact job succeeds. +The `Release` workflow is Maka's convenience-artifact release entry point. Desktop, CLI/TUI, and +bundled Git source materials are built from the exact IPMC-approved ASF source candidate commit. +They share that source commit, the root product version, one convenience tag, one GitHub Release, +one Draft decision, and one release gate. The workflow creates no Draft until every required +artifact job succeeds. Phase 1 requires: @@ -23,6 +25,10 @@ configure: - `APPLE_API_KEY_ID`: App Store Connect API key ID; - `APPLE_API_ISSUER`: App Store Connect API issuer ID. +The checked-in release configuration pins Maka's Apple Team ID to `FABM2QUA8Q`. Confirm every +replacement `CSC_LINK` belongs to that team before changing credentials; changing the pinned Team +ID requires its own reviewed product-release change. + Windows remains unsigned until an Authenticode policy and certificate are added. Release secrets must never be exposed to fork or ordinary pull-request jobs. @@ -35,31 +41,36 @@ These controls close the check-to-upload and check-to-stage windows. Keep the Re ## Create the complete Draft -1. Confirm the intended commit is on `main`, required CI is green, and root `package.json` - contains a product version that has never been released. -2. Confirm `apps/desktop/package.json` and `packages/cli/package.json` exactly match the root +1. Confirm the podling and Incubator PMC votes have both passed for one immutable source candidate. + Record both result URLs and independently verify its signed annotated + `v-incubating-rc` tag. +2. Confirm that tag resolves to a commit on `main`, required CI is green for that exact commit, and + root `package.json` contains a product version that has never been released. +3. Confirm `apps/desktop/package.json` and `packages/cli/package.json` exactly match the root version, and the CLI manifest exposes only the `maka` command. -3. In GitHub Actions, run `Release` against `main`. -4. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, `source`, and +4. Dispatch `Release` from the exact approved candidate tag and supply the same tag as + `source_reference_tag`. A rerun must use that same tag; never select current `main` instead. +5. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, `source`, and `publish` pass. A skipped or failed required job must prevent Draft creation. -5. Confirm one Draft named `v` targets the intended source SHA and contains at least: +6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source + reference in its notes, and contains at least: - `Maka--mac-arm64.dmg` and checksum; - `Maka--win-x64.exe` and checksum; - `Maka--cli-mac-arm64.zip` and checksum; - `Maka--bundled-git-source.tar.gz` and checksum; - the platform update metadata and Desktop ZIPs produced by electron-builder. -6. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, +7. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, `THIRD_PARTY_NOTICES.txt`, the pinned Node license, and no `bin/maka-agent`. -7. Confirm `RELEASE.json` records the Draft's product version and source SHA, the official Node +8. Confirm `RELEASE.json` records the Draft's product version and source SHA, Apple Team ID + `FABM2QUA8Q`, the official Node URL/archive/digest, npm version, workspace and production dependency closures, dependency patches, Mach-O inventory, and `developer-id-notarized` signing state. -8. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, +9. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, all manifest archives, and the expected Dugite native release are present. -If the publish job created the product tag or Draft but failed before every asset was uploaded, rerun -`Release` from `main` with `source_commit` set to the exact commit already named by the tag. This -input is recovery-only: the workflow requires it to remain an ancestor of `main`, rejects a tag that -points elsewhere, and refuses to replace a published Release. Existing Draft assets must exactly +If the publish job created the product tag or Draft but failed before every asset was uploaded, +rerun `Release` from the same approved ASF source candidate tag with the same +`source_reference_tag` input. Existing Draft assets must exactly match the newly verified bytes; the retry keeps matching assets and uploads only missing ones. If an asset conflicts or is unexpected, inspect and remove it manually while the Release is still a Draft, then rerun. If only the tag exists, the retry creates the missing Draft. @@ -97,6 +108,8 @@ Download the installer, Windows Desktop ZIP, and both checksum files through a b 5. Run one terminal task and confirm packaged `node-pty` behavior. 6. Confirm the documented Computer Use limitation remains accurate. -Publish only after both independent-machine acceptance passes. If any required artifact or +Immediately before publication, reverify that the approved ASF candidate tag and convenience +`v` tag still resolve to the same recorded commit. Publish only after both +independent-machine acceptance passes. If any required artifact or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35871c642b..9d89bfc736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' with: node-version: '24' cache: npm @@ -100,7 +100,7 @@ jobs: fi - name: Install dependencies - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm ci - name: Lint @@ -112,11 +112,11 @@ jobs: run: npm run format:check - name: Build - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm run build - name: Release contracts - if: steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.release_contract == 'true' run: npm run check:release - name: Typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab9a3cd0a9..7defc46359 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,9 @@ name: Release on: workflow_dispatch: inputs: - source_commit: - description: Exact main ancestor used only to retry an interrupted Draft publication - required: false + source_reference_tag: + description: Exact approved ASF source candidate tag (v-incubating-rc) + required: true type: string permissions: @@ -17,13 +17,13 @@ concurrency: jobs: release-identity: - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest 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 }} dmg: ${{ steps.identity.outputs.dmg }} exe: ${{ steps.identity.outputs.exe }} cli_archive: ${{ steps.identity.outputs.cli_archive }} @@ -39,46 +39,41 @@ jobs: - name: Check out the dispatched commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.source_commit || github.sha }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - name: Resolve product release identity id: identity env: - GITHUB_SHA: ${{ inputs.source_commit || github.sha }} + GITHUB_SHA: ${{ github.sha }} + SOURCE_REFERENCE_TAG: ${{ inputs.source_reference_tag }} run: node scripts/product-release-identity.mjs - - name: Require the release source on main + - name: Require the exact ASF source reference env: + RELEASE_REF: ${{ github.ref }} + RELEASE_REPOSITORY: ${{ github.repository }} SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }} - run: git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main - - - name: Restrict an explicit source to Draft recovery - env: - GH_TOKEN: ${{ github.token }} - RECOVERY_SOURCE: ${{ inputs.source_commit }} - SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }} - TAG: ${{ steps.identity.outputs.tag }} + SOURCE_REFERENCE_TAG: ${{ steps.identity.outputs.source_reference_tag }} run: | - if [[ -z "$RECOVERY_SOURCE" ]]; then - exit 0 - fi - tagged_source="$(git ls-remote --tags --refs origin "refs/tags/$TAG" | awk '{print $1}')" - if [[ "$tagged_source" != "$SOURCE_COMMIT" ]]; then - echo "Recovery tag ${TAG} does not point to ${SOURCE_COMMIT}." >&2 + if [[ "$RELEASE_REPOSITORY" != "apache/maka" ]]; then + echo "Product releases must run in apache/maka; found $RELEASE_REPOSITORY" >&2 exit 1 fi - if gh release view "$TAG" >/dev/null 2>&1; then - if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then - echo "Recovery cannot replace published Release ${TAG}." >&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 - if: github.ref == 'refs/heads/main' strategy: # Both platforms ship from one dispatch, so a failure on one still leaves # the other artifact available for diagnosis. @@ -234,7 +229,6 @@ jobs: cli-macos-arm64: needs: release-identity - if: github.ref == 'refs/heads/main' runs-on: macos-15 environment: release timeout-minutes: 45 @@ -326,7 +320,6 @@ jobs: source: needs: release-identity - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -380,7 +373,6 @@ jobs: # 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, source] - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -423,6 +415,18 @@ jobs: (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") done < <(find release-assets -type f -name '*.sha256' -print0) + - 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 }} @@ -438,8 +442,9 @@ jobs: 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 commit ${SOURCE_COMMIT}. + 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 another Apple Silicon Mac and on a Windows x64 machine, and complete .github/RELEASE_CHECKLIST.md. diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 6111021c2b..4dad5f1249 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -6,7 +6,8 @@ This runbook is the operational authority for publishing the `maka-agent` npm in ## Release invariants -- Dispatch the product Release and npm Finalize workflows only from `main`. Dispatch npm Stage only from the existing product `v` tag. +- Dispatch the product Release workflow only from the exact approved ASF source candidate tag. + Dispatch npm Stage only from the resulting product `v` tag and npm Finalize from `main`. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. @@ -45,8 +46,8 @@ In the `maka-agent` package settings, configure one GitHub Actions trusted publi | Field | Value | | --- | --- | -| Organization or user | `maka-agent` | -| Repository | `maka-agent` | +| Organization or user | `apache` | +| Repository | `maka` | | Workflow filename | `release-cli-stage.yml` | | Environment name | `npm-release` | | Allowed actions | `npm stage publish` only | @@ -60,10 +61,15 @@ package owner or recovery access as part of that change. ## Prepare a release -1. Merge all intended package, documentation, and release changes to `main`. -2. Set the root product version, `apps/desktop/package.json`, and `packages/cli/package.json` to the same unused target version and merge that change. The npm channel maps prerelease versions to `next` and stable versions to `latest`. -3. Run the product `Release` workflow and confirm its Draft `v` Release points to the - intended source commit. npm staging consumes this identity and cannot precede it. +1. Merge all intended package, documentation, and release changes to `main`, prepare the ASF source + candidate, and complete both the podling and Incubator PMC votes. +2. Confirm the root product version, `apps/desktop/package.json`, and + `packages/cli/package.json` have the same unused target version at the approved source commit. + The npm channel maps prerelease versions to `next` and stable versions to `latest`. +3. Dispatch the product `Release` workflow from the exact approved + `v-incubating-rc` tag, supplying that same tag as `source_reference_tag`. Confirm its + Draft `v` Release points to the approved commit. npm staging consumes this identity and + cannot precede it. 4. Confirm the target version is absent from both public and staged package state: ```sh diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 1e489891c1..3232ab26d9 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -6,7 +6,7 @@ ## 发布不变量 -- 产品 Release 和 npm Finalize workflow 只从 `main` dispatch;npm Stage 只从已有的产品 `v` tag dispatch; +- 产品 Release workflow 只能从已批准的 ASF source candidate tag dispatch;npm Stage 只能从随后创建的产品 `v` tag dispatch,npm Finalize 只能从 `main` dispatch; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; - 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; @@ -42,8 +42,8 @@ token。 | 字段 | 值 | | --- | --- | -| Organization or user | `maka-agent` | -| Repository | `maka-agent` | +| Organization or user | `apache` | +| Repository | `maka` | | Workflow filename | `release-cli-stage.yml` | | Environment name | `npm-release` | | Allowed actions | 仅 `npm stage publish` | @@ -57,10 +57,9 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ## 准备发布 -1. 将本次包、文档和发布变更全部合并到 `main`; -2. 将根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 设置为同一个尚未使用的目标版本并合并。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; -3. 运行产品 `Release` workflow,确认其 Draft `v` Release 指向预期 source commit;npm - staging 消费这个身份,不能先于它运行; +1. 将本次包、文档和发布变更全部合并到 `main`,准备 ASF source candidate,并完成 podling 和 Incubator PMC 两轮投票; +2. 确认已批准 source commit 上的根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 是同一个尚未使用的目标版本。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; +3. 从精确的已批准 `v-incubating-rc` tag dispatch 产品 `Release` workflow,并将同一个 tag 作为 `source_reference_tag`。确认其 Draft `v` Release 指向已批准 commit;npm staging 消费这个身份,不能先于它运行; 4. 确认目标版本既不在公共 registry,也不在 staged package 中: ```sh diff --git a/package.json b/package.json index 20218e846a..76313eadb2 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "packageManager": "npm@11.19.0", "releaseToolchain": { + "appleTeamIdentifier": "FABM2QUA8Q", "node": "24.18.1", "nodeDarwinArm64Sha256": "1d60b703fe5d7e7072489be8187f430f1a095a658c31e5e1e281331a5873fac3" }, diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 7dbc69611b..bc57303257 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -16,7 +16,21 @@ const FULL_SUITE_FILES = new Set([ 'scripts/run-workspace-tests-parallel.mjs', ]); -const RELEASE_CONTRACT_FILES = new Set(['.github/workflows/release.yml']); +const RELEASE_CONTRACT_FILES = new Set([ + '.github/workflows/cli-package-validation.yml', + '.github/workflows/release-cli-finalize.yml', + '.github/workflows/release-cli-stage.yml', + '.github/workflows/release.yml', + 'scripts/package-macos-arm64-cli.mjs', + 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/prepare-windows-upgrade-baseline.test.mjs', + 'scripts/product-release-identity.mjs', + 'scripts/product-release-tag.mjs', + 'scripts/product-release.test.mjs', + 'scripts/release-checksum.mjs', + 'scripts/release-version.mjs', + 'scripts/verify-macos-arm64-cli.mjs', +]); const TYPECHECK_ONLY_FILES = new Set([ 'biome.jsonc', @@ -63,6 +77,10 @@ function isCliPackagePath(path) { ); } +function isReleaseContractPath(path) { + return RELEASE_CONTRACT_FILES.has(path) || path.startsWith('scripts/release-cli-'); +} + const DEDICATED_WORKSPACE_LANES = new Set(['packages/runtime-host']); // Scripts the Electron e2e job runs. Editing one of these changes what that @@ -253,6 +271,7 @@ export function planTests(changedFiles, options = {}) { code: true, e2e: true, full: true, + releaseContract: true, runtimeSandbox: graph.dirs.includes('packages/cli'), // A complete functional suite is still the default release/main gate. // Stress multipliers and native child-process lock probes run only when @@ -296,10 +315,6 @@ export function planTests(changedFiles, options = {}) { code = true; continue; } - if (RELEASE_CONTRACT_FILES.has(path)) { - code = true; - continue; - } if (path.startsWith('.github/') || isDocumentation(path)) continue; code = true; unknownCode = true; @@ -312,16 +327,18 @@ export function planTests(changedFiles, options = {}) { const workspaces = reverseDependencyClosure(directWorkspaces, graph); const storageStress = files.some((path) => STORAGE_STRESS_FILES.has(path)); + const cliPackage = files.some((path) => isCliPackagePath(path)); return { asfSource: files.some((path) => ASF_SOURCE_FILES.has(path)), astryxSurface: files.some((path) => isAstryxSurfaceInventoryPath(path)), - cliPackage: files.some((path) => isCliPackagePath(path)), + cliPackage, code, // Electron E2E + alignment audit (same job). Product desktop/ui sources and // e2e drivers only — a storage/runtime change must not drag cold Electron // boots, and packages/ui unit-test-only PRs must not either. e2e: files.some((path) => isE2eProductPath(path)), full: false, + releaseContract: cliPackage || files.some((path) => isReleaseContractPath(path)), // packages/cli/src/__tests__/runtime-host-session-driver.test.ts executes real sandboxed // shell tools, so the bubblewrap + user-namespace setup is required whenever // the cli workspace runs in the dependency closure, not only for direct @@ -346,6 +363,7 @@ export function formatGitHubOutputs(plan) { `e2e=${plan.e2e}`, `runtime_host=${plan.runtimeHost}`, `runtime_sandbox=${plan.runtimeSandbox}`, + `release_contract=${plan.releaseContract}`, `storage_stress=${plan.storageStress}`, `storybook=${plan.storybook}`, `standard_workspaces=${plan.standardWorkspaces.join(',')}`, diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index cf545ef561..642629155d 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -81,10 +81,32 @@ test('release metadata selects only the gate that consumes it', () => { for (const path of ['LICENSE', 'NOTICE']) { const plan = planTests([path], { graph }); assert.equal(plan.cliPackage, true, path); + assert.equal(plan.releaseContract, true, path); assert.equal(plan.asfSource, false, path); } }); +test('release authority changes select their dedicated contract gate', () => { + for (const path of [ + '.github/workflows/cli-package-validation.yml', + '.github/workflows/release-cli-finalize.yml', + '.github/workflows/release-cli-stage.yml', + '.github/workflows/release.yml', + 'scripts/package-macos-arm64-cli.mjs', + 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/product-release-identity.mjs', + 'scripts/product-release-tag.mjs', + 'scripts/product-release.test.mjs', + 'scripts/release-checksum.mjs', + 'scripts/release-version.mjs', + 'scripts/release-cli-publication.test.mjs', + 'scripts/verify-macos-arm64-cli.mjs', + ]) { + assert.equal(planTests([path], { graph }).releaseContract, true, path); + } + assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false); +}); + test('ASF source authority changes select their dedicated gate', () => { for (const path of [ '.gitattributes', @@ -112,6 +134,7 @@ test('full selection covers every live surface', () => { assert.equal(plan.e2e, true); assert.equal(plan.storybook, true); assert.equal(plan.runtimeHost, true); + assert.equal(plan.releaseContract, true); assert.deepEqual(plan.workspaces, dirs); }); @@ -197,8 +220,12 @@ test('release contracts run against built CLI outputs', () => { const releaseIndex = workflow.indexOf(' - name: Release contracts\n'); assert.ok(buildIndex >= 0); - assert.match(workflow.slice(buildIndex, buildEnd), /cli_package == 'true'/u); + assert.match(workflow.slice(buildIndex, buildEnd), /release_contract == 'true'/u); assert.ok(buildIndex < releaseIndex); + assert.match( + workflow.slice(releaseIndex), + /if: steps\.plan\.outputs\.release_contract == 'true'/u, + ); }); test('pull request triggers stay on an explicit allowlist', () => { diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index f9e44e11d3..f1d89711d9 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -45,6 +45,14 @@ const requiredSigningEnvironment = [ 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER', ]; +export const NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ + 'com.apple.security.cs.allow-dyld-environment-variables', + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + 'com.apple.security.cs.disable-executable-page-protection', + 'com.apple.security.cs.disable-library-validation', + 'com.apple.security.get-task-allow', +]); function runCommand(command, args, options = {}) { return new Promise((resolvePromise, reject) => { @@ -331,14 +339,36 @@ export function assertOfficialNodeRuntime({ } } +export function extractOfficialNodeEntitlements(output) { + const start = output.indexOf(''); + if (start < 0 || end < start) { + throw new Error('Official Node runtime has no readable entitlement plist.'); + } + const plist = output.slice(start, end + ''.length); + const allKeys = [...plist.matchAll(/([^<]+)<\/key>/gu)].map((match) => match[1]).sort(); + const keys = [...plist.matchAll(/([^<]+)<\/key>\s*/gu)] + .map((match) => match[1]) + .sort(); + if ( + JSON.stringify(allKeys) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) || + JSON.stringify(keys) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) + ) { + throw new Error('Official Node runtime entitlements do not match the reviewed contract.'); + } + return plist; +} + async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { - const [nodeVersion, npmVersion, architectures, signature, dependencies] = await Promise.all([ - inspect(execPath, ['-p', 'process.versions.node'], { env }), - inspect('npm', ['--version'], { env }), - inspect('lipo', ['-archs', execPath], { env }), - inspect('codesign', ['-d', '--verbose=4', execPath], { env }), - inspect('otool', ['-L', execPath], { env }), - ]); + const [nodeVersion, npmVersion, architectures, signature, entitlements, dependencies] = + await Promise.all([ + inspect(execPath, ['-p', 'process.versions.node'], { env }), + inspect('npm', ['--version'], { env }), + inspect('lipo', ['-archs', execPath], { env }), + inspect('codesign', ['-d', '--verbose=4', execPath], { env }), + inspect('codesign', ['-d', '--entitlements', ':-', execPath], { env }), + inspect('otool', ['-L', execPath], { env }), + ]); assertOfficialNodeRuntime({ actualVersion: nodeVersion.stdout.trim(), expectedVersion: toolchain.nodeVersion, @@ -351,6 +381,7 @@ async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { `CLI release requires npm ${toolchain.npmVersion}, found ${npmVersion.stdout.trim()}.`, ); } + return extractOfficialNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); } export async function assertOfficialNodeArchive( @@ -547,15 +578,31 @@ export function decodeSigningCertificate(value) { export function parseDeveloperIdApplicationIdentity(output) { const identities = [...output.matchAll(/^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"([^"]+)"\s*$/gmu)] - .map((match) => ({ hash: match[1].toUpperCase(), name: match[2] })) + .map((match) => ({ + hash: match[1].toUpperCase(), + name: match[2], + teamIdentifier: / \(([A-Z0-9]{10})\)$/u.exec(match[2])?.[1], + })) .filter(({ name }) => name.startsWith('Developer ID Application:')); if (identities.length !== 1) { throw new Error('CLI signing keychain must contain one Developer ID Application identity.'); } + if (!identities[0].teamIdentifier) { + throw new Error('Developer ID Application identity has no exact Apple Team ID.'); + } return identities[0]; } -async function createSigningKeychain({ env, run, inspect }) { +export function assertExpectedAppleTeam(identity, expectedTeamIdentifier) { + if (identity.teamIdentifier !== expectedTeamIdentifier) { + throw new Error( + `CLI signing identity belongs to Apple team ${identity.teamIdentifier}, expected ${expectedTeamIdentifier}.`, + ); + } + return identity; +} + +async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }) { const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-cli-signing-')); const certificatePath = join(temporaryRoot, 'identity.p12'); const pemPath = join(temporaryRoot, 'identity.pem'); @@ -615,9 +662,14 @@ async function createSigningKeychain({ env, run, inspect }) { ['find-identity', '-v', '-p', 'codesigning', keychainFile], { env }, ); + const identity = assertExpectedAppleTeam( + parseDeveloperIdApplicationIdentity(identityOutput.stdout), + expectedTeamIdentifier, + ); return { cleanup, - identity: parseDeveloperIdApplicationIdentity(identityOutput.stdout), + directory: temporaryRoot, + identity, keychainFile, }; } catch (error) { @@ -626,11 +678,18 @@ async function createSigningKeychain({ env, run, inspect }) { } } -async function signCliBinaries(machOBinaries, { env, run, inspect }) { +async function signCliBinaries( + machOBinaries, + { env, expectedTeamIdentifier, run, inspect, nodeEntitlements, nodePath }, +) { assertReleaseSigningEnvironment(env); - const signing = await createSigningKeychain({ env, run, inspect }); + const signing = await createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }); try { + const nodeEntitlementsPath = join(signing.directory, 'node-entitlements.plist'); + await writeFile(nodeEntitlementsPath, `${nodeEntitlements}\n`, { mode: 0o600 }); for (const binaryPath of machOBinaries) { + const entitlements = + resolve(binaryPath) === resolve(nodePath) ? ['--entitlements', nodeEntitlementsPath] : []; await run( 'codesign', [ @@ -638,6 +697,7 @@ async function signCliBinaries(machOBinaries, { env, run, inspect }) { '--options', 'runtime', '--timestamp', + ...entitlements, '--sign', signing.identity.hash, '--keychain', @@ -648,9 +708,18 @@ async function signCliBinaries(machOBinaries, { env, run, inspect }) { ); await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); } + const signedNodeEntitlements = await inspect( + 'codesign', + ['-d', '--entitlements', ':-', nodePath], + { env }, + ); + extractOfficialNodeEntitlements( + `${signedNodeEntitlements.stdout}\n${signedNodeEntitlements.stderr}`, + ); return { identityName: signing.identity.name, machOBinaryCount: machOBinaries.length, + teamIdentifier: signing.identity.teamIdentifier, }; } finally { await signing.cleanup(); @@ -750,7 +819,12 @@ export async function packageMacosArm64Cli({ toolchain, { env, run }, ); - await inspectReleaseToolchain({ execPath: officialNode.execPath, env, inspect, toolchain }); + const nodeEntitlements = await inspectReleaseToolchain({ + execPath: officialNode.execPath, + env, + inspect, + toolchain, + }); const installRoot = join(stagingRoot, 'install'); await mkdir(installRoot, { recursive: true }); @@ -832,6 +906,7 @@ export async function packageMacosArm64Cli({ sourceUrl: toolchain.nodeSourceUrl, archive: toolchain.nodeArchive, archiveSha256: toolchain.nodeArchiveSha256, + entitlements: NODE_RUNTIME_ENTITLEMENTS, }, npmVersion: toolchain.npmVersion, dependencyPatches, @@ -840,6 +915,7 @@ export async function packageMacosArm64Cli({ workspacePackages: workspacePackages.map(({ name }) => name).sort(), machOBinaries: machOBinaries.map((path) => relative(archiveRoot, path)).sort(), signing: releaseSigning ? 'developer-id-notarized' : 'development', + signingTeamIdentifier: releaseSigning ? toolchain.appleTeamIdentifier : null, }; await writeFile( join(archiveRoot, 'RELEASE.json'), @@ -848,7 +924,16 @@ export async function packageMacosArm64Cli({ ); let signing; - if (releaseSigning) signing = await signCliBinaries(machOBinaries, { env, run, inspect }); + if (releaseSigning) { + signing = await signCliBinaries(machOBinaries, { + env, + expectedTeamIdentifier: toolchain.appleTeamIdentifier, + run, + inspect, + nodeEntitlements, + nodePath: join(embeddedNodeDirectory, 'bin', 'node'), + }); + } await createCliZip(archiveRoot, archivePath, { env, run }); if (releaseSigning) await notarizeCliZip(archivePath, { env, inspect }); diff --git a/scripts/prepare-windows-upgrade-baseline.mjs b/scripts/prepare-windows-upgrade-baseline.mjs index 9a6c76263b..9ce0e81529 100644 --- a/scripts/prepare-windows-upgrade-baseline.mjs +++ b/scripts/prepare-windows-upgrade-baseline.mjs @@ -44,7 +44,7 @@ export async function prepareWindowsUpgradeBaseline( outputDirectory, { manifestPath = defaultManifestPath, - repository = process.env.GITHUB_REPOSITORY ?? 'maka-agent/maka-agent', + repository = process.env.GITHUB_REPOSITORY ?? 'apache/maka', run = runFile, checksum = sha256File, } = {}, diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 7a3ea5f1ac..e9412c538d 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -9,18 +9,23 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const execFileAsync = promisify(execFile); export function releaseToolchainFromManifest(rootManifest) { + const appleTeamIdentifier = rootManifest.releaseToolchain?.appleTeamIdentifier; const nodeVersion = rootManifest.releaseToolchain?.node; const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; const npmMatch = /^npm@(\d+\.\d+\.\d+)$/u.exec(rootManifest.packageManager ?? ''); if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { throw new Error('package.json must define an exact releaseToolchain.node version'); } + if (typeof appleTeamIdentifier !== 'string' || !/^[A-Z0-9]{10}$/u.test(appleTeamIdentifier)) { + throw new Error('releaseToolchain.appleTeamIdentifier must be an exact Apple Team ID'); + } const nodeArchive = `node-v${nodeVersion}-darwin-arm64.tar.xz`; if (typeof nodeArchiveSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(nodeArchiveSha256)) { throw new Error('releaseToolchain.nodeDarwinArm64Sha256 must be an exact SHA-256 digest'); } if (!npmMatch) throw new Error('package.json packageManager must pin an exact npm version'); return { + appleTeamIdentifier, nodeVersion, nodeArchive, nodeArchiveSha256, @@ -29,7 +34,26 @@ export function releaseToolchainFromManifest(rootManifest) { }; } -export function resolveProductReleaseIdentity({ rootManifest, desktopManifest, cliManifest, sha }) { +export function parseAsfSourceReferenceTag(tag) { + const match = typeof tag === 'string' ? /^v(.+)-incubating-rc([1-9]\d*)$/u.exec(tag) : undefined; + if (!match) { + throw new Error('ASF source reference must match v-incubating-rc'); + } + try { + parseProductReleaseVersion(match[1]); + } catch { + throw new Error('ASF source reference must match v-incubating-rc'); + } + return { rcNumber: match[2], tag, version: match[1] }; +} + +export function resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha, + sourceReferenceTag, +}) { const { version } = parseProductReleaseVersion(rootManifest.version); for (const [label, manifest] of [ ['Desktop', desktopManifest], @@ -47,6 +71,14 @@ export function resolveProductReleaseIdentity({ rootManifest, desktopManifest, c if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { throw new Error('Product releases require an exact 40-character source commit SHA'); } + if (sourceReferenceTag !== undefined) { + const sourceReference = parseAsfSourceReferenceTag(sourceReferenceTag); + if (sourceReference.version !== version) { + throw new Error( + `ASF source reference version ${sourceReference.version} does not match product ${version}`, + ); + } + } const toolchain = releaseToolchainFromManifest(rootManifest); const cliArchive = `Maka-${version}-cli-mac-arm64.zip`; @@ -56,6 +88,7 @@ export function resolveProductReleaseIdentity({ rootManifest, desktopManifest, c version, tag: `v${version}`, sourceCommit: sha, + sourceReferenceTag, dmg: `Maka-${version}-mac-arm64.dmg`, exe: `Maka-${version}-win-x64.exe`, cliArchive, @@ -78,7 +111,10 @@ export function assertProductReleaseExpectation(identity, { version, tag, source return identity; } -export async function readProductReleaseIdentity({ sha } = {}) { +export async function readProductReleaseIdentity({ + sha, + sourceReferenceTag = process.env.SOURCE_REFERENCE_TAG, +} = {}) { const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), @@ -93,6 +129,7 @@ export async function readProductReleaseIdentity({ sha } = {}) { desktopManifest, cliManifest, sha: sourceCommit, + sourceReferenceTag, }); } @@ -101,6 +138,7 @@ function githubOutputEntries(identity) { version: identity.version, tag: identity.tag, source_commit: identity.sourceCommit, + source_reference_tag: identity.sourceReferenceTag, dmg: identity.dmg, exe: identity.exe, cli_archive: identity.cliArchive, @@ -129,6 +167,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } if (process.env.GITHUB_OUTPUT) { const output = Object.entries(githubOutputEntries(identity)) + .filter(([, value]) => value !== undefined) .map(([name, value]) => `${name}=${value}`) .join('\n'); await appendFile(process.env.GITHUB_OUTPUT, `${output}\n`, 'utf8'); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index e144d33914..88226ed133 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -10,11 +10,14 @@ import { parseCliReleaseVersion } from './release-cli-publication.mjs'; import { planTests } from './ci-test-plan.mjs'; import { assertProductReleaseExpectation, + parseAsfSourceReferenceTag, releaseToolchainFromManifest, resolveProductReleaseIdentity, } from './product-release-identity.mjs'; import { + assertExpectedAppleTeam, decodeSigningCertificate, + extractOfficialNodeEntitlements, macosArm64MachOAction, parseDeveloperIdApplicationIdentity, pruneThirdPartyDevelopmentArtifacts, @@ -35,6 +38,7 @@ const rootManifest = { version: '1.2.3', packageManager: 'npm@11.19.0', releaseToolchain: { + appleTeamIdentifier: 'FABM2QUA8Q', node: '24.18.1', nodeDarwinArm64Sha256: '1'.repeat(64), }, @@ -46,17 +50,41 @@ test('one root version defines every product artifact from one source commit', ( desktopManifest: { version: '1.2.3' }, cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, sha: 'a'.repeat(40), + sourceReferenceTag: 'v1.2.3-incubating-rc2', }); assert.equal(identity.version, '1.2.3'); assert.equal(identity.tag, 'v1.2.3'); assert.equal(identity.sourceCommit, 'a'.repeat(40)); + assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); assert.equal(identity.dmg, 'Maka-1.2.3-mac-arm64.dmg'); assert.equal(identity.exe, 'Maka-1.2.3-win-x64.exe'); assert.equal(identity.cliArchive, 'Maka-1.2.3-cli-mac-arm64.zip'); assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('product releases accept only an exact same-version ASF source reference', () => { + assert.deepEqual(parseAsfSourceReferenceTag('v1.2.3-incubating-rc2'), { + rcNumber: '2', + tag: 'v1.2.3-incubating-rc2', + version: '1.2.3', + }); + for (const tag of ['v1.2.3-incubating-rc0', 'v1.2.3-incubating-rc01', 'v1.2.3-rc1']) { + assert.throws(() => parseAsfSourceReferenceTag(tag), /ASF source reference must match/u); + } + assert.throws( + () => + resolveProductReleaseIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + sourceReferenceTag: 'v1.2.4-incubating-rc1', + }), + /source reference version 1\.2\.4 does not match product 1\.2\.3/u, + ); +}); + test('the product identity CLI uses the checked-out commit outside GitHub Actions', async () => { const env = { ...process.env }; delete env.GITHUB_SHA; @@ -157,6 +185,7 @@ test('product tag creation is exact and idempotent but rejects a conflicting com test('the root manifest pins the Node archive and npm used by release jobs', () => { assert.deepEqual(releaseToolchainFromManifest(rootManifest), { + appleTeamIdentifier: 'FABM2QUA8Q', nodeVersion: '24.18.1', nodeArchive: 'node-v24.18.1-darwin-arm64.tar.xz', nodeArchiveSha256: '1'.repeat(64), @@ -215,11 +244,12 @@ test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identi assert.throws(() => decodeSigningCertificate('not base64!'), /base64-encoded/u); assert.deepEqual( parseDeveloperIdApplicationIdentity( - ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (TEAMID)"\n 1 valid identities found\n', + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (FABM2QUA8Q)"\n 1 valid identities found\n', ), { hash: 'ABCDEF0123456789ABCDEF0123456789ABCDEF01', - name: 'Developer ID Application: Maka Test (TEAMID)', + name: 'Developer ID Application: Maka Test (FABM2QUA8Q)', + teamIdentifier: 'FABM2QUA8Q', }, ); assert.throws( @@ -229,6 +259,35 @@ test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identi ), /one Developer ID Application/u, ); + assert.throws( + () => + assertExpectedAppleTeam( + { teamIdentifier: 'ABCDEFGHIJ' }, + rootManifest.releaseToolchain.appleTeamIdentifier, + ), + /belongs to Apple team ABCDEFGHIJ, expected FABM2QUA8Q/u, + ); +}); + +test('CLI signing preserves the reviewed official Node entitlement contract', () => { + const plist = ` + +com.apple.security.cs.allow-jit +com.apple.security.cs.allow-unsigned-executable-memory +com.apple.security.cs.disable-executable-page-protection +com.apple.security.cs.allow-dyld-environment-variables +com.apple.security.cs.disable-library-validation +com.apple.security.get-task-allow +`; + + assert.equal(extractOfficialNodeEntitlements(`Executable=/node\n${plist}`), plist); + assert.throws( + () => + extractOfficialNodeEntitlements( + plist.replace(/com\.apple\.security\.cs\.allow-jit<\/key>/u, ''), + ), + /entitlements do not match/u, + ); }); test('the Eval workspace owns the complete runtime asset declaration', async () => { @@ -341,12 +400,24 @@ test('one product workflow gates one draft release on every required artifact', 'cli-macos-arm64', 'source', ]); - assert.equal(jobs.publish.if, "github.ref == 'refs/heads/main'"); + assert.equal(jobs.publish.if, undefined); assert.equal(Object.hasOwn(jobs, 'npm'), false); - assert.equal( - jobs['release-identity'].steps[0].with.ref, - '${{ inputs.source_commit || github.sha }}', - ); + assert.equal(workflow.on.workflow_dispatch.inputs.source_reference_tag.required, true); + assert.equal(jobs['release-identity'].steps[0].with.ref, '${{ github.sha }}'); + const sourceAuthority = jobs['release-identity'].steps.find( + (step) => step.name === 'Require the exact ASF source reference', + ).run; + assert.match(sourceAuthority, /RELEASE_REPOSITORY.*apache\/maka/su); + assert.match(sourceAuthority, /refs\/tags\/\$SOURCE_REFERENCE_TAG/u); + assert.match(sourceAuthority, /git cat-file -t/u); + assert.match(sourceAuthority, /git rev-parse.*\^\{commit\}/u); + assert.match(sourceAuthority, /git merge-base --is-ancestor/u); + const liveSourceAuthority = jobs.publish.steps.find( + (step) => step.name === 'Revalidate the live ASF source reference', + ).run; + assert.match(liveSourceAuthority, /git fetch --force --no-tags origin/u); + assert.match(liveSourceAuthority, /git rev-parse.*\^\{commit\}/u); + assert.match(liveSourceAuthority, /git merge-base --is-ancestor/u); for (const name of ['desktop', 'cli-macos-arm64', 'source', 'publish']) { const checkout = jobs[name].steps.find((step) => String(step.uses).startsWith('actions/checkout@'), @@ -382,7 +453,7 @@ test('one product workflow gates one draft release on every required artifact', assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); assert.match(commands, /product-release-tag\.mjs ensure/u); - assert.match(commands, /RECOVERY_SOURCE/u); + assert.doesNotMatch(commands, /RECOVERY_SOURCE|inputs\.source_commit/u); assert.match(commands, /if gh release view "\$TAG"/u); assert.doesNotMatch(commands, /requires an existing Draft release/u); assert.match(commands, /--json isDraft/u); @@ -422,7 +493,8 @@ test('product workflow changes select the release contracts in CI', () => { dependents: new Map(), }; const plan = planTests(['.github/workflows/release.yml'], { graph }); - assert.equal(plan.code, true); + assert.equal(plan.releaseContract, true); + assert.equal(plan.code, false); assert.equal(plan.full, false); assert.equal(plan.e2e, false); assert.equal(plan.storybook, false); diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index 57bfc9f9f8..74ae576996 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -453,11 +453,11 @@ function writeReleaseManifest(cli, publishable) { engines: root.engines, repository: { type: 'git', - url: 'git+https://github.com/maka-agent/maka-agent.git', + url: 'git+https://github.com/apache/maka.git', directory: 'packages/cli', }, - homepage: 'https://github.com/maka-agent/maka-agent#readme', - bugs: { url: 'https://github.com/maka-agent/maka-agent/issues' }, + homepage: 'https://github.com/apache/maka#readme', + bugs: { url: 'https://github.com/apache/maka/issues' }, keywords: ['ai', 'agent', 'cli', 'tui', 'local-first'], publishConfig: publishable ? { diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 665a768b8b..88910158ab 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -7,7 +7,7 @@ import { parseProductReleaseVersion } from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; -const REPOSITORY = 'maka-agent/maka-agent'; +const REPOSITORY = 'apache/maka'; const STAGE_WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; const RELEASE_RECORD_KEYS = [ 'schemaVersion', diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index be29f93a64..bd84643d4c 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -29,7 +29,7 @@ const STAGE_RUN = { head_branch: PRODUCT_TAG, head_sha: SOURCE_SHA, conclusion: 'success', - head_repository: { full_name: 'maka-agent/maka-agent' }, + head_repository: { full_name: 'apache/maka' }, }; test('release versions map prereleases and stable versions to distinct channels', () => { @@ -95,7 +95,7 @@ test('stage records bind the checked candidate to one source workflow run', () = sourceSha: SOURCE_SHA, runId: '321', runAttempt: '1', - repository: 'maka-agent/maka-agent', + repository: 'apache/maka', workflowPath: WORKFLOW_PATH, }); @@ -124,7 +124,7 @@ test('stage preparation rejects a product tag that does not match the version', sourceSha: SOURCE_SHA, runId: '321', runAttempt: '1', - repository: 'maka-agent/maka-agent', + repository: 'apache/maka', workflowPath: WORKFLOW_PATH, }), /Product tag .* does not match/u, @@ -143,7 +143,7 @@ test('stage preparation rejects confirmation and checksum drift', () => { sourceSha: SOURCE_SHA, runId: '321', runAttempt: '1', - repository: 'maka-agent/maka-agent', + repository: 'apache/maka', workflowPath: WORKFLOW_PATH, }), /confirmation/u, @@ -160,7 +160,7 @@ test('stage preparation rejects confirmation and checksum drift', () => { sourceSha: SOURCE_SHA, runId: '321', runAttempt: '1', - repository: 'maka-agent/maka-agent', + repository: 'apache/maka', workflowPath: WORKFLOW_PATH, }), /checksum does not match/u, @@ -341,7 +341,7 @@ test('signature audit binds provenance to the exact tag, source, workflow, and r }, (statement) => { statement.predicate.runDetails.metadata.invocationId = - 'https://github.com/maka-agent/maka-agent/actions/runs/999/attempts/1'; + 'https://github.com/apache/maka/actions/runs/999/attempts/1'; }, (statement) => { statement.predicate.buildDefinition.externalParameters.workflow.repository = @@ -414,7 +414,7 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { SOURCE_SHA, '321', '1', - 'maka-agent/maka-agent', + 'apache/maka', WORKFLOW_PATH, output, ], @@ -443,7 +443,7 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () head_branch: PRODUCT_TAG, head_sha: SOURCE_SHA, conclusion: 'success', - head_repository: { full_name: 'maka-agent/maka-agent' }, + head_repository: { full_name: 'apache/maka' }, }), ); @@ -477,7 +477,7 @@ function createPreparedCandidate() { sourceSha: SOURCE_SHA, runId: '321', runAttempt: '1', - repository: 'maka-agent/maka-agent', + repository: 'apache/maka', workflowPath: WORKFLOW_PATH, }); return fixture; @@ -492,14 +492,14 @@ function provenanceBundle(mutate = () => {}) { buildType: 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1', externalParameters: { workflow: { - repository: 'https://github.com/maka-agent/maka-agent', + repository: 'https://github.com/apache/maka', ref: `refs/tags/${PRODUCT_TAG}`, path: WORKFLOW_PATH, }, }, resolvedDependencies: [ { - uri: `git+https://github.com/maka-agent/maka-agent@refs/tags/${PRODUCT_TAG}`, + uri: `git+https://github.com/apache/maka@refs/tags/${PRODUCT_TAG}`, digest: { gitCommit: SOURCE_SHA }, }, ], @@ -508,7 +508,7 @@ function provenanceBundle(mutate = () => {}) { runDetails: { builder: { id: 'https://github.com/actions/runner/github-hosted' }, metadata: { - invocationId: 'https://github.com/maka-agent/maka-agent/actions/runs/321/attempts/1', + invocationId: 'https://github.com/apache/maka/actions/runs/321/attempts/1', }, }, }, diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index edcc514a03..b2a24e418b 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -20,9 +20,11 @@ import { promisify, stripVTControlCharacters } from 'node:util'; import { assertMacosArm64CliHost, assertNoDanglingSymlinks, + extractOfficialNodeEntitlements, inspectNativeArtifacts, isMacosArm64MachO, listApplicableDependencyPatchNames, + NODE_RUNTIME_ENTITLEMENTS, resolveCliWorkspacePackages, resolveMacosArm64CliArtifactPaths, } from './package-macos-arm64-cli.mjs'; @@ -266,7 +268,7 @@ async function smokePackagedEval(archiveRoot, sourceCommit, environment, run) { benchmark: { id: 'release-smoke', version: sourceCommit, - config: { repository: 'https://github.com/maka-agent/maka-agent.git' }, + config: { repository: 'https://github.com/apache/maka.git' }, }, executor: { kind: 'harbor', @@ -454,8 +456,10 @@ async function smokePatchedStreamingToolCalls(archiveRoot) { assertPatchedStreamingToolCalls(parts); } -async function verifyBinarySignatures(binaryPaths, { requireReleaseSigning, run }) { - let expectedTeamIdentifier; +async function verifyBinarySignatures( + binaryPaths, + { expectedTeamIdentifier, nodePath, requireReleaseSigning, run }, +) { for (const binaryPath of binaryPaths) { await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath]); if (!requireReleaseSigning) continue; @@ -464,9 +468,14 @@ async function verifyBinarySignatures(binaryPaths, { requireReleaseSigning, run if (!details.authority || !details.hardenedRuntime || !details.teamIdentifier) { throw new Error(`${binaryPath} is not signed with a hardened Developer ID identity.`); } - expectedTeamIdentifier ??= details.teamIdentifier; if (details.teamIdentifier !== expectedTeamIdentifier) { - throw new Error(`${binaryPath} is signed by a different Developer ID team.`); + throw new Error( + `${binaryPath} is signed by Apple team ${details.teamIdentifier}, expected ${expectedTeamIdentifier}.`, + ); + } + if (resolve(binaryPath) === resolve(nodePath)) { + const entitlements = await run('codesign', ['-d', '--entitlements', ':-', binaryPath]); + extractOfficialNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); } } return expectedTeamIdentifier; @@ -558,7 +567,9 @@ export async function verifyMacosArm64Cli( metadata.node?.sourceUrl !== toolchain.nodeSourceUrl || metadata.node?.archive !== toolchain.nodeArchive || metadata.node?.archiveSha256 !== toolchain.nodeArchiveSha256 || + JSON.stringify(metadata.node?.entitlements) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) || metadata.npmVersion !== toolchain.npmVersion || + (requireReleaseSigning && metadata.signingTeamIdentifier !== toolchain.appleTeamIdentifier) || JSON.stringify(metadata.publicCommands) !== JSON.stringify(['maka']) ) { throw new Error('CLI release metadata does not match the product release identity.'); @@ -602,6 +613,8 @@ export async function verifyMacosArm64Cli( const nodeDependencies = await run('otool', ['-L', nodePath]); assertSelfContainedNode(nodeDependencies.stdout); const signingTeamIdentifier = await verifyBinarySignatures(machOBinaries, { + expectedTeamIdentifier: toolchain.appleTeamIdentifier, + nodePath, requireReleaseSigning, run, }); From 8df5188ca81218f9d8381202a1c8837aa5cc0c10 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 15:54:23 +0800 Subject: [PATCH 23/31] fix(release): enforce distributable product artifacts Derive Desktop Runtime Host setup metadata from the shared product manifests and verify it in packaged applications. Re-sign the standalone Node runtime without its development-only entitlement while retaining exact upstream validation. Route direct release inputs through the contract gate and document the npm-before-publication acceptance order. --- .github/RELEASE_CHECKLIST.md | 30 +++++++++-- apps/desktop/electron-builder.config.mjs | 21 ++++---- docs/cli-npm-release.md | 5 ++ docs/cli-npm-release.zh-CN.md | 5 ++ scripts/ci-test-plan.mjs | 18 +++++++ scripts/ci-test-plan.test.mjs | 10 ++++ scripts/package-macos-arm64-cli.mjs | 54 +++++++++++++++---- scripts/product-release-identity.mjs | 49 ++++++++++++----- scripts/product-release.test.mjs | 68 +++++++++++++++++++++++- scripts/verify-macos-arm64-cli.mjs | 14 +++-- scripts/verify-macos-arm64-dmg.mjs | 10 ++-- scripts/verify-packaged-app.mjs | 16 +++++- scripts/verify-windows-autoupdate.mjs | 1 + scripts/verify-windows-x64.mjs | 17 +++--- 14 files changed, 267 insertions(+), 51 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index a13a33b764..87bfb59b6a 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -14,6 +14,10 @@ Phase 1 requires: - bundled Git source materials; - checksums generated after each artifact reaches its final form. +The first product release also requires the exact `maka-agent@` npm package. The product +tag and Draft must exist before npm staging, but the Draft must remain unpublished until npm is +public, Finalize has verified it, and Desktop acceptance has exercised remote Runtime Host setup. + ## One-time repository setup Create a protected GitHub Environment named `release`, require the appropriate reviewers, and @@ -64,7 +68,10 @@ These controls close the check-to-upload and check-to-stage windows. Keep the Re 8. Confirm `RELEASE.json` records the Draft's product version and source SHA, Apple Team ID `FABM2QUA8Q`, the official Node URL/archive/digest, npm version, workspace and production dependency closures, dependency - patches, Mach-O inventory, and `developer-id-notarized` signing state. + patches, Mach-O inventory, and `developer-id-notarized` signing state. Its final Node + entitlements must retain the required hardened-runtime capabilities and omit + `com.apple.security.get-task-allow`, as required by Apple's + [notarization guidance](https://developer.apple.com/documentation/security/resolving-common-notarization-issues). 9. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, all manifest archives, and the expected Dugite native release are present. @@ -75,6 +82,19 @@ match the newly verified bytes; the retry keeps matching assets and uploads only asset conflicts or is unexpected, inspect and remove it manually while the Release is still a Draft, then rerun. If only the tag exists, the retry creates the missing Draft. +## Publish and verify the npm channel + +Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: + +1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. +2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. +3. Run **Finalize CLI npm channel** from `main` and confirm it verifies the public package bytes, + provenance, signature, and release dist-tag. +4. Install the exact public version on each release platform and complete the npm acceptance steps. + +Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate +requires a new product version; never publish the Draft to work around npm state. + ## Acceptance on another Apple Silicon Mac Download the DMG, CLI ZIP, and their checksum files through a browser from the Draft. Do not move @@ -96,6 +116,8 @@ boundary. tool-call path against the packaged artifact. 8. Configure a Desktop model connection, send one prompt, and run one representative file-tool task. Confirm the documented Computer Use limitation remains accurate. +9. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact + public `maka-agent@` package and the remote session completes one model turn. ## Acceptance on a Windows x64 machine @@ -107,9 +129,11 @@ Download the installer, Windows Desktop ZIP, and both checksum files through a b 4. Launch Maka from the Start menu, configure a model connection, send one prompt, and run one representative file-tool task. 5. Run one terminal task and confirm packaged `node-pty` behavior. 6. Confirm the documented Computer Use limitation remains accurate. +7. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact + public `maka-agent@` package and the remote session completes one model turn. Immediately before publication, reverify that the approved ASF candidate tag and convenience -`v` tag still resolve to the same recorded commit. Publish only after both -independent-machine acceptance passes. If any required artifact or +`v` tag still resolve to the same recorded commit. Publish only after npm Finalize and both +independent-machine acceptance passes. If any required artifact, npm step, or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 25edb53737..ed3bf774c1 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,19 +1,22 @@ -const runtimeHostSetupPackage = process.env.MAKA_RUNTIME_HOST_SETUP_PACKAGE?.trim(); -if ( - runtimeHostSetupPackage !== undefined && - !/^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(runtimeHostSetupPackage) -) { - throw new Error('MAKA_RUNTIME_HOST_SETUP_PACKAGE must name an exact Maka CLI version'); +import { readFileSync } from 'node:fs'; +import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; + +function readManifest(relativePath) { + return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ + rootManifest: readManifest('../../package.json'), + desktopManifest: readManifest('./package.json'), + cliManifest: readManifest('../../packages/cli/package.json'), +}); + export default { appId: 'com.maka.desktop', productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, - ...(runtimeHostSetupPackage - ? { extraMetadata: { runtimeHostSetupPackage } } - : {}), + extraMetadata: { runtimeHostSetupPackage }, directories: { output: 'release', }, diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 4dad5f1249..c1c71567c2 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -12,6 +12,8 @@ This runbook is the operational authority for publishing the `maka-agent` npm in a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. - Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow, and must already exist before npm staging. +- Keep that GitHub Release in Draft until npm Finalize and Desktop remote Runtime Host acceptance + succeed. The Draft supplies npm's product identity; its publication is the final product action. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package maintainer approves the staged package with npm 2FA. - Do not rebuild between validation, staging, approval, and finalization. @@ -184,6 +186,9 @@ Finally, install the exact public version on each release platform and complete turn. On the supported Eval host, complete at least one real experiment cell and inspect score, usage, cost, and artifacts. +Return to the [product release checklist](../.github/RELEASE_CHECKLIST.md) and exercise remote Runtime +Host setup from the packaged Desktop apps before publishing the GitHub Release. + ## Failure recovery ### Before npm staging diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 3232ab26d9..9fe539c3fd 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -10,6 +10,8 @@ - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; - 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; +- 在 npm Finalize 与 Desktop 远程 Runtime Host 验收成功前,GitHub Release 必须保持 Draft; + Draft 为 npm 提供产品身份,发布 Draft 是最终的产品发布动作; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准 staged package; - validation、staging、approval 和 finalization 之间不得重新构建; @@ -170,6 +172,9 @@ npm view maka-agent dist-tags --json 最后,在每个发布平台安装精确的公共版本,并完成一次真实的 TUI/model turn。在支持的 Eval host 上完成至少一个真实 experiment cell,检查 score、usage、cost 和 artifacts。 +回到[产品发布检查清单](../.github/RELEASE_CHECKLIST.md),使用打包后的 Desktop 应用完成远程 +Runtime Host setup 验收,再发布 GitHub Release。 + ## 失败恢复 ### npm staging 之前失败 diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index bc57303257..68bec98866 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -17,19 +17,37 @@ const FULL_SUITE_FILES = new Set([ ]); const RELEASE_CONTRACT_FILES = new Set([ + 'apps/desktop/build/entitlements.mac.inherit.plist', + 'apps/desktop/build/entitlements.mac.plist', + 'apps/desktop/bundled-git.json', + 'apps/desktop/bundled-tools.json', + 'apps/desktop/electron-builder.config.mjs', + 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', + 'scripts/package-macos-arm64.mjs', 'scripts/package-macos-arm64-cli.mjs', + 'scripts/package-windows-autoupdate-next.mjs', + 'scripts/package-windows-x64.mjs', + 'scripts/prepare-bundled-git-source.mjs', + 'scripts/prepare-bundled-git.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', 'scripts/prepare-windows-upgrade-baseline.test.mjs', 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', 'scripts/release-checksum.mjs', + 'scripts/release-eval-smoke-sitecustomize.py', 'scripts/release-version.mjs', 'scripts/verify-macos-arm64-cli.mjs', + 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-windows-autoupdate.mjs', + 'scripts/verify-windows-installer-lifecycle.mjs', + 'scripts/verify-windows-x64.mjs', + 'scripts/windows-upgrade-baseline.json', ]); const TYPECHECK_ONLY_FILES = new Set([ diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 642629155d..f419d7841e 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -88,19 +88,29 @@ test('release metadata selects only the gate that consumes it', () => { test('release authority changes select their dedicated contract gate', () => { for (const path of [ + 'apps/desktop/build/entitlements.mac.plist', + 'apps/desktop/electron-builder.config.mjs', + 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', + 'scripts/package-macos-arm64.mjs', 'scripts/package-macos-arm64-cli.mjs', + 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', 'scripts/release-checksum.mjs', + 'scripts/release-eval-smoke-sitecustomize.py', 'scripts/release-version.mjs', 'scripts/release-cli-publication.test.mjs', 'scripts/verify-macos-arm64-cli.mjs', + 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-windows-x64.mjs', + 'scripts/windows-upgrade-baseline.json', ]) { assert.equal(planTests([path], { graph }).releaseContract, true, path); } diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index f1d89711d9..5bc7fd934e 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -45,7 +45,7 @@ const requiredSigningEnvironment = [ 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER', ]; -export const NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ +export const OFFICIAL_NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ 'com.apple.security.cs.allow-dyld-environment-variables', 'com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory', @@ -53,6 +53,14 @@ export const NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ 'com.apple.security.cs.disable-library-validation', 'com.apple.security.get-task-allow', ]); +// The official archive is validated verbatim above, but Apple rejects +// distribution software that keeps get-task-allow. Re-sign only the Node +// executable with the same runtime capabilities minus that development grant. +export const DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS = Object.freeze( + OFFICIAL_NODE_RUNTIME_ENTITLEMENTS.filter( + (entitlement) => entitlement !== 'com.apple.security.get-task-allow', + ), +); function runCommand(command, args, options = {}) { return new Promise((resolvePromise, reject) => { @@ -339,11 +347,11 @@ export function assertOfficialNodeRuntime({ } } -export function extractOfficialNodeEntitlements(output) { +function extractNodeEntitlements(output, expectedEntitlements, description) { const start = output.indexOf(''); if (start < 0 || end < start) { - throw new Error('Official Node runtime has no readable entitlement plist.'); + throw new Error(`${description} has no readable entitlement plist.`); } const plist = output.slice(start, end + ''.length); const allKeys = [...plist.matchAll(/([^<]+)<\/key>/gu)].map((match) => match[1]).sort(); @@ -351,14 +359,39 @@ export function extractOfficialNodeEntitlements(output) { .map((match) => match[1]) .sort(); if ( - JSON.stringify(allKeys) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) || - JSON.stringify(keys) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) + JSON.stringify(allKeys) !== JSON.stringify(expectedEntitlements) || + JSON.stringify(keys) !== JSON.stringify(expectedEntitlements) ) { - throw new Error('Official Node runtime entitlements do not match the reviewed contract.'); + throw new Error(`${description} entitlements do not match the reviewed contract.`); } return plist; } +export function extractOfficialNodeEntitlements(output) { + return extractNodeEntitlements( + output, + OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, + 'Official Node runtime', + ); +} + +export function extractDistributionNodeEntitlements(output) { + return extractNodeEntitlements( + output, + DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, + 'Distribution Node runtime', + ); +} + +export function distributionNodeEntitlements(officialEntitlements) { + const plist = extractOfficialNodeEntitlements(officialEntitlements); + const distributionPlist = plist.replace( + /\s*com\.apple\.security\.get-task-allow<\/key>\s*/u, + '', + ); + return extractDistributionNodeEntitlements(distributionPlist); +} + async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { const [nodeVersion, npmVersion, architectures, signature, entitlements, dependencies] = await Promise.all([ @@ -713,7 +746,7 @@ async function signCliBinaries( ['-d', '--entitlements', ':-', nodePath], { env }, ); - extractOfficialNodeEntitlements( + extractDistributionNodeEntitlements( `${signedNodeEntitlements.stdout}\n${signedNodeEntitlements.stderr}`, ); return { @@ -819,12 +852,13 @@ export async function packageMacosArm64Cli({ toolchain, { env, run }, ); - const nodeEntitlements = await inspectReleaseToolchain({ + const officialNodeEntitlements = await inspectReleaseToolchain({ execPath: officialNode.execPath, env, inspect, toolchain, }); + const nodeEntitlements = distributionNodeEntitlements(officialNodeEntitlements); const installRoot = join(stagingRoot, 'install'); await mkdir(installRoot, { recursive: true }); @@ -906,7 +940,9 @@ export async function packageMacosArm64Cli({ sourceUrl: toolchain.nodeSourceUrl, archive: toolchain.nodeArchive, archiveSha256: toolchain.nodeArchiveSha256, - entitlements: NODE_RUNTIME_ENTITLEMENTS, + entitlements: releaseSigning + ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS + : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, }, npmVersion: toolchain.npmVersion, dependencyPatches, diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index e9412c538d..011a549f4e 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -47,13 +47,7 @@ export function parseAsfSourceReferenceTag(tag) { return { rcNumber: match[2], tag, version: match[1] }; } -export function resolveProductReleaseIdentity({ - rootManifest, - desktopManifest, - cliManifest, - sha, - sourceReferenceTag, -}) { +export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { const { version } = parseProductReleaseVersion(rootManifest.version); for (const [label, manifest] of [ ['Desktop', desktopManifest], @@ -68,6 +62,27 @@ export function resolveProductReleaseIdentity({ if (JSON.stringify(cliManifest.bin) !== JSON.stringify({ maka: './dist/cli.js' })) { throw new Error('The only public CLI command must be maka'); } + + return { + version, + runtimeHostSetupPackage: `maka-agent@${version}`, + publicCommands: ['maka'], + }; +} + +export function resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha, + sourceReferenceTag, +}) { + const manifestIdentity = resolveProductManifestIdentity({ + rootManifest, + desktopManifest, + cliManifest, + }); + const { version } = manifestIdentity; if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { throw new Error('Product releases require an exact 40-character source commit SHA'); } @@ -85,7 +100,7 @@ export function resolveProductReleaseIdentity({ return { ...toolchain, - version, + ...manifestIdentity, tag: `v${version}`, sourceCommit: sha, sourceReferenceTag, @@ -94,7 +109,6 @@ export function resolveProductReleaseIdentity({ cliArchive, cliChecksum: `${cliArchive}.sha256`, sourceArchive: `Maka-${version}-bundled-git-source.tar.gz`, - publicCommands: ['maka'], }; } @@ -111,15 +125,24 @@ export function assertProductReleaseExpectation(identity, { version, tag, source return identity; } -export async function readProductReleaseIdentity({ - sha, - sourceReferenceTag = process.env.SOURCE_REFERENCE_TAG, -} = {}) { +async function readProductManifests() { const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), ]); + return { rootManifest, desktopManifest, cliManifest }; +} + +export async function readProductManifestIdentity() { + return resolveProductManifestIdentity(await readProductManifests()); +} + +export async function readProductReleaseIdentity({ + sha, + sourceReferenceTag = process.env.SOURCE_REFERENCE_TAG, +} = {}) { + const { rootManifest, desktopManifest, cliManifest } = await readProductManifests(); const sourceCommit = sha ?? process.env.GITHUB_SHA ?? diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 88226ed133..755f6d5853 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -6,17 +6,22 @@ import { join } from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; +import desktopBuilderConfig from '../apps/desktop/electron-builder.config.mjs'; import { parseCliReleaseVersion } from './release-cli-publication.mjs'; import { planTests } from './ci-test-plan.mjs'; import { assertProductReleaseExpectation, parseAsfSourceReferenceTag, releaseToolchainFromManifest, + resolveProductManifestIdentity, resolveProductReleaseIdentity, } from './product-release-identity.mjs'; import { assertExpectedAppleTeam, decodeSigningCertificate, + DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, + distributionNodeEntitlements, + extractDistributionNodeEntitlements, extractOfficialNodeEntitlements, macosArm64MachOAction, parseDeveloperIdApplicationIdentity, @@ -28,6 +33,7 @@ import { } from './package-macos-arm64-cli.mjs'; import { resolveWorkspaceReleaseFiles } from './release-cli-file-policy.mjs'; import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; +import { makePtyProbe } from './verify-packaged-app.mjs'; import { ensureProductTag } from './product-release-tag.mjs'; import { writeSha256Sidecar } from './release-checksum.mjs'; @@ -57,12 +63,61 @@ test('one root version defines every product artifact from one source commit', ( assert.equal(identity.tag, 'v1.2.3'); assert.equal(identity.sourceCommit, 'a'.repeat(40)); assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); + assert.equal(identity.runtimeHostSetupPackage, 'maka-agent@1.2.3'); assert.equal(identity.dmg, 'Maka-1.2.3-mac-arm64.dmg'); assert.equal(identity.exe, 'Maka-1.2.3-win-x64.exe'); assert.equal(identity.cliArchive, 'Maka-1.2.3-cli-mac-arm64.zip'); assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('Desktop packaging derives the Runtime Host setup package from product manifests', async () => { + const manifestIdentity = resolveProductManifestIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + }); + assert.equal(manifestIdentity.runtimeHostSetupPackage, 'maka-agent@1.2.3'); + + const checkedRootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + assert.deepEqual(desktopBuilderConfig.extraMetadata, { + runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`, + }); +}); + +test('the packaged-app probe rejects a mismatched Runtime Host setup package', async () => { + const fixture = await mkdtemp(join(tmpdir(), 'maka-packaged-manifest-')); + try { + const manifestPath = join(fixture, 'package.json'); + const ptyDirectory = join(fixture, 'node_modules', 'node-pty'); + await mkdir(ptyDirectory, { recursive: true }); + await Promise.all([ + writeFile(manifestPath, JSON.stringify({ runtimeHostSetupPackage: 'maka-agent@1.2.3' })), + writeFile( + join(ptyDirectory, 'index.js'), + `module.exports = { spawn() { return { + onData(listener) { queueMicrotask(() => listener('maka-node-pty-ok')); }, + onExit(listener) { setImmediate(() => listener({ exitCode: 0 })); }, +}; } };\n`, + ), + ]); + await execFileAsync(process.execPath, [ + '-e', + makePtyProbe('/bin/echo', ['maka-node-pty-ok'], 'maka-agent@1.2.3'), + manifestPath, + ]); + await assert.rejects( + execFileAsync(process.execPath, [ + '-e', + makePtyProbe('/bin/echo', ['maka-node-pty-ok'], 'maka-agent@1.2.4'), + manifestPath, + ]), + /Packaged Runtime Host setup package mismatch/u, + ); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + test('product releases accept only an exact same-version ASF source reference', () => { assert.deepEqual(parseAsfSourceReferenceTag('v1.2.3-incubating-rc2'), { rcNumber: '2', @@ -269,7 +324,7 @@ test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identi ); }); -test('CLI signing preserves the reviewed official Node entitlement contract', () => { +test('CLI signing removes development-only access from the official Node entitlements', () => { const plist = ` com.apple.security.cs.allow-jit @@ -281,6 +336,16 @@ test('CLI signing preserves the reviewed official Node entitlement contract', () `; assert.equal(extractOfficialNodeEntitlements(`Executable=/node\n${plist}`), plist); + const distributionPlist = distributionNodeEntitlements(plist); + assert.deepEqual(DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, [ + 'com.apple.security.cs.allow-dyld-environment-variables', + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + 'com.apple.security.cs.disable-executable-page-protection', + 'com.apple.security.cs.disable-library-validation', + ]); + assert.doesNotMatch(distributionPlist, /get-task-allow/u); + assert.equal(extractDistributionNodeEntitlements(distributionPlist), distributionPlist); assert.throws( () => extractOfficialNodeEntitlements( @@ -288,6 +353,7 @@ test('CLI signing preserves the reviewed official Node entitlement contract', () ), /entitlements do not match/u, ); + assert.throws(() => extractDistributionNodeEntitlements(plist), /entitlements do not match/u); }); test('the Eval workspace owns the complete runtime asset declaration', async () => { diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index b2a24e418b..048c0d35e9 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -20,11 +20,12 @@ import { promisify, stripVTControlCharacters } from 'node:util'; import { assertMacosArm64CliHost, assertNoDanglingSymlinks, - extractOfficialNodeEntitlements, + DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, + extractDistributionNodeEntitlements, inspectNativeArtifacts, isMacosArm64MachO, listApplicableDependencyPatchNames, - NODE_RUNTIME_ENTITLEMENTS, + OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, resolveCliWorkspacePackages, resolveMacosArm64CliArtifactPaths, } from './package-macos-arm64-cli.mjs'; @@ -475,7 +476,7 @@ async function verifyBinarySignatures( } if (resolve(binaryPath) === resolve(nodePath)) { const entitlements = await run('codesign', ['-d', '--entitlements', ':-', binaryPath]); - extractOfficialNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); + extractDistributionNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); } } return expectedTeamIdentifier; @@ -567,7 +568,12 @@ export async function verifyMacosArm64Cli( metadata.node?.sourceUrl !== toolchain.nodeSourceUrl || metadata.node?.archive !== toolchain.nodeArchive || metadata.node?.archiveSha256 !== toolchain.nodeArchiveSha256 || - JSON.stringify(metadata.node?.entitlements) !== JSON.stringify(NODE_RUNTIME_ENTITLEMENTS) || + JSON.stringify(metadata.node?.entitlements) !== + JSON.stringify( + requireReleaseSigning + ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS + : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, + ) || metadata.npmVersion !== toolchain.npmVersion || (requireReleaseSigning && metadata.signingTeamIdentifier !== toolchain.appleTeamIdentifier) || JSON.stringify(metadata.publicCommands) !== JSON.stringify(['maka']) diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index 95448c9fad..bb1f0df160 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; +import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -24,9 +25,7 @@ import { } from './verify-packaged-app.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const desktopRoot = join(repoRoot, 'apps', 'desktop'); const expectedAppId = 'com.maka.desktop'; -const ptyProbe = makePtyProbe('/bin/echo', ['maka-node-pty-ok']); function runCommandFromRepo(command, args, options = {}) { return runCommand(command, args, { cwd: repoRoot, ...options }); @@ -106,7 +105,7 @@ export async function verifyPackagedMacApp( workingDirectory = dirname(appPath), } = {}, ) { - const desktopManifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const product = await readProductManifestIdentity(); const contents = join(appPath, 'Contents'); const resources = join(contents, 'Resources'); const infoPlist = join(contents, 'Info.plist'); @@ -116,8 +115,8 @@ export async function verifyPackagedMacApp( throw new Error(`Expected app id ${expectedAppId}, found ${appId}.`); } const version = await readPlistValue(run, infoPlist, 'CFBundleShortVersionString'); - if (version !== desktopManifest.version) { - throw new Error(`Expected app version ${desktopManifest.version}, found ${version}.`); + if (version !== product.version) { + throw new Error(`Expected app version ${product.version}, found ${version}.`); } const executableName = await readPlistValue(run, infoPlist, 'CFBundleExecutable'); const executable = join(contents, 'MacOS', executableName); @@ -135,6 +134,7 @@ export async function verifyPackagedMacApp( await run('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]); await run('xcrun', ['stapler', 'validate', appPath]); + const ptyProbe = makePtyProbe('/bin/echo', ['maka-node-pty-ok'], product.runtimeHostSetupPackage); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { ELECTRON_RUN_AS_NODE: '1', diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 800a0d0749..ab4446efb7 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -346,10 +346,24 @@ export async function stopChild(child) { } } -export function makePtyProbe(shellFile, shellArgs) { +export function makePtyProbe(shellFile, shellArgs, runtimeHostSetupPackage) { return String.raw` const { createRequire } = require('node:module'); const requireFromApp = createRequire(process.argv[1]); +const appManifest = requireFromApp('./package.json'); +const expectedRuntimeHostSetupPackage = ${JSON.stringify(runtimeHostSetupPackage)}; +if ( + expectedRuntimeHostSetupPackage !== undefined && + appManifest.runtimeHostSetupPackage !== expectedRuntimeHostSetupPackage +) { + console.error( + 'Packaged Runtime Host setup package mismatch: expected ' + + expectedRuntimeHostSetupPackage + + ', found ' + + JSON.stringify(appManifest.runtimeHostSetupPackage), + ); + process.exit(1); +} const pty = requireFromApp('node-pty'); const child = pty.spawn(${JSON.stringify(shellFile)}, ${JSON.stringify(shellArgs)}, { name: 'xterm-color', diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index f02f723a14..01a4197e6f 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -509,6 +509,7 @@ export async function verifyWindowsAutoupdate( requireWindowsSandbox: true, requireDisclaimer: true, requireDependencyClosure: true, + requireRuntimeHostSetupPackage: true, smokeRenderer: async (executable, { workingDirectory }) => { const smokeHome = join(workingDirectory, 'home'); const smokeUserData = join(workingDirectory, 'user-data'); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 59b22d8807..527d9d79b7 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -1,8 +1,9 @@ import { createHash } from 'node:crypto'; -import { access, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, open, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -15,14 +16,12 @@ import { } from './verify-packaged-app.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const desktopRoot = join(repoRoot, 'apps', 'desktop'); const executableName = 'Maka.exe'; const amd64Machine = 0x8664; const temporaryCleanupRetries = 20; const temporaryCleanupRetryDelayMs = 250; // conpty echoes the command and terminates lines with CRLF, so the probe keeps // matching on a substring rather than the whole output. -const ptyProbe = makePtyProbe(process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok']); function runCommandFromRepo(command, args, options = {}) { return runCommand(command, args, { cwd: repoRoot, ...options }); @@ -95,15 +94,16 @@ export async function verifyPackagedWindowsApp( // caller verifying a *current* build under a different version (the // auto-update gate's upgraded install) overrides these to true so the // sandbox and disclaimer checks are not silently skipped. `== null` - // matches the `expectedVersion ?? desktopManifest.version` fallback below, + // matches the `expectedVersion ?? product.version` fallback below, // so a `null` cannot disable these checks while also meaning "no expected // version". requireWindowsSandbox = expectedVersion == null, requireDisclaimer = expectedVersion == null, requireDependencyClosure = expectedVersion == null, + requireRuntimeHostSetupPackage = expectedVersion == null, } = {}, ) { - const desktopManifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const product = await readProductManifestIdentity(); const resources = join(appDirectory, 'resources'); const executable = join(appDirectory, executableName); const appAsar = join(resources, 'app.asar'); @@ -208,9 +208,14 @@ export async function verifyPackagedWindowsApp( run, `(Get-Item -LiteralPath ${powerShellLiteral(executable)}).VersionInfo.ProductVersion`, ); - assertWindowsProductVersion(stdout, expectedVersion ?? desktopManifest.version); + assertWindowsProductVersion(stdout, expectedVersion ?? product.version); step('smoking node-pty through conpty'); + const ptyProbe = makePtyProbe( + process.env.ComSpec || 'cmd.exe', + ['/c', 'echo', 'maka-node-pty-ok'], + requireRuntimeHostSetupPackage ? product.runtimeHostSetupPackage : undefined, + ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { ELECTRON_RUN_AS_NODE: '1', From 7d6b89d546a32550e73e83ad08631becf625552c Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 16:20:28 +0800 Subject: [PATCH 24/31] fix(release): unify product update and version authority Keep packaged Desktop updates bound to apache/maka and preserve electron-builder metadata as the production feed authority.\n\nShare the product SemVer contract across npm and Windows release verification, including prerelease candidates, while removing duplicate artifact and toolchain identities. --- apps/desktop/electron-builder.config.mjs | 4 +-- .../main/__tests__/app-update-service.test.ts | 9 +++--- apps/desktop/src/main/app-update-service.ts | 14 +++----- scripts/package-macos-arm64-cli.mjs | 24 ++++++-------- scripts/package-windows-autoupdate-next.mjs | 17 ++++------ scripts/prepare-windows-upgrade-baseline.mjs | 18 ++--------- scripts/product-release-identity.mjs | 2 +- scripts/product-release.test.mjs | 25 ++++++++------- scripts/release-cli-publication.mjs | 31 ++---------------- scripts/release-version.mjs | 27 ++++++++++++++++ scripts/verify-macos-arm64-cli.mjs | 24 ++++++-------- scripts/verify-windows-autoupdate.mjs | 7 ---- scripts/verify-windows-harness.test.mjs | 21 ++++++++++++ .../verify-windows-installer-lifecycle.mjs | 9 +++++- scripts/verify-windows-x64.mjs | 32 ++++++------------- 15 files changed, 121 insertions(+), 143 deletions(-) diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index ed3bf774c1..24a451e100 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -206,8 +206,8 @@ export default { publish: [ { provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', + owner: 'apache', + repo: 'maka', }, ], }; diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index eb31b8ed58..396945d67f 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -56,9 +56,11 @@ class FakeUpdater extends EventEmitter { quitAndInstallDispatchError = false; onQuitAndInstall: (() => void) | undefined; feed: unknown; + setFeedURLCalls = 0; checkResult: Promise | undefined; setFeedURL(input: unknown): void { + this.setFeedURLCalls += 1; this.feed = input; } @@ -135,11 +137,7 @@ describe('AppUpdateService', () => { assert.equal(updater.autoDownload, true); assert.equal(updater.autoInstallOnAppQuit, false); assert.equal(updater.allowPrerelease, false); - assert.deepEqual(updater.feed, { - provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', - }); + assert.equal(updater.setFeedURLCalls, 0); service.start(); service.start(); @@ -159,6 +157,7 @@ describe('AppUpdateService', () => { provider: 'generic', url: 'http://127.0.0.1:8443/feed', }); + assert.equal(updater.setFeedURLCalls, 1); }); test('rejects a non-loopback test feed instead of falling back to production', () => { diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index c14c7e1c09..7644fe4fe3 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -298,16 +298,10 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater.autoInstallOnAppQuit = false; updater.allowPrerelease = false; updater.logger = null; - // The override changes the feed URL and nothing else: every other updater - // setting and the whole status machine behave identically under it, so what - // the loopback harness verifies is what production runs. - updater.setFeedURL( - resolveUpdateFeedOverride(deps.testFeedUrl) ?? { - provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', - }, - ); + const testFeed = resolveUpdateFeedOverride(deps.testFeedUrl); + // Production reads electron-builder's packaged app-update.yml. Only the + // loopback harness replaces that single authority boundary. + if (testFeed) updater.setFeedURL(testFeed); updater.on('checking-for-update', () => { publish({ state: 'checking', currentVersion: deps.currentVersion }); diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index 5bc7fd934e..df75ebb837 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -20,10 +20,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; -import { - releaseToolchainFromManifest, - resolveProductReleaseIdentity, -} from './product-release-identity.mjs'; +import { resolveProductReleaseIdentity } from './product-release-identity.mjs'; import { isMakaDevelopmentArtifact, isThirdPartyDevelopmentArtifact, @@ -826,10 +823,9 @@ export async function packageMacosArm64Cli({ cliManifest, sha: sourceCommit, }); - const toolchain = releaseToolchainFromManifest(rootManifest); if (releaseSigning) assertReleaseSigningEnvironment(env); if (!nodeArchivePath) { - throw new Error(`Set MAKA_CLI_NODE_ARCHIVE to the verified ${toolchain.nodeArchive} path.`); + throw new Error(`Set MAKA_CLI_NODE_ARCHIVE to the verified ${identity.nodeArchive} path.`); } const version = identity.version; @@ -934,24 +930,24 @@ export async function packageMacosArm64Cli({ sourceCommit, platform: 'macos', architecture: 'arm64', - publicCommands: ['maka'], + publicCommands: identity.publicCommands, node: { - version: toolchain.nodeVersion, - sourceUrl: toolchain.nodeSourceUrl, - archive: toolchain.nodeArchive, - archiveSha256: toolchain.nodeArchiveSha256, + version: identity.nodeVersion, + sourceUrl: identity.nodeSourceUrl, + archive: identity.nodeArchive, + archiveSha256: identity.nodeArchiveSha256, entitlements: releaseSigning ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, }, - npmVersion: toolchain.npmVersion, + npmVersion: identity.npmVersion, dependencyPatches, productionDependencies, thirdPartyNoticesSha256, workspacePackages: workspacePackages.map(({ name }) => name).sort(), machOBinaries: machOBinaries.map((path) => relative(archiveRoot, path)).sort(), signing: releaseSigning ? 'developer-id-notarized' : 'development', - signingTeamIdentifier: releaseSigning ? toolchain.appleTeamIdentifier : null, + signingTeamIdentifier: releaseSigning ? identity.appleTeamIdentifier : null, }; await writeFile( join(archiveRoot, 'RELEASE.json'), @@ -963,7 +959,7 @@ export async function packageMacosArm64Cli({ if (releaseSigning) { signing = await signCliBinaries(machOBinaries, { env, - expectedTeamIdentifier: toolchain.appleTeamIdentifier, + expectedTeamIdentifier: identity.appleTeamIdentifier, run, inspect, nodeEntitlements, diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index eb9c9b00f6..eefc5058d0 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -3,24 +3,21 @@ import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; +import { parseProductReleaseVersion } from './release-version.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); /** * The version the auto-update harness serves as "newer than the candidate". - * A plain patch bump keeps the artifact name matching the release regex and - * avoids every channel/prerelease ambiguity: `allowPrerelease` only affects - * the GitHub provider, but a stable version never depends on that behavior. + * A stable successor keeps the loopback feed independent from prerelease + * channel behavior: a prerelease advances to its stable core, while a stable + * candidate advances to the next patch. */ export function bumpedAutoupdateVersion(candidateVersion) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidateVersion); - if (!match) { - throw new Error( - `Cannot bump a non-stable x.y.z candidate version: ${JSON.stringify(candidateVersion)}`, - ); - } - return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`; + const { core, prerelease } = parseProductReleaseVersion(candidateVersion); + const [major, minor, patch] = core; + return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; } /** diff --git a/scripts/prepare-windows-upgrade-baseline.mjs b/scripts/prepare-windows-upgrade-baseline.mjs index 9ce0e81529..14eedc7a3c 100644 --- a/scripts/prepare-windows-upgrade-baseline.mjs +++ b/scripts/prepare-windows-upgrade-baseline.mjs @@ -3,28 +3,14 @@ import { mkdir, readFile, rm } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { compareProductReleaseVersions } from './release-version.mjs'; import { sha256File } from './verify-packaged-app.mjs'; const runFile = promisify(execFile); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const defaultManifestPath = join(repoRoot, 'scripts', 'windows-upgrade-baseline.json'); -function stableVersion(value) { - const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value); - return match ? match.slice(1).map(Number) : undefined; -} - -function compareVersions(left, right) { - for (let index = 0; index < 3; index += 1) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { - const baseline = stableVersion(manifest?.version); - const candidate = stableVersion(candidateVersion); - if (!baseline || !candidate) throw new Error('Windows upgrade versions must be stable x.y.z.'); if (manifest.tag !== `v${manifest.version}`) throw new Error('Baseline tag must match its version.'); if (manifest.assetName !== `Maka-${manifest.version}-win-x64.exe`) { @@ -33,7 +19,7 @@ export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { if (!/^[0-9a-f]{64}$/u.test(manifest.sha256)) { throw new Error('Baseline SHA-256 must be a lowercase 64-character digest.'); } - if (compareVersions(baseline, candidate) >= 0) { + if (compareProductReleaseVersions(manifest.version, candidateVersion) >= 0) { throw new Error('Windows upgrade baseline must be older than the candidate.'); } return manifest; diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 011a549f4e..29f8038a18 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -8,7 +8,7 @@ import { parseProductReleaseVersion } from './release-version.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const execFileAsync = promisify(execFile); -export function releaseToolchainFromManifest(rootManifest) { +function releaseToolchainFromManifest(rootManifest) { const appleTeamIdentifier = rootManifest.releaseToolchain?.appleTeamIdentifier; const nodeVersion = rootManifest.releaseToolchain?.node; const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 755f6d5853..1f2e9150d0 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -12,7 +12,6 @@ import { planTests } from './ci-test-plan.mjs'; import { assertProductReleaseExpectation, parseAsfSourceReferenceTag, - releaseToolchainFromManifest, resolveProductManifestIdentity, resolveProductReleaseIdentity, } from './product-release-identity.mjs'; @@ -64,6 +63,16 @@ test('one root version defines every product artifact from one source commit', ( assert.equal(identity.sourceCommit, 'a'.repeat(40)); assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); assert.equal(identity.runtimeHostSetupPackage, 'maka-agent@1.2.3'); + assert.deepEqual(identity.publicCommands, ['maka']); + assert.equal(identity.appleTeamIdentifier, 'FABM2QUA8Q'); + assert.equal(identity.nodeVersion, '24.18.1'); + assert.equal(identity.nodeArchive, 'node-v24.18.1-darwin-arm64.tar.xz'); + assert.equal(identity.nodeArchiveSha256, '1'.repeat(64)); + assert.equal( + identity.nodeSourceUrl, + 'https://nodejs.org/download/release/v24.18.1/node-v24.18.1-darwin-arm64.tar.xz', + ); + assert.equal(identity.npmVersion, '11.19.0'); assert.equal(identity.dmg, 'Maka-1.2.3-mac-arm64.dmg'); assert.equal(identity.exe, 'Maka-1.2.3-win-x64.exe'); assert.equal(identity.cliArchive, 'Maka-1.2.3-cli-mac-arm64.zip'); @@ -82,6 +91,9 @@ test('Desktop packaging derives the Runtime Host setup package from product mani assert.deepEqual(desktopBuilderConfig.extraMetadata, { runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`, }); + assert.deepEqual(desktopBuilderConfig.publish, [ + { provider: 'github', owner: 'apache', repo: 'maka' }, + ]); }); test('the packaged-app probe rejects a mismatched Runtime Host setup package', async () => { @@ -238,17 +250,6 @@ test('product tag creation is exact and idempotent but rejects a conflicting com } }); -test('the root manifest pins the Node archive and npm used by release jobs', () => { - assert.deepEqual(releaseToolchainFromManifest(rootManifest), { - appleTeamIdentifier: 'FABM2QUA8Q', - nodeVersion: '24.18.1', - nodeArchive: 'node-v24.18.1-darwin-arm64.tar.xz', - nodeArchiveSha256: '1'.repeat(64), - nodeSourceUrl: 'https://nodejs.org/download/release/v24.18.1/node-v24.18.1-darwin-arm64.tar.xz', - npmVersion: '11.19.0', - }); -}); - test('release artifacts get a checksum sidecar for their final bytes', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-release-checksum-')); try { diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 88910158ab..c377c2bd53 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -3,7 +3,7 @@ import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; -import { parseProductReleaseVersion } from './release-version.mjs'; +import { compareProductReleaseVersions, parseProductReleaseVersion } from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; @@ -45,7 +45,7 @@ export function validateRegistryChannels({ releaseVersion, releaseDistTag, distT throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); } if (typeof latest === 'string' && typeof next === 'string') { - if (compareReleaseSemver(next, latest) < 0) { + if (compareProductReleaseVersions(next, latest) < 0) { throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); } } @@ -342,33 +342,6 @@ function matchesReleaseProvenance(statement, record) { ); } -function compareReleaseSemver(left, right) { - const a = parseProductReleaseVersion(left); - const b = parseProductReleaseVersion(right); - for (let index = 0; index < a.core.length; index += 1) { - if (a.core[index] < b.core[index]) return -1; - if (a.core[index] > b.core[index]) return 1; - } - if (a.prerelease.length === 0) return b.prerelease.length === 0 ? 0 : 1; - if (b.prerelease.length === 0) return -1; - for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) { - const leftIdentifier = a.prerelease[index]; - const rightIdentifier = b.prerelease[index]; - if (leftIdentifier === undefined) return -1; - if (rightIdentifier === undefined) return 1; - if (leftIdentifier === rightIdentifier) continue; - const leftNumeric = /^\d+$/u.test(leftIdentifier); - const rightNumeric = /^\d+$/u.test(rightIdentifier); - if (leftNumeric && rightNumeric) { - return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1; - } - if (leftNumeric) return -1; - if (rightNumeric) return 1; - return leftIdentifier < rightIdentifier ? -1 : 1; - } - return 0; -} - function channelLagError({ releaseVersion, releaseDistTag, latest, next }) { const current = typeof next === 'string' ? next : 'missing'; if (releaseDistTag === 'next') { diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 8021c6ef34..9da80af7f1 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -22,6 +22,33 @@ export function parseProductReleaseVersion(version) { }; } +export function compareProductReleaseVersions(left, right) { + const a = parseProductReleaseVersion(left); + const b = parseProductReleaseVersion(right); + for (let index = 0; index < a.core.length; index += 1) { + if (a.core[index] < b.core[index]) return -1; + if (a.core[index] > b.core[index]) return 1; + } + if (a.prerelease.length === 0) return b.prerelease.length === 0 ? 0 : 1; + if (b.prerelease.length === 0) return -1; + for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) { + const leftIdentifier = a.prerelease[index]; + const rightIdentifier = b.prerelease[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + if (leftIdentifier === rightIdentifier) continue; + const leftNumeric = /^\d+$/u.test(leftIdentifier); + const rightNumeric = /^\d+$/u.test(rightIdentifier); + if (leftNumeric && rightNumeric) { + return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1; + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return leftIdentifier < rightIdentifier ? -1 : 1; + } + return 0; +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [version] = process.argv.slice(2); if (!version || process.argv.length !== 3) { diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs index 048c0d35e9..acf7edbd76 100644 --- a/scripts/verify-macos-arm64-cli.mjs +++ b/scripts/verify-macos-arm64-cli.mjs @@ -29,10 +29,7 @@ import { resolveCliWorkspacePackages, resolveMacosArm64CliArtifactPaths, } from './package-macos-arm64-cli.mjs'; -import { - releaseToolchainFromManifest, - resolveProductReleaseIdentity, -} from './product-release-identity.mjs'; +import { resolveProductReleaseIdentity } from './product-release-identity.mjs'; const execFileAsync = promisify(execFile); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -499,7 +496,6 @@ export async function verifyMacosArm64Cli( readFile(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8').then(JSON.parse), run('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }), ]); - const toolchain = releaseToolchainFromManifest(rootManifest); const identity = resolveProductReleaseIdentity({ rootManifest, desktopManifest, @@ -564,19 +560,19 @@ export async function verifyMacosArm64Cli( metadata.sourceCommit !== identity.sourceCommit || metadata.platform !== 'macos' || metadata.architecture !== 'arm64' || - metadata.node?.version !== toolchain.nodeVersion || - metadata.node?.sourceUrl !== toolchain.nodeSourceUrl || - metadata.node?.archive !== toolchain.nodeArchive || - metadata.node?.archiveSha256 !== toolchain.nodeArchiveSha256 || + metadata.node?.version !== identity.nodeVersion || + metadata.node?.sourceUrl !== identity.nodeSourceUrl || + metadata.node?.archive !== identity.nodeArchive || + metadata.node?.archiveSha256 !== identity.nodeArchiveSha256 || JSON.stringify(metadata.node?.entitlements) !== JSON.stringify( requireReleaseSigning ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, ) || - metadata.npmVersion !== toolchain.npmVersion || - (requireReleaseSigning && metadata.signingTeamIdentifier !== toolchain.appleTeamIdentifier) || - JSON.stringify(metadata.publicCommands) !== JSON.stringify(['maka']) + metadata.npmVersion !== identity.npmVersion || + (requireReleaseSigning && metadata.signingTeamIdentifier !== identity.appleTeamIdentifier) || + JSON.stringify(metadata.publicCommands) !== JSON.stringify(identity.publicCommands) ) { throw new Error('CLI release metadata does not match the product release identity.'); } @@ -619,7 +615,7 @@ export async function verifyMacosArm64Cli( const nodeDependencies = await run('otool', ['-L', nodePath]); assertSelfContainedNode(nodeDependencies.stdout); const signingTeamIdentifier = await verifyBinarySignatures(machOBinaries, { - expectedTeamIdentifier: toolchain.appleTeamIdentifier, + expectedTeamIdentifier: identity.appleTeamIdentifier, nodePath, requireReleaseSigning, run, @@ -652,7 +648,7 @@ export async function verifyMacosArm64Cli( cwd: commandWorkspace, env: environment, }); - if (embeddedNodeVersion.stdout.trim() !== toolchain.nodeVersion) { + if (embeddedNodeVersion.stdout.trim() !== identity.nodeVersion) { throw new Error('Embedded Node version does not match the pinned release toolchain.'); } const versionResult = await run(makaPath, ['--version'], { diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index 01a4197e6f..6f250ec8c0 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -503,13 +503,6 @@ export async function verifyWindowsAutoupdate( await verifyPackagedWindowsApp(installDirectory, { workingDirectory: smokeDirectory, expectedVersion: nextVersion, - // The upgraded install is a current build (only its version is bumped), - // so it gets the full sandbox, disclaimer and dependency-closure - // verification a released baseline would be exempt from. - requireWindowsSandbox: true, - requireDisclaimer: true, - requireDependencyClosure: true, - requireRuntimeHostSetupPackage: true, smokeRenderer: async (executable, { workingDirectory }) => { const smokeHome = join(workingDirectory, 'home'); const smokeUserData = join(workingDirectory, 'user-data'); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index a0c714f56c..d21ffd82e7 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -6,6 +6,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { after, describe, it } from 'node:test'; +import { bumpedAutoupdateVersion } from './package-windows-autoupdate-next.mjs'; +import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs'; import { diffTreeManifests, directoryTreeManifest, @@ -16,6 +18,7 @@ import { import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs'; import { completeInstalledApplicationUninstall, + installerVersion, listInstalledProcesses, terminateInstalledProcesses, waitForInstalledProcessAppearance, @@ -31,6 +34,24 @@ const temporaryRoots = []; const delay = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +it('uses the product SemVer contract throughout Windows release verification', () => { + assert.equal(installerVersion('Maka-1.2.3-beta.2-win-x64.exe'), '1.2.3-beta.2'); + assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); + assert.equal(bumpedAutoupdateVersion('1.2.3'), '1.2.4'); + + const baseline = { + version: '1.2.3-beta.1', + tag: 'v1.2.3-beta.1', + assetName: 'Maka-1.2.3-beta.1-win-x64.exe', + sha256: 'a'.repeat(64), + }; + assert.equal(validateWindowsUpgradeBaseline(baseline, '1.2.3-beta.2'), baseline); + assert.throws( + () => validateWindowsUpgradeBaseline(baseline, '1.2.3-alpha.1'), + /must be older than the candidate/u, + ); +}); + async function makeTree(shape) { const root = await mkdtemp(join(tmpdir(), 'maka-harness-test-')); temporaryRoots.push(root); diff --git a/scripts/verify-windows-installer-lifecycle.mjs b/scripts/verify-windows-installer-lifecycle.mjs index e229ad332d..ad46b9bf6f 100644 --- a/scripts/verify-windows-installer-lifecycle.mjs +++ b/scripts/verify-windows-installer-lifecycle.mjs @@ -2,6 +2,7 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { parseProductReleaseVersion } from './release-version.mjs'; import { runCommand } from './verify-packaged-app.mjs'; import { powerShellLiteral, verifyPackagedWindowsApp } from './verify-windows-x64.mjs'; @@ -11,10 +12,15 @@ const temporaryCleanupRetryDelayMs = 250; const pollingProbeTimeoutMs = 10_000; export function installerVersion(path) { - const match = basename(path).match(/^Maka-(\d+\.\d+\.\d+)-win-x64\.exe$/u); + const match = basename(path).match(/^Maka-(.+)-win-x64\.exe$/u); if (!match) { throw new Error(`Cannot infer a release version from ${basename(path)}.`); } + try { + parseProductReleaseVersion(match[1]); + } catch { + throw new Error(`Cannot infer a release version from ${basename(path)}.`); + } return match[1]; } @@ -399,6 +405,7 @@ export async function verifyWindowsInstallerLifecycle( await verifyApp(installDirectory, { workingDirectory: smokeDirectory, expectedVersion: previousVersion, + artifactContract: 'legacy-baseline', }); console.log('[verify-windows-installer] waiting for previous-version processes to exit'); await waitForProcessesToExit(installDirectory); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 527d9d79b7..8bf7a0f280 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -89,20 +89,13 @@ export async function verifyPackagedWindowsApp( smokeRenderer = smokePackagedRenderer, workingDirectory = appDirectory, expectedVersion, - // An expectedVersion historically marks a previously released baseline, - // which cannot be required to carry resources added after it shipped. A - // caller verifying a *current* build under a different version (the - // auto-update gate's upgraded install) overrides these to true so the - // sandbox and disclaimer checks are not silently skipped. `== null` - // matches the `expectedVersion ?? product.version` fallback below, - // so a `null` cannot disable these checks while also meaning "no expected - // version". - requireWindowsSandbox = expectedVersion == null, - requireDisclaimer = expectedVersion == null, - requireDependencyClosure = expectedVersion == null, - requireRuntimeHostSetupPackage = expectedVersion == null, + artifactContract = 'current', } = {}, ) { + if (artifactContract !== 'current' && artifactContract !== 'legacy-baseline') { + throw new Error(`Unknown packaged Windows artifact contract: ${artifactContract}`); + } + const requiresCurrentContract = artifactContract === 'current'; const product = await readProductManifestIdentity(); const resources = join(appDirectory, 'resources'); const executable = join(appDirectory, executableName); @@ -114,15 +107,10 @@ export async function verifyPackagedWindowsApp( await assertPackagedResources(resources, { requirePath, forbidPath, - requireWindowsSandbox, - requireDisclaimer, + requireWindowsSandbox: requiresCurrentContract, + requireDisclaimer: requiresCurrentContract, }); - // Defaulted like the sandbox and disclaimer gates above, and overridable for - // the same reason: a baseline install predates this classification, so - // requiring it of a previously released build would fail a release that was - // correct when it shipped — but a caller that knows it is verifying a current - // build (the auto-update check's upgraded install) can ask for it back. - if (requireDependencyClosure) await assertPackagedDependencyClosure(resources); + if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); await requirePath(join(resources, 'git', 'cmd', 'git.exe')); step('reading the executable architecture'); @@ -131,7 +119,7 @@ export async function verifyPackagedWindowsApp( throw new Error(`${executableName} must be x64, found PE machine 0x${machine.toString(16)}.`); } - if (requireWindowsSandbox) { + if (requiresCurrentContract) { step('smoking the packaged Windows sandbox'); const sandboxMachine = await readMachine(sandboxExecutable); if (sandboxMachine !== amd64Machine) { @@ -214,7 +202,7 @@ export async function verifyPackagedWindowsApp( const ptyProbe = makePtyProbe( process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok'], - requireRuntimeHostSetupPackage ? product.runtimeHostSetupPackage : undefined, + requiresCurrentContract ? product.runtimeHostSetupPackage : undefined, ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { From fc3822c95927779da9e76217fd0ef7ff9b8e4155 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 17:04:56 +0800 Subject: [PATCH 25/31] fix(release): align publication channel authority Derive GitHub release classification from the shared product version and preserve electron-updater's version-derived channel policy. Reuse the platform verifiers as checksum owners and remove the stale macOS toolchain reference so packaging cannot diverge from the resolved release identity. --- .github/RELEASE_CHECKLIST.md | 3 +- .github/workflows/release.yml | 33 ++++++--- .../main/__tests__/app-update-service.test.ts | 10 ++- apps/desktop/src/main/app-update-service.ts | 1 - scripts/ci-test-plan.mjs | 1 - scripts/ci-test-plan.test.mjs | 1 - scripts/package-macos-arm64-cli.mjs | 4 +- scripts/product-release-identity.mjs | 4 +- scripts/product-release.test.mjs | 70 +++++++++---------- scripts/release-checksum.mjs | 21 ------ 10 files changed, 76 insertions(+), 72 deletions(-) delete mode 100644 scripts/release-checksum.mjs diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 87bfb59b6a..8e5a773bf3 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -57,7 +57,8 @@ These controls close the check-to-upload and check-to-stage windows. Keep the Re 5. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, `source`, and `publish` pass. A skipped or failed required job must prevent Draft creation. 6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source - reference in its notes, and contains at least: + reference in its notes, is marked as a GitHub prerelease exactly when the product version is a + prerelease (stable releases are marked Latest), and contains at least: - `Maka--mac-arm64.dmg` and checksum; - `Maka--win-x64.exe` and checksum; - `Maka--cli-mac-arm64.zip` and checksum; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7defc46359..d6a0be23b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ jobs: timeout-minutes: 10 outputs: version: ${{ steps.identity.outputs.version }} + is_prerelease: ${{ steps.identity.outputs.is_prerelease }} tag: ${{ steps.identity.outputs.tag }} source_commit: ${{ steps.identity.outputs.source_commit }} source_reference_tag: ${{ steps.identity.outputs.source_reference_tag }} @@ -208,11 +209,6 @@ jobs: "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ apps/desktop/release-autoupdate-next - - name: Hash the final Desktop installer - env: - INSTALLER: apps/desktop/release/${{ matrix.platform == 'macos' && needs.release-identity.outputs.dmg || needs.release-identity.outputs.exe }} - run: node scripts/release-checksum.mjs "$INSTALLER" - - name: Upload the verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -440,6 +436,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.release-identity.outputs.version }} + IS_PRERELEASE: ${{ needs.release-identity.outputs.is_prerelease }} TAG: ${{ needs.release-identity.outputs.tag }} SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} @@ -454,6 +451,16 @@ jobs: Known limitation: Computer Use is not included in this release." + create_classification=(--latest) + edit_classification=(--prerelease=false --latest) + if [[ "$IS_PRERELEASE" == "true" ]]; then + create_classification=(--prerelease --latest=false) + edit_classification=(--prerelease --latest=false) + elif [[ "$IS_PRERELEASE" != "false" ]]; then + echo "Product prerelease classification must be true or false; found $IS_PRERELEASE" >&2 + exit 1 + fi + 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 @@ -464,6 +471,7 @@ jobs: gh release create "$TAG" \ --draft \ --verify-tag \ + "${create_classification[@]}" \ --title "Maka ${VERSION}" \ --notes "$notes" fi @@ -472,7 +480,15 @@ jobs: echo "Release ${TAG} was published while recovery was running." >&2 exit 1 fi - gh release edit "$TAG" --title "Maka ${VERSION}" --notes "$notes" + gh release edit "$TAG" \ + "${edit_classification[@]}" \ + --title "Maka ${VERSION}" \ + --notes "$notes" + actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)" + if [[ "$actual_prerelease" != "$IS_PRERELEASE" ]]; then + echo "Release ${TAG} prerelease state is ${actual_prerelease}, expected ${IS_PRERELEASE}." >&2 + exit 1 + fi asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" declare -A expected_assets=() declare -A existing_assets=() @@ -502,8 +518,9 @@ jobs: missing_assets+=("release-assets/$asset") fi done - if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then - echo "Release ${TAG} was published while assets were checked." >&2 + release_state="$(gh release view "$TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | map(tostring) | join(" ")')" + if [[ "$release_state" != "true $IS_PRERELEASE" ]]; then + echo "Release ${TAG} changed state while assets were checked: ${release_state}." >&2 exit 1 fi if (( ${#missing_assets[@]} > 0 )); then diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index 396945d67f..d98d4aa2a1 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -136,7 +136,6 @@ describe('AppUpdateService', () => { assert.equal(updater.autoDownload, true); assert.equal(updater.autoInstallOnAppQuit, false); - assert.equal(updater.allowPrerelease, false); assert.equal(updater.setFeedURLCalls, 0); service.start(); @@ -151,6 +150,15 @@ describe('AppUpdateService', () => { assert.equal(clock.pending().length, 0); }); + test('preserves electron-updater channel policy derived from the app version', () => { + for (const allowPrerelease of [false, true]) { + const updater = new FakeUpdater(); + updater.allowPrerelease = allowPrerelease; + createHarness({ updater }); + assert.equal(updater.allowPrerelease, allowPrerelease); + } + }); + test('routes the feed to a loopback generic provider when the test override is set', () => { const { updater } = createHarness({ testFeedUrl: 'http://127.0.0.1:8443/feed' }); assert.deepEqual(updater.feed, { diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 7644fe4fe3..1076283ab9 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -296,7 +296,6 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater.autoDownload = true; updater.autoInstallOnAppQuit = false; - updater.allowPrerelease = false; updater.logger = null; const testFeed = resolveUpdateFeedOverride(deps.testFeedUrl); // Production reads electron-builder's packaged app-update.yml. Only the diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 68bec98866..8ae643cbbd 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -38,7 +38,6 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', - 'scripts/release-checksum.mjs', 'scripts/release-eval-smoke-sitecustomize.py', 'scripts/release-version.mjs', 'scripts/verify-macos-arm64-cli.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index f419d7841e..f0f341b080 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -102,7 +102,6 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', - 'scripts/release-checksum.mjs', 'scripts/release-eval-smoke-sitecustomize.py', 'scripts/release-version.mjs', 'scripts/release-cli-publication.test.mjs', diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index df75ebb837..079a6541b9 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -845,14 +845,14 @@ export async function packageMacosArm64Cli({ const officialNode = await extractOfficialNodeRuntime( stagingRoot, resolve(nodeArchivePath), - toolchain, + identity, { env, run }, ); const officialNodeEntitlements = await inspectReleaseToolchain({ execPath: officialNode.execPath, env, inspect, - toolchain, + toolchain: identity, }); const nodeEntitlements = distributionNodeEntitlements(officialNodeEntitlements); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 29f8038a18..7d8a4f0edc 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -48,7 +48,7 @@ export function parseAsfSourceReferenceTag(tag) { } export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { - const { version } = parseProductReleaseVersion(rootManifest.version); + const { version, prerelease } = parseProductReleaseVersion(rootManifest.version); for (const [label, manifest] of [ ['Desktop', desktopManifest], ['CLI', cliManifest], @@ -65,6 +65,7 @@ export function resolveProductManifestIdentity({ rootManifest, desktopManifest, return { version, + isPrerelease: prerelease.length > 0, runtimeHostSetupPackage: `maka-agent@${version}`, publicCommands: ['maka'], }; @@ -159,6 +160,7 @@ export async function readProductReleaseIdentity({ function githubOutputEntries(identity) { return { version: identity.version, + is_prerelease: identity.isPrerelease, tag: identity.tag, source_commit: identity.sourceCommit, source_reference_tag: identity.sourceReferenceTag, diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 1f2e9150d0..d7f9b679ef 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -34,7 +34,6 @@ import { resolveWorkspaceReleaseFiles } from './release-cli-file-policy.mjs'; import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; import { makePtyProbe } from './verify-packaged-app.mjs'; import { ensureProductTag } from './product-release-tag.mjs'; -import { writeSha256Sidecar } from './release-checksum.mjs'; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, '..'); @@ -59,6 +58,7 @@ test('one root version defines every product artifact from one source commit', ( }); assert.equal(identity.version, '1.2.3'); + assert.equal(identity.isPrerelease, false); assert.equal(identity.tag, 'v1.2.3'); assert.equal(identity.sourceCommit, 'a'.repeat(40)); assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); @@ -79,6 +79,20 @@ test('one root version defines every product artifact from one source commit', ( assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('the product identity classifies prereleases once for every publication surface', () => { + const version = '1.2.3-beta.2'; + const identity = resolveProductReleaseIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + sourceReferenceTag: `v${version}-incubating-rc1`, + }); + + assert.equal(identity.isPrerelease, true); + assert.equal(identity.tag, `v${version}`); +}); + test('Desktop packaging derives the Runtime Host setup package from product manifests', async () => { const manifestIdentity = resolveProductManifestIdentity({ rootManifest, @@ -250,24 +264,6 @@ test('product tag creation is exact and idempotent but rejects a conflicting com } }); -test('release artifacts get a checksum sidecar for their final bytes', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-release-checksum-')); - try { - const artifact = join(root, 'Maka-1.2.3.dmg'); - await writeFile(artifact, 'final installer\n'); - - const checksum = await writeSha256Sidecar(artifact); - - assert.equal(checksum, `${artifact}.sha256`); - assert.equal( - await readFile(checksum, 'utf8'), - 'ddc9faf55279d297f3fd55a04de0e66c4d7906512677c2e3a05b657a19ccdf92 Maka-1.2.3.dmg\n', - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test('standalone verification recognizes the current TUI status line through ANSI output', () => { assert.equal( isTuiReadyOutput( @@ -493,22 +489,12 @@ test('one product workflow gates one draft release on every required artifact', } const desktopStepNames = jobs.desktop.steps.map((step) => step.name); - const checksumStep = jobs.desktop.steps.find( - (step) => step.name === 'Hash the final Desktop installer', - ); - assert.ok(checksumStep); - assert.ok( - desktopStepNames.indexOf('Hash the final Desktop installer') > - desktopStepNames.indexOf('Verify the final DMG'), - ); - assert.ok( - desktopStepNames.indexOf('Hash the final Desktop installer') > - desktopStepNames.indexOf('Exercise pinned Windows upgrade and uninstall'), - ); - assert.ok( - desktopStepNames.indexOf('Hash the final Desktop installer') < - desktopStepNames.indexOf('Upload the verified release assets'), - ); + const uploadIndex = desktopStepNames.indexOf('Upload the verified release assets'); + assert.ok(uploadIndex >= 0); + for (const verifier of ['Verify the final DMG', 'Verify the Windows release']) { + const verifierIndex = desktopStepNames.indexOf(verifier); + assert.ok(verifierIndex >= 0 && verifierIndex < uploadIndex); + } const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) @@ -528,6 +514,20 @@ test('one product workflow gates one draft release on every required artifact', const publishRelease = jobs.publish.steps.find( (step) => step.name === 'Create or update the draft GitHub Release', ).run; + assert.equal( + jobs['release-identity'].outputs.is_prerelease, + '${{ steps.identity.outputs.is_prerelease }}', + ); + assert.equal( + jobs.publish.steps.find((step) => step.name === 'Create or update the draft GitHub Release').env + .IS_PRERELEASE, + '${{ needs.release-identity.outputs.is_prerelease }}', + ); + assert.match(publishRelease, /create_classification=\(--latest\)/u); + assert.match(publishRelease, /edit_classification=\(--prerelease=false --latest\)/u); + assert.match(publishRelease, /--prerelease --latest=false/u); + assert.match(publishRelease, /--prerelease=false/u); + assert.match(publishRelease, /--json isPrerelease/u); assert.doesNotMatch(publishRelease, /gh release delete-asset/u); assert.match(publishRelease, /gh release download/u); assert.match(publishRelease, /cmp -s/u); diff --git a/scripts/release-checksum.mjs b/scripts/release-checksum.mjs deleted file mode 100644 index 18f8d0bb46..0000000000 --- a/scripts/release-checksum.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; -import { basename } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -export async function writeSha256Sidecar(artifactPath) { - const hash = createHash('sha256'); - for await (const chunk of createReadStream(artifactPath)) hash.update(chunk); - const checksumPath = `${artifactPath}.sha256`; - await writeFile(checksumPath, `${hash.digest('hex')} ${basename(artifactPath)}\n`, 'utf8'); - return checksumPath; -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [artifactPath, ...unsupported] = process.argv.slice(2); - if (!artifactPath || unsupported.length > 0) { - throw new Error('Usage: node scripts/release-checksum.mjs '); - } - console.log(await writeSha256Sidecar(artifactPath)); -} From bfd06fa74563d381a5fde62904565b7898321562 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 17:23:32 +0800 Subject: [PATCH 26/31] fix(release): enforce draft publication authority Keep every GitHub Release non-Latest while it remains a Draft, and require the exact live Draft state before npm staging or finalization. Centralize tag, ancestry, and release-classification checks while removing the duplicate Stage-run precheck. --- .github/RELEASE_CHECKLIST.md | 5 +- .github/workflows/release-cli-finalize.yml | 29 +------ .github/workflows/release-cli-stage.yml | 19 +---- .github/workflows/release.yml | 10 +-- docs/cli-npm-release.md | 10 +-- docs/cli-npm-release.zh-CN.md | 10 +-- package.json | 4 +- scripts/ci-test-plan.mjs | 2 + scripts/ci-test-plan.test.mjs | 2 + scripts/product-release-authority.mjs | 79 +++++++++++++++++ scripts/product-release-authority.test.mjs | 90 ++++++++++++++++++++ scripts/product-release-tag.mjs | 18 ++-- scripts/product-release.test.mjs | 7 +- scripts/release-cli-workflow-policy.test.mjs | 31 ++----- 14 files changed, 218 insertions(+), 98 deletions(-) create mode 100644 scripts/product-release-authority.mjs create mode 100644 scripts/product-release-authority.test.mjs diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 8e5a773bf3..43e12b82a5 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -58,7 +58,7 @@ These controls close the check-to-upload and check-to-stage windows. Keep the Re `publish` pass. A skipped or failed required job must prevent Draft creation. 6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source reference in its notes, is marked as a GitHub prerelease exactly when the product version is a - prerelease (stable releases are marked Latest), and contains at least: + prerelease, is not marked Latest while it remains a Draft, and contains at least: - `Maka--mac-arm64.dmg` and checksum; - `Maka--win-x64.exe` and checksum; - `Maka--cli-mac-arm64.zip` and checksum; @@ -96,6 +96,9 @@ Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact p Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate requires a new product version; never publish the Draft to work around npm state. +When every npm and cross-machine acceptance check has passed, publish the Draft. Mark a stable +release as Latest at that final publication boundary; prereleases must remain non-Latest. + ## Acceptance on another Apple Silicon Mac Download the DMG, CLI ZIP, and their checksum files through a browser from the Draft. Do not move diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index a326ddde7f..5ae0a20900 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -40,9 +40,7 @@ jobs: fi - name: Load the exact stage workflow run - id: stage-run env: - EXPECTED_VERSION: ${{ inputs.version }} GH_TOKEN: ${{ github.token }} STAGE_RUN_ID: ${{ inputs.stage_run_id }} STAGE_RUN_ATTEMPT: ${{ inputs.stage_run_attempt }} @@ -56,22 +54,6 @@ jobs: exit 1 fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" - node -e ' - const fs = require("node:fs"); - const run = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - if ( - String(run.id) !== process.env.STAGE_RUN_ID || - String(run.run_attempt) !== process.env.STAGE_RUN_ATTEMPT || - run.path !== ".github/workflows/release-cli-stage.yml" || - run.event !== "workflow_dispatch" || - run.head_branch !== "v" + process.env.EXPECTED_VERSION || - !/^[0-9a-f]{40}$/.test(run.head_sha) || - run.conclusion !== "success" || - run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY - ) { - throw new Error("Stage run is not an exact successful product-tag CLI stage attempt"); - } - ' "$RUNNER_TEMP/stage-run.json" - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -114,15 +96,8 @@ jobs: PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} run: | - current_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" - if [[ "$current_source" != "$PRODUCT_SOURCE_COMMIT" ]]; then - echo "Product tag ${PRODUCT_TAG} no longer points to ${PRODUCT_SOURCE_COMMIT}." >&2 - exit 1 - fi - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor "$PRODUCT_SOURCE_COMMIT" origin/main - release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" - test "$release_tag" = "$PRODUCT_TAG" + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" - name: Fetch and verify the public registry bytes run: | diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index a8d6addd20..01f38f4a66 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -49,12 +49,8 @@ jobs: node scripts/release-version.mjs "$EXPECTED_VERSION" source_commit="$(git rev-parse HEAD)" test "$source_commit" = "$GITHUB_SHA" - release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" - test "$release_tag" = "$PRODUCT_TAG" - remote_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" - test "$remote_source" = "$source_commit" - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor "$source_commit" origin/main + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$source_commit" "$GITHUB_REPOSITORY" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" @@ -166,15 +162,8 @@ jobs: RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} run: | - current_source="$(git ls-remote --tags --refs origin "refs/tags/$PRODUCT_TAG" | awk '{print $1}')" - if [[ "$current_source" != "$PRODUCT_SOURCE_COMMIT" ]]; then - echo "Product tag ${PRODUCT_TAG} no longer points to ${PRODUCT_SOURCE_COMMIT}." >&2 - exit 1 - fi - release_tag="$(gh release view "$PRODUCT_TAG" --json tagName --jq .tagName)" - test "$release_tag" = "$PRODUCT_TAG" - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor "$PRODUCT_SOURCE_COMMIT" origin/main + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" npm stage publish "$RELEASE_TARBALL" \ --tag "$RELEASE_DIST_TAG" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d6a0be23b2..f4943c89b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -451,11 +451,9 @@ jobs: Known limitation: Computer Use is not included in this release." - create_classification=(--latest) - edit_classification=(--prerelease=false --latest) + classification=(--prerelease=false --latest=false) if [[ "$IS_PRERELEASE" == "true" ]]; then - create_classification=(--prerelease --latest=false) - edit_classification=(--prerelease --latest=false) + classification=(--prerelease --latest=false) elif [[ "$IS_PRERELEASE" != "false" ]]; then echo "Product prerelease classification must be true or false; found $IS_PRERELEASE" >&2 exit 1 @@ -471,7 +469,7 @@ jobs: gh release create "$TAG" \ --draft \ --verify-tag \ - "${create_classification[@]}" \ + "${classification[@]}" \ --title "Maka ${VERSION}" \ --notes "$notes" fi @@ -481,7 +479,7 @@ jobs: exit 1 fi gh release edit "$TAG" \ - "${edit_classification[@]}" \ + "${classification[@]}" \ --title "Maka ${VERSION}" \ --notes "$notes" actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)" diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index c1c71567c2..afed7ca49a 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -127,15 +127,13 @@ Immediately before approval, recheck the live product authority recorded by the ```sh set -eu -git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" source_commit=replace-with-stage-recorded-commit -tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" -test "$tag_commit" = "$source_commit" -git merge-base --is-ancestor "$source_commit" origin/main -gh release view "v$version" --json tagName --jq .tagName +node scripts/product-release-authority.mjs verify-draft \ + "v$version" "$source_commit" apache/maka ``` -The last command must print `v`. Stop if the tag is absent, moved, no longer on `main`, or no matching GitHub Release exists. +The verifier must succeed. Stop if the tag is absent, moved, no longer on `main`, the matching +GitHub Release is no longer a Draft, or its prerelease classification does not match the version. Approve only that stage ID. npm requires 2FA and makes the package public as part of approval: diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 9fe539c3fd..8e3aa44352 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -115,15 +115,13 @@ npm stage download "$stage_id" --registry https://registry.npmjs.org/ ```sh set -eu -git fetch --no-tags origin main:refs/remotes/origin/main "refs/tags/v$version:refs/tags/v$version" source_commit=replace-with-stage-recorded-commit -tag_commit="$(git rev-parse "refs/tags/v$version^{commit}")" -test "$tag_commit" = "$source_commit" -git merge-base --is-ancestor "$source_commit" origin/main -gh release view "v$version" --json tagName --jq .tagName +node scripts/product-release-authority.mjs verify-draft \ + "v$version" "$source_commit" apache/maka ``` -最后一条命令必须输出 `v`。tag 不存在、已移动、不再位于 `main`,或不存在匹配的 GitHub Release 时都必须停止。 +verifier 必须成功。tag 不存在、已移动、不再位于 `main`,匹配的 GitHub Release 不再是 Draft, +或 prerelease 分类与版本不一致时都必须停止。 只批准这个 stage ID。npm 会要求 2FA,并在批准时将 package 公开: diff --git a/package.json b/package.json index 76313eadb2..a96a6ebd93 100644 --- a/package.json +++ b/package.json @@ -56,10 +56,10 @@ "check:product-release-identity": "node scripts/product-release-identity.mjs", "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", - "test:product-release": "node --test scripts/product-release.test.mjs", + "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:product-release-identity && node --test scripts/product-release.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:product-release-identity && node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 8ae643cbbd..e9e70e3fda 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -35,6 +35,8 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/prepare-bundled-git.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', 'scripts/prepare-windows-upgrade-baseline.test.mjs', + 'scripts/product-release-authority.mjs', + 'scripts/product-release-authority.test.mjs', 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index f0f341b080..b4cf6ff6eb 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -99,6 +99,8 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/product-release-authority.mjs', + 'scripts/product-release-authority.test.mjs', 'scripts/product-release-identity.mjs', 'scripts/product-release-tag.mjs', 'scripts/product-release.test.mjs', diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs new file mode 100644 index 0000000000..09e179c110 --- /dev/null +++ b/scripts/product-release-authority.mjs @@ -0,0 +1,79 @@ +import { execFile } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; + +const execFileAsync = promisify(execFile); + +function expectedReleaseIdentity(tag) { + const { prerelease } = parseProductTag(tag); + return { tag, isPrerelease: prerelease.length > 0 }; +} + +export function assertDraftProductRelease(release, tag) { + const expected = expectedReleaseIdentity(tag); + if (!release || release.tagName !== expected.tag) { + throw new Error(`GitHub Release does not identify product tag ${tag}`); + } + if (release.isDraft !== true) { + throw new Error(`GitHub Release ${tag} must remain a Draft`); + } + if (release.isPrerelease !== expected.isPrerelease) { + throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); + } + return release; +} + +export async function verifyDraftProductRelease({ + tag, + sourceCommit, + repository, + cwd = process.cwd(), + run = execFileAsync, +}) { + expectedReleaseIdentity(tag); + if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error(`Product source must be an exact commit SHA; found ${sourceCommit}`); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error(`Product repository must be an exact owner/name; found ${repository}`); + } + + const remoteCommit = await remoteProductTagCommit({ cwd, remote: 'origin', tag, run }); + if (!remoteCommit) throw new Error(`Product tag ${tag} does not exist on origin`); + if (remoteCommit !== sourceCommit) { + throw new Error(`Product tag ${tag} points to ${remoteCommit} instead of ${sourceCommit}`); + } + + await run('git', ['fetch', '--force', '--no-tags', 'origin', 'main:refs/remotes/origin/main'], { + cwd, + }); + await run('git', ['merge-base', '--is-ancestor', sourceCommit, 'refs/remotes/origin/main'], { + cwd, + }); + + const release = await run( + 'gh', + ['release', 'view', tag, '--repo', repository, '--json', 'tagName,isDraft,isPrerelease'], + { cwd }, + ); + let parsedRelease; + try { + parsedRelease = JSON.parse(release.stdout); + } catch (error) { + throw new Error(`GitHub returned an invalid Release record for ${tag}`, { cause: error }); + } + return assertDraftProductRelease(parsedRelease, tag); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, tag, sourceCommit, repository = process.env.GITHUB_REPOSITORY] = + process.argv.slice(2); + if (command !== 'verify-draft' || !tag || !sourceCommit || !repository) { + throw new Error( + 'usage: product-release-authority.mjs verify-draft ', + ); + } + await verifyDraftProductRelease({ tag, sourceCommit, repository }); + console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); +} diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs new file mode 100644 index 0000000000..59e2f7fdd5 --- /dev/null +++ b/scripts/product-release-authority.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertDraftProductRelease, + verifyDraftProductRelease, +} from './product-release-authority.mjs'; + +test('Draft state and prerelease classification are one product Release contract', () => { + for (const [tag, isPrerelease] of [ + ['v1.2.3', false], + ['v1.2.3-beta.1', true], + ]) { + assert.equal( + assertDraftProductRelease({ tagName: tag, isDraft: true, isPrerelease }, tag).tagName, + tag, + ); + } + assert.throws( + () => + assertDraftProductRelease( + { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, + 'v1.2.3', + ), + /must remain a Draft/u, + ); + assert.throws( + () => + assertDraftProductRelease( + { tagName: 'v1.2.3-beta.1', isDraft: true, isPrerelease: false }, + 'v1.2.3-beta.1', + ), + /prerelease state must be true/u, + ); +}); + +test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { + const sourceCommit = 'a'.repeat(40); + const calls = []; + const run = async (command, args) => { + calls.push([command, args]); + if (args[0] === 'ls-remote') { + return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; + } + if (command === 'gh') { + return { + stdout: JSON.stringify({ + tagName: 'v1.2.3', + isDraft: true, + isPrerelease: false, + }), + }; + } + return { stdout: '' }; + }; + + await verifyDraftProductRelease({ + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + run, + }); + + assert.deepEqual( + calls.map(([command, args]) => [command, args[0]]), + [ + ['git', 'ls-remote'], + ['git', 'fetch'], + ['git', 'merge-base'], + ['gh', 'release'], + ], + ); + assert.deepEqual(calls.at(-1)[1].slice(-2), ['--json', 'tagName,isDraft,isPrerelease']); +}); + +test('the live authority verifier rejects product tag drift before later checks', async () => { + const calls = []; + await assert.rejects( + verifyDraftProductRelease({ + tag: 'v1.2.3', + sourceCommit: 'a'.repeat(40), + repository: 'apache/maka', + run: async (command, args) => { + calls.push([command, args]); + return { stdout: `${'b'.repeat(40)}\trefs/tags/v1.2.3\n` }; + }, + }), + /points to .* instead of/u, + ); + assert.equal(calls.length, 1); +}); diff --git a/scripts/product-release-tag.mjs b/scripts/product-release-tag.mjs index 527f459536..71e074ed35 100644 --- a/scripts/product-release-tag.mjs +++ b/scripts/product-release-tag.mjs @@ -5,22 +5,26 @@ import { parseProductReleaseVersion } from './release-version.mjs'; const execFileAsync = promisify(execFile); -function validateInputs(tag, source) { - if (!tag.startsWith('v')) { +export function parseProductTag(tag) { + if (typeof tag !== 'string' || !tag.startsWith('v')) { throw new Error(`Product tag must be an exact version tag; found ${tag}`); } try { - parseProductReleaseVersion(tag.slice(1)); + return parseProductReleaseVersion(tag.slice(1)); } catch (error) { throw new Error(`Product tag must be an exact version tag; found ${tag}`, { cause: error }); } +} + +function validateInputs(tag, source) { + parseProductTag(tag); if (!/^[0-9a-f]{40}$/u.test(source)) { throw new Error(`Product tag source must be an exact commit SHA; found ${source}`); } } -async function remoteTagCommit({ cwd, remote, tag }) { - const { stdout } = await execFileAsync( +export async function remoteProductTagCommit({ cwd, remote, tag, run = execFileAsync }) { + const { stdout } = await run( 'git', ['ls-remote', '--tags', '--refs', remote, `refs/tags/${tag}`], { cwd }, @@ -47,7 +51,7 @@ export async function ensureProductTag({ cwd = process.cwd(), remote = 'origin', throw new Error(`Product source ${source} is not the exact checked-out commit`); } - const existing = await remoteTagCommit({ cwd, remote, tag }); + const existing = await remoteProductTagCommit({ cwd, remote, tag }); if (existing) { if (existing !== source) { throw new Error(`Product tag ${tag} points to ${existing} instead of ${source}`); @@ -59,7 +63,7 @@ export async function ensureProductTag({ cwd = process.cwd(), remote = 'origin', await execFileAsync('git', ['push', remote, `${source}:refs/tags/${tag}`], { cwd }); return 'created'; } catch (error) { - const raced = await remoteTagCommit({ cwd, remote, tag }); + const raced = await remoteProductTagCommit({ cwd, remote, tag }); if (raced === source) return 'existing'; if (raced) throw new Error(`Product tag ${tag} points to ${raced} instead of ${source}`); throw new Error(`Could not create product tag ${tag}`, { cause: error }); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index d7f9b679ef..d71f437e13 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -523,10 +523,9 @@ test('one product workflow gates one draft release on every required artifact', .IS_PRERELEASE, '${{ needs.release-identity.outputs.is_prerelease }}', ); - assert.match(publishRelease, /create_classification=\(--latest\)/u); - assert.match(publishRelease, /edit_classification=\(--prerelease=false --latest\)/u); - assert.match(publishRelease, /--prerelease --latest=false/u); - assert.match(publishRelease, /--prerelease=false/u); + assert.match(publishRelease, /classification=\(--prerelease=false --latest=false\)/u); + assert.match(publishRelease, /classification=\(--prerelease --latest=false\)/u); + assert.doesNotMatch(publishRelease, /--latest(?:\s|\\|$)/u); assert.match(publishRelease, /--json isPrerelease/u); assert.doesNotMatch(publishRelease, /gh release delete-asset/u); assert.match(publishRelease, /gh release download/u); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index b233bbc0ee..749636952b 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -41,11 +41,8 @@ test('stage consumes the validated artifact and makes provenance staging the fin assert.match(guidance, /npm dist-tag add/u); const submit = namedStep(steps, 'Submit the candidate to npm staging'); assert.equal(steps.at(-1), submit); - assert.match(submit, /git ls-remote --tags --refs origin/u); - assert.match(submit, /git merge-base --is-ancestor "\$PRODUCT_SOURCE_COMMIT" origin\/main/u); - assert.match(submit, /gh release view "\$PRODUCT_TAG"/u); - assert.ok(submit.indexOf('git ls-remote') < submit.indexOf('npm stage publish')); - assert.ok(submit.indexOf('gh release view') < submit.indexOf('npm stage publish')); + assert.match(submit, /product-release-authority\.mjs verify-draft/u); + assert.ok(submit.indexOf('verify-draft') < submit.indexOf('npm stage publish')); assert.match(submit, /npm stage publish/u); assert.match(submit, /--provenance/u); }); @@ -63,13 +60,12 @@ test('stage builds the npm candidate from the exact product release commit', () ); assert.match(workflow, /source_commit: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); assert.match(workflow, /ref: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); - assert.match(workflow, /gh release view "\$PRODUCT_TAG"/u); + assert.match(workflow, /product-release-authority\.mjs verify-draft/u); assert.match(workflow, /node scripts\/release-version\.mjs "\$EXPECTED_VERSION"/u); assert.doesNotMatch(workflow, /EXPECTED_VERSION.*=~/u); assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); assert.match(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); assert.doesNotMatch(workflow, /RELEASE_WORKFLOW_SHA/u); - assert.match(workflow, /git merge-base --is-ancestor "\$source_commit" origin\/main/u); const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); @@ -78,23 +74,12 @@ test('finalize validates one exact stage attempt before running the current veri const workflow = readWorkflow('release-cli-finalize.yml'); const steps = workflowSteps(workflow); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); - const loadIndex = workflow.indexOf('id: stage-run'); + const loadIndex = workflow.indexOf('name: Load the exact stage workflow run'); const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); - assert.match(workflow, /run\.head_branch !== "v" \+ process\.env\.EXPECTED_VERSION/u); - for (const field of [ - 'run.id', - 'run.run_attempt', - 'run.path', - 'run.event', - 'run.head_branch', - 'run.head_sha', - 'run.conclusion', - 'run.head_repository?.full_name', - ]) { - assert.ok(workflow.includes(field), `missing pre-check for ${field}`); - } + assert.doesNotMatch(workflow, /id: stage-run/u); + assert.doesNotMatch(workflow, /node -e/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); assert.doesNotMatch(checkout, /steps\.stage-run\.outputs\.source_sha/u); @@ -107,9 +92,7 @@ test('finalize revalidates the live product release before trusting public npm b assert.match(record, /id: release/u); assert.match(record, /"\$GITHUB_OUTPUT"/u); const authority = namedStep(steps, 'Revalidate the product release authority'); - assert.match(authority, /git ls-remote --tags --refs origin/u); - assert.match(authority, /git merge-base --is-ancestor "\$PRODUCT_SOURCE_COMMIT" origin\/main/u); - assert.match(authority, /gh release view "\$PRODUCT_TAG"/u); + assert.match(authority, /product-release-authority\.mjs verify-draft/u); assert.ok( workflow.indexOf(authority) < workflow.indexOf('Fetch and verify the public registry bytes'), ); From be7cea78b3e5b8f52385a6925bdf5463c6ca49e0 Mon Sep 17 00:00:00 2001 From: Wang Date: Fri, 21 Aug 2026 17:34:34 +0800 Subject: [PATCH 27/31] test(release): trim redundant contract coverage Keep behavior coverage at its owning release boundary while removing duplicate channel and CI-routing checks. Drop historical negative assertions that only preserved superseded implementation shapes. --- scripts/product-release.test.mjs | 53 +++++--------------- scripts/release-cli-publication.test.mjs | 2 - scripts/release-cli-workflow-policy.test.mjs | 7 +-- 3 files changed, 13 insertions(+), 49 deletions(-) diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index d71f437e13..561a3e0d20 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -7,8 +7,6 @@ import test from 'node:test'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; import desktopBuilderConfig from '../apps/desktop/electron-builder.config.mjs'; -import { parseCliReleaseVersion } from './release-cli-publication.mjs'; -import { planTests } from './ci-test-plan.mjs'; import { assertProductReleaseExpectation, parseAsfSourceReferenceTag, @@ -182,17 +180,18 @@ test('the product identity CLI uses the checked-out commit outside GitHub Action assert.equal(stdout.trim(), `Product release v${manifest.version} from ${head.trim()}`); }); -test('product and npm release identities reject the same non-canonical versions', () => { - for (const version of ['01.2.3', '1.2.3-beta..1', '1.2.3-.', '1.2.3-beta.01']) { - const manifests = { - rootManifest: { ...rootManifest, version }, - desktopManifest: { version }, - cliManifest: { version, bin: { maka: './dist/cli.js' } }, - sha: 'a'.repeat(40), - }; - assert.throws(() => resolveProductReleaseIdentity(manifests), /valid product release version/u); - assert.throws(() => parseCliReleaseVersion(version), /valid product release version/u); - } +test('product release identity rejects a non-canonical version at its boundary', () => { + const version = '01.2.3'; + assert.throws( + () => + resolveProductReleaseIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + }), + /valid product release version/u, + ); }); test('an npm candidate must name the exact product tag, version, and source commit', () => { @@ -508,7 +507,6 @@ test('one product workflow gates one draft release on every required artifact', assert.match(commands, /product-release-tag\.mjs ensure/u); assert.doesNotMatch(commands, /RECOVERY_SOURCE|inputs\.source_commit/u); assert.match(commands, /if gh release view "\$TAG"/u); - assert.doesNotMatch(commands, /requires an existing Draft release/u); assert.match(commands, /--json isDraft/u); assert.match(commands, /gh release create[\s\S]*--verify-tag/u); const publishRelease = jobs.publish.steps.find( @@ -538,31 +536,4 @@ test('one product workflow gates one draft release on every required artifact', assert.ok(listAssets >= 0 && listAssets < compareAssets && compareAssets < uploadAssets); assert.doesNotMatch(commands, /gh release create[\s\S]*--target/u); assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); - await assert.rejects( - readFile(new URL('../.github/workflows/release-desktop.yml', import.meta.url)), - { code: 'ENOENT' }, - ); -}); - -test('npm channel identity has no independent product tag', () => { - assert.deepEqual(parseCliReleaseVersion('1.2.3'), { - version: '1.2.3', - distTag: 'latest', - tarball: 'maka-agent-1.2.3.tgz', - }); -}); - -test('product workflow changes select the release contracts in CI', () => { - const graph = { - dirs: [], - testDirs: new Set(), - dependents: new Map(), - }; - const plan = planTests(['.github/workflows/release.yml'], { graph }); - assert.equal(plan.releaseContract, true); - assert.equal(plan.code, false); - assert.equal(plan.full, false); - assert.equal(plan.e2e, false); - assert.equal(plan.storybook, false); - assert.deepEqual(plan.workspaces, []); }); diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index bd84643d4c..63685a66b3 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -103,7 +103,6 @@ test('stage records bind the checked candidate to one source workflow run', () = assert.equal(prepared.record.schemaVersion, 3); assert.equal(prepared.record.productTag, PRODUCT_TAG); assert.equal(prepared.record.source.commit, SOURCE_SHA); - assert.equal(Object.hasOwn(prepared.record.source, 'workflowCommit'), false); assert.equal(prepared.record.source.runId, '321'); assert.equal(prepared.record.source.runAttempt, '1'); assert.deepEqual( @@ -235,7 +234,6 @@ test('registry finalization requires the exact staged bytes and dist-tag', async ); const registryRecord = JSON.parse(readFileSync(join(registryDirectory, 'release.json'), 'utf8')); assert.equal(registryRecord.version, result.version); - assert.equal(Object.hasOwn(registryRecord, 'gitTag'), false); await assert.rejects( fetchRegistryRelease({ diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 749636952b..a7c6589229 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -62,10 +62,8 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(workflow, /ref: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); assert.match(workflow, /product-release-authority\.mjs verify-draft/u); assert.match(workflow, /node scripts\/release-version\.mjs "\$EXPECTED_VERSION"/u); - assert.doesNotMatch(workflow, /EXPECTED_VERSION.*=~/u); assert.match(workflow, /EXPECTED_PRODUCT_SOURCE_COMMIT/u); assert.match(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); - assert.doesNotMatch(workflow, /RELEASE_WORKFLOW_SHA/u); const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); @@ -78,11 +76,8 @@ test('finalize validates one exact stage attempt before running the current veri const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); - assert.doesNotMatch(workflow, /id: stage-run/u); - assert.doesNotMatch(workflow, /node -e/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); - assert.doesNotMatch(checkout, /steps\.stage-run\.outputs\.source_sha/u); }); test('finalize revalidates the live product release before trusting public npm bytes', () => { @@ -102,7 +97,7 @@ test('finalize preserves verified npm bytes without creating another product rel const workflow = readWorkflow('release-cli-finalize.yml'); assert.match(workflow, /name: Preserve the verified public npm package/u); assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); - assert.doesNotMatch(workflow, /cli-v|contents: write|validate-github-release/u); + assert.doesNotMatch(workflow, /cli-v|contents: write/u); }); test('release workflows select npm from the root packageManager authority', () => { From 0eb14500cb779a8bf1578f072ac1783787c8650b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 18:43:33 +0800 Subject: [PATCH 28/31] fix(release): harden standalone macOS CLI Generated-by: Codex --- scripts/package-macos-arm64-cli.mjs | 50 ++++++++++++++++--- scripts/product-release.test.mjs | 77 ++++++++++++++++++++++++++++- scripts/verify-macos-arm64-cli.mjs | 28 ++++++++--- 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs index 079a6541b9..b248c2ab65 100644 --- a/scripts/package-macos-arm64-cli.mjs +++ b/scripts/package-macos-arm64-cli.mjs @@ -59,7 +59,7 @@ export const DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS = Object.freeze( ), ); -function runCommand(command, args, options = {}) { +export function runCommand(command, args, options = {}) { return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { cwd: options.cwd ?? repoRoot, @@ -74,7 +74,7 @@ function runCommand(command, args, options = {}) { } reject( new Error( - `${command} ${args.join(' ')} failed with ${ + `${command} ${(options.displayArgs ?? args).join(' ')} failed with ${ signal ? `signal ${signal}` : `exit code ${code}` }`, ), @@ -120,7 +120,10 @@ while [ -L "$launcher" ]; do esac done bin_dir=$(CDPATH= cd -P "$(dirname "$launcher")" && pwd) -exec "$bin_dir/../libexec/node/bin/node" "$bin_dir/../libexec/node_modules/maka-agent/dist/cli.js" "$@" +libexec_dir=$(CDPATH= cd -P "$bin_dir/../libexec" && pwd) +MAKA_EVAL_MAKA_BUNDLE_PATH=$libexec_dir +export MAKA_EVAL_MAKA_BUNDLE_PATH +exec "$libexec_dir/node/bin/node" "$libexec_dir/node_modules/maka-agent/dist/cli.js" "$@" `; } @@ -632,7 +635,7 @@ export function assertExpectedAppleTeam(identity, expectedTeamIdentifier) { return identity; } -async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }) { +export async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }) { const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-cli-signing-')); const certificatePath = join(temporaryRoot, 'identity.p12'); const pemPath = join(temporaryRoot, 'identity.pem'); @@ -640,21 +643,41 @@ async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect const keychainPassword = randomBytes(32).toString('hex'); let created = false; const cleanup = async () => { + let keychainDeletionError; if (created) { try { await run('security', ['delete-keychain', keychainFile], { env }); + } catch (error) { + keychainDeletionError = error; } finally { created = false; } } - await rm(temporaryRoot, { recursive: true, force: true }); + try { + await rm(temporaryRoot, { recursive: true, force: true }); + } catch (error) { + if (keychainDeletionError) { + throw new AggregateError( + [keychainDeletionError, error], + 'Signing keychain and temporary credential cleanup both failed', + ); + } + throw error; + } + if (keychainDeletionError) throw keychainDeletionError; }; try { await writeFile(certificatePath, decodeSigningCertificate(env.CSC_LINK).bytes, { mode: 0o600 }); - await run('security', ['create-keychain', '-p', keychainPassword, keychainFile], { env }); + await run('security', ['create-keychain', '-p', keychainPassword, keychainFile], { + env, + displayArgs: ['create-keychain', '-p', '', keychainFile], + }); created = true; - await run('security', ['unlock-keychain', '-p', keychainPassword, keychainFile], { env }); + await run('security', ['unlock-keychain', '-p', keychainPassword, keychainFile], { + env, + displayArgs: ['unlock-keychain', '-p', '', keychainFile], + }); await run('security', ['set-keychain-settings', '-lut', '21600', keychainFile], { env }); await run( 'openssl', @@ -685,7 +708,18 @@ async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect keychainPassword, keychainFile, ], - { env }, + { + env, + displayArgs: [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:', + '-s', + '-k', + '', + keychainFile, + ], + }, ); const identityOutput = await inspect( 'security', diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 561a3e0d20..36d33a4fdb 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -15,21 +15,24 @@ import { } from './product-release-identity.mjs'; import { assertExpectedAppleTeam, + createSigningKeychain, decodeSigningCertificate, DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, distributionNodeEntitlements, extractDistributionNodeEntitlements, extractOfficialNodeEntitlements, macosArm64MachOAction, + macosArm64CliWrapper, parseDeveloperIdApplicationIdentity, pruneThirdPartyDevelopmentArtifacts, resolveCliWorkspacePackages, + runCommand, stageWorkspacePackages, standaloneInstallEnvironment, standaloneInstallRootManifest, } from './package-macos-arm64-cli.mjs'; import { resolveWorkspaceReleaseFiles } from './release-cli-file-policy.mjs'; -import { isTuiReadyOutput } from './verify-macos-arm64-cli.mjs'; +import { isTuiReadyOutput, verifyQuarantinedExecution } from './verify-macos-arm64-cli.mjs'; import { makePtyProbe } from './verify-packaged-app.mjs'; import { ensureProductTag } from './product-release-tag.mjs'; @@ -77,6 +80,26 @@ test('one root version defines every product artifact from one source commit', ( assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); }); +test('the standalone launcher identifies its installed Eval bundle root', async (t) => { + const archiveRoot = await mkdtemp(join(tmpdir(), 'maka-standalone-launcher-')); + t.after(() => rm(archiveRoot, { recursive: true, force: true })); + const launcher = join(archiveRoot, 'bin', 'maka'); + const node = join(archiveRoot, 'libexec', 'node', 'bin', 'node'); + await Promise.all([ + mkdir(join(archiveRoot, 'bin'), { recursive: true }), + mkdir(join(archiveRoot, 'libexec', 'node', 'bin'), { recursive: true }), + ]); + await Promise.all([ + writeFile(launcher, macosArm64CliWrapper()), + writeFile(node, '#!/bin/sh\nprintf %s "$MAKA_EVAL_MAKA_BUNDLE_PATH"\n'), + ]); + await Promise.all([chmod(launcher, 0o755), chmod(node, 0o755)]); + + const { stdout } = await execFileAsync(launcher); + + assert.equal(stdout, await realpath(join(archiveRoot, 'libexec'))); +}); + test('the product identity classifies prereleases once for every publication surface', () => { const version = '1.2.3-beta.2'; const identity = resolveProductReleaseIdentity({ @@ -272,6 +295,20 @@ test('standalone verification recognizes the current TUI status line through ANS ); }); +test('standalone verification assesses the downloaded quarantine state', async () => { + const commands = []; + + await verifyQuarantinedExecution( + '/artifact/Maka', + '/artifact/Maka/libexec/node/bin/node', + async (command) => { + commands.push(command); + }, + ); + + assert.deepEqual(commands, ['xattr', 'spctl']); +}); + test('standalone packaging keeps or thins arm64 Mach-O files instead of deleting them', () => { const macos = 'platform MACOS\n'; assert.equal(macosArm64MachOAction('arm64', macos), 'keep'); @@ -320,6 +357,42 @@ test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identi ); }); +test('CLI signing removes temporary credentials when keychain deletion fails', async () => { + const signing = await createSigningKeychain({ + env: { CSC_LINK: Buffer.from('pkcs12').toString('base64') }, + expectedTeamIdentifier: 'FABM2QUA8Q', + run: async (command, args) => { + if (command === 'openssl') { + await writeFile(args[args.indexOf('-out') + 1], 'temporary private key'); + } + if (command === 'security' && args[0] === 'delete-keychain') { + throw new Error('simulated keychain deletion failure'); + } + }, + inspect: async () => ({ + stdout: + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (FABM2QUA8Q)"\n 1 valid identities found\n', + }), + }); + + await assert.rejects(signing.cleanup(), /simulated keychain deletion failure/u); + await assert.rejects(access(signing.directory), { code: 'ENOENT' }); +}); + +test('CLI signing command failures do not disclose credential arguments', async () => { + const secret = 'temporary-keychain-password'; + await assert.rejects( + runCommand(process.execPath, ['-e', 'process.exit(9)', secret], { + displayArgs: ['-e', '