diff --git a/.agents/skills/testing-workflow/SKILL.md b/.agents/skills/testing-workflow/SKILL.md index 512bd7c7..402c289b 100644 --- a/.agents/skills/testing-workflow/SKILL.md +++ b/.agents/skills/testing-workflow/SKILL.md @@ -33,6 +33,7 @@ suites are outside this distribution's verification. | Permission facade on macOS/Linux | `pnpm test:policy` | | Sandbox on macOS | `pnpm test:sandbox` | | Source-sync, workflow and release tools | `pnpm test:release-tools` | +| npm release archive installation | `MCODE_RELEASE_TAG=vX.Y.Z MCODE_RELEASE_ARCHIVE=/path/to/package.tar.gz pnpm verify --profile package` | | Types and standalone build boundary | `pnpm typecheck`, `pnpm build`, `pnpm check:standalone` | | Published files and generated paths | `pnpm check:source`, `pnpm check:tsconfig` | @@ -61,6 +62,13 @@ Skills under `.agents/skills`, unknown paths and inventory changes require the full profile. The `archive` profile is for source-archive validation, not a way to bypass the clean-commit export requirement. +The `package` profile authenticates an npm release archive, installs it into a +temporary npm prefix, and exercises its launcher, native dependencies and offline +smoke/BYOK suites. It requires `MCODE_RELEASE_TAG` and `MCODE_RELEASE_ARCHIVE` and +does not replace source validation. The release command commits matching root/TUI source versions before tagging. +The release workflow rejects version mismatches and runs the full profile before +package installation checks. + ## Manual evidence and reporting For CLI behavior, exercise the built `dist/cli.js` and inspect stdout, stderr and diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 00000000..cc7d1b10 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,146 @@ +name: CLI release +on: + push: + tags: ['v*'] + pull_request: + paths: + - '.github/workflows/cli-release.yml' + - 'scripts/build.mjs' + - 'scripts/*cli-release.mjs' + - 'scripts/release-cli.mjs' + - 'scripts/lib/cli-release.mjs' + - 'scripts/verify.mjs' + - 'test/smoke.test.mjs' + - 'test/byok.test.mjs' + - 'pnpm-lock.yaml' + - 'package.json' + - 'packages/tui/package.json' + workflow_dispatch: + inputs: + tag: + description: 'Optional tag matching the source version (dry run; does not publish)' + required: false + type: string +permissions: + contents: read +concurrency: + group: cli-release-${{ inputs.tag || github.ref }} + cancel-in-progress: false +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + version: ${{ steps.release.outputs.version }} + matrix: ${{ steps.release.outputs.matrix }} + tag: ${{ steps.release.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: pnpm + - name: Validate release version and revision + id: release + shell: bash + run: | + node --input-type=module -e 'import { appendFileSync } from "node:fs"; import { cliBuildVersion, cliReleaseTargets } from "./scripts/lib/cli-release.mjs"; const tag = process.env.REQUESTED_TAG || `v${cliBuildVersion(process.cwd())}`; const version = cliBuildVersion(process.cwd(), tag); appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\nmatrix=${JSON.stringify({include: cliReleaseTargets})}\n`); appendFileSync(process.env.GITHUB_ENV, `MCODE_RELEASE_TAG=${tag}\n`);' + if [ "$EVENT_NAME" = push ]; then + test "$(git rev-parse "refs/tags/$REQUESTED_TAG^{commit}")" = "$(git rev-parse HEAD)" + fi + env: + EVENT_NAME: ${{ github.event_name }} + REQUESTED_TAG: ${{ inputs.tag || (github.event_name == 'push' && github.ref_name) || '' }} + - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/setup-gitleaks + - name: Scan source history + run: gitleaks git --redact --config .gitleaks.toml --log-opts=--all + - run: pnpm verify + env: + MCODE_VERIFY_REPORT_DIR: ${{ runner.temp }}/build-report + - name: Scan distribution + run: gitleaks dir dist --redact --config .gitleaks.toml + - name: Package the tagged CLI + shell: bash + run: node scripts/package-cli-release.mjs "$MCODE_RELEASE_TAG" "$RUNNER_TEMP/package" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cli-package + path: ${{ runner.temp }}/package/ + retention-days: 14 + if-no-files-found: error + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: ${{ always() }} + with: + name: cli-build-report + path: ${{ runner.temp }}/build-report/ + retention-days: 14 + install: + needs: build + env: + MCODE_RELEASE_TAG: ${{ needs.build.outputs.tag }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-package + path: ${{ runner.temp }}/package + - run: pnpm verify --profile package + env: + MCODE_RELEASE_ARCHIVE: ${{ runner.temp }}/package/minimax-code-${{ needs.build.outputs.version }}.tar.gz + MCODE_VERIFY_REPORT_DIR: ${{ runner.temp }}/install-report + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + if: ${{ always() }} + with: + name: cli-install-${{ matrix.os }}-${{ matrix.node }} + path: ${{ runner.temp }}/install-report/ + retention-days: 14 + publish: + needs: [build, install] + if: github.event_name == 'push' + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + MCODE_RELEASE_TAG: ${{ needs.build.outputs.tag }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: cli-package + path: ${{ runner.temp }}/package + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: cli-install-* + path: ${{ runner.temp }}/reports + - name: Publish only the authenticated, validated archive + run: node scripts/publish-cli-release.mjs + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + MCODE_RELEASE_DIRECTORY: ${{ runner.temp }}/package + MCODE_RELEASE_REPORTS: ${{ runner.temp }}/reports diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66d96cae..e3531287 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ Existing README files, `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, Ma `Node compatibility` runs weekly and on demand against macOS and Linux with Node 22.19.0, 24.2.0, 25 and 26. Windows is also temporarily excluded from this matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. -Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its Linux/macOS archive validation is described in [Releasing](docs/releasing.md). Product npm and installer publication remains a separate release process. +Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its Linux/macOS archive validation is described in [Releasing](docs/releasing.md). `CLI release` builds npm-installable tar.gz packages from version tags and validates the same archive before attaching it to a GitHub Release. Its `package` verification profile tests installation of an existing archive; it does not replace full source verification. npm registry and official installer publication remain separate release processes. Source export reports separate archive creation, extraction, inventory validation, hashing, and cleanup timings in `export.json`. Windows uses native `tar` after complete archive preflight; other systems use the Node extractor. The preflight rejects traversal, links, duplicate entries and Git metadata before writing files. A machine without native `tar` falls back to Node. Set `MCODE_SOURCE_EXTRACTOR=node` or `native` when comparing extractors locally. diff --git a/docs/installation.md b/docs/installation.md index 86f16c40..9939ccca 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,4 +1,35 @@ -# Install from source +# Installation packages and source builds + +## Install a GitHub release archive + +When a CLI release is available on [GitHub Releases](https://github.com/MiniMax-AI/minimax-code/releases), +download `minimax-code-X.Y.Z.tar.gz` and the matching `.sha256` file. This is an npm +installation package containing the built CLI; no source build or pnpm is needed. +Install Node.js 22.19+ (22.x), 24.2+ (24.x), 25 or 26 first. npm still needs network +access to public npm for runtime dependencies. Native dependencies can require +Python and C/C++ build tools when no matching prebuilt binary is available. + +For example, for an available `v0.4.13` release: + +```bash +# Linux; on macOS use: shasum -a 256 -c minimax-code-0.4.13.tar.gz.sha256 +sha256sum -c minimax-code-0.4.13.tar.gz.sha256 +npm install --global ./minimax-code-0.4.13.tar.gz --registry=https://registry.npmjs.org/ --include=optional --ignore-scripts=false --allow-scripts=better-sqlite3 +mcode --version +``` + +Keep optional dependencies enabled and allow the native SQLite installation +script. The tag determines the installed version. GitHub archive installation is +validated on Linux and macOS; Windows package acceptance is currently not run. + +This archive uses the same `@minimax-ai/code` package name, `mcode` command and +default user data directory as the official npm CLI. Installing it globally into +the same npm prefix replaces that npm installation. Update to another GitHub +version by explicitly installing its archive; the built-in updater follows the +official npm registry channel and does not select GitHub release assets. To remove +the package, use `npm uninstall --global @minimax-ai/code`. User data remains in place. + +## Install from source The official CLI is available as [`@minimax-ai/code`](https://www.npmjs.com/package/@minimax-ai/code). Public npm `latest` was 0.4.12 on 2026-09-18. Follow the [official quick start](https://agent.minimax.io/docs/cli/quick-start) or the [README installation steps](../README.md#quick-start) for the macOS / Linux / WSL installer, Windows PowerShell installer, or npm installation. diff --git a/docs/releasing.md b/docs/releasing.md index fc421713..79f274aa 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,4 +1,74 @@ -# Releasing a source preview +# Releasing MiniMax Code + +## Tag-triggered CLI installation packages + +Run the release command from a clean checkout of the latest reviewed `origin/main`. +Git and an authenticated `gh-axi` or `gh` are required: + +```bash +pnpm release:cli --version 0.4.13 --dry-run +pnpm release:cli --version 0.4.13 +``` + +The dry run checks the starting revision, versions and remote refs without changing +files, creating commits or pushing. The release command then: + +1. Creates `release/v0.4.13` from the reviewed starting commit. +2. Bumps `package.json` and `packages/tui/package.json` together and commits them. +3. Creates the annotated `v0.4.13` tag on that version commit. +4. Atomically pushes the release branch and tag, without pushing `main`. +5. Opens a version PR back to `main`; merge it through the normal review process. + +CI requires the tag, both committed source versions and `mcode --version` to agree. +It does not override the source version during a build. An existing tag or release +branch, a non-increasing version, uncommitted files, or a starting commit other +than the latest `origin/main` stops the command before version changes. + +The tag starts the release workflow independently of the version PR. If a network +or PR-creation failure occurs, inspect the local and remote branch/tag before +retrying: the version commit and tag are retained for recovery. If both refs were +pushed but opening the PR failed, open that version PR manually. Never delete and +recreate an already distributed tag. Merge the version PR before starting the +next release so `main` carries the released version. + +The workflow runs the full verification profile and secret scans, builds one +`minimax-code-X.Y.Z.tar.gz` npm installation package, and authenticates and installs +that same archive on Linux and macOS with Node 22.19.0, 24.2.0, 25 and 26. Each +installation checks the generated `mcode` launcher, native SQLite, ripgrep, and +the offline smoke/BYOK suites. Windows validation remains paused. + +Only after every installation succeeds does CI create a GitHub Release with the +archive and its `.sha256` checksum. Tags such as `v0.4.13-rc.1` create prereleases. +Release creation starts as a draft; assets are uploaded before it becomes public. +Existing releases are never overwritten. If publication fails after draft +creation, inspect the draft and workflow artifacts before deciding whether to +finish publication manually or delete only the incomplete draft and rerun. +Never move an already distributed tag to different code. + +To exercise this workflow without publication, dispatch `CLI release` on a +selected branch. An optional tag input must match the committed source version. A manual dispatch only builds and +validates Actions artifacts, even when the requested tag already exists. +PRs that change release tooling also run the build/install matrix using the +committed source version, without creating a tag or publishing a release. + +To reproduce the packaging and installation checks locally, use a clean reviewed +commit and keep output outside the repository: + +```bash +export MCODE_RELEASE_TAG="v$(node -p 'require("./package.json").version')" +pnpm verify +node scripts/package-cli-release.mjs "$MCODE_RELEASE_TAG" /tmp/mcode-release +MCODE_RELEASE_ARCHIVE="/tmp/mcode-release/minimax-code-${MCODE_RELEASE_TAG#v}.tar.gz" pnpm verify --profile package +``` + +The `package` profile validates installation of an existing archive; it does not +replace full source verification. The archive includes the compiled CLI, runtime +assets, licenses and a `release.json` source receipt. npm installs external runtime +dependencies, including native dependencies, for the user's platform. It does not +include Node.js and is not an offline bundle. See [installation](installation.md#install-a-github-release-archive). +This workflow does not publish to the npm registry or change the official installer. + +## Source previews The current source target is MiniMax Code 0.4.12. Workspace and local-build manifests remain `private: true` to prevent accidental npm publication. A source release, npm package, and installer are separate artifacts with separate verification. diff --git a/package.json b/package.json index 00220803..f8e06b86 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "build": "node scripts/build.mjs", + "release:cli": "node scripts/release-cli.mjs", "start": "node dist/cli.js", "mcode": "node dist/cli.js", "verify": "node scripts/verify.mjs", diff --git a/release/public-source.json b/release/public-source.json index 1b2cca7e..3efc597c 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -18,6 +18,7 @@ ".github/branch-protection.json", ".github/dependabot.yml", ".github/workflows/ci.yml", + ".github/workflows/cli-release.yml", ".github/workflows/compatibility.yml", ".github/workflows/label-issue-product.yml", ".github/workflows/security.yml", @@ -3278,6 +3279,7 @@ "scripts/export-source-preview.mjs", "scripts/gen-tsconfig-paths.mjs", "scripts/lib/builtin-skills.mjs", + "scripts/lib/cli-release.mjs", "scripts/lib/local-runtime-assets.mjs", "scripts/lib/mcode-tools-artifact.mjs", "scripts/lib/package-exports.mjs", @@ -3287,10 +3289,14 @@ "scripts/lib/tui-npm-bundle-profile.mjs", "scripts/lib/tui-package-privacy.mjs", "scripts/lib/vitest-suites.mjs", + "scripts/package-cli-release.mjs", "scripts/prepare-source-sync.mjs", + "scripts/publish-cli-release.mjs", + "scripts/release-cli.mjs", "scripts/run-vitest-suite.mjs", "scripts/source-candidate.mjs", "scripts/source-inventory.mjs", + "scripts/verify-cli-release.mjs", "scripts/verify.mjs", "test/byok.test.mjs", "test/executable-resolution.test.ts", diff --git a/scripts/build.mjs b/scripts/build.mjs index 9c98e681..cd77d2e6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -9,6 +9,7 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from "node:url"; import path from "node:path"; import { copyLocalRuntimeAssets } from "./lib/local-runtime-assets.mjs"; @@ -17,6 +18,7 @@ import { shouldCopyTuiRuntimeResource } from "./lib/tui-package-privacy.mjs"; import { TUI_DISABLED_BUILTIN_SKILL_NAMES } from "./lib/builtin-skills.mjs"; import { copyMcodeToolsArtifact } from './lib/mcode-tools-artifact.mjs'; import { readExtraction } from "./lib/release-metadata.mjs"; +import { cliBuildVersion, cliExternalModules } from './lib/cli-release.mjs'; const root = fileURLToPath(new URL("../", import.meta.url)); const metadata = readExtraction(root); @@ -72,7 +74,7 @@ const sourcePlugin = { }); }, }; -const version = packages.get("@minimax/code").manifest.version; +const version = cliBuildVersion(root); const result = await build({ absWorkingDir: root, entryPoints: { @@ -81,12 +83,7 @@ const result = await build({ 'mcode-tools': 'packages/tui/src/cli/mcode-tools-entry.ts', 'matrix-mcp-stdio': 'packages/agent-tools/src/desktop/matrix-mcp-stdio.ts', }, - external: [ - "better-sqlite3", - "@mariozechner/clipboard", - "@vscode/ripgrep", - "@larksuiteoapi/node-sdk", - ], + external: cliExternalModules, outdir, bundle: true, splitting: true, @@ -143,7 +140,12 @@ console.log( writeFileSync( path.join(outdir, "package.json"), JSON.stringify( - { name: "@minimax-ai/code", version, type: "module", private: true }, + { + name: "@minimax-ai/code", version, type: "module", private: true, + ...(process.env.MCODE_RELEASE_TAG ? { + gitHead: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(), + } : {}), + }, null, 2, ) + "\n", diff --git a/scripts/lib/cli-release.mjs b/scripts/lib/cli-release.mjs new file mode 100644 index 00000000..3b1eaae0 --- /dev/null +++ b/scripts/lib/cli-release.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +export function versionFromTag(tag) { + // Accept canonical SemVer release/prerelease tags, with no build metadata. + const number = '(?:0|[1-9][0-9]*)'; + const identifier = '(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)'; + if (typeof tag !== 'string' || tag.trim() !== tag || !new RegExp(`^v${number}\\.${number}\\.${number}(?:-${identifier}(?:\\.${identifier})*)?$`).test(tag)) { + throw new Error('Release tag must be vX.Y.Z or vX.Y.Z-prerelease (canonical SemVer).'); + } + return tag.slice(1); +} + +export function cliBuildVersion(root, tag = process.env.MCODE_RELEASE_TAG) { + const version = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).version; + const tuiVersion = JSON.parse(readFileSync(path.join(root, 'packages/tui/package.json'), 'utf8')).version; + if (version !== tuiVersion) throw new Error('Root and TUI package versions must match.'); + if (tag != null && versionFromTag(tag) !== version) throw new Error('Release tag must match root and TUI package versions. Run the release command before tagging.'); + return version; +} + +// These modules stay outside the JS bundle and must travel with an installation. +export const cliExternalModules = [ + 'better-sqlite3', + '@mariozechner/clipboard', + '@vscode/ripgrep', + '@larksuiteoapi/node-sdk', +]; + +export const cliReleaseTargets = ['ubuntu-latest', 'macos-latest'].flatMap(os => + ['22.19.0', '24.2.0', '25', '26'].map(node => ({ os, node }))); diff --git a/scripts/package-cli-release.mjs b/scripts/package-cli-release.mjs new file mode 100644 index 00000000..e559d95e --- /dev/null +++ b/scripts/package-cli-release.mjs @@ -0,0 +1,114 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { c as createTar } from 'tar'; +import { cliBuildVersion, cliExternalModules } from './lib/cli-release.mjs'; +import { readExtraction, dependencyLicensesPath } from './lib/release-metadata.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const json = file => JSON.parse(readFileSync(file, 'utf8')); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); + +function resolvePackage(name, importer) { + const require = createRequire(path.join(importer, 'package.json')); + for (const search of require.resolve.paths(`${name}/package.json`) ?? []) { + const candidate = path.join(search, name); + if (existsSync(path.join(candidate, 'package.json'))) return realpathSync(candidate); + } + return undefined; +} + +function copyLicenses(stage) { + const licenses = path.join(stage, 'licenses'); + mkdirSync(licenses); + for (const name of ['LICENSE', 'NOTICE', 'LICENSE-STATUS.md', 'THIRD_PARTY_NOTICES.md']) copyFileSync(path.join(root, name), path.join(stage, name)); + copyFileSync(path.join(root, dependencyLicensesPath), path.join(licenses, 'dependency-licenses.json')); + for (const directory of ['third_party/pi-mono', 'third_party/sandbox-runtime']) { + copyFileSync(path.join(root, directory, 'LICENSE'), path.join(licenses, `${path.basename(directory)}-LICENSE`)); + } + const metafile = json(path.join(root, 'dist/metafile.json')); + const packageRoots = new Set(); + for (const input of Object.keys(metafile.inputs)) { + if (!input.includes('node_modules/')) continue; + let directory = path.dirname(path.resolve(root, input)); + while (directory !== path.dirname(directory)) { + const manifest = path.join(directory, 'package.json'); + if (existsSync(manifest) && json(manifest).name) break; + directory = path.dirname(directory); + } + if (existsSync(path.join(directory, 'package.json'))) packageRoots.add(realpathSync(directory)); + } + for (const directory of [...packageRoots].sort()) { + const manifest = json(path.join(directory, 'package.json')); + const target = path.join(licenses, `${manifest.name.replaceAll('/', '+')}@${manifest.version}`); + mkdirSync(target, { recursive: true }); + for (const name of readdirSync(directory).filter(name => /^(licen[cs]e|copying|notice)([.-]|$)/i.test(name))) { + cpSync(path.join(directory, name), path.join(target, name), { recursive: true }); + } + } +} + +export function releaseManifest(importers, version) { + const dependencies = {}, optionalDependencies = {}; + for (const name of cliExternalModules) { + const versions = new Set(importers.map(importer => resolvePackage(name, importer)).filter(Boolean) + .map(directory => json(path.join(directory, 'package.json')).version)); + if (versions.size !== 1) throw new Error(`Expected one installed version for ${name}, found ${[...versions]}`); + const target = name === '@mariozechner/clipboard' ? optionalDependencies : dependencies; + target[name] = [...versions][0]; + } + return { + name: '@minimax-ai/code', version, private: true, type: 'module', license: 'MIT', + description: 'MiniMax Code CLI built from the tagged public source.', + bin: { mcode: 'cli.js' }, + engines: json(path.join(root, 'package.json')).engines, + repository: { type: 'git', url: 'https://github.com/MiniMax-AI/minimax-code.git' }, + dependencies, optionalDependencies, + }; +} + +export async function packageCliRelease({ tag, out }) { + const version = cliBuildVersion(root, tag); + const dist = path.join(root, 'dist'); + if (json(path.join(dist, 'package.json')).version !== version) throw new Error('Build version does not match the release tag. Build with MCODE_RELEASE_TAG first.'); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + if (execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { cwd: root, encoding: 'utf8' }).trim()) throw new Error('Packaging requires a clean committed working tree.'); + if (json(path.join(dist, 'package.json')).gitHead !== revision) throw new Error('Build revision does not match HEAD. Rebuild with MCODE_RELEASE_TAG.'); + const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-release-')); + try { + const stage = path.join(temporary, 'package'); + cpSync(dist, stage, { recursive: true, filter: file => path.basename(file) !== 'metafile.json' }); + const manifest = releaseManifest(readExtraction(root).packageRoots.map(directory => path.join(root, directory)), version); + writeFileSync(path.join(stage, 'package.json'), JSON.stringify(manifest, null, 2) + '\n'); + copyLicenses(stage); + writeFileSync(path.join(stage, 'release.json'), JSON.stringify({ version, tag, revision, buildNode: process.version }, null, 2) + '\n'); + writeFileSync(path.join(stage, 'README.md'), `# MiniMax Code ${version} + +Built from https://github.com/MiniMax-AI/minimax-code/tree/${revision}. +Install this tar.gz with npm. Node.js must satisfy the package engines requirement. +Keep optional dependencies enabled and allow better-sqlite3 installation scripts. +Update by installing a newer GitHub release archive; the built-in updater follows npm. +The archive uses the same package name, mcode command and user data as the official npm CLI. +See https://github.com/MiniMax-AI/minimax-code/blob/${revision}/docs/installation.md. +`); + mkdirSync(out, { recursive: true }); + const archive = path.join(out, `minimax-code-${version}.tar.gz`); + if (existsSync(archive) || existsSync(`${archive}.sha256`)) throw new Error(`Output already exists: ${archive}`); + await createTar({ file: archive, gzip: true, cwd: temporary, portable: true }, ['package']); + writeFileSync(`${archive}.sha256`, `${digest(readFileSync(archive))} ${path.basename(archive)}\n`, { flag: 'wx' }); + console.log(archive); + return archive; + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [tag, out] = process.argv.slice(2); + if (!tag || !out) throw new Error('Usage: node scripts/package-cli-release.mjs vX.Y.Z /path/to/output'); + await packageCliRelease({ tag, out: path.resolve(out) }); +} diff --git a/scripts/publish-cli-release.mjs b/scripts/publish-cli-release.mjs new file mode 100644 index 00000000..dec60d46 --- /dev/null +++ b/scripts/publish-cli-release.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { versionFromTag, cliReleaseTargets } from './lib/cli-release.mjs'; + +export function validateReleaseReports({ archive, reports, version, revision }) { + const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); + assert.equal(readFileSync(`${archive}.sha256`, 'utf8'), `${sha256} ${path.basename(archive)}\n`); + for (const target of cliReleaseTargets) { + const directory = path.join(reports, `cli-install-${target.os}-${target.node}`); + const installation = JSON.parse(readFileSync(path.join(directory, 'package-install.json'), 'utf8')); + const verification = JSON.parse(readFileSync(path.join(directory, 'verification.json'), 'utf8')); + assert.equal(verification.status, 'PASS'); + assert.equal(verification.profile, 'package'); + assert.equal(verification.revision, revision); + assert.equal(verification.gates.find(gate => gate.name === 'test:release-package')?.status, 'PASS'); + assert.equal(installation.status, 'PASS'); + assert.equal(installation.version, version); + assert.equal(installation.revision, revision); + assert.equal(installation.sha256, sha256); + assert.equal(installation.platform, target.os.startsWith('ubuntu') ? 'linux' : 'darwin'); + assert.ok(installation.node === `v${target.node}` || installation.node.startsWith(`v${target.node}.`)); + } + return sha256; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const tag = process.env.MCODE_RELEASE_TAG; + const version = versionFromTag(tag); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + if (!process.env.MCODE_RELEASE_DIRECTORY || !process.env.MCODE_RELEASE_REPORTS) throw new Error('Release directory and reports are required.'); + const archive = path.join(process.env.MCODE_RELEASE_DIRECTORY, `minimax-code-${version}.tar.gz`); + const sha256 = validateReleaseReports({ archive, reports: process.env.MCODE_RELEASE_REPORTS, version, revision }); + const notes = path.join(process.env.MCODE_RELEASE_DIRECTORY, 'release-notes.md'); + writeFileSync(notes, `Built from public source commit ${revision}. SHA-256: \`${sha256}\`. + +Download the tar.gz and its checksum, verify the checksum, then install: + +\`\`\`sh +npm install --global ./minimax-code-${version}.tar.gz --registry=https://registry.npmjs.org/ --include=optional --ignore-scripts=false --allow-scripts=better-sqlite3 +mcode --version +\`\`\` + +Requires Node.js 22.19+ (22.x), 24.2+ (24.x), 25 or 26 and network access to public npm for runtime dependencies. Native dependencies may require a C/C++ toolchain and Python when no prebuilt binary is available. + +The same archive passed npm installation and offline CLI/BYOK tests on Linux and macOS across the supported Node lines. Windows and live-service acceptance were not run. This package shares the official npm CLI's package name and user data. Install future GitHub archives explicitly; the built-in updater follows the npm registry channel. +`); + // An existing release is never overwritten. Upload to a draft so failures + // cannot expose a release with missing assets; maintainers can inspect/retry. + const gh = (...args) => execFileSync('gh', args, { stdio: 'inherit' }); + gh('release', 'create', tag, '--verify-tag', '--draft', '--title', `MiniMax Code ${version}`, '--notes-file', notes, ...(version.includes('-') ? ['--prerelease'] : [])); + gh('release', 'upload', tag, archive, `${archive}.sha256`); + gh('release', 'edit', tag, '--draft=false'); +} diff --git a/scripts/release-cli.mjs b/scripts/release-cli.mjs new file mode 100644 index 00000000..ce7efbe2 --- /dev/null +++ b/scripts/release-cli.mjs @@ -0,0 +1,99 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { cliBuildVersion, versionFromTag } from './lib/cli-release.mjs'; + +const versionFiles = ['package.json', 'packages/tui/package.json']; + +export function compareVersions(left, right) { + versionFromTag(`v${left}`); versionFromTag(`v${right}`); + const parse = version => { + const index = version.indexOf('-'); + return { core: (index < 0 ? version : version.slice(0, index)).split('.').map(BigInt), + pre: index < 0 ? undefined : version.slice(index + 1).split('.') }; + }; + const a = parse(left), b = parse(right); + const compare = (x, y) => x === y ? 0 : x > y ? 1 : -1; + for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return compare(a.core[i], b.core[i]); + if (!a.pre || !b.pre) return compare(a.pre ? 0 : 1, b.pre ? 0 : 1); + for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { + const x = a.pre[i], y = b.pre[i]; + if (x === y) continue; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + const nx = /^[0-9]+$/.test(x), ny = /^[0-9]+$/.test(y); + return nx && ny ? compare(BigInt(x), BigInt(y)) : nx !== ny ? (nx ? -1 : 1) : compare(x, y); + } + return 0; +} + +function githubCli(root) { + for (const command of ['gh-axi', 'gh']) { + try { + execFileSync(command, ['--help'], { cwd: root, stdio: 'ignore' }); + return command; + } catch { /* Try the standard GitHub CLI when the wrapper is unavailable. */ } + } + throw new Error('Install and authenticate gh (or gh-axi) to create the version PR.'); +} + +function createVersionPullRequest({ root, branch, version, tag }) { + const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-version-pr-')); + try { + const body = path.join(temporary, 'body.md'); + writeFileSync(body, `Update the root and TUI source versions to ${version}.\n\nTag \`${tag}\` points to this version commit. The tag-triggered CLI release workflow builds and validates the npm installation archive. Merge this PR to carry the released source version back to main; do not move or recreate the release tag.\n`); + execFileSync(githubCli(root), ['pr', 'create', '--base', 'main', '--head', branch, + '--title', `chore: release MiniMax Code ${version}`, '--body-file', body], { cwd: root, stdio: 'inherit' }); + } finally { rmSync(temporary, { recursive: true, force: true }); } +} + +export function releaseCli({ root, version, dryRun = false, openPullRequest = createVersionPullRequest }) { + const tag = `v${version}`; + versionFromTag(tag); + const branch = `release/${tag}`; + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('status', '--porcelain')) throw new Error('Release requires a clean working tree, including untracked files.'); + const current = cliBuildVersion(root, null); + if (compareVersions(version, current) <= 0) throw new Error(`Release version must be newer than ${current}.`); + git('fetch', '--no-tags', 'origin', 'main'); + const base = git('rev-parse', 'HEAD'); + if (base !== git('rev-parse', 'refs/remotes/origin/main')) throw new Error('Start the release from the latest origin/main commit.'); + for (const ref of [`refs/tags/${tag}`, `refs/heads/${branch}`]) { + let exists = false; + try { git('show-ref', '--verify', '--quiet', ref); exists = true; } catch (error) { if (error.status !== 1) throw error; } + if (exists) throw new Error(`Release ref already exists locally: ${ref}`); + } + if (git('ls-remote', 'origin', `refs/tags/${tag}`, `refs/heads/${branch}`)) throw new Error('Release tag or branch already exists on origin.'); + const plan = { current, version, tag, branch, base, files: versionFiles }; + console.log(JSON.stringify({ ...plan, dryRun }, null, 2)); + if (dryRun) return plan; + // Fail before making local commits if the required PR client is unavailable. + if (openPullRequest === createVersionPullRequest) + execFileSync(githubCli(root), ['api', 'user', '--jq', '.login'], { cwd: root, stdio: 'ignore' }); + git('switch', '-c', branch); + for (const name of versionFiles) { + const file = path.join(root, name); + const manifest = JSON.parse(readFileSync(file, 'utf8')); + manifest.version = version; + writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n'); + } + git('add', '--', ...versionFiles); + git('commit', '-m', `chore: release MiniMax Code ${version}`); + if (git('status', '--porcelain')) throw new Error('Working tree changed during the version commit; inspect it before tagging.'); + if (git('diff', '--name-only', base, 'HEAD') !== [...versionFiles].sort().join('\n')) throw new Error('Version commit must change only the two package manifests.'); + cliBuildVersion(root, tag); + git('tag', '-a', tag, '-m', `MiniMax Code ${version}`); + // Push both refs or neither. Never force an existing tag or update main. + git('push', '--atomic', '--set-upstream', 'origin', `refs/heads/${branch}:refs/heads/${branch}`, `refs/tags/${tag}:refs/tags/${tag}`); + console.log(`Pushed ${branch} and ${tag}; CI will build and publish the archive after validation.`); + openPullRequest({ root, branch, version, tag }); + return { ...plan, revision: git('rev-parse', 'HEAD') }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { version: { type: 'string' }, 'dry-run': { type: 'boolean', default: false } } }); + if (!values.version) throw new Error('Usage: pnpm release:cli --version X.Y.Z [--dry-run]'); + releaseCli({ root: fileURLToPath(new URL('../', import.meta.url)), version: values.version, dryRun: values['dry-run'] }); +} diff --git a/scripts/verify-cli-release.mjs b/scripts/verify-cli-release.mjs new file mode 100644 index 00000000..09f64753 --- /dev/null +++ b/scripts/verify-cli-release.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { versionFromTag } from './lib/cli-release.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const version = versionFromTag(process.env.MCODE_RELEASE_TAG); +if (!process.env.MCODE_RELEASE_ARCHIVE) throw new Error('MCODE_RELEASE_ARCHIVE is required.'); +if (!['linux', 'darwin'].includes(process.platform)) throw new Error('Package validation currently supports Linux and macOS.'); +const archive = path.resolve(process.env.MCODE_RELEASE_ARCHIVE); +const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); +assert.equal(readFileSync(`${archive}.sha256`, 'utf8'), `${sha256} ${path.basename(archive)}\n`, 'Release archive checksum mismatch'); +const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); +const temporary = mkdtempSync(path.join(tmpdir(), 'mcode-package-install-')); +try { + // No global install or real user profile is modified. Dependencies and the + // generated npm launcher must resolve from this fresh installation alone. + const prefix = path.join(temporary, 'install'); + const home = path.join(temporary, 'home'); + mkdirSync(home); + const env = { + ...process.env, HOME: home, USERPROFILE: home, + MINIMAX_DATA_DIR: path.join(home, 'data'), MAVIS_DATA_DIR: path.join(home, 'data'), + MCODE_DISABLE_TELEMETRY: '1', + }; + for (const name of Object.keys(env)) { + if (/^npm_config_/i.test(name)) delete env[name]; + } + Object.assign(env, { + npm_config_cache: path.join(temporary, 'npm-cache'), + npm_config_userconfig: path.join(home, '.npmrc'), + }); + delete env.NODE_PATH; + delete env.NODE_OPTIONS; + execFileSync('npm', ['install', '--global', '--prefix', prefix, + '--registry=https://registry.npmjs.org/', '--include=optional', '--ignore-scripts=false', + '--allow-scripts=better-sqlite3', '--no-audit', '--no-fund', archive], + { cwd: home, env, stdio: 'inherit', timeout: 300000 }); + const installed = path.join(prefix, 'lib/node_modules/@minimax-ai/code'); + const release = JSON.parse(readFileSync(path.join(installed, 'release.json'), 'utf8')); + assert.equal(release.version, version); + assert.equal(release.tag, process.env.MCODE_RELEASE_TAG); + assert.equal(release.revision, revision); + const result = execFileSync(path.join(prefix, 'bin/mcode'), ['--version'], { cwd: home, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.trim(), version); + const require = createRequire(path.join(installed, 'package.json')); + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + try { assert.equal(db.prepare('select 42 as value').get().value, 42); } finally { db.close(); } + assert.match(execFileSync(require('@vscode/ripgrep').rgPath, ['--version'], { encoding: 'utf8' }), /ripgrep/); + execFileSync(process.execPath, ['--test', 'test/smoke.test.mjs', 'test/byok.test.mjs'], { + cwd: root, env: { ...env, MCODE_TEST_CLI: path.join(installed, 'cli.js') }, stdio: 'inherit', timeout: 240000, + }); + if (process.env.MCODE_VERIFY_REPORT_DIR) { + mkdirSync(process.env.MCODE_VERIFY_REPORT_DIR, { recursive: true }); + writeFileSync(path.join(process.env.MCODE_VERIFY_REPORT_DIR, 'package-install.json'), JSON.stringify({ + status: 'PASS', version, revision, sha256, platform: process.platform, arch: process.arch, node: process.version, + }, null, 2) + '\n'); + } + console.log(`Verified npm installation of ${path.basename(archive)} (${sha256}).`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 23e3720b..d47f4c2f 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -25,8 +25,10 @@ const { values } = parseArgs({ }, }); const profile = values.profile; -if (!["full", "platform", "docs", "archive"].includes(profile)) +if (!["full", "platform", "docs", "archive", "package"].includes(profile)) throw new Error(`Unknown verification profile: ${profile}`); +if (profile === 'package' && !['darwin', 'linux'].includes(process.platform)) + throw new Error('Package verification currently supports Linux and macOS only.'); // Listing must not leave a temporary export directory behind. const temporary = values.list ? undefined @@ -61,9 +63,17 @@ const steps = [ }, // Seatbelt sandbox backend; only macOS provides the native helper. { name: "test:sandbox", script: "test:sandbox", platforms: ["darwin"] }, + { + name: "test:release-package", + command: ['scripts/verify-cli-release.mjs'], + packageOnly: true, + platforms: ['darwin', 'linux'], + }, ]; function skipReason(step) { + if (profile === 'package' && !step.packageOnly) return 'validating an npm release archive'; + if (profile !== 'package' && step.packageOnly) return 'requires an npm release archive'; if (profile === "docs" && !step.docs) return "documentation-only change"; if (profile === "archive" && step.requiresGit) return "validating an already exported archive"; diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 64dba7ee..9c68d556 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -17,7 +17,7 @@ import Database from "better-sqlite3"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { withoutProxyEnvironment } from "./offline-environment.mjs"; -const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); +const cli = process.env.MCODE_TEST_CLI ?? fileURLToPath(new URL("../dist/cli.js", import.meta.url)); // This fixture validates BYOK transport and real Runtime persistence, not model quality. test( "BYOK runs without managed login and resumes its saved conversation", @@ -264,27 +264,30 @@ test( proxyNames.map((name) => [name, proxyValue(name)]), )], ]) { - await t.test(`offline BYOK ignores ambient proxies: ${label}`, async () => { - const environment = Object.freeze({ ...cleanEnvironment, ...proxies }); - // NO_PROXY alone does not enable proxy mode, so check its isolation explicitly. - const isolated = withoutProxyEnvironment(environment); - for (const name of proxyNames) assert.equal(isolated[name], ""); - assert.equal(isolated.PATH, environment.PATH); - const beforeRequests = requests.length; - const managedAudit = `${networkAudit}.managed`; - const beforeManaged = readFileSync(managedAudit, "utf8").length; - await run([ - "provider", "test", selected.providerId, "--model", "fixture-model", - ], environment); - assert.ok(requests.length > beforeRequests, "The local provider must receive the request"); - assert.equal(requests[beforeRequests].body.model, "fixture-model"); - assert.match( - readFileSync(managedAudit, "utf8").slice(beforeManaged), - /https:\/\/models\.dev\/api\.json|\/mavis\/api\/v1\/models-dev\/catalog/, - ); - assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); - for (const [name, value] of Object.entries(proxies)) assert.equal(environment[name], value); - }); + // Node 24.0–24.2 returns undefined from t.test(), so awaiting it does + // not wait for the proxy checks. Keep this shared-fixture phase directly + // sequential before changing rejectConnection or checking request counts. + // https://github.com/nodejs/node/issues/58227 + t.diagnostic(`offline BYOK ignores ambient proxies: ${label}`); + const environment = Object.freeze({ ...cleanEnvironment, ...proxies }); + // NO_PROXY alone does not enable proxy mode, so check its isolation explicitly. + const isolated = withoutProxyEnvironment(environment); + for (const name of proxyNames) assert.equal(isolated[name], ""); + assert.equal(isolated.PATH, environment.PATH); + const beforeRequests = requests.length; + const managedAudit = `${networkAudit}.managed`; + const beforeManaged = readFileSync(managedAudit, "utf8").length; + await run([ + "provider", "test", selected.providerId, "--model", "fixture-model", + ], environment); + assert.ok(requests.length > beforeRequests, "The local provider must receive the request"); + assert.equal(requests[beforeRequests].body.model, "fixture-model"); + assert.match( + readFileSync(managedAudit, "utf8").slice(beforeManaged), + /https:\/\/models\.dev\/api\.json|\/mavis\/api\/v1\/models-dev\/catalog/, + ); + assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); + for (const [name, value] of Object.entries(proxies)) assert.equal(environment[name], value); } const configPath = path.join(dataDir, "config.yaml"); const savedConfig = () => parseYaml(readFileSync(configPath, "utf8")); diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 055efcb8..72082a74 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -8,9 +8,10 @@ import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; import { parse as parseYaml } from "yaml"; import { withoutProxyEnvironment } from "./offline-environment.mjs"; +import { cliBuildVersion } from '../scripts/lib/cli-release.mjs'; const root = fileURLToPath(new URL("../", import.meta.url)); -const cli = path.join(root, "dist/cli.js"); +const cli = process.env.MCODE_TEST_CLI ?? path.join(root, "dist/cli.js"); // Full runtime startup can exceed 15s on Windows CI (ACP took 22s). // Match the ACP startup budget; lightweight help/validation stays at 15s. const runtimeTimeoutMs = process.platform === "win32" ? 30000 : 15000; @@ -19,9 +20,7 @@ function assertSuccessfulChild(result) { `CLI spawn failed: ${result.error?.message}; signal=${result.signal}; stderr=${result.stderr}`); assert.equal(result.status, 0, result.stderr); } -const version = JSON.parse( - readFileSync(path.join(root, "packages/tui/package.json"), "utf8"), -).version; +const version = cliBuildVersion(root); function fixture(t, environment = process.env) { const dataDir = mkdtempSync(path.join(tmpdir(), "minimax-code-smoke-")); const audit = path.join(dataDir, "network-attempts.log"); diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index 75ccf070..584f464e 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -14,6 +14,175 @@ import { gzipSync } from 'node:zlib'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; +import { cliBuildVersion, cliExternalModules, cliReleaseTargets, versionFromTag } from '../scripts/lib/cli-release.mjs'; +import { releaseManifest } from '../scripts/package-cli-release.mjs'; +import { validateReleaseReports } from '../scripts/publish-cli-release.mjs'; +import { compareVersions, releaseCli } from '../scripts/release-cli.mjs'; + +test('release tags are canonical and must match both source versions without overriding them', t => { + for (const tag of ['v0.4.13', 'v1.0.0-rc.1', 'v0.0.0', 'v2.3.4-beta-test.0']) + assert.equal(versionFromTag(tag), tag.slice(1)); + for (const tag of [undefined, '', '0.4.13', 'v01.2.3', 'v1.02.3', 'v1.2.03', 'v1.2.3-01', 'v1.2.3+build', 'v1.2.3\n', 'v1.2.3;echo bad', 'v1.2.3/../bad']) + assert.throws(() => versionFromTag(tag), /Release tag/); + const f = fixture(t); + mkdirSync(path.join(f.root, 'packages/tui'), { recursive: true }); + const manifest = path.join(f.root, 'packages/tui/package.json'); + writeFileSync(manifest, JSON.stringify({ version: '0.4.12', private: true })); + writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ version: '0.4.12', private: true })); + const before = readFileSync(manifest); + assert.equal(cliBuildVersion(f.root, 'v0.4.12'), '0.4.12'); + assert.throws(() => cliBuildVersion(f.root, 'v0.4.13-rc.1'), /Release tag must match/); + assert.deepEqual(readFileSync(manifest), before); + writeFileSync(manifest, JSON.stringify({ version: '0.4.13' })); + assert.throws(() => cliBuildVersion(f.root, null), /Root and TUI/); +}); + +function cliReleaseFixture(t) { + const home = mkdtempSync(path.join(tmpdir(), 'cli-release-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const root = path.join(home, 'checkout'), remote = path.join(home, 'remote.git'); + execFileSync('git', ['init', '--bare', remote], { stdio: 'ignore' }); + execFileSync('git', ['init', '--initial-branch=main', root], { stdio: 'ignore' }); + const git = (...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('config', 'user.name', 'Release Fixture'); git('config', 'user.email', 'release@example.invalid'); + git('config', 'core.hooksPath', path.join(home, 'no-hooks')); + mkdirSync(path.join(root, 'packages/tui'), { recursive: true }); + for (const name of ['package.json', 'packages/tui/package.json']) writeFileSync(path.join(root, name), JSON.stringify({ name: 'fixture', version: '1.2.3', private: true }, null, 2) + '\n'); + git('add', '.'); git('commit', '-m', 'Fixture baseline'); git('remote', 'add', 'origin', remote); git('push', '-u', 'origin', 'main'); + return { root, remote, git, base: git('rev-parse', 'HEAD') }; +} + +test('release command bumps source before tagging, pushes a release branch and opens its version PR', t => { + const f = cliReleaseFixture(t); + const prs = []; + const plan = releaseCli({ root: f.root, version: '1.2.4', dryRun: true }); + assert.equal(plan.tag, 'v1.2.4'); + assert.equal(f.git('status', '--porcelain'), ''); + assert.equal(f.git('rev-parse', 'HEAD'), f.base); + const result = releaseCli({ root: f.root, version: '1.2.4', openPullRequest: request => prs.push(request) }); + assert.equal(f.git('branch', '--show-current'), 'release/v1.2.4'); + assert.equal(f.git('cat-file', '-t', 'v1.2.4'), 'tag'); + assert.equal(f.git('rev-parse', 'v1.2.4^{commit}'), result.revision); + assert.equal(f.git('rev-parse', 'origin/main'), f.base); + assert.equal(f.git('rev-parse', 'origin/release/v1.2.4'), result.revision); + for (const name of ['package.json', 'packages/tui/package.json']) + assert.equal(JSON.parse(f.git('show', `v1.2.4:${name}`)).version, '1.2.4'); + assert.equal(prs.length, 1); + assert.equal(prs[0].branch, 'release/v1.2.4'); + assert.equal(f.git('ls-remote', 'origin', 'refs/heads/main').split('\t')[0], f.base); + assert.ok(f.git('ls-remote', 'origin', 'refs/tags/v1.2.4')); +}); + +test('release command rejects dirty trees, version regressions, stale bases and existing remote tags', t => { + const f = cliReleaseFixture(t); + const release = version => releaseCli({ root: f.root, version, dryRun: true }); + for (const version of ['1.2.3', '1.2.2', '1.2.3-rc.1']) assert.throws(() => release(version), /must be newer/); + writeFileSync(path.join(f.root, 'untracked'), 'unfinished'); + assert.throws(() => release('1.2.4'), /clean working tree/); + f.git('add', 'untracked'); f.git('commit', '-m', 'Unreviewed change'); + assert.throws(() => release('1.2.4'), /latest origin\/main/); + f.git('switch', '--detach', f.base); + f.git('tag', 'v1.2.4'); f.git('push', 'origin', 'refs/tags/v1.2.4'); f.git('tag', '-d', 'v1.2.4'); + assert.throws(() => release('1.2.4'), /already exists on origin/); + assert.equal(f.git('rev-parse', 'HEAD'), f.base); + assert.equal(f.git('status', '--porcelain'), ''); + for (const [a, b] of [['1.2.4', '1.2.3'], ['1.2.4', '1.2.4-rc.1'], ['1.2.4-rc.10', '1.2.4-rc.2'], ['1.2.4-beta', '1.2.4-1']]) { + assert.equal(compareVersions(a, b), 1); assert.equal(compareVersions(b, a), -1); + } +}); + +test('rejected tag pushes cannot leave a partial remote release branch or open a version PR', { skip: process.platform === 'win32' }, t => { + const f = cliReleaseFixture(t); + execFileSync('git', ['--git-dir', f.remote, 'config', 'core.hooksPath', path.join(f.remote, 'hooks')]); + writeFileSync(path.join(f.remote, 'hooks/update'), '#!/bin/sh\ncase "$1" in refs/tags/*) exit 1 ;; esac\nexit 0\n', { mode: 0o755 }); + let opened = false; + assert.throws(() => releaseCli({ root: f.root, version: '1.2.4', openPullRequest: () => { opened = true; } })); + assert.equal(opened, false); + assert.equal(f.git('ls-remote', 'origin', 'refs/tags/v1.2.4', 'refs/heads/release/v1.2.4'), ''); + assert.equal(f.git('rev-parse', 'origin/main'), f.base); + assert.equal(f.git('rev-parse', 'v1.2.4^{commit}'), f.git('rev-parse', 'HEAD')); +}); + +test('npm release manifests require native SQLite and pin installed external dependencies', t => { + const f = fixture(t); + for (const name of cliExternalModules) { + const directory = path.join(f.root, 'node_modules', name); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ name, version: '1.2.3' })); + } + const manifest = releaseManifest([f.root], '0.4.13'); + assert.equal(manifest.version, '0.4.13'); + assert.equal(manifest.private, true); + assert.equal(manifest.bin.mcode, 'cli.js'); + assert.equal(manifest.dependencies['better-sqlite3'], '1.2.3'); + assert.equal(manifest.dependencies['@vscode/ripgrep'], '1.2.3'); + assert.equal(manifest.optionalDependencies['@mariozechner/clipboard'], '1.2.3'); + assert.equal(manifest.scripts, undefined); + const conflicting = path.join(f.source, 'node_modules/better-sqlite3'); + mkdirSync(conflicting, { recursive: true }); + writeFileSync(path.join(conflicting, 'package.json'), JSON.stringify({ name: 'better-sqlite3', version: '9.9.9' })); + assert.throws(() => releaseManifest([f.root, f.source], '0.4.13'), /Expected one installed version/); +}); + +test('CLI publication requires every supported installation receipt for the exact archive and revision', t => { + const f = fixture(t); + const archive = path.join(f.root, 'minimax-code-0.4.13.tar.gz'); + writeFileSync(archive, 'synthetic archive'); + const sha256 = createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(`${archive}.sha256`, `${sha256} ${path.basename(archive)}\n`); + const revision = 'a'.repeat(40); + const reports = path.join(f.root, 'reports'); + for (const target of cliReleaseTargets) { + const directory = path.join(reports, `cli-install-${target.os}-${target.node}`); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, 'package-install.json'), JSON.stringify({ + status: 'PASS', version: '0.4.13', revision, sha256, + platform: target.os.startsWith('ubuntu') ? 'linux' : 'darwin', node: `v${target.node}`, + })); + writeFileSync(path.join(directory, 'verification.json'), JSON.stringify({ + status: 'PASS', profile: 'package', revision, gates: [{ name: 'test:release-package', status: 'PASS' }], + })); + } + const options = { archive, reports, version: '0.4.13', revision }; + assert.equal(validateReleaseReports(options), sha256); + assert.throws(() => validateReleaseReports({ ...options, version: '0.4.14' })); + assert.throws(() => validateReleaseReports({ ...options, revision: 'b'.repeat(40) })); + const target = cliReleaseTargets.at(-1); + const receipt = path.join(reports, `cli-install-${target.os}-${target.node}`, 'package-install.json'); + const original = readFileSync(receipt, 'utf8'); + for (const override of [{ sha256: '0'.repeat(64) }, { status: 'FAIL' }, { node: 'v20.0.0' }]) { + writeFileSync(receipt, JSON.stringify({ ...JSON.parse(original), ...override })); + assert.throws(() => validateReleaseReports(options)); + } + rmSync(receipt); + assert.throws(() => validateReleaseReports(options)); + writeFileSync(receipt, original); + writeFileSync(archive, 'changed archive'); + assert.throws(() => validateReleaseReports(options)); +}); + +test('CLI release publishes only tag pushes after full verification and archive installation', () => { + const workflow = parseYaml(readFileSync(new URL('../.github/workflows/cli-release.yml', import.meta.url), 'utf8')); + assert.deepEqual(workflow.on.push, { tags: ['v*'] }); + assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, false); + assert.equal(workflow.permissions.contents, 'read'); + assert.equal(workflow.concurrency['cancel-in-progress'], false); + assert.ok(workflow.jobs.build.steps.some(step => step.run === 'pnpm verify')); + assert.ok(workflow.jobs.build.steps.some(step => step.run?.includes('cliBuildVersion(process.cwd(), tag)'))); + assert.deepEqual(workflow.jobs.publish.needs, ['build', 'install']); + assert.equal(workflow.jobs.publish.if, "github.event_name == 'push'"); + assert.equal(workflow.jobs.publish.permissions.contents, 'write'); + assert.equal(workflow.jobs.install.strategy.matrix, '${{ fromJSON(needs.build.outputs.matrix) }}'); + const install = workflow.jobs.install.steps.find(step => step.run === 'pnpm verify --profile package'); + assert.ok(install.env.MCODE_RELEASE_ARCHIVE.endsWith('.tar.gz')); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.uses && !step.uses.startsWith('./')) assert.match(step.uses, /@[a-f0-9]{40}$/); + if (step.uses?.startsWith('actions/checkout@')) assert.equal(step.with['persist-credentials'], false); + if (step.run) assert.doesNotMatch(step.run, /\$\{\{.*(?:inputs|github\.(?:ref|event))/); + } + } +}); import { runInNewContext } from 'node:vm'; function fixture(t) { @@ -194,6 +363,11 @@ test('documentation and archive profiles preserve their required validation gate const archive = f.run(['--profile', 'archive', '--list']); assert.equal(archive.status, 0, archive.stderr); assert.deepEqual(archive.stdout.trim().split('\n'), full.filter(g => g !== 'export source preview')); + const packageProfile = f.run(['--profile', 'package', '--list']); + if (['linux', 'darwin'].includes(process.platform)) { + assert.equal(packageProfile.status, 0, packageProfile.stderr); + assert.equal(packageProfile.stdout.trim(), 'test:release-package'); + } else assert.notEqual(packageProfile.status, 0); const failure = f.run(['--profile', 'docs'], { VERIFY_FIXTURE_FAIL: 'check:source' }); assert.equal(failure.status, 1); assert.equal(f.report().status, 'FAIL');