Skip to content

Commit 2588384

Browse files
committed
fix(BG0575, CR0545): the documented verified install completes, and a release publishes the assets it verifies against
README and the website offered `SDLC_STUDIO_REQUIRE_CHECKSUM=1` with a pinned tag as the path for a reader who will not accept an unverified download. It refused, at every version, for everybody: both installers looked for a `.sha256` beside GitHub's GENERATED source archive, and GitHub serves no such sidecar for any tag. The digest resolved empty, the requirement made empty fatal, and the one command offered to that reader was the one command guaranteed to fail. Nothing exercised it, so it was consumer-facing from the day it was written and green the whole time. A tagged version now prefers a release asset built from the tag and published with its digest in the same automated step, so both halves of the pair are ours and cannot drift. GitHub's generated archives are deliberately not verified: they are regenerated rather than published, and a digest recorded for one can stop matching with nobody touching the tag, which reaches a user as `Checksum mismatch` - indistinguishable from an attack. CR0545 is the reason it is a workflow and not a command somebody runs. Everything after the tag was un-tooled: `release_cut.py` stopped at `tag-check` and the runbook had no Release row at all. That was paid for twice - v4.1.0 published an empty asset list, v5.0.0 was tagged with no Release at all - and both were somebody meaning to and not doing it. `runbook.py` now REQUIRES the Release step, so the section cannot be silently dropped the way the step was. TWO REVIEWS, and the second one rejected this. The plan review killed the argument this was built on. I justified publishing our own archives by calling `git archive` deterministic; measured, the same tree yields three different digests across three plausible invocations, so it is deterministic only with the command pinned. The real argument is custody - bytes and digest published together, by us - and the build commands are now pinned to the byte. That review also found the test problem I had called unsolvable was already solved by a harness in the tree I had not looked for. The fix review found six blocking defects, two of them mutants I had claimed were killed and had not executed against the shipped surface: - The AC5 test ran its OWN `git archive` and never opened the workflow, so it asserted that git honours `--prefix` - a property of git, not of this repo - and stayed green while the workflow drifted. It now reads the workflow's command and executes THAT. - "Aborts before extraction" was unobservable: install.sh deletes its temp directory on exit, so the test could not tell "never extracted" from "extracted then cleaned up". A `tar` stub makes the ordering visible, with a positive control proving the successful path reaches it. - "A 404 and only a 404" was false. `curl -f` exits 22 for EVERY status at or above 400, so a 403, a 429 or a CDN 503 was read as "no asset published" and silently downgraded a default install to the unverified archive. The status is now read rather than inferred. - The Windows control compared an uppercase digest against an uppercase digest, so `-ine` to `-cne` would have survived the only job that checks the PowerShell half while breaking every real verified install on Windows. Nine mutants now execute and all nine are killed, with the unmutated suite green beside them. The PowerShell half is NOT verified locally - `pwsh` is not on this machine - and that is recorded in the bug rather than implied. It is covered by a green/red/404 trio in the `windows-smoke` job, which runs `install.ps1` under real PowerShell on every push. A test asserting over the TEXT of `install.ps1` would be a weaker claim than the criterion and is not what shipped. Tags before v5.0.1 have no published assets and still refuse under `REQUIRE_CHECKSUM=1`. The fix is forward-only and says so rather than widening what counts as verified. Also: `test_prd_cli_path` leaked the whole plan digest to stdout, so the suite's noise count moved whenever the lessons ranking or the runbook grew - which is how adding a Release step turned the tree red. It now captures stdout, as the test directly below it already did. The stale disclosed-findings count in the v5.0.0 notes (38, against a page carrying 40) is corrected here rather than left, which is why US0670 is referenced: that page is its Affects. Refs: BG0575, CR0545, US0670
1 parent 7142937 commit 2588384

21 files changed

Lines changed: 1221 additions & 30 deletions

.claude/skills/sdlc-studio/reference-sprint-toolchain.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ Read this at plan time. `sprint plan` prints it.
7171
| Take the backlog census | `status.py points` | counting artefacts by hand |
7272
| Mirror to the installed copy | `bash tools/forward-port.sh --yes` | `install.sh`, which clobbers the tree |
7373

