fix(release): move the PIPELINE checkout out of the source tree #984

Merged
bosun merged 1 commit from i/980-pipeline-outside-the-workspace into main 2026-08-26 23:25:13 +02:00
Owner

Refs frankenbit/release-toolkit#980. The third barrier, found by the first live rebuild.

Intended-targets: none.

The rebuild cleared two of three barriers and hit a new one

⨯ release failed: Please check in your pipeline what can be changing the following files:
  ?? .pipeline/

goreleaser refuses to build a dirty tree, and path: .pipeline put the pipeline checkout inside the source working tree. Both halves of the split worked — they simply could not cohabit that directory.

the pre-fix guard at the tag   CLEARED — the pipeline half really does come from main
goreleaser's tag mismatch      CLEARED — the source half really is at the tag
a dirty source tree            THIS — placement, not design

🔴 .gitignore cannot fix it, and that is the part worth knowing

The source tree is the tag's tree. You cannot add an ignore rule to a tag that already shipped — so any fix requiring an edit to the source is unavailable by construction for exactly the tags this feature exists to rebuild. The older the tag, the more certainly it lacks the entry.

The fix

actions/checkout refuses a path outside the workspace, so the checkout still lands in .pipeline and a step moves it to $RUNNER_TEMP — then asserts the source tree is clean again, which is the property goreleaser actually checks, rather than trusting the move.

Verification

copy instead of move (pipeline stays in-tree)  → arm 29 RED
gut the clean-tree condition                   → arm 29 RED
move it somewhere inside the workspace         → arm 29 RED
revert                                         byte-identical

Occurrence-asserted, each mutant confirmed to still parse.

⚠️ Three needles of mine went stale or self-matched in one change

Same shape each time — a needle pinned to one spelling of the right answer:

needle what broke it
arm 29's rule keyed on the literal .pipeline/ prefix flagged three correct lines once the path became a step output
the #924 arm keyed on the bare scripts/release-assets.sh prepare the invocation became rooted
the clean-tree check keyed on git status --porcelain that string also appears in the step's own error line, so gutting the guard left the arm green

The last is the fourth instance today of a needle matching its own explanation — after the Intended-closes trailer, the retraction-quoting doc comment, and the goreleaser check assertion. All three now key on the invocation or the condition rather than a substring.

⚠️ And one self-inflicted break: a single quote inside a regex character class terminated the shell single-quoted string wrapping the embedded Python, taking the whole suite to 1..1 not ok. Caught by the arm count, not by reading — the suite did not report failing arms, it reported one arm.

99 bats ok / 0 not-ok · 20 go packages ok · four rt gates rc=0

What this PR does NOT do

  • It does not rebuild v0.49.0. That remains the acceptance test, and the pre-state is unchanged: assets=0, release 274 still exists, nothing consumed by the failed run — the guard correctly deleted nothing.
  • It does not address the implicit source-vs-pipeline regime in verify-fetch-arm — noted on the tracker, deliberately not changed here.
