diff --git a/docs/configuration.md b/docs/configuration.md index 0e7a089d07..323052d335 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,10 +77,27 @@ of the `ko` process. The `ldflags` default value is `[]`. -> 💡 **Note:** Even though the configuration section is similar to the -[GoReleaser `builds` section](https://goreleaser.com/customization/build/), -only the `env`, `flags` and `ldflags` fields are currently supported. Also, the -templating support is currently limited to using environment variables only. +### Templating support + +The `ko` builds supports templating of `flags` and `ldflags`, similar to the +[GoReleaser `builds` section](https://goreleaser.com/customization/build/). + +The table below lists the supported template parameters. + +| Template param | Description | +|-----------------------|-------------------------------------------------------| +| `Env` | Map of system environment variables from `os.Environ` | +| `Date` | The UTC build date in RFC 3339 format | +| `Timestamp` | The UTC build date as Unix epoc seconds | +| `Git.Branch` | The current git branch | +| `Git.Tag` | The current git tag | +| `Git.ShortCommit` | The git commit short hash | +| `Git.FullCommit` | The git commit full hash | +| `Git.CommitDate` | The UTC commit date in RFC 3339 format | +| `Git.CommitTimestamp` | The UTC commit date in Unix format | +| `Git.IsDirty` | Whether or not current git state is dirty | +| `Git.IsClean` | Whether or not current git state is clean. | +| `Git.TreeState` | Either `clean` or `dirty` | ### Setting default platforms diff --git a/pkg/build/gobuild.go b/pkg/build/gobuild.go index f7ff43dfb1..98cbf85362 100644 --- a/pkg/build/gobuild.go +++ b/pkg/build/gobuild.go @@ -31,6 +31,7 @@ import ( "strconv" "strings" "text/template" + "time" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -40,6 +41,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/types" "github.com/google/ko/internal/sbom" "github.com/google/ko/pkg/caps" + "github.com/google/ko/pkg/internal/git" specsv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/v2/pkg/oci" ocimutate "github.com/sigstore/cosign/v2/pkg/oci/mutate" @@ -63,11 +65,12 @@ type GetBase func(context.Context, string) (name.Reference, Result, error) // buildContext provides parameters for a builder function. type buildContext struct { - ip string - dir string - env []string - platform v1.Platform - config Config + creationTime v1.Time + ip string + dir string + env []string + platform v1.Platform + config Config } type builder func(context.Context, buildContext) (string, error) @@ -264,7 +267,7 @@ func getGoBinary() string { } func build(ctx context.Context, buildCtx buildContext) (string, error) { - buildArgs, err := createBuildArgs(buildCtx.config) + buildArgs, err := createBuildArgs(ctx, buildCtx) if err != nil { return "", err } @@ -721,7 +724,7 @@ func (g *gobuild) tarKoData(ref reference, platform *v1.Platform) (*bytes.Buffer return buf, walkRecursive(tw, root, chroot, creationTime, platform) } -func createTemplateData() map[string]interface{} { +func createTemplateData(ctx context.Context, buildCtx buildContext) map[string]interface{} { envVars := map[string]string{ "LDFLAGS": "", } @@ -730,8 +733,23 @@ func createTemplateData() map[string]interface{} { envVars[kv[0]] = kv[1] } + // Get the git information, if available. + info, err := git.GetInfo(ctx, buildCtx.dir) + if err != nil { + log.Printf("%v", err) + } + + // Use the creation time as the build date, if provided. + date := buildCtx.creationTime.Time + if date.IsZero() { + date = time.Now() + } + return map[string]interface{}{ - "Env": envVars, + "Env": envVars, + "Git": info.TemplateValue(), + "Date": date.Format(time.RFC3339), + "Timestamp": date.UTC().Unix(), } } @@ -754,13 +772,13 @@ func applyTemplating(list []string, data map[string]interface{}) ([]string, erro return result, nil } -func createBuildArgs(buildCfg Config) ([]string, error) { +func createBuildArgs(ctx context.Context, buildCtx buildContext) ([]string, error) { var args []string - data := createTemplateData() + data := createTemplateData(ctx, buildCtx) - if len(buildCfg.Flags) > 0 { - flags, err := applyTemplating(buildCfg.Flags, data) + if len(buildCtx.config.Flags) > 0 { + flags, err := applyTemplating(buildCtx.config.Flags, data) if err != nil { return nil, err } @@ -768,8 +786,8 @@ func createBuildArgs(buildCfg Config) ([]string, error) { args = append(args, flags...) } - if len(buildCfg.Ldflags) > 0 { - ldflags, err := applyTemplating(buildCfg.Ldflags, data) + if len(buildCtx.config.Ldflags) > 0 { + ldflags, err := applyTemplating(buildCtx.config.Ldflags, data) if err != nil { return nil, err } @@ -850,11 +868,12 @@ func (g *gobuild) buildOne(ctx context.Context, refStr string, base v1.Image, pl // Do the build into a temporary file. config := g.configForImportPath(ref.Path()) file, err := g.build(ctx, buildContext{ - ip: ref.Path(), - dir: g.dir, - env: g.env, - platform: *platform, - config: config, + creationTime: g.creationTime, + ip: ref.Path(), + dir: g.dir, + env: g.env, + platform: *platform, + config: config, }) if err != nil { return nil, fmt.Errorf("build: %w", err) diff --git a/pkg/build/gobuild_test.go b/pkg/build/gobuild_test.go index 6dd278b59b..8764386608 100644 --- a/pkg/build/gobuild_test.go +++ b/pkg/build/gobuild_test.go @@ -37,6 +37,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/google/ko/pkg/internal/gittesting" specsv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sigstore/cosign/v2/pkg/oci" ) @@ -313,6 +314,98 @@ func TestBuildEnv(t *testing.T) { } } +func TestCreateTemplateData(t *testing.T) { + t.Run("env", func(t *testing.T) { + t.Setenv("FOO", "bar") + params := createTemplateData(context.TODO(), buildContext{}) + vars := params["Env"].(map[string]string) + if vars["FOO"] != "bar" { + t.Fatalf("vars[FOO]=%q, want %q", vars["FOO"], "bar") + } + }) + + t.Run("empty creation time", func(t *testing.T) { + params := createTemplateData(context.TODO(), buildContext{}) + + // Make sure the date was set to time.Now(). + actualDateStr := params["Date"].(string) + actualDate, err := time.Parse(time.RFC3339, actualDateStr) + if err != nil { + t.Fatal(err) + } + if time.Since(actualDate) > time.Minute { + t.Fatalf("expected date to be now, but was %v", actualDate) + } + + // Check the timestamp. + actualTimestampSec := params["Timestamp"].(int64) + actualTimestamp := time.Unix(actualTimestampSec, 0) + expectedTimestamp := actualDate.Truncate(time.Second) + if !actualTimestamp.Equal(expectedTimestamp) { + t.Fatalf("expected timestamp %v, but was %v", + expectedTimestamp, actualTimestamp) + } + }) + + t.Run("creation time", func(t *testing.T) { + // Create a reference time for use as a creation time. + expectedTime, err := time.Parse(time.RFC3339, "2012-11-01T22:08:41+00:00") + if err != nil { + t.Fatal(err) + } + + params := createTemplateData(context.TODO(), buildContext{ + creationTime: v1.Time{Time: expectedTime}, + }) + + // Check the date. + actualDateStr := params["Date"].(string) + actualDate, err := time.Parse(time.RFC3339, actualDateStr) + if err != nil { + t.Fatal(err) + } + if !actualDate.Equal(expectedTime) { + t.Fatalf("expected date to be %v, but was %v", expectedTime, actualDate) + } + + // Check the timestamp. + actualTimestampSec := params["Timestamp"].(int64) + actualTimestamp := time.Unix(actualTimestampSec, 0) + if !actualTimestamp.Equal(expectedTime) { + t.Fatalf("expected timestamp to be %v, but was %v", expectedTime, actualTimestamp) + } + }) + + t.Run("no git available", func(t *testing.T) { + dir := t.TempDir() + params := createTemplateData(context.TODO(), buildContext{dir: dir}) + gitParams := params["Git"].(map[string]interface{}) + + requireEqual(t, "", gitParams["Branch"]) + requireEqual(t, "", gitParams["Tag"]) + requireEqual(t, "", gitParams["ShortCommit"]) + requireEqual(t, "", gitParams["FullCommit"]) + requireEqual(t, "clean", gitParams["TreeState"]) + }) + + t.Run("git", func(t *testing.T) { + // Create a fake git structure under the test temp dir. + const fakeGitURL = "git@github.com:foo/bar.git" + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit1") + gittesting.GitTag(t, dir, "v0.0.1") + + params := createTemplateData(context.TODO(), buildContext{dir: dir}) + gitParams := params["Git"].(map[string]interface{}) + + requireEqual(t, "main", gitParams["Branch"]) + requireEqual(t, "v0.0.1", gitParams["Tag"]) + requireEqual(t, "clean", gitParams["TreeState"]) + }) +} + func TestBuildConfig(t *testing.T) { tests := []struct { description string @@ -1248,3 +1341,10 @@ func TestGoBuildConsistentMediaTypes(t *testing.T) { }) } } + +func requireEqual(t *testing.T, expected any, actual any) { + t.Helper() + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("%T differ (-got, +want): %s", expected, diff) + } +} diff --git a/pkg/internal/git/errors.go b/pkg/internal/git/errors.go new file mode 100644 index 0000000000..9ac5f5c44d --- /dev/null +++ b/pkg/internal/git/errors.go @@ -0,0 +1,64 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package git + +import ( + "errors" + "fmt" +) + +var ( + // ErrNoTag happens if the underlying git repository doesn't contain any tags + // but no snapshot-release was requested. + ErrNoTag = errors.New("git doesn't contain any tags. Tag info will not be available") + + // ErrNotRepository happens if you try to run ko against a folder + // which is not a git repository. + ErrNotRepository = errors.New("current folder is not a git repository. Git info will not be available") + + // ErrNoGit happens when git is not present in PATH. + ErrNoGit = errors.New("git not present in PATH. Git info will not be available") +) + +// ErrDirty happens when the repo has uncommitted/unstashed changes. +type ErrDirty struct { + status string +} + +func (e ErrDirty) Error() string { + return fmt.Sprintf("git is in a dirty state\nPlease check in your pipeline what can be changing the following files:\n%v\n", e.status) +} diff --git a/pkg/internal/git/git.go b/pkg/internal/git/git.go new file mode 100644 index 0000000000..208f74670f --- /dev/null +++ b/pkg/internal/git/git.go @@ -0,0 +1,103 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package git + +import ( + "bytes" + "context" + "errors" + "os/exec" + "strings" +) + +type runConfig struct { + dir string + env []string + args []string +} + +// run a git command and returns its output or errors. +func run(ctx context.Context, cfg runConfig) (string, error) { + extraArgs := []string{ + "-c", "log.showSignature=false", + } + cfg.args = append(extraArgs, cfg.args...) + /* #nosec */ + cmd := exec.CommandContext(ctx, "git", cfg.args...) + cmd.Dir = cfg.dir + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(cmd.Env, cfg.env...) + + err := cmd.Run() + + if err != nil { + return "", errors.New(stderr.String()) + } + + return stdout.String(), nil +} + +// clean the output. +func clean(output string, err error) (string, error) { + output = strings.ReplaceAll(strings.Split(output, "\n")[0], "'", "") + if err != nil { + err = errors.New(strings.TrimSuffix(err.Error(), "\n")) + } + return output, err +} + +// cleanAllLines returns all the non-empty lines of the output, cleaned up. +func cleanAllLines(output string, err error) ([]string, error) { + result := make([]string, 0) + for _, line := range strings.Split(output, "\n") { + l := strings.TrimSpace(strings.ReplaceAll(line, "'", "")) + if l == "" { + continue + } + result = append(result, l) + } + // TODO: maybe check for exec.ExitError only? + if err != nil { + err = errors.New(strings.TrimSuffix(err.Error(), "\n")) + } + return result, err +} diff --git a/pkg/internal/git/info.go b/pkg/internal/git/info.go new file mode 100644 index 0000000000..cd2325ecca --- /dev/null +++ b/pkg/internal/git/info.go @@ -0,0 +1,231 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package git + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// Info includes tags and diffs used in some point. +type Info struct { + Branch string + Tag string + ShortCommit string + FullCommit string + CommitDate time.Time + Dirty bool +} + +// TemplateValue converts this Info into a map for use in golang templates. +func (i Info) TemplateValue() map[string]interface{} { + treeState := "clean" + if i.Dirty { + treeState = "dirty" + } + + return map[string]interface{}{ + "Branch": i.Branch, + "Tag": i.Tag, + "ShortCommit": i.ShortCommit, + "FullCommit": i.FullCommit, + "CommitDate": i.CommitDate.UTC().Format(time.RFC3339), + "CommitTimestamp": i.CommitDate.UTC().Unix(), + "IsDirty": i.Dirty, + "IsClean": !i.Dirty, + "TreeState": treeState, + } +} + +// GetInfo returns git information for the given directory +func GetInfo(ctx context.Context, dir string) (Info, error) { + if _, err := exec.LookPath("git"); err != nil { + return Info{}, ErrNoGit + } + + if !isRepo(ctx, dir) { + return Info{}, ErrNotRepository + } + + branch, err := getBranch(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current branch: %w", err) + } + short, err := getShortCommit(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + full, err := getFullCommit(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get current commit: %w", err) + } + date, err := getCommitDate(ctx, dir) + if err != nil { + return Info{}, fmt.Errorf("couldn't get commit date: %w", err) + } + + dirty := checkDirty(ctx, dir) + + // TODO: allow exclusions. + tag, err := getTag(ctx, dir, []string{}) + if err != nil { + return Info{ + Branch: branch, + FullCommit: full, + ShortCommit: short, + CommitDate: date, + Tag: "v0.0.0", + Dirty: dirty != nil, + }, errors.Join(ErrNoTag, dirty) + } + + return Info{ + Branch: branch, + Tag: tag, + FullCommit: full, + ShortCommit: short, + CommitDate: date, + Dirty: dirty != nil, + }, dirty +} + +// isRepo returns true if current folder is a git repository. +func isRepo(ctx context.Context, dir string) bool { + out, err := run(ctx, runConfig{ + dir: dir, + args: []string{"rev-parse", "--is-inside-work-tree"}, + }) + return err == nil && strings.TrimSpace(out) == "true" +} + +// checkDirty returns an error if the current git repository is dirty. +func checkDirty(ctx context.Context, dir string) error { + out, err := run(ctx, runConfig{ + dir: dir, + args: []string{"status", "--porcelain"}, + }) + if strings.TrimSpace(out) != "" || err != nil { + return ErrDirty{status: out} + } + return nil +} + +func getBranch(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"rev-parse", "--abbrev-ref", "HEAD", "--quiet"}, + })) +} + +func getCommitDate(ctx context.Context, dir string) (time.Time, error) { + ct, err := clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format='%ct'", "HEAD", "--quiet"}, + })) + if err != nil { + return time.Time{}, err + } + if ct == "" { + return time.Time{}, nil + } + i, err := strconv.ParseInt(ct, 10, 64) + if err != nil { + return time.Time{}, err + } + t := time.Unix(i, 0).UTC() + return t, nil +} + +func getShortCommit(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format=%h", "HEAD", "--quiet"}, + })) +} + +func getFullCommit(ctx context.Context, dir string) (string, error) { + return clean(run(ctx, runConfig{ + dir: dir, + args: []string{"show", "--format=%H", "HEAD", "--quiet"}, + })) +} + +func getTag(ctx context.Context, dir string, excluding []string) (string, error) { + // this will get the last tag, even if it wasn't made against the + // last commit... + tags, err := cleanAllLines(gitDescribe(ctx, dir, "HEAD", excluding)) + if err != nil { + return "", err + } + tag := filterOut(tags, excluding) + return tag, err +} + +func gitDescribe(ctx context.Context, dir, ref string, excluding []string) (string, error) { + args := []string{ + "describe", + "--tags", + "--abbrev=0", + ref, + } + for _, exclude := range excluding { + args = append(args, "--exclude="+exclude) + } + return clean(run(ctx, runConfig{ + dir: dir, + args: args, + })) +} + +func filterOut(tags []string, exclude []string) string { + if len(exclude) == 0 && len(tags) > 0 { + return tags[0] + } + for _, tag := range tags { + for _, exl := range exclude { + if exl != tag { + return tag + } + } + } + return "" +} diff --git a/pkg/internal/git/info_test.go b/pkg/internal/git/info_test.go new file mode 100644 index 0000000000..767116d0dd --- /dev/null +++ b/pkg/internal/git/info_test.go @@ -0,0 +1,255 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package git_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/ko/pkg/internal/git" + "github.com/google/ko/pkg/internal/gittesting" +) + +const fakeGitURL = "git@github.com:foo/bar.git" + +func TestNotAGitFolder(t *testing.T) { + dir := t.TempDir() + i, err := git.GetInfo(context.TODO(), dir) + requireErrorIs(t, err, git.ErrNotRepository) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestSingleCommit(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit1") + gittesting.GitTag(t, dir, "v0.0.1") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.1", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestBranch(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "test-branch-commit") + gittesting.GitTag(t, dir, "test-branch-tag") + gittesting.GitCheckoutBranch(t, dir, "test-branch") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + + tpl := i.TemplateValue() + requireEqual(t, "test-branch", tpl["Branch"]) + requireEqual(t, "test-branch-tag", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestNewRepository(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + i, err := git.GetInfo(context.TODO(), dir) + // TODO: improve this error handling + requireErrorContains(t, err, `fatal: ambiguous argument 'HEAD'`) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func TestNoTags(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "first") + i, err := git.GetInfo(context.TODO(), dir) + requireErrorIs(t, err, git.ErrNoTag) + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.0", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func TestDirty(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + testFile, err := os.Create(filepath.Join(dir, "testFile")) + requireNoError(t, err) + requireNoError(t, testFile.Close()) + gittesting.GitAdd(t, dir) + gittesting.GitCommit(t, dir, "commit2") + gittesting.GitTag(t, dir, "v0.0.1") + requireNoError(t, os.WriteFile(testFile.Name(), []byte("lorem ipsum"), 0o644)) + i, err := git.GetInfo(context.TODO(), dir) + requireErrorContains(t, err, "git is in a dirty state") + + tpl := i.TemplateValue() + requireEqual(t, "main", tpl["Branch"]) + requireEqual(t, "v0.0.1", tpl["Tag"]) + requireNotEmpty(t, tpl["ShortCommit"].(string)) + requireNotEmpty(t, tpl["FullCommit"].(string)) + requireNotEmpty(t, tpl["CommitDate"].(string)) + requireNotZero(t, tpl["CommitTimestamp"].(int64)) + requireTrue(t, tpl["IsDirty"].(bool)) + requireFalse(t, tpl["IsClean"].(bool)) + requireEqual(t, "dirty", tpl["TreeState"]) +} + +func TestValidState(t *testing.T) { + dir := t.TempDir() + gittesting.GitInit(t, dir) + gittesting.GitRemoteAdd(t, dir, fakeGitURL) + gittesting.GitCommit(t, dir, "commit3") + gittesting.GitTag(t, dir, "v0.0.1") + gittesting.GitTag(t, dir, "v0.0.2") + gittesting.GitCommit(t, dir, "commit4") + gittesting.GitTag(t, dir, "v0.0.3") + i, err := git.GetInfo(context.TODO(), dir) + requireNoError(t, err) + requireEqual(t, "v0.0.3", i.Tag) + requireFalse(t, i.Dirty) +} + +func TestGitNotInPath(t *testing.T) { + t.Setenv("PATH", "") + i, err := git.GetInfo(context.TODO(), "") + requireErrorIs(t, err, git.ErrNoGit) + + tpl := i.TemplateValue() + requireEmpty(t, tpl) +} + +func requireEmpty(t *testing.T, tpl map[string]interface{}) { + requireEqual(t, "", tpl["Branch"]) + requireEqual(t, "", tpl["Tag"]) + requireEqual(t, "", tpl["ShortCommit"]) + requireEqual(t, "", tpl["FullCommit"]) + requireFalse(t, tpl["IsDirty"].(bool)) + requireTrue(t, tpl["IsClean"].(bool)) + requireEqual(t, "clean", tpl["TreeState"]) +} + +func requireEqual(t *testing.T, expected any, actual any) { + t.Helper() + if diff := cmp.Diff(expected, actual); diff != "" { + t.Fatalf("%T differ (-got, +want): %s", expected, diff) + } +} + +func requireTrue(t *testing.T, val bool) { + t.Helper() + requireEqual(t, true, val) +} + +func requireFalse(t *testing.T, val bool) { + t.Helper() + requireEqual(t, false, val) +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func requireError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } +} + +func requireErrorIs(t *testing.T, err error, target error) { + t.Helper() + if !errors.Is(err, target) { + t.Fatalf("expected error to be %v, got %v", target, err) + } +} + +func requireErrorContains(t *testing.T, err error, target string) { + t.Helper() + requireError(t, err) + if !strings.Contains(err.Error(), target) { + t.Fatalf("expected error to contain %q, got %q", target, err) + } +} + +func requireNotEmpty(t *testing.T, val string) { + t.Helper() + if len(val) == 0 { + t.Fatalf("value should not be empty") + } +} + +func requireNotZero(t *testing.T, val int64) { + t.Helper() + if val == 0 { + t.Fatalf("value should not be zero") + } +} diff --git a/pkg/internal/gittesting/git.go b/pkg/internal/gittesting/git.go new file mode 100644 index 0000000000..d2397d1692 --- /dev/null +++ b/pkg/internal/gittesting/git.go @@ -0,0 +1,160 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package gittesting + +import ( + "bytes" + "errors" + "os/exec" + "strings" + "testing" +) + +// GitInit inits a new git project. +func GitInit(t *testing.T, dir string) { + t.Helper() + out, err := fakeGit(dir, "init") + requireNoError(t, err) + requireContains(t, out, "Initialized empty Git repository", "") + requireNoError(t, err) + GitCheckoutBranch(t, dir, "main") + _, _ = fakeGit("branch", "-D", "master") +} + +// GitRemoteAdd adds the given url as remote. +func GitRemoteAdd(t *testing.T, dir, url string) { + t.Helper() + out, err := fakeGit(dir, "remote", "add", "origin", url) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitCommit creates a git commits. +func GitCommit(t *testing.T, dir, msg string) { + t.Helper() + out, err := fakeGit(dir, "commit", "--allow-empty", "-m", msg) + requireNoError(t, err) + requireContains(t, out, "main", msg) +} + +// GitTag creates a git tag. +func GitTag(t *testing.T, dir, tag string) { + t.Helper() + out, err := fakeGit(dir, "tag", tag) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAnnotatedTag creates an annotated tag. +func GitAnnotatedTag(t *testing.T, dir, tag, message string) { + t.Helper() + out, err := fakeGit(dir, "tag", "-a", tag, "-m", message) + requireNoError(t, err) + requireEmpty(t, out) +} + +// GitAdd adds all files to stage. +func GitAdd(t *testing.T, dir string) { + t.Helper() + out, err := fakeGit(dir, "add", "-A") + requireNoError(t, err) + requireEmpty(t, out) +} + +func fakeGit(dir string, args ...string) (string, error) { + allArgs := []string{ + "-c", "user.name='GoReleaser'", + "-c", "user.email='test@goreleaser.github.com'", + "-c", "commit.gpgSign=false", + "-c", "tag.gpgSign=false", + "-c", "log.showSignature=false", + } + allArgs = append(allArgs, args...) + return gitRun(dir, allArgs...) +} + +// GitCheckoutBranch allows us to change the active branch that we're using. +func GitCheckoutBranch(t *testing.T, dir, name string) { + t.Helper() + out, err := fakeGit(dir, "checkout", "-b", name) + requireNoError(t, err) + requireEmpty(t, out) +} + +func gitRun(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if err != nil { + return "", errors.New(stderr.String()) + } + + return stdout.String(), nil +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func requireContains(t *testing.T, val, expected, msg string) { + t.Helper() + if !strings.Contains(val, expected) { + if len(msg) > 0 { + t.Fatalf("%s: expected value %s missing from value %s", msg, expected, val) + } else { + t.Fatalf("expected value %s missing from value %s", expected, val) + } + } +} + +func requireEmpty(t *testing.T, val string) { + t.Helper() + if len(val) > 0 { + t.Fatalf("%s: expected empty string", val) + } +} diff --git a/pkg/internal/gittesting/git_test.go b/pkg/internal/gittesting/git_test.go new file mode 100644 index 0000000000..66d9e3e75b --- /dev/null +++ b/pkg/internal/gittesting/git_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 ko Build Authors All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// MIT License +// +// Copyright (c) 2016-2022 Carlos Alexandro Becker +// +// 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. + +package gittesting + +import "testing" + +func TestGit(t *testing.T) { + dir := t.TempDir() + GitInit(t, dir) + GitAdd(t, dir) + GitCommit(t, dir, "commit1") + GitRemoteAdd(t, dir, "git@github.com:goreleaser/nope.git") + GitTag(t, dir, "v1.0.0") +}