Skip to content

title: phpboyscout/cicd — svelte-* frontend track (build, lint, test, security) description: Add a svelte-* component track for Go projects that embed a Svelte web UI (keyrx, haileys-app: pkg/studio/web/ built into pkg/studio/web/embed/ via //go:embed). Covers the release build-order (frontend bundle must be built before goreleaser embeds it), the embed coupling with the Go gates, and a security-forward set of frontend scanners. status: implemented date: 2026-06-23 authors: [Matt Cockayne] tags: [spec, cicd, components, svelte, frontend, security]


Spec: svelte-* frontend track

  • Repository: gitlab.com/phpboyscout/cicd
  • Status: implemented — shipped across cicd v0.17.0 (svelte-build, svelte-security) and v0.18.0 (svelte-lint, svelte-test).
  • Target: new components, released across one or more minors (see D8).
  • Driver: Go projects built on the go-tool-base pattern now ship a Svelte web UI embedded in the Go binary. We need (a) the frontend bundle built before release, (b) the Go↔frontend change coupling handled, and © frontend validation + a strong security posture.

Context — the concrete structure (keyrx, haileys-app)

  • Svelte app: pkg/studio/web/ — vite + @sveltejs/vite-plugin-svelte, npm (package-lock.json), scripts dev / build / preview.
  • Build output embedded via pkg/studio/embed.go: //go:embed all:web/embedpkg/studio/web/embed/.
  • A committed fallback bundle + a "synthetic FS" unit test (embed_test.go) means go build compiles standalone (no hard build-order dependency for the Go gates); the real bundle is only required at release time.
  • go-tool-base itself is the base library (no UI); its descendants (keyrx, haileys-app) carry the UI.

Decisions

D1 — Two coupling points, handled separately

  1. Release build-order (D2): the release binary embeds web/embed/, so the real Svelte bundle must be built before goreleaser runs.
  2. CI change coupling (D7 + the change-detection spec): a pkg/studio/web/** change alters the embedded UI that Go tests (embed_test.go, served-SPA tests) assert on, so it must also trigger the relevant Go gates — not only the frontend jobs.

Everything else (lint, test, security) is ordinary MR-gated frontend work that runs only when frontend files change.

D2 — svelte-build component (closes the release gap)

A component that builds each frontend root and exposes the bundles as artifacts:

  • Runs on the dev-tools image (Node 22 + corepack already baked).
  • paths input (required, no default) — space-separated frontend root(s). Each job iterates: for dir in $[[ inputs.paths ]]; do (cd "$dir" && npm ci && npm run $[[ inputs.build_script ]]); done (build_script default build). Output dir input (default embed).
  • All built output dirs are exposed as a job artifact so the release job consumes them: goreleaser gains needs: [svelte-build] with artifacts: true in the consumer, so goreleaser embeds the real bundle(s). (Alternative — a goreleaser before.hooks entry — rejected; it couples the build into every consumer's .goreleaser.yaml.)
  • Cache ~/.npm (and/or each node_modules) keyed on package-lock.json (stable per-project key, per the cache-key convention).
  • MR-gated by default for visibility; on a release tag it runs to feed goreleaser.

Open question for review: do we build the frontend in svelte-build and hand the artifact to goreleaser, or have goreleaser itself depend on a frontend build? The artifact-handoff is cleaner and cacheable.

Revised (2026-06-23, post-adoption). The blanket "before-hook rejected" stance above was wrong. Projects that embed the SPA via go:generate (keyrx, krites) already build pkg/studio/web/embed from a //go:embed-adjacent //go:generate bash build-web.sh directive, run at release by goreleaser's before: hooks: - go generate ./.... That hook also makes a plain go build / go install produce a working (or graceful-placeholder) binary — something a CI-only component cannot. For those projects the before-hook is the build path, and svelte-build is redundant (it would build the bundle a second time at tag). So the resolved guidance is per-project:

  • go:generate embed projects → do NOT wire svelte-build. The release build is the goreleaser go generate before-hook (build-web.sh); CI uses svelte-lint / svelte-test / svelte-security only. This is correct because all gating happens in MRs, never the tag: security is always-on so it re-runs on the releaser-pleaser Release MR (a merge_request_event) against the latest CVE DB right before the tag is cut, and the tag pipeline then builds as-is.
  • Projects that do NOT use go:generate → use svelte-build + a goreleaser needs: [svelte-build] artifact handoff (the original D2). The component is retained for exactly this case.

D3 — svelte-security component (the security emphasis)

Mirrors go-security / rust-security (one job per scanner, MR-gated, secret scanning always-on). Maximises posture for a web UI across three layers — known vulns, malicious packages, and provenance/ integrity — with every free/OSS tool shipping in v1.

Always-on (free / open-source): - SAST / XSSsemgrep with p/javascript, p/typescript, p/xss, p/secrets rulesets. XSS is the top web-UI risk; semgrep is already our Go SAST (go-security analyze), so this is consistent. Hard-fail on ERROR. - Known-vuln deps (CVE-DB)osv-scanner on package-lock.json (same tool as Go; covers npm) and npm audit --audit-level=high (npm-native; catches advisories osv may lag on). - Known-vulnerable JS librariesretire.js (bundled/outdated libs with CVEs). - Provenance / integritynpm audit signatures (registry provenance) and lockfile-lint (every resolved URL is HTTPS + the official registry — catches dependency-confusion / registry-spoofing; free, no account). - SBOMcyclonedx-npm emits a frontend SBOM artifact. - Secretsgitleaks scans all files (web dir in scope). Always-on, not path-filtered.

Malicious-package layer — OSS defense-in-depth (Socket dropped). Socket/Phylum (behavioral analysis) are commercial + GitHub-centric, and no free turnkey OSS behavioral scanner exists (OpenSSF package-analysis needs sandbox infra, not a CI one-liner). So we cover the vectors with free/OSS controls instead: - Known-malicious packagesosv-scanner already ingests the OSV malicious-packages feed (MAL-#### advisories); covered by the existing osv job at no extra cost. - Malicious postinstall scripts — run npm ci --ignore-scripts in CI (+ a documented allowlist for deps that genuinely need scripts), neutralising the main execution vector. - Dependency-confusion / registry spoofinglockfile-lint (above). - Provenancenpm audit signatures (above). - Novel malicious new releaseRenovate minimumReleaseAge (3–7 days) in the consumer's renovate config: don't adopt a brand-new version until it's been public long enough for malware to surface/yank. Best free mitigation for the window Socket watched; GitLab-native via renovate-self.

Residual gap: no live behavioral sandboxing. Revisit self-hosted package-analysis or a paid tool (Socket/Phylum) only if the threat model warrants it.

Each scanner image/version is a pinned input (Renovate-friendly), as in go-security. osv-scanner / trivy / gitleaks / semgrep reuse the images already pinned in the Go/Rust security components; retire.js / lockfile-lint / cyclonedx-npm / Socket CLI run via npx on dev-tools (or are baked into dev-tools later if cold-fetch becomes a pain point).

D4 — svelte-lint component (quality gate)

  • svelte-check — Svelte + TypeScript diagnostics / type-check (highest-signal Svelte gate).
  • eslint (with eslint-plugin-svelte) + prettier --check.
  • Runs on dev-tools; npm ci then npx the project-local tools.
  • Note: keyrx/haileys' current package.json has only svelte+vite — the lint devDeps (svelte-check, eslint, plugins) would be added to the consumer projects as part of adopting this component.

D5 — svelte-test component

  • vitest run (unit) on dev-tools.
  • Optional opt-in Playwright e2e job (browser-based, heavier) gated behind an enable_e2e-style input + $RUN_* variable, like rust-test's cross-OS jobs, so it stays dormant until wired.

D6 — Toolchain hosting

  • Node + project tools (svelte-check, eslint, vitest) → dev-tools image + npm ci. The per-job npm ci is the one residual install; mitigate with an ~/.npm/node_modules cache keyed on package-lock.json.
  • Scanners (semgrep, osv-scanner, trivy, retire.js, gitleaks) → pinned upstream images, as go-security does. Revisit baking retire/cyclonedx-npm into dev-tools if npx cold-fetch is a pain.
  • dev-tools already bakes Node 22 + corepack (added precisely for this).

D7 — Change-detection (integrates with the change-detection spec)

  • svelte-* jobs gate on the frontend paths: pkg/studio/web/** (or **/*.{svelte,ts,js,css}, package.json, package-lock.json, vite.config.*, svelte.config.*, tsconfig.json, .eslintrc.*, .gitlab-ci.yml). gitleaks/semgrep-secrets stay always-on.
  • Embed coupling: consumers that embed a frontend extend their go-test (and as needed go-lint) changes input to include pkg/studio/web/**, so a Svelte change still runs the Go embed / served-UI tests. This is why the change-detection changes input must be consumer-extensible. See 2026-06-23-change-detection.md.

D8 — Versioning / sequencing

New components → feat → minor bumps. Suggested order: 1. svelte-build + wire goreleaser needs: (the concrete release blocker for keyrx/haileys shipping a real UI). 2. svelte-security (the security ask; highest posture value). 3. svelte-lint / svelte-test.

Each lands with a self-test fixture (a minimal vite+svelte app under tests/svelte-*/fixture/) and a root-pipeline trigger, per the authoring guide. Validate the toolchain on dev-tools before release (Node npm ci + each scanner) — the dev-tools non-root incident showed fixtures alone miss real-project issues, so test against a real pkg/studio/web.

