Files
legacy-email-proxy/.gitea/workflows/build-and-publish.yaml
T
lyrathorpeandEmma Thorpe efa76ce17b fix: do not fail the release when pyproject.toml is already current (#18)
## The defect

The release step added in #17 commits the computed version unconditionally:

```sh
git add pyproject.toml
git commit -m "chore(release): v${VERSION}"
```

If `pyproject.toml` already carries that version — a re-run of a release, or a version corrected by hand — nothing is staged, `git commit` exits non-zero, and `set -euo pipefail` fails the job. By that point the image has already been pushed, so the release is half-done: published container, no tag. The next run then computes the same version again from the same commits.

## The fix

Skip the commit and its branch push when there is nothing staged; tag either way.

## Verification

The step was extracted from the workflow and run against a scratch repository with a real `origin`:

- `VERSION` equal to the file's version → logs `pyproject.toml is already at 0.1.0`, pushes the tag, no commit.
- `VERSION` different → commits `chore(release): v0.2.0`, pushes the branch, then the tag.

Both paths leave `main` and the tags consistent. `bash -n` clean.

Found while porting this same release scheme to `music-mirror`, which carries the guarded version from the start.

---------

Co-authored-by: Emma Thorpe <emma.thorpe@citrix.com>
Reviewed-on: #18
2026-08-21 14:50:08 +01:00

207 lines
7.5 KiB
YAML

name: Build and publish container
on:
# On merge to main, only build/release when image-affecting files change;
# CI-config, Renovate-config and docs changes do not produce a new image.
push:
branches: [main]
paths:
- 'Dockerfile'
- '.dockerignore'
- 'proxy_server.py'
- 'requirements.txt'
# Pull requests always run (the build is a required check); no path filter.
pull_request:
branches: [main]
workflow_dispatch:
# A newer run cancels an older in-flight run in the same group (keyed by ref),
# so a fresh merge to main supersedes the previous build and only the latest
# release is produced, avoiding tags that would be immediately replaced. Each
# pull request likewise supersedes only its own earlier runs.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Full history and tags are required to derive the next version
# from the conventional-commit messages since the last release.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: "${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }}"
restore-keys: |
${{ runner.os }}-pip-
- name: Install test dependencies
run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt
- name: Run unit tests
run: python -m pytest -q
- name: Determine registry host
run: echo "REGISTRY=${GITHUB_SERVER_URL#*://}" >> "$GITHUB_ENV"
# Derive the release version from conventional commits since the last
# v* tag: feat -> minor, fix/perf -> patch, ! or BREAKING CHANGE -> major.
# Anything else (chore, ci, docs, build) produces no release; those builds
# are published under a sha-<short> tag only.
- name: Compute version and image tags
id: version
run: |
set -euo pipefail
image="${REGISTRY}/${GITHUB_REPOSITORY,,}"
last_tag="$(git tag --list 'v*' --sort=-v:refname | head -n1 || true)"
if [ -n "$last_tag" ]; then
range="${last_tag}..HEAD"
base="${last_tag#v}"
else
range=""
base="0.0.0"
fi
subjects="$(git log ${range} --format='%s')"
bodies="$(git log ${range} --format='%B')"
bump="none"
if printf '%s\n' "$bodies" | grep -qiE 'BREAKING[ -]CHANGE' \
|| printf '%s\n' "$subjects" | grep -qE '^[a-z]+([(][^)]*[)])?!:'; then
bump="major"
elif printf '%s\n' "$subjects" | grep -qE '^feat([(][^)]*[)])?:'; then
bump="minor"
elif printf '%s\n' "$subjects" | grep -qE '^(fix|perf)([(][^)]*[)])?:'; then
bump="patch"
fi
major="${base%%.*}"
rest="${base#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
release="false"
if [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "$bump" != "none" ]; then
release="true"
case "$bump" in
major) major=$((major + 1)); minor=0; patch=0 ;;
minor) minor=$((minor + 1)); patch=0 ;;
patch) patch=$((patch + 1)) ;;
esac
version="${major}.${minor}.${patch}"
{
echo "tags<<__EOT__"
echo "${image}:${version}"
echo "${image}:${major}.${minor}"
echo "${image}:${major}"
echo "${image}:latest"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
echo "version=${version}" >> "$GITHUB_OUTPUT"
else
short="$(git rev-parse --short HEAD)"
{
echo "tags<<__EOT__"
echo "${image}:sha-${short}"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
fi
echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "Computed bump=${bump}, release=${release}, base=${base}"
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Log in to the Gitea container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.version.outputs.tags }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
# Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release
# instead of drifting behind it. The version is derived from commit
# messages and only known here, after the build, so it cannot be set by
# hand in the pull request that causes the release.
#
# Neither push re-triggers this workflow: it listens on main only for the
# image-affecting paths above, and pyproject.toml is not one of them. The
# chore(release) subject also produces no bump of its own on the next run.
- name: Record and tag the release
if: steps.version.outputs.release == 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
python - "$VERSION" <<'PY'
import pathlib
import re
import sys
version = sys.argv[1]
path = pathlib.Path("pyproject.toml")
text = path.read_text()
text, count = re.subn(
r'(?m)^version = ".*"$', f'version = "{version}"', text, count=1
)
if count != 1:
raise SystemExit("no version line found in pyproject.toml")
path.write_text(text)
PY
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.${REGISTRY}"
git add pyproject.toml
# The file may already carry this version, in which case there is
# nothing to commit and `git commit` would fail the job after the
# image has already been pushed.
if git diff --cached --quiet; then
echo "pyproject.toml is already at ${VERSION}"
else
git commit -m "chore(release): v${VERSION}"
# Push the branch before the tag. If main has moved on and this push
# is rejected, the job fails without having left a tag pointing at a
# commit that is not on main.
git push origin "HEAD:${GITHUB_REF_NAME}"
fi
git tag -a "v${VERSION}" -m "v${VERSION}"
git push origin "v${VERSION}"