Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/actions/manage-release/action.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -72,16 +76,25 @@ 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 }}
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=(
Expand All@@ -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"
Expand Down
9 changes: 2 additions & 7 deletions .github/workflows/orchestrate.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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
Expand Down
15 changes: 5 additions & 10 deletions .github/workflows/promote.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand DownExpand Up@@ -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') }}
Expand All@@ -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' }}
Expand All@@ -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 != '' }}
Expand DownExpand Up@@ -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' }}
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/reference/generated-workflows.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
34 changes: 20 additions & 14 deletions e2e/scenarios/62-generated-output-hardening.yaml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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<<EOF"
# The changelog must never ride the output heredoc / env path again.
- "echo \"changelog<<"
- "changelog: ${{ steps.changelog.outputs.changelog }}"
- path: ".github/workflows/promote.yaml"
contains:
- "CASCADE_DELIM=\"$(dd if=/dev/urandom bs=15 count=1 status=none | base64)\""
- "echo \"changelog<<${CASCADE_DELIM}\""
- "echo \"$RESULT\" | jq -r '.changelog' > \"$RUNNER_TEMP/cascade-changelog.md\""
- "changelog_file: ${{ runner.temp }}/cascade-changelog.md"
not_contains:
- "changelog<<EOF"
- "echo \"changelog<<"
- "changelog: ${{ steps.changelog.outputs.changelog }}"
- path: ".github/workflows/cascade-pr-preview.yaml"
contains:
# Summary body heredoc is randomized like every other heredoc.
Expand Down
Loading