[grade=B urn:ump:zluxbji5tepzdqybrctqfyjdvbfin2o66atw6rwykg6vdwkzhfsa] backlog v3: P6 Shipdeck era (DC-109-119) cross-platform + barrier-removal lane
CI / Test & Lint (push) Waiting to run
CI / Security audit (push) Waiting to run

- DC-109-112: native runtime, installer remote mode, self-distribution,
  updater v2 (Shipdeck engine; v0 DoD-verified)
- DC-113-115: first-run doctor, auth-gate helper, update nudge
- DC-116-119: Podman runtime, service-control abstraction, Mac signing
  (electron-builder, 5 acceptance gates), 3-OS CI matrix
- DC-P6-EVIDENCE.md: verbatim live-state evidence (E1-E6) backing the claims
- DC-102 status corrected (mismatched DiskSpaceMonitor annotation)
This commit is contained in:
Hermes
2026-09-14 05:22:16 -07:00
parent 65a447e27e
commit 7557b49b5b
2 changed files with 318 additions and 5 deletions
+201
View File
@@ -0,0 +1,201 @@
# Evidence file for DC-PRODUCTION-GRADE-BACKLOG.md P6 section (rev14)
Verbatim excerpts captured 2026-09-14 from the production tree `/opt/dashcaddy`
and the live DNS2 deployment, so every live-state claim in the backlog can be
checked against this file.
## E1. Self-updater contract — dashcaddy-api/src/docker/self-updater.js (production tree /opt/dashcaddy)
Verbatim grep output (`grep -n "UPDATE_URL\|MIRROR_URL\|CHANNEL\|checkInterval\|INTERVAL" ...`):
```
24: CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
25: UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
26: MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
35: CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
46: checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
47: updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
48: mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
64: channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
```
Revoked kill switch, verbatim (same file, lines 531/535):
```js
if (remote?.revoked === true) {
```
```js
reason: 'release revoked',
```
Proves: 30-min poll interval, update + mirror feed URLs, channel selection,
and the `revoked: true` kill switch — all in `self-updater.js` of the
production tree.
## E2. Shipdeck v0 DoD — shipdeck/docs/SPEC.md, verbatim
```
## Definition of done (v0) — MET 2026-09-14
- [x] `shipdeck deploy` lands a real Go hello-world on samihost end-to-end:
build → systemd active → caddy gate (tailnet 200 / public 403) → DNS on
DNS2+DNS1 → HTTP health 200 → journal row (epochs 1789360016, …0128,
…0610, …0672; rollback …0128→…0016 verified with content diff)
- [x] `shipdeck status` all green; `shipdeck rollback` swaps + re-verifies green
```
## E3. Version endpoint — production tree /opt/dashcaddy, verbatim grep output
PUBLIC_ROUTES membership (`src/utilities/middleware.js`):
```
488- { path: '/api/v1/monitoring/stats', exact: true, method: 'GET', monitoring: true },
489- { path: '/api/v1/health-checks/status', exact: true, method: 'GET', monitoring: true },
490: { path: '/api/v1/version', exact: true, method: 'GET' },
```
Route registration at startup (`src/app.js`, verbatim `sed -n '519,523p'`):
```js
appName = versionRoute.getName();
// Pre-build the version router once at startup and reuse it.
const versionRouter = versionRoute.buildRouter();
apiRouter.use(versionRouter);
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
```
(The version HANDLER source lives in the route module wired by
`versionRoute.buildRouter()``/api/v1/version` returns
`{ name, version, node, platform, arch, uptime, instanceId }` from
`package.json` read at startup, per the E5 live response below.)
## E4. Installer build targets — dashcaddy-installer/BUILD_GUIDE.md, verbatim
```
# Windows (creates portable .exe and installer)
# macOS zip (from any host; electron-builder's native target)
npm run build:mac
# macOS real drag-install .dmg — built ON LINUX, no Mac needed
# (one-time toolchain: see scripts/build-dmg-linux.sh header)
# Linux (creates AppImage and .deb)
npm run build:linux
```
Output artifacts (same file): portable + NSIS .exe (Windows), mac .zip +
Linux-built .dmg (macOS, unsigned), AppImage + .deb (Linux).
## E5. Live deployment state on DNS2 — complete captured output (rev14), capture start 2026-09-14T11:50:10Z, all three exit codes 0
The block below is the COMPLETE, unedited terminal capture of the three
commands (per-command timestamp + exit status embedded). The first command's
stdout is the full version.json — no ellipses; the long changelog string is
part of the real response.
```
=== E5 CAPTURE 2026-09-14T11:50:10Z — per-command timestamps below ===
$ curl -sk --resolve get.dashcaddy.net:443:127.0.0.1 https://get.dashcaddy.net/release/version.json
{
"version": "1.16.0",
"commit": "70e252c",
"channel": "stable",
"url": "https://get.dashcaddy.net/release/latest.tar.gz",
"sha256": "f4dd3a6efe70a99c64b2b2093af13b79943846f83546c76fba9f99b24e489357",
"publishedAt": "2026-09-13T12:45:26Z",
"changelog": "DashCaddy v1.16.0 \u2014 self-updater hardening + auto-update enabled + docker disk discipline\n\nFIXES\n- self-updater: same-version releases are NEVER \"newer\" again (DC-122). Commit\n labels are opaque build stamps; only a semver bump counts. This bug made\n same-version installs with any commit-string difference report\n \"update available\" forever and would have re-applied stale tarballs in a\n loop once auto-update was enabled.\n- self-updater: _autoCheckAndApply skips re-applying an identical\n version@sha256 within a process lifetime (defense-in-depth against apply\n loops).\n- dashcaddy-update.sh: data-dir cp fallback copied the directory INTO the\n destination (nested data/data), so rollback restored nothing. Now copies\n contents (\"dir/.\") \u2014 backup AND restore paths fixed.\n- dashcaddy-update.sh: frontend is now snapshotted before deploy and restored\n on build-failure and health-check-failure rollbacks. Previously a failed\n update left the NEW frontend paired with the ROLLED BACK API.\n- start.sh: bundle sync is newer-source-only. The old unconditional copy\n reverted self-updater-deployed frontends on every container restart.\n- dashcaddy-update.sh: docker prune now runs on the success path, both\n failure paths, and after rollbacks (shared prune_docker helper).\n\nCHANGES\n- start.sh: DASHCADDY_UPDATE_ENABLED=true \u2014 auto-update is ON (Sami 2026-09-13).\n- start.sh + fallback docker run: json-file log caps (10MB x 3) so container\n logs can never grow unbounded again.\n\nTESTS\n- __tests__/self-updater-isnewer.test.js: 8 regression cases covering the\n same-version commit-mismatch bug, semver ordering, and edge inputs."
}curl_exit=0 at 2026-09-14T11:50:10Z
$ curl -s -m 6 http://127.0.0.1:3001/api/v1/version
{"success":true,"name":"dashcaddy-api","version":"1.16.0","node":"v20.11.1","platform":"linux","arch":"x64","uptime":1768.092256197,"instanceId":null}curl_exit=0 at 2026-09-14T11:50:10Z
$ docker inspect dashcaddy-api --format '{{range .Config.Env}}{{println .}}{{end}}' | grep UPDATE
DASHCADDY_UPDATE_ENABLED=true
grep_exit=0 at 2026-09-14T11:50:10Z
```
Proves, AS OF the capture timestamp (a dated deployment snapshot, not
necessarily current state at review time): production DNS2 ran DashCaddy
v1.16.0, channel `stable`, auto-update ON, feed reachable, and no `revoked`
key in the live feed (kill switch not engaged). Re-run the three commands
above to refresh.
## E6. Update-stamp contract — live + source capture, rev14
(a) Live stamp file — `/var/www/dashcaddy-status/update-stamp.json` (verbatim `cat`):
```json
{"version":"1.16.0","at":"2026-09-13T12:47:56Z"}
```
(b) The ACTUAL writer: `/opt/dashcaddy/scripts/dashcaddy-update.sh` (host-side
helper executed by the self-updater's update flow; the Node self-updater
orchestrates, this script writes the stamp — see excerpt, verbatim `sed -n '714,725p'`):
```bash
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
fi
# DC-122: host-side deployment stamp — start.sh treats a stamped, newer
# deployment as authoritative and skips its source-bundle sync (this is
# the only writer that can reach the web root with real host paths).
local esc_ver
esc_ver=$(json_escape "$to_version")
printf '{"version":"%s","at":"%s"}\n' "$esc_ver" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
> "$frontend_target_dir/update-stamp.json" 2>/dev/null || true
fi # DC-122 close: validated frontend-target branch
fi
```
(version JSON-escaped, UTC timestamp, written into the validated
frontend-target dir; `|| true` only guards the stamp write itself — the
deployment result is recorded separately in updates/result.json.)
(b2) Self-updater call-site — `/opt/dashcaddy/dashcaddy-api/src/docker/self-updater.js`
lines 303-313 (verbatim `sed -n '303,313p'`), showing the orchestrator writing
trigger.json and deferring stamp writing to the host-side helper:
```js
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2)
);
// DC-122 note: the frontend deployment stamp (update-stamp.json) is
// written by the HOST-side dashcaddy-update.sh after it syncs the
// frontend — the container has no bind mount for the web root, so
// writing the stamp here would silently target the container layer.
// The host-side systemd service will handle the rest.
```
So the identity chain is: Node self-updater (orchestrator, writes
trigger.json) → host-side `dashcaddy-update.sh` (executed via the systemd path
unit; writes update-stamp.json into the web root). The word "writer" in (b)
refers to dashcaddy-update.sh specifically. A repo-wide search was NOT run to
prove it is the only writer; the excerpt above proves the intended division of
labor, not uniqueness.
(c) Sync-gating side: `/opt/dashcaddy/start.sh` (verbatim `sed -n '150,170p'`) —
start.sh treats the stamp as the frontend authority and skips its source-bundle
sync while the stamp file's MTIME beats the source tree's mtime — authority is
decided by comparing file mtimes, not by parsing version values inside the
files (prevents restart-reverts):
```
# Sync the freshly-built dashboard bundle into the static directory Caddy
# serves — decided by VERSION METADATA, not file mtimes (mtimes are not
# reliable: scp/tar/cp can preserve or shuffle them). DC-122 contract:
# - The self-updater writes a STAMP (update-stamp.json) into the live web
# root when it deploys a frontend; while that stamp is newer than the
# source tree's VERSION file, start.sh must NOT touch the live bundle.
# - Normal builds: publishing bumps the source VERSION (mtime = build time)
# and clears any stale stamp, so source wins and the sync happens.
echo "[start.sh] Syncing dashboard bundle into static dir (metadata-driven)..."
mkdir -p /var/www/dashcaddy-status/dist
if [ -d /opt/dashcaddy/status/dist ]; then
NEEDS_SYNC=1
STAMP=/var/www/dashcaddy-status/update-stamp.json
SRC_VERSION=/opt/dashcaddy/dashcaddy-api/VERSION
if [ -f "$STAMP" ] && [ -f "$SRC_VERSION" ] && [ "$STAMP" -nt "$SRC_VERSION" ]; then
# A self-updater deployment is newer than the last source build: hands off.
echo "[start.sh] Deployed frontend stamp newer than source VERSION — skipping sync to preserve deployed frontend."
NEEDS_SYNC=0
fi
if [ "$NEEDS_SYNC" = "1" ]; then
```
(d) Rollback interplay: `dashcaddy-update.sh` restore_frontend() REMOVES the
stamp on rollback (verbatim, lines 356-358) so start.sh's source sync resumes
authority — the restored frontend is not a self-updater deployment:
```bash
# Rollback removes the deployment stamp: the restored frontend is NOT a
# self-updater deployment, so start.sh's source sync must resume authority.
rm -f "${target:?}/update-stamp.json"
```
Proves the update-stamp-on-frontend-deploy behavior cited by DC-112 end to
end: writer identity, stamp content (version + UTC timestamp), sync gating,
and rollback semantics.
+117 -5
View File
@@ -1,8 +1,10 @@
# DashCaddy Production-Grade Backlog (v2) # DashCaddy Production-Grade Backlog (v3)
> Generated 2026-08-12 from a full codebase audit. > Generated 2026-08-12 from a full codebase audit; last revised 2026-09-14 (P6 added).
> v1 items (P0-1 through P2-7) are ALL DONE. > v1 items (P0-1 through P2-7) are ALL DONE.
> Current state: 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings. > NOTE: the "Current Health Snapshot" below is HISTORICAL (2026-08-12 audit snapshot),
> kept for trend reference — re-run the audit before quoting these numbers.
> Snapshot values (not current): 1539 tests, 86.55% statement coverage, 0 ESLint errors, 173 warnings.
## Current Health Snapshot ## Current Health Snapshot
- **Tests:** 1539 passing across 63 suites - **Tests:** 1539 passing across 63 suites
@@ -257,7 +259,7 @@
- **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it. - **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it.
### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record ### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record
- **status:** already done (DiskSpaceMonitor) - **status:** pending (prior "already done (DiskSpaceMonitor)" annotation was a mismatched status note — DiskSpaceMonitor is a DC-101 disk-budget component, not a deploy-chain implementation; the deploy chain itself is not wired)
- **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr. - **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr.
- **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform. - **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform.
@@ -293,6 +295,115 @@
--- ---
## P6 — Shipdeck era: cross-platform & barrier removal (added 2026-09-14, Sami directive)
> Source: "further improve DashCaddy and remove barriers to quality usage of it
> on all platforms including PC, Mac and Linux" — planned alongside the Shipdeck
> v0 landing (`shipdeck/docs/SPEC.md` in the shipdeck repo, "Definition of done
> (v0) — MET 2026-09-14": hello-world on samihost, build → systemd active →
> caddy gate (tailnet 200 / public 403) → DNS on DNS2+DNS1 → HTTP health 200 →
> journal row, verified rollback). The Shipdeck SPEC names DashCaddy-family use:
> "the
> deploy engine under the DashCaddy family (DashCaddy panel drives the CLI)";
> these items wire that in. Build order: Lane A deployment (DC-109112) →
> Lane B onboarding (DC-113115) → Lane C platform parity (DC-116119).
>
> **Lane acceptance criteria (lane-level outcomes; a lane is done when these hold):**
> - Lane A: on a Linux/systemd host where Docker is absent or unused, a real
> service completes a fresh shipdeck deploy, one in-place update, and one
> rollback — each verified green. Docker regression criteria (must all hold
> after each lane item): existing Docker services still deploy/update/roll
> back through the unchanged Docker path; existing services.json configs
> load without migration errors; the full Jest suite passes at or above the
> current gate. Secrets are never logged. This lane owns the
> rollback-failure requirement: DC-112 must auto-rollback and alert on
> failed post-update health.
> - Lane B: on a fresh environment (new browser profile / clean VM), the
> doctor detects each seeded environment fault, the auth helper names the
> failed hop, and the update nudge stays silent when current.
> - Lane C: every parity claim is CI-proven per OS (build + boot + smoke),
> not grep-audited.
### DC-109: Native (Docker-free) service runtime — services gain `runtime: docker|native`
- **status:** pending
- **details:** Extend the service model with a runtime field. `docker` = today's behavior, unchanged. `native` = the Shipdeck pipeline: local build → tarball → scp → `/opt/<name>/releases/<epoch>/` + `current` symlink → systemd unit → Caddy block → DNS → verify → rollback. All implemented and DoD-verified in Shipdeck v0 (`shipdeck/docs/SPEC.md` — "Definition of done (v0) — MET 2026-09-14": hello-world on samihost, tailnet 200 / public 403, DNS on DNS2+DNS1, journal row, verified rollback). A host running only native services needs NO Docker. **Scope note: DC-109112 initially target remote Linux/systemd hosts; local macOS/Windows native service parity arrives via DC-117.** Effort: ~6 hr (services.json schema + deploy routing + status surfacing).
- **impact:** Removes the largest install dependency identified in this lane's analysis (local Docker Desktop) for PC/Mac users deploying to a remote server; native rollback is one command instead of `docker build` on the VPS.
### DC-110: Installer "remote server" mode — SSH target, zero local dependencies
- **status:** pending
- **details:** The Electron installer asks: this machine (classic 5-step wizard, Docker mode) or remote server (SSH host + key, Shipdeck mode). Remote mode skips the Docker/Caddy dependency checks entirely — it configures the remote host and prints the dashboard URL. This is the Mac unlock: no Docker Desktop account, no local engine, works from any laptop. Single-host seed of DC-108 (fleet). Open design points to settle at build time: whether remote mode also provisions remote Caddy/DNS or guides the operator through them, SSH host-key verification policy, and secret-handling (keys never logged, never stored plaintext beyond the user's chosen location). Effort: ~5 hr.
- **impact:** Turns "install DashCaddy" from a multi-step dependency hunt into a short wizard; removes the largest single install dependency (local Docker) for non-Linux users.
### DC-111: DashCaddy dogfoods its own distribution via Shipdeck (native self-host path)
- **status:** pending
- **details:** Publish a native (non-Docker) DashCaddy flavor: release tarball + generated systemd unit + `current` symlink swap, deployed by Shipdeck. `start.sh` stays for the Docker flavor; the native flavor makes updates a symlink swap instead of `docker build` on the user's VPS. Same release feed (version.json + `revoked` kill switch). Effort: ~4 hr.
- **impact:** Installs DashCaddy on hosts without Docker; dogfoods the native path we are selling.
### DC-112: Updater v2 — release-dir + symlink-swap updates for the native engine
- **status:** pending
- **details:** Generalize the self-updater shipped in DashCaddy v1.16.0 to the native flavor. Verifiable source of the existing contract: `dashcaddy-api/src/docker/self-updater.js:531` relative to the production-tree root `/opt/dashcaddy` — 30-min release-feed poll (`CHECK_INTERVAL: 30 * 60 * 1000` at line 24), channel selection (`CHANNEL` at line 35), `"revoked": true` kill switch (that line), update-stamp on frontend deploys — written by the host-side `scripts/dashcaddy-update.sh` helper which the self-updater orchestrates (verbatim excerpts, live stamp file, and sync-gate capture in `DC-P6-EVIDENCE.md`, E1/E6); the deploy/rollback flow is also documented in the `dashcaddy-ops` skill, "Self-updater (v1.16.0+, DC-122)" section). Native flavor: download tarball → new release dir → swap `current` → restart → health check → auto-rollback on failed health. Docker path untouched. Effort: ~5 hr.
- **impact:** Native installs get the same hands-off updates and rollback safety Docker installs already have.
### DC-113: First-run doctor — preflight checks + one-click fixes
- **status:** pending
- **details:** One screen at first run (and from Help): Docker reachable? Caddy binary + admin port? ports 80/443 free? DNS resolvable? disk space? Each check shows fix instructions or a one-click fix where safe. Kills the "blank page on gated service" support class (documented failure mode). Effort: ~4 hr.
- **impact:** Support experience to date (DashCaddy sessions 2026-05→09) has been dominated by environment/config issues rather than code bugs; the doctor turns that class into self-service.
### DC-114: "Why am I seeing this?" helper on the TOTP/auth gate
- **status:** pending
- **details:** The auth gate is a recurring confusion point (documented in the dashcaddy skill reference `totp-session-ip-key-inconsistency.md`, including the operator report "I keep providing a code and it doesn't work"). Login page gets inline diagnostics: which hop failed, cookie status, IP-consistency note, retry guidance. Server returns structured reason codes instead of bare 401. Effort: ~3 hr.
- **impact:** Converts the documented auth-gate drop-off case into a guided flow.
### DC-115: Update nudge in the dashboard
- **status:** pending
- **details:** Footer badge comparing running version vs latest release feed; links to the update flow; dismissible; silent when current. The public version endpoint is verified in the live tree: `/api/v1/version` is in `PUBLIC_ROUTES` (`src/utilities/middleware.js`) and wired at startup (`src/app.js`). Latest-version source = the same release feed the self-updater polls (v1.16.0 contract). Effort: ~2 hr.
- **impact:** Users running months-old builds file phantom bugs; the nudge keeps fleets current.
### DC-116: Podman as a supported container runtime
- **status:** pending
- **details:** Podman speaks Docker's socket API, so most DashCaddy container paths work against it with runtime detection + docs + a CI smoke test (rootless/quadlet notes included). Positions DashCaddy for orgs that cannot run Docker Desktop (licensing) and answers the "Docker vs open-source alternatives" wave with support instead of migration. Effort: ~4 hr.
- **impact:** Business-friendly runtime choice; removes licensing objections in the sellable tier.
### DC-117: Service-control abstraction (systemd/launchd/Windows service) + per-OS static Pylon
- **status:** pending
- **details:** One service-control API over systemctl / launchd / sc.exe; Pylon ships as a single static agent per OS (no Node runtime required on managed hosts). `platform-paths.js` stays the single source of truth for paths (v1.12.0 lesson). Effort: ~8 hr.
- **impact:** True cross-platform management, not Linux-with-caveats.
### DC-118: Mac Gatekeeper trust path — electron-builder native signing + notarization
- **status:** pending
- **details:** The Linux-built .dmg/.zip are unsigned → macOS Gatekeeper blocks/scare-warns on first open, and NO lightweight measure removes that friction: **ad-hoc signing does not establish developer identity and does not satisfy Gatekeeper distribution trust** (needs ~$99/yr Apple Developer Program). The fix: **electron-builder's built-in mac signing + notarization** — it signs nested frameworks/helpers with entitlements before the outer app (never hand-rolled `codesign --deep`) and submits notarization itself. Requires `electron-builder >= 24` (verify the version pinned in `dashcaddy-installer/package.json` at implementation time).
Implementation shape (config, not a hand-written script — the script gets written and exercised IN this item's PR where a `macos-latest` runner can prove it):
```js
// electron-builder.config.js (mac section) — shape valid for electron-builder 24.x26.x.
// PIN the actual major against dashcaddy-installer/package.json at implementation time;
// if the pinned major is >= 27, migrate per its mac.sign/notarize schema change before use.
mac: {
identity: "Developer ID Application: <name> (${TEAMID})",
hardenedRuntime: true,
gatekeeperAssess: true,
entitlements: "build/entitlements.mac.plist",
notarize: true, // auto notarize + staple on CI (24.x26.x shape)
forceCodeSigning: true, // FATAL on missing credentials — no silent unsigned artifacts
}
```
CI env (Actions secrets only): `CSC_LINK` (the base64-decoded .p12 file path) + `CSC_KEY_PASSWORD` (the .p12's password) — with a CSC_LINK p12, electron-builder imports it into its OWN temporary keychain internally, so the PR must NOT hand-roll keychain lifecycle (no custom create/unlock/partition-list code); notarization uses `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID`.
**Acceptance criteria (this item is done only when all pass on a real `macos-latest` run):**
1. `electron-builder --mac` exits 0 with signing + notarization enabled.
2. Produced `.dmg`: `xcrun stapler validate <dmg>` passes and `spctl -a -t open --context context:primary-signature-id -v <dmg>` reports accepted.
3. Produced `.zip`: extract it, then run `spctl -a -t exec -v <extracted .app>` on the contained app — must report accepted. Stapling is per-bundle: the `.app` inside the ZIP carries electron-builder's staple; the ZIP container itself cannot be stapled, and Gatekeeper on macOS 12+ re-queries Apple's notarization service online at first open to assess the signed `.app` inside.
4. Notary submission id logged; on failure, `xcrun notarytool log <id> …` output is attached before any retry.
5. First-open gate on a clean macOS VM must exercise the real download path with quarantine applied: download via a browser, OR confirm/add the xattr explicitly after any non-browser transfer (`xattr -w com.apple.quarantine "0081;00000000;Safari;" <file>` — do NOT rely on plain `curl`/`scp` to set it; macOS may not apply quarantine to CLI-downloaded files). First open then shows no Gatekeeper block.
Drafting-review pitfalls from the 2026-09-14 adversarial rounds are HISTORICAL and inapplicable to this electron-builder approach (they applied to an earlier hand-rolled shell-script draft: keychain passwords, `mktemp -u`, search-list restore, filename gating — all now handled by electron-builder's internal keychain management). The version-validation rule stands: at implementation, confirm the pinned electron-builder major's actual `mac.sign`, `notarize`, and `forceCodeSigning` schema against its docs — do not trust the broad 24.x26.x shape above without checking.
The Linux .dmg build this slots into is documented in `dashcaddy-installer/BUILD_GUIDE.md` (libguestfs HFS+ volume + libdmg-hfsplus UDZO). Effort: ~2 hr once enrolled (+$99/yr Apple Developer Program).
- **impact:** Today the first Mac impression is a security warning; the trust path must be fixed before paid acquisition, and only real signing + notarization does it.
### DC-119: Cross-platform CI matrix — boot it on all 3 OSes per release
- **status:** pending
- **details:** Per release: build API + installer on Windows/macOS/Linux runners, boot the API, run smoke probes (version + health), run the installer's dependency-checker in report mode. Replaces grep-audits with runtime proof — matches the reproducibility principle (Sami 2026-06: "other people can do the same things and expect reproducibility"). Effort: ~6 hr.
- **impact:** Platform-parity claims become tested facts, not hopes.
---
## Summary by Priority ## Summary by Priority
| Priority | Count | Effort | Theme | | Priority | Count | Effort | Theme |
@@ -305,4 +416,5 @@
| P3.5 | 9 (DC-086094) | ~14.5 hr | Operational maturity | | P3.5 | 9 (DC-086094) | ~14.5 hr | Operational maturity |
| P4 | 6 (DC-095100) | ~16.5 hr | Advanced features | | P4 | 6 (DC-095100) | ~16.5 hr | Advanced features |
| P5 | 8 (DC-101108) | ~29 hr | Product vision: self-hosting platform | | P5 | 8 (DC-101108) | ~29 hr | Product vision: self-hosting platform |
| **Total** | **47** | **~110.5 hr** | | | P6 | 11 (DC-109119) | ~49 hr | Shipdeck era: cross-platform & barrier removal |
| **Total** | **58** | **~159.5 hr** | |