74+
## 6. Release
75+
76+
| Do | Command | Instead of |
77+
| --- | --- | --- |
78+
| Compose the changelog section | `release_cut.py changelog-cut --version <v>` | hand-merging fragments |
79+
| Stamp the commit the gate passed on | `release_cut.py record-green --commit <sha> --gate <which>` | a green claim naming no gate |
80+
| Refuse a tag the gate never covered | `release_cut.py tag-check --version <v>` | tagging on memory |
81+
| Publish the release and its artefacts | your project's release automation, triggered by the tag | a hand-uploaded artefact, which is the step that gets skipped |
82+
83+
Make the publish step something the tag triggers, not a line somebody runs. Where a project offers
84+
a verified install, the checksum a user verifies against is published by that step, so a release
85+
that skips it leaves the documented verification broken while everything else looks fine. Two
86+
releases shipped that way here before it was automated - one with no artefacts attached, one with
87+
no release entry at all - and both were somebody meaning to and not doing it. The second case is
88+
the quieter one: `skill-update` asks the forge for the LATEST RELEASE, so until the release entry
89+
exists, every installed copy still reports the previous version and prompts nobody to upgrade. A
90+
tag without a release is, to the update mechanism, unreleased.
91+
7492
## In-flight: changing a run that is already open
7593

7694
| Do | Command | Instead of |

.claude/skills/sdlc-studio/scripts/tests/test_sprint.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,12 @@ def test_prd_cli_path(self) -> None:
612612
root = Path(d)
613613
prd = root / "prd.md"
614614
prd.write_text("# PRD\n", encoding="utf-8")
615-
rc = _load().main(["plan", "--prd", str(prd), "--root", str(root)])
615+
# stdout captured, as the sibling test below already does: this call prints the whole
616+
# plan digest - the lessons ranking and the toolchain runbook's step list - so the
617+
# suite's leaked-line count moved every time either GREW. A green suite must print
618+
# nothing, or a real error hides in the noise.
619+
with contextlib.redirect_stdout(io.StringIO()):
620+
rc = _load().main(["plan", "--prd", str(prd), "--root", str(root)])
616621
self.assertEqual(rc, 0)
617622

618623
def test_plan_write_persists_artifact(self) -> None: # CR0091

