Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 26 additions & 11 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
# Continuous integration.
#
# Runs the same checks the release gate runs (release-deploy.yaml), but on every
# push and pull request instead of only on a v* tag — so a regression surfaces at
# commit time rather than half way through a deploy.
# Runs on every PR, on every push to master, and is reused by release.yml (the Release
# workflow's first job). The pytest job is deliberately called `test`: its name is the
# status check shown on a PR. Nothing enforces it: no ruleset requires it (see CLAUDE.md,
# "The checks"), so look at it before squash-merging.
name: CI

on:
pull_request:
push:
branches:
- "**"
tags-ignore:
- "**" # tags are handled by release-deploy.yaml / plugin-release.yml
pull_request:
- master
workflow_call:

permissions:
contents: read

concurrency:
# A new push to the same ref supersedes an in-flight run.
group: ci-${{ github.ref }}
cancel-in-progress: true
# A new push to the same PR supersedes its in-flight run. Pushes to master and the
# run called from release.yml are never cancelled: a cancelled `test` would skip the
# release bump. event_name is part of the key so those two cannot cancel each other
# (when called, github.* is the caller's context).
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
test:
name: Tests
name: test
runs-on: ubuntu-latest
env:
# pytest.ini and manage.py already default to the dev settings; stated here so the
# CI settings module is visible in one place.
DJANGO_SETTINGS_MODULE: smart_training_checklist.settings.dev
steps:
- uses: actions/checkout@v7

Expand All @@ -37,6 +47,11 @@ jobs:
pip install "poetry>=2.1,<3"
poetry install --no-interaction

# No frontend build: the templates do not use compiled assets, so pytest does not
# need node. (The `js` job below runs the browser-helper unit tests.)
- name: Django system check
run: poetry run python manage.py check

- name: Run tests
run: poetry run pytest

Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: PR title

# PRs are squash-merged, so the PR title becomes the one commit on master, and that is
# the only thing `cz bump` reads to pick the version and write the changelog. A title
# bump cannot parse (e.g. "Follow-up: fix things") silently produces no release.
#
# Job `pr-title` shows green/red on the PR and re-runs when the title is edited. It is
# not enforced: no ruleset requires it, so a red check does not block the merge button.
# Fix the title before squash-merging. The rules live here and in CLAUDE.md.

on:
pull_request:
types: [opened, edited, synchronize, reopened]

permissions:
contents: read

jobs:
pr-title:
name: pr-title
runs-on: ubuntu-latest
env:
# Passed through the environment, never interpolated into the script: a title is
# untrusted input.
PR_TITLE: ${{ github.event.pull_request.title }}
steps:
# Checked out so commitizen reads the same [tool.commitizen] config as `cz bump`.
- uses: actions/checkout@v7

- uses: actions/setup-python@v7
with:
python-version: "3.11"

- name: Install commitizen
run: pip install "commitizen>=4,<5"

- name: Conventional commit format
run: cz check --message "${PR_TITLE}"

# `cz check` also lets Merge / Revert / Pull request / fixup! / squash! titles
# through (they are on its allowed-prefix list), but `cz bump` ignores them. It does
# not enforce the style rules from CLAUDE.md either, so do that here.
# It cannot judge whether the TYPE is right: `fix` on a PR that adds a feature
# costs the minor bump.
- name: Bumpable and style rules
run: |
fail() { echo "::error title=PR title::$1"; exit 1; }

case "${PR_TITLE}" in
"Merge"* | "Revert"* | "Pull request"* | "fixup!"* | "squash!"*)
fail "Titles starting with Merge/Revert/Pull request/fixup!/squash! are not read by cz bump. Use feat/fix/refactor/... instead." ;;
esac

subject="${PR_TITLE#*: }"
[ "${#PR_TITLE}" -le 72 ] || fail "Title is ${#PR_TITLE} characters; keep it at 72 or fewer."
[[ "${subject}" =~ ^[a-z0-9] ]] || fail "Subject must start with a lowercase letter or digit: '${subject}'."
[[ "${subject}" != *. ]] || fail "Subject must not end with a period."

echo "OK: ${PR_TITLE}"
38 changes: 29 additions & 9 deletions .github/workflows/release-deploy.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Release & Deploy
name: Build & Deploy

# SELF-CONTAINED deploy pipeline — copy this whole file into <app-repo>/.github/workflows/
# and edit the marked per-repo values. It has no dependency on any workflow in another
Expand All @@ -10,10 +10,24 @@ name: Release & Deploy
# Requires SSH_* secrets on THIS repo (Settings > Secrets and variables > Actions):
# SSH_HOST, SSH_USER, SSH_PRIVATE_KEY, SSH_KNOWN_HOSTS, and optionally SSH_PORT.

