Skip to content

fix(config): require trunk_branch so a manifest cannot silently generate a dead pipeline - #629

Merged
joshua-temple merged 2 commits into
mainfrom
fix/trunk-branch-required-or-defaulted
Jul 17, 2026
Merged

fix(config): require trunk_branch so a manifest cannot silently generate a dead pipeline#629
joshua-temple merged 2 commits into
mainfrom
fix/trunk-branch-required-or-defaulted

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

A manifest that omits trunk_branch passed cascade lint with zero diagnostics, generated successfully, and emitted:

on:
  push:
    branches: []

An empty allow-list matches no branch. GitHub accepts the workflow and reports it green while orchestrate never fires on a trunk push. Silent, green, dead.

Three sources disagreed, and that disagreement was the bug:

  • the schema has always listed trunk_branch as required (definitions/trunkConfig)
  • lint accepted its absence
  • the docs row claimed Required: Yes and Default: main, which cannot both hold

Measured on origin/main: 27 of the 31 generatable manifest examples in the documentation emitted a dead workflow. Copying the docs produced a pipeline that never ran.

Root cause of the drift: TestSchema_ValidatesREADMEExamples has always held README examples to the schema, which is why the README was never broken. The docs/ tree had no equivalent test.

Fix

Required, not defaulted. The schema is frozen and already says required, so this makes the implementation conform to the published contract rather than editing the contract to match a buggy implementation. No default is inferred: guessing main for a repo whose trunk is master rebuilds the same dead workflow silently. TestFinalize_StateWriteTargetsTrunkBranch already exercises a trunk named trunk.

  • internal/config: trunk_branch required, naming the field in the error
  • internal/generate: generation fails on an unset trunk_branch, plus a class guard rejecting any empty trigger allow-list (branches: [], paths: [], tags: []) before emission
  • internal/schema: new TestSchema_ValidatesDocsExamples holds every docs example to the schema, mirroring the README test, so the three sources now agree by construction
  • internal/hotfix: replaced a silent trunk = "main" fallback with an error. Its comment claimed it resolved "the same way the state write does", but the state write reads GITHUB_REF and never touches cfg.TrunkBranch. A silent guess there writes state to the wrong branch
  • docs: all 33 examples fixed; the self-contradictory row now reads Required: Yes, Default: -

Siblings swept

Probed every schema-required field against lint. Two more of the same class, both already schema-required (no schema change):

  • publish.workflow: accepted when empty, and promote.go then skips the publish step entirely. Declared publish, never published
  • external[].deploys: accepted when empty, generating no jobs and coordinating nothing

Backward compatibility

schema_version 1 is untouched; the schema is unchanged. No manifest that works today changes behavior: a manifest setting trunk_branch generates byte-identical output (verify --own-repo: no drift), and one omitting it could not run at all. Turning a silent no-op into a loud error is the fix, not a break.

Verification

  • failing test first: TestGenerate_MissingTrunkBranch_IsLoudNotEmptyAllowList and TestValidate_TrunkBranchMissing_IsRejected both red before the fix (Validate returned zero errors; Generate returned nil error while emitting the dead workflow)
  • ran every documented example, lint and generate, before and after: dead workflows 27 -> 0
  • go build ./..., go test ./... -count=1 (3415 pass), go test ./... -race, golangci-lint run ./... all clean
  • e2e: go build ./..., go vet ./... clean
  • cascade verify --own-repo: 3 files, no drift
  • TestSchema_ValidatesE2EScenarioConfigs, TestSchema_ValidatesREADMEExamples, TestSchema_OnDiskCopiesAreByteIdentical pass

Changes generated output, so this is fleet-relevant.

…ate a dead pipeline

Omitting trunk_branch passed lint with no diagnostics and generated an
orchestrate workflow whose push trigger read "branches: []", an allow-list
matching no branch. GitHub accepts that workflow and reports it green while it
never fires on a trunk push.

The schema has always listed trunk_branch as required, so lint and the schema
disagreed and the docs claimed it was both required and defaulted to main. 27 of
the 31 generatable manifest examples in the documentation emitted a dead
workflow.

Enforce it in lint to match the schema, guard the generator against emitting any
empty trigger allow-list, fix every documented example, and validate the docs
examples against the schema with the test that already guards the README.

