Skip to content
Draft
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
52 changes: 52 additions & 0 deletions e2e-tests/git-archive-build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package:
name: test-git-archive
version: 1.0.0
epoch: 0
description: This package mainly just tests the git-archive pipeline
copyright:
- license: Apache-2.0

environment:
contents:
packages:
- busybox

# git-archive sources a subtree of the local git repository (the one containing
# this config) host-side, without cloning and without mounting .git into the
# build. With no tag/branch it archives the commit being built, so it packages
# the fixture exactly as committed in this repository. Tag/branch/expected-commit
# variants are covered by the unit tests in pkg/build/gitarchive_test.go; this
# e2e test exercises the real build integration.
pipeline:
- name: "Create the bogus package content"
runs: |
echo "package does not do anything" > "${{targets.contextdir}}/README"

- name: "Archive a subtree of the local repository at the build commit"
uses: git-archive
with:
path: e2e-tests/git-archive-fixture

- name: "Verify the subtree was archived into the workspace"
runs: |
set -e
dir=e2e-tests/git-archive-fixture

# The archived files retain their repository-root-relative path prefix.
[ -f "$dir/marker.txt" ] || { echo "FAIL: $dir/marker.txt missing"; exit 1; }
[ -f "$dir/nested/deep.txt" ] || { echo "FAIL: $dir/nested/deep.txt missing"; exit 1; }

# Content matches the committed fixture.
grep -q "git-archive e2e fixture" "$dir/marker.txt" || { echo "FAIL: marker.txt content"; exit 1; }
grep -q "nested content" "$dir/nested/deep.txt" || { echo "FAIL: nested/deep.txt content"; exit 1; }

# Only the requested subtree is archived: repository files outside the
# archived path must NOT be present in the workspace.
for f in go.mod e2e-tests/git-archive-build.yaml pkg/build/build.go; do
if [ -e "$f" ]; then
echo "FAIL: '$f' should not be present; only the requested subtree should be archived"
exit 1
fi
done

echo "PASS: git-archive populated the workspace with only the requested subtree"
1 change: 1 addition & 0 deletions e2e-tests/git-archive-fixture/marker.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
git-archive e2e fixture
1 change: 1 addition & 0 deletions e2e-tests/git-archive-fixture/nested/deep.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nested content
192 changes: 192 additions & 0 deletions pkg/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"maps"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
Expand Down Expand Up @@ -554,6 +555,187 @@ func (b *Build) populateWorkspace(ctx context.Context, src fs.FS) error {
})
}

// gitArchiveStepName is the pipeline `uses:` identifier handled host-side.
const gitArchiveStepName = "git-archive"

// countGitArchiveSteps returns the number of git-archive steps in the given
// pipeline tree, descending into nested sub-pipelines.
func countGitArchiveSteps(steps []config.Pipeline) int {
n := 0
for i := range steps {
if steps[i].Uses == gitArchiveStepName {
n++
}
n += countGitArchiveSteps(steps[i].Pipeline)
}
return n
}