Resolved (review)

  • Naming → svelte-* (2026-06-23). Matches the framework-named precedent (zensical-pages, hugo-pages); svelte-check is first-class. Inputs keep it adaptable if a non-Svelte UI appears later.
  • Security packaging → one svelte-security (2026-06-23), with each scanner as a separate job (semgrep/osv-scanner/npm-audit/retire.js/audit- signatures), exactly like go-security/rust-security. Consumers opt a job out via <job>: { rules: [{ when: never }] }. One include, one pin.
  • Frontend location → paths input, REQUIRED, no default, a LIST (2026-06-23). pkg/studio/web is arbitrary — only coincidentally shared by keyrx/haileys, no guarantee for other projects. And a project may have multiple frontend apps. So:
  • Replace working_directory with paths — a space-separated string of one-or-more frontend roots (per authoring rule 4: path-lists are word-split strings, not arrays — arrays don't iterate), mirroring tofu-validate's validate_paths.
  • No default — required; every consumer states its frontend root(s).
  • Each svelte-* job iterates over the paths: for dir in $[[ inputs.paths ]]; do (cd "$dir" && …); done. svelte-build produces one embed artifact per root; goreleaser needs: them all.
  • Supply-chain scope → all free/OSS, Socket dropped (2026-06-23). Socket.dev is paywalled (real analysis behind a paid plan) + GitHub- centric → not our path. Always-on free/OSS tools: semgrep-XSS/SAST, osv-scanner (incl. the OSV malicious-packages feed), npm-audit, retire.js, npm-audit-signatures, lockfile-lint, cyclonedx-npm SBOM, gitleaks. The malicious-package vectors are covered by defense-in- depth (npm ci --ignore-scripts, lockfile-lint, OSV MAL feed, Renovate minimumReleaseAge cooldown) rather than a behavioral scanner — no free turnkey OSS one exists. See D3.

All svelte-track open questions resolved (2026-06-23).