Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Publish

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]

env:
IMAGE: ghcr.io/nitrictech/sugapack

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- run: go test ./... -v

publish:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha

- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@

# Dependency directories (remove the comment below to include it)
# vendor/

# Built binary
sugapack
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM golang:1.26-alpine AS builder

WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /sugapack .

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /sugapack /bin/sugapack
ENTRYPOINT ["/bin/sugapack"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Nitric Technologies

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
95 changes: 95 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
.PHONY: build image test clean infra infra-stop dry-run dry-run-private

# Internal registry hostname (as seen by buildkitd inside the Docker network)
IMAGE := sugapack-registry:5000/sugapack:local
BUILDKITD := sugapack-buildkitd
REGISTRY_C := sugapack-registry
NETWORK := sugapack-net
SOCK := /tmp/sugapack-buildkit/buildkitd.sock
ADDR := unix://$(SOCK)
TEST_CONFIG ?= test.json

# ── Local dev ────────────────────────────────────────────────

build:
go build -o sugapack .

test:
go test ./... -v

# ── Docker / BuildKit infra ──────────────────────────────────

# Start local registry + buildkitd on a shared network
infra:
@docker network inspect $(NETWORK) >/dev/null 2>&1 || \
docker network create $(NETWORK)
@if ! docker inspect $(REGISTRY_C) >/dev/null 2>&1; then \
echo "Starting registry..."; \
docker run -d --name $(REGISTRY_C) --network $(NETWORK) registry:2; \
elif [ "$$(docker inspect -f '{{.State.Running}}' $(REGISTRY_C))" != "true" ]; then \
docker start $(REGISTRY_C); \
else \
echo "registry already running"; \
fi
@mkdir -p $(dir $(SOCK))
@if ! docker inspect $(BUILDKITD) >/dev/null 2>&1; then \
echo "Starting buildkitd..."; \
docker run -d --name $(BUILDKITD) --privileged \
--network $(NETWORK) \
-v $(dir $(SOCK)):/run/buildkit \
-v $(CURDIR)/buildkitd.toml:/etc/buildkit/buildkitd.toml:ro \
moby/buildkit:latest \
--addr unix:///run/buildkit/buildkitd.sock \
--group $(shell id -g); \
elif [ "$$(docker inspect -f '{{.State.Running}}' $(BUILDKITD))" != "true" ]; then \
docker start $(BUILDKITD); \
else \
echo "buildkitd already running"; \
fi
@echo "Waiting for buildkitd..."
@for i in 1 2 3 4 5; do \
buildctl --addr $(ADDR) debug workers >/dev/null 2>&1 && break; \
sleep 1; \
done

infra-stop:
docker rm -f $(BUILDKITD) $(REGISTRY_C) 2>/dev/null || true
docker network rm $(NETWORK) 2>/dev/null || true
rm -rf $(dir $(SOCK))

# Build and push the single image (frontend + railpack CLI) to local registry
image: infra
buildctl --addr $(ADDR) build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--output type=image,name=$(IMAGE),push=true,registry.insecure=true

# ── Dry-run ──────────────────────────────────────────────────
#
# make dry-run # public repo, uses test.json
# make dry-run TEST_CONFIG=myapp.json # public repo, custom config
# GIT_AUTH_TOKEN=ghp_xxx make dry-run-private # private repo
# GIT_AUTH_TOKEN=ghp_xxx make dry-run-private TEST_CONFIG=private.json

dry-run: image
buildctl --addr $(ADDR) build \
--frontend gateway.v0 \
--opt source=$(IMAGE) \
--local dockerfile=. \
--opt filename=$(TEST_CONFIG) \
--output type=docker,name=sugapack-test-output | docker load

dry-run-private: image
buildctl --addr $(ADDR) build \
--frontend gateway.v0 \
--opt source=$(IMAGE) \
--local dockerfile=. \
--opt filename=$(TEST_CONFIG) \
--secret id=GIT_AUTH_TOKEN,env=GIT_AUTH_TOKEN \
--output type=docker,name=sugapack-test-output | docker load

# ── Cleanup ──────────────────────────────────────────────────

clean: infra-stop
rm -f sugapack
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,98 @@
# sugapack

A BuildKit frontend that wraps [railpack](https://github.com/railwayapp/railpack) with remote git source support. Everything runs on the builder — zero local context transfer.

## How it works

Sugapack is a BuildKit gRPC frontend that accepts a small JSON config as input (not the full source):

1. **Fetch source** — clones the repo via `llb.Git()` with optional token auth for private repos
2. **Generate plan** — runs railpack's plan generation (embedded as a Go library) on the fetched source
3. **Execute plan** — converts the plan to LLB using railpack's `build_llb` package, substituting the git source for local context

## Usage

### With Depot

```bash
echo '{"repo":"https://github.com/user/repo.git","ref":"abc123"}' | \
depot build -f - \
--build-arg BUILDKIT_SYNTAX=ghcr.io/nitrictech/sugapack:latest \
--save --platform linux/amd64 \
/dev/null
```

### With buildctl

```bash
buildctl build \
--frontend gateway.v0 \
--opt source=ghcr.io/nitrictech/sugapack:latest \
--local dockerfile=. \
--opt filename=config.json \
--output type=image,name=my-app:latest
```

### Private repos

Pass a git auth token as a BuildKit secret:

```bash
echo '{"repo":"https://github.com/user/private-repo.git","ref":"main","authSecret":"GIT_AUTH_TOKEN"}' | \
depot build -f - \
--build-arg BUILDKIT_SYNTAX=ghcr.io/nitrictech/sugapack:latest \
--secret id=GIT_AUTH_TOKEN \
--save --platform linux/amd64 \
/dev/null
```

## Config format

The "Dockerfile" input is a JSON config:

```json
{
"repo": "https://github.com/user/repo.git",
"ref": "abc123def",
"context": "apps/web",
"authSecret": "GIT_AUTH_TOKEN",
"railpack": {
"buildCmd": "npm run build",
"startCmd": "npm start",
"envs": {
"NODE_ENV": "production"
}
}
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `repo` | yes | Git repository URL (HTTPS) |
| `ref` | no | Commit SHA, branch, or tag (default: `main`) |
| `context` | no | Subdirectory within the repo to use as build context |
| `authSecret` | no | BuildKit secret ID containing a git auth token |
| `railpack.buildCmd` | no | Override the build command |
| `railpack.startCmd` | no | Override the start command |
| `railpack.envs` | no | Additional environment variables for plan generation |

## Local development

Requires Docker and [buildctl](https://github.com/moby/buildkit).

```bash
# Run tests
make test

# Start local infra (buildkitd + registry) and run a full build
make dry-run

# Custom config
make dry-run TEST_CONFIG=myapp.json

# Private repo
GIT_AUTH_TOKEN=ghp_xxx make dry-run-private

# Tear down
make clean
```
3 changes: 3 additions & 0 deletions buildkitd.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[registry."sugapack-registry:5000"]
http = true
insecure = true
23 changes: 23 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

// Config is the JSON document passed as the "Dockerfile" input to the frontend.
// It tells the frontend where to fetch source and how to configure railpack.
type Config struct {
// Git repository URL (HTTPS)
Repo string `json:"repo"`
// Git ref (commit SHA, branch, or tag)
Ref string `json:"ref"`
// Subdirectory within the repo to use as build context
Context string `json:"context,omitempty"`
// BuildKit secret ID containing the git auth token for private repos
AuthSecret string `json:"authSecret,omitempty"`
// Railpack-specific configuration
Railpack RailpackConfig `json:"railpack,omitempty"`
}

// RailpackConfig holds railpack plan generation overrides.
type RailpackConfig struct {
BuildCmd string `json:"buildCmd,omitempty"`
StartCmd string `json:"startCmd,omitempty"`
Envs map[string]string `json:"envs,omitempty"`
}
69 changes: 69 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package main

import (
"encoding/json"
"testing"
)

func TestConfigParsing(t *testing.T) {
input := `{
"repo": "https://github.com/user/repo.git",
"ref": "abc123def",
"context": "apps/web",
"authSecret": "GIT_AUTH_TOKEN",
"railpack": {
"buildCmd": "npm run build",
"startCmd": "npm start",
"envs": {"NODE_ENV": "production"}
}
}`

var config Config
if err := json.Unmarshal([]byte(input), &config); err != nil {
t.Fatalf("failed to parse config: %v", err)
}

if config.Repo != "https://github.com/user/repo.git" {
t.Errorf("repo = %q, want %q", config.Repo, "https://github.com/user/repo.git")
}
if config.Ref != "abc123def" {
t.Errorf("ref = %q, want %q", config.Ref, "abc123def")
}
if config.Context != "apps/web" {
t.Errorf("context = %q, want %q", config.Context, "apps/web")
}
if config.AuthSecret != "GIT_AUTH_TOKEN" {
t.Errorf("authSecret = %q, want %q", config.AuthSecret, "GIT_AUTH_TOKEN")
}
if config.Railpack.BuildCmd != "npm run build" {
t.Errorf("railpack.buildCmd = %q, want %q", config.Railpack.BuildCmd, "npm run build")
}
if config.Railpack.StartCmd != "npm start" {
t.Errorf("railpack.startCmd = %q, want %q", config.Railpack.StartCmd, "npm start")
}
if config.Railpack.Envs["NODE_ENV"] != "production" {
t.Errorf("railpack.envs[NODE_ENV] = %q, want %q", config.Railpack.Envs["NODE_ENV"], "production")
}
}

func TestConfigMinimal(t *testing.T) {
input := `{"repo": "https://github.com/user/repo.git"}`

var config Config
if err := json.Unmarshal([]byte(input), &config); err != nil {
t.Fatalf("failed to parse config: %v", err)
}

if config.Repo != "https://github.com/user/repo.git" {
t.Errorf("repo = %q, want %q", config.Repo, "https://github.com/user/repo.git")
}
if config.Ref != "" {
t.Errorf("ref = %q, want empty", config.Ref)
}
if config.Context != "" {
t.Errorf("context = %q, want empty", config.Context)
}
if config.AuthSecret != "" {
t.Errorf("authSecret = %q, want empty", config.AuthSecret)
}
}
Loading
Loading