From e62f7d6a10589130d543be3eb93c146d6dfaed55 Mon Sep 17 00:00:00 2001 From: Faye Date: Mon, 24 Aug 2026 16:07:01 +0200 Subject: [PATCH 1/2] fix(attest): don't fail when a CI-defaulted --commit has no repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --commit is populated from CI environment variables (GITHUB_SHA, CI_COMMIT_SHA, BITBUCKET_COMMIT, ...) whether or not the user asked for it. The common attestation flow and begin trail then ran a git lookup because commitSHA was non-empty, so a CI job that has not checked out the repository failed with "failed to get commit info. failed to open git repository at .: repository does not exist" for a commit the user never requested. A commit that arrived from the CI default now warns and proceeds without commit info; a commit the user passed explicitly still errors, so a wrong --repo-root is not silently swallowed. An unresolvable commit in a shallow clone takes the same route, being the same surprise for the same reason. Doing nothing was not an option for affected users: the empty-value rule in refuseEmptyFlagValues rejects --commit "", so there is no way to opt out of the CI default from the command line. The two copies of the lookup are now one resolveCommitInfo, which is also the only level at which the CI default is testable — DefaultValue returns "" whenever KOSLI_TESTS is set, so no command-level test can produce an implicitly defaulted --commit. attest pr * and attest jira need the commit to do their work and dereferenced payload.Commit unguarded. That was unreachable while the empty-value rule blocked every route to a nil commit; soft-failing opens one, so both now report what is missing instead of panicking. Refs kosli-dev/server#6094 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kosli/attestCustom.go | 1 + cmd/kosli/attestDecision.go | 1 + cmd/kosli/attestGeneric.go | 1 + cmd/kosli/attestJira.go | 5 + cmd/kosli/attestJunit.go | 1 + cmd/kosli/attestOverride.go | 1 + cmd/kosli/attestPRAzure.go | 1 + cmd/kosli/attestPRBitbucket.go | 1 + cmd/kosli/attestPRGithub.go | 1 + cmd/kosli/attestPRGitlab.go | 1 + cmd/kosli/attestSnyk.go | 1 + cmd/kosli/attestSonar.go | 1 + cmd/kosli/attestation.go | 29 +++- cmd/kosli/beginTrail.go | 9 +- cmd/kosli/commitInfoResolution_test.go | 126 ++++++++++++++++++ cmd/kosli/pullrequest.go | 4 + cmd/kosli/root.go | 4 +- .../testdata/output/docs/mintlify/snyk.md | 2 +- 18 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 cmd/kosli/commitInfoResolution_test.go diff --git a/cmd/kosli/attestCustom.go b/cmd/kosli/attestCustom.go index 0391aeff7..f9e442b94 100644 --- a/cmd/kosli/attestCustom.go +++ b/cmd/kosli/attestCustom.go @@ -137,6 +137,7 @@ func newAttestCustomCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestDecision.go b/cmd/kosli/attestDecision.go index dcc9a3a2a..376067e5f 100644 --- a/cmd/kosli/attestDecision.go +++ b/cmd/kosli/attestDecision.go @@ -133,6 +133,7 @@ func newAttestDecisionCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestGeneric.go b/cmd/kosli/attestGeneric.go index e5dc6bbf5..d7399dd2e 100644 --- a/cmd/kosli/attestGeneric.go +++ b/cmd/kosli/attestGeneric.go @@ -131,6 +131,7 @@ func newAttestGenericCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestJira.go b/cmd/kosli/attestJira.go index b300c57f7..46a699b59 100644 --- a/cmd/kosli/attestJira.go +++ b/cmd/kosli/attestJira.go @@ -247,6 +247,7 @@ func newAttestJiraCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } @@ -292,6 +293,10 @@ func (o *attestJiraOptions) run(args []string) error { return err } + if o.payload.Commit == nil { + return fmt.Errorf("failed to get commit info, which is required to search for Jira issue keys. Pass --commit and point --repo-root at a repository containing it") + } + gv, err := gitview.New(o.srcRepoRoot) if err != nil { return err diff --git a/cmd/kosli/attestJunit.go b/cmd/kosli/attestJunit.go index a074c622b..4ce50389a 100644 --- a/cmd/kosli/attestJunit.go +++ b/cmd/kosli/attestJunit.go @@ -135,6 +135,7 @@ func newAttestJunitCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestOverride.go b/cmd/kosli/attestOverride.go index 55fea5c7c..393110e7a 100644 --- a/cmd/kosli/attestOverride.go +++ b/cmd/kosli/attestOverride.go @@ -105,6 +105,7 @@ func newAttestOverrideCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestPRAzure.go b/cmd/kosli/attestPRAzure.go index e13de8162..38159bb48 100644 --- a/cmd/kosli/attestPRAzure.go +++ b/cmd/kosli/attestPRAzure.go @@ -148,6 +148,7 @@ func newAttestAzurePRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.retriever = azUtils.NewAzureConfig(azureFlagsValues.Token, azureFlagsValues.OrgUrl, azureFlagsValues.Project, o.repoName) return o.run(args) diff --git a/cmd/kosli/attestPRBitbucket.go b/cmd/kosli/attestPRBitbucket.go index dd79ab993..37d81f1ee 100644 --- a/cmd/kosli/attestPRBitbucket.go +++ b/cmd/kosli/attestPRBitbucket.go @@ -169,6 +169,7 @@ func newAttestBitbucketPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.getRetriever().(*bbUtils.Config).Repository = o.repoName return o.run(args) }, diff --git a/cmd/kosli/attestPRGithub.go b/cmd/kosli/attestPRGithub.go index aae2fac2a..be15ceb9a 100644 --- a/cmd/kosli/attestPRGithub.go +++ b/cmd/kosli/attestPRGithub.go @@ -143,6 +143,7 @@ func newAttestGithubPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") o.retriever = ghUtils.NewGithubRetrieverFunc(githubFlagsValues.Token, githubFlagsValues.BaseURL, githubFlagsValues.Org, o.repoName, global.Debug) return o.run(args) diff --git a/cmd/kosli/attestPRGitlab.go b/cmd/kosli/attestPRGitlab.go index cbbb1476f..98b7ce6ea 100644 --- a/cmd/kosli/attestPRGitlab.go +++ b/cmd/kosli/attestPRGitlab.go @@ -143,6 +143,7 @@ func newAttestGitlabPRCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") // GitlabConfig.Repository is the short project name (CI_PROJECT_NAME); // combined with Org (CI_PROJECT_NAMESPACE) it forms the API ProjectID. // This is separate from repo_info.name, which uses the full CI_PROJECT_PATH. diff --git a/cmd/kosli/attestSnyk.go b/cmd/kosli/attestSnyk.go index 6c1bb7afb..85939c323 100644 --- a/cmd/kosli/attestSnyk.go +++ b/cmd/kosli/attestSnyk.go @@ -147,6 +147,7 @@ func newAttestSnykCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestSonar.go b/cmd/kosli/attestSonar.go index 1c354e048..6275b6312 100644 --- a/cmd/kosli/attestSonar.go +++ b/cmd/kosli/attestSonar.go @@ -207,6 +207,7 @@ func newAttestSonarCmd(out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { o.repoURLExplicit = cmd.Flags().Changed("repo-url") o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } diff --git a/cmd/kosli/attestation.go b/cmd/kosli/attestation.go index e0d747156..8e5905e7e 100644 --- a/cmd/kosli/attestation.go +++ b/cmd/kosli/attestation.go @@ -56,6 +56,7 @@ type CommonAttestationOptions struct { repoProvider string repoURLExplicit bool repoNameExplicit bool + commitSHAExplicit bool } func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestationPayload) error { @@ -80,15 +81,10 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation } if o.commitSHA != "" { - gv, err := gitview.New(o.srcRepoRoot) + payload.Commit, err = resolveCommitInfo(o.srcRepoRoot, o.commitSHA, o.commitSHAExplicit, o.redactedCommitInfo) if err != nil { - return fmt.Errorf("failed to get commit info. %s", err) + return err } - commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.commitSHA, false, o.redactedCommitInfo) - if err != nil { - return fmt.Errorf("failed to get commit info. %s", err) - } - payload.Commit = &commitInfo.BasicCommitInfo } payload.GitRepoInfo, err = getGitRepoInfoFromEnvironment() @@ -117,6 +113,25 @@ func (o *CommonAttestationOptions) run(args []string, payload *CommonAttestation return err } +// resolveCommitInfo returns nil when git cannot supply the commit info and the +// commit was not asked for explicitly, so a CI-defaulted --commit does not fail +// the command in a job with no checked-out repository (#6094). +func resolveCommitInfo(srcRepoRoot, commitSHA string, explicit bool, redactedCommitInfo []string) (*gitview.BasicCommitInfo, error) { + gv, err := gitview.New(srcRepoRoot) + if err == nil { + var commitInfo *gitview.CommitInfo + commitInfo, err = gv.GetCommitInfoFromCommitSHA(commitSHA, false, redactedCommitInfo) + if err == nil { + return &commitInfo.BasicCommitInfo, nil + } + } + if explicit { + return nil, fmt.Errorf("failed to get commit info. %s", err) + } + logger.Warn("attesting without commit info: --commit defaulted to %s from the CI environment, but %s. Point --repo-root at a repository containing that commit to attach it.", commitSHA, err.Error()) + return nil, nil +} + // mergeGitRepoInfo applies flag overrides onto base (which may be nil) and // returns nil if ID, Name, or URL is still empty after merging, so that the // field is omitted from the JSON payload. diff --git a/cmd/kosli/beginTrail.go b/cmd/kosli/beginTrail.go index 8a796b34f..b281c02b4 100644 --- a/cmd/kosli/beginTrail.go +++ b/cmd/kosli/beginTrail.go @@ -50,6 +50,7 @@ type beginTrailOptions struct { repoURL string repoProvider string repoNameExplicit bool + commitSHAExplicit bool } type TrailPayload struct { @@ -85,6 +86,7 @@ func newBeginTrailCmd(out io.Writer) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { o.repoNameExplicit = cmd.Flags().Changed("repository") + o.commitSHAExplicit = cmd.Flags().Changed("commit") return o.run(args) }, } @@ -128,15 +130,10 @@ func (o *beginTrailOptions) run(args []string) error { } if o.commitSHA != "" { - gv, err := gitview.New(o.srcRepoRoot) + o.payload.Commit, err = resolveCommitInfo(o.srcRepoRoot, o.commitSHA, o.commitSHAExplicit, o.redactedCommitInfo) if err != nil { return err } - commitInfo, err := gv.GetCommitInfoFromCommitSHA(o.commitSHA, false, o.redactedCommitInfo) - if err != nil { - return err - } - o.payload.Commit = &commitInfo.BasicCommitInfo } base, err := getGitRepoInfoFromEnvironment() diff --git a/cmd/kosli/commitInfoResolution_test.go b/cmd/kosli/commitInfoResolution_test.go new file mode 100644 index 000000000..092ff8de9 --- /dev/null +++ b/cmd/kosli/commitInfoResolution_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/suite" +) + +// CommitInfoResolutionTestSuite guards that a --commit which was defaulted from +// the CI environment does not fail the command when git cannot supply its info, +// while an explicitly passed --commit still does. +// +// The production trigger (a CI-defaulted --commit in a job with no checked-out +// repository) cannot be reproduced through the command harness, because +// DefaultValue returns "" whenever KOSLI_TESTS is set. resolveCommitInfo is +// therefore exercised directly, and the command cases below guard only that +// each command assigns commitSHAExplicit. +type CommitInfoResolutionTestSuite struct { + suite.Suite + headHash string + defaultKosliArguments string +} + +func (suite *CommitInfoResolutionTestSuite) SetupTest() { + repo, err := git.PlainOpen("../..") + suite.Require().NoError(err) + head, err := repo.Head() + suite.Require().NoError(err) + suite.headHash = head.Hash().String() + + global = &GlobalOpts{ + ApiToken: "DRY_RUN", + Org: "test-org", + Host: "http://localhost:8001", + DryRun: true, + } + suite.defaultKosliArguments = " --dry-run --host http://localhost:8001 --org test-org --api-token DRY_RUN" +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoWithoutRepository() { + const noRepo = "testdata" + + info, err := resolveCommitInfo(noRepo, suite.headHash, false, []string{}) + suite.Require().NoError(err, "a CI-defaulted commit must not fail when there is no repository") + suite.Nil(info) + + _, err = resolveCommitInfo(noRepo, suite.headHash, true, []string{}) + suite.Require().Error(err, "an explicit --commit must still fail when there is no repository") + suite.Contains(err.Error(), "repository does not exist") +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoWithUnresolvableCommit() { + // A well-formed SHA that is not in this repository, as in a shallow clone. + const absentSHA = "0d4c1e1b7f5c2a9e8b3d6f0a1c4e7b2d5a8f3c60" + + info, err := resolveCommitInfo("../..", absentSHA, false, []string{}) + suite.Require().NoError(err, "a CI-defaulted commit must not fail when it cannot be resolved") + suite.Nil(info) + + _, err = resolveCommitInfo("../..", absentSHA, true, []string{}) + suite.Require().Error(err, "an explicit --commit must still fail when it cannot be resolved") +} + +func (suite *CommitInfoResolutionTestSuite) TestResolveCommitInfoSucceeds() { + info, err := resolveCommitInfo("../..", suite.headHash, false, []string{}) + suite.Require().NoError(err) + suite.Require().NotNil(info) + suite.Equal(suite.headHash, info.Sha1) +} + +func (suite *CommitInfoResolutionTestSuite) TestExplicitCommitWiring() { + tests := []cmdTestCase{ + { + wantError: true, + name: "attest generic: an explicit --commit fails when --repo-root has no repository", + cmd: fmt.Sprintf("attest generic --fingerprint 7509e5bda0c762d2bac7f90d758b5b2263fa01ccbc542ab5e3df163be08e6ca9 --name foo --flow f --trail t --commit %s --repo-root testdata%s", suite.headHash, suite.defaultKosliArguments), + goldenRegex: "Error: failed to get commit info\\. .*repository does not exist\n", + }, + { + wantError: true, + name: "begin trail: an explicit --commit fails when --repo-root has no repository", + cmd: fmt.Sprintf("begin trail t --flow f --commit %s --repo-root testdata%s", suite.headHash, suite.defaultKosliArguments), + goldenRegex: "Error: failed to get commit info\\. .*repository does not exist\n", + }, + } + runTestCmd(suite.T(), tests) +} + +// commitRequiredOptions builds the shared attestation options for a command run +// whose --commit came from the CI default and cannot be resolved, which is the +// only way payload.Commit reaches these commands as nil. +func (suite *CommitInfoResolutionTestSuite) commitRequiredOptions() *CommonAttestationOptions { + return &CommonAttestationOptions{ + fingerprintOptions: &fingerprintOptions{}, + attestationNameTemplate: "foo", + flowName: "f", + trailName: "t", + commitSHA: suite.headHash, + srcRepoRoot: "testdata", + commitSHAExplicit: false, + } +} + +func (suite *CommitInfoResolutionTestSuite) TestCommandsNeedingCommitReportIt() { + pr := &attestPROptions{ + CommonAttestationOptions: suite.commitRequiredOptions(), + payload: PRAttestationPayload{CommonAttestationPayload: &CommonAttestationPayload{}}, + } + err := pr.run([]string{}) + suite.Require().Error(err) + suite.Contains(err.Error(), "required to find pull requests") + + jira := &attestJiraOptions{ + CommonAttestationOptions: suite.commitRequiredOptions(), + payload: JiraAttestationPayload{CommonAttestationPayload: &CommonAttestationPayload{}}, + } + err = jira.run([]string{}) + suite.Require().Error(err) + suite.Contains(err.Error(), "required to search for Jira issue keys") +} + +func TestCommitInfoResolutionTestSuite(t *testing.T) { + suite.Run(t, new(CommitInfoResolutionTestSuite)) +} diff --git a/cmd/kosli/pullrequest.go b/cmd/kosli/pullrequest.go index 5f1c329d5..44c4e2d78 100644 --- a/cmd/kosli/pullrequest.go +++ b/cmd/kosli/pullrequest.go @@ -38,6 +38,10 @@ func (o *attestPROptions) run(args []string) error { return err } + if o.payload.Commit == nil { + return fmt.Errorf("failed to get commit info, which is required to find pull requests. Pass --commit and point --repo-root at a repository containing it") + } + label := "" o.payload.GitProvider, label = o.getRetriever().ProviderAndLabel() diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index f8efa6e7d..c601516b2 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -258,7 +258,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, intervalFlag = "[optional] Expression to define specified snapshots range." showUnchangedArtifactsFlag = "[defaulted] Show the unchanged artifacts present in both snapshots within the diff output." attestationFingerprintFlag = "[conditional] The SHA256 fingerprint of the artifact to attach the attestation to. Only required if the attestation is for an artifact and --artifact-type and artifact name/path are not used." - attestationCommitFlag = "[conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd )." + attestationCommitFlag = "[conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd ). When it is defaulted from the CI environment and no git repository is available at --repo-root, the attestation is sent without commit info." attestationRedactCommitInfoFlag = "[optional] The list of commit info to be redacted before sending to Kosli. Allowed values are one or more of [author, message, branch]." attestationOriginUrlFlag = "[optional] The url pointing to where the attestation came from or is related. (defaulted to the CI url in some CIs: https://docs.kosli.com/integrations/ci_cd/#defaulted-kosli-command-flags-from-ci-variables )." attestationNameFlag = "The name of the attestation as declared in the flow or trail yaml template." @@ -269,7 +269,7 @@ The ^.kosli_ignore^ will be treated as part of the artifact like any other file, uploadJunitResultsFlag = "[defaulted] Whether to upload the provided Junit results directory as an attachment to Kosli or not." uploadSnykResultsFlag = "[defaulted] Whether to upload the provided Snyk results file as an attachment to Kosli or not." attestationAssertFlag = "[optional] Exit with non-zero code if the attestation is non-compliant" - beginTrailCommitFlag = "[defaulted] The git commit from which the trail is begun. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd, otherwise defaults to HEAD )." + beginTrailCommitFlag = "[defaulted] The git commit from which the trail is begun. (defaulted in some CIs: https://docs.kosli.com/integrations/ci_cd, otherwise unset ). When it is defaulted from the CI environment and no git repository is available at --repo-root, the trail is begun without commit info." attachmentsFlag = "[optional] The comma-separated list of paths of attachments for the reported attestation. Attachments can be files or directories. All attachments are compressed and uploaded to Kosli's evidence vault." externalFingerprintFlag = "[optional] A SHA256 fingerprint of an external attachment represented by --external-url. The format is label=fingerprint (labels cannot contain '.' or '='). This flag can be set multiple times. There must be an external url with a matching label for each external fingerprint." externalURLFlag = "[optional] Add labeled reference URL for an external resource. The format is label=url (labels cannot contain '.' or '='). This flag can be set multiple times. If the resource is a file or dir, you can optionally add its fingerprint via --external-fingerprint" diff --git a/cmd/kosli/testdata/output/docs/mintlify/snyk.md b/cmd/kosli/testdata/output/docs/mintlify/snyk.md index ead3107ad..c55b4c129 100644 --- a/cmd/kosli/testdata/output/docs/mintlify/snyk.md +++ b/cmd/kosli/testdata/output/docs/mintlify/snyk.md @@ -38,7 +38,7 @@ In other CI systems, set them explicitly to capture repository metadata. | `--annotate` | stringToString | [optional] Annotate the attestation with data using key=value. | | `-t`, `--artifact-type` | string | The type of the artifact to calculate its SHA256 fingerprint. One of: [oci, docker, file, dir]. Only required if you want Kosli to calculate the fingerprint for you (i.e. when you don't specify '`--fingerprint`' on commands that allow it). | | `--attachments` | strings | [optional] The comma-separated list of paths of attachments for the reported attestation. Attachments can be files or directories. All attachments are compressed and uploaded to Kosli's evidence vault. | -| `-g`, `--commit` | string | [conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: [docs](/integrations/ci_cd) ). | +| `-g`, `--commit` | string | [conditional] The git commit for which the attestation is associated to. Becomes required when reporting an attestation for an artifact before reporting it to Kosli. (defaulted in some CIs: [docs](/integrations/ci_cd) ). When it is defaulted from the CI environment and no git repository is available at `--repo-root`, the attestation is sent without commit info. | | `--description` | string | [optional] attestation description | | `-D`, `--dry-run` | bool | [optional] Run in dry-run mode. When enabled, no data is sent to Kosli and the CLI exits with 0 exit code regardless of any errors. | | `-x`, `--exclude` | strings | [optional] The comma separated list of directories and files to exclude from fingerprinting. Can take glob patterns. Only applicable for `--artifact-type` dir. | From 607549142f3a7bc20b980393415f31f9261a1186 Mon Sep 17 00:00:00 2001 From: Faye Date: Mon, 24 Aug 2026 16:40:54 +0200 Subject: [PATCH 2/2] test(helpers): resolve symlinks in CloneGitRepo before cloning On macOS os.MkdirTemp returns a path under /var/folders, which is a symlink to /private/var/folders. osfs resolves the symlink for the worktree root but leaves the caller's unresolved path in place for the git dir, so go-git sees a git dir that is not ".git" relative to the worktree, decides the repository lives elsewhere, and tries to write a "gitdir:" file over the directory it just created: open /private/var/folders/.../testDir123/.git: is a directory InitializeGitRepo in the same file already resolves the path for exactly this reason. CloneGitRepo did not, so AttestGitlabPRCommandTestSuite and AttestBitbucketPRCommandTestSuite fail in SetupTest on any machine whose temp dir is symlinked. Co-Authored-By: Claude Opus 5 (1M context) --- internal/testHelpers/testHelpers.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/testHelpers/testHelpers.go b/internal/testHelpers/testHelpers.go index 534d7e486..fa53bae96 100644 --- a/internal/testHelpers/testHelpers.go +++ b/internal/testHelpers/testHelpers.go @@ -53,10 +53,15 @@ func GithubPRNumber() int { } func CloneGitRepo(url, cloneTo string) (*git.Repository, error) { + // Resolved for the same reason as in InitializeGitRepo below. + resolvedCloneTo, err := filepath.EvalSymlinks(cloneTo) + if err != nil { + return nil, err + } // the repo worktree filesystem. It has to be osfs so that we can give it a path - fs := osfs.New(cloneTo) + fs := osfs.New(resolvedCloneTo) // the filesystem for git database - storerFS := osfs.New(filepath.Join(cloneTo, ".git")) + storerFS := osfs.New(filepath.Join(resolvedCloneTo, ".git")) storer := filesystem.NewStorage(storerFS, cache.NewObjectLRUDefault()) return git.Clone(storer, fs, &git.CloneOptions{URL: url}) }