[object Object]

← back to Cli Printing Press

fix(cli): strip build/ from publish staging (#502)

263be3b3d24ecae4a9995fda9b98de14dd7f2dfe · 2026-05-02 09:27:20 -0700 · Trevin Chow

`autoBundleForHost` writes a host-platform .mcpb plus the staged binary
into `<cliDir>/build/` after generate. That output is intended as
local-dev scratch — useful for testing the bundle in Claude Desktop on
the generating machine — but `printing-press publish package`'s
`pipeline.CopyDir` was unconditional, so build/ also reached the public
library through the publish path. That created two problems:

- The public library accumulates ~10 MB binary blobs per CLI per
  generation, with no good way to track which platforms were built when.
- Different CLIs end up with different platform .mcpb files depending on
  what host the publisher used; users on other platforms have no
  bundles at all.

Going forward, the canonical multi-platform .mcpb artifacts are produced
by the public library's CI on merge to main and attached as release
assets, keyed by a moving `<slug>-current` tag the README links to.
build/ is local-dev only.

Adds a post-CopyDir RemoveAll on outCLIDir/build, a test that verifies
staging excludes build/ even when the source dir contains one, and a
docstring update on autoBundleForHost explaining the local-vs-CI split.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 263be3b3d24ecae4a9995fda9b98de14dd7f2dfe
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 2 09:27:20 2026 -0700

    fix(cli): strip build/ from publish staging (#502)
    
    `autoBundleForHost` writes a host-platform .mcpb plus the staged binary
    into `<cliDir>/build/` after generate. That output is intended as
    local-dev scratch — useful for testing the bundle in Claude Desktop on
    the generating machine — but `printing-press publish package`'s
    `pipeline.CopyDir` was unconditional, so build/ also reached the public
    library through the publish path. That created two problems:
    
    - The public library accumulates ~10 MB binary blobs per CLI per
      generation, with no good way to track which platforms were built when.
    - Different CLIs end up with different platform .mcpb files depending on
      what host the publisher used; users on other platforms have no
      bundles at all.
    
    Going forward, the canonical multi-platform .mcpb artifacts are produced
    by the public library's CI on merge to main and attached as release
    assets, keyed by a moving `<slug>-current` tag the README links to.
    build/ is local-dev only.
    
    Adds a post-CopyDir RemoveAll on outCLIDir/build, a test that verifies
    staging excludes build/ even when the source dir contains one, and a
    docstring update on autoBundleForHost explaining the local-vs-CI split.
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/cli/bundle.go       |  5 +++++
 internal/cli/publish.go      | 10 ++++++++++
 internal/cli/publish_test.go | 31 +++++++++++++++++++++++++++++++
 3 files changed, 46 insertions(+)

diff --git a/internal/cli/bundle.go b/internal/cli/bundle.go
index 859caf2b..c0388675 100644
--- a/internal/cli/bundle.go
+++ b/internal/cli/bundle.go
@@ -119,6 +119,11 @@ from another build pipeline.`,
 }
 
 // autoBundleForHost packages a host-platform .mcpb after generate.
+// The output lives at <cliDir>/build/ as local-dev scratch — useful for
+// trying the bundle in Claude Desktop on the generating machine before
+// publishing. The canonical multi-platform .mcpb artifacts are produced by
+// the public library's CI on merge to main and attached as release assets;
+// `printing-press publish package` strips build/ from the staged tree.
 // Best-effort: skips silently for expected non-bundle states (no manifest,
 // no go.sum) and warns on real failures (malformed manifest, build/zip
 // errors). Users can always re-run via `printing-press bundle <dir>`.
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index ee132d23..4e1bdad1 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -337,6 +337,16 @@ func newPublishPackageCmd() *cobra.Command {
 				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("copying CLI: %w", err)}
 			}
 
+			// Strip build/ from the staged tree. autoBundleForHost writes
+			// host-platform .mcpb bundles + staged binaries there as a
+			// local-dev convenience; the public library treats CI release
+			// artifacts as canonical, so build/ should never reach the
+			// public library through the publish path.
+			if err := os.RemoveAll(filepath.Join(outCLIDir, "build")); err != nil {
+				cleanupOnFailure()
+				return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("stripping build dir: %w", err)}
+			}
+
 			// Rewrite go.mod module path if --module-path is set
 			if modulePath != "" {
 				oldModPath := cliName // generated CLIs use bare CLI name as module path
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 72d018f2..ecf07096 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -377,6 +377,37 @@ func TestPublishPackageDoesNotStageCompiledBinary(t *testing.T) {
 	assert.ErrorIs(t, stagedErr, os.ErrNotExist, "packaged source should not include a compiled binary")
 }
 
+func TestPublishPackageStripsBuildDir(t *testing.T) {
+	home := setLibraryTestEnv(t)
+	cliDir := filepath.Join(home, "library", "test-pp-cli")
+	writePublishableTestCLI(t, cliDir)
+
+	// Simulate autoBundleForHost output: build/ exists in the source
+	// dir with a host-platform .mcpb and a staged binary copy.
+	buildDir := filepath.Join(cliDir, "build")
+	require.NoError(t, os.MkdirAll(filepath.Join(buildDir, "stage", "bin"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(buildDir, "test-pp-mcp-darwin-arm64.mcpb"), []byte("zip-bytes"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(buildDir, "stage", "bin", "test-pp-mcp"), []byte("staged-binary"), 0o755))
+
+	target := filepath.Join(t.TempDir(), "staging")
+	cmd := newPublishCmd()
+	cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+	output, err := runWithCapturedStdout(t, cmd.Execute)
+	require.NoError(t, err)
+
+	var result PackageResult
+	require.NoError(t, json.Unmarshal([]byte(output), &result))
+
+	// Source build/ stays intact — we don't touch the user's working tree.
+	_, sourceErr := os.Stat(buildDir)
+	assert.NoError(t, sourceErr, "package must not delete the source build/ dir")
+
+	// Staged tree must have no build/ — CI is canonical for distribution.
+	_, stagedErr := os.Stat(filepath.Join(result.StagedDir, "build"))
+	assert.ErrorIs(t, stagedErr, os.ErrNotExist, "staged dir must not include build/")
+}
+
 func TestPublishPackageFailsWhenManuscriptsCopyFails(t *testing.T) {
 	skipIfRootCannotSimulateUnreadable(t)
 	home := setLibraryTestEnv(t)

← 2817bdc7 fix(cli): route resource base urls through mcp surfaces (#50  ·  back to Cli Printing Press  ·  fix(cli): route discriminator sync to typed tables (#503) 97180c03 →