diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 6248136776..68ee9ce9be 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, and `apps/desktop/package.json` contains a version that has never been released. -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 `maka` with no arguments and confirm the TUI renders, accepts input, and exits cleanly. +7. Exercise one non-interactive `maka run`, one deterministic `maka eval`, 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 975982dab3..4391ee8b90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,8 @@ jobs: - run: npm run lint - run: npm run format:check - run: npm run build + - name: Release contract tests + run: npm run test:release - run: npm run typecheck # Generated-artifact governance. astryx-theme/maka.{css,js} are built # from makaTheme.ts, which is now the renderer's type-scale authority — diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml deleted file mode 100644 index be9dfff4a3..0000000000 --- a/.github/workflows/release-desktop.yml +++ /dev/null @@ -1,263 +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: 45 - 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: Audit production dependencies - run: npm audit --omit=dev --audit-level=moderate - - - 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: 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..cf7546abef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,416 @@ +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: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.18.1' + + - name: Prepare the pinned npm release toolchain + run: node scripts/prepare-release-npm.mjs + + - name: Verify the pinned npm release toolchain + run: npm run check:release-npm + + - name: Resolve product release identity + id: identity + run: node scripts/release-identity.mjs + + - name: Reserve tag and release identity + 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: 45 + 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: Prepare the pinned npm release toolchain + run: node scripts/prepare-release-npm.mjs + + - name: Verify the pinned npm release toolchain + run: npm run check:release-npm + + - name: Install dependencies with the pinned npm + run: npm ci + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=moderate + + - 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: ${{ 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 -- "${{ 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 -- "${{ 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 -- \ + "${{ needs.release-identity.outputs.exe }}" \ + "${{ steps.previous.outputs.exe }}" + + - 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: Prepare the pinned npm release toolchain + run: node scripts/prepare-release-npm.mjs + + - name: Verify the pinned npm release toolchain + run: npm run check:release-npm + + - 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 run build + + - 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 -- + "${{ 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: | + ${{ needs.release-identity.outputs.cli_archive }} + ${{ 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: Prepare the pinned npm release toolchain + run: node scripts/prepare-release-npm.mjs + + - name: Verify the pinned npm release toolchain + run: npm run check:release-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="${{ 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] + 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..bcd9cc5de7 --- /dev/null +++ b/docs/cli-distribution.md @@ -0,0 +1,53 @@ +# 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 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 distribution source. Future Homebrew or npm channels must +consume the same artifact, version, layout contract, and release line; they do not define a second +release or block Phase 1. diff --git a/package-lock.json b/package-lock.json index 944ae14256..08ef515cd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13594,7 +13594,7 @@ }, "packages/cli": { "name": "maka-agent", - "version": "0.1.0", + "version": "0.1.10", "license": "Apache-2.0", "dependencies": { "@earendil-works/pi-tui": "0.83.0", @@ -13606,8 +13606,7 @@ "@maka/storage": "0.1.0" }, "bin": { - "maka": "dist/cli.js", - "maka-agent": "dist/cli.js" + "maka": "dist/cli.js" } }, "packages/code-mode": { diff --git a/package.json b/package.json index bfdee343f0..257fe3f511 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,11 @@ "node": ">=22.19.0" }, "packageManager": "npm@11.12.1", + "releaseToolchain": { + "node": "24.18.1", + "nodeDarwinArm64Archive": "node-v24.18.1-darwin-arm64.tar.xz", + "nodeDarwinArm64Sha256": "1d60b703fe5d7e7072489be8187f430f1a095a658c31e5e1e281331a5873fac3" + }, "type": "module", "workspaces": [ "packages/code-mode", @@ -41,8 +46,13 @@ "generate:third-party-notices": "node scripts/generate-third-party-notices.mjs", "check:third-party-notices": "node scripts/generate-third-party-notices.mjs --check", "check:release": "npm run check:stale && npm run check:third-party-notices", + "check:release-npm": "node scripts/assert-release-npm.mjs", + "check:release-identity": "node scripts/release-identity.mjs", + "test:release": "node --test scripts/release.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", + "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", + "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", "verify:windows-x64": "node scripts/verify-windows-x64.mjs", "verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index 02e811d92b..40c1cb8fe5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,14 +1,13 @@ { "name": "maka-agent", - "version": "0.1.0", + "version": "0.1.10", "license": "Apache-2.0", "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "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 3b1a175d48..b78d1911a6 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -46,6 +46,15 @@ describe('Maka CLI args', () => { ); }); + test('documents maka as the only public launcher', () => { + const help = parseMakaCliArgs(['--help'], '0.1.0', 'maka'); + assert.equal(help.kind, 'help'); + if (help.kind === 'help') { + assert.match(help.text, /^Usage: maka$/m); + assert.doesNotMatch(help.text, /maka-agent/); + } + }); + test('establishes the fatal exit before reporting can throw', async () => { const cliUrl = new URL('../cli.js', import.meta.url).href; const childSource = ` diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 91d4085e6c..0abae3148a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -97,7 +97,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 d5430c8a9e..e3629f9d04 100644 --- a/packages/eval/package.json +++ b/packages/eval/package.json @@ -15,6 +15,17 @@ "./harbor": "./dist/harness-executor.js", "./pier": "./dist/harness-executor.js" }, + "releaseFiles": [ + "dist", + "harbor/deepseek-codex-models.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/relay_agent.py", + "harbor/run_trial.py" + ], "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", "build": "tsc -p tsconfig.json", diff --git a/scripts/assert-release-npm.mjs b/scripts/assert-release-npm.mjs new file mode 100644 index 0000000000..0e10a7c676 --- /dev/null +++ b/scripts/assert-release-npm.mjs @@ -0,0 +1,48 @@ +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { releaseToolchainFromManifest } from './release-identity.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function npmVersionFromUserAgent(userAgent) { + const match = /^npm\/([^\s]+)/u.exec(userAgent ?? ''); + return match?.[1]; +} + +export async function assertReleaseNpm({ + rootManifest, + env = process.env, + inspect = execFileAsync, + platform = process.platform, +} = {}) { + const manifest = + rootManifest ?? JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const expectedVersion = releaseToolchainFromManifest(manifest).npmVersion; + const parentVersion = npmVersionFromUserAgent(env.npm_config_user_agent); + if (parentVersion !== expectedVersion) { + throw new Error( + `Release command requires npm ${expectedVersion}, parent npm is ${parentVersion ?? 'unknown'}.`, + ); + } + const actual = await inspect('npm', ['--version'], { + cwd: repoRoot, + env, + shell: platform === 'win32', + }); + const nestedVersion = actual.stdout.trim(); + if (nestedVersion !== expectedVersion) { + throw new Error( + `Release command requires nested npm ${expectedVersion}, found ${nestedVersion}.`, + ); + } + return expectedVersion; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const version = await assertReleaseNpm(); + console.log(`Release npm ${version} is authoritative for parent and nested commands.`); +} diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index 761e561398..4d87060d9c 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -1,11 +1,14 @@ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { npmSpawnOptions } from './npm-spawn.mjs'; const repoRoot = resolve(import.meta.dirname, '..'); -const outputPath = join(repoRoot, 'apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt'); -const checkOnly = process.argv.includes('--check'); +const desktopOutputPath = join( + repoRoot, + 'apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt', +); const assetNoticePath = join(repoRoot, 'apps/desktop/src/renderer/public/THIRD_PARTY_LICENSES.txt'); const REQUIRED_ASSET_NOTICE_MARKERS = [ '## Simple Icons brand marks', @@ -78,6 +81,9 @@ const EMBEDDED_COMPONENT_LICENSES = new Map([ ], ]); const MIT_COPYRIGHT_OVERRIDES = new Map([ + // The package tarball omits the repository-root LICENSE. Verified against + // https://github.com/earendil-works/pi/blob/v0.83.0/LICENSE. + ['@earendil-works/pi-tui@0.83.0', 'Copyright (c) 2025 Mario Zechner'], // The published tarball omits the repository LICENSE; sibling @astryxdesign // packages ship it verbatim with this notice. ['@astryxdesign/core@0.1.9', 'Copyright (c) 2026 Meta Platforms, Inc.'], @@ -137,11 +143,11 @@ function normalizeText(text) { .trim(); } -function collectDesktopClosure() { +export function collectWorkspaceClosure(workspaceName) { const tree = JSON.parse( execFileSync( 'npm', - ['ls', '--workspace', '@maka/desktop', '--omit=dev', '--all', '--json'], + ['ls', '--workspace', workspaceName, '--omit=dev', '--all', '--json'], npmSpawnOptions({ cwd: repoRoot, encoding: 'utf8', @@ -149,8 +155,8 @@ function collectDesktopClosure() { }), ), ); - const desktop = tree.dependencies?.['@maka/desktop']; - if (!desktop) throw new Error('npm ls did not return the @maka/desktop workspace'); + const workspace = tree.dependencies?.[workspaceName]; + if (!workspace) throw new Error(`npm ls did not return the ${workspaceName} workspace`); const packages = new Map(); const visit = (dependencies) => { @@ -165,7 +171,7 @@ function collectDesktopClosure() { visit(dependency.dependencies); } }; - visit(desktop.dependencies); + visit(workspace.dependencies); return [...packages.values()].sort( (left, right) => left.name.localeCompare(right.name) || left.version.localeCompare(right.version), @@ -233,10 +239,10 @@ function overrideLicenseText(packageKey, selectedLicense) { return undefined; } -function renderNotice() { +export function renderNotice({ workspaceName = '@maka/desktop' } = {}) { const lockIndex = buildLockIndex(); const sections = []; - const dependencies = collectDesktopClosure(); + const dependencies = collectWorkspaceClosure(workspaceName); for (const dependency of dependencies) { const packageKey = `${dependency.name}@${dependency.version}`; const candidates = lockIndex.get(packageKey); @@ -303,11 +309,17 @@ function renderNotice() { } } - return `Maka Desktop — Production npm Third-Party Notices -==================================================== + const productName = workspaceName === '@maka/desktop' ? 'Maka Desktop' : 'Maka CLI/TUI'; + const heading = `${productName} — Production npm Third-Party Notices`; + const underline = + workspaceName === '@maka/desktop' + ? '====================================================' + : '='.repeat(heading.length); + return `${heading} +${underline} Generated by scripts/generate-third-party-notices.mjs from the exact -@maka/desktop production dependency closure and package-lock.json. +${workspaceName} production dependency closure and package-lock.json. Do not edit this file by hand. Policy: every package must resolve to an ASF-compatible SPDX license. Compound @@ -318,7 +330,7 @@ ${sections.join('\n\n=========================================================== `; } -function validateAssetNotices() { +export function validateAssetNotices() { const notice = readFileSync(assetNoticePath, 'utf8'); for (const marker of REQUIRED_ASSET_NOTICE_MARKERS) { if (!notice.includes(marker)) { @@ -333,17 +345,47 @@ function validateAssetNotices() { } } -validateAssetNotices(); -const generated = renderNotice(); -if (checkOnly) { - if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== generated) { - throw new Error( - 'Production dependency notices are stale. Run npm run generate:third-party-notices.', +export function generateThirdPartyNotices({ + workspaceName = '@maka/desktop', + outputPath = workspaceName === '@maka/desktop' ? desktopOutputPath : undefined, + checkOnly = false, +} = {}) { + if (!outputPath) throw new Error(`--output is required for ${workspaceName} notices.`); + if (workspaceName === '@maka/desktop') validateAssetNotices(); + const generated = renderNotice({ workspaceName }); + if (checkOnly) { + if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== generated) { + throw new Error( + 'Production dependency notices are stale. Run npm run generate:third-party-notices.', + ); + } + console.log( + `[third-party-notices] OK — ${workspaceName} production dependency inventory is current.`, ); + } else { + mkdirSync(resolve(outputPath, '..'), { recursive: true }); + writeFileSync(outputPath, generated, 'utf8'); + console.log(`[third-party-notices] wrote ${outputPath}`); } - console.log('[third-party-notices] OK — production dependency inventory is current.'); -} else { - mkdirSync(resolve(outputPath, '..'), { recursive: true }); - writeFileSync(outputPath, generated, 'utf8'); - console.log(`[third-party-notices] wrote ${outputPath}`); + return generated; +} + +function parseArguments(argv) { + const options = { checkOnly: false, workspaceName: '@maka/desktop', outputPath: undefined }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--check') options.checkOnly = true; + else if (argument === '--workspace') options.workspaceName = argv[++index]; + else if (argument === '--output') options.outputPath = resolve(argv[++index]); + else throw new Error(`Unknown argument: ${argument}`); + } + if (!options.outputPath && options.workspaceName === '@maka/desktop') { + options.outputPath = desktopOutputPath; + } + return options; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const options = parseArguments(process.argv.slice(2)); + generateThirdPartyNotices(options); } diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs new file mode 100644 index 0000000000..06d0b5af36 --- /dev/null +++ b/scripts/package-macos-arm64-cli.mjs @@ -0,0 +1,837 @@ +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, resolveReleaseIdentity } from './release-identity.mjs'; +import { generateThirdPartyNotices } from './generate-third-party-notices.mjs'; + +export { releaseToolchainFromManifest } from './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', + ]; +} + +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 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; +} + +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) { + await Promise.all([ + copyFile(join(repoRoot, 'package.json'), join(installRoot, 'package.json')), + 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 = resolveReleaseIdentity({ + 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 }); + 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 pruneTestArtifacts(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'); + generateThirdPartyNotices({ + workspaceName: cliPackageName, + outputPath: 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/prepare-release-npm.mjs b/scripts/prepare-release-npm.mjs new file mode 100644 index 0000000000..2496915790 --- /dev/null +++ b/scripts/prepare-release-npm.mjs @@ -0,0 +1,72 @@ +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 { releaseToolchainFromManifest } from './release-identity.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function releaseNpmPaths(prefix) { + return { + binDirectory: join(prefix, 'node_modules', '.bin'), + npmCliPath: join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + }; +} + +export async function prepareReleaseNpm({ + rootManifest, + env = process.env, + prefix, + packageSpec, + install = execFileAsync, + inspect = execFileAsync, + npmCommand = 'npm', + execPath = process.execPath, + platform = process.platform, +} = {}) { + const manifest = + rootManifest ?? JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const expectedVersion = releaseToolchainFromManifest(manifest).npmVersion; + const installPrefix = + prefix ?? (env.RUNNER_TEMP ? join(env.RUNNER_TEMP, 'maka-release-npm') : undefined); + if (!installPrefix) { + throw new Error('Release npm preparation requires RUNNER_TEMP or an explicit prefix.'); + } + if (!env.GITHUB_PATH) { + throw new Error('Release npm preparation requires GITHUB_PATH.'); + } + + await install( + npmCommand, + [ + 'install', + '--prefix', + installPrefix, + '--no-save', + '--ignore-scripts', + '--no-audit', + '--no-fund', + packageSpec ?? `npm@${expectedVersion}`, + ], + { cwd: repoRoot, env, shell: platform === 'win32' }, + ); + + const paths = releaseNpmPaths(installPrefix); + const actual = await inspect(execPath, [paths.npmCliPath, '--version'], { + cwd: repoRoot, + env, + }); + const actualVersion = actual.stdout.trim(); + if (actualVersion !== expectedVersion) { + throw new Error(`Prepared release npm must be ${expectedVersion}, found ${actualVersion}.`); + } + await appendFile(env.GITHUB_PATH, `${paths.binDirectory}\n`, 'utf8'); + return { ...paths, npmVersion: expectedVersion }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const prepared = await prepareReleaseNpm(); + console.log(`Prepared release npm ${prepared.npmVersion} at ${prepared.binDirectory}.`); +} diff --git a/scripts/release-eval-smoke-sitecustomize.py b/scripts/release-eval-smoke-sitecustomize.py new file mode 100644 index 0000000000..c026582b13 --- /dev/null +++ b/scripts/release-eval-smoke-sitecustomize.py @@ -0,0 +1,179 @@ +"""Minimal offline Harbor API used by the extracted CLI release smoke.""" + +import asyncio +import importlib.metadata +import json +import os +import sys +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": "/tmp", + } + ) + 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/release-identity.mjs b/scripts/release-identity.mjs new file mode 100644 index 0000000000..169d4328b2 --- /dev/null +++ b/scripts/release-identity.mjs @@ -0,0 +1,118 @@ +import { appendFile, readFile } from 'node:fs/promises'; +import { basename, 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.-]+)?$/.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+)$/.exec(rootManifest.packageManager ?? ''); + if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/.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}$/.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 { + nodeArchive, + nodeArchiveSha256, + nodeSourceUrl: `https://nodejs.org/download/release/v${nodeVersion}/${nodeArchive}`, + nodeVersion, + npmVersion: npmMatch[1], + }; +} + +export function resolveReleaseIdentity({ 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(`Release identity requires refs/heads/main, found ${ref ?? 'missing'}.`); + } + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/.test(sha)) { + throw new Error('Release identity requires an exact 40-character source commit SHA.'); + } + const toolchain = releaseToolchainFromManifest(rootManifest); + const tag = `v${version}`; + return { + ...toolchain, + version, + tag, + sourceCommit: sha, + dmg: `apps/desktop/release/Maka-${version}-mac-arm64.dmg`, + exe: `apps/desktop/release/Maka-${version}-win-x64.exe`, + cliArchive: `apps/desktop/release/Maka-${version}-cli-mac-arm64.zip`, + cliChecksum: `apps/desktop/release/Maka-${version}-cli-mac-arm64.zip.sha256`, + }; +} + +export async function readReleaseIdentity({ + 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 resolveReleaseIdentity({ rootManifest, desktopManifest, cliManifest, ref, sha }); +} + +function githubOutputEntries(identity) { + const outputs = { + version: identity.version, + tag: identity.tag, + source_commit: identity.sourceCommit, + dmg: identity.dmg, + exe: identity.exe, + cli_archive: identity.cliArchive, + cli_checksum: identity.cliChecksum, + node_version: identity.nodeVersion, + npm_version: identity.npmVersion, + node_archive: identity.nodeArchive, + node_archive_sha256: identity.nodeArchiveSha256, + node_source_url: identity.nodeSourceUrl, + }; + return Object.entries(outputs).map(([name, value]) => `${name}=${value}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const identity = await readReleaseIdentity(); + if (process.env.GITHUB_OUTPUT) { + await appendFile( + process.env.GITHUB_OUTPUT, + `${githubOutputEntries(identity).join('\n')}\n`, + 'utf8', + ); + } + console.log( + `Release ${identity.tag} from ${identity.sourceCommit}; CLI runtime ${basename(identity.nodeArchive)}.`, + ); +} diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 0000000000..3f1789a4e6 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,637 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { assertReleaseNpm } from './assert-release-npm.mjs'; +import { + assertNoDanglingSymlinks, + assertOfficialNodeArchive, + collectPackagedProductionDependencies, + collectWorkspaceDependencyClosure, + dependencyPatchPackageName, + inspectNativeArtifacts, + isMacosArm64MachO, + listApplicableDependencyPatchNames, + listDependencyPatchNames, + macosArm64CliWrapper, + resolveCliWorkspacePackages, + stageWorkspacePackages, + workspaceReleaseFiles, +} from './package-macos-arm64-cli.mjs'; +import { releaseToolchainFromManifest, resolveReleaseIdentity } from './release-identity.mjs'; +import { prepareReleaseNpm, releaseNpmPaths } from './prepare-release-npm.mjs'; +import { + assertCliThirdPartyNotices, + assertExpectedTuiExit, + assertSafeCliArchiveEntries, + isTuiReadyOutput, + resolvePackagedRuntimeModelFactory, +} from './verify-macos-arm64-cli.mjs'; + +const execFileAsync = promisify(execFile); + +const rootManifest = { + version: '1.2.3', + packageManager: 'npm@11.12.1', + releaseToolchain: { + node: '24.18.1', + nodeDarwinArm64Archive: 'node-v24.18.1-darwin-arm64.tar.xz', + nodeDarwinArm64Sha256: '1'.repeat(64), + }, +}; +const desktopManifest = { version: '1.2.3' }; +const cliManifest = { version: '1.2.3', bin: { maka: './dist/cli.js' } }; + +test('release identity is rooted in one version, command, tag, and source commit', () => { + const identity = resolveReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + 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.cliArchive, 'apps/desktop/release/Maka-1.2.3-cli-mac-arm64.zip'); + assert.equal( + identity.nodeSourceUrl, + 'https://nodejs.org/download/release/v24.18.1/node-v24.18.1-darwin-arm64.tar.xz', + ); +}); + +test('release identity fails before packaging on version, command, ref, or SHA drift', () => { + const resolve = (overrides = {}) => + resolveReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + ref: 'refs/heads/main', + sha: 'a'.repeat(40), + ...overrides, + }); + assert.throws(() => resolve({ desktopManifest: { version: '1.2.4' } }), /Desktop version/); + assert.throws( + () => + resolve({ + cliManifest: { + ...cliManifest, + bin: { maka: './dist/cli.js', 'maka-agent': './dist/cli.js' }, + }, + }), + /only public CLI command/, + ); + assert.throws(() => resolve({ ref: 'refs/heads/feature' }), /refs\/heads\/main/); + assert.throws(() => resolve({ sha: 'not-a-sha' }), /40-character/); +}); + +test('release toolchain pins the official archive and npm independently', () => { + assert.deepEqual(releaseToolchainFromManifest(rootManifest), { + 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', + nodeVersion: '24.18.1', + npmVersion: '11.12.1', + }); + assert.throws( + () => releaseToolchainFromManifest({ ...rootManifest, packageManager: 'npm@latest' }), + /exact npm version/, + ); + assert.throws( + () => + releaseToolchainFromManifest({ + ...rootManifest, + releaseToolchain: { ...rootManifest.releaseToolchain, nodeDarwinArm64Sha256: 'bad' }, + }), + /exact SHA-256/, + ); +}); + +test('release npm bootstrap overrides a different bundled npm for nested scripts', async (t) => { + if (process.platform === 'win32') { + t.skip('The behavioral PATH fixture uses a POSIX executable shim.'); + return; + } + const root = await mkdtemp(join(tmpdir(), 'maka-release-npm-')); + try { + const fixtureRoot = join(root, 'fixture-npm'); + const fixtureBin = join(fixtureRoot, 'bin'); + const installPrefix = join(root, 'prepared'); + const bundledBin = join(root, 'bundled-bin'); + const probeRoot = join(root, 'probe'); + const githubPath = join(root, 'github-path'); + await Promise.all([ + mkdir(fixtureBin, { recursive: true }), + mkdir(bundledBin, { recursive: true }), + mkdir(probeRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(fixtureRoot, 'package.json'), + `${JSON.stringify({ + name: 'npm', + version: rootManifest.packageManager.slice('npm@'.length), + type: 'module', + bin: { npm: 'bin/npm-cli.js' }, + })}\n`, + ), + writeFile( + join(fixtureBin, 'npm-cli.js'), + `#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const args = process.argv.slice(2); +if (args.length === 1 && args[0] === '--version') { + console.log(${JSON.stringify(rootManifest.packageManager.slice('npm@'.length))}); + process.exit(0); +} +const prefixIndex = args.indexOf('--prefix'); +const runIndex = args.indexOf('run'); +if (prefixIndex < 0 || runIndex < 0 || !args[runIndex + 1]) process.exit(2); +const cwd = args[prefixIndex + 1]; +const manifest = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); +const result = spawnSync(manifest.scripts[args[runIndex + 1]], { + cwd, + env: process.env, + shell: true, + encoding: 'utf8', +}); +process.stdout.write(result.stdout ?? ''); +process.stderr.write(result.stderr ?? ''); +process.exit(result.status ?? 1); +`, + ), + writeFile(join(bundledBin, 'npm'), '#!/bin/sh\nprintf "11.16.0\\n"\n'), + writeFile( + join(probeRoot, 'package.json'), + `${JSON.stringify({ + name: 'nested-release-npm-probe', + private: true, + scripts: { probe: 'npm --version' }, + })}\n`, + ), + ]); + await Promise.all([ + chmod(join(fixtureBin, 'npm-cli.js'), 0o755), + chmod(join(bundledBin, 'npm'), 0o755), + ]); + + assert.ok(process.env.npm_execpath, 'release tests must be launched through npm'); + const preparationEnvironment = { ...process.env, GITHUB_PATH: githubPath }; + const prepared = await prepareReleaseNpm({ + rootManifest, + env: preparationEnvironment, + prefix: installPrefix, + packageSpec: fixtureRoot, + install: (_command, args, options) => + execFileAsync(process.execPath, [process.env.npm_execpath, ...args], options), + }); + assert.deepEqual(prepared, { + ...releaseNpmPaths(installPrefix), + npmVersion: '11.12.1', + }); + assert.equal((await readFile(githubPath, 'utf8')).trim(), prepared.binDirectory); + + const mismatchedEnvironment = { + ...process.env, + PATH: `${bundledBin}${delimiter}${process.env.PATH}`, + npm_config_user_agent: 'npm/11.12.1 node/v24.18.1 darwin arm64', + }; + await assert.rejects( + assertReleaseNpm({ rootManifest, env: mismatchedEnvironment }), + /nested npm 11\.12\.1, found 11\.16\.0/, + ); + + const pinnedEnvironment = { + ...mismatchedEnvironment, + PATH: `${prepared.binDirectory}${delimiter}${mismatchedEnvironment.PATH}`, + }; + await assert.doesNotReject(assertReleaseNpm({ rootManifest, env: pinnedEnvironment })); + const nested = await execFileAsync( + process.execPath, + [prepared.npmCliPath, '--prefix', probeRoot, 'run', 'probe'], + { env: pinnedEnvironment }, + ); + assert.equal(nested.stdout.trim(), '11.12.1'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('every npm-using release job prepares and verifies the pin before release work', async () => { + const workflow = await readFile( + new URL('../.github/workflows/release.yml', import.meta.url), + 'utf8', + ); + assert.doesNotMatch(workflow, /npx --yes/u); + const jobs = ['release-identity', 'desktop', 'cli-macos-arm64', 'source']; + for (const [index, job] of jobs.entries()) { + const start = workflow.indexOf(` ${job}:`); + assert.ok(start >= 0, `${job} must remain in the product release workflow`); + const nextStarts = jobs + .slice(index + 1) + .map((name) => workflow.indexOf(` ${name}:`, start + 1)) + .filter((position) => position >= 0); + const publish = workflow.indexOf(' publish:', start + 1); + const end = Math.min( + ...[...nextStarts, publish, workflow.length].filter((value) => value >= 0), + ); + const section = workflow.slice(start, end); + const prepare = section.indexOf('run: node scripts/prepare-release-npm.mjs'); + const verify = section.indexOf('run: npm run check:release-npm'); + assert.ok(prepare >= 0, `${job} must prepare the pinned npm`); + assert.ok(verify > prepare, `${job} must verify the pinned npm after preparation`); + const directNpmRuns = [...section.matchAll(/^\s+run: (npm .+)$/gmu)].map((match) => match[1]); + assert.equal(directNpmRuns[0], 'npm run check:release-npm', `${job} npm entry order`); + } +}); + +test('release npm bootstrap reaches npm.cmd through the Windows shell contract', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-npm-windows-')); + try { + const githubPath = join(root, 'github-path'); + let installOptions; + let inspectOptions; + await prepareReleaseNpm({ + rootManifest, + env: { ...process.env, GITHUB_PATH: githubPath }, + prefix: join(root, 'prepared'), + platform: 'win32', + install: async (_command, _args, options) => { + installOptions = options; + }, + inspect: async () => ({ stdout: '11.12.1\n' }), + }); + assert.equal(installOptions.shell, true); + await assertReleaseNpm({ + rootManifest, + env: { + ...process.env, + npm_config_user_agent: 'npm/11.12.1 node/v24.18.1 win32 x64', + }, + platform: 'win32', + inspect: async (_command, _args, options) => { + inspectOptions = options; + return { stdout: '11.12.1\n' }; + }, + }); + assert.equal(inspectOptions.shell, true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('workspace closure follows manifests and rejects missing local packages', () => { + const manifests = new Map([ + ['maka-agent', { dependencies: { '@maka/core': '0.1.0', thirdParty: '1.0.0' } }], + ['@maka/core', { dependencies: { '@maka/storage': '0.1.0' } }], + ['@maka/storage', { dependencies: {} }], + ['@maka/unrelated', { dependencies: {} }], + ]); + assert.deepEqual(collectWorkspaceDependencyClosure('maka-agent', manifests), [ + '@maka/core', + '@maka/storage', + 'maka-agent', + ]); + manifests.get('@maka/core').dependencies['@maka/missing'] = '0.1.0'; + assert.throws( + () => collectWorkspaceDependencyClosure('maka-agent', manifests), + /not in workspaces/, + ); +}); + +test('workspace release declarations reject ambiguous or unsafe paths', () => { + const releaseFiles = (declared) => + workspaceReleaseFiles({ name: '@maka/example', releaseFiles: declared }); + assert.throws(() => releaseFiles(null), /must be non-empty/); + assert.throws(() => releaseFiles(['harbor/run_trial.py']), /must include dist/); + assert.throws(() => releaseFiles(['dist', '../secret']), /unsafe release file/); + assert.throws(() => releaseFiles(['dist', '/tmp/secret']), /unsafe release file/); + assert.throws(() => releaseFiles(['dist', 'harbor/*.py']), /unsafe release file/); + assert.throws(() => releaseFiles(['dist', 'harbor\\run_trial.py']), /unsafe release file/); + assert.throws(() => releaseFiles(['dist', 'dist']), /contain duplicates/); + assert.throws(() => releaseFiles(['dist', 'dist/cli.js']), /overlap/); +}); + +async function listRelativeFiles(directory, root = directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...(await listRelativeFiles(path, root))); + else if (entry.isFile()) files.push(path.slice(root.length + 1).replaceAll('\\', '/')); + } + return files.sort(); +} + +function isPrunedDistFile(path) { + const segments = path.split('/'); + return ( + segments.includes('__tests__') || + /\.(?:test|spec)\.(?:[cm]?js|d\.ts|[cm]?js\.map)$/u.test(segments.at(-1)) + ); +} + +async function assertStagedFile(source, staged, label) { + const [sourceStat, stagedStat, sourceContent, stagedContent] = await Promise.all([ + stat(source), + stat(staged), + readFile(source), + readFile(staged), + ]); + assert.equal(stagedStat.isFile(), true, label); + assert.equal(stagedStat.mode & 0o111, sourceStat.mode & 0o111, label); + assert.deepEqual(stagedContent, sourceContent, label); +} + +test('workspace release declarations are copied exactly into CLI staging', async () => { + const installRoot = await mkdtemp(join(tmpdir(), 'maka-release-workspaces-')); + try { + const workspacePackages = await resolveCliWorkspacePackages(); + await stageWorkspacePackages(installRoot, workspacePackages); + + for (const { directory, manifest, workspacePath } of workspacePackages) { + for (const releaseFile of workspaceReleaseFiles(manifest)) { + const source = join(directory, ...releaseFile.split('/')); + const staged = join(installRoot, workspacePath, ...releaseFile.split('/')); + const sourceStat = await stat(source); + if (sourceStat.isFile()) { + await assertStagedFile(source, staged, `${manifest.name}:${releaseFile}`); + continue; + } + assert.equal(sourceStat.isDirectory(), true, `${manifest.name}:${releaseFile}`); + const sourceFiles = (await listRelativeFiles(source)).filter( + (path) => releaseFile !== 'dist' || !isPrunedDistFile(path), + ); + const stagedFiles = await listRelativeFiles(staged); + assert.deepEqual(stagedFiles, sourceFiles, `${manifest.name}:${releaseFile}`); + for (const path of sourceFiles) { + await assertStagedFile( + join(source, ...path.split('/')), + join(staged, ...path.split('/')), + `${manifest.name}:${releaseFile}/${path}`, + ); + } + } + } + + const evalPackage = workspacePackages.find(({ name }) => name === '@maka/eval'); + assert.ok(evalPackage, '@maka/eval must declare its release files'); + assert.equal(Object.hasOwn(evalPackage.manifest, 'releaseFiles'), true); + const expectedHarborFiles = workspaceReleaseFiles(evalPackage.manifest) + .filter((path) => path.startsWith('harbor/')) + .map((path) => path.slice('harbor/'.length)) + .sort(); + const stagedHarbor = join(installRoot, evalPackage.workspacePath, 'harbor'); + assert.deepEqual(await listRelativeFiles(stagedHarbor), expectedHarborFiles); + assert.equal( + expectedHarborFiles.some((path) => /^test_.*\.py$/u.test(path)), + false, + ); + } finally { + await rm(installRoot, { recursive: true, force: true }); + } +}); + +test('the only launcher remains relocatable through an external symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-launcher-')); + try { + const archiveRoot = join(root, 'Maka-1.2.3-cli-mac-arm64'); + const binDirectory = join(archiveRoot, 'bin'); + const nodeDirectory = join(archiveRoot, 'libexec', 'node', 'bin'); + const externalDirectory = join(root, 'external'); + await Promise.all([ + mkdir(binDirectory, { recursive: true }), + mkdir(nodeDirectory, { recursive: true }), + mkdir(externalDirectory), + ]); + const launcher = join(binDirectory, 'maka'); + const fakeNode = join(nodeDirectory, 'node'); + await Promise.all([ + writeFile(launcher, macosArm64CliWrapper(), 'utf8'), + writeFile(fakeNode, '#!/bin/sh\nprintf "%s\\n" "$1|$2"\n', 'utf8'), + ]); + await Promise.all([chmod(launcher, 0o755), chmod(fakeNode, 0o755)]); + const externalLauncher = join(externalDirectory, 'maka'); + await symlink(launcher, externalLauncher); + const result = await execFileAsync(externalLauncher, ['--version']); + assert.match( + result.stdout.trim(), + /Maka-1\.2\.3-cli-mac-arm64\/bin\/\.\.\/libexec\/node_modules\/maka-agent\/dist\/cli\.js\|--version$/, + ); + assert.doesNotMatch(result.stdout, /external\/\.\.\/libexec/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('artifact symlinks must resolve inside the artifact and cannot dangle', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-symlinks-')); + const outside = await mkdtemp(join(tmpdir(), 'maka-release-outside-')); + try { + await mkdir(join(root, 'target')); + await symlink('target', join(root, 'valid')); + await assertNoDanglingSymlinks(root); + await symlink(outside, join(root, 'escape')); + await assert.rejects(assertNoDanglingSymlinks(root), /escapes the CLI artifact/); + await rm(join(root, 'escape')); + await symlink('missing', join(root, 'dangling')); + await assert.rejects(assertNoDanglingSymlinks(root), /Dangling symlink/); + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } +}); + +test('streaming smoke test resolves the exported runtime model-factory subpath', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-runtime-')); + try { + const cliRoot = join(root, 'libexec', 'node_modules', 'maka-agent'); + const runtimeRoot = join(root, 'libexec', 'node_modules', '@maka', 'runtime'); + await mkdir(join(runtimeRoot, 'dist'), { recursive: true }); + await mkdir(cliRoot, { recursive: true }); + await writeFile(join(cliRoot, 'package.json'), '{}\n'); + await writeFile( + join(runtimeRoot, 'package.json'), + `${JSON.stringify({ + name: '@maka/runtime', + type: 'module', + exports: { './model-factory': './dist/model-factory.js' }, + })}\n`, + ); + await writeFile( + join(runtimeRoot, 'dist', 'model-factory.js'), + 'export const getAIModel = () => {};\n', + ); + + assert.equal( + resolvePackagedRuntimeModelFactory(root), + await realpath(join(runtimeRoot, 'dist', 'model-factory.js')), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Node provenance and CLI notices are bound to exact digests and closures', async () => { + await assert.doesNotReject( + assertOfficialNodeArchive( + '/tmp/node-v24.18.1-darwin-arm64.tar.xz', + { + nodeArchive: 'node-v24.18.1-darwin-arm64.tar.xz', + nodeArchiveSha256: 'a'.repeat(64), + }, + { hashFile: async () => 'a'.repeat(64) }, + ), + ); + await assert.rejects( + assertOfficialNodeArchive( + '/tmp/node-v24.18.1-darwin-arm64.tar.xz', + { + nodeArchive: 'node-v24.18.1-darwin-arm64.tar.xz', + nodeArchiveSha256: 'a'.repeat(64), + }, + { hashFile: async () => 'b'.repeat(64) }, + ), + /digest mismatch/, + ); + const notice = 'Package: alpha@1.0.0\n\nPackage: beta@2.0.0\n'; + assert.doesNotThrow(() => + assertCliThirdPartyNotices( + notice, + { productionDependencies: ['alpha@1.0.0', 'beta@2.0.0'], thirdPartyNoticesSha256: 'digest' }, + 'digest', + ), + ); + assert.throws( + () => + assertCliThirdPartyNotices( + notice, + { productionDependencies: ['alpha@1.0.0'], thirdPartyNoticesSha256: 'digest' }, + 'digest', + ), + /packaged production closure/, + ); +}); + +test('packaged dependency metadata follows manifests actually present on disk', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-packages-')); + try { + await Promise.all([ + mkdir(join(root, 'alpha'), { recursive: true }), + mkdir(join(root, '@scope', 'beta', 'node_modules', 'nested'), { recursive: true }), + mkdir(join(root, 'maka-agent'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'alpha', 'package.json'), '{"name":"alpha","version":"1.0.0"}'), + writeFile( + join(root, '@scope', 'beta', 'package.json'), + '{"name":"@scope/beta","version":"2.0.0"}', + ), + writeFile( + join(root, '@scope', 'beta', 'node_modules', 'nested', 'package.json'), + '{"name":"nested","version":"3.0.0"}', + ), + writeFile( + join(root, 'maka-agent', 'package.json'), + '{"name":"maka-agent","version":"1.2.3"}', + ), + writeFile(join(root, '.package-lock.json'), '{}'), + ]); + assert.deepEqual(await collectPackagedProductionDependencies(root, new Set(['maka-agent'])), [ + '@scope/beta@2.0.0', + 'alpha@1.0.0', + 'nested@3.0.0', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('dependency patches are derived from packages present in the CLI closure', async () => { + const patchNames = await listDependencyPatchNames(); + const providerUtilsPatch = patchNames.find((name) => name.startsWith('@ai-sdk+provider-utils+')); + const astryxCorePatch = patchNames.find((name) => name.startsWith('@astryxdesign+core+')); + assert.ok(providerUtilsPatch); + assert.ok(astryxCorePatch); + assert.equal(await dependencyPatchPackageName(providerUtilsPatch), '@ai-sdk/provider-utils'); + assert.equal(await dependencyPatchPackageName(astryxCorePatch), '@astryxdesign/core'); + const root = await mkdtemp(join(tmpdir(), 'maka-release-patches-')); + try { + await mkdir(join(root, '@ai-sdk', 'provider-utils'), { recursive: true }); + assert.deepEqual(await listApplicableDependencyPatchNames(root), [providerUtilsPatch]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('native artifact inspection discovers Mach-O and foreign payloads by content', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-native-')); + try { + await Promise.all([ + writeFile(join(root, 'arm.node'), ''), + writeFile(join(root, 'linux.node'), ''), + writeFile(join(root, 'source.js'), ''), + ]); + const result = await inspectNativeArtifacts(root, { + concurrency: 2, + inspect: async (_command, args) => { + if (args[1].endsWith('arm.node')) return { stdout: 'Mach-O 64-bit bundle arm64\n' }; + if (args[1].endsWith('linux.node')) return { stdout: 'ELF 64-bit LSB shared object\n' }; + return { stdout: 'ASCII text\n' }; + }, + }); + assert.deepEqual(result.machOBinaries, [join(root, 'arm.node')]); + assert.deepEqual(result.foreignBinaries, [join(root, 'linux.node')]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Mach-O acceptance binds both CPU architecture and target platform', () => { + assert.equal(isMacosArm64MachO('arm64\n', ' platform MACOS\n'), true); + assert.equal(isMacosArm64MachO('x86_64\n', ' platform MACOS\n'), false); + assert.equal(isMacosArm64MachO('arm64\n', ' platform IOS\n'), false); +}); + +test('archive and TUI verification rejects traversal and false readiness', () => { + assert.doesNotThrow(() => + assertSafeCliArchiveEntries( + ['Maka-1-cli-mac-arm64/', 'Maka-1-cli-mac-arm64/bin/maka'], + 'Maka-1-cli-mac-arm64', + ), + ); + assert.throws( + () => assertSafeCliArchiveEntries(['../escape'], 'Maka-1-cli-mac-arm64'), + /Unsafe CLI archive entry/, + ); + assert.equal(isTuiReadyOutput('Error loading /tmp/Maka-1-cli-mac-arm64/addon.node'), false); + assert.equal(isTuiReadyOutput('陪你把事做完'), true); + assert.throws( + () => + assertExpectedTuiExit({ + ready: true, + stopRequested: true, + exitCode: 1, + signal: 0, + output: '陪你把事做完\ncrash', + }), + /crashed after startup/, + ); +}); diff --git a/scripts/verify-macos-arm64-cli.mjs b/scripts/verify-macos-arm64-cli.mjs new file mode 100644 index 0000000000..7a5e12fd20 --- /dev/null +++ b/scripts/verify-macos-arm64-cli.mjs @@ -0,0 +1,723 @@ +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 } from 'node:util'; +import { + assertMacosArm64CliHost, + assertNoDanglingSymlinks, + inspectNativeArtifacts, + isMacosArm64MachO, + listApplicableDependencyPatchNames, + resolveCliWorkspacePackages, + resolveMacosArm64CliArtifactPaths, +} from './package-macos-arm64-cli.mjs'; +import { releaseToolchainFromManifest, resolveReleaseIdentity } from './release-identity.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const tuiReadyPattern = /陪你把事做完|配置模型提供商/; + +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(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'); + const workspaceRoot = join( + environment.HOME, + 'Library', + 'Application Support', + 'Maka', + 'workspaces', + 'default', + ); + await mkdir(workspaceRoot, { recursive: true }); + const storageEntry = requireFromCli.resolve('@maka/storage'); + const { createConnectionStore } = await import(pathToFileURL(storageEntry).href); + await createConnectionStore(workspaceRoot).create({ + slug: 'release-smoke-local', + name: 'Release smoke local', + providerType: 'ollama', + defaultModel: 'release-smoke-model', + }); + + 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 = resolveReleaseIdentity({ + 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]); + 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]); + 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}`); +}