# Two ways in:
# * workflow_call - from release.yml, right after Commitizen created the tag.
# A tag pushed with GITHUB_TOKEN triggers nothing, so the caller
# chains this workflow explicitly and passes the tag as `version`.
# * push of a tag v* - manual fallback (tag created and pushed by hand). plugin-v* and
# sop-* tags do not match.
# TAG below is the single source for "which release is this": the `version` input when
# called, the pushed tag otherwise. Every job checks out that tag, never the caller's ref.
on:
push:
tags:
- "v*"
workflow_call:
inputs:
version:
description: "Tag to release and deploy, e.g. v2.11.0 (as created by Commitizen)"
type: string
required: true

permissions:
contents: write
Expand All @@ -24,6 +38,7 @@ concurrency:
cancel-in-progress: false

env:
TAG: ${{ inputs.version || github.ref_name }}
APP: simflow # <-- EDIT: the <app> argument for activate.sh/rollback.sh
VERSION_FILE: smart_training_checklist/__init__.py # <-- EDIT: file carrying __version__ = "X.Y.Z"
# URL of the deploy-tools repo cloned onto the server if not already present.
Expand All @@ -35,6 +50,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.TAG }}

- uses: actions/setup-python@v7
with:
Expand All @@ -43,9 +60,9 @@ jobs:
- name: Tag matches __version__
run: |
file_version="v$(grep -oP '__version__ = "\K[^"]+' "${VERSION_FILE}")"
echo "tag=${GITHUB_REF_NAME} file=${file_version}"
if [ "${file_version}" != "${GITHUB_REF_NAME}" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} does not match ${VERSION_FILE} (${file_version})"
echo "tag=${TAG} file=${file_version}"
if [ "${file_version}" != "${TAG}" ]; then
echo "::error::Tag ${TAG} does not match ${VERSION_FILE} (${file_version})"
exit 1
fi

Expand All @@ -67,13 +84,14 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.TAG }}
fetch-depth: 0
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
gh release create "${TAG}" \
--title "${TAG}" \
--generate-notes

ship:
Expand All @@ -82,6 +100,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.TAG }}

- uses: actions/setup-python@v7
with:
Expand All @@ -95,7 +115,7 @@ jobs:
- name: Attach requirements.txt to the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${GITHUB_REF_NAME}" requirements.txt --clobber
run: gh release upload "${TAG}" requirements.txt --clobber

- name: Configure SSH
run: |
Expand Down Expand Up @@ -140,7 +160,7 @@ jobs:
--exclude 'xChecklist-structure.txt' \
--exclude 'CLAUDE.md' \
-e "ssh -i ~/.ssh/id_deploy -p ${SSH_PORT:-22}" \
./ "${SSH_USER}@${SSH_HOST}:domains/releases/${APP}_${GITHUB_REF_NAME}/"
./ "${SSH_USER}@${SSH_HOST}:domains/releases/${APP}_${TAG}/"

# Ship the deploy config (kept in this repo under deploy/<app>_config.sh).
rsync -az \
Expand Down Expand Up @@ -182,7 +202,7 @@ jobs:
SSH_PORT: ${{ secrets.SSH_PORT }}
run: |
ssh -i ~/.ssh/id_deploy -p "${SSH_PORT:-22}" "${SSH_USER}@${SSH_HOST}" \
"bash -lc 'cd ~/domains && ~/deploy-tools/scripts/activate.sh ${APP} ${GITHUB_REF_NAME}'"
"bash -lc 'cd ~/domains && ~/deploy-tools/scripts/activate.sh ${APP} ${TAG}'"

- name: Smoke test
id: smoke
Expand Down
158 changes: 158 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: Release

# Started by hand: Actions > Release > "Run workflow" (on master).
# test -> the CI workflow (ci.yaml)
# bump -> Commitizen bumps the version, updates the changelog, commits and tags
# deploy -> the build / release / deploy pipeline (release-deploy.yaml), for the new tag
#
# This is the web-app release only. Plugin (plugin-v*) and SOP (sop-*) releases stay on
# their own scripts and tags, see docs/RELEASE.md.
#
# Take a database dump BEFORE starting a release that contains migrations: the automatic
# rollback restores the code, not the database.
#
# Only GITHUB_TOKEN is used. A tag pushed with GITHUB_TOKEN does not trigger other
# workflows, which is why the deploy is chained here instead of relying on the tag trigger.
#
# The bump runs scripts/check_content_bump.py first (pre_bump_hooks in pyproject.toml). If
# the checklist fixture moved without a content_version bump the bump job fails before any
# commit or tag exists; bump content_version (docs/RELEASE.md section 3) and run again.

on:
workflow_dispatch:
inputs:
increment:
description: "Version bump: auto reads the conventional commits since the last tag. PATCH/MINOR forces it (only needed when there are no bumpable commits)."
type: choice
options:
- auto
- PATCH
- MINOR
default: auto

