Skip to content

Commit 2dc6f71

Browse files
schubydooclaude
andcommitted
ci(packaging): extend release auto-bump to Homebrew + Nix
Generalize the Scoop-only manifest bump into a single post-release path that keeps all three in-repo package-manager manifests in lockstep with published releases: the Scoop bucket (bucket/clauster.json), the Homebrew formula (Formula/clauster.rb), and the Nix flake (flake.nix). None of them auto-update on their own for a self-hosted bucket / tap / flake. - Rename scoop-bump.yml -> packaging-bump.yml; the regen step now calls a tested helper instead of inline jq, and opens a packaging-bump/<tag> PR. - scripts/bump_packaging.py: regenerate version/url/hash for whichever manifests exist from the release's SHA256SUMS. Fails closed (missing asset, or a stale non-target version surviving) and is two-pass (validate all, then write) so an aborted run leaves no half-rewritten tree. Stdlib-only; absent manifests are skipped, so this can merge ahead of the Homebrew/Nix packaging (#285) and light them up later. - tests/test_bump_packaging.py: happy path, idempotency, missing-file skip, both fail-closed paths, two-pass atomicity, bad-version arg. Re-audited by clauster-ci-auditor (ref-gating, pinning, injection, token scope, fail-closed, branch-push safety, helper path/regex safety — clean). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a215e2e commit 2dc6f71

3 files changed

Lines changed: 391 additions & 59 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
name: Scoop manifest bump
1+
name: Packaging manifest bump
22

3-
# Keep the in-repo Scoop bucket manifest (bucket/clauster.json) in lockstep with
4-
# published releases. The manifest carries Scoop's own `checkver`/`autoupdate`
5-
# blocks, but nothing INVOKES them for a self-hosted bucket (the official Scoop
6-
# Excavator only services the ScoopInstaller org). Without this, `scoop install
7-
# clauster` keeps serving whatever version the committed manifest pins, frozen
8-
# until someone bumps it by hand.
3+
# Keep the in-repo package-manager manifests — the Scoop bucket (bucket/clauster.json),
4+
# the Homebrew formula (Formula/clauster.rb), and the Nix flake (flake.nix) — in
5+
# lockstep with published releases. Scoop's manifest carries `checkver`/`autoupdate`,
6+
# but nothing INVOKES them for a self-hosted bucket (the official Scoop Excavator only
7+
# services the ScoopInstaller org); Homebrew/Nix have no auto-update at all. Without
8+
# this, `scoop install` / `brew install` / `nix run` keep serving whatever version the
9+
# committed manifests pin, frozen until someone bumps them by hand.
910
#
10-
# So on every published release we regenerate the manifest's version/url/hash from
11-
# the release's authoritative SHA256SUMS and open a PR. We DON'T push to main: main
12-
# is branch-protected, and the bump is reviewable like any other change. release-
13-
# please is deliberately NOT the mechanism — it runs at PR-prep time, before the
14-
# binaries (and therefore the hash) exist.
11+
# So on every published release we regenerate each manifest's version/url/hash from the
12+
# release's authoritative SHA256SUMS (via scripts/bump_packaging.py) and open a PR. We
13+
# DON'T push to main: main is branch-protected, and the bump is reviewable like any
14+
# other change. release-please is deliberately NOT the mechanism — it runs at PR-prep
15+
# time, before the binaries (and therefore the hashes) exist.
1516
#
16-
# Trust root: the SHA256SUMS asset, fetched over GitHub TLS from our own just-
17-
# published release — the same trust model the install.sh/install.ps1 one-liners
18-
# use. (Optional future hardening: `cosign verify-blob` against the shipped
19-
# SHA256SUMS.sigstore.json before trusting it.)
17+
# A manifest not yet on main is skipped (the helper no-ops on absent files), so this can
18+
# merge ahead of the Homebrew/Nix packaging and light them up once they land.
19+
#
20+
# Trust root: the SHA256SUMS asset, fetched over GitHub TLS from our own just-published
21+
# release — the same trust model the install.sh/install.ps1 one-liners use. (Optional
22+
# future hardening: `cosign verify-blob` against the shipped SHA256SUMS.sigstore.json
23+
# before trusting it.)
2024
on:
2125
release:
2226
types: [published]
@@ -25,7 +29,7 @@ on:
2529
workflow_dispatch:
2630
inputs:
2731
tag:
28-
description: "Release tag to sync the Scoop manifest to (e.g. v0.11.0). Empty = latest published."
32+
description: "Release tag to sync the packaging manifests to (e.g. v0.11.0). Empty = latest."
2933
required: false
3034
default: ""
3135

