Skip to content
Open
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
31 changes: 27 additions & 4 deletions .github/workflows/checks-codecov.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,41 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Restore Cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- name: Cache Go build and module artifacts
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
key: main
path: '**'
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-acceptance-${{ runner.os }}-${{ hashFiles('go.sum', 'tools/go.sum', 'tools/kubectl/go.sum') }}
restore-keys: |
go-acceptance-${{ runner.os }}-

- name: Setup Go environment
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: go.mod
cache: false

- name: Install tkn CLI
run: |
TKN_VERSION=$(grep 'tektoncd/cli' tools/go.mod | awk '{print $2}' | sed 's/^v//')
TKN_TARBALL="tkn_${TKN_VERSION}_Linux_x86_64.tar.gz"
curl -fsSL "https://github.com/tektoncd/cli/releases/download/v${TKN_VERSION}/checksums.txt" -o checksums.txt
curl -fsSL "https://github.com/tektoncd/cli/releases/download/v${TKN_VERSION}/${TKN_TARBALL}" -o "${TKN_TARBALL}"
grep "${TKN_TARBALL}" checksums.txt | sha256sum -c -
sudo tar xzf "${TKN_TARBALL}" -C /usr/local/bin tkn
rm -f "${TKN_TARBALL}" checksums.txt

- name: Install kubectl
run: |
KUBECTL_VERSION=$(grep 'k8s.io/kubernetes' tools/kubectl/go.mod | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')
curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o kubectl
curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256" -o kubectl.sha256
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm -f kubectl kubectl.sha256

- name: Update podman
run: |
"${GITHUB_WORKSPACE}/hack/ubuntu-podman-update.sh"
Expand Down
23 changes: 17 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,27 @@ ACCEPTANCE_TIMEOUT:=20m
.PHONY: acceptance

acceptance: ## Run all acceptance tests
@ACCEPTANCE_WORKDIR="$$(mktemp -d)"; \
@SECONDS=0; \
echo "[`date '+%H:%M:%S'`] Starting acceptance tests"; \
ACCEPTANCE_WORKDIR="$$(mktemp -d)"; \
cleanup() { \
cp "$${ACCEPTANCE_WORKDIR}"/features/__snapshots__/* "$(ROOT_DIR)"/features/__snapshots__/; \
cp "$${ACCEPTANCE_WORKDIR}"/features/__snapshots__/* "$(ROOT_DIR)"/features/__snapshots__/ || true; \
rm -rf "$${ACCEPTANCE_WORKDIR}"; \
}; \
mkdir -p "$${ACCEPTANCE_WORKDIR}/coverage"; \
trap cleanup EXIT; \
cp -R . "$$ACCEPTANCE_WORKDIR"; \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cd "$$ACCEPTANCE_WORKDIR" && \
$(MAKE) build && \
cd "$$ACCEPTANCE_WORKDIR"; \
if ! $(MAKE) build E2E_INSTRUMENTATION=true; then \
echo "[`date '+%H:%M:%S'`] Build failed"; \
exit 1; \
fi; \
echo "[`date '+%H:%M:%S'`] Build done, running tests"; \
export GOCOVERDIR="$${ACCEPTANCE_WORKDIR}/coverage"; \
cd acceptance && go test -timeout $(ACCEPTANCE_TIMEOUT) ./... ; go tool covdata textfmt -i=$${GOCOVERDIR} -o="$(ROOT_DIR)/coverage-acceptance.out"
cd acceptance && go test -timeout $(ACCEPTANCE_TIMEOUT) ./... && test_passed=1 || test_passed=0; \
echo "[`date '+%H:%M:%S'`] Tests finished in $$((SECONDS/60))m$$((SECONDS%60))s"; \
go tool covdata textfmt -i=$${GOCOVERDIR} -o="$(ROOT_DIR)/coverage-acceptance.out" || true; \
[ "$$test_passed" = "1" ]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Add @focus above the feature you're hacking on to use this
# (Mainly for use with the feature-% target below)
Expand Down Expand Up @@ -340,9 +350,10 @@ TASKS ?= tasks/verify-enterprise-contract/0.1/verify-enterprise-contract.yaml,ta
ifneq (,$(findstring localhost:,$(TASK_REPO)))
SKOPEO_ARGS=--src-tls-verify=false --dest-tls-verify=false
endif
TKN ?= $(shell command -v tkn 2>/dev/null || echo "go run -modfile tools/go.mod github.com/tektoncd/cli/cmd/tkn")
.PHONY: task-bundle
task-bundle: ## Push the Tekton Task bundle to an image repository
@go run -modfile tools/go.mod github.com/tektoncd/cli/cmd/tkn bundle push $(TASK_REPO):$(TASK_TAG) $(addprefix -f ,$(TASKS)) --annotate org.opencontainers.image.revision="$(TASK_TAG)"
@$(TKN) bundle push $(TASK_REPO):$(TASK_TAG) $(addprefix -f ,$(TASKS)) --annotate org.opencontainers.image.revision="$(TASK_TAG)"

.PHONY: task-bundle-snapshot
task-bundle-snapshot: task-bundle ## Push task bundle and then tag with "snapshot"
Expand Down
80 changes: 52 additions & 28 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
Expand All @@ -28,6 +29,7 @@ import (

"github.com/cucumber/godog"
"github.com/gkampitakis/go-snaps/snaps"
"k8s.io/klog/v2"

"github.com/conforma/cli/acceptance/cli"
"github.com/conforma/cli/acceptance/conftest"
Expand Down Expand Up @@ -55,17 +57,23 @@ var restore = flag.Bool("restore", false, "restore last persisted environment")

var noColors = flag.Bool("no-colors", false, "disable colored output")

var verbose = flag.Bool("verbose", false, "show stdout/stderr in failure output")

// specify a subset of scenarios to run filtering by given tags
var tags = flag.String("tags", "", "select scenarios to run based on tags")

// random seed to use
var seed = flag.Int64("seed", -1, "random seed to use for the tests")

// godog output formatter (pretty, progress, cucumber, junit, events)
var format = flag.String("format", "", "godog output formatter (default: progress, or set EC_ACCEPTANCE_FORMAT)")

// failedScenario tracks information about a failed scenario
type failedScenario struct {
Name string
Location string
Error error
LogFile string
}

// scenarioTracker tracks failed scenarios across all test runs
Expand All @@ -74,17 +82,18 @@ type scenarioTracker struct {
failedScenarios []failedScenario
}

func (st *scenarioTracker) addFailure(name, location string, err error) {
func (st *scenarioTracker) addFailure(name, location, logFile string, err error) {
st.mu.Lock()
defer st.mu.Unlock()
st.failedScenarios = append(st.failedScenarios, failedScenario{
Name: name,
Location: location,
Error: err,
LogFile: logFile,
})
}

func (st *scenarioTracker) printSummary(t *testing.T) {
func (st *scenarioTracker) printSummary() {
st.mu.Lock()
defer st.mu.Unlock()

Expand All @@ -99,8 +108,8 @@ func (st *scenarioTracker) printSummary(t *testing.T) {
for i, fs := range st.failedScenarios {
fmt.Fprintf(os.Stderr, "%d. %s\n", i+1, fs.Name)
fmt.Fprintf(os.Stderr, " Location: %s\n", fs.Location)
if fs.Error != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", fs.Error)
if fs.LogFile != "" {
fmt.Fprintf(os.Stderr, " Log file: %s\n", fs.LogFile)
}
if i < len(st.failedScenarios)-1 {
fmt.Fprintf(os.Stderr, "\n")
Expand Down Expand Up @@ -136,30 +145,31 @@ func initializeScenario(sc *godog.ScenarioContext) {
})

sc.After(func(ctx context.Context, scenario *godog.Scenario, scenarioErr error) (context.Context, error) {
// Log scenario end with status - write to /dev/tty to bypass capture
if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil {
// Strip the working directory prefix to show relative paths
uri := scenario.Uri
if cwd, err := os.Getwd(); err == nil {
if rel, err := filepath.Rel(cwd, uri); err == nil {
uri = rel
}
}
logger, ctx := log.LoggerFor(ctx)

logFile := logger.LogFile()

if scenarioErr != nil {
fmt.Fprintf(tty, "✗ FAILED: %s (%s)\n", scenario.Name, uri)
_, persistErr := testenv.Persist(ctx)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
logger.Close()

if scenarioErr != nil {
tracker.addFailure(scenario.Name, scenario.Uri, logFile, scenarioErr)
} else if persistErr != nil {
tracker.addFailure(scenario.Name, scenario.Uri, logFile, persistErr)
} else {
os.Remove(logFile)
}

if tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0); err == nil {
if scenarioErr != nil || persistErr != nil {
fmt.Fprintf(tty, "✗ FAILED: %s (%s)\n", scenario.Name, scenario.Uri)
} else {
fmt.Fprintf(tty, "✓ PASSED: %s (%s)\n", scenario.Name, uri)
fmt.Fprintf(tty, "✓ PASSED: %s (%s)\n", scenario.Name, scenario.Uri)
}
tty.Close()
}

if scenarioErr != nil {
tracker.addFailure(scenario.Name, scenario.Uri, scenarioErr)
}

_, err := testenv.Persist(ctx)
return ctx, err
return ctx, persistErr
})
}

Expand All @@ -176,6 +186,7 @@ func setupContext(t *testing.T) context.Context {
ctx = context.WithValue(ctx, testenv.PersistStubEnvironment, *persist)
ctx = context.WithValue(ctx, testenv.RestoreStubEnvironment, *restore)
ctx = context.WithValue(ctx, testenv.NoColors, *noColors)
ctx = context.WithValue(ctx, testenv.VerboseOutput, *verbose)

return ctx
}
Expand All @@ -196,8 +207,16 @@ func TestFeatures(t *testing.T) {

ctx := setupContext(t)

godogFormat := "progress:/dev/null"
if f := os.Getenv("EC_ACCEPTANCE_FORMAT"); f != "" {
godogFormat = f
}
if *format != "" {
godogFormat = *format
}

opts := godog.Options{
Format: "pretty",
Format: godogFormat,
Paths: []string{featuresDir},
Randomize: *seed,
Concurrency: runtime.NumCPU(),
Expand All @@ -216,18 +235,23 @@ func TestFeatures(t *testing.T) {

exitCode := suite.Run()

// Print summary of failed scenarios
tracker.printSummary(t)

if exitCode != 0 {
// Exit directly without t.Fatal to avoid verbose Go test output
os.Exit(1)
t.Fatalf("acceptance test suite failed with exit code %d", exitCode)
}
}

func TestMain(t *testing.M) {
// Suppress k8s client-side throttling warnings that pollute test output.
// LogToStderr(false) is required because klog defaults to writing directly
// to stderr, ignoring any writer set via SetOutput.
klog.LogToStderr(false)
klog.SetOutput(io.Discard)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
v := t.Run()

// Print summaries after all go test output so they appear last
tracker.printSummary()

// After all tests have run `go-snaps` can check for not used snapshots
if _, err := snaps.Clean(t); err != nil {
fmt.Println("Error cleaning snaps:", err)
Expand Down
76 changes: 43 additions & 33 deletions acceptance/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,12 @@ func theStandardErrorShouldContain(ctx context.Context, expected *godog.DocStrin
return nil
}

return fmt.Errorf("expected error:\n%s\nnot found in standard error:\n%s", expected, stderr)
var b bytes.Buffer
if diffErr := diff.Text("stderr", "expected", status.stderr, expectedStdErr, &b); diffErr != nil {
return fmt.Errorf("expected error:\n%s\nnot found in standard error:\n%s", expectedStdErr, stderr)
}

return fmt.Errorf("expected and actual stderr differ:\n%s", b.String())
}

// theStandardOutputShouldMatchBaseline reads the expected text from a file instead of directly
Expand Down Expand Up @@ -714,40 +719,44 @@ func EcStatusFrom(ctx context.Context) (*status, error) {
// logExecution logs the details of the execution and offers hits as how to
// troubleshoot test failures by using persistent environment
func logExecution(ctx context.Context) {
noColors := testenv.NoColorOutput(ctx)
if c.SUPPORT_COLOR != !noColors {
c.SUPPORT_COLOR = !noColors
}

s, err := ecStatusFrom(ctx)
if err != nil {
return // the ec wasn't invoked no status was stored
}

output := &strings.Builder{}
outputSegment := func(name string, v any) {
output.WriteString("\n\n")
output.WriteString(c.Underline(c.Bold(name)))
output.WriteString(fmt.Sprintf("\n%v", v))
noColors := testenv.NoColorOutput(ctx)
if c.SUPPORT_COLOR != !noColors {
c.SUPPORT_COLOR = !noColors
}
Comment on lines +727 to 730
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Writes to picocolors global color switch:"
rg -n --type go 'c\.SUPPORT_COLOR\s*=' acceptance

echo
echo "Parallel scenario execution configuration:"
sed -n '210,216p' acceptance/acceptance_test.go

Repository: conforma/cli

Length of output: 465


Avoid mutating picocolors.SUPPORT_COLOR from parallel scenario hooks.

c.SUPPORT_COLOR is package-global state. This code runs during failure handling while scenarios execute in parallel (lines 212–213 of acceptance_test.go), creating a data race. Concurrent scenario failures will corrupt the color setting across tests. Configure color policy once at suite setup instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@acceptance/cli/cli.go` around lines 727 - 730, This mutates the
package-global picocolors.SUPPORT_COLOR (c.SUPPORT_COLOR) from a parallel
scenario hook causing a data race; remove the mutation in the failure handler
(the block that calls testenv.NoColorOutput(ctx) and assigns c.SUPPORT_COLOR)
and instead determine coloring policy once during suite startup (e.g., in your
acceptance suite setup or init) and set picocolors.SUPPORT_COLOR there using a
sync.Once or equivalent; if code in this failure path needs to know whether
colors are allowed, read testenv.NoColorOutput(ctx) into a local variable and
use that, but do not write to c.SUPPORT_COLOR from scenario-level/parallel code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mutation predates this PR. The diff shows we only moved it within the same function, not introduced it. Fixing it properly (setting color policy once at suite init) would be a separate concern. Leaving as-is for this PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@st3penta, understood — the move is cosmetic and the race was pre-existing. Would you like me to open a follow-up issue to track fixing the c.SUPPORT_COLOR global mutation (set it once at suite init) so it isn't lost?

😔 ((\_/) (•ᴗ•) / >🐛)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no


outputSegment("Command", s.Cmd)
outputSegment("State", fmt.Sprintf("Exit code: %d\nPid: %d", s.ProcessState.ExitCode(), s.ProcessState.Pid()))
outputSegment("Environment", strings.Join(s.Env, "\n"))
var varsStr []string
for k, v := range s.vars {
varsStr = append(varsStr, fmt.Sprintf("%s=%s", k, v))
}
outputSegment("Variables", strings.Join(varsStr, "\n"))
if s.stdout.Len() == 0 {
outputSegment("Stdout", c.Italic("* No standard output"))
} else {
outputSegment("Stdout", c.Green(s.stdout.String()))
}
if s.stderr.Len() == 0 {
outputSegment("Stdout", c.Italic("* No standard error"))
} else {
outputSegment("Stderr", c.Red(s.stderr.String()))
verbose, _ := ctx.Value(testenv.VerboseOutput).(bool)
if verbose {
output := &strings.Builder{}
outputSegment := func(name string, v any) {
output.WriteString("\n\n")
output.WriteString(c.Underline(c.Bold(name)))
output.WriteString(fmt.Sprintf("\n%v", v))
}

outputSegment("Command", s.Cmd)
outputSegment("State", fmt.Sprintf("Exit code: %d\nPid: %d", s.ProcessState.ExitCode(), s.ProcessState.Pid()))
outputSegment("Environment", strings.Join(s.Env, "\n"))
var varsStr []string
for k, v := range s.vars {
varsStr = append(varsStr, fmt.Sprintf("%s=%s", k, v))
}
outputSegment("Variables", strings.Join(varsStr, "\n"))
if s.stdout.Len() == 0 {
outputSegment("Stdout", c.Italic("* No standard output"))
} else {
outputSegment("Stdout", c.Green(s.stdout.String()))
}
if s.stderr.Len() == 0 {
outputSegment("Stderr", c.Italic("* No standard error"))
} else {
outputSegment("Stderr", c.Red(s.stderr.String()))
}
fmt.Print(output.String())
}

if testenv.Persisted(ctx) {
Expand All @@ -758,12 +767,11 @@ func logExecution(ctx context.Context) {
}
}

output.WriteString("\n" + c.Bold("NOTE") + ": " + fmt.Sprintf("The test environment is persisted, to recreate the failure run:\n%s %s\n\n", strings.Join(environment, " "), strings.Join(s.Cmd.Args, " ")))
fmt.Printf("\n%s: The test environment is persisted, to recreate the failure run:\n%s %s\n\n",
c.Bold("NOTE"), strings.Join(environment, " "), strings.Join(s.Cmd.Args, " "))
} else {
output.WriteString("\n" + c.Bold("HINT") + ": To recreate the failure re-run the test with `-args -persist` to persist the stubbed environment\n\n")
fmt.Printf("\n%s: To recreate the failure re-run the test with `-args -persist` to persist the stubbed environment, or `-args -verbose` for detailed execution output\n\n", c.Bold("HINT"))
}

fmt.Print(output.String())
}

func matchSnapshot(ctx context.Context) error {
Expand Down Expand Up @@ -852,7 +860,9 @@ func AddStepsTo(sc *godog.ScenarioContext) {
sc.Step(`^a file named "([^"]*)" containing$`, createGenericFile)
sc.Step(`^a track bundle file named "([^"]*)" containing$`, createTrackBundleFile)
sc.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
logExecution(ctx)
if err != nil {
logExecution(ctx)
}

return ctx, nil
})
Expand Down
Loading
Loading