Skip to content
Fran Gonzalez
← Back to blog
·Clanker·10 min read

Shipping DeepBlock 0.2.0: a verified Tauri release pipeline on GitHub Actions

The eight-job workflow that builds, installs, and byte-verifies a Tauri release across macOS and Debian, and the gotchas that shaped every guard in it.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

DeepBlock 0.2.0 is out. Every download was built by CI from the tagged commit, installed into clean Debian containers, and verified byte-for-byte before a human published anything. This is the pipeline that does it, and the failures that shaped it.

Note

DeepBlock is a localhost time-blocking app: a Rust/Axum server, a deepb CLI, and a Tauri desktop shell over a shared React UI. Daily plans are appended as revisions, never overwritten. The repo is frangonf/deepblock; the workflow is release.yml.

Tested with DeepBlock 0.2.0 on GitHub Actions (Ubuntu 24.04, macOS 15, Debian 12/13) on 2026-10-02.

The Problem

A Tauri release here is seven artifacts: a macOS dmg and app.tar.gz for aarch64-apple-darwin, standalone tools tarballs for two targets, and three Debian packages (desktop, CLI, server) for x86_64-unknown-linux-gnu. Five constraints drove the design:

  1. Provenance. Whatever ships must be built from the exact tagged commit, with versions agreeing across the Cargo workspace, the UI manifest, and Tauri’s config.
  2. Clean machines. Packages must install on stock Debian 12 and 13, run an unprivileged server and CLI, launch a desktop app under X11, and preserve user data across reinstall and purge.
  3. A hard CI-minutes budget. A full run costs ~38 minutes. Discovery loops are unaffordable; I burned roughly 700 included minutes learning that.
  4. No publication automation. CI creates and verifies a draft. A human publishes.
  5. Unprivileged everything. The token defaults to nothing; only one job ever gets contents: write.

The pipeline

preflight ──► quality gates ──► macOS arm64 ──► Debian 12 build
(validate     (tag SHA)        (adhoc or        (packages + native
 tag+versions)                   notarized)       WebKit test)
                                                     │
                    ┌────────────────────────────────┘
                    ▼
        Debian 12 + Debian 13 clean installs (fail-fast matrix)
                    │
                    ▼
        assemble (inventory + SHA256SUMS, 14-day artifact)
                    │
                    ▼
        draft upload (only job with contents:write; never publishes)

Each stage exists because of a concrete failure in an earlier run. The previous post covers the 45-minute CI hang in the native WebDriver test; this one is the rest of the machine.

Preflight: versions agree or nothing runs

The first job checks out the tag with full history and runs release.ts preflight: the tag must be annotated, point at a commit contained in origin/main, and the version must agree across every product manifest. The workflow builds needs.preflight.outputs.sha, never github.sha, so all downstream jobs test the tag’s commit even when the workflow itself was dispatched from a newer main.

Versions are bumped locally, dry-run first, through the same script:

mise run version:check
mise run release:prepare -- --version 0.2.0 --notes /tmp/notes.md           # preview
mise run release:prepare -- --version 0.2.0 --notes /tmp/notes.md --apply   # apply

Preparation updates the Cargo workspace, the UI package manifest, Tauri’s config, the product entries in Cargo.lock, and CHANGELOG.md, then goes through a PR like any other change. One detail that surprised me: Debian treats - in versions specially, so the packaging script maps prerelease hyphens to ~ (0.3.0-rc.1 → 0.3.0~rc.1) to keep upgrade ordering correct.

Builds, and two cache traps

Quality gates run on the tag SHA first: fmt, clippy, workspace tests, UI tests, Playwright, script tests. Two lessons are baked in as comments in the workflow itself:

# .github/workflows/release.yml
# Serialized deliberately: parallel cargo tasks contend for the workspace
# target-dir lock, and Playwright's `cargo run` webServer times out.

Cargo tasks run sequentially, CARGO_INCREMENTAL=0 bounds disk use, and the quality runner deletes ~25 GB of preinstalled Android/.NET/Haskell SDKs before starting, the same reclamation Firezone uses. An earlier pair of runs died with No space left on device mid-libdeepblock_desktop.a.

The macOS job runs next and gates the expensive Linux build: avoiding Debian runner minutes after a platform failure, and with no actions: write token the workflow avoids cancel-sibling gymnastics by just ordering the jobs. Signing has two modes selected by one env var: ad-hoc (APPLE_SIGNING_IDENTITY=-) or notarized via a protected macos-signing environment whose job imports a temporary keychain, masks its generated password, deletes the .p12 immediately, and restores the original keychain in an always() step. Missing notarization secrets fail loudly instead of silently falling back to ad-hoc.

The second trap was the build cache. Cargo invocations go through mise into mr-boxington, a cargo build cache, and cached build-script replay broke Tauri:

# .mbx.toml
# mbx's cached build-script replay drops tauri's colon-keyed links metadata
# (DEP_TAURI_CORE:path__CORE_PLUGIN___PERMISSION_FILES_PATH), which
# tauri-build needs to discover core ACL manifests. Without it
# tauri::generate_context! panics with SetPermissionNotFound
# "core:path:default". Cache hits are not worth broken ACL generation here.
build_script_execution = false

Tauri’s ACL manifests flow through links metadata emitted by build scripts. Replay that metadata lossily and generate_context! panics with SetPermissionNotFound. Compilation caching stays; build-script caching does not.

Three Debian packages, no WebKit for the headless ones

The Debian job builds in a digest-pinned debian:12-slim container. Tauri’s bundler produces the desktop .deb; a project script builds deepblock-cli and deepblock-server by hand with dpkg-shlibdeps-derived dependency lines, so the headless packages pull exactly their shared libraries, never WebKit. The smoke test asserts this explicitly later.

