diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index db0b13c..6fa2e56 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -235,6 +235,8 @@ cascade verify `verify` reports drift when a file the manifest would generate is missing on disk, or when its committed bytes differ from the generated bytes. It covers the complete set of files `generate-workflow` emits (orchestrate, promote or release, external-update, validate-check, merge-queue, hotfix, rollback, pr-preview, and the manage-release composite action), so adopters do not need to enumerate files by hand. +`verify` also reports orphans: cascade-owned workflow files left behind in the workflows output directory that the manifest no longer plans (for example, after removing an environment or build). Only files carrying the cascade-generated marker are considered, so hand-written workflows in the same directory are never flagged. An orphan is reported as drift and exits 1 like any other drift. Pass `--allow-orphans` to skip this check when stale generated files are expected. Orphan detection reads the workflows output directory only and never deletes anything. + #### Flags | Flag | Type | Default | Description | @@ -245,13 +247,14 @@ cascade verify | `--output`, `-o` | string | `.github/workflows/orchestrate.yaml` | Path of the orchestrate workflow | | `--promote-output` | string | `.github/workflows/promote.yaml` | Path of the promote workflow | | `--quiet`, `-q` | bool | false | Suppress the per-file report body; only set the exit code | +| `--allow-orphans` | bool | false | Do not report cascade-owned workflow files that are no longer in the plan as drift | #### Exit codes | Exit | Meaning | |------|---------| -| 0 | No drift: every generated file is present and byte-identical | -| 1 | Drift detected: a generated file is missing or its committed bytes differ | +| 0 | No drift: every generated file is present and byte-identical, and no orphaned generated files remain | +| 1 | Drift detected: a generated file is missing, its committed bytes differ, or a cascade-owned file is orphaned (unless `--allow-orphans` is set) | | 2 | Error: the manifest is missing or invalid, or another operational failure prevented the check from running | #### Use in CI diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index 4f6cec2..ba1a028 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -209,7 +209,16 @@ type VerifyStep struct { Regenerate bool `yaml:"regenerate,omitempty"` MutatePath string `yaml:"mutate_path,omitempty"` MutateAppend string `yaml:"mutate_append,omitempty"` - ExpectExit int `yaml:"expect_exit"` + // CreatePath and CreateFrom together drop a cascade-owned orphan into the + // repo before verifying: the file at CreateFrom (an existing generated file + // that already carries the generated marker) is copied to CreatePath, which + // the manifest does not plan. This drives verify's orphan-detection path. + CreatePath string `yaml:"create_path,omitempty"` + CreateFrom string `yaml:"create_from,omitempty"` + // AllowOrphans passes --allow-orphans to verify so a scenario can assert the + // opt-out suppresses orphan drift. + AllowOrphans bool `yaml:"allow_orphans,omitempty"` + ExpectExit int `yaml:"expect_exit"` } // StepExpect defines expected outcomes for a step diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 838dcdf..acdc728 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -128,6 +128,9 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.Verify.MutatePath != "" && step.Verify.MutateAppend == "" { return fmt.Errorf("step %d (%s): verify mutate_path requires mutate_append", i, step.Name) } + if (step.Verify.CreatePath == "") != (step.Verify.CreateFrom == "") { + return fmt.Errorf("step %d (%s): verify create_path and create_from must be set together", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -373,8 +376,11 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err // code matches the step's ExpectExit. When Regenerate is set it first runs // `cascade generate-workflow -f` so verify checks pristine generated output // rather than the harness's localized copies. When MutatePath is set it appends -// MutateAppend to that file before verifying, driving the drift path. The whole -// step is read-through-the-CLI and never asserts on workflow execution. +// MutateAppend to that file before verifying, driving the drift path. When +// CreatePath/CreateFrom are set it copies an existing generated (marker-carrying) +// file to an unplanned path before verifying, driving the orphan path; AllowOrphans +// adds --allow-orphans so the opt-out can be exercised. The whole step is +// read-through-the-CLI and never asserts on workflow execution. func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error { if r.harness == nil || r.harness.act == nil { r.t.Logf(" Would run cascade verify (expect exit %d, no harness)", step.ExpectExit) @@ -418,7 +424,29 @@ func (r *Runner) executeVerify(ctx context.Context, step *VerifyStep) error { } } - verifyCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade verify"} + if step.CreatePath != "" { + copyCmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && cp %s %s", + shellQuote(step.CreateFrom), shellQuote(step.CreatePath), + )} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, copyCmd) + if err != nil { + return fmt.Errorf("verify: create exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("verify: create failed (exit %d): %s", exitCode, out.String()) + } + } + + verifyArgs := "/usr/local/bin/cascade verify" + if step.AllowOrphans { + verifyArgs += " --allow-orphans" + } + verifyCmd := []string{"bash", "-c", "cd /tmp/repo && " + verifyArgs} exitCode, reader, err := r.harness.act.Container().Exec(ctx, verifyCmd) if err != nil { return fmt.Errorf("verify: exec failed: %w", err) diff --git a/e2e/scenarios/27-verify-orphan.yaml b/e2e/scenarios/27-verify-orphan.yaml new file mode 100644 index 0000000..68ef0b5 --- /dev/null +++ b/e2e/scenarios/27-verify-orphan.yaml @@ -0,0 +1,54 @@ +name: "Verify Orphan Detection" +description: | + Exercises orphan detection in the read-only `cascade verify` command. After + generating workflows from a two-environment manifest, verify against pristine + output reports no drift (exit 0). Copying a generated, cascade-owned workflow + to a path the manifest does not plan leaves an orphan: verify reports drift + (exit 1). Re-running verify with --allow-orphans suppresses the orphan and + exits clean (exit 0), proving the opt-out. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: cdk + workflow: deploy.yaml + triggers: ["cdk/**"] + +steps: + - name: "Initial feature commit" + action: commit + commit: + message: "feat: add app feature" + files: + src/app.go: | + package main + + func main() {} + + - name: "Verify clean against pristine generated output" + action: verify + verify: + regenerate: true + expect_exit: 0 + + - name: "Orphaned generated workflow is the sole drift; verify reports it" + action: verify + verify: + regenerate: true + create_path: ".github/workflows/orchestrate-old.yaml" + create_from: ".github/workflows/orchestrate.yaml" + expect_exit: 1 + + - name: "Same orphan with --allow-orphans; verify is clean" + action: verify + verify: + regenerate: true + create_path: ".github/workflows/orchestrate-old.yaml" + create_from: ".github/workflows/orchestrate.yaml" + allow_orphans: true + expect_exit: 0 diff --git a/internal/generate/actions.go b/internal/generate/actions.go index 9562431..39842ea 100644 --- a/internal/generate/actions.go +++ b/internal/generate/actions.go @@ -42,7 +42,8 @@ func GenerateLocalActions(baseDir string, cfg *config.TrunkConfig) error { func generateManageReleaseAction() string { var sb strings.Builder - sb.WriteString(`# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY + sb.WriteString(GeneratedFileMarker) + sb.WriteString(` # Regenerate with: cascade generate-workflow name: 'Manage Release' diff --git a/internal/generate/external.go b/internal/generate/external.go index 6ea3150..76f2c69 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -172,7 +172,7 @@ func resolveOnUpdateWorkflowPath(workflow, ref string) string { } func (g *ExternalUpdateGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile()) } diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 7484db9..ebd311a 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -576,7 +576,7 @@ func crossRepoOutputs(callbackType string) []string { } func (g *Generator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile()) } diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go index 8489884..c32a260 100644 --- a/internal/generate/hotfix.go +++ b/internal/generate/hotfix.go @@ -118,7 +118,7 @@ func (g *HotfixGenerator) Generate() (string, error) { } func (g *HotfixGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") sb.WriteString("# Cascade hotfix workflow.\n") diff --git a/internal/generate/marker.go b/internal/generate/marker.go new file mode 100644 index 0000000..b988c1a --- /dev/null +++ b/internal/generate/marker.go @@ -0,0 +1,12 @@ +package generate + +// GeneratedFileMarker is the first line written at the top of every file the +// generators emit (workflows and the composite action). It marks a file as +// cascade-owned so tooling can distinguish generated output from hand-written +// files in the same directory. The verify command keys orphan detection off +// this exact string: a file carrying it that the manifest no longer plans is an +// orphan, while a file without it is treated as hand-written and never touched. +// +// The string is load-bearing. Changing it is a breaking change for any repo +// whose committed workflows still carry the old marker, so treat it as stable. +const GeneratedFileMarker = "# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY" diff --git a/internal/generate/merge_queue.go b/internal/generate/merge_queue.go index 9eb23dc..f16c6a0 100644 --- a/internal/generate/merge_queue.go +++ b/internal/generate/merge_queue.go @@ -76,7 +76,7 @@ func (g *MergeQueueGenerator) Generate() (string, error) { } func (g *MergeQueueGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") sb.WriteString("# Merge-queue validation lane (opt-in via merge_queue.enabled).\n") diff --git a/internal/generate/pr_preview.go b/internal/generate/pr_preview.go index edb2adf..dc9e799 100644 --- a/internal/generate/pr_preview.go +++ b/internal/generate/pr_preview.go @@ -68,7 +68,7 @@ func (g *PRPreviewGenerator) Generate() (string, error) { } func (g *PRPreviewGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile()) } diff --git a/internal/generate/promote.go b/internal/generate/promote.go index 084720b..1a8ae2d 100644 --- a/internal/generate/promote.go +++ b/internal/generate/promote.go @@ -480,7 +480,7 @@ func (g *PromoteGenerator) writeMatrixBuildingLogic(sb *strings.Builder, deploy } func (g *PromoteGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") diff --git a/internal/generate/release.go b/internal/generate/release.go index e47b845..9d92a1a 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -94,7 +94,7 @@ func (g *ReleaseGenerator) Generate() (string, error) { } func (g *ReleaseGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") diff --git a/internal/generate/rollback.go b/internal/generate/rollback.go index af6234f..abbeeef 100644 --- a/internal/generate/rollback.go +++ b/internal/generate/rollback.go @@ -89,7 +89,7 @@ func (g *RollbackGenerator) Generate() (string, error) { } func (g *RollbackGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") sb.WriteString("# Manual rollback: re-deploy a prior version or SHA to an environment.\n") diff --git a/internal/generate/validate_check.go b/internal/generate/validate_check.go index ede2898..809bbc3 100644 --- a/internal/generate/validate_check.go +++ b/internal/generate/validate_check.go @@ -72,7 +72,7 @@ func (g *ValidateCheckGenerator) Generate() (string, error) { } func (g *ValidateCheckGenerator) writeHeader(sb *strings.Builder) { - sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + sb.WriteString(GeneratedFileMarker + "\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) sb.WriteString("#\n") sb.WriteString("# Manifest-validation PR check (opt-in via validate_check.enabled).\n") diff --git a/internal/verify/command.go b/internal/verify/command.go index b960917..906768b 100644 --- a/internal/verify/command.go +++ b/internal/verify/command.go @@ -36,6 +36,7 @@ verify is read-only: it never writes files, runs git, or modifies the repo.`, cmd.Flags().StringVarP(&o.OutputPath, "output", "o", ".github/workflows/orchestrate.yaml", "Path of the orchestrate workflow") cmd.Flags().StringVar(&o.PromoteOutputPath, "promote-output", ".github/workflows/promote.yaml", "Path of the promote workflow") cmd.Flags().BoolVarP(&o.Quiet, "quiet", "q", false, "Suppress the per-file report body; only set the exit code") + cmd.Flags().BoolVar(&o.AllowOrphans, "allow-orphans", false, "Do not report cascade-owned workflow files that are no longer in the plan as drift") return cmd } diff --git a/internal/verify/verify.go b/internal/verify/verify.go index 080c446..6d2a338 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -12,6 +12,7 @@ import ( "io" "os" "path/filepath" + "sort" "github.com/stablekernel/cascade/internal/config" "github.com/stablekernel/cascade/internal/generate" @@ -66,6 +67,10 @@ type Options struct { OutputPath string PromoteOutputPath string Quiet bool + // AllowOrphans disables orphan detection. When false (the default), verify + // reports cascade-owned workflow files that the manifest no longer plans as + // drift; when true, those files are ignored and never affect the exit code. + AllowOrphans bool } // Run compares every file the manifest would generate against the bytes @@ -112,11 +117,17 @@ func Run(o Options, stdout, stderr io.Writer) error { } var drifts []drift + // planned holds the absolute on-disk read path of every planned file, used + // both for the missing/changed comparison and to exclude planned files from + // orphan detection below. + plannedPaths := make(map[string]struct{}, len(planned)) + for _, p := range planned { readPath := p.Path if !filepath.IsAbs(readPath) { readPath = filepath.Join(baseDir, readPath) } + plannedPaths[filepath.Clean(readPath)] = struct{}{} committed, rerr := os.ReadFile(readPath) if rerr != nil { if errors.Is(rerr, os.ErrNotExist) { @@ -130,7 +141,15 @@ func Run(o Options, stdout, stderr io.Writer) error { } } - if len(drifts) == 0 { + var orphans []string + if !o.AllowOrphans { + orphans, err = findOrphans(planned, plannedPaths, baseDir) + if err != nil { + return operational(err) + } + } + + if len(drifts) == 0 && len(orphans) == 0 { if !o.Quiet { _, _ = fmt.Fprintf(stdout, "verify: %d files, no drift\n", len(planned)) } @@ -145,12 +164,80 @@ func Run(o Options, stdout, stderr io.Writer) error { _, _ = fmt.Fprintf(stderr, "~ %s\n", displayPath(d.path)) } } - _, _ = fmt.Fprintf(stderr, "\n%d file(s) drifted. Run `cascade generate-workflow` and commit the result.\n", len(drifts)) + for _, o := range orphans { + _, _ = fmt.Fprintf(stderr, "? %s (orphaned)\n", displayPath(o)) + } + _, _ = fmt.Fprintf(stderr, "\n%d file(s) drifted. Run `cascade generate-workflow` and commit the result.\n", len(drifts)+len(orphans)) } return ErrDrift } +// findOrphans scans the directories that hold the planned workflow files for +// cascade-owned files the manifest no longer plans. A file is an orphan only +// when it carries generate.GeneratedFileMarker (so hand-written workflows are +// never flagged) and is not itself a planned file. The scan is confined to the +// distinct directories of the planned workflow files (normally +// .github/workflows); the composite action directory and the wider repo are +// never scanned. It returns the orphans sorted by absolute path for stable +// output. A missing scan directory yields no orphans, not an error. +func findOrphans(planned []generate.PlannedFile, plannedPaths map[string]struct{}, baseDir string) ([]string, error) { + scanDirs := workflowScanDirs(planned, baseDir) + + var orphans []string + for dir := range scanDirs { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("scanning %s for orphans: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + full := filepath.Clean(filepath.Join(dir, entry.Name())) + if _, ok := plannedPaths[full]; ok { + continue + } + body, rerr := os.ReadFile(full) + if rerr != nil { + if errors.Is(rerr, os.ErrNotExist) { + continue + } + return nil, fmt.Errorf("reading %s for orphan check: %w", full, rerr) + } + if bytes.Contains(body, []byte(generate.GeneratedFileMarker)) { + orphans = append(orphans, full) + } + } + } + sort.Strings(orphans) + return orphans, nil +} + +// workflowScanDirs returns the distinct directories that hold planned workflow +// files. A planned file counts as a workflow when its (anchored) path lives +// under a ".github/workflows" path segment; the composite action's path does +// not, so its directory is excluded from the orphan scan. +func workflowScanDirs(planned []generate.PlannedFile, baseDir string) map[string]struct{} { + dirs := make(map[string]struct{}) + for _, p := range planned { + readPath := p.Path + if !filepath.IsAbs(readPath) { + readPath = filepath.Join(baseDir, readPath) + } + readPath = filepath.Clean(readPath) + dir := filepath.Dir(readPath) + if filepath.Base(dir) != "workflows" || filepath.Base(filepath.Dir(dir)) != ".github" { + continue + } + dirs[dir] = struct{}{} + } + return dirs +} + // displayPath renders an absolute planned path relative to the current working // directory when possible, so reports read as repo-relative paths. It falls back // to the original path on any error. diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index 91cefe0..f668778 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -150,6 +150,81 @@ func TestRun_UnrelatedFile_Ignored(t *testing.T) { require.NotContains(t, errOut.String(), "ci.yaml") } +func TestRun_OrphanedGeneratedFile_ReportsDrift(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // A cascade-owned file (carrying the generated marker) that the manifest no + // longer plans is an orphan and must drift. + orphan := filepath.Join(dir, ".github", "workflows", "stale.yaml") + require.NoError(t, os.WriteFile(orphan, + []byte(generate.GeneratedFileMarker+"\nname: Stale\non: push\n"), 0o644)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.Error(t, err) + require.True(t, errors.Is(err, ErrDrift), "orphaned generated file must be drift, got %v", err) + + report := errOut.String() + require.Contains(t, report, "stale.yaml") + require.Contains(t, report, "orphaned") + + var ec exitCoder + require.ErrorAs(t, err, &ec) + require.Equal(t, 1, ec.ExitCode(), "orphan drift maps to exit code 1") +} + +func TestRun_Orphan_AllowOrphans_NoDrift(t *testing.T) { + t.Parallel() + dir := newRepo(t) + orphan := filepath.Join(dir, ".github", "workflows", "stale.yaml") + require.NoError(t, os.WriteFile(orphan, + []byte(generate.GeneratedFileMarker+"\nname: Stale\non: push\n"), 0o644)) + + o := opts(dir) + o.AllowOrphans = true + var out, errOut bytes.Buffer + err := Run(o, &out, &errOut) + require.NoError(t, err, "--allow-orphans must suppress orphan drift") + require.NotContains(t, errOut.String(), "stale.yaml") +} + +func TestRun_HandWrittenFile_NeverOrphan(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // A file WITHOUT the generated marker is hand-written and must never be + // flagged as an orphan, even when the manifest does not plan it. + handwritten := filepath.Join(dir, ".github", "workflows", "ci.yaml") + require.NoError(t, os.WriteFile(handwritten, + []byte("name: CI\non: push\n"), 0o644)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err, "hand-written file must not be drift") + require.NotContains(t, errOut.String(), "ci.yaml") +} + +func TestRun_MultipleOrphans_SortedDeterministic(t *testing.T) { + t.Parallel() + dir := newRepo(t) + for _, name := range []string{"zeta.yaml", "alpha.yaml", "mid.yaml"} { + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "workflows", name), + []byte(generate.GeneratedFileMarker+"\nname: X\non: push\n"), 0o644)) + } + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.True(t, errors.Is(err, ErrDrift)) + + report := errOut.String() + ai := strings.Index(report, "alpha.yaml") + mi := strings.Index(report, "mid.yaml") + zi := strings.Index(report, "zeta.yaml") + require.Greater(t, ai, -1) + require.Greater(t, mi, ai, "orphans must be reported in sorted order") + require.Greater(t, zi, mi, "orphans must be reported in sorted order") +} + func TestRun_ManifestAbsent_OperationalNotDrift(t *testing.T) { t.Parallel() dir := t.TempDir()