.github/workflows/lint.yml

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,101 @@ jobs:
200200
}
201201
Write-Host 'NoSweep opt-out OK'
202202
Pop-Location
203+
204+
- name: install.ps1 - the verified install path, green and red (BG0575)
205+
shell: pwsh
206+
run: |
207+
# The Windows half of BG0575. install.ps1 must prefer the release ASSET for a tagged
208+
# version, because GitHub serves no .sha256 beside a generated source archive - so the
209+
# documented SDLC_STUDIO_REQUIRE_CHECKSUM=1 install could never complete.
210+
#
211+
# Invoke-WebRequest cannot be shadowed on PATH the way `curl` can, so it is shadowed as
212+
# a FUNCTION in the invoking scope - the same technique test_install_atomic.py uses for
213+
# `cp`. The real install.ps1 runs; only its downloader is answered locally.
214+
$ErrorActionPreference = 'Stop'
215+
$work = Join-Path $env:RUNNER_TEMP 'verified'
216+
New-Item -ItemType Directory -Force $work | Out-Null
217+
218+
# Build a payload shaped like the real asset: one sdlc-studio-<ver>/ at the top,
219+
# holding the skill tree, which is what install.ps1's Get-ChildItem -Filter reads.
220+
$stage = Join-Path $work 'sdlc-studio-9.9.9'
221+
$skill = Join-Path $stage '.claude/skills/sdlc-studio'
222+
New-Item -ItemType Directory -Force $skill | Out-Null
223+
Set-Content -Path (Join-Path $skill 'SKILL.md') -Value "---`nname: sdlc-studio`n---`n"
224+
Set-Content -Path (Join-Path $stage 'CHANGELOG.md') -Value "# Changelog`n"
225+
$zip = Join-Path $work 'asset.zip'
226+
Compress-Archive -Path $stage -DestinationPath $zip -Force
227+
# LOWERCASE, because that is what `sha256sum` publishes and therefore what a real
228+
# sidecar contains. Get-FileHash returns uppercase, so serving its output unchanged
229+
# would compare uppercase against uppercase - and the mutant `-ine` -> `-cne` would
230+
# survive this whole job while breaking every real verified install on Windows.
231+
$digest = (Get-FileHash -Path $zip -Algorithm SHA256).Hash.ToLower()
232+
233+
$script:ServedDigest = $digest
234+
$script:AssetZip = $zip
235+
236+
function Invoke-WebRequest {
237+
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing)
238+
if ($Uri -like '*/releases/download/*sdlc-studio-v9.9.9.zip.sha256') {
239+
return [pscustomobject]@{ Content = "$script:ServedDigest sdlc-studio-v9.9.9.zip" }
240+
}
241+
if ($Uri -like '*/releases/download/*sdlc-studio-v9.9.9.zip') {
242+
Copy-Item $script:AssetZip $OutFile -Force; return
243+
}
244+
# Anything else is "not there". install.ps1 reads the status off
245+
# $_.Exception.Response.StatusCode, so the thrown exception must actually CARRY one -
246+
# a bare WebException has a null Response, which install.ps1 correctly treats as a
247+
# transport error, and the 404 branch would then be exercised by nothing at all.
248+
$msg = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::NotFound)
249+
throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('404 Not Found', $msg)
250+
}
251+
252+
$env:SDLC_STUDIO_REQUIRE_CHECKSUM = '1'
253+
$proj = Join-Path $work 'proj'
254+
New-Item -ItemType Directory -Force $proj | Out-Null
255+
Push-Location $proj
256+
& "$env:GITHUB_WORKSPACE/install.ps1" -Target claude -Local -NoSweep -Version v9.9.9
257+
if (-not (Test-Path '.claude/skills/sdlc-studio/SKILL.md')) {
258+
throw 'BG0575 green control: the verified install did not place SKILL.md'
259+
}
260+
Write-Host 'BG0575 green control OK - verified install completed from the asset'
261+
Pop-Location
262+
263+
# RED control: corrupt the sidecar. The install must abort before extraction.
264+
$script:ServedDigest = 'deadbeef'
265+
$proj2 = Join-Path $work 'proj2'
266+
New-Item -ItemType Directory -Force $proj2 | Out-Null
267+
Push-Location $proj2
268+
$failed = $false
269+
try { & "$env:GITHUB_WORKSPACE/install.ps1" -Target claude -Local -NoSweep -Version v9.9.9 }
270+
catch { $failed = $true }
271+
if (-not $failed) { throw 'BG0575 red control: a mismatched digest did not abort' }
272+
if (Test-Path '.claude/skills/sdlc-studio/SKILL.md') {
273+
throw 'BG0575 red control: extraction happened despite a digest mismatch'
274+
}
275+
Write-Host 'BG0575 red control OK - mismatched digest aborted before extraction'
276+
Pop-Location
277+
278+
# THIRD control: no asset published for the tag. This is the only one that reaches the
279+
# 404 branch, so without it the fallback install.ps1 gained for BG0575 is exercised by
280+
# nothing. The tag must fall back to the source archive, find no digest there, and
281+
# refuse under REQUIRE_CHECKSUM rather than invent verification.
282+
$script:NoAsset = $true
283+
function Invoke-WebRequest {
284+
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing)
285+
$m = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]::NotFound)
286+
throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('404 Not Found', $m)
287+
}
288+
$proj3 = Join-Path $work 'proj3'
289+
New-Item -ItemType Directory -Force $proj3 | Out-Null
290+
Push-Location $proj3
291+
$refused = $false
292+
$err = ''
293+
try { & "$env:GITHUB_WORKSPACE/install.ps1" -Target claude -Local -NoSweep -Version v9.9.9 }
294+
catch { $refused = $true; $err = $_.Exception.Message }
295+
if (-not $refused) { throw 'BG0575 404 control: a tag with no asset did not refuse' }
296+
if ($err -match 'transport error') {
297+
throw "BG0575 404 control: a 404 was read as a transport error - the status is not being extracted from the exception. Got: $err"
298+
}
299+
Write-Host "BG0575 404 control OK - fell back, found no digest, refused: $err"
300+
Pop-Location

