Skip to content

Commit 8c9bb5f

Browse files
committed
feat: publish container images to GHCR
1 parent a1e4fce commit 8c9bb5f

5 files changed

Lines changed: 192 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ jobs:
4545
- name: Build Wiki UI
4646
run: make wiki-build
4747

48+
- name: Build production image
49+
run: |
50+
docker build \
51+
--build-arg VERSION=ci \
52+
--build-arg COMMIT=${{ github.sha }} \
53+
--build-arg DATE=unknown \
54+
--tag ccg:ci .
55+
4856
- name: Capture go build JSON
4957
run: make build-json
5058

.github/workflows/release.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,64 @@ jobs:
152152
artifacts/*.tar.gz
153153
artifacts/*.zip
154154
155+
publish-container:
156+
needs: release
157+
runs-on: ubuntu-latest
158+
permissions:
159+
contents: read
160+
packages: write
161+
162+
steps:
163+
- uses: actions/checkout@v6
164+
165+
- name: Set up QEMU
166+
uses: docker/setup-qemu-action@v4
167+
168+
- name: Set up Docker Buildx
169+
uses: docker/setup-buildx-action@v4
170+
171+
- name: Log in to GitHub Container Registry
172+
uses: docker/login-action@v4
173+
with:
174+
registry: ghcr.io
175+
username: ${{ github.actor }}
176+
password: ${{ secrets.GITHUB_TOKEN }}
177+
178+
- name: Extract container metadata
179+
id: metadata
180+
uses: docker/metadata-action@v6
181+
with:
182+
images: ghcr.io/${{ github.repository }}
183+
flavor: latest=false
184+
tags: |
185+
type=semver,pattern={{version}}
186+
type=semver,pattern={{major}}.{{minor}}
187+
type=semver,pattern={{major}}
188+
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
189+
190+
- name: Prepare container build metadata
191+
id: build-meta
192+
shell: bash
193+
run: |
194+
echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
195+
echo "commit=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
196+
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
197+
198+
- name: Build and publish container image
199+
uses: docker/build-push-action@v7
200+
with:
201+
context: .
202+
platforms: linux/amd64,linux/arm64
203+
push: true
204+
tags: ${{ steps.metadata.outputs.tags }}
205+
labels: ${{ steps.metadata.outputs.labels }}
206+
build-args: |
207+
VERSION=${{ steps.build-meta.outputs.version }}
208+
COMMIT=${{ steps.build-meta.outputs.commit }}
209+
DATE=${{ steps.build-meta.outputs.date }}
210+
cache-from: type=gha
211+
cache-to: type=gha,mode=max
212+
155213
publish-npm:
156214
needs: release
157215
runs-on: ubuntu-latest

Dockerfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ FROM golang:1.25-alpine AS builder
1212

1313
RUN apk add --no-cache gcc musl-dev git
1414

15+
ARG VERSION=dev
16+
ARG COMMIT=unknown
17+
ARG DATE=unknown
18+
1519
WORKDIR /src
1620
COPY go.mod go.sum ./
1721
RUN go mod download
1822

1923
COPY . .
20-
RUN CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w" -o /usr/local/bin/ccg ./cmd/ccg/ \
21-
&& CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w" -o /usr/local/bin/ccg-server ./cmd/ccg-server/
24+
RUN CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /usr/local/bin/ccg ./cmd/ccg/ \
25+
&& CGO_ENABLED=1 go build -tags "fts5" -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /usr/local/bin/ccg-server ./cmd/ccg-server/
2226

2327
# Runtime stage
2428
FROM alpine:3.21

guide/docker.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Docker
22

3+
## Pull Published Image
4+
5+
Tagged releases publish multi-platform images for Linux AMD64 and ARM64 to
6+
GitHub Container Registry:
7+
8+
```bash
9+
docker pull ghcr.io/tae2089/code-context-graph:0.11.0
10+
```
11+
12+
Each stable release publishes the full semantic version, minor, major, and
13+
`latest` tags. Prefer a full semantic-version tag for reproducible deployments.
14+
315
## Build Image
416

517
```bash
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package archtest
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"go.yaml.in/yaml/v3"
10+
)
11+
12+
func TestDockerfileInjectsReleaseMetadataIntoBothBinaries(t *testing.T) {
13+
dockerfile := readRepositoryFile(t, "Dockerfile")
14+
15+
for _, contract := range []string{
16+
"ARG VERSION=dev",
17+
"ARG COMMIT=unknown",
18+
"ARG DATE=unknown",
19+
} {
20+
if !strings.Contains(dockerfile, contract) {
21+
t.Errorf("Dockerfile missing release metadata contract %q", contract)
22+
}
23+
}
24+
25+
const linkerFlags = `-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}`
26+
if count := strings.Count(dockerfile, linkerFlags); count != 2 {
27+
t.Errorf("Dockerfile release linker flags count = %d, want 2 (ccg and ccg-server)", count)
28+
}
29+
}
30+
31+
func TestReleaseWorkflowPublishesVersionedMultiPlatformImage(t *testing.T) {
32+
workflow := readRepositoryFile(t, ".github", "workflows", "release.yml")
33+
var parsed struct {
34+
Jobs map[string]struct {
35+
Needs any `yaml:"needs"`
36+
Permissions map[string]string `yaml:"permissions"`
37+
} `yaml:"jobs"`
38+
}
39+
if err := yaml.Unmarshal([]byte(workflow), &parsed); err != nil {
40+
t.Fatalf("parse release workflow: %v", err)
41+
}
42+
containerJob, ok := parsed.Jobs["publish-container"]
43+
if !ok {
44+
t.Fatal("release workflow missing publish-container job")
45+
}
46+
if containerJob.Needs != "release" {
47+
t.Errorf("publish-container needs = %q, want release", containerJob.Needs)
48+
}
49+
if got := containerJob.Permissions["contents"]; got != "read" {
50+
t.Errorf("publish-container contents permission = %q, want read", got)
51+
}
52+
if got := containerJob.Permissions["packages"]; got != "write" {
53+
t.Errorf("publish-container packages permission = %q, want write", got)
54+
}
55+
56+
for _, contract := range []string{
57+
"docker/login-action@v4",
58+
"docker/metadata-action@v6",
59+
"docker/setup-qemu-action@v4",
60+
"docker/setup-buildx-action@v4",
61+
"docker/build-push-action@v7",
62+
"registry: ghcr.io",
63+
"images: ghcr.io/${{ github.repository }}",
64+
"flavor: latest=false",
65+
"type=semver,pattern={{version}}",
66+
"type=semver,pattern={{major}}.{{minor}}",
67+
"type=semver,pattern={{major}}",
68+
"type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}",
69+
"platforms: linux/amd64,linux/arm64",
70+
"push: true",
71+
"VERSION=${{ steps.build-meta.outputs.version }}",
72+
"COMMIT=${{ steps.build-meta.outputs.commit }}",
73+
"DATE=${{ steps.build-meta.outputs.date }}",
74+
} {
75+
if !strings.Contains(workflow, contract) {
76+
t.Errorf("release workflow missing container publication contract %q", contract)
77+
}
78+
}
79+
}
80+
81+
func TestCIWorkflowBuildsProductionImageWithoutPublishing(t *testing.T) {
82+
workflow := readRepositoryFile(t, ".github", "workflows", "ci.yml")
83+
84+
for _, contract := range []string{
85+
"name: Build production image",
86+
"docker build",
87+
"--build-arg VERSION=ci",
88+
"--build-arg COMMIT=${{ github.sha }}",
89+
"--build-arg DATE=unknown",
90+
} {
91+
if !strings.Contains(workflow, contract) {
92+
t.Errorf("CI workflow missing build-only image contract %q", contract)
93+
}
94+
}
95+
if strings.Contains(workflow, "docker push") {
96+
t.Error("CI workflow must not publish the production image")
97+
}
98+
}
99+
100+
func readRepositoryFile(t *testing.T, pathParts ...string) string {
101+
t.Helper()
102+
path := filepath.Join(append([]string{repositoryRoot(t)}, pathParts...)...)
103+
content, err := os.ReadFile(path)
104+
if err != nil {
105+
t.Fatalf("read repository file %s: %v", path, err)
106+
}
107+
return string(content)
108+
}

0 commit comments

Comments
 (0)