name: Publish Release on: release: types: [published] workflow_dispatch: inputs: register_tag: description: 'Release tag to (re-)register on the updater, e.g. 2.4.2.' required: true # 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: # 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 # release.yaml attaches the package before publishing, so by the time this # runs the asset is already there and no build dependency is needed. That also # retires the always() dance this condition previously required to survive a # skipped build job on a manual dispatch. if: github.event_name == 'release' || inputs.register_tag != '' 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"] ?? []);') # 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' runs-on: ubuntu-latest steps: # Releases are cut by pushing a tag: release.yaml runs the tests, builds the # package and attaches it to a draft. A release without that package was made # by hand and has been through none of it, so it does not get images. The # updater is already protected — registration downloads this same asset and # fails without it. - name: Refuse a release that did not come from the pipeline env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.event.release.tag_name }} run: | if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' \ | grep -qx 'InvoiceShelf.zip'; then echo "::error::$TAG has no InvoiceShelf.zip. Releases are cut by pushing a tag, not created by hand — see the Releasing section in AGENTS.md." exit 1 fi - 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)) }} # Scoped by line, or a pre-release on one line silently takes the # other's alias: 2.4.3-beta.1 claimed :next from 3.0.0-alpha.1, moving # anyone tracking the next major back onto a 2.x image. :beta is the # stable line's pre-release, :next the newer major's — both gated on # LATEST_MAJOR, exactly as :latest is. type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta') && startsWith(github.ref_name, format('{0}.', env.LATEST_MAJOR)) }} type=raw,value=next,enable=${{ contains(github.ref_name, '-') && !startsWith(github.ref_name, format('{0}.', env.LATEST_MAJOR)) }} # 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 }}"}}