Repository: rancher/fleet

Issue:
Support Webhook Secret per GitRepo
Webhook secret is unique per cluster, and it's shared for all gitrepos and all webhooks (its name has to be **gitjob-webhook**).

This is not flexible as it forces users to use/share the same secret when configuring their `gitrepos`. 

One possibility could be to add a new field in the `gitrepo` resource (something like `webhookSecret`) and, when we receive a webhook payload and we have identified which `gitrepo` is referencing, check for the secret in the `gitrepo`

We're instantiating different 3rd party webhook handlers and we need to pass the secret.


- [x] remove the watch on secrets, process in request. Instantiation is cheap. Request is already operating on all gitrepo resources.
- [x] keep old secret as a fallback, log a warning if old secret is missing, but authentication info is in header. If possible.
- [x] error if gitrepo references a secret, but secret is missing


Discussion:
@mmartin24: 
Verified in Rancher `v2.12-71a527a9c07db8bcc2ddfe95ae3ed5bd012f4589-head` with Fleet `107.0.0+up0.13.0-alpha.3`

---

#### Check secret solely on Webhook allows normal change

- Create Github webhook with 'correct' secret on webhook (namely: `webhooksecretvalue`)
- Deploy gitrepo `https://github.com/fleetqa/webhook-test-secrets` in path `.` which contains a simple configmap
- Create webhook ingress on Rancher:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: webhook-ingress
  namespace: cattle-fleet-system
spec:
  rules:
  - host: "35.245.204.187.nip.io"
    http:
      paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: gitjob
              port:
                number: 80
```
- Change commit on girepo
- Checked it can be changed without issues

#### Check `gitjob-webhook` secret in `cattle-fleet-system` alone compared to Webhook does `NOT` update Gitrepo

Pass different secret as in gihub webhook (ie: `badwebhooksecretvalue`)

```bash 
kubectl create secret generic gitjob-webhook -n cattle-fleet-system --from-literal=github=badwebhooksecretvalue 
```

Add gitrepo (WITHOUT specific flag `webhookSecret`. Note: `disablePolling` should be on):

```yaml
apiVersion: fleet.cattle.io/v1alpha1
kind: GitRepo
metadata:
  name: test-secret
  namespace: fleet-local
spec:
  branch: main
  insecureSkipTLSVerify: false
  repo: https://github.com/fleetqa/webhook-test-secrets
  paths:
    - .
  disablePolling: true
```

Change commit on Github repo linked with Webhook enabled


Result:
Girepo not updated
Configmap does *NOT* change

Logs:

`k -n cattle-fleet-system logs gitjob-665687f7bc-zx77r | tail | grep "error"`

Output:
```json
{"level":"error","ts":"2025-05-28T10:03:44Z","msg":"Reconciler error","controller":"gitrepo","controllerGroup":"fleet.cattle.io","controllerKind":"GitRepo","GitRepo":{"name":"test-secret","namespace":"fleet-local"},"namespace":"fleet-local","name":"test-secret","reconcileID":"32b8a04d-d223-47f2-8ffe-41789a5a58aa","error":"error creating git job: jobs.batch \"test-secret-8fbb7\" already exists","stacktrace":"sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).reconcileHandler\n\t/home/runner/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.20.4/pkg/internal/controller/controller.go:347\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).processNextWorkItem\n\t/home/runner/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.20.4/pkg/internal/controller/controller.go:294\nsigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func2.2\n\t/home/runner/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.20.4/pkg/internal/controller/controller.go:255"}
{"level":"error","ts":"2025-05-28T10:34:11Z","logger":"webhook","msg":"Webhook processing failed","error":"HMAC verification failed","stacktrace":"github.com/rancher/fleet/pkg/webhook.(*Webhook).logAndReturn\n\t/home/runner/work/fleet/fleet/pkg/webhook/webhook.go:200\ngithub.com/rancher/fleet/pkg/webhook.(*Webhook).ServeHTTP\n\t/home/runner/work/fleet/fleet/pkg/webhook/webhook.go:150\ngithub.com/gorilla/mux.(*Router).ServeHTTP\n\t/home/runner/go/pkg/mod/github.com/gorilla/mux@v1.8.1/mux.go:212\nnet/http.serverHandler.ServeHTTP\n\t/home/runner/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.3.linux-amd64/src/net/http/server.go:3301\nnet/http.(*conn).serve\n\t/home/runner/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.3.linux-amd64/src/net/http/server.go:2102"}
```



#### Check `wrong` secret in Gitrepo only compared to Webhook does not update Gitrepo
Remove previously created secret

```bash
k delete secrets -n cattle-fleet-system gitjob-webhook 
```

Add flag to gitrepo `webhookSecret: wrong` 
```yaml
apiVersion: fleet.cattle.io/v1alpha1
kind: GitRepo
metadata:
  name: test-secret
  namespace: fleet-local
spec:
  branch: main
  insecureSkipTLSVerify: false
  repo: https://github.com/fleetqa/webhook-test-secrets
  paths:
    - .
  disablePolling: true
  webhookSecret: wrong

