Compare commits

..
10 Commits
Author SHA1 Message Date
jknapp 81b1cfba09 Merge pull request 'Add a native Arch/CachyOS package via its own AUR publish workflow' (#39) from feat/arch-aur-package into main
Secret Scan / scan (push) Successful in 5s
2026-08-27 18:30:00 +00:00
jknapp ca6028bbb3 Merge pull request 'Work around WebKitGTK EGL crash on Wayland' (#38) from fix/wayland-webkit-egl-crash into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m50s
Build App / build-windows (push) Successful in 4m46s
Build App / build-linux (push) Successful in 6m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-08-27 18:29:51 +00:00
shadow-testandClaude Sonnet 5 b3d07bda09 Fix real workflow bugs a review found: dead bind mount, blind error gate
Secret Scan / scan (push) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
A review found the "Validate with makepkg and namcap" step's bind mount
(docker run -v "$PWD/rendered:/work") would very likely fail on Gitea's
own act_runner: a containerized job's $PWD isn't a path the daemon's host
can resolve, so the mount would silently attach an empty directory
instead of failing loudly — the same class of problem noted elsewhere for
this exact environment. Switched to docker create + docker cp (in and
back out) + docker start -a, the pattern already validated locally, which
works regardless of where the daemon actually lives.

Also found and fixed, most severe first:

- The namcap error gate (`grep -q "^[a-zA-Z0-9_-]*bin E:"`) only matched
  one of namcap's two line shapes for reporting an error
  ("triple-c-bin E: ...") and missed the other ("PKGBUILD
  (triple-c-bin) E: ...") entirely — confirmed by reproducing both against
  a real namcap run. The PKGBUILD-level half of the safety net was dead.
  Replaced with a plain `grep -q " E: "`, confirmed to match both real
  shapes (and a split-package variant) and nothing else.
- package()'s `ar x "Triple-C_${pkgver}_amd64.deb"` named the asset
  literally, defeating the whole point of the resolve step discovering
  the real filename from the release instead of assuming a pattern — a
  future Tauri bundler naming change would still break here with an
  opaque error. Changed to `ar x ./*_amd64.deb`, which `source=()` already
  guarantees matches exactly one file.
- `pacman -Sy` before installing packages is the canonical Arch partial-
  upgrade footgun; changed to `pacman -Syu --noconfirm --needed`.
- `${{ inputs.version }}` was interpolated directly into a shell step
  instead of routed through `env:`, unlike every other step in the file.
- `git push origin master` assumes the local branch name after cloning a
  brand-new (not-yet-created) AUR repo's empty state is `master`, which
  depends on the runner's own `init.defaultBranch` if the server sends no
  symref. `git push origin HEAD:master` is unambiguous either way.
- The private key was written with a plain redirect then chmod'd after,
  leaving a window where it's world-readable; now created at its final
  mode first via `install -m 600 /dev/null`. Added `-o IdentitiesOnly=yes`
  so a runner ssh-agent can't offer a different key first.
- Added GH_PAT auth to the api.github.com calls, matching every other
  workflow in this repo, to avoid the unauthenticated 60/hour rate limit.
- Fixed two comments: the `options` comment credited `!debug` for
  suppressing the empty debug-package directory, when it's actually
  `!strip` doing that (verified in a real build); and documented in the
  README that a hand-edit made directly in the AUR repo is silently
  reverted by the next dispatch, since every run renders fresh from this
  repo's template.

All of the above re-verified with the same real end-to-end methodology as
the original commit: real makepkg build, real namcap lint (clean), and
the exact updated docker create/cp/start sequence run against a live
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 11:24:31 -07:00
shadow-testandClaude Sonnet 5 e025a7441a Add a native Arch/CachyOS package via its own AUR publish workflow
Secret Scan / scan (push) Successful in 27s
Secret Scan / scan (pull_request) Successful in 10s
Part of triple-c#34's third ask ("I would like to also have an
Arch/CachyOS native version as well"), addressed separately from the
Wayland crash fix (fix/wayland-webkit-egl-crash) since it's an unrelated
feature, not a bug.

packaging/arch/PKGBUILD is a "-bin" AUR package repackaging the same .deb
build-app.yml already produces — no Rust/Node toolchain needed to install
it, and the user gets exactly the binary the project ships and tests.
Verified end to end against a real release (v0.4.14) rather than going by
Tauri's generic docs: downloaded the actual .deb, ldd'd the actual binary
to ground-truth `depends` (dropped `pango` and `libayatana-appindicator`
from an earlier draft — the first is already pulled in transitively by
gtk3, the second was never linked at all since this app has no tray icon
or menu), and ran a real makepkg/namcap/pacman -U cycle. namcap caught a
real issue this way (missing license file under
/usr/share/licenses/triple-c-bin/), now fixed by fetching LICENSE
alongside the .deb.

.gitea/workflows/publish-aur-package.yml does the actual publishing:
given a version (or "latest"), it finds that release's real Linux asset
on GitHub, downloads it, computes real checksums, renders the PKGBUILD
template, validates the result with makepkg and namcap inside a real
Arch container, and pushes to AUR. workflow_dispatch only, deliberately —
the same reasoning that killed sync-release.yml in triple-c#32 (releases
are assembled by build-app.yml across three separate platform jobs, so
there's no single automatic event that fires only once the Linux .deb
this needs actually exists) applies here too.

Requires a repo secret this workflow cannot set up itself:
AUR_SSH_PRIVATE_KEY, from an AUR account that has already created (or
been given co-maintainer access to) triple-c-bin — both one-time manual
steps on aur.archlinux.org. Until that secret exists, the workflow fails
loudly at the push step rather than silently doing nothing. See
packaging/arch/README.md for the full maintenance flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 11:10:55 -07:00
shadow-testandClaude Sonnet 5 8f62949902 Correct two overclaims in the Wayland workaround's comment
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 4m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m49s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Review found: "nothing this app's UI depends on" is backwards — the
terminal's @xterm/addon-webgl renderer is exactly the GPU compositing path
this setting disables, it just degrades gracefully (the addon's own
construction already handles WebGL being unavailable) rather than
crashing. And the "not simply Wayland vs X11" justification for going
unconditional doesn't hold up: WAYLAND_DISPLAY is exported into an
XWayland client's environment too, so gating on it would have caught that
case as well — the real reason to go unconditional is that there's no
reliable heuristic for the thing that actually matters (which
Mesa/driver/compositor combination is affected), not that the naive gate
misses XWayland specifically.

Also noted, not changed: the env var leaks to whatever the app spawns
afterwards (a cold-launched default browser via xdg-open), and the "=0
re-enables it" parenthetical isn't verified against WebKitGTK's own
source, so softened to say what's actually guaranteed (an already-set
value is left alone) rather than assume presence-vs-boolean parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:57:45 -07:00
shadow-testandClaude Sonnet 5 6354cb42b2 Work around WebKitGTK's EGL crash on Wayland (triple-c#34)
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m45s
Build App (Preview) / build-linux (pull_request) Successful in 5m10s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Reported on CachyOS/Arch with Wayland: the app aborts immediately with
"Could not create default EGL display: EGL_BAD_PARAMETER. Aborting."
printed straight to stderr by WebKitGTK's own C code, before Triple-C's
own logging even gets a chance to say anything useful about it.

This is WebKitGTK's DMA-BUF renderer (its default accelerated-compositing
path since 2.42) failing on some Mesa/driver/compositor combinations. Set
WEBKIT_DISABLE_DMABUF_RENDERER=1 unconditionally on Linux before the Tauri
builder runs, which is where GTK/WebKitGTK actually read it — there's no
reliable way to detect the affected combination ahead of time (reports of
this exact failure exist under XWayland too, not just pure Wayland
sessions), and WebKitGTK's fallback compositing path costs some rendering
performance this app's UI doesn't need. Left alone if a user has already
set the variable themselves.

