The feedback loop for our design system theming was broken. A designer would modify a set of SCSS variables in a feature branch, and the review process would stall. An engineer had to manually check out the branch, run a local build, deploy it to a shared staging environment—often overwriting someone else’s work—and then paste a link into the pull request. This process took anywhere from thirty minutes to half a day, turning a simple color change into a significant engineering dependency. The core problem was the lack of ephemeral, per-pull-request preview environments that could accurately reflect theme changes in a fully interactive component library.
Our initial concept was to build a fully automated system on Kubernetes. The desired workflow: a designer opens a pull request with SCSS changes, a webhook triggers a process, and within minutes, a comment appears on the PR with a unique URL to a live preview environment. This environment would host our component library, fully skinned with the new theme, allowing for immediate review and sign-off. To achieve this, we needed an orchestration engine, a way to process theme files intelligently, and a highly dynamic frontend capable of hot-swapping theme data.
The technology selection was driven by production constraints and a desire for maintainability. Tekton was the obvious choice for orchestration. Running natively on Kubernetes, its Task
and Pipeline
CRDs allowed us to define our workflow as a series of containerized, isolated steps. This avoided the overhead of a dedicated Jenkins server and provided better integration with our existing cluster tooling. For the theme processing, a simple node-sass
script was considered but dismissed. We anticipated future requirements like validating color contrast ratios, generating alternate color palettes, or even transforming design tokens from JSON. A robust, statically typed microservice was a better long-term investment, which led us to Go. Its performance, concurrency model, and simple binary deployment made it ideal for a compact, efficient service inside our pipeline. The frontend preview app itself needed to be dynamic. We needed to load the base component library and then apply themes on the fly. Valtio, with its minimal API and proxy-based state management, was perfect. It allowed us to create a global theme object that, when modified, would automatically trigger re-renders in any component subscribed to it, without the boilerplate of Redux or complex context providers. SCSS was the existing standard for our design team, so it was a non-negotiable part of the stack.
The Go Theming Service: Beyond Simple Compilation
The heart of the automation is a Go microservice responsible for more than just compiling SCSS. It acts as a “theme intelligence” engine. It receives a git commit hash, fetches the relevant SCSS variable file, processes it, and returns structured data. In a real-world project, this service is critical because it isolates the complex business logic of theming from the CI pipeline’s orchestration concerns.
Here is the main structure of the service. It’s a standard HTTP server using net/http
for simplicity and gorilla/mux
for routing, though the standard library would suffice.
main.go
:
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/gorilla/mux"
"theme-processor/internal/handler"
"theme-processor/internal/service"
)
func main() {
// In a production setup, these would come from config files or env vars.
repoPath := "/tmp/repo" // Path where the git repo will be cloned
repoURL := "https://github.com/your-org/your-design-system.git"
gitService, err := service.NewGitService(repoPath, repoURL)
if err != nil {
log.Fatalf("Failed to initialize Git service: %v", err)
}
themeService := service.NewThemeService(gitService)
themeHandler := handler.NewThemeHandler(themeService)
r := mux.NewRouter()
r.HandleFunc("/process-theme", themeHandler.ProcessTheme).Methods("POST")
// A simple health check endpoint is crucial for Kubernetes liveness probes.
r.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}).Methods("GET")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Handler: r,
Addr: "0.0.0.0:" + port,
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Printf("Server starting on port %s", port)
log.Fatal(srv.ListenAndServe())
}
The core logic resides in the services. The GitService
is responsible for cloning and checking out specific commits. The ThemeService
orchestrates the process of extracting variables.
internal/service/git_service.go
:
package service
import (
"fmt"
"os"
"os/exec"
"sync"
)
// GitService handles interactions with a Git repository.
// It includes a mutex to prevent race conditions from concurrent pipeline runs
// trying to manipulate the same local repo clone.
type GitService struct {
repoPath string
repoURL string
mu sync.Mutex
}
func NewGitService(repoPath, repoURL string) (*GitService, error) {
return &GitService{
repoPath: repoPath,
repoURL: repoURL,
}, nil
}
// PrepareRepo ensures the repository is cloned and updated to a specific commit.
func (s *GitService) PrepareRepo(commitSHA string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// If repo doesn't exist, clone it.
if _, err := os.Stat(s.repoPath); os.IsNotExist(err) {
cmd := exec.Command("git", "clone", s.repoURL, s.repoPath)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to clone repo: %w, output: %s", err, string(output))
}
}
// Fetch latest changes and checkout the specific commit.
// A common mistake is to forget 'git fetch' in an existing clone.
fetchCmd := exec.Command("git", "fetch", "origin")
fetchCmd.Dir = s.repoPath
if output, err := fetchCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to fetch repo: %w, output: %s", err, string(output))
}
checkoutCmd := exec.Command("git", "checkout", commitSHA)
checkoutCmd.Dir = s.repoPath
if output, err := checkoutCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to checkout commit %s: %w, output: %s", commitSHA, err, string(output))
}
return s.repoPath, nil
}
The ThemeService
uses the GitService
and then performs the core task: parsing SCSS. For this example, we use a regex-based parser. A production system should use a proper SCSS Abstract Syntax Tree (AST) parser for robustness, but this demonstrates the principle.
internal/service/theme_service.go
:
package service
import (
"encoding/json"
"fmt"
"io/ioutil"
"path/filepath"
"regexp"
"strings"
)
type ThemeService struct {
git *GitService
}
func NewThemeService(git *GitService) *ThemeService {
return &ThemeService{git: git}
}
// ExtractVariablesAsJSON fetches a specific SCSS file from a commit and returns its variables as JSON.
func (s *ThemeService) ExtractVariablesAsJSON(commitSHA, filePath string) ([]byte, error) {
repoPath, err := s.git.PrepareRepo(commitSHA)
if err != nil {
return nil, fmt.Errorf("could not prepare repository: %w", err)
}
fullPath := filepath.Join(repoPath, filePath)
content, err := ioutil.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("could not read file %s: %w", fullPath, err)
}
// This regex is a simplification. It captures SCSS variables like `$primary-color: #fff;`.
// A pitfall here is that it won't handle complex values, maps, or functions.
re := regexp.MustCompile(`\$([a-zA-Z0-9_-]+)\s*:\s*([^;]+);`)
matches := re.FindAllStringSubmatch(string(content), -1)
themeMap := make(map[string]string)
for _, match := range matches {
if len(match) == 3 {
key := strings.TrimSpace(match[1])
value := strings.TrimSpace(match[2])
themeMap[key] = value
}
}
jsonData, err := json.Marshal(themeMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal theme data to JSON: %w", err)
}
return jsonData, nil
}
Finally, we need a Dockerfile
to containerize this service for use in our Tekton Task
.
Dockerfile
:
# Use a multi-stage build for a smaller, more secure final image.
FROM golang:1.19-alpine AS builder
WORKDIR /app
# The go.mod and go.sum files are copied first to leverage Docker layer caching.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build the binary with optimizations.
# CGO_ENABLED=0 is crucial for creating a static binary that works in a minimal 'scratch' image.
RUN CGO_ENABLED=0 GOOS=linux go build -v -o /theme-processor ./...
# Final stage: copy the binary into a minimal image.
FROM scratch
COPY /theme-processor /theme-processor
# Expose the port the server listens on.
EXPOSE 8080
# Set the entrypoint.
ENTRYPOINT ["/theme-processor"]
Defining the CI/CD Workflow with Tekton
With the Go service ready, we can define the Tekton pipeline. The pipeline consists of several Tasks
that run sequentially, sharing data via a Workspace
.
First, a custom Task
to invoke our Go service. This Task
uses curl
to make a POST request to the service (which will be running as a separate pod or referenced via a Kubernetes Service
).
tekton/tasks/process-theme.yaml
:
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: process-theme
spec:
description: >-
This task calls the Go theme-processor service to extract SCSS variables
as JSON from a specific git commit.
params:
- name: commit-sha
description: The git commit SHA to process.
type: string
- name: scss-file-path
description: Path to the SCSS variables file within the repo.
type: string
- name: service-url
description: The URL of the theme-processor service.
type: string
workspaces:
- name: output
description: The workspace where the output JSON file will be stored.
steps:
- name: invoke-processor
image: curlimages/curl:7.79.1
script: |
#!/bin/sh
set -e
echo "Processing theme for commit $(params.commit-sha)"
# The output path is constructed from the workspace mount point.
OUTPUT_FILE="$(workspaces.output.path)/theme.json"
# We create a JSON payload for the service.
JSON_PAYLOAD=$(printf '{"commit": "%s", "filePath": "%s"}' \
"$(params.commit-sha)" \
"$(params.scss-file-path)")
# A common mistake is not handling HTTP error codes properly.
# The -f flag makes curl fail on server errors (4xx, 5xx).
curl -f -X POST \
-H "Content-Type: application/json" \
-d "$JSON_PAYLOAD" \
"$(params.service-url)/process-theme" \
-o "$OUTPUT_FILE"
echo "Theme JSON successfully written to $OUTPUT_FILE"
cat $OUTPUT_FILE
Next, a Task
to build the frontend. This Task
assumes a standard Node.js project. It takes the theme.json
produced by the previous Task
and makes it available to the build process, for example, by copying it into the public
directory.
tekton/tasks/build-frontend.yaml
:
apiVersion: tekton.dev/v1beta1
kind: Task
metadata:
name: build-frontend
spec:
description: Builds the React preview application.
params:
- name: image-repo
description: The container image repository to push the built image to.
type: string
- name: image-tag
description: The tag for the container image.
type: string
workspaces:
- name: source
description: The workspace containing the frontend source code and the theme.json.
steps:
- name: setup-and-build
image: node:16-alpine
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
# The theme.json from the previous step is expected to be in the workspace root.
if [ ! -f "theme.json" ]; then
echo "Error: theme.json not found in workspace!"
exit 1
fi
# Move it to a location where the app can fetch it.
# This is a critical integration point.
echo "Copying theme.json to public directory..."
mkdir -p public
cp theme.json public/theme-pr-$(params.image-tag).json
echo "Installing dependencies..."
# Using 'ci' is best practice for reproducible builds.
npm ci
echo "Building application..."
# The REACT_APP_THEME_URL tells the app where to find its theme file.
export REACT_APP_THEME_URL="/theme-pr-$(params.image-tag).json"
npm run build
- name: build-and-push-image
image: gcr.io/kaniko-project/executor:v1.9.0
args:
- "--dockerfile=$(workspaces.source.path)/Dockerfile.frontend"
- "--context=dir://$(workspaces.source.path)"
- "--destination=$(params.image-repo):$(params.image-tag)"
# Kaniko requires credentials to be mounted this way.
# This assumes a secret named 'docker-config' exists in the pipeline's service account.
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
env:
- name: DOCKER_CONFIG
value: /kaniko/.docker
volumes:
- name: docker-config
secret:
secretName: docker-config
items:
- key: .dockerconfigjson
path: config.json
Finally, the Pipeline
ties everything together. It defines the execution order and how the Workspace
is shared.
tekton/pipeline.yaml
:
apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
name: design-system-preview-pipeline
spec:
description: >-
This pipeline clones a design system repo, processes its theme using a Go service,
builds a frontend preview, and deploys it to Kubernetes.
params:
- name: repo-url
type: string
- name: revision
type: string
default: "main"
- name: preview-image-repo
type: string
- name: theme-service-url
type: string
workspaces:
- name: shared-data
tasks:
- name: fetch-source
taskRef:
name: git-clone
workspaces:
- name: output
workspace: shared-data
params:
- name: url
value: $(params.repo-url)
- name: revision
value: $(params.revision)
- name: generate-theme-json
taskRef:
name: process-theme
runAfter: ["fetch-source"]
workspaces:
- name: output
workspace: shared-data
params:
- name: commit-sha
value: $(tasks.fetch-source.results.commit)
- name: scss-file-path
value: "src/styles/variables.scss" # This should be configurable
- name: service-url
value: $(params.theme-service-url)
- name: build-and-push-preview-app
taskRef:
name: build-frontend
runAfter: ["generate-theme-json"]
workspaces:
- name: source
workspace: shared-data
params:
- name: image-repo
value: $(params.preview-image-repo)
- name: image-tag
# Using the git commit hash ensures a unique, traceable image tag.
value: $(tasks.fetch-source.results.commit)
# A final deployment task would go here, using kubectl or kustomize
# to apply a manifest with the newly built image tag. This is omitted
# for brevity but would be a standard 'kubernetes-actions' task.
The flow can be visualized:
graph TD A[Start PipelineRun] --> B(fetch-source: git-clone); B --> C(generate-theme-json: process-theme); C --> D(build-and-push-preview-app: build-frontend); D --> E(deploy-to-k8s); subgraph "Go Service Interaction" C -- HTTP POST --> F{Theme Processor Service}; F -- Clones Git Repo --> G[Git Commit]; F -- Returns theme.json --> C; end subgraph "Data Flow via Workspace" B -- Source Code --> D; C -- theme.json --> D; end
The Dynamic Frontend with Valtio and SCSS
The frontend’s role is to consume the theme data and apply it. We use CSS Custom Properties as the bridge between our JavaScript theme object and the SCSS styling. Valtio makes managing this state trivial.
First, define the Valtio store. It will fetch the theme JSON generated by the pipeline and hold it in a proxy state object.
src/state/themeStore.js
:
import { proxy } from 'valtio';
// The store starts with an empty theme and a loading state.
export const themeStore = proxy({
isLoading: true,
themeVariables: {},
});
// This function is called once when the application initializes.
export async function initializeTheme() {
try {
// REACT_APP_THEME_URL is injected by the build process in the Tekton task.
const themeUrl = process.env.REACT_APP_THEME_URL || '/theme.json';
const response = await fetch(themeUrl);
if (!response.ok) {
throw new Error(`Failed to fetch theme from ${themeUrl}`);
}
const data = await response.json();
// Mutating the proxy object directly is the Valtio way.
// This will trigger re-renders in any component that uses `useSnapshot`.
themeStore.themeVariables = data;
} catch (error) {
console.error("Could not initialize theme:", error);
// In a real app, you'd set an error state here.
} finally {
themeStore.isLoading = false;
}
}
Next, a top-level component applies the theme variables as CSS Custom Properties to the :root
element. This makes them available to all SCSS stylesheets.
src/components/ThemeProvider.jsx
:
import React, { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import { themeStore, initializeTheme } from '../state/themeStore';
// A simple utility to convert camelCase from JSON to kebab-case for CSS variables.
const toKebabCase = (str) => str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
export const ThemeProvider = ({ children }) => {
const { themeVariables, isLoading } = useSnapshot(themeStore);
useEffect(() => {
initializeTheme();
}, []);
useEffect(() => {
// When themeVariables change, update the CSS custom properties on the root element.
const root = document.documentElement;
if (root && !isLoading) {
Object.entries(themeVariables).forEach(([key, value]) => {
root.style.setProperty(`--${toKebabCase(key)}`, value);
});
}
}, [themeVariables, isLoading]);
if (isLoading) {
return <div>Loading Theme...</div>;
}
return <>{children}</>;
};
Our SCSS is now written to consume these CSS variables, with fallbacks for safety.
src/styles/components/_button.scss
:
.button {
// Use var() to reference the CSS Custom Property injected by the ThemeProvider.
// The second argument is a fallback value. This is a critical best practice.
background-color: var(--primary-color, #007bff);
color: var(--button-text-color, #ffffff);
border: 1px solid var(--primary-color, #007bff);
padding: 10px 15px;
border-radius: var(--border-radius-base, 4px);
cursor: pointer;
&:hover {
background-color: var(--primary-color-hover, #0056b3);
}
}
With this setup, the workflow is complete. The Tekton pipeline uses the Go service to generate a theme.json
, the build process embeds the path to this file, and the Valtio-powered frontend fetches it, populates CSS variables, and the SCSS styles react accordingly. The initial pain point of a multi-hour manual feedback loop is replaced by a fully automated, five-minute process.
The current implementation, while functional, has clear areas for improvement. The Go service’s regex-based SCSS parsing is fragile; migrating to a library that can build a full AST would prevent errors with more complex variable definitions like SCSS maps or functions. The lifecycle of preview environments is also not managed; we need a garbage collection process, perhaps a nightly CronJob, to delete Kubernetes resources associated with merged or closed pull requests to control costs. Furthermore, the frontend build time can be a bottleneck. Investigating Tekton’s workspace caching mechanisms or using a persistent volume for node_modules
could significantly speed up the npm ci
step in the pipeline. Looking forward, the Go service could evolve into a true “Design Token Engine,” supporting standards like the Design Tokens Community Group format, making our entire pipeline more interoperable and future-proof.