From 9ae32d6d3197f8addb7eb06b5d7e841b22c01ae1 Mon Sep 17 00:00:00 2001 From: "Philip K. Warren" Date: Wed, 18 Feb 2026 11:40:29 -0600 Subject: [PATCH] Migrate release command to app/go framework Migrate the release command to buf.build/app/go and enforce sloglint setting to always include context. --- .github/workflows/release.yml | 2 +- .gitignore | 2 + .golangci.yml | 1 + internal/cmd/fetcher/main.go | 39 ++++--- internal/cmd/fetcher/main_test.go | 2 +- internal/cmd/release/main.go | 168 ++++++++++++++++++------------ 6 files changed, 131 insertions(+), 83 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56bd112f3..30580bc7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: MINISIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.MINISIGN_PRIVATE_KEY_PASSWORD }} run: | echo "${MINISIGN_PRIVATE_KEY}" > minisign.key - go run ./internal/cmd/release -commit ${{ github.sha }} -minisign-private-key minisign.key . + go run ./internal/cmd/release --commit ${{ github.sha }} --minisign-private-key minisign.key . - name: Clean Up if: always() run: | diff --git a/.gitignore b/.gitignore index 9f18d5a95..4d65d7d54 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules/ tests/testdata/**/buf.gen.yaml tests/testdata/**/gen/ tests/testdata/**/protoc-gen-plugin +/fetcher +/release diff --git a/.golangci.yml b/.golangci.yml index 5484b800a..a8a589d9d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -36,6 +36,7 @@ linters: - FIXME sloglint: attr-only: true + context: all perfsprint: # Prefer sprintf for readability string-format: false diff --git a/internal/cmd/fetcher/main.go b/internal/cmd/fetcher/main.go index e8d549ca1..ab948a3dc 100644 --- a/internal/cmd/fetcher/main.go +++ b/internal/cmd/fetcher/main.go @@ -160,7 +160,7 @@ func runGoModTidy(ctx context.Context, logger *slog.Logger, plugin createdPlugin // no go.mod/go.sum to update return nil } - logger.Info("running go mod tidy", slog.Any("plugin", plugin)) + logger.InfoContext(ctx, "running go mod tidy", slog.Any("plugin", plugin)) cmd := exec.CommandContext(ctx, "go", "mod", "tidy") cmd.Dir = versionDir cmd.Stdout = os.Stdout @@ -184,7 +184,7 @@ func recreateNPMPackageLock(ctx context.Context, logger *slog.Logger, plugin cre if err := os.Remove(npmPackageLock); err != nil { return err } - logger.Info("recreating package-lock.json", slog.Any("plugin", plugin)) + logger.InfoContext(ctx, "recreating package-lock.json", slog.Any("plugin", plugin)) cmd := exec.CommandContext(ctx, "npm", "install") cmd.Dir = versionDir cmd.Stdout = os.Stdout @@ -213,7 +213,9 @@ func recreateSwiftPackageResolved(ctx context.Context, logger *slog.Logger, plug if err != nil { return fmt.Errorf("failed to open Dockerfile: %w", err) } - defer file.Close() + defer func() { + retErr = errors.Join(retErr, file.Close()) + }() var gitCloneCmd string scanner := bufio.NewScanner(file) @@ -232,7 +234,7 @@ func recreateSwiftPackageResolved(ctx context.Context, logger *slog.Logger, plug return errors.New("no 'RUN git clone' command found in Dockerfile") } - logger.Info("resolving Swift package", slog.Any("plugin", plugin)) + logger.InfoContext(ctx, "resolving Swift package", slog.Any("plugin", plugin)) // Create a tempdir for cloning the repo tmpDir, err := os.MkdirTemp("", "swift-repo-*") @@ -280,9 +282,9 @@ func runPluginTests(ctx context.Context, logger *slog.Logger, plugins []createdP env := os.Environ() env = append(env, "ALLOW_EMPTY_PLUGIN_SUM=true") start := time.Now() - logger.Info("starting running tests", slog.Int("num_plugins", len(plugins))) + logger.InfoContext(ctx, "starting running tests", slog.Int("num_plugins", len(plugins))) defer func() { - logger.Info("finished running tests", slog.Duration("duration", time.Since(start))) + logger.InfoContext(ctx, "finished running tests", slog.Duration("duration", time.Since(start))) }() cmd := exec.CommandContext(ctx, "make", "test", fmt.Sprintf("PLUGINS=%s", strings.Join(pluginsEnv, ","))) //nolint:gosec cmd.Env = env @@ -306,7 +308,7 @@ func runPluginTests(ctx context.Context, logger *slog.Logger, plugins []createdP // - plugin: buf.build/protocolbuffers/go:v1.36.11 // // It returns the modified content with updated dependency versions. -func updatePluginDeps(logger *slog.Logger, content []byte, latestVersions map[string]string) ([]byte, error) { +func updatePluginDeps(ctx context.Context, logger *slog.Logger, content []byte, latestVersions map[string]string) ([]byte, error) { var config bufremotepluginconfig.ExternalConfig if err := encoding.UnmarshalJSONOrYAMLStrict(content, &config); err != nil { return nil, fmt.Errorf("failed to parse buf.plugin.yaml: %w", err) @@ -336,7 +338,7 @@ func updatePluginDeps(logger *slog.Logger, content []byte, latestVersions map[st oldPluginRef := dep.Plugin newPluginRef := pluginName + ":" + latestVersion dep.Plugin = newPluginRef - logger.Info("updating plugin dependency", slog.String("old", oldPluginRef), slog.String("new", newPluginRef)) + logger.InfoContext(ctx, "updating plugin dependency", slog.String("old", oldPluginRef), slog.String("new", newPluginRef)) modified = true } } @@ -376,7 +378,7 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl logger := container.Logger() now := time.Now() defer func() { - logger.Info("finished running", slog.Duration("duration", time.Since(now))) + logger.InfoContext(ctx, "finished running", slog.Duration("duration", time.Since(now))) }() baseImageDir, err := docker.FindBaseImageDir(root) if err != nil { @@ -415,14 +417,14 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl for _, config := range configs { if config.Source.Disabled { - logger.Info("skipping source", slog.String("filename", config.Filename)) + logger.InfoContext(ctx, "skipping source", slog.String("filename", config.Filename)) continue } configDir := filepath.Dir(config.Filename) pluginName := filepath.Base(configDir) pluginOrg := filepath.Base(filepath.Dir(configDir)) if !filter.includes(pluginOrg, pluginName) { - logger.Debug("skipping source (not in --include list)", slog.String("filename", config.Filename)) + logger.DebugContext(ctx, "skipping source (not in --include list)", slog.String("filename", config.Filename)) continue } newVersion := latestVersions[config.CacheKey()] @@ -430,7 +432,7 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl newVersion, err = fetcher.Fetch(ctx, config) if err != nil { if errors.Is(err, fetchclient.ErrSemverPrerelease) { - logger.Info("skipping source", slog.String("filename", config.Filename), slog.Any("error", err)) + logger.InfoContext(ctx, "skipping source", slog.String("filename", config.Filename), slog.Any("error", err)) continue } return nil, err @@ -440,7 +442,7 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl // Some plugins share the same source but specify different ignore versions. // Ensure we continue to only fetch the latest version once but still respect ignores. if slices.Contains(config.Source.IgnoreVersions, newVersion) { - logger.Info("skipping source", slog.String("filename", config.Filename), slog.String("version", newVersion)) + logger.InfoContext(ctx, "skipping source", slog.String("filename", config.Filename), slog.String("version", newVersion)) continue } // Convert to absolute path to match plugin.Walk behavior (which converts paths via filepath.Abs) @@ -490,10 +492,10 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl continue } - if err := createPluginDir(logger, pending.pluginDir, pending.previousVersion, pending.newVersion, latestBaseImageVersions, latestPluginVersions); err != nil { + if err := createPluginDir(ctx, logger, pending.pluginDir, pending.previousVersion, pending.newVersion, latestBaseImageVersions, latestPluginVersions); err != nil { return nil, err } - logger.Info("created", slog.String("path", fmt.Sprintf("%v/%v", pending.pluginDir, pending.newVersion))) + logger.InfoContext(ctx, "created", slog.String("path", fmt.Sprintf("%v/%v", pending.pluginDir, pending.newVersion))) // Mark this directory as processed processedDirs[pluginDir] = true @@ -516,6 +518,7 @@ func run(ctx context.Context, container appext.Container, fetcher Fetcher, f *fl // creating the target directory if it does not exist. // If the source directory contains subdirectories this function returns an error. func copyDirectory( + ctx context.Context, logger *slog.Logger, source string, target string, @@ -541,6 +544,7 @@ func copyDirectory( return fmt.Errorf("failed to copy directory. Expecting files only: %s", source) } if err := copyFile( + ctx, logger, filepath.Join(source, file.Name()), filepath.Join(target, file.Name()), @@ -556,6 +560,7 @@ func copyDirectory( } func createPluginDir( + ctx context.Context, logger *slog.Logger, dir string, previousVersion string, @@ -572,6 +577,7 @@ func createPluginDir( } }() return copyDirectory( + ctx, logger, filepath.Join(dir, previousVersion), filepath.Join(dir, newVersion), @@ -583,6 +589,7 @@ func createPluginDir( } func copyFile( + ctx context.Context, logger *slog.Logger, src string, dest string, @@ -624,7 +631,7 @@ func copyFile( return fmt.Errorf("failed to read buf.plugin.yaml: %w", err) } // Update plugin dependencies to latest versions - content, err = updatePluginDeps(logger, content, latestPluginVersions) + content, err = updatePluginDeps(ctx, logger, content, latestPluginVersions) if err != nil { return fmt.Errorf("failed to update plugin deps: %w", err) } diff --git a/internal/cmd/fetcher/main_test.go b/internal/cmd/fetcher/main_test.go index 1a02e46fe..e3d6b744c 100644 --- a/internal/cmd/fetcher/main_test.go +++ b/internal/cmd/fetcher/main_test.go @@ -107,7 +107,7 @@ plugin_version: v1.0.0 t.Run(tt.name, func(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(testWriter{t}, &slog.HandlerOptions{Level: slog.LevelDebug})) - result, err := updatePluginDeps(logger, []byte(tt.input), tt.latestVersions) + result, err := updatePluginDeps(t.Context(), logger, []byte(tt.input), tt.latestVersions) if tt.wantErr { assert.Error(t, err) return diff --git a/internal/cmd/release/main.go b/internal/cmd/release/main.go index beb8f3f9f..5b5bc2189 100644 --- a/internal/cmd/release/main.go +++ b/internal/cmd/release/main.go @@ -7,11 +7,10 @@ import ( "context" "encoding/json" "errors" - "flag" "fmt" "io" "io/fs" - "log" + "log/slog" "os" "os/exec" "path/filepath" @@ -21,11 +20,13 @@ import ( "time" "aead.dev/minisign" - "buf.build/go/interrupt" + "buf.build/go/app/appcmd" + "buf.build/go/app/appext" githubkeychain "github.com/google/go-containerregistry/pkg/authn/github" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-github/v72/github" + "github.com/spf13/pflag" "golang.org/x/mod/semver" "github.com/bufbuild/plugins/internal/plugin" @@ -36,42 +37,62 @@ type pluginNameVersion struct { name, version string } -func main() { - dryRun := flag.Bool("dry-run", false, "perform a dry-run (no GitHub modifications)") - githubCommit := flag.String("commit", "", "GitHub commit for the release") - githubReleaseOwner := flag.String( +type flags struct { + dryRun bool + githubCommit string + githubReleaseOwner string + minisignPrivateKey string + minisignPublicKey string +} + +func (f *flags) Bind(flagSet *pflag.FlagSet) { + flagSet.BoolVar(&f.dryRun, "dry-run", false, "perform a dry-run (no GitHub modifications)") + flagSet.StringVar(&f.githubCommit, "commit", "", "GitHub commit for the release") + flagSet.StringVar( + &f.githubReleaseOwner, "github-release-owner", string(release.GithubOwnerBufbuild), "GitHub release owner (set to personal account to test against a fork)", ) - minisignPrivateKey := flag.String("minisign-private-key", "", "path to minisign private key file") - minisignPublicKey := flag.String( + flagSet.StringVar(&f.minisignPrivateKey, "minisign-private-key", "", "path to minisign private key file") + flagSet.StringVar( + &f.minisignPublicKey, "minisign-public-key", "", "path to public key used to verify the latest release's plugin-releases.json file (if different than private key)", ) - flag.Parse() +} - if len(flag.Args()) != 1 { - _, _ = fmt.Fprintln(flag.CommandLine.Output(), "usage: release ") - flag.PrintDefaults() - os.Exit(2) - } - root := flag.Args()[0] - cmd := &command{ - minisignPrivateKey: *minisignPrivateKey, - minisignPublicKey: *minisignPublicKey, - githubCommit: *githubCommit, - githubReleaseOwner: release.GithubOwner(*githubReleaseOwner), - dryRun: *dryRun, - rootDir: root, - } - if err := cmd.run(); err != nil { - log.Fatalln(err.Error()) +func main() { + appcmd.Main(context.Background(), newRootCommand("release")) +} + +func newRootCommand(name string) *appcmd.Command { + builder := appext.NewBuilder(name) + f := &flags{} + return &appcmd.Command{ + Use: name + " ", + Short: "Creates a GitHub release for changed plugins.", + Args: appcmd.ExactArgs(1), + Run: builder.NewRunFunc(func(ctx context.Context, container appext.Container) error { + cmd := &command{ + logger: container.Logger(), + minisignPrivateKey: f.minisignPrivateKey, + minisignPublicKey: f.minisignPublicKey, + githubCommit: f.githubCommit, + githubReleaseOwner: release.GithubOwner(f.githubReleaseOwner), + dryRun: f.dryRun, + rootDir: container.Arg(0), + } + return cmd.run(ctx) + }), + BindFlags: f.Bind, + BindPersistentFlags: builder.BindRoot, } } type command struct { + logger *slog.Logger minisignPrivateKey string minisignPublicKey string githubCommit string @@ -80,20 +101,19 @@ type command struct { rootDir string } -func (c *command) run() error { - ctx := interrupt.Handle(context.Background()) +func (c *command) run(ctx context.Context) error { // Create temporary directory tmpDir, err := os.MkdirTemp("", "plugins-release") if err != nil { return fmt.Errorf("failed to create temporary directory: %w", err) } - log.Printf("created tmp dir: %s", tmpDir) + c.logger.InfoContext(ctx, "created tmp dir", slog.String("dir", tmpDir)) defer func() { if c.dryRun { return } if err := os.RemoveAll(tmpDir); err != nil { - log.Printf("failed to remove %q: %v", tmpDir, err) + c.logger.WarnContext(ctx, "failed to remove tmp dir", slog.String("dir", tmpDir), slog.Any("error", err)) } }() client := release.NewClient() @@ -118,7 +138,7 @@ func (c *command) run() error { return fmt.Errorf("failed to determine latest plugin releases: %w", err) } if releases == nil { - log.Printf("no current release found") + c.logger.InfoContext(ctx, "no current release found") releases = &release.PluginReleases{} } @@ -134,9 +154,9 @@ func (c *command) run() error { } if len(plugins) == 0 { if tagName := latestRelease.GetTagName(); tagName != "" { - log.Printf("no changes to plugins since %v", tagName) + c.logger.InfoContext(ctx, "no changes to plugins since release", slog.String("tag", tagName)) } else { - log.Printf("no changes to plugins - not creating initial release") + c.logger.InfoContext(ctx, "no changes to plugins - not creating initial release") } return nil } @@ -144,7 +164,7 @@ func (c *command) run() error { return fmt.Errorf("failed to create %s: %w", release.PluginReleasesFile, err) } - if err := signPluginReleases(tmpDir, privateKey); err != nil { + if err := signPluginReleases(ctx, c.logger, tmpDir, privateKey); err != nil { return fmt.Errorf("failed to sign %q: %w", filepath.Join(tmpDir, release.PluginReleasesFile), err) } @@ -156,8 +176,8 @@ func (c *command) run() error { if err := os.WriteFile(filepath.Join(tmpDir, "RELEASE.md"), []byte(releaseBody), 0644); err != nil { //nolint:gosec return err } - log.Printf("skipping GitHub release creation in dry-run mode") - log.Printf("release assets created in %q", tmpDir) + c.logger.InfoContext(ctx, "skipping GitHub release creation in dry-run mode") + c.logger.InfoContext(ctx, "release assets created", slog.String("dir", tmpDir)) return nil } if err := c.createRelease(ctx, client, releaseName, plugins, tmpDir, privateKey); err != nil { @@ -192,7 +212,11 @@ func (c *command) calculateNewReleasePlugins(ctx context.Context, currentRelease } identity := plugin.Identity if registryImage == "" || imageID == "" { - log.Printf("unable to detect registry image and image ID for plugin %s/%s:%s", identity.Owner(), identity.Plugin(), plugin.PluginVersion) + c.logger.InfoContext(ctx, "unable to detect registry image and image ID", + slog.String("owner", identity.Owner()), + slog.String("plugin", identity.Plugin()), + slog.String("version", plugin.PluginVersion), + ) return nil } key := pluginNameVersion{name: identity.Owner() + "/" + identity.Plugin(), version: plugin.PluginVersion} @@ -200,7 +224,7 @@ func (c *command) calculateNewReleasePlugins(ctx context.Context, currentRelease // Found existing release - only rebuild if changed image digest or buf.plugin.yaml digest if pluginRelease.ImageID != imageID || pluginRelease.PluginYAMLDigest != pluginYamlDigest { downloadURL := c.pluginDownloadURL(plugin, releaseName) - zipDigest, err := createPluginZip(ctx, tmpDir, plugin, registryImage, imageID) + zipDigest, err := createPluginZip(ctx, c.logger, tmpDir, plugin, registryImage, imageID) if err != nil { return err } @@ -208,6 +232,10 @@ func (c *command) calculateNewReleasePlugins(ctx context.Context, currentRelease if pluginRelease.ImageID == "" { status = release.StatusNew } + deps, err := pluginDependencies(plugin) + if err != nil { + return err + } newPlugins = append(newPlugins, release.PluginRelease{ PluginName: fmt.Sprintf("%s/%s", identity.Owner(), identity.Plugin()), PluginVersion: plugin.PluginVersion, @@ -219,12 +247,19 @@ func (c *command) calculateNewReleasePlugins(ctx context.Context, currentRelease URL: downloadURL, LastUpdated: now, Status: status, - Dependencies: pluginDependencies(plugin), + Dependencies: deps, }) } else { - log.Printf("plugin %s:%s unchanged", pluginRelease.PluginName, pluginRelease.PluginVersion) + c.logger.InfoContext(ctx, "plugin unchanged", + slog.String("name", pluginRelease.PluginName), + slog.String("version", pluginRelease.PluginVersion), + ) pluginRelease.Status = release.StatusExisting - pluginRelease.Dependencies = pluginDependencies(plugin) + deps, err := pluginDependencies(plugin) + if err != nil { + return err + } + pluginRelease.Dependencies = deps existingPlugins = append(existingPlugins, pluginRelease) } return nil @@ -241,19 +276,19 @@ func (c *command) calculateNewReleasePlugins(ctx context.Context, currentRelease return plugins, nil } -func pluginDependencies(plugin *plugin.Plugin) []string { +func pluginDependencies(plugin *plugin.Plugin) ([]string, error) { if len(plugin.Deps) == 0 { - return nil + return nil, nil } deps := make([]string, len(plugin.Deps)) for i, dep := range plugin.Deps { if dep.Revision != 0 { - log.Fatalf("unsupported plugin dependency revision: %v", dep.Revision) + return nil, fmt.Errorf("unsupported plugin dependency revision: %v", dep.Revision) } deps[i] = dep.Plugin } slices.Sort(deps) - return deps + return deps, nil } func (c *command) loadMinisignPublicKeyFromFileOrPrivateKey(privateKey minisign.PrivateKey) (minisign.PublicKey, error) { @@ -310,7 +345,7 @@ func (c *command) createRelease(ctx context.Context, client *release.Client, rel if d.IsDir() { return nil } - log.Printf("uploading: %s", d.Name()) + c.logger.InfoContext(ctx, "uploading", slog.String("file", d.Name())) return client.UploadReleaseAsset(ctx, c.githubReleaseOwner, release.GithubRepoPlugins, repositoryRelease.GetID(), path) }); err != nil { return err @@ -379,13 +414,13 @@ func (c *command) createReleaseBody(name string, plugins []release.PluginRelease return sb.String(), nil } -func signPluginReleases(dir string, privateKey minisign.PrivateKey) error { +func signPluginReleases(ctx context.Context, logger *slog.Logger, dir string, privateKey minisign.PrivateKey) error { releasesFile := filepath.Join(dir, release.PluginReleasesFile) if privateKey.Equal(minisign.PrivateKey{}) { // Private key not initialized - log.Printf("skipping signing of %s", releasesFile) + logger.InfoContext(ctx, "skipping signing", slog.String("file", releasesFile)) return nil } - log.Printf("signing: %s", releasesFile) + logger.InfoContext(ctx, "signing", slog.String("file", releasesFile)) releasesFileBytes, err := os.ReadFile(releasesFile) if err != nil { return err @@ -397,8 +432,15 @@ func signPluginReleases(dir string, privateKey minisign.PrivateKey) error { return nil } -func createPluginZip(ctx context.Context, basedir string, plugin *plugin.Plugin, registryImage string, imageID string) (string, error) { - if err := pullImage(ctx, registryImage); err != nil { +func createPluginZip( + ctx context.Context, + logger *slog.Logger, + basedir string, + plugin *plugin.Plugin, + registryImage string, + imageID string, +) (string, error) { + if err := pullImage(ctx, logger, registryImage); err != nil { return "", err } zipName := pluginZipName(plugin) @@ -408,13 +450,13 @@ func createPluginZip(ctx context.Context, basedir string, plugin *plugin.Plugin, } defer func() { if err := os.RemoveAll(pluginTempDir); err != nil { - log.Printf("failed to remove %q: %v", pluginTempDir, err) + logger.WarnContext(ctx, "failed to remove tmp dir", slog.String("dir", pluginTempDir), slog.Any("error", err)) } }() if err := saveImageToDir(ctx, imageID, pluginTempDir); err != nil { return "", err } - log.Printf("creating %s", zipName) + logger.InfoContext(ctx, "creating zip", slog.String("name", zipName)) zipFile := filepath.Join(basedir, zipName) zf, err := os.OpenFile(zipFile, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) if err != nil { @@ -422,7 +464,7 @@ func createPluginZip(ctx context.Context, basedir string, plugin *plugin.Plugin, } defer func() { if err := zf.Close(); err != nil && !errors.Is(err, os.ErrClosed) { - log.Printf("failed to close: %v", err) + logger.WarnContext(ctx, "failed to close zip file", slog.Any("error", err)) } }() zw := zip.NewWriter(zf) @@ -448,7 +490,7 @@ func createPluginZip(ctx context.Context, basedir string, plugin *plugin.Plugin, return digest, nil } -func addFileToZip(zipWriter *zip.Writer, path string) error { +func addFileToZip(zipWriter *zip.Writer, path string) (retErr error) { w, err := zipWriter.Create(filepath.Base(path)) if err != nil { return err @@ -458,9 +500,7 @@ func addFileToZip(zipWriter *zip.Writer, path string) error { return err } defer func() { - if err := r.Close(); err != nil { - log.Printf("failed to close: %v", err) - } + retErr = errors.Join(retErr, r.Close()) }() if _, err := io.Copy(w, r); err != nil { return err @@ -474,24 +514,22 @@ func saveImageToDir(ctx context.Context, imageRef string, dir string) error { return cmd.Run() } -func createPluginReleases(dir string, plugins []release.PluginRelease) error { +func createPluginReleases(dir string, plugins []release.PluginRelease) (retErr error) { f, err := os.OpenFile(filepath.Join(dir, release.PluginReleasesFile), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err } defer func() { - if err := f.Close(); err != nil { - log.Printf("failed to close: %v", err) - } + retErr = errors.Join(retErr, f.Close()) }() encoder := json.NewEncoder(f) encoder.SetIndent("", " ") return encoder.Encode(&release.PluginReleases{Releases: plugins}) } -func pullImage(ctx context.Context, name string) error { - log.Printf("pulling image: %s", name) - return dockerCmd(ctx, "pull", name).Run() +func pullImage(ctx context.Context, logger *slog.Logger, imageName string) error { + logger.InfoContext(ctx, "pulling image", slog.String("name", imageName)) + return dockerCmd(ctx, "pull", imageName).Run() } func dockerCmd(ctx context.Context, command string, args ...string) *exec.Cmd {