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 8363a91..938dd4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -347,3 +347,22 @@ jobs: -F draft=false -F prerelease="$prerelease" \ -f make_latest="$latest" -f name="$title" >/dev/null done < metadata/release-plan.tsv + + # 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/README.md b/README.md index 65b8d92..e605659 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) @@ -78,23 +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. diff --git a/bucket/termgram.json b/bucket/termgram.json new file mode 100644 index 0000000..dbf5ce8 --- /dev/null +++ b/bucket/termgram.json @@ -0,0 +1,41 @@ +{ + "version": "0.1.20", + "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.", + "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" + } + } + } + } +} 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 new file mode 100644 index 0000000..24be195 --- /dev/null +++ b/scripts/update-package-metadata.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Regenerate the Homebrew formula and the Scoop manifest for one stable release. + +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 +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: + 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, args.checksums) + + (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" + ) + + +if __name__ == "__main__": + main()