Scoop Distribution
Scoop is the Windows counterpart of the Homebrew tap. Each publishing repo owns one manifest
template; the release pipeline renders it and pushes the result into xberg-io/scoop-bucket.
The bucket is generated output — never hand-edit bucket/*.json there, because the next
release of the owning repo overwrites it wholesale.
Per repo:
scripts/publish/scoop.json— which manifests to render, and which release asset each SHA placeholder binds toscripts/publish/<app>.json.tmpl— the manifest template- a
publish-scoop-manifestjob, mirroringpublish-homebrew-formulain the same workflow
The two escaping traps
Templates are rendered with Python string.Template.substitute, so every literal $ in the
manifest must be doubled. Two of them appear in every template:
"$$schema"— a bare"$schema"is read as the placeholder$schemaand raisesKeyError, failing the render. Loud, so you find it immediately.$$versioninsideautoupdate— this is Scoop's own placeholder, expanded by Scoop when it detects a new release. A bare$versionthere is not an error: it renders to the current release's version and silently freezesautoupdateon it forever.
The second is the one that bites, because a frozen autoupdate looks correct in review and in
the rendered manifest. Assert on it: the published autoupdate block must still contain a
literal $version, while the top-level architecture URLs must contain a concrete number.
extract_dir cannot be derived
The repos do not package their Windows zips identically, and there is no rule that maps an asset
name to its inner layout. Get this wrong and bin resolves to nothing — the manifest hashes and
validates perfectly and installs a broken shim.
| App | Zip layout | extract_dir |
|---|---|---|
xberg |
nested, dir named after the asset | xberg-cli-x86_64-pc-windows-msvc |
crawlberg |
nested, dir named after the asset | crawlberg-cli-x86_64-pc-windows-msvc |
html-to-markdown |
nested, dir named after the asset | cli-x86_64-pc-windows-msvc |
liter-llm |
nested, version in the dir name | liter-llm-${version}-x86_64-pc-windows-msvc |
ts-pack |
flat — the .exe is at the zip root |
omitted entirely |
alef |
nested, dir named after the asset | alef-x86_64-pc-windows-msvc |
Confirm with unzip -l <asset> against the actual release before trusting any of these; a repo
can change its packaging without anyone noticing the manifest went stale.
Render locally instead of waiting for CI
A template change is cheap to verify end to end. This downloads the real asset, hashes it, and writes the manifest exactly as the release job would:
git -C ../actions show origin/main:publish-scoop-manifests/scripts/render.py > /tmp/render.py
mkdir -p /tmp/bkt/bucket
GITHUB_WORKSPACE="$PWD" \
INPUT_BUCKET_DIR=/tmp/bkt \
INPUT_CONFIG_FILE="$PWD/scripts/publish/scoop.json" \
INPUT_TAG=v1.2.3 INPUT_VERSION=1.2.3 \
INPUT_GITHUB_REPO=xberg-io/<repo> INPUT_DRY_RUN=false \
python3 /tmp/render.py
Diff the result against the manifest currently in the bucket: for a released version it should
come out byte-identical, and for a new one the only change should be the version and hashes.
Set INPUT_DRY_RUN=true to render against a tag that does not exist yet — assets are replaced
with a zero-SHA placeholder and the run still succeeds, which is what the dry-run pipeline does.
Adding scoop to a repo's targets
release_scoop comes from alef release-metadata, not from repo YAML. Two consequences:
alef'sALL_RELEASE_TARGETSmust already containscoop, and a release carrying it must be published, before any repo can name the target.parse_targetshard-fails on an unknown name, so addingscoopto an explicitavailable-targets:list too early breaks that repo's release outright.- Repos that pass no
available-targetsdefault to all targets and need no edit. Repos that pass an explicit list must addscoopto it, but only after the alef release lands.
Until then the wiring is inert and harmless: release_scoop resolves to an empty string, the
job's gate is false, and the release report records the target as not enabled.
Job shape
Copy the repo's own publish-homebrew-formula rather than inventing one — in particular its
five-attempt rebase/push loop. Six release pipelines push to the single shared bucket, so a
concurrent release can land between checkout and push and reject it as non-fast-forward. Each
repo touches only its own manifest, so rebasing onto the new tip and retrying is safe.
Two deliberate differences from the Homebrew job:
- No
check-scoopidempotency gate. The render rewrites the manifest in place and the commit is guarded bygit diff --quiet bucket/, so re-running a release is already a no-op. Pass no fourth argument torequire_publish— there is no "already published" probe to consult. - The job runs on dry runs; only the commit step is skipped. Dry-run renders are the only
pre-release exercise the template gets. Gating the whole job on
dry_run != 'true'removes that safety net.
Native dependencies
Every CLI is a native MSVC build importing VCRUNTIME140.dll, so every manifest carries
"suggest": {"vcredist": "extras/vcredist2022"}.
Check the import table before adding an app — llvm-objdump -p <exe> | grep 'DLL Name'. A
third-party hard import that is not bundled in the zip produces a binary that cannot start at
all, and no amount of manifest correctness fixes it. xberg.exe imports onnxruntime.dll and
depends on its publish job vendoring the DLL closure into the archive; Scoop's shim resolves
DLLs from the app directory, so bundling is sufficient and no depends entry is needed. A
runtime lookup on PATH (Tesseract, for OCR) belongs in notes, not in depends.
What actually proves an install works
Nothing on macOS or Linux does. Schema validation, a correct hash, and a byte-identical render
all pass on a manifest that installs a binary which cannot launch. The only real proof is the
windows-latest job in the bucket's validate-manifests.yml, which adds the checkout as a
bucket, installs each changed app, and runs <app> --version. Treat a green local check as
"nothing is obviously wrong", not as "this installs".