From 453b5c29a56d1096f67589db3d3408726ab1b4cc Mon Sep 17 00:00:00 2001 From: 4fu Date: Mon, 21 Sep 2026 05:29:50 +0000 Subject: [PATCH 1/3] feat(scoop): Add an in-repository bucket and installation guide Install the stable Windows x64 and ARM64 release archives through Scoop with pinned SHA-256 checksums and SHA256SUMS-based autoupdate hash extraction. Document installation, Scoop-managed upgrades, and manifest maintenance in the current README, mirroring the Homebrew tap. Validated the manifest JSON with jq, both hash extraction patterns against the published v0.1.20 SHA256SUMS, and a full download of termgram-0.1.20-windows.zip whose SHA-256 matches the pinned value. Amp-Thread-ID: https://ampcode.com/threads/T-01a0c24b-d4c2-707c-86c1-da9f060dd6b7 Co-authored-by: Amp --- README.md | 33 +++++++++++++++++++++++++++++++-- bucket/termgram.json | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 bucket/termgram.json diff --git a/README.md b/README.md index 65b8d92..cbaba67 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,19 @@ followed by `brew upgrade iebb/termgram/termgram`; uninstall with `brew uninstall termgram`. If you previously installed `tg` manually, check `which tg`: a copy in `~/bin` or `~/.local/bin` may take precedence in `PATH`. +Scoop (64-bit / ARM64 Windows): + +```powershell +scoop bucket add termgram https://github.com/iebb/termgram.git +scoop install termgram +``` + +The [Scoop manifest](bucket/termgram.json) lives in this repository and installs +the prebuilt stable release for your Windows architecture with SHA-256 +verification. No separate bucket repository or Rust compiler is needed. Update +with `scoop update` followed by `scoop update termgram`; uninstall with +`scoop uninstall termgram`. + Standalone installer (Linux / macOS): ```sh @@ -53,8 +66,9 @@ cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg t Run `tg`, sign in with your phone or press Tab for QR login. Open a chat with Enter, press `i` to compose, and `?` for help. Repeat the Cargo command to update a source install; use `tg update` for standalone release binaries. Use -`brew upgrade iebb/termgram/termgram` for Homebrew installations so Homebrew -can track the installed version. +`brew upgrade iebb/termgram/termgram` for Homebrew installations and +`scoop update termgram` for Scoop installations so the package manager can +track the installed version. Releases support Linux x86_64/ARM64, macOS Intel/Apple silicon, and Windows x64/ARM64. See [installation and source builds](docs/wiki/en/Getting-Started.md) @@ -98,3 +112,18 @@ brew test iebb/termgram/termgram The formula tests version output, help, and invalid-argument handling without signing in to Telegram. + +### Maintaining Scoop + +Update the manifest together with the formula. Set `version` to the stable +release version and update both archive URLs and their SHA-256 values in +`bucket/termgram.json` from the release's `SHA256SUMS`. The `64bit` entry uses +the unqualified `windows` archive; `arm64` uses `windows-aarch64`. Commit the +manifest update here so `scoop update` can pick it up. The release workflow +does not update the manifest automatically, and Scoop follows the stable +version pinned in the manifest rather than prereleases. + +Validate the JSON with `jq --exit-status . bucket/termgram.json`. To test local +edits, add this repository as a Scoop bucket in a checkout where the change is +on disk, then run `scoop install termgram`; the manifest's `checkver` and +`autoupdate` templates must resolve the same assets. diff --git a/bucket/termgram.json b/bucket/termgram.json new file mode 100644 index 0000000..457665f --- /dev/null +++ b/bucket/termgram.json @@ -0,0 +1,41 @@ +{ + "version": "0.1.20", + "description": "A focused, keyboard-first Telegram client for the terminal", + "homepage": "https://github.com/iebb/termgram", + "license": "MIT", + "notes": "Official release binaries include the project's Telegram application credentials; sign in with your Telegram account after starting tg.", + "architecture": { + "64bit": { + "url": "https://github.com/iebb/termgram/releases/download/v0.1.20/termgram-0.1.20-windows.zip", + "hash": "7951980922ac401d517e943966c7db234330ba3208d7124fb70b8d6e38656224" + }, + "arm64": { + "url": "https://github.com/iebb/termgram/releases/download/v0.1.20/termgram-0.1.20-windows-aarch64.zip", + "hash": "f81f52bc0c443263bdb51fa886b0b5bce09f5da6d9ba088b41d155f2f8b251bb" + } + }, + "bin": "tg.exe", + "checkver": { + "github": "https://github.com/iebb/termgram" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/iebb/termgram/releases/download/v$version/termgram-$version-windows.zip", + "hash": { + "mode": "extract", + "url": "https://github.com/iebb/termgram/releases/download/v$version/SHA256SUMS", + "find": "([a-fA-F0-9]{64})\\s+\\*?termgram-$version-windows\\.zip" + } + }, + "arm64": { + "url": "https://github.com/iebb/termgram/releases/download/v$version/termgram-$version-windows-aarch64.zip", + "hash": { + "mode": "extract", + "url": "https://github.com/iebb/termgram/releases/download/v$version/SHA256SUMS", + "find": "([a-fA-F0-9]{64})\\s+\\*?termgram-$version-windows-aarch64\\.zip" + } + } + } + } +} From 67d388c88bd99db12df0b32065ff7588804dddd0 Mon Sep 17 00:00:00 2001 From: 4fu Date: Mon, 21 Sep 2026 05:43:49 +0000 Subject: [PATCH 2/3] build(release): Generate package-manager metadata at release time Regenerate Formula/termgram.rb and bucket/termgram.json from the release's SHA256SUMS after publishing the latest stable release in a plan, commit the metadata with a chore(release) subject, and push it to the default branch. Prerelease-only plans leave the pinned versions untouched, and an unchanged manifest skips the commit so idempotent republishes stay silent. Remove the now-redundant Maintaining Homebrew and Maintaining Scoop README sections, and match the manifest description with the formula's. Validated the workflow YAML and its shell syntax, reproduced the committed formula byte-for-byte from the published v0.1.20 SHA256SUMS, verified both autoupdate hash-extraction patterns against those checksums, and simulated the update flow for the pinned and a next version. Amp-Thread-ID: https://ampcode.com/threads/T-01a0c24b-d4c2-707c-86c1-da9f060dd6b7 Co-authored-by: Amp --- .github/workflows/release.yml | 29 +++++ README.md | 35 ------ bucket/termgram.json | 2 +- scripts/update-package-metadata.py | 180 +++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 scripts/update-package-metadata.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8363a91..df3a3fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,11 +154,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} TERMGRAM_RELEASE_SSH_KEY: ${{ secrets.TERMGRAM_RELEASE_SSH_KEY }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail key_path= known_hosts= + stable_version= cleanup() { [[ -z "$key_path" ]] || rm -f -- "$key_path" [[ -z "$known_hosts" ]] || rm -f -- "$known_hosts" @@ -285,6 +288,7 @@ jobs: prerelease=false title="Termgram $version" latest=legacy + stable_version="$version" fi asset_dir="prepared/$version" @@ -347,3 +351,28 @@ jobs: -F draft=false -F prerelease="$prerelease" \ -f make_latest="$latest" -f name="$title" >/dev/null done < metadata/release-plan.tsv + + # Regenerate the package-manager metadata for the latest stable + # release in this plan. Prerelease-only plans leave the pinned + # versions untouched, and an unchanged manifest skips the commit. + if [[ -n "$stable_version" ]]; then + python3 scripts/update-package-metadata.py \ + "$stable_version" "prepared/$stable_version/SHA256SUMS" + jq --exit-status . bucket/termgram.json >/dev/null + git add Formula/termgram.rb bucket/termgram.json + if ! git diff --cached --quiet; then + test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + git fetch origin "$DEFAULT_BRANCH" + actual_sha="$(git rev-parse "origin/$DEFAULT_BRANCH")" + [[ "$actual_sha" == "$SOURCE_SHA" ]] || { + echo "$DEFAULT_BRANCH moved from $SOURCE_SHA to $actual_sha during the release" >&2 + exit 1 + } + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore(release): update package metadata for v$stable_version" + [[ -n "$key_path" ]] || setup_release_ssh + GIT_SSH_COMMAND="ssh -i $key_path -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$known_hosts" \ + git push "git@github.com:$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$DEFAULT_BRANCH" + fi + fi diff --git a/README.md b/README.md index cbaba67..e605659 100644 --- a/README.md +++ b/README.md @@ -92,38 +92,3 @@ See [development and architecture](docs/wiki/en/Development.md), The [Wiki source](docs/wiki/README.md) is reviewed alongside code changes and provides English and Simplified Chinese pages. Local working plans live in the Git-ignored `dev-notes/` directory. - -### Maintaining Homebrew - -After publishing a stable release, update the version, all four archive URLs, -and their SHA-256 values in `Formula/termgram.rb` from the release's -`SHA256SUMS`. The unqualified `macos` archive is Apple silicon; `linux` is -x86_64. Commit the formula update here so `brew update` can pick it up. The -release workflow does not update the formula automatically, and Homebrew -follows the stable version pinned in the formula rather than prereleases. - -Run `brew style Formula/termgram.rb`. To test local edits, copy the formula to -`$(brew --repository iebb/termgram)/Formula/termgram.rb`, then run: - -```sh -brew reinstall iebb/termgram/termgram -brew test iebb/termgram/termgram -``` - -The formula tests version output, help, and invalid-argument handling without -signing in to Telegram. - -### Maintaining Scoop - -Update the manifest together with the formula. Set `version` to the stable -release version and update both archive URLs and their SHA-256 values in -`bucket/termgram.json` from the release's `SHA256SUMS`. The `64bit` entry uses -the unqualified `windows` archive; `arm64` uses `windows-aarch64`. Commit the -manifest update here so `scoop update` can pick it up. The release workflow -does not update the manifest automatically, and Scoop follows the stable -version pinned in the manifest rather than prereleases. - -Validate the JSON with `jq --exit-status . bucket/termgram.json`. To test local -edits, add this repository as a Scoop bucket in a checkout where the change is -on disk, then run `scoop install termgram`; the manifest's `checkver` and -`autoupdate` templates must resolve the same assets. diff --git a/bucket/termgram.json b/bucket/termgram.json index 457665f..dbf5ce8 100644 --- a/bucket/termgram.json +++ b/bucket/termgram.json @@ -1,6 +1,6 @@ { "version": "0.1.20", - "description": "A focused, keyboard-first Telegram client for the terminal", + "description": "Focused, keyboard-first Telegram client for the terminal", "homepage": "https://github.com/iebb/termgram", "license": "MIT", "notes": "Official release binaries include the project's Telegram application credentials; sign in with your Telegram account after starting tg.", diff --git a/scripts/update-package-metadata.py b/scripts/update-package-metadata.py new file mode 100644 index 0000000..de9c87e --- /dev/null +++ b/scripts/update-package-metadata.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Regenerate the Homebrew formula and the Scoop manifest for one stable release. + +Run from the repository root after the release assets and their SHA256SUMS are +prepared. The generated files must match the formats reviewed in the repository; +a stable release is required because Homebrew and Scoop follow stable versions. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "https://github.com/iebb/termgram" +DESCRIPTION = "Focused, keyboard-first Telegram client for the terminal" + +TARGETS = { + "mac_arm": ("macos", "tar.gz"), + "mac_intel": ("macos-x86_64", "tar.gz"), + "linux_arm": ("linux-aarch64", "tar.gz"), + "linux_intel": ("linux", "tar.gz"), + "windows_intel": ("windows", "zip"), + "windows_arm": ("windows-aarch64", "zip"), +} + + +def asset_name(version: str, target: str, extension: str) -> str: + return f"termgram-{version}-{target}.{extension}" + + +def read_checksums(version: str, path: Path) -> dict[str, str]: + checksums: dict[str, str] = {} + for line in path.read_text().splitlines(): + fields = line.split() + if len(fields) != 2 or not re.fullmatch(r"[0-9a-fA-F]{64}", fields[0]): + raise SystemExit(f"invalid checksum line: {line!r}") + filename = fields[1].removeprefix("*") + if filename in checksums: + raise SystemExit(f"duplicate checksum for {filename}") + checksums[filename] = fields[0].lower() + + expected = {asset_name(version, target, extension) for target, extension in TARGETS.values()} + missing = sorted(expected - checksums.keys()) + if missing: + raise SystemExit(f"SHA256SUMS does not cover every release archive: missing={missing}") + return checksums + + +def release_url(version: str, target: str, extension: str) -> str: + asset = asset_name(version, target, extension) + return f"{REPOSITORY}/releases/download/v{version}/{asset}" + + +def formula(version: str, checksums: dict[str, str]) -> str: + def source(key: str) -> tuple[str, str]: + target, extension = TARGETS[key] + asset = asset_name(version, target, extension) + return release_url(version, target, extension), checksums[asset] + + mac_arm_url, mac_arm_sha = source("mac_arm") + mac_intel_url, mac_intel_sha = source("mac_intel") + linux_arm_url, linux_arm_sha = source("linux_arm") + linux_intel_url, linux_intel_sha = source("linux_intel") + return f'''class Termgram < Formula + desc "{DESCRIPTION}" + homepage "{REPOSITORY}" + version "{version}" + license "MIT" + + on_macos do + on_arm do + url "{mac_arm_url}" + sha256 "{mac_arm_sha}" + end + on_intel do + url "{mac_intel_url}" + sha256 "{mac_intel_sha}" + end + end + + on_linux do + on_arm do + url "{linux_arm_url}" + sha256 "{linux_arm_sha}" + end + on_intel do + url "{linux_intel_url}" + sha256 "{linux_intel_sha}" + end + end + + def install + bin.install "tg" + end + + def caveats + <<~EOS + Run `tg` to launch Termgram. + Update this installation with `brew upgrade termgram` instead of `tg update` + so Homebrew can track the installed version. + EOS + end + + test do + assert_match(/^(?:tg|version)\\s+#{{Regexp.escape(version.to_s)}}$/, shell_output("#{{bin}}/tg --version")) + assert_match "Open the TUI", shell_output("#{{bin}}/tg --help") + assert_match "unknown argument", shell_output("#{{bin}}/tg invalid-command 2>&1", 1) + end +end +''' + + +def checksum_extractor(version: str, target: str, extension: str) -> dict[str, object]: + asset = asset_name(version, target, extension) + return { + "mode": "extract", + "url": f"{REPOSITORY}/releases/download/v{version}/SHA256SUMS", + "find": rf"([a-fA-F0-9]{{64}})\s+\*?{asset}".replace(".", r"\."), + } + + +def scoop_manifest(version: str, checksums: dict[str, str]) -> dict[str, object]: + def entry(key: str) -> dict[str, object]: + target, extension = TARGETS[key] + asset = asset_name(version, target, extension) + return { + "url": release_url(version, target, extension), + "hash": checksums[asset], + } + + def autoupdate_entry(key: str) -> dict[str, object]: + target, extension = TARGETS[key] + asset = asset_name(version, target, extension) + return { + "url": f"{REPOSITORY}/releases/download/v$version/{asset.replace(version, '$version')}", + "hash": checksum_extractor("$version", target, extension), + } + + return { + "version": version, + "description": DESCRIPTION, + "homepage": REPOSITORY, + "license": "MIT", + "notes": "Official release binaries include the project's Telegram application credentials; sign in with your Telegram account after starting tg.", + "architecture": { + "64bit": entry("windows_intel"), + "arm64": entry("windows_arm"), + }, + "bin": "tg.exe", + "checkver": {"github": REPOSITORY}, + "autoupdate": { + "architecture": { + "64bit": autoupdate_entry("windows_intel"), + "arm64": autoupdate_entry("windows_arm"), + } + }, + } + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit(f"usage: {Path(sys.argv[0]).name} ") + version = sys.argv[1] + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise SystemExit("version must contain three numeric components") + checksums = read_checksums(version, Path(sys.argv[2])) + + (ROOT / "Formula").mkdir(exist_ok=True) + (ROOT / "bucket").mkdir(exist_ok=True) + (ROOT / "Formula/termgram.rb").write_text(formula(version, checksums)) + (ROOT / "bucket/termgram.json").write_text( + json.dumps(scoop_manifest(version, checksums), indent=4) + "\n" + ) + + +if __name__ == "__main__": + main() From 040145d77dea17f26344ef42a2c2bdcf1716b78a Mon Sep 17 00:00:00 2001 From: ieb^ Date: Tue, 22 Sep 2026 15:03:13 +0900 Subject: [PATCH 3/3] fix(release): Reconcile metadata from published stable assets Read checksums from the latest published stable release independently of this CI run's release plan. Rebuilt archives can differ from bytes already published by an overlapping run, and later plans omit published versions. Apply generated metadata on the current default branch in an isolated worktree, retry ordinary pushes when the branch advances, and skip commits when the remote already contains the expected metadata. This also repairs missed updates during prerelease-only or empty plans. Add CI regression coverage for published checksums, reruns, missing stable releases, and concurrent branch updates. Validate shell/workflow syntax and reproduce the pinned manifests using the published v0.1.20 checksums. --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 46 ++++------ scripts/publish-package-metadata.sh | 65 ++++++++++++++ scripts/test-package-metadata.py | 132 ++++++++++++++++++++++++++++ scripts/update-package-metadata.py | 25 +++--- 5 files changed, 231 insertions(+), 39 deletions(-) create mode 100644 scripts/publish-package-metadata.sh create mode 100644 scripts/test-package-metadata.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 751e351..c3f0578 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: - name: Check installer scripts shell: bash run: bash -n scripts/install.sh + - name: Verify package metadata publishing + run: python3 scripts/test-package-metadata.py - name: Validate bilingual Wiki sources run: python3 scripts/export-wiki.py --check - name: Parse PowerShell installer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df3a3fb..938dd4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,14 +154,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} TERMGRAM_RELEASE_SSH_KEY: ${{ secrets.TERMGRAM_RELEASE_SSH_KEY }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} run: | set -euo pipefail key_path= known_hosts= - stable_version= cleanup() { [[ -z "$key_path" ]] || rm -f -- "$key_path" [[ -z "$known_hosts" ]] || rm -f -- "$known_hosts" @@ -288,7 +285,6 @@ jobs: prerelease=false title="Termgram $version" latest=legacy - stable_version="$version" fi asset_dir="prepared/$version" @@ -352,27 +348,21 @@ jobs: -f make_latest="$latest" -f name="$title" >/dev/null done < metadata/release-plan.tsv - # Regenerate the package-manager metadata for the latest stable - # release in this plan. Prerelease-only plans leave the pinned - # versions untouched, and an unchanged manifest skips the commit. - if [[ -n "$stable_version" ]]; then - python3 scripts/update-package-metadata.py \ - "$stable_version" "prepared/$stable_version/SHA256SUMS" - jq --exit-status . bucket/termgram.json >/dev/null - git add Formula/termgram.rb bucket/termgram.json - if ! git diff --cached --quiet; then - test "$(git rev-parse HEAD)" = "$SOURCE_SHA" - git fetch origin "$DEFAULT_BRANCH" - actual_sha="$(git rev-parse "origin/$DEFAULT_BRANCH")" - [[ "$actual_sha" == "$SOURCE_SHA" ]] || { - echo "$DEFAULT_BRANCH moved from $SOURCE_SHA to $actual_sha during the release" >&2 - exit 1 - } - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(release): update package metadata for v$stable_version" - [[ -n "$key_path" ]] || setup_release_ssh - GIT_SSH_COMMAND="ssh -i $key_path -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$known_hosts" \ - git push "git@github.com:$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$DEFAULT_BRANCH" - fi - fi + # Reconcile even when this CI plan contains only prereleases or is empty: + # a previous publisher may have released successfully before its push failed. + - name: Update package metadata from the latest published stable release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TERMGRAM_RELEASE_SSH_KEY: ${{ secrets.TERMGRAM_RELEASE_SSH_KEY }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + umask 077 + key_path="$(mktemp)" + known_hosts="$(mktemp)" + trap 'rm -f -- "$key_path" "$known_hosts"' EXIT + printf '%s\n' "$TERMGRAM_RELEASE_SSH_KEY" > "$key_path" + gh api meta --jq '.ssh_keys[] | "github.com " + .' > "$known_hosts" + export GIT_SSH_COMMAND="ssh -i $key_path -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$known_hosts" + bash scripts/publish-package-metadata.sh diff --git a/scripts/publish-package-metadata.sh b/scripts/publish-package-metadata.sh new file mode 100644 index 0000000..937ec27 --- /dev/null +++ b/scripts/publish-package-metadata.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run after publishing, including plans without a new stable release. +set -euo pipefail + +: "${GITHUB_REPOSITORY:?}" +: "${DEFAULT_BRANCH:?}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +metadata_dir="$(mktemp -d)" +# shellcheck disable=SC2329 # Invoked by the EXIT trap. +cleanup() { + if [[ -d "$metadata_dir/checkout" ]]; then + git worktree remove --force "$metadata_dir/checkout" + fi + rm -rf -- "$metadata_dir" +} +trap 'cleanup' EXIT + +# Already-published versions are omitted from later CI plans. Read releases +# independently so a failed metadata push can be repaired by any successful run. +gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' \ + > "$metadata_dir/stable-tags" +tag="$(sort -V "$metadata_dir/stable-tags" | tail -n 1)" +if [[ -z "$tag" ]]; then + echo "No published stable release; leaving package metadata unchanged." + exit 0 +fi +[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + +# Two CI runs can rebuild the same version with different archive timestamps. +# Only the checksums attached to the published release describe installed bytes. +gh release download "$tag" --repo "$GITHUB_REPOSITORY" \ + --pattern SHA256SUMS --dir "$metadata_dir" +python3 "$script_dir/update-package-metadata.py" \ + "${tag#v}" "$metadata_dir/SHA256SUMS" --output-dir "$metadata_dir/generated" + +for attempt in 1 2 3; do + git fetch origin "refs/heads/$DEFAULT_BRANCH" + base_sha="$(git rev-parse FETCH_HEAD)" + if [[ -d "$metadata_dir/checkout" ]]; then + git -C "$metadata_dir/checkout" reset --hard "$base_sha" + else + git worktree add --detach "$metadata_dir/checkout" "$base_sha" + fi + mkdir -p "$metadata_dir/checkout/Formula" "$metadata_dir/checkout/bucket" + cp "$metadata_dir/generated/Formula/termgram.rb" "$metadata_dir/checkout/Formula/termgram.rb" + cp "$metadata_dir/generated/bucket/termgram.json" "$metadata_dir/checkout/bucket/termgram.json" + git -C "$metadata_dir/checkout" add Formula/termgram.rb bucket/termgram.json + if git -C "$metadata_dir/checkout" diff --cached --quiet; then + echo "Package metadata already matches $tag." + exit 0 + fi + git -C "$metadata_dir/checkout" \ + -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "chore(release): Update package metadata for $tag" + if git -C "$metadata_dir/checkout" push \ + "git@github.com:$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$DEFAULT_BRANCH"; then + exit 0 + fi + echo "Metadata push attempt $attempt failed; retrying from the current branch." >&2 +done + +echo "Could not update package metadata after three attempts." >&2 +exit 1 diff --git a/scripts/test-package-metadata.py b/scripts/test-package-metadata.py new file mode 100644 index 0000000..2d50873 --- /dev/null +++ b/scripts/test-package-metadata.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Exercise metadata publishing against local Git remotes and release fixtures.""" + +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name("publish-package-metadata.sh").resolve() +PLATFORMS = ("linux.tar.gz", "linux-aarch64.tar.gz", "macos.tar.gz", + "macos-x86_64.tar.gz", "windows.zip", "windows-aarch64.zip") + + +class PackageMetadataTests(unittest.TestCase): + def run_command(self, *args, cwd=None): + return subprocess.run(args, cwd=cwd or self.repo, env=self.env, + text=True, capture_output=True, check=True).stdout.strip() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.repo = self.root / "source" + self.remote = self.root / "origin.git" + self.env = os.environ | {"GITHUB_REPOSITORY": "fixture/termgram", "DEFAULT_BRANCH": "main"} + self.run_command("git", "init", "--bare", "--initial-branch=main", str(self.remote), cwd=self.root) + self.run_command("git", "clone", str(self.remote), str(self.repo), cwd=self.root) + self.run_command("git", "config", "user.name", "Metadata test") + self.run_command("git", "config", "user.email", "test@example.invalid") + self.run_command("git", "config", f"url.{self.remote}.insteadOf", "git@github.com:fixture/termgram.git") + (self.repo / "README.md").write_text("source commit\n") + self.run_command("git", "add", ".") + self.run_command("git", "commit", "-m", "release: Publish fixture") + self.run_command("git", "push", "origin", "main") + self.source_sha = self.run_command("git", "rev-parse", "HEAD") + + # Published archives differ from the same version rebuilt by another CI run. + self.hashes = {f"termgram-0.1.21-{platform}": hashlib.sha256(platform.encode()).hexdigest() + for platform in PLATFORMS} + checksums = "".join(f"{digest} {name}\n" for name, digest in self.hashes.items()) + (self.root / "SHA256SUMS").write_text(checksums) + rebuilt = self.repo / "prepared/0.1.21" + rebuilt.mkdir(parents=True) + (rebuilt / "SHA256SUMS").write_text("".join(f"{'0' * 64} {name}\n" for name in self.hashes)) + self.releases = self.root / "releases.json" + self.releases.write_text(json.dumps([ + {"tag_name": "v0.1.9", "draft": False, "prerelease": False}, + {"tag_name": "v0.1.21", "draft": False, "prerelease": False}, + {"tag_name": "v0.1.30", "draft": False, "prerelease": True}, + {"tag_name": "v0.1.40", "draft": True, "prerelease": False}, + ])) + commands = self.root / "bin" + commands.mkdir() + gh = commands / "gh" + gh.write_text('''#!/usr/bin/env python3 +import os, pathlib, shutil, subprocess, sys +args = sys.argv[1:] +root = pathlib.Path(os.environ["RELEASE_FIXTURE"]) +if args[0] == "api": + subprocess.run(["jq", "-r", args[args.index("--jq") + 1], str(root / "releases.json")], check=True) +elif args[:3] == ["release", "download", "v0.1.21"]: + assert args[args.index("--pattern") + 1] == "SHA256SUMS" + shutil.copyfile(root / "SHA256SUMS", pathlib.Path(args[args.index("--dir") + 1]) / "SHA256SUMS") +else: + raise SystemExit(f"unexpected gh call: {args}") +''') + gh.chmod(0o755) + self.env |= {"PATH": str(commands) + os.pathsep + self.env["PATH"], "RELEASE_FIXTURE": str(self.root)} + + def remote_head(self): + return self.run_command("git", "--git-dir", str(self.remote), "rev-parse", "main") + + def remote_file(self, name): + return self.run_command("git", "--git-dir", str(self.remote), "show", f"main:{name}") + + def test_published_hashes_repair_prerelease_plan_and_rerun_is_noop(self): + metadata = self.repo / "metadata" + metadata.mkdir() + (metadata / "release-plan.tsv").write_text(f"{self.source_sha}\t30\t0.1.30\n") + self.run_command("bash", str(SCRIPT)) + manifest = json.loads(self.remote_file("bucket/termgram.json")) + self.assertEqual(manifest["version"], "0.1.21") + self.assertEqual(manifest["architecture"]["64bit"]["hash"], self.hashes["termgram-0.1.21-windows.zip"]) + formula = self.remote_file("Formula/termgram.rb") + self.assertIn(self.hashes["termgram-0.1.21-linux.tar.gz"], formula) + first_head = self.remote_head() + # A rerun still starts at the tested source SHA, before the metadata commit. + self.assertEqual(self.run_command("git", "rev-parse", "HEAD"), self.source_sha) + (metadata / "release-plan.tsv").write_text("") + self.run_command("bash", str(SCRIPT)) + self.assertEqual(self.remote_head(), first_head) + + def test_new_main_commits_survive_initial_fetch_and_push_race(self): + (self.repo / "new-main.txt").write_text("new main work\n") + self.run_command("git", "add", ".") + self.run_command("git", "commit", "-m", "fix: Advance main before metadata") + self.run_command("git", "push", "origin", "main") + (self.repo / "push-race.txt").write_text("concurrent work\n") + self.run_command("git", "add", ".") + self.run_command("git", "commit", "-m", "fix: Advance main during metadata push") + racing_sha = self.run_command("git", "rev-parse", "HEAD") + self.run_command("git", "push", "origin", "HEAD:refs/heads/race-fixture") + self.run_command("git", "checkout", "--detach", self.source_sha) + hook = self.repo / ".git/hooks/pre-push" + hook.write_text('''#!/usr/bin/env python3 +import os, pathlib, subprocess +root = pathlib.Path(os.environ["RELEASE_FIXTURE"]) +marker = root / "push-raced" +if not marker.exists(): + marker.touch() + subprocess.run(["git", "--git-dir", str(root / "origin.git"), "update-ref", "refs/heads/main", os.environ["RACING_SHA"]], check=True) +''') + hook.chmod(0o755) + self.env["RACING_SHA"] = racing_sha + self.run_command("bash", str(SCRIPT)) + self.assertTrue((self.root / "push-raced").exists()) + self.assertEqual(self.remote_file("new-main.txt"), "new main work") + self.assertEqual(self.remote_file("push-race.txt"), "concurrent work") + self.assertEqual(self.run_command("git", "--git-dir", str(self.remote), "rev-parse", "main^"), racing_sha) + self.assertEqual(json.loads(self.remote_file("bucket/termgram.json"))["version"], "0.1.21") + + def test_no_stable_release_does_not_commit(self): + self.releases.write_text("[]") + self.run_command("bash", str(SCRIPT)) + self.assertEqual(self.remote_head(), self.source_sha) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/update-package-metadata.py b/scripts/update-package-metadata.py index de9c87e..24be195 100644 --- a/scripts/update-package-metadata.py +++ b/scripts/update-package-metadata.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """Regenerate the Homebrew formula and the Scoop manifest for one stable release. -Run from the repository root after the release assets and their SHA256SUMS are -prepared. The generated files must match the formats reviewed in the repository; +Use the SHA256SUMS downloaded from the published release, never a local rebuild. +The generated files must match the formats reviewed in the repository; a stable release is required because Homebrew and Scoop follow stable versions. """ from __future__ import annotations +import argparse import json import re -import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -161,17 +161,20 @@ def autoupdate_entry(key: str) -> dict[str, object]: def main() -> None: - if len(sys.argv) != 3: - raise SystemExit(f"usage: {Path(sys.argv[0]).name} ") - version = sys.argv[1] + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version") + parser.add_argument("checksums", type=Path) + parser.add_argument("--output-dir", type=Path, default=ROOT) + args = parser.parse_args() + version = args.version if not re.fullmatch(r"\d+\.\d+\.\d+", version): raise SystemExit("version must contain three numeric components") - checksums = read_checksums(version, Path(sys.argv[2])) + checksums = read_checksums(version, args.checksums) - (ROOT / "Formula").mkdir(exist_ok=True) - (ROOT / "bucket").mkdir(exist_ok=True) - (ROOT / "Formula/termgram.rb").write_text(formula(version, checksums)) - (ROOT / "bucket/termgram.json").write_text( + (args.output_dir / "Formula").mkdir(parents=True, exist_ok=True) + (args.output_dir / "bucket").mkdir(exist_ok=True) + (args.output_dir / "Formula/termgram.rb").write_text(formula(version, checksums)) + (args.output_dir / "bucket/termgram.json").write_text( json.dumps(scoop_manifest(version, checksums), indent=4) + "\n" )