@@ -34,12 +38,12 @@ permissions:
3438
contents: read
3539

3640
concurrency:
37-
group: scoop-bump-${{ github.workflow }}
41+
group: packaging-bump-${{ github.workflow }}
3842
cancel-in-progress: false
3943

4044
jobs:
4145
bump:
42-
name: Sync bucket/clauster.json to the release
46+
name: Sync packaging manifests to the release
4347
# workflow_dispatch is restricted to the default branch so a dispatch from an
4448
# arbitrary ref can't run this write-capable job. (release events already use
4549
# the default branch's workflow file.)
@@ -96,85 +100,66 @@ jobs:
96100
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
97101
echo "Target tag: $tag"
98102
99-
# Regenerate version/url/hash from the release's SHA256SUMS. Fails closed if
100-
# the Windows asset (or its checksum) is missing — we never push a half-baked
101-
# manifest. jq --indent 4 preserves the file's existing formatting.
102-
- name: Regenerate Scoop manifest
103+
# Regenerate version/url/hash for every present manifest from the release's
104+
# SHA256SUMS. The helper fails closed (non-zero exit) if a present manifest's
105+
# asset is missing or a stale version would survive — we never push a
106+
# half-baked manifest. Absent manifests are skipped.
107+
- name: Regenerate packaging manifests
103108
id: regen
104109
env:
105110
GH_TOKEN: ${{ github.token }}
106111
TAG: ${{ steps.tag.outputs.tag }}
107112
VERSION: ${{ steps.tag.outputs.version }}
108113
run: |
109114
set -euo pipefail
110-
manifest="bucket/clauster.json"
111-
if [ ! -f "$manifest" ]; then
112-
echo "::error::$manifest not found on the default branch"
113-
exit 1
114-
fi
115-
116115
tmp="$(mktemp -d)"
117116
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern SHA256SUMS --dir "$tmp"
117+
python3 scripts/bump_packaging.py "$VERSION" "$GITHUB_REPOSITORY" "$tmp/SHA256SUMS"
118118
119-
asset="clauster-${VERSION}-windows-x86_64.exe"
120-
hash="$(awk -v f="$asset" '$2 == f { print $1 }' "$tmp/SHA256SUMS")"
121-
if ! printf '%s' "$hash" | grep -Eq '^[0-9a-f]{64}$'; then
122-
echo "::error::no valid sha256 for $asset in the $TAG SHA256SUMS (got '${hash:-<empty>}')"
123-
exit 1
124-
fi
125-
126-
url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${asset}#/clauster.exe"
127-
jq --indent 4 \
128-
--arg v "$VERSION" --arg u "$url" --arg h "$hash" \
129-
'.version = $v
130-
| .architecture."64bit".url = $u
131-
| .architecture."64bit".hash = $h' \
132-
"$manifest" > "$manifest.new"
133-
mv "$manifest.new" "$manifest"
134-
135-
if git diff --quiet -- "$manifest"; then
136-
echo "Manifest already at $VERSION — nothing to do."
119+
if git diff --quiet; then
120+
echo "Manifests already at $VERSION — nothing to do."
137121
echo "changed=false" >> "$GITHUB_OUTPUT"
138122
else
139123
echo "changed=true" >> "$GITHUB_OUTPUT"
140-
git --no-pager diff -- "$manifest"
124+
git --no-pager diff --stat
141125
fi
142126
143127
# Push to a per-version branch and open (or update) a PR. Never main.
144-
- name: Open Scoop bump PR
128+
- name: Open packaging bump PR
145129
if: ${{ steps.regen.outputs.changed == 'true' }}
146130
env:
147131
GH_TOKEN: ${{ steps.app-token.outputs.token }}
148132
TAG: ${{ steps.tag.outputs.tag }}
149133
VERSION: ${{ steps.tag.outputs.version }}
150134
run: |
151135
set -euo pipefail
152-
branch="scoop-bump/${TAG}"
136+
branch="packaging-bump/${TAG}"
153137
case "$branch" in
154-
scoop-bump/v[0-9]*) ;;
138+
packaging-bump/v[0-9]*) ;;
155139
*) echo "::error::refusing to push to unexpected branch '$branch'"; exit 1 ;;
156140
esac
157141
158142
git config user.name "clauster-ci[bot]"
159143
git config user.email "clauster-ci[bot]@users.noreply.github.com"
160144
git checkout -B "$branch"
161-
git add bucket/clauster.json
162-
git commit -m "chore(scoop): bump bucket manifest to ${VERSION}"
145+
git add bucket/clauster.json Formula/clauster.rb flake.nix
146+
git commit -m "chore(packaging): bump manifests to ${VERSION}"
163147
git push --force-with-lease \
164148
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${branch}"
165149
166150
# Open the PR only if one isn't already open for this branch (re-run safe).
167151
existing="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "$branch" --json number --jq 'length')"
168152
if [ "$existing" = "0" ]; then
169153
body="$(printf '%s\n' \
170-
"Auto-generated by \`scoop-bump.yml\` on release **${TAG}**." \
154+
"Auto-generated by \`packaging-bump.yml\` on release **${TAG}**." \
171155
"" \
172-
"Updates \`bucket/clauster.json\` version/url/hash from the release's" \
173-
"signed \`SHA256SUMS\` (Windows x86_64). Review + merge to publish the" \
174-
"new version to Scoop users.")"
156+
"Regenerates the Scoop / Homebrew / Nix manifests' version, URLs, and" \
157+
"checksums from the release's signed \`SHA256SUMS\` (only manifests present" \
158+
"on main are touched). Review + merge to publish the new version to those" \
159+
"package managers.")"
175160
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" \
176-
--title "chore(scoop): bump bucket manifest to ${VERSION}" \
161+
--title "chore(packaging): bump manifests to ${VERSION}" \
177162
--body "$body"
178163
else
179-
echo "PR already open for $branch — pushed the updated manifest to it."
164+
echo "PR already open for $branch — pushed the updated manifests to it."
180165
fi

