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), scriptsdev/build/preview. - Build output embedded via
pkg/studio/embed.go://go:embed all:web/embed→pkg/studio/web/embed/. - A committed fallback bundle + a "synthetic FS" unit test
(
embed_test.go) meansgo buildcompiles standalone (no hard build-order dependency for the Go gates); the real bundle is only required at release time. go-tool-baseitself is the base library (no UI); its descendants (keyrx, haileys-app) carry the UI.
Decisions¶
D1 — Two coupling points, handled separately¶
- Release build-order (D2): the release binary embeds
web/embed/, so the real Svelte bundle must be built before goreleaser runs. - 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).
pathsinput (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_scriptdefaultbuild). Output dir input (defaultembed).- All built output dirs are exposed as a job artifact so the release job
consumes them:
goreleasergainsneeds: [svelte-build]withartifacts: truein the consumer, so goreleaser embeds the real bundle(s). (Alternative — a goreleaserbefore.hooksentry — rejected; it couples the build into every consumer's.goreleaser.yaml.) - Cache
~/.npm(and/or eachnode_modules) keyed onpackage-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:generateembed projects → do NOT wiresvelte-build. The release build is the goreleasergo generatebefore-hook (build-web.sh); CI usessvelte-lint/svelte-test/svelte-securityonly. 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 (amerge_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→ usesvelte-build+ agoreleaser 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 / XSS — semgrep 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 libraries — retire.js (bundled/outdated libs
with CVEs).
- Provenance / integrity — npm audit signatures (registry
provenance) and lockfile-lint (every resolved URL is HTTPS + the
official registry — catches dependency-confusion / registry-spoofing;
free, no account).
- SBOM — cyclonedx-npm emits a frontend SBOM artifact.
- Secrets — gitleaks 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 packages — osv-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 spoofing — lockfile-lint (above).
- Provenance — npm audit signatures (above).
- Novel malicious new release — Renovate 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(witheslint-plugin-svelte) +prettier --check.- Runs on dev-tools;
npm cithennpxthe project-local tools. - Note: keyrx/haileys' current
package.jsonhas 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, likerust-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-jobnpm ciis the one residual install; mitigate with an~/.npm/node_modulescache keyed onpackage-lock.json. - Scanners (semgrep, osv-scanner, trivy, retire.js, gitleaks) →
pinned upstream images, as
go-securitydoes. Revisit bakingretire/cyclonedx-npminto dev-tools ifnpxcold-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-secretsstay always-on.- Embed coupling: consumers that embed a frontend extend their
go-test(and as neededgo-lint)changesinput to includepkg/studio/web/**, so a Svelte change still runs the Go embed / served-UI tests. This is why the change-detectionchangesinput must be consumer-extensible. See2026-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-checkis 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 likego-security/rust-security. Consumers opt a job out via<job>: { rules: [{ when: never }] }. One include, one pin. - Frontend location →
pathsinput, REQUIRED, no default, a LIST (2026-06-23).pkg/studio/webis arbitrary — only coincidentally shared by keyrx/haileys, no guarantee for other projects. And a project may have multiple frontend apps. So: - Replace
working_directorywithpaths— 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), mirroringtofu-validate'svalidate_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; goreleaserneeds: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-packagesfeed), 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, RenovateminimumReleaseAgecooldown) rather than a behavioral scanner — no free turnkey OSS one exists. See D3.
All svelte-track open questions resolved (2026-06-23).