Skip to content
Open
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
30 changes: 28 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ concurrency: release

jobs:
version:
uses: mentaldesk/a-team/.github/workflows/release-version.yml@v0.1.3
uses: mentaldesk/a-team/.github/workflows/release-version.yml@v0.1.95
# A called workflow doesn't inherit the caller's permissions.
permissions:
contents: read
Expand Down Expand Up @@ -189,7 +189,7 @@ jobs:
publish:
needs: [version, build]
if: github.ref == 'refs/heads/main'
uses: mentaldesk/a-team/.github/workflows/release-publish.yml@v0.1.3
uses: mentaldesk/a-team/.github/workflows/release-publish.yml@v0.1.95
permissions:
contents: write
with:
Expand All @@ -198,7 +198,33 @@ jobs:
tag: ${{ needs.version.outputs.tag }}
formula: packaging/tuicode.rb
scoop: packaging/tuicode.json
apt: true
artifacts: tuicode-*
environment: release
# The only way an environment's secrets reach a called workflow: actions/runner#4453.
secrets: inherit

apt-check:
name: apt install ${{ matrix.arch }}
needs: [version, publish]
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-22.04
- arch: arm64
runner: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v6

- name: Install from the apt repository
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
for image in debian:12 ubuntu:22.04; do
echo "::group::$image"
packaging/test-apt-repo.sh "$image" "$VERSION"
echo "::endgroup::"
done
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,8 @@ What follows is how TuiCode implements it today.

## Release workflow