.github/workflows/release.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Publish the release assets the verified install path depends on (BG0575, CR0545).
2+
#
3+
# WHY THIS IS A WORKFLOW AND NOT A COMMAND SOMEBODY RUNS. The installers verify a download
4+
# against a `.sha256` sidecar published beside it. GitHub serves no sidecar next to a generated
5+
# source archive, so before this file existed the sidecar could never resolve and the documented
6+
# `SDLC_STUDIO_REQUIRE_CHECKSUM=1` install refused at every version. The repair is to publish our
7+
# own archives and their digests - which only works if they are published EVERY time. The v4.1.0
8+
# Release carries zero assets and v5.0.0 was tagged with no Release at all, both by a person
9+
# meaning to and not doing it. A repair that is correct and depends on somebody remembering is
10+
# the same defect one version later, so it binds to the tag rather than to a runbook line.
11+
#
12+
# Determinism: `git archive` output is a function of the tree AND the compressor invocation, so
13+
# the commands here are pinned to the byte. `--format=tar.gz` uses git's internal gzip rather
14+
# than whatever `gzip` is on the runner, and the digest recorded is taken from the file actually
15+
# uploaded - never recomputed elsewhere, so the pair cannot disagree.
16+
name: release
17+
18+
on:
19+
push:
20+
tags:
21+
- 'v*'
22+
# The manual leg exists for a tag pushed before this workflow did - v5.0.0 is one - and as the
23+
# recovery path when a publish fails halfway. It is deliberately the exception: a step anybody
24+
# can forget is the step this file exists to stop being forgettable.
25+
workflow_dispatch:
26+
inputs:
27+
tag:
28+
description: 'Existing tag to publish assets for (e.g. v5.0.0)'
29+
required: true
30+
31+
permissions:
32+
contents: write # required to create a Release and upload its assets
33+
34+
jobs:
35+
publish:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
39+
with:
40+
fetch-depth: 0
41+
42+
- name: Resolve the tag being published
43+
id: tag
44+
# The dispatch input reaches the shell through `env:` rather than by interpolation.
45+
# `${{ }}` substitutes BEFORE the shell parses, so a crafted tag would be script rather
46+
# than data - only write-access holders can dispatch this, but the house style does not
47+
# make that the thing standing between an input and `set -euo pipefail`.
48+
env:
49+
INPUT_TAG: ${{ inputs.tag }}
50+
REF_NAME: ${{ github.ref_name }}
51+
run: |
52+
set -euo pipefail
53+
tag="${INPUT_TAG:-$REF_NAME}"
54+
git rev-parse -q --verify "refs/tags/$tag" >/dev/null \
55+
|| { echo "no such tag: $tag" >&2; exit 1; }
56+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
57+
58+
- name: Build the release assets from the tag
59+
id: build
60+
run: |
61+
set -euo pipefail
62+
tag="${{ steps.tag.outputs.tag }}"
63+
prefix="sdlc-studio-${tag#v}/"
64+
mkdir -p dist
65+
git archive --format=tar.gz --prefix="$prefix" -o "dist/sdlc-studio-$tag.tar.gz" "$tag"
66+
git archive --format=zip --prefix="$prefix" -o "dist/sdlc-studio-$tag.zip" "$tag"
67+
# The digest is taken from the uploaded file itself. Deriving it any other way is how
68+
# bytes and sidecar drift, which presents to a user as `Checksum mismatch` - a message
69+
# indistinguishable from an attack.
70+
for f in "dist/sdlc-studio-$tag.tar.gz" "dist/sdlc-studio-$tag.zip"; do
71+
sha256sum "$f" | awk -v n="$(basename "$f")" '{print $1 " " n}' > "$f.sha256"
72+
done
73+
ls -l dist/
74+
75+
- name: Verify the sidecars before anything is published
76+
run: |
77+
set -euo pipefail
78+
cd dist
79+
# Refuse to publish a pair that does not already verify. A sidecar that does not match
80+
# its own asset would reach users as an aborted install, and this is the last moment
81+
# the failure costs nothing.
82+
for f in *.sha256; do
83+
sha256sum -c "$f"
84+
done
85+
86+
- name: Publish the Release with its assets
87+
env:
88+
GH_TOKEN: ${{ github.token }}
89+
run: |
90+
set -euo pipefail
91+
tag="${{ steps.tag.outputs.tag }}"
92+
notes="docs/release-notes-${tag}.md"
93+
# A Release may already exist if the tag was pushed before this workflow shipped; upload
94+
# into it rather than failing, so an existing Release can be completed rather than
95+
# duplicated. --clobber keeps a re-run idempotent.
96+
if gh release view "$tag" >/dev/null 2>&1; then
97+
gh release upload "$tag" dist/* --clobber
98+
elif [ -f "$notes" ]; then
99+
gh release create "$tag" dist/* --title "$tag" --notes-file "$notes"
100+
else
101+
gh release create "$tag" dist/* --title "$tag" --generate-notes
102+
fi

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ irm https://raw.githubusercontent.com/DarrenBenson/sdlc-studio/main/install.ps1
5757
**Installing in a sensitive environment?** The default install tracks `main`, which publishes no `.sha256` sidecar, so the installer warns and proceeds unverified. Pin a tagged release and make the checksum mandatory instead:
5858

5959
```bash
60-
curl -fsSL https://raw.githubusercontent.com/DarrenBenson/sdlc-studio/main/install.sh | SDLC_STUDIO_REQUIRE_CHECKSUM=1 bash -s -- --version v4.1.0
60+
curl -fsSL https://raw.githubusercontent.com/DarrenBenson/sdlc-studio/main/install.sh | SDLC_STUDIO_REQUIRE_CHECKSUM=1 bash -s -- --version v5.0.1
6161
```
6262

63+
What that verifies: an archive this project built from the tag and published as a release asset, against a `.sha256` published beside it in the same step. Both halves are ours, so they cannot drift apart. Tags before v5.0.1 have no published assets and this command will refuse them rather than pretend - see [Verifying the download](docs/INSTALL.md#verifying-the-download).
64+
6365
</details>
6466

6567
<details>

changelog.d/BG0575.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!-- section: Fixed -->
2+
- **The documented verified install could never complete, at any version, and now does (BG0575).**
3+
README and the website both offer `SDLC_STUDIO_REQUIRE_CHECKSUM=1` with a pinned tag as the path
4+
for a reader who will not accept an unverified download. It refused every time. `install.sh`
5+
looked for a `.sha256` sidecar beside GitHub's GENERATED source archive, and GitHub serves no
6+
such sidecar at any version - so the digest resolved empty, the requirement made empty fatal,
7+
and the one command offered to that reader was the one command guaranteed to fail. `install.ps1`
8+
carried the identical defect against the `.zip`. Nothing exercised either, so both were
9+
consumer-facing from the day they were written and green the whole time.
10+
A tagged version now prefers a release ASSET that this project builds from the tag and publishes
11+
with its digest in the same automated step (`.github/workflows/release.yml`), so both halves of
12+
the pair are ours and cannot drift. GitHub's generated archives are deliberately NOT verified:
13+
they are regenerated rather than published, and a recorded digest for one can stop matching with
14+
nobody touching the tag - which reaches a user as `Checksum mismatch`, indistinguishable from an
15+
attack. Falling back to the unverified archive now happens on a 404 and only on a 404, because
16+
reading a fault as an absence silently downgrades a user from bytes we published to bytes we did
17+
not. That distinction needed the HTTP status to be READ rather than inferred: `curl -f` exits 22
18+
for every status at or above 400, so a 403, a rate-limiting 429 and a CDN 503 were
19+
indistinguishable from a genuine 404 by exit code alone - the first version of this fix claimed
20+
to separate them and did not. `wget` collapses the same range onto exit 8 and has its `-S` trace
21+
read instead. Tags before v5.0.0 have no assets and still refuse, honestly, rather than widening
22+
what counts as verified.
23+
The regression test drives the shipped `install.sh` end to end with a `PATH`-stubbed `curl` over
24+
a local origin - no network, and no base-URL argument added to production for a test's benefit.
25+
It has to: a unit test of `verify_download` cannot see this defect, because that function is
26+
correct and always was, and the bug is the URL handed to it. **The PowerShell half is not
27+
verified locally** - `pwsh` is absent on the development machine - and is covered instead by a
28+
green-and-red pair in the `windows-smoke` job, which runs `install.ps1` under real PowerShell on
29+
every push. Asserting over the text of `install.ps1` would have been a weaker claim than the
30+
criterion, and is not what shipped.
31+
Nobody should publish a digest for a generated archive later: a user pinning it via
32+
`SDLC_STUDIO_SHA256` would now mismatch against the asset.
33+
34+
<!-- section: Added -->
35+
- **Releases publish their own assets, and the runbook finally has a step for the release
36+
(BG0575, CR0545).** Everything after the tag was un-tooled: `release_cut.py` stopped at
37+
`tag-check`, and `reference-sprint-toolchain.md` - the document AGENTS.md tells every session to
38+
read BEFORE starting a step - had no row for release at all. It was paid for twice. The v4.1.0
39+
Release carries an empty asset list, and v5.0.0 was tagged and pushed with no Release published
40+
at all, which matters more than it reads: `version_check.py` polls `releases/latest`, so every
41+
installed copy still reported v4.1.0 as current and prompted nobody to upgrade. A tag without a
42+
Release is, to the tool's own update mechanism, unreleased.
43+
`.github/workflows/release.yml` now builds a `.tar.gz` and a `.zip` from the tag with pinned
44+
commands, records each digest from the file it actually uploads, verifies both sidecars before
45+
publishing anything, and creates or completes the Release. It binds to the tag push rather than
46+
to a runbook line for the reason the history shows: the assets are what the verified install
47+
depends on, and a step that depends on somebody remembering is the same defect one version
48+
later. A `workflow_dispatch` leg exists for a tag pushed before the workflow did, and for
49+
recovery. The runbook gains a Release section naming each command beside the hand-rolled shape
50+
it replaces.

0 commit comments

Comments
 (0)