Packages upload before the native WebKit test runs. If native validation fails, the build artifacts survive for diagnosis while installation, assembly, and draft upload stay blocked. The test reuses the compiled release binary via DEEPBLOCK_TAURI_E2E_APP, removing a duplicate ~4 GB debug build from the job.

Clean-machine smoke: every directory level, owned

The install matrix runs the built packages inside digest-pinned debian:12-slim and debian:13-slim, fail-fast. The smoke script is dense on purpose:

# scripts/debian-smoke.sh
# `install -d` only applies -o/-g to the final listed directories, so every
# parent level is listed explicitly: the unprivileged server creates its data
# subdirectories beneath XDG_DATA_HOME/deepblock during startup.
install -d -o smoke -g smoke \
  "$XDG_DATA_HOME" "$XDG_DATA_HOME/deepblock" "$XDG_DATA_HOME/deepblock/notes" \
  "$XDG_CONFIG_HOME" "$XDG_CONFIG_HOME/deepblock" \
  "$XDG_STATE_HOME" "$XDG_STATE_HOME/deepblock"

That comment is the tombstone of this release’s most instructive bug. install -d -o smoke -g smoke "$XDG_DATA_HOME/deepblock/notes" creates the parents root-owned; the server, running as smoke, then fails create_dir_all with a bare Error: Io(Os { code: 13, kind: PermissionDenied }) and the health check never passes. Debian 13 failed, fail-fast cancelled Debian 12, and the whole run died 38 minutes in.

The fix came with discipline attached: I downloaded the run’s retained package artifact and replayed the smoke script locally in both pinned containers (~2 minutes each on a Linux box) before requesting another release run. docs/testing.md now includes the exact docker run replay as the standard move: retained artifacts first, full runs last.

The rest of the smoke script proves coexistence and hygiene: the server holds an advisory flock on server.lock; deepb add/list work against the running server; the desktop package installs alongside the headless ones and must survive a full 15-second dbus-run-session/xvfb-run interval (timeout --kill-after=2s bounds a TERM-handling app); then everything is reinstalled, purged, and a pre-recorded sha256sum manifest proves user data survived untouched.

Assembly and a draft that verifies its own bytes

The assemble job merges platform artifacts, checks the complete seven-asset inventory, and writes SHA256SUMS. The final job, the only one with permissions: contents: write, creates a draft and then distrusts its own upload: it re-downloads every asset from GitHub and compares bytes against the local checksums. It refuses moved tags and published releases before and after uploading, records source and tooling SHAs in the draft body, and never publishes. Ad-hoc macOS builds force --prerelease so an unsigned bundle can’t masquerade as stable.

There is one deliberate seam in the provenance model: a repair dispatch uses the workflow at the dispatch ref, while the application source stays pinned at the tag. Validation, packaging, and upload tooling are sparse-checked-out separately at github.workflow_sha, pointed at the tagged source via DEEPBLOCK_SOURCE_DIR. Release-tooling bugs can be fixed for old tags without moving the tag or recompiling newer app code; application fixes still require a new version. Even the APT-setup composite action is pinned to an explicit commit, because locked actionlint doesn’t yet understand GitHub’s $/ same-repo syntax; the alternative (workspace-relative action refs) would load tooling from the application checkout instead of reviewed tooling.

The GITHUB_TOKEN 403 and rerun permission context

With every stage green, the last run of 0.2.0 failed only at draft creation:

HTTP 403: Resource not accessible by integration
(https://api.github.com/repos/frangonf/deepblock/releases)

Four times, deterministically, while the same token could read private refs, and probe workflows I built (same repo, same contents: write, same environment, same flags, existing annotated v* tags) created releases fine minutes later. The resolution was mundane: the repository’s default workflow permissions were read-only, which community reports say can block the write grant for the release call1, and, the part that cost me three wasted reruns, reruns reuse the original run’s permission context2. A settings change only takes effect on a freshly triggered run. Flipping the default to write and dispatching fresh produced an all-green run, draft repaired and re-verified in-band.

This is safe in this repo because every job in both workflows declares explicit permissions (permissions: {} at the top level plus per-job minimums), so the default applies to nothing that exists; the docs now require any new workflow to declare its own block. And the runbook kept the escape hatch that shipped 0.2.0 the first time: the draft can always be completed locally from the run’s checksum-verified release-candidate artifact by running the same upload-draft.sh with an owner-authenticated gh: same upload, same re-download verification, still draft-only.

Results

  • One all-green run end-to-end: 38 minutes, from dispatch to a draft whose every byte was verified twice.
  • Eight downloadable assets: 7 platform artifacts plus SHA256SUMS, all built at tag 1af65a0.
  • The 0.3.0 cycle is now boring: release:prepare → PR → annotated tag → push → review draft → publish.
  • Since the candidate-and-replay discipline took hold: zero discovery dispatches. Every failure since was diagnosed from retained artifacts locally.

What I’d Do Differently

Check repository settings before building probe workflows. I constructed a five-experiment matrix to chase the 403 and even pushed a diagnostic workflow straight to main before a two-minute search surfaced the read-only-default explanation. The probes did rule out tag protection, rulesets, environments, triggers, and the gh CLI, but the answer was in gh api repos/OWNER/REPO/actions/permissions/workflow the whole time. And knowing that reruns inherit stale permission context would have saved three of the four failed attempts.

References

Footnotes

  1. GitHub community discussions and incident reports attribute draft release creation 403 errors to repository default permissions overriding job-level grants. See Setting GITHUB_TOKEN permissions. ↩

  2. When re-running a workflow or job, GitHub Actions preserves the original run’s permission context and secrets configuration from when the run was initially triggered. See Re-running workflows and jobs. ↩

This post was written with AI assistance.