name: Docker Build and Push on: release: types: [published] workflow_dispatch: inputs: tag: description: 'Docker tag' required: true default: 'latest' register_tag: description: 'Release tag to (re-)register on the updater, e.g. 2.4.2. Leave blank to skip.' required: false default: '' # Single source of truth for which major owns the moving stable tags # (:latest, :{major}, :{major}.{minor}) and the temporary :nightly alias. # Bump to "3" on BOTH the 2.x and 3.x branches when 3.0.0 GA is tagged, and # drop the :nightly alias tag at the same time (end of the deprecation window). env: LATEST_MAJOR: "2" jobs: php_syntax_errors: name: 1️⃣ PHP Code Style errors if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: 8.4 - name: Checkout code uses: actions/checkout@v6 - name: Install dependencies uses: ramsey/composer-install@4.0.0 - name: Check source code for syntax errors run: ./vendor/bin/pint --test tests: name: 2️⃣ PHP Tests if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' needs: - php_syntax_errors runs-on: ubuntu-latest strategy: matrix: php-version: - 8.4 env: extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup PHP Action uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} extensions: ${{ env.extensions }} coverage: none tools: pecl, composer - name: Install Composer dependencies uses: ramsey/composer-install@4.0.0 - name: Apply tests ${{ matrix.php-version }} (parallel) run: php artisan test --parallel --exclude-group=modules - name: Apply module tests ${{ matrix.php-version }} (serial) run: php artisan test --group=modules release_artifact_build: name: 🏗️ Build / Upload - Release File if: github.event_name == 'release' needs: - tests runs-on: ubuntu-latest env: extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.4 extensions: ${{ env.extensions }} coverage: none - name: Install Composer dependencies uses: ramsey/composer-install@4.0.0 with: composer-options: --no-dev - name: Install pnpm uses: pnpm/action-setup@v6 - name: Use Node.js 24 uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - name: Install run: pnpm install --frozen-lockfile - name: Compile Front-end run: pnpm build - name: Build Dist run: | make clean dist - name: Upload package uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ github.token }} file: InvoiceShelf.zip asset_name: InvoiceShelf.zip tag: ${{ github.ref }} overwrite: true # Registration lives in its own job, and fetches the published asset rather than # reusing the build job's working directory. That decoupling is deliberate: the # previous in-line step read `changelog.txt` relative to the checkout while writing # it to /tmp, and the mismatch was undetectable until a real release ran. As its own # job it can also be re-run on demand for an existing tag, so a failure here no longer # requires production shell access to repair. Idempotent per version — the endpoint # upserts. Release fields are passed via env (not inline ${{ }}) to avoid shell # injection from the release body. register_release: name: 📡 Register release on the updater # always() is required because release_artifact_build is skipped on a manual # dispatch, and a job needing a skipped job is skipped too. if: >- always() && ((github.event_name == 'release' && needs.release_artifact_build.result == 'success') || (github.event_name == 'workflow_dispatch' && inputs.register_tag != '')) needs: - release_artifact_build runs-on: ubuntu-latest steps: - name: Resolve the tag being registered id: resolve env: EVENT: ${{ github.event_name }} RELEASE_TAG: ${{ github.event.release.tag_name }} INPUT_TAG: ${{ inputs.register_tag }} run: | if [ "$EVENT" = "release" ]; then TAG="$RELEASE_TAG"; else TAG="$INPUT_TAG"; fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" # config/installer.php is read at the tag being registered, not at HEAD, so a # re-registration reports the requirements that release actually shipped with. - name: Checkout code at that tag uses: actions/checkout@v6 with: ref: ${{ steps.resolve.outputs.tag }} - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: 8.4 coverage: none - name: Download the published release asset env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.resolve.outputs.tag }} run: gh release download "$TAG" --repo "$GITHUB_REPOSITORY" -p InvoiceShelf.zip --clobber - name: Register on the updater id: register env: CI_RELEASE_TOKEN: ${{ secrets.WEBSITE_RELEASE_TOKEN }} GH_TOKEN: ${{ github.token }} EVENT: ${{ github.event_name }} TAG: ${{ steps.resolve.outputs.tag }} run: | if [ -z "$CI_RELEASE_TOKEN" ]; then # A release that silently skips registration looks entirely successful while # reaching nobody, so on a real release this is fatal. A manual dispatch # without the secret is legitimate, so warn there instead. if [ "$EVENT" = "release" ]; then echo "::error::WEBSITE_RELEASE_TOKEN is not set — $TAG would never be offered to installs." exit 1 fi echo "::warning::WEBSITE_RELEASE_TOKEN not set — skipping updater registration for $TAG" echo "registered=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "registered=true" >> "$GITHUB_OUTPUT" MIN_PHP=$(php -r '$c=require "config/installer.php"; echo $c["core"]["minPhpVersion"] ?? "";') EXTS=$(php -r '$c=require "config/installer.php"; echo implode(", ", $c["requirements"]["php"] ?? []);') # Read the notes and pre-release flag from the release itself, so this behaves # identically whether triggered by a publish or re-run later by hand. # CHANGELOG.md is the source: it is written and reviewed alongside the # change itself, so what installs are offered cannot drift from what was # merged. A release with no section fails here rather than registering an # empty changelog — except on a manual dispatch, where re-registering a # release older than the file is legitimate and the GitHub body stands in. if php .github/scripts/changelog-section.php "$TAG" > /tmp/changelog.txt; then echo "Using the CHANGELOG.md section for $TAG" elif [ "$EVENT" = "release" ]; then echo "::error::No CHANGELOG.md section for $TAG — add one and re-run this job." exit 1 else echo "::warning::No CHANGELOG.md section for $TAG — falling back to the release body." gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' > /tmp/changelog.txt fi # The pre-release flag and timestamp come from the release itself, so this # behaves identically whether triggered by a publish or re-run by hand. PRERELEASE=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq '.isPrerelease') PUBLISHED=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json publishedAt --jq '.publishedAt') case "$TAG" in *-*) CHANNEL=insider ;; *) [ "$PRERELEASE" = "true" ] && CHANNEL=insider || CHANNEL=stable ;; esac echo "Registering $TAG (channel=$CHANNEL, min_php=$MIN_PHP) on the updater" curl -fsS --retry 3 --retry-delay 5 -X POST https://invoiceshelf.com/api/releases \ -H "Authorization: Bearer $CI_RELEASE_TOKEN" \ -F "version=$TAG" \ -F "channel=$CHANNEL" \ -F "released_at=$PUBLISHED" \ -F "min_php_version=$MIN_PHP" \ -F "extensions=$EXTS" \ -F "changelog= /dev/null; then echo "::error::$TAG was accepted by the updater but is not being served — installs will not receive it." exit 1 fi echo "$TAG is registered and downloadable." release_docker_build: name: 🐳 Release Docker Build if: github.event_name == 'release' needs: - tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v6 with: images: invoiceshelf/invoiceshelf # Pre-release semver (e.g. 3.0.0-beta.1) automatically gets ONLY the exact # {{version}} tag — metadata-action withholds {{major}} / {{major}}.{{minor}}. # The moving stable tags (:latest, etc.) are gated on LATEST_MAJOR so a 2.x # patch can never steal :latest from 3.x after the GA flip, and vice-versa. tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') && startsWith(github.ref_name, format('{0}.', env.LATEST_MAJOR)) }} type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta') }} type=raw,value=next,enable=${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') }} # DEPRECATED transitional alias: keeps existing :nightly deployments converging # onto stable until they migrate. Remove this line at the 3.0.0 GA flip. type=raw,value=nightly,enable=${{ !contains(github.ref_name, '-') && startsWith(github.ref_name, format('{0}.', env.LATEST_MAJOR)) }} - name: Build and push Docker image uses: docker/build-push-action@v7 with: context: . file: docker/production/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max secrets: | composer_auth={"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}} manual_docker_build: name: 🛠️ Manual Docker Build # A dispatch carrying register_tag is asking to register a release, not to # build an image. Without this guard it would also run, and `tag` defaults to # "latest" — so re-registering a release would rebuild from this branch and # overwrite the published :latest image. if: github.event_name == 'workflow_dispatch' && inputs.register_tag == '' needs: - tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_TOKEN }} - name: Build and push Docker image uses: docker/build-push-action@v7 with: context: . file: docker/production/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: invoiceshelf/invoiceshelf:${{ github.event.inputs.tag }} cache-from: type=gha cache-to: type=gha,mode=max secrets: | composer_auth={"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}