‎scripts/bump_packaging.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
#!/usr/bin/env python3
2+
"""Regenerate the packaging manifests to a released version from its SHA256SUMS.
3+
4+
After a release publishes, the Scoop bucket manifest, the Homebrew formula, and
5+
the Nix flake must point at the new version's binaries and checksums. This helper
6+
rewrites whichever of those manifests exist in the working tree, in place, keying
7+
every checksum off the release's authoritative ``SHA256SUMS`` (the list of what
8+
was actually built and signed).
9+
10+
Driven by ``.github/workflows/packaging-bump.yml``. A manifest that is not present
11+
is skipped (it simply is not on ``main`` yet). It fails closed rather than write a
12+
half-baked manifest: the Scoop manifest's Windows asset must be in ``SHA256SUMS``,
13+
and after rewriting, no manifest may still reference a non-target version. Standard
14+
library only. Usage::
15+
16+
python scripts/bump_packaging.py <version> <owner/repo> <path-to-SHA256SUMS>
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import re
23+
import sys
24+
from pathlib import Path
25+
26+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
27+
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
28+
# Any ``clauster-<x.y.z>-`` asset token left in a rewritten manifest: a missed arch.
29+
_ASSET_VERSION_RE = re.compile(r"clauster-(\d+\.\d+\.\d+)-")
30+
31+
32+
def parse_sums(text: str) -> dict[str, str]:
33+
"""Parse ``SHA256SUMS`` text into an ``{asset_filename: sha256}`` mapping."""
34+
sums: dict[str, str] = {}
35+
for raw in text.splitlines():
36+
line = raw.strip()
37+
if not line:
38+
continue
39+
parts = line.split()
40+
if len(parts) != 2 or not _SHA256_RE.match(parts[0]):
41+
continue
42+
digest, name = parts
43+
sums[name.lstrip("*")] = digest # coreutils marks binary mode with a leading '*'
44+
return sums
45+
46+
47+
def asset_url(repo: str, version: str, asset: str) -> str:
48+
"""Build the release download URL for one asset filename."""
49+
return f"https://github.com/{repo}/releases/download/v{version}/{asset}"
50+
51+
52+
def arch_assets(version: str, sums: dict[str, str]) -> dict[str, str]:
53+
"""Map each ``clauster-<version>-<arch>`` asset to its arch token and digest."""
54+
prefix = f"clauster-{version}-"
55+
return {
56+
asset[len(prefix) :]: digest for asset, digest in sums.items() if asset.startswith(prefix)
57+
}
58+
59+
60+
def stale_versions(text: str, version: str) -> set[str]:
61+
"""Return any asset-token versions in ``text`` other than the target version."""
62+
return set(_ASSET_VERSION_RE.findall(text)) - {version}
63+
64+
65+
def bump_scoop(text: str, version: str, repo: str, sums: dict[str, str]) -> str:
66+
"""Rewrite the Scoop bucket manifest's version, URL, and hash (Windows x86_64)."""
67+
asset = f"clauster-{version}-windows-x86_64.exe"
68+
digest = sums.get(asset)
69+
if digest is None:
70+
raise ValueError(f"Scoop manifest: release asset {asset!r} missing from SHA256SUMS")
71+
data = json.loads(text)
72+
data["version"] = version
73+
arch = data["architecture"]["64bit"]
74+
arch["url"] = f"{asset_url(repo, version, asset)}#/clauster.exe"
75+
arch["hash"] = digest
76+
return json.dumps(data, indent=4) + "\n"
77+
78+
79+
def bump_formula(text: str, version: str, repo: str, sums: dict[str, str]) -> str:
80+
"""Rewrite the Homebrew formula's version and each present arch's URL + sha256."""
81+
text = re.sub(r'version "[^"]+"', f'version "{version}"', text, count=1)
82+
for arch, digest in arch_assets(version, sums).items():
83+
url = asset_url(repo, version, f"clauster-{version}-{arch}")
84+
block = re.compile(
85+
r'(?P<head>url ")[^"]*-'
86+
+ re.escape(arch)
87+
+ r'(?P<mid>"\s*\n\s*sha256 ")[0-9a-f]{64}(?P<tail>")'
88+
)
89+
text = block.sub(
90+
lambda m, url=url, digest=digest: f"{m['head']}{url}{m['mid']}{digest}{m['tail']}",
91+
text,
92+
)
93+
return text
94+
95+
96+
def bump_flake(text: str, version: str, sums: dict[str, str]) -> str:
97+
"""Rewrite the Nix flake's version and each present system's sha256.
98+
99+
The flake's ``file`` field interpolates ``${version}``, so only the top-level
100+
version string and the per-system checksums need rewriting.
101+
"""
102+
text = re.sub(r'(version = ")[^"]+(";)', rf"\g<1>{version}\g<2>", text, count=1)
103+
for arch, digest in arch_assets(version, sums).items():
104+
block = re.compile(
105+
r'(?P<head>file = "clauster-\$\{version\}-'
106+
+ re.escape(arch)
107+
+ r'";\s*\n\s*sha256 = ")[0-9a-f]{64}(?P<tail>";)'
108+
)
109+
text = block.sub(lambda m, digest=digest: f"{m['head']}{digest}{m['tail']}", text)
110+
return text
111+
112+
113+
def main(argv: list[str]) -> int:
114+
"""Bump every present packaging manifest to ``version`` from ``SHA256SUMS``."""
115+
if len(argv) != 4:
116+
print("usage: bump_packaging.py <version> <owner/repo> <SHA256SUMS>", file=sys.stderr)
117+
return 2
118+
version, repo, sums_path = argv[1], argv[2], argv[3]
119+
if not _VERSION_RE.match(version):
120+
print(f"error: version {version!r} is not MAJOR.MINOR.PATCH", file=sys.stderr)
121+
return 2
122+
123+
sums = parse_sums(Path(sums_path).read_text())
124+
if not arch_assets(version, sums):
125+
print(f"error: SHA256SUMS has no clauster-{version}-* assets", file=sys.stderr)
126+
return 1
127+
128+
manifests = {
129+
Path("bucket/clauster.json"): lambda t: bump_scoop(t, version, repo, sums),
130+
Path("Formula/clauster.rb"): lambda t: bump_formula(t, version, repo, sums),
131+
Path("flake.nix"): lambda t: bump_flake(t, version, sums),
132+
}
133+
134+
# First pass: compute + validate every present manifest. Fail closed BEFORE any
135+
# write so a mid-sequence abort never leaves a half-rewritten tree.
136+
planned: list[tuple[Path, str]] = []
137+
try:
138+
for path, rewrite in manifests.items():
139+
if not path.exists():
140+
print(f"skip {path} (not present)")
141+
continue
142+
new_text = rewrite(path.read_text())
143+
leftover = stale_versions(new_text, version)
144+
if leftover:
145+
raise ValueError(
146+
f"{path}: still references version(s) {sorted(leftover)} after bump"
147+
)
148+
planned.append((path, new_text))
149+
except ValueError as exc:
150+
print(f"error: {exc}", file=sys.stderr)
151+
return 1
152+
153+
# Second pass: write only the manifests that actually changed.
154+
changed: list[str] = []
155+
for path, new_text in planned:
156+
if new_text == path.read_text():
157+
print(f"unchanged {path}")
158+
continue
159+
path.write_text(new_text)
160+
changed.append(str(path))
161+
print(f"bumped {path}")
162+
163+
print(f"changed={','.join(changed)}" if changed else "changed=")
164+
return 0
165+
166+
167+
if __name__ == "__main__":
168+
sys.exit(main(sys.argv))

0 commit comments

Comments
 (0)