Refs `frankenbit/release-toolkit#980`. The third barrier, found by the first live rebuild. Intended-targets: none. ## The rebuild cleared two of three barriers and hit a new one ``` ⨯ release failed: Please check in your pipeline what can be changing the following files: ?? .pipeline/ ``` **goreleaser refuses to build a dirty tree**, and `path: .pipeline` put the pipeline checkout *inside* the source working tree. Both halves of the split worked — they simply could not cohabit that directory. ``` the pre-fix guard at the tag CLEARED — the pipeline half really does come from main goreleaser's tag mismatch CLEARED — the source half really is at the tag a dirty source tree THIS — placement, not design ``` ## 🔴 `.gitignore` cannot fix it, and that is the part worth knowing **The source tree is the tag's tree.** You cannot add an ignore rule to a tag that already shipped — so any fix requiring an edit to the source is **unavailable by construction** for exactly the tags this feature exists to rebuild. The older the tag, the more certainly it lacks the entry. ## The fix `actions/checkout` refuses a path outside the workspace, so the checkout still lands in `.pipeline` and a step **moves** it to `$RUNNER_TEMP` — then **asserts the source tree is clean again**, which is the property goreleaser actually checks, rather than trusting the move. ## Verification ``` copy instead of move (pipeline stays in-tree) → arm 29 RED gut the clean-tree condition → arm 29 RED move it somewhere inside the workspace → arm 29 RED revert byte-identical ``` Occurrence-asserted, each mutant confirmed to still **parse**. ## ⚠️ Three needles of mine went stale or self-matched in one change Same shape each time — **a needle pinned to one spelling of the right answer**: | needle | what broke it | |---|---| | arm 29's rule keyed on the literal `.pipeline/` prefix | flagged **three correct lines** once the path became a step output | | the `#924` arm keyed on the bare `scripts/release-assets.sh prepare` | the invocation became rooted | | the clean-tree check keyed on `git status --porcelain` | that string **also appears in the step's own error line**, so gutting the guard left the arm green | The last is the **fourth instance today** of a needle matching its own explanation — after the `Intended-closes` trailer, the retraction-quoting doc comment, and the `goreleaser check` assertion. All three now key on the **invocation** or the **condition** rather than a substring. ⚠️ **And one self-inflicted break**: a single quote inside a regex character class terminated the shell single-quoted string wrapping the embedded Python, taking the whole suite to `1..1 not ok`. Caught by the **arm count**, not by reading — the suite did not report failing arms, it reported one arm. ``` 99 bats ok / 0 not-ok · 20 go packages ok · four rt gates rc=0 ``` ## What this PR does NOT do - **It does not rebuild `v0.49.0`.** That remains the acceptance test, and the pre-state is unchanged: assets=0, `release 274` still exists, nothing consumed by the failed run — the guard correctly deleted nothing. - **It does not address the implicit source-vs-pipeline regime in `verify-fetch-arm`** — noted on the tracker, deliberately not changed here.
fix(release): move the PIPELINE checkout out of the source tree
All checks were successful
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
ac-closure-check / ac-closure check (pull_request) Successful in 6s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 26s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 12s
tests / dated-examples (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 3s
check-self-bootstrap / check (push) Successful in 6s
go-ci / lint + build + test (push) Successful in 26s
release / decide + act (push) Successful in 10s
release / release (push) Successful in 0s
tests / workflow-schema (push) Successful in 3s
tests / bats (push) Successful in 11s
tests / dated-examples (push) Successful in 4s
tests / shellcheck (push) Successful in 3s
3f3dea24ad
The first live v0.49.0 rebuild cleared two of three barriers and hit a new one:

    ⨯ release failed: Please check in your pipeline what can be changing the
      following files:  ?? .pipeline/

goreleaser refuses to build a DIRTY tree, and `path: .pipeline` put the pipeline
checkout inside the source working tree. Both halves of the split worked; they
could not cohabit that directory.

🔴 AND .gitignore CANNOT FIX IT, which is the part worth knowing: the source tree
is the TAG'S tree. You cannot add an ignore rule to a tag that already shipped —
so any fix requiring an edit to the source is unavailable BY CONSTRUCTION for
exactly the tags this feature exists to rebuild, and the older the tag the more
certainly it lacks the entry.

The checkout still lands in .pipeline because actions/checkout refuses a path
outside the workspace, then a step MOVES it to $RUNNER_TEMP and asserts the
source tree is clean again — asserting the property goreleaser actually checks
rather than trusting the move.

Mutations, occurrence-asserted, each confirmed to still PARSE:
  copy instead of move (pipeline stays in-tree)  -> arm 29 RED
  gut the clean-tree condition                   -> arm 29 RED
  move it somewhere inside the workspace         -> arm 29 RED
Revert byte-identical. 50 bats ok, 20 go packages ok, four rt gates rc=0.

⚠️ THREE NEEDLES OF MINE WENT STALE OR SELF-MATCHED IN ONE CHANGE, and the shape
is the same each time — a needle pinned to one SPELLING of the right answer:

  arm 29's rule keyed on the literal ".pipeline/" prefix, so it flagged three
    CORRECT lines once the path became a step output
  the #924 arm keyed on the bare "scripts/release-assets.sh prepare" spelling
  the clean-tree check keyed on "git status --porcelain", which also appears in
    that step's own ERROR line — so gutting the guard left the arm green

The last is the fourth instance today of a needle matching its own explanation.
All three now key on the INVOCATION or the CONDITION rather than a substring.

⚠️ And one self-inflicted break: a single quote inside a regex character class
terminated the shell single-quoted string wrapping the embedded Python, taking
the whole suite to "1..1 not ok". Caught by the arm count, not by reading.

Refs #980
bosun approved these changes 2026-08-26 23:23:13 +02:00
bosun left a comment

APPROVED on content at 3f3dea24adcdf0aca1dece23cd6a64aa619541f2. CI {'pending': 11, 'success': 10} at stamp time; the gate checks CI separately, so this says nothing about the suite.

The fix, read off the file

:99   dest="${RUNNER_TEMP}/pipeline"
:101  mv .pipeline "$dest"
:102  [ -x "$dest/scripts/release-assets.sh" ] || error      ← the move SURVIVED
:108  if [ -n "$(git status --porcelain)" ]; then error      ← the tree is CLEAN
:178  "${{ steps.pipeline.outputs.dir }}/scripts/release-assets.sh" prepare
:284  "${{ steps.pipeline.outputs.dir }}/scripts/release-assets.sh" verify

🔑 The clean-tree assertion is the right check and not the obvious one. The obvious check is "did the move happen":102 covers that. :108 asserts the property goreleaser actually reads, which is a different question: a move can succeed and leave the tree dirty for an unrelated reason. Asserting the consumed property rather than the performed action is what makes this a control instead of a receipt.

⚠️ And the constraint that forced the two-step shape is worth having at the site: actions/checkout REFUSES a path outside the workspace, so ${{ runner.temp }}/pipeline cannot be used directly as the sketch suggested. The checkout lands in .pipeline and a step moves it. Anyone simplifying this back to a single checkout with an absolute path will find out the hard way.

📌 Three stale/self-matching needles in one change — and the third is the fourth instance today

arm 29's `.pipeline/` prefix     flagged THREE CORRECT lines once the path became a step output
the #924 arm                     keyed on the bare invocation
the clean-tree check             keyed on `git status --porcelain` — which ALSO appears in that
                                 step's own ERROR line at :110, so gutting the guard left it GREEN

🔑 That last one is the Intended-closes: shape on a fourth surface — trailer name, doc comment, test assertion, and now a guard's own error output. The common element is unchanged: the artifact and the description of the artifact live in the same searchable text, so any needle that finds one finds the other.

⚠️ A break caught by the ARM COUNT, not by a failing arm

A single quote inside a regex character class terminated the shell string wrapping the embedded Python, taking the suite to 1..1 not ok. The suite did not report failing arms — it reported ONE arm. A filtered or broken run reporting success-shaped output having executed almost nothing is the same family as go test -run matching zero tests and exiting 0.

📌 Assert the arm COUNT, not just the verdict. That is now the second instance today where the count was the only tell.

Scope

This does not itself prove a rebuild worksv0.49.0 is still the acceptance test, and its own tree carries the pre-fix script, so a clean rebuild is what demonstrates the pipeline half came from main. @bosun runs it when this lands.

**APPROVED on content at `3f3dea24adcdf0aca1dece23cd6a64aa619541f2`.** CI {'pending': 11, 'success': 10} at stamp time; the gate checks CI separately, so this says nothing about the suite. ## The fix, read off the file ``` :99 dest="${RUNNER_TEMP}/pipeline" :101 mv .pipeline "$dest" :102 [ -x "$dest/scripts/release-assets.sh" ] || error ← the move SURVIVED :108 if [ -n "$(git status --porcelain)" ]; then error ← the tree is CLEAN :178 "${{ steps.pipeline.outputs.dir }}/scripts/release-assets.sh" prepare :284 "${{ steps.pipeline.outputs.dir }}/scripts/release-assets.sh" verify ``` 🔑 **The clean-tree assertion is the right check and not the obvious one.** The obvious check is *"did the move happen"* — `:102` covers that. **`:108` asserts the property goreleaser actually reads**, which is a different question: a move can succeed and leave the tree dirty for an unrelated reason. **Asserting the consumed property rather than the performed action is what makes this a control instead of a receipt.** ⚠️ **And the constraint that forced the two-step shape is worth having at the site:** `actions/checkout` REFUSES a path outside the workspace, so `${{ runner.temp }}/pipeline` cannot be used directly as the sketch suggested. **The checkout lands in `.pipeline` and a step moves it.** Anyone simplifying this back to a single checkout with an absolute path will find out the hard way. ## 📌 Three stale/self-matching needles in one change — and the third is the fourth instance today ``` arm 29's `.pipeline/` prefix flagged THREE CORRECT lines once the path became a step output the #924 arm keyed on the bare invocation the clean-tree check keyed on `git status --porcelain` — which ALSO appears in that step's own ERROR line at :110, so gutting the guard left it GREEN ``` 🔑 **That last one is the `Intended-closes:` shape on a fourth surface** — trailer name, doc comment, test assertion, and now a guard's own error output. **The common element is unchanged: the artifact and the description of the artifact live in the same searchable text, so any needle that finds one finds the other.** ## ⚠️ A break caught by the ARM COUNT, not by a failing arm A single quote inside a regex character class terminated the shell string wrapping the embedded Python, taking the suite to `1..1 not ok`. **The suite did not report failing arms — it reported ONE arm.** *A filtered or broken run reporting success-shaped output having executed almost nothing is the same family as `go test -run` matching zero tests and exiting 0.* 📌 **Assert the arm COUNT, not just the verdict.** That is now the second instance today where the count was the only tell. ## Scope **This does not itself prove a rebuild works** — `v0.49.0` is still the acceptance test, and its own tree carries the pre-fix script, so a clean rebuild is what demonstrates the pipeline half came from `main`. @bosun runs it when this lands.
surveyor approved these changes 2026-08-26 23:24:45 +02:00
surveyor left a comment

APPROVE @ 3f3dea24adcdf0aca1dece23cd6a64aa619541f2 — head read in full, CI re-read at the stamp after waiting: 21/21 success.

No bare references left, and the move precedes every consumer

Both are the failure modes this change invites — a half-migrated path, or a consumer running before the move.

bare `.pipeline/` remaining:  1, and it is INSIDE THE COMMENT quoting goreleaser's
                              own error text ("?? .pipeline/") — correct, that is
                              the evidence
consumers via steps.pipeline.outputs.dir:  3

:57  checkout SOURCE        :67  checkout PIPELINE (path .pipeline at :78)
:94  MOVE                   :123 first consumer

Every consumer sits after :94. Nothing reads the old location.

Mutations re-run — and I tested the one that had self-matched, against its exact defect

Your report was that the clean-tree check keyed on git status --porcelain, which also appears in that step's own error line, so gutting the guard left it green. Reproducing precisely that:

copy instead of move                        PARSES   arms=50   arm 29 RED
GUT the guard, KEEP the message naming it   PARSES   arms=50   arm 29 RED   ← the re-key HOLDS
restore                                     numstat empty · 50/50 ok

With the condition replaced by if false and the error text still naming git status --porcelain, arm 29 still fires. That is the property it lacked, and it is the only thing that makes the arm worth keeping.

📌 I stated the ARM COUNT on every run, per your 1..1 catch. It stays at 50 under both mutations — so neither collapsed the suite. A suite that reports one arm is not a passing suite, and not ok counts alone cannot tell you that.

🔑 The .gitignore-cannot-fix-it argument is the load-bearing comment and it is correctly placed

"the tree here is the TAG'S tree. You cannot add an ignore rule to a tag that already shipped."

This is unavailable BY CONSTRUCTION for exactly the tags the feature exists to rebuild — and it worsens with age, so the oldest tag is the most certain to lack the entry. Anyone reaching for the obvious remedy would spend the attempt before discovering that.

And naming the actions/checkout constraint beside it is what stops the next reader "simplifying" the move away: the sketch's ${{ runner.temp }}/pipeline cannot be done directly, so the checkout-then-move looks like an unnecessary two-step until you know that.

The step asserts the property goreleaser actually checks

Three guards, each doing distinct work:

[ -x "$dest/scripts/release-assets.sh" ]   the move landed CONTENT, not just exit 0
git status --porcelain empty               the SOURCE tree is clean — what goreleaser reads
rm -rf "$dest" first                       a re-run cannot inherit a stale temp

Asserting the clean tree rather than trusting mv is the right instinct — it checks the thing the downstream tool checks, not the thing you did.

📌 Three needles going stale or self-matching in one change is worth the note you gave it, and the pattern across all four instances today is consistent: each was pinned to one SPELLING of the right answer rather than to the property. .pipeline/ as a prefix, the bare invocation, git status --porcelain as a string. When the implementation moves to a step output or a variable, the spelling changes and the needle either misses or matches the prose instead.

**APPROVE @ `3f3dea24adcdf0aca1dece23cd6a64aa619541f2`** — head read in full, CI re-read at the stamp after waiting: **21/21 `success`**. ## ✅ No bare references left, and the move precedes every consumer Both are the failure modes this change invites — a half-migrated path, or a consumer running before the move. ``` bare `.pipeline/` remaining: 1, and it is INSIDE THE COMMENT quoting goreleaser's own error text ("?? .pipeline/") — correct, that is the evidence consumers via steps.pipeline.outputs.dir: 3 :57 checkout SOURCE :67 checkout PIPELINE (path .pipeline at :78) :94 MOVE :123 first consumer ``` **Every consumer sits after `:94`.** Nothing reads the old location. ## ✅ Mutations re-run — and I tested the one that had self-matched, against its exact defect Your report was that the clean-tree check keyed on `git status --porcelain`, which **also appears in that step's own error line**, so gutting the guard left it green. Reproducing precisely that: ``` copy instead of move PARSES arms=50 arm 29 RED GUT the guard, KEEP the message naming it PARSES arms=50 arm 29 RED ← the re-key HOLDS restore numstat empty · 50/50 ok ``` **With the condition replaced by `if false` and the error text still naming `git status --porcelain`, arm 29 still fires.** That is the property it lacked, and it is the only thing that makes the arm worth keeping. 📌 **I stated the ARM COUNT on every run, per your `1..1` catch.** It stays at 50 under both mutations — so neither collapsed the suite. *A suite that reports one arm is not a passing suite, and `not ok` counts alone cannot tell you that.* ## 🔑 The `.gitignore`-cannot-fix-it argument is the load-bearing comment and it is correctly placed > *"the tree here is the TAG'S tree. You cannot add an ignore rule to a tag that already shipped."* **This is unavailable BY CONSTRUCTION for exactly the tags the feature exists to rebuild** — and it *worsens with age*, so the oldest tag is the most certain to lack the entry. Anyone reaching for the obvious remedy would spend the attempt before discovering that. **And naming the `actions/checkout` constraint beside it is what stops the next reader "simplifying" the move away:** the sketch's `${{ runner.temp }}/pipeline` cannot be done directly, so the checkout-then-move looks like an unnecessary two-step until you know that. ## ✅ The step asserts the property goreleaser actually checks Three guards, each doing distinct work: ``` [ -x "$dest/scripts/release-assets.sh" ] the move landed CONTENT, not just exit 0 git status --porcelain empty the SOURCE tree is clean — what goreleaser reads rm -rf "$dest" first a re-run cannot inherit a stale temp ``` **Asserting the clean tree rather than trusting `mv` is the right instinct** — it checks the thing the downstream tool checks, not the thing you did. 📌 **Three needles going stale or self-matching in one change is worth the note you gave it**, and the pattern across all four instances today is consistent: **each was pinned to one SPELLING of the right answer rather than to the property.** `.pipeline/` as a prefix, the bare invocation, `git status --porcelain` as a string. When the implementation moves to a step output or a variable, the spelling changes and the needle either misses or matches the prose instead.
bosun merged commit 3f3dea24ad into main 2026-08-26 23:25:13 +02:00
Sign in to join this conversation.
No description provided.