No default is inferred: guessing main for a repository whose trunk is named
otherwise would rebuild the same dead workflow silently.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple force-pushed the fix/trunk-branch-required-or-defaulted branch from 8f13400 to ad4c60b Compare July 17, 2026 09:45
@joshua-temple

Copy link
Copy Markdown
Collaborator Author

Pushed 92ff1c6 addressing the guard overclaim.

Chose to make the guard true to its claim rather than narrow the wording. Verifying the finding first turned up one more instance than reported: hotfix.go:249 emits a block-style branches:, a genuine second occurrence that a strings.Index scan cannot reach. And no tags: is emitted anywhere, so tags: [] was matching nothing.

assertNoDeadAllowList now line-scans every occurrence, every filter key (branches, paths, tags and their -ignore forms), in both YAML list styles. The key match is anchored at the start of the line, so a run: echo "branches:" step or a name: list-branches key cannot trip it.

Red-first: the cases are unreachable via config today (every block-style site is length-guarded), which makes the function itself the unit under test. TestAssertNoDeadAllowList is table-driven over 11 cases: 4 were red before this commit (block-style paths, tags, paths-ignore, plus the fragile later-occurrence path), all 12 green after.

No false positives: 3437 pass (root, and under -race), verify --own-repo no drift, dispatch-only (no push trigger) and populated block-style filters both still pass, and all 43 documented examples still generate with 0 dead workflows.

CHANGELOG reworded to what the check does rather than an aspirational fragment list.

Gates: build, 3437 tests, race, golangci-lint, changelog guard, e2e build/vet all clean. -S -s.

… claims

The guard's comment claimed it caught any present-but-empty trigger allow-list
from any field or code path, but it scanned four literal fragments with a
first-occurrence-only match. Block-style paths:, tags:, and paths-ignore: were
never caught, and "paths: []" and "tags: []" matched nothing that is emitted,
since those sites emit block style. A block-style key whose first occurrence is
populated also hid a later empty one, because the scan stopped at the match.

No reachable instance exists today. The guard has one call site, on the
orchestrate workflow, and every block-style emission site it can see is
length-guarded, so nothing shipped dead in either implementation. The widening
is justified by the class, not by an instance: a future emission site, or a
future call site, that forgets a length guard should fail the build rather than
ship a workflow that never runs. The defect being fixed is the claim itself. A
guard that overstates its reach is worse than a narrow one, because the next
reader assumes the class is covered and stops checking.

Replace the fragment list with a line scan over every occurrence, every filter
key, and both YAML list styles, so the comment is true. Reword the changelog to
what the check does, and scope it to the orchestrate workflow.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple force-pushed the fix/trunk-branch-required-or-defaulted branch from 92ff1c6 to d749c1a Compare July 17, 2026 10:09
@joshua-temple

Copy link
Copy Markdown
Collaborator Author

Force-pushed d749c1a. Both false claims verified against the code before correcting, and both were right.

1. Commit-message motivation was false. assertNoDeadAllowList has exactly one production call site (generator.go:385, orchestrate). HotfixGenerator.Generate() is a separate method that never calls it, so hotfix.go:249's block-style branches: is unreachable by the scan in either implementation. It could not have motivated the widening. The message now says what survives scrutiny: no reachable instance exists today, nothing shipped dead, and the widening is justified by the class (a future emission site or a future call site that forgets a length guard). The defect being fixed is the claim itself.

2. Red-first was 3, not 4. Reconstructed the old implementation and ran both fixtures through it:

MY fixture (flow first, block later)           -> RED  (old impl catches it)
REVIEWER fixture (block populated, block empty) -> GREEN (old impl misses it)

Mine was caught by luck: the first "branches:\n" literal match was the empty one, since the populated flow-style line reads branches: [main]\n and cannot match. The reviewer's fixture is the genuine case: the first match lands on the populated block, hits continue, and the dead one below is never examined. Fixture and comment corrected to that mechanism. 12/12 green.

Import nit: confirmed goimports flagged generator.go at 92ff1c6 but not ad4c60b (my regexp insertion). Fixed. The 3 other flagged files are pre-existing at ad4c60b and left alone.

Gates: build, 3437 tests, race, golangci-lint, changelog guard, verify --own-repo no drift, e2e build/vet all clean. -S -s.

@joshua-temple
joshua-temple merged commit 25b365f into main Jul 17, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/trunk-branch-required-or-defaulted branch July 17, 2026 10:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant