From cf484a1d2ca1802743e954efbdf1b854b0c16b1f Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:32:40 +1300 Subject: [PATCH] Publish TuiCode to the signed apt repository on every release Pins a-team's shared release workflows to v0.1.95 and passes apt: true, so the publish job signs and pushes the release's .debs to mentaldesk/apt. The other inputs (formula, scoop, artifacts, environment) are unchanged between v0.1.3 and v0.1.95, and release-version.yml is identical. The one other change in release-publish.yml is that it now closes superseded tap PRs. A new apt-check job installs the just-released version from the repo in clean debian:12 and ubuntu:22.04 containers on amd64 and arm64, waiting for Pages to serve it and failing on any apt update warning. Closes #417 Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 30 +++++++++++++++++++++++++++-- AGENTS.md | 4 ++-- CONTRIBUTING.md | 18 +++++++++++++++--- README.md | 11 ++++++++++- packaging/test-apt-repo.sh | 36 +++++++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 8 deletions(-) create mode 100755 packaging/test-apt-repo.sh 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"