From aaff81a3e9c94d29abba57699dab203c57822ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Thu, 8 Oct 2026 20:51:38 -0300 Subject: [PATCH 1/5] build(appstore): run the release Nextcloud setup as an internal target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nextcloud initialization that signs a release lived inside the appstore recipe, so nothing could exercise it without running the whole package and signing flow. That is how the ${GITHUB_ACTIONS:-} expansion skipped the setup silently. Move the block unchanged into _appstore-nextcloud-setup, called by appstore at the same point, and read the Nextcloud location from nextcloud_directory (default unchanged: ../nextcloud). The target is internal and stays out of make help. The Bats tests run the real target with occ, curl and openssl stubbed: setup on GitHub Actions with the key, skip without GITHUB_ACTIONS or without the key, stop when occ fails, and appstore reaching the setup before signing. Refs: #8845 Signed-off-by: André Maia --- Makefile | 48 ++++++++------- tests/ci/appstore-nextcloud-setup.bats | 76 ++++++++++++++++++++++++ tests/ci/helpers.bash | 82 ++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 tests/ci/appstore-nextcloud-setup.bats create mode 100644 tests/ci/helpers.bash diff --git a/Makefile b/Makefile index 53223cc6e0..f0783575bd 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,14 @@ appstore_build_directory=$(CURDIR)/build/artifacts appstore_package_name=$(appstore_build_directory)/$(app_name) appstore_sign_dir=$(appstore_build_directory)/sign cert_dir=$(build_tools_directory)/certificates +nextcloud_directory=$(CURDIR)/../nextcloud release_version=$(shell sed -n 's:.*\([^<]*\).*:\1:p' appinfo/info.xml) release_major=$(word 1,$(subst ., ,$(release_version))) release_changelog=$(CURDIR)/docs/changelogs/changelog-$(release_major).md npm=$(shell which npm 2> /dev/null) composer=$(shell which composer 2> /dev/null) -ifneq (,$(wildcard $(CURDIR)/../nextcloud/occ)) - occ=php $(CURDIR)/../nextcloud/occ +ifneq (,$(wildcard $(nextcloud_directory)/occ)) + occ=php $(nextcloud_directory)/occ else ifneq (,$(wildcard $(CURDIR)/../../occ)) occ=php $(CURDIR)/../../occ endif @@ -205,24 +206,7 @@ appstore: verify-release-metadata cp tests/php/fixtures/pdfs/small_valid.pdf $(appstore_sign_dir)/$(app_name)/tests/php/fixtures mkdir -p $(cert_dir) - if [ -f $(cert_dir)/$(app_name).key ] && [ "$(GITHUB_ACTIONS)" = "true" ]; then \ - set -e; \ - echo "⌛️ Starting Nextcloud setup..."; \ - mkdir $(CURDIR)/../nextcloud/data; \ - ln -s $(CURDIR) $(CURDIR)/../nextcloud/apps/libresign; \ - $(occ) maintenance:install \ - --verbose \ - --database=sqlite \ - --database-name=nextcloud \ - --database-host=127.0.0.1 \ - --database-user=root \ - --database-pass=rootpassword \ - --admin-user admin \ - --admin-pass admin; \ - $(occ) --version; \ - $(occ) app:enable --force libresign; \ - echo "🏁 Setup finished"; \ - fi + $(MAKE) --no-print-directory _appstore-nextcloud-setup if [ -f $(cert_dir)/$(app_name).key ]; then \ set -e; \ @@ -251,6 +235,30 @@ appstore: verify-release-metadata openssl dgst -sha512 -sign $(cert_dir)/$(app_name).key $(appstore_package_name).tar.gz | openssl base64; \ fi +# Internal step of appstore, kept as a target so it can be tested on its own +# (tests/ci/). Prepares the Nextcloud instance that signs the release; it only +# runs on GitHub Actions when the app private key is present. +.PHONY: _appstore-nextcloud-setup +_appstore-nextcloud-setup: + if [ -f $(cert_dir)/$(app_name).key ] && [ "$(GITHUB_ACTIONS)" = "true" ]; then \ + set -e; \ + echo "⌛️ Starting Nextcloud setup..."; \ + mkdir $(nextcloud_directory)/data; \ + ln -s $(CURDIR) $(nextcloud_directory)/apps/libresign; \ + $(occ) maintenance:install \ + --verbose \ + --database=sqlite \ + --database-name=nextcloud \ + --database-host=127.0.0.1 \ + --database-user=root \ + --database-pass=rootpassword \ + --admin-user admin \ + --admin-pass admin; \ + $(occ) --version; \ + $(occ) app:enable --force libresign; \ + echo "🏁 Setup finished"; \ + fi + .PHONY: verify-appstore-package verify-appstore-package: verify-release-metadata test -f $(appstore_sign_dir)/$(app_name)/CHANGELOG.md diff --git a/tests/ci/appstore-nextcloud-setup.bats b/tests/ci/appstore-nextcloud-setup.bats new file mode 100644 index 0000000000..f3afa2fa2a --- /dev/null +++ b/tests/ci/appstore-nextcloud-setup.bats @@ -0,0 +1,76 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Behavior of the Nextcloud initialization that `make appstore` runs before +# signing a release. + +load helpers + +setup() { + setup_appstore_test +} + +@test "initializes Nextcloud on GitHub Actions when the signing key is present" { + with_signing_key + + run_make true "$REPO_ROOT" _appstore-nextcloud-setup + + [ "$status" -eq 0 ] + [ -d "$NEXTCLOUD_DIR/data" ] + [ "$(readlink "$NEXTCLOUD_DIR/apps/libresign")" = "$REPO_ROOT" ] + ran 'occ maintenance:install' + grep -q -- '^occ maintenance:install .*--database=sqlite' "$COMMAND_LOG" + ran 'occ --version' + ran 'occ app:enable --force libresign' + [ "$(call_line 'occ maintenance:install')" -lt "$(call_line 'occ --version')" ] + [ "$(call_line 'occ --version')" -lt "$(call_line 'occ app:enable')" ] +} + +@test "skips the initialization outside GitHub Actions" { + with_signing_key + + run_make '' "$REPO_ROOT" _appstore-nextcloud-setup + + [ "$status" -eq 0 ] + [ ! -s "$COMMAND_LOG" ] + [ ! -e "$NEXTCLOUD_DIR/data" ] + [ ! -e "$NEXTCLOUD_DIR/apps/libresign" ] +} + +@test "skips the initialization when the signing key is absent" { + run_make true "$REPO_ROOT" _appstore-nextcloud-setup + + [ "$status" -eq 0 ] + [ ! -s "$COMMAND_LOG" ] + [ ! -e "$NEXTCLOUD_DIR/data" ] + [ ! -e "$NEXTCLOUD_DIR/apps/libresign" ] +} + +@test "stops with an error when a required initialization command fails" { + with_signing_key + export OCC_FAIL_ON=maintenance:install + + run_make true "$REPO_ROOT" _appstore-nextcloud-setup + + [ "$status" -ne 0 ] + ran 'occ maintenance:install' + [ -z "$(call_line 'occ app:enable')" ] +} + +@test "appstore initializes Nextcloud before signing the release" { + local app + app="$(make_app_fixture)" + with_signing_key + + run_make true "$app" appstore + + [ "$status" -eq 0 ] + [ -f "$app/build/artifacts/$APP_NAME.tar.gz" ] + [ "$(readlink "$NEXTCLOUD_DIR/apps/libresign")" = "$app" ] + ran 'occ maintenance:install' + ran 'occ app:enable --force libresign' + ran 'occ integrity:sign-app' + [ "$(call_line 'occ app:enable')" -lt "$(call_line 'occ libresign:install')" ] + [ "$(call_line 'occ app:enable')" -lt "$(call_line 'occ integrity:sign-app')" ] +} diff --git a/tests/ci/helpers.bash b/tests/ci/helpers.bash new file mode 100644 index 0000000000..8f07b97e21 --- /dev/null +++ b/tests/ci/helpers.bash @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Shared setup for the release packaging tests. Nextcloud, occ and the network +# are replaced by stubs that record what was executed; nothing outside +# BATS_TEST_TMPDIR is written. + +setup_appstore_test() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + APP_NAME="$(basename "$REPO_ROOT")" + + NEXTCLOUD_DIR="$BATS_TEST_TMPDIR/nextcloud" + CERT_DIR="$BATS_TEST_TMPDIR/certificates" + STUB_BIN="$BATS_TEST_TMPDIR/bin" + COMMAND_LOG="$BATS_TEST_TMPDIR/commands.log" + mkdir -p "$NEXTCLOUD_DIR/apps" "$CERT_DIR" "$STUB_BIN" + : > "$COMMAND_LOG" + + # occ records its arguments and fails for the subcommand named in OCC_FAIL_ON. + cat > "$STUB_BIN/occ" <<-'EOF' + #!/usr/bin/env bash + printf 'occ %s\n' "$*" >> "$COMMAND_LOG" + [ "$1" != "${OCC_FAIL_ON:-}" ] + EOF + # The signing steps of `appstore` download the certificate and sign the + # archive; neither may reach the network or need a real key. + cat > "$STUB_BIN/curl" <<-'EOF' + #!/usr/bin/env bash + printf 'curl %s\n' "$*" >> "$COMMAND_LOG" + EOF + cat > "$STUB_BIN/openssl" <<-'EOF' + #!/usr/bin/env bash + printf 'openssl %s\n' "$1" >> "$COMMAND_LOG" + EOF + chmod +x "$STUB_BIN"/* + export COMMAND_LOG +} + +with_signing_key() { + touch "$CERT_DIR/$APP_NAME.key" +} + +# Runs make like CI does: GITHUB_ACTIONS comes from the environment, never from +# the command line. Any value inherited from the runner is dropped first. +run_make() { + local github_actions="$1" directory="$2" + shift 2 + run env -u GITHUB_ACTIONS -u MAKEFLAGS -u MAKELEVEL -u MFLAGS \ + ${github_actions:+GITHUB_ACTIONS="$github_actions"} \ + PATH="$STUB_BIN:$PATH" \ + make --no-print-directory -C "$directory" "$@" \ + cert_dir="$CERT_DIR" \ + nextcloud_directory="$NEXTCLOUD_DIR" \ + occ="$STUB_BIN/occ" +} + +# Line of the first recorded call whose arguments start with "$1"; empty when +# the command never ran. +call_line() { + grep -n -- "^$1\( \|\$\)" "$COMMAND_LOG" | head -n 1 | cut -d: -f1 +} + +ran() { + [ -n "$(call_line "$1")" ] +} + +# Creates a minimal app with the inputs `appstore` copies and links the real +# Makefile into it, so packaging runs without touching the repository. Prints +# the app directory. +make_app_fixture() { + local app="$BATS_TEST_TMPDIR/work/$APP_NAME" + mkdir -p "$app"/{appinfo,composer,css,img,js,l10n,lib,templates,vendor,3rdparty} \ + "$app/docs/changelogs" "$app/tests/php/fixtures/pdfs" + printf 'libresign1.0.0\n' > "$app/appinfo/info.xml" + printf '# Changelog\n' > "$app/docs/changelogs/changelog-1.md" + for spec in openapi openapi-administration openapi-full; do + printf '{}\n' > "$app/$spec.json" + done + touch "$app/js/pdf.worker.min-fixture.mjs" "$app/tests/php/fixtures/pdfs/small_valid.pdf" + ln -s "$REPO_ROOT/Makefile" "$app/Makefile" + printf '%s\n' "$app" +} From 0b08cd2d7c3e154de97cdb0e43aafe7674a965fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Thu, 8 Oct 2026 20:51:38 -0300 Subject: [PATCH 2/5] build(appstore): check the package archive against the release contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-appstore-package only looked at the staging directory. It now also reads the tarball and fails when it cannot be read, when an entry sits outside the app directory, when a required path is missing or when a development path is shipped. The lists mirror package.required_paths and package.forbidden_paths in .nextcloud-release.yml. tests/php/fixtures/pdfs/small_valid.pdf is left out of the required list: appstore packages it as tests/php/fixtures/small_valid.pdf, as in the published v15.0.8 archive, so requiring the configured path would fail every release. Refs: #8845 Signed-off-by: André Maia --- Makefile | 15 ++++++++++ tests/ci/appstore-package.bats | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/ci/appstore-package.bats diff --git a/Makefile b/Makefile index f0783575bd..43b1b3e84e 100644 --- a/Makefile +++ b/Makefile @@ -259,6 +259,11 @@ _appstore-nextcloud-setup: echo "🏁 Setup finished"; \ fi +# Package contract, mirroring package.required_paths and package.forbidden_paths +# in .nextcloud-release.yml. +appstore_required_paths=appinfo/info.xml composer css img js l10n lib templates vendor 3rdparty openapi.json openapi-administration.json openapi-full.json CHANGELOG.md +appstore_forbidden_paths=.git .github .devcontainer .patches build docs node_modules playwright src vendor-bin img/screenshot 3rdparty/.git 3rdparty/.github 3rdparty/vendor 3rdparty/vendor-bin 3rdparty/scoper.inc.php + .PHONY: verify-appstore-package verify-appstore-package: verify-release-metadata test -f $(appstore_sign_dir)/$(app_name)/CHANGELOG.md @@ -276,3 +281,13 @@ verify-appstore-package: verify-release-metadata setup_signature_count=$$(tar -tzf $(appstore_package_name).tar.gz | grep -E -c '^$(app_name)/appinfo/install-.*\.json$$' || true); \ test "$$setup_signature_count" -eq 10 || (echo "Expected 10 setup integrity metadata files in app store package, found $$setup_signature_count" >&2; exit 1); \ fi + @package_paths=$$(tar -tzf $(appstore_package_name).tar.gz) || { echo "Unable to read $(appstore_package_name).tar.gz" >&2; exit 1; }; \ + has_path() { printf '%s\n' "$$package_paths" | awk -v path="$(app_name)/$$1" '$$0 == path || index($$0, path "/") == 1 { found = 1 } END { exit !found }'; }; \ + outside=$$(printf '%s\n' "$$package_paths" | grep -v '^$(app_name)/' || true); \ + test -z "$$outside" || { echo "App store package has entries outside $(app_name)/: $$outside" >&2; exit 1; }; \ + for path in $(appstore_required_paths); do \ + has_path "$$path" || { echo "App store package is missing $$path" >&2; exit 1; }; \ + done; \ + for path in $(appstore_forbidden_paths); do \ + ! has_path "$$path" || { echo "App store package must not contain $$path" >&2; exit 1; }; \ + done diff --git a/tests/ci/appstore-package.bats b/tests/ci/appstore-package.bats new file mode 100644 index 0000000000..8ad6f36246 --- /dev/null +++ b/tests/ci/appstore-package.bats @@ -0,0 +1,54 @@ +#!/usr/bin/env bats +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Behavior of `make verify-appstore-package` against the archive built by +# `make appstore` without a signing key, as on pull requests. + +load helpers + +setup() { + setup_appstore_test + APP="$(make_app_fixture)" + SIGN_DIR="$APP/build/artifacts/sign" + PACKAGE="$APP/build/artifacts/$APP_NAME.tar.gz" +} + +# Rebuilds the archive from the staging directory after the test altered it. +repack() { + tar -czf "$PACKAGE" -C "$SIGN_DIR" "$APP_NAME" +} + +@test "builds and accepts the unsigned package" { + run_make true "$APP" appstore verify-appstore-package + + [ "$status" -eq 0 ] + [ -f "$PACKAGE" ] + [ ! -s "$COMMAND_LOG" ] + tar -tzf "$PACKAGE" | grep -qx "$APP_NAME/appinfo/info.xml" +} + +@test "rejects a package that ships a development path" { + run_make true "$APP" appstore + [ "$status" -eq 0 ] + mkdir -p "$SIGN_DIR/$APP_NAME/src" + touch "$SIGN_DIR/$APP_NAME/src/main.ts" + repack + + run_make true "$APP" verify-appstore-package + + [ "$status" -ne 0 ] + [[ "$output" == *"must not contain src"* ]] +} + +@test "rejects a package that is missing a required path" { + run_make true "$APP" appstore + [ "$status" -eq 0 ] + rm -rf "$SIGN_DIR/$APP_NAME/templates" + repack + + run_make true "$APP" verify-appstore-package + + [ "$status" -ne 0 ] + [[ "$output" == *"missing templates"* ]] +} From 8dde68b367fbeecaa3451a910654d53f94d56109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Thu, 8 Oct 2026 20:51:38 -0300 Subject: [PATCH 3/5] ci: test the release packaging and build the unsigned package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release metadata runs the Bats tests in tests/ci, and also runs when only those tests or the workflow change. The Playwright job already installs the composer and npm dependencies and the 3rdparty submodule, so it now builds the real unsigned package with make appstore verify-appstore-package right after the frontend build. The image has no make, so it is installed with the other system packages, and the job also runs when the Makefile or .nextcloud-release.yml change. Refs: #8845 Signed-off-by: André Maia --- .github/workflows/playwright.yml | 9 ++++++++- .github/workflows/release-metadata.yml | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 304dab8996..37d0cdbed8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -51,6 +51,8 @@ jobs: - 'playwright.config.ts' - 'package.json' - 'package-lock.json' + - 'Makefile' + - '.nextcloud-release.yml' playwright: runs-on: ubuntu-latest @@ -87,7 +89,8 @@ jobs: DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ poppler-utils \ libgd3 \ - libc-client2007e + libc-client2007e \ + make - name: Checkout server uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -162,6 +165,10 @@ jobs: npm ci --prefer-offline TESTING=true npm run build --if-present + - name: Package the app without signing + working-directory: apps/${{ env.APP_NAME }} + run: make appstore verify-appstore-package + - name: Cache LibreSign binary payloads id: libresign-binaries-cache uses: actions/cache@v6 diff --git a/.github/workflows/release-metadata.yml b/.github/workflows/release-metadata.yml index 9a4e7346f9..bcf67a5446 100644 --- a/.github/workflows/release-metadata.yml +++ b/.github/workflows/release-metadata.yml @@ -11,6 +11,8 @@ on: - 'package-lock.json' - 'docs/changelogs/**' - 'Makefile' + - 'tests/ci/**' + - '.github/workflows/release-metadata.yml' push: branches: - main @@ -22,6 +24,8 @@ on: - 'package-lock.json' - 'docs/changelogs/**' - 'Makefile' + - 'tests/ci/**' + - '.github/workflows/release-metadata.yml' permissions: contents: read @@ -47,3 +51,14 @@ jobs: env: EXPECTED_CHANGELOG: ${{ steps.release.outputs.changelog-path }} run: test "$(make -s print-release-changelog)" = "${EXPECTED_CHANGELOG}" + + - name: Setup Bats-core + uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + + - name: Test release packaging behavior + run: bats --print-output-on-failure tests/ci From c006baee1a89abdb2fbe34381dd9895d19395519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Thu, 8 Oct 2026 20:51:38 -0300 Subject: [PATCH 4/5] docs(tests): describe the release packaging tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: #8845 Signed-off-by: André Maia --- tests/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/README.md b/tests/README.md index ddee4fdf1d..d5f1bc114f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,7 @@ Use this file only as a repository map. - `tests/integration/`: Behat scenarios and support code. - `src/tests/`: frontend unit tests executed with Vitest. - `playwright/`: browser/end-to-end tests. +- `tests/ci/`: [Bats](https://bats-core.readthedocs.io/) tests for the release packaging in the `Makefile`. Prefer the narrowest useful validation while implementing a change, then broaden before opening or updating a pull request. @@ -37,4 +38,19 @@ vendor/bin/behat -dl vendor/bin/behat features/path/to/feature.feature:LINE -v ``` +The release packaging tests run the real `make appstore` and +`make verify-appstore-package` against a temporary minimal app, with `occ`, +`curl` and `openssl` stubbed. They need only Bats, GNU Make and tar, use no +network and leave the repository untouched: + +```bash +bats tests/ci +``` + +They cover when `appstore` initializes the Nextcloud instance that signs a +release (only on GitHub Actions with the app private key present), that a +failing initialization stops the build, and that the package keeps its +required paths and excludes development ones. Pull requests to `main` also +build the real unsigned package in the Playwright workflow. + See the Developer Manual for linting, static analysis, full validation, and environment requirements. From 03687a2221fa063b01874fe13bd872c415b03e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maia?= Date: Sat, 17 Oct 2026 18:42:54 -0300 Subject: [PATCH 5/5] test(appstore): resolve physical paths in the packaging test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Maia --- tests/ci/helpers.bash | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/ci/helpers.bash b/tests/ci/helpers.bash index 8f07b97e21..161d3c1610 100644 --- a/tests/ci/helpers.bash +++ b/tests/ci/helpers.bash @@ -6,7 +6,7 @@ # BATS_TEST_TMPDIR is written. setup_appstore_test() { - REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd -P)" APP_NAME="$(basename "$REPO_ROOT")" NEXTCLOUD_DIR="$BATS_TEST_TMPDIR/nextcloud" @@ -68,7 +68,8 @@ ran() { # Makefile into it, so packaging runs without touching the repository. Prints # the app directory. make_app_fixture() { - local app="$BATS_TEST_TMPDIR/work/$APP_NAME" + local app + app="$(cd "$BATS_TEST_TMPDIR" && pwd -P)/work/$APP_NAME" mkdir -p "$app"/{appinfo,composer,css,img,js,l10n,lib,templates,vendor,3rdparty} \ "$app/docs/changelogs" "$app/tests/php/fixtures/pdfs" printf 'libresign1.0.0\n' > "$app/appinfo/info.xml"