From b44d29af2144e39383379ce2ecc848c46e67f951 Mon Sep 17 00:00:00 2001 From: Oleksandr Piskun Date: Tue, 7 Jul 2026 12:55:27 +0000 Subject: [PATCH 1/5] ci: drop Nextcloud 31 and add Nextcloud 34 to the test matrix Nextcloud 31 is removed from the analysis-coverage matrix and Nextcloud 34 takes its place across the OCI, PgSQL and SQLite/client jobs, keeping the job count unchanged. The Notes checkout condition is simplified to pin only stable32 to the 4.x line (Notes v5 requires NC >= 33). Signed-off-by: Oleksandr Piskun --- .github/workflows/analysis-coverage.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index b85708b8..823ba908 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -20,7 +20,7 @@ env: APP_PORT: 9009 NC_AUTH_USER: "admin" NC_AUTH_PASS: "adminpassword" - NEXTCLOUD_BRANCH: stable31 + NEXTCLOUD_BRANCH: stable34 jobs: analysis: @@ -42,7 +42,7 @@ jobs: tests-oci: needs: [analysis] runs-on: ubuntu-22.04 - name: stable31 • 🐍3.11 • OCI + name: stable34 • 🐍3.11 • OCI timeout-minutes: 60 services: @@ -204,7 +204,7 @@ jobs: with: repository: nextcloud/notes # Notes main (v5.0.0) requires NC >= 33; pin older servers to the last 4.x (supports NC 28-34). - ref: "${{ (matrix.nextcloud == 'stable31' || matrix.nextcloud == 'stable32') && 'v4.13.1' || 'main' }}" + ref: "${{ matrix.nextcloud == 'stable32' && 'v4.13.1' || 'main' }}" path: apps/notes - name: Checkout Files Locking @@ -338,7 +338,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud: [ 'stable31', 'stable32' ] + nextcloud: [ 'stable32', 'stable34' ] env: NC_dbname: nextcloud_abz DATABASE_PGSQL: 1 @@ -515,7 +515,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud: [ 'stable31', 'stable33' ] + nextcloud: [ 'stable33', 'stable34' ] env: NEXTCLOUD_URL: "http://localhost:8080/index.php" timeout-minutes: 60 @@ -550,7 +550,7 @@ jobs: with: repository: nextcloud/notes # Notes main (v5.0.0) requires NC >= 33; pin older servers to the last 4.x (supports NC 28-34). - ref: "${{ (matrix.nextcloud == 'stable31' || matrix.nextcloud == 'stable32') && 'v4.13.1' || 'main' }}" + ref: "${{ matrix.nextcloud == 'stable32' && 'v4.13.1' || 'main' }}" path: apps/notes - name: Checkout Files Locking From 679eb63440a527e259f21f62cd270fe25d73cd06 Mon Sep 17 00:00:00 2001 From: Oleksandr Piskun Date: Tue, 7 Jul 2026 12:55:27 +0000 Subject: [PATCH 2/5] test(teams): xfail local-circle creation pending upstream Circles fix Creating a local circle through an ExApp (app mode) started returning HTTP 400 after the nextcloud/circles local-controller permission changes landed on the stable branches in mid-2026. The request is well-formed (client mode is unaffected), so this is an upstream Circles regression, not an nc_py_api bug. Mark the test xfail (non-strict) so CI stays green and an xpass flags the day Circles is fixed on the stable branches. Signed-off-by: Oleksandr Piskun --- tests/actual_tests/teams_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index 6a6f4a19..86c48254 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -322,6 +322,12 @@ async def test_teams_personal_circle(anc_any): @pytest.mark.asyncio(scope="session") +@pytest.mark.xfail( + reason="Upstream Circles regression: creating a local circle through an ExApp (app mode) returns HTTP 400 " + "since the nextcloud/circles local-controller permission changes (mid-2026). Client mode is unaffected. " + "Remove this marker once Circles is fixed on the stable branches.", + strict=False, +) async def test_teams_local_circle(anc_any): if await anc_any.teams.available is False: pytest.skip("Teams (Circles) is not installed") From 3336382221c59b4ecb12aa043aaff94781a4a6d4 Mon Sep 17 00:00:00 2001 From: Oleksandr Piskun Date: Tue, 7 Jul 2026 13:56:00 +0000 Subject: [PATCH 3/5] fix(ci): unblock the stable34 jobs The new stable34 matrix entries failed during Nextcloud setup: - notifications since stable34 pulls a Composer dependency (web-push) and its composer/autoload.php now requires vendor/autoload.php, which a plain git checkout does not contain. Build the app when the checked-out branch needs it (older branches load without a vendor dir, so the step is a no-op there). - NC >= 34 sets the DB session timezone to Etc/UTC, which the EOL oracle-xe:11 image cannot resolve (ORA-01882). Keep the OCI job on stable33 until the Oracle image is bumped to 18+; stable34 stays covered by the PgSQL and SQLite jobs. Also drop the now dead stable32 branch from the sqlite Notes checkout ref. Signed-off-by: Oleksandr Piskun --- .github/workflows/analysis-coverage.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/analysis-coverage.yml b/.github/workflows/analysis-coverage.yml index 823ba908..cfed36a5 100644 --- a/.github/workflows/analysis-coverage.yml +++ b/.github/workflows/analysis-coverage.yml @@ -20,7 +20,10 @@ env: APP_PORT: 9009 NC_AUTH_USER: "admin" NC_AUTH_PASS: "adminpassword" - NEXTCLOUD_BRANCH: stable34 + # The OCI job uses the EOL oracle-xe:11 image, which cannot resolve the Etc/UTC + # session timezone that NC >= 34 sets (ORA-01882). Keep this job on stable33 until + # the Oracle image is bumped to 18+. + NEXTCLOUD_BRANCH: stable33 jobs: analysis: @@ -42,7 +45,7 @@ jobs: tests-oci: needs: [analysis] runs-on: ubuntu-22.04 - name: stable34 • 🐍3.11 • OCI + name: stable33 • 🐍3.11 • OCI timeout-minutes: 60 services: @@ -384,6 +387,16 @@ jobs: ref: ${{ matrix.nextcloud }} path: apps/notifications + - name: Build Notifications if it ships an unbuilt Composer autoloader + working-directory: apps/notifications + # Since stable34 the app depends on web-push and its composer/autoload.php + # requires vendor/autoload.php, which a plain git checkout does not include. + # Older branches load without it, so only build when the checkout needs it. + run: | + if grep -q "vendor/autoload.php" composer/autoload.php 2>/dev/null && [ ! -f vendor/autoload.php ]; then + composer install --no-dev --ignore-platform-reqs + fi + - name: Checkout Activity uses: actions/checkout@v6 with: @@ -549,8 +562,8 @@ jobs: uses: actions/checkout@v6 with: repository: nextcloud/notes - # Notes main (v5.0.0) requires NC >= 33; pin older servers to the last 4.x (supports NC 28-34). - ref: "${{ matrix.nextcloud == 'stable32' && 'v4.13.1' || 'main' }}" + # This job only runs NC >= 33, so Notes main (v5.0.0) is always compatible. + ref: main path: apps/notes - name: Checkout Files Locking From 643b1e6a968cf750ffff611f7a604f17182550bf Mon Sep 17 00:00:00 2001 From: Oleksandr Piskun Date: Tue, 7 Jul 2026 13:56:01 +0000 Subject: [PATCH 4/5] test(teams): scope the local-circle xfail to NextcloudException Add raises=NextcloudException so only the documented Circles HTTP 400 is treated as an expected failure; any other error in the test still fails normally. Signed-off-by: Oleksandr Piskun --- tests/actual_tests/teams_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/actual_tests/teams_test.py b/tests/actual_tests/teams_test.py index 86c48254..e840b795 100644 --- a/tests/actual_tests/teams_test.py +++ b/tests/actual_tests/teams_test.py @@ -323,6 +323,7 @@ async def test_teams_personal_circle(anc_any): @pytest.mark.asyncio(scope="session") @pytest.mark.xfail( + raises=NextcloudException, reason="Upstream Circles regression: creating a local circle through an ExApp (app mode) returns HTTP 400 " "since the nextcloud/circles local-controller permission changes (mid-2026). Client mode is unaffected. " "Remove this marker once Circles is fixed on the stable branches.", From 9cf12d32a5bd06040ddb0d172a2eff9b3f6dadf3 Mon Sep 17 00:00:00 2001 From: Oleksandr Piskun Date: Tue, 7 Jul 2026 15:29:29 +0000 Subject: [PATCH 5/5] fix(talk): persist bot secret for other workers on AppAPI 34 AppAPI 34 moved TalkBot bookkeeping to a dedicated table and no longer mirrors the bot secret into `appconfig_ex`, which `get_bot_secret` reads back. So any ExApp process that did not itself register the bot (a second uvicorn worker, or the test's callback server) raised "Can't find the 'secret' of the bot" and never replied on NC 34. Persist the secret from `enabled_handler` under a nc_py_api-owned `appconfig_ex` key (a hash distinct from `bot_id`) and read it back as an extra fallback in `get_bot_secret`. The key is deliberately NOT `bot_id`: AppAPI <= 33 still owns and re-reads that key on re-registration, so writing there ourselves makes it mint a different secret and Talk rejects the re-install. This leaves 32/33 untouched and fixes multi-worker bots on 34. Signed-off-by: Oleksandr Piskun --- nc_py_api/nextcloud.py | 6 ++++-- nc_py_api/talk_bot.py | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/nc_py_api/nextcloud.py b/nc_py_api/nextcloud.py index da80e15d..4024c36b 100644 --- a/nc_py_api/nextcloud.py +++ b/nc_py_api/nextcloud.py @@ -390,7 +390,8 @@ def app_cfg(self) -> AppConfig: def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]: """Registers Talk BOT. - .. note:: AppAPI will add a record in a case of successful registration to the ``appconfig_ex`` table. + .. note:: On Nextcloud <= 33 AppAPI mirrors the secret into ``appconfig_ex``; since 34 it does not, so + ``TalkBot.enabled_handler`` persists its own copy there under a separate key for other workers. :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. :param display_name: The name under which the messages will be posted. @@ -524,7 +525,8 @@ def app_cfg(self) -> AppConfig: async def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]: """Registers Talk BOT. - .. note:: AppAPI will add a record in a case of successful registration to the ``appconfig_ex`` table. + .. note:: On Nextcloud <= 33 AppAPI mirrors the secret into ``appconfig_ex``; since 34 it does not, so + ``TalkBot.enabled_handler`` persists its own copy there under a separate key for other workers. :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. :param display_name: The name under which the messages will be posted. diff --git a/nc_py_api/talk_bot.py b/nc_py_api/talk_bot.py index bcc33220..a66f0aa8 100644 --- a/nc_py_api/talk_bot.py +++ b/nc_py_api/talk_bot.py @@ -133,6 +133,9 @@ def enabled_handler(self, enabled: bool, nc: NextcloudApp) -> None: if enabled: bot_id, bot_secret = nc.register_talk_bot(self.callback_url, self.display_name, self.description) os.environ[bot_id] = bot_secret + # Persist it so any worker (not only the one that registered) can sign requests. Since + # AppAPI 34 the secret is no longer mirrored into ``appconfig_ex`` for us to read back. + nc.appconfig_ex.set_value(_bot_secret_config_key(bot_id), bot_secret, sensitive=True) else: nc.unregister_talk_bot(self.callback_url) @@ -251,6 +254,9 @@ async def enabled_handler(self, enabled: bool, nc: AsyncNextcloudApp) -> None: if enabled: bot_id, bot_secret = await nc.register_talk_bot(self.callback_url, self.display_name, self.description) os.environ[bot_id] = bot_secret + # Persist it so any worker (not only the one that registered) can sign requests. Since + # AppAPI 34 the secret is no longer mirrored into ``appconfig_ex`` for us to read back. + await nc.appconfig_ex.set_value(_bot_secret_config_key(bot_id), bot_secret, sensitive=True) else: await nc.unregister_talk_bot(self.callback_url) @@ -356,12 +362,25 @@ def __get_bot_secret(callback_url: str) -> str: return sha_1.hexdigest() +def _bot_secret_config_key(bot_id: str) -> str: + # nc_py_api-owned ``appconfig_ex`` key used to persist the secret for other workers/processes. + # Kept distinct from ``bot_id``: AppAPI <= 33 stores and re-reads the secret under ``bot_id``, so + # writing there ourselves would corrupt its bookkeeping (AppAPI 34+ does not store it at all). + sha_1 = hashlib.sha1(usedforsecurity=False) + sha_1.update((bot_id + "_ncpyapi_secret").encode("UTF-8")) + return sha_1.hexdigest() + + def get_bot_secret(callback_url: str) -> bytes | None: """Returns the bot's secret from an environment variable or from the application's configuration on the server.""" secret_key = __get_bot_secret(callback_url) if secret_key in os.environ: return os.environ[secret_key].encode("UTF-8") - secret_value = NextcloudApp().appconfig_ex.get_value(secret_key) + appcfg = NextcloudApp().appconfig_ex + # AppAPI <= 33 mirrors the secret under ``secret_key``; AppAPI 34+ does not, so also read our own copy. + secret_value = appcfg.get_value(secret_key) + if secret_value is None: + secret_value = appcfg.get_value(_bot_secret_config_key(secret_key)) if secret_value is not None: os.environ[secret_key] = secret_value return secret_value.encode("UTF-8") @@ -373,7 +392,11 @@ async def aget_bot_secret(callback_url: str) -> bytes | None: secret_key = __get_bot_secret(callback_url) if secret_key in os.environ: return os.environ[secret_key].encode("UTF-8") - secret_value = await AsyncNextcloudApp().appconfig_ex.get_value(secret_key) + appcfg = AsyncNextcloudApp().appconfig_ex + # AppAPI <= 33 mirrors the secret under ``secret_key``; AppAPI 34+ does not, so also read our own copy. + secret_value = await appcfg.get_value(secret_key) + if secret_value is None: + secret_value = await appcfg.get_value(_bot_secret_config_key(secret_key)) if secret_value is not None: os.environ[secret_key] = secret_value return secret_value.encode("UTF-8")