From 5adabb4457181f3d573ffae2e00143dac11fcbd7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:13:38 +0000 Subject: [PATCH 1/3] Add renovate.json --- renovate.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9241b71 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>fgm/renovate-config" + ] +} From ef110481b09eca87f4591c2681f80f26cd17c3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20G=2E=20MARAND?= Date: Sat, 22 Aug 2026 22:34:27 +0200 Subject: [PATCH 2/3] chore(#28): add a self-hosted Renovate configuration example. renovate.json configures this repository for the hosted app. This example covers the other case: one central Renovate instance driving several repos, with a schedule per repository rather than per manager. Not read by anything: it documents the setup for whoever runs that instance. --- renovate-config-example.json | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 renovate-config-example.json diff --git a/renovate-config-example.json b/renovate-config-example.json new file mode 100644 index 0000000..7aeea95 --- /dev/null +++ b/renovate-config-example.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "repositories": [ + "fgm/envrun", + "fgm/untilMongod", + "fgm/container", + "fgm/drupal_redis_stats", + "fgm/izidic", + "fgm/pflagheaders" + ], + "enabledManagers": ["gomod", "github-actions", "npm"], + "packageRules": [ + { + "matchManagers": ["gomod"], + "groupName": "Go dependencies", + "groupSlug": "go-deps", + "updateTypes": ["minor", "patch"] + }, + { + "matchManagers": ["github-actions"], + "groupName": "GitHub Actions", + "groupSlug": "github-actions" + }, + { + "matchManagers": ["npm"], + "matchPaths": ["examples/**"], + "groupName": "npm dependencies", + "groupSlug": "npm-deps" + } + ], + "repositoryRules": [ + { + "matchRepositoryNames": ["fgm/pflagheaders"], + "schedule": ["before 3am on Monday"] + }, + { + "matchRepositoryNames": ["fgm/envrun", "fgm/container"], + "schedule": ["before 3am on the first Saturday"] + }, + { + "matchRepositoryNames": ["fgm/untilMongod"], + "schedule": ["before 3am on the 1st of the month"] + }, + { + "matchRepositoryNames": ["fgm/drupal_redis_stats", "fgm/izidic"], + "schedule": ["before 3am on Monday"], + "enabledManagers": ["gomod"] + } + ] +} From 0ba413fdd76d78235ee4a7d78e5dd8526f469aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20G=2E=20MARAND?= Date: Sun, 6 Sep 2026 12:11:25 +0200 Subject: [PATCH 3/3] refactor(#58): make the close failure reachable from a test. Exporting the seam instead would hand an importer a function that closes what they opened. Co-Authored-By: Claude Opus 5 --- env/env.go | 39 ++++++++++++------- env/load_internal_test.go | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 env/load_internal_test.go diff --git a/env/env.go b/env/env.go index b5cd0bd..812c78b 100644 --- a/env/env.go +++ b/env/env.go @@ -265,35 +265,48 @@ func parseReader(r io.Reader) (Vars, []Problem, error) { // alongside the error whenever a file was opened at all. Only [Result.Env] is // nil, since a rejected file declares nothing. func Load(paths ...string) (Result, error) { + file, path, err := openFirst(paths) + if err != nil { + return Result{}, err + } + return loadFile(file, path) +} + +// openFirst opens the first candidate that exists, and reports which one that +// was: with a search path, the caller cannot otherwise tell. +// +// The file is returned open, and closing it is the caller's job from here. +func openFirst(paths []string) (*os.File, string, error) { if len(paths) == 0 { paths = []string{DefaultPath} } - var ( - file *os.File - path string - lastErr error - ) + var lastErr error for _, candidate := range paths { f, err := os.Open(candidate) if err == nil { - file, path = f, candidate - break + return f, candidate, nil } // Only a missing file is a miss. A candidate that exists but cannot be // read — no permission, a directory — is a problem to report rather than // a reason to look further: silently falling through to the next // candidate would hide it. if !errors.Is(err, iofs.ErrNotExist) { - return Result{}, fmt.Errorf("reading %s: %w", candidate, err) + return nil, "", fmt.Errorf("reading %s: %w", candidate, err) } lastErr = err } - if file == nil { - return Result{}, fmt.Errorf("reading %s: %w", strings.Join(paths, ", "), lastErr) - } + return nil, "", fmt.Errorf("reading %s: %w", strings.Join(paths, ", "), lastErr) +} - v, problems, err := parseReader(file) +// loadFile reads an opened environment file, closes it, and reports what it +// declared. path names the file, for the Result and any [ParseError] to carry. +// +// It takes an interface where its only caller holds an *os.File, because the +// close it has to report on is the one thing a real file will not do: a +// descriptor opened read-only has nothing left to fail at. +func loadFile(rc io.ReadCloser, path string) (Result, error) { + v, problems, err := parseReader(rc) // Closed here rather than deferred: the command hands over with syscall.Exec, // which runs no deferred function, so this package must not leave the close // to one either. @@ -302,7 +315,7 @@ func Load(paths ...string) (Result, error) { // give has already been read, so refusing to run the command over it would // withhold a working environment for a problem that no longer affects it. var notes []Note - if cErr := file.Close(); cErr != nil { + if cErr := rc.Close(); cErr != nil { notes = append(notes, CloseError{Err: cErr}) } // Path and Notes survive a failure, and only Env does not: a file that was diff --git a/env/load_internal_test.go b/env/load_internal_test.go new file mode 100644 index 0000000..0d9ac10 --- /dev/null +++ b/env/load_internal_test.go @@ -0,0 +1,79 @@ +// This file is in package env, where every other test file is in env_test, +// because the property it covers is unreachable from outside: Load opens the +// file itself, read-only, and a descriptor with no write-back has nothing left +// to fail at on Close. loadFile takes a reader precisely so a fake can fail. +package env + +import ( + "errors" + "io" + "io/fs" + "strings" + "testing" +) + +// failingCloser is an opened environment file whose Close reports err. +type failingCloser struct { + io.Reader + err error +} + +func (f failingCloser) Close() error { return f.err } + +// TestLoadFileCloseError covers what a failed close must not cost the caller: +// everything the file had to give has already been read, so the close failure +// travels as a Note beside the result rather than replacing it. +func TestLoadFileCloseError(t *testing.T) { + const path = "x.env" + cause := &fs.PathError{Op: "close", Path: path, Err: fs.ErrPermission} + + t.Run("a usable file", func(t *testing.T) { + rc := failingCloser{Reader: strings.NewReader("NAME=value\n"), err: cause} + + res, err := loadFile(rc, path) + if err != nil { + t.Fatalf("error = %v, expected none: a close failure must not withhold a working environment", err) + } + if res.Env["NAME"] != "value" { + t.Errorf("Env = %v, expected it to hold NAME=value", res.Env) + } + assertCloseNote(t, res, path, cause) + }) + + t.Run("a rejected file", func(t *testing.T) { + rc := failingCloser{Reader: strings.NewReader("9LEADING=x\n"), err: cause} + + res, err := loadFile(rc, path) + perr, ok := errors.AsType[*ParseError](err) + if !ok { + t.Fatalf("error %v is not a *ParseError, so a caller cannot act on it", err) + } + if perr.Path != path { + t.Errorf("ParseError.Path = %q, expected %q", perr.Path, path) + } + if res.Env != nil { + t.Errorf("expected no environment beside an error, got %v", res.Env) + } + // The property this case exists for: the parse failure does not bury the + // unrelated finding the caller could still act on. + assertCloseNote(t, res, path, cause) + }) +} + +func assertCloseNote(t *testing.T, res Result, path string, cause error) { + t.Helper() + + if res.Path != path { + t.Errorf("Result.Path = %q, expected %q", res.Path, path) + } + if len(res.Notes) != 1 { + t.Fatalf("Notes = %v, expected exactly the close failure", res.Notes) + } + note, ok := res.Notes[0].(CloseError) + if !ok { + t.Fatalf("note %v is not a CloseError, so its cause is unreachable", res.Notes[0]) + } + if !errors.Is(note, cause) { + t.Errorf("errors.Is(note, cause) is false for %v", note) + } +}