diff --git a/.asf.yaml b/.asf.yaml index 1d201e36be..1e25bedc57 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -63,20 +63,42 @@ github: contexts: - test + rulesets: + - name: Immutable release tags + type: tag + branches: + includes: + - "v*" + excludes: [] + restrict_deletion: true + restrict_force_push: true + environments: - # release holds the Apple notarization and code signing credentials used by - # .github/workflows/release-desktop.yml. That workflow's own branch guard - # sits inside the file it is meant to protect, so this policy is the only - # boundary GitHub enforces from outside the workflow. - # - # Naming an environment here replaces its settings wholesale, so restate - # its full configuration when adding one. npm-release carries a required - # reviewer and a branch policy that live only on GitHub. + # These environments are the external human gates around signing secrets + # and npm OIDC. Naming an environment here replaces its settings wholesale, + # so keep every protection rule in this declarative authority. release: + required_reviewers: + - id: M4n5ter + type: User + wait_timer: 0 + prevent_self_review: true + deployment_branch_policy: + protected_branches: false + policies: + - name: "v*-incubating-rc*" + type: tag + npm-release: + required_reviewers: + - id: M4n5ter + type: User + wait_timer: 0 + prevent_self_review: true deployment_branch_policy: + protected_branches: false policies: - - name: main - type: branch + - name: "v*" + type: tag notifications: commits: commits@maka.apache.org diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 3496be3b9d..426a6e6f43 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -1,10 +1,27 @@ -# 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 convenience-artifact release entry point. Desktop, CLI/TUI, and +bundled Git source materials are built from the exact IPMC-approved ASF source candidate commit. +They share that source commit, the root product version, one convenience tag, one GitHub Release, +one Draft decision, and one release gate. The workflow creates no Draft until every required +artifact job succeeds. + +Phase 1 requires: + +- 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. + +The first product release also requires the exact `maka-agent@` npm package. The product +tag and Draft must exist before npm staging, but the Draft must remain unpublished until npm is +public, Finalize has verified it, and Desktop acceptance has exercised remote Runtime Host setup. ## One-time repository setup -Create a 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 +29,113 @@ 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. +The checked-in release configuration pins Maka's Apple Team ID to `FABM2QUA8Q`. Confirm every +replacement `CSC_LINK` belongs to that team before changing credentials; changing the pinned Team +ID requires its own reviewed product-release change. + +Windows remains unsigned until an Authenticode policy and certificate are added. Release secrets +must never be exposed to fork or ordinary pull-request jobs. + +Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: + +- the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; +- the `release` and `npm-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; +- enable immutable releases so assets and the associated tag cannot change after publication. + +These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. + +## Create the complete Draft + +1. Confirm the podling and Incubator PMC votes have both passed for one immutable source candidate. + Record both result URLs and independently verify its signed annotated + `v-incubating-rc` tag. +2. Confirm that tag resolves to a commit on `main`, required CI is green for that exact commit, and + root `package.json` contains a product version that has never been released. +3. Confirm `apps/desktop/package.json` and `packages/cli/package.json` exactly match the root + version, and the CLI manifest exposes only the `maka` command. +4. Dispatch `Release` from the exact approved candidate tag and supply the same tag as + `source_reference_tag`. A rerun must use that same tag; never select current `main` instead. +5. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, `source`, and + `publish` pass. A skipped or failed required job must prevent Draft creation. +6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source + reference in its notes, is marked as a GitHub prerelease exactly when the product version is a + prerelease, is not marked Latest while it remains a Draft, and contains exactly the manifest + reported by `node scripts/product-release-artifacts.mjs list`. The manifest covers both Desktop + platforms and update metadata, the standalone CLI/TUI, bundled source, and their required checksums. +7. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, + `THIRD_PARTY_NOTICES.txt`, the pinned Node license, and no `bin/maka-agent`. +8. Confirm `RELEASE.json` records the Draft's product version and source SHA, Apple Team ID + `FABM2QUA8Q`, the official Node + URL/archive/digest, npm version, workspace and production dependency closures, dependency + patches, Mach-O inventory, and `developer-id-notarized` signing state. Its final Node + entitlements must retain the required hardened-runtime capabilities and omit + `com.apple.security.get-task-allow`, as required by Apple's + [notarization guidance](https://developer.apple.com/documentation/security/resolving-common-notarization-issues). +9. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json`, `README.txt`, + all manifest archives, and the expected Dugite native release are present. + +If the publish job created the product tag or Draft but failed before every asset was uploaded, +rerun `Release` from the same approved ASF source candidate tag with the same +`source_reference_tag` input. Existing Draft assets must exactly +match the newly verified bytes; the retry keeps matching assets and uploads only missing ones. If an +asset conflicts or is unexpected, inspect and remove it manually while the Release is still a Draft, +then rerun. If only the tag exists, the retry creates the missing Draft. + +## Publish and verify the npm channel + +Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: + +1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. +2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. +3. Run **Finalize CLI npm channel** from `main` and confirm it verifies the public package bytes, + provenance, signature, and release dist-tag. +4. Install the exact public version on each release platform and complete the npm acceptance steps. -## Create the draft +Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate +requires a new product version; never publish the Draft to work around npm state. -1. Confirm the intended commit is on `main`, CI is green, `apps/desktop/package.json` contains a version that has never been released, and the exact `maka-agent` version in `packages/cli/package.json` is public on npm. -2. In GitHub Actions, run `Release desktop` against `main`. -3. Confirm every workflow step passes on both platforms and a draft release named `v` exists. -4. Confirm the draft records the intended commit SHA and contains the macOS DMG, ZIP, `latest-mac.yml`, the Windows `.exe`, ZIP, `latest.yml`, the bundled Git source-materials archive, and matching `.sha256` files. -5. Extract the bundled Git source-materials archive. Confirm `SOURCE_MANIFEST.json` and `README.txt` are present, every manifest archive is present, and the manifest pins the expected Dugite native release. -6. Confirm the packaged applications contain `licenses/git/LICENSE.txt`, `NOTICE.txt`, and `SOURCE_OFFER.txt`. +When every npm and cross-machine acceptance check has passed, publish the Draft. Mark a stable +release as Latest at that final publication boundary; prereleases must remain non-Latest. ## Acceptance on another Apple Silicon Mac -Download the DMG 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`. + Keep the Mac online for this first Gatekeeper assessment: the notarized ZIP cannot carry a + stapled ticket, so macOS may retrieve it from Apple. +5. Create an external link, for example `ln -s "$PWD/bin/maka" /tmp/maka-release-acceptance`, and + confirm the linked command reports the same version and help output. +6. Start `bin/maka` with no arguments and confirm the TUI renders, accepts input, and exits cleanly. +7. Exercise one non-interactive `bin/maka run`, one deterministic `bin/maka eval run`, and one streaming + tool-call path against the packaged artifact. +8. Configure a Desktop model connection, send one prompt, and run one representative file-tool + task. Confirm the documented Computer Use limitation remains accurate. +9. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact + public `maka-agent@` package and the remote session completes one model turn. ## Acceptance on a Windows x64 machine -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, Windows Desktop ZIP, and both checksum files 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 both SHA-256 checksums in PowerShell. +2. Expand the ZIP and launch its Maka executable once to confirm the portable artifact starts. +3. Run the installer and confirm the expected unsigned-publisher SmartScreen flow. +4. Launch Maka from the Start menu, configure a model connection, send one prompt, and run one representative file-tool task. +5. Run one terminal task and confirm packaged `node-pty` behavior. +6. Confirm the documented Computer Use limitation remains accurate. +7. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact + public `maka-agent@` package and the remote session completes one model turn. -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. +Immediately before publication, reverify that the approved ASF candidate tag and convenience +`v` tag still resolve to the same recorded commit. Publish only after npm Finalize and both +independent-machine acceptance passes. If any required artifact, npm step, or +acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product +version, and run the full workflow again. Never replace an existing release identity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba5a4d79d4..9d89bfc736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: run: node --test --test-concurrency=1 scripts/protocol-epoch-check.test.mjs - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' with: node-version: '24' cache: npm @@ -100,7 +100,7 @@ jobs: fi - name: Install dependencies - if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm ci - name: Lint @@ -112,9 +112,13 @@ jobs: run: npm run format:check - name: Build - if: steps.plan.outputs.code == 'true' + if: steps.plan.outputs.code == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm run build + - name: Release contracts + if: steps.plan.outputs.release_contract == 'true' + run: npm run check:release + - name: Typecheck if: steps.plan.outputs.code == 'true' run: npm run typecheck diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index a66e8ecbf7..1f9a749afb 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -2,6 +2,12 @@ name: CLI package validation on: workflow_call: + inputs: + source_commit: + description: Exact product release commit to build; defaults to the triggering commit + required: false + type: string + default: '' outputs: release_candidate_artifact_id: description: Immutable artifact produced by the build job @@ -24,6 +30,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -77,6 +84,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -105,6 +113,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.source_commit || github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 825a8fe049..5ae0a20900 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -1,4 +1,4 @@ -name: Finalize CLI npm release +name: Finalize CLI npm channel on: workflow_dispatch: @@ -12,7 +12,7 @@ on: required: true type: string version: - description: Exact staged maka-agent version + description: Exact staged maka-agent product version required: true type: string @@ -26,27 +26,20 @@ concurrency: jobs: inspect: - name: Verify the public npm release + name: Verify the public npm channel runs-on: ubuntu-24.04 timeout-minutes: 20 - outputs: - dist_tag: ${{ steps.release.outputs.dist_tag }} - git_tag: ${{ steps.release.outputs.git_tag }} - public_release_artifact_id: ${{ steps.public-release.outputs.artifact-id }} - source_sha: ${{ steps.release.outputs.source_sha }} - tarball: ${{ steps.release.outputs.tarball }} - version: ${{ steps.release.outputs.version }} steps: - name: Require main env: RELEASE_REF: ${{ github.ref }} run: | if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + echo "CLI npm finalization must be dispatched from main; found $RELEASE_REF" >&2 exit 1 fi + - name: Load the exact stage workflow run - id: stage-run env: GH_TOKEN: ${{ github.token }} STAGE_RUN_ID: ${{ inputs.stage_run_id }} @@ -61,34 +54,22 @@ jobs: exit 1 fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" - node -e ' - const fs = require("node:fs"); - const run = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); - if ( - String(run.id) !== process.env.STAGE_RUN_ID || - String(run.run_attempt) !== process.env.STAGE_RUN_ATTEMPT || - run.path !== ".github/workflows/release-cli-stage.yml" || - run.event !== "workflow_dispatch" || - run.head_branch !== "main" || - run.conclusion !== "success" || - run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY - ) { - throw new Error("Stage run is not an exact successful main CLI stage attempt"); - } - if (!/^[0-9a-f]{40}$/.test(run.head_sha)) throw new Error("Stage run has no valid source SHA"); - fs.appendFileSync(process.env.GITHUB_OUTPUT, "source_sha=" + run.head_sha + "\n"); - ' "$RUNNER_TEMP/stage-run.json" + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} + fetch-depth: 0 persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false + - name: Select the release npm toolchain run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Download the exact staged candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -97,6 +78,7 @@ jobs: github-token: ${{ github.token }} repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} + - name: Verify the stage run and release record id: release env: @@ -107,11 +89,22 @@ jobs: "$RUNNER_TEMP/stage-run.json" \ "$EXPECTED_VERSION" \ "$GITHUB_OUTPUT" + + - name: Revalidate the product release authority + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} + PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} + run: | + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" + - name: Fetch and verify the public registry bytes run: | node scripts/release-cli-publication.mjs fetch-registry \ packages/cli/release \ "$RUNNER_TEMP/registry-release" + - name: Verify npm signatures and provenance run: | node scripts/release-cli-publication.mjs prepare-audit \ @@ -122,151 +115,12 @@ jobs: node "$GITHUB_WORKSPACE/scripts/release-cli-publication.mjs" validate-audit \ "$GITHUB_WORKSPACE/packages/cli/release" \ "$RUNNER_TEMP/signature-audit/audit.json" - - name: Preserve the verified public release - id: public-release + + - name: Preserve the verified public npm package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-public-release-${{ github.run_attempt }} + name: cli-public-package-${{ github.run_attempt }} path: ${{ runner.temp }}/registry-release if-no-files-found: error compression-level: 0 retention-days: 30 - - publish: - name: Create the GitHub CLI release - needs: inspect - runs-on: ubuntu-24.04 - timeout-minutes: 10 - environment: - name: npm-release - url: https://github.com/maka-agent/maka-agent/releases/tag/${{ needs.inspect.outputs.git_tag }} - permissions: - contents: write - steps: - - name: Check out the current release finalizer - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Download the verified public release - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - artifact-ids: ${{ needs.inspect.outputs.public_release_artifact_id }} - path: ${{ runner.temp }}/registry-release - - name: Create the Git tag and GitHub Release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_DIST_TAG: ${{ needs.inspect.outputs.dist_tag }} - RELEASE_DIRECTORY: ${{ runner.temp }}/registry-release - RELEASE_SHA: ${{ needs.inspect.outputs.source_sha }} - RELEASE_TAG: ${{ needs.inspect.outputs.git_tag }} - RELEASE_TARBALL_NAME: ${{ needs.inspect.outputs.tarball }} - RELEASE_VERSION: ${{ needs.inspect.outputs.version }} - run: | - tag_json="$RUNNER_TEMP/release-tag.json" - tag_ref="repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" - if ! gh api "$tag_ref" > "$tag_json" 2>/dev/null; then - if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$RELEASE_TAG" \ - -f sha="$RELEASE_SHA" > "$tag_json"; then - gh api "$tag_ref" > "$tag_json" - fi - fi - TAG_JSON="$tag_json" node -e ' - const fs = require("node:fs"); - const tag = JSON.parse(fs.readFileSync(process.env.TAG_JSON, "utf8")); - if ( - tag.ref !== "refs/tags/" + process.env.RELEASE_TAG || - tag.object?.type !== "commit" || - tag.object.sha !== process.env.RELEASE_SHA - ) { - throw new Error("Git tag does not point to the verified CLI release commit"); - } - ' - - release_flags=(--latest=false) - if [[ "$RELEASE_DIST_TAG" == "next" ]]; then - release_flags+=(--prerelease) - elif [[ "$RELEASE_DIST_TAG" == "latest" ]]; then - release_flags+=(--prerelease=false) - else - echo "Unsupported CLI release dist-tag: $RELEASE_DIST_TAG" >&2 - exit 1 - fi - - release_json="$RUNNER_TEMP/github-release.json" - release_assets=( - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME" - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.sha256" - "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.files.json" - "$RELEASE_DIRECTORY/release.json" - ) - - # The REST release-by-tag endpoint does not return drafts. Resolve both draft and - # published releases through gh, then use the immutable API URL for exact reads. - if ! release_api_url="$(gh release view "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json apiUrl \ - --jq .apiUrl 2>/dev/null)"; then - if ! gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md"; then - echo "GitHub Release creation did not confirm success; inspecting remote state" >&2 - fi - release_api_url="$(gh release view "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --json apiUrl \ - --jq .apiUrl)" - fi - if [[ ! "$release_api_url" =~ ^https://api\.github\.com/repos/$GITHUB_REPOSITORY/releases/[1-9][0-9]*$ ]]; then - echo "GitHub Release API URL is not bound to the expected repository: $release_api_url" >&2 - exit 1 - fi - gh api "$release_api_url" > "$release_json" - - release_draft="$(RELEASE_JSON="$release_json" node -e ' - const fs = require("node:fs"); - const release = JSON.parse(fs.readFileSync(process.env.RELEASE_JSON, "utf8")); - if (typeof release.draft !== "boolean") throw new Error("GitHub Release draft state is invalid"); - process.stdout.write(String(release.draft)); - ')" - if [[ "$release_draft" == "true" ]]; then - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft=true \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md" - gh release upload "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --clobber \ - "${release_assets[@]}" - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --draft=false \ - "${release_flags[@]}" \ - --title "Maka CLI $RELEASE_VERSION" \ - --notes-file "$RELEASE_DIRECTORY/release-notes.md" - fi - - gh api "$release_api_url" > "$release_json" - node scripts/release-cli-publication.mjs validate-github-release \ - "$RELEASE_DIRECTORY" \ - "$release_json" - - latest_json="$RUNNER_TEMP/latest-release.json" - if gh api "repos/$GITHUB_REPOSITORY/releases/latest" > "$latest_json" 2>/dev/null; then - LATEST_JSON="$latest_json" node -e ' - const fs = require("node:fs"); - const latest = JSON.parse(fs.readFileSync(process.env.LATEST_JSON, "utf8")); - if (latest.tag_name === process.env.RELEASE_TAG) { - throw new Error("CLI release must not become the repository GitHub Latest release"); - } - ' - fi diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 7e09cc1a45..c4f894a9e1 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -17,27 +17,57 @@ concurrency: jobs: authorize: - name: Require main + name: Resolve product release runs-on: ubuntu-24.04 timeout-minutes: 5 + outputs: + source_commit: ${{ steps.product.outputs.source_commit }} + product_tag: ${{ steps.product.outputs.product_tag }} steps: - - name: Reject non-main dispatches + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: v${{ inputs.version }} + fetch-depth: 0 + persist-credentials: false + - name: Require the exact product tag env: RELEASE_REF: ${{ github.ref }} + PRODUCT_TAG: v${{ inputs.version }} run: | - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then + echo "CLI releases must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 exit 1 fi + - name: Resolve the exact product release + id: product + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_TAG: v${{ inputs.version }} + run: | + source_commit="$(git rev-parse HEAD)" + test "$source_commit" = "$GITHUB_SHA" + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$source_commit" "$GITHUB_REPOSITORY" + echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" + echo "product_tag=$PRODUCT_TAG" >> "$GITHUB_OUTPUT" + + - name: Verify the product release identity + env: + GITHUB_SHA: ${{ steps.product.outputs.source_commit }} + EXPECTED_PRODUCT_VERSION: ${{ inputs.version }} + run: node scripts/product-release-identity.mjs + validate: name: Validate immutable candidate needs: authorize uses: ./.github/workflows/cli-package-validation.yml + with: + source_commit: ${{ needs.authorize.outputs.source_commit }} stage: name: Stage maka-agent on npm - needs: validate + needs: [authorize, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: @@ -49,6 +79,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.authorize.outputs.source_commit }} + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -66,6 +98,7 @@ jobs: id: release env: EXPECTED_VERSION: ${{ inputs.version }} + PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} @@ -75,6 +108,7 @@ jobs: node scripts/release-cli-publication.mjs prepare-stage \ packages/cli/release \ "$EXPECTED_VERSION" \ + "$PRODUCT_TAG" \ "$RELEASE_SHA" \ "$RELEASE_RUN_ID" \ "$RELEASE_RUN_ATTEMPT" \ @@ -104,7 +138,7 @@ jobs: echo "## maka-agent@$RELEASE_VERSION staging" echo echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." - echo "After the package becomes public, run **Finalize CLI npm release** with:" + echo "After the package becomes public, run **Finalize CLI npm channel** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" @@ -118,10 +152,16 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Submit the candidate to npm staging env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.authorize.outputs.source_commit }} + PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} - run: >- - npm stage publish "$RELEASE_TARBALL" - --tag "$RELEASE_DIST_TAG" - --registry https://registry.npmjs.org/ - --provenance + run: | + node scripts/product-release-authority.mjs verify-draft \ + "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" + + npm stage publish "$RELEASE_TARBALL" \ + --tag "$RELEASE_DIST_TAG" \ + --registry https://registry.npmjs.org/ \ + --provenance diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml deleted file mode 100644 index d0fc2fc248..0000000000 --- a/.github/workflows/release-desktop.yml +++ /dev/null @@ -1,290 +0,0 @@ -name: Release desktop - -on: - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: release-desktop - cancel-in-progress: false - -jobs: - build: - if: github.ref == 'refs/heads/main' - strategy: - # Both platforms ship from one dispatch, so a failure on one still leaves - # the other artifact available for diagnosis. - fail-fast: false - matrix: - include: - - platform: macos - runner: macos-15 - - platform: windows - runner: windows-2025 - runs-on: ${{ matrix.runner }} - environment: release - timeout-minutes: 60 - defaults: - run: - # Windows runners default to pwsh; the release steps are written once, - # in bash, for both platforms. - shell: bash - - steps: - - name: Check out the dispatched commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Verify Runtime Host setup package - run: | - requested_version="$(node -p "require('./packages/cli/package.json').version")" - setup_package="maka-agent@${requested_version}" - published_version="$(npm view "$setup_package" version)" - if [ "$published_version" != "$requested_version" ]; then - echo "Published package resolved to ${published_version}, expected ${requested_version}." >&2 - exit 1 - fi - echo "MAKA_RUNTIME_HOST_SETUP_PACKAGE=$setup_package" >> "$GITHUB_ENV" - - - name: Audit production dependencies - run: npm audit --omit=dev --audit-level=moderate - - - name: Audit shipped desktop closure - run: node scripts/audit-shipped-dependencies.mjs - - - name: Write App Store Connect API key - if: matrix.platform == 'macos' - env: - APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 - run: | - umask 077 - printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" - - - name: Resolve and reserve release identity - id: release - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - tag="v${version}" - dmg="apps/desktop/release/Maka-${version}-mac-arm64.dmg" - exe="apps/desktop/release/Maka-${version}-win-x64.exe" - - if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then - echo "Tag ${tag} already exists." >&2 - exit 1 - fi - if gh release view "$tag" >/dev/null 2>&1; then - echo "Release ${tag} already exists." >&2 - exit 1 - fi - - { - echo "version=${version}" - echo "tag=${tag}" - echo "dmg=${dmg}" - echo "exe=${exe}" - } >> "$GITHUB_OUTPUT" - - - name: Package notarized app and signed DMG - if: matrix.platform == 'macos' - env: - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} - APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - run: npm run package:macos-arm64 - - - name: Notarize and staple the signed final DMG - if: matrix.platform == 'macos' - env: - APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 - APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - DMG_PATH: ${{ steps.release.outputs.dmg }} - run: | - codesign --verify --verbose=4 "$DMG_PATH" - xcrun notarytool submit "$DMG_PATH" \ - --key "$APPLE_API_KEY" \ - --key-id "$APPLE_API_KEY_ID" \ - --issuer "$APPLE_API_ISSUER" \ - --wait - xcrun stapler staple "$DMG_PATH" - - codesign --verify --verbose=4 "$DMG_PATH" - xcrun stapler validate "$DMG_PATH" - spctl --assess \ - --type open \ - --context context:primary-signature \ - --verbose=4 \ - "$DMG_PATH" - - - name: Verify the final DMG - if: matrix.platform == 'macos' - run: npm run verify:macos-arm64 -- "${{ steps.release.outputs.dmg }}" - - # Windows has no Authenticode certificate yet, so this build is unsigned - # and there is nothing to notarize between packaging and verification. - - name: Package the Windows installer and ZIP - if: matrix.platform == 'windows' - run: npm run package:windows-x64 - - - name: Verify the Windows release - if: matrix.platform == 'windows' - run: npm run verify:windows-x64 -- "${{ steps.release.outputs.exe }}" - - - name: Download and verify the pinned Windows upgrade baseline - id: previous - if: matrix.platform == 'windows' - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \ - "$version" artifacts/windows-upgrade-baseline)" - echo "exe=$previous_exe" >> "$GITHUB_OUTPUT" - - - name: Exercise pinned Windows upgrade and uninstall - if: matrix.platform == 'windows' - run: | - npm run verify:windows-installer -- \ - "${{ steps.release.outputs.exe }}" \ - "${{ steps.previous.outputs.exe }}" - - - name: Build the version-bumped autoupdate installer - if: matrix.platform == 'windows' - run: npm run package:windows-autoupdate-next - - # The fake-versioned artifacts live outside apps/desktop/release, so the - # upload globs below can never pick them up. - - name: Verify automatic update end to end - if: matrix.platform == 'windows' - run: | - npm run verify:windows-autoupdate -- \ - "${{ steps.release.outputs.exe }}" \ - apps/desktop/release-autoupdate-next - - - name: Upload the verified release assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-${{ matrix.platform }} - path: | - apps/desktop/release/Maka-* - apps/desktop/release/latest*.yml - if-no-files-found: error - retention-days: 7 - - - name: Remove temporary release credentials - if: always() && matrix.platform == 'macos' - run: rm -f "${{ runner.temp }}/AuthKey_Maka.p8" - - source: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: - shell: bash - - steps: - - name: Check out the dispatched commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '24' - cache: npm - - - name: Install pinned dependency metadata - run: npm ci --ignore-scripts - - - name: Materialize bundled Git source materials - run: npm run prepare:bundled-git-source - - - name: Archive and hash source materials - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - archive_dir="apps/desktop/release-sources" - archive_name="Maka-${version}-bundled-git-source.tar.gz" - archive="${archive_dir}/${archive_name}" - tar -C apps/desktop/release-sources/bundled-git -czf "$archive" . - (cd "$archive_dir" && sha256sum "$archive_name" > "${archive_name}.sha256") - - - name: Upload verified source assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-source - path: | - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz - apps/desktop/release-sources/Maka-*-bundled-git-source.tar.gz.sha256 - if-no-files-found: error - retention-days: 7 - - publish: - # One draft release carries both platforms, so it is created once, after - # every platform has been packaged and verified. - needs: [build, source] - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - shell: bash - - steps: - - name: Check out the dispatched commit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - - name: Download the verified release assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: release-* - path: release-assets - merge-multiple: true - - - name: Create draft GitHub Release - env: - GH_TOKEN: ${{ github.token }} - run: | - version="$(node -p "require('./apps/desktop/package.json').version")" - tag="v${version}" - - notes="Apple Silicon macOS and Windows x64 release built from commit ${GITHUB_SHA}. - - Before publishing, download these draft assets on another Apple Silicon Mac and on a Windows x64 machine, and complete .github/RELEASE_CHECKLIST.md. - - The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. - - Bundled Git source materials and their checksum are attached to this draft. The packaged applications also carry the GPLv2 license and written source offer. - - Known limitation: Computer Use is not included in this release." - - gh release create "$tag" release-assets/* \ - --draft \ - --target "$GITHUB_SHA" \ - --title "Maka ${version}" \ - --notes "$notes" - - echo "Draft release ${tag} created from ${GITHUB_SHA}." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..c5b2ede9cf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,528 @@ +name: Release + +on: + workflow_dispatch: + inputs: + source_reference_tag: + description: Exact approved ASF source candidate tag (v-incubating-rc) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release-identity: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.identity.outputs.version }} + is_prerelease: ${{ steps.identity.outputs.is_prerelease }} + tag: ${{ steps.identity.outputs.tag }} + source_commit: ${{ steps.identity.outputs.source_commit }} + source_reference_tag: ${{ steps.identity.outputs.source_reference_tag }} + dmg: ${{ steps.identity.outputs.dmg }} + exe: ${{ steps.identity.outputs.exe }} + cli_archive: ${{ steps.identity.outputs.cli_archive }} + source_archive: ${{ steps.identity.outputs.source_archive }} + node_version: ${{ steps.identity.outputs.node_version }} + npm_version: ${{ steps.identity.outputs.npm_version }} + node_archive: ${{ steps.identity.outputs.node_archive }} + node_archive_sha256: ${{ steps.identity.outputs.node_archive_sha256 }} + node_source_url: ${{ steps.identity.outputs.node_source_url }} + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Resolve product release identity + id: identity + env: + GITHUB_SHA: ${{ github.sha }} + SOURCE_REFERENCE_TAG: ${{ inputs.source_reference_tag }} + run: node scripts/product-release-identity.mjs + + - name: Require the exact ASF source reference + env: + RELEASE_REF: ${{ github.ref }} + RELEASE_REPOSITORY: ${{ github.repository }} + SOURCE_COMMIT: ${{ steps.identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ steps.identity.outputs.source_reference_tag }} + run: | + if [[ "$RELEASE_REPOSITORY" != "apache/maka" ]]; then + echo "Product releases must run in apache/maka; found $RELEASE_REPOSITORY" >&2 + exit 1 + fi + if [[ "$RELEASE_REF" != "refs/tags/$SOURCE_REFERENCE_TAG" ]]; then + echo "Product releases must be dispatched from $SOURCE_REFERENCE_TAG; found $RELEASE_REF" >&2 + exit 1 + fi + git fetch --force --no-tags origin \ + "refs/tags/$SOURCE_REFERENCE_TAG:refs/tags/$SOURCE_REFERENCE_TAG" + test "$(git cat-file -t "refs/tags/$SOURCE_REFERENCE_TAG")" = tag + test "$(git rev-parse "refs/tags/$SOURCE_REFERENCE_TAG^{commit}")" = "$SOURCE_COMMIT" + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main + + desktop: + needs: release-identity + strategy: + # Both platforms ship from one dispatch, so a failure on one still leaves + # the other artifact available for diagnosis. + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + environment: release + timeout-minutes: 60 + defaults: + run: + # Windows runners default to pwsh; the release steps are written once, + # in bash, for both platforms. + shell: bash + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-identity.outputs.source_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + cache: npm + + - name: Select and verify the pinned npm release toolchain + run: | + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install dependencies with the pinned npm + run: npm ci + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=moderate + + - name: Audit shipped desktop closure + run: node scripts/audit-shipped-dependencies.mjs + + - name: Write App Store Connect API key + if: matrix.platform == 'macos' + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 + run: | + umask 077 + printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" + + - name: Package notarized app and signed DMG + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: npm run package:macos-arm64 + + - name: Notarize and staple the signed final DMG + if: matrix.platform == 'macos' + env: + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + DMG_PATH: apps/desktop/release/${{ needs.release-identity.outputs.dmg }} + run: | + codesign --verify --verbose=4 "$DMG_PATH" + xcrun notarytool submit "$DMG_PATH" \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "$DMG_PATH" + + codesign --verify --verbose=4 "$DMG_PATH" + xcrun stapler validate "$DMG_PATH" + spctl --assess \ + --type open \ + --context context:primary-signature \ + --verbose=4 \ + "$DMG_PATH" + + - name: Verify the final DMG + if: matrix.platform == 'macos' + run: npm run verify:macos-arm64 -- "apps/desktop/release/${{ needs.release-identity.outputs.dmg }}" + + # Windows has no Authenticode certificate yet, so this build is unsigned + # and there is nothing to notarize between packaging and verification. + - name: Package the Windows installer and ZIP + if: matrix.platform == 'windows' + run: npm run package:windows-x64 + + - name: Verify the Windows release + if: matrix.platform == 'windows' + run: npm run verify:windows-x64 -- "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" + + - name: Download and verify the pinned Windows upgrade baseline + id: previous + if: matrix.platform == 'windows' + env: + GH_TOKEN: ${{ github.token }} + run: | + previous_exe="$(node scripts/prepare-windows-upgrade-baseline.mjs \ + "${{ needs.release-identity.outputs.version }}" artifacts/windows-upgrade-baseline)" + echo "exe=$previous_exe" >> "$GITHUB_OUTPUT" + + - name: Exercise pinned Windows upgrade and uninstall + if: matrix.platform == 'windows' + run: | + npm run verify:windows-installer -- \ + "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ + "${{ steps.previous.outputs.exe }}" + + - name: Build the version-bumped autoupdate installer + if: matrix.platform == 'windows' + run: npm run package:windows-autoupdate-next + + - name: Verify automatic update end to end + if: matrix.platform == 'windows' + run: | + npm run verify:windows-autoupdate -- \ + "apps/desktop/release/${{ needs.release-identity.outputs.exe }}" \ + apps/desktop/release-autoupdate-next + + - name: Stage the exact product artifact group + run: >- + node scripts/product-release-artifacts.mjs stage + "desktop-${{ matrix.platform }}" + apps/desktop/release + "${{ runner.temp }}/release-assets" + + - name: Upload the verified release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-${{ matrix.platform }} + path: ${{ runner.temp }}/release-assets + 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 + runs-on: macos-15 + environment: release + timeout-minutes: 45 + defaults: + run: + shell: bash + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-identity.outputs.source_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + cache: npm + + - name: Select and verify the pinned npm release toolchain + run: | + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install dependencies with the pinned npm + run: npm ci + + - name: Audit production dependencies + run: npm audit --omit=dev --audit-level=moderate + + - name: Build the CLI production workspace closure + run: | + npm --workspace maka-agent run pretest + npm --workspace maka-agent run build + npm run check:cli-third-party-notices + + - name: Download and verify the official Node runtime + env: + NODE_ARCHIVE: ${{ runner.temp }}/${{ needs.release-identity.outputs.node_archive }} + NODE_ARCHIVE_SHA256: ${{ needs.release-identity.outputs.node_archive_sha256 }} + NODE_SOURCE_URL: ${{ needs.release-identity.outputs.node_source_url }} + run: | + curl --proto '=https' --tlsv1.2 --fail --location \ + --output "$NODE_ARCHIVE" "$NODE_SOURCE_URL" + actual="$(shasum -a 256 "$NODE_ARCHIVE" | awk '{print $1}')" + test "$actual" = "$NODE_ARCHIVE_SHA256" + + - name: Write App Store Connect API key + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 + run: | + umask 077 + printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" + + - name: Package signed and notarized CLI and TUI + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MAKA_CLI_NODE_ARCHIVE: ${{ runner.temp }}/${{ needs.release-identity.outputs.node_archive }} + MAKA_CLI_RELEASE_SIGNING: '1' + run: npm run package:cli:macos-arm64 + + - name: Verify the final CLI and TUI artifact + env: + MAKA_CLI_REQUIRE_RELEASE_SIGNING: '1' + run: >- + npm run verify:cli:macos-arm64 -- + "apps/desktop/release/${{ needs.release-identity.outputs.cli_archive }}" + + - name: Stage the exact product artifact group + run: >- + node scripts/product-release-artifacts.mjs stage + "cli-macos-arm64" + apps/desktop/release + "${{ runner.temp }}/release-assets" + + - name: Upload the verified CLI release assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-cli-macos-arm64 + path: ${{ runner.temp }}/release-assets + 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 + 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: ${{ needs.release-identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + cache: npm + + - name: Select and verify the pinned npm release toolchain + run: | + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install pinned dependency metadata + run: npm ci --ignore-scripts + + - name: Materialize bundled Git source materials + run: npm run prepare:bundled-git-source + + - name: Archive and hash source materials + env: + SOURCE_ARCHIVE: ${{ needs.release-identity.outputs.source_archive }} + run: | + archive_dir="apps/desktop/release-sources" + archive="${archive_dir}/${SOURCE_ARCHIVE}" + tar -C apps/desktop/release-sources/bundled-git -czf "$archive" . + (cd "$archive_dir" && sha256sum "$SOURCE_ARCHIVE" > "${SOURCE_ARCHIVE}.sha256") + + - name: Stage the exact product artifact group + run: >- + node scripts/product-release-artifacts.mjs stage + "source" + apps/desktop/release-sources + "${{ runner.temp }}/release-assets" + + - name: Upload verified source assets + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-source + path: ${{ runner.temp }}/release-assets + 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 + permissions: + contents: write + defaults: + run: + shell: bash + + steps: + - name: Check out the dispatched commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-identity.outputs.source_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Download the verified release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-* + path: release-assets + merge-multiple: true + + - name: Verify the exact product artifact manifest + run: | + node scripts/product-release-artifacts.mjs verify release-assets + while IFS= read -r -d '' checksum; do + (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") + done < <(find release-assets -type f -name '*.sha256' -print0) + + - name: Revalidate the live ASF source reference + env: + SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} + run: | + git fetch --force --no-tags origin \ + "refs/tags/$SOURCE_REFERENCE_TAG:refs/tags/$SOURCE_REFERENCE_TAG" + test "$(git cat-file -t "refs/tags/$SOURCE_REFERENCE_TAG")" = tag + test "$(git rev-parse "refs/tags/$SOURCE_REFERENCE_TAG^{commit}")" = "$SOURCE_COMMIT" + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$SOURCE_COMMIT" origin/main + + - name: Ensure the exact product tag + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release-identity.outputs.tag }} + SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} + run: | + gh auth setup-git + node scripts/product-release-tag.mjs ensure "$TAG" "$SOURCE_COMMIT" + + - name: Create or update the draft GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.release-identity.outputs.version }} + IS_PRERELEASE: ${{ needs.release-identity.outputs.is_prerelease }} + TAG: ${{ needs.release-identity.outputs.tag }} + SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} + run: | + notes="Maka Desktop and the required Apple Silicon CLI/TUI artifact built from approved ASF source reference ${SOURCE_REFERENCE_TAG} at commit ${SOURCE_COMMIT}. + + Before publishing, download these draft assets on 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." + + classification=(--prerelease=false --latest=false) + if [[ "$IS_PRERELEASE" == "true" ]]; then + classification=(--prerelease --latest=false) + elif [[ "$IS_PRERELEASE" != "false" ]]; then + echo "Product prerelease classification must be true or false; found $IS_PRERELEASE" >&2 + exit 1 + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)" + if [[ "$is_draft" != "true" ]]; then + echo "Release ${TAG} is already published and cannot be replaced." >&2 + exit 1 + fi + else + gh release create "$TAG" \ + --draft \ + --verify-tag \ + "${classification[@]}" \ + --title "Maka ${VERSION}" \ + --notes "$notes" + fi + + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" != "true" ]]; then + echo "Release ${TAG} was published while recovery was running." >&2 + exit 1 + fi + gh release edit "$TAG" \ + "${classification[@]}" \ + --title "Maka ${VERSION}" \ + --notes "$notes" + actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)" + if [[ "$actual_prerelease" != "$IS_PRERELEASE" ]]; then + echo "Release ${TAG} prerelease state is ${actual_prerelease}, expected ${IS_PRERELEASE}." >&2 + exit 1 + fi + asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" + declare -A expected_assets=() + declare -A existing_assets=() + mapfile -t local_assets < <(find release-assets -maxdepth 1 -type f -printf '%f\n' | sort) + for asset in "${local_assets[@]}"; do + expected_assets["$asset"]=1 + done + existing_dir="$RUNNER_TEMP/existing-release-assets" + mkdir -p "$existing_dir" + if [[ -n "$asset_names" ]]; then + while IFS= read -r asset; do + if [[ -z "${expected_assets[$asset]+present}" ]]; then + echo "Draft ${TAG} contains unexpected asset ${asset}; inspect and remove it manually before retrying." >&2 + exit 1 + fi + gh release download "$TAG" --pattern "$asset" --dir "$existing_dir" + if ! cmp -s "release-assets/$asset" "$existing_dir/$asset"; then + echo "Draft ${TAG} asset ${asset} differs from the verified artifact." >&2 + exit 1 + fi + existing_assets["$asset"]=1 + done <<< "$asset_names" + fi + missing_assets=() + for asset in "${local_assets[@]}"; do + if [[ -z "${existing_assets[$asset]+present}" ]]; then + missing_assets+=("release-assets/$asset") + fi + done + release_state="$(gh release view "$TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | map(tostring) | join(" ")')" + if [[ "$release_state" != "true $IS_PRERELEASE" ]]; then + echo "Release ${TAG} changed state while assets were checked: ${release_state}." >&2 + exit 1 + fi + if (( ${#missing_assets[@]} > 0 )); then + gh release upload "$TAG" "${missing_assets[@]}" + fi + + echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 25edb53737..24a451e100 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,19 +1,22 @@ -const runtimeHostSetupPackage = process.env.MAKA_RUNTIME_HOST_SETUP_PACKAGE?.trim(); -if ( - runtimeHostSetupPackage !== undefined && - !/^maka-agent@[0-9][0-9A-Za-z.+-]*$/u.test(runtimeHostSetupPackage) -) { - throw new Error('MAKA_RUNTIME_HOST_SETUP_PACKAGE must name an exact Maka CLI version'); +import { readFileSync } from 'node:fs'; +import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; + +function readManifest(relativePath) { + return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ + rootManifest: readManifest('../../package.json'), + desktopManifest: readManifest('./package.json'), + cliManifest: readManifest('../../packages/cli/package.json'), +}); + export default { appId: 'com.maka.desktop', productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, - ...(runtimeHostSetupPackage - ? { extraMetadata: { runtimeHostSetupPackage } } - : {}), + extraMetadata: { runtimeHostSetupPackage }, directories: { output: 'release', }, @@ -203,8 +206,8 @@ export default { publish: [ { provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', + owner: 'apache', + repo: 'maka', }, ], }; diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index eb31b8ed58..d98d4aa2a1 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -56,9 +56,11 @@ class FakeUpdater extends EventEmitter { quitAndInstallDispatchError = false; onQuitAndInstall: (() => void) | undefined; feed: unknown; + setFeedURLCalls = 0; checkResult: Promise | undefined; setFeedURL(input: unknown): void { + this.setFeedURLCalls += 1; this.feed = input; } @@ -134,12 +136,7 @@ describe('AppUpdateService', () => { assert.equal(updater.autoDownload, true); assert.equal(updater.autoInstallOnAppQuit, false); - assert.equal(updater.allowPrerelease, false); - assert.deepEqual(updater.feed, { - provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', - }); + assert.equal(updater.setFeedURLCalls, 0); service.start(); service.start(); @@ -153,12 +150,22 @@ describe('AppUpdateService', () => { assert.equal(clock.pending().length, 0); }); + test('preserves electron-updater channel policy derived from the app version', () => { + for (const allowPrerelease of [false, true]) { + const updater = new FakeUpdater(); + updater.allowPrerelease = allowPrerelease; + createHarness({ updater }); + assert.equal(updater.allowPrerelease, allowPrerelease); + } + }); + test('routes the feed to a loopback generic provider when the test override is set', () => { const { updater } = createHarness({ testFeedUrl: 'http://127.0.0.1:8443/feed' }); assert.deepEqual(updater.feed, { provider: 'generic', url: 'http://127.0.0.1:8443/feed', }); + assert.equal(updater.setFeedURLCalls, 1); }); test('rejects a non-loopback test feed instead of falling back to production', () => { diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index c14c7e1c09..1076283ab9 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -296,18 +296,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater.autoDownload = true; updater.autoInstallOnAppQuit = false; - updater.allowPrerelease = false; updater.logger = null; - // The override changes the feed URL and nothing else: every other updater - // setting and the whole status machine behave identically under it, so what - // the loopback harness verifies is what production runs. - updater.setFeedURL( - resolveUpdateFeedOverride(deps.testFeedUrl) ?? { - provider: 'github', - owner: 'Maka-Agent', - repo: 'maka-agent', - }, - ); + const testFeed = resolveUpdateFeedOverride(deps.testFeedUrl); + // Production reads electron-builder's packaged app-update.yml. Only the + // loopback harness replaces that single authority boundary. + if (testFeed) updater.setFeedURL(testFeed); updater.on('checking-for-update', () => { publish({ state: 'checking', currentVersion: deps.currentVersion }); diff --git a/docs/README.md b/docs/README.md index 5bad119f49..1c8d34a9da 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ This page is the authority map for Maka documentation. Code and contract tests r ### Release operations +- [CLI/TUI distribution contract](./cli-distribution.md) - [CLI npm release](./cli-npm-release.md) ([中文](./cli-npm-release.zh-CN.md)) ### Security and privacy diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md new file mode 100644 index 0000000000..2030ed52ea --- /dev/null +++ b/docs/cli-distribution.md @@ -0,0 +1,74 @@ +# CLI/TUI distribution contract + +Maka ships its CLI/TUI as a required artifact of the same product release as Desktop. Phase 1 +publishes one signed and notarized Apple Silicon artifact: + +`Maka--cli-mac-arm64.zip` + +The ZIP contains an exactly pinned official Node runtime and the production workspace/npm +dependency closure derived from repository manifests and `package-lock.json`. It does not use a +system Node installation or a single-file/SEA build. + +## Public contract + +Only these surfaces are stable: + +- `bin/maka`, including invocation through a symlink outside the extracted archive; +- the documented `RELEASE.json` fields below. + +`libexec/**` is private and may change between releases. There is no public `maka-agent` launcher. +The TUI is the default interactive mode of `maka`, not a separate artifact. + +`RELEASE.json` fields: + +| Field | Meaning | +| --- | --- | +| `schemaVersion` | Metadata schema version, initially `1` | +| `product` | Product name, `Maka` | +| `version` | Root `package.json` product version | +| `sourceCommit` | Exact source commit shared by every release artifact | +| `platform` / `architecture` | Artifact target, `macos` / `arm64` | +| `publicCommands` | Public command list; exactly `["maka"]` in Phase 1 | +| `node` | Official Node version, source URL, archive name, and archive SHA-256 | +| `npmVersion` | Exact npm version used to materialize the production closure | +| `dependencyPatches` | Sorted repository patches applied to the staged dependencies | +| `productionDependencies` | Sorted external `name@version` production closure | +| `thirdPartyNoticesSha256` | Digest binding notices to this artifact | +| `workspacePackages` | Sorted manifest-derived production workspace closure | +| `machOBinaries` | Sorted paths of every Mach-O file that must be signed and verified | +| `signing` | `developer-id-notarized` for release artifacts; `development` for local checks | + +The CLI-specific `THIRD_PARTY_NOTICES.txt` must enumerate exactly the external production +dependencies recorded in `RELEASE.json`. The archive also carries the repository's +`DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, and the pinned Node runtime license. The archive checksum is +generated only after signing and notarization complete. + +Every Mach-O file inside the archive is signed and the ZIP is submitted to Apple's notary service. +ZIP files cannot carry a stapled notarization ticket, so the first Gatekeeper assessment on another +Mac may require network access to retrieve the ticket from Apple. The embedded code signatures and +published SHA-256 remain available for offline verification; do not describe the ZIP itself as +stapled. + +## Release and installation boundary + +Root `package.json` is the sole version authority. Desktop and CLI manifests must match before +packaging. Desktop, CLI/TUI, and source jobs build independently from one commit; one publish job +collects their verified outputs and creates one Draft GitHub Release. + +The GitHub Release ZIP is the immutable standalone distribution source. npm keeps its +installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but may start only after +the product `v` tag and GitHub Release exist. It checks out that tag's exact commit and +derives the same version, runtime closure, file policy, notices, and source identity. It does not +create a tag or GitHub Release and does not block creation of the product Draft. Homebrew must +consume the standalone ZIP. + +## Decision ledger + +| Question | Decision | Enforced by | +| --- | --- | --- | +| Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | +| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | +| Which artifacts are required? | macOS and Windows Desktop installers and update assets, the macOS arm64 standalone CLI ZIP, and bundled source. | The exact manifest from `product-release-identity.mjs`, enforced by each artifact job and the publish job | +| Is npm another release authority? | No. It is an optional install channel whose Stage ref, source, workflow identity, and provenance all resolve to the existing product tag commit. | Tag-dispatched OIDC staging and read-only finalization; no npm-specific tag or GitHub Release | +| Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | +| Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 37964e6833..bb717f4a2a 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -2,17 +2,18 @@ [简体中文](./cli-npm-release.zh-CN.md) -This runbook is the operational authority for publishing `maka-agent`. The package version is -independent of the Desktop release line. Every public version must come from the exact tarball -validated by the Stage workflow. +This runbook is the operational authority for publishing the `maka-agent` npm installation channel. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from the exact tarball validated by the Stage workflow. ## Release invariants -- Dispatch release workflows only from `main`. +- Dispatch the product Release workflow only from the exact approved ASF source candidate tag. + Dispatch npm Stage only from the resulting product `v` tag and npm Finalize from `main`. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. -- Use the Git tag `cli-v`; CLI releases never replace the Desktop GitHub Latest release. +- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow, and must already exist before npm staging. +- Keep that GitHub Release in Draft until npm Finalize and Desktop remote Runtime Host acceptance + succeed. The Draft supplies npm's product identity; its publication is the final product action. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package maintainer approves the staged package with npm 2FA. - Do not rebuild between validation, staging, approval, and finalization. @@ -20,27 +21,28 @@ validated by the Stage workflow. The two workflow boundaries are: -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) builds and validates one - immutable tarball, records its source identity, enters the protected `npm-release` Environment, - and submits it to npm staging through OIDC. -2. [Finalize CLI npm release](../.github/workflows/release-cli-finalize.yml) accepts only the exact - successful Stage run and attempt, verifies the public registry bytes, signature, provenance, and - dist-tag, then creates the exact Git tag and a non-Latest GitHub Release. +1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) resolves the existing product + tag and GitHub Release, checks out that exact product commit, builds and validates one immutable + tarball, records that single tag commit and workflow run, enters the protected `npm-release` + Environment, and submits it to npm staging through OIDC. +2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. ## One-time control-plane configuration ### GitHub Environment -Create an Environment named `npm-release` with: +The checked-in `.asf.yaml` is the authority for the `npm-release` Environment. After it reaches +`main`, confirm ASF reconciliation produced: -- `main` as the only allowed deployment branch; -- the active CLI release maintainer as a required reviewer; -- self-review allowed while one person is the sole release maintainer; +- a selected deployment tag rule matching `v*`, with no branch rule; +- `M4n5ter` as the required reviewer; +- self-review disabled; - administrator bypass disabled where repository policy permits it; - no environment secrets or variables. -Repository administration permission is required to configure the Environment. The workflow itself -uses GitHub OIDC and does not read an npm token. +Repository administration permission is required to inspect or repair reconciliation. Do not maintain +a second manual Environment policy in GitHub. The workflow itself uses GitHub OIDC and does not read +an npm token. ### npm Trusted Publisher @@ -48,8 +50,8 @@ In the `maka-agent` package settings, configure one GitHub Actions trusted publi | Field | Value | | --- | --- | -| Organization or user | `maka-agent` | -| Repository | `maka-agent` | +| Organization or user | `apache` | +| Repository | `maka` | | Workflow filename | `release-cli-stage.yml` | | Environment name | `npm-release` | | Allowed actions | `npm stage publish` only | @@ -63,10 +65,16 @@ package owner or recovery access as part of that change. ## Prepare a release -1. Merge all intended package, documentation, and release changes to `main`. -2. Set `packages/cli/package.json` to the unused target version and merge that change. The release - tool maps prerelease versions to `next` and stable versions to `latest`. -3. Confirm the target version is absent from both public and staged package state: +1. Merge all intended package, documentation, and release changes to `main`, prepare the ASF source + candidate, and complete both the podling and Incubator PMC votes. +2. Confirm the root product version, `apps/desktop/package.json`, and + `packages/cli/package.json` have the same unused target version at the approved source commit. + The npm channel maps prerelease versions to `next` and stable versions to `latest`. +3. Dispatch the product `Release` workflow from the exact approved + `v-incubating-rc` tag, supplying that same tag as `source_reference_tag`. Confirm its + Draft `v` Release points to the approved commit. npm staging consumes this identity and + cannot precede it. +4. Confirm the target version is absent from both public and staged package state: ```sh version=0.1.0-beta.1 @@ -76,13 +84,19 @@ package owner or recovery access as part of that change. The first command should report that the target version is not present. Resolve any existing stage instead of submitting the same version again. -4. Confirm the `npm-release` Environment and Trusted Publisher still match the values above and the +5. Confirm the `npm-release` Environment and Trusted Publisher still match the values above and the approving npm account has 2FA enabled. ## Stage the candidate -1. Open **Actions → Stage CLI npm release → Run workflow**. -2. Select `main` and enter the exact version from `packages/cli/package.json`. +1. Dispatch the workflow with the exact product tag as its GitHub ref: + + ```sh + version=0.1.0-beta.1 + gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + ``` + +2. Confirm the created run uses `v`. The workflow requires its GitHub ref, checkout, product tag, Release, source commit, and npm provenance to identify that one tag commit, and requires the commit to remain an ancestor of `main`. 3. Wait for the reusable package validation jobs to pass. They build one tarball and validate the installed CLI on Linux x64, macOS arm64, and Windows x64, plus real Harbor and Pier Docker cells on Linux x64. @@ -111,6 +125,18 @@ Before approval: - inspect the file inventory and the packaged `README.md`; - confirm the tarball belongs to the recorded Stage run and source commit. +Immediately before approval, recheck the live product authority recorded by the Stage run: + +```sh +set -eu +source_commit=replace-with-stage-recorded-commit +node scripts/product-release-authority.mjs verify-draft \ + "v$version" "$source_commit" apache/maka +``` + +The verifier must succeed. Stop if the tag is absent, moved, no longer on `main`, the matching +GitHub Release is no longer a Draft, or its prerelease classification does not match the version. + Approve only that stage ID. npm requires 2FA and makes the package public as part of approval: ```sh @@ -138,17 +164,15 @@ Do not change `next` when it already points to a newer version such as `0.2.0-be intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, not dist-tag mutations, and the release workflows must not gain a long-lived npm token. -## Finalize the public release +## Finalize the public npm channel After npm reports the version as public: -1. Open **Actions → Finalize CLI npm release → Run workflow** on `main`. +1. Open **Actions → Finalize CLI npm channel → Run workflow** on `main`. 2. Enter the successful Stage run ID, its exact run attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. -4. Review and approve the `npm-release` Environment deployment for the Git tag and GitHub Release. -5. Confirm the workflow created `cli-v` at the Stage source commit. A prerelease must be - marked prerelease; no CLI release may become the repository's GitHub Latest release. +4. Confirm the workflow preserved the verified public package as an Actions artifact and did not create or modify any Git tag or GitHub Release. Check the resulting registry state: @@ -162,12 +186,14 @@ Finally, install the exact public version on each release platform and complete turn. On the supported Eval host, complete at least one real experiment cell and inspect score, usage, cost, and artifacts. +Return to the [product release checklist](../.github/RELEASE_CHECKLIST.md) and exercise remote Runtime +Host setup from the packaged Desktop apps before publishing the GitHub Release. + ## Failure recovery ### Before npm staging -If validation or Environment approval fails before `npm stage publish`, fix the problem on `main` -and start a new Stage run. No npm version has been consumed. +If a transient failure occurs before `npm stage publish`, rerun Stage from the same product tag. If code or workflow changes are required, fix them on `main`, increment the product version, create a new product tag and Draft, and Stage that new version. No npm version has been consumed. ### Stage workflow failed but npm contains a stage @@ -187,8 +213,7 @@ Never reject a stage based only on version text; bind the action to the inspecte ### Stage succeeded but review found a problem -Reject the stage, fix the problem on `main`, and stage again. Do not approve a candidate merely to -clear the staging area. +Reject the stage, fix the problem on `main`, increment the product version, create a new product tag and Draft, and Stage that new version. Do not approve a candidate merely to clear the staging area. ### npm approval succeeded but Finalize failed @@ -196,11 +221,7 @@ The npm version is already immutable. Do not publish or approve it again. Preser attempt, version, and artifacts. If the package bytes and provenance are valid, fix the current Finalize verifier on `main` and rerun Finalize against that same successful Stage identity. -Finalize is idempotent across partial GitHub Release creation: it resumes an exact draft and accepts -an already-published release only after verifying its metadata and asset digests. If an existing -`cli-v` tag points anywhere other than the recorded Stage source commit, or an existing -published release differs from the verified candidate, stop and investigate. Do not move or delete -it to make the workflow pass. +Finalize is read-only with respect to product release state. If the npm package version, bytes, dist-tag, signature, provenance, or recorded Stage identity differ, stop and investigate; do not modify the product tag or GitHub Release to make npm verification pass. ### The public version is defective diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index b0ba7425f1..70be105184 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -2,15 +2,16 @@ [English](./cli-npm-release.md) -本文档是发布 `maka-agent` 的操作权威。CLI 包版本独立于 Desktop 发布线。每个公开版本都必须 -来自 Stage workflow 验证过的同一个精确 tarball。 +本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自 Stage workflow 验证过的同一个精确 tarball。 ## 发布不变量 -- 只从 `main` dispatch 发布 workflow; +- 产品 Release workflow 只能从已批准的 ASF source candidate tag dispatch;npm Stage 只能从随后创建的产品 `v` tag dispatch,npm Finalize 只能从 `main` dispatch; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; -- Git tag 使用 `cli-v`;CLI release 不得替换 Desktop 的 GitHub Latest release; +- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; +- 在 npm Finalize 与 Desktop 远程 Runtime Host 验收成功前,GitHub Release 必须保持 Draft; + Draft 为 npm 提供产品身份,发布 Draft 是最终的产品发布动作; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准 staged package; - validation、staging、approval 和 finalization 之间不得重新构建; @@ -18,27 +19,25 @@ 两个 workflow 边界分别是: -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 构建并验证一个 immutable - tarball,记录其 source identity,进入受保护的 `npm-release` Environment,然后通过 OIDC - 提交到 npm staging; -2. [Finalize CLI npm release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 - Stage run 和 attempt,验证公共 registry 字节、signature、provenance 和 dist-tag,然后创建 - 精确 Git tag 和非 Latest 的 GitHub Release。 +1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub + Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; +2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 ## 一次性控制面配置 ### GitHub Environment -创建名为 `npm-release` 的 Environment,并设置: +仓库中的 `.asf.yaml` 是 `npm-release` Environment 的权威。该配置进入 `main` 后,确认 ASF +同步出的 live 配置满足: -- 只允许 `main` 部署; -- 将当前 CLI 发布维护者设为 required reviewer; -- 只有一名发布维护者期间允许 self-review; +- 使用匹配 `v*` 的 selected deployment tag rule,不配置 branch rule; +- required reviewer 为 `M4n5ter`; +- 禁止 self-review; - 仓库策略允许时禁用 administrator bypass; - 不配置 environment secret 或 variable。 -配置 Environment 需要仓库 administration 权限。workflow 使用 GitHub OIDC,不读取 npm -token。 +检查或修复同步结果需要仓库 administration 权限;不要再在 GitHub UI 中维护第二套手工 +Environment policy。workflow 使用 GitHub OIDC,不读取 npm token。 ### npm Trusted Publisher @@ -46,8 +45,8 @@ token。 | 字段 | 值 | | --- | --- | -| Organization or user | `maka-agent` | -| Repository | `maka-agent` | +| Organization or user | `apache` | +| Repository | `maka` | | Workflow filename | `release-cli-stage.yml` | | Environment name | `npm-release` | | Allowed actions | 仅 `npm stage publish` | @@ -61,10 +60,10 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ## 准备发布 -1. 将本次包、文档和发布变更全部合并到 `main`; -2. 把 `packages/cli/package.json` 更新为尚未使用的目标版本并合并。release tool 会把 - prerelease 映射到 `next`,stable 映射到 `latest`; -3. 确认目标版本既不在公共 registry,也不在 staged package 中: +1. 将本次包、文档和发布变更全部合并到 `main`,准备 ASF source candidate,并完成 podling 和 Incubator PMC 两轮投票; +2. 确认已批准 source commit 上的根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 是同一个尚未使用的目标版本。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; +3. 从精确的已批准 `v-incubating-rc` tag dispatch 产品 `Release` workflow,并将同一个 tag 作为 `source_reference_tag`。确认其 Draft `v` Release 指向已批准 commit;npm staging 消费这个身份,不能先于它运行; +4. 确认目标版本既不在公共 registry,也不在 staged package 中: ```sh version=0.1.0-beta.1 @@ -73,13 +72,19 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token ``` 第一个命令应报告目标版本不存在。如果已经存在同版本 stage,先处理它,不要再次提交; -4. 确认 `npm-release` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 +5. 确认 `npm-release` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 npm 账号已经启用 2FA。 ## Stage 候选包 -1. 打开 **Actions → Stage CLI npm release → Run workflow**; -2. 选择 `main`,输入 `packages/cli/package.json` 中的精确版本; +1. 使用精确产品 tag 作为 GitHub ref dispatch workflow: + + ```sh + version=0.1.0-beta.1 + gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + ``` + +2. 确认新建的 run 使用 `v`。workflow 要求其 GitHub ref、checkout、产品 tag、Release、source commit 和 npm provenance 全部指向这一个 tag commit,并要求该 commit 仍是 `main` 的 ancestor; 3. 等待可复用 package validation jobs 全部通过。它们只构建一个 tarball,并在 Linux x64、 macOS arm64、Windows x64 上验证安装态 CLI,在 Linux x64 上运行真实 Harbor 和 Pier Docker cell; @@ -107,6 +112,18 @@ npm stage download "$stage_id" --registry https://registry.npmjs.org/ - 检查文件清单和包内 `README.md`; - 确认 tarball 属于所记录的 Stage run 和 source commit。 +批准前的最后一步,重新检查 Stage run 记录的 live 产品权威: + +```sh +set -eu +source_commit=replace-with-stage-recorded-commit +node scripts/product-release-authority.mjs verify-draft \ + "v$version" "$source_commit" apache/maka +``` + +verifier 必须成功。tag 不存在、已移动、不再位于 `main`,匹配的 GitHub Release 不再是 Draft, +或 prerelease 分类与版本不一致时都必须停止。 + 只批准这个 stage ID。npm 会要求 2FA,并在批准时将 package 公开: ```sh @@ -133,17 +150,15 @@ npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.or npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 release workflow 不得获得长期 npm token。 -## Finalize 公共发布 +## Finalize 公共 npm 渠道 npm 显示该版本已经公开后: -1. 在 `main` 上打开 **Actions → Finalize CLI npm release → Run workflow**; +1. 在 `main` 上打开 **Actions → Finalize CLI npm channel → Run workflow**; 2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; -4. 审查并批准用于 Git tag 和 GitHub Release 的 `npm-release` Environment deployment; -5. 确认 workflow 在 Stage source commit 上创建了 `cli-v`。预发布版本必须标记为 - prerelease;任何 CLI release 都不得成为仓库的 GitHub Latest release。 +4. 确认 workflow 将验证后的公开包保存为 Actions artifact,且没有创建或修改任何 Git tag 或 GitHub Release。 检查最终 registry 状态: @@ -156,12 +171,14 @@ npm view maka-agent dist-tags --json 最后,在每个发布平台安装精确的公共版本,并完成一次真实的 TUI/model turn。在支持的 Eval host 上完成至少一个真实 experiment cell,检查 score、usage、cost 和 artifacts。 +回到[产品发布检查清单](../.github/RELEASE_CHECKLIST.md),使用打包后的 Desktop 应用完成远程 +Runtime Host setup 验收,再发布 GitHub Release。 + ## 失败恢复 ### npm staging 之前失败 -如果 validation 或 Environment approval 在 `npm stage publish` 前失败,在 `main` 修复后启动 -新的 Stage run。此时没有消耗 npm 版本。 +如果在 `npm stage publish` 前发生瞬时失败,从同一个产品 tag 重新运行 Stage。如果必须修改代码或 workflow,则在 `main` 修复、递增产品版本、创建新的产品 tag 和 Draft,再 Stage 新版本。此时没有消耗 npm 版本。 ### Stage workflow 失败,但 npm 中存在 stage @@ -180,7 +197,7 @@ npm stage reject "$stage_id" --registry https://registry.npmjs.org/ ### Stage 成功,但人工检查发现问题 -拒绝该 stage,在 `main` 修复后重新 Stage。不要为了清空 staging area 而批准有问题的候选。 +拒绝该 stage,在 `main` 修复、递增产品版本、创建新的产品 tag 和 Draft,再 Stage 新版本。不要为了清空 staging area 而批准有问题的候选。 ### npm approval 成功,但 Finalize 失败 @@ -188,10 +205,7 @@ npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Sta 和 artifacts。如果 package 字节与 provenance 有效,在 `main` 修复当前 Finalize verifier, 然后针对同一个成功 Stage identity 重新运行 Finalize。 -Finalize 可以幂等地恢复部分完成的 GitHub Release 创建:它会继续处理精确匹配的 -draft,并且只会在验证 metadata 和 asset digest 后接受已经发布的 release。如果已经 -存在的 `cli-v` 指向的不是记录的 Stage source commit,或已经发布的 release -与验证过的 candidate 不一致,立即停止并调查。不要通过移动或删除 tag 让 workflow 通过。 +Finalize 对产品发布状态只读。如果 npm 包版本、字节、dist-tag、签名、provenance 或记录的 Stage identity 不一致,立即停止并调查;不要修改产品 tag 或 GitHub Release 来让 npm 验证通过。 ### 公共版本存在缺陷 diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md index 5db1f698ed..d1f48baaa9 100644 --- a/docs/runtime-host-remote-access.md +++ b/docs/runtime-host-remote-access.md @@ -10,7 +10,7 @@ On a Linux machine with Node.js 22.19 or newer and a working systemd user manage can install and verify a persistent Runtime Host in one command: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root /srv/maka \ @@ -22,7 +22,7 @@ instead of accumulating credentials. The command installs its exact Maka package directory, starts a loopback-only service, verifies the new credential, and then prints the connection details once. Use `terminal-client` for TUI or CLI. -Run `npx --yes maka-agent@next runtime-host service uninstall` on the Host to remove the service and +Run `npx --yes --package maka-agent@next maka runtime-host service uninstall` on the Host to remove the service and managed package. The State Root and Project data are retained. ## Manual Host setup diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md index 0cc46f9c46..626960f0a7 100644 --- a/docs/runtime-host-remote-access.zh-CN.md +++ b/docs/runtime-host-remote-access.zh-CN.md @@ -9,7 +9,7 @@ Maka Desktop、TUI 和 CLI 可以通过 TLS、SSH 或明确启用的明文 WebSo 在具备 Node.js 22.19 或更新版本以及可用 systemd user manager 的 Linux 机器上,发布版 CLI 可以用一个命令安装并验证持久 Runtime Host: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root /srv/maka \ @@ -18,7 +18,7 @@ npx --yes maka-agent@next runtime-host setup \ `--principal` 应使用稳定标识;重复执行会替换该 Client 的 credential,不会不断累积 credential。命令会把当前精确版本的 Maka 安装到托管目录,启动仅监听 loopback 的服务,验证新 credential,然后只显示一次连接信息。TUI 或 CLI 使用 `terminal-client`。 -在 Host 上运行 `npx --yes maka-agent@next runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 +在 Host 上运行 `npx --yes --package maka-agent@next maka runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 ## 手动设置 Host diff --git a/package-lock.json b/package-lock.json index 126ccdfd89..eabeb432e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "dugite": "3.2.2", "knip": "^6.26.0", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "engines": { "node": ">=22.19.0" @@ -13682,7 +13683,7 @@ }, "packages/cli": { "name": "maka-agent", - "version": "0.1.0-beta.2", + "version": "0.1.11", "license": "Apache-2.0", "dependencies": { "@earendil-works/pi-tui": "0.83.0", @@ -13694,8 +13695,7 @@ "@maka/storage": "0.1.0" }, "bin": { - "maka": "dist/cli.js", - "maka-agent": "dist/cli.js" + "maka": "dist/cli.js" } }, "packages/computer-use": { diff --git a/package.json b/package.json index 754c1da14f..ae908c681e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,11 @@ "node": ">=22.19.0" }, "packageManager": "npm@11.19.0", + "releaseToolchain": { + "appleTeamIdentifier": "FABM2QUA8Q", + "node": "24.18.1", + "nodeDarwinArm64Sha256": "1d60b703fe5d7e7072489be8187f430f1a095a658c31e5e1e281331a5873fac3" + }, "type": "module", "workspaces": [ "packages/core", @@ -48,9 +53,13 @@ "release:asf:verify": "node scripts/asf-source-release.mjs verify", "release:asf:sign": "node scripts/asf-source-release.mjs sign", "check:asf-source": "node --test scripts/asf-source-release.test.mjs scripts/asf-source-workflow-policy.test.mjs", + "check:product-release-identity": "node scripts/product-release-identity.mjs", + "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", + "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", + "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:product-release-identity && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", @@ -81,7 +90,8 @@ "dugite": "3.2.2", "knip": "^6.26.0", "patch-package": "8.0.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "yaml": "2.9.0" }, "allowScripts": { "esbuild@0.27.7": true, diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a41242a01..3324653f99 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # Maka CLI -[简体中文](https://github.com/maka-agent/maka-agent/blob/main/packages/cli/README.zh-CN.md) +[简体中文](https://github.com/apache/maka/blob/main/packages/cli/README.zh-CN.md) Maka is a local-first agent workspace. The `maka-agent` npm package installs the interactive terminal UI, the non-interactive CLI, Runtime Host tooling, and the Eval command. @@ -37,8 +37,8 @@ maka --version maka --help ``` -`maka-agent` is an alias for `maka`. For a one-off invocation, use -`npx --yes maka-agent@next`; the unrelated `maka` package on npm is not this project. +The public command is `maka`. For a one-off invocation, use +`npx --yes --package maka-agent@next maka`; the unrelated `maka` package on npm is not this project. `runtime-host service install` uses the persistent global installation above; `runtime-host setup` creates its own managed copy from the exact package invoked by `npx`. @@ -58,7 +58,7 @@ to switch models. API keys and workspace state stay in the local `Maka` profile. The current credential vault is a local plaintext file protected by the operating-system account boundary; on POSIX systems Maka enforces owner-only directory and file modes. It is not an OS keychain. See the repository -[security policy](https://github.com/maka-agent/maka-agent/blob/main/SECURITY.md) for the current +[security policy](https://github.com/apache/maka/blob/main/SECURITY.md) for the current boundary. Run one non-interactive turn with: @@ -90,7 +90,7 @@ with `npm install --global maka-agent@latest`. To set up a persistent remote Runtime Host from an exact released package on Linux: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-client \ --preset terminal-client ``` @@ -102,7 +102,7 @@ Rerunning setup replaces that Client credential. The service no longer depends o ```sh # Linux only, when a managed Runtime Host service was installed -npx --yes maka-agent@next runtime-host service uninstall +npx --yes --package maka-agent@next maka runtime-host service uninstall # If Maka was installed globally npm uninstall --global maka-agent @@ -158,7 +158,7 @@ python3.12 -m venv ~/.venvs/maka-pier-0.3.0 Set the spec's `pythonPathEnv` to the corresponding `bin/python` path. Do not reuse one environment for both frameworks: their dependency and trial contracts differ. Advanced experiment and toolchain details live in the -[Eval documentation](https://github.com/maka-agent/maka-agent/tree/main/packages/eval). +[Eval documentation](https://github.com/apache/maka/tree/main/packages/eval). ## Troubleshooting @@ -178,10 +178,10 @@ maka --version - When reporting a problem, include the three versions above, the operating system and architecture, the command, and the complete error with credentials removed. -Report issues at . +Report issues at . ## Links -- [Repository](https://github.com/maka-agent/maka-agent) -- [Release operations](https://github.com/maka-agent/maka-agent/blob/main/docs/cli-npm-release.md) -- [License](https://github.com/maka-agent/maka-agent/blob/main/LICENSE) +- [Repository](https://github.com/apache/maka) +- [Release operations](https://github.com/apache/maka/blob/main/docs/cli-npm-release.md) +- [License](https://github.com/apache/maka/blob/main/LICENSE) diff --git a/packages/cli/README.zh-CN.md b/packages/cli/README.zh-CN.md index f75bb431b0..07ec785a6e 100644 --- a/packages/cli/README.zh-CN.md +++ b/packages/cli/README.zh-CN.md @@ -35,7 +35,7 @@ maka --version maka --help ``` -`maka-agent` 是 `maka` 的别名。一次性运行请使用 `npx --yes maka-agent@next`;npm 上与本项目 +公开命令只有 `maka`。一次性运行请使用 `npx --yes --package maka-agent@next maka`;npm 上与本项目 无关的 `maka` 包不是本项目。`runtime-host service install` 使用上面的持久全局安装; `runtime-host setup` 会从 `npx` 调用的精确 package 创建自己的托管副本。 @@ -54,7 +54,7 @@ maka API Key 和工作空间状态保存在本机的 `Maka` profile 中。当前 credential vault 是受操作系统 账号边界保护的本地明文文件;在 POSIX 系统上,Maka 会强制使用仅 owner 可访问的目录和文件 权限。它不是操作系统 Keychain。当前边界详见仓库的 -[安全策略](https://github.com/maka-agent/maka-agent/blob/main/SECURITY.md)。 +[安全策略](https://github.com/apache/maka/blob/main/SECURITY.md)。 执行一次非交互 Turn: @@ -84,7 +84,7 @@ Beta 升级不要使用不带 tag 的 `npm update --global maka-agent`:npm 的 在 Linux 上从精确的发布 package 设置持久 remote Runtime Host: ```sh -npx --yes maka-agent@next runtime-host setup \ +npx --yes --package maka-agent@next maka runtime-host setup \ --principal my-client \ --preset terminal-client ``` @@ -95,7 +95,7 @@ npx --yes maka-agent@next runtime-host setup \ ```sh # 仅限安装过 managed Runtime Host service 的 Linux -npx --yes maka-agent@next runtime-host service uninstall +npx --yes --package maka-agent@next maka runtime-host service uninstall # 如果曾全局安装 Maka npm uninstall --global maka-agent @@ -147,7 +147,7 @@ python3.12 -m venv ~/.venvs/maka-pier-0.3.0 把 spec 的 `pythonPathEnv` 指向相应的 `bin/python`。不要让两个 framework 复用一个环境: 它们的依赖和 trial contract 不同。高级实验和 toolchain 说明位于 -[Eval 文档](https://github.com/maka-agent/maka-agent/tree/main/packages/eval)。 +[Eval 文档](https://github.com/apache/maka/tree/main/packages/eval)。 ## 故障排查 @@ -166,10 +166,10 @@ maka --version - 报告问题时,请提供以上三个版本、操作系统和架构、执行的命令,以及移除凭证后的完整 错误信息。 -请在 报告问题。 +请在 报告问题。 ## 链接 -- [代码仓库](https://github.com/maka-agent/maka-agent) -- [发布操作手册](https://github.com/maka-agent/maka-agent/blob/main/docs/cli-npm-release.zh-CN.md) -- [许可证](https://github.com/maka-agent/maka-agent/blob/main/LICENSE) +- [代码仓库](https://github.com/apache/maka) +- [发布操作手册](https://github.com/apache/maka/blob/main/docs/cli-npm-release.zh-CN.md) +- [许可证](https://github.com/apache/maka/blob/main/LICENSE) diff --git a/packages/cli/package.json b/packages/cli/package.json index 5c6f097aa5..34333fc396 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,13 +1,12 @@ { "name": "maka-agent", - "version": "0.1.0-beta.2", + "version": "0.1.11", "license": "Apache-2.0", "private": true, "type": "module", "exports": {}, "bin": { - "maka": "./dist/cli.js", - "maka-agent": "./dist/cli.js" + "maka": "./dist/cli.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", diff --git a/packages/cli/src/__tests__/cli.test.ts b/packages/cli/src/__tests__/cli.test.ts index b7c5b47426..9d9fc613ab 100644 --- a/packages/cli/src/__tests__/cli.test.ts +++ b/packages/cli/src/__tests__/cli.test.ts @@ -16,7 +16,6 @@ describe('Maka CLI args', () => { ) as Record; assert.deepEqual(manifest.bin, { maka: './dist/cli.js', - 'maka-agent': './dist/cli.js', }); assert.deepEqual(manifest.exports, {}); assert.equal(Object.hasOwn(manifest, 'main'), false); @@ -29,7 +28,7 @@ describe('Maka CLI args', () => { assert.equal(help.kind, 'help'); if (help.kind !== 'help') return; assert.match(help.text, /^ maka Start the TUI$/m); - assert.match(help.text, /^ maka-agent Start the TUI$/m); + assert.doesNotMatch(help.text, /maka-agent/); assert.match(help.text, /^ maka run /m); assert.match(help.text, /^ maka activate /m); assert.match(help.text, /^ maka eval /m); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index a87ee4d57e..812d38c21e 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -94,7 +94,6 @@ function helpText(cliCommand: string): string { '', 'Commands:', ` ${cliCommand} Start the TUI`, - ...(cliCommand === 'maka' ? [' maka-agent Start the TUI'] : []), ` ${cliCommand} run ... Run one non-interactive model turn`, ` ${cliCommand} activate ... Run one Cloud Session activation and emit JSONL`, ` ${cliCommand} -p ... Alias for ${cliCommand} run`, diff --git a/packages/eval/package.json b/packages/eval/package.json index 68c4534e79..67e03a827a 100644 --- a/packages/eval/package.json +++ b/packages/eval/package.json @@ -4,6 +4,21 @@ "license": "Apache-2.0", "description": "Minimal experiment semantics for Maka evaluation.", "type": "module", + "releaseFiles": [ + "dist", + "harbor/deepseek-codex-models.json", + "harbor/deepseek-harness-profile/cordis.patch.yml", + "harbor/deepseek-harness-profile/cordis.yml", + "harbor/deepseek-harness-profile/package.json", + "harbor/docker-compose-egress-proxy.yaml", + "harbor/egress-proxy/Dockerfile", + "harbor/egress-proxy/entrypoint.sh", + "harbor/egress-proxy/network-policy", + "harbor/egress_filter.py", + "harbor/eval_framework.py", + "harbor/relay_agent.py", + "harbor/run_trial.py" + ], "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index aede3c79fc..5f614786ce 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -16,6 +16,37 @@ const FULL_SUITE_FILES = new Set([ 'scripts/run-workspace-tests-parallel.mjs', ]); +const RELEASE_CONTRACT_FILES = new Set([ + 'apps/desktop/build/entitlements.mac.inherit.plist', + 'apps/desktop/build/entitlements.mac.plist', + 'apps/desktop/bundled-git.json', + 'apps/desktop/bundled-tools.json', + 'apps/desktop/electron-builder.config.mjs', + 'apps/desktop/package.json', + '.github/workflows/cli-package-validation.yml', + '.github/workflows/release-cli-finalize.yml', + '.github/workflows/release-cli-stage.yml', + '.github/workflows/release.yml', + 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-arm64-cli.mjs', + 'scripts/package-windows-autoupdate-next.mjs', + 'scripts/package-windows-x64.mjs', + 'scripts/prepare-bundled-git-source.mjs', + 'scripts/prepare-bundled-git.mjs', + 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/prepare-windows-upgrade-baseline.test.mjs', + 'scripts/product-release.test.mjs', + 'scripts/release-eval-smoke-sitecustomize.py', + 'scripts/release-version.mjs', + 'scripts/verify-macos-arm64-cli.mjs', + 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-windows-autoupdate.mjs', + 'scripts/verify-windows-installer-lifecycle.mjs', + 'scripts/verify-windows-x64.mjs', + 'scripts/windows-upgrade-baseline.json', +]); + const TYPECHECK_ONLY_FILES = new Set([ 'biome.jsonc', 'components.json', @@ -61,6 +92,14 @@ function isCliPackagePath(path) { ); } +function isReleaseContractPath(path) { + return ( + RELEASE_CONTRACT_FILES.has(path) || + path.startsWith('scripts/product-release-') || + path.startsWith('scripts/release-cli-') + ); +} + const DEDICATED_WORKSPACE_LANES = new Set(['packages/runtime-host']); // Scripts the Electron e2e job runs. Editing one of these changes what that @@ -251,6 +290,7 @@ export function planTests(changedFiles, options = {}) { code: true, e2e: true, full: true, + releaseContract: true, runtimeSandbox: graph.dirs.includes('packages/cli'), // A complete functional suite is still the default release/main gate. // Stress multipliers and native child-process lock probes run only when @@ -306,16 +346,18 @@ export function planTests(changedFiles, options = {}) { const workspaces = reverseDependencyClosure(directWorkspaces, graph); const storageStress = files.some((path) => STORAGE_STRESS_FILES.has(path)); + const cliPackage = files.some((path) => isCliPackagePath(path)); return { asfSource: files.some((path) => ASF_SOURCE_FILES.has(path)), astryxSurface: files.some((path) => isAstryxSurfaceInventoryPath(path)), - cliPackage: files.some((path) => isCliPackagePath(path)), + cliPackage, code, // Electron E2E + alignment audit (same job). Product desktop/ui sources and // e2e drivers only — a storage/runtime change must not drag cold Electron // boots, and packages/ui unit-test-only PRs must not either. e2e: files.some((path) => isE2eProductPath(path)), full: false, + releaseContract: cliPackage || files.some((path) => isReleaseContractPath(path)), // packages/cli/src/__tests__/runtime-host-session-driver.test.ts executes real sandboxed // shell tools, so the bubblewrap + user-namespace setup is required whenever // the cli workspace runs in the dependency closure, not only for direct @@ -340,6 +382,7 @@ export function formatGitHubOutputs(plan) { `e2e=${plan.e2e}`, `runtime_host=${plan.runtimeHost}`, `runtime_sandbox=${plan.runtimeSandbox}`, + `release_contract=${plan.releaseContract}`, `storage_stress=${plan.storageStress}`, `storybook=${plan.storybook}`, `standard_workspaces=${plan.standardWorkspaces.join(',')}`, diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index ab48fa3b91..880a52c82c 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -81,10 +81,45 @@ test('release metadata selects only the gate that consumes it', () => { for (const path of ['LICENSE', 'NOTICE']) { const plan = planTests([path], { graph }); assert.equal(plan.cliPackage, true, path); + assert.equal(plan.releaseContract, true, path); assert.equal(plan.asfSource, false, path); } }); +test('release authority changes select their dedicated contract gate', () => { + for (const path of [ + 'apps/desktop/build/entitlements.mac.plist', + 'apps/desktop/electron-builder.config.mjs', + 'apps/desktop/package.json', + '.github/workflows/cli-package-validation.yml', + '.github/workflows/release-cli-finalize.yml', + '.github/workflows/release-cli-stage.yml', + '.github/workflows/release.yml', + 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-arm64-cli.mjs', + 'scripts/package-windows-x64.mjs', + 'scripts/prepare-windows-upgrade-baseline.mjs', + 'scripts/product-release-artifacts.mjs', + 'scripts/product-release-artifacts.test.mjs', + 'scripts/product-release-authority.mjs', + 'scripts/product-release-authority.test.mjs', + 'scripts/product-release-identity.mjs', + 'scripts/product-release-tag.mjs', + 'scripts/product-release.test.mjs', + 'scripts/release-eval-smoke-sitecustomize.py', + 'scripts/release-version.mjs', + 'scripts/release-cli-publication.test.mjs', + 'scripts/verify-macos-arm64-cli.mjs', + 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-packaged-app.mjs', + 'scripts/verify-windows-x64.mjs', + 'scripts/windows-upgrade-baseline.json', + ]) { + assert.equal(planTests([path], { graph }).releaseContract, true, path); + } + assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false); +}); + test('ASF source authority changes select their dedicated gate', () => { for (const path of [ '.gitattributes', @@ -112,6 +147,7 @@ test('full selection covers every live surface', () => { assert.equal(plan.e2e, true); assert.equal(plan.storybook, true); assert.equal(plan.runtimeHost, true); + assert.equal(plan.releaseContract, true); assert.deepEqual(plan.workspaces, dirs); }); @@ -190,6 +226,21 @@ test('core CI validates affected installed CLI packages on its existing runner', assert.match(workflow, /run: npm run release:cli:smoke/u); }); +test('release contracts run against built CLI outputs', () => { + const workflow = readWorkflow('ci.yml'); + const buildIndex = workflow.indexOf(' - name: Build\n'); + const buildEnd = workflow.indexOf('\n - ', buildIndex + 1); + const releaseIndex = workflow.indexOf(' - name: Release contracts\n'); + + assert.ok(buildIndex >= 0); + assert.match(workflow.slice(buildIndex, buildEnd), /release_contract == 'true'/u); + assert.ok(buildIndex < releaseIndex); + assert.match( + workflow.slice(releaseIndex), + /if: steps\.plan\.outputs\.release_contract == 'true'/u, + ); +}); + test('pull request triggers stay on an explicit allowlist', () => { // Naming the lanes that must not run on pull requests only covers the ones // someone remembered to name; W0 kept an unbounded trigger that way. diff --git a/scripts/package-macos-arm64-cli.mjs b/scripts/package-macos-arm64-cli.mjs new file mode 100644 index 0000000000..b248c2ab65 --- /dev/null +++ b/scripts/package-macos-arm64-cli.mjs @@ -0,0 +1,1023 @@ +import { execFile, spawn } from 'node:child_process'; +import { createHash, randomBytes } 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 { resolveProductReleaseIdentity } from './product-release-identity.mjs'; +import { + isMakaDevelopmentArtifact, + isThirdPartyDevelopmentArtifact, + releaseNpmEnvironment, + resolveReleaseWorkspacePackages, + resolveWorkspaceReleaseFiles, +} from './release-cli-file-policy.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 requiredSigningEnvironment = [ + 'CSC_LINK', + 'CSC_KEY_PASSWORD', + 'APPLE_API_KEY', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', +]; +export const OFFICIAL_NODE_RUNTIME_ENTITLEMENTS = Object.freeze([ + 'com.apple.security.cs.allow-dyld-environment-variables', + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + 'com.apple.security.cs.disable-executable-page-protection', + 'com.apple.security.cs.disable-library-validation', + 'com.apple.security.get-task-allow', +]); +// The official archive is validated verbatim above, but Apple rejects +// distribution software that keeps get-task-allow. Re-sign only the Node +// executable with the same runtime capabilities minus that development grant. +export const DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS = Object.freeze( + OFFICIAL_NODE_RUNTIME_ENTITLEMENTS.filter( + (entitlement) => entitlement !== 'com.apple.security.get-task-allow', + ), +); + +export 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} ${(options.displayArgs ?? 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) +libexec_dir=$(CDPATH= cd -P "$bin_dir/../libexec" && pwd) +MAKA_EVAL_MAKA_BUNDLE_PATH=$libexec_dir +export MAKA_EVAL_MAKA_BUNDLE_PATH +exec "$libexec_dir/node/bin/node" "$libexec_dir/node_modules/maka-agent/dist/cli.js" "$@" +`; +} + +export function macosArm64CliInstallArgs() { + return [ + 'ci', + '--omit=dev', + '--workspace', + cliPackageName, + '--include-workspace-root=false', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ]; +} + +export function standaloneInstallEnvironment(environment) { + return releaseNpmEnvironment(environment, join(repoRoot, '.npmrc')); +} + +export async function resolveCliWorkspacePackages() { + return resolveReleaseWorkspacePackages(repoRoot, cliPackageName); +} + +export async function pruneThirdPartyDevelopmentArtifacts(directory, root = directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const relativePath = relative(root, path); + if (isThirdPartyDevelopmentArtifact(relativePath)) { + await rm(path, { recursive: entry.isDirectory(), force: true }); + } else if (entry.isDirectory()) { + await pruneThirdPartyDevelopmentArtifacts(path, root); + } + } +} + +export function standaloneInstallRootManifest(rootManifest, workspacePackages) { + const { allowScripts: _allowScripts, ...staged } = rootManifest; + return { + ...staged, + workspaces: workspacePackages.map(({ workspacePath }) => workspacePath), + }; +} + +async function sha256File(path) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} + +export async function stageWorkspacePackages(installRoot, workspacePackages) { + const rootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + await Promise.all([ + writeFile( + join(installRoot, 'package.json'), + `${JSON.stringify(standaloneInstallRootManifest(rootManifest, workspacePackages), null, 2)}\n`, + 'utf8', + ), + copyFile(join(repoRoot, 'package-lock.json'), join(installRoot, 'package-lock.json')), + ...workspacePackages.map(async ({ directory, manifest, workspacePath }) => { + const targetDirectory = join(installRoot, workspacePath); + await mkdir(targetDirectory, { recursive: true }); + await copyFile(join(directory, 'package.json'), join(targetDirectory, 'package.json')); + await Promise.all( + resolveWorkspaceReleaseFiles(directory, 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 pruneMakaDevelopmentArtifacts(targetDirectory); + }), + ]); +} + +async function pruneMakaDevelopmentArtifacts(directory, root = directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (isMakaDevelopmentArtifact(relative(root, path))) { + await rm(path, { recursive: entry.isDirectory(), force: true }); + } else if (entry.isDirectory()) { + await pruneMakaDevelopmentArtifacts(path, root); + } + } +} + +export async function listDependencyPatchNames() { + let entries; + try { + 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(', ')}`, + ); + } +} + +function extractNodeEntitlements(output, expectedEntitlements, description) { + const start = output.indexOf(''); + if (start < 0 || end < start) { + throw new Error(`${description} has no readable entitlement plist.`); + } + const plist = output.slice(start, end + ''.length); + const allKeys = [...plist.matchAll(/([^<]+)<\/key>/gu)].map((match) => match[1]).sort(); + const keys = [...plist.matchAll(/([^<]+)<\/key>\s*/gu)] + .map((match) => match[1]) + .sort(); + if ( + JSON.stringify(allKeys) !== JSON.stringify(expectedEntitlements) || + JSON.stringify(keys) !== JSON.stringify(expectedEntitlements) + ) { + throw new Error(`${description} entitlements do not match the reviewed contract.`); + } + return plist; +} + +export function extractOfficialNodeEntitlements(output) { + return extractNodeEntitlements( + output, + OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, + 'Official Node runtime', + ); +} + +export function extractDistributionNodeEntitlements(output) { + return extractNodeEntitlements( + output, + DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, + 'Distribution Node runtime', + ); +} + +export function distributionNodeEntitlements(officialEntitlements) { + const plist = extractOfficialNodeEntitlements(officialEntitlements); + const distributionPlist = plist.replace( + /\s*com\.apple\.security\.get-task-allow<\/key>\s*/u, + '', + ); + return extractDistributionNodeEntitlements(distributionPlist); +} + +async function inspectReleaseToolchain({ execPath, env, inspect, toolchain }) { + const [nodeVersion, npmVersion, architectures, signature, entitlements, dependencies] = + await Promise.all([ + inspect(execPath, ['-p', 'process.versions.node'], { env }), + inspect('npm', ['--version'], { env }), + inspect('lipo', ['-archs', execPath], { env }), + inspect('codesign', ['-d', '--verbose=4', execPath], { env }), + inspect('codesign', ['-d', '--entitlements', ':-', execPath], { env }), + inspect('otool', ['-L', execPath], { env }), + ]); + assertOfficialNodeRuntime({ + actualVersion: nodeVersion.stdout.trim(), + expectedVersion: toolchain.nodeVersion, + 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()}.`, + ); + } + return extractOfficialNodeEntitlements(`${entitlements.stdout}\n${entitlements.stderr}`); +} + +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); +} + +export function macosArm64MachOAction(architectures, buildVersion) { + const architectureList = architectures.trim().split(/\s+/u).filter(Boolean); + if (!architectureList.includes('arm64') || !/^\s*platform MACOS\s*$/m.test(buildVersion)) { + return 'remove'; + } + return architectureList.length === 1 ? 'keep' : 'thin'; +} + +async function pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }) { + 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]), + ]); + const action = macosArm64MachOAction(architectures.stdout, buildVersion.stdout); + if (action === 'remove') { + await rm(binaryPath, { force: true }); + } else if (action === 'thin') { + const thinnedPath = `${binaryPath}.arm64`; + try { + await run('lipo', [binaryPath, '-thin', 'arm64', '-output', thinnedPath]); + const [thinnedArchitectures, thinnedBuildVersion] = await Promise.all([ + inspect('lipo', ['-archs', thinnedPath]), + inspect('xcrun', ['vtool', '-show-build', thinnedPath]), + ]); + if (!isMacosArm64MachO(thinnedArchitectures.stdout, thinnedBuildVersion.stdout)) { + throw new Error(`Could not thin Mach-O file to macOS arm64: ${binaryPath}`); + } + await rename(thinnedPath, binaryPath); + } finally { + await rm(thinnedPath, { force: true }); + } + } + } +} + +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'}.`); + } +} + +export function decodeSigningCertificate(value) { + const encoded = value?.replace(/[\t\n\r ]+/gu, ''); + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded)) { + throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length === 0) { + throw new Error('CSC_LINK must contain one base64-encoded PKCS12 certificate.'); + } + return { bytes }; +} + +export function parseDeveloperIdApplicationIdentity(output) { + const identities = [...output.matchAll(/^\s*\d+\)\s+([0-9A-Fa-f]{40})\s+"([^"]+)"\s*$/gmu)] + .map((match) => ({ + hash: match[1].toUpperCase(), + name: match[2], + teamIdentifier: / \(([A-Z0-9]{10})\)$/u.exec(match[2])?.[1], + })) + .filter(({ name }) => name.startsWith('Developer ID Application:')); + if (identities.length !== 1) { + throw new Error('CLI signing keychain must contain one Developer ID Application identity.'); + } + if (!identities[0].teamIdentifier) { + throw new Error('Developer ID Application identity has no exact Apple Team ID.'); + } + return identities[0]; +} + +export function assertExpectedAppleTeam(identity, expectedTeamIdentifier) { + if (identity.teamIdentifier !== expectedTeamIdentifier) { + throw new Error( + `CLI signing identity belongs to Apple team ${identity.teamIdentifier}, expected ${expectedTeamIdentifier}.`, + ); + } + return identity; +} + +export async function createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }) { + const temporaryRoot = await mkdtemp(join(tmpdir(), 'maka-cli-signing-')); + const certificatePath = join(temporaryRoot, 'identity.p12'); + const pemPath = join(temporaryRoot, 'identity.pem'); + const keychainFile = join(temporaryRoot, 'signing.keychain-db'); + const keychainPassword = randomBytes(32).toString('hex'); + let created = false; + const cleanup = async () => { + let keychainDeletionError; + if (created) { + try { + await run('security', ['delete-keychain', keychainFile], { env }); + } catch (error) { + keychainDeletionError = error; + } finally { + created = false; + } + } + try { + await rm(temporaryRoot, { recursive: true, force: true }); + } catch (error) { + if (keychainDeletionError) { + throw new AggregateError( + [keychainDeletionError, error], + 'Signing keychain and temporary credential cleanup both failed', + ); + } + throw error; + } + if (keychainDeletionError) throw keychainDeletionError; + }; + + try { + await writeFile(certificatePath, decodeSigningCertificate(env.CSC_LINK).bytes, { mode: 0o600 }); + await run('security', ['create-keychain', '-p', keychainPassword, keychainFile], { + env, + displayArgs: ['create-keychain', '-p', '', keychainFile], + }); + created = true; + await run('security', ['unlock-keychain', '-p', keychainPassword, keychainFile], { + env, + displayArgs: ['unlock-keychain', '-p', '', keychainFile], + }); + await run('security', ['set-keychain-settings', '-lut', '21600', keychainFile], { env }); + await run( + 'openssl', + [ + 'pkcs12', + '-in', + certificatePath, + '-nodes', + '-passin', + 'env:CSC_KEY_PASSWORD', + '-out', + pemPath, + ], + { env }, + ); + await chmod(pemPath, 0o600); + await run('security', ['import', pemPath, '-k', keychainFile, '-T', '/usr/bin/codesign'], { + env, + }); + await run( + 'security', + [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:', + '-s', + '-k', + keychainPassword, + keychainFile, + ], + { + env, + displayArgs: [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:', + '-s', + '-k', + '', + keychainFile, + ], + }, + ); + const identityOutput = await inspect( + 'security', + ['find-identity', '-v', '-p', 'codesigning', keychainFile], + { env }, + ); + const identity = assertExpectedAppleTeam( + parseDeveloperIdApplicationIdentity(identityOutput.stdout), + expectedTeamIdentifier, + ); + return { + cleanup, + directory: temporaryRoot, + identity, + keychainFile, + }; + } catch (error) { + await cleanup(); + throw error; + } +} + +async function signCliBinaries( + machOBinaries, + { env, expectedTeamIdentifier, run, inspect, nodeEntitlements, nodePath }, +) { + assertReleaseSigningEnvironment(env); + const signing = await createSigningKeychain({ env, expectedTeamIdentifier, run, inspect }); + try { + const nodeEntitlementsPath = join(signing.directory, 'node-entitlements.plist'); + await writeFile(nodeEntitlementsPath, `${nodeEntitlements}\n`, { mode: 0o600 }); + for (const binaryPath of machOBinaries) { + const entitlements = + resolve(binaryPath) === resolve(nodePath) ? ['--entitlements', nodeEntitlementsPath] : []; + await run( + 'codesign', + [ + '--force', + '--options', + 'runtime', + '--timestamp', + ...entitlements, + '--sign', + signing.identity.hash, + '--keychain', + signing.keychainFile, + binaryPath, + ], + { env }, + ); + await run('codesign', ['--verify', '--strict', '--verbose=2', binaryPath], { env }); + } + const signedNodeEntitlements = await inspect( + 'codesign', + ['-d', '--entitlements', ':-', nodePath], + { env }, + ); + extractDistributionNodeEntitlements( + `${signedNodeEntitlements.stdout}\n${signedNodeEntitlements.stderr}`, + ); + return { + identityName: signing.identity.name, + machOBinaryCount: machOBinaries.length, + teamIdentifier: signing.identity.teamIdentifier, + }; + } finally { + await signing.cleanup(); + } +} + +async function createCliZip(archiveRoot, archivePath, { env, run }) { + await rm(archivePath, { force: true }); + await run( + 'ditto', + [ + '-c', + '-k', + '--keepParent', + '--norsrc', + '--noextattr', + '--noqtn', + '--noacl', + archiveRoot, + archivePath, + ], + { env }, + ); +} + +async function notarizeCliZip(archivePath, { env, inspect }) { + const result = await inspect( + 'xcrun', + [ + 'notarytool', + 'submit', + archivePath, + '--key', + env.APPLE_API_KEY, + '--key-id', + env.APPLE_API_KEY_ID, + '--issuer', + env.APPLE_API_ISSUER, + '--wait', + '--output-format', + 'json', + ], + { env, timeout: 20 * 60_000 }, + ); + assertAcceptedNotarization(result.stdout); +} + +export async function packageMacosArm64Cli({ + platform = process.platform, + arch = process.arch, + env = process.env, + run = runCommand, + inspect = inspectCommand, + releaseSigning = env.MAKA_CLI_RELEASE_SIGNING === '1', + nodeArchivePath = env.MAKA_CLI_NODE_ARCHIVE, +} = {}) { + assertMacosArm64CliHost(platform, arch); + + const [rootManifest, desktopManifest, cliManifest, workspacePackages, sourceCommitResult] = + await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps', 'desktop', 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8').then(JSON.parse), + resolveCliWorkspacePackages(), + inspect('git', ['rev-parse', 'HEAD']), + ]); + const sourceCommit = sourceCommitResult.stdout.trim(); + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha: sourceCommit, + }); + if (releaseSigning) assertReleaseSigningEnvironment(env); + if (!nodeArchivePath) { + throw new Error(`Set MAKA_CLI_NODE_ARCHIVE to the verified ${identity.nodeArchive} path.`); + } + + const version = identity.version; + await Promise.all([ + access(join(repoRoot, 'DISCLAIMER-WIP')), + access(join(repoRoot, 'LICENSE')), + access(join(repoRoot, 'NOTICE')), + ...workspacePackages.map(({ directory }) => access(join(directory, 'dist'))), + ]); + + 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), + identity, + { env, run }, + ); + const officialNodeEntitlements = await inspectReleaseToolchain({ + execPath: officialNode.execPath, + env, + inspect, + toolchain: identity, + }); + const nodeEntitlements = distributionNodeEntitlements(officialNodeEntitlements); + + const installRoot = join(stagingRoot, 'install'); + await mkdir(installRoot, { recursive: true }); + await stageWorkspacePackages(installRoot, workspacePackages); + await run('npm', macosArm64CliInstallArgs(), { + cwd: installRoot, + env: standaloneInstallEnvironment(env), + }); + const dependencyPatches = await applyDependencyPatches(installRoot, { env, run }); + const productionDependencies = await collectPackagedProductionDependencies( + join(installRoot, 'node_modules'), + new Set(workspacePackages.map(({ name }) => name)), + ); + + const nodeModulesDirectory = join(installRoot, 'node_modules'); + await pruneNonTargetNativeBinaries(nodeModulesDirectory, { inspect, run }); + await pruneThirdPartyDevelopmentArtifacts(nodeModulesDirectory); + + const archiveRoot = join(stagingRoot, archiveRootName); + const binDirectory = join(archiveRoot, 'bin'); + const embeddedNodeDirectory = join(archiveRoot, 'libexec', 'node'); + await Promise.all([ + mkdir(binDirectory, { recursive: true }), + mkdir(join(embeddedNodeDirectory, 'bin'), { recursive: true }), + ]); + await rename(nodeModulesDirectory, join(archiveRoot, 'libexec', 'node_modules')); + for (const { workspacePath } of workspacePackages) { + const source = join(installRoot, workspacePath); + const target = join(archiveRoot, 'libexec', workspacePath); + await mkdir(dirname(target), { recursive: true }); + await rename(source, target); + } + await Promise.all([ + copyFile(officialNode.execPath, join(embeddedNodeDirectory, 'bin', 'node')), + copyFile(officialNode.licensePath, join(embeddedNodeDirectory, 'LICENSE')), + copyFile(join(repoRoot, 'DISCLAIMER-WIP'), join(archiveRoot, 'DISCLAIMER-WIP')), + copyFile(join(repoRoot, 'LICENSE'), join(archiveRoot, 'LICENSE')), + copyFile(join(repoRoot, 'NOTICE'), join(archiveRoot, 'NOTICE')), + writeFile(join(binDirectory, 'maka'), macosArm64CliWrapper(), 'utf8'), + writeFile( + join(archiveRoot, 'README.txt'), + [ + `Maka CLI/TUI ${version} for Apple Silicon macOS`, + '', + "Add this directory's bin folder to PATH, then run:", + ' maka --help', + '', + 'The archive includes its own Node.js runtime and does not require the Maka desktop app.', + '', + ].join('\n'), + 'utf8', + ), + ]); + await Promise.all([ + chmod(join(embeddedNodeDirectory, 'bin', 'node'), 0o755), + chmod(join(binDirectory, 'maka'), 0o755), + ]); + await assertWorkspaceLinks(archiveRoot, workspacePackages); + + const thirdPartyNoticesPath = join(archiveRoot, 'THIRD_PARTY_NOTICES.txt'); + await copyFile( + join(repoRoot, 'packages', 'cli', 'THIRD_PARTY_NOTICES.txt'), + thirdPartyNoticesPath, + ); + const thirdPartyNoticesSha256 = await sha256File(thirdPartyNoticesPath); + + const machOBinaries = await findMachOBinaries(archiveRoot, { inspect }); + if (machOBinaries.length === 0) throw new Error('CLI artifact contains no Mach-O binaries.'); + const releaseMetadata = { + schemaVersion: 1, + product: 'Maka', + version, + sourceCommit, + platform: 'macos', + architecture: 'arm64', + publicCommands: identity.publicCommands, + node: { + version: identity.nodeVersion, + sourceUrl: identity.nodeSourceUrl, + archive: identity.nodeArchive, + archiveSha256: identity.nodeArchiveSha256, + entitlements: releaseSigning + ? DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS + : OFFICIAL_NODE_RUNTIME_ENTITLEMENTS, + }, + npmVersion: identity.npmVersion, + dependencyPatches, + productionDependencies, + thirdPartyNoticesSha256, + workspacePackages: workspacePackages.map(({ name }) => name).sort(), + machOBinaries: machOBinaries.map((path) => relative(archiveRoot, path)).sort(), + signing: releaseSigning ? 'developer-id-notarized' : 'development', + signingTeamIdentifier: releaseSigning ? identity.appleTeamIdentifier : null, + }; + await writeFile( + join(archiveRoot, 'RELEASE.json'), + `${JSON.stringify(releaseMetadata, null, 2)}\n`, + 'utf8', + ); + + let signing; + if (releaseSigning) { + signing = await signCliBinaries(machOBinaries, { + env, + expectedTeamIdentifier: identity.appleTeamIdentifier, + run, + inspect, + nodeEntitlements, + nodePath: join(embeddedNodeDirectory, 'bin', 'node'), + }); + } + 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/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index eb9c9b00f6..eefc5058d0 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -3,24 +3,21 @@ import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; +import { parseProductReleaseVersion } from './release-version.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); /** * The version the auto-update harness serves as "newer than the candidate". - * A plain patch bump keeps the artifact name matching the release regex and - * avoids every channel/prerelease ambiguity: `allowPrerelease` only affects - * the GitHub provider, but a stable version never depends on that behavior. + * A stable successor keeps the loopback feed independent from prerelease + * channel behavior: a prerelease advances to its stable core, while a stable + * candidate advances to the next patch. */ export function bumpedAutoupdateVersion(candidateVersion) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidateVersion); - if (!match) { - throw new Error( - `Cannot bump a non-stable x.y.z candidate version: ${JSON.stringify(candidateVersion)}`, - ); - } - return `${match[1]}.${match[2]}.${Number(match[3]) + 1}`; + const { core, prerelease } = parseProductReleaseVersion(candidateVersion); + const [major, minor, patch] = core; + return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; } /** diff --git a/scripts/prepare-windows-upgrade-baseline.mjs b/scripts/prepare-windows-upgrade-baseline.mjs index 9a6c76263b..14eedc7a3c 100644 --- a/scripts/prepare-windows-upgrade-baseline.mjs +++ b/scripts/prepare-windows-upgrade-baseline.mjs @@ -3,28 +3,14 @@ import { mkdir, readFile, rm } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { compareProductReleaseVersions } from './release-version.mjs'; import { sha256File } from './verify-packaged-app.mjs'; const runFile = promisify(execFile); const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const defaultManifestPath = join(repoRoot, 'scripts', 'windows-upgrade-baseline.json'); -function stableVersion(value) { - const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(value); - return match ? match.slice(1).map(Number) : undefined; -} - -function compareVersions(left, right) { - for (let index = 0; index < 3; index += 1) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { - const baseline = stableVersion(manifest?.version); - const candidate = stableVersion(candidateVersion); - if (!baseline || !candidate) throw new Error('Windows upgrade versions must be stable x.y.z.'); if (manifest.tag !== `v${manifest.version}`) throw new Error('Baseline tag must match its version.'); if (manifest.assetName !== `Maka-${manifest.version}-win-x64.exe`) { @@ -33,7 +19,7 @@ export function validateWindowsUpgradeBaseline(manifest, candidateVersion) { if (!/^[0-9a-f]{64}$/u.test(manifest.sha256)) { throw new Error('Baseline SHA-256 must be a lowercase 64-character digest.'); } - if (compareVersions(baseline, candidate) >= 0) { + if (compareProductReleaseVersions(manifest.version, candidateVersion) >= 0) { throw new Error('Windows upgrade baseline must be older than the candidate.'); } return manifest; @@ -44,7 +30,7 @@ export async function prepareWindowsUpgradeBaseline( outputDirectory, { manifestPath = defaultManifestPath, - repository = process.env.GITHUB_REPOSITORY ?? 'maka-agent/maka-agent', + repository = process.env.GITHUB_REPOSITORY ?? 'apache/maka', run = runFile, checksum = sha256File, } = {}, diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs new file mode 100644 index 0000000000..d1085db9cb --- /dev/null +++ b/scripts/product-release-artifacts.mjs @@ -0,0 +1,84 @@ +import { copyFile, mkdir, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { readProductReleaseIdentity } from './product-release-identity.mjs'; + +export function assertExactArtifactSet(actualNames, expectedNames) { + const actual = [...new Set(actualNames)].sort(); + const expected = [...new Set(expectedNames)].sort(); + const actualSet = new Set(actual); + const expectedSet = new Set(expected); + const missing = expected.filter((name) => !actualSet.has(name)); + const unexpected = actual.filter((name) => !expectedSet.has(name)); + if (missing.length > 0 || unexpected.length > 0) { + const details = [ + ...(missing.length > 0 ? [`missing ${missing.join(', ')}`] : []), + ...(unexpected.length > 0 ? [`unexpected ${unexpected.join(', ')}`] : []), + ]; + throw new Error(`Product release artifact set mismatch: ${details.join('; ')}`); + } + return expected; +} + +async function regularFileNames(directory) { + const names = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) continue; + if (!entry.isFile()) { + throw new Error(`Product release artifact must be a regular file: ${entry.name}`); + } + names.push(entry.name); + } + return names; +} + +export async function stageProductReleaseArtifactGroup({ + sourceDirectory, + targetDirectory, + expectedNames, +}) { + const names = assertExactArtifactSet(await regularFileNames(sourceDirectory), expectedNames); + await mkdir(targetDirectory, { recursive: true }); + if ((await readdir(targetDirectory)).length > 0) { + throw new Error(`Product release artifact target directory must be empty: ${targetDirectory}`); + } + await Promise.all( + names.map((name) => copyFile(join(sourceDirectory, name), join(targetDirectory, name))), + ); + return names; +} + +export async function verifyProductReleaseArtifactDirectory(directory, expectedNames) { + return assertExactArtifactSet(await regularFileNames(directory), expectedNames); +} + +function allArtifactNames(identity) { + return Object.values(identity.artifacts).flat(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, ...args] = process.argv.slice(2); + const identity = await readProductReleaseIdentity(); + if (command === 'stage') { + const [group, sourceDirectory, targetDirectory] = args; + const expectedNames = identity.artifacts[group]; + if (!expectedNames || !sourceDirectory || !targetDirectory) { + throw new Error( + 'usage: product-release-artifacts.mjs stage ', + ); + } + await stageProductReleaseArtifactGroup({ sourceDirectory, targetDirectory, expectedNames }); + console.log(`Staged exact ${group} product artifacts in ${targetDirectory}`); + } else if (command === 'verify') { + const [directory] = args; + if (!directory) { + throw new Error('usage: product-release-artifacts.mjs verify '); + } + await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); + console.log(`Verified exact product release artifacts in ${directory}`); + } else if (command === 'list' && args.length === 0) { + console.log(JSON.stringify(identity.artifacts, null, 2)); + } else { + throw new Error('usage: product-release-artifacts.mjs ...'); + } +} diff --git a/scripts/product-release-artifacts.test.mjs b/scripts/product-release-artifacts.test.mjs new file mode 100644 index 0000000000..24813ce1dd --- /dev/null +++ b/scripts/product-release-artifacts.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + assertExactArtifactSet, + stageProductReleaseArtifactGroup, +} from './product-release-artifacts.mjs'; + +test('product release artifact validation rejects missing and unexpected files', () => { + assert.deepEqual(assertExactArtifactSet(['b.zip', 'a.dmg'], ['a.dmg', 'b.zip']), [ + 'a.dmg', + 'b.zip', + ]); + assert.throws(() => assertExactArtifactSet(['a.dmg'], ['a.dmg', 'b.zip']), /missing b\.zip/u); + assert.throws( + () => assertExactArtifactSet(['a.dmg', 'debug.log'], ['a.dmg']), + /unexpected debug\.log/u, + ); +}); + +test('artifact staging publishes exactly one manifest group', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceDirectory = join(root, 'source'); + const targetDirectory = join(root, 'target'); + await mkdir(sourceDirectory); + await Promise.all([ + writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), + writeFile(join(sourceDirectory, 'a.dmg.sha256'), 'checksum'), + ]); + + await stageProductReleaseArtifactGroup({ + sourceDirectory, + targetDirectory, + expectedNames: ['a.dmg', 'a.dmg.sha256'], + }); + + assert.equal(await readFile(join(targetDirectory, 'a.dmg'), 'utf8'), 'dmg'); + assert.equal(await readFile(join(targetDirectory, 'a.dmg.sha256'), 'utf8'), 'checksum'); +}); + +test('artifact staging refuses to replace an existing target directory', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-existing-')); + t.after(() => rm(root, { recursive: true, force: true })); + const sourceDirectory = join(root, 'source'); + const targetDirectory = join(root, 'target'); + await Promise.all([mkdir(sourceDirectory), mkdir(targetDirectory)]); + await Promise.all([ + writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), + writeFile(join(targetDirectory, 'keep.txt'), 'keep'), + ]); + + await assert.rejects( + stageProductReleaseArtifactGroup({ + sourceDirectory, + targetDirectory, + expectedNames: ['a.dmg'], + }), + /target directory must be empty/u, + ); + assert.equal(await readFile(join(targetDirectory, 'keep.txt'), 'utf8'), 'keep'); +}); diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs new file mode 100644 index 0000000000..09e179c110 --- /dev/null +++ b/scripts/product-release-authority.mjs @@ -0,0 +1,79 @@ +import { execFile } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; + +const execFileAsync = promisify(execFile); + +function expectedReleaseIdentity(tag) { + const { prerelease } = parseProductTag(tag); + return { tag, isPrerelease: prerelease.length > 0 }; +} + +export function assertDraftProductRelease(release, tag) { + const expected = expectedReleaseIdentity(tag); + if (!release || release.tagName !== expected.tag) { + throw new Error(`GitHub Release does not identify product tag ${tag}`); + } + if (release.isDraft !== true) { + throw new Error(`GitHub Release ${tag} must remain a Draft`); + } + if (release.isPrerelease !== expected.isPrerelease) { + throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); + } + return release; +} + +export async function verifyDraftProductRelease({ + tag, + sourceCommit, + repository, + cwd = process.cwd(), + run = execFileAsync, +}) { + expectedReleaseIdentity(tag); + if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error(`Product source must be an exact commit SHA; found ${sourceCommit}`); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error(`Product repository must be an exact owner/name; found ${repository}`); + } + + const remoteCommit = await remoteProductTagCommit({ cwd, remote: 'origin', tag, run }); + if (!remoteCommit) throw new Error(`Product tag ${tag} does not exist on origin`); + if (remoteCommit !== sourceCommit) { + throw new Error(`Product tag ${tag} points to ${remoteCommit} instead of ${sourceCommit}`); + } + + await run('git', ['fetch', '--force', '--no-tags', 'origin', 'main:refs/remotes/origin/main'], { + cwd, + }); + await run('git', ['merge-base', '--is-ancestor', sourceCommit, 'refs/remotes/origin/main'], { + cwd, + }); + + const release = await run( + 'gh', + ['release', 'view', tag, '--repo', repository, '--json', 'tagName,isDraft,isPrerelease'], + { cwd }, + ); + let parsedRelease; + try { + parsedRelease = JSON.parse(release.stdout); + } catch (error) { + throw new Error(`GitHub returned an invalid Release record for ${tag}`, { cause: error }); + } + return assertDraftProductRelease(parsedRelease, tag); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, tag, sourceCommit, repository = process.env.GITHUB_REPOSITORY] = + process.argv.slice(2); + if (command !== 'verify-draft' || !tag || !sourceCommit || !repository) { + throw new Error( + 'usage: product-release-authority.mjs verify-draft ', + ); + } + await verifyDraftProductRelease({ tag, sourceCommit, repository }); + console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); +} diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs new file mode 100644 index 0000000000..59e2f7fdd5 --- /dev/null +++ b/scripts/product-release-authority.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertDraftProductRelease, + verifyDraftProductRelease, +} from './product-release-authority.mjs'; + +test('Draft state and prerelease classification are one product Release contract', () => { + for (const [tag, isPrerelease] of [ + ['v1.2.3', false], + ['v1.2.3-beta.1', true], + ]) { + assert.equal( + assertDraftProductRelease({ tagName: tag, isDraft: true, isPrerelease }, tag).tagName, + tag, + ); + } + assert.throws( + () => + assertDraftProductRelease( + { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, + 'v1.2.3', + ), + /must remain a Draft/u, + ); + assert.throws( + () => + assertDraftProductRelease( + { tagName: 'v1.2.3-beta.1', isDraft: true, isPrerelease: false }, + 'v1.2.3-beta.1', + ), + /prerelease state must be true/u, + ); +}); + +test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { + const sourceCommit = 'a'.repeat(40); + const calls = []; + const run = async (command, args) => { + calls.push([command, args]); + if (args[0] === 'ls-remote') { + return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; + } + if (command === 'gh') { + return { + stdout: JSON.stringify({ + tagName: 'v1.2.3', + isDraft: true, + isPrerelease: false, + }), + }; + } + return { stdout: '' }; + }; + + await verifyDraftProductRelease({ + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + run, + }); + + assert.deepEqual( + calls.map(([command, args]) => [command, args[0]]), + [ + ['git', 'ls-remote'], + ['git', 'fetch'], + ['git', 'merge-base'], + ['gh', 'release'], + ], + ); + assert.deepEqual(calls.at(-1)[1].slice(-2), ['--json', 'tagName,isDraft,isPrerelease']); +}); + +test('the live authority verifier rejects product tag drift before later checks', async () => { + const calls = []; + await assert.rejects( + verifyDraftProductRelease({ + tag: 'v1.2.3', + sourceCommit: 'a'.repeat(40), + repository: 'apache/maka', + run: async (command, args) => { + calls.push([command, args]); + return { stdout: `${'b'.repeat(40)}\trefs/tags/v1.2.3\n` }; + }, + }), + /points to .* instead of/u, + ); + assert.equal(calls.length, 1); +}); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs new file mode 100644 index 0000000000..98942ddabb --- /dev/null +++ b/scripts/product-release-identity.mjs @@ -0,0 +1,200 @@ +import { execFile } from 'node:child_process'; +import { appendFile, readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const execFileAsync = promisify(execFile); + +function releaseToolchainFromManifest(rootManifest) { + const appleTeamIdentifier = rootManifest.releaseToolchain?.appleTeamIdentifier; + const nodeVersion = rootManifest.releaseToolchain?.node; + const nodeArchiveSha256 = rootManifest.releaseToolchain?.nodeDarwinArm64Sha256; + const npmMatch = /^npm@(\d+\.\d+\.\d+)$/u.exec(rootManifest.packageManager ?? ''); + if (typeof nodeVersion !== 'string' || !/^\d+\.\d+\.\d+$/u.test(nodeVersion)) { + throw new Error('package.json must define an exact releaseToolchain.node version'); + } + if (typeof appleTeamIdentifier !== 'string' || !/^[A-Z0-9]{10}$/u.test(appleTeamIdentifier)) { + throw new Error('releaseToolchain.appleTeamIdentifier must be an exact Apple Team ID'); + } + const nodeArchive = `node-v${nodeVersion}-darwin-arm64.tar.xz`; + if (typeof nodeArchiveSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(nodeArchiveSha256)) { + throw new Error('releaseToolchain.nodeDarwinArm64Sha256 must be an exact SHA-256 digest'); + } + if (!npmMatch) throw new Error('package.json packageManager must pin an exact npm version'); + return { + appleTeamIdentifier, + nodeVersion, + nodeArchive, + nodeArchiveSha256, + nodeSourceUrl: `https://nodejs.org/download/release/v${nodeVersion}/${nodeArchive}`, + npmVersion: npmMatch[1], + }; +} + +export function parseAsfSourceReferenceTag(tag) { + const match = typeof tag === 'string' ? /^v(.+)-incubating-rc([1-9]\d*)$/u.exec(tag) : undefined; + if (!match) { + throw new Error('ASF source reference must match v-incubating-rc'); + } + try { + parseProductReleaseVersion(match[1]); + } catch { + throw new Error('ASF source reference must match v-incubating-rc'); + } + return { rcNumber: match[2], tag, version: match[1] }; +} + +export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { + const { version, prerelease } = parseProductReleaseVersion(rootManifest.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'); + } + + return { + version, + isPrerelease: prerelease.length > 0, + runtimeHostSetupPackage: `maka-agent@${version}`, + publicCommands: ['maka'], + }; +} + +export function resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha, + sourceReferenceTag, +}) { + const manifestIdentity = resolveProductManifestIdentity({ + rootManifest, + desktopManifest, + cliManifest, + }); + const { version } = manifestIdentity; + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/u.test(sha)) { + throw new Error('Product releases require an exact 40-character source commit SHA'); + } + if (sourceReferenceTag !== undefined) { + const sourceReference = parseAsfSourceReferenceTag(sourceReferenceTag); + if (sourceReference.version !== version) { + throw new Error( + `ASF source reference version ${sourceReference.version} does not match product ${version}`, + ); + } + } + + const toolchain = releaseToolchainFromManifest(rootManifest); + const dmg = `Maka-${version}-mac-arm64.dmg`; + const macZip = `Maka-${version}-mac-arm64.zip`; + const exe = `Maka-${version}-win-x64.exe`; + const windowsZip = `Maka-${version}-win-x64.zip`; + const cliArchive = `Maka-${version}-cli-mac-arm64.zip`; + const sourceArchive = `Maka-${version}-bundled-git-source.tar.gz`; + const artifacts = { + 'desktop-macos': [dmg, `${dmg}.sha256`, macZip, `${macZip}.blockmap`, 'latest-mac.yml'], + 'desktop-windows': [ + exe, + `${exe}.blockmap`, + `${exe}.sha256`, + windowsZip, + `${windowsZip}.sha256`, + 'latest.yml', + ], + 'cli-macos-arm64': [cliArchive, `${cliArchive}.sha256`], + source: [sourceArchive, `${sourceArchive}.sha256`], + }; + + return { + ...toolchain, + ...manifestIdentity, + tag: `v${version}`, + sourceCommit: sha, + sourceReferenceTag, + dmg, + exe, + cliArchive, + sourceArchive, + artifacts, + }; +} + +async function readProductManifests() { + const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), + readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), + ]); + return { rootManifest, desktopManifest, cliManifest }; +} + +export async function readProductManifestIdentity() { + return resolveProductManifestIdentity(await readProductManifests()); +} + +export async function readProductReleaseIdentity({ + sha, + sourceReferenceTag = process.env.SOURCE_REFERENCE_TAG, +} = {}) { + const { rootManifest, desktopManifest, cliManifest } = await readProductManifests(); + const sourceCommit = + sha ?? + process.env.GITHUB_SHA ?? + (await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot })).stdout.trim(); + return resolveProductReleaseIdentity({ + rootManifest, + desktopManifest, + cliManifest, + sha: sourceCommit, + sourceReferenceTag, + }); +} + +function githubOutputEntries(identity) { + return { + version: identity.version, + is_prerelease: identity.isPrerelease, + tag: identity.tag, + source_commit: identity.sourceCommit, + source_reference_tag: identity.sourceReferenceTag, + dmg: identity.dmg, + exe: identity.exe, + cli_archive: identity.cliArchive, + source_archive: identity.sourceArchive, + node_version: identity.nodeVersion, + node_archive: identity.nodeArchive, + node_archive_sha256: identity.nodeArchiveSha256, + node_source_url: identity.nodeSourceUrl, + npm_version: identity.npmVersion, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const identity = await readProductReleaseIdentity(); + const expectedVersion = process.env.EXPECTED_PRODUCT_VERSION; + if (expectedVersion !== undefined && identity.version !== expectedVersion) { + throw new Error( + `Checked product version ${identity.version} does not match requested release ${expectedVersion}`, + ); + } + if (process.env.GITHUB_OUTPUT) { + const output = Object.entries(githubOutputEntries(identity)) + .filter(([, value]) => value !== undefined) + .map(([name, value]) => `${name}=${value}`) + .join('\n'); + await appendFile(process.env.GITHUB_OUTPUT, `${output}\n`, 'utf8'); + } + console.log(`Product release ${identity.tag} from ${identity.sourceCommit}`); +} diff --git a/scripts/product-release-tag.mjs b/scripts/product-release-tag.mjs new file mode 100644 index 0000000000..71e074ed35 --- /dev/null +++ b/scripts/product-release-tag.mjs @@ -0,0 +1,80 @@ +import { execFile } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +const execFileAsync = promisify(execFile); + +export function parseProductTag(tag) { + if (typeof tag !== 'string' || !tag.startsWith('v')) { + throw new Error(`Product tag must be an exact version tag; found ${tag}`); + } + try { + return parseProductReleaseVersion(tag.slice(1)); + } catch (error) { + throw new Error(`Product tag must be an exact version tag; found ${tag}`, { cause: error }); + } +} + +function validateInputs(tag, source) { + parseProductTag(tag); + if (!/^[0-9a-f]{40}$/u.test(source)) { + throw new Error(`Product tag source must be an exact commit SHA; found ${source}`); + } +} + +export async function remoteProductTagCommit({ cwd, remote, tag, run = execFileAsync }) { + const { stdout } = await run( + 'git', + ['ls-remote', '--tags', '--refs', remote, `refs/tags/${tag}`], + { cwd }, + ); + const line = stdout.trim(); + if (!line) return undefined; + const [commit, ref, ...extra] = line.split(/\s+/u); + if (extra.length > 0 || ref !== `refs/tags/${tag}` || !/^[0-9a-f]{40}$/u.test(commit)) { + throw new Error(`Remote returned an invalid product tag reference for ${tag}`); + } + return commit; +} + +export async function ensureProductTag({ cwd = process.cwd(), remote = 'origin', tag, source }) { + validateInputs(tag, source); + const { stdout: resolvedSource } = await execFileAsync( + 'git', + ['rev-parse', `${source}^{commit}`], + { + cwd, + }, + ); + if (resolvedSource.trim() !== source) { + throw new Error(`Product source ${source} is not the exact checked-out commit`); + } + + const existing = await remoteProductTagCommit({ cwd, remote, tag }); + if (existing) { + if (existing !== source) { + throw new Error(`Product tag ${tag} points to ${existing} instead of ${source}`); + } + return 'existing'; + } + + try { + await execFileAsync('git', ['push', remote, `${source}:refs/tags/${tag}`], { cwd }); + return 'created'; + } catch (error) { + const raced = await remoteProductTagCommit({ cwd, remote, tag }); + if (raced === source) return 'existing'; + if (raced) throw new Error(`Product tag ${tag} points to ${raced} instead of ${source}`); + throw new Error(`Could not create product tag ${tag}`, { cause: error }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [command, tag, source, remote = 'origin'] = process.argv.slice(2); + if (command !== 'ensure' || !tag || !source) { + throw new Error('usage: product-release-tag.mjs ensure [remote]'); + } + const result = await ensureProductTag({ tag, source, remote }); + console.log(`Product tag ${tag}: ${result} at ${source}`); +} diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs new file mode 100644 index 0000000000..95e01023f8 --- /dev/null +++ b/scripts/product-release.test.mjs @@ -0,0 +1,665 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { parse as parseYaml } from 'yaml'; +import desktopBuilderConfig from '../apps/desktop/electron-builder.config.mjs'; +import { + parseAsfSourceReferenceTag, + resolveProductManifestIdentity, + resolveProductReleaseIdentity, +} from './product-release-identity.mjs'; +import { + assertExpectedAppleTeam, + createSigningKeychain, + decodeSigningCertificate, + DISTRIBUTION_NODE_RUNTIME_ENTITLEMENTS, + distributionNodeEntitlements, + extractDistributionNodeEntitlements, + extractOfficialNodeEntitlements, + macosArm64MachOAction, + macosArm64CliWrapper, + parseDeveloperIdApplicationIdentity, + pruneThirdPartyDevelopmentArtifacts, + resolveCliWorkspacePackages, + runCommand, + stageWorkspacePackages, + standaloneInstallEnvironment, + standaloneInstallRootManifest, +} from './package-macos-arm64-cli.mjs'; +import { resolveWorkspaceReleaseFiles } from './release-cli-file-policy.mjs'; +import { isTuiReadyOutput, verifyQuarantinedExecution } from './verify-macos-arm64-cli.mjs'; +import { makePtyProbe } from './verify-packaged-app.mjs'; +import { ensureProductTag } from './product-release-tag.mjs'; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, '..'); + +const rootManifest = { + version: '1.2.3', + packageManager: 'npm@11.19.0', + releaseToolchain: { + appleTeamIdentifier: 'FABM2QUA8Q', + node: '24.18.1', + nodeDarwinArm64Sha256: '1'.repeat(64), + }, +}; + +test('one root version defines every product artifact from one source commit', () => { + const identity = resolveProductReleaseIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + sourceReferenceTag: 'v1.2.3-incubating-rc2', + }); + + assert.equal(identity.version, '1.2.3'); + assert.equal(identity.isPrerelease, false); + assert.equal(identity.tag, 'v1.2.3'); + assert.equal(identity.sourceCommit, 'a'.repeat(40)); + assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); + assert.equal(identity.runtimeHostSetupPackage, 'maka-agent@1.2.3'); + assert.deepEqual(identity.publicCommands, ['maka']); + assert.equal(identity.appleTeamIdentifier, 'FABM2QUA8Q'); + assert.equal(identity.nodeVersion, '24.18.1'); + assert.equal(identity.nodeArchive, 'node-v24.18.1-darwin-arm64.tar.xz'); + assert.equal(identity.nodeArchiveSha256, '1'.repeat(64)); + assert.equal( + identity.nodeSourceUrl, + 'https://nodejs.org/download/release/v24.18.1/node-v24.18.1-darwin-arm64.tar.xz', + ); + assert.equal(identity.npmVersion, '11.19.0'); + assert.equal(identity.dmg, 'Maka-1.2.3-mac-arm64.dmg'); + assert.equal(identity.exe, 'Maka-1.2.3-win-x64.exe'); + assert.equal(identity.cliArchive, 'Maka-1.2.3-cli-mac-arm64.zip'); + assert.equal(identity.sourceArchive, 'Maka-1.2.3-bundled-git-source.tar.gz'); + assert.deepEqual(identity.artifacts, { + 'desktop-macos': [ + 'Maka-1.2.3-mac-arm64.dmg', + 'Maka-1.2.3-mac-arm64.dmg.sha256', + 'Maka-1.2.3-mac-arm64.zip', + 'Maka-1.2.3-mac-arm64.zip.blockmap', + 'latest-mac.yml', + ], + 'desktop-windows': [ + 'Maka-1.2.3-win-x64.exe', + 'Maka-1.2.3-win-x64.exe.blockmap', + 'Maka-1.2.3-win-x64.exe.sha256', + 'Maka-1.2.3-win-x64.zip', + 'Maka-1.2.3-win-x64.zip.sha256', + 'latest.yml', + ], + 'cli-macos-arm64': ['Maka-1.2.3-cli-mac-arm64.zip', 'Maka-1.2.3-cli-mac-arm64.zip.sha256'], + source: ['Maka-1.2.3-bundled-git-source.tar.gz', 'Maka-1.2.3-bundled-git-source.tar.gz.sha256'], + }); +}); + +test('the standalone launcher identifies its installed Eval bundle root', async (t) => { + const archiveRoot = await mkdtemp(join(tmpdir(), 'maka-standalone-launcher-')); + t.after(() => rm(archiveRoot, { recursive: true, force: true })); + const launcher = join(archiveRoot, 'bin', 'maka'); + const node = join(archiveRoot, 'libexec', 'node', 'bin', 'node'); + await Promise.all([ + mkdir(join(archiveRoot, 'bin'), { recursive: true }), + mkdir(join(archiveRoot, 'libexec', 'node', 'bin'), { recursive: true }), + ]); + await Promise.all([ + writeFile(launcher, macosArm64CliWrapper()), + writeFile( + node, + `#!/bin/sh +expected=$(CDPATH= cd -P "$(dirname "$0")/../.." && pwd) +[ "$MAKA_EVAL_MAKA_BUNDLE_PATH" = "$expected" ] +printf verified +`, + ), + ]); + await Promise.all([chmod(launcher, 0o755), chmod(node, 0o755)]); + + const { stdout } = await execFileAsync('bash', [launcher]); + + assert.equal(stdout, 'verified'); +}); + +test('the product identity classifies prereleases once for every publication surface', () => { + const version = '1.2.3-beta.2'; + const identity = resolveProductReleaseIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + sourceReferenceTag: `v${version}-incubating-rc1`, + }); + + assert.equal(identity.isPrerelease, true); + assert.equal(identity.tag, `v${version}`); +}); + +test('Desktop packaging derives the Runtime Host setup package from product manifests', async () => { + const manifestIdentity = resolveProductManifestIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + }); + assert.equal(manifestIdentity.runtimeHostSetupPackage, 'maka-agent@1.2.3'); + + const checkedRootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + assert.deepEqual(desktopBuilderConfig.extraMetadata, { + runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`, + }); + assert.deepEqual(desktopBuilderConfig.publish, [ + { provider: 'github', owner: 'apache', repo: 'maka' }, + ]); +}); + +test('the packaged-app probe rejects a mismatched Runtime Host setup package', async () => { + const fixture = await mkdtemp(join(tmpdir(), 'maka-packaged-manifest-')); + try { + const manifestPath = join(fixture, 'package.json'); + const ptyDirectory = join(fixture, 'node_modules', 'node-pty'); + await mkdir(ptyDirectory, { recursive: true }); + await Promise.all([ + writeFile(manifestPath, JSON.stringify({ runtimeHostSetupPackage: 'maka-agent@1.2.3' })), + writeFile( + join(ptyDirectory, 'index.js'), + `module.exports = { spawn() { return { + onData(listener) { queueMicrotask(() => listener('maka-node-pty-ok')); }, + onExit(listener) { setImmediate(() => listener({ exitCode: 0 })); }, +}; } };\n`, + ), + ]); + await execFileAsync(process.execPath, [ + '-e', + makePtyProbe('/bin/echo', ['maka-node-pty-ok'], 'maka-agent@1.2.3'), + manifestPath, + ]); + await assert.rejects( + execFileAsync(process.execPath, [ + '-e', + makePtyProbe('/bin/echo', ['maka-node-pty-ok'], 'maka-agent@1.2.4'), + manifestPath, + ]), + /Packaged Runtime Host setup package mismatch/u, + ); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + +test('product releases accept only an exact same-version ASF source reference', () => { + assert.deepEqual(parseAsfSourceReferenceTag('v1.2.3-incubating-rc2'), { + rcNumber: '2', + tag: 'v1.2.3-incubating-rc2', + version: '1.2.3', + }); + for (const tag of ['v1.2.3-incubating-rc0', 'v1.2.3-incubating-rc01', 'v1.2.3-rc1']) { + assert.throws(() => parseAsfSourceReferenceTag(tag), /ASF source reference must match/u); + } + assert.throws( + () => + resolveProductReleaseIdentity({ + rootManifest, + desktopManifest: { version: '1.2.3' }, + cliManifest: { version: '1.2.3', bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + sourceReferenceTag: 'v1.2.4-incubating-rc1', + }), + /source reference version 1\.2\.4 does not match product 1\.2\.3/u, + ); +}); + +test('the product identity CLI uses the checked-out commit outside GitHub Actions', async () => { + const env = { ...process.env }; + delete env.GITHUB_SHA; + delete env.GITHUB_OUTPUT; + const [{ stdout }, { stdout: head }, manifest] = await Promise.all([ + execFileAsync(process.execPath, [join(repoRoot, 'scripts/product-release-identity.mjs')], { + cwd: repoRoot, + env, + }), + execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }), + readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), + ]); + + assert.equal(stdout.trim(), `Product release v${manifest.version} from ${head.trim()}`); + await assert.rejects( + execFileAsync(process.execPath, [join(repoRoot, 'scripts/product-release-identity.mjs')], { + cwd: repoRoot, + env: { ...env, EXPECTED_PRODUCT_VERSION: '9.9.9' }, + }), + /does not match requested release/u, + ); +}); + +test('product release identity rejects a non-canonical version at its boundary', () => { + const version = '01.2.3'; + assert.throws( + () => + resolveProductReleaseIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + sha: 'a'.repeat(40), + }), + /valid product release version/u, + ); +}); + +test('product tag creation is exact and idempotent but rejects a conflicting commit', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-product-tag-')); + const remote = join(root, 'remote.git'); + const source = join(root, 'source'); + try { + await execFileAsync('git', ['init', '--bare', remote]); + await mkdir(source); + await execFileAsync('git', ['init'], { cwd: source }); + await execFileAsync('git', ['config', 'user.name', 'Maka release test'], { cwd: source }); + await execFileAsync('git', ['config', 'user.email', 'release-test@example.invalid'], { + cwd: source, + }); + await writeFile(join(source, 'source.txt'), 'one\n'); + await execFileAsync('git', ['add', 'source.txt'], { cwd: source }); + await execFileAsync('git', ['commit', '-m', 'first'], { cwd: source }); + const first = ( + await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: source }) + ).stdout.trim(); + + assert.equal( + await ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: first }), + 'created', + ); + assert.equal( + await ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: first }), + 'existing', + ); + + await writeFile(join(source, 'source.txt'), 'two\n'); + await execFileAsync('git', ['commit', '-am', 'second'], { cwd: source }); + const second = ( + await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: source }) + ).stdout.trim(); + await assert.rejects( + ensureProductTag({ cwd: source, remote, tag: 'v1.2.3', source: second }), + /points to .* instead of/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('standalone verification recognizes the current TUI status line through ANSI output', () => { + assert.equal( + isTuiReadyOutput( + '\u001b[1mMaka\u001b[22m\u001b[2m · \u001b[22m\u001b[2mAuto\u001b[22m\u001b[2m · model · provider\u001b[0m', + ), + true, + ); +}); + +test('standalone verification assesses the downloaded quarantine state', async () => { + const commands = []; + + await verifyQuarantinedExecution( + '/artifact/Maka', + '/artifact/Maka/libexec/node/bin/node', + async (command) => { + commands.push(command); + }, + ); + + assert.deepEqual(commands, ['xattr', 'spctl']); +}); + +test('standalone packaging keeps or thins arm64 Mach-O files instead of deleting them', () => { + const macos = 'platform MACOS\n'; + assert.equal(macosArm64MachOAction('arm64', macos), 'keep'); + assert.equal(macosArm64MachOAction('x86_64 arm64', macos), 'thin'); + assert.equal(macosArm64MachOAction('x86_64', macos), 'remove'); + assert.equal(macosArm64MachOAction('arm64', 'platform IOS\n'), 'remove'); +}); + +test('CLI signing accepts one base64 PKCS12 and one isolated Developer ID identity', () => { + const certificate = Buffer.from('pkcs12'); + const encodedCertificate = certificate.toString('base64'); + assert.deepEqual(decodeSigningCertificate(encodedCertificate), { + bytes: Buffer.from('pkcs12'), + }); + assert.deepEqual( + decodeSigningCertificate(`${encodedCertificate.slice(0, 4)}\n${encodedCertificate.slice(4)}`), + { + bytes: certificate, + }, + ); + assert.throws(() => decodeSigningCertificate('not base64!'), /base64-encoded/u); + assert.deepEqual( + parseDeveloperIdApplicationIdentity( + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (FABM2QUA8Q)"\n 1 valid identities found\n', + ), + { + hash: 'ABCDEF0123456789ABCDEF0123456789ABCDEF01', + name: 'Developer ID Application: Maka Test (FABM2QUA8Q)', + teamIdentifier: 'FABM2QUA8Q', + }, + ); + assert.throws( + () => + parseDeveloperIdApplicationIdentity( + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Apple Development: Test"\n', + ), + /one Developer ID Application/u, + ); + assert.throws( + () => + assertExpectedAppleTeam( + { teamIdentifier: 'ABCDEFGHIJ' }, + rootManifest.releaseToolchain.appleTeamIdentifier, + ), + /belongs to Apple team ABCDEFGHIJ, expected FABM2QUA8Q/u, + ); +}); + +test('CLI signing removes temporary credentials when keychain deletion fails', async () => { + const signing = await createSigningKeychain({ + env: { CSC_LINK: Buffer.from('pkcs12').toString('base64') }, + expectedTeamIdentifier: 'FABM2QUA8Q', + run: async (command, args) => { + if (command === 'openssl') { + await writeFile(args[args.indexOf('-out') + 1], 'temporary private key'); + } + if (command === 'security' && args[0] === 'delete-keychain') { + throw new Error('simulated keychain deletion failure'); + } + }, + inspect: async () => ({ + stdout: + ' 1) ABCDEF0123456789ABCDEF0123456789ABCDEF01 "Developer ID Application: Maka Test (FABM2QUA8Q)"\n 1 valid identities found\n', + }), + }); + + await assert.rejects(signing.cleanup(), /simulated keychain deletion failure/u); + await assert.rejects(access(signing.directory), { code: 'ENOENT' }); +}); + +test('CLI signing command failures do not disclose credential arguments', async () => { + const secret = 'temporary-keychain-password'; + await assert.rejects( + runCommand(process.execPath, ['-e', 'process.exit(9)', secret], { + displayArgs: ['-e', '