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 diff --git a/Makefile b/Makefile index 53223cc6e0..43b1b3e84e 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,35 @@ 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 + +# 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 @@ -268,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/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. 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/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"* ]] +} diff --git a/tests/ci/helpers.bash b/tests/ci/helpers.bash new file mode 100644 index 0000000000..161d3c1610 --- /dev/null +++ b/tests/ci/helpers.bash @@ -0,0 +1,83 @@ +# 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 -P)" + 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 + 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" + 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" +}