From 08f2e35f1041864908d86c66e88346101641599e Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 5 May 2026 16:17:25 +0100 Subject: [PATCH 1/6] external-plugins/netlify-preview: add Netlify API client --- .../netlify-preview/netlify/client.go | 107 ++++++++++++++++++ .../netlify-preview/netlify/client_test.go | 106 +++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 cmd/external-plugins/netlify-preview/netlify/client.go create mode 100644 cmd/external-plugins/netlify-preview/netlify/client_test.go diff --git a/cmd/external-plugins/netlify-preview/netlify/client.go b/cmd/external-plugins/netlify-preview/netlify/client.go new file mode 100644 index 0000000000..616c0c1fdf --- /dev/null +++ b/cmd/external-plugins/netlify-preview/netlify/client.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package netlify + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Deploy is the subset of Netlify deploy data needed to retry PR deploy previews. +type Deploy struct { + ID string `json:"id"` + Context string `json:"context"` + State string `json:"state"` + ReviewID int `json:"review_id"` + Branch string `json:"branch"` + DeploySSLURL string `json:"deploy_ssl_url"` + CreatedAt time.Time `json:"created_at"` +} + +type Client struct { + baseURL string + httpClient *http.Client + tokenGenerator func() []byte +} + +func NewClient(baseURL string, httpClient *http.Client, tokenGenerator func() []byte) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + httpClient: httpClient, + tokenGenerator: tokenGenerator, + } +} + +func (c *Client) ListDeploys(ctx context.Context, siteID string) ([]Deploy, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/v1/sites/%s/deploys", c.baseURL, url.PathEscape(siteID)), nil) + if err != nil { + return nil, err + } + c.authorize(req) + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("list deploys returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var deploys []Deploy + if err := json.NewDecoder(resp.Body).Decode(&deploys); err != nil { + return nil, err + } + return deploys, nil +} + +func (c *Client) RetryDeploy(ctx context.Context, deployID string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api/v1/deploys/%s/retry", c.baseURL, url.PathEscape(deployID)), nil) + if err != nil { + return err + } + c.authorize(req) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("retry deploy returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return nil +} + +func (c *Client) authorize(req *http.Request) { + if c.tokenGenerator == nil { + return + } + token := strings.TrimSpace(string(c.tokenGenerator())) + if token == "" { + return + } + req.Header.Set("Authorization", "Bearer "+token) +} diff --git a/cmd/external-plugins/netlify-preview/netlify/client_test.go b/cmd/external-plugins/netlify-preview/netlify/client_test.go new file mode 100644 index 0000000000..8f083c86e9 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/netlify/client_test.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package netlify + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +func TestListDeploys(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/api/v1/sites/site-123/deploys" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer token-123" { + t.Fatalf("unexpected auth header %q", got) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`[{ + "id": "deploy-123", + "context": "deploy-preview", + "state": "ready", + "review_id": 5, + "branch": "feature", + "deploy_ssl_url": "https://deploy-preview-5.example.netlify.app", + "created_at": "2026-04-28T22:10:06.585Z" + }]`)), + Header: make(http.Header), + }, nil + })} + + client := NewClient("https://api.netlify.test", httpClient, func() []byte { return []byte("token-123") }) + deploys, err := client.ListDeploys(context.Background(), "site-123") + if err != nil { + t.Fatalf("failed to list deploys: %v", err) + } + if len(deploys) != 1 { + t.Fatalf("expected one deploy, got %d", len(deploys)) + } + if deploys[0].ID != "deploy-123" || deploys[0].ReviewID != 5 { + t.Fatalf("unexpected deploy: %#v", deploys[0]) + } +} + +func TestRetryDeploy(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method != http.MethodPost { + t.Fatalf("unexpected method %q", r.Method) + } + if r.URL.Path != "/api/v1/deploys/deploy-123/retry" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Status: "201 Created", + Body: io.NopCloser(strings.NewReader(`{}`)), + Header: make(http.Header), + }, nil + })} + + client := NewClient("https://api.netlify.test", httpClient, func() []byte { return []byte("token-123") }) + if err := client.RetryDeploy(context.Background(), "deploy-123"); err != nil { + t.Fatalf("failed to retry deploy: %v", err) + } +} + +func TestRetryDeployReportsNonSuccess(t *testing.T) { + httpClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Status: "429 Too Many Requests", + Body: io.NopCloser(strings.NewReader("rate limited")), + Header: make(http.Header), + }, nil + })} + + client := NewClient("https://api.netlify.test", httpClient, func() []byte { return []byte("token-123") }) + if err := client.RetryDeploy(context.Background(), "deploy-123"); err == nil { + t.Fatal("expected error") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} From e6ba2eafcf61e6390a36036810f78f1e3b899fa1 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 5 May 2026 16:20:06 +0100 Subject: [PATCH 2/6] external-plugins/netlify-preview: add per-repo site mapping config --- .../netlify-preview/config/config.go | 64 +++++++++++++++++++ .../netlify-preview/config/config_test.go | 61 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 cmd/external-plugins/netlify-preview/config/config.go create mode 100644 cmd/external-plugins/netlify-preview/config/config_test.go diff --git a/cmd/external-plugins/netlify-preview/config/config.go b/cmd/external-plugins/netlify-preview/config/config.go new file mode 100644 index 0000000000..9d9382b264 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/config/config.go @@ -0,0 +1,64 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "fmt" + "os" + + "sigs.k8s.io/yaml" +) + +type Config struct { + Repos map[string]Repo `json:"repos,omitempty"` +} + +type Repo struct { + SiteID string `json:"site_id,omitempty"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := yaml.UnmarshalStrict(data, &cfg); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +func (c *Config) Validate() error { + for repo, cfg := range c.Repos { + if cfg.SiteID == "" { + return fmt.Errorf("missing site_id for repo %q", repo) + } + } + return nil +} + +func (c *Config) Repo(org, repo string) (Repo, bool) { + if c == nil { + return Repo{}, false + } + cfg, ok := c.Repos[org+"/"+repo] + return cfg, ok +} diff --git a/cmd/external-plugins/netlify-preview/config/config_test.go b/cmd/external-plugins/netlify-preview/config/config_test.go new file mode 100644 index 0000000000..3e5c016305 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/config/config_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(`repos: + kubernetes/website: + site_id: site-123 +`), 0600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + repo, ok := cfg.Repo("kubernetes", "website") + if !ok { + t.Fatal("expected kubernetes/website mapping") + } + if repo.SiteID != "site-123" { + t.Fatalf("expected site id %q, got %q", "site-123", repo.SiteID) + } +} + +func TestLoadRejectsMissingSiteID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(`repos: + kubernetes/website: {} +`), 0600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + if _, err := Load(path); err == nil { + t.Fatal("expected error for missing site id") + } +} From 726f61cf1eaf04e5b3173f90775bbd415b168758 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Tue, 5 May 2026 16:29:38 +0100 Subject: [PATCH 3/6] external-plugins/netlify-preview: add command parser and decision logic --- .../netlify-preview/plugin/plugin.go | 117 ++++++++++++ .../netlify-preview/plugin/plugin_test.go | 173 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 cmd/external-plugins/netlify-preview/plugin/plugin.go create mode 100644 cmd/external-plugins/netlify-preview/plugin/plugin_test.go diff --git a/cmd/external-plugins/netlify-preview/plugin/plugin.go b/cmd/external-plugins/netlify-preview/plugin/plugin.go new file mode 100644 index 0000000000..1f76021091 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/plugin/plugin.go @@ -0,0 +1,117 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "regexp" + + "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/netlify" + "sigs.k8s.io/prow/pkg/config" + "sigs.k8s.io/prow/pkg/markdown" + "sigs.k8s.io/prow/pkg/pluginhelp" +) + +const PluginName = "netlify-preview" + +type Command string + +const ( + RetestCommand Command = "retest" + RebuildPreviewCommand Command = "rebuild-preview" +) + +var commandRe = regexp.MustCompile(`(?mi)^/(retest|rebuild-preview)\s*$`) + +func ParseCommand(body string) (Command, bool) { + body = markdown.DropCodeBlock(body) + matches := commandRe.FindStringSubmatch(body) + if len(matches) != 2 { + return "", false + } + return Command(matches[1]), true +} + +func LatestDeployPreview(deploys []netlify.Deploy, reviewID int) (netlify.Deploy, bool) { + var latest netlify.Deploy + var found bool + for _, deploy := range deploys { + if deploy.Context != "deploy-preview" || deploy.ReviewID != reviewID { + continue + } + if !found || deploy.CreatedAt.After(latest.CreatedAt) { + latest = deploy + found = true + } + } + return latest, found +} + +type Action string + +const ( + ActionRetry Action = "retry" + ActionNoPreview Action = "no_preview" + ActionAlreadyRunning Action = "already_running" + ActionReadyRequiresRebuild Action = "ready_requires_rebuild" + ActionUnsupportedState Action = "unsupported_state" +) + +type Decision struct { + Action Action + ShouldRetry bool +} + +func Evaluate(command Command, preview *netlify.Deploy) Decision { + if preview == nil { + return Decision{Action: ActionNoPreview} + } + if preview.State == "building" || preview.State == "enqueued" { + return Decision{Action: ActionAlreadyRunning} + } + if command == RebuildPreviewCommand { + return Decision{Action: ActionRetry, ShouldRetry: true} + } + switch preview.State { + case "error": + return Decision{Action: ActionRetry, ShouldRetry: true} + case "ready": + return Decision{Action: ActionReadyRequiresRebuild} + default: + return Decision{Action: ActionUnsupportedState} + } +} + +func HelpProvider(_ []config.OrgRepo) (*pluginhelp.PluginHelp, error) { + pluginHelp := &pluginhelp.PluginHelp{ + Description: "The netlify-preview plugin retries Netlify deploy previews for pull requests.", + } + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/retest", + Description: "Retry the latest Netlify deploy preview for a PR when that preview is in error state.", + Featured: true, + WhoCanUse: "Anyone can trigger this command on a trusted PR.", + Examples: []string{"/retest"}, + }) + pluginHelp.AddCommand(pluginhelp.Command{ + Usage: "/rebuild-preview", + Description: "Force a retry of the latest Netlify deploy preview for a PR regardless of its current state, except when a build is already running.", + Featured: true, + WhoCanUse: "Anyone can trigger this command on a trusted PR.", + Examples: []string{"/rebuild-preview"}, + }) + return pluginHelp, nil +} diff --git a/cmd/external-plugins/netlify-preview/plugin/plugin_test.go b/cmd/external-plugins/netlify-preview/plugin/plugin_test.go new file mode 100644 index 0000000000..a7810b8fba --- /dev/null +++ b/cmd/external-plugins/netlify-preview/plugin/plugin_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "testing" + "time" + + "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/netlify" +) + +func TestParseCommand(t *testing.T) { + tests := []struct { + name string + body string + want Command + ok bool + }{ + { + name: "retest", + body: "/retest", + want: RetestCommand, + ok: true, + }, + { + name: "rebuild preview", + body: "/rebuild-preview", + want: RebuildPreviewCommand, + ok: true, + }, + { + name: "command inside multiline comment", + body: "please try this again\n/rebuild-preview\nthanks", + want: RebuildPreviewCommand, + ok: true, + }, + { + name: "trailing words are rejected", + body: "/rebuild-preview please", + ok: false, + }, + { + name: "command in code block is ignored", + body: "```\n/rebuild-preview\n```", + ok: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := ParseCommand(tc.body) + if ok != tc.ok { + t.Fatalf("expected ok=%v, got %v", tc.ok, ok) + } + if got != tc.want { + t.Fatalf("expected command %q, got %q", tc.want, got) + } + }) + } +} + +func TestLatestDeployPreview(t *testing.T) { + old := time.Date(2026, 4, 28, 17, 0, 0, 0, time.UTC) + newer := old.Add(time.Hour) + deploys := []netlify.Deploy{ + {ID: "branch", Context: "branch-deploy", ReviewID: 5, CreatedAt: newer}, + {ID: "other-pr", Context: "deploy-preview", ReviewID: 6, CreatedAt: newer}, + {ID: "old", Context: "deploy-preview", ReviewID: 5, CreatedAt: old}, + {ID: "new", Context: "deploy-preview", ReviewID: 5, CreatedAt: newer}, + } + + got, ok := LatestDeployPreview(deploys, 5) + if !ok { + t.Fatal("expected to find a deploy preview") + } + if got.ID != "new" { + t.Fatalf("expected latest deploy preview %q, got %q", "new", got.ID) + } +} + +func TestEvaluate(t *testing.T) { + tests := []struct { + name string + command Command + preview *netlify.Deploy + wantAction Action + wantRetry bool + }{ + { + name: "no preview", + command: RebuildPreviewCommand, + wantAction: ActionNoPreview, + }, + { + name: "building preview is already running", + command: RebuildPreviewCommand, + preview: &netlify.Deploy{State: "building"}, + wantAction: ActionAlreadyRunning, + }, + { + name: "enqueued preview is already running", + command: RebuildPreviewCommand, + preview: &netlify.Deploy{State: "enqueued"}, + wantAction: ActionAlreadyRunning, + }, + { + name: "retest retries error preview", + command: RetestCommand, + preview: &netlify.Deploy{State: "error"}, + wantAction: ActionRetry, + wantRetry: true, + }, + { + name: "rebuild preview retries error preview", + command: RebuildPreviewCommand, + preview: &netlify.Deploy{State: "error"}, + wantAction: ActionRetry, + wantRetry: true, + }, + { + name: "retest does not retry ready preview", + command: RetestCommand, + preview: &netlify.Deploy{State: "ready"}, + wantAction: ActionReadyRequiresRebuild, + }, + { + name: "rebuild preview retries ready preview", + command: RebuildPreviewCommand, + preview: &netlify.Deploy{State: "ready"}, + wantAction: ActionRetry, + wantRetry: true, + }, + { + name: "retest does not retry unknown state", + command: RetestCommand, + preview: &netlify.Deploy{State: "uploaded"}, + wantAction: ActionUnsupportedState, + }, + { + name: "rebuild preview overrides unknown state", + command: RebuildPreviewCommand, + preview: &netlify.Deploy{State: "uploaded"}, + wantAction: ActionRetry, + wantRetry: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Evaluate(tc.command, tc.preview) + if got.Action != tc.wantAction { + t.Fatalf("expected action %q, got %q", tc.wantAction, got.Action) + } + if got.ShouldRetry != tc.wantRetry { + t.Fatalf("expected ShouldRetry=%v, got %v", tc.wantRetry, got.ShouldRetry) + } + }) + } +} From a8b242cb25e40589722ab7224e6b2e7538329266 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Thu, 7 May 2026 09:16:12 +0100 Subject: [PATCH 4/6] external-plugins/netlify-preview: wire HTTP server and command handler --- cmd/external-plugins/netlify-preview/main.go | 147 +++++++++++++ .../netlify-preview/server.go | 207 ++++++++++++++++++ .../netlify-preview/server_test.go | 207 ++++++++++++++++++ 3 files changed, 561 insertions(+) create mode 100644 cmd/external-plugins/netlify-preview/main.go create mode 100644 cmd/external-plugins/netlify-preview/server.go create mode 100644 cmd/external-plugins/netlify-preview/server_test.go diff --git a/cmd/external-plugins/netlify-preview/main.go b/cmd/external-plugins/netlify-preview/main.go new file mode 100644 index 0000000000..c801ab6827 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/main.go @@ -0,0 +1,147 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// netlify-preview retries Netlify deploy previews for pull requests. +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "github.com/sirupsen/logrus" + + previewconfig "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/config" + "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/netlify" + netlifypreview "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/plugin" + "sigs.k8s.io/prow/pkg/config/secret" + "sigs.k8s.io/prow/pkg/flagutil" + prowflagutil "sigs.k8s.io/prow/pkg/flagutil" + pluginsflagutil "sigs.k8s.io/prow/pkg/flagutil/plugins" + "sigs.k8s.io/prow/pkg/interrupts" + "sigs.k8s.io/prow/pkg/logrusutil" + "sigs.k8s.io/prow/pkg/pjutil" + "sigs.k8s.io/prow/pkg/pluginhelp/externalplugins" +) + +type options struct { + port int + + pluginsConfig pluginsflagutil.PluginOptions + dryRun bool + github prowflagutil.GitHubOptions + instrumentationOptions prowflagutil.InstrumentationOptions + logLevel string + + webhookSecretFile string + netlifyTokenFile string + configPath string + netlifyAPIURL string +} + +func (o *options) Validate() error { + for idx, group := range []flagutil.OptionGroup{&o.github} { + if err := group.Validate(o.dryRun); err != nil { + return fmt.Errorf("%d: %w", idx, err) + } + } + if o.netlifyTokenFile == "" { + return fmt.Errorf("--netlify-token-file is required") + } + if o.configPath == "" { + return fmt.Errorf("--config-path is required") + } + if o.netlifyAPIURL == "" { + return fmt.Errorf("--netlify-api-url is required") + } + return nil +} + +func gatherOptions() options { + o := options{} + fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) + fs.IntVar(&o.port, "port", 8888, "Port to listen on.") + fs.BoolVar(&o.dryRun, "dry-run", true, "Dry run for testing. Uses API tokens but does not mutate.") + fs.StringVar(&o.webhookSecretFile, "hmac-secret-file", "/etc/webhook/hmac", "Path to the file containing the GitHub HMAC secret.") + fs.StringVar(&o.netlifyTokenFile, "netlify-token-file", "/etc/netlify-preview/token", "Path to the file containing the Netlify API token.") + fs.StringVar(&o.configPath, "config-path", "/etc/netlify-preview/config.yaml", "Path to the netlify-preview plugin config file.") + fs.StringVar(&o.netlifyAPIURL, "netlify-api-url", "https://api.netlify.com", "Base URL for the Netlify API.") + fs.StringVar(&o.logLevel, "log-level", "debug", fmt.Sprintf("Log level is one of %v.", logrus.AllLevels)) + o.pluginsConfig.PluginConfigPathDefault = "/etc/plugins/plugins.yaml" + for _, group := range []flagutil.OptionGroup{&o.github, &o.instrumentationOptions, &o.pluginsConfig} { + group.AddFlags(fs) + } + fs.Parse(os.Args[1:]) + return o +} + +func main() { + logrusutil.ComponentInit() + o := gatherOptions() + if err := o.Validate(); err != nil { + logrus.Fatalf("Invalid options: %v", err) + } + + logLevel, err := logrus.ParseLevel(o.logLevel) + if err != nil { + logrus.WithError(err).Fatal("Failed to parse loglevel") + } + logrus.SetLevel(logLevel) + log := logrus.StandardLogger().WithField("plugin", netlifypreview.PluginName) + + if err := secret.Add(o.webhookSecretFile); err != nil { + logrus.WithError(err).Fatal("Error starting webhook secret agent.") + } + if err := secret.Add(o.netlifyTokenFile); err != nil { + logrus.WithError(err).Fatal("Error starting Netlify token secret agent.") + } + + pluginAgent, err := o.pluginsConfig.PluginAgent() + if err != nil { + log.WithError(err).Fatal("Error loading plugin config.") + } + previewConfig, err := previewconfig.Load(o.configPath) + if err != nil { + log.WithError(err).Fatal("Error loading netlify-preview config.") + } + githubClient, err := o.github.GitHubClient(o.dryRun) + if err != nil { + logrus.WithError(err).Fatal("Error getting GitHub client.") + } + + serv := &server{ + tokenGenerator: secret.GetTokenGenerator(o.webhookSecretFile), + ghc: githubClient, + netlifyClient: netlify.NewClient(o.netlifyAPIURL, http.DefaultClient, secret.GetTokenGenerator(o.netlifyTokenFile)), + pluginConfig: pluginAgent, + previewConfig: previewConfig, + log: log, + dryRun: o.dryRun, + } + + health := pjutil.NewHealthOnPort(o.instrumentationOptions.HealthPort) + health.ServeReady() + + mux := http.NewServeMux() + mux.Handle("/", serv) + externalplugins.ServeExternalPluginHelp(mux, log, netlifypreview.HelpProvider) + httpServer := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux} + defer interrupts.WaitForGracefulShutdown() + interrupts.ListenAndServe(httpServer, 5*time.Second) +} diff --git a/cmd/external-plugins/netlify-preview/server.go b/cmd/external-plugins/netlify-preview/server.go new file mode 100644 index 0000000000..769ed9bdf2 --- /dev/null +++ b/cmd/external-plugins/netlify-preview/server.go @@ -0,0 +1,207 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/sirupsen/logrus" + + previewconfig "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/config" + "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/netlify" + netlifypreview "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/plugin" + "sigs.k8s.io/prow/pkg/github" + "sigs.k8s.io/prow/pkg/plugins" + "sigs.k8s.io/prow/pkg/plugins/trigger" +) + +type githubClient interface { + BotUserChecker() (func(candidate string) bool, error) + CreateComment(org, repo string, number int, comment string) error + GetIssueLabels(org, repo string, number int) ([]github.Label, error) + IsCollaborator(owner, repo, login string) (bool, error) + IsMember(org, user string) (bool, error) +} + +type netlifyClient interface { + ListDeploys(ctx context.Context, siteID string) ([]netlify.Deploy, error) + RetryDeploy(ctx context.Context, deployID string) error +} + +type pluginConfigAgent interface { + Config() *plugins.Configuration +} + +type server struct { + tokenGenerator func() []byte + ghc githubClient + netlifyClient netlifyClient + pluginConfig pluginConfigAgent + previewConfig *previewconfig.Config + log *logrus.Entry + dryRun bool +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + eventType, eventGUID, payload, ok, _ := github.ValidateWebhook(w, r, s.tokenGenerator) + if !ok { + return + } + fmt.Fprint(w, "Event received. Have a nice day.") + + if err := s.handleEvent(eventType, eventGUID, payload); err != nil { + s.log.WithError(err).Error("Error parsing event.") + } +} + +func (s *server) handleEvent(eventType, eventGUID string, payload []byte) error { + l := s.log.WithFields(logrus.Fields{ + "event-type": eventType, + github.EventGUID: eventGUID, + }) + switch eventType { + case "issue_comment": + var ic github.IssueCommentEvent + if err := json.Unmarshal(payload, &ic); err != nil { + return err + } + go func() { + if err := s.handleIssueComment(l, ic); err != nil { + s.log.WithError(err).WithFields(l.Data).Info("Failed to handle issue comment.") + } + }() + default: + l.Debugf("skipping event of type %q", eventType) + } + return nil +} + +func (s *server) handleIssueComment(l *logrus.Entry, ic github.IssueCommentEvent) error { + if !ic.Issue.IsPullRequest() || ic.Action != github.IssueCommentActionCreated || ic.Issue.State == "closed" { + return nil + } + + command, ok := netlifypreview.ParseCommand(ic.Comment.Body) + if !ok { + return nil + } + + org := ic.Repo.Owner.Login + repo := ic.Repo.Name + number := ic.Issue.Number + commentAuthor := ic.Comment.User.Login + l = l.WithFields(logrus.Fields{ + github.OrgLogField: org, + github.RepoLogField: repo, + github.PrLogField: number, + "command": command, + }) + + botUserChecker, err := s.ghc.BotUserChecker() + if err != nil { + return err + } + if botUserChecker(commentAuthor) { + l.Debug("Comment is made by the bot, skipping.") + return nil + } + + if trusted, err := s.trustedForCommand(org, repo, number, ic.Issue.User.Login, commentAuthor); err != nil { + return err + } else if !trusted { + return s.comment(ic, "Cannot retry the Netlify deploy preview until a trusted user reviews the PR and leaves an `/ok-to-test` message.") + } + + repoConfig, ok := s.previewConfig.Repo(org, repo) + if !ok { + l.WithField("action", "config_error").Info("Repository has no Netlify preview site mapping.") + return s.comment(ic, "This repository does not have a Netlify preview site mapping configured, so I can't retry a deploy preview for it.") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + deploys, err := s.netlifyClient.ListDeploys(ctx, repoConfig.SiteID) + if err != nil { + return err + } + preview, found := netlifypreview.LatestDeployPreview(deploys, number) + var previewPtr *netlify.Deploy + if found { + previewPtr = &preview + } + decision := netlifypreview.Evaluate(command, previewPtr) + l = l.WithFields(logrus.Fields{ + "site-id": repoConfig.SiteID, + "action": decision.Action, + }) + if found { + l = l.WithFields(logrus.Fields{ + "deploy-id": preview.ID, + "deploy-state": preview.State, + "preview-link": preview.DeploySSLURL, + }) + } + + if decision.ShouldRetry { + if !s.dryRun { + if err := s.netlifyClient.RetryDeploy(ctx, preview.ID); err != nil { + return err + } + } + l.Info("Requested Netlify deploy preview retry.") + return s.comment(ic, fmt.Sprintf("Retrying the latest Netlify deploy preview for this PR: %s", preview.DeploySSLURL)) + } + + l.Info("Did not request Netlify deploy preview retry.") + return s.comment(ic, responseForDecision(command, decision, previewPtr)) +} + +func (s *server) trustedForCommand(org, repo string, number int, issueAuthor, commentAuthor string) (bool, error) { + triggerConfig := s.pluginConfig.Config().TriggerFor(org, repo) + trustedResponse, err := trigger.TrustedUser(s.ghc, triggerConfig.OnlyOrgMembers, triggerConfig.TrustedApps, triggerConfig.TrustedOrg, commentAuthor, org, repo) + if err != nil { + return false, fmt.Errorf("error checking trust of %s: %w", commentAuthor, err) + } + if trustedResponse.IsTrusted { + return true, nil + } + _, trusted, err := trigger.TrustedPullRequest(s.ghc, triggerConfig, issueAuthor, org, repo, number, nil) + return trusted, err +} + +func (s *server) comment(ic github.IssueCommentEvent, response string) error { + return s.ghc.CreateComment(ic.Repo.Owner.Login, ic.Repo.Name, ic.Issue.Number, plugins.FormatICResponse(ic.Comment, response)) +} + +func responseForDecision(command netlifypreview.Command, decision netlifypreview.Decision, preview *netlify.Deploy) string { + switch decision.Action { + case netlifypreview.ActionNoPreview: + return "No Netlify deploy preview was found for this PR, so there is nothing to retry." + case netlifypreview.ActionAlreadyRunning: + return fmt.Sprintf("A Netlify deploy preview is already in progress for this PR: %s", preview.DeploySSLURL) + case netlifypreview.ActionReadyRequiresRebuild: + return fmt.Sprintf("The latest Netlify deploy preview is `ready`. `/retest` only retries previews in `error` state. Use `/rebuild-preview` to refresh it: %s", preview.DeploySSLURL) + case netlifypreview.ActionUnsupportedState: + return fmt.Sprintf("The latest Netlify deploy preview is in state `%s`. `/retest` does not retry this state. Use `/rebuild-preview` to force a retry: %s", preview.State, preview.DeploySSLURL) + default: + return fmt.Sprintf("No Netlify deploy preview retry was requested for command `%s`.", command) + } +} diff --git a/cmd/external-plugins/netlify-preview/server_test.go b/cmd/external-plugins/netlify-preview/server_test.go new file mode 100644 index 0000000000..98309b1f1c --- /dev/null +++ b/cmd/external-plugins/netlify-preview/server_test.go @@ -0,0 +1,207 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + + previewconfig "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/config" + "sigs.k8s.io/prow/cmd/external-plugins/netlify-preview/netlify" + "sigs.k8s.io/prow/pkg/github" + "sigs.k8s.io/prow/pkg/labels" + "sigs.k8s.io/prow/pkg/plugins" +) + +func TestHandleIssueCommentIgnoresNonActionableComments(t *testing.T) { + tests := []struct { + name string + ice github.IssueCommentEvent + }{ + { + name: "non pr comment", + ice: issueCommentEvent("/rebuild-preview", "open", false), + }, + { + name: "closed pr comment", + ice: issueCommentEvent("/rebuild-preview", "closed", true), + }, + { + name: "non command comment", + ice: issueCommentEvent("hello", "open", true), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ghc := &fakeGitHubClient{member: true} + netlifyClient := &fakeNetlifyClient{} + s := newTestServer(ghc, netlifyClient, &previewconfig.Config{}) + if err := s.handleIssueComment(logrus.NewEntry(logrus.New()), tc.ice); err != nil { + t.Fatalf("handleIssueComment returned error: %v", err) + } + if len(ghc.comments) != 0 { + t.Fatalf("expected no comments, got %v", ghc.comments) + } + if netlifyClient.listCalled { + t.Fatal("expected Netlify not to be called") + } + }) + } +} + +func TestHandleIssueCommentRejectsUntrustedComment(t *testing.T) { + ghc := &fakeGitHubClient{} + s := newTestServer(ghc, &fakeNetlifyClient{}, &previewconfig.Config{}) + + if err := s.handleIssueComment(logrus.NewEntry(logrus.New()), issueCommentEvent("/rebuild-preview", "open", true)); err != nil { + t.Fatalf("handleIssueComment returned error: %v", err) + } + if len(ghc.comments) != 1 { + t.Fatalf("expected one comment, got %d", len(ghc.comments)) + } + if !strings.Contains(ghc.comments[0], "Cannot retry the Netlify deploy preview") { + t.Fatalf("unexpected comment: %s", ghc.comments[0]) + } +} + +func TestHandleIssueCommentAllowsTrustedPullRequest(t *testing.T) { + ghc := &fakeGitHubClient{labels: []github.Label{{Name: labels.OkToTest}}} + netlifyClient := &fakeNetlifyClient{deploys: []netlify.Deploy{{ + ID: "deploy-123", + Context: "deploy-preview", + State: "error", + ReviewID: 5, + DeploySSLURL: "https://deploy-preview-5.example.netlify.app", + CreatedAt: time.Now(), + }}} + cfg := &previewconfig.Config{Repos: map[string]previewconfig.Repo{"kubernetes/website": {SiteID: "site-123"}}} + s := newTestServer(ghc, netlifyClient, cfg) + + if err := s.handleIssueComment(logrus.NewEntry(logrus.New()), issueCommentEvent("/retest", "open", true)); err != nil { + t.Fatalf("handleIssueComment returned error: %v", err) + } + if !netlifyClient.retryCalled { + t.Fatal("expected Netlify retry") + } + if len(ghc.comments) != 1 || !strings.Contains(ghc.comments[0], "Retrying the latest Netlify deploy preview") { + t.Fatalf("unexpected comments: %v", ghc.comments) + } +} + +func TestHandleIssueCommentFailsClosedWhenMappingMissing(t *testing.T) { + ghc := &fakeGitHubClient{member: true} + s := newTestServer(ghc, &fakeNetlifyClient{}, &previewconfig.Config{}) + + if err := s.handleIssueComment(logrus.NewEntry(logrus.New()), issueCommentEvent("/rebuild-preview", "open", true)); err != nil { + t.Fatalf("handleIssueComment returned error: %v", err) + } + if len(ghc.comments) != 1 { + t.Fatalf("expected one comment, got %d", len(ghc.comments)) + } + if !strings.Contains(ghc.comments[0], "does not have a Netlify preview site mapping configured") { + t.Fatalf("unexpected comment: %s", ghc.comments[0]) + } +} + +func newTestServer(ghc *fakeGitHubClient, netlifyClient *fakeNetlifyClient, cfg *previewconfig.Config) *server { + return &server{ + ghc: ghc, + netlifyClient: netlifyClient, + pluginConfig: fakePluginConfigAgent{cfg: &plugins.Configuration{}}, + previewConfig: cfg, + log: logrus.NewEntry(logrus.New()), + } +} + +func issueCommentEvent(body, state string, isPR bool) github.IssueCommentEvent { + issue := github.Issue{ + Number: 5, + State: state, + User: github.User{Login: "pr-author"}, + } + if isPR { + issue.PullRequest = &struct{}{} + } + return github.IssueCommentEvent{ + Action: github.IssueCommentActionCreated, + Issue: issue, + Comment: github.IssueComment{ + Body: body, + User: github.User{Login: "commenter"}, + }, + Repo: github.Repo{ + Owner: github.User{Login: "kubernetes"}, + Name: "website", + }, + } +} + +type fakeGitHubClient struct { + member bool + collaborator bool + labels []github.Label + comments []string +} + +func (f *fakeGitHubClient) BotUserChecker() (func(candidate string) bool, error) { + return func(candidate string) bool { return candidate == "k8s-ci-robot" }, nil +} + +func (f *fakeGitHubClient) CreateComment(org, repo string, number int, comment string) error { + f.comments = append(f.comments, comment) + return nil +} + +func (f *fakeGitHubClient) GetIssueLabels(org, repo string, number int) ([]github.Label, error) { + return f.labels, nil +} + +func (f *fakeGitHubClient) IsCollaborator(owner, repo, login string) (bool, error) { + return f.collaborator, nil +} + +func (f *fakeGitHubClient) IsMember(org, user string) (bool, error) { + return f.member, nil +} + +type fakeNetlifyClient struct { + deploys []netlify.Deploy + listCalled bool + retryCalled bool +} + +func (f *fakeNetlifyClient) ListDeploys(ctx context.Context, siteID string) ([]netlify.Deploy, error) { + f.listCalled = true + return f.deploys, nil +} + +func (f *fakeNetlifyClient) RetryDeploy(ctx context.Context, deployID string) error { + f.retryCalled = true + return nil +} + +type fakePluginConfigAgent struct { + cfg *plugins.Configuration +} + +func (f fakePluginConfigAgent) Config() *plugins.Configuration { + return f.cfg +} From 0d14214ca0994e037d8a8e96313ebcd77298b31c Mon Sep 17 00:00:00 2001 From: caesarsage Date: Thu, 7 May 2026 09:17:12 +0100 Subject: [PATCH 5/6] external-plugins/netlify-preview: register image build and add plugin docs --- .prow-images.yaml | 1 + .../external-plugins/netlify-preview.md | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 site/content/en/docs/components/external-plugins/netlify-preview.md diff --git a/.prow-images.yaml b/.prow-images.yaml index 9a11255dc4..15d0a0596b 100644 --- a/.prow-images.yaml +++ b/.prow-images.yaml @@ -38,5 +38,6 @@ images: arch: all - dir: cmd/external-plugins/needs-rebase - dir: cmd/external-plugins/cherrypicker + - dir: cmd/external-plugins/netlify-preview - dir: cmd/external-plugins/refresh - dir: cmd/ghproxy diff --git a/site/content/en/docs/components/external-plugins/netlify-preview.md b/site/content/en/docs/components/external-plugins/netlify-preview.md new file mode 100644 index 0000000000..4f8a901870 --- /dev/null +++ b/site/content/en/docs/components/external-plugins/netlify-preview.md @@ -0,0 +1,62 @@ +--- +title: "netlify-preview" +weight: 20 +description: > + +--- + +netlify-preview is an external Prow plugin that retries the latest Netlify +deploy preview for a pull request in response to a chat command. It is intended +for repositories whose pull request previews are built by Netlify (for example +`kubernetes/website` and `kubernetes/contributor-site`). + +## Commands + +``` +/retest +``` + +Retries the latest Netlify deploy preview for the PR **only when that preview +is in `error` state**. If the preview is `ready`, the plugin posts a comment +explaining that `/retest` will not retry passing previews and points the user +to `/rebuild-preview`. + +``` +/rebuild-preview +``` + +Forces a retry of the latest Netlify deploy preview regardless of its current +state, with one exception: if a build is already running (`building` or +`enqueued`), the plugin declines and reports the in-progress preview rather +than triggering a redundant build. + +Both commands require the comment author to be trusted under the same rules +that Prow's `trigger` plugin applies: org members, configured trusted apps and +orgs, or PR authors who have received `/ok-to-test`. + +## Configuration + +The plugin reads a small YAML configuration file that maps each repository to +a Netlify site. Repositories that are not listed are ignored. + +```yaml +repos: + kubernetes/website: + site_id: + kubernetes/contributor-site: + site_id: +``` + +Repositories that are listed under `external_plugins:` in the Prow +configuration but missing from this file will receive a comment explaining +that no Netlify preview site is configured. + +## Required credentials + +A Netlify personal access token (or a scoped equivalent) with permission to +list site deploys and call the deploy retry endpoint. The plugin reads it from +a file specified at startup. The token never reaches core Prow components. + +## Webhook events + +The plugin only subscribes to `issue_comment` events. From 35fd83923ae5a1ad2d8f65a7bb411ae1d9e695d7 Mon Sep 17 00:00:00 2001 From: caesarsage Date: Sun, 31 May 2026 04:43:13 +0100 Subject: [PATCH 6/6] external-plugins/netlify-preview: drop year from copyright header --- cmd/external-plugins/netlify-preview/config/config.go | 2 +- cmd/external-plugins/netlify-preview/config/config_test.go | 2 +- cmd/external-plugins/netlify-preview/main.go | 2 +- cmd/external-plugins/netlify-preview/netlify/client.go | 2 +- cmd/external-plugins/netlify-preview/netlify/client_test.go | 2 +- cmd/external-plugins/netlify-preview/plugin/plugin.go | 2 +- cmd/external-plugins/netlify-preview/plugin/plugin_test.go | 2 +- cmd/external-plugins/netlify-preview/server.go | 2 +- cmd/external-plugins/netlify-preview/server_test.go | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/external-plugins/netlify-preview/config/config.go b/cmd/external-plugins/netlify-preview/config/config.go index 9d9382b264..dd767b80fb 100644 --- a/cmd/external-plugins/netlify-preview/config/config.go +++ b/cmd/external-plugins/netlify-preview/config/config.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/config/config_test.go b/cmd/external-plugins/netlify-preview/config/config_test.go index 3e5c016305..a0a06f3bf0 100644 --- a/cmd/external-plugins/netlify-preview/config/config_test.go +++ b/cmd/external-plugins/netlify-preview/config/config_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/main.go b/cmd/external-plugins/netlify-preview/main.go index c801ab6827..5502768765 100644 --- a/cmd/external-plugins/netlify-preview/main.go +++ b/cmd/external-plugins/netlify-preview/main.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/netlify/client.go b/cmd/external-plugins/netlify-preview/netlify/client.go index 616c0c1fdf..7a9844df63 100644 --- a/cmd/external-plugins/netlify-preview/netlify/client.go +++ b/cmd/external-plugins/netlify-preview/netlify/client.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/netlify/client_test.go b/cmd/external-plugins/netlify-preview/netlify/client_test.go index 8f083c86e9..d6428e0021 100644 --- a/cmd/external-plugins/netlify-preview/netlify/client_test.go +++ b/cmd/external-plugins/netlify-preview/netlify/client_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/plugin/plugin.go b/cmd/external-plugins/netlify-preview/plugin/plugin.go index 1f76021091..dd54c1732c 100644 --- a/cmd/external-plugins/netlify-preview/plugin/plugin.go +++ b/cmd/external-plugins/netlify-preview/plugin/plugin.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/plugin/plugin_test.go b/cmd/external-plugins/netlify-preview/plugin/plugin_test.go index a7810b8fba..de25584ef3 100644 --- a/cmd/external-plugins/netlify-preview/plugin/plugin_test.go +++ b/cmd/external-plugins/netlify-preview/plugin/plugin_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/server.go b/cmd/external-plugins/netlify-preview/server.go index 769ed9bdf2..99d33717f5 100644 --- a/cmd/external-plugins/netlify-preview/server.go +++ b/cmd/external-plugins/netlify-preview/server.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/cmd/external-plugins/netlify-preview/server_test.go b/cmd/external-plugins/netlify-preview/server_test.go index 98309b1f1c..1037b90432 100644 --- a/cmd/external-plugins/netlify-preview/server_test.go +++ b/cmd/external-plugins/netlify-preview/server_test.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Kubernetes Authors. +Copyright The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.