permissions:
contents: read

# Never run two releases at once. (Not the same group as release-deploy.yaml's
# `production-deploy`: a caller and its called workflow sharing a group would block each other.)
concurrency:
group: release
cancel-in-progress: false

jobs:
test:
name: test
uses: ./.github/workflows/ci.yaml

bump:
name: Bump version & tag
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
bumped: ${{ steps.result.outputs.bumped }}
version: ${{ steps.result.outputs.version }}
tag: ${{ steps.result.outputs.tag }}
steps:
- name: Only release from master
if: github.ref != 'refs/heads/master'
run: |
echo "::error::Run the Release workflow on master (it was started on ${GITHUB_REF_NAME})."
exit 1

# Full history and tags: commitizen finds the last tag from them, and
# check_content_bump.py walks the fixture's git history.
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: actions/setup-python@v7
with:
python-version: "3.11"

# Heads-up only, never fails the release. The pr-title check is not enforced, so a
# title it rejected, a direct push, or a plugin/SOP script commit can reach master
# without being a conventional commit. List the commits since the last tag that
# `cz bump` cannot read, i.e. the ones that will be missing from the version bump
# and the changelog.
- name: Warn about commits cz bump cannot read
run: |
pip install --quiet "commitizen>=4,<5"
last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
if [ -z "${last_tag}" ]; then
echo "No v* tag found, skipping the commit check."
exit 0
fi

if report=$(cz check --rev-range "${last_tag}..HEAD" 2>&1); then
echo "All commits since ${last_tag} are readable by cz bump."
exit 0
fi

# cz prints a verbose block per bad commit; keep just the hashes and show subjects.
shas=$(printf '%s\n' "${report}" | grep -oP '^commit "\K[0-9a-f]{40}' || true)
if [ -z "${shas}" ]; then
echo "::warning title=Commit check::cz check failed for ${last_tag}..HEAD:"
printf '%s\n' "${report}"
exit 0
fi

{
echo "## Commits cz bump will ignore"
echo ""
echo "These commits since \`${last_tag}\` are not conventional commits, so they add nothing to the version bump or the changelog:"
echo ""
for sha in ${shas}; do
echo "- \`$(git log -1 --format='%h' "${sha}")\` $(git log -1 --format='%s' "${sha}")"
done
echo ""
echo "The release continues. If a change is missing from the changelog, fix it up in a \`docs(changelog): ...\` PR afterwards."
} | tee -a "$GITHUB_STEP_SUMMARY"
echo "::warning title=Unreadable commits::$(echo "${shas}" | wc -l) commit(s) since ${last_tag} are not conventional commits and will be missing from the changelog. See the run summary."

# Runs `cz bump --yes --changelog`, commits, tags and pushes. Exit code 21
# (no bumpable commits) is not an error for this action (no_raise defaults to 21);
# in that case nothing is pushed and `version` equals `previous_version`.
# `auto` leaves `increment` empty so commitizen decides from the commits.
- name: Commitizen bump
id: cz
uses: commitizen-tools/commitizen-action@0.27.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
increment: ${{ inputs.increment != 'auto' && inputs.increment || '' }}

- name: Evaluate result
id: result
env:
NEW_VERSION: ${{ steps.cz.outputs.version }}
PREVIOUS_VERSION: ${{ steps.cz.outputs.previous_version }}
run: |
if [ "${NEW_VERSION}" = "${PREVIOUS_VERSION}" ]; then
echo "bumped=false" >> "$GITHUB_OUTPUT"
echo "::notice title=Nothing to release::No bumpable commits (feat / fix / ...) since v${PREVIOUS_VERSION}. Nothing was tagged or deployed."
{
echo "## Nothing to release"
echo ""
echo "There are no bumpable commits (\`feat\`, \`fix\`, \`refactor\`, ... or a breaking change) since \`v${PREVIOUS_VERSION}\`."
echo "No version bump, tag or deploy was made. To release anyway, run again with increment PATCH or MINOR."
} >> "$GITHUB_STEP_SUMMARY"
else
# tag_format in pyproject.toml is "v$version"
echo "bumped=true" >> "$GITHUB_OUTPUT"
echo "version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
echo "## Released v${NEW_VERSION} (was v${PREVIOUS_VERSION})" >> "$GITHUB_STEP_SUMMARY"
fi

deploy:
name: deploy
needs: bump
if: needs.bump.outputs.bumped == 'true'
# The called workflow requests contents: write; the caller must allow it.
permissions:
contents: write
uses: ./.github/workflows/release-deploy.yaml
with:
version: ${{ needs.bump.outputs.tag }}
secrets: inherit
Loading
Loading