From 9f1c7766727590733a3477d012e05396d74cf07c Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:01:42 +0000 Subject: [PATCH 01/24] Update l4rm4nd/vouchervault Docker tag to v1.30.2 --- voucher-vault/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/voucher-vault/Chart.yaml b/voucher-vault/Chart.yaml index ab1e08b..b68379b 100644 --- a/voucher-vault/Chart.yaml +++ b/voucher-vault/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/l4rm4nd/VoucherVault/main/myapp/static/a type: application -version: 6.1.0+up1.30.1 -appVersion: "1.30.1" +version: 6.1.1+up1.30.2 +appVersion: "1.30.2" maintainers: - name: jklein \ No newline at end of file From 7e168da4922ead4549c2af92eec679b11726e797 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:52:08 +0200 Subject: [PATCH 02/24] ci: parse the exclusion list in exactly one place Two consumers read ci/excluded-charts.txt: this script and the install-test matrix in validate-charts.yml, which has to drop excluded charts before the matrix is built (#231). Two parsers would be two chances to select different charts, and the disagreement would be silent in either direction. So the script grows a --list-excluded mode and the workflow will call it instead of re-implementing the parsing. A line carrying only a chart name and no justification is deliberately not an exclusion: the README requires the reason, and a bare name would drop a chart out of CI with nothing to review. The reason lookup matches the name exactly instead of as a regex, so a chart name is never read as a pattern. The workflow side is not part of this commit. --- ci/run-install-test.sh | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/ci/run-install-test.sh b/ci/run-install-test.sh index 9415f3e..fa9d530 100755 --- a/ci/run-install-test.sh +++ b/ci/run-install-test.sh @@ -4,6 +4,7 @@ # fixtures under ci// and requires every workload to become Ready. # # Usage: ci/run-install-test.sh +# ci/run-install-test.sh --list-excluded # # Layout (repo root): # ci/common/ fixtures applied for every chart (OnePasswordItem stub CRD) @@ -14,12 +15,48 @@ # ci/excluded-charts.txt charts that cannot run in CI, with reasons set -euo pipefail -CHART="${1:?usage: $0 }" NAMESPACE="default" RELEASE="ci" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CI_DIR="$ROOT/ci" +# --- The exclusion list, parsed in exactly one place ------------------------ +# Two consumers read ci/excluded-charts.txt: this script, and the install-test +# matrix in .github/workflows/validate-charts.yml, which drops the excluded +# charts before the matrix is built (issue #231). Two parsers would be two +# chances to select different charts - and the failure would be silent in the +# worst direction, a chart the workflow believes excluded and the script +# believes testable, or the reverse. So there is one parser, here, and the +# workflow calls it through "--list-excluded" instead of re-implementing it. +# +# The format is one chart per line: first whitespace-separated field the chart +# name, the rest of the line its mandatory justification. Blank lines and lines +# starting with "#" are comments. A line carrying only a name and no reason is +# deliberately NOT an exclusion (the "NF > 1" below): the justification is what +# the README requires, and a bare name would silently drop a chart out of CI +# with nothing to review. +list_excluded() { + [ -f "$CI_DIR/excluded-charts.txt" ] || return 0 + { grep -vE '^[[:space:]]*(#|$)' "$CI_DIR/excluded-charts.txt" || true; } \ + | awk 'NF > 1 { name = $1; $1 = ""; sub(/^[[:space:]]+/, ""); print name "\t" $0 }' +} + +# The reason a chart is excluded, empty if it is not. Exact string match on the +# name, not a regex, so a chart name is never read as a pattern. +excluded_reason() { + list_excluded | awk -F'\t' -v chart="$1" '$1 == chart { print $2; exit }' +} + +# Emits "" for every excluded chart. Read by the detect job +# of validate-charts.yml before any chart is picked, which is why this runs +# before the argument is required. +if [ "${1:-}" = "--list-excluded" ]; then + list_excluded + exit 0 +fi + +CHART="${1:?usage: $0 | --list-excluded}" + # Per-chart override via ci//settings.env (INSTALL_TIMEOUT in seconds). # REQUIRED_ENV is a space-separated list of environment variables the chart's # fixtures need; see the check below. From 622e4c3ff88cf40b8d71727a0cd0a8a0367aa2c3 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:58:14 +0200 Subject: [PATCH 03/24] ci: drop excluded charts from the install-test matrix A chart in ci/excluded-charts.txt got an install-test job that ran the script, printed SKIP and exited 0. GitHub knows only the exit code, so the check list showed "Install-test satisfactory OK" for a chart whose image was never pulled - indistinguishable from one that really came up in the cluster (#231). detect-changed-charts now removes those charts before the matrix is built, so no job exists for them and nothing claims they passed. An absence says nothing by itself, so the detect job writes every excluded chart of the PR with its documented justification into the job summary. A "Skipped" would only say "not run"; the reason is the question a review actually asks. The list is not parsed in YAML. The workflow calls ci/run-install-test.sh --list-excluded, the one parser, and compares whole strings with jq, so a chart name is never read as a pattern. The script's own exclusion branch now goes through the same excluded_reason() helper instead of a grep that used the chart name as a regex; it stays for local runs, where the CI filter does not apply. Not viable, and recorded at the matrix as a comment: deciding per chart in an "if" on the install-test job, which would give a visible Skipped entry instead of an absent one. GitHub rejects it - jobs..if is evaluated before the matrix is expanded, so the matrix context does not exist there ("context \"matrix\" is not allowed here. available contexts are \"github\", \"inputs\", \"needs\", \"vars\"."). Run 33650724109 on the closed PR #233 ended in failure with zero jobs created. --- .github/workflows/validate-charts.yml | 79 ++++++++++++++++++++++++--- ci/run-install-test.sh | 16 ++++-- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 045206c..2488734 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -11,6 +11,8 @@ jobs: any-chart-changed: ${{ steps.detect.outputs.any-chart-changed }} scan-charts: ${{ steps.detect.outputs.scan-charts }} any-scan: ${{ steps.detect.outputs.any-scan }} + install-charts: ${{ steps.detect.outputs.install-charts }} + any-install: ${{ steps.detect.outputs.any-install }} steps: - name: "Checkout Repository" uses: actions/checkout@v7 @@ -54,6 +56,55 @@ jobs: echo "any-scan=true" >> "$GITHUB_OUTPUT" fi + # --- The install-test matrix: changed charts minus the excluded ones + # (issue #231). A chart in ci/excluded-charts.txt used to get a job + # that ran the script, printed SKIP and exited 0 - and GitHub knows + # only the exit code, so the check list showed a green tick for a + # chart nothing was ever attempted on. Dropping it here means no job + # exists for it and nothing claims it passed. + # + # The list is NOT parsed here. ci/run-install-test.sh --list-excluded + # emits "", and that script is the one parser; + # re-implementing the format in YAML would be a second chance to + # select different charts, silently. The comparison below is jq's + # "!=" on whole strings, so a chart name is never read as a pattern. + INSTALL_CHARTS="$CHARTS" + SUMMARY_ROWS="" + while IFS=$'\t' read -r EX_NAME EX_REASON; do + [ -n "$EX_NAME" ] || continue + if [ "$(echo "$INSTALL_CHARTS" | jq -r --arg c "$EX_NAME" 'index($c) != null')" = "true" ]; then + # A "|" in the justification would break out of the table cell. + SUMMARY_ROWS="${SUMMARY_ROWS}| \`$EX_NAME\` | ${EX_REASON//|/\\|} |"$'\n' + echo "Excluded from the install-test: $EX_NAME - $EX_REASON" + fi + INSTALL_CHARTS="$(echo "$INSTALL_CHARTS" | jq -c --arg c "$EX_NAME" 'map(select(. != $c))')" + done <<< "$(ci/run-install-test.sh --list-excluded)" + echo "install-charts=$INSTALL_CHARTS" >> "$GITHUB_OUTPUT" + if [ "$INSTALL_CHARTS" = "[]" ]; then + echo "any-install=false" >> "$GITHUB_OUTPUT" + else + echo "any-install=true" >> "$GITHUB_OUTPUT" + fi + + # An absent job says "not run" but never why, and an absence is easy + # to miss entirely. So every excluded chart of this PR is named here + # WITH its documented justification - which is the question a review + # actually asks. This summary is the visible half of the change; the + # dropped matrix entry is only the half that stops the lie. + if [ -n "$SUMMARY_ROWS" ]; then + { + echo "### Charts excluded from the install-test" + echo + echo "These charts are changed by this PR but listed in \`ci/excluded-charts.txt\`, so they get **no** install-test job. There is no green tick for them, because nothing was attempted (issue #231)." + echo + echo "| Chart | Documented reason |" + echo "| --- | --- |" + printf '%s' "$SUMMARY_ROWS" + echo + } >> "$GITHUB_STEP_SUMMARY" + fi + echo "Install-test matrix: $INSTALL_CHARTS" + build-chart: name: "Build ${{ matrix.chart }}" needs: detect-changed-charts @@ -162,7 +213,7 @@ jobs: install-test: name: "Install-test ${{ matrix.chart }}" needs: [detect-changed-charts, build-chart] - if: needs.detect-changed-charts.outputs.any-chart-changed == 'true' + if: needs.detect-changed-charts.outputs.any-install == 'true' runs-on: ubuntu-latest # Blocking since Renovate automerges patch/minor updates: a chart that no # longer starts must stop the PR before it reaches main and publishes. This @@ -171,8 +222,22 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false + # "install-charts", not "charts": the changed charts minus the ones in + # ci/excluded-charts.txt, filtered in detect-changed-charts. Excluded + # charts are named with their reason in that job's summary (issue #231). + # + # DO NOT try to move that decision into an "if" on this job, e.g. + # if: ... && !contains(fromJSON(needs.detect-changed-charts.outputs.excluded-charts), matrix.chart) + # to get a visible "Skipped" per chart instead of an absent entry. + # GitHub rejects it - jobs..if is evaluated BEFORE the matrix is + # expanded, so the matrix context does not exist there: + # context "matrix" is not allowed here. + # available contexts are "github", "inputs", "needs", "vars". + # Measured on run 33650724109 (PR #233, closed): the whole workflow ended + # in failure with zero jobs created. The per-chart value has to be gone + # from the matrix by the time this job is reached. matrix: - chart: ${{ fromJSON(needs.detect-changed-charts.outputs.charts) }} + chart: ${{ fromJSON(needs.detect-changed-charts.outputs.install-charts) }} env: K3D_VERSION: v5.9.0 K3S_IMAGE: docker.io/rancher/k3s:v1.35.5-k3s1 @@ -227,11 +292,11 @@ jobs: run: curl -sSfL https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG="$K3D_VERSION" bash - name: "Create k3d cluster" run: k3d cluster create install-test --image "$K3S_IMAGE" --wait --timeout 180s - # Excluded charts (see ci/excluded-charts.txt) are skipped inside the - # script with their documented reason. The variables below are empty for - # every other chart and are read only by the chart that declares them in - # ci//settings.env (REQUIRED_ENV); the script fails loudly if a - # declared one is missing, it never skips over it. + # 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 + # declares them in ci//settings.env (REQUIRED_ENV); the script + # fails loudly if a declared one is missing, it never skips over it. - name: "Install chart and wait for readiness" env: FREESAILARR_WG_KEY: ${{ steps.load-freesailarr-vpn.outputs.FREESAILARR_WG_KEY }} diff --git a/ci/run-install-test.sh b/ci/run-install-test.sh index fa9d530..8609d36 100755 --- a/ci/run-install-test.sh +++ b/ci/run-install-test.sh @@ -69,12 +69,16 @@ if [ -f "$CI_DIR/$CHART/settings.env" ]; then fi # --- Exclusion list --------------------------------------------------------- -if [ -f "$CI_DIR/excluded-charts.txt" ]; then - REASON="$(grep -E "^$CHART([[:space:]]|$)" "$CI_DIR/excluded-charts.txt" | sed -E "s/^$CHART[[:space:]]*//" || true)" - if [ -n "$REASON" ]; then - echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON" - exit 0 - fi +# Through excluded_reason(), i.e. the same parser --list-excluded uses, so a +# local run and the workflow matrix can never disagree about which charts are +# excluded. In CI this branch is no longer reached: the detect job drops the +# excluded charts before the matrix is built, so no install-test job exists for +# them (#231). It stays for local runs, where "ci/run-install-test.sh +# satisfactory" should say why it does nothing instead of trying. +REASON="$(excluded_reason "$CHART")" +if [ -n "$REASON" ]; then + echo "SKIP: chart '$CHART' is excluded from the install-test: $REASON" + exit 0 fi if [ ! -d "$ROOT/$CHART" ] || [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then From 5a4eeefd0eca79b24f077b88f6864924d7f66fd2 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:58:22 +0200 Subject: [PATCH 04/24] docs: the excluded-chart signal, and minecraft-server's image-tag exception Two documentation gaps, both about a reader drawing the wrong conclusion. The install-test section said nothing about what an exclusion looks like in a PR's check list. It now says that an excluded chart gets no job at all rather than a green tick, that the detect job names it with its documented reason in the summary, that the mandatory justification is what makes a line an exclusion in the first place, and why the per-chart "if" that would produce a visible Skipped is not available (#231). Section 3 of the style guidelines lists the charts that deviate from "the main image tag comes from appVersion" so that nobody mistakes a deviation for a mistake and tidies it away. minecraft-server has been the only chart with an overridable tag since #158 and was missing from that list: the line says that image.tagOverride exists, why (only the newest Java line is maintained, so the line becomes a decision at rollout time instead of a property of the chart), and that the chart checks the pairing of effective tag and minecraft.version itself and fails the render on a mismatch (#232). --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3c273ff..149f8c5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Collection of some self-made helm-charts. Each top-level directory is a standalo Note: the registry **accepts a re-upload of an already published version** (HTTP 201) and replaces the stored artifact, so a republish of an existing version is still silent — see #160. - `change-app-version.yml` — manually dispatched app-version bump; the chart must be listed in its `chart-name` choice list. - `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 (same changed-charts matrix) 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. +- `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. ### Resource-quantity render test (all charts, every PR) @@ -80,10 +80,12 @@ Fixtures live at the **repo root** under `ci/` (deliberately *not* inside the ch - `ci//fixtures.yaml` — dummy secrets with in-cluster connection data, throwaway infrastructure (postgres/redis/dex), static hostPath PVs/StorageClasses where the chart pins a storage class or needs RWX, and seed jobs (e.g. `synapse generate`) - `ci//fixtures.env.yaml` — fixtures that need a value which must not be committed. The file holds `${VARIABLE}` placeholders and nothing else; `ci/run-install-test.sh` renders it with `envsubst` and pipes the result straight into `kubectl` (never printed). Substitution is restricted to the names from `REQUIRED_ENV`, so an undeclared variable is never filled in and no unrelated `$…` in the manifest is touched - `ci//settings.env` — optional overrides: `INSTALL_TIMEOUT` for slow starters, and `REQUIRED_ENV="VAR_A VAR_B"` to declare the variables `fixtures.env.yaml` needs. **A missing or empty declared variable fails the install-test loudly, naming the variable — it never skips the chart.** Skipping would restore exactly the green-but-meaningless signal this mechanism exists to remove; the one legitimate case of absent secrets, a PR from a fork, belongs in an `if` on the workflow job rather than in the script -- `ci/excluded-charts.txt` — charts that cannot run in CI, each with a justification. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image) +- `ci/excluded-charts.txt` — charts that cannot run in CI, one per line as ` `. Currently: `satisfactory` (multi-GB image + 10GB+ Steam download), `template-chart` (scaffold with placeholder image). The justification is mandatory: a line carrying only a chart name is deliberately **not** an exclusion, because a bare name would drop a chart out of CI with nothing to review. **An excluded chart gets no install-test job at all** — see below `freesailarr` is the only chart using `fixtures.env.yaml` today: gluetun needs a real Wireguard key, or it never completes a handshake and no app container behind its kill switch becomes Ready. The install-test job loads the key from 1Password Connect for that chart only, using the same pattern as `publish-helm-chart.yml` — `steps..outputs.*` into the step's own `env:`, quoted, with `:?` guards that name the missing `op://` field before the cluster is even created. The key in that item is a **CI-only Mullvad key**, deliberately not the production one: Mullvad ties an address to a key and routes the tunnel to the endpoint it saw last, so a shared key would make every PR run pull the tunnel out from under the running stack — with the symptom in the cluster and the cause in a pull request. +**An excluded chart is dropped from the matrix, not skipped inside its job (#231).** It used to get a job that ran the script, printed `SKIP` and exited 0 — and GitHub knows only the exit code, so the check list showed `Install-test satisfactory ✓` for a chart whose image was never pulled, indistinguishable from one that really came up in the cluster. `detect-changed-charts` now removes the excluded charts before the matrix is built, so no job exists for them and nothing claims they passed. Because an absence on its own says nothing, that job writes every excluded chart of the PR **with its documented justification** into the job summary — a `Skipped` would only say "not run", and why is the question a review actually asks. The list is parsed in exactly one place: `ci/run-install-test.sh --list-excluded` emits `` and the workflow calls it instead of re-implementing the format in YAML, since a second parser would be a second chance to select different charts — silently, and in either direction. The script keeps its own `SKIP` branch for local runs. What cannot be done instead: deciding per chart in an `if` on the install-test job, to get a visible `Skipped` entry rather than an absent one. GitHub rejects it with `context "matrix" is not allowed here` — `jobs..if` is evaluated **before** the matrix is expanded, so the `matrix` context does not exist there, and the whole workflow ends in failure with zero jobs created (measured on the closed PR #233). + Fixture images avoid Docker Hub where possible (`public.ecr.aws`/`ghcr.io`) to dodge runner rate limits. The job **blocks the PR** on failure — the stabilization phase of #22 ended when Renovate started automerging patch/minor updates, since a chart that no longer starts must not reach `main` and publish unattended. When adding a new chart, add its `ci//` fixtures (or an entry in `ci/excluded-charts.txt`) — the install-test fails if both are missing. The script also runs locally against any existing cluster: `k3d cluster create t && ci/run-install-test.sh && k3d cluster delete t`. ## Dependency updates (Renovate) @@ -182,6 +184,7 @@ These guidelines are **binding for every current and future chart** in this repo - The main app image tag comes from `{{ .Chart.AppVersion }}` — never hardcoded. - **Documented exception — freesailarr**: the chart bundles nine independently versioned upstreams, so none of them is "the" app version. **No** tag comes from `appVersion`; all nine images are pinned as `.image.repository` / `.image.tag` in `values.yaml`, which is also what puts every one of them under the built-in `helm-values` manager. Its `appVersion` is the chart's own version marker, freely chosen and tied to no upstream — raised by hand when a new edition of the chart is marked as a whole. Consequently the chart has **no** block in `renovate.json5` and is **not** in the `change-app-version.yml` choice list. +- **Documented exception — minecraft-server**: the only chart whose main image tag can be overridden, through `image.tagOverride` (#158, empty by default — the tag still comes from `appVersion`). `appVersion` is the tag and therefore fixes the JVM, and since chart 5.0.0 only the **newest** Java line is maintained out of this one directory; the older lines stay published as frozen chart versions (java8 and java17 at 4.0.0, java21 at 4.1.0) and never receive another change. Without the override, running an older JVM would mean installing a frozen chart and giving up every later fix — so the line is a decision at rollout time rather than a property of the chart. The override is named `tagOverride`, not `tag`, because it decouples the running image from the chart version, and it is deliberately outside Renovate's reach (`renovate.json5` tracks the `appVersion` in `Chart.yaml` and nothing else). The chart checks the pairing itself: the Java line of the **effective** tag against `minecraft.version`, failing the render with both values named — a mismatch used to install silently and only break the container afterwards. - Auxiliary images (sidecars, init containers, secondary workloads — e.g. the delegation nginx in matrix-synapse, guacd in patchmon, busybox init/consume images in paperless-ngx) are pinned via values instead of being hardcoded in templates, and their pinned versions are reviewed alongside every app-version update round. ### 4. Ingress From b9a2e3daa3d77902fd6fed3475e93c566f4fd5fb Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:02:03 +0000 Subject: [PATCH 05/24] Update docker.io/searxng/searxng Docker tag to v2026.9.6-8b01679e8 --- searxng/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/searxng/Chart.yaml b/searxng/Chart.yaml index 1beff4a..3d7ca4c 100644 --- a/searxng/Chart.yaml +++ b/searxng/Chart.yaml @@ -9,8 +9,8 @@ type: application # (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the # exact tag, and the +up suffix normalizes the dash to a dot as the versioning # scheme requires - the same shape rss-bridge and minecraft-server use. -version: 1.0.2+up2026.8.29.d226b78bc -appVersion: "2026.8.29-d226b78bc" +version: 1.0.3+up2026.9.6.8b01679e8 +appVersion: "2026.9.6-8b01679e8" maintainers: - name: jklein From 9c0e1e6306c463264027634db8164b4411d4a829 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:33:57 +0000 Subject: [PATCH 06/24] Update itzg/mc-router Docker tag to v1.46.5 --- minecraft-router/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minecraft-router/Chart.yaml b/minecraft-router/Chart.yaml index bfe3755..e5a1121 100644 --- a/minecraft-router/Chart.yaml +++ b/minecraft-router/Chart.yaml @@ -5,8 +5,8 @@ icon: https://avatars.githubusercontent.com/u/988985 type: application -version: 4.1.0+up1.46.4 -appVersion: "1.46.4" +version: 4.1.1+up1.46.5 +appVersion: "1.46.5" maintainers: - name: jklein From 14412a467094d6991a319c869393d338dbd41c25 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:34:19 +0000 Subject: [PATCH 07/24] Update lukevella/rallly Docker tag to v4.14.0 --- rallly/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rallly/Chart.yaml b/rallly/Chart.yaml index 463c7fd..14d2d32 100644 --- a/rallly/Chart.yaml +++ b/rallly/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi type: application -version: 8.1.1+up4.13.1 -appVersion: "4.13.1" +version: 8.1.2+up4.14.0 +appVersion: "4.14.0" maintainers: - name: jklein \ No newline at end of file From 706dee745815fed0826eb0f6c0b3c0bc3d92c006 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:34:30 +0000 Subject: [PATCH 08/24] Update outlinewiki/outline Docker tag to v1.10.0 --- outline/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/outline/Chart.yaml b/outline/Chart.yaml index bdd6ddd..723421e 100644 --- a/outline/Chart.yaml +++ b/outline/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon- type: application -version: 7.1.0+up1.9.2 -appVersion: 1.9.2 +version: 7.1.1+up1.10.0 +appVersion: 1.10.0 maintainers: - name: jklein \ No newline at end of file From e0c2de883cd7053ea29016a5594f7ee5e04c7189 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:34:41 +0000 Subject: [PATCH 09/24] Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.10.0 --- urlaubsverwaltung/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/urlaubsverwaltung/Chart.yaml b/urlaubsverwaltung/Chart.yaml index c05627d..6a8b603 100644 --- a/urlaubsverwaltung/Chart.yaml +++ b/urlaubsverwaltung/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png type: application -version: 9.0.0+up6.7.0 -appVersion: 6.7.0 +version: 9.0.1+up6.10.0 +appVersion: 6.10.0 From a4464549008d1800601aa56dbd35f13dca389893 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:02:07 +0000 Subject: [PATCH 10/24] Update outlinewiki/outline Docker tag to v1.10.1 --- outline/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/outline/Chart.yaml b/outline/Chart.yaml index 723421e..afc06b4 100644 --- a/outline/Chart.yaml +++ b/outline/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon- type: application -version: 7.1.1+up1.10.0 -appVersion: 1.10.0 +version: 7.1.2+up1.10.1 +appVersion: 1.10.1 maintainers: - name: jklein \ No newline at end of file From 20c2bf9d9728f2cfa0ec95c284f631a39f585478 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:02:22 +0000 Subject: [PATCH 11/24] Update lukevella/rallly Docker tag to v4.15.0 --- rallly/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rallly/Chart.yaml b/rallly/Chart.yaml index 14d2d32..fc30b44 100644 --- a/rallly/Chart.yaml +++ b/rallly/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi type: application -version: 8.1.2+up4.14.0 -appVersion: "4.14.0" +version: 8.1.3+up4.15.0 +appVersion: "4.15.0" maintainers: - name: jklein \ No newline at end of file From eb6e40d9a0fff7bb2d29935663e14bd39d0e2c1d Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:02:39 +0000 Subject: [PATCH 12/24] Update urlaubsverwaltung/urlaubsverwaltung Docker tag to v6.12.0 --- urlaubsverwaltung/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/urlaubsverwaltung/Chart.yaml b/urlaubsverwaltung/Chart.yaml index 6a8b603..b7d225e 100644 --- a/urlaubsverwaltung/Chart.yaml +++ b/urlaubsverwaltung/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png type: application -version: 9.0.1+up6.10.0 -appVersion: 6.10.0 +version: 9.0.2+up6.12.0 +appVersion: 6.12.0 From 1fa5179813f4fe2ef3cde22a02460f85593888f4 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:02:09 +0000 Subject: [PATCH 13/24] Update docker.io/searxng/searxng Docker tag to v2026.9.11-ffe96f8a6 --- searxng/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/searxng/Chart.yaml b/searxng/Chart.yaml index 3d7ca4c..f8bcd67 100644 --- a/searxng/Chart.yaml +++ b/searxng/Chart.yaml @@ -9,8 +9,8 @@ type: application # (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the # exact tag, and the +up suffix normalizes the dash to a dot as the versioning # scheme requires - the same shape rss-bridge and minecraft-server use. -version: 1.0.3+up2026.9.6.8b01679e8 -appVersion: "2026.9.6-8b01679e8" +version: 1.0.4+up2026.9.11.ffe96f8a6 +appVersion: "2026.9.11-ffe96f8a6" maintainers: - name: jklein From f9a5e48461deafc00c835dd499d8c52d1f14b165 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:02:17 +0000 Subject: [PATCH 14/24] Update gotenberg/gotenberg Docker tag to v8.37.0 --- gotenberg/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gotenberg/Chart.yaml b/gotenberg/Chart.yaml index 832fd05..7373cd1 100644 --- a/gotenberg/Chart.yaml +++ b/gotenberg/Chart.yaml @@ -5,8 +5,8 @@ icon: https://gotenberg.dev/img/logo.png type: application -version: 8.0.1+up8.36.0 -appVersion: "8.36.0" +version: 8.0.2+up8.37.0 +appVersion: "8.37.0" maintainers: - name: jklein \ No newline at end of file From a6c27ebef3fa355c6c860da5f1822ce557875193 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:07:31 +0200 Subject: [PATCH 15/24] Prepare the Gitea move: measured CI deviations, self-hosted Renovate, issue migration script Groundwork for #225, built on a throwaway pull request against the Gitea instance rather than on assumptions. The move itself is not part of this change - no Actions are switched off, no mirror is enabled, no branch protection is touched. What the probe run measured, and what follows from it: - Which jobs need a Docker socket: only the install-test. Measured per job - detect-changed-charts, build-chart, the quantity render and the Trivy config scan all pass on `ubuntu-latest`, which on that instance has no socket. The install-test dies there at "Create k3d cluster" with "Cannot connect to the Docker daemon", an error naming k3d and never the runner label. Its `runs-on` is now an expression: the label `ubuntu-docker` on Gitea, `ubuntu-latest` on GitHub, because the former does not exist there and a chart PR would otherwise queue forever during the handover. Gitea evaluates it - the probe job was taken by the docker runner. - The install-test still cannot pass on Gitea. That daemon is rootless with "Cgroup Driver: none"; the k3s node restarts immediately with an empty log because k3s needs cgroup delegation. The label is necessary and not sufficient, and the fix is the maintainer's. - aquasecurity/setup-trivy cannot be used on Gitea: it checks out aquasecurity/trivy from github.com with `github.token`, which there is a Gitea token, and ends on "could not read Username for https://github.com". Replaced by the release tarball - one request, same behaviour on both forges, green on the probe run. Also in this change: - .github/workflows/renovate.yml, Renovate in self-operation: a daily scheduled run in the official container, inert on github.com via a `github.server_url` guard. Every rule proved by dry run instead of assumed - the pinned Renovate image is tracked by the built-in github-actions manager (44.80.0 -> 44.87.1, minor), TRIVY_VERSION is still tracked after setup-trivy is gone (v0.72.0 -> v0.74.0, minor), and `ubuntu-docker` is not extracted by the github-runners manager at all, so nothing will quietly "correct" it. - ci/migrate-issues-to-gitea.sh, runnable, with --dry-run, and NOT run. Its header names what it cannot do: comment authorship, the # references between issues, and attachments. - CLAUDE.md and README.md on the new flow: which forge and which tool, auto-merge on green checks in place of the merge queue Gitea does not have, and the measured differences of the two CIs. Refs #225 - deliberately no "Fixes": the move is not finished with this. --- .github/workflows/renovate.yml | 108 +++++++++++ .github/workflows/validate-charts.yml | 54 +++++- CLAUDE.md | 17 +- README.md | 36 +++- ci/migrate-issues-to-gitea.sh | 256 ++++++++++++++++++++++++++ renovate.json5 | 15 +- 6 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/renovate.yml create mode 100755 ci/migrate-issues-to-gitea.sh diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 0000000..2d967f0 --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,108 @@ +# Renovate, self-hosted (issue #225). +# +# On github.com Renovate arrived as the `jlab-renovate` GitHub App and this +# repo only ever carried renovate.json5. Gitea has no app model and no hosted +# Renovate, so after the move the bot has to be *run* by this repo: a scheduled +# job with its own token. That is the only change to how dependencies are +# updated - renovate.json5 stays the single source of what gets updated, and +# everything in it works unchanged on the Gitea platform. +# +# What the maintainer has to create once on the instance (the token this +# workflow cannot create for itself): +# +# RENOVATE_TOKEN Gitea access token of the bot account, scopes +# read:user, write:issue, write:repository. Renovate opens +# the PRs and the dependency dashboard issue with it, so +# the PRs will be authored by whoever owns the token. +# GITHUB_COM_TOKEN Read-only github.com PAT (public_repo / no scopes at all +# is enough). NOT optional here: every `github-releases` and +# `github-tags` lookup in renovate.json5 - and the whole +# built-in github-actions manager, which resolves +# actions/checkout and friends on github.com - hits +# github.com's unauthenticated rate limit within one run +# otherwise. On the App this came for free; here it does not. +# README already names this for the `no-result` symptom. +# +# There is deliberately no `allowedCommands` to set up by hand any more: the +# postUpgradeTasks hook renovate.json5 needs (ci/renovate-bump-chart.sh, which +# recomputes `version:` after an appVersion change) is allow-listed by the +# RENOVATE_ALLOWED_COMMANDS env below, because this workflow *is* the instance +# config now. Without it Renovate skips the task silently and every chart PR +# stays red on the version-bump check - see README, "The version-bump hook". +name: Renovate + +on: + schedule: + # 03:40 UTC, once a day. Renovate's own `timezone` in renovate.json5 + # (Europe/Berlin) governs the PR schedules; this cron is plain UTC. + - cron: "40 3 * * *" + workflow_dispatch: + inputs: + dryRun: + description: "Dry run: 'full' looks up and renders everything but writes nothing" + type: choice + default: "full" + options: ["full", "lookup", "extract", "disabled"] + logLevel: + description: "Renovate log level" + type: choice + default: "info" + options: ["info", "debug"] + +jobs: + renovate: + name: Renovate + # The guard that keeps this workflow inert on github.com. The file reaches + # GitHub as well (that is where the migration PR is merged), and a + # scheduled run there would authenticate a *Gitea* token against a Gitea + # endpoint from a runner that has neither - failing nightly for a reason + # nobody is watching for. GITHUB_SERVER_URL is the one context value that + # differs between the two platforms without naming our instance: Gitea + # sets it to its own base URL. Delete this line once github.com is a + # push mirror without Actions. + if: github.server_url != 'https://github.com' + # No Docker socket needed: the Gitea runner starts every job in a container + # itself, and `container:` is the runner's job to create, not the job's. + # Measured on the probe PR of #225 - the same distinction that puts + # install-test on ubuntu-docker leaves this job on ubuntu-latest. + runs-on: ubuntu-latest + # The official image rather than `npx renovate`: it ships the git, node and + # package-manager versions Renovate expects, and pulling ~200 MB from npm + # on every scheduled run is the alternative. Renovate's built-in + # github-actions manager keeps this tag current (proved by dry run, see the + # migration PR) - it reads `container:` images, not only `uses:`. + container: ghcr.io/renovatebot/renovate:44.87.1 + steps: + - name: "Checkout Repository" + uses: actions/checkout@v7 + + - name: "Run Renovate" + env: + # Platform. The endpoint is derived from the run itself so the + # instance address stays out of this repository, the same rule the + # publish workflow follows for the registry URL. + RENOVATE_PLATFORM: gitea + RENOVATE_ENDPOINT: ${{ github.server_url }}/api/v1 + RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} + GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }} + + # Exactly this repository. Autodiscovery would enrol every repo the + # token can see, and the token is an account token, not an app + # installation scoped to one repo. + RENOVATE_AUTODISCOVER: "false" + RENOVATE_REPOSITORIES: ${{ github.repository }} + + # Who the commits are by. The e-mail must not be a real address of a + # person - Gitea attributes a commit to the account whose verified + # address matches, and these commits are the bot's. + RENOVATE_GIT_AUTHOR: "Renovate Bot " + + # The instance-level half of renovate.json5's postUpgradeTasks. A + # command not listed here is skipped WITHOUT an error message. + RENOVATE_ALLOWED_COMMANDS: '["^ci/renovate-bump-chart\\.sh"]' + + # Manual runs default to a dry run; the schedule is the only thing + # that writes on its own. + RENOVATE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dryRun || 'null' }} + LOG_LEVEL: ${{ github.event_name == 'workflow_dispatch' && inputs.logLevel || 'info' }} + run: renovate diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 2488734..bafcc42 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 diff --git a/CLAUDE.md b/CLAUDE.md index 3b33edf..2e6b88b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,16 +7,27 @@ 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. A `#n` written today keeps pointing at the GitHub issue; `ci/migrate-issues-to-gitea.sh` writes the old→new mapping but deliberately rewrites nothing. ## Keep the docs current @@ -26,7 +37,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..3cb6b63 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 as a scheduled job of its own (`.github/workflows/renovate.yml`) on Gitea, where no app model exists — see "Renovate on Gitea" below. 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,19 @@ 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 + +There is no Renovate app for Gitea, so after the move the bot is run by this repository: `.github/workflows/renovate.yml`, a daily schedule plus a manual dispatch that defaults to a dry run. It executes the official `ghcr.io/renovatebot/renovate` image; no Docker socket is involved, because starting a job's `container:` is the runner's own work — the job only lives in it. The file carries an `if: github.server_url != 'https://github.com'` guard so the same workflow stays inert on github.com, where a Gitea token and a Gitea endpoint would fail nightly for a reason nobody is watching for. + +Three things about it are easy to get wrong and cost nothing to get right: + +- **`GITHUB_COM_TOKEN` is not optional here.** Every `github-releases`/`github-tags` lookup and the whole built-in `github-actions` manager resolve against github.com. Under the App those calls rode on the installation token; a Gitea token is worth nothing there, and unauthenticated github.com runs into the rate limit inside a single pass. The symptom is the `no-result` line on the dependency dashboard, not an error. +- **`allowedCommands` moved into the workflow.** `postUpgradeTasks` only runs when the command is allow-listed in the *instance* config, which this workflow now is: `RENOVATE_ALLOWED_COMMANDS='["^ci/renovate-bump-chart\.sh"]'`. Without it Renovate skips the version-bump hook **silently** and every chart PR stays red on the version check. +- **Renovate keeps its own image current.** The built-in `github-actions` manager reads `container:` images, not only `uses:`. Proved by dry run rather than assumed: pinned to `44.80.0`, the lookup returns `44.87.1` as a `minor` update on branch `renovate/ghcr.io-renovatebot-renovate-44.x`, with `pendingChecks: true` from the 3-day `minimumReleaseAge`. The `ubuntu-docker` runner label is **not** touched by the `github-runners` manager — it is not extracted at all, so nothing will quietly "fix" it back to a version label. + ### 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/ci/migrate-issues-to-gitea.sh b/ci/migrate-issues-to-gitea.sh new file mode 100755 index 0000000..a5aed2f --- /dev/null +++ b/ci/migrate-issues-to-gitea.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash +# Copy this repository's GitHub issues into the Gitea repository, over the API. +# +# Part of the move to JLab-Git (#225). The Gitea repo already exists and keeps +# its history, so Gitea's own "migrate from GitHub" is not available - it +# insists on creating the repository itself. What is left is this: read the +# issues from GitHub, write them to Gitea. +# +# WHAT THIS SCRIPT CANNOT DO - read this before running it +# +# Three losses are inherent to the API path, not bugs to be fixed later. They +# are named here because each of them is invisible in the result: the migrated +# issue looks complete, and only the detail is gone. +# +# 1. COMMENT AUTHORSHIP. Every issue and every comment is created by the +# token's user. Gitea has no "create as another user" outside the admin +# sudo API, and the GitHub accounts do not exist on the instance anyway. +# The script therefore prepends an attribution line to each body naming +# the original author and date. A thread of three people still reads as +# three people - but the avatar, the @mention and every "authored by" +# filter say the token's user wrote all of it. +# +# 2. CROSS-REFERENCES BETWEEN ISSUES. Gitea hands out numbers sequentially, +# and issues and pull requests share one counter on both systems, so +# GitHub's #218 does not become Gitea's #218. Every "#" inside a body, +# a comment, a values.yaml, a NOTES.txt or a commit message then points at +# a different entry - silently, because a wrong number still renders as a +# valid link. This script does NOT rewrite them. It writes the mapping to +# --map-file (oldnew, one per line) so the rewrite can be decided and +# done separately; #225 lists the options and none of them is decided. +# +# 3. ATTACHMENTS. Images and files in a body live behind GitHub's +# user-content CDN. The markdown is copied verbatim, so the links keep +# working exactly as long as the GitHub repo stays reachable - and break +# the day it does not. Re-uploading them is a separate job (download, +# POST to Gitea's attachment API, rewrite the link) and deliberately not +# part of this one. +# +# Smaller ones, for completeness: milestones, assignees, reactions, review +# threads and the closed pull requests are not migrated. Pull requests are +# skipped entirely - a PR without its branch and diff is a dead entry, and +# every branch it referred to is merged or gone. +# +# USAGE +# +# GITEA_URL=https:// GITEA_TOKEN= \ +# ci/migrate-issues-to-gitea.sh --dry-run +# +# --state open|closed|all which issues (default: open) +# --limit N stop after N issues (a trial run on real data) +# --map-file PATH where old->new lands (default: issue-map.tsv) +# --source OWNER/REPO GitHub source (default: jk-cluster/helm-charts) +# --target OWNER/REPO Gitea target (default: jlab-cluster/helm-charts) +# +# The instance address comes from the environment, never from this file - the +# same rule the publish workflow follows for the registry URL. +# +# IDEMPOTENCE. The map file is also the guard: an issue already listed there is +# skipped. Delete the file and the next run creates everything a second time, +# so keep it next to the migration and do not regenerate it from scratch. +# +# REQUIREMENTS. `gh` authenticated against github.com (in this setup that means +# `op plugin run -- gh`, otherwise the identity falls back to the wrong user), +# plus `curl`, `jq` and a Gitea token with write access to issues on the target +# repo. + +set -euo pipefail + +DRY_RUN=0 +STATE=open +LIMIT=0 +MAP_FILE=issue-map.tsv +SOURCE_REPO=jk-cluster/helm-charts +TARGET_REPO=jlab-cluster/helm-charts + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --state) STATE="${2:?--state needs open|closed|all}"; shift ;; + --limit) LIMIT="${2:?--limit needs a number}"; shift ;; + --map-file) MAP_FILE="${2:?--map-file needs a path}"; shift ;; + --source) SOURCE_REPO="${2:?--source needs OWNER/REPO}"; shift ;; + --target) TARGET_REPO="${2:?--target needs OWNER/REPO}"; shift ;; + -h | --help) sed -n '2,66p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac + shift +done + +case "$STATE" in + open | closed | all) ;; + *) echo "--state must be open, closed or all (got: $STATE)" >&2; exit 2 ;; +esac + +for tool in gh curl jq; do + command -v "$tool" >/dev/null 2>&1 || { + echo "missing required tool: $tool" >&2 + exit 1 + } +done + +# The target address is required even for a dry run: a dry run that skips the +# address proves less than it appears to, and the run would then differ from +# the real one in more than the writes. +: "${GITEA_URL:?set GITEA_URL to the instance base URL, e.g. https://git.example.com}" +GITEA_URL="${GITEA_URL%/}" +if [ "$DRY_RUN" -eq 0 ]; then + : "${GITEA_TOKEN:?set GITEA_TOKEN to a token with issue write access on $TARGET_REPO}" +fi + +API="$GITEA_URL/api/v1" + +# The token goes into a curl config file rather than onto the command line, so +# it never appears in the process list - the same reason publish-helm-chart.yml +# pipes its options into `curl --config -`. Here it is a file and not stdin, +# because stdin carries the JSON body. mktemp creates it 0600. +CURL_CFG="$(mktemp)" +trap 'rm -f "$CURL_CFG"' EXIT +if [ "$DRY_RUN" -eq 0 ]; then + printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" >"$CURL_CFG" +fi + +# --fail-with-body turns 401/403/422 into a non-zero exit and still prints the +# response. Without it curl exits 0 on all of them and the migration would +# report success having written nothing. +gitea() { + local method="$1" path="$2" + if [ "$method" = GET ]; then + curl --config "$CURL_CFG" --silent --show-error --fail-with-body \ + --request GET "$API/$path" + else + curl --config "$CURL_CFG" --silent --show-error --fail-with-body \ + --request "$method" \ + --header "Content-Type: application/json" \ + --data-binary @- "$API/$path" + fi +} + +say() { printf '%s\n' "$*"; } + +[ "$DRY_RUN" -eq 1 ] && say "DRY RUN - nothing is written to $GITEA_URL" +say "source: github.com/$SOURCE_REPO target: $GITEA_URL/$TARGET_REPO" +say "state: $STATE map file: $MAP_FILE" +say "" + +touch "$MAP_FILE" + +# --- labels ------------------------------------------------------------------ +# +# A label that does not exist on the target is dropped by Gitea's issue API +# without an error, so they are created on demand below. Names are compared +# case-sensitively, which is how both systems store them. +existing_labels="" +if [ "$DRY_RUN" -eq 0 ]; then + existing_labels="$(gitea GET "repos/$TARGET_REPO/labels?limit=100" | jq -r '.[].name')" +fi + +# --- issues ------------------------------------------------------------------ +# +# --paginate walks every page; PRs are filtered out here rather than by an API +# flag, because GitHub's issues endpoint returns them too and only the +# .pull_request key tells them apart. Oldest first, so the Gitea numbers come +# out in the same order as the originals even though the values differ. +issues_json="$(gh api --paginate \ + -H "Accept: application/vnd.github+json" \ + "repos/$SOURCE_REPO/issues?state=$STATE&per_page=100&sort=created&direction=asc" | + jq -s 'add | map(select(.pull_request == null))')" + +total="$(jq 'length' <<<"$issues_json")" +say "found $total issue(s) on GitHub (pull requests excluded)" +say "" + +count=0 +while read -r number; do + [ -n "$number" ] || continue + + if grep -qE "^${number} " "$MAP_FILE"; then + say "#$number already migrated (in $MAP_FILE) - skipped" + continue + fi + + if [ "$LIMIT" -gt 0 ] && [ "$count" -ge "$LIMIT" ]; then + say "--limit $LIMIT reached, stopping" + break + fi + count=$((count + 1)) + + issue="$(jq --argjson n "$number" '.[] | select(.number == $n)' <<<"$issues_json")" + title="$(jq -r '.title' <<<"$issue")" + state="$(jq -r '.state' <<<"$issue")" + labels="$(jq -c '[.labels[].name]' <<<"$issue")" + + # The provenance line is the only edit made to a body. It carries the old + # number, which is what makes an un-rewritten "#" elsewhere traceable at + # all, and it names the original author because the API cannot. + body="$(jq -r ' + "_Migriert von GitHub: " + .html_url + " - urspruenglich von @" + .user.login + + " am " + (.created_at | split("T")[0]) + "._\n\n" + + (.body // "")' <<<"$issue")" + + say "#$number $title [$state, labels: $labels]" + + if [ "$DRY_RUN" -eq 1 ]; then + comment_count="$(jq -r '.comments' <<<"$issue")" + suffix="" + [ "$state" = closed ] && suffix=", then close it" + say " would create the issue, $comment_count comment(s)$suffix" + continue + fi + + # Create any label this issue needs and the target does not have yet. + while read -r label; do + [ -n "$label" ] || continue + if ! grep -qxF "$label" <<<"$existing_labels"; then + jq -n --arg n "$label" '{name: $n, color: "#ededed"}' | + gitea POST "repos/$TARGET_REPO/labels" >/dev/null + existing_labels="$existing_labels"$'\n'"$label" + say " created label: $label" + fi + done < <(jq -r '.[]' <<<"$labels") + + created="$(jq -n --arg t "$title" --arg b "$body" --argjson l "$labels" \ + '{title: $t, body: $b, labels: $l}' | + gitea POST "repos/$TARGET_REPO/issues")" + new_number="$(jq -r '.number' <<<"$created")" + say " -> $GITEA_URL/$TARGET_REPO/issues/$new_number" + + # Comments in order. Each gets the same attribution header as the body; see + # loss (1) in the header of this file. + while read -r comment; do + [ -n "$comment" ] || continue + comment_body="$(jq -r ' + "_@" + .user.login + " am " + (.created_at | split("T")[0]) + + " (migriert von GitHub)._\n\n" + (.body // "")' <<<"$comment")" + jq -n --arg b "$comment_body" '{body: $b}' | + gitea POST "repos/$TARGET_REPO/issues/$new_number/comments" >/dev/null + done < <(gh api --paginate -H "Accept: application/vnd.github+json" \ + "repos/$SOURCE_REPO/issues/$number/comments?per_page=100" | + jq -s -c 'add // [] | .[]') + + if [ "$state" = closed ]; then + jq -n '{state: "closed"}' | + gitea PATCH "repos/$TARGET_REPO/issues/$new_number" >/dev/null + say " closed" + fi + + printf '%s\t%s\n' "$number" "$new_number" >>"$MAP_FILE" +done < <(jq -r '.[].number' <<<"$issues_json") + +say "" +if [ "$DRY_RUN" -eq 1 ]; then + say "dry run finished - $count issue(s) would have been created" +else + say "finished - $count issue(s) created, mapping appended to $MAP_FILE" + say "the mapping is the input for the # rewrite, which this script does not do" +fi diff --git a/renovate.json5 b/renovate.json5 index 51ed472..e8d8754 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -49,7 +49,9 @@ // allowedCommands: ["^ci/renovate-bump-chart\\.sh"] // // Without that entry Renovate skips the task silently and every chart PR - // stays red on the version-bump check. + // stays red on the version-bump check. On Gitea, where this repo runs + // Renovate itself, "the instance config" is .github/workflows/renovate.yml - + // it sets RENOVATE_ALLOWED_COMMANDS to exactly that value. postUpgradeTasks: { commands: ["ci/renovate-bump-chart.sh {{{packageFileDir}}}"], fileFilters: ["**/Chart.yaml"], @@ -80,10 +82,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. From 0f92701f4e659d0b23db279a8838dbb1a43b39d6 Mon Sep 17 00:00:00 2001 From: "jlab-renovate[bot]" <274046083+jlab-renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:00:56 +0000 Subject: [PATCH 16/24] Update docker.io/searxng/searxng Docker tag to v2026.9.13-3b8009065 --- searxng/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/searxng/Chart.yaml b/searxng/Chart.yaml index f8bcd67..8423968 100644 --- a/searxng/Chart.yaml +++ b/searxng/Chart.yaml @@ -9,8 +9,8 @@ type: application # (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the # exact tag, and the +up suffix normalizes the dash to a dot as the versioning # scheme requires - the same shape rss-bridge and minecraft-server use. -version: 1.0.4+up2026.9.11.ffe96f8a6 -appVersion: "2026.9.11-ffe96f8a6" +version: 1.0.5+up2026.9.13.3b8009065 +appVersion: "2026.9.13-3b8009065" maintainers: - name: jklein From c3889a66b6c124a73e92cb86d5167b5c6759c769 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:49:36 +0200 Subject: [PATCH 17/24] Drop the Renovate workflow: the Gitea side already has a shared instance A measurement overtook this part of the change. A shared `jlab-renovate-bot` instance already runs on the Gitea side and already has this repository enrolled - its first run there opened PRs for searxng and gotenberg plus the dependency dashboard. A second runner in this repo would produce duplicate PRs and two schedulers rewriting each other's branches, so .github/workflows/renovate.yml is removed again. Two things fall out of that run, and both were open risks: - The instance reads our renovate.json5. searxng and gotenberg 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. The searxng PR's diff carries `version: 1.0.4+up2026.9.11.ffe96f8a6 -> 1.0.5+up2026.9.13.96479fff6` next to the appVersion, and nothing but ci/renovate-bump-chart.sh writes `version:`. Had that allow-list been missing, postUpgradeTasks would have been skipped SILENTLY and every chart PR on Gitea would have been red on the version check with nothing naming the cause. With the workflow gone there is no container image to pin either; verified by dry run that the ghcr.io/renovatebot/renovate dependency is no longer extracted, while TRIVY_VERSION still is - that one belongs to the Trivy replacement and stays. README and CLAUDE.md now say Renovate runs on Gitea as an existing shared instance and that no workflow belongs here, so the next reader who finds none knows why instead of building one. --- .github/workflows/renovate.yml | 108 --------------------------------- CLAUDE.md | 1 + README.md | 13 ++-- renovate.json5 | 12 +++- 4 files changed, 17 insertions(+), 117 deletions(-) delete mode 100644 .github/workflows/renovate.yml diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml deleted file mode 100644 index 2d967f0..0000000 --- a/.github/workflows/renovate.yml +++ /dev/null @@ -1,108 +0,0 @@ -# Renovate, self-hosted (issue #225). -# -# On github.com Renovate arrived as the `jlab-renovate` GitHub App and this -# repo only ever carried renovate.json5. Gitea has no app model and no hosted -# Renovate, so after the move the bot has to be *run* by this repo: a scheduled -# job with its own token. That is the only change to how dependencies are -# updated - renovate.json5 stays the single source of what gets updated, and -# everything in it works unchanged on the Gitea platform. -# -# What the maintainer has to create once on the instance (the token this -# workflow cannot create for itself): -# -# RENOVATE_TOKEN Gitea access token of the bot account, scopes -# read:user, write:issue, write:repository. Renovate opens -# the PRs and the dependency dashboard issue with it, so -# the PRs will be authored by whoever owns the token. -# GITHUB_COM_TOKEN Read-only github.com PAT (public_repo / no scopes at all -# is enough). NOT optional here: every `github-releases` and -# `github-tags` lookup in renovate.json5 - and the whole -# built-in github-actions manager, which resolves -# actions/checkout and friends on github.com - hits -# github.com's unauthenticated rate limit within one run -# otherwise. On the App this came for free; here it does not. -# README already names this for the `no-result` symptom. -# -# There is deliberately no `allowedCommands` to set up by hand any more: the -# postUpgradeTasks hook renovate.json5 needs (ci/renovate-bump-chart.sh, which -# recomputes `version:` after an appVersion change) is allow-listed by the -# RENOVATE_ALLOWED_COMMANDS env below, because this workflow *is* the instance -# config now. Without it Renovate skips the task silently and every chart PR -# stays red on the version-bump check - see README, "The version-bump hook". -name: Renovate - -on: - schedule: - # 03:40 UTC, once a day. Renovate's own `timezone` in renovate.json5 - # (Europe/Berlin) governs the PR schedules; this cron is plain UTC. - - cron: "40 3 * * *" - workflow_dispatch: - inputs: - dryRun: - description: "Dry run: 'full' looks up and renders everything but writes nothing" - type: choice - default: "full" - options: ["full", "lookup", "extract", "disabled"] - logLevel: - description: "Renovate log level" - type: choice - default: "info" - options: ["info", "debug"] - -jobs: - renovate: - name: Renovate - # The guard that keeps this workflow inert on github.com. The file reaches - # GitHub as well (that is where the migration PR is merged), and a - # scheduled run there would authenticate a *Gitea* token against a Gitea - # endpoint from a runner that has neither - failing nightly for a reason - # nobody is watching for. GITHUB_SERVER_URL is the one context value that - # differs between the two platforms without naming our instance: Gitea - # sets it to its own base URL. Delete this line once github.com is a - # push mirror without Actions. - if: github.server_url != 'https://github.com' - # No Docker socket needed: the Gitea runner starts every job in a container - # itself, and `container:` is the runner's job to create, not the job's. - # Measured on the probe PR of #225 - the same distinction that puts - # install-test on ubuntu-docker leaves this job on ubuntu-latest. - runs-on: ubuntu-latest - # The official image rather than `npx renovate`: it ships the git, node and - # package-manager versions Renovate expects, and pulling ~200 MB from npm - # on every scheduled run is the alternative. Renovate's built-in - # github-actions manager keeps this tag current (proved by dry run, see the - # migration PR) - it reads `container:` images, not only `uses:`. - container: ghcr.io/renovatebot/renovate:44.87.1 - steps: - - name: "Checkout Repository" - uses: actions/checkout@v7 - - - name: "Run Renovate" - env: - # Platform. The endpoint is derived from the run itself so the - # instance address stays out of this repository, the same rule the - # publish workflow follows for the registry URL. - RENOVATE_PLATFORM: gitea - RENOVATE_ENDPOINT: ${{ github.server_url }}/api/v1 - RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} - GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }} - - # Exactly this repository. Autodiscovery would enrol every repo the - # token can see, and the token is an account token, not an app - # installation scoped to one repo. - RENOVATE_AUTODISCOVER: "false" - RENOVATE_REPOSITORIES: ${{ github.repository }} - - # Who the commits are by. The e-mail must not be a real address of a - # person - Gitea attributes a commit to the account whose verified - # address matches, and these commits are the bot's. - RENOVATE_GIT_AUTHOR: "Renovate Bot " - - # The instance-level half of renovate.json5's postUpgradeTasks. A - # command not listed here is skipped WITHOUT an error message. - RENOVATE_ALLOWED_COMMANDS: '["^ci/renovate-bump-chart\\.sh"]' - - # Manual runs default to a dry run; the schedule is the only thing - # that writes on its own. - RENOVATE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dryRun || 'null' }} - LOG_LEVEL: ${{ github.event_name == 'workflow_dispatch' && inputs.logLevel || 'info' }} - run: renovate diff --git a/CLAUDE.md b/CLAUDE.md index 2e6b88b..23aa80f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ The repository is moving from github.com to the Gitea instance JLab-Git (#225). - **`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. A `#n` written today keeps pointing at the GitHub issue; `ci/migrate-issues-to-gitea.sh` writes the old→new mapping but deliberately rewrites nothing. +- **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 diff --git a/README.md b/README.md index 3cb6b63..93d756b 100644 --- a/README.md +++ b/README.md @@ -108,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 while the repo is on github.com, and as a scheduled job of its own (`.github/workflows/renovate.yml`) on Gitea, where no app model exists — see "Renovate on Gitea" below. 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`. +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: @@ -154,13 +154,14 @@ If the dependency dashboard reports `Failed to look up github-releases package ### Renovate on Gitea -There is no Renovate app for Gitea, so after the move the bot is run by this repository: `.github/workflows/renovate.yml`, a daily schedule plus a manual dispatch that defaults to a dry run. It executes the official `ghcr.io/renovatebot/renovate` image; no Docker socket is involved, because starting a job's `container:` is the runner's own work — the job only lives in it. The file carries an `if: github.server_url != 'https://github.com'` guard so the same workflow stays inert on github.com, where a Gitea token and a Gitea endpoint would fail nightly for a reason nobody is watching for. +**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. -Three things about it are easy to get wrong and cost nothing to get right: +The instance is outside this repository, which has two consequences worth writing down: -- **`GITHUB_COM_TOKEN` is not optional here.** Every `github-releases`/`github-tags` lookup and the whole built-in `github-actions` manager resolve against github.com. Under the App those calls rode on the installation token; a Gitea token is worth nothing there, and unauthenticated github.com runs into the rate limit inside a single pass. The symptom is the `no-result` line on the dependency dashboard, not an error. -- **`allowedCommands` moved into the workflow.** `postUpgradeTasks` only runs when the command is allow-listed in the *instance* config, which this workflow now is: `RENOVATE_ALLOWED_COMMANDS='["^ci/renovate-bump-chart\.sh"]'`. Without it Renovate skips the version-bump hook **silently** and every chart PR stays red on the version check. -- **Renovate keeps its own image current.** The built-in `github-actions` manager reads `container:` images, not only `uses:`. Proved by dry run rather than assumed: pinned to `44.80.0`, the lookup returns `44.87.1` as a `minor` update on branch `renovate/ghcr.io-renovatebot-renovate-44.x`, with `pendingChecks: true` from the 3-day `minimumReleaseAge`. The `ubuntu-docker` runner label is **not** touched by the `github-runners` manager — it is not extracted at all, so nothing will quietly "fix" it back to a version label. +- **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 diff --git a/renovate.json5 b/renovate.json5 index e8d8754..b68fd34 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -49,9 +49,15 @@ // allowedCommands: ["^ci/renovate-bump-chart\\.sh"] // // Without that entry Renovate skips the task silently and every chart PR - // stays red on the version-bump check. On Gitea, where this repo runs - // Renovate itself, "the instance config" is .github/workflows/renovate.yml - - // it sets RENOVATE_ALLOWED_COMMANDS to exactly that value. + // 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"], From b9917505cd4acef4d6eef8692ff6e8be1a1683c6 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:02:41 +0200 Subject: [PATCH 18/24] Drop the issue-migration script: a one-off job is not repo content The migration from GitHub issues to Gitea runs exactly once, by hand over the API. Checking a script in for it would leave permanently maintained code behind for a task that stops existing after its single run - and worse, it would read to every later visitor as a tool meant to be reused. A one-off move is carried out once and is history afterwards; it is not a part of the repository. The one reference in CLAUDE.md is rewritten rather than deleted: that the issue numbers do not survive the move is still true and still worth knowing, so the bullet now says a `#n` in this repo means the GitHub issue, and that what to do about the old references is open in #225. --- CLAUDE.md | 2 +- ci/migrate-issues-to-gitea.sh | 256 ---------------------------------- 2 files changed, 1 insertion(+), 257 deletions(-) delete mode 100755 ci/migrate-issues-to-gitea.sh diff --git a/CLAUDE.md b/CLAUDE.md index 23aa80f..1a46d63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ The repository is moving from github.com to the Gitea instance JLab-Git (#225). - **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. A `#n` written today keeps pointing at the GitHub issue; `ci/migrate-issues-to-gitea.sh` writes the old→new mapping but deliberately rewrites nothing. +- **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 diff --git a/ci/migrate-issues-to-gitea.sh b/ci/migrate-issues-to-gitea.sh deleted file mode 100755 index a5aed2f..0000000 --- a/ci/migrate-issues-to-gitea.sh +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env bash -# Copy this repository's GitHub issues into the Gitea repository, over the API. -# -# Part of the move to JLab-Git (#225). The Gitea repo already exists and keeps -# its history, so Gitea's own "migrate from GitHub" is not available - it -# insists on creating the repository itself. What is left is this: read the -# issues from GitHub, write them to Gitea. -# -# WHAT THIS SCRIPT CANNOT DO - read this before running it -# -# Three losses are inherent to the API path, not bugs to be fixed later. They -# are named here because each of them is invisible in the result: the migrated -# issue looks complete, and only the detail is gone. -# -# 1. COMMENT AUTHORSHIP. Every issue and every comment is created by the -# token's user. Gitea has no "create as another user" outside the admin -# sudo API, and the GitHub accounts do not exist on the instance anyway. -# The script therefore prepends an attribution line to each body naming -# the original author and date. A thread of three people still reads as -# three people - but the avatar, the @mention and every "authored by" -# filter say the token's user wrote all of it. -# -# 2. CROSS-REFERENCES BETWEEN ISSUES. Gitea hands out numbers sequentially, -# and issues and pull requests share one counter on both systems, so -# GitHub's #218 does not become Gitea's #218. Every "#" inside a body, -# a comment, a values.yaml, a NOTES.txt or a commit message then points at -# a different entry - silently, because a wrong number still renders as a -# valid link. This script does NOT rewrite them. It writes the mapping to -# --map-file (oldnew, one per line) so the rewrite can be decided and -# done separately; #225 lists the options and none of them is decided. -# -# 3. ATTACHMENTS. Images and files in a body live behind GitHub's -# user-content CDN. The markdown is copied verbatim, so the links keep -# working exactly as long as the GitHub repo stays reachable - and break -# the day it does not. Re-uploading them is a separate job (download, -# POST to Gitea's attachment API, rewrite the link) and deliberately not -# part of this one. -# -# Smaller ones, for completeness: milestones, assignees, reactions, review -# threads and the closed pull requests are not migrated. Pull requests are -# skipped entirely - a PR without its branch and diff is a dead entry, and -# every branch it referred to is merged or gone. -# -# USAGE -# -# GITEA_URL=https:// GITEA_TOKEN= \ -# ci/migrate-issues-to-gitea.sh --dry-run -# -# --state open|closed|all which issues (default: open) -# --limit N stop after N issues (a trial run on real data) -# --map-file PATH where old->new lands (default: issue-map.tsv) -# --source OWNER/REPO GitHub source (default: jk-cluster/helm-charts) -# --target OWNER/REPO Gitea target (default: jlab-cluster/helm-charts) -# -# The instance address comes from the environment, never from this file - the -# same rule the publish workflow follows for the registry URL. -# -# IDEMPOTENCE. The map file is also the guard: an issue already listed there is -# skipped. Delete the file and the next run creates everything a second time, -# so keep it next to the migration and do not regenerate it from scratch. -# -# REQUIREMENTS. `gh` authenticated against github.com (in this setup that means -# `op plugin run -- gh`, otherwise the identity falls back to the wrong user), -# plus `curl`, `jq` and a Gitea token with write access to issues on the target -# repo. - -set -euo pipefail - -DRY_RUN=0 -STATE=open -LIMIT=0 -MAP_FILE=issue-map.tsv -SOURCE_REPO=jk-cluster/helm-charts -TARGET_REPO=jlab-cluster/helm-charts - -while [ $# -gt 0 ]; do - case "$1" in - --dry-run) DRY_RUN=1 ;; - --state) STATE="${2:?--state needs open|closed|all}"; shift ;; - --limit) LIMIT="${2:?--limit needs a number}"; shift ;; - --map-file) MAP_FILE="${2:?--map-file needs a path}"; shift ;; - --source) SOURCE_REPO="${2:?--source needs OWNER/REPO}"; shift ;; - --target) TARGET_REPO="${2:?--target needs OWNER/REPO}"; shift ;; - -h | --help) sed -n '2,66p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "unknown argument: $1" >&2; exit 2 ;; - esac - shift -done - -case "$STATE" in - open | closed | all) ;; - *) echo "--state must be open, closed or all (got: $STATE)" >&2; exit 2 ;; -esac - -for tool in gh curl jq; do - command -v "$tool" >/dev/null 2>&1 || { - echo "missing required tool: $tool" >&2 - exit 1 - } -done - -# The target address is required even for a dry run: a dry run that skips the -# address proves less than it appears to, and the run would then differ from -# the real one in more than the writes. -: "${GITEA_URL:?set GITEA_URL to the instance base URL, e.g. https://git.example.com}" -GITEA_URL="${GITEA_URL%/}" -if [ "$DRY_RUN" -eq 0 ]; then - : "${GITEA_TOKEN:?set GITEA_TOKEN to a token with issue write access on $TARGET_REPO}" -fi - -API="$GITEA_URL/api/v1" - -# The token goes into a curl config file rather than onto the command line, so -# it never appears in the process list - the same reason publish-helm-chart.yml -# pipes its options into `curl --config -`. Here it is a file and not stdin, -# because stdin carries the JSON body. mktemp creates it 0600. -CURL_CFG="$(mktemp)" -trap 'rm -f "$CURL_CFG"' EXIT -if [ "$DRY_RUN" -eq 0 ]; then - printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" >"$CURL_CFG" -fi - -# --fail-with-body turns 401/403/422 into a non-zero exit and still prints the -# response. Without it curl exits 0 on all of them and the migration would -# report success having written nothing. -gitea() { - local method="$1" path="$2" - if [ "$method" = GET ]; then - curl --config "$CURL_CFG" --silent --show-error --fail-with-body \ - --request GET "$API/$path" - else - curl --config "$CURL_CFG" --silent --show-error --fail-with-body \ - --request "$method" \ - --header "Content-Type: application/json" \ - --data-binary @- "$API/$path" - fi -} - -say() { printf '%s\n' "$*"; } - -[ "$DRY_RUN" -eq 1 ] && say "DRY RUN - nothing is written to $GITEA_URL" -say "source: github.com/$SOURCE_REPO target: $GITEA_URL/$TARGET_REPO" -say "state: $STATE map file: $MAP_FILE" -say "" - -touch "$MAP_FILE" - -# --- labels ------------------------------------------------------------------ -# -# A label that does not exist on the target is dropped by Gitea's issue API -# without an error, so they are created on demand below. Names are compared -# case-sensitively, which is how both systems store them. -existing_labels="" -if [ "$DRY_RUN" -eq 0 ]; then - existing_labels="$(gitea GET "repos/$TARGET_REPO/labels?limit=100" | jq -r '.[].name')" -fi - -# --- issues ------------------------------------------------------------------ -# -# --paginate walks every page; PRs are filtered out here rather than by an API -# flag, because GitHub's issues endpoint returns them too and only the -# .pull_request key tells them apart. Oldest first, so the Gitea numbers come -# out in the same order as the originals even though the values differ. -issues_json="$(gh api --paginate \ - -H "Accept: application/vnd.github+json" \ - "repos/$SOURCE_REPO/issues?state=$STATE&per_page=100&sort=created&direction=asc" | - jq -s 'add | map(select(.pull_request == null))')" - -total="$(jq 'length' <<<"$issues_json")" -say "found $total issue(s) on GitHub (pull requests excluded)" -say "" - -count=0 -while read -r number; do - [ -n "$number" ] || continue - - if grep -qE "^${number} " "$MAP_FILE"; then - say "#$number already migrated (in $MAP_FILE) - skipped" - continue - fi - - if [ "$LIMIT" -gt 0 ] && [ "$count" -ge "$LIMIT" ]; then - say "--limit $LIMIT reached, stopping" - break - fi - count=$((count + 1)) - - issue="$(jq --argjson n "$number" '.[] | select(.number == $n)' <<<"$issues_json")" - title="$(jq -r '.title' <<<"$issue")" - state="$(jq -r '.state' <<<"$issue")" - labels="$(jq -c '[.labels[].name]' <<<"$issue")" - - # The provenance line is the only edit made to a body. It carries the old - # number, which is what makes an un-rewritten "#" elsewhere traceable at - # all, and it names the original author because the API cannot. - body="$(jq -r ' - "_Migriert von GitHub: " + .html_url + " - urspruenglich von @" + .user.login - + " am " + (.created_at | split("T")[0]) + "._\n\n" - + (.body // "")' <<<"$issue")" - - say "#$number $title [$state, labels: $labels]" - - if [ "$DRY_RUN" -eq 1 ]; then - comment_count="$(jq -r '.comments' <<<"$issue")" - suffix="" - [ "$state" = closed ] && suffix=", then close it" - say " would create the issue, $comment_count comment(s)$suffix" - continue - fi - - # Create any label this issue needs and the target does not have yet. - while read -r label; do - [ -n "$label" ] || continue - if ! grep -qxF "$label" <<<"$existing_labels"; then - jq -n --arg n "$label" '{name: $n, color: "#ededed"}' | - gitea POST "repos/$TARGET_REPO/labels" >/dev/null - existing_labels="$existing_labels"$'\n'"$label" - say " created label: $label" - fi - done < <(jq -r '.[]' <<<"$labels") - - created="$(jq -n --arg t "$title" --arg b "$body" --argjson l "$labels" \ - '{title: $t, body: $b, labels: $l}' | - gitea POST "repos/$TARGET_REPO/issues")" - new_number="$(jq -r '.number' <<<"$created")" - say " -> $GITEA_URL/$TARGET_REPO/issues/$new_number" - - # Comments in order. Each gets the same attribution header as the body; see - # loss (1) in the header of this file. - while read -r comment; do - [ -n "$comment" ] || continue - comment_body="$(jq -r ' - "_@" + .user.login + " am " + (.created_at | split("T")[0]) - + " (migriert von GitHub)._\n\n" + (.body // "")' <<<"$comment")" - jq -n --arg b "$comment_body" '{body: $b}' | - gitea POST "repos/$TARGET_REPO/issues/$new_number/comments" >/dev/null - done < <(gh api --paginate -H "Accept: application/vnd.github+json" \ - "repos/$SOURCE_REPO/issues/$number/comments?per_page=100" | - jq -s -c 'add // [] | .[]') - - if [ "$state" = closed ]; then - jq -n '{state: "closed"}' | - gitea PATCH "repos/$TARGET_REPO/issues/$new_number" >/dev/null - say " closed" - fi - - printf '%s\t%s\n' "$number" "$new_number" >>"$MAP_FILE" -done < <(jq -r '.[].number' <<<"$issues_json") - -say "" -if [ "$DRY_RUN" -eq 1 ]; then - say "dry run finished - $count issue(s) would have been created" -else - say "finished - $count issue(s) created, mapping appended to $MAP_FILE" - say "the mapping is the input for the # rewrite, which this script does not do" -fi From 301fc0d4e59106ee37da69becc4b44a961749289 Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:22:16 +0200 Subject: [PATCH 19/24] install-test: one cluster per matrix leg, and a pinned kubectl Two assumptions about the runner environment, both found by the Gitea probe of #225 and both wrong on GitHub as well - they simply could not show there. CLUSTER NAME. Every leg created a cluster called "install-test". On GitHub each matrix leg gets its own VM with its own Docker daemon, so two legs never saw each other and the fixed name looked fine for two years. Gitea runs all legs of a matrix CONCURRENTLY in ONE job on ONE daemon, and there it collides immediately: 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 under the second. Two red legs, and neither message names the cause. The cluster is now named after the chart, in the create step and in the delete step - a blanket cleanup would tear down the neighbour's cluster, which was the half of the collision that turned one failure into two. ci/run-install-test.sh never touches k3d, so nothing else had to move. The bare chart name rather than a prefixed one, measured not assumed: k3d v5.9.0 accepts a cluster name of 32 characters and rejects 33 ("Cluster name must be <= 32 characters"), and the longest chart here is calibre-web-automated at 21. "install-test-" would be 34 - it would break that one chart and no other, which is worse than the fixed name because it only sometimes breaks. A guard states the limit instead of leaving it to be rediscovered. KUBECTL. It was inherited from the runner image. The Gitea image has none ("kubectl: command not found", 0 seconds into the chart install), but that is how it was noticed, not why it is being changed: a client version that arrives with the image drifts with every image rebuild, and an install-test should run against a known one. GitHub's image carrying one only makes the drift invisible there. Pinned to v1.35.5 to match the K3S_IMAGE it talks to, tracked by Renovate the same way TRIVY_VERSION is, and guarded against skew: kubectl supports one minor away from the server, Renovate moves KUBECTL_VERSION on its own, and K3S_IMAGE is tracked by nothing. Dry run proves both directions - the rule extracts (kubernetes/kubernetes, github-releases) and it fires (from v1.34.0 the lookup offers v1.37.0, minor). That same measurement shows the guard is not theoretical: Renovate jumps several minors in one PR, so it will go red on purpose, naming which pin to move. --- .github/workflows/validate-charts.yml | 77 ++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index bafcc42..f9b945e 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -285,11 +285,51 @@ 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 # 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 @@ -334,8 +374,37 @@ 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 + k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s # 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 @@ -346,6 +415,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 From 31e42605800a22a310f940053a4bf4a861bfa4fd Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:29:08 +0200 Subject: [PATCH 20/24] install-test: reach the k3d cluster when the job itself is a container Closes the last gap from #227. k3d publishes the API server port on the Docker HOST. On GitHub's hosted runners the job is that host, so the https://0.0.0.0: k3d writes into the kubeconfig works. Every Gitea Actions job is itself a container, and there the host is the dind container: 0.0.0.0 points at the job container, where nothing listens. The cluster comes up cleanly and the first kubectl call is refused - as measured, surfacing two steps later as a validation error on the fixtures, which named nothing useful. The case is decided on whether THIS job runs in a container, not on which forge it runs on. github.server_url would pick the right branch today and still be wrong: a self-hosted GitHub runner in a container has the identical problem, and a condition that says GitHub while meaning not-containerised is the kind of lie this repo exists to remove. The detection is the same question the fix needs answered anyway - ask the daemon about ourselves. In a container it returns our network, k3d joins it, and Docker's DNS resolves the node names; on a host it returns nothing and the previous path is taken unchanged. kubectl cluster-info and get nodes now run inside the create step, so a cluster that is up but unreachable fails where it is caused instead of two steps downstream. --- .github/workflows/validate-charts.yml | 50 ++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index f9b945e..6a6f040 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -404,7 +404,55 @@ jobs: echo "::error::chart name '$CLUSTER' is ${#CLUSTER} characters and k3d allows at most 32 for a cluster name." exit 1 fi - k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s + + # 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 + + # Proves reachability here rather than letting it surface two steps + # later as a validation error on the fixtures, which is where it + # showed up before and named nothing useful. + kubectl cluster-info + kubectl get nodes # 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 From 47661266185c27899b1fcc43ffa9fbdb8b1a6f7c Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:38:41 +0200 Subject: [PATCH 21/24] install-test: poll for the cluster instead of asking once The reachability check added with the containerisation fix was itself racy and went red on one of two legs of the same GitHub run: 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, and kubectl cluster-info asked exactly once. A check that produces sporadic red is worse than no check - that is the one property #227 rules out explicitly. It now polls kubectl get nodes, which is the thing that has to work and exercises discovery on the way. --- .github/workflows/validate-charts.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 6a6f040..1918c4a 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -451,7 +451,26 @@ jobs: # Proves reachability here rather than letting it surface two steps # later as a validation error on the fixtures, which is where it # showed up before and named nothing useful. - kubectl cluster-info + # + # Polled, not asked once. k3d's --wait returns when the node + # container reports ready, but the API server can still answer + # discovery with 503 for a moment afterwards. Measured the hard way: + # a bare `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 is worse than no check, since + # it produces exactly the sporadic red this job must not have. The + # loop asks for the node list, which is the thing that has to work + # and exercises discovery on the way. + for ATTEMPT in $(seq 1 60); do + kubectl get nodes >/dev/null 2>&1 && break + if [ "$ATTEMPT" -eq 60 ]; then + echo "::error::the k3d cluster '$CLUSTER' was created but never became reachable" + kubectl get nodes || true + exit 1 + fi + sleep 2 + done kubectl get nodes # 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 From 417dff0f806a4e18a1aaaa49f910de6b2120224e Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:46:54 +0200 Subject: [PATCH 22/24] install-test: wait for a Ready node, not for an answer Second defect in the same check, and the more serious one: kubectl get nodes exits 0 on an EMPTY list. The cluster on Gitea answered while its node had not registered, the step logged 'No resources found' and went green, and the failure surfaced five minutes later as a pod stuck in Pending with no node and no scheduling event - the cause nowhere near the symptom. kubectl wait --for=condition=Ready nodes --all has the same hole; with zero nodes it succeeds vacuously. It now counts Ready nodes, and on timeout dumps the node conditions and the k3s server log so the error picture is in the job that caused it. --- .github/workflows/validate-charts.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 1918c4a..aa52bee 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -462,16 +462,30 @@ jobs: # it produces exactly the sporadic red this job must not have. The # loop asks for the node list, which is the thing that has to work # and exercises discovery on the way. - for ATTEMPT in $(seq 1 60); do - kubectl get nodes >/dev/null 2>&1 && break - if [ "$ATTEMPT" -eq 60 ]; then - echo "::error::the k3d cluster '$CLUSTER' was created but never became reachable" - kubectl get nodes || true + # It waits for a READY NODE, not for the API server to answer. The + # difference is not pedantry, it is the second bug this check had: + # `kubectl get nodes` exits 0 on an EMPTY list, so the first version + # passed against a cluster whose server was up and whose node had + # not registered - the log said "No resources found" and the step + # went green, and the failure surfaced five minutes later as a pod + # stuck in Pending with no node and no scheduling event. A check + # that a broken cluster passes is worse than none. `kubectl wait + # --for=condition=Ready nodes --all` has the same hole: with zero + # nodes it succeeds vacuously. + 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 + 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 From fdd798e1d9fb8558f3b54581a4426fd80d6f0c9f Mon Sep 17 00:00:00 2001 From: MeepMr <67143880+MeepMr@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:00:38 +0200 Subject: [PATCH 23/24] install-test: provide envsubst, and say why it is not pinned like kubectl ci/run-install-test.sh renders ci//fixtures.env.yaml with envsubst. GitHub's hosted image ships gettext-base, the Gitea runner image does not, and without it the fixtures collapse to 'error: no objects passed to apply' - gluetun never gets its Wireguard key and nothing behind its kill switch becomes Ready. The script is left untouched; it does nothing wrong, a standard tool is simply absent. Checked for rather than pinned, and the difference from kubectl is written next to it: kubectl's version changes what the test MEANS, because its distance to the API server is a real source of failure, so it is pinned, tracked and guarded. envsubst substitutes text and the release performing it does not change the result, so a pin would be upkeep without value. Present means do nothing, which keeps the GitHub path free of any package install; missing means fetch it, so the difference between the two runners is stated instead of hidden behind an unconditional apt-get. Also rewrites the reachability comment into one block. It had grown into two stacked halves whose first half still described the behaviour of the version before last - both reasons (the loop against a discovery 503, counting Ready nodes against an empty list that exits 0) now stand together with the evidence that the second one is what made the inotify failure visible at all. --- .github/workflows/validate-charts.yml | 81 +++++++++++++++++++-------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index aa52bee..dfadd24 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -330,6 +330,35 @@ jobs: 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 @@ -448,30 +477,36 @@ jobs: k3d cluster create "$CLUSTER" --image "$K3S_IMAGE" --wait --timeout 180s fi - # Proves reachability here rather than letting it surface two steps - # later as a validation error on the fixtures, which is where it - # showed up before and named nothing useful. + # 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. # - # Polled, not asked once. k3d's --wait returns when the node - # container reports ready, but the API server can still answer - # discovery with 503 for a moment afterwards. Measured the hard way: - # a bare `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 is worse than no check, since - # it produces exactly the sporadic red this job must not have. The - # loop asks for the node list, which is the thing that has to work - # and exercises discovery on the way. - # It waits for a READY NODE, not for the API server to answer. The - # difference is not pedantry, it is the second bug this check had: - # `kubectl get nodes` exits 0 on an EMPTY list, so the first version - # passed against a cluster whose server was up and whose node had - # not registered - the log said "No resources found" and the step - # went green, and the failure surfaced five minutes later as a pod - # stuck in Pending with no node and no scheduling event. A check - # that a broken cluster passes is worse than none. `kubectl wait - # --for=condition=Ready nodes --all` has the same hole: with zero - # nodes it succeeds vacuously. + # 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 From 82b90475ce1d01a50e78546f4686ca05d805de82 Mon Sep 17 00:00:00 2001 From: jlab-renovate-bot <9+jlab-renovate-bot@noreply.git.jk-dev.de> Date: Wed, 16 Sep 2026 22:01:19 +0000 Subject: [PATCH 24/24] Update lukevella/rallly Docker tag to v4.15.1 --- rallly/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rallly/Chart.yaml b/rallly/Chart.yaml index fc30b44..3e1587e 100644 --- a/rallly/Chart.yaml +++ b/rallly/Chart.yaml @@ -5,8 +5,8 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi type: application -version: 8.1.3+up4.15.0 -appVersion: "4.15.0" +version: 8.1.4+up4.15.1 +appVersion: "4.15.1" maintainers: - name: jklein \ No newline at end of file