← back to Cli Printing Press
fix(cli): stage runstate manuscripts during lock promote (#1094)
17d1930735e5e9d5f26298640e869f978951b886 · 2026-05-11 11:15:41 -0700 · Trevin Chow
Phase 5 writes its acceptance marker into the runstate's proofs/ dir
($PRESS_HOME/.runstate/<scope>/runs/<run-id>/proofs/), but downstream
consumers like `publish validate` look for it inside the published
library copy at <lib>/.manuscripts/<run-id>/proofs/. Before this
change, `lock promote` did not bridge that gap, so every successful
generation tripped the phase5 leg unless the operator manually
`cp -r`'d the runstate dirs before validating.
`PromoteWorkingCLI` now stages the runstate's proofs/research/discovery
trees into the staging copy's .manuscripts/<run-id>/ before the atomic
swap. The helper is a no-op when state has no RunID (NewMinimalState
path for plan-driven CLIs), and a pre-existing subtree wins so artifacts
generate or polish dropped directly into the working dir survive.
Closes #889
Files touched
M docs/ARTIFACTS.mdM internal/pipeline/lock.goM internal/pipeline/lock_test.go
Diff
commit 17d1930735e5e9d5f26298640e869f978951b886
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 11 11:15:41 2026 -0700
fix(cli): stage runstate manuscripts during lock promote (#1094)
Phase 5 writes its acceptance marker into the runstate's proofs/ dir
($PRESS_HOME/.runstate/<scope>/runs/<run-id>/proofs/), but downstream
consumers like `publish validate` look for it inside the published
library copy at <lib>/.manuscripts/<run-id>/proofs/. Before this
change, `lock promote` did not bridge that gap, so every successful
generation tripped the phase5 leg unless the operator manually
`cp -r`'d the runstate dirs before validating.
`PromoteWorkingCLI` now stages the runstate's proofs/research/discovery
trees into the staging copy's .manuscripts/<run-id>/ before the atomic
swap. The helper is a no-op when state has no RunID (NewMinimalState
path for plan-driven CLIs), and a pre-existing subtree wins so artifacts
generate or polish dropped directly into the working dir survive.
Closes #889
---
docs/ARTIFACTS.md | 1 +
internal/pipeline/lock.go | 51 +++++++++++++++
internal/pipeline/lock_test.go | 144 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 196 insertions(+)
diff --git a/docs/ARTIFACTS.md b/docs/ARTIFACTS.md
index 27ee56a7..deaf67ae 100644
--- a/docs/ARTIFACTS.md
+++ b/docs/ARTIFACTS.md
@@ -5,6 +5,7 @@ Generated artifacts live under the user's home directory, not in this repo.
## Local artifacts
- `~/printing-press/library/<api-slug>/` — local library: printed CLIs the generator has produced. Directory names are keyed by API slug, not CLI name. The binary inside is still `<api-slug>-pp-cli`.
+- `~/printing-press/library/<api-slug>/.manuscripts/<run-id>/` — per-run manuscripts (proofs, research, discovery) embedded inside the printed CLI by `printing-press lock promote`. Mirrors what `publish package` later copies into a packaged tarball, so `publish validate` can find the Phase 5 acceptance marker on a freshly promoted CLI without manual `cp -r` from the runstate.
- `~/printing-press/manuscripts/<api-slug>/` — archived research and verification proofs, keyed by API slug. One API can have multiple runs.
- `~/printing-press/.runstate/<scope>/` — mutable per-workspace state such as current run and sync cursors.
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 31ae7e51..f5f3586b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -266,6 +266,13 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
return fmt.Errorf("copying to staging directory: %w", err)
}
+ // Phase 5 writes acceptance markers to the runstate, but the published
+ // copy is the path downstream consumers see — embed them before the swap.
+ if err := stageRunstateManuscripts(stagingDir, state); err != nil {
+ _ = os.RemoveAll(stagingDir)
+ return fmt.Errorf("staging runstate manuscripts: %w", err)
+ }
+
// Update state to reflect promotion.
state.PublishedDir = libraryDir
@@ -335,6 +342,50 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
}
}
+// A pre-existing subtree in the staging copy wins so artifacts that generate
+// or polish wrote directly into the working dir are never overwritten by an
+// older runstate snapshot. No-op when state has no RunID — the
+// NewMinimalState path for plan-driven CLIs has no runstate to stage from.
+func stageRunstateManuscripts(stagingDir string, state *PipelineState) error {
+ if state == nil || state.RunID == "" {
+ return nil
+ }
+ // The leaf component of each source path becomes the subdir name in
+ // staging (proofs/research/discovery). This pairs with the directory
+ // shape established by paths.go's RunProofsDir, RunResearchDir, and
+ // RunDiscoveryDir helpers; if those rename their leaf names, the
+ // destination layout follows automatically.
+ sources := []string{
+ state.ProofsDir(),
+ state.ResearchDir(),
+ state.DiscoveryDir(),
+ }
+ dstRoot := filepath.Join(stagingDir, ".manuscripts", state.RunID)
+ for _, src := range sources {
+ name := filepath.Base(src)
+ info, err := os.Stat(src)
+ if err != nil {
+ if os.IsNotExist(err) {
+ continue
+ }
+ return fmt.Errorf("inspecting runstate %s: %w", name, err)
+ }
+ if !info.IsDir() {
+ continue
+ }
+ target := filepath.Join(dstRoot, name)
+ if _, statErr := os.Stat(target); statErr == nil {
+ continue
+ } else if !os.IsNotExist(statErr) {
+ return fmt.Errorf("checking %s in staging: %w", target, statErr)
+ }
+ if err := CopyDir(src, target); err != nil {
+ return fmt.Errorf("copying runstate %s: %w", name, err)
+ }
+ }
+ return nil
+}
+
func validatePhase5GateForPromote(workingDir string, state *PipelineState) error {
if state == nil || state.RunID == "" {
return nil
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index b9515b3f..028abe63 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -626,6 +626,150 @@ func TestPromoteWorkingCLI_MinimalStateNoRunstate(t *testing.T) {
assert.Equal(t, libDir, state.PublishedDir)
}
+func TestPromoteWorkingCLI_StagesRunstateManuscripts(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-stage-001", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ // Plant research and discovery alongside the phase5 marker so we can
+ // assert the whole runstate triplet gets staged into the published copy.
+ require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "notes.md"), []byte("research notes\n"), 0o644))
+ require.NoError(t, os.MkdirAll(state.DiscoveryDir(), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(state.DiscoveryDir(), "endpoints.json"), []byte("{}\n"), 0o644))
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+ // phase5-acceptance.json must be reachable at the path publish validate
+ // looks at first: <lib>/.manuscripts/<run-id>/proofs/.
+ _, err = os.Stat(filepath.Join(manuRoot, "proofs", Phase5AcceptanceFilename))
+ assert.NoError(t, err, "phase5 acceptance marker should be staged into the published copy")
+
+ data, err := os.ReadFile(filepath.Join(manuRoot, "research", "notes.md"))
+ require.NoError(t, err)
+ assert.Equal(t, "research notes\n", string(data))
+
+ data, err = os.ReadFile(filepath.Join(manuRoot, "discovery", "endpoints.json"))
+ require.NoError(t, err)
+ assert.Equal(t, "{}\n", string(data))
+}
+
+func TestPromoteWorkingCLI_PreservesPreexistingManuscripts(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-stage-002", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ // Working dir already carries a proofs subtree at the destination path
+ // with a sentinel file. We must not clobber it during staging.
+ preexistingProofs := filepath.Join(workDir, ".manuscripts", state.RunID, "proofs")
+ require.NoError(t, os.MkdirAll(preexistingProofs, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(preexistingProofs, "sentinel.txt"), []byte("keep-me\n"), 0o644))
+
+ // Also seed a different, non-overlapping research dir in runstate so
+ // non-conflicting subdirs still get staged on the same run.
+ require.NoError(t, os.MkdirAll(state.ResearchDir(), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(state.ResearchDir(), "notes.md"), []byte("from runstate\n"), 0o644))
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+ data, err := os.ReadFile(filepath.Join(manuRoot, "proofs", "sentinel.txt"))
+ require.NoError(t, err, "pre-existing proofs subtree should survive staging")
+ assert.Equal(t, "keep-me\n", string(data))
+
+ data, err = os.ReadFile(filepath.Join(manuRoot, "research", "notes.md"))
+ require.NoError(t, err)
+ assert.Equal(t, "from runstate\n", string(data))
+}
+
+func TestPromoteWorkingCLI_StagesPartialRunstate(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-stage-003", "test-scope")
+ // Only proofs is populated — research and discovery dirs do not exist
+ // in the runstate. The staging step must skip them via os.IsNotExist
+ // without aborting the promote.
+ writePhase5PassForState(t, state, "none")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ manuRoot := filepath.Join(libDir, ".manuscripts", state.RunID)
+
+ _, err = os.Stat(filepath.Join(manuRoot, "proofs", Phase5AcceptanceFilename))
+ assert.NoError(t, err)
+
+ _, statErr := os.Stat(filepath.Join(manuRoot, "research"))
+ assert.True(t, os.IsNotExist(statErr), "research subdir should not be created when runstate has none")
+ _, statErr = os.Stat(filepath.Join(manuRoot, "discovery"))
+ assert.True(t, os.IsNotExist(statErr), "discovery subdir should not be created when runstate has none")
+}
+
+func TestPromoteWorkingCLI_StagingNoopForMinimalState(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+
+ state := NewMinimalState("test-pp-cli", workDir)
+ require.Empty(t, state.RunID, "minimal state should have no RunID")
+
+ err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ _, statErr := os.Stat(filepath.Join(libDir, ".manuscripts"))
+ assert.True(t, os.IsNotExist(statErr), "minimal-state promote should not create a .manuscripts dir")
+}
+
func TestIsStale(t *testing.T) {
fresh := &LockState{UpdatedAt: time.Now()}
assert.False(t, IsStale(fresh))
← 3d515c45 fix(cli): delegate workflow archive to syncResource (#1090)
·
back to Cli Printing Press
·
fix(cli): unwrap single-key API envelopes before agent prove ff4b6528 →