diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6e6c333..454d8a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 @@ -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 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..a9d9795 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -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}" diff --git a/.github/workflows/release-deploy.yaml b/.github/workflows/release-deploy.yaml index 008f75d..1e965a3 100644 --- a/.github/workflows/release-deploy.yaml +++ b/.github/workflows/release-deploy.yaml @@ -1,4 +1,4 @@ -name: Release & Deploy +name: Build & Deploy # SELF-CONTAINED deploy pipeline — copy this whole file into /.github/workflows/ # and edit the marked per-repo values. It has no dependency on any workflow in another @@ -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 @@ -24,6 +38,7 @@ concurrency: cancel-in-progress: false env: + TAG: ${{ inputs.version || github.ref_name }} APP: simflow # <-- EDIT: the 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. @@ -35,6 +50,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.TAG }} - uses: actions/setup-python@v7 with: @@ -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 @@ -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: @@ -82,6 +100,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.TAG }} - uses: actions/setup-python@v7 with: @@ -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: | @@ -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/_config.sh). rsync -az \ @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c9bf1b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ca3064a..7412b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,9 @@ py -m coverage lcov # Lint Django templates djlint checklist/templates/ -# Bump version (uses commitizen + post-hook to push) -cz bump +# Releases come from the Release workflow on GitHub, not from a local `cz bump` (see "Releasing") +# Check a commit message / PR title the way the pr-title check does +cz check --message "feat(scope): subject" ``` ## Settings @@ -55,19 +56,19 @@ Settings are split into `smart_training_checklist/settings/`: ## Deployment -Deploys are automated via GitHub Actions and triggered by pushing a `v*` tag — which `cz bump` does for you (its `post_bump_hooks` run `git push` then `git push --tags`; both are needed because commitizen creates lightweight tags, which `--follow-tags` will not push). The pipeline runs tests, creates a GitHub release, ships the built tree to the cPanel/CloudLinux server (`simflow.vdwaal.net` on `/home/vdwanet`), then activates it with an automatic smoke test and rollback. +Deploys are automated via GitHub Actions. The **Release workflow** (see "Releasing") bumps the version, tags `v` and calls the deploy pipeline for that tag; pushing a `v*` tag by hand also deploys (manual fallback). The pipeline runs tests, creates a GitHub release, ships the built tree to the cPanel/CloudLinux server (`simflow.vdwaal.net` on `/home/vdwanet`), then activates it with an automatic smoke test and rollback. Three moving parts: | Part | Where | Role | |---|---|---| -| `.github/workflows/release-deploy.yaml` | this repo | Self-contained pipeline: test → release → rsync ship → activate/smoke/rollback. Trigger: `v*` tags only (`plugin-v*` / `sop*` do **not** match). | +| `.github/workflows/release-deploy.yaml` | this repo | Self-contained pipeline (display name "Build & Deploy"): test → release → rsync ship → activate/smoke/rollback. Triggers: `workflow_call` from `release.yml` with a `version` input, and pushed `v*` tags as a manual fallback (`plugin-v*` / `sop*` do **not** match). `TAG` (`inputs.version || github.ref_name`) is the one source for which release is built; every checkout uses `ref: ${{ env.TAG }}`. | | `deploy/simflow_config.sh` | this repo | Secret-free per-app config, rsynced to `~/domains/simflow_config.sh` each deploy. Sets `DOMAIN`, `PYTHON_ENV`, `DJANGO_SETTINGS_MODULE=…settings.prod`, `DATABASE_SOURCE=production`, `POST_MIGRATE_COMMANDS`, `SMOKE_URL`. | | `~/deploy-tools/scripts/{activate,rollback}.sh` | server (from [`www_installer`](https://github.com/chezhj/www_installer)) | Bring a shipped release live: symlink shared `db.sqlite3`/`.env`, back up DB, `migrate`, run post-migrate commands, `collectstatic`, restart. Clone-bootstrapped by the workflow if missing. | **Persistent state** (`db.sqlite3`, `.env`) lives in `~/domains/shared/simflow/` and is symlinked into each release, so it survives deploys. Because `DATABASE_SOURCE=production`, the live DB is backed up before every `migrate`. -**Checklist content ships with the app**: after `migrate`, `POST_MIGRATE_COMMANDS` runs `checklist_content import --prune --noinput`, which upserts the content tables from `checklist/fixtures/checklist_content.json` in that release and then deletes the rows that fixture no longer carries. Edit content via the fixture (see the content workflow), not the destructive export. A fixture edit requires a `content_version` bump: `cz bump` runs `scripts/check_content_bump.py` first and refuses the release when the content hash moved but the version did not. +**Checklist content ships with the app**: after `migrate`, `POST_MIGRATE_COMMANDS` runs `checklist_content import --prune --noinput`, which upserts the content tables from `checklist/fixtures/checklist_content.json` in that release and then deletes the rows that fixture no longer carries. Edit content via the fixture (see the content workflow), not the destructive export. A fixture edit requires a `content_version` bump: the bump (`cz bump`, which the Release workflow runs) first runs `scripts/check_content_bump.py` and refuses the release when the content hash moved but the version did not. The Release workflow has no `SIMFLOW_SKIP_CONTENT_CHECK` override; bump `content_version` and run it again. The import is deliberately not a wipe-and-reload. `Attribute`, `Procedure` and `CheckItem` are referenced with CASCADE by `UserAttributeDefault`, `FlightSessionAttribute` and `FlightItemState`, so deleting a row that is about to be re-created destroys saved preferences and in-flight progress — which is exactly what happened on every deploy before the import was rewritten. Rows that survive keep their pk and are updated in place; a delete now means the content is genuinely gone, and the cascade that follows is intended. Two rules the fixture must honour, both enforced by pre-flight validation that aborts before writing: never recycle a pk, and every reference must resolve to a row that survives the import. `checklist_content import --prune --dry-run` reports the add/update/delete plan without writing. @@ -82,6 +83,39 @@ cd ~/domains && ~/deploy-tools/scripts/rollback.sh simflow --- +## Commits, PR titles and releasing + +### Commit and PR-title style (commitizen) + +`CHANGELOG.md` and the version are generated from conventional commits (`cz_conventional_commits`, tag `v$version`). PRs are squash-merged, so the **PR title becomes the one commit on `master`** and is the only thing `cz bump` reads: + + (): + +- Subject: lowercase (or a digit) first, imperative, no trailing period; the whole title at most 72 characters (the squash adds ` (#N)`). Scope is optional but preferred. +- `feat` → minor bump. `fix`, `refactor`, `perf` → patch bump. `docs`, `chore`, `test`, `build`, `ci`, `style` → no bump. `!` / `BREAKING CHANGE` only for genuinely breaking changes. +- The `pr-title` workflow runs `cz check --message` on every PR (and again when the title is edited) and also rejects titles starting with `Merge`, `Revert`, `Pull request`, `fixup!` or `squash!` (commitizen accepts them, `cz bump` ignores them) plus the style rules above. It cannot judge whether the *type* is right: `fix` on a PR that adds a feature costs the minor bump, so pick it deliberately. +- Check a title before opening the PR: `cz check --message "feat(menu): add a thing"`. +- Always squash-merge, and keep Settings → General → Pull requests → default squash commit message on "Pull request title". + +### The checks + +`test` (CI: `manage.py check` + `pytest`, plus the `js` job) and `pr-title` run on every PR, but **nothing enforces them**: no ruleset requires them, so a red check does not disable the merge button. Look at the checks before squash-merging and do not merge on red. + +Why no required-status-checks rule: the Release workflow pushes its bump commit with `GITHUB_TOKEN`, and `github-actions[bot]` cannot be added to a ruleset's bypass list. A brand-new, untested commit would therefore be rejected by an enforced `required_status_checks` rule (GitHub's branch-protection docs say commits must pass the checks before they reach the branch; not verified for rulesets), and the Release would then fail at the push. Enforcing the checks would need the bump to be pushed with a token of a bypass user (e.g. a fine-grained PAT of the repo owner); because a token-pushed tag *does* trigger the `v*` deploy, the chained `deploy` job in `release.yml` would then have to go, or every release would deploy twice. If you ever enable it, the context names are `test` and `pr-title` (not `Tests`). + +### Releasing + +- Web-app releases are made by the **Release workflow** (GitHub: Actions → Release → Run workflow, on `master`). It runs `ci.yaml`, then `commitizen-action` (bump, `CHANGELOG.md`, commit, `v*` tag, push), then `release-deploy.yaml` for that tag. Do not run `cz bump` or create tags locally for a normal release. +- `increment`: leave it on `auto` so the version comes from the conventional commits since the last tag. Choose `PATCH` or `MINOR` only to force a release when nothing bumpable has landed. With nothing to bump the run ends with "Nothing to release" and pushes and deploys nothing. +- A warning step lists commits since the last tag that `cz bump` cannot read (a bad title that slipped through, a direct push, a `tools:`-style type). It only warns; those commits are missing from the bump and the changelog. +- A local `cz bump` still works (it bumps, commits and tags, and runs the content-version check) but pushes nothing; `post_bump_hooks` is only an echo. To ship such a bump by hand: `git push && git push --tags`; the pushed `v*` tag deploys. +- Take a database dump *before* a release that contains migrations: the automatic rollback restores code, not the database. +- Do not hand-write changelog text under a version header: the workflow generates it. Fix-ups go through a `docs(changelog): ...` PR. +- Plugin (`plugin-v*`) and SOP (`sop-*`) releases are separate and unchanged, see `docs/RELEASE.md`. +- Never push to `master` directly for a web-app change; work on a branch and open a PR. + +--- + ## Architecture Single Django app: **`checklist`**, mounted at `/` in `smart_training_checklist/urls.py`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a4dc05e..3b672fa 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,7 +4,7 @@ Three independent release processes share one mono-repo. Each has its own versio | Artefact | Tag format | Tool | |---|---|---| -| Web app | `v*` | `cz bump` | +| Web app | `v*` | Release workflow (GitHub Actions) | | X-Plane plugin | `plugin-v*` | `scripts/bump_plugin.py` | | Checklist content (SOP) | `sop-{icao}-v*` | `scripts/bump_content_version.py` | @@ -13,27 +13,27 @@ Three independent release processes share one mono-repo. Each has its own versio ## 1. Web app release ### Prerequisites -- Working tree is clean on `master`, all tests pass (`pytest`). +- The changes are merged to `master` through PRs with conventional-commit titles (see `CLAUDE.md`, "Commits, PR titles and releasing"). +- If the release contains migrations, take a database dump first: the automatic rollback restores code, not data. +- If `checklist/fixtures/checklist_content.json` changed, `content_version` was bumped (section 3), otherwise the bump job refuses. ### Steps -**1. Bump version and push** +**1. Run the Release workflow** -``` -cz bump -``` +GitHub → Actions → **Release** → Run workflow, on `master`. Leave `increment` on `auto`. -Commitizen updates `smart_training_checklist/__init__.py` and `pyproject.toml`, appends an entry to `CHANGELOG.md`, creates a commit and tag (`v`), then runs its `post_bump_hooks`: `git push` followed by `git push --tags`. +The workflow: +1. runs CI (`ci.yaml`: `manage.py check`, `pytest`, JS helper tests); +2. warns (never fails) about commits since the last tag that `cz bump` cannot read; +3. runs Commitizen: `scripts/check_content_bump.py`, then the version bump in `pyproject.toml` and `smart_training_checklist/__init__.py`, a new `CHANGELOG.md` section, the bump commit and the `v` tag, pushed by the workflow itself; +4. calls `release-deploy.yaml` for that tag. -> Both commands are needed. Commitizen creates **lightweight** tags, and -> `git push --follow-tags` pushes only *annotated* ones — so the old -> `push_all` hook pushed the commit and silently skipped the tag. Since the -> deploy is triggered by the `v*` tag, that meant no deploy, with no error. -> Check `git ls-remote --tags origin "v*"` if a release seems not to have shipped. +With no bumpable commit (`feat`, `fix`, `refactor`, `perf` or a breaking change) since the last tag, the run ends with "Nothing to release" and nothing is pushed or deployed. Choose `increment: PATCH` or `MINOR` only to force a release in that case. -**2. Deploy to production** — nothing to do; the tag does it. +**2. Deploy to production** — step 1 does it. -`.github/workflows/release-deploy.yaml` runs on any `v*` tag: tests, GitHub +`.github/workflows/release-deploy.yaml` (display name "Build & Deploy"): tests, GitHub release, rsync to the server, then `activate.sh` (backup → migrate → post-migrate commands → collectstatic → restart), a smoke test, and an automatic rollback if the smoke test fails. @@ -41,6 +41,20 @@ automatic rollback if the smoke test fails. The manual `install.sh` / `deploy.sh` flow this section used to describe was replaced by that pipeline and no longer exists on the server. +**Fallback: bumping by hand** + +A local `cz bump` still bumps, commits and tags (and runs the content check) but +**pushes nothing**: `post_bump_hooks` is only an echo. To ship it: + +``` +git push && git push --tags +``` + +Both commands are needed. Commitizen creates **lightweight** tags, and +`git push --follow-tags` pushes only *annotated* ones. The pushed `v*` tag triggers +`release-deploy.yaml` directly. Check `git ls-remote --tags origin "v*"` if a release +seems not to have shipped. Do not use this alongside the Release workflow for the same version. + --- ## 2. Plugin release @@ -108,13 +122,13 @@ Users download from `https://github.com/chezhj/simflow/releases`. ### Steps -> `cz bump` refuses to release when the fixture's content has changed but +> The bump (Release workflow or local `cz bump`) refuses to release when the fixture's content has changed but > `content_version` has not — `scripts/check_content_bump.py`, wired as commitizen's > `pre_bump_hooks`. It names the rows that moved and the commit that set the current > version. Because commitizen writes the version files before running the hook, an > abort leaves `pyproject.toml`, `smart_training_checklist/__init__.py` and > `CHANGELOG.md` modified but uncommitted and untagged; the message prints the -> `git restore` line. Deliberate exception: `SIMFLOW_SKIP_CONTENT_CHECK=1 cz bump`. +> `git restore` line. Deliberate exception for a local bump: `SIMFLOW_SKIP_CONTENT_CHECK=1 cz bump`. The Release workflow has no such override: its bump job fails before any commit or tag exists; bump `content_version` and run it again. **1. Edit the fixture** @@ -147,7 +161,7 @@ The script: git push && git push origin sop-B738-v ``` -**4. Deploy to production** — again, the `v*` tag does it. See §1. +**4. Deploy to production** — run the Release workflow. See §1. > **Superseded.** `POST_MIGRATE_COMMANDS` in `deploy/simflow_config.sh` runs `checklist_content import --prune --noinput` after every migrate, so the fixture loads itself. The manual step below is kept only for recovering an older release. > ``` diff --git a/pyproject.toml b/pyproject.toml index 7ed4c63..f9e759a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,14 +55,17 @@ pre_bump_hooks = [ "python scripts/check_content_bump.py", ] -# Run inline rather than calling a machine-local `push_all` script: that script -# lived outside the repo, so when it stopped working the release silently pushed -# the commit without the tag — and the tag is what triggers the deploy. -# Two commands because commitizen creates LIGHTWEIGHT tags, which -# `git push --follow-tags` does not push (it only follows annotated ones). +# Releases are made by the Release workflow (Actions > Release > Run workflow), which +# pushes the bump commit and the tag itself and then deploys that tag. This hook only +# reminds anyone who runs `cz bump` locally that nothing is pushed. +# If you do bump locally anyway, push the commit AND the tag (commitizen creates +# lightweight tags, which `git push --follow-tags` does not push): +# git push && git push --tags +# The pushed v* tag deploys through release-deploy.yaml's tag trigger (manual fallback). +# No quotes or shell operators in the message: commitizen runs hooks through the +# platform shell, and on Windows cmd.exe a && inside quotes would still split the command. post_bump_hooks = [ - "git push", - "git push --tags", + "echo Bumped locally - nothing was pushed. Releases normally come from the Release workflow on GitHub.", ] [tool.poetry.requires-plugins]