← back to Cli Printing Press
fix(cli): gate publishing on Phase 5 proof (#558)
7a1438182b97a2707840b3d7813aa758626c69b5 · 2026-05-03 20:10:54 -0700 · Trevin Chow
* fix(cli): gate publishing on Phase 5 proof
* fix(cli): honor quick Phase 5 threshold
Files touched
M internal/cli/library_test.goM internal/cli/lock_test.goM internal/cli/publish.goM internal/cli/publish_test.goM internal/pipeline/climanifest.goM internal/pipeline/lock.goM internal/pipeline/lock_test.goA internal/pipeline/phase5_gate.goA internal/pipeline/phase5_gate_test.goM skills/printing-press-publish/SKILL.mdM skills/printing-press/SKILL.md
Diff
commit 7a1438182b97a2707840b3d7813aa758626c69b5
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 3 20:10:54 2026 -0700
fix(cli): gate publishing on Phase 5 proof (#558)
* fix(cli): gate publishing on Phase 5 proof
* fix(cli): honor quick Phase 5 threshold
---
internal/cli/library_test.go | 8 ++
internal/cli/lock_test.go | 16 +++
internal/cli/publish.go | 42 +++++++
internal/cli/publish_test.go | 57 ++++++++-
internal/pipeline/climanifest.go | 15 ++-
internal/pipeline/lock.go | 30 +++++
internal/pipeline/lock_test.go | 39 ++++++
internal/pipeline/phase5_gate.go | 216 +++++++++++++++++++++++++++++++++
internal/pipeline/phase5_gate_test.go | 212 ++++++++++++++++++++++++++++++++
skills/printing-press-publish/SKILL.md | 2 +
skills/printing-press/SKILL.md | 68 +++++++++--
11 files changed, 693 insertions(+), 12 deletions(-)
diff --git a/internal/cli/library_test.go b/internal/cli/library_test.go
index 5ae8ecdd..0990d0e8 100644
--- a/internal/cli/library_test.go
+++ b/internal/cli/library_test.go
@@ -32,6 +32,14 @@ func writeTestManifest(t *testing.T, dir string, m pipeline.CLIManifest) {
require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.CLIManifestFilename), data, 0o644))
}
+func writeTestPhase5GateMarker(t *testing.T, proofsDir, name string, marker pipeline.Phase5GateMarker) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(proofsDir, 0o755))
+ data, err := json.MarshalIndent(marker, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(proofsDir, name), data, 0o644))
+}
+
func TestLibraryListJSONWithManifests(t *testing.T) {
home := setLibraryTestEnv(t)
libDir := filepath.Join(home, "library")
diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go
index 9a0964bc..bddb1867 100644
--- a/internal/cli/lock_test.go
+++ b/internal/cli/lock_test.go
@@ -164,6 +164,7 @@ func TestLockPromote_Success(t *testing.T) {
// Create state file for the run.
state := pipeline.NewStateWithRun("test", workDir, runID, "test-scope")
require.NoError(t, state.Save())
+ writeLockPhase5Pass(t, state)
// Acquire lock.
_, code := runLockCmd("acquire", "--cli", "test-pp-cli", "--scope", "test-scope")
@@ -183,6 +184,21 @@ func TestLockPromote_Success(t *testing.T) {
assert.NoError(t, err)
}
+func writeLockPhase5Pass(t *testing.T, state *pipeline.PipelineState) {
+ t.Helper()
+ writeTestPhase5GateMarker(t, state.ProofsDir(), pipeline.Phase5AcceptanceFilename, pipeline.Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: state.APIName,
+ RunID: state.RunID,
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 1,
+ TestsPassed: 1,
+ TestsFailed: 0,
+ AuthContext: pipeline.Phase5AuthContext{Type: "none"},
+ })
+}
+
func TestLockHelpOutput(t *testing.T) {
cmd := newLockCmd()
buf := new(bytes.Buffer)
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 4e1bdad1..a44ff106 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -524,6 +524,12 @@ func runValidation(dir string) ValidateResult {
result.Checks = append(result.Checks, CheckResult{Name: "transcendence", Passed: true})
}
+ phase5Check := checkPhase5Gate(dir, manifest)
+ if !phase5Check.Passed {
+ allPassed = false
+ }
+ result.Checks = append(result.Checks, phase5Check)
+
cliName := result.CLIName
if cliName == "" {
cliName = filepath.Base(dir)
@@ -635,6 +641,42 @@ func runValidation(dir string) ValidateResult {
return result
}
+func checkPhase5Gate(dir string, manifest pipeline.CLIManifest) CheckResult {
+ if manifest.APIName == "" || manifest.CLIName == "" {
+ return CheckResult{Name: "phase5", Passed: false, Error: "manifest unavailable"}
+ }
+ if manifest.RunID == "" {
+ return CheckResult{Name: "phase5", Passed: false, Error: "manifest missing run_id; cannot locate Phase 5 gate proof"}
+ }
+
+ proofsDir := phase5ProofsDir(dir, manifest)
+ result := pipeline.ValidatePhase5Gate(proofsDir, manifest)
+ if !result.Passed {
+ return CheckResult{Name: "phase5", Passed: false, Error: result.Detail}
+ }
+ return CheckResult{Name: "phase5", Passed: true}
+}
+
+func phase5ProofsDir(dir string, manifest pipeline.CLIManifest) string {
+ runID := manifest.RunID
+ candidates := []string{
+ filepath.Join(dir, ".manuscripts", runID, "proofs"),
+ }
+ msRoot := pipeline.PublishedManuscriptsRoot()
+ if manifest.CLIName != "" {
+ candidates = append(candidates, filepath.Join(msRoot, manifest.CLIName, runID, "proofs"))
+ }
+ if manifest.APIName != "" {
+ candidates = append(candidates, filepath.Join(msRoot, manifest.APIName, runID, "proofs"))
+ }
+ for _, candidate := range candidates {
+ if info, err := os.Stat(candidate); err == nil && info.IsDir() {
+ return candidate
+ }
+ }
+ return candidates[0]
+}
+
func checkVerifySkill(dir string) CheckResult {
run, err := runVerifySkillScript(dir, nil, false, false)
if err != nil {
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index ecf07096..2fd7da14 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -150,13 +150,47 @@ func TestPublishValidateJSONHasAllChecks(t *testing.T) {
}
// All checks should be present (they may fail in test env, but must exist)
- expectedChecks := []string{"manifest", "transcendence", "go mod tidy", "go vet", "go build", "--help", "--version", "verify-skill", "manuscripts"}
+ expectedChecks := []string{"manifest", "transcendence", "phase5", "go mod tidy", "go vet", "go build", "--help", "--version", "verify-skill", "manuscripts"}
for _, name := range expectedChecks {
assert.True(t, checkNames[name], "should have %q check", name)
}
assert.Len(t, result.Checks, len(expectedChecks), "should have exactly the expected checks")
}
+func TestPublishValidateFailsWithoutPhase5Marker(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ writeTestManifest(t, cliDir, pipeline.CLIManifest{
+ SchemaVersion: 1,
+ APIName: "test",
+ CLIName: "test-pp-cli",
+ RunID: "run-missing-phase5",
+ AuthType: "api_key",
+ })
+
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"validate", "--dir", cliDir, "--json"})
+
+ output, err := runWithCapturedStdout(t, cmd.Execute)
+ require.Error(t, err)
+
+ var result ValidateResult
+ require.NoError(t, json.Unmarshal([]byte(output), &result))
+ assert.False(t, result.Passed)
+
+ var phase5Check *CheckResult
+ for i := range result.Checks {
+ if result.Checks[i].Name == "phase5" {
+ phase5Check = &result.Checks[i]
+ break
+ }
+ }
+ require.NotNil(t, phase5Check)
+ assert.False(t, phase5Check.Passed)
+ assert.Contains(t, phase5Check.Error, "missing")
+}
+
func TestPublishValidateRequiresTranscendenceFeatures(t *testing.T) {
home := setLibraryTestEnv(t)
cliDir := filepath.Join(home, "library", "test-pp-cli")
@@ -826,5 +860,26 @@ func newInsightCmd() *cobra.Command {
SchemaVersion: 1,
APIName: "test",
CLIName: "test-pp-cli",
+ RunID: "20260301-000000",
+ AuthType: "none",
+ })
+ writePublishablePhase5Pass(t)
+}
+
+func writePublishablePhase5Pass(t *testing.T) {
+ t.Helper()
+ home := os.Getenv("PRINTING_PRESS_HOME")
+ require.NotEmpty(t, home)
+ proofsDir := filepath.Join(home, "manuscripts", "test", "20260301-000000", "proofs")
+ writeTestPhase5GateMarker(t, proofsDir, pipeline.Phase5AcceptanceFilename, pipeline.Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "20260301-000000",
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 1,
+ TestsPassed: 1,
+ TestsFailed: 0,
+ AuthContext: pipeline.Phase5AuthContext{Type: "none"},
})
}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 19e2b0b8..c9a0ddb1 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -91,15 +91,24 @@ type NovelFeatureManifest struct {
// bundle builder, which can't store the CLI binary name in manifest.json
// (Claude Desktop's MCPB v0.3 validator rejects unknown top-level keys).
func ReadCLIBinaryName(dir string) string {
- data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+ m, err := ReadCLIManifest(dir)
if err != nil {
return ""
}
+ return m.CLIName
+}
+
+// ReadCLIManifest decodes dir/.printing-press.json.
+func ReadCLIManifest(dir string) (CLIManifest, error) {
+ data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+ if err != nil {
+ return CLIManifest{}, err
+ }
var m CLIManifest
if err := json.Unmarshal(data, &m); err != nil {
- return ""
+ return CLIManifest{}, err
}
- return m.CLIName
+ return m, nil
}
// RefreshCLIManifestFromSpec rereads dir/.printing-press.json, overlays the
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 5626597e..fd5c639b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -226,6 +226,9 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
if len(entries) == 0 {
return fmt.Errorf("working directory is empty: %s", workingDir)
}
+ if err := validatePhase5GateForPromote(workingDir, state); err != nil {
+ return err
+ }
slug := naming.TrimCLISuffix(cliName)
libraryDir := filepath.Join(PublishedLibraryRoot(), slug)
@@ -321,6 +324,33 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
}
}
+func validatePhase5GateForPromote(workingDir string, state *PipelineState) error {
+ if state == nil || state.RunID == "" {
+ return nil
+ }
+
+ manifest := CLIManifest{
+ APIName: state.APIName,
+ CLIName: naming.CLI(state.APIName),
+ RunID: state.RunID,
+ }
+ if existing, err := ReadCLIManifest(workingDir); err == nil {
+ if existing.APIName != "" {
+ manifest.APIName = existing.APIName
+ }
+ if existing.CLIName != "" {
+ manifest.CLIName = existing.CLIName
+ }
+ manifest.AuthType = existing.AuthType
+ }
+
+ result := ValidatePhase5Gate(state.ProofsDir(), manifest)
+ if result.Passed {
+ return nil
+ }
+ return fmt.Errorf("phase5 gate failed: %s", result.Detail)
+}
+
// IsStale returns true if the lock's UpdatedAt is older than StaleLockThreshold.
func IsStale(lock *LockState) bool {
return time.Since(lock.UpdatedAt) > StaleLockThreshold
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index e50d4a0e..f27e0169 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -328,6 +328,7 @@ func TestPromoteWorkingCLI(t *testing.T) {
// Create minimal state.
state := NewStateWithRun("test", workDir, "run-001", "test-scope")
+ writePhase5PassForState(t, state, "none")
err = PromoteWorkingCLI("test-pp-cli", workDir, state)
require.NoError(t, err)
@@ -369,6 +370,7 @@ func TestPromoteWorkingCLI_ReplacesExistingLibrary(t *testing.T) {
require.NoError(t, err)
state := NewStateWithRun("test", workDir, "run-002", "test-scope")
+ writePhase5PassForState(t, state, "none")
err = PromoteWorkingCLI("test-pp-cli", workDir, state)
require.NoError(t, err)
@@ -445,6 +447,7 @@ func TestPromoteWorkingCLI_RetryRestoresBackupBeforeFailure(t *testing.T) {
require.NoError(t, os.Symlink(outside, filepath.Join(workDir, "bad-link.txt")))
state := NewStateWithRun("test", workDir, "run-004", "test-scope")
+ writePhase5PassForState(t, state, "none")
err := PromoteWorkingCLI("test-pp-cli", workDir, state)
require.Error(t, err)
@@ -473,6 +476,7 @@ func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
require.NoError(t, err)
state := NewStateWithRun("test", workDir, "run-005", "test-scope")
+ writePhase5PassForState(t, state, "none")
// Force state.Save() to fail after the library swap succeeds.
require.NoError(t, os.MkdirAll(filepath.Dir(state.PipelineDir()), 0o755))
@@ -493,6 +497,26 @@ func TestPromoteWorkingCLI_ReleasesLockWhenStateSaveFails(t *testing.T) {
assert.True(t, os.IsNotExist(err))
}
+func TestPromoteWorkingCLI_RequiresPhase5GateForRunstatePromote(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 := NewStateWithRun("test", workDir, "run-no-phase5", "test-scope")
+ err := PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "phase5")
+
+ _, statErr := os.Stat(filepath.Join(PublishedLibraryRoot(), "test"))
+ assert.ErrorIs(t, statErr, os.ErrNotExist)
+}
+
func TestPromoteWorkingCLI_MinimalStateNoRunstate(t *testing.T) {
tmp := t.TempDir()
t.Setenv("PRINTING_PRESS_HOME", tmp)
@@ -537,6 +561,21 @@ func TestIsStale(t *testing.T) {
assert.True(t, IsStale(boundary))
}
+func writePhase5PassForState(t *testing.T, state *PipelineState, authType string) {
+ t.Helper()
+ writePhase5GateMarker(t, state.ProofsDir(), Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: state.APIName,
+ RunID: state.RunID,
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 1,
+ TestsPassed: 1,
+ TestsFailed: 0,
+ AuthContext: Phase5AuthContext{Type: authType},
+ })
+}
+
func TestConcurrentAcquire(t *testing.T) {
setupLockTest(t)
diff --git a/internal/pipeline/phase5_gate.go b/internal/pipeline/phase5_gate.go
new file mode 100644
index 00000000..a02014dd
--- /dev/null
+++ b/internal/pipeline/phase5_gate.go
@@ -0,0 +1,216 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const (
+ Phase5AcceptanceFilename = "phase5-acceptance.json"
+ Phase5SkipFilename = "phase5-skip.json"
+)
+
+type Phase5AuthContext struct {
+ Type string `json:"type,omitempty"`
+ APIKeyAvailable bool `json:"api_key_available,omitempty"`
+ BrowserSessionAvailable bool `json:"browser_session_available,omitempty"`
+}
+
+type Phase5GateMarker struct {
+ SchemaVersion int `json:"schema_version"`
+ APIName string `json:"api_name,omitempty"`
+ RunID string `json:"run_id,omitempty"`
+ Status string `json:"status"`
+ Level string `json:"level,omitempty"`
+ MatrixSize int `json:"matrix_size,omitempty"`
+ TestsPassed int `json:"tests_passed,omitempty"`
+ TestsFailed int `json:"tests_failed,omitempty"`
+ AuthContext Phase5AuthContext `json:"auth_context,omitzero"`
+ SkipReason string `json:"skip_reason,omitempty"`
+}
+
+type Phase5GateValidation struct {
+ Passed bool
+ Status string
+ MarkerPath string
+ Detail string
+}
+
+func ValidatePhase5Gate(proofsDir string, manifest CLIManifest) Phase5GateValidation {
+ if strings.TrimSpace(proofsDir) == "" {
+ return Phase5GateValidation{Detail: "phase5 proofs directory is empty"}
+ }
+
+ if result, ok := validatePhase5MarkerFile(filepath.Join(proofsDir, Phase5AcceptanceFilename), manifest, false); ok {
+ return result
+ }
+ if result, ok := validatePhase5MarkerFile(filepath.Join(proofsDir, Phase5SkipFilename), manifest, true); ok {
+ return result
+ }
+
+ return Phase5GateValidation{
+ Detail: fmt.Sprintf("missing %s or %s in %s", Phase5AcceptanceFilename, Phase5SkipFilename, proofsDir),
+ }
+}
+
+func validatePhase5MarkerFile(path string, manifest CLIManifest, skipFile bool) (Phase5GateValidation, bool) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return Phase5GateValidation{}, false
+ }
+ return Phase5GateValidation{MarkerPath: path, Detail: fmt.Sprintf("reading phase5 marker: %v", err)}, true
+ }
+
+ var marker Phase5GateMarker
+ if err := json.Unmarshal(data, &marker); err != nil {
+ return Phase5GateValidation{MarkerPath: path, Detail: fmt.Sprintf("parsing phase5 marker: %v", err)}, true
+ }
+
+ result := validatePhase5Marker(marker, manifest, skipFile)
+ result.MarkerPath = path
+ return result, true
+}
+
+func validatePhase5Marker(marker Phase5GateMarker, manifest CLIManifest, skipFile bool) Phase5GateValidation {
+ status := strings.ToLower(strings.TrimSpace(marker.Status))
+ result := Phase5GateValidation{Status: status}
+
+ if marker.SchemaVersion != 1 {
+ result.Detail = fmt.Sprintf("unsupported phase5 marker schema_version %d", marker.SchemaVersion)
+ return result
+ }
+ if marker.APIName != "" && manifest.APIName != "" && marker.APIName != manifest.APIName {
+ result.Detail = fmt.Sprintf("phase5 marker api_name %q does not match manifest api_name %q", marker.APIName, manifest.APIName)
+ return result
+ }
+ if marker.RunID != "" && manifest.RunID != "" && marker.RunID != manifest.RunID {
+ result.Detail = fmt.Sprintf("phase5 marker run_id %q does not match manifest run_id %q", marker.RunID, manifest.RunID)
+ return result
+ }
+
+ switch status {
+ case "pass":
+ if skipFile {
+ result.Detail = fmt.Sprintf("%s must use status skip, got pass", Phase5SkipFilename)
+ return result
+ }
+ if detail := validatePhase5PassMarker(marker); detail != "" {
+ result.Detail = detail
+ return result
+ }
+ if ok, detail := phase5AcceptancePassed(marker); !ok {
+ result.Detail = detail
+ return result
+ }
+ result.Passed = true
+ return result
+ case "fail":
+ result.Detail = "phase5 gate status is fail"
+ return result
+ case "skip":
+ if !skipFile {
+ result.Detail = fmt.Sprintf("%s must use status pass or fail, got skip", Phase5AcceptanceFilename)
+ return result
+ }
+ if detail := validatePhase5SkipMarker(marker); detail != "" {
+ result.Detail = detail
+ return result
+ }
+ if ok, detail := phase5SkipAllowed(marker, manifest); !ok {
+ result.Detail = detail
+ return result
+ }
+ result.Passed = true
+ return result
+ default:
+ result.Detail = fmt.Sprintf("unknown phase5 gate status %q", marker.Status)
+ return result
+ }
+}
+
+func validatePhase5PassMarker(marker Phase5GateMarker) string {
+ switch {
+ case strings.TrimSpace(marker.APIName) == "":
+ return "phase5 acceptance marker missing api_name"
+ case strings.TrimSpace(marker.RunID) == "":
+ return "phase5 acceptance marker missing run_id"
+ case phase5Level(marker) == "":
+ return "phase5 acceptance marker missing level"
+ case marker.MatrixSize <= 0:
+ return "phase5 acceptance marker missing matrix_size"
+ case marker.TestsPassed <= 0:
+ return "phase5 acceptance marker missing tests_passed"
+ default:
+ return ""
+ }
+}
+
+func phase5AcceptancePassed(marker Phase5GateMarker) (bool, string) {
+ level := phase5Level(marker)
+ switch level {
+ case "quick":
+ if marker.MatrixSize != 6 {
+ return false, fmt.Sprintf("phase5 quick acceptance expected matrix_size 6, got %d", marker.MatrixSize)
+ }
+ if marker.TestsPassed < 5 {
+ return false, fmt.Sprintf("phase5 quick acceptance requires at least 5/6 tests passed, got %d/6", marker.TestsPassed)
+ }
+ return true, ""
+ case "full":
+ if marker.TestsFailed != 0 {
+ return false, fmt.Sprintf("phase5 full acceptance has %d failed tests", marker.TestsFailed)
+ }
+ if marker.TestsPassed != marker.MatrixSize {
+ return false, fmt.Sprintf("phase5 full acceptance requires all %d tests passed, got %d", marker.MatrixSize, marker.TestsPassed)
+ }
+ return true, ""
+ default:
+ return false, fmt.Sprintf("unknown phase5 acceptance level %q", marker.Level)
+ }
+}
+
+func phase5Level(marker Phase5GateMarker) string {
+ return strings.ToLower(strings.TrimSpace(marker.Level))
+}
+
+func validatePhase5SkipMarker(marker Phase5GateMarker) string {
+ switch {
+ case strings.TrimSpace(marker.APIName) == "":
+ return "phase5 skip marker missing api_name"
+ case strings.TrimSpace(marker.RunID) == "":
+ return "phase5 skip marker missing run_id"
+ case strings.TrimSpace(marker.SkipReason) == "":
+ return "phase5 skip marker missing skip_reason"
+ default:
+ return ""
+ }
+}
+
+func phase5SkipAllowed(marker Phase5GateMarker, manifest CLIManifest) (bool, string) {
+ authType := strings.ToLower(strings.TrimSpace(manifest.AuthType))
+ markerAuthType := strings.ToLower(strings.TrimSpace(marker.AuthContext.Type))
+ if authType == "" {
+ authType = markerAuthType
+ } else if markerAuthType != "" && markerAuthType != authType {
+ return false, fmt.Sprintf("phase5 skip marker auth type %q does not match manifest auth type %q", marker.AuthContext.Type, manifest.AuthType)
+ }
+ if authType == "" || authType == "none" {
+ return false, "no-auth APIs require a phase5 pass marker, not a skip marker"
+ }
+ if marker.AuthContext.APIKeyAvailable {
+ return false, "phase5 skip claims an API key was available"
+ }
+ if authRequiresCredential(authType) {
+ return true, ""
+ }
+ switch authType {
+ case "cookie", "composed", "session_handshake":
+ return false, "browser-session auth APIs require phase5 acceptance; missing API key is not a valid skip"
+ default:
+ return false, fmt.Sprintf("phase5 skip not allowed for auth type %q", authType)
+ }
+}
diff --git a/internal/pipeline/phase5_gate_test.go b/internal/pipeline/phase5_gate_test.go
new file mode 100644
index 00000000..cd2ea5f9
--- /dev/null
+++ b/internal/pipeline/phase5_gate_test.go
@@ -0,0 +1,212 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func writePhase5GateMarker(t *testing.T, proofsDir, name string, marker Phase5GateMarker) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(proofsDir, 0o755))
+ data, err := json.MarshalIndent(marker, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(proofsDir, name), data, 0o644))
+}
+
+func TestValidatePhase5Gate_PassMarker(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 3,
+ TestsPassed: 3,
+ TestsFailed: 0,
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.True(t, result.Passed, result.Detail)
+ assert.Equal(t, "pass", result.Status)
+ assert.Equal(t, filepath.Join(proofsDir, Phase5AcceptanceFilename), result.MarkerPath)
+}
+
+func TestValidatePhase5Gate_QuickPassAllowsOneNonBlockingMiss(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "pass",
+ Level: "quick",
+ MatrixSize: 6,
+ TestsPassed: 5,
+ TestsFailed: 1,
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.True(t, result.Passed, result.Detail)
+ assert.Equal(t, "pass", result.Status)
+}
+
+func TestValidatePhase5Gate_QuickPassRequiresFiveOfSix(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "pass",
+ Level: "quick",
+ MatrixSize: 6,
+ TestsPassed: 4,
+ TestsFailed: 2,
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "5/6")
+}
+
+func TestValidatePhase5Gate_FullPassRejectsFailures(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 6,
+ TestsPassed: 5,
+ TestsFailed: 1,
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "full")
+}
+
+func TestValidatePhase5Gate_NoAuthRequiresPassMarker(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "skip",
+ Level: "none",
+ SkipReason: "auth_required_no_credential",
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "no-auth")
+}
+
+func TestValidatePhase5Gate_APIKeyMissingSkipAllowed(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"}
+ writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "skip",
+ Level: "none",
+ SkipReason: "auth_required_no_credential",
+ AuthContext: Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.True(t, result.Passed, result.Detail)
+ assert.Equal(t, "skip", result.Status)
+}
+
+func TestValidatePhase5Gate_CookieAuthNotSkippedByMissingAPIKey(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "cookie"}
+ writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "skip",
+ Level: "none",
+ SkipReason: "auth_required_no_credential",
+ AuthContext: Phase5AuthContext{Type: "cookie", APIKeyAvailable: false},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "browser-session")
+}
+
+func TestValidatePhase5Gate_SkipCannotOverrideManifestAuthType(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "cookie"}
+ writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ RunID: "run-1",
+ Status: "skip",
+ Level: "none",
+ SkipReason: "auth_required_no_credential",
+ AuthContext: Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "does not match")
+}
+
+func TestValidatePhase5Gate_PassMarkerRequiresIdentityAndTestCount(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "none"}
+ writePhase5GateMarker(t, proofsDir, Phase5AcceptanceFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ Status: "pass",
+ Level: "full",
+ MatrixSize: 1,
+ TestsPassed: 1,
+ AuthContext: Phase5AuthContext{Type: "none"},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "api_name")
+}
+
+func TestValidatePhase5Gate_SkipMarkerRequiresIdentity(t *testing.T) {
+ proofsDir := t.TempDir()
+ manifest := CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"}
+ writePhase5GateMarker(t, proofsDir, Phase5SkipFilename, Phase5GateMarker{
+ SchemaVersion: 1,
+ APIName: "test",
+ Status: "skip",
+ Level: "none",
+ SkipReason: "auth_required_no_credential",
+ AuthContext: Phase5AuthContext{Type: "api_key", APIKeyAvailable: false},
+ })
+
+ result := ValidatePhase5Gate(proofsDir, manifest)
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "run_id")
+}
+
+func TestValidatePhase5Gate_MissingMarkerFails(t *testing.T) {
+ result := ValidatePhase5Gate(t.TempDir(), CLIManifest{APIName: "test", CLIName: "test-pp-cli", RunID: "run-1", AuthType: "api_key"})
+ require.False(t, result.Passed)
+ assert.Contains(t, result.Detail, "missing")
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 88e75682..b868cb27 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -159,6 +159,7 @@ Parse the JSON result. Display each check result to the user:
```
Validating <api-slug>...
manifest PASS
+ phase5 PASS
go mod tidy PASS
go vet PASS
go build PASS
@@ -729,6 +730,7 @@ $ <cli-name> --help
| Check | Result |
|-------|--------|
| Manifest | PASS/FAIL |
+| Phase 5 | PASS/FAIL |
| go mod tidy | PASS/FAIL |
| go vet | PASS/FAIL |
| go build | PASS/FAIL |
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 415e5399..3d4b8244 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -2381,8 +2381,9 @@ When a test fails, fix it immediately — do not accumulate failures. Tag each f
### Step 4: Report and gate
-Write a structured acceptance report. This report is **required** — Phase 5.6
-checks for it before promoting.
+Write a structured acceptance report and a machine-readable gate marker. The
+JSON marker is **required** — Phase 5.6 and `publish validate` check for it
+before promoting or publishing.
```
Acceptance Report: <api>
@@ -2421,6 +2422,58 @@ Write:
`$PROOFS_DIR/<stamp>-fix-<api>-pp-cli-acceptance.md`
+For `Gate: PASS`, also write:
+
+`$PROOFS_DIR/phase5-acceptance.json`
+
+```json
+{
+ "schema_version": 1,
+ "api_name": "<api>",
+ "run_id": "<run-id>",
+ "status": "pass",
+ "level": "quick|full",
+ "matrix_size": 42,
+ "tests_passed": 42,
+ "tests_failed": 0,
+ "auth_context": {
+ "type": "none|api_key|bearer_token|cookie|composed|session_handshake",
+ "api_key_available": true,
+ "browser_session_available": false
+ }
+}
+```
+
+For `level: "quick"`, `tests_failed` may be `1` only when the Quick Check
+threshold still passed (`matrix_size: 6`, `tests_passed >= 5`) and the miss was
+not auth or sync related. For `level: "full"`, `tests_failed` must be `0`.
+
+If Phase 5 is legitimately skipped because the API requires API-key or bearer
+auth and no credential was available, write:
+
+`$PROOFS_DIR/phase5-skip.json`
+
+```json
+{
+ "schema_version": 1,
+ "api_name": "<api>",
+ "run_id": "<run-id>",
+ "status": "skip",
+ "level": "none",
+ "skip_reason": "auth_required_no_credential",
+ "auth_context": {
+ "type": "api_key|bearer_token|oauth2",
+ "api_key_available": false,
+ "browser_session_available": false
+ }
+}
+```
+
+Do **not** write a skip marker for `auth.type: none`. No-auth APIs are testable
+and require `phase5-acceptance.json`. Do **not** use missing API key as the skip
+reason for cookie, composed, or session-handshake auth; those require browser
+session proof or a hold decision.
+
## Phase 5.5: Polish
**Always runs.** Invoke the `printing-press-polish` skill to run diagnostics, fix quality issues, and return a delta. The polish skill carries `context: fork` in its frontmatter, so its diagnostic-fix-rediagnose loop runs in a forked context — diagnostic spam, fix iterations, and re-audits stay scoped to the polish session and don't pollute this generation flow. The skill is autonomous — no user input needed. The goal is to ship the best CLI possible, not the fastest.
@@ -2458,13 +2511,12 @@ Write the polish skill's full response to:
### Acceptance gate check
-Before promoting, verify the acceptance artifact exists when an API key was
-available during this run:
+Before promoting, verify the Phase 5 JSON gate marker:
-- If `$PROOFS_DIR/*-acceptance.md` exists with `Gate: PASS` → proceed to promote.
-- If `$PROOFS_DIR/*-acceptance.md` exists with `Gate: FAIL` → CLI is on hold. Do NOT promote. Proceed to Archive Manuscripts.
-- If no acceptance artifact exists AND an API key was available → Phase 5 was skipped. Go back and run it. Do NOT promote without it.
-- If no acceptance artifact exists AND no API key was available → acceptable. Proceed to promote (the CLI was verified mechanically only).
+- If `$PROOFS_DIR/phase5-acceptance.json` exists with `status: "pass"` → proceed to promote.
+- If `$PROOFS_DIR/phase5-acceptance.json` exists with `status: "fail"` → CLI is on hold. Do NOT promote. Proceed to Archive Manuscripts.
+- If `$PROOFS_DIR/phase5-skip.json` exists and the auth-aware skip is valid → proceed to promote.
+- If neither JSON marker exists → Phase 5 was skipped or not recorded. Go back and run it, or write the valid skip marker. Do NOT promote without one.
### Promote to Library
← aae2af9f feat(skills): retro files parent issue + per-WU sub-issues (
·
back to Cli Printing Press
·
feat(cli): add live dogfood matrix runner (#559) e973d5c5 →