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
29 changes: 28 additions & 1 deletion forge-cli/build/dockerfile_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,39 @@ func copyDir(src, dst string) error {
}

func (s *DockerfileStage) writeDockerignore(bc *pipeline.BuildContext) error {
dockerignoreContent := `.env
// Two categories of exclusions:
// 1. Secrets — never bake credentials into a layered image.
// 2. Operator-side artifacts the runtime does not open. The
// Dockerfile uses `COPY . .` from .forge-output/, so without
// these every operator-side helper lands in /app/, padding
// the image and leaking build metadata. The runtime opens
// only forge.yaml, guardrails.json, <channel>-config.yaml,
// agent.json, policy-scaffold.json, skills/, and
// checksums.json — anything outside that set is excluded.
dockerignoreContent := `# --- Secrets ---
.env
.env.*
*.enc
secrets.enc
*.key
*.pem

# --- Operator-side artifacts (issue #147) ---
# k8s/ is for kubectl apply on the host, not the runtime image.
k8s/
# build-manifest.json is consumed by forge package / forge export.
build-manifest.json
# compiled/ holds the egress allowlist (used by forge package and
# the K8s NetworkPolicy) plus the security audit report; the runtime
# never opens any of them. compiled/prompt.txt and
# compiled/skills/skills.json are no longer generated (#147).
compiled/
# Recursive — the Dockerfile shouldn't be baked into its own image.
Dockerfile
.dockerignore
# Build-only local binary stash; binaries land in /usr/local/bin via
# the bin stage already.
.local-bins/
`
ignorePath := filepath.Join(bc.Opts.OutputDir, ".dockerignore")
if err := os.WriteFile(ignorePath, []byte(dockerignoreContent), 0644); err != nil {
Expand Down
158 changes: 158 additions & 0 deletions forge-cli/build/dockerfile_stage_issue147_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package build

import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/initializ/forge/forge-core/agentspec"
"github.com/initializ/forge/forge-core/pipeline"
"github.com/initializ/forge/forge-core/types"
)

// TestDockerignore_ExcludesOperatorSideArtifacts pins the fix for
// issue #147 — the build's .dockerignore must keep operator-side
// artifacts (k8s manifests, build metadata, security audit report,
// the Dockerfile itself) out of the runtime container image. Pre-fix
// `COPY . .` blindly dumped all of them into /app/.
func TestDockerignore_ExcludesOperatorSideArtifacts(t *testing.T) {
_ = context.Background()
tmpDir := t.TempDir()
outDir := filepath.Join(tmpDir, "output")
if err := os.MkdirAll(outDir, 0755); err != nil {
t.Fatal(err)
}

bc := pipeline.NewBuildContext(pipeline.PipelineOptions{WorkDir: tmpDir, OutputDir: outDir})
bc.Config = &types.ForgeConfig{AgentID: "test", Version: "1.0.0"}
bc.Spec = &agentspec.AgentSpec{AgentID: "test"}

s := &DockerfileStage{}
if err := s.writeDockerignore(bc); err != nil {
t.Fatalf("writeDockerignore: %v", err)
}

data, err := os.ReadFile(filepath.Join(outDir, ".dockerignore"))
if err != nil {
t.Fatalf("reading .dockerignore: %v", err)
}
got := string(data)

mustExclude := []string{
// Secrets — preserved from the legacy list.
".env", ".env.*", "*.enc", "secrets.enc", "*.key", "*.pem",
// Issue #147 additions.
"k8s/",
"build-manifest.json",
"compiled/",
"Dockerfile",
".dockerignore",
".local-bins/",
}
for _, p := range mustExclude {
if !strings.Contains(got, p+"\n") {
t.Errorf(".dockerignore is missing required exclusion %q. Full content:\n%s", p, got)
}
}
}

// TestDockerignore_KeepsRuntimeFiles is the under-exclusion guard.
// The runtime opens forge.yaml, guardrails.json, <channel>-config.yaml,
// agent.json, policy-scaffold.json, skills/, checksums.json — none of
// those may appear as an exclusion pattern or the container would
// fail to start.
func TestDockerignore_KeepsRuntimeFiles(t *testing.T) {
tmpDir := t.TempDir()
outDir := filepath.Join(tmpDir, "output")
if err := os.MkdirAll(outDir, 0755); err != nil {
t.Fatal(err)
}

bc := pipeline.NewBuildContext(pipeline.PipelineOptions{WorkDir: tmpDir, OutputDir: outDir})
bc.Config = &types.ForgeConfig{AgentID: "test", Version: "1.0.0"}
bc.Spec = &agentspec.AgentSpec{AgentID: "test"}

s := &DockerfileStage{}
if err := s.writeDockerignore(bc); err != nil {
t.Fatalf("writeDockerignore: %v", err)
}

data, err := os.ReadFile(filepath.Join(outDir, ".dockerignore"))
if err != nil {
t.Fatalf("reading .dockerignore: %v", err)
}
got := string(data)

mustNotExclude := []string{
"forge.yaml",
"guardrails.json",
"agent.json",
"policy-scaffold.json",
"checksums.json",
"slack-config.yaml",
"telegram-config.yaml",
"msteams-config.yaml",
"skills/",
}
for _, p := range mustNotExclude {
for _, line := range strings.Split(got, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
continue
}
if trimmed == p {
t.Errorf(".dockerignore must NOT exclude runtime-required file %q. Full content:\n%s", p, got)
}
}
}
}

// TestDockerfile_NoLongerCopiesDeadCompiledArtifacts confirms the
// Dockerfile template no longer carries the redundant lines that
// copied compiled/prompt.txt and compiled/skills/skills.json into
// /app — those files are no longer generated (issue #147) so the
// COPYs would also fail on a real build.
func TestDockerfile_NoLongerCopiesDeadCompiledArtifacts(t *testing.T) {
tmpDir := t.TempDir()
outDir := filepath.Join(tmpDir, "output")
if err := os.MkdirAll(outDir, 0755); err != nil {
t.Fatal(err)
}

bc := pipeline.NewBuildContext(pipeline.PipelineOptions{WorkDir: tmpDir, OutputDir: outDir})
bc.Config = &types.ForgeConfig{
AgentID: "test",
Version: "1.0.0",
Framework: "forge",
Entrypoint: "agent",
}
bc.Spec = &agentspec.AgentSpec{
AgentID: "test",
Version: "1.0.0",
Runtime: &agentspec.RuntimeConfig{Image: "debian:bookworm-slim", Entrypoint: []string{"agent"}, Port: 8080},
}
bc.SkillsCount = 2

s := &DockerfileStage{}
if err := s.generateTemplateDockerfile(bc); err != nil {
t.Fatalf("generateTemplateDockerfile: %v", err)
}

data, err := os.ReadFile(filepath.Join(outDir, "Dockerfile"))
if err != nil {
t.Fatalf("reading Dockerfile: %v", err)
}
got := string(data)

mustNotContain := []string{
"COPY compiled/skills/",
"COPY compiled/prompt.txt",
}
for _, p := range mustNotContain {
if strings.Contains(got, p) {
t.Errorf("Dockerfile must not contain dead COPY %q (issue #147). Full content:\n%s", p, got)
}
}
}
29 changes: 14 additions & 15 deletions forge-cli/build/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,21 @@ func TestIntegration_BuildWithSkillsAndEgress(t *testing.T) {
t.Fatalf("pipeline.Run: %v", err)
}

// Verify skills artifacts
// Issue #147: compiled/skills/skills.json and compiled/prompt.txt
// are no longer generated — the runtime never opened them and they
// bloated the container image. Assert they are NOT present so a
// future change reintroducing the dead writers is caught. The
// in-memory skill count is verified through bc.SkillsCount below.
skillsPath := filepath.Join(outDir, "compiled", "skills", "skills.json")
if _, err := os.Stat(skillsPath); os.IsNotExist(err) {
t.Error("expected skills.json not found")
} else {
data, err := os.ReadFile(skillsPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var skills map[string]any
if err := json.Unmarshal(data, &skills); err != nil {
t.Fatalf("unmarshal skills.json: %v", err)
}
if skills["count"].(float64) != 1 {
t.Errorf("skills count = %v, want 1", skills["count"])
}
if _, err := os.Stat(skillsPath); err == nil {
t.Errorf("compiled/skills/skills.json should not be generated (issue #147)")
}
promptPath := filepath.Join(outDir, "compiled", "prompt.txt")
if _, err := os.Stat(promptPath); err == nil {
t.Errorf("compiled/prompt.txt should not be generated (issue #147)")
}
if bc.SkillsCount != 1 {
t.Errorf("bc.SkillsCount = %d, want 1", bc.SkillsCount)
}

// Verify egress allowlist
Expand Down
22 changes: 9 additions & 13 deletions forge-cli/build/skills_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

cliskills "github.com/initializ/forge/forge-cli/skills"
"github.com/initializ/forge/forge-core/pipeline"
skillcompiler "github.com/initializ/forge/forge-skills/compiler"
"github.com/initializ/forge/forge-skills/contract"
"github.com/initializ/forge/forge-skills/requirements"
)
Expand Down Expand Up @@ -61,23 +60,20 @@ func (s *SkillsStage) Execute(ctx context.Context, bc *pipeline.BuildContext) er
bc.SkillRequirements = reqs
}

compiled, err := skillcompiler.Compile(entries)
if err != nil {
return fmt.Errorf("compiling skills: %w", err)
}
// We deliberately do not write compiled/prompt.txt or
// compiled/skills/skills.json. The runtime re-globs skills/
// SKILL.md on every startup (runner.discoverSkillFiles +
// buildSkillCatalog) and never opens either file. Generating
// them just bloated the build output and the container image
// for no consumer benefit. See issue #147 for the trace.
// External library consumers (forgecore.Compile) still get the
// in-memory CompiledSkills struct directly.

if err := cliskills.WriteArtifacts(bc.Opts.OutputDir, compiled); err != nil {
return fmt.Errorf("writing skills artifacts: %w", err)
}

bc.SkillsCount = compiled.Count
bc.SkillsCount = len(entries)
if bc.Spec != nil {
bc.Spec.SkillsSpecVersion = "agentskills-v1"
bc.Spec.ForgeSkillsExtVersion = "1.0"
}

bc.AddFile("compiled/skills/skills.json", filepath.Join(bc.Opts.OutputDir, "compiled", "skills", "skills.json"))
bc.AddFile("compiled/prompt.txt", filepath.Join(bc.Opts.OutputDir, "compiled", "prompt.txt"))
return nil
}

Expand Down
13 changes: 8 additions & 5 deletions forge-cli/build/skills_stage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,15 @@ Summarize text content.
t.Errorf("SkillsSpecVersion = %q, want %q", bc.Spec.SkillsSpecVersion, "agentskills-v1")
}

// Check artifacts exist
if _, err := os.Stat(filepath.Join(outDir, "compiled", "skills", "skills.json")); os.IsNotExist(err) {
t.Error("skills.json not created")
// Issue #147: the stage no longer writes compiled/skills/skills.json or
// compiled/prompt.txt — the runtime never opened them and they bloated
// the container image. Assert they are NOT present so a future change
// reintroducing the dead writers is caught.
if _, err := os.Stat(filepath.Join(outDir, "compiled", "skills", "skills.json")); err == nil {
t.Error("compiled/skills/skills.json should not be generated (issue #147)")
}
if _, err := os.Stat(filepath.Join(outDir, "compiled", "prompt.txt")); os.IsNotExist(err) {
t.Error("prompt.txt not created")
if _, err := os.Stat(filepath.Join(outDir, "compiled", "prompt.txt")); err == nil {
t.Error("compiled/prompt.txt should not be generated (issue #147)")
}
}

Expand Down
14 changes: 0 additions & 14 deletions forge-cli/build/validate_stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,6 @@ func (s *ValidateStage) Execute(ctx context.Context, bc *pipeline.BuildContext)
}
}

// Validate skills artifacts if skills were compiled
if bc.SkillsCount > 0 {
skillsArtifacts := []string{
filepath.Join("compiled", "skills", "skills.json"),
filepath.Join("compiled", "prompt.txt"),
}
for _, f := range skillsArtifacts {
path := filepath.Join(bc.Opts.OutputDir, f)
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("expected skills artifact missing: %s", f)
}
}
}

// Validate egress artifact if egress was resolved
if bc.EgressResolved != nil {
egressPath := filepath.Join(bc.Opts.OutputDir, "compiled", "egress_allowlist.json")
Expand Down
12 changes: 12 additions & 0 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,19 @@ func (r *Runner) Run(ctx context.Context) error {
}

// 0b. Verify build output integrity if checksums.json exists.
// Inside a Forge container, .forge-output/ is flattened into
// WorkDir (typically /app) — the .dockerignore drops the dir
// while keeping checksums.json at /app/checksums.json. On the
// operator side `forge run` is invoked next to forge.yaml, so
// the build output still lives under <WorkDir>/.forge-output/.
// Try the operator-side layout first, then fall back to the
// flattened container layout (issue #147).
outputDir := filepath.Join(r.cfg.WorkDir, ".forge-output")
if _, err := os.Stat(filepath.Join(outputDir, "checksums.json")); os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(r.cfg.WorkDir, "checksums.json")); err == nil {
outputDir = r.cfg.WorkDir
}
}
if err := VerifyBuildOutput(outputDir); err != nil {
r.logger.Warn("build output verification failed", map[string]any{"error": err.Error()})
}
Expand Down
Loading
Loading