Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
136 changes: 136 additions & 0 deletions .github/workflows/publish-linux-packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: Publish Linux packages

# See packaging/linux/README.md.

on:
workflow_call:
inputs:
release_tag:
required: true
type: string
# Optional so releases keep working before the one-time setup in
# packaging/linux/README.md is done; the job then skips with a warning.
secrets:
LINUX_PACKAGES_GPG_PRIVATE_KEY:
required: false
CLOUDFLARE_API_TOKEN:
required: false
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to package"
required: true
type: string

permissions:
contents: read

# Each run rewrites its channel's index from the previous one, so runs for a
# channel must not overlap. Stable and nightly write separate indexes, and a
# separate group keeps a queued nightly from replacing a queued stable run.
concurrency:
group: publish-linux-packages-${{ contains(inputs.release_tag, '-nightly.') && 'nightly' || 'stable' }}
cancel-in-progress: false

env:
NFPM_VERSION: 2.47.0
NFPM_SHA256: 0660ca602b2d2d2ae4781a06c692b3eeb9d437ffea05b831d76e41f4a3188783

jobs:
publish:
name: Build, sign, and publish
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Checkout packaging sources
uses: actions/checkout@v6
with:
sparse-checkout: |
.github/actions
packaging/linux
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- id: setup
name: Check one-time setup
env:
LINUX_PACKAGES_GPG_PRIVATE_KEY: ${{ secrets.LINUX_PACKAGES_GPG_PRIVATE_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [[ -z "$LINUX_PACKAGES_GPG_PRIVATE_KEY" || -z "$CLOUDFLARE_API_TOKEN" ||
Comment thread
coderabbitai[bot] marked this conversation as resolved.
! -f packaging/linux/t3code-archive-keyring.asc ]]; then
echo "::warning::Linux packages are not set up yet. See packaging/linux/README.md."
echo "ready=false" >> "$GITHUB_OUTPUT"
else
echo "ready=true" >> "$GITHUB_OUTPUT"
fi

- uses: ./.github/actions/setup-apt-mirrors
if: steps.setup.outputs.ready == 'true'

- name: Install packaging tools
if: steps.setup.outputs.ready == 'true'
run: |
sudo apt-get update
sudo apt-get install -y apt-utils createrepo-c squashfs-tools
curl -fsSL -o "$RUNNER_TEMP/nfpm.tar.gz" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_Linux_x86_64.tar.gz"
echo "${NFPM_SHA256} $RUNNER_TEMP/nfpm.tar.gz" | sha256sum --check
sudo tar -xzf "$RUNNER_TEMP/nfpm.tar.gz" -C /usr/local/bin nfpm

- name: Import signing key
if: steps.setup.outputs.ready == 'true'
env:
LINUX_PACKAGES_GPG_PRIVATE_KEY: ${{ secrets.LINUX_PACKAGES_GPG_PRIVATE_KEY }}
run: |
printf '%s\n' "$LINUX_PACKAGES_GPG_PRIVATE_KEY" > "$RUNNER_TEMP/signing-key.asc"
gpg --batch --import "$RUNNER_TEMP/signing-key.asc"
# The key in the packages must be the key that signs the repository.
expected="$(gpg --with-colons --import-options show-only --import packaging/linux/t3code-archive-keyring.asc | awk -F: '/^fpr/ { print $10; exit }')"
gpg --batch --list-secret-keys --with-colons | grep -q "^fpr:::::::::${expected}:" \
|| { echo "The signing key does not match packaging/linux/t3code-archive-keyring.asc." >&2; exit 1; }
echo "GPG_KEY_ID=$expected" >> "$GITHUB_ENV"

- name: Build packages
if: steps.setup.outputs.ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
RPM_SIGNING_KEY_FILE: ${{ runner.temp }}/signing-key.asc
OUT_DIR: ${{ runner.temp }}/packages
run: |
for arch in x64 arm64; do
ARCH="$arch" packaging/linux/scripts/build-packages.sh
done

- name: Attach packages to the release
if: steps.setup.outputs.ready == 'true'
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
shopt -s nullglob
packages=("$RUNNER_TEMP"/packages/*.deb "$RUNNER_TEMP"/packages/*.rpm)
if [[ ${#packages[@]} -eq 0 ]]; then
echo "Release $RELEASE_TAG publishes no Linux packages."
exit 0
fi
gh release upload "$RELEASE_TAG" "${packages[@]}" --repo "$GITHUB_REPOSITORY" --clobber

- name: Update and upload repository indexes
if: steps.setup.outputs.ready == 'true'
env:
RELEASE_TAG: ${{ inputs.release_tag }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: |
source packaging/linux/scripts/channel.sh
resolve_channel "$RELEASE_TAG" || exit 0
site="$RUNNER_TEMP/site"
packaging/linux/scripts/sync-repo.sh pull "$site" "$channel"
REPO_DIR="$site" PACKAGE_DIR="$RUNNER_TEMP/packages" packaging/linux/scripts/publish-repos.sh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium workflows/publish-linux-packages.yml:134

Dispatching an older release_tag after more than five releases evicts a newer version from the repository metadata, so the channel no longer contains its five newest versions. publish-repos.sh retains the first five records without sorting by version, while this step prepends the dispatched tag's packages to the existing index; reject historical tags or make the index update sort and retain the newest five versions.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.github/workflows/publish-linux-packages.yml around line 134:

Dispatching an older `release_tag` after more than five releases evicts a newer version from the repository metadata, so the channel no longer contains its five newest versions. `publish-repos.sh` retains the first five records without sorting by version, while this step prepends the dispatched tag's packages to the existing index; reject historical tags or make the index update sort and retain the newest five versions.

packaging/linux/scripts/sync-repo.sh push "$site" "$channel"

- name: Remove signing key
if: always()
run: rm -f "$RUNNER_TEMP/signing-key.asc"
13 changes: 13 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,19 @@ jobs:
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}

publish_linux_packages:
name: Publish Linux packages
needs: [preflight, release]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel != 'preview' }}
uses: ./.github/workflows/publish-linux-packages.yml
permissions:
contents: write
with:
release_tag: ${{ needs.preflight.outputs.tag }}
secrets:
LINUX_PACKAGES_GPG_PRIVATE_KEY: ${{ secrets.LINUX_PACKAGES_GPG_PRIVATE_KEY }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

deploy_web:
name: Deploy hosted web app
needs: [preflight, relay_public_config, release]
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ winget install T3Tools.T3Code
brew install --cask t3-code
```

#### Debian, Ubuntu, Linux Mint

Download the `.deb` from the [download page](https://t3.codes/download), then:

```bash
sudo apt install ./t3code_*.deb
```

The package adds the T3 Code apt repository, so `sudo apt upgrade` installs new versions.

#### Fedora, RHEL

Download the `.rpm` from the [download page](https://t3.codes/download), then:

```bash
sudo dnf install ./t3code_*.rpm
```

The package adds the T3 Code dnf repository, so `sudo dnf upgrade` installs new versions.

#### Arch Linux (AUR)

Stable:
Expand All @@ -68,7 +88,7 @@ Nightly:
yay -S t3code-nightly-bin
```

The AUR packaging is maintained in this repository under [`packaging/aur`](./packaging/aur).
The Linux packaging is maintained in this repository under [`packaging/linux`](./packaging/linux) and [`packaging/aur`](./packaging/aur).

## Some notes

Expand Down
43 changes: 41 additions & 2 deletions apps/marketing/src/pages/download.astro
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,43 @@ const imageProps = {
<h2 class="platform-name">Linux</h2>
</div>
<div class="download-cards">
<a class="download-card" data-asset="amd64.deb" data-optional hidden href={RELEASES_URL}>
<span class="card-icon">
<Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
<Image class="card-icon--nightly" src={iconNightly} width={28} height={28} loading="lazy" {...imageProps} />
</span>
<span class="card-text">
<span class="card-arch">Debian, Ubuntu</span>
<span class="card-format">.deb</span>
</span>
</a>
<a class="download-card" data-asset="x86_64.rpm" data-optional hidden href={RELEASES_URL}>
<span class="card-icon">
<Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
<Image class="card-icon--nightly" src={iconNightly} width={28} height={28} loading="lazy" {...imageProps} />
</span>
<span class="card-text">
<span class="card-arch">Fedora, RHEL</span>
<span class="card-format">.rpm</span>
</span>
</a>
<a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>
<span class="card-icon">
<Image class="card-icon--stable" src={iconStable} width={28} height={28} loading="lazy" {...imageProps} />
<Image class="card-icon--nightly" src={iconNightly} width={28} height={28} loading="lazy" {...imageProps} />
</span>
<span class="card-text">
<span class="card-arch">x86_64</span>
<span class="card-arch">Any distribution</span>
<span class="card-format">AppImage</span>
</span>
</a>
</div>
<p class="intel-note">
On ARM? Download the arm64
<a data-asset="arm64.deb" data-optional hidden href={RELEASES_URL}>.deb</a>
<a data-asset="aarch64.rpm" data-optional hidden href={RELEASES_URL}>.rpm</a>
<a data-asset="arm64.AppImage" href={RELEASES_URL}>AppImage</a>
</p>
</section>

<!-- Mobile. Stable only, there is no nightly mobile build. -->
Expand Down Expand Up @@ -159,6 +185,8 @@ const imageProps = {
const releasesPageLink = document.getElementById("releases-page-link") as HTMLAnchorElement;
const tabs = page.querySelectorAll<HTMLButtonElement>(".channel-toggle button");
// Only release-asset links; mobile store cards have no data-asset and keep their href.
// data-optional links stay hidden unless the release has their asset, so a
// release without Linux packages shows no dead .deb or .rpm links.
const cards = page.querySelectorAll<HTMLAnchorElement>("a[data-asset]");

function readChannel(): ReleaseChannel {
Expand All @@ -182,6 +210,7 @@ const imageProps = {
versionLabel.textContent = "\u00a0";
cards.forEach((card) => {
card.href = fallback;
card.hidden = card.dataset.optional !== undefined;
});

try {
Expand All @@ -196,8 +225,13 @@ const imageProps = {
: release.tag_name;
if (release.html_url) changelogLink.href = release.html_url;
cards.forEach((card) => {
const match = (release.assets ?? []).find((a) => a.name.endsWith(`-${card.dataset.asset}`));
// Installers end in -<arch>.<ext>; Linux packages end in _<arch>.<ext>.
const match = (release.assets ?? []).find(
(a) =>
a.name.endsWith(`-${card.dataset.asset}`) || a.name.endsWith(`_${card.dataset.asset}`),
);
card.href = match?.browser_download_url ?? fallback;
card.hidden = card.dataset.optional !== undefined && !match;
Comment thread
t3dotgg marked this conversation as resolved.
});
} catch {
if (page.dataset.channel === channel) versionLabel.textContent = "Releases";
Expand Down Expand Up @@ -465,6 +499,11 @@ const imageProps = {
gap: 0.5rem;
}

.download-card[hidden],
.intel-note a[hidden] {
display: none;
}

.download-card {
display: flex;
align-items: center;
Expand Down
22 changes: 16 additions & 6 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,22 @@ update it with `git pull` and a rebuild.
Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases),
or use a package manager:

| Platform | Install |
| ------------------ | ------------------------------- |
| Windows | `winget install T3Tools.T3Code` |
| macOS | `brew install --cask t3-code` |
| Arch Linux | `yay -S t3code-bin` |
| Arch Linux nightly | `yay -S t3code-nightly-bin` |
| Platform | Install |
| -------------------- | ----------------------------------------------------------- |
| Windows | `winget install T3Tools.T3Code` |
| macOS | `brew install --cask t3-code` |
| Debian, Ubuntu, Mint | Download the `.deb`, then `sudo apt install ./t3code_*.deb` |
| Fedora, RHEL | Download the `.rpm`, then `sudo dnf install ./t3code_*.rpm` |
| Arch Linux | `yay -S t3code-bin` |
| Arch Linux nightly | `yay -S t3code-nightly-bin` |

The `.deb` and `.rpm` packages add the T3 Code package repository, so
`sudo apt upgrade` or `sudo dnf upgrade` installs new versions. The app does
not update itself in these installs. The nightly packages are named
`t3code-nightly`; only one of stable and nightly can be installed at a time.

The AppImage runs on any distribution and updates itself. It needs FUSE; if it
does not start, install your distribution's `fuse3` package.

### Windows Subsystem for Linux

Expand Down
12 changes: 12 additions & 0 deletions infra/packages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# pkg.t3.codes

The apt and dnf repositories for the Linux desktop packages. A Cloudflare
Worker ([`src/worker.ts`](./src/worker.ts)) serves the signed index files from
an R2 bucket and redirects package downloads to GitHub Releases.

See [`packaging/linux/README.md`](../../packaging/linux/README.md) for how
packages and indexes are built, signed, and published.

```sh
vp run --filter t3code-packages deploy --stage prod
```
25 changes: 25 additions & 0 deletions infra/packages/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";

// pkg.t3.codes: the Worker in src/worker.ts in front of an R2 bucket that
// holds the signed apt and dnf index files. The release workflow uploads new
// indexes with packaging/linux/scripts/publish-repos.sh; see
// packaging/linux/README.md.
export const Bucket = Cloudflare.R2.Bucket("Indexes", { name: "t3code-packages" });

export const Worker = Cloudflare.Worker("Packages", {
main: "./src/worker.ts",
domain: "pkg.t3.codes",
env: { Bucket },
});

export default Alchemy.Stack(
"T3CodePackages",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const bucket = yield* Bucket;
const worker = yield* Worker;
return { bucketName: bucket.bucketName, url: worker.url.as<string>() };
}),
);
20 changes: 20 additions & 0 deletions infra/packages/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "t3code-packages",
"private": true,
"type": "module",
"scripts": {
"deploy": "alchemy deploy",
"destroy": "alchemy destroy",
"test": "vp test run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"alchemy": "2.0.0-beta.79",
"effect": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260601.1",
"@types/node": "catalog:",
"vite-plus": "catalog:"
}
}
26 changes: 26 additions & 0 deletions infra/packages/src/worker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vite-plus/test";

import { releaseAssetUrl } from "./worker.ts";

describe("releaseAssetUrl", () => {
it("redirects apt and dnf pool paths to the release asset", () => {
expect(releaseAssetUrl("/apt/pool/v0.0.42/t3code_0.0.42_amd64.deb")).toBe(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.42/t3code_0.0.42_amd64.deb",
);
expect(
releaseAssetUrl(
"/rpm/nightly/aarch64/pool/v0.0.43-nightly.20260922.2110/t3code-nightly_0.0.43-nightly.20260922.2110_aarch64.rpm",
),
).toBe(
"https://github.com/pingdotgg/t3code/releases/download/v0.0.43-nightly.20260922.2110/t3code-nightly_0.0.43-nightly.20260922.2110_aarch64.rpm",
);
});

it("leaves index files and anything that is not a package to the bucket", () => {
expect(releaseAssetUrl("/apt/dists/stable/InRelease")).toBeNull();
expect(releaseAssetUrl("/rpm/stable/x86_64/repodata/repomd.xml")).toBeNull();
expect(releaseAssetUrl("/apt/pool/v0.0.42/../../secrets.deb")).toBeNull();
expect(releaseAssetUrl("/apt/pool/v0.0.42/notes.txt")).toBeNull();
expect(releaseAssetUrl("/apt/pool/latest/t3code_0.0.42_amd64.deb")).toBeNull();
});
});
Loading
Loading