// maybeGitArchiveSource looks for a `git-archive` step in the main pipeline and,
// if present, performs the archive host-side: it extracts the requested subtree
// of the local git repository into a temporary directory and repoints
// b.SourceDir at it, so the normal workspace population copies only the archived
// subtree into the build. This avoids cloning over the network and avoids
// mounting the repository's .git history into the sandbox. git-archive supersedes
// any --source-dir, as it is itself the source-acquisition mechanism.
//
// By default it archives at the commit melange is building (the config file's
// repository commit), so the packaged source matches the commit under build
// (subject to .gitattributes export rules; see gitArchive).
//
// It always returns a non-nil cleanup function (a no-op when no archive was
// performed), so callers can unconditionally defer it.
func (b *Build) maybeGitArchiveSource(ctx context.Context) (func(), error) {
log := clog.FromContext(ctx)
noop := func() {}

// git-archive runs host-side before the sandbox, so it is only meaningful as
// a single top-level step in the main pipeline. Reject duplicates or
// placement in nested/subpackage/test pipelines rather than silently
// treating the extra steps as in-sandbox no-op markers (`melange test`
// never performs the host-side archive).
total := countGitArchiveSteps(b.Configuration.Pipeline)
if b.Configuration.Test != nil {
total += countGitArchiveSteps(b.Configuration.Test.Pipeline)
}
for i := range b.Configuration.Subpackages {
total += countGitArchiveSteps(b.Configuration.Subpackages[i].Pipeline)
if b.Configuration.Subpackages[i].Test != nil {
total += countGitArchiveSteps(b.Configuration.Subpackages[i].Test.Pipeline)
}
}
if total == 0 {
return noop, nil
}
if total > 1 {
return noop, fmt.Errorf("git-archive: may be used at most once per build, found %d", total)
}

var step *config.Pipeline
for i := range b.Configuration.Pipeline {
if b.Configuration.Pipeline[i].Uses == gitArchiveStepName {
step = &b.Configuration.Pipeline[i]
break
}
}
if step == nil {
return noop, fmt.Errorf("git-archive: must be a top-level step in the main pipeline (not nested, in a subpackage, or in a test pipeline)")
}

// git-archive provides the workspace source, which is incompatible with an
// explicitly empty workspace.
if b.EmptyWorkspace {
return noop, fmt.Errorf("git-archive: cannot be used with an empty workspace")
}

// The archive shells out to host git and tar (the pipeline's `needs` only
// provisions the sandbox). Fail early with a clear message if either is
// missing.
for _, tool := range []string{"git", "tar"} {
if _, err := exec.LookPath(tool); err != nil {
return noop, fmt.Errorf("git-archive: requires %q on the host: %w", tool, err)
}
}

// Compile has already validated the step's inputs and substituted ${{...}}
// templates into step.With (compilePipeline rewrites With with the mutated,
// non-default values), so the values can be read directly.
get := func(name string) string { return step.With[name] }

archivePath := get("path")
if archivePath == "" {
return noop, fmt.Errorf("git-archive: 'path' is required")
}

tag := get("tag")
branch := get("branch")
if tag != "" && branch != "" {
return noop, fmt.Errorf("git-archive: 'tag' and 'branch' are mutually exclusive")
}

// Anchor git at the directory containing the config file; git discovers the
// enclosing repository root from there. Make the path absolute first so this
// does not depend on the current working directory.
configPath, err := filepath.Abs(b.ConfigFile)
if err != nil {
return noop, fmt.Errorf("resolving config file path: %w", err)
}
repoDir := filepath.Dir(configPath)

expectedCommit := get("expected-commit")

// Determine the ref to archive. A tag or branch is used as given; if neither
// is specified, default to the commit melange is building and pin the
// assurance to it. The chart source and the config live in the same
// repository, so the default packages 'path' as committed at the build
// commit (subject to .gitattributes export rules).
var ref string
switch {
case tag != "":
ref = tag
case branch != "":
ref = branch
default:
buildCommit := b.ConfigFileRepositoryCommit
if buildCommit == "" || buildCommit == config.UnknownCommit {
// Melange could not determine the build commit (for example, go-git
// cannot read HEAD in a linked worktree). Fall back to resolving HEAD
// with the git CLI, which is the commit being built.
head, err := gitRevParse(ctx, repoDir, "HEAD")
if err != nil {
return noop, fmt.Errorf("git-archive: no 'tag' or 'branch' given and could not determine the build commit (the config must live in a git repository, or set 'tag'/'branch' explicitly): %w", err)
}
buildCommit = head
}
ref = buildCommit
if expectedCommit == "" {
expectedCommit = buildCommit
}
}

dest, err := os.MkdirTemp("", "melange-git-archive-")
if err != nil {
return noop, fmt.Errorf("creating temp dir: %w", err)
}
cleanup := func() {
if err := os.RemoveAll(dest); err != nil {
log.Warnf("failed to remove git-archive temp dir %s: %v", dest, err)
}
}

resolved, err := gitArchive(ctx, &gitArchiveOptions{
RepositoryDir: repoDir,
Ref: ref,
RefIsBranch: branch != "",
Path: archivePath,
ExpectedCommit: expectedCommit,
Destination: dest,
})
if err != nil {
cleanup()
return noop, err
}

// Record the resolved commit back into the step so the SBOM provenance
// entry (which reads this `with` map) reflects the exact source even when
// the manifest left ref/expected-commit to default. It is safe because each
// architecture build parses and owns its own Configuration, so this is not
// shared across goroutines.
if step.With == nil {
step.With = map[string]string{}
}
step.With["expected-commit"] = resolved

// The extracted subtree is copied again by populateWorkspace into the build
// workspace, so the data is passed over twice. This deliberately reuses
// melange's standard workspace population (ignore rules, compiler config
// files) instead of extracting in place; for chart-sized subtrees the extra
// copy is negligible. Streaming directly into the workspace is a possible
// future optimization.
b.SourceDir = dest
return cleanup, nil
}

type linterTarget struct {
pkgName string
disabled []string // checks that are downgraded from required -> warn
Expand Down Expand Up @@ -622,6 +804,16 @@ func (b *Build) BuildPackage(ctx context.Context) error {
runner: b.Runner,
}

// If the pipeline declares a git-archive source step, perform the archive
// host-side and repoint SourceDir at the extracted subtree before the
// workspace is populated. This sources from a local git repository at a
// pinned ref without mounting the repository's .git history into the build.
archiveCleanup, err := b.maybeGitArchiveSource(ctx)
if err != nil {
return fmt.Errorf("git-archive source: %w", err)
}
defer archiveCleanup()

if b.EmptyWorkspace {
log.Debugf("empty workspace requested")
} else {
Expand Down
Loading
Loading