Skip to content

feat(npm): publish APM to npm via platform-specific optional packages#1017

Open
anrouxel wants to merge 18 commits intomicrosoft:mainfrom
anrouxel:feat/npm
Open

feat(npm): publish APM to npm via platform-specific optional packages#1017
anrouxel wants to merge 18 commits intomicrosoft:mainfrom
anrouxel:feat/npm

Conversation

@anrouxel
Copy link
Copy Markdown

Closes #732

TL;DR

Issue #732 reported that APM has no npm distribution channel, blocking TypeScript projects from consuming it as a dependency without installing a separate CLI tool manually on each dev machine. This PR implements the full npm publishing infrastructure: a pnpm monorepo, five platform-specific binary packages (@apm/cli-*), a Node.js launcher wrapper (@apm/apm), assembly scripts, and a new publish-npm CI job. After merge, users can run npm install -g @apm/apm or pin "@apm/apm": "^0.10.0" in devDependencies.

Problem (WHY)

  • No npm distribution (#732): "It can be easily added as dependency on any TS project so no need to install another CLI tool manually on each dev machine."@davi-mplan. APM had no presence on the npm registry.
  • No automated publish pipeline. Every release required manual steps; there was no CI job wiring GitHub release artifacts to npm.
  • No cross-platform binary dispatch. Without a launcher, a single npm package cannot ship five platform binaries and route execution to the correct one at runtime.

Note

The platform-as-optionalDependency pattern is the established approach for shipping native binaries via npm (esbuild, Bun, Vite all use it). Only the package for the current OS/arch is downloaded; the other four are skipped.

Approach (WHAT)

Concern Solution
Monorepo structure Root package.json + pnpm-workspace.yaml covering all packages/@apm/*
Cross-platform dispatch packages/@apm/apm/bin/apm Node.js launcher resolves correct binary via PLATFORMS[process.platform][process.arch]
Platform packages Five packages/@apm/cli-* stubs; binaries injected at publish time by generate-packages.mjs
Version sync generate-packages.mjs propagates @apm/apm version into all platform package.jsons
CI publish New publish-npm job in build-release.yml, gated on tag + non-private + non-prerelease

Implementation (HOW)

File Change
pnpm-workspace.yaml Declares workspace glob packages/@apm/*
package.json (root) Private monorepo manifest, pins pnpm@10.33.2
packages/@apm/apm/package.json Main published package @apm/apm@0.10.0; lists all five platform packages as optionalDependencies; enables npm provenance
packages/@apm/apm/bin/apm Node.js launcher: reads process.platform/arch, resolves the native binary via require.resolve(), and spawnSync with inherited stdio; honours APM_BINARY env override
packages/@apm/cli-*/package.json Stub manifests with os/cpu fields so npm only downloads the matching package
packages/@apm/apm/scripts/generate-packages.mjs Copies built binary from dist/ or CI artifacts/, updates package.json version in all packages, sets chmod 0o755
packages/@apm/apm/scripts/update-beta-version.mjs Stamps a beta version string from INPUT_VERSION env into @apm/apm/package.json
packages/@apm/apm/scripts/update-preview-version.mjs Same pattern for preview/RC versions
.github/workflows/build-release.yml New publish-npm job: downloads artifacts, installs Node 24, runs generate-packages.mjs, then publishes all six packages in dependency order

Diagrams

Package resolution at install time

npm downloads only the platform package matching the current OS/arch.

graph LR
    A["npm install -g @apm/apm"] --> B["@apm/apm (wrapper)"]
    B -- "optionalDependency (linux/x64 only)" --> C["@apm/cli-linux-x64"]
    B -- "optionalDependency (darwin/arm64 only)" --> D["@apm/cli-darwin-arm64"]
    B -- "optionalDependency (darwin/x64 only)" --> E["@apm/cli-darwin-x64"]
    B -- "optionalDependency (linux/arm64 only)" --> F["@apm/cli-linux-arm64"]
    B -- "optionalDependency (win32/x64 only)" --> G["@apm/cli-win32-x64"]
Loading

CI publish flow

publish-npm runs after all build/test/release jobs succeed.

sequenceDiagram
    participant CI as build-release.yml
    participant S as generate-packages.mjs
    participant npm
    CI->>CI: download-artifact (all platforms)
    CI->>S: node generate-packages.mjs
    S->>S: copy binary + chmod 0o755
    S->>S: sync version in package.json
    loop each of 6 packages
        CI->>npm: npm publish --tag latest
    end
Loading

Trade-offs

  • pnpm workspace added at repo root — additive but changes repo structure. Developers without pnpm installed will see pnpm-workspace.yaml; pnpm is only required to manage npm packages and is not needed to build the Python CLI.
  • configuration_schema.json bundled in @apm/apm — the schema (~16 000 lines) adds weight to the npm package. Acceptable for v0.10.0; can be moved to a CDN fetch in a future iteration.
  • Publish gated on is_prerelease != 'true' — beta/preview builds do NOT auto-publish to latest; they require a separate workflow step (version scripts provided). This is intentional to protect the latest tag.
  • win32 only ships x64 — no arm64 binary is currently built for Windows. ARM64 Windows support can be added when a build runner is available.

Benefits

  1. npm install -g @apm/apm and "devDependencies": { "@apm/apm": "^0.10.0" } become supported install paths, directly addressing #732.
  2. npm provenance attestation (publishConfig.provenance: true) is enabled out of the box, satisfying supply-chain security requirements.
  3. Version sync is fully automated — generate-packages.mjs eliminates manual edits across six package.json files on every release.
  4. The APM_BINARY env override in the launcher allows CI and local development to point to any binary without reinstalling.
  5. Fully additive — the existing binary release and shell-script install paths are unchanged.

How to test

  • Install Node.js 20+ and pnpm: npm install -g pnpm
  • From repo root: pnpm install — verify workspace packages resolve without error
  • Run node packages/@apm/apm/scripts/generate-packages.mjs after placing a local build binary in dist/apm-linux-x86_64/apm — confirm version is synced and binary is copied
  • Simulate a beta stamp: INPUT_VERSION=0.10.0-beta.1 GITHUB_SHA=abc123 node packages/@apm/apm/scripts/update-beta-version.mjs — verify packages/@apm/apm/package.json is updated
  • After a tagged release, confirm the publish-npm CI job runs and all six packages appear on https://www.npmjs.com/package/@apm/apm

anrouxel and others added 2 commits April 28, 2026 14:43
- Introduced the main APM package with version 0.10.0 and its metadata.
- Added scripts for generating platform-specific packages and updating versions.
- Created package.json files for CLI binaries targeting darwin (arm64 and x64), linux (arm64 and x64), and win32 (x64).
- Implemented workspace configuration for pnpm to manage packages efficiently.

Co-authored-by: Copilot <copilot@github.com>
Copilot AI review requested due to automatic review settings April 28, 2026 13:33
@anrouxel
Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@danielmeppiel danielmeppiel added the panel-review Trigger the apm-review-panel gh-aw workflow label Apr 28, 2026
@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict

Disposition: REQUEST_CHANGES (four required fixes before merge)


Per-persona findings

Python Architect: This PR contains no Python code changes. The analysis covers the JavaScript launcher, build scripts, and CI workflow.

1. OO / Class Diagram

The PR is purely procedural -- no classes. Module-boundary view:

classDiagram
    direction LR
    class NpmLauncher {
        <<IOBoundary>>
        +PLATFORMS map
        +detectPackageManager() string
        +isMusl() bool
        +spawnSync(binary, args, opts)
    }
    class GeneratePackages {
        <<Pure>>
        +getArchiveName(platform, arch) string
        +copyBinaryToNativePackage(platform, arch)
        +updateVersionInJsPackage(name)
        +updateVersionInDependencies(deps, version)
    }
    class VersionScripts {
        <<IOBoundary>>
        +update-beta-version.mjs
        +update-preview-version.mjs
    }
    class PublishNpmJob {
        <<IOBoundary>>
        +NPM_TOKEN secret
        +npm-publish environment
    }
    class NpmLauncher:::touched
    class GeneratePackages:::touched
    class VersionScripts:::touched
    class PublishNpmJob:::touched
    PublishNpmJob ..> GeneratePackages : executes at release
    PublishNpmJob ..> VersionScripts : optionally executes
    NpmLauncher ..> GeneratePackages : binary resolved at install time
    classDef touched fill:#fff3b0,stroke:#d47600
Loading

2. Execution Flow

flowchart TD
    A["[EXEC] npm install -g apm-wrapper"] --> B["[FS] npm resolves optionalDeps by os/cpu"]
    B --> C["[FS] platform binary package downloaded"]
    C --> D["[EXEC] apm CLI invoked -> packages/apm/bin/apm"]
    D --> E{"env.APM_BINARY set?"}
    E -->|Yes| F["[EXEC] spawnSync(env.APM_BINARY, args)"]
    E -->|No| G{"PLATFORMS[platform][arch] exists?"}
    G -->|Yes| H["[FS] require.resolve(platform-binary-pkg/apm)"]
    H --> I["[EXEC] spawnSync(binary, args, shell:false, stdio:inherit)"]
    G -->|No| J["console.error: platform not supported, exit 1"]
    I --> K["exit with result.status"]
    L["[EXEC] git tag push -> build-release.yml trigger"] --> M["build jobs for all platforms"]
    M --> N["[FS] download-artifact -> ./artifacts/"]
    N --> O["[EXEC] node generate-packages.mjs"]
    O --> P{"binary in dist/ or artifacts/?"}
    P -->|No| Q["console.error + process.exit(1)"]
    P -->|Yes| R["[FS] copyFileSync binary -> packages/cli-platform/"]
    R --> S["[FS] chmodSync 0o755"]
    S --> T["[FS] cpSync _internal dir"]
    T --> U["[FS] writeFileSync package.json with synced version"]
    U --> V["[NET] npm publish --tag latest for each of 6 packages"]
Loading

3. Design patterns

Design patterns

  • Used in this PR: none -- straight-line procedural scripts; the launcher uses a lookup-table dispatch (PLATFORMS map), appropriate for the scope.
  • Pragmatic suggestion: none -- the current shape is the simplest correct design.

Structural bugs found:

  • isMusl() in packages/apm/bin/apm is defined but never called -- dead code. The PLATFORMS map has no musl variant.
  • packages/apm/configuration_schema.json is the Biome linter configuration schema (title: "Configuration", description: "The configuration that is contained inside the file biome.json"), not APM's schema. 16,010 lines committed unnecessarily. It is excluded from npm files so it will not be published, but its presence in the repo is wrong.
  • All five platform package.json stubs have "directory": "packages/@apm/apm" -- each should point to its own directory.
  • pnpm-lock.yaml is not updated despite five new workspace packages being added.

CLI Logging Expert: No changes to Python CLI output paths, CommandLogger, _rich_* helpers, or STATUS_SYMBOLS. This PR introduces only JavaScript tooling, outside the Python output architecture scope.

The launcher's user-facing error message is clean, non-technical, and provides a concrete next action. The console.info() / console.warn() calls in generate-packages.mjs are developer-facing build output and appropriate for their context. No CLI logging violations.


DevX UX Expert: The npm install -g apm-cli and "devDependencies": { "apm-cli": "^0.10.0" } flows are immediately familiar to TypeScript developers -- this directly resolves issue #732 using the industry-standard esbuild/bun optional-dependency pattern. The APM_BINARY env override for CI is a useful escape hatch.

Gaps that matter:

  1. Docs not updated (Rule 4 -- required). docs/src/content/docs/getting-started/installation.md, quick-start.md, and packages/apm-guide/.apm/skills/apm-usage/installation.md are not updated to document the npm install path. Any user visiting docs sees only the shell-script install.

  2. README does not mention npm install. TypeScript developers looking for the npm install command will not find it in the top-of-funnel README.

  3. Node.js >=20 requirement not surfaced. Users on Node 18 will get a cryptic npm engine warning rather than a clear message.

  4. Version hardcoded at 0.10.0 in committed source. Most packages use 0.0.0 as placeholder and stamp the real version at publish time. This causes noisy diffs on each release.


Supply Chain Security Expert: The core choices are sound: npm provenance (publishConfig.provenance: true), id-token: write scoped to the publish-only job, contents: read only, shell: false in spawnSync, and the npm-publish environment gate all represent supply-chain best practices. The optional-dependency pattern reduces download surface to one platform binary. Findings:

  1. APM_BINARY arbitrary execution path. The launcher resolves env.APM_BINARY with require.resolve() and executes it unconditionally. An attacker who can inject APM_BINARY into the environment (via a compromised .env, a malicious npm preinstall script in a transitive dep, or a CI env override) can execute arbitrary code under the apm binary name. Acceptable escape hatch but must be documented with a security warning.

  2. Binary not hash-verified against sha256sums.txt. generate-packages.mjs copies the binary from dist/ or artifacts/ with copyFileSync but does not verify the binary's hash against the checksum artifact already produced by build-release.yml. An artifact-substitution attack would go undetected at the npm package level. Provenance attestation covers the npm package, not the binary contents within it.

  3. configuration_schema.json is the Biome schema. Unexplained 16,010-line file committed under a misleading name. While excluded from the published npm package, it could be mistakenly added to files in a future edit.

  4. No dry-run publish capability. The publish-npm job has no mechanism to test-publish without actually pushing to npm. A --dry-run path would catch publish-time errors without burning release slots.


Auth Expert: Not activated -- PR modifies only npm packaging infrastructure (packages/ stub manifests, CI publish job) with no changes to src/apm_cli/core/auth.py, token_manager.py, AuthResolver, host classification, or credential resolution code.


OSS Growth Hacker: This PR is a major conversion surface unlock for TypeScript and Node.js developers -- removing the single biggest friction point for teams that want to consume APM as a project dependency.

"devDependencies": { "apm-pkg": "^0.10.0" } is a genuine zero-config story. The publish is gated to is_prerelease != 'true', so first publish lands at 0.10.0 -- a launch beat worth a CHANGELOG entry, README update, and social post.

Critical gap flagged to CEO: The README does not mention npm installation. The npm package will exist after this PR merges but no user will discover it through the README or docs. The launch story is incomplete without a doc pass. This must be a merge requirement per Rule 4, not a follow-up.

Growth note: verifying @apm/apm package name availability on npmjs.com should happen before the first release tag triggers publish-npm.


CEO arbitration

This is a solid, well-structured contribution from an external contributor that directly addresses a long-standing community request (#732). The approach -- esbuild/bun optional-dependency pattern, npm provenance, pnpm workspace -- is industry-standard and the right call.

Four issues require resolution before merge, all straightforward without architectural rethinking. The configuration_schema.json bug is the most urgent: it is the Biome linter's schema committed under a misleading name -- must be removed. The isMusl() dead code and incorrect "directory" fields in platform stubs are cleanup items. The pnpm-lock.yaml gap is a CI correctness issue.

On security: APM_BINARY is an acceptable escape hatch given the binary distribution model, but needs documentation, not removal. Binary hash verification is a follow-up -- npm provenance covers the package level, which is the relevant supply-chain boundary for v0 of this feature.

The Growth Hacker and DevX UX findings are aligned: the npm install path must be discoverable through docs and README before or concurrent with merge. Rule 4 makes this a required action. Shipping the infrastructure without updating the funnel wastes the launch beat.

Once the four required actions below are addressed, this PR has CEO approval. This is exactly the kind of community contribution APM needs to encourage.


Required actions before merge

  1. Remove packages/@apm/apm/configuration_schema.json -- this is Biome's configuration schema (16,010 lines, title "Configuration", describes biome.json), not APM's. It is not in the npm files array and serves no purpose in this repo.

  2. Update installation docs (docs/src/content/docs/getting-started/installation.md, quick-start.md, packages/apm-guide/.apm/skills/apm-usage/installation.md) and README hero section to document the npm install path and devDependencies usage (Rule 4 -- required for distribution surface changes).

  3. Remove the dead isMusl() function from packages/@apm/apm/bin/apm (lines 4-15 of the launcher). It is defined but never called; no code path reaches it.

  4. Run pnpm install and commit the updated pnpm-lock.yaml to reflect the five new workspace packages.


Optional follow-ups

  • Fix "directory" in the five platform package.json stubs: all currently point to "packages/@apm/apm" but should each point to their own directory (e.g., "packages/@apm/cli-linux-x64").
  • Add binary hash verification in generate-packages.mjs against the sha256sums.txt artifact before copyFileSync -- closes the artifact-substitution window.
  • Document APM_BINARY env var in docs as an advanced override with a warning that it must point to a trusted local binary.
  • Use 0.0.0 as placeholder version in committed package.json source files instead of 0.10.0 to reduce release diff noise.
  • Pretty-print JSON in update-beta-version.mjs (JSON.stringify(manifest, null, 2)) to match the format used elsewhere.
  • Verify npm package name availability before the first tag triggers publish.
  • Add a --dry-run path to the publish-npm job for pre-release smoke testing.

Generated by PR Review Panel for issue #1017 · ● 981.8K ·

@danielmeppiel danielmeppiel added priority/high Ships in current or next milestone status/accepted Direction approved, safe to start work. labels Apr 28, 2026
@anrouxel
Copy link
Copy Markdown
Author

Hi @danielmeppiel ,

I wanted to clarify a point regarding the naming pattern that will be used for the packages published to npm. What will the final scope (namespace) be?

Currently, according to the PR, it seems we are going with the @apm scope, which would result in the following structure:

@apm/apm (or @apm/apm-cli)
├── @apm/cli-darwin-arm64
├── @apm/cli-darwin-x64
├── @apm/cli-linux-arm64
├── @apm/cli-linux-x64
└── @apm/cli-win32-x64

However, since this is a Microsoft project, shouldn't we use the official @microsoft scope instead? That would result in a structure like this:

@microsoft/apm (or @microsoft/apm-cli)
├── @microsoft/apm-cli-darwin-arm64
├── @microsoft/apm-cli-darwin-x64
├── @microsoft/apm-cli-linux-arm64
├── @microsoft/apm-cli-linux-x64
└── @microsoft/apm-cli-win32-x64

Has the availability of the @apm organization on npm been checked yet, or has it already been decided to use the @microsoft organization before the official release?

Best Regards

@danielmeppiel
Copy link
Copy Markdown
Collaborator

@anrouxel we should use microsoft org

anrouxel and others added 3 commits April 28, 2026 21:33
…eration

- Implemented the APM CLI launcher in Node.js to handle platform-specific binaries.
- Created Jinja2 templates for generating package.json files for the main CLI and platform-specific packages.
- Developed a Python script to automate the generation and publishing of npm packages for different platforms.
- Included logging for better traceability during package generation.
- Ensured compatibility with Node.js version 20 and above.

Co-authored-by: Copilot <copilot@github.com>
@anrouxel
Copy link
Copy Markdown
Author

I've significantly refactored our entire NPM packaging strategy to simplify the publish loop, eliminate static file repetition, and make the generation of platform-specific CLI wrappers much more robust.

Key Changes

  1. Repository Structure Migration: Moved NPM packages under the @microsoft scope (from @apm), eliminating static package.json duplicates for platforms like cli-darwin-x64, cli-win32-x64, etc.
  2. Template-Driven Architecture: We now use Jinja2 to dynamically generate all package.json manifests (platform-package.json.jinja2, apm-cli-package.json.jinja2) and the main CLI launcher.
  3. Dynamic CLI Launcher Generation: Instead of a hardcoded PLATFORMS mapping in the main apm entrypoint, we now use apm-cli-bin.js.jinja2. It dynamically injects the exact routing logic based on supported platforms and architectures.
  4. Robust Version Parsing: Replaced bespoke string-splitting of pyproject.toml with the standard toml library parser.
  5. Script Modularity & Cleanup: Extracted reusable helper functions (setup_jinja_env, copy_common_files, write_template, find_binary_content) in scripts/npm_publish.py out of the monolithic python script. Eliminated legacy Node updater scripts like update-preview-version.mjs and update-beta-version.mjs.

Architecture Simplification

Previous Approach

graph TD
    A[Node scripts: update-beta-version.mjs, etc.] --> B[Static duplicate packages]
    C[Hardcoded apm bin script] --> D[Static PLATFORMS map for CLI wrapper]
Loading

New Approach

graph TD
    G[scripts/npm_publish.py] -->|toml lib| H[pyproject.toml]
    G -->|Delegated helpers| I[Jinja2 Engine]
    I --> J[platform-package.json.jinja2]
    I --> K[apm-cli-package.json.jinja2]
    I --> L[apm-cli-bin.js.jinja2]
    L --> M[Dynamic & accurate PLATFORMS map for CLI wrapper]
Loading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

panel-review Trigger the apm-review-panel gh-aw workflow priority/high Ships in current or next milestone status/accepted Direction approved, safe to start work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Installation via NPM registry

3 participants