diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dee98ecf..9fcb6b78 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
@@ -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:
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 32b1c595..ddf40aeb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5d46606f..94e3a499 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
@@ -57,10 +57,14 @@ flowchart TD
TapPR -->|tap CI goes green| Brew[("brew install
mentaldesk/tap/tuicode")]
Render --> Bucket["commit to scoop-bucket
bucket/tuicode.json"]
Bucket --> Scoop[("scoop install
mentaldesk/tuicode")]
+ Tag --> Apt["sign + push mentaldesk/apt
with APT_GPG_PRIVATE_KEY"]
+ Apt --> AptCheck["apt-check job: apt install
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.
@@ -71,7 +75,7 @@ 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_}}`. 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.
@@ -79,6 +83,12 @@ The tap gets a PR its own CI gates. The bucket has no CI, so the manifest is com
`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
@@ -94,7 +104,9 @@ flowchart LR
Release -.->|future: #75| Winget["winget"]
Release -->|"download .deb / .rpm"| Linux["Debian, Ubuntu, Fedora
/usr/bin/tuicode"]
- Release -.->|future: #44| LinuxRepos["apt repo / AUR / Flatpak"]
+ Release -->|"signed .debs"| Apt["mentaldesk/apt
on GitHub Pages"]
+ Apt -->|apt install / upgrade| AptUser["Debian, Ubuntu
/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.
diff --git a/README.md b/README.md
index 52e63f47..a9fc9cab 100644
--- a/README.md
+++ b/README.md
@@ -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__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__amd64.deb` (or `_arm64.deb`) from the [latest release](https://github.com/mentaldesk/TuiCode/releases/latest), then:
```bash
sudo apt install ./tuicode__amd64.deb
diff --git a/packaging/test-apt-repo.sh b/packaging/test-apt-repo.sh
new file mode 100755
index 00000000..a7ad5df8
--- /dev/null
+++ b/packaging/test-apt-repo.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Usage: packaging/test-apt-repo.sh
+# Follows the README's apt setup in a clean container, waits for the repo to serve ,
+# 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"