* feat(ci): publish the What's New card from the repository Move the post-update highlight document into the repo and publish it from GitHub Actions, so changing the card is a pull request instead of a task that needs personal Cloudflare credentials. - docs/whats-new.json is the source of truth, next to its spec doc. - pnpm guard validates it through the daemon's own parser on every PR, so a document that would silently show no card cannot merge. - whats-new-publish.yml uploads on merge to main (and on manual dispatch, with a read-only dry-run mode) and verifies the object by reading it back from the origin the daemon fetches, past the edge cache. * docs: name the CI job that actually runs the guard check * fix(ci): gate the What's New publish behind a main-only environment A workflow_dispatch runs the workflow file from the ref it is dispatched against, so the in-workflow main-only assertion was editable by whoever triggered it: anyone with write access could publish unreviewed content to every installed client from their own branch. Split the workflow so credentials live in one place and that place is gated by GitHub rather than by YAML. `validate` holds no secrets and runs on any ref; `publish` declares the `whats-new-publish` environment, whose deployment-branch policy allows main only and which owns the R2 secrets. Dropping the environment declaration to escape the branch policy also drops the secrets. Also points the card at the new Arena URLs supplied by product. * fix(ci): pin the What's New publish trust boundary in a check that runs The missing-credentials error still told operators the R2 keys are repository secrets. Following that string is the exact setup the previous revision closed: repository secrets are readable from any job on any branch, so a modified workflow dispatched from an unreviewed ref could publish to every installed client — or just take the keys. Point the error at the `whats-new-publish` environment and refuse the repository path in the same breath. The boundary itself had no coverage: nothing read the workflow, so moving `environment:` or a `secrets.*` expression back onto the arbitrary-ref job would stay green while still publishing correctly from main. That coverage cannot be lane-routed. Running the planner over `.github/workflows/whats-new-publish.yml` selects neither `web_tests_required` nor `ui_p0_validation_required`, so the `e2e_vitest` workload does not run for a workflow-only edit — an assertion living only there would be skipped for exactly the change class it guards. The check therefore lives in `pnpm guard`, which CI invokes from the unconditional preflight job, same reasoning as the document guard next to it. It asserts that the credential-bearing job declares the environment by its exact name, needs `validate` and is gated on its output with no `always()`/`cancelled()` escape, reads all four bucket bindings, and that no other job attaches an environment or reads any secret at all. The e2e suite drives it over the real workflow mutated into each of those regressions in turn, so every assertion is proven able to fail; gutting the checker turns all eleven red. * chore(whats-new): seed the document from the live card Bringing the workflow in with new copy attached ties the infrastructure to a content decision. The Arena CTA this document pointed at is not live — `/llm-arena-for-design/` and `/zh/llm-arena/llm-arena-for-design/` both 404 — and the card renders regardless, so merging would have put a dead button on every client's Home. Seed the repository copy from the object that is already serving instead: byte-identical to `https://whatsnew.open-design.ai/whats-new.json`, id `0.21.0`. That puts the current state under version control and makes the first automatic publish idempotent — it uploads exactly what is already there, so the merge exercises the whole chain (validate, upload, read-back against the origin the daemon fetches) without changing anything a user sees. The Arena copy is not lost; it moves to the PR body as a follow-up for the release owner, who can now change the card through this workflow without holding personal Cloudflare credentials. * fix(ci): detect secrets by context, not by access shape The guard added last round matched `${{ secrets.NAME }}` with a regex over the raw workflow text. A guard whose whole job is to stop a secret reaching the arbitrary-ref job had a blind spot for most of the ways to read one. Verified against the previous implementation, each returning zero violations while a secret reached `validate`: `secrets['NAME']`, `secrets["NAME"]`, `secrets [ 'NAME' ]`, `secrets . NAME`, `toJSON(secrets)`, a bare `secrets`, and `fromJSON(toJSON(secrets)).NAME`. Extending the regex would have repeated the mistake that caused it. One bypass proves why: a double-quoted YAML scalar may carry a backslash line continuation, so STOLEN: "${{ sec\ rets.NAME }}" parses to a live secret read while the source text contains no such substring. No text scanner can see that, however many shapes it lists. So the workflow is read through a YAML parser, and detection is a single property instead of an enumeration: GitHub's expression language has no string-to-context evaluation, so a secret cannot be reached without naming the `secrets` context as an identifier. Finding that identifier inside any `${{ … }}` expression covers every access form, including ones nobody has thought of. Parsing also retires the hand-rolled job splitter and its own failure modes, and lets the check distinguish "no violations" from "nothing was examined" — an unparseable document, a flow-style jobs mapping, or a renamed publish job now fail closed rather than passing quietly. Two holes the shape list never covered are closed as a side effect: a job-level `secrets:` key passes credentials to a called workflow with no expression at all, and workflow-level `env:` is inherited by every job. `yaml@2.9.0` becomes a root devDependency. It was already in the lockfile via `tools/release` and pinned repository-wide by the existing pnpm override, so the lockfile gains only the root importer entry. Test coverage moves with it: 26 of 46 cases now go red when the checker is gutted, up from 11. --------- Co-authored-by: lefarcen <ontf116@gmail.com>
tools/pack
Local packaging control plane for Open Design.
tools-pack is the cross-platform packaging and smoke-lifecycle control plane. The macOS commands include:
tools-pack mac build --to alltools-pack mac build --to app|dmg|ziptools-pack mac build --to all --signedtools-pack mac build --to all --portablefor release artifacts that must not bake local tools-pack runtime pathstools-pack mac installtools-pack mac starttools-pack mac stoptools-pack mac logstools-pack mac uninstalltools-pack mac cleanup
Build artifacts are namespace-scoped under .tmp/tools-pack/out/mac/namespaces/<namespace>/.
Public release bundles keep channel-distinct identities: Open Design.app, Open Design Beta.app,
Open Design Prerelease.app, or Open Design Preview.app. Local tools-pack install adds the developer
namespace so installs can coexist without affecting runtime data/log/cache paths.
Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/mac/namespaces/<namespace>/:
- Packaged daemon storage is governed only by the root
AGENTS.mdsection Daemon data directory contract. Before changing or documenting packaged storage propagation, you MUST read that section; this README MUST NOT restate it. logs/contains packaged process logs fordesktop,web, anddaemon.runtime/is the sidecar runtime base used by the packaged desktop/web/daemon process group.cache/is reserved for namespace-local packaged cache state.user-data/is the Electron/ChromiumuserDataroot, withuser-data/session/used forsessionData.
Every packaged root registers the exact five-field sidecar stamp
(channel / namespace / source / mode / app) in its own argv. Direct OS
launches register during packaged startup; tools-pack launches receive the same
stamp through the sidecar launch atomic. No identity file participates in
discovery or ownership.
tools-pack mac stop scans exact argv stamps for desktop, Web, and daemon across
both tools-pack and direct packaged sources, requests private graceful teardown,
waits for every captured generation to exit, and then force-stops only the
still-matching process trees. Cleanup and uninstall refuse to remove artifacts
while any captured generation remains alive.
This keeps stop from killing processes outside the current namespace.
Packaged desktop also writes main-process lifecycle logs to logs/desktop/latest.log so Finder/manual launches are
diagnosable. This log is intentionally scoped to packaged desktop startup/shutdown/process errors and does not capture
web/renderer console output.
The packaged daemon path contract lives only in the root AGENTS.md section
Daemon data directory contract. Before changing or documenting packaged
path propagation, you MUST read that section; this README MUST NOT restate it.
Packaged desktop checks release metadata, verifies the downloaded artifact, and exposes update actions through desktop IPC. Launcher-based builds prefer verified payload activation followed by relaunch; installer replacement remains the fallback for artifact types and older builds that cannot apply a payload in place.
Electron-builder resources live under tools/pack/resources/mac/. The current logo is staged there as the mac icon/DMG
placeholder so future design-provided assets can replace the resource files without changing packaging code.
Local developer artifacts bake the tools-pack namespace runtime root so tools-pack mac start/stop/logs/cleanup can manage
them from the repo. Release artifacts use --portable so the installed app resolves namespace data/log/runtime/user-data
from the user's Electron userData root instead of the build machine's .tmp path.
macOS compatibility notes
tools-pack mac build --portable --to zipis the safest manual-install artifact for Intel Macs. This path was smoke-tested on macOS 12.7.6 Monterey on a 2015 Intel iMac and the app launched successfully from/Applications.- Finder/manual launches on macOS may not inherit your shell-managed
PATH. If packaged Open Design cannot detect agent CLIs that work in Terminal, expose those binaries to the GUI login environment or launch the packaged app from a shell session that already sees them.
Windows
Local lifecycle commands:
tools-pack win build --to dirfor fast unpacked smoke builds.tools-pack win build --to nsisfor installer builds.tools-pack win build --to allfor both outputs.tools-pack win installtools-pack win starttools-pack win inspect --expr "document.title"tools-pack win logstools-pack win stoptools-pack win cleanuptools-pack win listtools-pack win reset
Build artifacts are namespace-scoped under .tmp/tools-pack/out/win/namespaces/<namespace>/.
Packaged runtime state is namespace-scoped under .tmp/tools-pack/runtime/win/namespaces/<namespace>/.
--to dir may point built-app.json at an immutable cached win-unpacked executable while keeping
namespace-local config and runtime paths outside that cache entry.
Linux
Local lifecycle commands:
tools-pack linux build --to all(default; produces AppImage)tools-pack linux build --to appimage(explicit AppImage)tools-pack linux build --to dir(unpacked output for fast iteration)tools-pack linux build --containerized(run electron-builder insideelectronuserland/builder:baseDocker for a wider glibc compatibility target — requires Docker)tools-pack linux build --to all --portable(release artifacts that must not bake local tools-pack runtime paths)tools-pack linux installtools-pack linux install --headless(install the headless launcher script instead of the AppImage)tools-pack linux starttools-pack linux start --headless(start the headless entry — daemon + web, no Electron)tools-pack linux stoptools-pack linux stop --headless(stop a running headless process)tools-pack linux inspect(desktop status, eval, and screenshot for AppImage mode)tools-pack linux inspect --headless(status only)tools-pack linux logstools-pack linux uninstalltools-pack linux uninstall --headlesstools-pack linux cleanuptools-pack linux cleanup --headless
Build artifacts are namespace-scoped under .tmp/tools-pack/out/linux/namespaces/<namespace>/. Packaged logs, sidecar runtime, cache, and Electron user-data are namespace-scoped under the tools-pack runtime root. Daemon storage follows only the root AGENTS.md Daemon data directory contract. Containerized build cache lives under .tmp/tools-pack/.docker-cache/{electron,electron-builder}/.
Local installs use XDG paths:
- AppImage:
~/.local/bin/Open-Design.<namespace>.AppImage - Menu entry:
~/.local/share/applications/open-design-<namespace>.desktop - Icon:
~/.local/share/icons/hicolor/512x512/apps/open-design-<namespace>.png
The <namespace> suffix is unconditional so multiple developer namespaces can coexist on the same desktop. The .desktop file registers the od:// scheme via MimeType=x-scheme-handler/od; and pre-sets OD_PACKAGED_NAMESPACE on the Exec= line so menu launches identify the correct namespace.
Headless mode (--headless)
Headless mode targets environments without a display (WSL2, headless servers, CI) where Electron can't run. If you have a desktop, use the AppImage; if you're SSH'd into a machine or in WSL, use headless.
--headless makes install, start, stop, uninstall, and cleanup operate on the headless entry (@open-design/packaged/dist/headless.mjs) instead of the AppImage. Headless mode runs daemon + web without Electron.
install --headlesswrites a shell launcher at~/.local/bin/open-design-headless-<namespace>that bakes in the namespace and resource paths. The launcher is self-contained, but the assembled app directory at those paths must remain in place — don't move it after install.start --headlesslaunches through the sidecar atomic and waits up to 95s for private status readiness.stop --headlessterminally stops the exactmode=headlessargv stamp. The mode field keeps it disjoint from the AppImage desktop without identity files or public IPC paths.inspect --headlessreturns status only. Eval and screenshot require AppImage mode because there is no Electron renderer in headless mode.uninstall --headlessremoves the headless launcher after a safe stop.cleanup --headlessstops the headless process before removing namespace output/runtime roots.
logs always reads logs/desktop/latest.log regardless of mode, so headless output is visible via tools-pack linux logs.
AppImage launch mode (FUSE caveat)
tools-pack linux start always spawns the AppImage with --appimage-extract-and-run. Smoke testing on Ubuntu 24.04 and Arch Linux showed that direct FUSE-mounted AppImage launches make Node module loads (Express, better-sqlite3, etc.) slow enough that the daemon sidecar consistently failed to clear apps/packaged's 35-second startup timeout. Extract-and-run unpacks the AppImage into /tmp/appimage_extracted_<hex>/ and exec's the inner Electron from there, bypassing FUSE and getting daemon boot in under 5 seconds — roughly an order-of-magnitude improvement.
Implication for end-users: if launching the installed AppImage manually (not via tools-pack linux start), pass --appimage-extract-and-run yourself, or rely on a desktop launcher / appimage-launcher daemon that handles extract-and-run automatically.
Optional system tools
tools-pack linux install and tools-pack linux uninstall invoke update-desktop-database and gtk-update-icon-cache as best-effort post-hooks. Either tool being absent (iconCache: "missing" in the output) is harmless — the icon and menu entry still work, the cache just isn't refreshed. Install via your distro:
- Arch / CachyOS:
sudo pacman -S desktop-file-utils gtk-update-icon-cache - Debian / Ubuntu:
sudo apt install desktop-file-utils gtk-update-icon-cache - Fedora:
sudo dnf install desktop-file-utils gtk-update-icon-cache
libfuse2 is needed for FUSE-mounted AppImage launch (the default mode when running an AppImage directly without --appimage-extract-and-run). tools-pack linux start always uses extract-and-run and bypasses FUSE entirely, so it does not need libfuse2. Most modern distros ship libfuse2 by default; older Ubuntu LTS hosts may need sudo apt install libfuse2t64 (or libfuse2 on pre-24.04).
Sandbox / chrome-sandbox
Electron 41 on Linux requires kernel.unprivileged_userns_clone=1 (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's --no-sandbox fallback. Most modern distros need no extra setup.
Distro compatibility target
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use --containerized to build against the electronuserland/builder:base baseline (Ubuntu 18.04 / glibc 2.27), which is the compatibility target for release AppImages rather than a guarantee for every Linux distribution.
Verified smoke coverage in this repository currently includes:
- Main PR CI: packaged Linux smoke is intentionally outside the main CI gate.
- Release lane: Ubuntu GitHub-hosted runner, containerized AppImage build plus Xvfb AppImage runtime smoke when the Linux release lane is enabled.
- Manual AppImage behavior used to choose
--appimage-extract-and-run: Ubuntu 24.04 and Arch Linux.
Format choice: why AppImage first
Linux desktop apps in this space split across formats: VS Code ships .deb + .rpm + Snap; Discord ships AppImage + .deb; Slack ships .deb + .rpm; Cursor and Obsidian ship AppImage. We start with AppImage because one artifact can cover the widest glibc-compatible target without distro repositories, store packaging, signing infrastructure, or per-format install scripts, and it integrates cleanly with the namespace-scoped install layout. .deb / .rpm / Snap / Flatpak can land incrementally when user demand justifies the extra release ownership.
Out of scope (later phases)
- AppImage signing (
--signed) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA). - AppImage auto-update feed (
latest-linux.yml) — the linux electron-builder config has nopublishblock wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing. - Additional package formats:
.deb,.rpm, Snap, Flatpak — deferred until there is demand and an owner for per-distro metadata, signing/store/repository plumbing, install/remove hooks, and release validation. - Full Linux AppImage and headless packaged smoke remain outside the main PR gate; run the applicable tools-pack validation manually or through a release lane when Linux packaging changes.
--to dmg is manual-install DMG output only. Any builder-generated updater metadata such as latest-mac.yml or
.blockmap files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative
latest-mac.yml feed during release asset preparation, pointing at the update ZIP.