Does not address the other two things filed under the same issue (links
not opening on the host, and a request for a native Arch/CachyOS package)
— those need more information / are a separate scope, respectively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:45:24 -07:00
jknapp 9b55a12b32 Merge pull request 'Make preview versions monotonic and distinguishable from production' (#37) from fix/preview-version-numbering into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m41s
Build App / build-windows (push) Successful in 4m50s
Build App / build-linux (push) Successful in 6m27s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
2026-08-27 17:41:38 +00:00
shadow-testandClaude Sonnet 5 049232099b Dedupe the preview-build predicate, fix two comment inaccuracies
Secret Scan / scan (push) Successful in 24s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m44s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Final review pass gave this a clean bill of health overall but named
three small things:

- get_app_version() and check_for_updates() each read
  option_env!("TRIPLE_C_BUILD_SUFFIX") independently with slightly
  different idioms — if one were ever edited alone, the About panel and
  the update check could silently disagree about whether this is a
  preview build. Extracted preview_build_suffix() as the single place
  that reads and classifies it.
- pick_update's doc comment described the unparseable-tag case as a
  `-preview.<sha>` suffix; the actual tag build-app-preview.yml creates is
  `preview-<sha>` (no version, no dot) — already correct in the
  neighboring GitHubRelease::prerelease comment, just not here.
- That same prerelease comment claimed defence against a preview release
  leaking through backfill-releases.yml, but a preview's tag already fails
  semver parsing on its own — this field's actual job is the case parsing
  can't catch: a normally-tagged release someone flags prerelease on
  Gitea (a hotfix candidate, an RC) that a backfill would otherwise mirror
  as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:34:56 -07:00
shadow-testandClaude Sonnet 5 945883bb9d Actually offer a preview the release it precedes, and fix two more gaps
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m50s
Build App (Preview) / build-linux (pull_request) Successful in 7m25s
Build App (Preview) / prune-previews (pull_request) Successful in 6s
An Opus review of the previous commit found its headline claim didn't
hold: a preview and the release it precedes compute to the identical
numeric version by construction, but check_for_updates compared with a
strict `>` against the bare CARGO_PKG_VERSION (never the suffixed display
string), so `(0,4,13) > (0,4,13)` is false and the release was never
offered. Plain semver ordering doesn't make a `-preview.<sha>` suffix sort
below the same numeric release on its own here, since the comparison
never sees the suffix at all.

pick_update now takes is_preview_build, derived from whether
TRIPLE_C_BUILD_SUFFIX was baked in, and relaxes that one comparison to
`>=` — so "a release exists at my own number" reads as an update. A
production build still requires strictly newer.

Also: ported build-app.yml's `git tag --points-at HEAD` guard into the
preview version computation. Without it, workflow_dispatch (which this
workflow allows on main, not just PR builds) run on a commit a release
was already cut from would compute one past that release — reintroducing
"preview outranks production" through the manual-dispatch door. And
corrected two comments that claimed the prerelease filter was currently a
no-op: backfill-releases.yml mirrors every Gitea release to GitHub
unfiltered, prerelease flag included, so it's real defence-in-depth
against a dispatched backfill leaking a preview release, not a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:24:24 -07:00
shadow-testandClaude Sonnet 5 b71e15c2c0 Make preview versions monotonic and distinguishable from production
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m26s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
build-app-preview.yml computed its patch number as
`git rev-list --count <latest tag>..HEAD` — the exact formula build-app.yml
itself documents as broken and replaced (#26): a distance from whichever
tag sorts highest, not a counter, so it resets to zero on every release and
previews went backwards (0.4.62 -> 0.4.0) the moment one landed. Ported the
same "one past the highest patch already used" computation build-app.yml
uses for real releases, reading the same tags (including -mac/-win
suffixes), so a preview built right before a release now computes the
exact number that release is about to take — semver already orders
`0.4.12-preview.<sha> < 0.4.12`, so a preview user is offered the release
the moment it ships instead of being silently pinned forever.

The installed preview's reported version was also indistinguishable from
production: the bundle's own version field strips the `-preview.<sha>`
suffix before touching tauri.conf.json/Cargo.toml/package.json, since the
Windows MSI's ProductVersion has no room for one. Rather than risk that
(unverifiable without an actual Windows build), preview builds now bake
the suffix into the binary separately via a TRIPLE_C_BUILD_SUFFIX
build-time env var, and get_app_version() appends it when present — a
production build sets nothing, so this is a no-op there.

Also: added `prerelease` to `GitHubRelease` and filter on it in
check_for_updates (currently a no-op against real data — nothing mirrored
to GitHub is ever prerelease:true — but the updater is no longer
structurally incapable of enforcing a channel split if one is ever made
explicit). And deleted sync-release.yml: workflow_dispatch-only, reading
gitea.event.release.* fields a manual dispatch never populates, so it
could never have actually run; build-app.yml's inline mirror already does
the same job.

Refactored check_for_updates' filtering into a pure, testable pick_update
helper (this file had no tests before), and added tests for it and the
new get_app_version suffix handling.

Fixes #32.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:11:35 -07:00
9 changed files with 775 additions and 100 deletions
+83 -10
View File
@@ -43,7 +43,18 @@ name: Build App (Preview)
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a # prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
# release; the point of a preview is the build you are testing now. # release; the point of a preview is the build you are testing now.
# #
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub. # A preview release is not meant to reach GitHub. `build-app.yml`'s inline
# mirror never sees one (it only runs for its own `push`-triggered release),
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
# (never valid semver) already blocks it independently. (The previous
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
# `gitea.event.release.*` fields that are only ever populated by a `release`
# trigger, so it could never have actually run; deleted rather than fixed,
# since build-app.yml's inline mirror already does what it was meant to do
# for real releases. See triple-c#32.)
env: env:
GITEA_URL: ${{ gitea.server_url }} GITEA_URL: ${{ gitea.server_url }}
@@ -70,12 +81,23 @@ jobs:
outputs: outputs:
version: ${{ steps.version.outputs.VERSION }} version: ${{ steps.version.outputs.VERSION }}
sha: ${{ steps.version.outputs.SHA }} sha: ${{ steps.version.outputs.SHA }}
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
# The bundle version fields never see this — see "Set app version" in
# each build job — but it is baked into the binary as
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
# An installed preview otherwise reports the same bare number a
# production build would, indistinguishable in the About panel and to
# `check_for_updates`. See triple-c#32.
suffix: ${{ steps.version.outputs.SUFFIX }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute preview version - name: Compute preview version
id: version id: version
run: | run: |
@@ -86,21 +108,60 @@ jobs:
# is testing and not something to hang a tag on. # is testing and not something to hang a tag on.
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# The patch number is computed exactly as build-app.yml does it, so a # The patch number must be the same "one past the highest patch
# preview is labelled with the version the release it previews would # already used" build-app.yml computes for a real release — not a
# carry. This used to be hard-coded `.0`, which made every preview # distance from the latest tag. It used to be
# installer claim to be x.y.0 no matter what it contained. # `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true) # own history section documents as broken for exactly this reason:
if [ -n "$LATEST_TAG" ]; then # it resets to zero on every tag cut, so previews went *backwards*
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD") # (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)" # a preview number from later colliding with a real release's.
#
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
# / `-win` suffixed ones a partially-published release can leave
# behind) means a preview built right before a release computes the
# exact number that release is about to take — e.g. `0.4.13` for
# both. That makes the two numerically *equal*, not "preview less
# than release" — plain semver ordering does not make a
# `-preview.<sha>` suffix sort lower on its own here, because
# `check_for_updates` compares against the bare, stripped
# `CARGO_PKG_VERSION`, never the suffixed display string. What
# closes the loop is `update_commands.rs`'s `is_preview_build`
# check, which relaxes that one comparison to `>=` specifically so
# "a release exists at my own number" reads as an update. See
# triple-c#32.
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
| sort -n | tail -1 || true)
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
# HEAD can be a commit a release was already cut from. Without this,
# dispatching a preview there would compute `HIGHEST + 1` — one past
# that release — and produce exactly the "preview outranks
# production" failure triple-c#32 was filed over, just reintroduced
# through the manual-dispatch door instead of the automatic one.
EXISTING=$(git tag --points-at HEAD \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
| sort -n | tail -1 || true)
if [ -n "$EXISTING" ]; then
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
PATCH="${EXISTING}"
elif [ -n "$HIGHEST" ]; then
echo "Highest patch already used on this line: ${HIGHEST}"
PATCH=$((HIGHEST + 1))
else else
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0" echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
PATCH=0 PATCH=0
fi fi
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}" SUFFIX="preview.${SHORT_SHA}"
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
echo "Computed preview version: ${VERSION}" echo "Computed preview version: ${VERSION}"
# One release, created once. The three build jobs run concurrently, so # One release, created once. The three build jobs run concurrently, so
@@ -249,6 +310,13 @@ jobs:
- name: Build Tauri app - name: Build Tauri app
working-directory: ./app working-directory: ./app
env:
# Baked into the binary via `option_env!` in `get_app_version()` —
# the bundle version above stays bare (WiX/MSI's ProductVersion has
# no room for a suffix), so this is the only place a preview build
# can still tell itself apart from a production one. See
# triple-c#32.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build npx tauri build
@@ -361,6 +429,9 @@ jobs:
- name: Build Tauri app (universal) - name: Build Tauri app (universal)
working-directory: ./app working-directory: ./app
env:
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin npx tauri build --target universal-apple-darwin
@@ -489,6 +560,8 @@ jobs:
working-directory: ./app working-directory: ./app
env: env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}" TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: | run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%" set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build cargo tauri build
+274
View File
@@ -0,0 +1,274 @@
name: Publish AUR Package
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
# for a given release, or the latest one if none is given. Manual dispatch
# only — deliberately not triggered by `release` or `push`, for the same
# reason sync-release.yml (removed in triple-c#32) never worked safely as an
# automatic trigger: this repo's releases are assembled by build-app.yml
# across three separate platform jobs, and there is no single automatic event
# that fires only once everything (including the Linux .deb this workflow
# needs) is actually uploaded. A human deciding "this release is ready, go
# package it" is the correct trigger, the same reasoning
# backfill-releases.yml already uses for its own manual-only GitHub sync.
#
# ## What this does and does not do
#
# It renders `packaging/arch/PKGBUILD` for one specific version (real
# download URL, real sha256sums — never guessed; see the resolve-asset step)
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
# a hand-maintained template with a placeholder version, and every real,
# published version lives only in AUR's own git history, which is where a
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
# a hand-edit made directly in the AUR repo (outside this workflow) is
# silently overwritten the next time this runs, since every run renders fresh
# from this repo's template rather than starting from AUR's current state.
#
# ## Required secret
#
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
# account that has already created (or been given co-maintainer access to)
# the `triple-c-bin` package. This workflow cannot create that AUR account or
# register the key for you — both are manual, one-time steps on
# https://aur.archlinux.org. Until this secret exists, every run fails at the
# "Push to AUR" step with a clear error rather than silently doing nothing.
on:
workflow_dispatch:
inputs:
version:
description: >-
Release version to package, without a leading "v" (e.g. "0.4.14").
Leave empty to use the latest published GitHub release.
required: false
env:
GITHUB_REPO: shadowdao/triple-c
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and find the Linux asset
id: resolve
env:
VERSION_INPUT: ${{ inputs.version }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Authenticated when the secret is available (it is, everywhere
# else in this repo's workflows) to avoid the unauthenticated
# 60-requests/hour-per-IP cap; still works without it, just at that
# lower limit, since this hits nothing but a public repo's public
# releases.
AUTH=()
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
if [ -z "${VERSION_INPUT}" ]; then
echo "No version given — resolving the latest GitHub release"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
else
echo "Using requested version ${VERSION_INPUT}"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
fi
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
VERSION="${TAG#v}"
echo "Resolved to ${TAG}"
# Discovered from the real release, not assumed: Tauri names the
# asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name, and asset naming is exactly the kind
# of thing that silently drifts if a future Tauri upgrade changes
# bundler defaults — a hardcoded pattern here would then 404
# forever until someone noticed. `head -1` guards against a release
# somehow carrying more than one matching asset, which would
# otherwise pass the emptiness check below and then break the
# download step with two URLs on one line.
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
echo "No *_amd64.deb asset found on release ${TAG}" >&2
exit 1
fi
echo "Found asset: ${DEB_NAME}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
- name: Download the release asset and compute real checksums
id: checksums
env:
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Render PKGBUILD
id: render
env:
VERSION: ${{ steps.resolve.outputs.version }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
run: |
set -euo pipefail
mkdir -p rendered
cp packaging/arch/PKGBUILD rendered/PKGBUILD
cd rendered
# Plain string replacement throughout, not sed — the source URL
# contains slashes and the repo name does too, and getting a sed
# delimiter choice AND its escaping right for that is exactly the
# kind of thing that looks correct, passes review, and breaks the
# next time someone touches it. `re.sub` with `count=1` and an
# exact `.format`-free literal match is boring and that's the
# point: every substitution below fails loudly (an assertion /
# the checks after) rather than silently no-op'ing if the
# template's shape ever drifts from what this expects.
#
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
# same upstream version (a dependency bump, say) is what pkgrel is
# for, and this workflow always republishes the current PKGBUILD
# verbatim rather than incrementing anything, so 1 is always
# correct for what this workflow does. It is NOT correct for a
# dependency-only fix republished at the *same* pkgver: pkgrel
# would be forced back to 1, and no existing installation sees an
# upgrade. That case needs a manual pkgrel bump in the template
# before dispatching, which this workflow has no input for.
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
import re, sys
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
with open("PKGBUILD") as f:
text = f.read()
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
assert n == 1, "pkgver=... line not found"
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
assert n == 1, "pkgrel=... line not found"
old_source = (
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
f'Triple-C_${{pkgver}}_amd64.deb"'
)
new_source = (
f'source=("{deb_name}::'
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
)
assert old_source in text, "source=() line does not match the expected template shape"
text = text.replace(old_source, new_source, 1)
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
assert old_sums in text, "sha256sums=() placeholders not found"
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
with open("PKGBUILD", "w") as f:
f.write(text)
PY
grep -q "pkgver=${VERSION}$" PKGBUILD
! grep -q "SKIP" PKGBUILD
- name: Validate with makepkg and namcap
run: |
set -euo pipefail
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
# obvious way to write this, and was the first draft — but on a
# containerized Gitea act_runner job, `$PWD` is a path inside this
# job's own container, which the daemon's host cannot resolve; the
# mount would silently attach an empty directory instead of failing
# loudly. `docker cp` moves real bytes across that boundary
# regardless of where the daemon actually lives, which is what
# makes this work under both a bind-mount-capable runner and a
# containerized one.
docker pull archlinux:latest
CID=$(docker create -w /work archlinux:latest bash -c '
set -euo pipefail
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
useradd -m builder
chown -R builder:builder /work
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
echo "--- namcap ---"
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
echo "$NAMCAP_OUT"
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
# split-package variant ("triple-c-bin-debug E:") alike — namcap
# uses more than one line shape for its two rule families, and
# namcap itself exits 0 regardless of what it reports, so this
# grep is the only thing standing between an E: and a green job.
if echo "$NAMCAP_OUT" | grep -q " E: "; then
echo "namcap reported an error — see above" >&2
exit 1
fi
')
mkdir -p rendered
docker cp rendered/. "${CID}:/work"
# `docker start -a` streams output and its exit code is the
# container's own — the same failure this would have hit with a
# bind mount still fails the job the same way.
docker start -a "${CID}"
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
docker rm -f "${CID}" >/dev/null
- name: Push to AUR
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
VERSION: ${{ steps.resolve.outputs.version }}
run: |
set -euo pipefail
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
echo "the one-time AUR account setup this needs before it can publish anything." >&2
exit 1
fi
mkdir -p ~/.ssh
# Created with the final mode before any bytes land in it, rather
# than a plain redirect followed by chmod, which leaves the key
# world-readable for whatever window falls between the two calls.
install -m 600 /dev/null ~/.ssh/aur
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
# TOFU, not verification — accepted here because pinning AUR's
# actual host key needs a value fetched from somewhere trusted
# ahead of time, which this workflow doesn't have, and getting a
# pinned value wrong fails every future run rather than just this
# one. A keyscan failure below surfaces later as an opaque
# "Host key verification failed" rather than a clear one here.
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
git clone "${AUR_REPO}" aur-repo
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
cd aur-repo
git config user.name "Triple-C CI"
git config user.email "noreply@triple-c.invalid"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "No change from what's already published on AUR for ${VERSION}"
exit 0
fi
git commit -m "triple-c-bin: update to ${VERSION}"
# AUR itself uses `master`, which is what a fresh, not-yet-created
# AUR package's empty repo advertises on clone — but the *local*
# branch name after cloning an empty repo falls back to whatever
# this runner's `init.defaultBranch` is if the server sends no
# symref, so naming the destination explicitly is what keeps this
# working if that default is ever `main` instead of `master`.
git push origin HEAD:master
-59
View File
@@ -1,59 +0,0 @@
name: Sync Release to GitHub
on:
workflow_dispatch:
jobs:
sync-release:
runs-on: ubuntu-latest
steps:
- name: Mirror release to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
RELEASE_NAME: ${{ gitea.event.release.name }}
RELEASE_BODY: ${{ gitea.event.release.body }}
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
IS_DRAFT: ${{ gitea.event.release.draft }}
run: |
set -e
echo "==> Creating release $RELEASE_TAG on GitHub..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$RELEASE_TAG\",
\"name\": \"$RELEASE_NAME\",
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "Release created. Upload URL: $UPLOAD_URL"
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
echo "==> Downloading asset: $ASSET_NAME"
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
echo "==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Release sync complete."
+8 -3
View File
@@ -412,13 +412,18 @@ triple-c/
├── .gitea/ ├── .gitea/
│ └── workflows/ │ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows) │ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
│ ├── build-app-preview.yml # Preview builds │ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch) │ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image │ ├── build-stt.yml # Build the STT image
│ ├── sync-release.yml # Mirror releases to GitHub
│ ├── backfill-releases.yml # Bulk copy releases to GitHub │ ├── backfill-releases.yml # Bulk copy releases to GitHub
── cleanup-releases.yml # Prune old releases ── cleanup-releases.yml # Prune old releases
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
├── packaging/
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
│ ├── PKGBUILD
│ └── README.md
└── app/ # Tauri v2 desktop application └── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss ├── package.json # React, xterm.js, zustand, tailwindcss
+201 -24
View File
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
const GHCR_TOKEN_URL: &str = const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull"; "https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
/// The build-time preview suffix, if one was baked in and isn't blank.
///
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
/// it before patching those files, because the Windows MSI's `ProductVersion`
/// is a fixed-width numeric field with no room for one, and nothing here can
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
/// is the workaround: set as a build-time env var in the preview workflow
/// only, so `option_env!` bakes it into the binary without the bundle version
/// ever seeing it. A production build sets nothing, so `option_env!` reads
/// `None` here — see triple-c#32.
///
/// The single source of truth for "is this a preview build": both
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
/// (whether a same-numbered release counts as an update — see `pick_update`)
/// read this rather than each calling `option_env!` themselves, so the two
/// can never silently disagree about which build this is.
fn preview_build_suffix() -> Option<&'static str> {
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
}
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
match build_suffix {
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
_ => base.to_string(),
}
}
#[tauri::command] #[tauri::command]
pub fn get_app_version() -> String { pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string() format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
} }
#[tauri::command] #[tauri::command]
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
&[".AppImage", ".deb", ".rpm"] &[".AppImage", ".deb", ".rpm"]
}; };
// Filter releases that have at least one asset matching the current platform // `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
let platform_releases: Vec<&GitHubRelease> = releases // — the preview workflow patches `Cargo.toml` with that before compiling,
.iter() // never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
.filter(|r| { // so a preview build and the release it precedes compile to the identical
r.assets.iter().any(|a| { // numeric tuple by construction (see `build-app-preview.yml`'s "highest
platform_extensions.iter().any(|ext| a.name.ends_with(ext)) // tag used, +1" computation). A strict `>` therefore never fires for the
}) // one release a preview most needs to be offered. `is_preview_build`
}) // relaxes that one comparison to `>=` so "there is a real release at my
.collect(); // own number" reads as an update, without touching the production case
// — see `pick_update`.
let is_preview_build = preview_build_suffix().is_some();
// Find the latest release with a higher semver version match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None; Some(release) => {
for release in &platform_releases {
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
if best.is_none() || ver > best.unwrap().1 {
best = Some((release, ver));
}
}
}
}
match best {
Some((release, _)) => {
// Only include assets matching the current platform // Only include assets matching the current platform
let assets = release let assets = release
.assets .assets
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
} }
} }
/// Pick the newest available update out of a release list, or `None` if
/// nothing beats `current_semver`. Pure and synchronous — split out of
/// `check_for_updates` so the prerelease/platform/version filtering can be
/// tested without a live HTTP call.
///
/// Three filters, all of which must pass: not a prerelease (see the long
/// comment on `GitHubRelease::prerelease`), at least one asset for this
/// platform, and a tag that parses as semver *and* beats what is running. A
/// tag that does not parse — `preview-<sha>` (the shape
/// `build-app-preview.yml` actually creates release tags with), most
/// realistically — is skipped rather than erroring, the same as it always
/// has been; nothing here changes what an update tag is expected to look
/// like, only what channel it is allowed to come from.
///
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
/// `current_semver` is the bare number it was compiled with, which is by
/// construction identical to the release it precedes — see the comment at
/// `check_for_updates`'s call site — so a strict `>` would never fire for
/// exactly the release a preview install most needs to be told about.
fn pick_update<'a>(
releases: &'a [GitHubRelease],
current_semver: (u32, u32, u32),
platform_extensions: &[&str],
is_preview_build: bool,
) -> Option<&'a GitHubRelease> {
releases
.iter()
.filter(|r| !r.prerelease)
.filter(|r| {
r.assets
.iter()
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
})
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
.filter(|(_, ver)| {
if is_preview_build {
*ver >= current_semver
} else {
*ver > current_semver
}
})
.max_by_key(|(_, ver)| *ver)
.map(|(r, _)| r)
}
/// Parse a semver string like "0.2.5" -> (0, 2, 5) /// Parse a semver string like "0.2.5" -> (0, 2, 5)
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> { fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let clean = version.trim_start_matches('v'); let clean = version.trim_start_matches('v');
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
Some(format!("{}.{}.{}", major, minor, patch)) Some(format!("{}.{}.{}", major, minor, patch))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::models::GitHubAsset;
// ── format_app_version ──────────────────────────────────────────────
#[test]
fn a_production_build_reports_the_bare_version() {
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
// An empty env var (set but blank) must not print a trailing dash.
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
}
#[test]
fn a_preview_build_reports_its_suffix() {
assert_eq!(
format_app_version("0.4.12", Some("preview.a1b2c3d")),
"0.4.12-preview.a1b2c3d"
);
}
// ── pick_update ──────────────────────────────────────────────────────
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
GitHubRelease {
tag_name: tag.to_string(),
html_url: format!("https://example.invalid/{}", tag),
body: String::new(),
assets: asset_names
.iter()
.map(|name| GitHubAsset {
name: name.to_string(),
browser_download_url: String::new(),
size: 0,
})
.collect(),
published_at: "2026-01-01T00:00:00Z".to_string(),
prerelease,
}
}
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
#[test]
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn a_release_with_no_asset_for_this_platform_is_skipped() {
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn a_release_that_is_not_newer_is_not_offered() {
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
// A `-preview.<sha>` tag is exactly the shape this must not choke on
// or mistake for an update — it simply never parses as a bare semver.
let releases = vec![
release("preview-a1b2c3d", false, &["app.AppImage"]),
release("v0.4.12", false, &["app.AppImage"]),
];
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
assert_eq!(best.tag_name, "v0.4.12");
}
#[test]
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
let releases = vec![
release("v0.4.11", false, &["app.AppImage"]),
release("v0.4.13", false, &["app.AppImage"]),
release("v0.4.12", false, &["app.AppImage"]),
];
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
assert_eq!(best.tag_name, "v0.4.13");
}
// ── is_preview_build (>= instead of >) ─────────────────────────────────
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
/// offered the `v0.4.12` release that follows it, even though the two
/// compute to the identical numeric tuple.
#[test]
fn a_preview_build_is_offered_the_release_it_precedes() {
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
assert_eq!(best.tag_name, "v0.4.12");
}
#[test]
fn a_preview_build_is_not_offered_an_older_release() {
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
}
#[test]
fn a_production_build_still_requires_strictly_newer() {
// A production build must never treat "equal" as an update — that
// would perpetually re-offer the version already running.
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
}
}
/// Check whether a newer container image is available in the registry. /// Check whether a newer container image is available in the registry.
/// ///
/// Compares the local image digest with the remote registry digest using the /// Compares the local image digest with the remote registry digest using the
+48
View File
@@ -1,6 +1,54 @@
// Prevents additional console window on Windows in release // Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display:
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
/// code and killing the webview before Triple-C's own logging even starts —
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
///
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
/// variable is exported into an XWayland client's environment too, so a
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
/// there is no reliable heuristic at all for the actual variable that
/// matters, which Mesa/driver/compositor combination is affected. This is
/// the blunt instrument, chosen deliberately because the fallback is a real
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
/// GPU compositing, and it degrades to xterm's canvas renderer under this
/// setting — slower on very heavy output, but the addon's own construction
/// is already wrapped in a fallback (`WebGL not available` is a handled
/// case, not a crash), so this is a real but graceful downgrade, traded
/// against a startup abort that has no fallback at all.
///
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
/// their own init time, which happens inside the Tauri builder that
/// function calls into, not at binary load.
///
/// A user who has already set this themselves is left alone. That includes
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
/// rather than presence-only — not verified against WebKitGTK's own source,
/// so if it turns out to be presence-only, `=0` still reads as "set" here
/// and disables DMA-BUF the same as any other value, which is at least the
/// safe direction to be wrong in.
///
/// This env var also leaks to whatever the app spawns afterwards — notably
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
/// call. Narrow in practice (an already-running browser just receives the
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause.
#[cfg(target_os = "linux")]
fn apply_webkit_wayland_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
fn main() { fn main() {
#[cfg(target_os = "linux")]
apply_webkit_wayland_workaround();
triple_c_lib::run() triple_c_lib::run()
} }
+18
View File
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
pub body: String, pub body: String,
pub assets: Vec<GitHubAsset>, pub assets: Vec<GitHubAsset>,
pub published_at: String, pub published_at: String,
/// Whether GitHub itself has this release marked as a prerelease.
/// `#[serde(default)]` rather than required: every response GitHub sends
/// carries this, but nothing here should refuse to parse the rest of a
/// release over one missing field. Defaults to `false` (offered) rather
/// than `true` (excluded) — a missing field only happens if GitHub's API
/// shape changes, and "API changed, therefore updates silently stop
/// working forever" is the worse failure of the two.
///
/// `build-app.yml`'s own mirror never publishes a prerelease, but
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
/// tag already fails semver parsing on its own, so this field is not what
/// stops *that* case — it is what stops the case tag-parsing can't catch:
/// a normally-tagged release (`v0.4.13`) that someone marks as a
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
/// mirrors as-is. Real defence for that case, not a no-op.
#[serde(default)]
pub prerelease: bool,
} }
/// GitHub API asset response (internal). /// GitHub API asset response (internal).
+86
View File
@@ -0,0 +1,86 @@
# Maintainer: Triple-C Contributors
#
# This file is regenerated by .gitea/workflows/publish-aur-package.yml on every
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
# already-uploaded release asset, never guessed. Editing pkgver/source/
# sha256sums by hand here only matters until the next automated run overwrites
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
# maintained normally.
#
# "-bin" rather than building from source: this repackages the same .deb
# build-app.yml already produces and publishes, so a user gets exactly the
# binary the project ships and tests, and `makepkg` never needs a Rust
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
# with the installing system's — true for essentially every currently
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
# ships, and forward compatibility is the direction that holds.
pkgname=triple-c-bin
pkgver=0.4.0
pkgrel=1
pkgdesc="Sandbox Claude Code inside Docker containers"
arch=('x86_64')
url="https://github.com/shadowdao/triple-c"
license=('MIT')
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
# Arch container, and re-ran `ldd` on the actual binary until nothing came
# back "not found". `pango` and `libayatana-appindicator` were both in an
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
# transitively, and namcap correctly flags declaring it as redundant), and
# libayatana-appindicator is in Tauri's own linux dependency list but this
# binary never links it at all: there is no tray icon or menu in this app
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
# same reason), so it was never a real dependency to begin with.
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
optdepends=('docker: to actually run the sandboxed containers'
'xdg-utils: opening links from the app in your default browser')
provides=('triple-c')
conflicts=('triple-c')
# !strip: the upstream .deb's binary is already the release build Tauri
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
# no benefit. It's also what actually suppresses makepkg's debug-package
# machinery here (debug-package extraction requires strip; verified in a
# real build — with !strip alone, no debug package is produced at all).
# !debug is kept anyway, explicit about intent rather than relying on that
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
# tree containing a dangling .build-id symlink, which is a real namcap
# error (not just the empty-directory warning it looks like) — there is no
# debug info in this release binary for the machinery to have extracted in
# the first place.
options=('!strip' '!debug')
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name — verified against the real release, not
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
# because the .deb itself carries no license file — namcap flags an MIT
# package with nothing under /usr/share/licenses/ as an error, correctly.
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
sha256sums=('SKIP'
'SKIP')
package() {
cd "$srcdir"
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
# (part of base-devel's binutils) pulls just the payload out. Extracting
# that tar directly into $pkgdir works here with no path rewriting at all:
# verified against the real archive, whose entire payload is
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
# this app carries no separate resource directory under usr/lib/, so there
# is nothing that could disagree between Debian's and Arch's package trees
# for it to land in the wrong place.
#
# Globbed rather than named literally: the publish workflow discovers the
# real asset name from the release itself specifically so a Tauri bundler
# naming change can't silently break this — naming the file again here
# would throw that away and fail this one line with an opaque "No such
# file or directory" instead. `source=()` above guarantees exactly one
# `*_amd64.deb` entry, so the glob can only ever match that one file.
ar x ./*_amd64.deb
tar xf data.tar.* -C "$pkgdir"
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+53
View File
@@ -0,0 +1,53 @@
# Arch / CachyOS package
`PKGBUILD` here is the AUR `triple-c-bin` package's template — see triple-c#34
(the "I would like to also have an Arch/CachyOS native version" part of it).
## Why "-bin"
It repackages the same `.deb` `build-app.yml` already produces, rather than
building from source. That means `makepkg` never needs a Rust toolchain,
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
itself — and a user gets exactly the binary the project ships and tests,
built on Ubuntu 24.04 in CI. Verified end to end against a real release
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
against a real `ldd` of the actual binary (two packages that looked right
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
to be real dependencies of *this* binary and were dropped), and ran a real
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
## Publishing
`.gitea/workflows/publish-aur-package.yml` does the actual work: given a
version (or "latest" if none is given), it finds that release's real Linux
asset on GitHub, downloads it, computes real checksums, renders this
template into a version-specific PKGBUILD, validates it with `makepkg` and
`namcap` inside a real Arch container, and pushes the result to AUR.
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
header comment for why an automatic trigger isn't safe here (the same reason
`sync-release.yml` didn't work and was removed in triple-c#32).
**Before it can push anything**, an AUR account has to exist and the
`triple-c-bin` package has to have been created (or you added as a
co-maintainer) under it — both are one-time, manual steps on
https://aur.archlinux.org, since there's no API to automate creating an
account or a new package. Once that's done, add the account's SSH private
key as the `AUR_SSH_PRIVATE_KEY` secret on this repo. Until that secret
exists, the workflow fails at the "Push to AUR" step with a message saying
so, rather than silently doing nothing.
## What's hand-maintained vs. generated
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
the workflow rewrites them for every real publish and never commits the
result back here, so don't read this file's `pkgver` as "the last published
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
edited by hand normally, the same as any other PKGBUILD.
**A hand-edit made directly in the AUR repo is silently overwritten the
next time this workflow runs.** Every run renders fresh from *this*
repo's template rather than starting from whatever AUR's copy currently
looks like, so a quick fix pushed straight to AUR (bumping `pkgrel` for a
packaging-only issue, say) survives only until the next dispatch. Make
the fix here instead.