Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/workflows/publish-scoop.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions docs/WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
108 changes: 108 additions & 0 deletions scripts/prepare_scoop_update.py
Original file line number Diff line number Diff line change
@@ -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()
100 changes: 100 additions & 0 deletions scripts/test_prepare_scoop_update.py
Original file line number Diff line number Diff line change
@@ -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()
Loading