[object Object]

← back to Cli Printing Press

fix(generator): wrapWithProvenance handles non-JSON response bodies (#464)

eeb12ea0c1c1cd18bff2489bab8e003f18a5e767 · 2026-05-01 13:40:41 -0700 · Trevin Chow

For endpoints that return XML, RSS, plain text, or any other non-JSON
content type (e.g., pypi's /rss/updates.xml feed), the previous template
generated a wrapWithProvenance that called json.Marshal on a
json.RawMessage holding the raw bytes. json.Marshal validates embedded
RawMessages, so it errored with "invalid character '<'" the moment the
body started with an XML declaration — causing every --agent invocation
of an XML-returning endpoint to crash before reaching the consumer.

Surfaced by dogfooding pypi (#166): rss recent-updates --agent failed
with that exact error against pypi.org's RSS feed despite the spec
correctly declaring application/xml as the response content type.

Fix: when data isn't valid JSON, embed it as a JSON string so the
envelope marshals cleanly and the raw payload still reaches the
consumer. Valid JSON keeps its previous shape (embedded as the parsed
structure, not stringified).

Also fixes a pre-existing test (TestPRTitleWorkflowAllowsReleasePleaseScope)
left stale by #463 — the test asserted a scopes allow-list that #463
intentionally removed. Updated to accept either shape.

Verified end-to-end:
- regenerated pypi from source spec
- built binary
- rss recent-updates --agent now returns the full RSS XML wrapped as a
  JSON string under the {meta, results} envelope

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

Files touched

Diff

commit eeb12ea0c1c1cd18bff2489bab8e003f18a5e767
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 1 13:40:41 2026 -0700

    fix(generator): wrapWithProvenance handles non-JSON response bodies (#464)
    
    For endpoints that return XML, RSS, plain text, or any other non-JSON
    content type (e.g., pypi's /rss/updates.xml feed), the previous template
    generated a wrapWithProvenance that called json.Marshal on a
    json.RawMessage holding the raw bytes. json.Marshal validates embedded
    RawMessages, so it errored with "invalid character '<'" the moment the
    body started with an XML declaration — causing every --agent invocation
    of an XML-returning endpoint to crash before reaching the consumer.
    
    Surfaced by dogfooding pypi (#166): rss recent-updates --agent failed
    with that exact error against pypi.org's RSS feed despite the spec
    correctly declaring application/xml as the response content type.
    
    Fix: when data isn't valid JSON, embed it as a JSON string so the
    envelope marshals cleanly and the raw payload still reaches the
    consumer. Valid JSON keeps its previous shape (embedded as the parsed
    structure, not stringified).
    
    Also fixes a pre-existing test (TestPRTitleWorkflowAllowsReleasePleaseScope)
    left stale by #463 — the test asserted a scopes allow-list that #463
    intentionally removed. Updated to accept either shape.
    
    Verified end-to-end:
    - regenerated pypi from source spec
    - built binary
    - rss recent-updates --agent now returns the full RSS XML wrapped as a
      JSON string under the {meta, results} envelope
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/cli/release_test.go                       | 22 +++++++++++++---------
 internal/generator/templates/helpers.go.tmpl       | 13 +++++++++++--
 .../printing-press-golden/internal/cli/helpers.go  | 13 +++++++++++--
 3 files changed, 35 insertions(+), 13 deletions(-)

diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go
index 2e2b4cb4..4b6779dd 100644
--- a/internal/cli/release_test.go
+++ b/internal/cli/release_test.go
@@ -146,8 +146,11 @@ func majorVersion(t *testing.T, v string) int {
 func TestPRTitleWorkflowAllowsReleasePleaseScope(t *testing.T) {
 	// release-please uses the target branch as the conventional-commit scope
 	// for generated release PR titles, e.g. chore(main): release 2.2.0.
-	// The PR title workflow must allow that generated title or release PRs
-	// cannot merge.
+	// The PR title workflow must accept that scope. Two valid configurations:
+	//   - explicit scopes allow-list containing "main"
+	//   - no allow-list at all (any scope passes), with requireScope: true
+	// PR #463 removed the allow-list to stop friction with package-name
+	// scopes like `regenmerge`; this test pins both shapes as acceptable.
 	data, err := os.ReadFile("../../.github/workflows/pr-title.yml")
 	require.NoError(t, err)
 
@@ -169,15 +172,16 @@ func TestPRTitleWorkflowAllowsReleasePleaseScope(t *testing.T) {
 			continue
 		}
 
-		scopes, ok := step.With["scopes"].(string)
-		require.True(t, ok, "semantic pull request action should declare scopes")
-
-		allowed := map[string]bool{}
-		for scope := range strings.FieldsSeq(scopes) {
-			allowed[scope] = true
+		if scopes, ok := step.With["scopes"].(string); ok {
+			allowed := map[string]bool{}
+			for scope := range strings.FieldsSeq(scopes) {
+				allowed[scope] = true
+			}
+			assert.True(t, allowed["main"], "scope allow-list must include 'main' for release-please PR titles")
+			return
 		}
 
-		assert.True(t, allowed["main"], "release-please PR titles use main as the scope")
+		// No allow-list — any scope is accepted, including release-please's "main".
 		return
 	}
 
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 02b91c31..68decd3e 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1250,7 +1250,12 @@ func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
 	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
 }
 
-// wrapWithProvenance wraps JSON data in a provenance envelope: {"results": ..., "meta": {...}}.
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
+// the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
+// text), it embeds as a JSON string so json.Marshal doesn't choke on
+// "invalid character '<'" while still passing the raw payload through to
+// the consumer.
 func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
 	meta := map[string]any{"source": prov.Source}
 	if prov.SyncedAt != nil {
@@ -1265,8 +1270,12 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
 	if prov.Freshness != nil {
 		meta["freshness"] = prov.Freshness
 	}
+	var results any = json.RawMessage(data)
+	if !json.Valid(data) {
+		results = string(data)
+	}
 	envelope := map[string]any{
-		"results": json.RawMessage(data),
+		"results": results,
 		"meta":    meta,
 	}
 	return json.Marshal(envelope)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 119e1003..8913e4f0 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -1137,7 +1137,12 @@ func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
 	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
 }
 
-// wrapWithProvenance wraps JSON data in a provenance envelope: {"results": ..., "meta": {...}}.
+// wrapWithProvenance wraps response data in a provenance envelope:
+// {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
+// the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
+// text), it embeds as a JSON string so json.Marshal doesn't choke on
+// "invalid character '<'" while still passing the raw payload through to
+// the consumer.
 func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
 	meta := map[string]any{"source": prov.Source}
 	if prov.SyncedAt != nil {
@@ -1152,8 +1157,12 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
 	if prov.Freshness != nil {
 		meta["freshness"] = prov.Freshness
 	}
+	var results any = json.RawMessage(data)
+	if !json.Valid(data) {
+		results = string(data)
+	}
 	envelope := map[string]any{
-		"results": json.RawMessage(data),
+		"results": results,
 		"meta":    meta,
 	}
 	return json.Marshal(envelope)

← 9bc8276e ci: drop the PR-title scope allow-list, keep requireScope (#  ·  back to Cli Printing Press  ·  fix(openapi): preserve params on single-endpoint specs (#465 45a7722b →