diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 2488734..dfadd24 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -199,11 +199,27 @@ jobs: uses: actions/checkout@v7 - name: "Install Helm" uses: azure/setup-helm@v5 + # A plain release download rather than aquasecurity/setup-trivy, and the + # reason is measured on the Gitea probe PR of #225, not assumed. That + # action runs actions/checkout against aquasecurity/trivy on github.com + # and hands it `github.token` - which on Gitea is a GITEA token. + # github.com answers 401, checkout retries twice and the job ends on + # "fatal: could not read Username for 'https://github.com': terminal + # prompts disabled", an error naming neither the action nor the token. + # Its actions/cache step is inert on the instance as well ("Cache action + # is only supported on GHES version >= 3.5"), and fetching the nested + # actions cost 4m30s before the job even started. One tarball behaves + # identically on both forges, and the `# renovate:` comment above still + # keeps TRIVY_VERSION current - verified by dry run: pinned to v0.72.0 + # the lookup offers v0.74.0 as a minor update. - name: "Install Trivy" - uses: aquasecurity/setup-trivy@v0.3.1 - with: - version: ${{ env.TRIVY_VERSION }} - cache: true + run: | + curl -sSfL -o /tmp/trivy.tar.gz \ + "https://github.com/aquasecurity/trivy/releases/download/${TRIVY_VERSION}/trivy_${TRIVY_VERSION#v}_Linux-64bit.tar.gz" + mkdir -p "$HOME/.local/bin" + tar -xzf /tmp/trivy.tar.gz -C "$HOME/.local/bin" trivy + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/trivy" --version # Findings land in the job summary, not only in the log: exceptions with # their reasons come from ci/trivy/trivyignore.yaml, the trusted-registry # list from ci/trivy/data/ksv0125.yaml. @@ -214,7 +230,35 @@ jobs: name: "Install-test ${{ matrix.chart }}" needs: [detect-changed-charts, build-chart] if: needs.detect-changed-charts.outputs.any-install == 'true' - runs-on: ubuntu-latest + # The one job in this repo that does not simply say ubuntu-latest, and the + # expression is not decoration - the same label means different machines on + # the two forges this repo currently lives on (#225). + # + # This is the only job that needs a Docker socket: it creates a k3d + # cluster, and k3d is a Docker client. On GitHub's hosted runners every + # label carries Docker, so the question never came up. On the Gitea + # instance it was MEASURED on a probe pull request, per job: + # detect-changed-charts, build-chart, the quantity render and the Trivy + # scan all pass on `ubuntu-latest` - none of them needs Docker - while the + # install-test dies there at "Create k3d cluster" with "Cannot connect to + # the Docker daemon at unix:///var/run/docker.sock", an error that points + # at k3d and never at the runner label. `ubuntu-docker` is the label that + # carries a socket, and Gitea does evaluate this expression: the probe job + # was picked up by the docker runner with labels ["ubuntu-docker"]. + # + # Why an expression and not a plain `ubuntu-docker`: that label does not + # exist on GitHub, so every chart PR would sit queued forever for as long + # as this repo still builds on github.com - blocking the merge with no + # error anywhere. github.server_url is the one context value that separates + # the two without naming our instance. + # + # KNOWN, and not fixed by this line: `ubuntu-docker`'s daemon is ROOTLESS + # and reports "Cgroup Driver: none" plus "Running in rootless-mode without + # cgroups". k3d then gets as far as starting the k3s node, which restarts + # immediately with an empty log, because k3s needs cgroup delegation. Until + # the instance's Docker-in-Docker provides it, this job cannot pass on + # Gitea - see #225. The label is necessary, it is just not sufficient. + runs-on: ${{ github.server_url == 'https://github.com' && 'ubuntu-latest' || 'ubuntu-docker' }} # Blocking since Renovate automerges patch/minor updates: a chart that no # longer starts must stop the PR before it reaches main and publishes. This # ends the stabilization phase of issue #22 - the job reported failures @@ -241,11 +285,80 @@ jobs: env: K3D_VERSION: v5.9.0 K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1 + # Pinned rather than taken from the runner image. The argument is not + # "Gitea's image has no kubectl" - that is only how it was noticed. A + # kubectl inherited from whatever the image happens to ship drifts + # unannounced with every image rebuild, and an install-test is supposed + # to run against a KNOWN client. That GitHub's hosted image already + # carries one does not make the pin unnecessary; it only makes the drift + # invisible there. The value mirrors K3S_IMAGE above (v1.35.5), which is + # the cluster this client talks to. + # renovate: datasource=github-releases depName=kubernetes/kubernetes + KUBECTL_VERSION: v1.35.5 steps: - name: "Checkout Repository" uses: actions/checkout@v7 - name: "Install Helm" uses: azure/setup-helm@v5 + - name: "Install kubectl" + run: | + # kubectl and the cluster are a pair: the client talks to the server + # K3S_IMAGE starts, and Kubernetes supports at most one minor of + # skew between them. Renovate updates KUBECTL_VERSION on its own + # while K3S_IMAGE is tracked by nothing, so without this check the + # two drift apart until the client is talking to a server it no + # longer supports - silently, because kubectl only warns. And it is + # not theoretical: measured by dry run, a lookup from v1.34.0 + # offers v1.37.0 in ONE step, because Renovate fills the non-major + # bucket with the newest candidate rather than walking the minors. + # So this check WILL fire one day, on purpose, with a message that + # says which of the two pins to move. + # + # Deliberately not solved with an allowedVersions constraint pinning + # kubectl to the k3s minor: that is the nginx trap the README + # describes - a filter that silently stops offering updates the day + # the other side moves. A loud failure beats a quiet freeze. + read -r KMAJ KMIN <<<"$(printf '%s' "$KUBECTL_VERSION" | sed -E 's/^v([0-9]+)\.([0-9]+).*/\1 \2/')" + read -r SMAJ SMIN <<<"$(printf '%s' "$K3S_IMAGE" | sed -E 's/.*:v([0-9]+)\.([0-9]+).*/\1 \2/')" + SKEW=$(( (KMAJ * 100 + KMIN) - (SMAJ * 100 + SMIN) )) + if [ "${SKEW#-}" -gt 1 ]; then + echo "::error::kubectl $KUBECTL_VERSION and k3s $K3S_IMAGE are more than one minor apart. Bump K3S_IMAGE to match, or pin KUBECTL_VERSION back." + exit 1 + fi + curl -sSfL -o /tmp/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" + mkdir -p "$HOME/.local/bin" + install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + "$HOME/.local/bin/kubectl" version --client + - name: "Provide envsubst" + run: | + # ci/run-install-test.sh renders ci//fixtures.env.yaml with + # envsubst - today only freesailarr has one, and without the tool + # the fixtures collapse to "error: no objects passed to apply", + # gluetun never gets its Wireguard key, and nothing behind its kill + # switch becomes Ready. GitHub's hosted image ships gettext-base, + # the Gitea runner image does not. The script is fine as it is; a + # standard tool is simply missing. + # + # CHECKED FOR, NOT PINNED - and that is a different decision from + # kubectl above, on purpose. kubectl's VERSION changes what the test + # means: its distance to the API server is a real source of failure, + # which is why it is pinned, tracked and guarded. envsubst + # substitutes text; which release performs the substitution does not + # change the result. Pinning it would buy nothing and add a line for + # Renovate to carry. So: present - do nothing, which keeps the + # GitHub path free of any package install; missing - fetch it, and + # the difference between the two runners is stated rather than + # hidden behind an unconditional apt-get. + if command -v envsubst >/dev/null 2>&1; then + echo "envsubst present at $(command -v envsubst) - nothing to do" + exit 0 + fi + echo "envsubst missing on this runner image - installing gettext-base" + if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends gettext-base + command -v envsubst # freesailarr is the one chart whose install-test needs credentials: # without a real Wireguard key gluetun never completes a handshake, and # behind its kill switch not a single app container becomes Ready. The key @@ -290,8 +403,124 @@ jobs: echo "Both VPN values arrived non-empty." - name: "Install k3d" run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash + # One cluster per matrix leg, named after the chart. It used to be the + # fixed name "install-test", and that was wrong on GitHub too - it just + # could not show there, because GitHub gives every matrix leg its own VM + # with its own Docker daemon, so two legs never saw each other's + # cluster. The environment hid the bug for two years rather than + # preventing it. Gitea runs all legs of a matrix CONCURRENTLY in ONE job + # on ONE daemon, and there the fixed name collides at once: measured on + # the probe PR of #225, one leg logs "Re-using existing network + # 'k3d-install-test'", the other dies on "The container name + # /k3d-install-test-server-0 is already in use" - and then the first + # leg's rollback deletes the shared network out from under the second, + # which fails with "network k3d-install-test not found". Both legs red, + # neither cause in either message. DO NOT put the fixed name back. + # + # Why the bare chart name and not a prefixed one: k3d rejects a cluster + # name over 32 characters ("Cluster name must be <= 32 characters", + # measured against k3d v5.9.0 - 32 passes, 33 fails), and every + # container is named k3d--server-0 from it. The longest chart + # here is calibre-web-automated at 21, so "install-test-" would + # be 34 and break exactly that one chart and no other - the kind of + # prefix that is worse than the fixed name because it only sometimes + # breaks. The guard below states the limit instead of leaving it to be + # rediscovered. - name: "Create k3d cluster" - run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s + run: | + CLUSTER="${{ matrix.chart }}" + if [ "${#CLUSTER}" -gt 32 ]; then + echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name." + exit 1 + fi + + # Does THIS job run inside a container? That, and not which forge we + # are on, is what decides whether k3d's cluster is reachable (#227). + # + # k3d publishes the API server port on the Docker *host*. When the + # job runs straight on the host - GitHub's hosted runners - that is + # the same machine, and the "https://0.0.0.0:" k3d writes into + # the kubeconfig works. When the job itself is a container, as every + # Gitea Actions job is, the host is the dind container and 0.0.0.0 + # points at the job container itself, where nothing listens. The + # cluster comes up fine and the first kubectl call is refused. + # + # Deliberately NOT keyed on github.server_url, although that would + # pick the right branch today: a self-hosted GitHub runner in a + # container has exactly this problem, and a condition that says + # "GitHub" while meaning "not containerised" is the kind of lie this + # repo exists to remove. So the state is detected instead of + # inferred, and the detection is the same question the fix needs + # answered anyway: ask the daemon about ourselves. Inside a + # container that the daemon knows, this returns our network and k3d + # joins it, which puts k3s and this job on one bridge where Docker's + # own DNS resolves the container names. On a host it returns nothing, + # because the hostname is not a container, and the old path is taken + # unchanged. + # The "|| true" is load-bearing: this step runs under `bash -e -o + # pipefail`, and on a host `docker inspect` exits non-zero, which + # would abort the step instead of taking the else branch. + JOB_NET="$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}' || true)" + + if [ -n "$JOB_NET" ]; then + echo "Containerised job: $(hostname) on docker network '$JOB_NET' - k3d joins it." + k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --network "$JOB_NET" --wait --timeout 180s + # Same reason: the published port is useless from in here, so the + # kubeconfig is pointed at the load balancer container by name, + # over the shared network. If the API server certificate did not + # carry that name this would fail on the TLS handshake and the + # cluster-info below would say so - it holds without an extra + # --tls-san, measured on the probe PR of #225. + kubectl config set-cluster "k3d-$CLUSTER" --server="https://k3d-$CLUSTER-serverlb:6443" + else + echo "Job runs on the docker host - k3d's published port is reachable as written." + k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s + fi + + # Reachability is proven HERE, and the shape of this check is the + # product of getting it wrong twice. Both mistakes are written down + # because both look like something to simplify away. + # + # It is a LOOP and not a single call. k3d's --wait returns when the + # node container reports ready, but the API server can still answer + # discovery with 503 for a moment after that. Measured: a single + # `kubectl cluster-info` right here failed with "ServiceUnavailable: + # the server is currently unable to handle the request" on one of + # two legs of the SAME GitHub run while the other passed. A check + # that is itself racy produces exactly the sporadic red this job + # must never have (#227) - and a test people have learned to re-run + # is one they have stopped believing. + # + # It counts READY NODES and not "did kubectl answer". `kubectl get + # nodes` exits 0 on an EMPTY list, so the first version passed + # against a cluster whose API server was up and whose node had never + # registered: the log said "No resources found", the step went + # green, and the failure surfaced five minutes later as a pod stuck + # in Pending with no node and no scheduling event - nowhere near its + # cause. A check that a broken cluster passes is worse than no + # check. `kubectl wait --for=condition=Ready nodes --all` is not the + # shorter way to write this: it has the identical hole, because with + # zero nodes the condition is vacuously satisfied. + # + # The evidence that this version earns its keep: only after it + # counted nodes did the real cause become visible in the dump below + # - k3s could not start containerd ("error creating fsnotify + # watcher: too many open files", an inotify limit on the host), and + # nothing above this line had ever said so. + for ATTEMPT in $(seq 1 90); do + READY_NODES="$(kubectl get nodes --no-headers 2>/dev/null | awk '$2 == "Ready"' | wc -l | tr -d ' ')" + [ "$READY_NODES" -ge 1 ] && break + if [ "$ATTEMPT" -eq 90 ]; then + echo "::error::the k3d cluster '$CLUSTER' answers but has no Ready node after 180s" + kubectl get nodes -o wide || true + kubectl describe nodes || true + echo "--- k3s server log ---" + docker logs "k3d-$CLUSTER-server-0" 2>&1 | tail -80 || true + exit 1 + fi + sleep 2 + done + kubectl get nodes -o wide # Every chart reaching this step is one the exclusion filter let through, # so the script's SKIP branch is never taken here - it exists for local # runs. The variables below are empty for every chart but the one that @@ -302,6 +531,10 @@ jobs: FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }} FREESAILARR_WG_ADDRESSES: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_ADDRESSES }} run: ci/run-install-test.sh "${{ matrix.chart }}" + # Must carry the same per-leg name as the create step above: a blanket + # cleanup, or the old fixed name, would tear down the neighbouring leg's + # cluster on a runner that shares one Docker daemon - which is precisely + # the half of the collision that turned one red leg into two. - name: "Delete k3d cluster" if: always() - run: k3d cluster delete install-test || true + run: k3d cluster delete "${{ matrix.chart }}" || true diff --git a/CLAUDE.md b/CLAUDE.md index 3b33edf..1a46d63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,16 +7,28 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co The main Claude session acts as the **task manager** and does not implement issues itself: 1. **Plan first, get approval**: The manager presents a plan for the issue and waits for the user's explicit approval. No implementation before approval. -2. **Document the final plan on the issue**: Once approved, the plan is posted as a comment on the GitHub issue before implementation starts (skip if the issue body already contains the full plan; later decisions go there as comments too). +2. **Document the final plan on the issue**: Once approved, the plan is posted as a comment on the issue before implementation starts (skip if the issue body already contains the full plan; later decisions go there as comments too). 3. **Delegate implementation**: After approval, the manager launches a dedicated **development agent**. The agent implements the issue completely on its own branch and opens a PR. 4. **PR comment handling by the agent**: After opening the PR, the development agent watches it for review comments. New comments are addressed automatically (code changes, reply on the thread), and addressed threads are marked as **resolved**. 5. **Agent lifetime**: The development agent terminates itself once its PR has been merged (or closed). 6. Only after the PR has been **merged by the user** may work on the next issue begin. The user merges; never merge yourself. -7. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. +7. **After the merge, verify the linked issue was closed** (auto-close via "Fixes #n"); close it manually if not. Gitea auto-closes on the same keywords, but only for an issue in the same repository — a migrated issue whose number changed is closed by hand. Additional rules for all commits, PRs, issues, and comments: - Do **not** add a `Co-Authored-By` trailer and do **not** append "Generated with Claude Code" footers. +- **Commits must be signed.** Verify the signature before opening the PR; if `op-ssh-sign` fails, stop and report rather than pushing unsigned work. + +## Which forge, and with which tool + +The repository is moving from github.com to the Gitea instance JLab-Git (#225). The move is **not finished**, so both are live and the steps below differ by where the work happens. README, "Where this repo lives", holds the measured differences between the two CIs; this section holds only what changes about the *workflow*. + +- **Remotes are named `github` and `JLab-Git`** — there is no `origin`. Say which one you mean; `git push` without a remote is ambiguous here. +- **Today the merge flow still runs on GitHub.** Issues, pull requests and merges happen there until the maintainer switches Actions off and turns on the mirror. Until then, **do not push to `JLab-Git/main`**: with publish workflows armed on both sides, two CIs publish into the same registry, and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160). Pushing a *branch* to Gitea is fine — only `main` triggers a publish. +- **`gh` only as `op plugin run -- gh`**, `tea` only as `op plugin run -- tea`. A bare `gh` falls back to the wrong identity. Every call costs a 1Password confirmation, so bundle them and never poll. +- **After the move: no merge queue.** Gitea has none. Its place is taken by **auto-merge on green checks** — the PR is armed once and merges itself when the required checks pass. The manager still decides *whether* to arm it; rule 6 above is unchanged: the user merges, and an agent never merges its own PR. +- **Issue numbers will not survive the move.** Gitea renumbers, and issues and PRs share one counter, so a `#n` written today will not land on the same number over there. The migration itself is the maintainer's one-off job over the API; how the old references are dealt with afterwards is open and decided in #225. Until that is decided, a `#n` anywhere in this repo means the GitHub issue. +- **Renovate is already running on the Gitea side, and not from this repo.** A shared `jlab-renovate-bot` instance serves the other repositories there and has this one enrolled; it reads our `renovate.json5` and its `postUpgradeTasks` hook is allow-listed (both verified on its first run). **Do not add a Renovate workflow here** — a second runner against the same repo means duplicate PRs and two schedulers rewriting each other's branches. README, "Renovate on Gitea", has the evidence. ## Keep the docs current @@ -26,7 +38,7 @@ Additional rules for all commits, PRs, issues, and comments: ## What this repo is -A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry via GitHub Actions — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README): +A collection of self-made Helm charts, one directory per chart, published to a private, classic (index-based) Helm registry by CI (GitHub Actions today, Gitea Actions after #225; the workflow files are read by both unchanged) — an HTTP `POST` of the packaged `.tgz`, not an OCI `helm push`; the target URL and its credentials live in 1Password and never in this repository. Validation happens on three levels: locally with Helm, in the PR pipeline (`validate-charts.yml`: strict lint, package, version-bump check), and in the PR install-test stage (k3d, fixtures under `ci/` — see README): ```bash helm lint --strict diff --git a/README.md b/README.md index 149f8c5..93d756b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # helm-charts -Collection of some self-made helm-charts. Each top-level directory is a standalone Helm chart that gets packaged and uploaded to a private, classic (index-based) Helm registry via GitHub Actions. +Collection of some self-made helm-charts. Each top-level directory is a standalone Helm chart that gets packaged and uploaded to a private, classic (index-based) Helm registry by CI. + +> **The repository is moving from github.com to the Gitea instance JLab-Git (#225).** The code is already on both and identical; what still runs the show is GitHub. The workflow files under `.github/workflows/` are read by both forges unchanged, and "Where this repo lives" below names the handful of places where the two behave differently — each of them measured on a probe pull request, not assumed. ## Charts @@ -38,7 +40,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo - Bump the **chart version** in every change that touches a chart directory — a push to `main` publishes the chart. - **App-version updates go through PRs** (so the validate and install-test pipelines run). The manually dispatched `Change App-Version` workflow (rewrites `version:`/`appVersion:` and commits directly to `main`) exists but bypasses both pipelines. For app **major** jumps, update stepwise: first to the last release of the old major line (own PR, merge, publish), then to the newest major. -## CI/CD (GitHub Actions) +## CI/CD - `publish-.yml` — one thin workflow per chart; triggers on pushes to `main` touching `/**` (or manually) and calls the reusable workflow below. **A push to a chart directory on `main` publishes that chart**, so the chart version must be bumped in the same change. - `publish-helm-chart.yml` — reusable workflow: `helm package`, then a single HTTP `POST` of the packaged `.tgz` to the private chart registry. The target is a **classic, index-based** Helm registry (Gitea's `/api/charts` endpoint), not an OCI one: `helm push` speaks only OCI and is not used, there is no `helm registry login` session, and every request carries its own Basic auth. Credentials **and the target URL** come from 1Password Connect (`OP_CONNECT_TOKEN` secret, item `op://GitHub-Actions/jlab-registry-access/` with `username`, `password`, `charts-url`); the workflow appends `/api/charts` to `charts-url`. No registry address ever appears in this repository. The loaded values are read via **`steps..outputs.*`**, mapped into the step's own `env:` block and used as quoted shell variables — never interpolated into the command line. `load-secrets-action` **v5 no longer exports them as environment variables** for later steps the way v4 did, so after the v5 bump every `${{ env.* }}` reference resolved to an empty string while the loading step still logged `Populating variable` and stayed green. Each value is additionally guarded with `:?`, because an empty one used to collapse into a missing argument rather than an error. Three properties of the upload step are load-bearing: @@ -51,6 +53,22 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo - `ci/helm-dependencies.sh ` — not a workflow but the step they all share: fetches a chart's declared dependencies into `charts/`, and returns silently for the charts that declare none (all but one). It runs in **five** places, because every helm command this repo builds on exits 1 while a declared dependency is missing: before lint and package in `validate-charts.yml`, before package in `publish-helm-chart.yml`, and inside `ci/run-install-test.sh`, `ci/run-quantity-render-test.sh` and `ci/run-trivy-config.sh`. See "Chart dependencies" in the style guidelines for what that costs. - `validate-charts.yml` — PR pipeline: detects which charts a PR touches and, for exactly those, runs `helm lint --strict`, `helm package`, and a chart-version bump check (`template-chart` is exempt from the version check). PRs without chart changes skip the build matrix. After the build stage, an **install-test** job (the same changed-charts matrix, minus the charts listed in `ci/excluded-charts.txt`) installs each changed chart into an ephemeral [k3d](https://k3d.io) cluster on the runner and requires every workload to become Ready — see below. Both matrices follow the changed chart directories, so a PR that only changes the harness under `ci/` exercises nothing by itself; the Trivy scan has a fixed-sample fallback for that case, the install-test has none — bump the affected chart's patch version to pull it into the matrix. The **resource-quantity render test** is the exception to that rule: it runs over every chart on every PR — see below. +### Where this repo lives, and what Gitea Actions does differently + +The move to the Gitea instance JLab-Git (#225) is in progress. Order matters and is not negotiable: **CI provably green on Gitea → GitHub Actions off → mirror on.** Any other order leaves a window in which either no CI exists or two of them publish into the same registry — and that registry accepts a re-upload of an existing version silently, replacing the artifact (#160). + +Everything below was measured on a throwaway pull request against the Gitea instance, not taken from documentation. It is the reason for the two deviations now in `validate-charts.yml`. + +- **Reusable workflows with `secrets: inherit` work.** The 23 thin `publish-.yml` hulls survive the move unchanged. (A missing secret does not fail the job — Gitea fails at *job creation*, so the run shows no steps at all, which is worth knowing before debugging the wrong thing.) +- **`ubuntu-latest` on this instance has no Docker socket; `ubuntu-docker` has.** Same label, different meaning than on GitHub's hosted runners, and only one job in this repo cares: the install-test creates a k3d cluster, and k3d is a Docker client. Measured, per job: `detect-changed-charts`, `build-chart`, the quantity render and the Trivy config scan all pass on `ubuntu-latest` — none of them needs Docker. The install-test on `ubuntu-latest` dies at `Create k3d cluster` with `Cannot connect to the Docker daemon at unix:///var/run/docker.sock`, an error that names k3d and never the runner label. +- **`ubuntu-docker` is necessary but not yet sufficient.** Its daemon (8 vCPU, 64 GB, Docker 29.5.2) is **rootless** and reports `Cgroup Driver: none` plus `WARNING: Running in rootless-mode without cgroups`. k3d gets as far as creating the network, pulling the images and starting the node; the k3s server container then restarts immediately with an empty log. k3s needs cgroup delegation. Until the instance's Docker-in-Docker gets it (rootful, or rootless with systemd delegation), **the install-test cannot run on Gitea at all** — see #225. +- **`aquasecurity/setup-trivy` cannot be used.** It runs `actions/checkout` against `aquasecurity/trivy` on github.com with `github.token`, which on Gitea is a *Gitea* token; github.com answers 401 and checkout retries twice before ending on `fatal: could not read Username for 'https://github.com'`. Its `actions/cache` step is inert on the instance as well (`Cache action is only supported on GHES version >= 3.5`), and fetching the nested actions cost 4m30s before the job started. The workflow now downloads the release tarball instead — one request, identical behaviour on both forges, and the `# renovate:` comment above `TRIVY_VERSION` still keeps it current (proved by dry run: pinned to `v0.72.0`, the lookup offers `v0.74.0` as a minor). +- **Matrix jobs lose their name in the status list.** Gitea does not expand `${{ matrix.chart }}` in a job `name:`; it substitutes the matrix *expression*, so the commit status reads `Validate Helm Charts / Install-test ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} (pull_request)` and **all legs of a matrix share that one context**. Consequence for branch protection: a per-chart required check is not possible on Gitea, and the required-check names differ from GitHub's — they have to be read off a real run rather than copied. +- **Actions are fetched from github.com anonymously and that works.** `actions/checkout`, `azure/setup-helm` and the 1Password action need no replacement; only an action that *itself* authenticates against github.com breaks (see Trivy above). +- **No merge queue.** Gitea has none. Its place is taken by auto-merge on green checks, which for a single maintainer is equivalent in effect and different in one way worth remembering: auto-merge merges as soon as the checks it knows about are green, so it is only as strong as the *required* checks configured on `main`. With none configured it merges immediately. + +Still on the maintainer's side and not delegable: branch protection plus the commit-signature requirement on the Gitea repo, the runner's cgroup problem above, and switching GitHub Actions off before enabling the mirror. The bot token gets `403` on `actions/runners`, `actions/secrets` and `branch_protections`, so none of that can be verified from here. + ### Resource-quantity render test (all charts, every PR) `ci/run-quantity-render-test.sh [chart …]` (no argument = all charts) renders every chart twice with the fixtures the install-test and the Trivy scan already use, injecting a bare integer memory request at **every** `resources` block the chart's `values.schema.json` declares: @@ -90,7 +108,7 @@ Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to d ## Dependency updates (Renovate) -Renovate runs as the self-hosted `jlab-renovate` GitHub App. Its config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`. +Renovate runs as the self-hosted `jlab-renovate` GitHub App while the repo is on github.com, and on Gitea as the shared `jlab-renovate-bot` instance that already serves the other repositories there — see "Renovate on Gitea" below. **There is no Renovate workflow in this repo, and none should be added.** Which of the two runs it changes nothing about *what* gets updated: its config is **`renovate.json5`** in the repo root (JSON5 for the inline reasoning; requires Renovate >= 41 for `managerFilePatterns`/`customType`). There must be exactly one config file — do not add a second `renovate.json` or `.github/renovate.json`. What Renovate keeps current: @@ -134,9 +152,20 @@ Everything is derived from the base commit, so the script is idempotent across t If the dependency dashboard reports `Failed to look up github-releases package …: no-result` for third-party repos, that is the instance's GitHub credentials, not this config: a GitHub App installation token is not a good fit for looking up repos the app is not installed on. Give the instance a separate read-only PAT as `GITHUB_COM_TOKEN` for github.com datasource lookups. +### Renovate on Gitea + +**Do not add a Renovate workflow to this repository.** It is the obvious thing to reach for — Gitea has no app model, so a scheduled job in the repo looks like the only way to run the bot — and it is wrong here: a **shared `jlab-renovate-bot` instance already runs on the Gitea side** and already has this repository enrolled. A second runner against the same repo produces duplicate PRs and two schedulers rewriting each other's branches, which is not a safety net but a fault. This paragraph exists so the next reader who finds no workflow knows why instead of building one. + +The instance is outside this repository, which has two consequences worth writing down: + +- **It reads our `renovate.json5` and the per-chart rules do fire.** Measured, not assumed: its first run on the migrated repo opened PRs for `searxng` and `gotenberg` plus the dependency dashboard. Those two are `customManagers` targets of ours — no built-in manager finds an `appVersion:` in a `Chart.yaml` — so the config is demonstrably in effect. +- **`allowedCommands` is already granted there, and the version-bump hook runs.** This was the expensive unknown: `postUpgradeTasks` only executes when the command is allow-listed in the *instance* config, and a missing entry is skipped **silently**, leaving every chart PR red on the version check. The proof is in that searxng PR's diff, which carries `version: 1.0.4+up2026.9.11.ffe96f8a6` → `1.0.5+up2026.9.13.96479fff6` alongside the `appVersion` change. Nothing but `ci/renovate-bump-chart.sh` writes `version:`. Nothing has to be configured for the move — but the allow-list is not visible from this repo either, so if it is ever dropped, the symptom here will be red chart PRs with no explanation. + +One item does not come for free on either side and is named in "The version-bump hook" above: a `GITHUB_COM_TOKEN` for github.com datasource lookups. Under the GitHub App those rode on the installation token; a Gitea token is worth nothing at github.com, and unauthenticated lookups hit the rate limit inside a single pass. The symptom is a `no-result` line on the dependency dashboard, not an error. + ### PR policy -- **Automerge** for `patch`, `minor` and `digest` updates, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire. Renovate merges itself rather than using GitHub auto-merge, which would merge immediately as long as `main` has no required status checks. Majors are merged by hand. +- **Automerge** for `patch`, `minor` and `digest` updates, after a `minimumReleaseAge` of 3 days and only once the PR pipeline is green — install-test included, which is why that job no longer runs with `continue-on-error`. Minor is in scope because the sidecar images barely move in patch steps (`alpine 3.22` → `3.24`, `nginx 1.30.4` → `1.31.4` are both minor), so a patch-only rule would practically never fire. Renovate merges itself (`platformAutomerge: false`) rather than handing the PR to the forge's own auto-merge, which on **both** GitHub and Gitea would merge immediately as long as `main` has no required status checks. That is a statement about the branch protection, not about the platform: once required checks exist on `main`, `platformAutomerge: true` becomes the better setting on either side. Majors are merged by hand. - **Majors** arrive one line at a time (`separateMultipleMajor`): a v1 → v3 jump produces a PR for the latest v2 first and then one for v3 — the stepwise procedure the versioning section requires. - `prConcurrentLimit: 10` / `prHourlyLimit: 4` keep the k3d install-tests from all starting at once. - The manually dispatched `change-app-version.yml` still exists for one-off bumps, but the normal path for app updates is now a Renovate PR. diff --git a/renovate.json5 b/renovate.json5 index 51ed472..b68fd34 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -50,6 +50,14 @@ // // Without that entry Renovate skips the task silently and every chart PR // stays red on the version-bump check. + // + // On the Gitea side (#225) that entry EXISTS and was verified rather than + // hoped for: the shared instance's first run on the migrated repo produced + // a searxng PR whose diff carries `version: 1.0.4+up... -> 1.0.5+up...` + // next to the appVersion. No built-in manager touches `version:`, so that + // line can only come from this hook. Nothing has to be configured for the + // move - but the allow-list lives on that instance, not in this repo, so it + // is also not visible from here if it is ever removed. postUpgradeTasks: { commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"], fileFilters: ["**/Chart.yaml"], @@ -80,10 +88,13 @@ matchUpdateTypes: ["patch", "minor", "digest"], automerge: true, automergeType: "pr", - // Renovate waits for the checks itself instead of handing the PR to - // GitHub's auto-merge. GitHub would merge immediately when the branch has - // no required status checks configured — and validate-charts is not (yet) - // a required check. + // Renovate waits for the checks itself instead of handing the PR to the + // forge's own auto-merge. Both of them — GitHub's, and the auto-merge + // that takes the merge queue's place on Gitea, which has none — merge + // immediately when the branch has no required status checks configured, + // and validate-charts is not (yet) a required check on either side. This + // is a statement about the branch protection, not about the platform: + // once required checks exist on `main`, true is the better value. platformAutomerge: false, // A release that is hours old should not walk into main unattended; a // chart publish follows the merge straight away.