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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 16 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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.
41 changes: 41 additions & 0 deletions bucket/termgram.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
65 changes: 65 additions & 0 deletions scripts/publish-package-metadata.sh
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions scripts/test-package-metadata.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading