Make extract_dir part of the Scoop publishing contract - #48
Merged
Conversation
Scoop resolves `bin` from the app root, so a release archive that nests its payload under a top-level directory must declare that directory as extract_dir. Neither Scoop manifest writer could emit it, and nothing in the compliance reference tied archive layout to the manifest, so an adopter that stages a directory first — multiple binaries, or a bundled README/LICENSE — got a manifest that downloads and hash-verifies successfully and then fails at shim creation. The templates were only correct by accident: they zip a bare .exe, so root-relative bin happens to work. Both writers now read EXTRACT_DIR and omit the key entirely when it is empty, because Scoop treats "" as a directory named "" rather than as no nesting. The template takes an optional extract_dir input and the reusable workflow a scoop_extract_dir input, both defaulting to empty. The compliance reference and audit checklist now state the archive-root contract explicitly, including the autoupdate block and the frozen-tag failure mode, since a manifest that declares extract_dir only under architecture regresses on the next auto-update. Nimblesite/Deslop#313
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Both Scoop manifest writers can now declare
extract_dir, and the compliance reference states the archive-root contract that ties a release archive's layout to the manifest describing it.Details
Scoop resolves
binfrom the app root. If a release archive nests its payload under a top-level directory, the manifest has to name that directory inextract_dir— otherwise the download and SHA-256 verification both succeed and the install then fails at shim creation (Can't shim 'tool.exe': File doesn't exist). The partial success hides the cause.Neither writer could emit that field, and nothing in the compliance docs connected archive layout to the manifest. The templates were correct only by accident: they zip a bare
.exe, so the archive is flat and root-relativebinhappens to resolve. An adopter that stages a directory first — multiple binaries, or a bundledREADME/LICENSE, which is the normal shape for an LSP + MCP + CLI product — silently got a broken bucket manifest with no warning from the template or the checklist. That is exactly what happened downstream inNimblesite/Deslop#313.templates/gh-actions/publish-scoop-bucket.yml— new optionalextract_dirinput (default: ""), wired to the writer throughEXTRACT_DIR..github/workflows/release.reusable.yml— new optionalscoop_extract_dirinput (default: ""), same wiring. This workflow packages a single.exeand so is flat today; the input exists so the manifest cannot silently drift out of sync if its packaging step ever stages a directory, and so both writers stay identical.Both writers omit the key entirely when the value is empty, rather than emitting
"extract_dir": ""— Scoop reads an empty string as a directory named"", not as "no nesting", so an unconditional field would break flat archives.docs/agents/shipwright-compliance/reference/implement-release.md§7 — documents thatextract_dirMUST match the archive's top-level directory, thatCompress-Archive -Path "dist/$stage"nests while"dist/$stage/*"does not, and that Homebrew masks the mistake by auto-descending into a lone top-level directory (so a green tap proves nothing about the bucket). Also covers carryingextract_dirintoautoupdate, and keeping every versioned segment of the autoupdate URL — including the release tag — a literal$versionplaceholder.docs/agents/shipwright-compliance/reference/audit-checklist.md§8 — three new FAIL conditions covering the same three defects, so an audit catches them.No breaking changes. Both inputs are optional and default to empty; existing callers produce byte-identical manifests.
How Do The Automated Tests Prove It Works?
scoop manifest writers declare extract_dir for nested archivesintests/fixtures.test.mjs, run bynode --testundermake test.It does not match workflow text — text matching cannot distinguish a declared field from a commented-out one.
runScoopManifestWriterlifts thenode - <<'NODE'heredoc out of the workflow, writes it to a temp.cjsfile (the heredoc is CommonJS; this package is ESM), executes it with the step's environment, and parses the manifest it writes to disk. So the assertions are made against the artifact the release job would actually publish.It runs against both writers — the vendored template and the reusable workflow — and asserts both directions of the contract:
EXTRACT_DIR=sampletool-1.2.3-win32-x64, the emittedarchitecture.64bit.extract_direquals it exactly.EXTRACT_DIR="", the key is absent ("extract_dir" in …isfalse), covering the flat-archive case that an unconditional field would break.url,hashandbinare asserted to survive unchanged, which is what proves the extracted writer genuinely executed rather than the test passing vacuously.Against
mainit fails withactual: undefined, expected: 'sampletool-1.2.3-win32-x64', and theurl/hash/binassertions pass first — confirming the failure is the missing field, not a broken harness. With this change all four assertions pass for both writers.Full local suite:
node --test tests/fixtures.test.mjs8/8,cargo clippy --release --all-targets --workspace -- -D warningsclean, andnode tools/validate-manifest/index.mjs fixtures/manifestsvalidates all five golden manifests.