mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
release.yaml has never run. It listens for "v*" tags while every tag ever cut is bare — 3.0.0-alpha.1, 2.4.2, 2.4.3-beta.1 — so tagging by the established convention produced silence, and whoever cuts 3.0.0 would have hit that first. It now accepts both spellings. It also hand-copied the release file list, which docker.yaml then rebuilt via `make clean dist` and uploaded with overwrite: true. The two lists were identical, so nothing had broken yet, but which zip users received was decided by job ordering. `make dist` owns the artifact now and the duplicate is gone. The release is created as a draft with the package already attached and published in a separate step, so `release: published` fires only once the tests have passed and the asset is in place. A failed run leaves no release at all, rather than a published one nobody can download — which is what 2.4.2 left behind. Registration can no longer race the upload either, so docker.yaml drops its build job, and register_release loses both that dependency and the always() dance it needed to survive the job being skipped on a manual dispatch. GitHub's "Latest release" pointer is gated on LATEST_MAJOR, exactly as docker.yaml gates its moving image tags: a 3.0.0 alpha can no longer displace 2.4.x as the release users are shown first. Release notes come from CHANGELOG.md instead of being generated from commits, matching where the updater already reads them, and a tag with no section fails before anything is published. The test job moves to a reusable workflow rather than being written out in both places — the duplicated file list above is the argument.
266 lines
11 KiB
YAML
266 lines
11 KiB
YAML
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
|
||
uses: ./.github/workflows/tests.yaml
|
||
|
||
# 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' ||
|
||
(github.event_name == 'workflow_dispatch' && 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"] ?? []);')
|
||
|
||
# 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 }}"}}
|