- `.github/workflows/release.yml` runs from the Actions tab only (`workflow_dispatch`, with a `bump` choice) — see [CONTRIBUTING.md § Cutting a release](CONTRIBUTING.md#cutting-a-release). It calls a-team's reusable `release-version.yml` for the next version, matrix-builds AOT single-file binaries on native runners for each RID (Apple Silicon only on macOS — Intel Macs are EOL), archives them (`.tar.gz` on Unix, `.zip` on Windows) with a `.sha256` sidecar, then calls `release-publish.yml`, which tags the commit, publishes the release, opens the Homebrew tap PR and commits the Scoop manifest.
- Both shared workflows are pinned to a released a-team tag (`@v0.1.3`), never a branch. Anything missing from them gets fixed on `mentaldesk/a-team` and re-pinned here, not forked into this repo.
- `.github/workflows/release.yml` runs from the Actions tab only (`workflow_dispatch`, with a `bump` choice) — see [CONTRIBUTING.md § Cutting a release](CONTRIBUTING.md#cutting-a-release). It calls a-team's reusable `release-version.yml` for the next version, matrix-builds AOT single-file binaries on native runners for each RID (Apple Silicon only on macOS — Intel Macs are EOL), archives them (`.tar.gz` on Unix, `.zip` on Windows) with a `.sha256` sidecar, then calls `release-publish.yml`, which tags the commit, publishes the release, opens the Homebrew tap PR, commits the Scoop manifest and publishes the signed apt repository; `apt-check` then installs from that repository.
- Both shared workflows are pinned to a released a-team tag (`@v0.1.95`), never a branch. Anything missing from them gets fixed on `mentaldesk/a-team` and re-pinned here, not forked into this repo.
- **A called workflow doesn't inherit the caller's permissions**, so the `version` job declares `contents: read` + `pull-requests: read` and `publish` declares `contents: write`.
- `publish` runs only from `main`, in the `release` environment, which only `main` can deploy to. The packages token is that environment's `PACKAGES_TOKEN` secret, so a workflow on any other branch can't read it. `publish` passes `secrets: inherit` because that's the only way an environment's secrets reach a called workflow ([actions/runner#4453](https://github.com/actions/runner/issues/4453)).
- Shared `publish` creates the tag itself (`gh release create --target $GITHUB_SHA`), so the caller must never tag, and the build matrix is `fail-fast: true`: a platform that fails leaves no tag behind to clean up. `concurrency: release` stops two dispatches racing for the same version.
Expand Down
18 changes: 15 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ For code-level conventions (branch names, key handling, AOT gotchas, test framew

## Releases

A release is a `v*` git tag, a GitHub Release attaching native single-file binaries for every supported runtime plus `.deb` and `.rpm` packages for Linux x64 and arm64, and a Homebrew formula and Scoop manifest pointing at them. It all happens from the Actions tab — nothing is done locally, and no tag is pushed by hand.
A release is a `v*` git tag, a GitHub Release attaching native single-file binaries for every supported runtime plus `.deb` and `.rpm` packages for Linux x64 and arm64, a Homebrew formula and Scoop manifest pointing at them, and a signed apt repository serving the `.deb`s. It all happens from the Actions tab — nothing is done locally, and no tag is pushed by hand.

Version-picking and publishing are [a-team's reusable workflows](https://github.com/mentaldesk/a-team), pinned to a released tag. `release.yml` keeps TuiCode's own build matrix — five RIDs, AOT, macOS signing and notarisation — and calls the shared ones on either side of it, so there's one release flow to maintain rather than two that drift.

Expand Down Expand Up @@ -57,10 +57,14 @@ flowchart TD
TapPR -->|tap CI goes green| Brew[("brew install<br/>mentaldesk/tap/tuicode")]
Render --> Bucket["commit to scoop-bucket<br/>bucket/tuicode.json"]
Bucket --> Scoop[("scoop install<br/>mentaldesk/tuicode")]
Tag --> Apt["sign + push mentaldesk/apt<br/>with APT_GPG_PRIVATE_KEY"]
Apt --> AptCheck["apt-check job: apt install<br/>in debian:12, ubuntu:22.04"]
AptCheck --> AptUser[("apt install tuicode")]

style Public fill:#e0ffe0
style Brew fill:#e0ffe0
style Scoop fill:#e0ffe0
style AptUser fill:#e0ffe0
```

The shared publish job creates the tag, so a build that fails on any platform (the matrix is `fail-fast`) leaves no tag behind and nothing to clean up. `concurrency: release` keeps two dispatches from racing for the same version. Re-running the workflow after a successful publish picks the *next* version — tags are immutable; never delete and re-push one.
Expand All @@ -71,14 +75,20 @@ The release is published outright rather than as a draft: draft assets 404 for u

`packaging/tuicode.rb` is the tap's formula and `packaging/tuicode.json` the bucket's Scoop manifest, each with the version and its SHA256s replaced by `{{version}}` and `{{sha_<rid>}}`. The shared publish job renders both from the archives it downloaded and fails the job on a placeholder nothing filled — `PackagingTemplateTests` catches a typo'd one before the release rather than during it, and checks the manifest still parses as JSON once rendered. The formula has no `version` line: `brew audit --strict` rejects one that duplicates the URL.

Both are pushed with `PACKAGES_TOKEN`, a fine-grained PAT with Contents + Pull requests write on `mentaldesk/homebrew-tap` and Contents write on `mentaldesk/scoop-bucket`, stored in the `release` environment, which only `main` can deploy to. Without that secret the release still publishes and the job warns.
Both are pushed with `PACKAGES_TOKEN`, a fine-grained PAT with Contents + Pull requests write on `mentaldesk/homebrew-tap` and Contents write on `mentaldesk/scoop-bucket` and `mentaldesk/apt`, stored in the `release` environment, which only `main` can deploy to. Without that secret the release still publishes and the job warns.

The tap gets a PR its own CI gates. The bucket has no CI, so the manifest is committed straight to its default branch — a mistake in the template is live for Windows users immediately, which is what the placeholder and JSON checks are there to prevent.

### The Linux packages

`packaging/nfpm.yaml` describes the `.deb` and `.rpm`: the binary as `/usr/bin/tuicode`, the licence and both notices files, a dependency on glibc 2.35 or newer (the binary links nothing else), and a suggestion of `wl-clipboard` or `xclip`, which apt and dnf don't install by default. On the Linux legs, `packaging/build.sh` builds both formats from the binary the tarball holds, with [nfpm](https://nfpm.goreleaser.com/), and `packaging/test-install.sh` installs each in a clean `debian:12`, `ubuntu:22.04` and `fedora` container, runs `tuicode --smoke`, then removes it and checks nothing is left. A failure there fails the build, so nothing is published. CI's `aot` job does the same on every push, at two versions so the second install checks the upgrade.

### The apt repository

With `apt: true`, the shared publish job puts the release's `.deb`s in [mentaldesk/apt](https://github.com/mentaldesk/apt), replacing the previous version, regenerates its `Packages` and `Release` files, signs them and pushes. GitHub Pages serves it at `https://mentaldesk.github.io/apt`, with the public key at `key.asc`. The signing key is `APT_GPG_PRIVATE_KEY` and `APT_GPG_PASSPHRASE` in the `release` environment (fingerprint `BEC853B1FD1C68F3D381CE6B55B0C01199016EA6`); without it the release still publishes and the job warns. Every repo publishing to `mentaldesk/apt` signs with that same key, since users trust the one `key.asc`.

Once it's pushed, `release.yml`'s `apt-check` job runs `packaging/test-apt-repo.sh` on amd64 and arm64: in clean `debian:12` and `ubuntu:22.04` containers it follows the README's setup, waits up to ten minutes for Pages to serve the new version, installs it and runs `tuicode --smoke-syntax`, failing on any warning from `apt update`. The release is already out by then, so a failure there means fixing the repo, not the release.

### Distribution

```mermaid
Expand All @@ -94,7 +104,9 @@ flowchart LR

Release -.->|future: #75| Winget["winget"]
Release -->|"download .deb / .rpm"| Linux["Debian, Ubuntu, Fedora<br/>/usr/bin/tuicode"]
Release -.->|future: #44| LinuxRepos["apt repo / AUR / Flatpak"]
Release -->|"signed .debs"| Apt["mentaldesk/apt<br/>on GitHub Pages"]
Apt -->|apt install / upgrade| AptUser["Debian, Ubuntu<br/>/usr/bin/tuicode"]
Release -.->|future: #383| LinuxRepos["AUR"]
```

The Homebrew formula points at the release tarball URL with a pinned SHA256. Both the PR and its merge are automatic; `brew upgrade tuicode` picks up the new version once it lands, and `tuicode`'s About dialog reports the same number.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@ scoop install tuicode

Other channels (winget, Linux packagers) are tracked in [#43](https://github.com/mentaldesk/TuiCode/issues/43) and [#44](https://github.com/mentaldesk/TuiCode/issues/44). In the meantime, pre-built single-file binaries for every supported RID are attached to each [GitHub Release](https://github.com/mentaldesk/TuiCode/releases).

Debian or Ubuntu, as a system package: download `tuicode_<version>_amd64.deb` (or `_arm64.deb`) from the [latest release](https://github.com/mentaldesk/TuiCode/releases/latest), then:
Debian or Ubuntu, from the signed apt repository, so `sudo apt upgrade` keeps it up to date:

```bash
curl -fsSL https://mentaldesk.github.io/apt/key.asc | sudo tee /etc/apt/keyrings/mentaldesk.asc >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/mentaldesk.asc] https://mentaldesk.github.io/apt stable main" \
| sudo tee /etc/apt/sources.list.d/mentaldesk.list
sudo apt update && sudo apt install tuicode
```

Or, without the repository, download `tuicode_<version>_amd64.deb` (or `_arm64.deb`) from the [latest release](https://github.com/mentaldesk/TuiCode/releases/latest), then:

```bash
sudo apt install ./tuicode_<version>_amd64.deb
Expand Down
36 changes: 36 additions & 0 deletions packaging/test-apt-repo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Usage: packaging/test-apt-repo.sh <image> <version>
# Follows the README's apt setup in a clean <image> container, waits for the repo to serve <version>,
# installs it and checks it starts. Fails on any apt warning. Needs docker.
set -euo pipefail

image=$1
version=$2
repo=https://mentaldesk.github.io/apt

container=$(docker run -d --rm "$image" sleep infinity)
trap 'docker rm -f "$container" >/dev/null' EXIT
run() { docker exec -e DEBIAN_FRONTEND=noninteractive "$container" sh -c "$1"; }
fail() { echo "::error::$image: $1"; exit 1; }

run 'apt-get update -qq && apt-get install -y -qq curl ca-certificates sudo >/dev/null'

run "curl -fsSL $repo/key.asc | sudo tee /etc/apt/keyrings/mentaldesk.asc >/dev/null"
run "echo 'deb [signed-by=/etc/apt/keyrings/mentaldesk.asc] $repo stable main' | sudo tee /etc/apt/sources.list.d/mentaldesk.list"

# GitHub Pages takes a few minutes to serve a fresh push.
for attempt in $(seq 30); do
log=$(run 'sudo apt-get update 2>&1') || fail "apt update failed: $log"
candidate=$(run "apt-cache policy tuicode | sed -n 's/^ *Candidate: //p'")
[ "$candidate" = "$version" ] && break
echo "Attempt $attempt: the repo offers '${candidate:-nothing}', waiting for $version"
sleep 20
done
[ "$candidate" = "$version" ] || fail "the repo still offers '${candidate:-nothing}', expected $version"
if grep -E '^(W|E|Err):' <<<"$log"; then fail "apt update reported the warnings above"; fi

run 'sudo apt-get install -y -qq tuicode'
installed=$(run "dpkg-query -W -f='\${Version}' tuicode")
[ "$installed" = "$version" ] || fail "installed version is $installed, expected $version"
run 'tuicode --smoke-syntax'
echo "$image: installed tuicode $version from $repo"
Loading