Repository navigation
fix(deps): update module github.com/fxamacker/cbor/v2 to v2.9.4 #45
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Test and Release | |
| # Accepted risk, recorded so a reader meets it here rather than deducing it: | |
| # this repository is public and every job below runs on `self-hosted` runners, so | |
| # a pull request opened from a fork executes fork-authored code on that | |
| # infrastructure. Repository secrets are NOT exposed to a fork run - GitHub passes | |
| # only a read-only GITHUB_TOKEN - so the exposure is code execution on the runner, | |
| # not disclosure of DOCKERHUB_PAT or BOT_PAT. The maintainers accept this and gate | |
| # it outside the repository, under Settings > Actions > General > "Fork pull | |
| # request workflows from outside collaborators", set to "Require approval for all | |
| # outside collaborators"; no fork guard belongs in this file. | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Least privilege for the default job token: only `coverage-report` (sticky PR | |
| # comment) and `semantic-release` (tag, release, badge commit) need more, and both | |
| # raise it in their own job-level `permissions:` block. | |
| permissions: | |
| contents: read | |
| env: | |
| GO_VERSION: '1.27.1' | |
| KUBERNETES_VERSION: '1.33.4' | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # TEMPORARILY DISABLED - the E2E tier, both legs and the gate job. | |
| # | |
| # Disabled on 2026-09-01 while the operator is still being built. The trigger | |
| # was a multi-node leg that died after ten minutes inside | |
| # `sudo apt-get install build-essential`, before it ever reached a test - an | |
| # ephemeral ARC runner pod losing its network, not a defect in this repo. The | |
| # single-node leg ran the identical step in the same workflow and passed. | |
| # | |
| # The jobs are COMMENTED OUT rather than skipped with `if: false`, on purpose. | |
| # A skipped job reports `result: skipped`, which would make the e2e-gate check | |
| # `[ "$result" = "success" ]` fail, and GitHub skips any job whose `needs` | |
| # were skipped - so `semantic-release` would stop cutting releases as well. | |
| # Removing the jobs outright leaves both of those untouched. | |
| # | |
| # TO RESTORE: uncomment this whole block and add `e2e-tests` back to the | |
| # `needs:` list of `semantic-release` (search for "TEMPORARILY REMOVED" there). | |
| # Nothing else has to change. Note that `main` carries no branch protection | |
| # today; if a required status check named "E2E Tests" is ever configured, this | |
| # block has to come back first or no pull request will be mergeable. | |
| # --------------------------------------------------------------------------- | |
| # e2e-tests: | |
| # name: E2E Tests (${{ matrix.topology }}) | |
| # runs-on: self-hosted | |
| # timeout-minutes: 60 | |
| # strategy: | |
| # # The legs run in parallel: each lands on its own ephemeral ARC runner pod, | |
| # # and the distinct cluster names keep them collision-free even if two pods | |
| # # are ever scheduled onto the same node. | |
| # fail-fast: false | |
| # matrix: | |
| # include: | |
| # # Single-node leg: the full suite on a one-node cluster. It carries no | |
| # # -run filter, so every scenario that does not need a second node runs | |
| # # here. The cluster keeps the Makefile default name; the worker count does | |
| # # not (KIND_WORKERS defaults to 3 there), so reproducing this leg locally | |
| # # is `make e2e-local KIND_WORKERS=0`. | |
| # - topology: single-node | |
| # workers: 0 | |
| # cluster: mosquitto-operator-test | |
| # run_filter: "" | |
| # require_multi_node: "false" | |
| # # Multi-node leg: re-runs only the scenarios whose outcome depends on the | |
| # # node count - hard anti-affinity spread. | |
| # # Three workers, not two: Kind keeps the control-plane NoSchedule taint on | |
| # # multi-node clusters, so spreading three replicas needs three workers. | |
| # - topology: multi-node | |
| # workers: 3 | |
| # cluster: mosquitto-operator-test-multinode | |
| # run_filter: TestE2E_AntiAffinity | |
| # require_multi_node: "true" | |
| # env: | |
| # KIND_CLUSTER: ${{ matrix.cluster }} | |
| # KIND_WORKERS: ${{ matrix.workers }} | |
| # steps: | |
| # - name: Checkout | |
| # uses: actions/checkout@v7 | |
| # with: | |
| # fetch-depth: 0 | |
| # | |
| # - name: Set up Go | |
| # uses: actions/setup-go@v7 | |
| # with: | |
| # go-version: ${{ env.GO_VERSION }} | |
| # | |
| # - name: Install dependencies | |
| # run: go mod download | |
| # | |
| # - name: Install build tools | |
| # run: | | |
| # sudo apt-get update | |
| # sudo apt-get install -y build-essential | |
| # | |
| # - name: Login to Docker Hub | |
| # uses: docker/login-action@v4 | |
| # with: | |
| # username: guidedtraffic | |
| # password: ${{ secrets.DOCKERHUB_PAT }} | |
| # | |
| # - name: Prepare host for DinD networking | |
| # run: | | |
| # # kube-proxy in iptables mode requires specific kernel modules. | |
| # # On self-hosted DinD runners these may not be loaded, causing | |
| # # kube-proxy to crash with "Error" immediately. | |
| # for mod in br_netfilter nf_conntrack nf_conntrack_ipv4 xt_conntrack \ | |
| # ip_tables ip6_tables iptable_nat iptable_filter; do | |
| # sudo modprobe "$mod" 2>/dev/null || true | |
| # done | |
| # sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 2>/dev/null || true | |
| # sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1 2>/dev/null || true | |
| # sudo sysctl -w net.ipv4.ip_forward=1 2>/dev/null || true | |
| # echo "Loaded kernel modules:" | |
| # lsmod | grep -E 'br_netfilter|nf_conntrack|ip_tables|ip6_tables' || true | |
| # | |
| # - name: Create Kind config for DinD | |
| # run: | | |
| # set -euo pipefail | |
| # { | |
| # cat << EOF | |
| # kind: Cluster | |
| # apiVersion: kind.x-k8s.io/v1alpha4 | |
| # name: ${KIND_CLUSTER} | |
| # networking: | |
| # kubeProxyMode: "iptables" | |
| # nodes: | |
| # - role: control-plane | |
| # EOF | |
| # # One worker line per matrix worker; the single-node leg emits none. | |
| # worker=0 | |
| # while [ "${worker}" -lt "${KIND_WORKERS}" ]; do | |
| # echo "- role: worker" | |
| # worker=$((worker + 1)) | |
| # done | |
| # cat << EOF | |
| # # Use native snapshotter to avoid nested overlay issues in DinD | |
| # containerdConfigPatches: | |
| # - |- | |
| # [plugins."io.containerd.grpc.v1.cri".containerd] | |
| # snapshotter = "native" | |
| # disable_snapshot_annotations = false | |
| # EOF | |
| # } > kind-config.yaml | |
| # echo "=== kind-config.yaml ===" | |
| # cat kind-config.yaml | |
| # | |
| # - name: Setup kubectl | |
| # uses: azure/setup-kubectl@v5 | |
| # with: | |
| # version: 'v${{ env.KUBERNETES_VERSION }}' | |
| # | |
| # - name: Create Kind cluster | |
| # id: kind-cluster | |
| # uses: helm/kind-action@v1.14.0 | |
| # with: | |
| # version: v0.30.0 | |
| # node_image: kindest/node:v${{ env.KUBERNETES_VERSION }} | |
| # cluster_name: ${{ matrix.cluster }} | |
| # wait: 300s | |
| # ignore_failed_clean: true | |
| # config: kind-config.yaml | |
| # | |
| # - name: Verify Kind cluster | |
| # run: | | |
| # set -euo pipefail | |
| # kubectl cluster-info | |
| # kubectl wait --for=condition=Ready nodes --all --timeout=180s | |
| # kubectl get nodes | |
| # # The multi-node leg is only worth running with the nodes it asked for: | |
| # # a cluster that silently came up smaller would turn its assertions into | |
| # # skips, which is exactly the blind spot this leg exists to close. | |
| # expected=$(( KIND_WORKERS + 1 )) | |
| # actual=$(kubectl get nodes --no-headers | wc -l | tr -d ' ') | |
| # if [ "$actual" -ne "$expected" ]; then | |
| # echo "ERROR: the ${{ matrix.topology }} leg expects ${expected} nodes, cluster has ${actual}" | |
| # exit 1 | |
| # fi | |
| # | |
| # - name: Fix inotify limits inside Kind nodes | |
| # run: | | |
| # set -euo pipefail | |
| # # Since Linux 5.13, inotify limits are user-namespace-aware. | |
| # # Host sysctl settings are NOT inherited by containers. | |
| # # kube-proxy crashes with "too many open files" without this. | |
| # # Applied per node: every worker of the multi-node leg runs its own kube-proxy. | |
| # for node in $(kind get nodes --name "${KIND_CLUSTER}"); do | |
| # docker exec "$node" sysctl -w fs.inotify.max_user_watches=1048576 | |
| # docker exec "$node" sysctl -w fs.inotify.max_user_instances=8192 | |
| # done | |
| # | |
| # - name: Wait for kube-system pods | |
| # run: | | |
| # set -euo pipefail | |
| # echo "=== kube-system pod status before wait ===" | |
| # kubectl -n kube-system get pods -o wide | |
| # echo "" | |
| # echo "=== containerd info inside Kind node ===" | |
| # docker exec "${KIND_CLUSTER}-control-plane" ctr version || true | |
| # echo "" | |
| # | |
| # # ---------- helper: resilient daemonset wait ---------- | |
| # # kube-proxy can crash on DinD runners when iptables kernel modules | |
| # # are slow to initialise. This helper detects Error/CrashLoopBackOff | |
| # # pods, collects logs for diagnosis, deletes them so the DaemonSet | |
| # # controller recreates them, and retries. | |
| # wait_daemonset() { | |
| # local ds_name="$1" label="$2" max_attempts=5 | |
| # for attempt in $(seq 1 $max_attempts); do | |
| # echo "[${ds_name}] attempt ${attempt}/${max_attempts}" | |
| # if kubectl -n kube-system rollout status daemonset/"${ds_name}" --timeout=60s 2>/dev/null; then | |
| # echo "[${ds_name}] ready." | |
| # return 0 | |
| # fi | |
| # # Collect diagnostics from broken pods | |
| # bad_pods=$(kubectl -n kube-system get pods -l "${label}" --no-headers 2>/dev/null \ | |
| # | grep -E 'Error|CrashLoopBackOff|Init:Error' | awk '{print $1}') || true | |
| # if [ -n "$bad_pods" ]; then | |
| # for pod in $bad_pods; do | |
| # echo "[${ds_name}] logs from ${pod}:" | |
| # kubectl -n kube-system logs "$pod" --tail=30 2>&1 || true | |
| # echo "[${ds_name}] deleting ${pod} to trigger recreation..." | |
| # kubectl -n kube-system delete pod "$pod" --force --grace-period=0 2>/dev/null || true | |
| # done | |
| # sleep 10 | |
| # else | |
| # echo "[${ds_name}] no error pods found, pods may still be starting..." | |
| # sleep 15 | |
| # fi | |
| # done | |
| # echo "ERROR: daemonset ${ds_name} not ready after ${max_attempts} attempts" | |
| # kubectl -n kube-system describe daemonset/"${ds_name}" || true | |
| # kubectl -n kube-system describe pods -l "${label}" || true | |
| # return 1 | |
| # } | |
| # | |
| # wait_daemonset "kube-proxy" "k8s-app=kube-proxy" | |
| # wait_daemonset "kindnet" "app=kindnet" | |
| # | |
| # echo "Waiting for coredns..." | |
| # kubectl -n kube-system rollout status deployment/coredns --timeout=120s | |
| # | |
| # echo "" | |
| # echo "=== Final pod status ===" | |
| # kubectl -n kube-system get pods -o wide | |
| # echo "kube-system pods ready." | |
| # | |
| # - name: Verify in-cluster API connectivity | |
| # run: | | |
| # # kube-proxy pods being Ready does NOT mean their iptables DNAT rules for | |
| # # ClusterIPs (e.g. kubernetes.default.svc -> 10.96.0.1:443) are applied yet. | |
| # # In DinD this race causes "dial tcp 10.96.0.1:443: i/o timeout" inside pods. | |
| # # Fix: probe from inside the cluster; if it fails, restart kube-proxy to force | |
| # # a full iptables resync, then verify again. | |
| # echo "Probing in-cluster kubernetes service connectivity..." | |
| # PROBE_OK=false | |
| # for attempt in 1 2 3; do | |
| # kubectl run api-probe -n default \ | |
| # --image=busybox:1.36 \ | |
| # --restart=Never \ | |
| # -- sleep 60 | |
| # kubectl wait -n default --for=condition=Ready pod/api-probe --timeout=60s | |
| # | |
| # if kubectl exec -n default api-probe -- \ | |
| # timeout 20 sh -c \ | |
| # 'until nc -z -w 2 kubernetes.default.svc 443 2>/dev/null; do sleep 2; done'; then | |
| # echo "In-cluster API connectivity OK (attempt ${attempt})" | |
| # PROBE_OK=true | |
| # kubectl delete -n default pod api-probe --force --grace-period=0 | |
| # break | |
| # fi | |
| # | |
| # echo "Connectivity check failed on attempt ${attempt} — restarting kube-proxy to force iptables resync..." | |
| # kubectl delete -n default pod api-probe --force --grace-period=0 || true | |
| # kubectl -n kube-system rollout restart daemonset/kube-proxy | |
| # kubectl -n kube-system rollout status daemonset/kube-proxy --timeout=60s | |
| # done | |
| # | |
| # if [ "$PROBE_OK" = "false" ]; then | |
| # echo "ERROR: In-cluster API connectivity could not be established after 3 attempts" | |
| # exit 1 | |
| # fi | |
| # | |
| # - name: Build and load operator image | |
| # run: | | |
| # set -euo pipefail | |
| # make docker-build IMG=mosquitto-operator:test | |
| # # Manual ctr import without --all-platforms/--snapshotter flags | |
| # # to avoid "no unpack platforms defined" error with native snapshotter in DinD. | |
| # # Imported into every node: the operator Deployment runs with | |
| # # pullPolicy: Never (test/e2e/helm-values.yaml) and may land on any worker. | |
| # mkdir -p tmp | |
| # docker save mosquitto-operator:test -o tmp/mosquitto-operator-test.tar | |
| # for node in $(kind get nodes --name "${KIND_CLUSTER}"); do | |
| # echo "Importing operator image into ${node}..." | |
| # docker exec -i "$node" ctr --namespace=k8s.io images import - < tmp/mosquitto-operator-test.tar | |
| # done | |
| # rm -f tmp/mosquitto-operator-test.tar | |
| # | |
| # # The broker image is pulled by the kubelet inside the Kind nodes, and those | |
| # # nodes carry no registry credential - only the runner host is logged in. Left | |
| # # alone, every node pulls eclipse-mosquitto anonymously from the shared runner | |
| # # IP and counts against the anonymous Docker Hub rate limit. Preloading it the | |
| # # same way the operator image is loaded makes the pull happen once, on the | |
| # # authenticated host. The broker tag is not `latest`, so the kubelet's default | |
| # # IfNotPresent policy uses what is already there. | |
| # # | |
| # # The tag is read out of test/testimages/images.go rather than written here: a | |
| # # second copy of the pin would lag behind a Renovate bump and preload an image | |
| # # the suite no longer provisions, which looks exactly like a working cache. | |
| # - name: Preload the pinned broker image into the Kind nodes | |
| # run: | | |
| # set -euo pipefail | |
| # BROKER_IMAGE=$(sed -n 's/^[[:space:]]*MosquittoImage = "\(.*\)".*$/\1/p' test/testimages/images.go) | |
| # if [ -z "${BROKER_IMAGE}" ]; then | |
| # echo "ERROR: could not read MosquittoImage from test/testimages/images.go" | |
| # exit 1 | |
| # fi | |
| # echo "Broker image pin: ${BROKER_IMAGE}" | |
| # mkdir -p tmp | |
| # docker pull "${BROKER_IMAGE}" | |
| # docker save "${BROKER_IMAGE}" -o tmp/mosquitto-broker.tar | |
| # for node in $(kind get nodes --name "${KIND_CLUSTER}"); do | |
| # echo "Importing ${BROKER_IMAGE} into ${node}..." | |
| # docker exec -i "$node" ctr --namespace=k8s.io images import - < tmp/mosquitto-broker.tar | |
| # done | |
| # rm -f tmp/mosquitto-broker.tar | |
| # | |
| # - name: Verify image in cluster | |
| # run: | | |
| # set -euo pipefail | |
| # echo "=== Verifying image is available on every Kind node ===" | |
| # for node in $(kind get nodes --name "${KIND_CLUSTER}"); do | |
| # docker exec "$node" crictl images | grep -E 'mosquitto-operator\s+test|mosquitto-operator:test' || { | |
| # echo "ERROR: Image not found on ${node}!" | |
| # echo "Available images:" | |
| # docker exec "$node" crictl images | |
| # exit 1 | |
| # } | |
| # done | |
| # | |
| # - name: Install Helm | |
| # uses: azure/setup-helm@v5 | |
| # with: | |
| # version: 'v4.2.4' | |
| # | |
| # - name: Debug cluster state | |
| # run: | | |
| # echo "=== Cluster info ===" | |
| # kubectl cluster-info | |
| # echo "=== Nodes ===" | |
| # kubectl get nodes -o wide | |
| # echo "=== Namespaces ===" | |
| # kubectl get namespaces | |
| # echo "=== Storage classes ===" | |
| # kubectl get storageclass | |
| # | |
| # - name: Install cert-manager | |
| # run: | | |
| # kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml | |
| # echo "Waiting for cert-manager deployments to be available..." | |
| # kubectl wait --for=condition=Available deployment/cert-manager -n cert-manager --timeout=300s | |
| # kubectl wait --for=condition=Available deployment/cert-manager-cainjector -n cert-manager --timeout=300s | |
| # kubectl wait --for=condition=Available deployment/cert-manager-webhook -n cert-manager --timeout=300s | |
| # echo "Waiting for all cert-manager pods to be Ready..." | |
| # kubectl -n cert-manager wait --for=condition=Ready pods --all --timeout=300s | |
| # echo "Creating self-signed ClusterIssuer..." | |
| # # Retry in case the webhook TLS isn't fully initialised yet | |
| # ISSUER_OK=false | |
| # for i in 1 2 3 4 5; do | |
| # if kubectl apply -f test/e2e/testdata/cert-manager-issuer.yaml; then | |
| # echo "ClusterIssuer created successfully on attempt $i" | |
| # ISSUER_OK=true | |
| # break | |
| # fi | |
| # echo "Attempt $i failed, retrying in 10s..." | |
| # sleep 10 | |
| # done | |
| # | |
| # # Without this branch the loop exits 0 after five failures and the missing | |
| # # ClusterIssuer only surfaces minutes later, inside the TLS E2E, as a | |
| # # certificate that never becomes ready. | |
| # if [ "$ISSUER_OK" = "false" ]; then | |
| # echo "ERROR: the self-signed ClusterIssuer could not be created after 5 attempts" | |
| # kubectl -n cert-manager get pods -o wide || true | |
| # exit 1 | |
| # fi | |
| # | |
| # - name: Install operator via Helm | |
| # id: helm-install | |
| # continue-on-error: true | |
| # run: | | |
| # helm install mosquitto-operator deploy/helm/mosquitto-operator \ | |
| # --namespace mosquitto-operator-system \ | |
| # --create-namespace \ | |
| # --values test/e2e/helm-values.yaml \ | |
| # --set image.repository=mosquitto-operator \ | |
| # --set image.tag=test \ | |
| # --wait \ | |
| # --timeout 180s \ | |
| # --debug | |
| # | |
| # - name: Debug Helm install failure | |
| # if: steps.helm-install.outcome == 'failure' | |
| # run: | | |
| # echo "=== Helm install failed, collecting diagnostics ===" | |
| # echo "=== Pods in operator namespace ===" | |
| # kubectl -n mosquitto-operator-system get pods -o wide || true | |
| # | |
| # echo "" | |
| # echo "=== Get Pod Names ===" | |
| # POD_NAME=$(kubectl -n mosquitto-operator-system get pods -l app.kubernetes.io/name=mosquitto-operator -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") | |
| # echo "Pod name: ${POD_NAME}" | |
| # | |
| # if [ -n "$POD_NAME" ]; then | |
| # echo "" | |
| # echo "=== Operator logs (current attempt) ===" | |
| # kubectl -n mosquitto-operator-system logs "$POD_NAME" --tail=100 2>&1 || echo "No current logs available" | |
| # | |
| # echo "" | |
| # echo "=== Operator logs (previous crash) ===" | |
| # kubectl -n mosquitto-operator-system logs "$POD_NAME" --previous --tail=100 2>&1 || echo "No previous logs available" | |
| # fi | |
| # | |
| # echo "" | |
| # echo "=== Describe pods ===" | |
| # kubectl -n mosquitto-operator-system describe pods || true | |
| # | |
| # echo "" | |
| # echo "=== Events ===" | |
| # kubectl -n mosquitto-operator-system get events --sort-by='.lastTimestamp' || true | |
| # | |
| # echo "" | |
| # echo "=== ConfigMap content ===" | |
| # kubectl -n mosquitto-operator-system get configmap -l app.kubernetes.io/name=mosquitto-operator -o yaml || true | |
| # | |
| # echo "" | |
| # echo "=== ServiceAccount ===" | |
| # kubectl -n mosquitto-operator-system get serviceaccount mosquitto-operator -o yaml || true | |
| # | |
| # echo "" | |
| # echo "=== RBAC ===" | |
| # kubectl get clusterrolebinding | grep mosquitto-operator || true | |
| # kubectl describe clusterrolebinding mosquitto-operator || true | |
| # | |
| # exit 1 | |
| # | |
| # - name: Wait for operator | |
| # run: kubectl -n mosquitto-operator-system wait --for=condition=available --timeout=120s deployment/mosquitto-operator | |
| # | |
| # - name: Run E2E tests | |
| # run: | | |
| # set -euo pipefail | |
| # mkdir -p tmp | |
| # make test-e2e E2E_RUN="${E2E_RUN}" 2>&1 | tee "tmp/e2e-${{ matrix.topology }}.log" | |
| # # A -run filter that stopped matching (renamed test, moved file) would leave | |
| # # this leg green without executing anything, so the filtered leg proves the | |
| # # scenario it exists for actually ran. | |
| # if [ -n "${E2E_RUN}" ]; then | |
| # guard=TestE2E_AntiAffinity_HardSpreadsAcrossNodes | |
| # grep -q -- "--- PASS: ${guard}" "tmp/e2e-${{ matrix.topology }}.log" || { | |
| # echo "ERROR: ${guard} did not run - E2E_RUN='${E2E_RUN}' no longer matches it" | |
| # exit 1 | |
| # } | |
| # fi | |
| # env: | |
| # E2E_RUN: ${{ matrix.run_filter }} | |
| # # Turns "not enough nodes" from a skip into a failure on the multi-node leg. | |
| # E2E_REQUIRE_MULTI_NODE: ${{ matrix.require_multi_node }} | |
| # | |
| # - name: Collect logs on failure | |
| # if: failure() | |
| # run: | | |
| # # A short tail is not enough here: the operator logs one line per | |
| # # reconcile pass and per CR, so the message of the failing pass scrolls | |
| # # out of a small window and leaves only the stack trace behind it. | |
| # echo "=== Operator logs ===" | |
| # kubectl -n mosquitto-operator-system logs -l app.kubernetes.io/name=mosquitto-operator --tail=2000 || true | |
| # echo "=== Describe pods ===" | |
| # kubectl -n mosquitto-operator-system describe pods || true | |
| # echo "=== Events ===" | |
| # kubectl -n mosquitto-operator-system get events --sort-by='.lastTimestamp' || true | |
| # # Pod logs of the test namespaces. A defect that lives inside the broker | |
| # # pod - a generated mosquitto.conf the broker refuses to load, a TLS file | |
| # # it cannot read - leaves nothing in the operator log, so collecting only | |
| # # the operator would leave it without an artifact at all. --previous is the | |
| # # half that carries the crashed attempt once the restart is up. | |
| # # Node conditions first: a pod that lost its process to an eviction or to a | |
| # # failing probe leaves its reason here and nowhere else, and the failure | |
| # # that motivated this collection could not be told apart from an operator | |
| # # decision without it. | |
| # echo "=== Nodes ===" | |
| # kubectl get nodes -o wide || true | |
| # kubectl describe nodes | grep -E '^(Name:| Ready| MemoryPressure| DiskPressure| PIDPressure)' || true | |
| # echo "=== Test namespace pod logs ===" | |
| # for ns in $(kubectl get ns -o name | sed 's|namespace/||' | grep '^e2e-'); do | |
| # echo "--- $ns pods ---" | |
| # kubectl -n "$ns" get pods -o wide || true | |
| # # Events outlive the pods they describe, so they answer "was it killed, | |
| # # evicted or unhealthy" even for a pod the test already tore down. On the | |
| # # multi-node leg they also carry the scheduler's reason for a Pending pod, | |
| # # which is what a failed hard-anti-affinity spread looks like. | |
| # echo "--- $ns events ---" | |
| # kubectl -n "$ns" get events --sort-by='.lastTimestamp' || true | |
| # for pod in $(kubectl -n "$ns" get pods -o name | sed 's|pod/||'); do | |
| # echo "--- $ns/$pod (current) ---" | |
| # kubectl -n "$ns" logs "$pod" --all-containers --prefix --tail=300 2>&1 || true | |
| # echo "--- $ns/$pod (previous) ---" | |
| # kubectl -n "$ns" logs "$pod" --all-containers --prefix --previous --tail=300 2>&1 || true | |
| # done | |
| # done | |
| # | |
| # - name: Cleanup Kind cluster | |
| # if: always() | |
| # run: kind delete cluster --name "${KIND_CLUSTER}" || true | |
| # | |
| # # Branch protection requires the status context "E2E Tests". The matrix legs | |
| # # report as "E2E Tests (<topology>)" - one context per leg, renamed whenever a | |
| # # leg is - so this gate job carries the stable context and fails unless every | |
| # # leg succeeded. Keeps the required check independent of matrix reshaping. | |
| # e2e-gate: | |
| # name: E2E Tests | |
| # runs-on: self-hosted | |
| # needs: [e2e-tests] | |
| # if: always() | |
| # steps: | |
| # - name: Require all matrix legs to succeed | |
| # run: | | |
| # result="${{ needs.e2e-tests.result }}" | |
| # echo "E2E matrix result: ${result}" | |
| # [ "${result}" = "success" ] | |
| # | |
| linter: | |
| name: Code Linting | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run linting | |
| run: make lint | |
| gosec: | |
| name: GoSec Security Scan | |
| runs-on: self-hosted | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run GoSec security scan | |
| timeout-minutes: 10 | |
| run: make gosec | |
| malware-scan: | |
| name: Malware Scan (Source Code) | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Install ClamAV | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y clamav clamav-daemon | |
| - name: Update ClamAV database | |
| run: | | |
| sudo systemctl stop clamav-freshclam || true | |
| sudo freshclam | |
| - name: Run ClamAV scan on source code | |
| run: | | |
| echo "🔍 Scanning source code for malware..." | |
| clamscan --recursive --infected --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=vendor . | tee clamav-report.txt | |
| # Check if any infected files were found | |
| if grep -q "Infected files: 0" clamav-report.txt; then | |
| echo "✅ No malware detected in source code" | |
| else | |
| echo "❌ MALWARE DETECTED! Blocking release." | |
| cat clamav-report.txt | |
| exit 1 | |
| fi | |
| - name: Upload ClamAV report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: clamav-report | |
| path: clamav-report.txt | |
| retention-days: 30 | |
| unit-tests: | |
| name: Unit Tests | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run unit tests with coverage | |
| run: make test-unit-coverage | |
| - name: Upload unit coverage artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: unit-coverage | |
| path: coverage/unit.out | |
| retention-days: 7 | |
| # The operator does not build the Mosquitto image, it configures one: the | |
| # generated mosquitto.conf, the config, TLS and data paths the pod mounts, and | |
| # the broker binary the container command starts. This job asks the pinned image | |
| # whether it still carries them. It needs docker and no cluster, so it answers in | |
| # seconds and names what is missing - instead of surfacing in the E2E job minutes | |
| # later as a cluster that will not converge. | |
| mosquitto-image-tools: | |
| name: Mosquitto Image Tools | |
| runs-on: self-hosted | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| # The self-hosted runner image ships no make, so every job that enters the | |
| # Makefile installs it. Leaving it out fails at the first target with | |
| # "make: command not found", not at a missing tool inside the target. | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| # Docker Hub rate-limits anonymous pulls, and this job pulls the pinned | |
| # broker image on every run. | |
| - name: Login to Docker Hub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: guidedtraffic | |
| password: ${{ secrets.DOCKERHUB_PAT }} | |
| - name: Check the pinned Mosquitto image | |
| run: make test-image-tools | |
| govulncheck: | |
| name: Vulnerability Check | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run vulnerability check | |
| run: make vuln | |
| cyclomatic-complexity: | |
| name: Cyclomatic Complexity | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run cyclomatic complexity check | |
| run: make cyclo | |
| - name: Generate complexity report | |
| if: always() | |
| run: | | |
| echo "## 📊 Cyclomatic Complexity Report" >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "Top 20 most complex functions:" >> $GITHUB_STEP_SUMMARY | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| gocyclo -top 20 . >> $GITHUB_STEP_SUMMARY || true | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| echo "" >> $GITHUB_STEP_SUMMARY | |
| echo "> Threshold: 15 (functions above this are flagged)" >> $GITHUB_STEP_SUMMARY | |
| generated-manifests: | |
| name: Generated Manifests Up To Date | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| # Needed by the RBAC parity check below, which renders the chart. | |
| - name: Install Helm | |
| uses: azure/setup-helm@v5 | |
| with: | |
| version: 'v4.3.0' | |
| # Runs the generator itself, so the kubebuilder markers in the Go sources are | |
| # compared against the checked-in manifests. build.yml runs the same check, but | |
| # only on `release: published`; this job moves it to every push and PR, where a | |
| # stale config/rbac/role.yaml is still cheap to fix. | |
| - name: Regenerate CRDs, RBAC and DeepCopy | |
| run: make generate-all | |
| - name: Verify generated artifacts are committed | |
| run: | | |
| if ! git diff --quiet || [ -n "$(git status --porcelain --untracked-files=normal)" ]; then | |
| echo "::error::Generated CRDs / RBAC role / DeepCopy / Helm chart CRD are out of sync with the Go sources." | |
| echo "Run 'make generate-all' locally and commit the result." | |
| git --no-pager diff --stat | |
| git --no-pager diff | |
| git status --porcelain | |
| exit 1 | |
| fi | |
| # The generator above keeps config/rbac/role.yaml honest against the markers. | |
| # Nothing keeps the chart's ClusterRole honest against either - it is written | |
| # by hand - so a narrowed marker would leave chart users with authority the | |
| # code no longer asks for, or a widened one would 403 them on every pass. | |
| # This compares what both install paths actually render. | |
| - name: Verify both install paths grant the same RBAC | |
| run: make verify-rbac-parity | |
| integration-tests: | |
| name: Integration Tests (envtest) | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Install dependencies | |
| run: go mod download | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| - name: Run integration tests with coverage | |
| run: make test-integration-coverage | |
| - name: Upload integration coverage artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: integration-coverage | |
| path: coverage/integration.out | |
| retention-days: 7 | |
| coverage-report: | |
| name: Combined Coverage Report | |
| runs-on: self-hosted | |
| needs: [unit-tests, integration-tests] | |
| if: always() && (needs.unit-tests.result == 'success' || needs.integration-tests.result == 'success') | |
| permissions: | |
| contents: read # Read only - semantic-release will commit the badge | |
| pull-requests: write # Needed to comment on PRs | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set up Go | |
| uses: actions/setup-go@v7 | |
| with: | |
| go-version: ${{ env.GO_VERSION }} | |
| - name: Download unit test coverage | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: unit-coverage | |
| continue-on-error: true | |
| - name: Download integration test coverage | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: integration-coverage | |
| continue-on-error: true | |
| - name: Merge coverage reports | |
| id: merge-coverage | |
| run: | | |
| echo "Merging coverage reports..." | |
| # Check which coverage files exist | |
| COVERAGE_FILES="" | |
| if [ -f "unit.out" ]; then | |
| echo "✓ Unit test coverage found" | |
| COVERAGE_FILES="$COVERAGE_FILES unit.out" | |
| fi | |
| if [ -f "integration.out" ]; then | |
| echo "✓ Integration test coverage found" | |
| COVERAGE_FILES="$COVERAGE_FILES integration.out" | |
| fi | |
| if [ -z "$COVERAGE_FILES" ]; then | |
| echo "ERROR: No coverage files found" | |
| exit 1 | |
| fi | |
| # Merge coverage files | |
| echo "mode: set" > coverage-merged.out | |
| grep -h -v "^mode:" $COVERAGE_FILES >> coverage-merged.out || true | |
| # Generate reports | |
| mkdir -p coverage | |
| go tool cover -func=coverage-merged.out > coverage/coverage.txt | |
| go tool cover -html=coverage-merged.out -o coverage/coverage.html | |
| # Extract total coverage | |
| TOTAL_COVERAGE=$(grep "total:" coverage/coverage.txt | awk '{print $3}' | sed 's/%//') | |
| echo "percentage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT | |
| echo "" | |
| echo "========================================" | |
| echo "📊 Combined Coverage: ${TOTAL_COVERAGE}%" | |
| echo "========================================" | |
| # Coverage per package, aggregated from the profile rather than from the | |
| # per-function report: coverage.txt has one line per *function*, which is | |
| # far too much to inline, and an unweighted mean over it would be wrong | |
| # anyway. Aggregating statements out of the profile gives one line per | |
| # package and the correct number. | |
| # | |
| # Blocks are deduplicated by location before summing: the merge above simply | |
| # concatenates the unit and integration profiles, so every block that both runs | |
| # touched appears twice. Under "mode: set" a block counts as covered when any | |
| # run covered it, which is the max of the two counts. | |
| python3 - <<'PY' > coverage/summary.txt | |
| import collections | |
| blocks = {} | |
| with open("coverage-merged.out") as fh: | |
| next(fh, None) # drop the "mode:" line | |
| for line in fh: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| loc, num_stmt, count = line.rsplit(" ", 2) | |
| prev = blocks.get(loc) | |
| count = int(count) | |
| if prev is None or count > prev[1]: | |
| blocks[loc] = (int(num_stmt), count) | |
| per_pkg = collections.defaultdict(lambda: [0, 0]) | |
| for loc, (num_stmt, count) in blocks.items(): | |
| pkg = loc.rsplit(":", 1)[0].rsplit("/", 1)[0] | |
| per_pkg[pkg][0] += num_stmt | |
| if count > 0: | |
| per_pkg[pkg][1] += num_stmt | |
| print("| Package | Coverage | Statements |") | |
| print("|---|---:|---:|") | |
| for pkg in sorted(per_pkg): | |
| total, covered = per_pkg[pkg] | |
| pct = (100.0 * covered / total) if total else 0.0 | |
| print(f"| `{pkg}` | {pct:.1f}% | {covered}/{total} |") | |
| PY | |
| # Add to step summary | |
| echo "## Combined Coverage Report" >> $GITHUB_STEP_SUMMARY | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| cat coverage/coverage.txt >> $GITHUB_STEP_SUMMARY | |
| echo '```' >> $GITHUB_STEP_SUMMARY | |
| - name: Upload combined coverage report | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-combined | |
| path: coverage/ | |
| - name: Create coverage badge | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| run: | | |
| COVERAGE="${{ steps.merge-coverage.outputs.percentage }}" | |
| # Determine badge color based on coverage using Python (more reliable than bc) | |
| COLOR=$(python3 -c " | |
| coverage = float('$COVERAGE') | |
| if coverage >= 90: | |
| print('brightgreen') | |
| elif coverage >= 80: | |
| print('green') | |
| elif coverage >= 70: | |
| print('yellow') | |
| elif coverage >= 60: | |
| print('orange') | |
| else: | |
| print('red') | |
| ") | |
| # Create JSON for shields.io endpoint badge | |
| mkdir -p .github/badges | |
| cat > .github/badges/coverage.json << EOF | |
| { | |
| "schemaVersion": 1, | |
| "label": "coverage", | |
| "message": "${COVERAGE}%", | |
| "color": "${COLOR}" | |
| } | |
| EOF | |
| echo "Coverage badge created: ${COVERAGE}% (${COLOR})" | |
| - name: Upload coverage badge as artifact | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-badge | |
| path: .github/badges/coverage.json | |
| - name: Get main branch coverage | |
| if: github.event_name == 'pull_request' | |
| id: main-coverage | |
| continue-on-error: true | |
| run: | | |
| # Try to fetch coverage from main branch badge | |
| MAIN_COVERAGE="N/A" | |
| MAIN_BADGE_URL="https://raw.githubusercontent.com/${{ github.repository }}/main/.github/badges/coverage.json" | |
| echo "Fetching main branch coverage from: $MAIN_BADGE_URL" | |
| if curl -f -s "$MAIN_BADGE_URL" -o main-coverage.json; then | |
| MAIN_COVERAGE=$(jq -r '.message' main-coverage.json | sed 's/%//') | |
| echo "Main branch coverage: ${MAIN_COVERAGE}%" | |
| else | |
| echo "Could not fetch main branch coverage badge" | |
| fi | |
| echo "percentage=$MAIN_COVERAGE" >> $GITHUB_OUTPUT | |
| - name: Calculate coverage difference | |
| if: github.event_name == 'pull_request' | |
| id: coverage-diff | |
| run: | | |
| CURRENT="${{ steps.merge-coverage.outputs.percentage }}" | |
| MAIN="${{ steps.main-coverage.outputs.percentage }}" | |
| if [ "$MAIN" = "N/A" ]; then | |
| echo "diff=N/A" >> $GITHUB_OUTPUT | |
| echo "emoji=ℹ️" >> $GITHUB_OUTPUT | |
| echo "trend=baseline" >> $GITHUB_OUTPUT | |
| echo "Cannot compare - main branch coverage not available" | |
| else | |
| # Use Python for floating point arithmetic (always available in GitHub Actions) | |
| DIFF=$(python3 -c "print(round($CURRENT - $MAIN, 2))") | |
| echo "diff=$DIFF" >> $GITHUB_OUTPUT | |
| # Determine emoji and trend based on difference | |
| # Use Python for comparison to avoid bc dependency | |
| if [ "$(python3 -c "print(1 if $DIFF > 0 else 0)")" = "1" ]; then | |
| echo "emoji=📈" >> $GITHUB_OUTPUT | |
| echo "trend=improved" >> $GITHUB_OUTPUT | |
| echo "Coverage improved by ${DIFF}%" | |
| elif [ "$(python3 -c "print(1 if $DIFF < 0 else 0)")" = "1" ]; then | |
| echo "emoji=📉" >> $GITHUB_OUTPUT | |
| echo "trend=decreased" >> $GITHUB_OUTPUT | |
| echo "Coverage decreased by ${DIFF}%" | |
| else | |
| echo "emoji=➡️" >> $GITHUB_OUTPUT | |
| echo "trend=unchanged" >> $GITHUB_OUTPUT | |
| echo "Coverage unchanged" | |
| fi | |
| fi | |
| - name: Generate coverage summary for PR | |
| if: github.event_name == 'pull_request' | |
| id: coverage-summary | |
| run: | | |
| # A with: input reaches the action as a single environment variable, and Linux | |
| # caps one of those at MAX_ARG_STRLEN = 131072 bytes. Exceeding it does not | |
| # truncate anything -- execve fails and the step dies with "Argument list too | |
| # long". | |
| # | |
| # The package table is ~1 KB, so this cap never fires in normal operation. It | |
| # exists so that an unexpected growth degrades into a truncated comment that | |
| # names where the full data is, instead of a failed job. | |
| MAX_BYTES=50000 | |
| if [ "$(wc -c < coverage/summary.txt)" -gt "$MAX_BYTES" ]; then | |
| echo "Coverage summary is $(wc -c < coverage/summary.txt) bytes; truncating to ${MAX_BYTES}." | |
| head -c "$MAX_BYTES" coverage/summary.txt > coverage/summary-capped.txt | |
| printf '\n\n_Truncated. The full report is in the job summary and the `coverage-combined` artifact._\n' \ | |
| >> coverage/summary-capped.txt | |
| mv coverage/summary-capped.txt coverage/summary.txt | |
| fi | |
| echo "COVERAGE_SUMMARY<<EOF" >> $GITHUB_ENV | |
| cat coverage/summary.txt >> $GITHUB_ENV | |
| echo "EOF" >> $GITHUB_ENV | |
| - name: Comment PR with combined coverage | |
| if: github.event_name == 'pull_request' | |
| uses: marocchino/sticky-pull-request-comment@v3 | |
| with: | |
| header: coverage | |
| message: | | |
| ## 📊 Combined Test Coverage Report | |
| **Current Coverage:** `${{ steps.merge-coverage.outputs.percentage }}%` | |
| **Main Branch Coverage:** `${{ steps.main-coverage.outputs.percentage }}%` | |
| **Difference:** ${{ steps.coverage-diff.outputs.emoji }} `${{ steps.coverage-diff.outputs.diff }}%` | |
| <details> | |
| <summary>📁 Coverage by package</summary> | |
| ${{ env.COVERAGE_SUMMARY }} | |
| </details> | |
| > Unit and integration tests combined. The full per-function report is in this | |
| > job's summary and in the `coverage-combined` artifact — it is deliberately not | |
| > inlined here, because a `with:` input travels to the action as one environment | |
| > variable and Linux caps that at 131072 bytes. | |
| container-malware-scan: | |
| name: Container Malware Scan | |
| runs-on: self-hosted | |
| needs: [malware-scan] # Run after source scan passes | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| # This job builds a container from the checked-out tree, and the | |
| # Containerfile does `COPY . .`. Leaving credentials persisted would put | |
| # the job token into .git/config and from there into the build context. | |
| # .dockerignore excludes .git/ as well - both halves, because either one | |
| # alone is a single point of failure. Nothing here pushes with git. | |
| persist-credentials: false | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Login to Docker Hub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: guidedtraffic | |
| password: ${{ secrets.DOCKERHUB_PAT }} | |
| - name: Get current epoch time | |
| id: time | |
| run: echo "epoch=$(date +%s)" >> $GITHUB_OUTPUT | |
| - name: Build container for scanning | |
| uses: docker/build-push-action@v7 | |
| with: | |
| file: Containerfile | |
| context: . | |
| build-args: | | |
| BUILD_NUMBER=scan-${{ github.sha }} | |
| GIT_COMMIT=${{ github.sha }} | |
| BUILD_TIME=${{ steps.time.outputs.epoch }} | |
| platforms: linux/amd64 | |
| push: false | |
| load: true | |
| tags: mosquitto-operator:scan-${{ github.sha }} | |
| # Defence in depth for the two Trivy steps below: `docker/login-action` wrote | |
| # the Docker Hub credential into ~/.docker/config.json on this runner, and the | |
| # image is already built, so nothing after this point needs the registry. The | |
| # scan steps therefore run third-party action code with no credential on disk. | |
| - name: Log out of Docker Hub | |
| run: docker logout | |
| # Pinned to a commit, not to the moving `master` it used to track: this | |
| # action runs on a self-hosted runner, two steps after a Docker Hub login, | |
| # so whoever can move that branch could run code here with the credential | |
| # still on disk. `docker logout` above closes the second half. | |
| # | |
| # What the pin actually buys, stated honestly: Renovate automerges | |
| # github-actions DIGEST updates, so a new upstream release still lands here | |
| # without a human reading it. The pin removes the "whatever is on master | |
| # right now" window and replaces it with a Renovate cycle - it is not human | |
| # review. Drop `digest` from the github-actions automerge rule in | |
| # renovate.json if review is what is wanted. | |
| # | |
| # Only trivy is pinned this way. The rest (actions/*, docker/*, azure/*, | |
| # helm/*) keep tag refs: they are first-party or vendor-published actions | |
| # whose tags are protected upstream, whereas this one was tracking a branch | |
| # head. That is the distinction, not a half-finished sweep. | |
| - name: Run Trivy vulnerability scan | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: mosquitto-operator:scan-${{ github.sha }} | |
| format: 'table' | |
| exit-code: '1' | |
| ignore-unfixed: true | |
| vuln-type: 'os,library' | |
| severity: 'CRITICAL,HIGH' | |
| scanners: 'vuln,secret,misconfig' | |
| - name: Run Trivy scan (SARIF output) | |
| if: always() | |
| uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 | |
| with: | |
| image-ref: mosquitto-operator:scan-${{ github.sha }} | |
| format: 'sarif' | |
| output: 'trivy-results.sarif' | |
| scanners: 'vuln,secret,misconfig' | |
| - name: Upload Trivy scan results | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: trivy-container-report | |
| path: trivy-results.sarif | |
| retention-days: 30 | |
| - name: Cleanup scan image | |
| if: always() | |
| run: docker rmi mosquitto-operator:scan-${{ github.sha }} || true | |
| # Proves on every PR that the semantic-release dependency set can still render | |
| # release notes. The release job is the only other place these npm packages are | |
| # exercised, and it runs on pushes to main - after a Renovate bump has merged. | |
| # Without this job a preset/writer mismatch would surface as a failed (or | |
| # silently note-less) release on main instead of as a red check on the bump PR. | |
| release-tooling: | |
| name: Release Tooling | |
| runs-on: self-hosted | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: "lts/*" | |
| # The self-hosted runner image ships no make, and the target below is entered | |
| # through the Makefile like every other check in this workflow. | |
| - name: Install build tools | |
| run: sudo apt-get update && sudo apt-get install -y build-essential | |
| # Runs before npm install because it needs nothing from node_modules: a | |
| # Renovate customManager whose regex stopped matching drops its file out of a | |
| # version group silently, and this is the cheapest place to catch that. | |
| - name: Verify every Renovate customManager still matches | |
| run: make verify-ci-references | |
| # --ignore-scripts: the semantic-release dependency tree is pure JavaScript and | |
| # needs no build step, so nothing here has a reason to run arbitrary install | |
| # hooks on a self-hosted runner. | |
| - name: Install dependencies | |
| run: npm ci --ignore-scripts | |
| - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies | |
| run: npm audit signatures | |
| - name: Verify release tooling renders release notes | |
| run: node hack/verify-release-tooling.mjs | |
| semantic-release: | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| # TEMPORARILY REMOVED from this list: e2e-tests. The E2E tier is commented | |
| # out at the top of this file; a job that does not exist cannot be waited | |
| # on, and leaving it here would make every release run fail to start. | |
| needs: [malware-scan, container-malware-scan, gosec, govulncheck, linter, cyclomatic-complexity, generated-manifests, unit-tests, integration-tests, mosquitto-image-tools, coverage-report, release-tooling] | |
| name: Semantic Release | |
| runs-on: self-hosted | |
| permissions: | |
| contents: write | |
| issues: write | |
| pull-requests: write | |
| id-token: write | |
| steps: | |
| # persist-credentials: false keeps BOT_PAT out of .git/config. Otherwise the | |
| # checkout writes it as an http.extraheader that stays readable in the working | |
| # directory for every later step, npm install included. semantic-release does | |
| # not need it there: it builds its own authenticated push URL from the | |
| # GITHUB_TOKEN env var set on the Release step below. | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| token: ${{ secrets.BOT_PAT }} | |
| - name: Download coverage badge | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: coverage-badge | |
| path: .github/badges/ | |
| continue-on-error: true | |
| - name: Verify coverage badge downloaded | |
| run: | | |
| if [ -f ".github/badges/coverage.json" ]; then | |
| echo "✅ Coverage badge downloaded successfully" | |
| cat .github/badges/coverage.json | |
| else | |
| echo "⚠️ Coverage badge not found, using existing file" | |
| fi | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: "lts/*" | |
| # --ignore-scripts: this job holds BOT_PAT in its environment, so the install | |
| # of the dependency tree must not execute lifecycle scripts. The packages are | |
| # pure JavaScript and need none. | |
| - name: Install dependencies | |
| run: npm ci --ignore-scripts | |
| - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies | |
| run: npm audit signatures | |
| - name: Release | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.BOT_PAT }} | |
| run: npx semantic-release |