From 149b184110f364e5c73a339d7ba99e0327e0b000 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:13:38 +0000 Subject: [PATCH] Publish a Scoop manifest for Windows with each release Windows users can now install and update errand with Scoop, which also puts errand.exe on PATH. When a stable release is published, a Windows job installs the generated manifest with Scoop and checks the version, then a second job updates bucket/errand.json in lydakis/scoop-errand with the same guards as the Homebrew tap. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Qzjh5YFvH2uew2AQsqp3No --- .github/workflows/publish-scoop.yml | 113 +++++++++++++++++++++++++++ docs/RELEASING.md | 28 +++++++ docs/WINDOWS.md | 16 +++- scripts/prepare_scoop_update.py | 108 +++++++++++++++++++++++++ scripts/test_prepare_scoop_update.py | 100 ++++++++++++++++++++++++ 5 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/publish-scoop.yml create mode 100644 scripts/prepare_scoop_update.py create mode 100644 scripts/test_prepare_scoop_update.py diff --git a/.github/workflows/publish-scoop.yml b/.github/workflows/publish-scoop.yml new file mode 100644 index 00000000..9dedb7bd --- /dev/null +++ b/.github/workflows/publish-scoop.yml @@ -0,0 +1,113 @@ +name: Publish Scoop + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Already published stable release tag (for example v0.1.0) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-scoop + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + +jobs: + # Installs the manifest with Scoop on Windows. No secrets reach this job. + validate: + if: >- + github.event_name == 'workflow_dispatch' || + (!github.event.release.draft && !github.event.release.prerelease) + runs-on: windows-2025 + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download published release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo 'Expected a stable tag such as v0.1.0' >&2 + exit 1 + fi + gh api "repos/lydakis/errand/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + gh release download "$RELEASE_TAG" --repo lydakis/errand --pattern checksums.txt \ + --pattern "errand_${RELEASE_TAG#v}_windows_*.zip" --dir "$RUNNER_TEMP/release" + - name: Render manifest + run: | + python scripts/prepare_scoop_update.py "$RELEASE_TAG" "$RUNNER_TEMP/release.json" \ + "$RUNNER_TEMP/release" "$RUNNER_TEMP/manifest/errand.json" "$RUNNER_TEMP/request.json" + - name: Install with Scoop + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $installer = "$env:RUNNER_TEMP\install-scoop.ps1" + Invoke-WebRequest -UseBasicParsing -OutFile $installer ` + https://raw.githubusercontent.com/ScoopInstaller/Install/1e2f334083d609986d8c8bc9e31ae8e87c39fab4/install.ps1 + if ((Get-FileHash $installer -Algorithm SHA256).Hash -ne '94F983B190438311E006B957DB7C8422709E0BA62A6C2AC04E278164108F2512') { + throw 'Scoop installer hash changed' + } + & $installer -RunAsAdmin + $env:PATH = "$env:USERPROFILE\scoop\shims;$env:PATH" + scoop install "$env:RUNNER_TEMP\manifest\errand.json" + if ($LASTEXITCODE -ne 0) { throw 'scoop install failed' } + $version = (errand version).Trim() + if ($version -ne "errand $($env:RELEASE_TAG.Substring(1))") { throw "unexpected version: $version" } + + publish: + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download published release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/lydakis/errand/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + gh release download "$RELEASE_TAG" --repo lydakis/errand --pattern checksums.txt \ + --pattern "errand_${RELEASE_TAG#v}_windows_*.zip" --dir "$RUNNER_TEMP/release" + - name: Check out bucket + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: lydakis/scoop-errand + ref: main + path: bucket + persist-credentials: false + - name: Verify release and prepare update + id: prepare + run: | + python3 scripts/prepare_scoop_update.py "$RELEASE_TAG" "$RUNNER_TEMP/release.json" \ + "$RUNNER_TEMP/release" bucket/bucket/errand.json "$RUNNER_TEMP/bucket-update.json" + if [ -f "$RUNNER_TEMP/bucket-update.json" ]; then + echo 'changed=true' >> "$GITHUB_OUTPUT" + fi + - name: Publish validated manifest + if: steps.prepare.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GORELEASER_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo 'Set GORELEASER_TOKEN with Contents write access to lydakis/scoop-errand.' >&2 + exit 1 + fi + gh api --method PUT repos/lydakis/scoop-errand/contents/bucket/errand.json \ + --input "$RUNNER_TEMP/bucket-update.json" --jq .commit.html_url diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 566de8f1..2f24d41b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -145,6 +145,34 @@ It installs the CLI only; it does not start a runner, modify its authorization, or remove runner receipts and config on uninstall. Do not publish a prerelease formula over the stable tap entry. +## Scoop bucket + +Windows users install from the Scoop bucket +[lydakis/scoop-errand](https://github.com/lydakis/scoop-errand). It needs a +`main` branch (an initial README is enough) before the first update, and +`GORELEASER_TOKEN` needs Contents read/write access to it as well as to the tap. + +The **Publish Scoop** workflow also runs on `release.published` and skips drafts +and prereleases. A Windows job with no secrets downloads the published Windows +zips and `checksums.txt`, renders `bucket/errand.json` with +`scripts/prepare_scoop_update.py`, installs it with Scoop and checks +`errand version`. A second job renders the manifest again from the same +assets and updates the bucket through the GitHub Contents API, with the same +guards as the tap: identical updates do nothing, older releases cannot +downgrade it, and a different manifest for the same version is refused. If it +fails, fix the problem and rerun **Publish Scoop** with the published tag. + +Users then install or upgrade with: + +```powershell +scoop bucket add errand https://github.com/lydakis/scoop-errand +scoop install errand +scoop update errand +``` + +The manifest installs the CLI only. Scoop prints a note to run `errand setup`, +which starts or restarts the runner after an install or update. + ## Runner upgrades For runners on 0.2.1 or earlier, follow the diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index ceef2f85..343084d4 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -14,7 +14,15 @@ yet. ## Install -Download `errand_VERSION_windows_amd64.zip` (or `_arm64`) from +With [Scoop](https://scoop.sh), in PowerShell: + +```powershell +scoop bucket add errand https://github.com/lydakis/scoop-errand +scoop install errand +errand setup +``` + +Or download `errand_VERSION_windows_amd64.zip` (or `_arm64`) from [GitHub Releases](https://github.com/lydakis/errand/releases) and unzip `errand.exe` somewhere permanent, such as `%LOCALAPPDATA%\Programs\errand`. The binary is not signed yet, so a browser download shows a SmartScreen @@ -35,9 +43,9 @@ New Windows runners listen on the tailnet only (`transport = "tailscale"`), because SSH callers are not supported on Windows yet. The runner log is `%LOCALAPPDATA%\errand\errand.log`. -To upgrade, replace `errand.exe` and run `errand.exe setup` again. The running -task uses its own copy, so replacing the file never fails because the runner -is using it. +To upgrade, run `scoop update errand` (or replace `errand.exe`), then run +`errand setup` again. The running task uses its own copy, so replacing the +file never fails because the runner is using it. ## Send jobs from your Mac diff --git a/scripts/prepare_scoop_update.py b/scripts/prepare_scoop_update.py new file mode 100644 index 00000000..cc7b214e --- /dev/null +++ b/scripts/prepare_scoop_update.py @@ -0,0 +1,108 @@ +"""Verify a published stable release and prepare a Scoop bucket Contents API update.""" + +import argparse +import base64 +import hashlib +import json +from pathlib import Path +import re +import zipfile + + +STABLE_VERSION = r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" +ARCHITECTURES = {"64bit": "amd64", "arm64": "arm64"} + + +def render_manifest(version: str, hashes: dict[str, str]) -> str: + manifest = { + "version": version, + "description": "Personal job runner for machines you own (Windows runner)", + "homepage": "https://github.com/lydakis/errand", + "license": "MIT", + "notes": "Run `errand setup` to start the runner, and again after each update.", + "architecture": { + scoop: { + "url": f"https://github.com/lydakis/errand/releases/download/v{version}/" + f"errand_{version}_windows_{goarch}.zip", + "hash": hashes[goarch], + } + for scoop, goarch in ARCHITECTURES.items() + }, + "bin": "errand.exe", + } + return json.dumps(manifest, indent=4) + "\n" + + +def prepare_update(tag: str, metadata: Path, assets: Path, current: Path): + match = re.fullmatch("v" + STABLE_VERSION, tag) + if not match: + raise ValueError("expected a stable tag such as v0.1.0") + version = tag[1:] + release = json.loads(metadata.read_text()) + if (release.get("tag_name") != tag or release.get("draft") is not False + or release.get("prerelease") is not False or not release.get("published_at")): + raise ValueError("release must match the tag and be published, stable, and not a draft") + + checksums = {} + for line in (assets / "checksums.txt").read_text().splitlines(): + entry = re.fullmatch(r"([0-9a-f]{64}) [ *](\S+)", line) + if not entry or entry[2] in checksums: + raise ValueError("malformed or duplicate checksum entry") + checksums[entry[2]] = entry[1] + hashes = {} + for goarch in ARCHITECTURES.values(): + archive = assets / f"errand_{version}_windows_{goarch}.zip" + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + if checksums.get(archive.name) != digest: + raise ValueError(f"checksum mismatch or missing checksum for {archive.name}") + with zipfile.ZipFile(archive) as contents: + if "errand.exe" not in contents.namelist(): + raise ValueError(f"{archive.name} has no errand.exe at its root") + hashes[goarch] = digest + manifest = render_manifest(version, hashes).encode() + + request = { + "message": f"Update errand to {version}", + "branch": "main", + "content": base64.b64encode(manifest).decode(), + } + if current.exists(): + previous = current.read_bytes() + previous_version = json.loads(previous).get("version") + previous_match = re.fullmatch(STABLE_VERSION, str(previous_version)) + if not previous_match: + raise ValueError("current manifest must have one stable version") + if tuple(map(int, previous_match.groups())) > tuple(map(int, match.groups())) or previous == manifest: + return None + if previous_version == version: + raise ValueError("refusing to replace a different manifest with the same version") + # GitHub requires the previous Git blob ID to guard against concurrent edits. + request["sha"] = hashlib.sha1( + b"blob " + str(len(previous)).encode() + b"\0" + previous + ).hexdigest() + current.parent.mkdir(parents=True, exist_ok=True) + current.write_bytes(manifest) + return request + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag") + parser.add_argument("metadata", type=Path) + parser.add_argument("assets", type=Path) + parser.add_argument("current", type=Path) + parser.add_argument("request", type=Path) + args = parser.parse_args() + args.request.unlink(missing_ok=True) + try: + request = prepare_update(args.tag, args.metadata, args.assets, args.current) + if request is not None: + args.request.write_text(json.dumps(request)) + else: + print("Bucket already contains this version or a newer release; no update needed.") + except (ValueError, OSError, zipfile.BadZipFile) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_prepare_scoop_update.py b/scripts/test_prepare_scoop_update.py new file mode 100644 index 00000000..5319a3c6 --- /dev/null +++ b/scripts/test_prepare_scoop_update.py @@ -0,0 +1,100 @@ +import base64 +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +import zipfile + +from prepare_scoop_update import prepare_update + + +class ScoopUpdateTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.assets = self.root / "assets" + self.assets.mkdir() + self.current = self.root / "bucket" / "bucket" / "errand.json" + self.metadata = self.root / "release.json" + self.release = { + "tag_name": "v0.2.0", "draft": False, "prerelease": False, + "published_at": "2026-09-05T00:00:00Z", + } + self.archives = [] + for goarch in ["amd64", "arm64"]: + archive = self.assets / f"errand_0.2.0_windows_{goarch}.zip" + with zipfile.ZipFile(archive, "w") as contents: + contents.writestr("errand.exe", f"errand {goarch}") + contents.writestr("LICENSE", "MIT") + self.archives.append(archive) + self.write_checksums() + + def write_checksums(self): + (self.assets / "checksums.txt").write_text("".join( + f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n" + for path in self.archives + )) + + def prepare(self, tag="v0.2.0"): + self.metadata.write_text(json.dumps(self.release)) + return prepare_update(tag, self.metadata, self.assets, self.current) + + def test_first_publication_and_upgrade_request(self): + request = self.prepare() + manifest = json.loads(base64.b64decode(request["content"])) + self.assertEqual(manifest["version"], "0.2.0") + self.assertEqual(manifest["bin"], "errand.exe") + arm64 = manifest["architecture"]["arm64"] + self.assertEqual(arm64["url"], "https://github.com/lydakis/errand/releases/download/" + "v0.2.0/errand_0.2.0_windows_arm64.zip") + self.assertEqual(arm64["hash"], hashlib.sha256(self.archives[1].read_bytes()).hexdigest()) + self.assertIn("64bit", manifest["architecture"]) + self.assertNotIn("sha", request) + written = self.current.read_bytes() + + old = written.replace(b'"0.2.0"', b'"0.1.0"') + self.current.write_bytes(old) + request = self.prepare() + self.assertEqual(request["sha"], hashlib.sha1( + b"blob " + str(len(old)).encode() + b"\0" + old + ).hexdigest()) + self.assertEqual(self.current.read_bytes(), written) + + def test_rerun_and_older_release_do_not_write(self): + self.prepare() + self.assertIsNone(self.prepare()) + newer = self.current.read_bytes().replace(b'"0.2.0"', b'"0.10.0"') + self.current.write_bytes(newer) + self.assertIsNone(self.prepare()) + self.assertEqual(self.current.read_bytes(), newer) + + def test_same_version_with_different_content_is_refused(self): + self.prepare() + self.current.write_bytes(self.current.read_bytes().replace(b"errand.exe", b"other.exe")) + with self.assertRaisesRegex(ValueError, "same version"): + self.prepare() + + def test_rejects_unpublished_prerelease_and_bad_assets(self): + for change in [{"draft": True}, {"prerelease": True}, {"published_at": None}]: + with self.subTest(change=change): + self.release.update(change) + with self.assertRaisesRegex(ValueError, "published"): + self.prepare() + self.setUp() + with self.assertRaisesRegex(ValueError, "stable tag"): + self.prepare("v0.2.0-rc.1") + self.archives[0].write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + self.prepare() + with zipfile.ZipFile(self.archives[0], "w") as contents: + contents.writestr("bin/errand.exe", "nested") + self.write_checksums() + with self.assertRaisesRegex(ValueError, "no errand.exe"): + self.prepare() + self.assertFalse(self.current.exists()) + + +if __name__ == "__main__": + unittest.main()