diff --git a/.dockerignore b/.dockerignore index f8e92ccd2..bbdd9a930 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ .github .ddev .DS_Store +**/.DS_Store .php-cs-fixer.cache .phpunit.cache .vscode @@ -19,6 +20,3 @@ src/data/cache/* !src/data/cache/.gitkeep src/data/log/* !src/data/log/index.html - -tests -tests-legacy diff --git a/.github/scripts/run-docker-live-tests.sh b/.github/scripts/run-docker-live-tests.sh new file mode 100644 index 000000000..43544bb1a --- /dev/null +++ b/.github/scripts/run-docker-live-tests.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:?Usage: run-docker-live-tests.sh }" +suffix="${GITHUB_RUN_ID:-local}-$$" +network="fossbilling-live-${suffix}" +db_container="fossbilling-db-${suffix}" +app_container="fossbilling-app-${suffix}" + +db_name="fossbilling" +db_user="root" +db_pass="root" +db_port="3306" +test_email="email@example.com" +test_pass="4WGemqiihh8iM3" +test_api_key="AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH" + +cleanup() { + status=$? + + if [[ $status -ne 0 ]]; then + docker exec "${app_container}" sh -c ' + for file in install/php_error.log data/log/php_error.log; do + if [ -f "$file" ]; then + echo "===== $file =====" + cat "$file" + fi + done + + find data/log -type f ! -name "*.html" ! -name "php_error.log" -print 2>/dev/null | while read -r file; do + echo "===== $file =====" + cat "$file" + done + ' || true + docker logs "${app_container}" || true + docker logs "${db_container}" || true + fi + + docker rm -f "${app_container}" "${db_container}" >/dev/null 2>&1 || true + docker network rm "${network}" >/dev/null 2>&1 || true + + exit "$status" +} +trap cleanup EXIT + +docker network create "${network}" >/dev/null + +docker run --detach \ + --name "${db_container}" \ + --network "${network}" \ + --env MYSQL_DATABASE="${db_name}" \ + --env MYSQL_ROOT_PASSWORD="${db_pass}" \ + mysql:8.4 >/dev/null + +for _ in {1..60}; do + if docker exec "${db_container}" mysqladmin ping --host=127.0.0.1 --user="${db_user}" --password="${db_pass}" --silent >/dev/null 2>&1; then + break + fi + sleep 2 +done + +docker exec "${db_container}" mysqladmin ping --host=127.0.0.1 --user="${db_user}" --password="${db_pass}" --silent >/dev/null + +docker run --detach \ + --name "${app_container}" \ + --network "${network}" \ + --env APP_ENV=test \ + "${image}" >/dev/null + +for _ in {1..60}; do + if docker run --rm --network "${network}" "${image}" curl -fsS "http://${app_container}/install/" >/dev/null; then + break + fi + sleep 2 +done + +docker run --rm --network "${network}" "${image}" curl -fsS "http://${app_container}/install/" >/dev/null + +docker run --rm \ + --network "${network}" \ + --env APP_ENV=test \ + --env APP_URL="http://${app_container}/" \ + --env TEST_API_KEY="${test_api_key}" \ + "${image}" \ + sh -euxc " + curl -fsS \ + -H 'Content-type: multipart/form-data' \ + -F error_reporting=0 \ + -F database_hostname='${db_container}' \ + -F database_port='${db_port}' \ + -F database_name='${db_name}' \ + -F database_username='${db_user}' \ + -F database_password='${db_pass}' \ + -F admin_name='test' \ + -F admin_email='${test_email}' \ + -F admin_password='${test_pass}' \ + -F currency_code='USD' \ + -F currency_title='US Dollar' \ + -F admin_api_token='${test_api_key}' \ + -X POST \ + 'http://${app_container}/install/install.php?a=install' + + ./src/vendor/bin/phpunit --configuration phpunit-live.xml + " diff --git a/.github/workflows/auto-cs-fix.yml b/.github/workflows/auto-cs-fix.yml index 5b5b876ff..0ee96cfe4 100644 --- a/.github/workflows/auto-cs-fix.yml +++ b/.github/workflows/auto-cs-fix.yml @@ -5,6 +5,10 @@ on: - cron: "0 12 * * 2,4" # Runs once every Tuesday and Thursday workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-chore-cs-fix + cancel-in-progress: false + permissions: contents: read pull-requests: write @@ -13,6 +17,7 @@ jobs: fix-cs: name: Rector & PHP-CS-Fixer runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -29,7 +34,7 @@ jobs: - name: Create Cache Directories run: | - echo "MONTH=$(date '+%h')" >> ${GITHUB_ENV} + echo "MONTH=$(date '+%h')" >> "${GITHUB_ENV}" mkdir cache mkdir cache/rector diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d5110b8..7de68dd27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ on: types: [opened, reopened, synchronize] concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && (github.event.action == 'labeled' || github.event.action == 'unlabeled') && 'labels' || 'run' }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -21,6 +21,7 @@ jobs: name: 'Check for Merge Hold' if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: 'Check merge-hold label' run: | @@ -40,6 +41,7 @@ jobs: pull-requests: write if: ${{ github.event_name == 'pull_request_target' }} runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 with: @@ -53,6 +55,7 @@ jobs: pull-requests: read if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7 with: @@ -63,6 +66,7 @@ jobs: spellcheck: name: 'Spellcheck' runs-on: ubuntu-latest + timeout-minutes: 10 if: &normal_ci ${{ (github.event_name == 'pull_request' && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.pull_request.base.repo.id != github.event.pull_request.head.repo.id) || github.event_name == 'push' }} steps: - &checkout_step @@ -74,191 +78,61 @@ jobs: incremental_files_only: true php-build-test: - name: 'PHP Build and Test' + name: 'PHP Build and Test (${{ matrix.php }})' + permissions: + actions: write + contents: read needs: [ spellcheck ] if: *normal_ci runs-on: ubuntu-latest + timeout-minutes: 60 strategy: matrix: - php: [8.3, 8.4] + php: ['8.3', '8.4', '8.5'] fail-fast: true steps: - *checkout_step - - name: 'PHP Build' - uses: FOSSBilling/.workflows/.github/actions/php-build@76b4af76fb2a1a2b553ec1c2839044984299a271 - with: - php-version: ${{ matrix.php }} + - name: 'Set up Docker Buildx' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: 'Prepare Test Environment' + - name: 'Build Docker Test Image' run: | - php -r '$config = require "./src/config-sample.php"; file_put_contents("./src/config.php", "> $GITHUB_ENV - - - name: 'PHP Tests (PHPStan and PHPUnit)' - uses: FOSSBilling/.workflows/.github/actions/php-test@3273310677c9a62cdb61aa6f8f0d3ec4993b1a8e - with: - php-version: ${{ matrix.php }} - php-vendor-dir: './src/vendor' - phpstan-enable: 'true' - phpstan-php-version: 8.4 - phpunit-enable: 'true' + docker buildx build \ + --target test \ + --build-arg PHP_VERSION=${{ matrix.php }} \ + --build-arg INSTALL_TRANSLATIONS=false \ + --cache-from type=gha,scope=fossbilling-test-${{ matrix.php }} \ + --cache-to type=gha,scope=fossbilling-test-${{ matrix.php }},mode=max \ + --load \ + --tag fossbilling:test-${{ matrix.php }} \ + . - live-tests: - name: 'Live Tests' - needs: [ spellcheck ] - if: *normal_ci - runs-on: ubuntu-latest - env: - DB_HOST: 127.0.0.1 - DB_NAME: fossbilling - DB_USER: root - DB_PASS: root - DB_PORT: 3306 - APP_ENV: test - TEST_PASS: 4WGemqiihh8iM3 - TEST_EMAIL: email@example.com - APP_URL: http://localhost/ - TEST_API_KEY: AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH - steps: - - *checkout_step - - - name: 'PHP Build' - uses: FOSSBilling/.workflows/.github/actions/php-build@76b4af76fb2a1a2b553ec1c2839044984299a271 - with: - cache-deps: 'true' - composer-args: '--prefer-dist --optimize-autoloader' - php-version: 8.3 - - - name: 'Prepare Application Assets' - uses: ./.github/actions/prepare-application-assets - with: - cache-theme-builds: 'true' - install-command: 'npm ci' - build-command: 'npm run build' - - - name: Start MySQL - run: sudo systemctl start mysql.service - - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 - with: - php-version: '8.4' - extensions: imagick, gd, intl, xml, dom, curl, iconv, json, opcache, mbstring, bz2, simplexml, pdo, pdo_mysql - - - name: Setup Apache + - name: 'PHPStan' run: | - # Only install apache2 from the default Ubuntu repos — no Ondrej PPA needed. - # PHP 8.4 (including php8.4-fpm) was already installed by the Setup PHP step. - sudo apt-get install --no-install-recommends apache2 - sudo a2enmod proxy_fcgi setenvif rewrite - sudo systemctl start apache2 - sudo mkdir /var/www/localhost - sudo cp -r ./src/* /var/www/localhost + docker run --rm \ + --env APP_ENV=test \ + fossbilling:test-${{ matrix.php }} \ + ./src/vendor/bin/phpstan analyse --configuration phpstan.neon --memory-limit=512M - # Pass APP_ENV to the PHP-FPM pool so getenv('APP_ENV') works inside PHP. - # setup-php may have already started php8.4-fpm; use restart to ensure the - # updated pool config (with env[APP_ENV]) is reloaded. - echo "env[APP_ENV] = ${APP_ENV}" | sudo tee -a /etc/php/8.4/fpm/pool.d/www.conf - sudo systemctl restart php8.4-fpm - - sudo tee /etc/apache2/sites-available/localhost.conf > /dev/null < - ServerAdmin webmaster@localhost - ServerName localhost - DocumentRoot /var/www/localhost - - - Options Indexes FollowSymLinks - AllowOverride All - Require all granted - - - - SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost/" - - - ErrorLog \${APACHE_LOG_DIR}/error.log - CustomLog \${APACHE_LOG_DIR}/access.log combined - - EOF - - sudo a2ensite localhost.conf - sudo a2dissite 000-default.conf - sudo apache2ctl configtest - sudo systemctl restart apache2 - - sudo chown -R www-data:www-data /var/www/localhost* - cd /var/www/localhost - sudo find . -type d -exec chmod 755 {} \; - sudo find . -type f -exec chmod 644 {} \; - - - name: Setup MySQL + - name: 'PHPUnit' run: | - mysql -u ${{ env.DB_USER }} -p${{ env.DB_PASS }} -e "create database ${{ env.DB_NAME }}"; - sudo service mysql status + docker run --rm \ + --env APP_ENV=test \ + fossbilling:test-${{ matrix.php }} \ + ./src/vendor/bin/phpunit --configuration phpunit.xml.dist - - name: Install FOSSBilling - run: | - curl -H "Content-type: multipart/form-data" \ - -F error_reporting=0 \ - -F database_hostname="${{ env.DB_HOST }}" \ - -F database_port="${{ env.DB_PORT }}" \ - -F database_name="${{ env.DB_NAME }}" \ - -F database_username="${{ env.DB_USER }}" \ - -F database_password="${{ env.DB_PASS }}" \ - -F admin_name="test" \ - -F admin_email="${{ env.TEST_EMAIL }}" \ - -F admin_password="${{ env.TEST_PASS }}" \ - -F currency_code="USD" \ - -F currency_title="US Dollar" \ - -F admin_api_token="${{ env.TEST_API_KEY }}" \ - -X POST \ - http://localhost/install/install.php?a=install - - - name: Display the installer error log if it exists - run: | - LOG_PATH="/var/www/localhost/install/php_error.log" - - if [ -f "$LOG_PATH" ]; then - cat "$LOG_PATH" - else - echo "The installer error log does not exist." - fi - - - name: Display the admin page - run: curl http://localhost/admin - - - name: Run the API test suite - run: php ./src/vendor/bin/phpunit --configuration phpunit-live.xml - - - name: Display the FOSSBilling PHP error log, if it exists - if: always() - run: | - PHP_ERROR_LOG_PATH="/var/www/localhost/data/log/php_error.log" - - if [ -f "$PHP_ERROR_LOG_PATH" ]; then - cat "$PHP_ERROR_LOG_PATH" - else - echo "The PHP error log does not exist." - fi - - - name: Display the other FOSSBilling logs - if: always() - working-directory: /var/www/localhost/data/log/ - run: | - files=$(find . -type f ! -name '*.html' ! -name 'php_error.log') - - for file in $files; do - echo "Contents of $file:" - cat "$file" - echo "--------------------------" - done + - name: 'Run Live Tests' + if: ${{ matrix.php == '8.5' }} + run: bash ./.github/scripts/run-docker-live-tests.sh fossbilling:test-${{ matrix.php }} preview-build: name: 'Create Preview Release' - needs: [ php-build-test, live-tests ] + permissions: + actions: write + contents: read + id-token: write + needs: [ php-build-test ] if: *normal_ci uses: ./.github/workflows/release-build.yml with: @@ -270,6 +144,7 @@ jobs: needs: [ preview-build ] if: *normal_ci runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: 'Download Preview Release Archive' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -280,10 +155,10 @@ jobs: - name: 'Extract Preview Release Archive' run: | mkdir ./src - tar -xvf /tmp/build.tar -C ./src + tar -xf /tmp/build.tar -C ./src - name: 'Create Downloadable Preview' - run: zip -r ../FOSSBilling-preview.zip . + run: zip -qr ../FOSSBilling-preview.zip . working-directory: ./src/ - name: 'Move Downloadable Preview for Upload' @@ -331,6 +206,7 @@ jobs: platforms: linux/amd64,linux/arm64 cache: true cache-mode: max + cache-scope: fossbilling-preview-image sbom: true set-meta-labels: true set-meta-annotations: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9d905ad32..6c42e2afb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,10 +8,15 @@ on: schedule: - cron: '43 1 * * 0' +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: analyze: name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest + timeout-minutes: 20 permissions: actions: read contents: read diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 98c73d26a..c27701564 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -15,55 +15,41 @@ on: type: boolean jobs: + build-release-tree: + name: 'Build Release Tree' + permissions: + contents: read + id-token: write + uses: docker/github-builder/.github/workflows/build.yml@c2782c55efa56a01b9c30021db8f5ec3993228a3 # v1.8.0 + with: + output: local + target: release-artifact + artifact-upload: true + artifact-name: ${{ inputs.artifact-name }}-release-tree + cache: false + sign: false + build-args: | + FOSSBILLING_VERSION=${{ inputs.preview && github.sha || github.ref_name }} + FOSSBILLING_VERSION_TRUNCATE=${{ inputs.preview && '7' || '0' }} + SENTRY_DSN=${{ vars.SENTRY_DSN }} + release-build: name: 'Build and Prepare Release' + needs: [ build-release-tree ] permissions: + actions: write contents: read runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: 'PHP Build' - uses: FOSSBilling/.workflows/.github/actions/php-build@76b4af76fb2a1a2b553ec1c2839044984299a271 + - name: 'Download Release Tree' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - cache-deps: 'false' - composer-args: '--prefer-dist --no-dev --optimize-autoloader' - php-version: 8.3 - - - name: 'Prepare Application Assets' - uses: ./.github/actions/prepare-application-assets - with: - install-command: 'npm install' - build-command: 'NODE_ENV=production npm run build' - remove-admin-node-modules: 'true' - - - name: 'Add Short Commit Hash as the Release Vesion' - if: ${{ inputs.preview }} - run: sed -i s/0.0.1/${GITHUB_SHA::7}/ Version.php - working-directory: ./src/library/FOSSBilling - - - name: 'Add the Branch/Tag Name as the Release Version' - if: ${{ inputs.preview == false }} - run: sed -i s/0.0.1/${{ github.ref_name }}/ Version.php - working-directory: ./src/library/FOSSBilling - - - name: 'Add the Sentry DSN' - if: ${{ vars.SENTRY_DSN }} - run: | - d=$'\03' - sed -i "s${d}--replace--this--during--release--process--${d}$SENTRY_DSN$d" SentryHelper.php - working-directory: ./src/library/FOSSBilling - env: - SENTRY_DSN: ${{ vars.SENTRY_DSN }} - - - name: 'Correct the file & folder permissions' - run: | - cd ./src/ - find . -type d -exec chmod 755 {} \; - find . -type f -exec chmod 644 {} \; + name: ${{ inputs.artifact-name }}-release-tree + path: /tmp/release-tree - name: 'Create Release Build Archive' - run: tar -cvf /tmp/build.tar -C ./src/ ./ + run: tar -cf /tmp/build.tar -C /tmp/release-tree ./ - name: 'Upload Release Build Archive' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -72,3 +58,21 @@ jobs: name: ${{ inputs.artifact-name }} path: /tmp/build.tar retention-days: 1 + + - name: 'Delete Intermediate Release Tree Artifact' + if: ${{ always() }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_NAME: ${{ inputs.artifact-name }}-release-tree + run: | + artifact_ids="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts" --paginate --jq '.artifacts[] | select(.name == env.ARTIFACT_NAME) | .id')" + + if [ -z "${artifact_ids}" ]; then + echo "No intermediate artifact found for ${ARTIFACT_NAME}" + exit 0 + fi + + while IFS= read -r artifact_id; do + gh api --method DELETE "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}" + done <<< "${artifact_ids}" diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 7ab2dca10..68793a2fe 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -5,6 +5,10 @@ on: types: - published +concurrency: + group: ${{ github.workflow }}-dockerhub + cancel-in-progress: false + jobs: # Latest release is identified by branch/tag SemVer, # not the 'Set as the latest release' GitHub option. @@ -13,6 +17,7 @@ jobs: permissions: contents: read runs-on: ubuntu-latest + timeout-minutes: 10 outputs: is_latest: ${{ steps.compare-latest.outputs.is_latest }} steps: @@ -28,9 +33,9 @@ jobs: id: compare-latest run: | if [ "${{ github.ref_name }}" = "${{ steps.get-latest.outputs.release }}" ]; then - echo "is_latest=true" >> $GITHUB_OUTPUT + echo "is_latest=true" >> "${GITHUB_OUTPUT}" else - echo "is_latest=false" >> $GITHUB_OUTPUT + echo "is_latest=false" >> "${GITHUB_OUTPUT}" fi deploy-release-docker: @@ -51,6 +56,7 @@ jobs: platforms: linux/amd64,linux/arm64 cache: true cache-mode: max + cache-scope: fossbilling-release-image sbom: true set-meta-labels: true set-meta-annotations: true diff --git a/Dockerfile b/Dockerfile index 4bd78ec6e..ecd967557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,17 @@ -# syntax=docker/dockerfile:1.7 +# syntax=docker/dockerfile:1.18 ARG PHP_VERSION=8.5 ARG NODE_VERSION=24 FROM php:${PHP_VERSION}-apache AS php-base -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ +RUN set -eux; \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ ca-certificates \ cron \ curl \ - git \ libbz2-dev \ libfreetype6-dev \ libicu-dev \ @@ -18,24 +19,56 @@ RUN apt-get update \ libpng-dev \ libzip-dev \ unzip \ - zlib1g-dev \ - && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j"$(nproc)" bz2 gd intl pdo_mysql zip \ - && a2enmod rewrite \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + zlib1g-dev; \ + docker-php-ext-configure gd --with-freetype --with-jpeg; \ + docker-php-ext-install -j"$(nproc)" bz2 gd intl pdo_mysql zip; \ + a2enmod rewrite; \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual \ + ${savedAptMark} \ + ca-certificates \ + cron \ + curl \ + unzip; \ + find /usr/local -type f \( -perm /0111 -o -name '*.so' \) -exec sh -c 'ldd "$@" 2>/dev/null' sh '{}' + \ + | awk 'NF == 4 && $2 == "=>" { print $3 } NF == 2 && $1 ~ /^\// { print $1 }' \ + | sort -u \ + | while read -r library; do \ + library="$(readlink -e "${library}")"; \ + dpkg-query --search "${library}" 2>/dev/null | grep -v '^diversion ' | head -n1 | cut -d: -f1; \ + done \ + | sort -u \ + | xargs -r apt-mark manual; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/* -FROM php-base AS php-vendor +FROM php-base AS composer-base WORKDIR /app COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/* + +FROM composer-base AS php-vendor + COPY composer.json composer.lock ./ RUN --mount=type=cache,target=/tmp/composer-cache \ COMPOSER_CACHE_DIR=/tmp/composer-cache \ composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction --no-progress +FROM composer-base AS php-dev-vendor +COPY composer.json composer.lock ./ + +RUN --mount=type=cache,target=/tmp/composer-cache \ + COMPOSER_CACHE_DIR=/tmp/composer-cache \ + composer install --prefer-dist --optimize-autoloader --no-interaction --no-progress + FROM node:${NODE_VERSION}-bookworm-slim AS frontend-assets WORKDIR /app @@ -46,7 +79,9 @@ COPY src/themes/huraga/package.json src/themes/huraga/package.json RUN --mount=type=cache,target=/root/.npm npm ci -COPY src ./src +COPY src/themes/admin_default ./src/themes/admin_default +COPY src/themes/huraga ./src/themes/huraga +COPY src/modules ./src/modules RUN NODE_ENV=production npm run build @@ -57,7 +92,9 @@ WORKDIR /app ARG FOSSBILLING_VERSION=0.0.1 ARG FOSSBILLING_VERSION_TRUNCATE=0 ARG SENTRY_DSN= +ARG INSTALL_TRANSLATIONS=true ARG TRANSLATIONS_URL=https://github.com/FOSSBilling/locale/releases/latest/download/translations.zip +ARG TRANSLATIONS_SHA256= COPY src ./src COPY README.md LICENSE ./src/ @@ -67,15 +104,24 @@ COPY --from=frontend-assets /app/src/themes/huraga/assets/build ./src/themes/hur RUN set -eux; \ mkdir -p ./src/locale; \ - curl -fsSL "${TRANSLATIONS_URL}" -o /tmp/translations.zip; \ - unzip -oq /tmp/translations.zip -d ./src/locale; \ - rm /tmp/translations.zip; \ + if [ "${INSTALL_TRANSLATIONS}" = "true" ]; then \ + curl -fsSL "${TRANSLATIONS_URL}" -o /tmp/translations.zip; \ + if [ -n "${TRANSLATIONS_SHA256}" ]; then \ + echo "${TRANSLATIONS_SHA256} /tmp/translations.zip" | sha256sum -c -; \ + fi; \ + unzip -oq /tmp/translations.zip -d ./src/locale; \ + rm /tmp/translations.zip; \ + fi; \ FOSSBILLING_VERSION="${FOSSBILLING_VERSION}" \ FOSSBILLING_VERSION_TRUNCATE="${FOSSBILLING_VERSION_TRUNCATE}" \ SENTRY_DSN="${SENTRY_DSN}" \ php -r '$version = getenv("FOSSBILLING_VERSION") ?: "0.0.1"; $truncate = (int) (getenv("FOSSBILLING_VERSION_TRUNCATE") ?: 0); if ($truncate > 0) { $version = substr($version, 0, $truncate); } $versionFile = "./src/library/FOSSBilling/Version.php"; file_put_contents($versionFile, str_replace("0.0.1", $version, file_get_contents($versionFile))); $dsn = getenv("SENTRY_DSN"); if ($dsn !== false && $dsn !== "") { $sentryFile = "./src/library/FOSSBilling/SentryHelper.php"; file_put_contents($sentryFile, str_replace("--replace--this--during--release--process--", $dsn, file_get_contents($sentryFile))); }'; \ chmod -R u=rwX,go=rX ./src +FROM scratch AS release-artifact + +COPY --from=release-tree /app/src / + FROM php-base AS runtime WORKDIR /var/www/html @@ -91,3 +137,18 @@ RUN set -eux; \ rm /tmp/www-data.cron CMD ["sh", "-c", "cron & exec apache2-foreground"] + +FROM runtime AS test + +WORKDIR /workspace + +COPY --from=release-tree /app/src ./src +COPY --from=php-dev-vendor /app/src/vendor ./src/vendor +COPY composer.json composer.lock phpstan.neon phpstan-baseline.neon phpunit.xml.dist phpunit-live.xml ./ +COPY tests ./tests +COPY tests-legacy ./tests-legacy + +RUN set -eux; \ + php -r '$config = require "./src/config-sample.php"; file_put_contents("./src/config.php", "hasRetryAfter()) { diff --git a/tests/APIHelper.php b/tests/APIHelper.php index e42a6b130..0dcb6f227 100644 --- a/tests/APIHelper.php +++ b/tests/APIHelper.php @@ -87,6 +87,11 @@ class Response return $this->code; } + public function getStatusCode(): int + { + return $this->code; + } + public function getResponse(): array { return $this->decodedResponse; diff --git a/tests/Modules/Theme/AdminTest.php b/tests/Modules/Theme/AdminTest.php index 3248385ba..4150c0c7b 100644 --- a/tests/Modules/Theme/AdminTest.php +++ b/tests/Modules/Theme/AdminTest.php @@ -38,18 +38,14 @@ final class AdminTest extends TestCase public function testInvalidThemeActionReturnsError(): void { $result = Request::makeRequest('admin/theme/non_existing_action'); + $this->assertSame(404, $result->getStatusCode(), 'Invalid theme action should return HTTP 404.'); $this->assertFalse($result->wasSuccessful(), 'Invalid theme action should not be successful.'); + $this->assertSame(740, $result->getErrorCode(), 'Invalid theme action should return error code 740.'); $errorMessage = $result->getErrorMessage(); $this->assertIsString($errorMessage, 'Error message should be a string.'); $this->assertNotSame('', trim($errorMessage), 'Error message should not be empty.'); $this->assertStringContainsString('non_existing_action', $errorMessage, 'Error message should reference the invalid action or endpoint.'); - - if (method_exists($result, 'getStatusCode')) { - $this->assertSame(404, $result->getStatusCode(), 'Invalid theme action should return HTTP 404.'); - } else { - // The API helper result used in live tests does not expose HTTP status directly. - $this->assertStringContainsString('does not exist', $errorMessage, 'Invalid theme action should report a missing endpoint/action.'); - } + $this->assertStringContainsString('does not exist', $errorMessage, 'Invalid theme action should report a missing endpoint/action.'); } }