From d5d643f65db6a8d6457683628e31e20ccca609f0 Mon Sep 17 00:00:00 2001 From: Henrik Holmboe Date: Wed, 14 Oct 2026 02:16:57 +0200 Subject: [PATCH 1/2] feat: choose the PostgreSQL version with PG `make up PG=18` runs Odoo on that PostgreSQL major version, `PG=min` on the oldest the built Odoo supports (odoo.release.MIN_PG_VERSION, below which Odoo only warns). The default stays the postgres:17 tag in k8s/base/postgres.yaml, the last PG is remembered in .k8s/postgres. Data of another major version is refused by `make deploy` while postgres runs and by a check-version init container on start, asking for `make reset`. The volume moves to /var/lib/postgresql with the data in pgdata/, the layout of postgres:18 and later, existing volumes keep working. CI runs the ci:odoo-* versions on each ci:postgres- label, and the weekly run every build on the default, min and 18. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/k8s.yml | 37 +++++++++++++++++++++------------- AGENTS.md | 12 ++++++++--- Makefile | 37 ++++++++++++++++++++++++---------- README.md | 12 +++++++++++ docs/odoo-setup.md | 39 ++++++++++++++++++++++++++++++++---- k8s/base/postgres.yaml | 34 +++++++++++++++++++++++++++++-- renovate.json | 12 +++++++++++ scripts/postgres-version.sh | 40 +++++++++++++++++++++++++++++++++++++ tests/k8s/test_smoke.py | 11 +++++++++- 9 files changed, 200 insertions(+), 34 deletions(-) create mode 100755 scripts/postgres-version.sh diff --git a/.github/workflows/k8s.yml b/.github/workflows/k8s.yml index 647bfef..1130147 100644 --- a/.github/workflows/k8s.yml +++ b/.github/workflows/k8s.yml @@ -3,7 +3,7 @@ name: Kubernetes on: pull_request: - # labeled and unlabeled for the ci:odoo-* labels, see the deploy matrix + # labeled and unlabeled for the ci:odoo-* and ci:postgres-* labels, see the deploy matrix types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: @@ -13,13 +13,14 @@ on: - cron: "0 4 * * 1" workflow_dispatch: -# A label other than ci:odoo-* gets a group of its own, so it does not cancel -# the running tests only to skip everything itself +# A label other than ci:odoo-* and ci:postgres-* gets a group of its own, so it +# does not cancel the running tests only to skip everything itself concurrency: group: >- ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}${{ contains(fromJSON('["labeled", "unlabeled"]'), github.event.action) && - !startsWith(github.event.label.name, 'ci:odoo-') && format('-{0}', github.run_id) || '' }} + !startsWith(github.event.label.name, 'ci:odoo-') && !startsWith(github.event.label.name, 'ci:postgres-') && + format('-{0}', github.run_id) || '' }} cancel-in-progress: true permissions: @@ -42,15 +43,17 @@ jobs: - run: make validate versions: - name: Pick the Odoo versions + name: Pick the Odoo and PostgreSQL versions runs-on: ubuntu-latest needs: validate outputs: - versions: ${{ steps.pick.outputs.versions }} + matrix: ${{ steps.pick.outputs.matrix }} steps: - # Odoo 20 on every change, or the versions of the ci:odoo-19, - # ci:odoo-20 and ci:odoo-master labels on a pull request. Every build - # weekly and on demand + # Odoo 20 on the default PostgreSQL on every change. On a pull request + # the versions of the ci:odoo-19, ci:odoo-20 and ci:odoo-master labels, + # each on the PostgreSQL of every ci:postgres- label. Weekly and on + # demand every build on the default, the oldest supported (min) and the + # newest PostgreSQL - id: pick env: EVENT: ${{ github.event_name }} @@ -58,15 +61,20 @@ jobs: run: | if [ "$EVENT" = schedule ] || [ "$EVENT" = workflow_dispatch ]; then versions='["19", "20", "19-nightly", "20-nightly", "master"]' + postgres='["", "min", "18"]' else versions=$(jq -c '[.[]? | select(IN("ci:odoo-19", "ci:odoo-20", "ci:odoo-master")) | ltrimstr("ci:odoo-")] | if . == [] then ["20"] else . end' <<<"$LABELS") + postgres=$(jq -c '[.[]? | select(test("^ci:postgres-[0-9]+$")) + | ltrimstr("ci:postgres-")] | if . == [] then [""] else . end' <<<"$LABELS") fi - echo "Odoo versions: $versions" - echo "versions=$versions" >>"$GITHUB_OUTPUT" + matrix=$(jq -nc --argjson v "$versions" --argjson p "$postgres" \ + '[$v[] as $version | $p[] as $postgres | {$version, $postgres}]') + echo "Odoo and PostgreSQL versions: $matrix" + echo "matrix=$matrix" >>"$GITHUB_OUTPUT" deploy: - name: Deploy and test Odoo ${{ matrix.version }} in k3d + name: Deploy and test Odoo ${{ matrix.version }} on PostgreSQL ${{ matrix.postgres || 'default' }} in k3d runs-on: ubuntu-latest needs: [validate, versions] timeout-minutes: 60 @@ -75,9 +83,10 @@ jobs: strategy: fail-fast: false matrix: - version: ${{ fromJSON(needs.versions.outputs.versions) }} + include: ${{ fromJSON(needs.versions.outputs.matrix) }} env: VERSION: ${{ matrix.version }} + PG: ${{ matrix.postgres }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -114,7 +123,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: k8s-diagnostics-${{ matrix.version }} + name: k8s-diagnostics-${{ matrix.version }}-postgres-${{ matrix.postgres || 'default' }} path: diagnostics/ - name: Delete cluster diff --git a/AGENTS.md b/AGENTS.md index f8e0b4c..7a4e33d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ container setup, the init and seed scripts, and custom modules in `addons/`. ```bash make up # create/reuse the k3d cluster, build, deploy, follow logs make up VERSION=19 # 20 (default), 19, 19-nightly, 20-nightly, master (remembered in .k8s/version) +make up PG=18 # PostgreSQL major version, 17 (default) or min (remembered in .k8s/postgres) make down # stop, keep data make reset # delete the noodle namespace and its data make destroy # delete the shared cluster (FORCE=1 if other apps run) @@ -55,12 +56,14 @@ This runs shellcheck, ruff (check and format) for `odoo/seed`, yamllint and tapl - `k8s/cluster/k3d.yaml` - shared k3d cluster `dynamist` (Traefik on 127.0.0.1:80/443), identical in every repo that uses it -- `k8s/base` - namespace `noodle`: `postgres` StatefulSet (postgres:17), `odoo` Deployment, Ingress `odoo.localhost`, - NetworkPolicies, quota. Settings in `config.env`/`secret.env`. Overlays `local` and `ci` +- `k8s/base` - namespace `noodle`: `postgres` StatefulSet (postgres:17, `PG` overrides the tag), `odoo` Deployment, + Ingress `odoo.localhost`, NetworkPolicies, quota. Settings in `config.env`/`secret.env`. Overlays `local` and `ci` - `tests/k8s` - smoke, seed data and isolation tests against the deployed instance - `Dockerfile` - one stage per Odoo source (`release-19`, `release-20`, `nightly`, `master`), picked by the `BASE` build arg, then adds `odoo/` and `addons/` - `scripts/build-args.sh` - maps `VERSION` to the build args, resolving the newest nightly deb or master commit +- `scripts/postgres-version.sh` - resolves `PG` to a PostgreSQL major version, at least the image's + `MIN_PG_VERSION` - `odoo/entrypoint.sh` - runs `init-odoo.sh` and the banner, then the official `/entrypoint.sh` - `odoo/init-odoo.sh` - orchestrator: database setup, then seeding - `odoo/lib/` - `common.sh` (defaults, `TEST_USERS`), `setup-database.sh`, `banner.sh` @@ -78,7 +81,8 @@ This runs shellcheck, ruff (check and format) for `odoo/seed`, yamllint and tapl - Conventional commits (`feat:`, `fix:`, `docs:`, `chore:`), default branch `main` - Work on a branch and open a pull request, do not push to `main`. CI tests only Odoo 20 on a pull request, add the - labels `ci:odoo-19`, `ci:odoo-20` and `ci:odoo-master` to test exactly those versions instead + labels `ci:odoo-19`, `ci:odoo-20` and `ci:odoo-master` to test exactly those versions instead, and + `ci:postgres-` to run them on those PostgreSQL versions - Init and seed steps must be idempotent (check, then create or update) since they run on every start. Shell modules in `odoo/lib/` must also run on their own. - Sample data goes in a dataset file in `odoo/seed/datasets/`, declaring its Odoo `modules` and the datasets it @@ -95,6 +99,8 @@ This runs shellcheck, ruff (check and format) for `odoo/seed`, yamllint and tapl - Seed code runs on 19, 20 and master: detect features (`hasattr`) instead of checking versions, and share the helpers in `seed_common.py` - A database only works with the series that created it, `setup-database.sh` refuses others, `make reset` to switch +- The PostgreSQL data only works with the major version that created it, `make deploy` and the `check-version` init + container refuse others, `make reset` to switch. Odoo only warns below `MIN_PG_VERSION`, `postgres-version.sh` refuses - Odoo 20: `ir.config_parameter.set_param()` is gone, use typed `set_str()`/`set_bool()`/`set_int()` (see `seed_common.set_param()`) - Odoo 20: bearer API keys must have the route's scope (`rpc`), keys without a scope no longer work there diff --git a/Makefile b/Makefile index c70ea39..38d1057 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,13 @@ OVERLAY ?= local IMAGE := dynamist/odoo BUILD_DIR := .k8s -# Odoo to build: 19, 20, 20-nightly or master, see scripts/build-args.sh. +# Odoo to build: 19, 20, 19-nightly, 20-nightly or master, see scripts/build-args.sh. # Defaults to the last one built, switching needs `make reset`. VERSION ?= $(shell cat $(BUILD_DIR)/version 2>/dev/null || echo 20) +# PostgreSQL major version to run, or min for the oldest Odoo supports, see +# scripts/postgres-version.sh. Defaults to the last one deployed, else the tag +# in k8s/base/postgres.yaml. Switching needs `make reset`. +PG ?= $(shell cat $(BUILD_DIR)/postgres 2>/dev/null) # odooly.ini section used by `make console` and `make sample` ODOOLY_ENV ?= dev @@ -79,18 +83,30 @@ odoo-image: check-tools ## build the odoo image of VERSION (19, 20, 20-nightly, $(K3D) image import -c $(CLUSTER) $(IMAGE):$$tag; \ echo $$tag > $(BUILD_DIR)/image-tag -deploy: check-tools ## apply the manifests of OVERLAY (local or ci) with the imported image +deploy: check-tools ## apply the manifests of OVERLAY (local or ci) with the imported image and PostgreSQL PG @test -f $(BUILD_DIR)/image-tag || { echo "Error: no image imported yet, run make odoo-image"; exit 1; } @touch k8s/overlays/local/config.local.env - @printf '%s\n' \ + @tag=$$(cat $(BUILD_DIR)/image-tag) && \ + pg=$$(scripts/postgres-version.sh $(IMAGE):$$tag $(PG)) && \ + data=$$($(KUBECTL) exec statefulset/postgres -c postgres -- sh -c 'cat "$$PGDATA/PG_VERSION"' 2>/dev/null); \ + if [ -n "$$data" ] && [ "$$data" != "$$pg" ]; then \ + echo "Error: the database was created with PostgreSQL $$data, PG asks for $$pg"; \ + echo "Error: run 'make reset' to delete the data, then 'make up PG=$$pg' again (or PG=$$data to keep it)"; \ + exit 1; \ + fi; \ + printf '%s\n' \ 'apiVersion: kustomize.config.k8s.io/v1beta1' \ 'kind: Kustomization' \ 'resources: [../k8s/overlays/$(OVERLAY)]' \ - 'images: [{name: $(IMAGE), newTag: '"$$(cat $(BUILD_DIR)/image-tag)"'}]' \ - > $(BUILD_DIR)/kustomization.yaml - mise exec -- kubectl --context $(KUBE_CONTEXT) apply -k $(BUILD_DIR) - -up: cluster odoo-image deploy ## start odoo of VERSION in the cluster and follow its logs until it is ready + 'images:' \ + " - {name: $(IMAGE), newTag: $$tag}" \ + " - {name: postgres, newTag: \"$$pg\"}" \ + > $(BUILD_DIR)/kustomization.yaml && \ + echo "Deploying PostgreSQL $$pg" && \ + mise exec -- kubectl --context $(KUBE_CONTEXT) apply -k $(BUILD_DIR) && \ + echo $$pg > $(BUILD_DIR)/postgres + +up: cluster odoo-image deploy ## start odoo of VERSION on PostgreSQL PG in the cluster and follow its logs until it is ready @$(KUBECTL) rollout status statefulset/postgres --timeout=5m @$(KUBECTL) logs -f deploy/odoo --pod-running-timeout=5m & logs=$$!; \ $(KUBECTL) rollout status deploy/odoo --timeout=30m; status=$$?; \ @@ -105,9 +121,10 @@ reset: check-tools ## DELETE the noodle namespace with all its data (other apps logs: check-tools ## follow odoo logs $(KUBECTL) logs -f deploy/odoo -ps: check-tools ## show pods, services, ingress, volumes and the running Odoo version +ps: check-tools ## show pods, services, ingress, volumes and the running Odoo and PostgreSQL versions $(KUBECTL) get pods,svc,ingress,pvc @$(KUBECTL) exec deploy/odoo -- odoo --version 2>/dev/null || true + @$(KUBECTL) exec statefulset/postgres -c postgres -- postgres --version 2>/dev/null || true shell: check-tools ## open shell in the odoo pod $(KUBECTL) exec -it deploy/odoo -- /bin/bash @@ -142,7 +159,7 @@ validate: ## validate the rendered manifests of all overlays done test-k8s: ## run the smoke, seed data and isolation tests in tests/k8s against the deployed odoo (PYTEST_ARGS="-m 'not slow'" for pytest) - NOODLE_VERSION=$(VERSION) mise exec -- uv run --no-project --with pytest --with requests --with pyyaml \ + NOODLE_VERSION=$(VERSION) NOODLE_POSTGRES=$$(cat $(BUILD_DIR)/postgres 2>/dev/null) mise exec -- uv run --no-project --with pytest --with requests --with pyyaml \ pytest tests/k8s -p no:cacheprovider $(PYTEST_ARGS) ##@ CI diff --git a/README.md b/README.md index f211e33..8860300 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,18 @@ The last `VERSION` built is kept in `.k8s/version`, a plain `make up` reuses it. A database only works with the Odoo series that created it: Odoo refuses to start on another one and asks for `make reset`. +`PG` picks the PostgreSQL major version next to it, PostgreSQL 17 by default. +`PG=min` picks the oldest version the Odoo series supports, and a version below +that is refused before deploying: + +```bash +make reset && make up VERSION=19 PG=min +make reset && make up PG=18 +``` + +The last `PG` is kept in `.k8s/postgres`. The data only works with the +PostgreSQL major version that created it, switching needs `make reset` as well. + ```bash make console # odooly console logged in as admin with the API key make test-k8s # run the smoke, seed data and isolation tests diff --git a/docs/odoo-setup.md b/docs/odoo-setup.md index b1f876d..e3dbc3a 100644 --- a/docs/odoo-setup.md +++ b/docs/odoo-setup.md @@ -311,10 +311,41 @@ Differences between the versions are handled in the seed code by feature detection rather than version checks, e.g. `set_param()` in `seed_common.py`. -CI tests `20` on every change. A pull request with the label `ci:odoo-19`, -`ci:odoo-20` or `ci:odoo-master` tests exactly the labelled versions instead. -The weekly and manual runs test `19`, `20`, `19-nightly`, `20-nightly` and -`master` (`master` may fail without failing the workflow). +## PostgreSQL Versions + +`make up PG=...` runs the official `postgres:` image next to Odoo, the +deploy replaces the tag of `k8s/base/postgres.yaml` in the generated +`.k8s/kustomization.yaml`. `scripts/postgres-version.sh` resolves `PG`: + +| `PG` | PostgreSQL | +|------|------------| +| empty (default) | the tag in `k8s/base/postgres.yaml`, `17` | +| `min` | the oldest the Odoo in the image supports, `odoo.release.MIN_PG_VERSION` | +| `16`, `18`, ... | that major version | + +Odoo itself only warns when PostgreSQL is older than `MIN_PG_VERSION`, so +`make deploy` reads it from the built image and refuses an older `PG`. + +The last `PG` is kept in `.k8s/postgres` and reused by a plain `make up`, so a +new default in `k8s/base/postgres.yaml` does not break existing data. The data +belongs to the major version that created it (`PG_VERSION` in the data +directory). `make deploy` compares it with `PG` while postgres runs, and the +`check-version` init container of the StatefulSet when it starts. Either stops +with an error, run `make reset` to switch. + +The volume is mounted at `/var/lib/postgresql` with the data in `pgdata/`, +which is the volume of `postgres:18` and later images and works for the older +ones as well. + +## Versions in CI + +CI tests `20` on the default PostgreSQL on every change. A pull request with +the label `ci:odoo-19`, `ci:odoo-20` or `ci:odoo-master` tests exactly the +labelled versions instead, and the labels `ci:postgres-` (e.g. +`ci:postgres-18`) run each of them on those PostgreSQL versions. The weekly +and manual runs test `19`, `20`, `19-nightly`, `20-nightly` and `master`, each +on the default, `min` and `18` (`master` may fail without failing the +workflow). ## Kubernetes Setup diff --git a/k8s/base/postgres.yaml b/k8s/base/postgres.yaml index a9364c8..77b4088 100644 --- a/k8s/base/postgres.yaml +++ b/k8s/base/postgres.yaml @@ -25,15 +25,45 @@ spec: app.kubernetes.io/name: postgres spec: enableServiceLinks: false + initContainers: + # A major version cannot read the data of another, say so instead of + # failing on start. `make deploy` replaces both images with PG + - name: check-version + image: postgres:17 + command: + - sh + - -c + - | + data=$(cat "$PGDATA/PG_VERSION" 2>/dev/null) || exit 0 + if [ "$data" != "$PG_MAJOR" ]; then + echo "ERROR: the database was created with PostgreSQL $data, this image runs PostgreSQL $PG_MAJOR" + echo "ERROR: run 'make reset' to delete the data, then 'make up PG=$PG_MAJOR' again (or PG=$data to keep it)" + exit 1 + fi + env: + - name: PGDATA + value: /var/lib/postgresql/pgdata + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + memory: 64Mi + volumeMounts: + - name: data + mountPath: /var/lib/postgresql containers: - name: postgres + # The default PostgreSQL, `make deploy` replaces the tag with PG image: postgres:17 env: # Do not pre-create the Odoo database, init-odoo.sh creates it - name: POSTGRES_DB value: postgres + # The volume of the postgres:18 and later images, a subdirectory + # so initdb gets an empty directory - name: PGDATA - value: /var/lib/postgresql/data/pgdata + value: /var/lib/postgresql/pgdata envFrom: - secretRef: name: odoo-secret @@ -52,7 +82,7 @@ spec: memory: 1Gi volumeMounts: - name: data - mountPath: /var/lib/postgresql/data + mountPath: /var/lib/postgresql volumeClaimTemplates: - metadata: name: data diff --git a/renovate.json b/renovate.json index 1097ea5..9222714 100644 --- a/renovate.json +++ b/renovate.json @@ -83,6 +83,18 @@ "matchCurrentValue": "/^20\\./", "allowedVersions": "/^20\\./" }, + { + "description": "The postgres tag in k8s/base/postgres.yaml is the default PostgreSQL for new data only", + "matchManagers": [ + "kubernetes" + ], + "matchDepNames": [ + "postgres" + ], + "prBodyNotes": [ + ":warning: Existing setups keep the PostgreSQL in `.k8s/postgres`. A new major version only applies after `make reset` (the data belongs to the major version that created it). Check that it is not newer than the PostgreSQL client in the Odoo images." + ] + }, { "description": "dynamist/odoo is built from this repo and tagged at deploy time, there is nothing upstream to track", "matchDepNames": [ diff --git a/scripts/postgres-version.sh b/scripts/postgres-version.sh new file mode 100755 index 0000000..982cd97 --- /dev/null +++ b/scripts/postgres-version.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Print the PostgreSQL major version to run next to an Odoo image +# +# (empty) the default, the postgres tag in k8s/base/postgres.yaml +# min the oldest PostgreSQL the Odoo in the image supports +# 16, 17 that major version, if the Odoo in the image supports it + +set -euo pipefail + +image=${1:?usage: $0 IMAGE [PG]} +pg=${2:-} +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +log() { + echo "$*" >&2 +} + +# Odoo only warns below its minimum, so check it before deploying +read -r series minimum < <(docker run --rm --entrypoint python3 "$image" \ + -c 'from odoo.release import MIN_PG_VERSION, serie; print(serie, MIN_PG_VERSION)') + +case "$pg" in + "") + pg=$(sed -nE 's/^ *image: postgres:([0-9]+)$/\1/p' "${root}/k8s/base/postgres.yaml") + [[ -n "$pg" ]] || { log "Error: no postgres: image in k8s/base/postgres.yaml"; exit 1; } + ;; + min) + pg=$minimum + ;; + *[!0-9]*) + log "Error: unknown PG '${pg}', use a major version (e.g. 17) or min" + exit 1 + ;; +esac + +if ((pg < minimum)); then + log "Error: Odoo ${series} needs PostgreSQL ${minimum} or newer, not ${pg} (PG=min picks ${minimum})" + exit 1 +fi +echo "$pg" diff --git a/tests/k8s/test_smoke.py b/tests/k8s/test_smoke.py index 8eeb6fd..3eb0336 100644 --- a/tests/k8s/test_smoke.py +++ b/tests/k8s/test_smoke.py @@ -7,7 +7,7 @@ import pytest import requests -from conftest import ADMIN_KEY, ODOO_URL, json2, json2_ok +from conftest import ADMIN_KEY, ODOO_URL, json2, json2_ok, kubectl def test_health(): @@ -92,3 +92,12 @@ def test_odoo_series(): pytest.skip("no NOODLE_VERSION with a series, e.g. master") response = requests.post(f"{ODOO_URL}/web/webclient/version_info", json={}, timeout=30) assert response.json()["result"]["server_serie"] == f"{match[1]}.0" + + +def test_postgres_version(): + """Odoo runs on the PostgreSQL major version that was deployed (make passes NOODLE_POSTGRES).""" + expected = os.environ.get("NOODLE_POSTGRES") + if not expected: + pytest.skip("no NOODLE_POSTGRES, deploy with make up") + version = kubectl("exec", "deploy/odoo", "--", "psql", "-tAc", "SHOW server_version_num").stdout.strip() + assert int(version) // 10000 == int(expected) From 509cfe10359dcef7394b45de247c353c52155195 Mon Sep 17 00:00:00 2001 From: Henrik Holmboe Date: Wed, 14 Oct 2026 02:20:44 +0200 Subject: [PATCH 2/2] fix: run the k8s workflow on ci:postgres-* label events Co-Authored-By: Claude Opus 5.5 --- .github/workflows/k8s.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/k8s.yml b/.github/workflows/k8s.yml index 1130147..0ad4ca4 100644 --- a/.github/workflows/k8s.yml +++ b/.github/workflows/k8s.yml @@ -36,7 +36,7 @@ jobs: # Other labels do not change what runs if: >- !contains(fromJSON('["labeled", "unlabeled"]'), github.event.action) || - startsWith(github.event.label.name, 'ci:odoo-') + startsWith(github.event.label.name, 'ci:odoo-') || startsWith(github.event.label.name, 'ci:postgres-') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0