diff --git a/.github/coverage-floors.txt b/.github/coverage-floors.txt index d13bc16dc..4b36ef895 100644 --- a/.github/coverage-floors.txt +++ b/.github/coverage-floors.txt @@ -61,10 +61,12 @@ internal/database/vectorsearch 94 # measured 96.9 2026-10-10 internal/deploy 53 # measured 55.2 2026-10-05 internal/deploy/archguard 76 # measured 78.6 2026-10-09 internal/deploy/bluegreen 44 # measured 46.3 2026-10-05 +internal/deploy/pathmap 0 # measured 0.0 2026-10-29 (no test files yet) internal/deploy/imagetags 98 # measured 100.0 2026-10-29 internal/deploy/pintest 57 # measured 59.5 2026-10-13 (P7-HYG-49) internal/deploy/progress 86 # measured 88.8 2026-10-12 internal/deprecation 75 # measured 77.3 2026-10-05 +internal/dirwritable 91 # measured 93.8 2026-10-29 internal/docker 52 # measured 54.7 2026-10-05 internal/docker/dockertest 85 # measured 89.5 (darwin, local) 2026-10-11; new package (D-0286) internal/doctor 47 # measured 49.6 2026-10-05 @@ -72,6 +74,7 @@ internal/doctor/report 85 # measured 87.8 2026-10-11 internal/domain 60 # measured 62.1 2026-10-05 internal/embedded 78 # measured 80.8 2026-10-05 internal/env 19 # measured 21.7 2026-10-05 +internal/envrestore 61 # measured 63.4 2026-10-29 internal/errs 93 # measured 95.8 2026-10-05 internal/eval 77 # measured 79.1 2026-10-05 internal/exporter 58 # measured 60.2 2026-10-12 (P7-ADOPT-17; snapshot, pg_dump and auth reads need a live Postgres, covered by scripts/e2e/export.sh) @@ -81,6 +84,7 @@ internal/generate 65 # measured 67.5 2026-10-05 internal/hasura 87 # measured 89.1 2026-10-05 internal/health 26 # measured 28.1 2026-10-05 internal/hostcase 95 # measured 100.0 2026-10-17 (D-0881) +internal/hostpin 0 # measured 0.0 2026-10-29 (no test files yet) internal/httpapi 93 # measured 95.1 2026-10-11 (P7-SURF-02) internal/httptimeout 98 # measured 100.0 2026-10-05 internal/importer 47 # measured 49.7 2026-10-12 @@ -125,6 +129,7 @@ internal/oplock 74 # measured 79.3 2026-10-05 internal/oplock/oplockcmd 88 # measured 93.5 2026-10-05 internal/output 96 # measured 98.2 2026-10-05 internal/plugin 50 # measured 53.0 2026-10-05 +internal/plugin/autoinstall 0 # measured 0.0 2026-10-30 (no test files yet) internal/plugin/catalog 81 # measured 83.0 2026-10-13 internal/plugin/count 98 # measured 100.0 2026-10-05 internal/plugin/lifecycle 74 # measured 76.4 2026-10-05 @@ -137,6 +142,7 @@ internal/plugin/verify 85 # measured 87.2 2026-10-05 internal/plugin/verify/signature 95 # measured 99.1 2026-10-13 (P7-PLUG-23) internal/plugin/verify/signature/signaturetest 0 # measured 0.0 2026-10-13; test-only key-table swap helper, exercised from other packages' tests (P7-PLUG-23) internal/pluginname 85 # measured 90.0 2026-10-26 (D-0602) +internal/pluginperm 48 # measured 50.0 2026-10-29 internal/portable 85 # measured 87.5 2026-10-05 internal/ports 9 # measured 11.5 2026-10-05 internal/postgres 49 # measured 51.4 2026-10-05 diff --git a/cmd/commands/config_explain_monorepo_test.go b/cmd/commands/config_explain_monorepo_test.go index f64effc85..70f0d2868 100644 --- a/cmd/commands/config_explain_monorepo_test.go +++ b/cmd/commands/config_explain_monorepo_test.go @@ -71,8 +71,13 @@ func TestConfigExplainMonorepoExecution(t *testing.T) { if tc.redirect { wantDir = backend } - if cwd, err := os.Getwd(); err != nil || cwd != wantDir { - t.Errorf("cwd = %q (%v), want %q", cwd, err, wantDir) + // Paths differ in form (macOS /var vs /private/var, Windows 8.3 short + // names), so compare the directories themselves. + cwdInfo, cwdErr := os.Stat(".") + wantInfo, wantErr := os.Stat(wantDir) + if cwdErr != nil || wantErr != nil || !os.SameFile(cwdInfo, wantInfo) { + cwd, _ := os.Getwd() + t.Errorf("cwd = %q (%v), want the directory %q (%v)", cwd, cwdErr, wantDir, wantErr) } }) } diff --git a/cmd/commands/next_step_test.go b/cmd/commands/next_step_test.go index 2635aaae6..4aa65730c 100644 --- a/cmd/commands/next_step_test.go +++ b/cmd/commands/next_step_test.go @@ -154,7 +154,13 @@ func TestNextStep(t *testing.T) { t.Run(tc.stage+"_"+tc.mode, func(t *testing.T) { nextStepFixture(t, tc.stage) t.Setenv("NEXTSTEP_DOCKER", tc.mode) - ctx, cancel := context.WithTimeout(context.Background(), nextStepTimeout) + // Only the slow stub (it sleeps 10s) may reach the production deadline; + // a loaded Windows runner needs far more than 3s to start the helper. + timeout := 60 * time.Second + if tc.mode == "slow" { + timeout = nextStepTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() if got := nextStep(ctx); got != "Next: nself "+tc.want { t.Errorf("got %q, want %q", got, tc.want) diff --git a/internal/build/expect_publish.go b/internal/build/expect_publish.go index 3737b94d4..6a1fc58a8 100644 --- a/internal/build/expect_publish.go +++ b/internal/build/expect_publish.go @@ -131,6 +131,13 @@ func (p *artifactPublisher) target(path string) (*os.Root, string, error) { return root, filepath.FromSlash(rel), nil } +func (p *artifactPublisher) releaseCleanupRoot(path string) { + if abs, err := filepath.Abs(p.sink.abs(path)); err == nil && p.roots[abs] != nil { + _ = p.roots[abs].Close() + delete(p.roots, abs) + } +} + func (p *artifactPublisher) publish() error { for _, k := range sortedArtifactKeys(p.sink.dirs) { if err := p.mkdir(p.sink.abs(k), p.sink.dirs[k]); err != nil { @@ -173,6 +180,7 @@ func (p *artifactPublisher) publish() error { if err != nil { return err } + p.releaseCleanupRoot(k) // Windows cannot remove a directory with an open handle if err := root.Remove(rel); err != nil { return err } diff --git a/internal/build/orchestrator_build_config.go b/internal/build/orchestrator_build_config.go index 25fc8fdf4..29861b800 100644 --- a/internal/build/orchestrator_build_config.go +++ b/internal/build/orchestrator_build_config.go @@ -17,6 +17,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strings" "time" @@ -156,7 +157,11 @@ func (st *buildState) loadValidateConfig() (*BuildResult, error) { // ensureEnvFilePermissions fixes an existing env file to 0600 when it is more // permissive, through the sink (setup.EnsureEnvFilePermissions semantics: a // missing file is a no-op). Plan mode records the mode change, not applies it. +// Windows has no owner-only permission bits, so there is nothing to fix there. func ensureEnvFilePermissions(sink Sink, path string) error { + if runtime.GOOS == "windows" { + return nil + } info, err := sink.Stat(path) if os.IsNotExist(err) { return nil diff --git a/internal/build/proxy_restore.go b/internal/build/proxy_restore.go index 0a93ebb80..d83565542 100644 --- a/internal/build/proxy_restore.go +++ b/internal/build/proxy_restore.go @@ -13,6 +13,8 @@ import ( "os" "path/filepath" "sort" + + "github.com/nself-org/cli/internal/dirwritable" ) // The traefik renderer marks its output with this file and prefix. @@ -61,7 +63,7 @@ func cleanupOutputDir(sink Sink, workdir, dir string) error { return proxyKeep(dir, why) } // Plan and apply must agree on removals even for unwritable output. - if why := unwritableIn(filepath.Dir(dir), dir); why != "" { + if why := dirwritable.FirstUnwritable(filepath.Dir(dir), dir); why != "" { return proxyKeep(dir, "it is owned by root (the traefik-config container wrote it): "+why+" is not writable; run `sudo rm -rf "+dir+"` or chown it to your user, then build again") } err = removeTree(sink, dir, info) @@ -75,31 +77,6 @@ func cleanupOutputDir(sink Sink, workdir, dir string) error { return nil } -// unwritableIn finds the first directory that prevents unlinking, parent first. -func unwritableIn(parent, dir string) string { - if !dirWritable(parent) { - return parent - } - var bad string - _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { - if bad != "" { - return nil - } - if err != nil { // an unreadable directory cannot be emptied either - bad = p - return nil - } - if !d.IsDir() { - return nil - } - if !dirWritable(p) { - bad = p - } - return nil - }) - return bad -} - // proxyKeep warns and continues without deleting unproven output. func proxyKeep(dir, why string) error { slog.Warn("leaving "+dir+" in place: "+why, @@ -156,6 +133,7 @@ func removeTree(sink Sink, dir string, before fs.FileInfo) error { if err := removeTreeIn(sink, root, dir, "."); err != nil { return err } + _ = root.Close() // Windows cannot remove a directory while a handle on it is open return sink.Remove(dir) } diff --git a/internal/build/proxy_restore_windows.go b/internal/build/proxy_restore_windows.go deleted file mode 100644 index b179549d5..000000000 --- a/internal/build/proxy_restore_windows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build windows - -package build - -// dirWritable has no access(2) on Windows; a failed removal is reported by the -// remove itself, which cleanupOutputDir treats the same way. -func dirWritable(string) bool { return true } diff --git a/internal/bundle/installer_reconcile_rollback.go b/internal/bundle/installer_reconcile_rollback.go deleted file mode 100644 index 6e686850f..000000000 --- a/internal/bundle/installer_reconcile_rollback.go +++ /dev/null @@ -1,54 +0,0 @@ -package bundle - -// Purpose: undo fresh members after a refused reconcile without dropping kept schemas. -// Inputs: pre-install membership and ledger, completed install result. -// Outputs: rollback result and restored ledger records. Failed removals stay recorded. -import ( - "context" - "io" - "strings" - - "github.com/nself-org/cli/internal/bundle/ledger" - "github.com/nself-org/cli/internal/plugin" -) - -func rollbackReconcile(ctx context.Context, store *ledger.Store, dir string, before map[string]string, prior ledger.Ledger, opts InstallOpts, result *InstallResult, out io.Writer) error { - var fresh []string - for _, name := range result.Installed { - if _, existed := before[strings.ToLower(name)]; !existed { - fresh = append(fresh, name) - } - } - result.RolledBack = rollbackInstalled(ctx, opts.Config, plugin.UndoInstall, dir, fresh, out) - removed := make(map[string]bool) - for _, name := range result.RolledBack { - removed[strings.ToLower(name)] = true - } - return store.Update(func(l *ledger.Ledger) error { - // Restore the pre-install ledger, retaining any fresh member whose cleanup - // failed so that it remains discoverable for a later remove. - current := l.Clone() - *l = prior.Clone() - // Existing members may have been upgraded; retain their current facts - // while restoring the ownership references from before this install. - for key, record := range l.Plugins { - if now, ok := current.Plugins[key]; ok { - record.Tier, record.Version, record.Checksum = now.Tier, now.Version, now.Checksum - l.Plugins[key] = record - } - } - for _, name := range fresh { - key := strings.ToLower(name) - if removed[key] { - continue - } - if record, ok := current.Plugins[key]; ok { - l.Plugins[key] = record - for _, slug := range record.InstalledBy { - l.Bundles[slug] = current.Bundles[slug] - } - } - } - return nil - }) -} diff --git a/internal/cmdregistry/flag_argv_test.go b/internal/cmdregistry/flag_argv_test.go new file mode 100644 index 000000000..d75010a1c --- /dev/null +++ b/internal/cmdregistry/flag_argv_test.go @@ -0,0 +1,50 @@ +package cmdregistry + +import ( + "testing" + + "github.com/spf13/pflag" +) + +func TestFlagInsertionIndex(t *testing.T) { + local := pflag.NewFlagSet("local", pflag.ContinueOnError) + local.String("name", "", "takes a value") + inherited := pflag.NewFlagSet("inherited", pflag.ContinueOnError) + inherited.String("env", "", "takes a value") + inherited.Bool("verbose", false, "bool") + inherited.Lookup("verbose").NoOptDefVal = "true" + + for _, tc := range []struct { + desc string + args []string + wantIndex int + wantFound bool + }{ + {"no args", nil, 0, false}, + {"positional only", []string{"a", "b"}, 2, false}, + {"separator stops the scan", []string{"a", "--", "--json"}, 1, false}, + {"flag already forwarded", []string{"a", "--json"}, 1, true}, + {"forwarded inline", []string{"--json=false"}, 0, true}, + {"local value flag skips its value", []string{"--name", "--json"}, 2, false}, + {"inherited value flag skips its value", []string{"--env", "--json", "x"}, 3, false}, + {"inline value is not skipped", []string{"--name=x", "--json"}, 1, true}, + {"no-value flag is not skipped", []string{"--verbose", "--json"}, 1, true}, + {"unknown flag is not skipped", []string{"--other", "--json"}, 1, true}, + {"single dash is positional", []string{"-x", "--"}, 1, false}, + } { + got, found := FlagInsertionIndex(tc.args, "json", local, inherited) + if got != tc.wantIndex || found != tc.wantFound { + t.Errorf("%s: FlagInsertionIndex(%q) = (%d, %t), want (%d, %t)", tc.desc, tc.args, got, found, tc.wantIndex, tc.wantFound) + } + } +} + +func TestNoopBoolValueIgnoresWrites(t *testing.T) { + var v NoopBoolValue + if err := v.Set("true"); err != nil { + t.Fatalf("Set returned %v, want nil", err) + } + if v.String() != "false" || v.Type() != "bool" { + t.Fatalf("NoopBoolValue = (%q, %q), want (false, bool)", v.String(), v.Type()) + } +} diff --git a/internal/compose/dry_run_env.go b/internal/compose/dry_run_env.go index 1635bdcd3..778b5ffd4 100644 --- a/internal/compose/dry_run_env.go +++ b/internal/compose/dry_run_env.go @@ -32,6 +32,6 @@ func PrintCustomServiceEnvSummary(w io.Writer, workdir string) { } for _, cs := range cfg.CustomServices { names := CustomServiceEnvVarNames(cs) - fmt.Fprintf(w, " [dry-run] Custom service %s will receive: %s\n", cs.Name, strings.Join(names, ", ")) + _, _ = fmt.Fprintf(w, " [dry-run] Custom service %s will receive: %s\n", cs.Name, strings.Join(names, ", ")) } } diff --git a/internal/controlplane/promote/verify/trust_pin_test.go b/internal/controlplane/promote/verify/trust_pin_test.go index e6a26c937..c1f1004a8 100644 --- a/internal/controlplane/promote/verify/trust_pin_test.go +++ b/internal/controlplane/promote/verify/trust_pin_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -27,7 +28,8 @@ func TestVerifyPinsOriginOnFirstUse(t *testing.T) { if err != nil || !strings.Contains(string(raw), "\ngit@github.com:o/r.git\n") { t.Fatalf("pin file = %q, %v", raw, err) } - if info, _ := os.Stat(pinFile(r.home)); info.Mode().Perm() != 0o600 { + // Windows has no owner-only permission bits (it reports 0666). + if info, _ := os.Stat(pinFile(r.home)); runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { t.Errorf("pin file mode %v, want 0600", info.Mode().Perm()) } if len(notes) != 1 || !strings.Contains(notes[0], "git@github.com:o/r.git") || len(warns) != 0 { diff --git a/internal/dirwritable/dirwritable.go b/internal/dirwritable/dirwritable.go new file mode 100644 index 000000000..c88b97740 --- /dev/null +++ b/internal/dirwritable/dirwritable.go @@ -0,0 +1,35 @@ +// Package dirwritable tells, before anything is planned or deleted, whether the +// current user can unlink inside a directory tree (the traefik-config one-shot +// runs as root, so Docker creates its bind directories root-owned on Linux). +// It is read-only (access(2)) and identical in plan and apply mode. +package dirwritable + +import ( + "io/fs" + "path/filepath" +) + +// FirstUnwritable finds the first directory that prevents unlinking, parent first. +func FirstUnwritable(parent, dir string) string { + if !writable(parent) { + return parent + } + var bad string + _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if bad != "" { + return nil + } + if err != nil { // an unreadable directory cannot be emptied either + bad = p + return nil + } + if !d.IsDir() { + return nil + } + if !writable(p) { + bad = p + } + return nil + }) + return bad +} diff --git a/internal/dirwritable/dirwritable_test.go b/internal/dirwritable/dirwritable_test.go new file mode 100644 index 000000000..f080039eb --- /dev/null +++ b/internal/dirwritable/dirwritable_test.go @@ -0,0 +1,55 @@ +package dirwritable + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestFirstUnwritableCleanTree(t *testing.T) { + dir := filepath.Join(t.TempDir(), "out") + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "f"), nil, 0o644); err != nil { + t.Fatal(err) + } + if got := FirstUnwritable(filepath.Dir(dir), dir); got != "" { + t.Fatalf("FirstUnwritable = %q, want none", got) + } +} + +func TestFirstUnwritableReportsLockedDirectories(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permissions are not enforced here") + } + root := t.TempDir() + dir := filepath.Join(root, "out") + locked := filepath.Join(dir, "locked") + if err := os.MkdirAll(locked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + if got := FirstUnwritable(root, dir); got != locked { + t.Fatalf("FirstUnwritable = %q, want %q", got, locked) + } + if err := os.Chmod(root, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + if got := FirstUnwritable(root, dir); got != root { + t.Fatalf("FirstUnwritable = %q, want the parent %q", got, root) + } +} + +func TestFirstUnwritableUnreadableTree(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "missing") + if got := FirstUnwritable(root, missing); got != missing { + t.Fatalf("FirstUnwritable = %q, want %q for an unreadable tree", got, missing) + } +} diff --git a/internal/build/proxy_restore_unix.go b/internal/dirwritable/writable_unix.go similarity index 78% rename from internal/build/proxy_restore_unix.go rename to internal/dirwritable/writable_unix.go index 343a88599..13ab6efac 100644 --- a/internal/build/proxy_restore_unix.go +++ b/internal/dirwritable/writable_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package build +package dirwritable // Purpose: tell, before anything is planned or deleted, whether this user can // unlink inside a directory (the traefik-config one-shot runs as root, so Docker @@ -10,4 +10,4 @@ package build import "syscall" -func dirWritable(dir string) bool { return syscall.Access(dir, 0x2|0x1) == nil } // W_OK|X_OK +func writable(dir string) bool { return syscall.Access(dir, 0x2|0x1) == nil } // W_OK|X_OK diff --git a/internal/dirwritable/writable_windows.go b/internal/dirwritable/writable_windows.go new file mode 100644 index 000000000..353ae077a --- /dev/null +++ b/internal/dirwritable/writable_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package dirwritable + +// writable has no access(2) on Windows; a failed removal is reported by the +// remove itself, which the build cleanup treats the same way. +func writable(string) bool { return true } diff --git a/internal/docker/compose_upgrade_test.go b/internal/docker/compose_upgrade_test.go index dc70d8fd5..4fbd3d96b 100644 --- a/internal/docker/compose_upgrade_test.go +++ b/internal/docker/compose_upgrade_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -11,6 +12,9 @@ import ( // Model Compose's image reuse contract: a tagged image survives an up unless // --build is supplied. The installed plugin context has already been upgraded. func TestComposeUpRebuildsInstalledPlugin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake docker is a POSIX shell script") + } for _, services := range [][]string{nil, {"plugin"}} { name := "all services" if len(services) > 0 { diff --git a/internal/license/entitlement/atomic_refresh_test.go b/internal/license/entitlement/atomic_refresh_test.go index 349af7499..80a9608b9 100644 --- a/internal/license/entitlement/atomic_refresh_test.go +++ b/internal/license/entitlement/atomic_refresh_test.go @@ -17,11 +17,11 @@ func TestUnmountedRefreshReplacesInode(t *testing.T) { if err := writeAtomic(path, []byte("first")); err != nil { t.Fatal(err) } - before, _ := os.Stat(path) + before := fileIdentity(t, path) if err := writeAtomic(path, []byte("second")); err != nil { t.Fatal(err) } - after, _ := os.Stat(path) + after := fileIdentity(t, path) if os.SameFile(before, after) { t.Fatal("unmounted refresh rewrote the live inode") } @@ -81,3 +81,20 @@ func TestMountProbePolicy(t *testing.T) { }) } } + +// fileIdentity stats through an open handle: on Windows os.Stat resolves the +// file id lazily, by path, when SameFile runs, so two Stat results of one path +// always compare equal even after the file was replaced. +func fileIdentity(t *testing.T, path string) os.FileInfo { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + return info +} diff --git a/internal/plugin/archive_snapshot_test.go b/internal/plugin/archive_snapshot_test.go index 9c272ca39..a7a6bdd63 100644 --- a/internal/plugin/archive_snapshot_test.go +++ b/internal/plugin/archive_snapshot_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "os" "path/filepath" + "runtime" "testing" ) @@ -17,6 +18,12 @@ import ( // read that handle, so replacing the path after the open cannot change what // gets verified or extracted. func TestArchiveSnapshotSurvivesPathReplacement(t *testing.T) { + if runtime.GOOS == "windows" { + // Go opens files without FILE_SHARE_DELETE, so the path of an open + // archive cannot be unlinked there and the replacement below would + // rewrite the very file the handle reads. The contract is POSIX-only. + t.Skip("an open file cannot be unlinked on Windows") + } var archive bytes.Buffer gz := gzip.NewWriter(&archive) tw := tar.NewWriter(gz) diff --git a/internal/reconcile/state_test.go b/internal/reconcile/state_test.go index e7c9e3c9f..3aea9e7ca 100644 --- a/internal/reconcile/state_test.go +++ b/internal/reconcile/state_test.go @@ -544,6 +544,10 @@ func TestApplyRecordWriteFailureIsPublished(t *testing.T) { // generated file was written, with a completed result. The apply fails but the // files stay, so the error must match ErrPublished; a failure with no result // (nothing committed) or with a caller Rollback that restores the files must not. +// publishedCompose is the compose file the faked build publishes. It must be +// valid compose: under NSELF_V15=1 the next apply reads it for the image pins. +const publishedCompose = "services: {}\n" + func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { f := loadFixture(t, "dev-minimal") _ = os.RemoveAll(filepath.Join(f.project, ".nself", "state")) @@ -551,7 +555,7 @@ func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { t.Cleanup(func() { buildBuild = oldBuild }) refresh := &nbuild.EntitlementRefreshError{Err: errs.New("E125", "plugin entitlement unavailable")} buildBuild = func(projectDir string, opts nbuild.BuildOptions) (*nbuild.BuildResult, error) { - if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte("published"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(projectDir, "docker-compose.yml"), []byte(publishedCompose), 0o644); err != nil { return nil, err } return &nbuild.BuildResult{}, refresh @@ -565,7 +569,7 @@ func TestApplyEntitlementRefreshFailureIsPublished(t *testing.T) { t.Fatalf("err = %v, want the E125 cause kept on the chain", err) } - if data, readErr := os.ReadFile(filepath.Join(f.project, "docker-compose.yml")); readErr != nil || string(data) != "published" { + if data, readErr := os.ReadFile(filepath.Join(f.project, "docker-compose.yml")); readErr != nil || string(data) != publishedCompose { t.Fatalf("published artifacts must remain after E125: %q, %v", data, readErr) } if detail := errs.Describe(err); detail == nil || detail.Code != "E125" || errs.ExitCodeFor(err) != 3 {