Automating Dynamic SCSS Theme Previews with a Go Service and Tekton Pipelines


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 --from=builder /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.


  TOC