From d472f4e7f68b0b0ae91acb61ef82392d92ba1803 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 19 Jul 2026 12:06:00 -0400 Subject: [PATCH 1/3] fix(generate): pass built-in changelog to manage-release by file to avoid E2BIG The generated manage-release composite action put the full release changelog on an Actions input, which GitHub exposes as an environment variable. Linux caps a single environment variable near 128KB (MAX_ARG_STRLEN), so a changelog above that fails the execve of the step's bash with E2BIG before the program runs. A real repository hit this once its accumulated built-in changelog reached about 135KB: the Manage Release step died and every downstream job cascaded. The built-in changelog window is bounded by design (base is the next env's current SHA, head is the source SHA), so the size is an inherent large-history case, not a windowing bug. The fix keeps the exact same content but stops routing it through an input: the Generate Changelog step writes the markdown to $RUNNER_TEMP/cascade-changelog.md and callers pass that path via a new changelog_file action input, which the action hands straight to cascade manage-release --changelog-file. Only a small fixed path transits the input; the content never touches the env or argv. This preserves byte-identical changelog output and works for same-job and cross-job SHA references. The custom-changelog reusable-workflow path runs on a separate runner and its output can only reach the finalize job as a cross-job workflow output, so it retains the content input as a documented residual. Adds unit coverage proving the built-in path no longer places changelog content on an input, a large-content regression on the CLI file read, and an e2e scenario exercising the file-reference path through orchestrate and publish. Signed-off-by: Joshua Temple --- CONTRIBUTING.md | 1 + .../docs/reference/generated-workflows.md | 5 +- .../75-release-changelog-file-reference.yaml | 87 ++++++++++++ internal/generate/actions.go | 21 ++- .../generate/changelog_file_reference_test.go | 130 ++++++++++++++++++ internal/generate/generator.go | 19 ++- internal/generate/output_hardening_test.go | 41 ++++-- internal/generate/promote.go | 14 +- internal/generate/release.go | 12 +- ...ctions__manage-release__action.yaml.golden | 21 ++- ...github__workflows__orchestrate.yaml.golden | 9 +- .../.github__workflows__promote.yaml.golden | 15 +- internal/release/large_changelog_file_test.go | 49 +++++++ 13 files changed, 374 insertions(+), 50 deletions(-) create mode 100644 e2e/scenarios/75-release-changelog-file-reference.yaml create mode 100644 internal/generate/changelog_file_reference_test.go create mode 100644 internal/release/large_changelog_file_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7949cc6..9977ef7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,7 @@ cascade holds to a few conventions in its own codebase and in the workflows it g - **Every emitted manifest value is shape-validated at the boundary, and every manifest field is classified**: a manifest value that reaches emitted output (a YAML key or scalar, a shell string, a github-script literal, a cron entry, argv, or a `${{ }}` expression) must be validated at `config.Validate` with a shape the sink can carry safely, using the shared helpers in `internal/config/validate_shapes.go` (cron, event-type, single-line, shell-double-quoted, JS-single-quoted, secret-name, tag-prefix, and so on) rather than a new ad-hoc rule. Values with a legitimate need for a risky character are quoted at emit (`yamlSingleQuote`, shell single quotes) and validation rejects only what quoting cannot carry. The guard test `TestEmittedFieldRegistry_EveryFieldClassified` (internal/generate) walks every string-carrying manifest field by reflection and fails until a new field is either registered with its shape (which also drives the adversarial battery in `TestEmittedFieldBattery_HostileRejectedGoodRoundTrips`) or explicitly allowlisted as not emitted, with the reason. Validation applies to each component's resolved configuration too, so a per-component override can never bypass a rule the top level enforces. - **A component-scoped runtime reads resolved component values, never root ones**: the generator emits each per-component workflow from `config.TrunkConfig.ResolveComponent` (its deploy job names and gates, tag grammar, environments, publish shape), so every component-scoped decision a CLI path makes must read the value resolved for that component, or the runtime and the workflow it is driving disagree. Swapping the resolved config in whole is how a path reaches that when it can hold one, and it is the default: `promote`, `rollback`, and `orchestrate` preflights and finalizes all do. A path that cannot hold a swapped config resolves per value instead, through named resolvers every sink reads (see the `hotfix` carve-out below); that satisfies the rule by the same standard, since the test is that no sink is left reading a root value, not which mechanism got it there. What is forbidden either way is resolving a component and then reading root values anyway. A per-field copy silently leaves every uncopied override reading the root value, and the workflow and the runtime then disagree about job names, gates, or grammar: the component promote path once carried over only `environments`, so the generated deploy gates checked component deploy names the runtime never emitted and every deploy skipped while the promotion recorded success. On a path that reads the tag grammar, the component grammar's `StrictPrefix` invariant rides the swap (a component parses its tags literally), matching `ResolvedComponent.TagGrammarSpec`; `ResolveComponent` does not set it on the resolved config, so the swapping path forces it. A path that never parses a tag, such as `rollback`, has no grammar to carry and must not fake one. `hotfix` is the path that cannot hold a swapped config: both its verbs resolve the component out of the working config a second time to reach the component's grammar, and `ResolveComponent` clears `Components` on the config it returns, so a swapped-in config could not be resolved from again and would fail with "component is not declared". It therefore resolves its ladder and its grammar through named resolvers (`resolveEnvLadder`, `resolveFinalizeSpec`) that every sink reads. Reaching for a resolver rather than a swap needs that kind of structural reason, stated at the resolver, never a preference; and a resolver only holds the line while every sink reads it, so a new component-scoped read is a new sink to route through it. - **Every value sent to a length-capped API field is bounded before it is sent**: a GitHub API field with a documented maximum (a release body at 125,000 characters, a pull request or issue comment body at 65,536) rejects an oversized value with a 422 rather than truncating it server-side, so a value composed from unbounded input must be capped at the call site, not assumed to fit. Unbounded means the input grows with the repository rather than with the manifest: a changelog, a commit range, a diff, a log. Size the input by its worst realistic case, not its typical one: a changelog looks small until `previous_tag` is empty, which makes it span the repository's entire history, and that is the normal state both after a state reset and for the first cascade release in a repository with existing history. The release body reached 125,000 characters on a real repository this way and failed finalize, which stranded the state write and cascaded into every downstream job. A cap truncates on a boundary that keeps the retained content meaningful (a whole line or entry, never mid-item), counts characters the way the API does (runes, never bytes, so a multi-byte character is never split into invalid UTF-8), reserves its own marker's length from the budget so the result lands under the cap rather than at it, and leaves a marker saying what was dropped and linking to where the full content lives. Truncation is never silent. A field bounded by construction (a semver tag, a fixed-format release name) needs no cap; say so rather than adding one. +- **Large or unbounded content passes by file reference, never through an action input or environment variable**: a generated Actions step must not place content that grows with the repository (a changelog, a diff, a commit range, a log) on a composite-action input or an environment variable. GitHub exposes an action input as an environment variable, and `execve` caps a single environment variable near 128KB, so a large value on that path fails the step with `E2BIG` before the program runs. The built-in changelog hit this on a real repository: an accumulated changelog around 135KB killed the Manage Release step and cascaded into every downstream job. Pass such content by file reference instead: write it to a file (the runner temp dir is job-constant and shared across steps in the same job) and pass the path, which stays small regardless of content size. The `manage-release` composite action takes a `changelog_file` path for exactly this reason, and the generated workflows write the changelog to `$RUNNER_TEMP/cascade-changelog.md` and pass the path rather than the content. A value bounded by construction (a tag, a SHA, a version) is fine on an input; the rule is for content whose size the manifest does not bound. - **Callback isolation**: generated workflows call your workflows via `workflow_call`, and cascade never reaches into your callback logic. - **Metadata courier**: cascade passes artifact identifiers and versions between stages. It never touches your container registry, package registry, or the systems you deploy to directly. diff --git a/docs/src/content/docs/reference/generated-workflows.md b/docs/src/content/docs/reference/generated-workflows.md index c35f11d2..e82fd796 100644 --- a/docs/src/content/docs/reference/generated-workflows.md +++ b/docs/src/content/docs/reference/generated-workflows.md @@ -138,12 +138,15 @@ Baseline trigger is `workflow_dispatch` only, with inputs `environment`, `target |-------|---------| | `action` | `create`, `update`, `lock`, `prerelease`, `publish`, or `delete`. | | `repo`, `sha`, `tag`, `environment` | Identify the release target. | -| `changelog` | Release notes markdown. | +| `changelog` | Release notes markdown, passed inline. | +| `changelog_file` | Path to a file holding the release notes. Preferred over `changelog`; the generated workflows pass the built-in changelog this way. | | `previous_tag`, `new_tag`, `delete_tag`, `create_tag` | Used by specific actions (changelog comparison, retagging, cleanup). | | `token` | A GitHub token with repo permissions. | Outputs: `release_id`, `release_url`, `html_url`. The action shells out to the same `cascade` binary already installed by `setup-cli`, so its behavior matches the CLI exactly. +The built-in changelog is passed by file reference, not inline. The Generate Changelog step writes the notes to a file under the runner temp dir and the Manage Release step reads that path via `changelog_file`. This keeps large content off the environment: a changelog placed on an action input becomes an environment variable, and `execve` caps a single environment variable near 128KB, so a big changelog on that path fails the step with `E2BIG`. A file path is small and fixed, so the content never transits an input regardless of size. The inline `changelog` input remains for callers that pass notes directly. + ## Opt-in companions These emit only when their manifest block is present and enabled; an unconfigured manifest is unaffected. diff --git a/e2e/scenarios/75-release-changelog-file-reference.yaml b/e2e/scenarios/75-release-changelog-file-reference.yaml new file mode 100644 index 00000000..eb4e97a9 --- /dev/null +++ b/e2e/scenarios/75-release-changelog-file-reference.yaml @@ -0,0 +1,87 @@ +name: "Release changelog passed by file reference" +description: | + A single-environment repository generates a Release workflow into + promote.yaml (see 09-single-env-repo.yaml). Its built-in changelog can grow + large on a big-history environment, so the Generate Changelog step writes the + content to a file on the runner temp dir and the Manage Release step reads + that path via changelog_file rather than placing the content on a composite + action input. Passing large content on an input makes it an environment + variable, and execve caps a single env var near 128KB, so a big changelog on + that path fails the step with E2BIG. + + This scenario cuts a draft RC (orchestrate exercises the manage-release update + via changelog_file at runtime) and then publishes it (manage-release publish + via changelog_file at runtime). Both runtime paths flip the observed release + state, and the generation-only workflow_files assertions pin the file-write + redirect and the changelog_file wiring so the content never transits an input. + +config: + trunk_branch: main + environments: [prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: [] + +steps: + - name: "Initial feature commit" + action: commit + commit: + message: "feat: add initial feature" + files: + src/app.go: | + package main + func main() { + println("Hello v0.1.0") + } + + - name: "Orchestrate cuts a draft RC via changelog_file" + action: orchestrate + expect: + state: + prod: + sha: commit1 + version: "v0.1.0-rc.0" + jobs: + build-app: success + releases: + - tag: "v0.1.0-rc.0" + prerelease: true + draft: true + tags: + exist: ["v0.1.0-rc.0"] + # Generation-only: the built-in changelog is written to a file and passed + # to the Manage Release step by path, so large content never reaches an + # action input (env var, capped near 128KB by execve). + workflow_files: + - path: ".github/workflows/promote.yaml" + contains: + - " changelog_file: ${{ runner.temp }}/cascade-changelog.md\n" + - "echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\"\n" + not_contains: + # The changelog content never rides an action input nor a + # $GITHUB_OUTPUT heredoc for the built-in path. + - "changelog: ${{ steps.changelog.outputs.changelog }}" + - "changelog<<" + + - name: "Publish the release via changelog_file" + action: promote + promote: + mode: default + expect: + state: + release: + sha: commit1 + version: "v0.1.0" + prod: + sha: commit1 + version: "v0.1.0-rc.0" + releases: + - tag: "v0.1.0" + prerelease: false + draft: false + latest: true + tags: + exist: ["v0.1.0"] + deleted: ["v0.1.0-rc.0"] diff --git a/internal/generate/actions.go b/internal/generate/actions.go index 464e1fdb..041c9bd8 100644 --- a/internal/generate/actions.go +++ b/internal/generate/actions.go @@ -89,6 +89,10 @@ inputs: description: 'Release notes markdown' required: false default: '' + changelog_file: + description: 'Path to a file with release notes markdown (preferred over changelog; keeps large content off the env/argv path to avoid E2BIG)' + required: false + default: '' token: description: 'GitHub token with repo permissions' required: true @@ -141,6 +145,7 @@ runs: INPUT_SHA: ${{ inputs.sha }} INPUT_TAG: ${{ inputs.tag }} INPUT_CHANGELOG: ${{ inputs.changelog }} + INPUT_CHANGELOG_FILE: ${{ inputs.changelog_file }} INPUT_PREVIOUS_TAG: ${{ inputs.previous_tag }} INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} @@ -151,9 +156,17 @@ runs: } sb.WriteString(` GITHUB_TOKEN: ${{ inputs.token }} run: | - # Write changelog to temp file to handle multiline content - CHANGELOG_FILE=$(mktemp) - printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + # Resolve changelog source. A caller-provided file keeps large changelog + # content off the env/argv path: execve caps one env var near 128KB, so a + # big changelog on INPUT_CHANGELOG fails with E2BIG. Fall back to the + # inline input for callers that still pass content directly. + if [[ -n "$INPUT_CHANGELOG_FILE" ]]; then + CHANGELOG_FILE="$INPUT_CHANGELOG_FILE" + else + CHANGELOG_FILE=$(mktemp) + CHANGELOG_TEMP="$CHANGELOG_FILE" + printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + fi # Build command arguments CMD_ARGS=( @@ -175,7 +188,7 @@ runs: sb.WriteString(` # Run CLI OUTPUT=$(cascade manage-release "${CMD_ARGS[@]}" --changelog-file "$CHANGELOG_FILE") - rm -f "$CHANGELOG_FILE" + [[ -n "${CHANGELOG_TEMP:-}" ]] && rm -f "$CHANGELOG_TEMP" # Parse and write outputs echo "release_id=$(echo "$OUTPUT" | sed -n '1p')" >> "$GITHUB_OUTPUT" diff --git a/internal/generate/changelog_file_reference_test.go b/internal/generate/changelog_file_reference_test.go new file mode 100644 index 00000000..d3cc8089 --- /dev/null +++ b/internal/generate/changelog_file_reference_test.go @@ -0,0 +1,130 @@ +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The built-in release changelog window is bounded by design but can still grow +// large on a big-history environment. Passing that content through a composite +// action input places it on an environment variable, and execve caps a single +// env var near 128KB, so a large changelog fails the step with E2BIG. The +// built-in callers must therefore pass the changelog by file reference: the +// Generate Changelog step writes the content to a fixed path under the runner +// temp dir, and the manage-release step reads that path via changelog_file. +// +// These assertions pin that wiring for every built-in caller: the single-env +// release workflow, the multi-env promote workflow, and the orchestrate +// finalize job. The custom cross-job changelog path is out of scope here and is +// covered by custom_changelog_test.go, which keeps the content input. + +const ( + changelogFileInput = "changelog_file: ${{ runner.temp }}/cascade-changelog.md" + changelogFileRedirect = `> "$RUNNER_TEMP/cascade-changelog.md"` + changelogContentInput = "changelog: ${{ steps.changelog.outputs.changelog }}" + changelogHeredocStart = "changelog<<" +) + +// assertBuiltinChangelogByFile asserts a generated workflow passes its built-in +// changelog by file reference and never places the content on an action input. +func assertBuiltinChangelogByFile(t *testing.T, content string) { + t.Helper() + assert.Contains(t, content, changelogFileInput, + "built-in caller must pass the changelog by file path via changelog_file") + assert.Contains(t, content, changelogFileRedirect, + "Generate Changelog step must write the changelog to the runner temp file") + assert.NotContains(t, content, changelogContentInput, + "built-in caller must not pass changelog content on the action input") + assert.NotContains(t, content, changelogHeredocStart, + "Generate Changelog step must not emit the changelog as a heredoc output") +} + +// TestReleasePublish_BuiltinChangelogByFileNotContent covers the single-env +// release workflow (promote.yaml), whose create-draft, prerelease, and publish +// steps all consume the built-in changelog. +func TestReleasePublish_BuiltinChangelogByFileNotContent(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: config.EnvNames("prod"), + } + + content, err := NewReleaseGenerator(cfg, "").Generate() + require.NoError(t, err) + + assertBuiltinChangelogByFile(t, content) +} + +// TestPromote_BuiltinChangelogByFileNotContent covers the multi-env promote +// workflow (promote.yaml), whose update, prerelease, and publish steps all +// consume the built-in changelog. +func TestPromote_BuiltinChangelogByFileNotContent(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: config.EnvNames("dev", "staging", "prod"), + } + + content, err := NewPromoteGenerator(cfg, "").Generate() + require.NoError(t, err) + + assertBuiltinChangelogByFile(t, content) +} + +// TestOrchestrateFinalize_BuiltinChangelogByFileNotContent covers the +// orchestrate workflow finalize job, whose Manage Release step consumes the +// built-in changelog produced by the in-job Generate Changelog step. +func TestOrchestrateFinalize_BuiltinChangelogByFileNotContent(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github/workflows"), 0755)) + require.NoError(t, os.WriteFile( + filepath.Join(tmpDir, ".github/workflows/build.yaml"), + []byte("on:\n workflow_call:\n"), 0644)) + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: config.EnvNames("dev", "prod"), + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + } + + content, err := NewGenerator(cfg, tmpDir).Generate() + require.NoError(t, err) + + assertBuiltinChangelogByFile(t, content) +} + +// TestManageReleaseAction_SupportsChangelogFileInput pins the composite action +// contract in both the own-repo and standard variants: it declares a +// changelog_file input, threads it to INPUT_CHANGELOG_FILE, prefers a +// caller-provided file, passes the resolved path to --changelog-file, and only +// removes a temp file it created itself, never a caller-provided file. +func TestManageReleaseAction_SupportsChangelogFileInput(t *testing.T) { + for _, ownRepo := range []bool{false, true} { + ownRepo := ownRepo + name := "standard" + if ownRepo { + name = "own-repo" + } + t.Run(name, func(t *testing.T) { + action := generateManageReleaseAction(ownRepo) + + assert.Contains(t, action, "changelog_file:", + "action must declare a changelog_file input") + assert.Contains(t, action, "INPUT_CHANGELOG_FILE: ${{ inputs.changelog_file }}", + "action must thread changelog_file to INPUT_CHANGELOG_FILE") + assert.Contains(t, action, `if [[ -n "$INPUT_CHANGELOG_FILE" ]]; then`, + "action must prefer a caller-provided changelog file") + assert.Contains(t, action, `--changelog-file "$CHANGELOG_FILE"`, + "action must pass the resolved changelog path to the CLI") + assert.Contains(t, action, `[[ -n "${CHANGELOG_TEMP:-}" ]] && rm -f "$CHANGELOG_TEMP"`, + "action must only remove a temp file it created, never a caller file") + assert.NotContains(t, action, `rm -f "$CHANGELOG_FILE"`, + "action must not unconditionally delete the changelog file") + }) + } +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index d291176e..28852672 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -2301,7 +2301,11 @@ func (g *Generator) writeChangelogStep(sb *strings.Builder) { sb.WriteString(" --contributors \\\n") } sb.WriteString(" --repo \"${{ github.repository }}\")\n") - writeOutputHeredocLines(sb, " ", "changelog", "echo \"$RESULT\" | jq -r '.changelog'") + // Write the changelog to a file on the runner temp dir and pass its path + // to manage-release via changelog_file. Placing the content on an action + // input makes it an environment variable, and execve caps a single env + // var near 128KB, so a large changelog would fail the step with E2BIG. + sb.WriteString(" echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\"\n") } } @@ -2380,11 +2384,18 @@ func (g *Generator) writeReleaseStep(sb *strings.Builder) { sb.WriteString(" sha: ${{ needs.setup.outputs.head_sha }}\n") if g.config.ChangelogEnabled() { if g.config.HasCustomChangelog() { - // Custom changelog runs as its own job; read its job output. + // Custom changelog runs as its own job on a different runner, so its + // content only reaches this step as a cross-job workflow output. + // That path retains the content input; it cannot be file-referenced + // without an artifact contract and is a documented residual. The + // built-in path below passes the changelog by file (changelog_file). fmt.Fprintf(sb, " changelog: ${{ needs.%s.outputs.changelog }}\n", changelogJobID) } else { - // Built-in changelog runs as a step in this job; read the step output. - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + // Built-in changelog runs as a step in this job, which wrote the + // content to a file on the runner temp dir. Pass its path so large + // content never transits an action input (env var, capped near 128KB + // by execve, which fails a big changelog with E2BIG). + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") } } sb.WriteString(" previous_tag: ${{ needs.setup.outputs.previous_tag }}\n") diff --git a/internal/generate/output_hardening_test.go b/internal/generate/output_hardening_test.go index 051216c7..8e8019f5 100644 --- a/internal/generate/output_hardening_test.go +++ b/internal/generate/output_hardening_test.go @@ -8,11 +8,11 @@ import ( "github.com/stretchr/testify/require" ) -// The changelog written to $GITHUB_OUTPUT carries arbitrary commit-message -// text. With a fixed heredoc delimiter, a commit message containing a bare -// "EOF" line terminates the heredoc early and the remaining lines parse as -// forged step outputs (versions, refs) consumed by downstream jobs. Every -// generated heredoc must therefore mint a random delimiter at runtime. +// A multiline value written to $GITHUB_OUTPUT carries arbitrary text. With a +// fixed heredoc delimiter, a line containing a bare "EOF" terminates the +// heredoc early and the remaining lines parse as forged step outputs (versions, +// refs) consumed by downstream jobs. Every generated $GITHUB_OUTPUT heredoc must +// therefore mint a random delimiter at runtime. // assertRandomizedOutputHeredoc asserts the generated step body writes its // multiline output through a runtime-random delimiter rather than a fixed one. @@ -28,7 +28,26 @@ func assertRandomizedOutputHeredoc(t *testing.T, content, key string) { "heredoc must close with the same random delimiter") } -func TestOrchestrate_ChangelogHeredocRandomDelimiter(t *testing.T) { +// The changelog carries arbitrary commit-message text and can grow large. It is +// no longer routed through $GITHUB_OUTPUT at all: the Generate Changelog step +// writes it to a file on the runner temp dir, and manage-release reads that path +// via changelog_file. This closes the heredoc-forgery vector by construction +// (no $GITHUB_OUTPUT heredoc for the changelog to forge) and keeps large content +// off the env/argv path (execve caps a single env var near 128KB, failing a big +// changelog with E2BIG). + +// assertChangelogWrittenToFile asserts the generated Generate Changelog step +// writes the changelog to the runner temp file and never emits it as a +// $GITHUB_OUTPUT heredoc. +func assertChangelogWrittenToFile(t *testing.T, content string) { + t.Helper() + assert.Contains(t, content, `echo "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md"`, + "Generate Changelog step must write the changelog to the runner temp file") + assert.NotContains(t, content, "changelog<<", + "changelog must not be emitted as a $GITHUB_OUTPUT heredoc; the forgery vector is closed by writing to a file") +} + +func TestOrchestrate_ChangelogWrittenToFile(t *testing.T) { tmpDir := t.TempDir() writeStubWorkflow(t, tmpDir, "build.yaml") @@ -42,10 +61,10 @@ func TestOrchestrate_ChangelogHeredocRandomDelimiter(t *testing.T) { content, err := NewGenerator(cfg, tmpDir).Generate() require.NoError(t, err) - assertRandomizedOutputHeredoc(t, content, "changelog") + assertChangelogWrittenToFile(t, content) } -func TestPromote_ChangelogHeredocRandomDelimiter(t *testing.T) { +func TestPromote_ChangelogWrittenToFile(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: config.EnvNames("staging", "prod"), @@ -53,10 +72,10 @@ func TestPromote_ChangelogHeredocRandomDelimiter(t *testing.T) { content, err := NewPromoteGenerator(cfg, "").Generate() require.NoError(t, err) - assertRandomizedOutputHeredoc(t, content, "changelog") + assertChangelogWrittenToFile(t, content) } -func TestRelease_ChangelogHeredocRandomDelimiter(t *testing.T) { +func TestRelease_ChangelogWrittenToFile(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", Environments: config.EnvNames("prod"), @@ -64,7 +83,7 @@ func TestRelease_ChangelogHeredocRandomDelimiter(t *testing.T) { content, err := NewReleaseGenerator(cfg, "").Generate() require.NoError(t, err) - assertRandomizedOutputHeredoc(t, content, "changelog") + assertChangelogWrittenToFile(t, content) } // TestPRPreview_SummaryHeredocRandomDelimiter asserts the plan-summary body diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 2db1913b..775f4d76 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -1137,7 +1137,11 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { changelogCmd += " --contributors" } fmt.Fprintf(sb, " RESULT=$(%s)\n", changelogCmd) - writeOutputHeredocLines(sb, " ", "changelog", "echo \"$RESULT\" | jq -r '.changelog'") + // Write the changelog to a file on the runner temp dir and pass its path to + // manage-release via changelog_file. Placing the content on an action input + // makes it an environment variable, and execve caps a single env var near + // 128KB, so a large changelog would fail the step with E2BIG. + sb.WriteString(" echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\"\n") // Extract release data from promotion result (for prerelease/publish steps) // This contains the correct SHA and versions for the environment being released, @@ -1179,7 +1183,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") sb.WriteString(" sha: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" tag: ${{ needs.preflight.outputs.source_version }}\n") - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // For prerelease/final env targets, ensure the release exists first (create as draft if needed) @@ -1193,7 +1197,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" environment: ${{ needs.preflight.outputs.source_env }}\n") sb.WriteString(" sha: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" tag: ${{ needs.preflight.outputs.source_version }}\n") - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // Prerelease - at second-to-last environment, mark as pre-release but KEEP RC tag @@ -1207,7 +1211,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" environment: ${{ needs.preflight.outputs.target_env }}\n") sb.WriteString(" sha: ${{ steps.release-data.outputs.sha }}\n") sb.WriteString(" tag: ${{ steps.release-data.outputs.rc_version }}\n") - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // Clean up orphaned releases for skipped environments @@ -1242,7 +1246,7 @@ func (g *PromoteGenerator) writeFinalizeJob(sb *strings.Builder) { sb.WriteString(" sha: ${{ steps.release-data.outputs.sha }}\n") sb.WriteString(" tag: ${{ steps.release-data.outputs.sem_version }}\n") sb.WriteString(" delete_tag: ${{ steps.release-data.outputs.rc_version }}\n") // RC tag to find release - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // Trigger the configured release-build workflow to build and attach binaries. diff --git a/internal/generate/release.go b/internal/generate/release.go index d26b11fc..0313ce39 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -335,7 +335,11 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { sb.WriteString(" fi\n") sb.WriteString(" \n") sb.WriteString(" RESULT=$(cascade generate-changelog --base-sha \"$LATEST_SHA\" --head-sha \"$SOURCE_SHA\" --repo \"${{ github.repository }}\")\n") - writeOutputHeredocLines(sb, " ", "changelog", "echo \"$RESULT\" | jq -r '.changelog'") + // Write the changelog to a file on the runner temp dir and pass its path to + // manage-release via changelog_file. Placing the content on an action input + // makes it an environment variable, and execve caps a single env var near + // 128KB, so a large changelog would fail the step with E2BIG. + sb.WriteString(" echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\"\n") // Create draft release sb.WriteString(" - name: Create Draft Release\n") @@ -347,7 +351,7 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { sb.WriteString(" environment: draft\n") sb.WriteString(" sha: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" tag: ${{ needs.preflight.outputs.semver_tag }}\n") - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // Create prerelease @@ -361,7 +365,7 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { sb.WriteString(" sha: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" tag: ${{ needs.preflight.outputs.source_version }}\n") sb.WriteString(" new_tag: ${{ needs.preflight.outputs.semver_tag }}\n") - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n", g.getReleaseTokenRef()) // Publish release @@ -375,7 +379,7 @@ func (g *ReleaseGenerator) writeReleaseJob(sb *strings.Builder) { sb.WriteString(" sha: ${{ needs.preflight.outputs.source_sha }}\n") sb.WriteString(" tag: ${{ needs.preflight.outputs.semver_tag }}\n") sb.WriteString(" delete_tag: ${{ needs.preflight.outputs.source_version }}\n") // RC tag to find release - sb.WriteString(" changelog: ${{ steps.changelog.outputs.changelog }}\n") + sb.WriteString(" changelog_file: ${{ runner.temp }}/cascade-changelog.md\n") fmt.Fprintf(sb, " token: %s\n\n", g.getReleaseTokenRef()) } diff --git a/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden index b888fb73..a5d66f93 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden @@ -24,6 +24,10 @@ inputs: description: 'Release notes markdown' required: false default: '' + changelog_file: + description: 'Path to a file with release notes markdown (preferred over changelog; keeps large content off the env/argv path to avoid E2BIG)' + required: false + default: '' token: description: 'GitHub token with repo permissions' required: true @@ -68,15 +72,24 @@ runs: INPUT_SHA: ${{ inputs.sha }} INPUT_TAG: ${{ inputs.tag }} INPUT_CHANGELOG: ${{ inputs.changelog }} + INPUT_CHANGELOG_FILE: ${{ inputs.changelog_file }} INPUT_PREVIOUS_TAG: ${{ inputs.previous_tag }} INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} INPUT_CREATE_TAG: ${{ inputs.create_tag }} GITHUB_TOKEN: ${{ inputs.token }} run: | - # Write changelog to temp file to handle multiline content - CHANGELOG_FILE=$(mktemp) - printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + # Resolve changelog source. A caller-provided file keeps large changelog + # content off the env/argv path: execve caps one env var near 128KB, so a + # big changelog on INPUT_CHANGELOG fails with E2BIG. Fall back to the + # inline input for callers that still pass content directly. + if [[ -n "$INPUT_CHANGELOG_FILE" ]]; then + CHANGELOG_FILE="$INPUT_CHANGELOG_FILE" + else + CHANGELOG_FILE=$(mktemp) + CHANGELOG_TEMP="$CHANGELOG_FILE" + printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + fi # Build command arguments CMD_ARGS=( @@ -93,7 +106,7 @@ runs: # Run CLI OUTPUT=$(cascade manage-release "${CMD_ARGS[@]}" --changelog-file "$CHANGELOG_FILE") - rm -f "$CHANGELOG_FILE" + [[ -n "${CHANGELOG_TEMP:-}" ]] && rm -f "$CHANGELOG_TEMP" # Parse and write outputs echo "release_id=$(echo "$OUTPUT" | sed -n '1p')" >> "$GITHUB_OUTPUT" diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden index 61937cf5..cb372333 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden @@ -207,12 +207,7 @@ jobs: --base-sha "${{ needs.setup.outputs.changelog_base_sha }}" \ --head-sha "${{ needs.setup.outputs.head_sha }}" \ --repo "${{ github.repository }}") - CASCADE_DELIM="$(dd if=/dev/urandom bs=15 count=1 status=none | base64)" - { - echo "changelog<<${CASCADE_DELIM}" - echo "$RESULT" | jq -r '.changelog' - echo "${CASCADE_DELIM}" - } >> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md" - name: Manage Release uses: ./.github/actions/manage-release with: @@ -222,7 +217,7 @@ jobs: create_tag: 'true' environment: ${{ github.event.inputs.environment || 'dev' }} sha: ${{ needs.setup.outputs.head_sha }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md previous_tag: ${{ needs.setup.outputs.previous_tag }} token: ${{ secrets.GITHUB_TOKEN }} - name: Update Manifest diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden index 4beb729e..f6fd9e32 100644 --- a/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden @@ -371,12 +371,7 @@ jobs: fi RESULT=$(cascade generate-changelog --base-sha "$TARGET_SHA" --head-sha "$SOURCE_SHA" --repo "${{ github.repository }}") - CASCADE_DELIM="$(dd if=/dev/urandom bs=15 count=1 status=none | base64)" - { - echo "changelog<<${CASCADE_DELIM}" - echo "$RESULT" | jq -r '.changelog' - echo "${CASCADE_DELIM}" - } >> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md" - name: Extract Release Data id: release-data env: @@ -412,7 +407,7 @@ jobs: environment: ${{ needs.preflight.outputs.target_env }} sha: ${{ needs.preflight.outputs.source_sha }} tag: ${{ needs.preflight.outputs.source_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.GITHUB_TOKEN }} - name: Ensure Release Exists if: ${{ github.event.inputs.dry_run != 'true' && (needs.preflight.outputs.is_prerelease_env == 'true' || needs.preflight.outputs.is_final_env == 'true') }} @@ -423,7 +418,7 @@ jobs: environment: ${{ needs.preflight.outputs.source_env }} sha: ${{ needs.preflight.outputs.source_sha }} tag: ${{ needs.preflight.outputs.source_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.GITHUB_TOKEN }} - name: Create Prerelease if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_prerelease_env == 'true' }} @@ -434,7 +429,7 @@ jobs: environment: ${{ needs.preflight.outputs.target_env }} sha: ${{ steps.release-data.outputs.sha }} tag: ${{ steps.release-data.outputs.rc_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.GITHUB_TOKEN }} - name: Cleanup Orphaned Releases if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.skipped_envs != '' }} @@ -463,7 +458,7 @@ jobs: sha: ${{ steps.release-data.outputs.sha }} tag: ${{ steps.release-data.outputs.sem_version }} delete_tag: ${{ steps.release-data.outputs.rc_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.GITHUB_TOKEN }} - name: Finalize Promotion if: ${{ github.event.inputs.dry_run != 'true' }} diff --git a/internal/release/large_changelog_file_test.go b/internal/release/large_changelog_file_test.go new file mode 100644 index 00000000..c3cd280d --- /dev/null +++ b/internal/release/large_changelog_file_test.go @@ -0,0 +1,49 @@ +package release + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/globals" +) + +// TestManageRelease_LargeChangelogFlowsViaFile proves the manage-release command +// accepts a changelog larger than the execve single-env-var cap (near 128KB) +// when it arrives by file reference. The generated composite action passes the +// built-in changelog by path for exactly this reason: content on an input +// becomes an environment variable and a large changelog fails the step with +// E2BIG. Reading the file in-process cannot reproduce the shell limit, so this +// asserts the CLI reads the file without complaint; the generator tests prove +// built-in callers only ever place a path on the input. +func TestManageRelease_LargeChangelogFlowsViaFile(t *testing.T) { + globals.SetDryRun(true) + t.Cleanup(func() { globals.SetDryRun(false) }) + + // 200KB comfortably exceeds the execve single-env-var cap near 128KB. + large := strings.Repeat("changelog entry line\n", 10000) + if len(large) < 128*1024 { + t.Fatalf("test changelog must exceed the 128KB env cap, got %d bytes", len(large)) + } + + changelogPath := filepath.Join(t.TempDir(), "cascade-changelog.md") + if err := os.WriteFile(changelogPath, []byte(large), 0644); err != nil { + t.Fatalf("writing large changelog file: %v", err) + } + + cmd := NewCommand() + cmd.SetArgs([]string{ + "--repo", "owner/repo", + "--action", "update", + "--environment", "prod", + "--sha", "deadbeef", + "--tag", "v1.2.3", + "--token", "test-token", + "--changelog-file", changelogPath, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("manage-release with a large changelog file must succeed under dry-run, got: %v", err) + } +} From 8753becf02109f9f14d7a6f34a377a4c3db57257 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 19 Jul 2026 12:12:02 -0400 Subject: [PATCH 2/3] chore(workflows): regenerate own-repo workflows for changelog file reference Regenerate cascade's own emitted workflows and manage-release action so the committed .github/ matches the generator after the changelog file-reference change. The built-in changelog now writes to $RUNNER_TEMP/cascade-changelog.md and the release/promote/orchestrate steps pass changelog_file instead of the changelog content, keeping large content off the env path. Signed-off-by: Joshua Temple --- .github/actions/manage-release/action.yaml | 21 +++++++++++++++++---- .github/workflows/orchestrate.yaml | 9 ++------- .github/workflows/promote.yaml | 15 +++++---------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/.github/actions/manage-release/action.yaml b/.github/actions/manage-release/action.yaml index efab3258..cd684254 100644 --- a/.github/actions/manage-release/action.yaml +++ b/.github/actions/manage-release/action.yaml @@ -24,6 +24,10 @@ inputs: description: 'Release notes markdown' required: false default: '' + changelog_file: + description: 'Path to a file with release notes markdown (preferred over changelog; keeps large content off the env/argv path to avoid E2BIG)' + required: false + default: '' token: description: 'GitHub token with repo permissions' required: true @@ -72,6 +76,7 @@ runs: INPUT_SHA: ${{ inputs.sha }} INPUT_TAG: ${{ inputs.tag }} INPUT_CHANGELOG: ${{ inputs.changelog }} + INPUT_CHANGELOG_FILE: ${{ inputs.changelog_file }} INPUT_PREVIOUS_TAG: ${{ inputs.previous_tag }} INPUT_NEW_TAG: ${{ inputs.new_tag }} INPUT_DELETE_TAG: ${{ inputs.delete_tag }} @@ -79,9 +84,17 @@ runs: INPUT_TAG_ONLY: ${{ inputs.tag_only }} GITHUB_TOKEN: ${{ inputs.token }} run: | - # Write changelog to temp file to handle multiline content - CHANGELOG_FILE=$(mktemp) - printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + # Resolve changelog source. A caller-provided file keeps large changelog + # content off the env/argv path: execve caps one env var near 128KB, so a + # big changelog on INPUT_CHANGELOG fails with E2BIG. Fall back to the + # inline input for callers that still pass content directly. + if [[ -n "$INPUT_CHANGELOG_FILE" ]]; then + CHANGELOG_FILE="$INPUT_CHANGELOG_FILE" + else + CHANGELOG_FILE=$(mktemp) + CHANGELOG_TEMP="$CHANGELOG_FILE" + printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + fi # Build command arguments CMD_ARGS=( @@ -99,7 +112,7 @@ runs: # Run CLI OUTPUT=$(cascade manage-release "${CMD_ARGS[@]}" --changelog-file "$CHANGELOG_FILE") - rm -f "$CHANGELOG_FILE" + [[ -n "${CHANGELOG_TEMP:-}" ]] && rm -f "$CHANGELOG_TEMP" # Parse and write outputs echo "release_id=$(echo "$OUTPUT" | sed -n '1p')" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/orchestrate.yaml b/.github/workflows/orchestrate.yaml index 5afe9758..60ef16a9 100644 --- a/.github/workflows/orchestrate.yaml +++ b/.github/workflows/orchestrate.yaml @@ -137,12 +137,7 @@ jobs: --head-sha "${{ needs.setup.outputs.head_sha }}" \ --contributors \ --repo "${{ github.repository }}") - CASCADE_DELIM="$(dd if=/dev/urandom bs=15 count=1 status=none | base64)" - { - echo "changelog<<${CASCADE_DELIM}" - echo "$RESULT" | jq -r '.changelog' - echo "${CASCADE_DELIM}" - } >> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md" - name: Manage Release uses: ./.github/actions/manage-release with: @@ -153,7 +148,7 @@ jobs: tag_only: 'true' environment: prerelease sha: ${{ needs.setup.outputs.head_sha }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md previous_tag: ${{ needs.setup.outputs.previous_tag }} token: ${{ secrets.GITHUB_TOKEN }} - name: Dispatch Release Candidate Build diff --git a/.github/workflows/promote.yaml b/.github/workflows/promote.yaml index cfa3a407..16ad69b5 100644 --- a/.github/workflows/promote.yaml +++ b/.github/workflows/promote.yaml @@ -174,12 +174,7 @@ jobs: fi RESULT=$(cascade generate-changelog --base-sha "$TARGET_SHA" --head-sha "$SOURCE_SHA" --repo "${{ github.repository }}" --contributors) - CASCADE_DELIM="$(dd if=/dev/urandom bs=15 count=1 status=none | base64)" - { - echo "changelog<<${CASCADE_DELIM}" - echo "$RESULT" | jq -r '.changelog' - echo "${CASCADE_DELIM}" - } >> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md" - name: Extract Release Data id: release-data env: @@ -215,7 +210,7 @@ jobs: environment: ${{ needs.preflight.outputs.target_env }} sha: ${{ needs.preflight.outputs.source_sha }} tag: ${{ needs.preflight.outputs.source_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.CASCADE_STATE_TOKEN }} - name: Ensure Release Exists if: ${{ github.event.inputs.dry_run != 'true' && (needs.preflight.outputs.is_prerelease_env == 'true' || needs.preflight.outputs.is_final_env == 'true') }} @@ -226,7 +221,7 @@ jobs: environment: ${{ needs.preflight.outputs.source_env }} sha: ${{ needs.preflight.outputs.source_sha }} tag: ${{ needs.preflight.outputs.source_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.CASCADE_STATE_TOKEN }} - name: Create Prerelease if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_prerelease_env == 'true' }} @@ -237,7 +232,7 @@ jobs: environment: ${{ needs.preflight.outputs.target_env }} sha: ${{ steps.release-data.outputs.sha }} tag: ${{ steps.release-data.outputs.rc_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.CASCADE_STATE_TOKEN }} - name: Cleanup Orphaned Releases if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.skipped_envs != '' }} @@ -266,7 +261,7 @@ jobs: sha: ${{ steps.release-data.outputs.sha }} tag: ${{ steps.release-data.outputs.sem_version }} delete_tag: ${{ steps.release-data.outputs.rc_version }} - changelog: ${{ steps.changelog.outputs.changelog }} + changelog_file: ${{ runner.temp }}/cascade-changelog.md token: ${{ secrets.CASCADE_STATE_TOKEN }} - name: Trigger Release Build if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }} From 1729086436b963ebba33c1a9a7a47a3f921a156b Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 19 Jul 2026 12:32:20 -0400 Subject: [PATCH 3/3] test(e2e): update output-hardening scenario for changelog file reference The built-in changelog no longer rides a $GITHUB_OUTPUT heredoc into a manage-release input; orchestrate and promote now write it to a file on the runner temp dir and pass the path via changelog_file. Update scenario 62 to assert the file-reference shape (the RUNNER_TEMP redirect and the changelog_file input) and lock the migration by forbidding the old heredoc and content-input forms. The PR-preview summary body heredoc is unchanged and its assertions stay as they were. Signed-off-by: Joshua Temple --- .../62-generated-output-hardening.yaml | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/e2e/scenarios/62-generated-output-hardening.yaml b/e2e/scenarios/62-generated-output-hardening.yaml index ce4a51e8..a20ae3a1 100644 --- a/e2e/scenarios/62-generated-output-hardening.yaml +++ b/e2e/scenarios/62-generated-output-hardening.yaml @@ -9,12 +9,15 @@ description: | parser rejects it, so the whole workflow is unusable). A sibling deploy with run_policy: always proves the combined form still chains correctly. - 2. $GITHUB_OUTPUT heredoc integrity: changelog and plan-summary bodies carry - arbitrary commit-message and manifest-derived text. Every emitted heredoc - must mint a random delimiter at runtime (dd | base64) so no line of the - value can close the block early and forge extra step outputs. The fixed - EOF / CASCADE_EOF delimiters must be gone from orchestrate, promote, and - the PR-preview summary step. + 2. Changelog stays off the env/output path: the built-in changelog carries + arbitrary commit-message text whose size the manifest does not bound, so + orchestrate and promote write it to a file on the runner temp dir and pass + the path via changelog_file, rather than emitting it through a + $GITHUB_OUTPUT heredoc that would land on a composite-action input (an + environment variable capped near 128KB by execve, failing with E2BIG). The + PR-preview summary body remains a heredoc and must still mint a random + delimiter at runtime (dd | base64) so no line of the value can close the + block early and forge extra step outputs. 3. github-script splice removal: the PR-preview comment step must bind the plan body via env: (PLAN_BODY) and read process.env.PLAN_BODY, never @@ -62,20 +65,23 @@ steps: - "if: always()\n uses:" # Always policy keeps the prefix and chains the dependency condition. - "always() &&\n (needs.build-app.result == 'success' || needs.build-app.result == 'skipped')" - # Changelog heredoc uses a runtime-random delimiter. - - "CASCADE_DELIM=\"$(dd if=/dev/urandom bs=15 count=1 status=none | base64)\"" - - "echo \"changelog<<${CASCADE_DELIM}\"" + # Changelog is written to a file on the runner temp dir and passed by + # path, keeping large content off the env/output heredoc path. + - "echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\"" + - "changelog_file: ${{ runner.temp }}/cascade-changelog.md" not_contains: # The dangling operator form that GitHub's parser rejects. - "always() &&\n uses:" - # The forgeable fixed delimiter. - - "changelog< \"$RUNNER_TEMP/cascade-changelog.md\"" + - "changelog_file: ${{ runner.temp }}/cascade-changelog.md" not_contains: - - "changelog<