diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 5c467d53..896fc5ec 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -519,3 +519,82 @@ jobs: exit 1 fi echo "payload verified: binary, man page, three completion files" + + # The Windows half of packaging verification. Exercises the Scoop manifest template, + # renders it against the last released tag's real GitHub Release .exe assets, and + # installs it through a local Scoop bucket on windows-latest. + scoop: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + + - name: Scoop manifest template is valid (offline) + shell: bash + run: python scripts/scoop_check.py --self-test && python scripts/scoop_check.py + + - name: Render the Scoop manifest for the last released tag + shell: bash + run: | + set -euo pipefail + TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + if [ -z "$TAG" ]; then + echo "::error::no tags in this checkout — cannot render a Scoop manifest to install" + exit 1 + fi + VER="${TAG#v}" + BASE_URL="https://github.com/l1a/retch/releases/download/${TAG}" + echo "last released tag: $TAG" + WORK="$(mktemp -d)" + curl -sfL --retry 3 -o "$WORK/retch-windows-x86_64.exe" "$BASE_URL/retch-windows-x86_64.exe" \ + || { echo "::error::cannot fetch $BASE_URL/retch-windows-x86_64.exe"; exit 1; } + curl -sfL --retry 3 -o "$WORK/retch-windows-arm64.exe" "$BASE_URL/retch-windows-arm64.exe" \ + || { echo "::error::cannot fetch $BASE_URL/retch-windows-arm64.exe"; exit 1; } + SHA_X64=$(python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$WORK/retch-windows-x86_64.exe") + SHA_ARM=$(python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$WORK/retch-windows-arm64.exe") + echo "computed x86_64 sha256: $SHA_X64" + echo "computed arm64 sha256: $SHA_ARM" + python scripts/render_packaging.py --target scoop --version "$VER" \ + --sha256-x86-64 "$SHA_X64" --sha256-arm64 "$SHA_ARM" \ + --out "$RUNNER_TEMP/retch.json" + python - "$RUNNER_TEMP/retch.json" "$VER" "$SHA_X64" "$SHA_ARM" <<'PY' + import json, pathlib, sys + path, ver, sha_x64, sha_arm = sys.argv[1:] + data = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + assert data["version"] == ver, f"version {data['version']!r} != {ver!r}" + assert data["architecture"]["64bit"]["hash"] == sha_x64 + assert data["architecture"]["arm64"]["hash"] == sha_arm + assert data["architecture"]["64bit"]["url"].endswith(f"/v{ver}/retch-windows-x86_64.exe#/retch.exe") + assert data["architecture"]["arm64"]["url"].endswith(f"/v{ver}/retch-windows-arm64.exe#/retch.exe") + PY + echo "RENDERED_VER=$VER" >> "$GITHUB_ENV" + rm -rf "$WORK" + echo "render verified against the real $TAG Windows release binaries" + + - name: Install via Scoop from a staged local bucket and verify binary + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process -Force + if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) { + Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression + $env:PATH = "$HOME\scoop\shims;$env:PATH" + } + # Scoop resolves `/` from `~\scoop\buckets\\bucket\.json` + # (see `Find-BucketDirectory` in Scoop's `lib/buckets.ps1`), whereas `scoop bucket add` + # rejects a Windows drive-letter path in `Convert-RepositoryUri`. Staging directly + # under `~\scoop\buckets\local-test\bucket\` mirrors the `brew` job's `brew tap-new --no-git`. + $bucketDir = Join-Path $HOME "scoop\buckets\local-test\bucket" + New-Item -ItemType Directory -Force -Path $bucketDir | Out-Null + Copy-Item "$env:RUNNER_TEMP\retch.json" (Join-Path $bucketDir "retch.json") + scoop install local-test/retch + if ($LASTEXITCODE -ne 0) { + throw "scoop install local-test/retch failed with exit code $LASTEXITCODE" + } + $verOut = & retch --version + Write-Host "retch --version: $verOut" + if ($verOut -notmatch [regex]::Escape($env:RENDERED_VER)) { + throw "Expected retch --version to contain $($env:RENDERED_VER), got: $verOut" + } + & retch --fields os,cpu --no-logo diff --git a/AGENTS.md b/AGENTS.md index 1cc249e2..a9eee148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,31 +309,34 @@ Clone `https://github.com/l1a/retch.wiki.git`, edit the relevant pages, and push ### 4.9 Packaging - [ ] **There is no post-release packaging PR, and a release requires no version bump.** - `packaging/aur/PKGBUILD`, `packaging/copr/retch.spec` and - `packaging/homebrew/retch.rb` are **templates** carrying `@VERSION@` / `@SHA256@`; + `packaging/aur/PKGBUILD`, `packaging/copr/retch.spec`, + `packaging/homebrew/retch.rb` and `packaging/scoop/retch.json` are **templates** + carrying `@VERSION@` / `@SHA256@` (or `@SHA256_X86_64@` / `@SHA256_ARM64@`); `scripts/render_packaging.py` fills those in at publish time, from the tag. So a release is: tag → `just publish` → `just aur-publish ` → - `just brew-publish ` (COPR rebuilds itself off the tag), and nothing is - committed afterwards. `main` sits **at** the released version until the next feature - PR bumps it — which is what makes `just publish` on `main` correct, and its - HEAD-is-the-tag guard is what enforces that rather than trusting it. + `just brew-publish ` → `just scoop-publish ` (COPR rebuilds + itself off the tag), and nothing is committed afterwards. `main` sits **at** the + released version until the next feature PR bumps it — which is what makes + `just publish` on `main` correct, and its HEAD-is-the-tag guard is what enforces + that rather than trusting it. - [ ] If you are touching packaging, the guards run offline in `just check` - (`render-check`, `aur-check`, `copr-check`, `brew-check`) and assert the templates - still record nothing. Do not "fix" one by writing a version back into a template. + (`render-check`, `aur-check`, `copr-check`, `brew-check`, `scoop-check`) and assert + the templates still record nothing. Do not "fix" one by writing a version back into a + template. - [ ] `packaging/nixpkgs/package.nix` is the one target still pinned by hand, deliberately: that channel is undecided and its CI job is `if: false`. Check whether it needs `just nix-update` only if a nixpkgs submission is actually happening. - [ ] **Channel text is part of publishing each channel.** `packaging/metadata.toml` holds the one-line summary, the Homebrew `desc`, the licence, the GitHub description and topics, and the COPR project-page files; `just check` (`metadata-check`) fails when - `Cargo.toml`, the PKGBUILD, the spec, the formula or the Nix files drift from it. Text - an artifact carries (crates.io description and README, AUR `pkgdesc`, RPM - `%description`, the tap's `desc`) ships with that channel's publish step. The COPR - project page is pushed by `copr.yml` on the tag, and GitHub's About box by - `just github-metadata` after it. **Before tagging, read the READMEs and those files - for claims that are no longer true** — the guard proves the copies agree, not that - they are right; the v0.17.9 review found a README flag that does not exist and a - crates.io example that did not compile. + `Cargo.toml`, the PKGBUILD, the spec, the formula, the Scoop manifest or the Nix files + drift from it. Text an artifact carries (crates.io description and README, AUR `pkgdesc`, + RPM `%description`, the tap's `desc`, the Scoop bucket's `description`) ships with that + channel's publish step. The COPR project page is pushed by `copr.yml` on the tag, and + GitHub's About box by `just github-metadata` after it. **Before tagging, read the + READMEs and those files for claims that are no longer true** — the guard proves the + copies agree, not that they are right; the v0.17.9 review found a README flag that does + not exist and a crates.io example that did not compile. - [ ] History, for anyone reading an old commit: releases up to v0.9.10 committed the packaging bump straight to `main` believing the gate made a PR impossible (it did not); v0.9.12 turned it into a gated PR via `just post-release`; and the bump itself diff --git a/Cargo.lock b/Cargo.lock index 4a7e684a..bc07bac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1564,7 +1564,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "retch-cli" -version = "0.20.35" +version = "0.20.36" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index f3bc8b33..88889955 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ [package] name = "retch-cli" -version = "0.20.35" +version = "0.20.36" edition = "2021" authors = ["Ken Tobias"] description = "A fast, feature-rich system information fetcher written in Rust (similar to fastfetch or neofetch)" diff --git a/Justfile b/Justfile index 6652f424..e1aa00ff 100644 --- a/Justfile +++ b/Justfile @@ -127,7 +127,7 @@ fmt: lint: cargo clippy --workspace -- -D warnings -# The renderer is the single implementation behind all three packaging targets, so its +# The renderer is the single implementation behind all four packaging targets, so its # self-test is a `check` dependency like theirs. It is the piece that must refuse: a # substitution matching nothing, a sentinel surviving, a placeholder checksum. # @@ -136,7 +136,7 @@ render-check: @{{PY}} scripts/render_packaging.py --self-test # Run strict checks (formatting and linting) as done in CI -check: standard-check render-check aur-check copr-check brew-check metadata-check text-check wip-check bench-check +check: standard-check render-check aur-check copr-check brew-check scoop-check metadata-check text-check wip-check bench-check cargo fmt -- --check cargo clippy --workspace -- -D warnings # Also lint retch-cli WITHOUT its default `graphics` feature (base64/image/icy_sixel). @@ -585,6 +585,18 @@ brew-check: @{{PY}} scripts/brew_check.py --self-test @{{PY}} scripts/brew_check.py +# ===== SCOOP ===== +# +# packaging/scoop/retch.json is the SOURCE for the Windows Scoop bucket at l1a/scoop-retch. +# Like the AUR PKGBUILD, COPR spec and Homebrew formula, it is a template carrying +# @VERSION@, @SHA256_X86_64@ and @SHA256_ARM64@; scripts/render_packaging.py fills them in +# at publish time from the released GitHub Release .exe assets. + +# Verify the Scoop manifest is still a template recording no version (offline) +scoop-check: + @{{PY}} scripts/scoop_check.py --self-test + @{{PY}} scripts/scoop_check.py + # Every channel's description and licence agrees with packaging/metadata.toml (offline) metadata-check: @{{PY}} scripts/metadata_check.py --self-test @@ -789,6 +801,90 @@ brew-publish VERSION: pass "pushed retch $VER to $TAP_REPO" echo "Verify: brew tap l1a/retch && brew install retch" +# +# Takes the version for the same reason `aur-publish` and `brew-publish` do: the manifest is a +# template, and this is the moment the released version and its two binary checksums come into +# existence. +# +# Render the Scoop manifest for a released tag and push it to the bucket at l1a/scoop-retch +scoop-publish VERSION: + #!/usr/bin/env bash + set -euo pipefail + BOLD='\033[1m'; GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' + pass() { echo -e "${GREEN}[✓]${NC} $1"; } + fail() { echo -e "${RED}[✗]${NC} $1"; exit 1; } + info() { echo -e "${YELLOW}[→]${NC} $1"; } + + BUCKET_REPO="git@github.com:l1a/scoop-retch.git" + VER="{{VERSION}}"; VER="${VER#v}" + [ -n "$VER" ] || fail "scoop-publish needs a version, e.g. just scoop-publish 0.20.28" + + {{PY}} scripts/scoop_check.py || fail "packaging/scoop/retch.json is not a valid template" + pass "Scoop manifest template is valid" + + # Both Windows architecture binaries are downloaded from the GitHub Release for v$VER + # and checksummed before rendering -- never copied from a committed field. + WORK=$(mktemp -d) + trap 'rm -rf "$WORK"' EXIT + BASE_URL="https://github.com/l1a/retch/releases/download/v${VER}" + info "Downloading and checksumming the v$VER Windows release binaries..." + curl -sfL -o "$WORK/retch-windows-x86_64.exe" "$BASE_URL/retch-windows-x86_64.exe" \ + || fail "no x86_64 binary at $BASE_URL/retch-windows-x86_64.exe — tag and wait for the v$VER release build first" + curl -sfL -o "$WORK/retch-windows-arm64.exe" "$BASE_URL/retch-windows-arm64.exe" \ + || fail "no arm64 binary at $BASE_URL/retch-windows-arm64.exe — tag and wait for the v$VER release build first" + SHA_X64=$({{PY}} -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$WORK/retch-windows-x86_64.exe") + SHA_ARM=$({{PY}} -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$WORK/retch-windows-arm64.exe") + pass "v$VER x86_64: $(wc -c < "$WORK/retch-windows-x86_64.exe" | tr -d ' ') bytes, sha256 $SHA_X64" + pass "v$VER arm64: $(wc -c < "$WORK/retch-windows-arm64.exe" | tr -d ' ') bytes, sha256 $SHA_ARM" + + {{PY}} scripts/render_packaging.py --target scoop --version "$VER" \ + --sha256-x86-64 "$SHA_X64" --sha256-arm64 "$SHA_ARM" \ + --out "$WORK/retch.json" + MANIFEST="$WORK/retch.json" + pass "rendered Scoop manifest for v$VER" + + echo + echo -e "${BOLD}About to publish retch $VER to the Scoop bucket.${NC}" + echo "This is public and immediate: $BUCKET_REPO" + echo "" + if [ -n "${SCOOP_CONFIRM:-}" ]; then + CONFIRM="$SCOOP_CONFIRM" + echo "Type 'yes' to continue: $CONFIRM (answered by SCOOP_CONFIRM)" + elif [ -t 0 ]; then + echo -n "Type 'yes' (or 'y') to continue: "; read -r CONFIRM + else + read -r -t 10 CONFIRM || CONFIRM="" + echo "$CONFIRM" + [ -n "$CONFIRM" ] || fail "no terminal and nothing on stdin. Re-run with SCOOP_CONFIRM=yes" + fi + case "$CONFIRM" in + y|Y|yes|YES) ;; + *) echo -e "${RED}Aborted.${NC}"; exit 1 ;; + esac + + info "Cloning the bucket..." + git clone -q "$BUCKET_REPO" "$WORK/bucket" || fail "could not clone $BUCKET_REPO — does the repo exist, and is the SSH key registered?" + mkdir -p "$WORK/bucket/bucket" + cp "$MANIFEST" "$WORK/bucket/bucket/retch.json" + + if ! git -C "$WORK/bucket" rev-parse --verify -q HEAD >/dev/null 2>&1; then + git -C "$WORK/bucket" symbolic-ref HEAD refs/heads/main + info "empty bucket: default branch pinned to main" + fi + + git -C "$WORK/bucket" config user.name "$(git -C "{{justfile_directory()}}" config user.name)" + git -C "$WORK/bucket" config user.email "$(git -C "{{justfile_directory()}}" config user.email)" + + git -C "$WORK/bucket" add bucket/retch.json + if git -C "$WORK/bucket" diff --cached --quiet; then + pass "bucket already has this exact manifest — nothing to push" + exit 0 + fi + git -C "$WORK/bucket" commit -q -m "retch $VER" + git -C "$WORK/bucket" push -q -u origin HEAD + pass "pushed retch $VER to $BUCKET_REPO" + echo "Verify: scoop bucket add l1a https://github.com/l1a/scoop-retch && scoop install retch" + # ===== RELEASING ===== # # A release needs NO VERSION BUMP, and that is the point of the shape below. @@ -819,16 +915,17 @@ brew-publish VERSION: # 3. just publish # crates.io; refuses unless HEAD is the tag for X # 4. just aur-publish X # renders + pushes to the AUR (needs podman) # just brew-publish X # renders + pushes to the tap +# just scoop-publish X # renders + pushes to the Scoop bucket # 5. just github-metadata # the repo's About box, from packaging/metadata.toml # # There is no step that edits a file, and therefore nothing to commit afterwards. Steps 3 # and 4 still ask before acting: they are public and irreversible. # -# CHANNEL TEXT TRAVELS WITH EACH STEP. crates.io, the AUR, COPR's RPM and the tap read their -# description from the files they publish, and `just check` (metadata-check) keeps those in -# step with packaging/metadata.toml. Only the COPR project page and GitHub's About box live on -# the service rather than in a published file -- nothing ever updated them, which is how both -# went stale -- so they get steps of their own (2 and 5). +# CHANNEL TEXT TRAVELS WITH EACH STEP. crates.io, the AUR, COPR's RPM, the tap and the Scoop +# bucket read their description from the files they publish, and `just check` (metadata-check) +# keeps those in step with packaging/metadata.toml. Only the COPR project page and GitHub's +# About box live on the service rather than in a published file -- nothing ever updated them, +# which is how both went stale -- so they get steps of their own (2 and 5). # Merge the active PR, switch to main, pull, delete the branch, and update WIP.md (requires gh) # diff --git a/NOTES.md b/NOTES.md index 7f0ba405..912d8b49 100644 --- a/NOTES.md +++ b/NOTES.md @@ -140,6 +140,7 @@ information gathering without any dependency on `clap` or the CLI. just publish # crates.io. Refuses unless HEAD is the tag. just aur-publish X # renders + pushes to the AUR (needs podman; see §7.4). AUR_CONFIRM=y just brew-publish X # renders + pushes to the tap. BREW_CONFIRM=y + just scoop-publish X # renders + pushes to the Scoop bucket. SCOOP_CONFIRM=y just github-metadata # About box from packaging/metadata.toml ``` `retch-sysinfo` publishes before `retch-cli`, which pins `=0.1.x`; `just publish` orders them @@ -150,9 +151,9 @@ information gathering without any dependency on `clap` or the CLI. --- -## Current State (v0.20.35) +## Current State (v0.20.36) -`main` carries **`retch-cli` 0.20.35** / **`retch-sysinfo` 0.1.100**. Newest released tag is +`main` carries **`retch-cli` 0.20.36** / **`retch-sysinfo` 0.1.100**. Newest released tag is **`v0.20.34`**. The fastfetch feature gap (§6) is closed on Linux, macOS and Windows (except for the @@ -161,6 +162,27 @@ and §6c. Recent work worth knowing about, beyond what `git log` says: +- **v0.20.36: Windows Scoop packaging (`packaging/scoop/retch.json`, `scripts/scoop_check.py`, + `just scoop-publish `, and `l1a/scoop-retch` bucket).** + - **Template manifest (`packaging/scoop/retch.json`):** Follows the same zero-drift template + architecture as `packaging/aur/PKGBUILD`, `packaging/copr/retch.spec`, and + `packaging/homebrew/retch.rb`. Records `@VERSION@`, `@SHA256_X86_64@`, and `@SHA256_ARM64@` + sentinels in-repo; both `64bit` (`retch-windows-x86_64.exe`) and `arm64` + (`retch-windows-arm64.exe`) asset URLs append `#/retch.exe` so Scoop renames the downloaded + binary to `retch.exe` on install and exposes it through a single `"bin": "retch.exe"` shim. + - **Renderer & guards (`scripts/render_packaging.py`, `scripts/scoop_check.py`, + `scripts/metadata_check.py`):** Extended `render_packaging.py --target scoop` with + `--sha256-x86-64` and `--sha256-arm64`, added offline guard `scoop_check.py` (wired into + `just check` via `scoop-check`), and extended `metadata_check.py` to enforce that the + manifest's `description` and `license` match `packaging/metadata.toml`. + - **Publishing & CI (`just scoop-publish `, `.github/workflows/packaging.yml`):** + `just scoop-publish ` downloads both `.exe` assets from the GitHub Release for + `v`, computes their SHA-256 digests, renders `bucket/retch.json`, and pushes to + `git@github.com:l1a/scoop-retch.git` (gated by `SCOOP_CONFIRM`). The `scoop` job in + `packaging.yml` runs `scoop_check.py`, renders the manifest against the latest released tag's + real GitHub Release `.exe` assets on `windows-latest`, stages a local bucket, and verifies + `scoop install` + `retch --version`. + - **v0.20.35: Dynamic Linux DE/compositor/daemon `wallpaper` detection and removal of stale cross-DE GNOME fallback.** Verified on `corrino` (Dell Inspiron 16 7630 2-in-1, Fedora 44, Umbriel + Noctalia Shell): diff --git a/README.md b/README.md index 4d925564..11037afd 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,17 @@ sudo dnf install retch *(The RPM spec file is available in [packaging/copr](packaging/copr/retch.spec)).* +### On Windows (Scoop) + +`retch` is available for Windows (`x86_64` and `arm64`) from a [Scoop](https://scoop.sh) bucket: + +```powershell +scoop bucket add l1a https://github.com/l1a/scoop-retch +scoop install retch +``` + +*(The Scoop manifest is maintained in [packaging/scoop](packaging/scoop/retch.json) as a template and pushed to the bucket by `just scoop-publish` — the bucket is never hand-edited.)* + ### From crates.io ```sh diff --git a/docs/retch.1 b/docs/retch.1 index f3686647..f0aeedfe 100644 --- a/docs/retch.1 +++ b/docs/retch.1 @@ -1,4 +1,4 @@ -.TH "RETCH" "1" "October 2026" "retch 0.20.35" "System Information Fetcher" +.TH "RETCH" "1" "October 2026" "retch 0.20.36" "System Information Fetcher" .SH "NAME" .PP diff --git a/packaging/scoop/retch.json b/packaging/scoop/retch.json new file mode 100644 index 00000000..7bf90e21 --- /dev/null +++ b/packaging/scoop/retch.json @@ -0,0 +1,28 @@ +{ + "version": "@VERSION@", + "description": "A fast, feature-rich system information fetcher written in Rust", + "homepage": "https://github.com/l1a/retch", + "license": "GPL-3.0-or-later", + "architecture": { + "64bit": { + "url": "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-x86_64.exe#/retch.exe", + "hash": "@SHA256_X86_64@" + }, + "arm64": { + "url": "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-arm64.exe#/retch.exe", + "hash": "@SHA256_ARM64@" + } + }, + "bin": "retch.exe", + "checkver": "github", + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/l1a/retch/releases/download/v$version/retch-windows-x86_64.exe#/retch.exe" + }, + "arm64": { + "url": "https://github.com/l1a/retch/releases/download/v$version/retch-windows-arm64.exe#/retch.exe" + } + } + } +} diff --git a/scripts/metadata_check.py b/scripts/metadata_check.py index f7fbae55..3d52a33a 100644 --- a/scripts/metadata_check.py +++ b/scripts/metadata_check.py @@ -25,7 +25,8 @@ All offline, deterministic and free: 1. retch-cli's Cargo.toml `description` BEGINS with `summary`; the PKGBUILD `pkgdesc`, the - spec's `Summary:`, the Nix derivation's and the flake's descriptions EQUAL it. + spec's `Summary:`, the Scoop manifest's `description`, the Nix derivation's and the + flake's descriptions EQUAL it. 2. The Homebrew formula's `desc` equals `brew_desc`, and `brew_desc` obeys `brew audit`'s rules (no leading article, no formula name, no trailing full stop, at most 80 chars). 3. Every licence field names `license`, in its channel's vocabulary. @@ -73,6 +74,7 @@ "pkgbuild": "packaging/aur/PKGBUILD", "spec": "packaging/copr/retch.spec", "formula": "packaging/homebrew/retch.rb", + "scoop": "packaging/scoop/retch.json", "package_nix": "packaging/nixpkgs/package.nix", "flake_nix": "flake.nix", "readme": "README.md", @@ -228,6 +230,14 @@ def expect(where: str, got: str | None, want: str) -> None: except ParseError as exc: problems.append(str(exc)) + try: + scoop = json.loads(texts["scoop"]) + except json.JSONDecodeError as exc: + problems.append(f"could not parse packaging/scoop/retch.json: {exc}") + else: + expect("scoop description", scoop.get("description"), summary) + expect("scoop license", scoop.get("license"), licence) + nix_licence = NIX_LICENSE.get(licence) if nix_licence is None: problems.append(f"no Nix licence attribute is known for {licence!r}; add it to NIX_LICENSE") @@ -375,6 +385,7 @@ def sync_github(meta: dict, dry_run: bool) -> int: "pkgbuild": "pkgname=retch\npkgdesc=\"A fast thing written in Rust\"\nlicense=('GPL-3.0-or-later')\n", "spec": "Name: retch\nSummary: A fast thing written in Rust\nLicense: GPL-3.0-or-later\n", "formula": 'class Retch < Formula\n desc "Fast thing"\n license "GPL-3.0-or-later"\nend\n', + "scoop": '{\n "description": "A fast thing written in Rust",\n "license": "GPL-3.0-or-later"\n}\n', "package_nix": ' meta = with lib; {\n description = "A fast thing written in Rust";\n license = licenses.gpl3Plus;\n };\n', "flake_nix": '{\n description = "A fast thing written in Rust";\n opts = {\n description = "Configuration for retch";\n };\n}\n', "readme": ( @@ -438,6 +449,8 @@ def expect_problem(label: str, key: str, old: str, new: str, needle: str) -> Non expect_problem("spec Summary drift detected", "spec", "Summary: A fast", "Summary: A slow", "spec Summary") expect_problem("spec License drift detected", "spec", "License: GPL-3.0-or-later", "License: MIT", "spec License") expect_problem("formula desc drift detected", "formula", 'desc "Fast thing"', 'desc "Slow thing"', "formula desc") + expect_problem("scoop description drift detected", "scoop", '"A fast thing written in Rust"', '"A slow thing"', "scoop description") + expect_problem("scoop license drift detected", "scoop", '"GPL-3.0-or-later"', '"MIT"', "scoop license") expect_problem("Cargo description drift detected", "cargo_cli", '"A fast thing written in Rust (like others)"', '"A fetcher"', "does not begin with the summary") expect_problem("sysinfo licence drift detected", "cargo_sysinfo", 'license = "GPL-3.0-or-later"', diff --git a/scripts/render_packaging.py b/scripts/render_packaging.py index 6ac67f91..ab4175eb 100644 --- a/scripts/render_packaging.py +++ b/scripts/render_packaging.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2026 l1a -"""Render packaging/{aur,copr,homebrew} for a released version. +"""Render packaging/{aur,copr,homebrew,scoop} for a released version. WHY THIS EXISTS --------------- @@ -86,13 +86,15 @@ class RenderError(Exception): class Target: path: str needs_sha: bool + needs_arch_shas: bool what: str TARGETS = { - "aur": Target("packaging/aur/PKGBUILD", True, "AUR PKGBUILD"), - "copr": Target("packaging/copr/retch.spec", False, "COPR spec"), - "brew": Target("packaging/homebrew/retch.rb", True, "Homebrew formula"), + "aur": Target("packaging/aur/PKGBUILD", True, False, "AUR PKGBUILD"), + "copr": Target("packaging/copr/retch.spec", False, False, "COPR spec"), + "brew": Target("packaging/homebrew/retch.rb", True, False, "Homebrew formula"), + "scoop": Target("packaging/scoop/retch.json", False, True, "Scoop manifest"), } @@ -191,6 +193,8 @@ def render( version: str, sha256: str | None = None, *, + sha256_x86_64: str | None = None, + sha256_arm64: str | None = None, root: Path = REPO_ROOT, stamp: str | None = None, ) -> str: @@ -205,9 +209,22 @@ def render( if spec.needs_sha: if sha256 is None: raise RenderError(f"{spec.what} pins a checksum: --sha256 is required") + if sha256_x86_64 is not None or sha256_arm64 is not None: + raise RenderError(f"{spec.what} takes --sha256, not per-arch sha256 flags") values["SHA256"] = validate_sha256(sha256) - elif sha256 is not None: - raise RenderError(f"{spec.what} pins no checksum: --sha256 is not accepted") + elif spec.needs_arch_shas: + if sha256 is not None: + raise RenderError( + f"{spec.what} pins per-architecture checksums: use --sha256-x86-64 and --sha256-arm64" + ) + if sha256_x86_64 is None or sha256_arm64 is None: + raise RenderError( + f"{spec.what} pins per-architecture checksums: --sha256-x86-64 and --sha256-arm64 are required" + ) + values["SHA256_X86_64"] = validate_sha256(sha256_x86_64) + values["SHA256_ARM64"] = validate_sha256(sha256_arm64) + elif sha256 is not None or sha256_x86_64 is not None or sha256_arm64 is not None: + raise RenderError(f"{spec.what} pins no checksum: sha256 flags are not accepted") text = template if target == "copr": @@ -222,6 +239,7 @@ def render( # -------------------------------------------------------------------------------------- _SHA = "77ccf85843d24ac3216ab31d2584ff4a95869266c59ddb8bc83819425cfc2033" +_SHA_ARM = "88ddf96954e35bd4327bc42e3695005b06970377d60eec9cd94920536dfd3144" def _self_test() -> int: @@ -249,6 +267,20 @@ def refuses(name: str, fn) -> None: check("brew sha", f'sha256 "{_SHA}"' in brew, "rendered formula has no sha256") check("brew no sentinel", not SENTINEL_RE.search(brew), "sentinel survived") + scoop = render("scoop", "1.2.3", sha256_x86_64=_SHA, sha256_arm64=_SHA_ARM) + check("scoop version", '"version": "1.2.3"' in scoop, "rendered manifest has no version 1.2.3") + check( + "scoop x86_64 url", + "/releases/download/v1.2.3/retch-windows-x86_64.exe#/retch.exe" in scoop, + "rendered manifest has no versioned x86_64 url", + ) + check("scoop x86_64 sha", f'"hash": "{_SHA}"' in scoop, "rendered manifest has no x86_64 hash") + check("scoop arm64 sha", f'"hash": "{_SHA_ARM}"' in scoop, "rendered manifest has no arm64 hash") + check("scoop no sentinel", not SENTINEL_RE.search(scoop), "sentinel survived") + # Also ensure the autoupdate $version literal was kept intact. + check("scoop autoupdate kept", "v$version/retch-windows-x86_64.exe#/retch.exe" in scoop, + "autoupdate $version was lost") + copr = render("copr", "1.2.3", stamp="Mon Jan 05 2026") check("copr version", "\nVersion: 1.2.3\n" in copr, "rendered spec has no Version: 1.2.3") check("copr no sentinel", not SENTINEL_RE.search(copr), "sentinel survived") @@ -277,6 +309,9 @@ def refuses(name: str, fn) -> None: refuses("placeholder sha", lambda: render("aur", "1.2.3", "0" * 64)) refuses("sha for copr", lambda: render("copr", "1.2.3", _SHA)) refuses("missing sha", lambda: render("brew", "1.2.3")) + refuses("single sha for scoop", lambda: render("scoop", "1.2.3", _SHA)) + refuses("missing arm64 sha for scoop", lambda: render("scoop", "1.2.3", sha256_x86_64=_SHA)) + refuses("arch sha for brew", lambda: render("brew", "1.2.3", _SHA, sha256_x86_64=_SHA)) refuses("unknown target", lambda: render("nope", "1.2.3")) # A template that has LOST its sentinel must fail rather than render unchanged -- the @@ -317,6 +352,8 @@ def main() -> int: ap.add_argument("--target", choices=sorted(TARGETS), help="which packaging file to render") ap.add_argument("--version", help="released version (default: Cargo.toml's)") ap.add_argument("--sha256", help="sha256 of the release tarball (aur and brew only)") + ap.add_argument("--sha256-x86-64", help="sha256 of retch-windows-x86_64.exe (scoop only)") + ap.add_argument("--sha256-arm64", help="sha256 of retch-windows-arm64.exe (scoop only)") ap.add_argument("--out", help="write here instead of stdout") args = ap.parse_args() @@ -329,7 +366,13 @@ def main() -> int: return 0 if not args.target: ap.error("--target is required (or use --print-version / --self-test)") - text = render(args.target, args.version or repo_version(), args.sha256) + text = render( + args.target, + args.version or repo_version(), + args.sha256, + sha256_x86_64=args.sha256_x86_64, + sha256_arm64=args.sha256_arm64, + ) except RenderError as exc: print(f"error: {exc}", file=sys.stderr) return 1 diff --git a/scripts/scoop_check.py b/scripts/scoop_check.py new file mode 100644 index 00000000..db00dc59 --- /dev/null +++ b/scripts/scoop_check.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 l1a +"""Assert packaging/scoop/retch.json is still a template that records no release. + +WHY THIS EXISTS +--------------- +`packaging/scoop/retch.json` is the Windows Scoop sibling of `packaging/homebrew/retch.rb`, +`packaging/aur/PKGBUILD` and `packaging/copr/retch.spec`. Like them, it records no released +version and no checksum in the repository: `scripts/render_packaging.py` fills in `@VERSION@`, +`@SHA256_X86_64@` and `@SHA256_ARM64@` at publish time (`just scoop-publish `), +computing the hashes from the real GitHub Release `.exe` assets. + +WHAT IT CHECKS +-------------- +All offline, deterministic and free: + + 1. The manifest parses as valid JSON and its top-level `version` is `"@VERSION@"`. + 2. `architecture.64bit` and `architecture.arm64` carry the exact sentinel URLs + (`.../releases/download/v@VERSION@/retch-windows-{x86_64,arm64}.exe#/retch.exe`) + and sentinel hashes (`"@SHA256_X86_64@"` and `"@SHA256_ARM64@"`). The `#/retch.exe` + fragment is load-bearing: Scoop uses it to rename the downloaded architecture-suffixed + binary to `retch.exe` so `"bin": "retch.exe"` works on both architectures. + 3. `"bin"` is `"retch.exe"`, `"checkver"` is `"github"`, and `"autoupdate"` carries the + matching `v$version` URLs for `64bit` and `arm64`. + 4. Nothing anywhere in the file records a literal semver version or 64-hex sha256 digest. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +TEMPLATE_VERSION = 1 + +REPO = Path(__file__).resolve().parent.parent +MANIFEST = REPO / "packaging" / "scoop" / "retch.json" + +EXPECTED_URL_X64 = ( + "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-x86_64.exe#/retch.exe" +) +EXPECTED_URL_ARM64 = ( + "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-arm64.exe#/retch.exe" +) +EXPECTED_AUTO_X64 = ( + "https://github.com/l1a/retch/releases/download/v$version/retch-windows-x86_64.exe#/retch.exe" +) +EXPECTED_AUTO_ARM64 = ( + "https://github.com/l1a/retch/releases/download/v$version/retch-windows-arm64.exe#/retch.exe" +) + +SHA256_RE = re.compile(r"\b[0-9a-fA-F]{64}\b") +VERSIONISH_RE = re.compile(r"\b[0-9]+\.[0-9]+\.[0-9]+\b") + + +def check_template(text: str) -> list[str]: + """Return a list of problems; empty means the Scoop manifest is still a valid template.""" + problems: list[str] = [] + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + return [f"invalid JSON: {exc}"] + + if not isinstance(data, dict): + return ["manifest root must be a JSON object"] + + if data.get("version") != "@VERSION@": + problems.append( + f"version is {data.get('version')!r}, not '@VERSION@' — this manifest is a template; " + "scripts/render_packaging.py fills the version in at publish time" + ) + + arch = data.get("architecture") + if not isinstance(arch, dict): + problems.append("missing `architecture` object") + else: + x64 = arch.get("64bit") if isinstance(arch.get("64bit"), dict) else {} + arm = arch.get("arm64") if isinstance(arch.get("arm64"), dict) else {} + if x64.get("url") != EXPECTED_URL_X64: + problems.append(f"architecture.64bit.url is {x64.get('url')!r}, not {EXPECTED_URL_X64!r}") + if x64.get("hash") != "@SHA256_X86_64@": + problems.append( + f"architecture.64bit.hash is {x64.get('hash')!r}, not '@SHA256_X86_64@'" + ) + if arm.get("url") != EXPECTED_URL_ARM64: + problems.append(f"architecture.arm64.url is {arm.get('url')!r}, not {EXPECTED_URL_ARM64!r}") + if arm.get("hash") != "@SHA256_ARM64@": + problems.append( + f"architecture.arm64.hash is {arm.get('hash')!r}, not '@SHA256_ARM64@'" + ) + + if data.get("bin") != "retch.exe": + problems.append(f"bin is {data.get('bin')!r}, not 'retch.exe'") + + if data.get("checkver") != "github": + problems.append(f"checkver is {data.get('checkver')!r}, not 'github'") + + autoupdate = data.get("autoupdate") + if not isinstance(autoupdate, dict) or not isinstance(autoupdate.get("architecture"), dict): + problems.append("missing `autoupdate.architecture` object") + else: + auto_arch = autoupdate["architecture"] + auto_x64 = auto_arch.get("64bit") if isinstance(auto_arch.get("64bit"), dict) else {} + auto_arm = auto_arch.get("arm64") if isinstance(auto_arch.get("arm64"), dict) else {} + if auto_x64.get("url") != EXPECTED_AUTO_X64: + problems.append( + f"autoupdate.architecture.64bit.url is {auto_x64.get('url')!r}, not {EXPECTED_AUTO_X64!r}" + ) + if auto_arm.get("url") != EXPECTED_AUTO_ARM64: + problems.append( + f"autoupdate.architecture.arm64.url is {auto_arm.get('url')!r}, not {EXPECTED_AUTO_ARM64!r}" + ) + + for line in text.splitlines(): + for regex, what in ((SHA256_RE, "a sha256 digest"), (VERSIONISH_RE, "a version number")): + m = regex.search(line) + if m: + problems.append(f"manifest records {what} ({m.group(0)}): {line.strip()!r}") + + return problems + + +# ── self-test ──────────────────────────────────────────────────────────────── + +_GOOD_MANIFEST = """\ +{ + "version": "@VERSION@", + "description": "A fast, feature-rich system information fetcher written in Rust", + "homepage": "https://github.com/l1a/retch", + "license": "GPL-3.0-or-later", + "architecture": { + "64bit": { + "url": "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-x86_64.exe#/retch.exe", + "hash": "@SHA256_X86_64@" + }, + "arm64": { + "url": "https://github.com/l1a/retch/releases/download/v@VERSION@/retch-windows-arm64.exe#/retch.exe", + "hash": "@SHA256_ARM64@" + } + }, + "bin": "retch.exe", + "checkver": "github", + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/l1a/retch/releases/download/v$version/retch-windows-x86_64.exe#/retch.exe" + }, + "arm64": { + "url": "https://github.com/l1a/retch/releases/download/v$version/retch-windows-arm64.exe#/retch.exe" + } + } + } +} +""" + +_REAL_SHA = "77ccf85843d24ac3216ab31d2584ff4a95869266c59ddb8bc83819425cfc2033" + + +def _self_test() -> int: + failures: list[str] = [] + + def check(name: str, cond: bool, detail: str = "") -> None: + if not cond: + failures.append(f"{name}: {detail}") + + def expect_problem(label: str, text: str, needle: str) -> None: + probs = check_template(text) + check(label, any(needle in p for p in probs), f"got {probs}") + + clean = check_template(_GOOD_MANIFEST) + check("template fixture clean", clean == [], f"got {clean}") + + if MANIFEST.is_file(): + live_problems = check_template(MANIFEST.read_text(encoding="utf-8")) + check("live manifest clean", live_problems == [], f"got {live_problems}") + + expect_problem("invalid json detected", "{not json", "invalid JSON") + expect_problem( + "pinned version detected", + _GOOD_MANIFEST.replace('"@VERSION@"', '"0.20.28"'), + "version is", + ) + expect_problem( + "pinned x86_64 hash detected", + _GOOD_MANIFEST.replace('"@SHA256_X86_64@"', f'"{_REAL_SHA}"'), + "architecture.64bit.hash is", + ) + expect_problem( + "pinned arm64 hash detected", + _GOOD_MANIFEST.replace('"@SHA256_ARM64@"', f'"{_REAL_SHA}"'), + "architecture.arm64.hash is", + ) + expect_problem( + "missing #/retch.exe fragment detected", + _GOOD_MANIFEST.replace("retch-windows-x86_64.exe#/retch.exe", "retch-windows-x86_64.exe"), + "architecture.64bit.url is", + ) + expect_problem( + "wrong bin detected", + _GOOD_MANIFEST.replace('"bin": "retch.exe"', '"bin": "retch-windows-x86_64.exe"'), + "bin is", + ) + expect_problem( + "broken autoupdate detected", + _GOOD_MANIFEST.replace("v$version/retch-windows-arm64.exe", "v0.20.28/retch-windows-arm64.exe"), + "autoupdate.architecture.arm64.url is", + ) + + if failures: + for f in failures: + print(f" FAIL {f}", file=sys.stderr) + print(f"scoop_check.py self-test FAILED ({len(failures)})", file=sys.stderr) + return 1 + print(f"scoop_check.py self-test passed (template v{TEMPLATE_VERSION})") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--self-test", action="store_true", help="run built-in tests and exit") + args = ap.parse_args() + if args.self_test: + return _self_test() + + if not MANIFEST.is_file(): + print(f"error: {MANIFEST} does not exist", file=sys.stderr) + return 1 + problems = check_template(MANIFEST.read_text(encoding="utf-8")) + if problems: + for p in problems: + print(f"[scoop-check] {p}", file=sys.stderr) + return 1 + print("[scoop-check] retch.json is a template (records no version, no checksum)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())