```

Result: Gitrepo not updated.
Configmap does *NOT* change

Output on Gitjob:
```json
{"level":"error","ts":"2025-05-28T11:26:26Z","logger":"webhook","msg":"Webhook processing failed","error":"secret \"wrong\" in namespace \"fleet-local\" does not exist","stacktrace":"github.com/rancher/fleet/pkg/webhook.(*Webhook).logAndReturn\n\t/home/runner/work/fleet/fleet/pkg/webhook/webhook.go:200\ngithub.com/rancher/fleet/pkg/webhook.(*Webhook).ServeHTTP\n\t/home/runner/work/fleet/fleet/pkg/webhook/webhook.go:137\ngithub.com/gorilla/mux.(*Router).ServeHTTP\n\t/home/runner/go/pkg/mod/github.com/gorilla/mux@v1.8.1/mux.go:212\nnet/http.serverHandler.ServeHTTP\n\t/home/runner/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.3.linux-amd64/src/net/http/server.go:3301\nnet/http.(*conn).serve\n\t/home/runner/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.24.3.linux-amd64/src/net/http/server.go:2102"}
```

Output on Webhook: `500` error


![Image](https://github.com/user-attachments/assets/896c2143-9eef-49ab-818c-a5369505ee2c)

Solution patch:
diff --git a/.github/workflows/check-changes.yml b/.github/workflows/check-changes.yml
index 12d84bfd..d3c152ad 100644
--- a/.github/workflows/check-changes.yml
+++ b/.github/workflows/check-changes.yml
@@ -14,11 +14,11 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed67549a..9b49e754 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,20 +18,18 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
 
       -
         name: Set up chart-testing
-        uses: helm/chart-testing-action@v2.3.1
-        with:
-          version: v3.3.0
+        uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
       -
         name: Run chart-testing (lint)
         run: ct lint --all --validate-maintainers=false charts/
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
diff --git a/.github/workflows/e2e-ci.yml b/.github/workflows/e2e-ci.yml
index c9c0d44c..21e5f821 100644
--- a/.github/workflows/e2e-ci.yml
+++ b/.github/workflows/e2e-ci.yml
@@ -35,11 +35,11 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -160,7 +160,7 @@ jobs:
           ginkgo --github-output e2e/require-secrets
       -
         name: Upload Logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-e2e-logs-${{ github.sha }}-${{ matrix.k3s.version }}-${{ matrix.test_type.name }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-fleet-upgrade-ci.yml b/.github/workflows/e2e-fleet-upgrade-ci.yml
index 7b3b5ae8..c7f4feb1 100644
--- a/.github/workflows/e2e-fleet-upgrade-ci.yml
+++ b/.github/workflows/e2e-fleet-upgrade-ci.yml
@@ -24,11 +24,11 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -85,7 +85,7 @@ jobs:
           ginkgo --github-output --label-filter="!multi-cluster" e2e/installation
       -
         name: Upload Logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-e2e-logs-${{ github.sha }}-${{ matrix.k3s.version }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-multicluster-ci.yml b/.github/workflows/e2e-multicluster-ci.yml
index dd020d47..c19fca18 100644
--- a/.github/workflows/e2e-multicluster-ci.yml
+++ b/.github/workflows/e2e-multicluster-ci.yml
@@ -20,11 +20,11 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -216,7 +216,7 @@ jobs:
           crust-gather collect --exclude-namespace=kube-system --exclude-kind=Lease --duration=5s -f tmp/downstream
       -
         name: Upload Logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-mc-e2e-logs-${{ github.sha }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-nightly-ci.yml b/.github/workflows/e2e-nightly-ci.yml
index 257e9f28..2eafe3f1 100644
--- a/.github/workflows/e2e-nightly-ci.yml
+++ b/.github/workflows/e2e-nightly-ci.yml
@@ -28,11 +28,11 @@ jobs:
           - e2e-nosecrets
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -144,7 +144,7 @@ jobs:
           ginkgo --github-output e2e/require-secrets
       -
         name: Upload Logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-nightly-e2e-logs-${{ github.sha }}-${{ matrix.k3s_version }}-${{ matrix.test_type }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml b/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
index 768a5b0b..9ee99778 100644
--- a/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
+++ b/.github/workflows/e2e-rancher-upgrade-fleet-to-head-ci.yml
@@ -28,12 +28,12 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           ref: ${{ github.event.inputs.ref }}
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -44,7 +44,7 @@ jobs:
         name: Install crust-gather CLI
         run: curl -sSfL https://github.com/crust-gather/crust-gather/raw/main/install.sh | sh -s -- --yes
       -
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
         id: rancher-cli-cache
         with:
           path: /home/runner/.local/bin
@@ -64,23 +64,23 @@ jobs:
           ./.github/scripts/build-fleet-binaries.sh
       -
         name: Set up QEMU
-        uses: docker/setup-qemu-action@v3
+        uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
       -
         name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
+        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
       -
         name: Get uuid
         id: uuid
         run: echo "::set-output name=uuid::$(uuidgen)"
       -
         id: meta-fleet
-        uses: docker/metadata-action@v5
+        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
         with:
           images: |
             ttl.sh/rancher/fleet-${{ steps.uuid.outputs.uuid }}
           tags: type=raw,value=1h
       -
-        uses: docker/build-push-action@v6
+        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
         with:
           context: .
           file: package/Dockerfile
@@ -91,13 +91,13 @@ jobs:
           labels: ${{ steps.meta-fleet.outputs.labels }}
       -
         id: meta-fleet-agent
-        uses: docker/metadata-action@v5
+        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
         with:
           images: |
             ttl.sh/rancher/fleet-agent-${{ steps.uuid.outputs.uuid }}
           tags: type=raw,value=1h
       -
-        uses: docker/build-push-action@v6
+        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
         with:
           context: .
           file: package/Dockerfile.agent
@@ -181,7 +181,7 @@ jobs:
           crust-gather collect --exclude-namespace=kube-system --exclude-kind=Lease --duration=5s -f tmp/downstream
       -
         name: Upload logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-rancher-logs-${{ github.sha }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-rancher-upgrade-fleet.yml b/.github/workflows/e2e-rancher-upgrade-fleet.yml
index 1e73d6e6..179e2803 100644
--- a/.github/workflows/e2e-rancher-upgrade-fleet.yml
+++ b/.github/workflows/e2e-rancher-upgrade-fleet.yml
@@ -46,12 +46,12 @@ jobs:
 
     steps:
       -
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           ref: ${{ github.event.inputs.ref }}
       -
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -62,7 +62,7 @@ jobs:
         name: Install crust-gather CLI
         run: curl -sSfL https://github.com/crust-gather/crust-gather/raw/main/install.sh | sh -s -- --yes
       -
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
         id: rancher-cli-cache
         with:
           path: /home/runner/.local/bin
@@ -178,7 +178,7 @@ jobs:
           crust-gather collect --exclude-namespace=kube-system --exclude-kind=Lease --duration=5s -f tmp/downstream
       -
         name: Upload logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-upgrade-rancher-logs-${{ github.event.inputs.rancher_version }}-${{ github.event.inputs.k3s_version }}-${{ github.sha }}-${{ github.run_id }}
diff --git a/.github/workflows/e2e-test-fleet-in-rancher.yml b/.github/workflows/e2e-test-fleet-in-rancher.yml
index 8936e2fa..9e672e1d 100644
--- a/.github/workflows/e2e-test-fleet-in-rancher.yml
+++ b/.github/workflows/e2e-test-fleet-in-rancher.yml
@@ -51,13 +51,13 @@ jobs:
 
       -
         name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
 
       -
         name: Install Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -72,7 +72,7 @@ jobs:
 
       -
         name: Set up build cache
-        uses: actions/cache@v4
+        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
         id: rancher-cli-cache
         with:
           path: ~/.local/bin
@@ -222,7 +222,7 @@ jobs:
 
       -
         name: Upload logs
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         if: failure()
         with:
           name: gha-fleet-rancher-logs-${{ github.sha }}-${{ github.run_id }}
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 35548d99..8ca275f1 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -12,12 +12,12 @@ jobs:
 
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           submodules: recursive
 
       - name: Setup Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -28,10 +28,10 @@ jobs:
           export PATH=$PATH:/home/runner/go/bin/
 
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@v6.5.0
+        uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
         with:
           # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version.
-          version: v1.64.5
+          version: v2.0.2
 
           args: --timeout=10m --config=.golangci.json
 
diff --git a/.github/workflows/release-against-charts.yml b/.github/workflows/release-against-charts.yml
index 9ceb7124..47cbeb19 100644
--- a/.github/workflows/release-against-charts.yml
+++ b/.github/workflows/release-against-charts.yml
@@ -32,20 +32,20 @@ jobs:
     runs-on: runs-on,runner=4cpu-linux-x64,run-id=${{ github.run_id }}
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           path: fleet
       - name: Checkout rancher/charts
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           repository: rancher/charts
           ref: ${{github.event.inputs.charts_ref}}
           path: charts
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
-          go-version: '1.22.*'
+          go-version: '1.24.*'
       - name: Install dependencies
         run: |
           sudo apt-get update
diff --git a/.github/workflows/release-against-rancher.yml b/.github/workflows/release-against-rancher.yml
index 680cea25..f236dd60 100644
--- a/.github/workflows/release-against-rancher.yml
+++ b/.github/workflows/release-against-rancher.yml
@@ -33,18 +33,18 @@ jobs:
     runs-on: runs-on,runner=4cpu-linux-x64,run-id=${{ github.run_id }}
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           path: fleet
       - name: Checkout rancher/rancher
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           repository: rancher/rancher
           ref: ${{github.event.inputs.rancher_ref}}
           path: rancher
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version: ${{ env.SETUP_GO_VERSION }}
       - name: Run release script
diff --git a/.github/workflows/release-against-test-charts.yml b/.github/workflows/release-against-test-charts.yml
index ca3d0bc4..4c5a72d6 100644
--- a/.github/workflows/release-against-test-charts.yml
+++ b/.github/workflows/release-against-test-charts.yml
@@ -43,12 +43,12 @@ jobs:
           echo "charts_repo=$charts_repo" >> "$GITHUB_OUTPUT"
 
       - name: Checkout rancher/fleet
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           path: fleet
 
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'fleet/go.mod'
           check-latest: true
@@ -65,14 +65,14 @@ jobs:
 
       - name: Extract metadata for Fleet controller image
         id: meta-fleet
-        uses: docker/metadata-action@v5
+        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
         with:
           images: |
             ttl.sh/rancher-fleet-${{ steps.uuid.outputs.uuid }}
           tags: type=raw,value=1h
 
       - name: Build and push Fleet controller image
-        uses: docker/build-push-action@v6
+        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
         with:
           context: ./fleet
           file: ./fleet/package/Dockerfile
@@ -84,14 +84,14 @@ jobs:
 
       - name: Extract metadata for Fleet agent image
         id: meta-fleet-agent
-        uses: docker/metadata-action@v5
+        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
         with:
           images: |
             ttl.sh/rancher-fleet-agent-${{ steps.uuid.outputs.uuid }}
           tags: type=raw,value=1h
 
       - name: Build and push Fleet agent image
-        uses: docker/build-push-action@v6
+        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
         with:
           context: ./fleet
           file: ./fleet/package/Dockerfile.agent
@@ -102,7 +102,7 @@ jobs:
           labels: ${{ steps.meta-fleet-agent.outputs.labels }}
 
       - name: Checkout test charts repo
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
           token: ${{secrets.CI_PUSH_TO_FLEETREPOCI}}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f89c1fc6..fae3f34d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,12 +26,12 @@ jobs:
     if: github.repository == 'rancher/fleet'
     steps:
       - name: Check out Fleet
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
         with:
           fetch-depth: 0
 
       - name: Set up Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5
         with:
           go-version-file: 'go.mod'
           check-latest: true
@@ -43,12 +43,12 @@ jobs:
             secret/data/github/repo/${{ github.repository }}/fossa/credential token | FOSSA_API_KEY
 
       - name: Run FOSSA scan
-        uses: fossas/fossa-action@v1.5.0
+        uses: fossas/fossa-action@c0a7d013f84c8ee5e910593186598625513cc1e4 # v1.6.0
         with:
           api-key: ${{ env.FOSSA_API_KEY }}
 
       - name: Run FOSSA tests
-        uses: fossas/fossa-action@v1.5.0
+        uses: fossas/fossa-action@c0a7d013f84c8ee5e910593186598625513cc1e4 # v1.6.0
         with:
           api-key: ${{ env.FOSSA_API_KEY }}
           run-tests: false
@@ -74,14 +74,14 @@ jobs:
         run: ./.github/scripts/run-integration-tests.sh
 
       - name: Set up QEMU
-        uses: docker/setup-qemu-action@v3
+        uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
 
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
+        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
 
       - name: Install Cosign
-        uses: sigstore/cosign-installer@v3.8.1
-      - uses: rancherlabs/slsactl/actions/install-slsactl@v0.0.10
+        uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1
+      - uses: rancherlabs/slsactl/actions/install-slsactl@0d7a982a403721b4bef1737b80eddf91676c9dbb # v0.0.12
 
       - name: "Read Vault Secrets"
         uses: rancher-eio/read-vault-secrets@main
@@ -101,21 +101,21 @@ jobs:
           sudo systemctl restart docker
 
       - name: Log into Docker Container registry
-        uses: docker/login-action@v3
+        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
         if: ${{ env.IS_HOTFIX == 'false' }}
         with:
           username: ${{ env.DOCKER_USERNAME }}
           password: ${{ env.DOCKER_PASSWORD }}
 
       - name: Log into Prime registry
-        uses: docker/login-action@v3
+        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
         with:
           username: ${{ env.PRIME_REGISTRY_USERNAME }}
           password: ${{ env.PRIME_REGISTRY_PASSWORD }}
           registry: ${{ env.PRIME_REGISTRY }}
 
       - name: Run GoReleaser
-        uses: goreleaser/goreleaser-action@v6
+        uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6
         id: goreleaser
         with:
           distribution: goreleaser
diff --git a/.github/workflows/typos.yaml b/.github/workflows/typos.yaml
index d6c54093..cf8b3532 100644
--- a/.github/workflows/typos.yaml
+++ b/.github/workflows/typos.yaml
@@ -8,7 +8,7 @@ jobs:
 
     steps:
     - name: Checkout Repository
-      uses: actions/checkout@v4
+      uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
 
     - name: Check spelling of file.txt
       uses: crate-ci/typos@master
diff --git a/.github/workflows/updatecli.yml b/.github/workflows/updatecli.yml
index 15ff4683..38280099 100644
--- a/.github/workflows/updatecli.yml
+++ b/.github/workflows/updatecli.yml
@@ -21,7 +21,7 @@ jobs:
     if: github.ref == 'refs/heads/main'
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
 
       - name: Install Updatecli in the runner
         uses: updatecli/updatecli-action@v2
diff --git a/.golangci.json b/.golangci.json
index 9ebbbb28..62441eea 100644
--- a/.golangci.json
+++ b/.golangci.json
@@ -1,98 +1,81 @@
 {
-  "run": {
-    "timeout": "20s"
+  "formatters": {
+    "enable": ["goimports"]
   },
-  "linters-settings": {
-    "revive": {
+  "linters": {
+    "exclusions": {
+      "generated": "lax",
+      "presets": [
+        "comments",
+        "common-false-positives",
+        "legacy",
+        "std-error-handling"
+      ],
       "rules": [
         {
-          "name": "blank-imports",
-          "severity": "warning"
+          "path": "cmd/fleetcontroller/main.go",
+          "text": "G108: Profiling endpoint is automatically exposed on /debug/pprof"
+        },
+        {
+          "path": "cmd/fleetagent/main.go",
+          "text": "G108: Profiling endpoint is automatically exposed on /debug/pprof"
+        },
+        {
+          "path": "internal/cmd/agent/register/register.go",
+          "text": "G107: Potential HTTP request made with variable url"
+        },
+        {
+          "path": "internal/cmd",
+          "text": "SA1019: workqueue.RateLimiter is deprecated"
+        },
+        {
+          "path": "e2e",
+          "text": "ST1001: should not use dot imports"
+        },
+        {
+          "path": "benchmarks",
+          "text": "ST1001: should not use dot imports"
+        },
+        {
+          "linters": ["revive"],
+          "path": "internal/cmd/agent/deployer/internal/"
+        },
+        {
+          "linters": ["dupl", "funlen", "gocyclo", "gosec"],
+          "path": "_test.go"
+        },
+        {
+          "linters": ["gosec"],
+          "path": "e2e"
+        },
+        {
+          "linters": ["gosec"],
+          "path": "integrationtests"
         },
         {
-          "name": "unexported-return",
-          "severity": "warning"
+          "linters": ["gosec"],
+          "path": "benchmarks"
         }
       ]
     },
-    "funlen": {
-      "lines": 270,
-      "statements": 110
-    }
-  },
-  "linters": {
-    "disable-all": true,
-    "enable": [
-      "misspell",
-      "govet",
-      "staticcheck",
-      "errcheck",
-      "unparam",
-      "ineffassign",
-      "nakedret",
-      "gocyclo",
-      "dupl",
-      "goimports",
-      "revive",
-      "gosec",
-      "gosimple",
-      "typecheck",
-      "unused",
-      "funlen",
-      "ginkgolinter"
-    ]
-  },
-  "issues": {
-    "exclude-rules": [
-      {
-        "path": "cmd/fleetcontroller/main.go",
-        "text": "G108: Profiling endpoint is automatically exposed on /debug/pprof"
-      },
-      {
-        "path": "cmd/fleetagent/main.go",
-        "text": "G108: Profiling endpoint is automatically exposed on /debug/pprof"
-      },
-      {
-        "path": "internal/cmd/agent/register/register.go",
-        "text": "G107: Potential HTTP request made with variable url"
-      },
-      {
-        "path": "internal/cmd",
-        "text": "SA1019: workqueue.RateLimiter is deprecated"
-      },
-      {
-        "path": "internal/cmd/agent/deployer/internal/",
-        "linters": [
-          "revive"
-        ]
-      },
-      {
-        "path": "_test.go",
-        "linters": [
-          "gocyclo",
-          "dupl",
-          "funlen",
-          "gosec"
-        ]
-      },
-      {
-        "path": "e2e",
-        "linters": [
-          "gosec"
-        ]
+    "settings": {
+      "funlen": {
+        "lines": 270,
+        "statements": 110
       },
-      {
-        "path": "integrationtests",
-        "linters": [
-          "gosec"
-        ]
-      },
-      {
-        "path": "benchmarks",
-        "linters": [
-          "gosec"
+      "revive": {
+        "rules": [
+          {
+            "name": "blank-imports",
+            "severity": "warning"
+          },
+          {
+            "name": "unexported-return",
+            "severity": "warning"
+          }
         ]
       }
-    ]
-  }
+    }
+  },
+  "version": "2"
 }
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index bb0a6598..10beaf5c 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: bundledeployments.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -1128,7 +1128,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: bundlenamespacemappings.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -1308,7 +1308,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: bundles.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -3266,7 +3266,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: clustergroups.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -3693,7 +3693,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: clusterregistrations.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -3797,7 +3797,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: clusterregistrationtokens.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -3876,7 +3876,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: clusters.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -6086,7 +6086,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: contents.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -6154,7 +6154,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: gitreporestrictions.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -6260,7 +6260,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: gitrepos.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -6625,6 +6625,10 @@ spec:
                         type: string
                     type: object
                   type: array
+                webhookSecret:
+                  description: WebhookSecret contains the name of the secret to use
+                    for webhook parsing
+                  type: string
               type: object
             status:
               properties:
@@ -7110,7 +7114,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: helmapps.fleet.cattle.io
 spec:
   group: fleet.cattle.io
@@ -9001,7 +9005,7 @@ apiVersion: apiextensions.k8s.io/v1
 kind: CustomResourceDefinition
 metadata:
   annotations:
-    controller-gen.kubebuilder.io/version: v0.17.2
+    controller-gen.kubebuilder.io/version: v0.17.3
   name: imagescans.fleet.cattle.io
 spec:
   group: fleet.cattle.io
diff --git a/cmd/docs/generate-cli-docs.go b/cmd/docs/generate-cli-docs.go
index 9a2576c9..079429f3 100644
--- a/cmd/docs/generate-cli-docs.go
+++ b/cmd/docs/generate-cli-docs.go
@@ -101,7 +101,7 @@ func createMarkdownFile(cmd *cobra.Command, dir string) error {
 		return nil
 	}
 
-	basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md"
+	basename := strings.ReplaceAll(cmd.CommandPath(), " ", "_") + ".md"
 	filename := filepath.Join(dir, basename)
 
 	f, err := os.Create(filename)
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml
deleted file mode 100644
index 7ba744f9..00000000
--- a/e2e/assets/helm/repo/with-release-name/config-chart/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: config-chart
-description: A test chart that verifies its config
-type: application
-version: 0.1.0
-appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml
deleted file mode 100644
index 340a61bf..00000000
--- a/e2e/assets/helm/repo/with-release-name/config-chart/templates/configmap.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: test-simple-chart-config
-data:
-  test: "value123"
-  name: {{ .Values.name }}
diff --git a/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml b/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml
deleted file mode 100644
index 3266b4da..00000000
--- a/e2e/assets/helm/repo/with-release-name/config-chart/values.yaml
+++ /dev/null
@@ -1 +0,0 @@
-name: default-name
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml
deleted file mode 100644
index b6f09293..00000000
--- a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: crds-chart
-description: A test chart that only contains CRDs
-type: application
-version: 0.1.0
-appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml
deleted file mode 100644
index 4c87bb28..00000000
--- a/e2e/assets/helm/repo/with-release-name/crds-only/crds-chart/templates/crds.yaml
+++ /dev/null
@@ -1,41 +0,0 @@
-apiVersion: apiextensions.k8s.io/v1
-kind: CustomResourceDefinition
-metadata:
-  annotations:
-    controller-gen.kubebuilder.io/version: v0.15.0
-  name: foobars.crd.test
-spec:
-  group: crd.test
-  names:
-    kind: Foobar
-    listKind: FoobarList
-    plural: foobars
-    singular: foobar
-  scope: Namespaced
-  versions:
-    - name: v1alpha1
-      schema:
-        openAPIV3Schema:
-          description: 'Foobar is a dummy resource.'
-          properties:
-            apiVersion:
-              description: 'APIVersion is always necessary.'
-              type: string
-            foo:
-              description: 'Foo is important.'
-              nullable: true
-              type: string
-            bar:
-              description: 'Bar does not walk into a Foo. A Foo walks into a bar.'
-              nullable: true
-              type: string
-            kind:
-              description: 'Kindness is everything.'
-              type: string
-            metadata:
-              type: object
-          type: object
-      served: true
-      storage: true
-      subresources:
-        status: {}
diff --git a/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml b/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml
deleted file mode 100644
index 7de353d4..00000000
--- a/e2e/assets/helm/repo/with-release-name/crds-only/fleet.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-helm:
-  chart: crds-chart
-  values:
-    name: example-value
-  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/fleet.yaml b/e2e/assets/helm/repo/with-release-name/fleet.yaml
deleted file mode 100644
index b1464bd8..00000000
--- a/e2e/assets/helm/repo/with-release-name/fleet.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-helm:
-  chart: config-chart
-  values:
-    name: example-value
-  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml
deleted file mode 100644
index 7ba744f9..00000000
--- a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/Chart.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-apiVersion: v2
-name: config-chart
-description: A test chart that verifies its config
-type: application
-version: 0.1.0
-appVersion: "1.16.0"
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml
deleted file mode 100644
index 340a61bf..00000000
--- a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/templates/configmap.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
-  name: test-simple-chart-config
-data:
-  test: "value123"
-  name: {{ .Values.name }}
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml
deleted file mode 100644
index 3266b4da..00000000
--- a/e2e/assets/helm/repo/with-release-name/no-crds/config-chart/values.yaml
+++ /dev/null
@@ -1 +0,0 @@
-name: default-name
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml
deleted file mode 100644
index b1464bd8..00000000
--- a/e2e/assets/helm/repo/with-release-name/no-crds/fleet.yaml
+++ /dev/null
@@ -1,5 +0,0 @@
-helm:
-  chart: config-chart
-  values:
-    name: example-value
-  releaseName: test-config-release
diff --git a/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml b/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml
deleted file mode 100644
index b0a9f2b3..00000000
--- a/e2e/assets/helm/repo/with-release-name/no-crds/values.yaml
+++ /dev/null
@@ -1 +0,0 @@
-url: global.fleet.clusterLabels.name
diff --git a/e2e/assets/helm/repo/with-release-name/values.yaml b/e2e/assets/helm/repo/with-release-name/values.yaml
deleted file mode 100644
index b0a9f2b3..00000000
--- a/e2e/assets/helm/repo/with-release-name/values.yaml
+++ /dev/null
@@ -1 +0,0 @@
-url: global.fleet.clusterLabels.name
diff --git a/e2e/assets/single-cluster/release-cleanup/bundle-crds.yaml b/e2e/assets/single-cluster/release-cleanup/bundle-crds.yaml
new file mode 100644
index 00000000..605febc1
--- /dev/null
+++ b/e2e/assets/single-cluster/release-cleanup/bundle-crds.yaml
@@ -0,0 +1,54 @@
+kind: Bundle
+apiVersion: fleet.cattle.io/v1alpha1
+metadata:
+  name: release-name-update-crds
+spec:
+  helm:
+    releaseName: {{.ReleaseName}}
+  resources:
+  - content: |
+      apiVersion: apiextensions.k8s.io/v1
+      kind: CustomResourceDefinition
+      metadata:
+        annotations:
+          controller-gen.kubebuilder.io/version: v0.15.0
+        name: foobars.crd.test
+      spec:
+        group: crd.test
+        names:
+          kind: Foobar
+          listKind: FoobarList
+          plural: foobars
+          singular: foobar
+        scope: Namespaced
+        versions:
+          - name: v1alpha1
+            schema:
+              openAPIV3Schema:
+                description: 'Foobar is a dummy resource.'
+                properties:
+                  apiVersion:
+                    description: 'APIVersion is always necessary.'
+                    type: string
+                  foo:
+                    description: 'Foo is important.'
+                    nullable: true
+                    type: string
+                  bar:
+                    description: 'Bar does not walk into a Foo. A Foo walks into a bar.'
+                    nullable: true
+                    type: string
+                  kind:
+                    description: 'Kindness is everything.'
+                    type: string
+                  metadata:
+                    type: object
+                type: object
+            served: true
+            storage: true
+            subresources:
+              status: {}
+  targets:
+  - clusterGroup: default
+    ignore: {}
+    name: default
diff --git a/e2e/assets/single-cluster/release-cleanup/bundle-namespace-update.yaml b/e2e/assets/single-cluster/release-cleanup/bundle-namespace-update.yaml
new file mode 100644
index 00000000..203c77fc
--- /dev/null
+++ b/e2e/assets/single-cluster/release-cleanup/bundle-namespace-update.yaml
@@ -0,0 +1,18 @@
+kind: Bundle
+apiVersion: fleet.cattle.io/v1alpha1
+metadata:
+  name: namespace-update
+spec:
+  namespace: {{.TargetNamespace}}
+  resources:
+  - content: |
+      apiVersion: v1
+      kind: ConfigMap
+      metadata:
+        name: app-config
+      data:
+        test: "value"
+  targets:
+  - clusterGroup: default
+    ignore: {}
+    name: default
diff --git a/e2e/assets/single-cluster/release-cleanup/bundle-release-name-update.yaml b/e2e/assets/single-cluster/release-cleanup/bundle-release-name-update.yaml
new file mode 100644
index 00000000..b3525fd3
--- /dev/null
+++ b/e2e/assets/single-cluster/release-cleanup/bundle-release-name-update.yaml
@@ -0,0 +1,19 @@
+kind: Bundle
+apiVersion: fleet.cattle.io/v1alpha1
+metadata:
+  name: release-name-update
+spec:
+  helm:
+    releaseName: {{.ReleaseName}}
+  resources:
+  - content: |
+      apiVersion: v1
+      kind: ConfigMap
+      metadata:
+        name: app-config
+      data:
+        test: "value"
+  targets:
+  - clusterGroup: default
+    ignore: {}
+    name: default
diff --git a/go.mod b/go.mod
index b247eb92..b7358d0d 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet
 
 go 1.23.0
 
-toolchain go1.23.6
+toolchain go1.24.2
 
 replace (
 	github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
@@ -15,7 +15,7 @@ require (
 	github.com/chartmuseum/helm-push v0.10.4
 	github.com/cheggaaa/pb v1.0.29
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
-	github.com/docker/docker v28.0.2+incompatible
+	github.com/docker/docker v28.0.4+incompatible
 	github.com/evanphx/json-patch v5.9.11+incompatible
 	github.com/go-git/go-billy/v5 v5.6.2
 	github.com/go-git/go-git/v5 v5.14.0
@@ -29,7 +29,7 @@ require (
 	github.com/hashicorp/go-getter v1.7.8
 	github.com/jpillora/backoff v1.0.0
 	github.com/onsi/ginkgo/v2 v2.23.3
-	github.com/onsi/gomega v1.36.3
+	github.com/onsi/gomega v1.37.0
 	github.com/opencontainers/go-digest v1.0.0
 	github.com/opencontainers/image-spec v1.1.1
 	github.com/otiai10/copy v1.14.1
@@ -37,14 +37,14 @@ require (
 	github.com/prometheus/client_golang v1.21.1
 	github.com/prometheus/client_model v0.6.1
 	github.com/prometheus/common v0.63.0
-	github.com/rancher/fleet/pkg/apis v0.11.3
+	github.com/rancher/fleet/pkg/apis v0.12.0
 	github.com/rancher/lasso v0.2.1
 	github.com/rancher/wrangler/v3 v3.2.0
 	github.com/reugn/go-quartz v0.13.0
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/cobra v1.9.1
 	github.com/stretchr/testify v1.10.0
-	github.com/testcontainers/testcontainers-go v0.35.0
+	github.com/testcontainers/testcontainers-go v0.36.0
 	go.uber.org/mock v0.5.0
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.36.0
@@ -66,10 +66,10 @@ require (
 	k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e
 	oras.land/oras-go/v2 v2.5.0
 	sigs.k8s.io/cli-utils v0.37.2
-	sigs.k8s.io/controller-runtime v0.20.3
-	sigs.k8s.io/controller-tools v0.17.2
-	sigs.k8s.io/kustomize/api v0.18.0
-	sigs.k8s.io/kustomize/kyaml v0.18.1
+	sigs.k8s.io/controller-runtime v0.20.4
+	sigs.k8s.io/controller-tools v0.17.3
+	sigs.k8s.io/kustomize/api v0.19.0
+	sigs.k8s.io/kustomize/kyaml v0.19.0
 	sigs.k8s.io/yaml v1.4.0
 )
 
@@ -111,6 +111,7 @@ require (
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
+	github.com/ebitengine/purego v0.8.2 // indirect
 	github.com/emicklei/go-restful/v3 v3.12.1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/evanphx/json-patch/v5 v5.9.11 // indirect
@@ -166,7 +167,7 @@ require (
 	github.com/lib/pq v1.10.9 // indirect
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
 	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
-	github.com/magiconair/properties v1.8.7 // indirect
+	github.com/magiconair/properties v1.8.9 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
@@ -200,8 +201,7 @@ require (
 	github.com/rubenv/sql-migrate v1.7.1 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
-	github.com/shirou/gopsutil/v3 v3.23.12 // indirect
-	github.com/shoenig/go-m1cpu v0.1.6 // indirect
+	github.com/shirou/gopsutil/v4 v4.25.1 // indirect
 	github.com/shopspring/decimal v1.4.0 // indirect
 	github.com/skeema/knownhosts v1.3.1 // indirect
 	github.com/spf13/cast v1.7.0 // indirect
@@ -216,14 +216,14 @@ require (
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 	github.com/xlab/treeprint v1.2.0 // indirect
-	github.com/yusufpapurcu/wmi v1.2.3 // indirect
+	github.com/yusufpapurcu/wmi v1.2.4 // indirect
 	go.opencensus.io v0.24.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
-	go.opentelemetry.io/otel v1.33.0 // indirect
-	go.opentelemetry.io/otel/metric v1.33.0 // indirect
-	go.opentelemetry.io/otel/trace v1.33.0 // indirect
+	go.opentelemetry.io/otel v1.35.0 // indirect
+	go.opentelemetry.io/otel/metric v1.35.0 // indirect
+	go.opentelemetry.io/otel/trace v1.35.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/mod v0.23.0 // indirect
 	golang.org/x/net v0.37.0 // indirect
diff --git a/go.sum b/go.sum
index 0faec2fd..d7f09f29 100644
--- a/go.sum
+++ b/go.sum
@@ -746,8 +746,8 @@ github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvD
 github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v28.0.2+incompatible h1:9BILleFwug5FSSqWBgVevgL3ewDJfWWWyZVqlDMttE8=
-github.com/docker/docker v28.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
+github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -762,6 +762,8 @@ github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arX
 github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
+github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
 github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU=
@@ -1098,8 +1100,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2
 github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
 github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
 github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
+github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -1173,8 +1175,8 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
 github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
 github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
-github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
-github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
+github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -1251,12 +1253,8 @@ github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfF
 github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
-github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
-github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
-github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
-github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
-github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
-github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
+github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
+github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
 github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
 github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -1292,11 +1290,10 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo=
-github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4=
+github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
+github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
 github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
 github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
 github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -1325,8 +1322,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
-github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
 github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE=
@@ -1350,18 +1347,18 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
-go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
-go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
+go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
+go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
-go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
-go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
+go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
+go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
 go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
 go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
-go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
-go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
+go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
 go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
@@ -1663,7 +1660,6 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -2194,16 +2190,16 @@ rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
 sigs.k8s.io/cli-utils v0.37.2 h1:GOfKw5RV2HDQZDJlru5KkfLO1tbxqMoyn1IYUxqBpNg=
 sigs.k8s.io/cli-utils v0.37.2/go.mod h1:V+IZZr4UoGj7gMJXklWBg6t5xbdThFBcpj4MrZuCYco=
-sigs.k8s.io/controller-runtime v0.20.3 h1:I6Ln8JfQjHH7JbtCD2HCYHoIzajoRxPNuvhvcDbZgkI=
-sigs.k8s.io/controller-runtime v0.20.3/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY=
-sigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg=
-sigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0=
+sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU=
+sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY=
+sigs.k8s.io/controller-tools v0.17.3 h1:lwFPLicpBKLgIepah+c8ikRBubFW5kOQyT88r3EwfNw=
+sigs.k8s.io/controller-tools v0.17.3/go.mod h1:1ii+oXcYZkxcBXzwv3YZBlzjt1fvkrCGjVF73blosJI=
 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
-sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo=
-sigs.k8s.io/kustomize/api v0.18.0/go.mod h1:f8isXnX+8b+SGLHQ6yO4JG1rdkZlvhaCf/uZbLVMb0U=
-sigs.k8s.io/kustomize/kyaml v0.18.1 h1:WvBo56Wzw3fjS+7vBjN6TeivvpbW9GmRaWZ9CIVmt4E=
-sigs.k8s.io/kustomize/kyaml v0.18.1/go.mod h1:C3L2BFVU1jgcddNBE1TxuVLgS46TjObMwW5FT9FcjYo=
+sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ=
+sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o=
+sigs.k8s.io/kustomize/kyaml v0.19.0 h1:RFge5qsO1uHhwJsu3ipV7RNolC7Uozc0jUBC/61XSlA=
+sigs.k8s.io/kustomize/kyaml v0.19.0/go.mod h1:FeKD5jEOH+FbZPpqUghBP8mrLjJ3+zD3/rf9NNu1cwY=
 sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
 sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
 sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
diff --git a/internal/bundlereader/loaddirectory.go b/internal/bundlereader/loaddirectory.go
index a90773e2..f30a071d 100644
--- a/internal/bundlereader/loaddirectory.go
+++ b/internal/bundlereader/loaddirectory.go
@@ -344,7 +344,7 @@ func GetContent(ctx context.Context, base, source, version string, auth Auth, di
 
 // downloadOCIChart uses Helm to download charts from OCI based registries
 func downloadOCIChart(name, version, path string, auth Auth) (string, error) {
-	var requiresLogin bool = auth.Username != "" && auth.Password != ""
+	var requiresLogin = auth.Username != "" && auth.Password != ""
 
 	url, err := url.Parse(name)
 	if err != nil {
diff --git a/internal/bundlereader/read.go b/internal/bundlereader/read.go
index 9e255642..faeb6ee0 100644
--- a/internal/bundlereader/read.go
+++ b/internal/bundlereader/read.go
@@ -176,7 +176,7 @@ func read(ctx context.Context, name, baseDir string, bundleSpecReader io.Reader,
 		})
 	}
 
-	fy.BundleSpec.Targets = append(fy.BundleSpec.Targets, fy.TargetCustomizations...)
+	fy.Targets = append(fy.Targets, fy.TargetCustomizations...)
 
 	meta, err := readMetadata(bytes)
 	if err != nil {
diff --git a/internal/cmd/agent/deployer/desiredset/desiredset_process.go b/internal/cmd/agent/deployer/desiredset/desiredset_process.go
index 8d377f95..d9de2a89 100644
--- a/internal/cmd/agent/deployer/desiredset/desiredset_process.go
+++ b/internal/cmd/agent/deployer/desiredset/desiredset_process.go
@@ -167,7 +167,7 @@ func (o *desiredSet) list(ctx context.Context, informer cache.SharedIndexInforme
 		// we're cleaning up any owned resources.  Otherwise, search only objects from the namespaces
 		// used by the objects.  Note: desiredSets without owners will never return objects to delete;
 		// deletion requires an owner to track object references across multiple apply runs.
-		var namespaces []string = desiredObjects.Namespaces()
+		var namespaces = desiredObjects.Namespaces()
 
 		// no owner or lister namespace intentionally restricted; only search in specified namespaces
 		err := multiNamespaceList(ctx, namespaces, client, selector, func(obj unstructured.Unstructured) {
diff --git a/internal/cmd/agent/deployer/normalizers/status.go b/internal/cmd/agent/deployer/normalizers/status.go
index 26f4605b..5661e228 100644
--- a/internal/cmd/agent/deployer/normalizers/status.go
+++ b/internal/cmd/agent/deployer/normalizers/status.go
@@ -7,7 +7,7 @@ import (
 // StatusNormalizer removes a top-level "status" fields from the object, if present
 type StatusNormalizer struct{}
 
-func (_ StatusNormalizer) Normalize(un *unstructured.Unstructured) error {
+func (StatusNormalizer) Normalize(un *unstructured.Unstructured) error {
 	unstructured.RemoveNestedField(un.Object, "status")
 	return nil
 }
diff --git a/internal/cmd/controller/finalize/finalize.go b/internal/cmd/controller/finalize/finalize.go
index 336a949e..1cfc3144 100644
--- a/internal/cmd/controller/finalize/finalize.go
+++ b/internal/cmd/controller/finalize/finalize.go
@@ -79,28 +79,7 @@ func PurgeBundleDeployments(ctx context.Context, c client.Client, bundle types.N
 		return err
 	}
 	for _, bd := range list.Items {
-		if controllerutil.ContainsFinalizer(&bd, BundleDeploymentFinalizer) { // nolint: gosec // does not store pointer
-			nn := types.NamespacedName{Namespace: bd.Namespace, Name: bd.Name}
-			err = retry.RetryOnConflict(retry.DefaultRetry, func() error {
-				t := &v1alpha1.BundleDeployment{}
-				if err := c.Get(ctx, nn, t); err != nil {
-					return err
-				}
-
-				controllerutil.RemoveFinalizer(t, BundleDeploymentFinalizer)
-
-				return c.Update(ctx, t)
-			})
-			if err != nil {
-				return err
-			}
-		}
-
-		if err := c.Delete(ctx, &bd); err != nil {
-			return err
-		}
-
-		if err = PurgeContent(ctx, c, bd.Name, bd.Spec.DeploymentID); err != nil {
+		if err := PurgeBundleDeployment(ctx, c, bd); err != nil {
 			return err
 		}
 	}
@@ -200,3 +179,47 @@ func PurgeNamespace(ctx context.Context, c client.Client, deleteNamespace bool,
 
 	return nil
 }
+
+// PurgeBundleDeploymentList deletes the given list of bundledeployments
+func PurgeBundleDeploymentList(ctx context.Context, c client.Client, bds []types.NamespacedName) error {
+	for _, bdId := range bds {
+		bd := &v1alpha1.BundleDeployment{}
+		if err := c.Get(ctx, bdId, bd); err != nil {
+			return err
+		}
+		if err := PurgeBundleDeployment(ctx, c, *bd); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// PurgeBundleDeployment deletes the given bundle deployment and the content related to it
+func PurgeBundleDeployment(ctx context.Context, c client.Client, bd v1alpha1.BundleDeployment) error {
+	if controllerutil.ContainsFinalizer(&bd, BundleDeploymentFinalizer) { // nolint: gosec // does not store pointer
+		nn := types.NamespacedName{Namespace: bd.Namespace, Name: bd.Name}
+		err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
+			t := &v1alpha1.BundleDeployment{}
+			if err := c.Get(ctx, nn, t); err != nil {
+				return err
+			}
+
+			controllerutil.RemoveFinalizer(t, BundleDeploymentFinalizer)
+
+			return c.Update(ctx, t)
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	if err := c.Delete(ctx, &bd); err != nil {
+		return err
+	}
+
+	if err := PurgeContent(ctx, c, bd.Name, bd.Spec.DeploymentID); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/cmd/controller/gitops/reconciler/status_controller.go b/internal/cmd/controller/gitops/reconciler/status_controller.go
index 76e44f77..c626840a 100644
--- a/internal/cmd/controller/gitops/reconciler/status_controller.go
+++ b/internal/cmd/controller/gitops/reconciler/status_controller.go
@@ -179,6 +179,12 @@ func (r StatusReconciler) setReadyStatusFromBundle(ctx context.Context, gitrepo
 		return err
 	}
 
+	// Make sure the bundles are always iterated in the same order
+	// The code below will pick the first element matching the condition, so successive executions should produce the same result.
+	sort.Slice(bList.Items, func(i, j int) bool {
+		return bList.Items[i].UID < bList.Items[j].UID
+	})
+
 	found := false
 	// Find a ready status condition in a bundle which is not ready.
 	var condition genericcondition.GenericCondition
diff --git a/internal/cmd/controller/imagescan/update/filter.go b/internal/cmd/controller/imagescan/update/filter.go
index 4639fb96..86779c1f 100644
--- a/internal/cmd/controller/imagescan/update/filter.go
+++ b/internal/cmd/controller/imagescan/update/filter.go
@@ -153,7 +153,7 @@ func (s *SetAllCallback) visitScalar(object *yaml.RNode, p string, fieldSchema *
 }
 
 func getExtFromSchema(schema *spec.Schema) (*extension, error) {
-	cep := schema.VendorExtensible.Extensions[K8sCliExtensionKey]
+	cep := schema.Extensions[K8sCliExtensionKey]
 	if cep == nil {
 		return nil, nil
 	}
diff --git a/internal/cmd/controller/reconciler/bundle_controller.go b/internal/cmd/controller/reconciler/bundle_controller.go
index 8689bcba..caef037b 100644
--- a/internal/cmd/controller/reconciler/bundle_controller.go
+++ b/internal/cmd/controller/reconciler/bundle_controller.go
@@ -283,6 +283,17 @@ func (r *BundleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		}
 	}
 
+	// purge bundle deployments that exist in the bundle but are no longer targeted
+	notTargeted, err := findNotTargetedBundleDeployments(ctx, r.Client, req.NamespacedName, matchedTargets)
+	if err != nil {
+		return ctrl.Result{}, err
+	}
+	if len(notTargeted) > 0 {
+		if err := finalize.PurgeBundleDeploymentList(ctx, r.Client, notTargeted); err != nil {
+			return ctrl.Result{}, err
+		}
+	}
+
 	updateDisplay(&bundle.Status)
 	err = r.updateStatus(ctx, bundleOrig, bundle)
 
@@ -487,3 +498,34 @@ func (r *BundleReconciler) updateStatus(ctx context.Context, orig *fleet.Bundle,
 	metrics.BundleCollector.Collect(ctx, bundle)
 	return nil
 }
+
+func findNotTargetedBundleDeployments(ctx context.Context, c client.Client, bundleId types.NamespacedName, targets []*target.Target) ([]types.NamespacedName, error) {
+	list := &fleet.BundleDeploymentList{}
+	err := c.List(
+		ctx,
+		list,
+		client.MatchingLabels{
+			fleet.BundleLabel:          bundleId.Name,
+			fleet.BundleNamespaceLabel: bundleId.Namespace,
+		},
+	)
+	if err != nil {
+		return []types.NamespacedName{}, err
+	}
+
+	var notTargeted []types.NamespacedName
+	for _, bd := range list.Items {
+		found := false
+		for _, t := range targets {
+			if t.Deployment.Name == bd.Name && t.Deployment.Namespace == bd.Namespace {
+				found = true
+				break
+			}
+		}
+		if !found {
+			notTargeted = append(notTargeted, types.NamespacedName{Namespace: bd.Namespace, Name: bd.Name})
+		}
+	}
+
+	return notTargeted, nil
+}
diff --git a/internal/cmd/controller/status/status.go b/internal/cmd/controller/status/status.go
index f7a3cf06..89e41289 100644
--- a/internal/cmd/controller/status/status.go
+++ b/internal/cmd/controller/status/status.go
@@ -4,7 +4,6 @@ import (
 	"reflect"
 
 	"github.com/rancher/fleet/internal/cmd/controller/summary"
-	"github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/event"
@@ -19,11 +18,11 @@ func BundleStatusChangedPredicate() predicate.Funcs {
 			return true
 		},
 		UpdateFunc: func(e event.UpdateEvent) bool {
-			n, isBundle := e.ObjectNew.(*v1alpha1.Bundle)
+			n, isBundle := e.ObjectNew.(*fleet.Bundle)
 			if !isBundle {
 				return false
 			}
-			o := e.ObjectOld.(*v1alpha1.Bundle)
+			o := e.ObjectOld.(*fleet.Bundle)
 			if n == nil || o == nil {
 				return false
 			}
diff --git a/internal/cmd/controller/target/target.go b/internal/cmd/controller/target/target.go
index 5f6fd96d..b0383fb5 100644
--- a/internal/cmd/controller/target/target.go
+++ b/internal/cmd/controller/target/target.go
@@ -126,8 +126,8 @@ func (t *Target) nonReady() []fleet.NonReadyStatus {
 
 // state calculates a fleet.BundleState from t (pure function)
 func (t *Target) state() fleet.BundleState {
-	switch {
-	case t.Deployment == nil:
+	switch t.Deployment {
+	case nil:
 		return fleet.Pending
 	default:
 		return summary.GetDeploymentState(t.Deployment)
diff --git a/internal/metrics/bundle_metrics.go b/internal/metrics/bundle_metrics.go
index c0f285b8..ae740ac5 100644
--- a/internal/metrics/bundle_metrics.go
+++ b/internal/metrics/bundle_metrics.go
@@ -112,9 +112,9 @@ func collectBundleMetrics(obj any, metrics map[string]prometheus.Collector) {
 	labels := prometheus.Labels{
 		"name":       bundle.Name,
 		"namespace":  bundle.Namespace,
-		"commit":     bundle.ObjectMeta.Labels[fleet.CommitLabel],
-		"repo":       bundle.ObjectMeta.Labels[fleet.RepoLabel],
-		"generation": fmt.Sprintf("%d", bundle.ObjectMeta.Generation),
+		"commit":     bundle.Labels[fleet.CommitLabel],
+		"repo":       bundle.Labels[fleet.RepoLabel],
+		"generation": fmt.Sprintf("%d", bundle.Generation),
 		"state":      string(currentState),
 	}
 
diff --git a/internal/metrics/bundledeployment_metrics.go b/internal/metrics/bundledeployment_metrics.go
index 3e90760a..69e843f3 100644
--- a/internal/metrics/bundledeployment_metrics.go
+++ b/internal/metrics/bundledeployment_metrics.go
@@ -52,13 +52,13 @@ func collectBundleDeploymentMetrics(obj any, metrics map[string]prometheus.Colle
 	labels := prometheus.Labels{
 		"name":              bundleDep.Name,
 		"namespace":         bundleDep.Namespace,
-		"cluster_name":      bundleDep.ObjectMeta.Labels[fleet.ClusterLabel],
-		"cluster_namespace": bundleDep.ObjectMeta.Labels[fleet.ClusterNamespaceLabel],
-		"repo":              bundleDep.ObjectMeta.Labels[fleet.RepoLabel],
-		"commit":            bundleDep.ObjectMeta.Labels[fleet.CommitLabel],
-		"bundle":            bundleDep.ObjectMeta.Labels[fleet.BundleLabel],
-		"bundle_namespace":  bundleDep.ObjectMeta.Labels[fleet.BundleNamespaceLabel],
-		"generation":        fmt.Sprintf("%d", bundleDep.ObjectMeta.Generation),
+		"cluster_name":      bundleDep.Labels[fleet.ClusterLabel],
+		"cluster_namespace": bundleDep.Labels[fleet.ClusterNamespaceLabel],
+		"repo":              bundleDep.Labels[fleet.RepoLabel],
+		"commit":            bundleDep.Labels[fleet.CommitLabel],
+		"bundle":            bundleDep.Labels[fleet.BundleLabel],
+		"bundle_namespace":  bundleDep.Labels[fleet.BundleNamespaceLabel],
+		"generation":        fmt.Sprintf("%d", bundleDep.Generation),
 		"state":             string(currentState),
 	}
 
diff --git a/internal/metrics/cluster_metrics.go b/internal/metrics/cluster_metrics.go
index b155d772..ce9e4b4e 100644
--- a/internal/metrics/cluster_metrics.go
+++ b/internal/metrics/cluster_metrics.go
@@ -149,9 +149,9 @@ func collectClusterMetrics(obj any, metrics map[string]prometheus.Collector) {
 	labels := prometheus.Labels{
 		"name":                 cluster.Name,
 		"namespace":            cluster.Namespace,
-		"cluster_name":         cluster.ObjectMeta.Labels[clusterNameLabel],
-		"cluster_display_name": cluster.ObjectMeta.Labels[clusterDisplayNameLabel],
-		"generation":           fmt.Sprintf("%d", cluster.ObjectMeta.Generation),
+		"cluster_name":         cluster.Labels[clusterNameLabel],
+		"cluster_display_name": cluster.Labels[clusterDisplayNameLabel],
+		"generation":           fmt.Sprintf("%d", cluster.Generation),
 		"state":                cluster.Status.Display.State,
 	}
 
diff --git a/internal/metrics/clustergroup_metrics.go b/internal/metrics/clustergroup_metrics.go
index 25d718fa..52059154 100644
--- a/internal/metrics/clustergroup_metrics.go
+++ b/internal/metrics/clustergroup_metrics.go
@@ -151,7 +151,7 @@ func collectClusterGroupMetrics(obj any, metrics map[string]prometheus.Collector
 	labels := prometheus.Labels{
 		"name":       clusterGroup.Name,
 		"namespace":  clusterGroup.Namespace,
-		"generation": fmt.Sprintf("%d", clusterGroup.ObjectMeta.Generation),
+		"generation": fmt.Sprintf("%d", clusterGroup.Generation),
 		"state":      clusterGroup.Status.Display.State,
 	}
 
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go b/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
index bbb56e95..c322ed83 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/gitrepo_types.go
@@ -137,6 +137,9 @@ type GitRepoSpec struct {
 
 	// OCIRegistry specifies the OCI registry related parameters
 	OCIRegistry *OCIRegistrySpec `json:"ociRegistry,omitempty"`
+
+	// WebhookSecret contains the name of the secret to use for webhook parsing
+	WebhookSecret string `json:"webhookSecret,omitempty"`
 }
 
 // GitTarget is a cluster or cluster group to deploy to.
diff --git a/pkg/apis/go.mod b/pkg/apis/go.mod
index d4661b8b..b7d84d78 100644
--- a/pkg/apis/go.mod
+++ b/pkg/apis/go.mod
@@ -2,7 +2,7 @@ module github.com/rancher/fleet/pkg/apis
 
 go 1.23.0
 
-toolchain go1.23.6
+toolchain go1.24.2
 
 require (
 	github.com/rancher/wrangler/v3 v3.2.0
diff --git a/pkg/git/netutils.go b/pkg/git/netutils.go
index be7c0b5e..87dffc32 100644
--- a/pkg/git/netutils.go
+++ b/pkg/git/netutils.go
@@ -23,7 +23,8 @@ func GetAuthFromSecret(url string, creds *corev1.Secret) (transport.AuthMethod,
 		// no auth information was provided
 		return nil, nil
 	}
-	if creds.Type == corev1.SecretTypeBasicAuth {
+	switch creds.Type {
+	case corev1.SecretTypeBasicAuth:
 		username, password := creds.Data[corev1.BasicAuthUsernameKey], creds.Data[corev1.BasicAuthPasswordKey]
 		if len(password) == 0 && len(username) == 0 {
 			return nil, nil
@@ -32,7 +33,7 @@ func GetAuthFromSecret(url string, creds *corev1.Secret) (transport.AuthMethod,
 			Username: string(username),
 			Password: string(password),
 		}, nil
-	} else if creds.Type == corev1.SecretTypeSSHAuth {
+	case corev1.SecretTypeSSHAuth:
 		gitURL, err := giturls.Parse(url)
 		if err != nil {
 			return nil, err
diff --git a/pkg/webhook/parser.go b/pkg/webhook/parser.go
new file mode 100644
index 00000000..e66e03f6
--- /dev/null
+++ b/pkg/webhook/parser.go
@@ -0,0 +1,159 @@
+package webhook
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/go-playground/webhooks/v6/azuredevops"
+	"gopkg.in/go-playground/webhooks.v5/bitbucket"
+	bitbucketserver "gopkg.in/go-playground/webhooks.v5/bitbucket-server"
+	"gopkg.in/go-playground/webhooks.v5/github"
+	"gopkg.in/go-playground/webhooks.v5/gitlab"
+	"gopkg.in/go-playground/webhooks.v5/gogs"
+	corev1 "k8s.io/api/core/v1"
+)
+
+const (
+	githubKey          = "github"
+	gitlabKey          = "gitlab"
+	bitbucketKey       = "bitbucket"
+	bitbucketServerKey = "bitbucket-server"
+	gogsKey            = "gogs"
+	azureUsername      = "azure-username"
+	azurePassword      = "azure-password"
+)
+
+func parseWebhook(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	switch {
+	//Gogs needs to be checked before Github since it carries both Gogs and (incompatible) Github headers
+	case r.Header.Get("X-Gogs-Event") != "":
+		return parseGogs(r, secret)
+	case r.Header.Get("X-GitHub-Event") != "":
+		return parseGithub(r, secret)
+	case r.Header.Get("X-Gitlab-Event") != "":
+		return parseGitlab(r, secret)
+	case r.Header.Get("X-Hook-UUID") != "":
+		return parseBitbucket(r, secret)
+	case r.Header.Get("X-Event-Key") != "":
+		return parseBitbucketServer(r, secret)
+	case r.Header.Get("X-Vss-Activityid") != "" || r.Header.Get("X-Vss-Subscriptionid") != "":
+		return parseAzureDevops(r, secret)
+	}
+
+	return nil, nil
+}
+
+func getValue(secret corev1.Secret, key string) (string, error) {
+	value, ok := secret.Data[key]
+	if !ok {
+		return "", fmt.Errorf("secret key %q not found in secret %q", key, secret.Name)
+	}
+
+	return string(value), nil
+}
+
+func parseGogs(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []gogs.Option{}
+	if secret != nil {
+		value, error := getValue(*secret, gogsKey)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, gogs.Options.Secret(value))
+	}
+	p, err := gogs.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, gogs.PushEvent)
+}
+
+func parseGithub(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []github.Option{}
+	if secret != nil {
+		value, error := getValue(*secret, githubKey)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, github.Options.Secret(value))
+	}
+	p, err := github.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, github.PushEvent)
+}
+
+func parseGitlab(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []gitlab.Option{}
+	if secret != nil {
+		value, error := getValue(*secret, gitlabKey)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, gitlab.Options.Secret(value))
+	}
+	p, err := gitlab.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, gitlab.PushEvents, gitlab.TagEvents)
+}
+
+func parseBitbucket(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []bitbucket.Option{}
+	if secret != nil {
+		value, error := getValue(*secret, bitbucketKey)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, bitbucket.Options.UUID(value))
+	}
+	p, err := bitbucket.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, bitbucket.RepoPushEvent)
+}
+
+func parseBitbucketServer(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []bitbucketserver.Option{}
+	if secret != nil {
+		value, error := getValue(*secret, bitbucketServerKey)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, bitbucketserver.Options.Secret(value))
+	}
+	p, err := bitbucketserver.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent)
+}
+
+func parseAzureDevops(r *http.Request, secret *corev1.Secret) (interface{}, error) {
+	opts := []azuredevops.Option{}
+	if secret != nil {
+		username, error := getValue(*secret, azureUsername)
+		if error != nil {
+			return nil, error
+		}
+		password, error := getValue(*secret, azurePassword)
+		if error != nil {
+			return nil, error
+		}
+		opts = append(opts, azuredevops.Options.BasicAuth(username, password))
+	}
+	p, err := azuredevops.New(opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Parse(r, azuredevops.GitPushEventType)
+}
diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go
index 00fba75a..749079c6 100644
--- a/pkg/webhook/webhook.go
+++ b/pkg/webhook/webhook.go
@@ -1,8 +1,10 @@
 package webhook
 
 import (
+	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"net/http"
 	"net/url"
 	"regexp"
@@ -19,13 +21,13 @@ import (
 	"gopkg.in/go-playground/webhooks.v5/gitlab"
 	"gopkg.in/go-playground/webhooks.v5/gogs"
 
-	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
-	kcache "k8s.io/client-go/tools/cache"
 	ctrl "sigs.k8s.io/controller-runtime"
 	"sigs.k8s.io/controller-runtime/pkg/cache"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -34,28 +36,15 @@ import (
 const (
 	webhookSecretName          = "gitjob-webhook" //nolint:gosec // this is a resource name
 	webhookDefaultSyncInterval = 3600
-	githubKey                  = "github"
-	gitlabKey                  = "gitlab"
-	bitbucketKey               = "bitbucket"
-	bitbucketServerKey         = "bitbucket-server"
-	gogsKey                    = "gogs"
-	azureUsername              = "azure-username"
-	azurePassword              = "azure-password"
 
 	branchRefPrefix = "refs/heads/"
 	tagRefPrefix    = "refs/tags/"
 )
 
 type Webhook struct {
-	client          client.Client
-	namespace       string
-	github          *github.Webhook
-	gitlab          *gitlab.Webhook
-	bitbucket       *bitbucket.Webhook
-	bitbucketServer *bitbucketserver.Webhook
-	gogs            *gogs.Webhook
-	log             logr.Logger
-	azureDevops     *azuredevops.Webhook
+	client    client.Client
+	namespace string
+	log       logr.Logger
 }
 
 func New(namespace string, client client.Client) (*Webhook, error) {
@@ -64,115 +53,34 @@ func New(namespace string, client client.Client) (*Webhook, error) {
 		namespace: namespace,
 		log:       ctrl.Log.WithName("webhook"),
 	}
-	err := webhook.initGitProviders()
-	if err != nil {
-		return nil, err
-	}
 
 	return webhook, nil
 }
 
-func (w *Webhook) initGitProviders() error {
-	var err error
-
-	w.github, err = github.New()
-	if err != nil {
-		return err
-	}
-	w.gitlab, err = gitlab.New()
-	if err != nil {
-		return err
-	}
-	w.bitbucket, err = bitbucket.New()
-	if err != nil {
-		return err
-	}
-	w.bitbucketServer, err = bitbucketserver.New()
-	if err != nil {
-		return err
-	}
-	w.gogs, err = gogs.New()
-	if err != nil {
-		return err
-	}
-	w.azureDevops, err = azuredevops.New()
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-func (w *Webhook) onSecretChange(obj interface{}) error {
-	secret, ok := obj.(*corev1.Secret)
-	if !ok {
-		return fmt.Errorf("expected secret object but got %T", obj)
-	}
-	if secret.Name != webhookSecretName && secret.Namespace != w.namespace {
-		return nil
-	}
-
-	var err error
-	github, err := github.New(github.Options.Secret(string(secret.Data[githubKey])))
-	if err != nil {
-		return err
-	}
-	w.github = github
-	gitlab, err := gitlab.New(gitlab.Options.Secret(string(secret.Data[gitlabKey])))
-	if err != nil {
-		return err
-	}
-	w.gitlab = gitlab
-	bitbucket, err := bitbucket.New(bitbucket.Options.UUID(string(secret.Data[bitbucketKey])))
-	if err != nil {
-		return err
-	}
-	w.bitbucket = bitbucket
-	bitbucketServer, err := bitbucketserver.New(bitbucketserver.Options.Secret(string(secret.Data[bitbucketServerKey])))
-	if err != nil {
-		return err
-	}
-	w.bitbucketServer = bitbucketServer
-	gogs, err := gogs.New(gogs.Options.Secret(string(secret.Data[gogsKey])))
-	if err != nil {
-		return err
-	}
-	w.gogs = gogs
-	azureDevops, err := azuredevops.New(azuredevops.Options.BasicAuth(string(secret.Data[azureUsername]), string(secret.Data[azurePassword])))
-	if err != nil {
-		return err
-	}
-	w.azureDevops = azureDevops
-
-	return nil
-}
-
 func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
 	// credit from https://github.com/argoproj/argo-cd/blob/97003caebcaafe1683e71934eb483a88026a4c33/util/webhook/webhook.go#L327-L350
 	var payload interface{}
 	var err error
 	ctx := r.Context()
 
+	// copy the body of the request because we need to parse it twice if secrets are defined
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		w.logAndReturn(rw, err)
+		return
+	}
+
 	switch {
-	//Gogs needs to be checked before Github since it carries both Gogs and (incompatible) Github headers
-	case r.Header.Get("X-Gogs-Event") != "":
-		payload, err = w.gogs.Parse(r, gogs.PushEvent)
 	case r.Header.Get("X-Github-Event") == "ping":
 		_, _ = rw.Write([]byte("Webhook received successfully"))
 		return
-	case r.Header.Get("X-GitHub-Event") != "":
-		payload, err = w.github.Parse(r, github.PushEvent)
-	case r.Header.Get("X-Gitlab-Event") != "":
-		payload, err = w.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents)
-	case r.Header.Get("X-Hook-UUID") != "":
-		payload, err = w.bitbucket.Parse(r, bitbucket.RepoPushEvent)
-	case r.Header.Get("X-Event-Key") != "":
-		payload, err = w.bitbucketServer.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent)
-	case r.Header.Get("X-Vss-Activityid") != "" || r.Header.Get("X-Vss-Subscriptionid") != "":
-		payload, err = w.azureDevops.Parse(r, azuredevops.GitPushEventType)
 	default:
-		w.log.V(1).Info("Ignoring unknown webhook event")
-		return
+		r.Body = io.NopCloser(bytes.NewBuffer(body))
+		payload, err = parseWebhook(r, nil)
+		if payload == nil && err == nil {
+			w.log.V(1).Info("Ignoring unknown webhook event")
+			return
+		}
 	}
 
 	w.log.V(1).Info("Webhook payload", "payload", payload)
@@ -184,7 +92,7 @@ func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
 
 	revision, branch, _, repoURLs := parsePayload(payload)
 
-	var gitRepoList v1alpha1.GitRepoList
+	var gitRepoList fleet.GitRepoList
 	err = w.client.List(ctx, &gitRepoList, &client.ListOptions{LabelSelector: labels.Everything()})
 	if err != nil {
 		w.logAndReturn(rw, err)
@@ -222,8 +130,30 @@ func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
 			}
 
 			if gitrepo.Status.WebhookCommit != revision && revision != "" {
-				var gitRepoFromCluster v1alpha1.GitRepo
-				err := w.client.Get(
+				// before updating the gitrepo check if a secret was
+				// defined and, if so, verify that it is correct
+				secret, err := w.getSecret(ctx, gitrepo)
+				if err != nil {
+					w.logAndReturn(rw, err)
+					return
+				}
+				if secret != nil {
+					// At this point we know that a secret is defined and exists.
+					// Parse the request again (this time with secret)
+					// We need to parse twice because in the first parsing we didn't
+					// know the gitrepo associated with the webhook payload.
+					// The first parsing is used to get the gitrepo and, if a secret is
+					// defined in the gitrepo, it takes precedence over the global one.
+					r.Body = io.NopCloser(bytes.NewBuffer(body))
+					_, err = parseWebhook(r, secret)
+					if err != nil {
+						w.logAndReturn(rw, err)
+						return
+					}
+				}
+
+				var gitRepoFromCluster fleet.GitRepo
+				err = w.client.Get(
 					ctx,
 					types.NamespacedName{
 						Name:      gitrepo.Name,
@@ -263,36 +193,6 @@ func HandleHooks(ctx context.Context, namespace string, client client.Client, cl
 	root.UseEncodedPath()
 	root.Handle("/", webhook)
 
-	var secret corev1.Secret
-	informer, err := clientCache.GetInformer(ctx, &secret)
-	if err != nil {
-		return nil, err
-	}
-
-	_, err = informer.AddEventHandler(kcache.ResourceEventHandlerFuncs{
-		AddFunc: func(obj interface{}) {
-			err := webhook.onSecretChange(obj)
-			if err != nil {
-				webhook.log.Error(err, "new secret added")
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			err := webhook.initGitProviders()
-			if err != nil {
-				webhook.log.Error(err, "secret deleted")
-			}
-		},
-		UpdateFunc: func(_, newObj interface{}) {
-			err := webhook.onSecretChange(newObj)
-			if err != nil {
-				webhook.log.Error(err, "secret updated")
-			}
-		},
-	})
-	if err != nil {
-		return nil, err
-	}
-
 	return root, nil
 }
 
@@ -302,23 +202,53 @@ func (w *Webhook) logAndReturn(rw http.ResponseWriter, err error) {
 	_, _ = rw.Write([]byte(err.Error()))
 }
 
+func (w *Webhook) getSecret(ctx context.Context, gitrepo fleet.GitRepo) (*corev1.Secret, error) {
+	// global secret first (for backward compatibility)
+	secretName := webhookSecretName
+	ns := w.namespace
+	mustExist := false
+	if gitrepo.Spec.WebhookSecret != "" {
+		// the gitrepo secret takes preference over the global one
+		secretName = gitrepo.Spec.WebhookSecret
+		ns = gitrepo.Namespace
+		mustExist = true // when the secret has been defined in the GitRepo it must exist
+	}
+	var secret corev1.Secret
+	err := w.client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: ns}, &secret)
+	if err != nil {
+		if !errors.IsNotFound(err) {
+			return nil, err
+		}
+		if !mustExist {
+			return nil, nil
+		}
+		return nil, fmt.Errorf("secret %q in namespace %q does not exist", secretName, ns)
+	}
+	return &secret, nil
+}
+
 func getErrorCodeFromErr(err error) int {
 	// check if the error is a verification of identity error
 	// secret check, or basic credentials or token verification
 	// depending on the provider
-	if err == gogs.ErrHMACVerificationFailed ||
-		err == github.ErrHMACVerificationFailed ||
-		err == gitlab.ErrGitLabTokenVerificationFailed ||
-		err == bitbucket.ErrUUIDVerificationFailed ||
-		err == bitbucketserver.ErrHMACVerificationFailed ||
-		err == azuredevops.ErrBasicAuthVerificationFailed {
+	switch err {
+	case
+		gogs.ErrHMACVerificationFailed,
+		github.ErrHMACVerificationFailed,
+		gitlab.ErrGitLabTokenVerificationFailed,
+		bitbucket.ErrUUIDVerificationFailed,
+		bitbucketserver.ErrHMACVerificationFailed,
+		azuredevops.ErrBasicAuthVerificationFailed:
+
 		return http.StatusUnauthorized
-	} else if err == gogs.ErrInvalidHTTPMethod ||
-		err == github.ErrInvalidHTTPMethod ||
-		err == gitlab.ErrInvalidHTTPMethod ||
-		err == bitbucket.ErrInvalidHTTPMethod ||
-		err == bitbucketserver.ErrInvalidHTTPMethod ||
-		err == azuredevops.ErrInvalidHTTPMethod {
+	case
+		gogs.ErrInvalidHTTPMethod,
+		github.ErrInvalidHTTPMethod,
+		gitlab.ErrInvalidHTTPMethod,
+		bitbucket.ErrInvalidHTTPMethod,
+		bitbucketserver.ErrInvalidHTTPMethod,
+		azuredevops.ErrInvalidHTTPMethod:
+
 		return http.StatusMethodNotAllowed
 	}
 	return http.StatusInternalServerError
@@ -363,9 +293,10 @@ func parsePayload(payload interface{}) (revision, branch, tag string, repoURLs [
 
 		change := t.Push.Changes[0]
 		revision = change.New.Target.Hash
-		if change.New.Type == "branch" {
+		switch change.New.Type {
+		case "branch":
 			branch = change.New.Name
-		} else if change.New.Type == "tag" {
+		case "tag":
 			tag = change.New.Name
 		}
 	case bitbucketserver.RepositoryReferenceChangedPayload:



Test patch:
diff --git a/e2e/single-cluster/release_cleanup_test.go b/e2e/single-cluster/release_cleanup_test.go
index 5979b2a0..b2b97c9a 100644
--- a/e2e/single-cluster/release_cleanup_test.go
+++ b/e2e/single-cluster/release_cleanup_test.go
@@ -3,52 +3,47 @@ package singlecluster_test
 import (
 	"fmt"
 	"math/rand"
-	"os"
 	"os/exec"
-	"path"
 	"strings"
 
-	"github.com/go-git/go-git/v5"
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	"github.com/rancher/fleet/e2e/testenv"
-	"github.com/rancher/fleet/e2e/testenv/githelper"
 	"github.com/rancher/fleet/e2e/testenv/kubectl"
 )
 
-var _ = Describe("Monitoring Helm releases along GitRepo/bundle namespace updates", Ordered, func() {
+var _ = Describe("Monitoring Helm releases along bundle namespace updates", Ordered, func() {
 	var (
 		k            kubectl.Command
 		r            = rand.New(rand.NewSource(GinkgoRandomSeed()))
 		oldNamespace string
 		newNamespace string
-		gitrepoName  string
-		path         string
+		bundleName   string
 	)
 
+	type TemplateData struct {
+		TargetNamespace string
+	}
+
 	BeforeAll(func() {
 		k = env.Kubectl.Namespace(env.Namespace)
+		bundleName = "namespace-update"
 		oldNamespace = testenv.NewNamespaceName("target", r)
 		newNamespace = testenv.NewNamespaceName("target", r)
-		gitrepoName = "releases-cleanup"
-		path = "simple-chart"
 
-		err := testenv.CreateGitRepo(
+		GinkgoWriter.Printf("old namespace: %s\n", oldNamespace)
+		GinkgoWriter.Printf("new namespace: %s\n", newNamespace)
+
+		err := testenv.ApplyTemplate(
 			k,
-			oldNamespace,
-			gitrepoName,
-			"master",
-			"",
-			path,
+			testenv.AssetPath("single-cluster/release-cleanup/bundle-namespace-update.yaml"),
+			TemplateData{TargetNamespace: oldNamespace},
 		)
 		Expect(err).ToNot(HaveOccurred())
-
-		// Check for existence of Helm release as the only release in the target namespace
-		checkRelease(oldNamespace, fmt.Sprintf("%s-%s", gitrepoName, path))
 	})
 
 	AfterAll(func() {
-		out, err := k.Delete("gitrepo", gitrepoName)
+		out, err := k.Delete("bundle", bundleName)
 		Expect(err).ToNot(HaveOccurred(), out)
 
 		out, err = k.Delete("ns", oldNamespace, "--wait=false")
@@ -62,15 +57,15 @@ var _ = Describe("Monitoring Helm releases along GitRepo/bundle namespace update
 		It("properly manages releases", func() {
 			By("creating a new release in the new namespace")
 			out, err := k.Patch(
-				"gitrepo",
-				gitrepoName,
+				"bundle",
+				bundleName,
 				"--type=merge",
 				"-p",
-				fmt.Sprintf(`{"spec":{"targetNamespace":"%s"}}`, newNamespace),
+				fmt.Sprintf(`{"spec":{"namespace":"%s"}}`, newNamespace),
 			)
 			Expect(err).ToNot(HaveOccurred(), out)
 
-			checkRelease(newNamespace, fmt.Sprintf("%s-%s", gitrepoName, path))
+			checkRelease(newNamespace, bundleName)
 
 			By("deleting the old release in the previous namespace")
 			Eventually(func(g Gomega) {
@@ -83,124 +78,91 @@ var _ = Describe("Monitoring Helm releases along GitRepo/bundle namespace update
 	})
 })
 
-var _ = Describe("Monitoring Helm releases along GitRepo/bundle release name updates", Ordered, Label("infra-setup"), func() {
+var _ = Describe("Monitoring Helm releases along bundle release name updates", Ordered, func() {
 	var (
-		tmpDir           string
-		clonedir         string
 		k                kubectl.Command
-		gh               *githelper.Git
-		clone            *git.Repository
-		repoName         string
-		inClusterRepoURL string
-		gitrepoName      string
-		r                = rand.New(rand.NewSource(GinkgoRandomSeed()))
-		targetNamespace  string
+		bundleName       string
 		oldReleaseName   string
 		newReleaseName   string
 		assetPath        string
+		releaseNamespace string
 	)
 
+	type TemplateData struct {
+		ReleaseName string
+	}
+
 	BeforeEach(func() {
 		k = env.Kubectl.Namespace(env.Namespace)
 		oldReleaseName = "test-config-release"
 		newReleaseName = "new-test-config-release"
-		repoName = "repo"
+		bundleName = "bundle-release-name-update"
 
 		DeferCleanup(func() {
-			out, err := k.Delete("gitrepo", gitrepoName)
+			out, err := k.Delete("bundle", bundleName)
 			Expect(err).ToNot(HaveOccurred(), out)
-
-			if targetNamespace != "" {
-				out, err = k.Delete("ns", targetNamespace, "--wait=false")
-				Expect(err).ToNot(HaveOccurred(), out)
-			}
-
-			_ = os.RemoveAll(tmpDir)
 		})
 	})
 
 	JustBeforeEach(func() {
-		// Build git repo URL reachable _within_ the cluster, for the GitRepo
-		host := githelper.BuildGitHostname()
-
-		addr, err := githelper.GetExternalRepoAddr(env, port, repoName)
-		Expect(err).ToNot(HaveOccurred())
-		gh = githelper.NewHTTP(addr)
-
-		inClusterRepoURL = gh.GetInClusterURL(host, port, repoName)
-
-		tmpDir, _ = os.MkdirTemp("", "fleet-")
-		clonedir = path.Join(tmpDir, repoName)
-
-		gitrepoName = testenv.RandomFilename("releases-test", r)
-
-		err = testenv.ApplyTemplate(k, testenv.AssetPath("gitrepo/gitrepo.yaml"), struct {
-			Name            string
-			Repo            string
-			Branch          string
-			PollingInterval string
-			TargetNamespace string
-		}{
-			gitrepoName,
-			inClusterRepoURL,
-			gh.Branch,
-			"15s",           // default
-			targetNamespace, // to avoid conflicts with other tests
-		})
-		Expect(err).ToNot(HaveOccurred())
-
-		clone, err = gh.Create(clonedir, testenv.AssetPath(assetPath), "examples")
+		err := testenv.ApplyTemplate(
+			k,
+			assetPath,
+			TemplateData{ReleaseName: oldReleaseName},
+		)
 		Expect(err).ToNot(HaveOccurred())
 
 		// Check for existence of Helm release with old name in the target namespace
-		checkRelease(targetNamespace, oldReleaseName)
+		checkRelease(releaseNamespace, oldReleaseName)
 	})
 
 	When("updating the release name of a bundle containing upstream Kubernetes resources", func() {
 		BeforeEach(func() {
-			assetPath = "helm/repo/with-release-name/no-crds"
-			targetNamespace = testenv.NewNamespaceName("target", r)
+			bundleName = "release-name-update"
+			assetPath = testenv.AssetPath("single-cluster/release-cleanup/bundle-release-name-update.yaml")
+			releaseNamespace = "default"
 		})
 		It("replaces the release", func() {
-			By("updating the release name in fleet.yaml")
+			By("updating the release name")
 			// Not adding `takeOwnership: true` leads to errors from Helm checking its own annotations and failing to
 			// deploy the new release because the config map already exists and belongs to the old one.
-			replace(
-				path.Join(clonedir, "examples", "fleet.yaml"),
-				oldReleaseName,
-				fmt.Sprintf("%s\n  takeOwnership: true", newReleaseName),
+			out, err := k.Patch(
+				"bundle",
+				bundleName,
+				"--type=merge",
+				"-p",
+				fmt.Sprintf(`{"spec":{"helm":{"releaseName":"%s", "takeOwnership": true}}}`, newReleaseName),
 			)
-
-			_, err := gh.Update(clone)
-			Expect(err).ToNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred(), out)
 
 			By("creating a release with the new name")
-			checkRelease(targetNamespace, newReleaseName)
+			checkRelease(releaseNamespace, newReleaseName)
 		})
 	})
 
 	When("updating the release name of a bundle containing only CRDs", func() {
 		BeforeEach(func() {
-			assetPath = "helm/repo/with-release-name/crds-only"
-			targetNamespace = ""
+			bundleName = "release-name-update-crds"
+			assetPath = testenv.AssetPath("single-cluster/release-cleanup/bundle-crds.yaml")
+			releaseNamespace = "default"
 			DeferCleanup(func() {
 				out, err := k.Delete("crd", "foobars.crd.test")
 				Expect(err).ToNot(HaveOccurred(), out)
 			})
 		})
 		It("replaces the release", func() {
-			By("updating the release name in fleet.yaml")
-			replace(
-				path.Join(clonedir, "examples", "fleet.yaml"),
-				oldReleaseName,
-				fmt.Sprintf("%s\n  takeOwnership: true", newReleaseName),
+			By("updating the release name")
+			out, err := k.Patch(
+				"bundle",
+				bundleName,
+				"--type=merge",
+				"-p",
+				fmt.Sprintf(`{"spec":{"helm":{"releaseName":"%s", "takeOwnership": true}}}`, newReleaseName),
 			)
-
-			_, err := gh.Update(clone)
-			Expect(err).ToNot(HaveOccurred())
+			Expect(err).ToNot(HaveOccurred(), out)
 
 			By("creating a release with the new name")
-			checkRelease("default", newReleaseName)
+			checkRelease(releaseNamespace, newReleaseName)
 		})
 	})
 })
diff --git a/integrationtests/agent/adoption_test.go b/integrationtests/agent/adoption_test.go
index d00631a6..82fbb769 100644
--- a/integrationtests/agent/adoption_test.go
+++ b/integrationtests/agent/adoption_test.go
@@ -198,8 +198,8 @@ func (e adoptEnv) waitForConfigMap(name string) {
 }
 
 func (e adoptEnv) createConfigMap(cm *corev1.ConfigMap) {
-	cm.ObjectMeta.Name = "cm1"
-	cm.ObjectMeta.Namespace = e.namespace
+	cm.Name = "cm1"
+	cm.Namespace = e.namespace
 	Expect(k8sClient.Create(ctx, cm)).To(Succeed())
 	e.waitForConfigMap("cm1")
 }
diff --git a/integrationtests/controller/bundle/bundle_targets_test.go b/integrationtests/controller/bundle/bundle_targets_test.go
index b61c5bd6..0d82fe79 100644
--- a/integrationtests/controller/bundle/bundle_targets_test.go
+++ b/integrationtests/controller/bundle/bundle_targets_test.go
@@ -178,9 +178,9 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and just BundleDeployment from cluster one and two are customized")
 			for _, bd := range bdList.Items {
-				if strings.Contains(bd.ObjectMeta.Namespace, "cluster-one") {
+				if strings.Contains(bd.Namespace, "cluster-one") {
 					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
-				} else if strings.Contains(bd.ObjectMeta.Namespace, "cluster-two") {
+				} else if strings.Contains(bd.Namespace, "cluster-two") {
 					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "2"}))
 				} else {
 					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
@@ -236,7 +236,7 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 			By("and just BundleDeployment from cluster one is customized")
 			for _, bd := range bdList.Items {
-				if strings.Contains(bd.ObjectMeta.Namespace, "cluster-one") {
+				if strings.Contains(bd.Namespace, "cluster-one") {
 					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
 				} else {
 					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
@@ -393,6 +393,87 @@ var _ = Describe("Bundle targets", Ordered, func() {
 			_ = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
 		})
 	})
+
+	When("setting doNotDeploy to a target customization after the bundle has been deployed", func() {
+		BeforeEach(func() {
+			bundleName = "one-customized-do-not-deploy-two-later"
+			bdLabels = map[string]string{
+				"fleet.cattle.io/bundle-name":      bundleName,
+				"fleet.cattle.io/bundle-namespace": namespace,
+			}
+			expectedNumberOfBundleDeployments = 3
+			// simulate targets in fleet.yaml which are used for customization
+			targets = []v1alpha1.BundleTarget{
+				{
+					BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
+						Helm: &v1alpha1.HelmOptions{
+							Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"replicas": "1"}},
+						},
+					},
+					ClusterGroup: "one",
+				},
+				{
+					BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
+						Helm: &v1alpha1.HelmOptions{
+							Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"replicas": "2"}},
+						},
+					},
+					ClusterGroup: "two",
+				},
+			}
+			// simulate targets in GitRepo. All targets in GitRepo are also added to targetRestrictions, which acts as a white list
+			targetsInGitRepo := []v1alpha1.BundleTarget{
+				{
+					BundleDeploymentOptions: v1alpha1.BundleDeploymentOptions{
+						Helm: &v1alpha1.HelmOptions{
+							Values: &v1alpha1.GenericMap{Data: map[string]interface{}{"replicas": "4"}},
+						},
+					},
+					ClusterGroup: "all",
+				},
+			}
+			targetRestrictions = make([]v1alpha1.BundleTarget, len(targetsInGitRepo))
+			copy(targetRestrictions, targetsInGitRepo)
+			targets = append(targets, targetsInGitRepo...)
+		})
+
+		It("checks that bundle deployments for all three clusters exist and two is gone after setting do not deploy", func() {
+			var bdList = verifyBundlesDeploymentsAreCreated(expectedNumberOfBundleDeployments, bdLabels, bundleName)
+			By("checking that all bundle deployments are customized as expected")
+			for _, bd := range bdList.Items {
+				if strings.Contains(bd.Namespace, "cluster-one") {
+					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+				} else if strings.Contains(bd.Namespace, "cluster-two") {
+					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "2"}))
+				} else {
+					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+				}
+			}
+			By("setting the customization for cluster two as do not deploy")
+			clusterBundle := &v1alpha1.Bundle{}
+			err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: bundleName}, clusterBundle)
+			Expect(err).ToNot(HaveOccurred())
+
+			// do not deploy cluster two
+			for i, t := range clusterBundle.Spec.Targets {
+				if t.ClusterGroup == "two" {
+					clusterBundle.Spec.Targets[i].DoNotDeploy = true
+				}
+			}
+			Expect(k8sClient.Update(ctx, clusterBundle)).ToNot(HaveOccurred())
+
+			By("checking that bundle deployment for cluster two is gone")
+			bdList = verifyBundlesDeploymentsAreCreated(2, bdLabels, bundleName)
+			for _, bd := range bdList.Items {
+				Expect(bd.Namespace).ToNot(ContainSubstring("cluster-two"))
+				if strings.Contains(bd.Namespace, "cluster-one") {
+					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "1"}))
+				} else if strings.Contains(bd.Namespace, "cluster-three") {
+					Expect(bd.Spec.Options.Helm.Values.Data).To(Equal(map[string]interface{}{"replicas": "4"}))
+				}
+			}
+		})
+	})
 })
 
 func verifyBundlesDeploymentsAreCreated(numBundleDeployments int, bdLabels map[string]string, bundleName string) *v1alpha1.BundleDeploymentList {
diff --git a/integrationtests/helmops/controller/controller_test.go b/integrationtests/helmops/controller/controller_test.go
index f18bbed5..2d3ccbf5 100644
--- a/integrationtests/helmops/controller/controller_test.go
+++ b/integrationtests/helmops/controller/controller_test.go
@@ -23,9 +23,7 @@ import (
 	"github.com/rancher/fleet/e2e/testenv"
 	"github.com/rancher/fleet/internal/cmd/controller/finalize"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 	"github.com/rancher/wrangler/v3/pkg/genericcondition"
-	corev1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -215,7 +213,7 @@ func compareBundleAndHelmAppSpecs(g Gomega, bundle fleet.BundleSpec, helmapp fle
 
 // checkBundleIsAsExpected verifies that the bundle is a valid bundle created after
 // the given HelmApp resource.
-func checkBundleIsAsExpected(g Gomega, bundle fleet.Bundle, helmapp fleet.HelmApp, expectedTargets []v1alpha1.BundleTarget) {
+func checkBundleIsAsExpected(g Gomega, bundle fleet.Bundle, helmapp fleet.HelmApp, expectedTargets []fleet.BundleTarget) {
 	g.Expect(bundle.Name).To(Equal(helmapp.Name))
 	g.Expect(bundle.Namespace).To(Equal(helmapp.Namespace))
 	// the bundle should have the same labels as the helmapp resource
@@ -268,7 +266,7 @@ func getCondition(fllethelm *fleet.HelmApp, condType string) (genericcondition.G
 	return genericcondition.GenericCondition{}, false
 }
 
-func checkConditionContains(g Gomega, fllethelm *fleet.HelmApp, condType string, status corev1.ConditionStatus, message string) {
+func checkConditionContains(g Gomega, fllethelm *fleet.HelmApp, condType string, status v1.ConditionStatus, message string) {
 	cond, found := getCondition(fllethelm, condType)
 	g.Expect(found).To(BeTrue())
 	g.Expect(cond.Type).To(Equal(condType))
@@ -326,7 +324,7 @@ var _ = Describe("HelmOps controller", func() {
 		var doAfterNamespaceCreated func()
 		JustBeforeEach(func() {
 			os.Setenv("EXPERIMENTAL_HELM_OPS", "true")
-			nsSpec := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}
+			nsSpec := &v1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}
 			err := k8sClient.Create(ctx, nsSpec)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(k8sClient.Create(ctx, &helmapp)).ToNot(HaveOccurred())
@@ -662,7 +660,7 @@ var _ = Describe("HelmOps controller", func() {
 						g,
 						fh,
 						fleet.HelmAppAcceptedCondition,
-						corev1.ConditionFalse,
+						v1.ConditionFalse,
 						"tls: failed to verify certificate: x509: certificate signed by unknown authority",
 					)
 
@@ -757,7 +755,7 @@ var _ = Describe("HelmOps controller", func() {
 						g,
 						fh,
 						fleet.HelmAppAcceptedCondition,
-						corev1.ConditionFalse,
+						v1.ConditionFalse,
 						"error code: 401, response body: Unauthorized",
 					)
 
@@ -822,7 +820,7 @@ var _ = Describe("HelmOps controller", func() {
 						g,
 						fh,
 						fleet.HelmAppAcceptedCondition,
-						corev1.ConditionFalse,
+						v1.ConditionFalse,
 						"error code: 401, response body: Unauthorized",
 					)
 
diff --git a/internal/bundlereader/helm_test.go b/internal/bundlereader/helm_test.go
index fe3d6a2a..1c2783c5 100644
--- a/internal/bundlereader/helm_test.go
+++ b/internal/bundlereader/helm_test.go
@@ -211,10 +211,11 @@ func newTLSServer(index string, withAuth bool) *httptest.Server {
 		}
 
 		w.WriteHeader(http.StatusOK)
-		if r.URL.Path == "/index.yaml" {
-			index = strings.Replace(index, "##URL##", r.Host, -1)
+		switch r.URL.Path {
+		case "/index.yaml":
+			index = strings.ReplaceAll(index, "##URL##", r.Host)
 			fmt.Fprint(w, index)
-		} else if r.URL.Path == "/sleeper-chart-0.1.0.tgz" {
+		case "/sleeper-chart-0.1.0.tgz":
 			dir, chartPath, err := createHelmChartGZIP()
 			if dir != "" {
 				defer os.RemoveAll(dir)
@@ -399,12 +400,12 @@ func TestGetManifestFromHelmChart(t *testing.T) {
 
 		resourcePrefix := ""
 		if c.bd.Spec.Options.Helm != nil {
-			c.bd.Spec.Options.Helm.Repo = strings.Replace(c.bd.Spec.Options.Helm.Repo, "##URL##", srv.URL, -1)
+			c.bd.Spec.Options.Helm.Repo = strings.ReplaceAll(c.bd.Spec.Options.Helm.Repo, "##URL##", srv.URL)
 			// resource names have a prefix that depends on helm options
 			resourcePrefix = checksumPrefix(c.bd.Spec.Options.Helm)
 		}
 		// change the url in the error in case it is present
-		c.expectedError = strings.Replace(c.expectedError, "##URL##", srv.URL, -1)
+		c.expectedError = strings.ReplaceAll(c.expectedError, "##URL##", srv.URL)
 
 		manifest, err := bundlereader.GetManifestFromHelmChart(context.TODO(), mockClient, &c.bd)
 
diff --git a/internal/cmd/controller/gitops/reconciler/restrictions_test.go b/internal/cmd/controller/gitops/reconciler/restrictions_test.go
index 7d6bc19f..c0d66300 100644
--- a/internal/cmd/controller/gitops/reconciler/restrictions_test.go
+++ b/internal/cmd/controller/gitops/reconciler/restrictions_test.go
@@ -17,7 +17,6 @@ import (
 	"github.com/rancher/fleet/internal/cmd/controller/gitops/reconciler"
 	"github.com/rancher/fleet/internal/mocks"
 	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
-	fleetv1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
 )
 
 func TestAuthorizeAndAssignDefaults(t *testing.T) {
@@ -223,7 +222,7 @@ func TestAuthorizeAndAssignDefaults(t *testing.T) {
 			client := mocks.NewMockClient(mockCtrl)
 
 			client.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn(
-				func(_ context.Context, rl *fleetv1.GitRepoRestrictionList, ns crclient.InNamespace) error {
+				func(_ context.Context, rl *fleet.GitRepoRestrictionList, ns crclient.InNamespace) error {
 					if c.restrictions != nil && len(c.restrictions.Items) > 0 {
 						rl.Items = c.restrictions.Items
 					}
diff --git a/pkg/webhook/parser_test.go b/pkg/webhook/parser_test.go
new file mode 100644
index 00000000..7390a1fa
--- /dev/null
+++ b/pkg/webhook/parser_test.go
@@ -0,0 +1,1274 @@
+package webhook
+
+import (
+	"bytes"
+	"net/http"
+	"testing"
+
+	"github.com/go-playground/webhooks/v6/azuredevops"
+	gogsclient "github.com/gogits/go-gogs-client"
+	"gopkg.in/go-playground/webhooks.v5/bitbucket"
+	bitbucketserver "gopkg.in/go-playground/webhooks.v5/bitbucket-server"
+	"gopkg.in/go-playground/webhooks.v5/github"
+	"gopkg.in/go-playground/webhooks.v5/gitlab"
+	"gotest.tools/assert"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+	"k8s.io/kubectl/pkg/scheme"
+)
+
+func TestParseGogs(t *testing.T) {
+	tests := map[string]struct {
+		secretData map[string][]byte
+		body       []byte
+		headers    map[string]string
+		wantErr    bool
+		wantErrMsg string
+		wantEvent  interface{}
+	}{
+		"valid-gogs-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://gogs.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gogs-Event": "push",
+			},
+			wantErr: false,
+			wantEvent: gogsclient.PushPayload{
+				Ref:   "refs/heads/main",
+				After: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Repo: &gogsclient.Repository{
+					HTMLURL: "https://gogs.example.com/example/repo",
+				},
+			},
+		},
+		"valid-gogs-push-event-with-secret": {
+			secretData: map[string][]byte{
+				gogsKey: []byte("gogssecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://gogs.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gogs-Event":     "push",
+				"X-Gogs-Signature": "bd00de14f93a8bef25c89bb3c976a1320b3a535515cd66c91ec1e8b9f67a2259",
+			},
+			wantErr: false,
+			wantEvent: gogsclient.PushPayload{
+				Ref:   "refs/heads/main",
+				After: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Repo: &gogsclient.Repository{
+					HTMLURL: "https://gogs.example.com/example/repo",
+				},
+			},
+		},
+		"invalid-gogs-push-event-with-secret": {
+			secretData: map[string][]byte{
+				gogsKey: []byte("gogssecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://gogs.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gogs-Event":     "push",
+				"X-Gogs-Signature": "wrongsignature",
+			},
+			wantErr:    true,
+			wantErrMsg: "HMAC verification failed",
+			wantEvent:  nil,
+		},
+		"missing-gogs-secret": {
+			secretData: map[string][]byte{
+				"wrongkey": []byte("gogssecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://gogs.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gogs-Event":     "push",
+				"X-Gogs-Signature": "3453557968570459352679905f2372454008f9015101a",
+			},
+			wantErr:    true,
+			wantErrMsg: "secret key \"gogs\" not found in secret \"test-secret\"",
+			wantEvent:  nil,
+		},
+		"no-gogs-event": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://gogs.example.com/example/repo"
+				}
+			}`),
+			headers:    map[string]string{},
+			wantErr:    true,
+			wantErrMsg: "missing X-Gogs-Event Header",
+			wantEvent:  nil,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseGogs(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseGogs() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantEvent == nil {
+				if got != nil {
+					t.Fatalf("parseGogs() = %v, want %v", got, tt.wantEvent)
+				}
+			} else {
+				assert.DeepEqual(t, got, tt.wantEvent)
+			}
+		})
+	}
+}
+
+func TestParseGithub(t *testing.T) {
+	utilruntime.Must(corev1.AddToScheme(scheme.Scheme))
+
+	tests := map[string]struct {
+		secretData   map[string][]byte
+		body         []byte
+		headers      map[string]string
+		wantErr      bool
+		wantErrMsg   string
+		wantNilEvent bool
+		wantRef      string
+		wantAfter    string
+		wantRepoURL  string
+	}{
+		"valid-github-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://github.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-GitHub-Event": "push",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRef:      "refs/heads/main",
+			wantAfter:    "af69d162de5a276abc86e0686b2b44033cd3f442",
+			wantRepoURL:  "https://github.com/example/repo",
+		},
+		"valid-github-push-event-with-secret": {
+			secretData: map[string][]byte{
+				githubKey: []byte("githubsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://github.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-GitHub-Event":  "push",
+				"X-Hub-Signature": "sha1=dba820f85951e0f100549aa167ef67dcd989ca4a",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRef:      "refs/heads/main",
+			wantAfter:    "af69d162de5a276abc86e0686b2b44033cd3f442",
+			wantRepoURL:  "https://github.com/example/repo",
+		},
+		"invalid-github-push-event-with-secret": {
+			secretData: map[string][]byte{
+				githubKey: []byte("githubsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://github.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-GitHub-Event":  "push",
+				"X-Hub-Signature": "sha1=wrongsignature",
+			},
+			wantErr:      true,
+			wantErrMsg:   "HMAC verification failed",
+			wantNilEvent: true,
+		},
+		"missing-github-secret": {
+			secretData: map[string][]byte{
+				"wrongkey": []byte("githubsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://github.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-GitHub-Event":  "push",
+				"X-Hub-Signature": "sha1=57968570459352679905f2372454008f9015101a",
+			},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "secret key \"github\" not found in secret \"test-secret\"",
+		},
+		"no-github-event": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"after": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"repository": {
+					"html_url": "https://github.com/example/repo"
+				}
+			}`),
+			headers:      map[string]string{},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "missing X-GitHub-Event Header",
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseGithub(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseGithub() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantNilEvent {
+				if got != nil {
+					t.Fatalf("parseGithub() got %v, want nil", got)
+				}
+			} else {
+				if got == nil {
+					t.Fatalf("parseGithub() got nil, want not nil")
+				}
+				gotGH, ok := got.(github.PushPayload)
+				if !ok {
+					t.Fatalf("parseGithub() got %T, want github.PushPayload", got)
+				}
+
+				if gotGH.Ref != tt.wantRef {
+					t.Fatalf("parseGithub() got ref %s, want %s", gotGH.Ref, tt.wantRef)
+				}
+				if gotGH.After != tt.wantAfter {
+					t.Fatalf("parseGithub() got after %s, want %s", gotGH.After, tt.wantAfter)
+				}
+				if gotGH.Repository.HTMLURL != tt.wantRepoURL {
+					t.Fatalf("parseGithub() got repo URL %s, want %s", gotGH.Repository.HTMLURL, tt.wantRepoURL)
+				}
+			}
+		})
+	}
+}
+
+func TestParseGitlab(t *testing.T) {
+	utilruntime.Must(corev1.AddToScheme(scheme.Scheme))
+
+	tests := map[string]struct {
+		secretData map[string][]byte
+		body       []byte
+		headers    map[string]string
+		wantErr    bool
+		wantErrMsg string
+		wantEvent  interface{}
+	}{
+		"valid-gitlab-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+			},
+			wantErr: false,
+			wantEvent: gitlab.PushEventPayload{
+				Ref:         "refs/heads/main",
+				CheckoutSHA: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Project: gitlab.Project{
+					WebURL: "https://gitlab.example.com/example/repo",
+				},
+			},
+		},
+		"valid-gitlab-push-event-with-secret": {
+			secretData: map[string][]byte{
+				gitlabKey: []byte("gitlabsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+				"X-Gitlab-Token": "gitlabsecret",
+			},
+			wantErr: false,
+			wantEvent: gitlab.PushEventPayload{
+				Ref:         "refs/heads/main",
+				CheckoutSHA: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Project: gitlab.Project{
+					WebURL: "https://gitlab.example.com/example/repo",
+				},
+			},
+		},
+		"invalid-gitlab-push-event-with-secret": {
+			secretData: map[string][]byte{
+				gitlabKey: []byte("gitlabsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+				"X-Gitlab-Token": "wrongsecret",
+			},
+			wantErr:    true,
+			wantErrMsg: "X-Gitlab-Token validation failed",
+			wantEvent:  nil,
+		},
+		"missing-gitlab-secret": {
+			secretData: map[string][]byte{
+				"wrongkey": []byte("gitlabsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Push Hook",
+				"X-Gitlab-Token": "gitlabsecret",
+			},
+			wantErr:    true,
+			wantErrMsg: "secret key \"gitlab\" not found in secret \"test-secret\"",
+			wantEvent:  nil,
+		},
+		"no-gitlab-event": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/heads/main",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers:    map[string]string{},
+			wantErr:    true,
+			wantErrMsg: "missing X-Gitlab-Event Header",
+			wantEvent:  nil,
+		},
+		"valid-gitlab-tag-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"ref": "refs/tags/v1.0.0",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Tag Push Hook",
+			},
+			wantErr: false,
+			wantEvent: gitlab.TagEventPayload{
+				Ref:         "refs/tags/v1.0.0",
+				CheckoutSHA: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Project: gitlab.Project{
+					WebURL: "https://gitlab.example.com/example/repo",
+				},
+			},
+		},
+		"valid-gitlab-tag-event-with-secret": {
+			secretData: map[string][]byte{
+				gitlabKey: []byte("gitlabsecret"),
+			},
+			body: []byte(`{
+				"ref": "refs/tags/v1.0.0",
+				"checkout_sha": "af69d162de5a276abc86e0686b2b44033cd3f442",
+				"project": {
+					"web_url": "https://gitlab.example.com/example/repo"
+				}
+			}`),
+			headers: map[string]string{
+				"X-Gitlab-Event": "Tag Push Hook",
+				"X-Gitlab-Token": "gitlabsecret",
+			},
+			wantErr: false,
+			wantEvent: gitlab.TagEventPayload{
+				Ref:         "refs/tags/v1.0.0",
+				CheckoutSHA: "af69d162de5a276abc86e0686b2b44033cd3f442",
+				Project: gitlab.Project{
+					WebURL: "https://gitlab.example.com/example/repo",
+				},
+			},
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseGitlab(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseGitlab() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantEvent == nil {
+				if got != nil {
+					t.Fatalf("parseGitlab() = %v, want %v", got, tt.wantEvent)
+				}
+			} else {
+				assert.DeepEqual(t, got, tt.wantEvent)
+			}
+		})
+	}
+}
+
+func TestParseBitbucket(t *testing.T) {
+	utilruntime.Must(corev1.AddToScheme(scheme.Scheme))
+
+	tests := map[string]struct {
+		secretData   map[string][]byte
+		body         []byte
+		headers      map[string]string
+		wantErr      bool
+		wantErrMsg   string
+		wantNilEvent bool
+		wantRepoURL  string
+		wantBranch   string
+		wantRevision string
+	}{
+		"valid-bitbucket-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"push": {
+					"changes": [
+						{
+							"new": {
+								"type": "branch",
+								"name": "main",
+								"target": {
+									"hash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+								}
+							}
+						}
+					]
+				},
+				"repository": {
+					"links": {
+						"html": {
+							"href": "https://bitbucket.org/example/repo"
+						}
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Hook-UUID": "some-uuid",
+				"X-Event-Key": "repo:push",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.org/example/repo",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"valid-bitbucket-push-event-with-secret": {
+			secretData: map[string][]byte{
+				bitbucketKey: []byte("some-uuid"),
+			},
+			body: []byte(`{
+				"push": {
+					"changes": [
+						{
+							"new": {
+								"type": "branch",
+								"name": "main",
+								"target": {
+									"hash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+								}
+							}
+						}
+					]
+				},
+				"repository": {
+					"links": {
+						"html": {
+							"href": "https://bitbucket.org/example/repo"
+						}
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Hook-UUID": "some-uuid",
+				"X-Event-Key": "repo:push",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.org/example/repo",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"invalid-bitbucket-push-event-with-secret": {
+			secretData: map[string][]byte{
+				bitbucketKey: []byte("some-uuid"),
+			},
+			body: []byte(`{
+				"push": {
+					"changes": [
+						{
+							"new": {
+								"type": "branch",
+								"name": "main",
+								"target": {
+									"hash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+								}
+							}
+						}
+					]
+				},
+				"repository": {
+					"links": {
+						"html": {
+							"href": "https://bitbucket.org/example/repo"
+						}
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Hook-UUID": "wrong-uuid",
+				"X-Event-Key": "repo:push",
+			},
+			wantErr:      true,
+			wantErrMsg:   "UUID verification failed",
+			wantNilEvent: true,
+		},
+		"missing-bitbucket-secret": {
+			secretData: map[string][]byte{
+				"wrongkey": []byte("some-uuid"),
+			},
+			body: []byte(`{
+				"push": {
+					"changes": [
+						{
+							"new": {
+								"type": "branch",
+								"name": "main",
+								"target": {
+									"hash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+								}
+							}
+						}
+					]
+				},
+				"repository": {
+					"links": {
+						"html": {
+							"href": "https://bitbucket.org/example/repo"
+						}
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Hook-UUID": "some-uuid",
+				"X-Event-Key": "repo:push",
+			},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "secret key \"bitbucket\" not found in secret \"test-secret\"",
+		},
+		"no-bitbucket-event": {
+			secretData:   nil,
+			body:         []byte(`{}`),
+			headers:      map[string]string{},
+			wantErr:      true,
+			wantErrMsg:   "missing X-Event-Key Header",
+			wantNilEvent: true,
+		},
+		"empty-changes": {
+			secretData: nil,
+			body: []byte(`{
+				"push": {
+					"changes": []
+				},
+				"repository": {
+					"links": {
+						"html": {
+							"href": "https://bitbucket.org/example/repo"
+						}
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Hook-UUID": "some-uuid",
+				"X-Event-Key": "repo:push",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.org/example/repo",
+			wantBranch:   "",
+			wantRevision: "",
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseBitbucket(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseBitbucket() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantNilEvent {
+				if got != nil {
+					t.Fatalf("parseBitbucket() got %v, want nil", got)
+				}
+			} else {
+				if got == nil {
+					t.Fatalf("parseBitbucket() got nil, want not nil")
+				}
+				gotBB, ok := got.(bitbucket.RepoPushPayload)
+				if !ok {
+					t.Fatalf("parseBitbucket() got %T, want bitbucket.RepoPushPayload", got)
+				}
+
+				if gotBB.Repository.Links.HTML.Href != tt.wantRepoURL {
+					t.Fatalf("parseBitbucket() got repo URL %s, want %s", gotBB.Repository.Links.HTML.Href, tt.wantRepoURL)
+				}
+				if len(gotBB.Push.Changes) > 0 {
+					if gotBB.Push.Changes[0].New.Name != tt.wantBranch {
+						t.Fatalf("parseBitbucket() got branch %s, want %s", gotBB.Push.Changes[0].New.Name, tt.wantBranch)
+					}
+					if gotBB.Push.Changes[0].New.Target.Hash != tt.wantRevision {
+						t.Fatalf("parseBitbucket() got revision %s, want %s", gotBB.Push.Changes[0].New.Target.Hash, tt.wantRevision)
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestParseBitbucketServer(t *testing.T) {
+	utilruntime.Must(corev1.AddToScheme(scheme.Scheme))
+
+	tests := map[string]struct {
+		secretData   map[string][]byte
+		body         []byte
+		headers      map[string]string
+		wantErr      bool
+		wantErrMsg   string
+		wantNilEvent bool
+		wantRepoURL  string
+		wantBranch   string
+		wantRevision string
+	}{
+		"valid-bitbucket-server-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"eventKey": "repo:refs_changed",
+				"changes": [
+					{
+						"ref": {
+							"id": "refs/heads/main",
+							"type": "BRANCH"
+						},
+						"toHash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+					}
+				],
+				"repository": {
+					"links": {
+						"clone": [
+							{
+								"href": "https://bitbucket.example.com/scm/example/repo.git",
+								"name": "http"
+							},
+							{
+								"href": "ssh://git@bitbucket.example.com:7999/example/repo.git",
+								"name": "ssh"
+							}
+						]
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Event-Key": "repo:refs_changed",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.example.com/scm/example/repo.git",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"valid-bitbucket-server-push-event-with-secret": {
+			secretData: map[string][]byte{
+				bitbucketServerKey: []byte("secret"),
+			},
+			body: []byte(`{
+				"eventKey": "repo:refs_changed",
+				"changes": [
+					{
+						"ref": {
+							"id": "refs/heads/main",
+							"type": "BRANCH"
+						},
+						"toHash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+					}
+				],
+				"repository": {
+					"links": {
+						"clone": [
+							{
+								"href": "https://bitbucket.example.com/scm/example/repo.git",
+								"name": "http"
+							},
+							{
+								"href": "ssh://git@bitbucket.example.com:7999/example/repo.git",
+								"name": "ssh"
+							}
+						]
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Event-Key":     "repo:refs_changed",
+				"X-Hub-Signature": "sha256=31233f258e5af3c0571515b460749da4babc891a1fc6088229d7c2904cad83d1",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.example.com/scm/example/repo.git",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"invalid-bitbucket-server-push-event-with-secret": {
+			secretData: map[string][]byte{
+				bitbucketServerKey: []byte("secret"),
+			},
+			body: []byte(`{
+				"eventKey": "repo:refs_changed",
+				"changes": [
+					{
+						"ref": {
+							"id": "refs/heads/main",
+							"type": "BRANCH"
+						},
+						"toHash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+					}
+				],
+				"repository": {
+					"links": {
+						"clone": [
+							{
+								"href": "https://bitbucket.example.com/scm/example/repo.git",
+								"name": "http"
+							},
+							{
+								"href": "ssh://git@bitbucket.example.com:7999/example/repo.git",
+								"name": "ssh"
+							}
+						]
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Event-Key":     "repo:refs_changed",
+				"X-Hub-Signature": "sha256=wrongsignature",
+			},
+			wantErr:      true,
+			wantErrMsg:   "HMAC verification failed",
+			wantNilEvent: true,
+		},
+		"missing-bitbucket-server-secret": {
+			secretData: map[string][]byte{
+				"wrongkey": []byte("secret"),
+			},
+			body: []byte(`{
+				"eventKey": "repo:refs_changed",
+				"changes": [
+					{
+						"ref": {
+							"id": "refs/heads/main",
+							"type": "BRANCH"
+						},
+						"toHash": "af69d162de5a276abc86e0686b2b44033cd3f442"
+					}
+				],
+				"repository": {
+					"links": {
+						"clone": [
+							{
+								"href": "https://bitbucket.example.com/scm/example/repo.git",
+								"name": "http"
+							},
+							{
+								"href": "ssh://git@bitbucket.example.com:7999/example/repo.git",
+								"name": "ssh"
+							}
+						]
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Event-Key":     "repo:refs_changed",
+				"X-Hub-Signature": "sha256=6043b765891b367c98604263173510004462847074747103258054242901590a",
+			},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "secret key \"bitbucket-server\" not found in secret \"test-secret\"",
+		},
+		"no-bitbucket-server-event": {
+			secretData:   nil,
+			body:         []byte(`{}`),
+			headers:      map[string]string{},
+			wantErr:      true,
+			wantErrMsg:   "missing X-Event-Key Header",
+			wantNilEvent: true,
+		},
+		"empty-changes": {
+			secretData: nil,
+			body: []byte(`{
+				"eventKey": "repo:refs_changed",
+				"changes": [],
+				"repository": {
+					"links": {
+						"clone": [
+							{
+								"href": "https://bitbucket.example.com/scm/example/repo.git",
+								"name": "http"
+							},
+							{
+								"href": "ssh://git@bitbucket.example.com:7999/example/repo.git",
+								"name": "ssh"
+							}
+						]
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Event-Key": "repo:refs_changed",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://bitbucket.example.com/scm/example/repo.git",
+			wantBranch:   "",
+			wantRevision: "",
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseBitbucketServer(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseBitbucketServer() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantNilEvent {
+				if got != nil {
+					t.Fatalf("parseBitbucketServer() got %v, want nil", got)
+				}
+			} else {
+				if got == nil {
+					t.Fatalf("parseBitbucketServer() got nil, want not nil")
+				}
+				gotBB, ok := got.(bitbucketserver.RepositoryReferenceChangedPayload)
+				if !ok {
+					t.Fatalf("parseBitbucketServer() got %T, want bitbucketserver.RepositoryReferenceChangedPayload", got)
+				}
+
+				_, okCloneLinks := gotBB.Repository.Links["clone"]
+				if okCloneLinks {
+					for _, l := range gotBB.Repository.Links["clone"].([]interface{}) {
+						link := l.(map[string]interface{})
+						if link["name"] == "http" {
+							if link["href"].(string) != tt.wantRepoURL {
+								t.Fatalf("parseBitbucketServer() got repo URL %s, want %s", link["href"].(string), tt.wantRepoURL)
+							}
+						}
+					}
+				}
+
+				if len(gotBB.Changes) > 0 {
+					if gotBB.Changes[0].Reference.ID != "refs/heads/"+tt.wantBranch && gotBB.Changes[0].Reference.ID != "refs/tags/"+tt.wantBranch {
+						t.Fatalf("parseBitbucketServer() got branch %s, want %s", gotBB.Changes[0].ReferenceId, tt.wantBranch)
+					}
+					if gotBB.Changes[0].ToHash != tt.wantRevision {
+						t.Fatalf("parseBitbucketServer() got revision %s, want %s", gotBB.Changes[0].ToHash, tt.wantRevision)
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestParseAzureDevops(t *testing.T) {
+	utilruntime.Must(corev1.AddToScheme(scheme.Scheme))
+
+	tests := map[string]struct {
+		secretData   map[string][]byte
+		body         []byte
+		headers      map[string]string
+		wantErr      bool
+		wantErrMsg   string
+		wantNilEvent bool
+		wantRepoURL  string
+		wantBranch   string
+		wantRevision string
+	}{
+		"valid-azure-devops-push-event-no-secret": {
+			secretData: nil,
+			body: []byte(`{
+				"eventType": "git.push",
+				"resource": {
+					"refUpdates": [
+						{
+							"name": "refs/heads/main",
+							"newObjectId": "af69d162de5a276abc86e0686b2b44033cd3f442"
+						}
+					],
+					"repository": {
+						"remoteUrl": "https://dev.azure.com/example/repo/_git/repo"
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Vss-Activityid": "some-activity-id",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://dev.azure.com/example/repo/_git/repo",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"valid-azure-devops-push-event-with-secret": {
+			secretData: map[string][]byte{
+				azureUsername: []byte("Aladdin"),
+				azurePassword: []byte("open sesame"),
+			},
+			body: []byte(`{
+				"eventType": "git.push",
+				"resource": {
+					"refUpdates": [
+						{
+							"name": "refs/heads/main",
+							"newObjectId": "af69d162de5a276abc86e0686b2b44033cd3f442"
+						}
+					],
+					"repository": {
+						"remoteUrl": "https://dev.azure.com/example/repo/_git/repo"
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Vss-Activityid": "some-activity-id",
+				"Authorization":    "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://dev.azure.com/example/repo/_git/repo",
+			wantBranch:   "main",
+			wantRevision: "af69d162de5a276abc86e0686b2b44033cd3f442",
+		},
+		"missing-azure-devops-username-secret": {
+			secretData: map[string][]byte{
+				"wrongkey":    []byte("testuser"),
+				azurePassword: []byte("testpassword"),
+			},
+			body: []byte(`{
+				"eventType": "git.push",
+				"resource": {
+					"refUpdates": [
+						{
+							"name": "refs/heads/main",
+							"newObjectId": "af69d162de5a276abc86e0686b2b44033cd3f442"
+						}
+					],
+					"repository": {
+						"remoteUrl": "https://dev.azure.com/example/repo/_git/repo"
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Vss-Activityid": "some-activity-id",
+			},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "secret key \"azure-username\" not found in secret \"test-secret\"",
+		},
+		"missing-azure-devops-password-secret": {
+			secretData: map[string][]byte{
+				azureUsername: []byte("testuser"),
+				"wrongkey":    []byte("testpassword"),
+			},
+			body: []byte(`{
+				"eventType": "git.push",
+				"resource": {
+					"refUpdates": [
+						{
+							"name": "refs/heads/main",
+							"newObjectId": "af69d162de5a276abc86e0686b2b44033cd3f442"
+						}
+					],
+					"repository": {
+						"remoteUrl": "https://dev.azure.com/example/repo/_git/repo"
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Vss-Activityid": "some-activity-id",
+			},
+			wantErr:      true,
+			wantNilEvent: true,
+			wantErrMsg:   "secret key \"azure-password\" not found in secret \"test-secret\"",
+		},
+		"no-azure-devops-event": {
+			secretData:   nil,
+			body:         []byte(`{}`),
+			headers:      map[string]string{},
+			wantErr:      true,
+			wantErrMsg:   "unknown event ",
+			wantNilEvent: true,
+		},
+		"empty-refUpdates": {
+			secretData: nil,
+			body: []byte(`{
+				"eventType": "git.push",
+				"resource": {
+					"refUpdates": [],
+					"repository": {
+						"remoteUrl": "https://dev.azure.com/example/repo/_git/repo"
+					}
+				}
+			}`),
+			headers: map[string]string{
+				"X-Vss-Activityid": "some-activity-id",
+			},
+			wantErr:      false,
+			wantNilEvent: false,
+			wantRepoURL:  "https://dev.azure.com/example/repo/_git/repo",
+			wantBranch:   "",
+			wantRevision: "",
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			var secret *corev1.Secret
+			if tt.secretData != nil {
+				secret = &corev1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:      "test-secret",
+						Namespace: "test-ns",
+					},
+					Data: tt.secretData,
+				}
+			}
+
+			req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(tt.body))
+			if err != nil {
+				t.Fatalf("Failed to create HTTP request: %v", err)
+			}
+
+			for k, v := range tt.headers {
+				req.Header.Set(k, v)
+			}
+
+			got, err := parseAzureDevops(req, secret)
+
+			if tt.wantErr {
+				assert.Error(t, err, tt.wantErrMsg)
+				return
+			}
+
+			if err != nil {
+				t.Fatalf("parseAzureDevops() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if tt.wantNilEvent {
+				if got != nil {
+					t.Fatalf("parseAzureDevops() got %v, want nil", got)
+				}
+			} else {
+				if got == nil {
+					t.Fatalf("parseAzureDevops() got nil, want not nil")
+				}
+				gotAzure, ok := got.(azuredevops.GitPushEvent)
+				if !ok {
+					t.Fatalf("parseAzureDevops() got %T, want azuredevops.GitPushEvent", got)
+				}
+
+				if gotAzure.Resource.Repository.RemoteURL != tt.wantRepoURL {
+					t.Fatalf("parseAzureDevops() got repo URL %s, want %s", gotAzure.Resource.Repository.RemoteURL, tt.wantRepoURL)
+				}
+				if len(gotAzure.Resource.RefUpdates) > 0 {
+					if gotAzure.Resource.RefUpdates[0].Name != "refs/heads/"+tt.wantBranch && gotAzure.Resource.RefUpdates[0].Name != "refs/tags/"+tt.wantBranch {
+						t.Fatalf("parseAzureDevops() got branch %s, want %s", gotAzure.Resource.RefUpdates[0].Name, tt.wantBranch)
+					}
+					if gotAzure.Resource.RefUpdates[0].NewObjectID != tt.wantRevision {
+						t.Fatalf("parseAzureDevops() got revision %s, want %s", gotAzure.Resource.RefUpdates[0].NewObjectID, tt.wantRevision)
+					}
+				}
+			}
+		})
+	}
+}
diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go
index dd43c25e..c2aea9e8 100644
--- a/pkg/webhook/webhook_test.go
+++ b/pkg/webhook/webhook_test.go
@@ -10,7 +10,9 @@ import (
 	"time"
 
 	"go.uber.org/mock/gomock"
+	"k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/kubectl/pkg/scheme"
 
@@ -22,7 +24,9 @@ import (
 	"gopkg.in/go-playground/webhooks.v5/github"
 	"gopkg.in/go-playground/webhooks.v5/gitlab"
 	"gopkg.in/go-playground/webhooks.v5/gogs"
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	cfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
 
@@ -33,6 +37,12 @@ import (
 	"gotest.tools/assert"
 )
 
+type errReader int
+
+func (errReader) Read(p []byte) (n int, err error) {
+	return 0, fmt.Errorf("ERROR READING")
+}
+
 func TestGetBranchTagFromRef(t *testing.T) {
 	inputs := []string{
 		"refs/heads/master",
@@ -72,13 +82,11 @@ func TestAzureDevopsWebhook(t *testing.T) {
 		},
 	}
 	scheme := runtime.NewScheme()
-	err := v1alpha1.AddToScheme(scheme)
-	if err != nil {
-		t.Errorf("unexpected error %v", err)
-	}
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(v1alpha1.AddToScheme(scheme))
+
 	client := cfake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gitRepo).WithStatusSubresource(gitRepo).Build()
 	w := &Webhook{client: client}
-	w.azureDevops, _ = azuredevops.New()
 	jsonBody := []byte(`{"subscriptionId":"xxx","notificationId":1,"id":"xxx","eventType":"git.push","publisherId":"tfs","message":{"text":"commit pushed","html":"commit pushed"},"detailedMessage":{"text":"pushed a commit to git-test"},"resource":{"commits":[{"commitId":"` + commit + `","author":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"committer":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"comment":"test commit","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/commits/f00c3a181697bb3829a6462e931c7456bbed557b"}],"refUpdates":[{"name":"refs/heads/main","oldObjectId":"135f8a827edae980466f72eef385881bb4e158d8","newObjectId":"` + commit + `"}],"repository":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx","project":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/projects/xxx","state":"wellFormed","visibility":"unchanged","lastUpdateTime":"0001-01-01T00:00:00"},"defaultBranch":"refs/heads/main","remoteUrl":"` + repoURL + `"},"pushedBy":{"displayName":"Fleet","url":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx","_links":{"avatar":{"href":"https://dev.azure.com/fleet/_apis/GraphProfile/MemberAvatars/msa.xxxx"}},"id":"xxx","uniqueName":"fleet@suse.com","imageUrl":"https://dev.azure.com/fleet/_api/_common/identityImage?id=xxx","descriptor":"xxxx"},"pushId":22,"date":"2024-01-05T10:17:18.735088Z","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22","_links":{"self":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22"},"repository":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx"},"commits":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22/commits"},"pusher":{"href":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx"},"refs":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx/refs/heads/main"}}},"resourceVersion":"1.0","resourceContainers":{"collection":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"},"account":{"id":"ec365173-fce3-4dfc-8fc2-950f0b5728b1","baseUrl":"https://dev.azure.com/fleet/"},"project":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"}},"createdDate":"2024-01-05T10:17:26.0098694Z"}`)
 	bodyReader := bytes.NewReader(jsonBody)
 	req, err := http.NewRequest(http.MethodPost, repoURL, bodyReader)
@@ -118,13 +126,11 @@ func TestAzureDevopsWebhookWithSSHURL(t *testing.T) {
 		},
 	}
 	scheme := runtime.NewScheme()
-	err := v1alpha1.AddToScheme(scheme)
-	if err != nil {
-		t.Errorf("unexpected error %v", err)
-	}
+	utilruntime.Must(corev1.AddToScheme(scheme))
+	utilruntime.Must(v1alpha1.AddToScheme(scheme))
+
 	client := cfake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gitRepo).WithStatusSubresource(gitRepo).Build()
 	w := &Webhook{client: client}
-	w.azureDevops, _ = azuredevops.New()
 	jsonBody := []byte(`{"subscriptionId":"xxx","notificationId":1,"id":"xxx","eventType":"git.push","publisherId":"tfs","message":{"text":"commit pushed","html":"commit pushed"},"detailedMessage":{"text":"pushed a commit to git-test"},"resource":{"commits":[{"commitId":"` + commit + `","author":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"committer":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"comment":"test commit","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/commits/f00c3a181697bb3829a6462e931c7456bbed557b"}],"refUpdates":[{"name":"refs/heads/main","oldObjectId":"135f8a827edae980466f72eef385881bb4e158d8","newObjectId":"` + commit + `"}],"repository":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx","project":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/projects/xxx","state":"wellFormed","visibility":"unchanged","lastUpdateTime":"0001-01-01T00:00:00"},"defaultBranch":"refs/heads/main","remoteUrl":"` + responseRemoteURL + `"},"pushedBy":{"displayName":"Fleet","url":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx","_links":{"avatar":{"href":"https://dev.azure.com/fleet/_apis/GraphProfile/MemberAvatars/msa.xxxx"}},"id":"xxx","uniqueName":"fleet@suse.com","imageUrl":"https://dev.azure.com/fleet/_api/_common/identityImage?id=xxx","descriptor":"xxxx"},"pushId":22,"date":"2024-01-05T10:17:18.735088Z","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22","_links":{"self":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22"},"repository":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx"},"commits":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22/commits"},"pusher":{"href":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx"},"refs":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx/refs/heads/main"}}},"resourceVersion":"1.0","resourceContainers":{"collection":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"},"account":{"id":"ec365173-fce3-4dfc-8fc2-950f0b5728b1","baseUrl":"https://dev.azure.com/fleet/"},"project":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"}},"createdDate":"2024-01-05T10:17:26.0098694Z"}`)
 	bodyReader := bytes.NewReader(jsonBody)
 	req, err := http.NewRequest(http.MethodPost, responseRemoteURL, bodyReader)
@@ -175,10 +181,9 @@ func TestGitHubPingWebhook(t *testing.T) {
 
 	// Kubernetes scheme and client configuration
 	sch := scheme.Scheme
-	err := v1alpha1.AddToScheme(sch)
-	if err != nil {
-		t.Fatalf("unable to add to scheme: %v", err)
-	}
+	utilruntime.Must(corev1.AddToScheme(sch))
+	utilruntime.Must(v1alpha1.AddToScheme(sch))
+
 	client := cfake.NewClientBuilder().WithScheme(sch).WithRuntimeObjects(gitRepo).Build()
 
 	// Webhook initialisation
@@ -187,8 +192,6 @@ func TestGitHubPingWebhook(t *testing.T) {
 		namespace: "default",
 	}
 
-	w.github, _ = github.New(github.Options.Secret(""))
-
 	// JSON payload for the ping event
 	jsonBody := []byte(fmt.Sprintf(`{
 		"zen": "%s",
@@ -315,13 +318,23 @@ func TestGitHubWrongSecret(t *testing.T) {
 		},
 	}
 
+	secret := &corev1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      webhookSecretName,
+			Namespace: "default",
+		},
+		Data: map[string][]byte{
+			"github": []byte("badsecret"),
+		},
+	}
+
 	// Kubernetes scheme and client configuration
 	sch := scheme.Scheme
 	err := v1alpha1.AddToScheme(sch)
 	if err != nil {
 		t.Fatalf("unable to add to scheme: %v", err)
 	}
-	client := cfake.NewClientBuilder().WithScheme(sch).WithRuntimeObjects(gitRepo).Build()
+	client := cfake.NewClientBuilder().WithScheme(sch).WithRuntimeObjects(gitRepo, secret).Build()
 
 	// Webhook initialisation
 	w := &Webhook{
@@ -329,10 +342,14 @@ func TestGitHubWrongSecret(t *testing.T) {
 		namespace: "default",
 	}
 
-	w.github, _ = github.New(github.Options.Secret("badsecret"))
-
-	// The secret header check is not going to pass so we don't need any particular payload
-	jsonBody := []byte("{}")
+	jsonBody := []byte(`
+	{
+	  "ref":"refs/heads/main",
+	  "after":"af69d162de5a276abc86e0686b2b44033cd3f442",
+	  "repository":{
+		"html_url":"https://github.com/example/repo"
+      }
+    }`)
 
 	// Request creation
 	req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(jsonBody))
@@ -356,83 +373,308 @@ func TestGitHubWrongSecret(t *testing.T) {
 	}
 }
 
-func TestGitHubRightSecretAndCommitUpdated(t *testing.T) {
+func TestGitHubSecretAndCommitUpdated(t *testing.T) {
 	expectedCommit := "af69d162de5a276abc86e0686b2b44033cd3f442"
-
-	ctlr := gomock.NewController(t)
-	mockClient := mocks.NewMockClient(ctlr)
-
-	gitRepo := &v1alpha1.GitRepo{
-		ObjectMeta: metav1.ObjectMeta{
-			Name: "test",
+	gitrepoSecretName := "gitrepoSecret"
+	tests := map[string]struct {
+		secretValueInRequest string
+		globalSecret         bool
+		gitrepoSecret        bool
+		globalSecretKey      string
+		globalSecretValue    string
+		gitrepoSecretKey     string
+		gitrepoSecretValue   string
+		setSecretInGitrepo   bool
+		expectedResCode      int
+		expectedCommitUpdate bool
+	}{
+		"global-secret-ok-no-gitrepo-secret": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        false,
+			globalSecretKey:      "github",
+			globalSecretValue:    "supersecretvalue",
+			gitrepoSecretKey:     "",
+			gitrepoSecretValue:   "",
+			setSecretInGitrepo:   false,
+			expectedResCode:      http.StatusOK,
+			expectedCommitUpdate: true,
 		},
-		Spec: v1alpha1.GitRepoSpec{
-			Repo:   "https://github.com/example/repo",
-			Branch: "main",
+		"global-secret-wrong-no-gitrepo-secret": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        false,
+			globalSecretKey:      "github",
+			globalSecretValue:    "bad-secret",
+			gitrepoSecretKey:     "",
+			gitrepoSecretValue:   "",
+			setSecretInGitrepo:   false,
+			expectedResCode:      http.StatusUnauthorized,
+			expectedCommitUpdate: false,
 		},
-	}
-
-	// List GitRepos mock call
-	mockClient.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn(
-		func(ctx context.Context, list *v1alpha1.GitRepoList, opts ...client.ListOption) error {
-			list.Items = append(list.Items, *gitRepo)
-			return nil
+		"global-secret-ok-gitrepo-secret-ok": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        true,
+			globalSecretKey:      "github",
+			globalSecretValue:    "supersecretvalue",
+			gitrepoSecretKey:     "github",
+			gitrepoSecretValue:   "supersecretvalue",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusOK,
+			expectedCommitUpdate: true,
 		},
-	)
-	// Get GitRepo mock call
-	mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
-	statusClient := mocks.NewMockSubResourceWriter(ctlr)
-
-	// Status().Update() mock call
-	mockClient.EXPECT().Status().Return(statusClient).Times(1)
-	statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()).Do(
-		func(ctx context.Context, repo *v1alpha1.GitRepo, _ client.Patch, opts ...interface{}) {
-			// check that the commit is the expected one
-			if repo.Status.WebhookCommit != expectedCommit {
-				t.Errorf("expecting gitrepo webhook commit %s, got %s", expectedCommit, repo.Status.WebhookCommit)
-			}
-			if repo.Spec.PollingInterval.Duration != time.Hour {
-				t.Errorf("expecting gitrepo polling interval 1h, got %s", repo.Spec.PollingInterval.Duration)
-			}
+		// does not matter that global secret is wrong because
+		// gitrepo secret takes preference
+		"global-secret-wrong-gitrepo-secret-ok": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        true,
+			globalSecretKey:      "github",
+			globalSecretValue:    "bad-secret",
+			gitrepoSecretKey:     "github",
+			gitrepoSecretValue:   "supersecretvalue",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusOK,
+			expectedCommitUpdate: true,
+		},
+		// does not matter that global secret is correct because
+		// gitrepo secret takes preference
+		"global-secret-ok-gitrepo-secret-wrong": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        true,
+			globalSecretKey:      "github",
+			globalSecretValue:    "supersecretvalue",
+			gitrepoSecretKey:     "github",
+			gitrepoSecretValue:   "bad-secret",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusUnauthorized,
+			expectedCommitUpdate: false,
+		},
+		"no-global-secret-gitrepo-secret-wrong": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         false,
+			gitrepoSecret:        true,
+			globalSecretKey:      "",
+			globalSecretValue:    "",
+			gitrepoSecretKey:     "github",
+			gitrepoSecretValue:   "bad-secret",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusUnauthorized,
+			expectedCommitUpdate: false,
+		},
+		"no-global-secret-gitrepo-secret-ok": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         false,
+			gitrepoSecret:        true,
+			globalSecretKey:      "",
+			globalSecretValue:    "",
+			gitrepoSecretKey:     "github",
+			gitrepoSecretValue:   "supersecretvalue",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusOK,
+			expectedCommitUpdate: true,
+		},
+		"global-secret-wrong-key-no-gitrepo-secret": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        false,
+			globalSecretKey:      "github-bad-key",
+			globalSecretValue:    "supersecretvalue",
+			gitrepoSecretKey:     "",
+			gitrepoSecretValue:   "",
+			setSecretInGitrepo:   false,
+			expectedResCode:      http.StatusInternalServerError,
+			expectedCommitUpdate: false,
+		},
+		// does not matter that global secret is ok
+		// because gitrepo secret takes preference
+		"global-secret-ok-gitrepo-secret-wrong-key": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         true,
+			gitrepoSecret:        true,
+			globalSecretKey:      "github",
+			globalSecretValue:    "supersecretvalue",
+			gitrepoSecretKey:     "github-bad-key",
+			gitrepoSecretValue:   "supersecretvalue",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusInternalServerError,
+			expectedCommitUpdate: false,
+		},
+		// when no secret is defined we accept the payload
+		"no-global-secret-no-gitrepo-secret": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         false,
+			gitrepoSecret:        false,
+			globalSecretKey:      "",
+			globalSecretValue:    "",
+			gitrepoSecretKey:     "",
+			gitrepoSecretValue:   "",
+			setSecretInGitrepo:   false,
+			expectedResCode:      http.StatusOK,
+			expectedCommitUpdate: true,
 		},
-	).Times(1)
 
-	w := &Webhook{
-		client:    mockClient,
-		namespace: "default",
+		// when a referenced secret does not exist, we throw an error
+		"no-global-secret-no-gitrepo-secret-secret-in-gitrepo": {
+			secretValueInRequest: "supersecretvalue",
+			globalSecret:         false,
+			gitrepoSecret:        false,
+			globalSecretKey:      "",
+			globalSecretValue:    "",
+			gitrepoSecretKey:     "",
+			gitrepoSecretValue:   "",
+			setSecretInGitrepo:   true,
+			expectedResCode:      http.StatusInternalServerError,
+			expectedCommitUpdate: false,
+		},
 	}
+	for _, tt := range tests {
+		ctlr := gomock.NewController(t)
+		mockClient := mocks.NewMockClient(ctlr)
+
+		gitRepo := &v1alpha1.GitRepo{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "test",
+				Namespace: "gitrepoNamespace",
+			},
+			Spec: v1alpha1.GitRepoSpec{
+				Repo:   "https://github.com/example/repo",
+				Branch: "main",
+			},
+		}
 
-	w.github, _ = github.New(github.Options.Secret(""))
+		if tt.setSecretInGitrepo {
+			gitRepo.Spec.WebhookSecret = gitrepoSecretName
+		}
 
-	// we set only the values that we're going to use in the push event to make things simple
-	jsonBody := []byte(fmt.Sprintf(`
-	{
-	  "ref":"refs/heads/main",
-	  "after":"%s",
-	  "repository":{
-		"html_url":"https://github.com/example/repo"
-      }
-    }`, expectedCommit))
+		// List GitRepos mock call
+		mockClient.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn(
+			func(ctx context.Context, list *v1alpha1.GitRepoList, opts ...client.ListOption) error {
+				list.Items = append(list.Items, *gitRepo)
+				return nil
+			},
+		)
+
+		// call for secret
+		mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+			func(ctx context.Context, name types.NamespacedName, secret *corev1.Secret, _ ...interface{}) error {
+				// check that we're calling Get with the expected name and Namespace
+				if tt.gitrepoSecret {
+					if name.Name != gitrepoSecretName {
+						t.Errorf("expecting calling secret Get with secret name %q, got %q", gitrepoSecretName, name.Name)
+					}
+					if name.Namespace != gitRepo.Namespace {
+						t.Errorf("expecting calling secret Get with secret namespace %q, got %q", gitRepo.Namespace, name.Namespace)
+					}
+				} else if tt.globalSecret {
+					// it expects the global secret name
+					if name.Name != webhookSecretName {
+						t.Errorf("expecting calling secret Get with secret name %q, got %q", webhookSecretName, name.Name)
+					}
+					// we're using "default" as the namespace for the webhook
+					if name.Namespace != "default" {
+						t.Errorf("expecting calling secret Get with secret namespace %q, got %q", "default", name.Namespace)
+					}
+				}
+				if tt.gitrepoSecret {
+					secret.Data = map[string][]byte{
+						tt.gitrepoSecretKey: []byte(tt.gitrepoSecretValue),
+					}
+					return nil
+				} else if tt.globalSecret {
+					secret.Data = map[string][]byte{
+						tt.globalSecretKey: []byte(tt.globalSecretValue),
+					}
+					return nil
+				}
+
+				// if no secret
+				return errors.NewNotFound(schema.GroupResource{}, "")
+			}).Times(1)
+
+		// Status().Update() mock call
+		if tt.expectedCommitUpdate {
+			mockClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+				func(ctx context.Context, name types.NamespacedName, gitrepo *v1alpha1.GitRepo, _ ...interface{}) error {
+					return nil
+				})
+			statusClient := mocks.NewMockSubResourceWriter(ctlr)
+
+			mockClient.EXPECT().Status().Return(statusClient).Times(1)
+			statusClient.EXPECT().Patch(gomock.Any(), gomock.Any(), gomock.Any()).Do(
+				func(ctx context.Context, repo *v1alpha1.GitRepo, _ client.Patch, opts ...interface{}) {
+					// check that the commit is the expected one
+					if repo.Status.WebhookCommit != expectedCommit {
+						t.Errorf("expecting gitrepo webhook commit %s, got %s", expectedCommit, repo.Status.WebhookCommit)
+					}
+					if repo.Spec.PollingInterval.Duration != time.Hour {
+						t.Errorf("expecting gitrepo polling interval 1h, got %s", repo.Spec.PollingInterval.Duration)
+					}
+				},
+			).Times(1)
+		}
 
-	// Request creation
-	req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(jsonBody))
-	if err != nil {
-		t.Fatalf("Failed to create HTTP request: %v", err)
+		w := &Webhook{
+			client:    mockClient,
+			namespace: "default",
+		}
+
+		// we set only the values that we're going to use in the push event to make things simple
+		jsonBody := []byte(fmt.Sprintf(`
+		{
+		  "ref":"refs/heads/main",
+		  "after":"%s",
+		  "repository":{
+			"html_url":"https://github.com/example/repo"
+		  }
+		}`, expectedCommit))
+
+		// Request creation
+		req, err := http.NewRequest(http.MethodPost, "/", bytes.NewReader(jsonBody))
+		if err != nil {
+			t.Fatalf("Failed to create HTTP request: %v", err)
+		}
+		req.Header.Set("X-Github-Event", "push")
+		// calculate the value to store in the X-Hub-Signature header
+		mac := hmac.New(sha1.New, []byte(tt.secretValueInRequest))
+		_, _ = mac.Write(jsonBody)
+		expectedMAC := hex.EncodeToString(mac.Sum(nil))
+		req.Header.Set("X-Hub-Signature", fmt.Sprintf("sha1=%s", expectedMAC))
+
+		// request execution
+		rr := httptest.NewRecorder()
+		w.ServeHTTP(rr, req)
+
+		// Verify the response status code is correct
+		if status := rr.Code; status != tt.expectedResCode {
+			t.Errorf("handler returned wrong status code: got %v want %v", status, tt.expectedResCode)
+		}
+
+		// Verify the response message is correct
+		if tt.expectedResCode == http.StatusOK {
+			expectedResponse := "succeeded"
+			if rr.Body.String() != expectedResponse {
+				t.Errorf("handler returned unexpected body: got %v want %v", rr.Body, expectedResponse)
+			}
+		}
 	}
-	req.Header.Set("X-Github-Event", "push")
+}
 
-	// request execution
+func TestErrorReadingRequest(t *testing.T) {
+	ctlr := gomock.NewController(t)
+	mockClient := mocks.NewMockClient(ctlr)
+	w := &Webhook{
+		client:    mockClient,
+		namespace: "default",
+	}
+	testRequest := httptest.NewRequest(http.MethodPost, "/something", errReader(0))
 	rr := httptest.NewRecorder()
-	w.ServeHTTP(rr, req)
+	w.ServeHTTP(rr, testRequest)
 
 	// Verify the response status code is correct
-	if status := rr.Code; status != http.StatusOK {
-		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
-	}
-
-	// Verify the response message is correct
-	expectedResponse := "succeeded"
-	if rr.Body.String() != expectedResponse {
-		t.Errorf("handler returned unexpected body: got %v want %v", rr.Body, expectedResponse)
+	if status := rr.Code; status != http.StatusInternalServerError {
+		t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError)
 	}
 }