Files
InvoiceShelf/.github/workflows/docker.yaml
Darko Gjorgjijoski 3308f3b2fa ci: source release notes from CHANGELOG.md (#713)
Port of the 2.x change to 3.x, with the same extractor script.

Registration sent the GitHub release body to the updater, so the notes
every install sees were written at publish time — after review, outside
the repo, with nothing checking they existed or matched what shipped.

CHANGELOG.md becomes the source. It is written and reviewed alongside the
change itself, so what installs are offered cannot drift from what was
merged, and the release body can simply point at it.

A release with no section for its tag fails the job rather than
registering an empty changelog. A manual dispatch falls back to the
release body, since re-registering a release older than this file is
legitimate — which covers 3.0.0-alpha.1.

Backfilled with 3.0.0-alpha.1.
2026-07-29 15:04:30 +02:00

348 lines
13 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=</tmp/changelog.txt" \
-F "description=</tmp/changelog.txt" \
-F "release_file=@InvoiceShelf.zip"
# Posting a 2xx is not proof an install can actually fetch the release. This
# endpoint 404s unless the Release row exists AND its zip is retrievable from
# storage, so it verifies the whole chain — and would have caught the failure
# this job was rewritten for.
- name: Verify the release is being served
if: steps.register.outputs.registered == 'true'
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
if ! curl -fsI --retry 3 --retry-delay 5 "https://invoiceshelf.com/releases/download/$TAG" > /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 }}"}}