---
name: ci-release
description: >
  Release a Pysae application: optionally offer a pre-release review (delegates to /code-review-pre-release),
  resolve the next semver via /code-get-next-version, create (or reuse) a `release/vX.Y.Z` branch,
  generate the user-facing release notes on that branch, trigger the release pipeline on it,
  locate the tag pipeline, hand off to /ci-deploy immediately (GitLab gates deploy_prod on its own needs:),
  monitor rollout health on Kubernetes and Datadog, then back-merge the release branch into the base branch.
  Supports support/vX.Y.x releases (backport / maintenance of an older, long-lived line): the release is cut from
  and back-merged into the support branch, the version is clamped to the line's range, and deploy is mobile/stores-only (never web).
  Use when the user says /ci-release, /release, 'release', 'fais une release', 'nouvelle version', 'bump version',
  'release en prod', 'release support', 'backport sur v5.2', 'maintiens une ancienne version', or wants to cut a new version.
argument-hint: "<optional: app name> <optional: major|minor|patch (default: auto from changelogs)> <optional: version override e.g. v1.2.3 or a prerelease v6.0.0-beta.1> [--prerelease <label> e.g. beta/rc to cut/iterate a prerelease line — finalized later by a normal release] [--hotfix to reuse the current/named release/vX.Y.Z branch instead of creating a fresh one off the default branch] [--support <vX.Y.x|vX.x.x> (or run from a support/* branch) to release a backport on an older, long-lived line — mobile/stores-only, back-merged into the support branch] [--skip-review to bypass the pre-release review prompt] [--skip-release-notes to bypass the docs/release-notes/ generation (FR/EN/IT)] [--slack-channel <id> --slack-status-ts <ts> to keep updating an existing Slack status line] [natural-language scoping like 'release with only #A #B' or 'release everything except #C' triggers a partial release — built off the last tag by fast-forward + cherry-pick]"
allowed-tools:
  - Read
  - Glob
  - Grep
  - Bash
  - Skill
  - AskUserQuestion
  - ExitWorktree
  - mcp__kubernetes__kubectl_get
  - mcp__kubernetes__kubectl_describe
  - mcp__datadog__get_logs
  - mcp__datadog__list_monitors
---

Release a Pysae application: determine the next semver version, **create (or reuse) a `release/vX.Y.Z` branch**, commit the user-facing release notes on it, trigger the release pipeline **on that branch** (never directly on the default branch), locate the tag pipeline, hand off to `/ci-deploy` immediately (deploy_prod is gated by GitLab's `needs:` graph, no need to wait for the full tag pipeline), monitor the rollout, then **back-merge the release branch into the default branch** once the deploy is validated.

## Required tools

- `glab`
- `kubectl`
- `datadog-mcp`
- `jq`

Run `pysae-ai-tools tools require <tools>` before starting; if it exits non-zero, abort and report the install command from stderr to the user.

## Project configuration

Honour `.pysae-ai-tools.yaml` (this repo's `CLAUDE.md` is NOT in your context at runtime). Check flags with `pysae-ai-tools project flag <path>` (exit 0 = on, 1 = off — no `jq`):

- `release.gitlab_release` — when `pysae-ai-tools project flag release.gitlab_release` exits non-zero, **skip creating the GitLab Release object** on the tag (still tag + deploy).
- `release.allow_prerelease` — when off, **reject** a prerelease version (e.g. `v6.0.0-beta`); the release must be a final semver. `code-get-next-version` and `ci release run` both enforce this, so a prerelease attempt on a non-opted-in repo fails early.
- Non-boolean values (`release.tag_prefix`, `release.tracks`, `release.stores`) — read with `pysae-ai-tools project show --json` and apply: tag prefix for the version, tracks/stores for what to deploy/publish. (`code-get-next-version` already honours `tag_prefix` / `allow_prerelease`.)
- `release.prompt` — custom pre-release instructions run at Step 5b (empty ⇒ no-op). **Read it with `pysae-ai-tools project show --json release.prompt`** (narrow `project show` to the key) — the value may be an `overlay:` reference (file/URL) or come from the `.pysae-ai-tools/` overlay directory, and `project show` returns it fully resolved.

## Mode detection

```bash
CTX=$(pysae-ai-tools internal detect-context)
IS_CI=$(echo "$CTX" | jq -r '.is_ci')
PROJECT_ID=$(echo "$CTX" | jq -r '.project_id // empty')
PROJECT_PATH=$(echo "$CTX" | jq -r '.project_path // empty')
```

CI mode: never use `AskUserQuestion`, skip confirmations, fail with exit code 1 on missing args.

### Which line am I releasing? (decide this first)

The deciding question is **which version line ships**, never the word « hotfix » on its own:

- **The current line** (the latest released version / the default branch) → a normal or **hotfix** release on an **ephemeral** `release/vX.Y.Z` branch, **deleted after back-merge** (Step 10). This is the **default** — a plain « fais un hotfix » lands here, on `release/*`, not on `support/*`.
- **An older line still maintained in parallel** while `main` has moved on → a **support** release on a **long-lived** `support/vX[.Y].x` branch that is never deleted and back-merges onto itself, never `main`.

Switch to support mode **only** when the user explicitly targets such an older line — names a `support/*` branch, passes `--support`, says « backport » / « maintiens la v5.2 », or runs from a `support/*` branch. When in doubt, it is a `release/*` hotfix.

### Hotfix mode detection

Every `/ci-release` ships through a `release/vX.Y.Z` branch — the difference between « normal » and « hotfix » is **only the source the release branch is built from** :

- Normal mode → Step 5 creates a fresh `release/${NEW_VERSION}` off the default branch tip.
- Hotfix mode → Step 5 reuses an existing `release/vX.Y.Z` branch already populated by `/code-implement --hotfix` (the version is encoded in the branch name).

Detect hotfix mode **before resolving the version**, because it changes the version source (parsed from the branch name, not auto-bumped from changelogs). A release is a hotfix when **any** of the following is true:

- `$ARGUMENTS` contains `--hotfix`.
- `$ARGUMENTS` contains a positional `release/vX.Y.Z` branch reference.
- The current local branch matches `release/v?\d+\.\d+\.\d+` and the user invoked `/ci-release` without an explicit positional version — treat as an implicit hotfix on that branch.

```bash
HOTFIX_MODE=false
HOTFIX_RELEASE_BRANCH=""
# Explicit branch in args
HOTFIX_RELEASE_BRANCH=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*\(release/v\?[0-9]\+\.[0-9]\+\.[0-9]\+\).*@\1@p')
if [ -n "$HOTFIX_RELEASE_BRANCH" ]; then HOTFIX_MODE=true; fi
case " $ARGUMENTS " in *" --hotfix "*) HOTFIX_MODE=true ;; esac
# Implicit from current branch (only when no positional version is given)
if ! $HOTFIX_MODE; then
  CUR=$(git branch --show-current 2>/dev/null || true)
  if printf '%s' "$CUR" | grep -Eq '^release/v?[0-9]+\.[0-9]+\.[0-9]+$' \
     && ! printf '%s\n' "$ARGUMENTS" | grep -Eq 'v[0-9]+\.[0-9]+\.[0-9]+'; then
    HOTFIX_MODE=true
    HOTFIX_RELEASE_BRANCH="$CUR"
  fi
fi
```

When `HOTFIX_MODE=true`:
- Derive `NEW_VERSION` directly from the branch name (`HOTFIX_RELEASE_BRANCH` → strip the `release/` prefix). The version is **already arrested** by the branch — Step 3 (pre-release review) and Step 4 (next-version delegation) are skipped.
- Step 5 (release branch resolution) **reuses** `HOTFIX_RELEASE_BRANCH` instead of creating a fresh branch from the default branch — set `RELEASE_BRANCH="${HOTFIX_RELEASE_BRANCH}"`. The `release.auto.yml` template tags `release/*` branches the same way it tags the default branch, so Step 7 (pipeline) and onwards flow unchanged.
- Step 6 (release notes) runs **like a normal release** — every release ships user-facing release notes, hotfixes included. (`--skip-release-notes` is still honored only when the caller already generated them, e.g. `/code-review-pre-release`.)

### Partial / selective release detection

A **partial release is a normal release** — same version resolution, release notes (Step 6), pre-tag verification, deploy and back-merge — **except the release branch carries only the selected commits, not all of `main`**. The user wants to **include** a small set of tickets/features or **exclude** a small set; the rest of `main` is left out. (Distinct from « hotfix mode » above, which reuses a pre-built branch — here we *build* the branch by selecting commits off the last prod tag.)

Detect it from `$ARGUMENTS` / the conversation. Signals (FR/EN): « release avec seulement #A #B », « release uniquement le fix du cache », « release tout sauf la feature X », « release sans #C », « partial release with only… », « exclude … from the release ». Resolve into a single **wanted set** of commits:

- `PARTIAL_MODE=true|false`.
- Whether the user phrased it as *include* (« only #A #B ») or *exclude* (« everything except #C ») — both just resolve to the same thing: **which commits since the last tag we keep**.
- The scope as **tickets** (IIDs) and/or **features** (prose → map to their ticket(s)/MR(s) below).

**One strategy only: branch off the last prod tag and cherry-pick — never revert.** Reverting on the release branch would later, on the Step 10 back-merge, undo those features on `main`; cherry-picking never does. Because every commit carries its own `changelogs/<…>.md`, the resulting branch already holds exactly the right changelog entries — Step 6/7 generate the correct `CHANGELOG.md` section by construction, nothing to filter.

**Minimise the work — fast-forward first, cherry-pick only the rest.** List the commits on `main` since the last tag in order and mark each wanted/unwanted. Then:

- If **every** commit is wanted → it's a normal full release (FF to the default-branch tip).
- Otherwise, **fast-forward the release branch to the longest all-wanted prefix** (the commit just before the first unwanted one) — that's a plain branch point, zero cherry-picks. Then **cherry-pick only the wanted commits that come after that first gap**, in order.
- So « exclude the last 2 commits » needs **no cherry-pick at all** — just branch at `HEAD~2`. « exclude one middle commit » → FF up to it, cherry-pick the few after it.

**Map a ticket/feature → commits** with the same precedence the board tooling uses: resolve the ticket's MR via GitLab's **`closed_by`** (`glab api "projects/${PROJECT_ID}/issues/<IID>/closed_by"`), then take that MR's commit(s) on the base branch **since the last tag**. For a feature given in prose, identify its ticket(s)/MR(s) from the conversation first. Inspect how the project merges (squash → one commit per MR; merge-commit/FF → the MR's commits) via `git log <last_tag>..origin/${BASE_BRANCH}` and the MR's `merge_commit_sha`/commits, so you select exactly the right SHAs. When a mapping is ambiguous, **list the candidate commits and ask the user** (local mode) rather than guessing.

The version is resolved normally (Step 4) — a partial release is usually a **patch** bump from the last tag; the user may override.

### Prerelease mode detection

A **prerelease is a normal release** — same release branch, release notes, pre-tag verification, **full prod deploy** and back-merge — that ships an intermediate `vX.Y.Z-<label>.N` version (`v6.0.0-beta.1`, then `beta.2`, …) before the final `vX.Y.Z`. The point: validate `v6.0.0` in production through one or more betas, then finalize.

Requires `release.allow_prerelease: true` in the repo's `.pysae-ai-tools.yaml` (the CLI rejects it otherwise — see Project configuration).

> ⚠️ **A hotfix is NOT a prerelease here.** A `-hotfix.N` or bare `-N` suffix (e.g. `v5.3.16-1`) is a **store-bound** post-release rebuild of the same `X.Y.Z` (it mirrors the Android versionCode scheme — stays at the final stage, outranks the bare release, and is published to the stores). So a hotfix is treated like a **final release**, not a beta: it ships the full store release notes (Step 6) and offers store publication (Step 9). Detect it and keep `IS_PRERELEASE=false`.

Detect it from `$ARGUMENTS`:

```bash
PRERELEASE_LABEL=""
# Explicit --prerelease <label>
PRERELEASE_LABEL=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*--prerelease[ =]\([0-9A-Za-z]\+\).*@\1@p')
# Or an explicit prerelease version override (v6.0.0-beta.1)
PRERELEASE_VERSION=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*\(v[0-9]\+\.[0-9]\+\.[0-9]\+-[0-9A-Za-z.-]\+\).*@\1@p')
# A hotfix suffix (-hotfix.N or bare -N) is store-bound — NOT a beta prerelease.
HOTFIX_VERSION=$(printf '%s\n' "$ARGUMENTS" | sed -n -E 's@.*(v[0-9]+\.[0-9]+\.[0-9]+-(hotfix(\.[0-9]+)?|[0-9]+))( .*)?$@\1@p')
IS_PRERELEASE=false
[ -n "$PRERELEASE_LABEL" ] && IS_PRERELEASE=true
[ -n "$PRERELEASE_VERSION" ] && IS_PRERELEASE=true
# A hotfix version overrides the beta detection above: it is store-bound, not a beta.
[ -n "$HOTFIX_VERSION" ] && IS_PRERELEASE=false
```

When `IS_PRERELEASE=false` because of a hotfix suffix, the release proceeds **exactly like a final release**: Step 6 generates every variant (markdown **and** the store `.google-play.txt` / `.apple-app-store.txt`), and Step 9 offers store publication. The CLI backs this up — `code release-notes` / `ci release verify` require the store variants for a hotfix tag, `next-version` reports `is_hotfix: "true"` and bypasses the `allow_prerelease` gate, and `/ci-deploy` offers the store level. **No changelog/release-notes coalescing applies to a hotfix** (each gets its own fresh section).

What changes when `IS_PRERELEASE=true` (a beta — `-alpha.N` / `-beta.N` / `-rc.N`, never a hotfix):
- **Step 4** forwards `--prerelease <label>` (or the explicit prerelease `--version`) to `/code-get-next-version`, which computes the next prerelease (`v6.0.0-beta.1` → `v6.0.0-beta.2`) or, once you release **without** `--prerelease` on a prerelease line, **finalizes** it (`v6.0.0-beta.2` → `v6.0.0`). Coalescing of the changelog and release notes into a single section per version line is handled by the CLI — nothing to do by hand.
- **Step 6** release notes are **markdown-only** for a beta (no store `.google-play.txt` / `.apple-app-store.txt` — those are generated only at the final release). `/code-release-notes` enforces this automatically from the tag.
- **Step 9** deploys to prod like a normal release, but **skips any store publication** (Google Play / Apple App Store) — betas ship only the webapps. Pass the prerelease nature to `/ci-deploy` so it does not offer store publishing.

The final `vX.Y.Z` is just a normal `/ci-release` run (no `--prerelease`) on top of the prerelease line: it finalizes the version and, at that point, generates the store release notes and offers store publication as usual.

### Support release mode detection

A **support release** ships a backport on an **older, long-lived version line** (a durable `support/vX[.Y].x` branch that is never deleted, unlike the ephemeral `release/*` branches) while the main line has moved on. It is **not a separate flow** — it's a normal release where two things change: the **base branch** (`BASE_BRANCH` — where the release branch is cut from *and* where it back-merges) becomes the support branch instead of the default branch, and the version is **constrained to the range** the support branch name allows. The hotfix / partial / complete sub-modes above all still apply, but relative to `BASE_BRANCH`.

Support mode is **mobile-only by design**: a support line only makes sense where several versions coexist in the wild — the **stores / APK** (multi-channel). The **web is a single live deployment**, so a support release **never deploys the web** (pushing an old version would downgrade prod). See Step 9.

Detect support mode from `$ARGUMENTS` or the current branch:

```bash
SUPPORT_MODE=false
SUPPORT_BRANCH=""
SUPPORT_LINE=""
# Explicit --support <line> (e.g. v5.2.x) or a support/... ref in args
SUPPORT_LINE=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*--support[ =]\(v\?[0-9][0-9.xX]*\).*@\1@p')
SUPPORT_BRANCH=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*\(support/v\?[0-9][0-9.xX]*\).*@\1@p')
# Or the current branch is itself a support line
if [ -z "$SUPPORT_BRANCH" ]; then
  CUR=$(git branch --show-current 2>/dev/null || true)
  case "$CUR" in support/*) SUPPORT_BRANCH="$CUR" ;; esac
fi
if [ -n "$SUPPORT_BRANCH" ] || [ -n "$SUPPORT_LINE" ]; then
  SUPPORT_MODE=true
  # Normalise: branch → line (strip support/) and line → branch (prefix support/)
  [ -z "$SUPPORT_LINE" ] && SUPPORT_LINE="${SUPPORT_BRANCH#support/}"
  [ -z "$SUPPORT_BRANCH" ] && SUPPORT_BRANCH="support/${SUPPORT_LINE}"
fi
```

When `SUPPORT_MODE=true`:

- **Gating — refuse on a project with no mobile track.** A support release has no deploy target on a web/service-only project (e.g. `pysae/api`). Read `release.tracks` and stop early if none is mobile:
  ```bash
  TRACKS=$(pysae-ai-tools project show release.tracks)  # scalar list → one track per line
  echo "$TRACKS" | grep -Eq '^(android|android-enterprise|apple)$' || {
    echo "✗ Les support releases n'ont de sens que pour les apps mobiles multi-canal ; ce projet n'a pas de track mobile (release.tracks=$TRACKS)."
    exit 1
  }
  ```
- Set `BASE_BRANCH="${SUPPORT_BRANCH}"` (Step 1 below otherwise defaults it to `DEFAULT_BRANCH`). Everywhere Step 5 cuts the release branch and Step 10 back-merges, they use `BASE_BRANCH`.
- Verify the support branch exists on origin (`git ls-remote --exit-code --heads origin "${SUPPORT_BRANCH}"`); if not, stop with a clear message.
- Step 4 forwards `--support-line ${SUPPORT_LINE}` **and** `--ref ${SUPPORT_BRANCH}` to `/code-get-next-version` so the version is clamped to the range and the bump is read from the support line's changelogs, not `main`.
- Step 9 deploys **mobile-only** (filter `web`/`service` out of the tracks) — never the web.
- **Step 10 back-merges into the support branch only — never into `main`.** A fix on the v5 line stays on v5; if it also applies to `main`, that's a separate manual forward-port (out of scope).

Support mode composes with the sub-modes: **complete** (cut the release branch off the support branch tip — the default), **hotfix** (reuse a `release/vX.Y.Z` branch built off a support-line tag), or **partial** (cut off the highest in-range tag + cherry-pick). In every case `BASE_BRANCH` is the support branch.

### Optional Slack status coordinates

Parse `--slack-channel <id>` and `--slack-status-ts <ts>` from `$ARGUMENTS` and stash them in `SLACK_CHANNEL` / `SLACK_STATUS_TS`. When **both** are present, `/code-review-pre-release` has handed off an existing Slack header message it wants this skill to keep refreshing as the release progresses (see [../code-review-pre-release/SKILL.md § Status lifecycle](../code-review-pre-release/SKILL.md#status-lifecycle)).

Define a shell helper once, at the start of the run, and call it at every transition below:

```bash
slack_status() {
  [ -z "${SLACK_CHANNEL:-}" ] && return 0
  [ -z "${SLACK_STATUS_TS:-}" ] && return 0
  pysae-ai-tools slack set-status-line \
    --channel "$SLACK_CHANNEL" \
    --ts "$SLACK_STATUS_TS" \
    --status "$1" \
    > /dev/null 2>&1 || true
}
```

The helper is a no-op when the coordinates are missing (direct user invocation, no Slack thread to update). Failures are swallowed — a stale status line must never block the actual release.

**Transition matrix** — call `slack_status` with the right value at each of these points:

| When | Status |
|------|--------|
| Step 7 — release pipeline failed (before `/ci-debug`) | `:x: échec du déploiement` |
| Step 8 — no tag pipeline within 30s | `:x: échec du déploiement` |
| Step 9 — just before delegating to `/ci-deploy` | `:rocket: validé, déploiement en cours` |
| Step 9 — `/ci-deploy` failed | `:x: échec du déploiement` |
| Step 9 — rollout completed, no errors | `:white_check_mark: validé, déployé en production` |
| Step 9 — rollout had critical errors (rollback proposed) | `:x: échec du déploiement` |

Every exit-on-error path inside Steps 7/8/9 must call `slack_status ":x: …"` **before** stopping or delegating to `/ci-debug`, so the Slack thread never stays frozen on `:arrows_counterclockwise: validé, création de la release en cours …`.

## Step 1 — Target repository

Resolve the target application (**default**: `pysae/api`, or the current repo). List repos with `pysae-ai-tools project list`; read a repo's config with `pysae-ai-tools project show --project pysae/<repo> --json`.

If targeting a different repo than the current directory, resolve `PROJECT_ID` via `glab api "projects/<url-encoded-path>"`.

Read the project's default branch — never assume `main`, it might be `master`, `develop`, or anything else:

```bash
DEFAULT_BRANCH=$(glab api "projects/${PROJECT_ID}" | jq -r '.default_branch')
```

**Resolve `BASE_BRANCH`** — the branch the release is cut from (Step 5) and back-merged into (Step 10). On a normal release it is the default branch; on a **support release** it is the support branch (see [Support release mode detection](#support-release-mode-detection)):

```bash
BASE_BRANCH="${DEFAULT_BRANCH}"
$SUPPORT_MODE && BASE_BRANCH="${SUPPORT_BRANCH}"
```

From here on, Steps 5 and 10 target `BASE_BRANCH`. `DEFAULT_BRANCH` is still used only where it genuinely means the main line (the Step 2 release-support check).

## Step 2 — Check release support

```bash
CI_YAML=$(glab api "projects/${PROJECT_ID}/repository/files/.gitlab-ci.yml/raw?ref=${DEFAULT_BRANCH}" 2>/dev/null)
```

Check if the YAML includes `release.auto.yml` (from `pysae/infra/gitlab-templates`). If not found, stop — the project does not support automated releases.

## Step 3 — Offer a pre-release review (local, direct invocation only)

**Skip this step entirely if any of the following is true** — these conditions mean we either can't ask, or we're already downstream of a review:

- `IS_CI == "true"` — CI never prompts.
- `$ARGUMENTS` contains `--skip-review` — explicit opt-out. `/code-review-pre-release` always passes this flag when re-invoking `/ci-release`, so this is the primary anti-loop guard. Users can also pass it manually to bypass the prompt on a direct invocation.
- `$ARGUMENTS` contains a positional `vX.Y.Z` — version is pre-arrested; treat as a strong implicit opt-out (the user already decided which version to ship, so the review prompt would be noise).
- `HOTFIX_MODE=true` — the hotfix MR has already gone through `/code-review` (it's how the fix landed on the `release/vX.Y.Z` branch). A pre-release review on top would just re-audit a tiny, already-reviewed diff.

Otherwise, ask the user once with `AskUserQuestion`:

> Lancer une review pre-release avant de releaser `<app>` ?
>
> - **Oui** *(Recommended)* — délègue à `/code-review-pre-release <app>` qui auditera le diff, créera les tickets éventuels, puis rappellera `/ci-release` avec la version arrêtée. **Ce `/ci-release` actuel s'arrête ici** — `/code-review-pre-release` orchestre la suite.
> - **Non** — continue le flow normal (Step 4 → version → release).

If the user picks **Oui**, delegate:

```
/code-review-pre-release <app>
```

**STOP this `/ci-release` run immediately after the delegation returns. Do NOT continue to Step 4 or any other downstream step (5 → 10) — even if the delegated skill reports success.** The delegated `/code-review-pre-release` owns the entire end-to-end flow from here:
1. If the user fixes tickets first or cancels → no release happens at all (correct outcome — the current `/ci-release` invocation simply ends).
2. If the user chooses to release → `/code-review-pre-release` re-invokes `/ci-release <app> <NEW_VERSION> --skip-review --skip-release-notes` itself, which is a **separate** run that skips Step 3 (both `--skip-review` and the pre-arrested version guard it) and Step 6 (`--skip-release-notes` — the upstream skill already committed the per-language files under `docs/release-notes/` on the release branch), then executes the full release.

Continuing to Step 4 here would double-release: the current run releasing from auto-detected state, then the nested `/code-review-pre-release` → `/ci-release` releasing again. The current run must terminate after the delegation, full stop.

If the user picks **Non**, fall through to Step 4.

## Step 4 — Determine the next version

**Hotfix path** — if `HOTFIX_MODE=true`, the version is encoded in `HOTFIX_RELEASE_BRANCH` (e.g. `release/v2.3.2` → `NEW_VERSION=v2.3.2`). Set `NEW_VERSION` from the branch name and skip the delegation. Verify that the tag does not already exist (`glab api "projects/${PROJECT_ID}/repository/tags/${NEW_VERSION}"` returns 404) — if it does, stop: the hotfix has already been released.

**Pre-arrested version path** — if `$ARGUMENTS` contains an explicit `vX.Y.Z` (positional), treat it as **already validated by an upstream caller** (typically `/code-review-pre-release` after its own changelog confirmation). Set `NEW_VERSION` to that value and skip the delegation. **Do not re-prompt the user.**

**Default path** — delegate to `/code-get-next-version` with the resolved app name (forward `--bump` / `--version` / `--prerelease` if present in `$ARGUMENTS`):

```
/code-get-next-version <app> [--bump …] [--version …] [--prerelease <label>]
```

**Support path** — when `SUPPORT_MODE=true`, additionally forward `--support-line ${SUPPORT_LINE}` **and** `--ref ${SUPPORT_BRANCH}` so the version is clamped to the support range and the bump is read from the support line's changelogs (not `main`):

```
/code-get-next-version <app> --support-line ${SUPPORT_LINE} --ref ${SUPPORT_BRANCH} [--bump …]
```

Read `clamped_from` from the `NEXT_VERSION_RESULT` block: when non-empty, the requested bump was clamped down to the range — mention it in the final report so the semver narrowing is visible.

For a prerelease, forward `--prerelease ${PRERELEASE_LABEL}` (or the explicit `--version ${PRERELEASE_VERSION}`). To **finalize** a prerelease line, call it with neither — `/code-get-next-version` sees the latest tag is `v6.0.0-beta.N` and returns `v6.0.0`. Set `IS_PRERELEASE=true` only when the resolved `NEW_VERSION` carries a **beta** suffix (`-alpha.N` / `-beta.N` / `-rc.N`) — it drives the store-skipping at Steps 6/9. A **hotfix** suffix (`-hotfix.N` / `-N`) keeps `IS_PRERELEASE=false` (store-bound, see the detection note above); `next-version` returns it with `is_hotfix: "true"`.

The delegated skill ends its response with a `NEXT_VERSION_RESULT:` JSON block followed by a handoff marker (contract: see [../code-get-next-version/SKILL.md § Step 5](../code-get-next-version/SKILL.md)). Read that block from the delegated skill's response and extract `new_version` + `cancelled` — set `NEW_VERSION` and `CANCELLED` from those fields. If you want a shell-extracted form (e.g. to pipe into another command), capture the delegated skill's stdout to a `RESULT` variable first:

```bash
NEW_VERSION=$(echo "$RESULT" | sed -n '/^NEXT_VERSION_RESULT:/,/^}/p' \
  | sed '1d' | jq -r '.new_version')
CANCELLED=$(echo "$RESULT" | sed -n '/^NEXT_VERSION_RESULT:/,/^}/p' \
  | sed '1d' | jq -r '.cancelled')
```

**Do NOT stop after parsing the JSON block.** The `NEXT_VERSION_RESULT:` block is *not* a terminal output — it's an intermediate value handed back to this skill. Reading it means « j'ai la version, je continue » : the very next thing you do is move on to Step 5 (create the release branch). Only `CANCELLED == "true"` is a stop signal — in that case, abort the release. In every other case, **continue immediately**: do not summarise, do not wait for user confirmation, do not treat the delegation as the end of `/ci-release`.

## Step 5 — Create or resolve the release branch

Every release ships through a `release/${NEW_VERSION}` branch. The branch is the working area where the release notes (Step 6) are committed and the release pipeline (Step 7) runs — it stays decoupled from the default branch until the production deploy is validated, at which point Step 10 back-merges it.

### Isolation mode — applies to **every** mode below (partial / hotfix / normal)

Before creating or checking out the release branch, choose **worktree vs simple branch** on the **same IDE criterion as the MR flow** (see [../code-implement/SKILL.md § Step 4](../code-implement/SKILL.md)). There is no explicit-user-request branch here — the choice is purely mechanical and is made by `/git-worktree` itself:

1. **IDE connecté** (`mcp__ide__getDiagnostics` disponible) → **branche simple** dans le répertoire courant (un worktree désynchronise l'IDE).
2. **Pas d'IDE** (terminal / headless / CI) → **worktree** via `/git-worktree`.

Délègue la détection à `/git-worktree` (il sonde l'IDE et skippe en CI tout seul) en lui passant le nom `release/${NEW_VERSION}` **et la base appropriée au mode** :

- **normal / complete** → base `origin/${BASE_BRANCH}` (tip — the default branch, or the support branch in support mode).
- **partial** → base `${FF_TARGET:-$LAST_TAG}` (calculée plus bas, avant la délégation ; `LAST_TAG` est le plus haut tag **en plage** en mode support).
- **hotfix** → base `origin/${RELEASE_BRANCH}` (branche déjà sur origin).

`/git-worktree` crée+pousse la branche (ou la checkout si elle existe déjà sur origin) et bascule la session dans le worktree. Note le résultat dans `RELEASE_WORKTREE` (`true` si un worktree a réellement été créé) — Step 10 en a besoin pour sortir du worktree avant le back-merge. Si `/git-worktree` refuse (IDE détecté en fail-safe, ou mode CI), il reste sur la branche courante : retombe sur les commandes **branche simple** du mode concerné et garde `RELEASE_WORKTREE=false`. Les commandes git de chaque mode ci-dessous s'exécutent **dans l'isolation choisie**.

**Partial release** — `PARTIAL_MODE=true` (see [Partial / selective release detection](#partial--selective-release-detection)) :

Build `release/${NEW_VERSION}` off the **last prod tag** and bring over only the wanted commits — **fast-forward the all-wanted prefix, cherry-pick the rest, never revert**.

```bash
# Highest final tag. In support mode, restrict to the support range so the partial
# release builds off the support line's last tag, not a later major on main.
TAG_GREP='^v?[0-9]+\.[0-9]+\.[0-9]+$'
$SUPPORT_MODE && TAG_GREP="^v?$(printf '%s' "${SUPPORT_LINE#v}" | sed -E 's/\.?[xX]//g' | sed 's/\./\\./g')\\."
LAST_TAG=$(git tag --list --sort=-version:refname | grep -E "$TAG_GREP" | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
git fetch origin "${BASE_BRANCH}" --tags
RELEASE_BRANCH="release/${NEW_VERSION}"

# Ordered commits on the base branch since the tag, oldest→newest:
#   git log --reverse --format='%H %s' "${LAST_TAG}..origin/${BASE_BRANCH}"
# Mark each wanted/unwanted from the resolved scope (ticket→commits via closed_by).
# FF_TARGET = the last commit of the longest leading run of *wanted* commits
#             (= just before the first unwanted one). CHERRY = the wanted commits
#             that come after FF_TARGET, in order.

# Once FF_TARGET / CHERRY are known, apply the shared **Isolation mode** (base = ${FF_TARGET:-$LAST_TAG}):
#   - no IDE → delegate to /git-worktree (name release/${NEW_VERSION}, base ${FF_TARGET:-$LAST_TAG}),
#     then cherry-pick inside the worktree.
#   - IDE / CI fallback → the simple-branch commands below, in the current dir.
git branch "${RELEASE_BRANCH}" "${FF_TARGET:-$LAST_TAG}"   # FF to the wanted prefix (or the tag if the first commit is unwanted)
git checkout "${RELEASE_BRANCH}"
for sha in ${CHERRY}; do
  git cherry-pick "$sha" || { git cherry-pick --abort; echo "✗ cherry-pick conflict on $sha"; exit 1; }
done
git push -u origin "${RELEASE_BRANCH}"
```

- If **every** commit since the tag is wanted → not partial: fall through to **Normal mode** (FF to the default-branch tip).
- « exclude the last N commits » → `FF_TARGET=HEAD~N`, `CHERRY` empty → **no cherry-pick**, just a branch point.
- On a cherry-pick **conflict** that you can't resolve cleanly, abort, keep nothing half-applied, and stop with a clear message — ask the user to curate the set or resolve manually (do not revert, do not force).
- The branch's `changelogs/` now holds exactly the embedded entries (each commit carried its own), so Steps 6–7 produce the right `CHANGELOG.md` by construction. **Step 10 needs a partial-specific back-merge** — see its note.
- After the cherry-picks, run **[Carry the release-driving infrastructure](#carry-the-release-driving-infrastructure--built-off-tag-modes-only)** below — the branch was built off an old tag and may be missing the config/CI that drives the release itself.

**Hotfix mode** — `HOTFIX_MODE=true` :

The branch already exists on origin (created by `/code-implement --hotfix` and populated by its merged MR). Reuse it as-is :

```bash
RELEASE_BRANCH="${HOTFIX_RELEASE_BRANCH}"
git fetch origin "${RELEASE_BRANCH}"
# Sanity check: branch exists on origin
git rev-parse --verify "refs/remotes/origin/${RELEASE_BRANCH}" >/dev/null \
  || { echo "✗ Hotfix release branch ${RELEASE_BRANCH} not found on origin"; exit 1; }
```

Then **check it out via the shared Isolation mode** (base = `origin/${RELEASE_BRANCH}`): no IDE → `/git-worktree` (name `${RELEASE_BRANCH}`, which checks out the existing remote branch into a worktree); IDE / CI fallback → `git checkout "${RELEASE_BRANCH}"` in the current dir. Set `RELEASE_WORKTREE` accordingly.

Don't push the hotfix *code* to it from this skill — those commits already landed via MR review. The only additions this skill makes to the branch are the user-facing release notes (Step 6 runs for hotfixes too) and, at the very end, the technical `CHANGELOG.md` commit produced by the release pipeline itself.

After checking it out, run **[Carry the release-driving infrastructure](#carry-the-release-driving-infrastructure--built-off-tag-modes-only)** below — like partial mode, a hotfix branch sits on an old tag and may predate config/CI changes that landed on the default branch since.

### Carry the release-driving infrastructure — built-off-tag modes only

**Applies to partial and hotfix modes; skip in normal mode** (a normal release branches off the default-branch tip, so it already carries the latest infra).

A release branch built off an **old tag** (partial: off the last prod tag; hotfix: off the tag the fix branched from) only contains the cherry-picked feature/fix commits. Any **infrastructure that drives the release itself** — added or updated on the default branch *since that tag* — is therefore **absent from the branch**. Two files matter, and both are read at the release ref, not at the default-branch tip:

- `.pysae-ai-tools.yaml` — the `release` job's `#mep` announce (`release-status --root`) resolves the channel id (`slack.mep_channel_id`) from this file **at the release ref**. If it is missing, the announce falls back to the bare channel name and Slack answers `channel_not_found` → **the #mep release message is never posted** (this is the exact failure that produced empty Screen/Editor announces while op/api — which carried the file at their tag — posted fine).
- `.gitlab-ci.yml` — carries the in-job `release-status` lifecycle (announce → building → awaiting-deploy → deployed). A stale copy posts nothing or the wrong sequence.

So, **before Step 7 triggers the pipeline**, bring any stale/missing infra file up to the **base-branch** state (`BASE_BRANCH` — the default branch normally, the support branch in support mode; in support mode we deliberately carry the support line's infra, **not** main's, which may reference the next major) by **cherry-picking the commits that touched it since the branch point** — they are isolated infra commits and apply cleanly. On conflict (rare — these commits don't touch feature code), fall back to taking just that file from the base branch:

```bash
git fetch origin "${BASE_BRANCH}"
MB=$(git merge-base HEAD "origin/${BASE_BRANCH}")   # the tag the branch was built off
for path in .pysae-ai-tools.yaml .gitlab-ci.yml; do
  # Already identical to the base branch (or absent there too)? nothing to carry.
  git diff --quiet HEAD "origin/${BASE_BRANCH}" -- "$path" && continue
  # Base-branch commits that touched this path since the branch point, oldest→newest.
  for sha in $(git log --reverse --format='%H' "${MB}..origin/${BASE_BRANCH}" -- "$path"); do
    if ! git cherry-pick -x "$sha"; then
      git cherry-pick --abort
      git checkout "origin/${BASE_BRANCH}" -- "$path"   # fallback: just the file
      git commit -m "chore(release): sync ${path} from ${BASE_BRANCH}"
      break
    fi
  done
done
git push origin "${RELEASE_BRANCH}"
```

These infra commits travel back to the base branch harmlessly at Step 10 (they are already there, so the back-merge recognises them as present — same property the partial-mode cherry-picks rely on).

**Normal / complete mode** — `HOTFIX_MODE=false` (covers both a normal release and a **support complete** release — the only difference is `BASE_BRANCH`) :

Create `release/${NEW_VERSION}` off the **tip of `BASE_BRANCH`** on origin (the default branch normally, the support branch in support mode), then push it. The branch starts as a 1:1 copy of `BASE_BRANCH` — Step 6 will add the user-facing release notes commit on top of it before Step 7 triggers the pipeline.

First, the **idempotency / divergence guard** (read-only — run it whichever isolation mode you pick below) :

```bash
RELEASE_BRANCH="release/${NEW_VERSION}"
git fetch origin "${BASE_BRANCH}"

# Idempotency: if the branch already exists on origin (e.g. the user re-runs /ci-release
# after a prior failure), reuse it rather than crash. Verify it sits at — or ahead of —
# the base-branch tip; if it has diverged, stop and ask the user to investigate manually.
BRANCH_EXISTS_ON_ORIGIN=false
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
  BRANCH_EXISTS_ON_ORIGIN=true
  git fetch origin "${RELEASE_BRANCH}"
  BASE_SHA=$(git rev-parse "origin/${BASE_BRANCH}")
  REMOTE_SHA=$(git rev-parse "origin/${RELEASE_BRANCH}")
  # Allow the case where the branch is ahead of (or equal to) the base branch by a
  # release-notes commit from a previous interrupted run. Reject only on divergence.
  if [ "$REMOTE_SHA" != "$BASE_SHA" ] \
     && ! git merge-base --is-ancestor "$BASE_SHA" "$REMOTE_SHA"; then
    slack_status ":x: échec du déploiement"
    echo "✗ ${RELEASE_BRANCH} has diverged from ${BASE_BRANCH} — resolve manually"
    exit 1
  fi
fi
```

Then apply the shared **[Isolation mode](#isolation-mode--applies-to-every-mode-below-partial--hotfix--normal)** with base `origin/${BASE_BRANCH}` :

**Worktree** (`RELEASE_WORKTREE=true`) — déléguer à `/git-worktree` avec le nom `release/${NEW_VERSION}` (base `origin/${BASE_BRANCH}`). Le skill crée la branche et la pousse si elle est nouvelle, ou la checkout si elle existe déjà sur origin (cas `BRANCH_EXISTS_ON_ORIGIN=true`), et bascule la session dans le worktree. Ne pas créer la branche manuellement avant.

**Branche simple** (`RELEASE_WORKTREE=false`, fallback IDE / CI) — créer/checkout la branche dans le répertoire courant :

```bash
if ! $BRANCH_EXISTS_ON_ORIGIN; then
  # Create the branch locally at the base-branch tip, then publish it.
  git checkout -B "${RELEASE_BRANCH}" "origin/${BASE_BRANCH}"
  git push -u origin "${RELEASE_BRANCH}"
fi
```

After this step, every subsequent operation in `/ci-release` targets `${RELEASE_BRANCH}` (release-notes push at Step 6, pipeline ref at Step 7). `BASE_BRANCH` is read-only from this skill until Step 10 back-merges.

## Step 5b — Apply the project's custom pre-release instructions

A repo can declare **project-specific instructions to run on `${RELEASE_BRANCH}` just before the release notes are generated** — a free-form escape hatch for anything the generic release flow can't know about (pinning a dependency off a canary build, regenerating a lockfile, bumping a version file, …). They live in the repo's `.pysae-ai-tools.yaml` under `release.prompt` and are **empty by default → this step is a no-op**. The value may inline the text, be an `overlay:` reference (`overlay:file://…`, `overlay:./path`, `overlay:https://…`), or be dropped in as a `.pysae-ai-tools/release.prompt.md` overlay file — `project show` returns the fully resolved text either way.

Run this after Step 5's release-branch isolation is in place and **before** Step 7 tags, so any resulting commit travels back to `${BASE_BRANCH}` via the Step 10 back-merge:

```bash
if ! RELEASE_PROMPT=$(pysae-ai-tools project show --json release.prompt); then
  slack_status ":x: échec du déploiement"
  echo "✗ release.prompt référence une source (overlay:) introuvable — release stoppée."
  exit 1
fi
RELEASE_PROMPT=$(printf '%s' "$RELEASE_PROMPT" | jq -r 'select(type=="string")')  # unwrap JSON, empty when unset
```

- **Empty** (`RELEASE_PROMPT` unset) → do nothing, continue to Step 6.
- **Non-empty** → treat its text as **literal instructions to execute now, on `${RELEASE_BRANCH}`**. Carry out exactly what it asks (make the change, then `git add` / `git commit` / `git push origin "${RELEASE_BRANCH}"`), then continue to Step 6. If the instructions describe a blocking pre-condition that fails to hold (e.g. "stop the release if …"), treat that as a release-blocking failure: call `slack_status ":x: échec du déploiement"` and `exit 1`. This behaves **identically in CI mode** (`IS_CI == "true"`) — an unmet pre-condition fails the run with exit code 1, never a tag.

> Example `release.prompt` (a frontend that must never ship a pre-release build of an internal dependency): *"If `package.json` depends on `<internal-client>` at a `*canary*` version, `yarn add <internal-client>@latest`; if `@latest` is still a canary, stop the release — the upstream MR isn't published in stable yet. Then commit `package.json` + `yarn.lock` and push."*

## Step 6 — Generate user-facing release notes

**Every release ships user-facing release notes — hotfixes and partial releases included.** Skip this step **only** if one of the following is true :
- `IS_CI == "true"` — release notes are validated manually in Claude Code, never in CI.
- `$ARGUMENTS` contains `--skip-release-notes` — release notes have already been generated upstream (most commonly by `/code-review-pre-release` at its Step 9, which passes both `--skip-review` and `--skip-release-notes` when delegating back to `/ci-release`) or the user opted out explicitly because they ran `/code-release-notes` by hand. **`--skip-review` alone is not a proxy** — it only suppresses the pre-release review prompt; if a caller wants to skip release notes too, they must pass `--skip-release-notes` explicitly.

Otherwise, check out the release branch locally and delegate to `/code-release-notes` so the user-facing entries under `docs/release-notes/release-notes.{fr,en,it}.{md,google-play.txt,apple-app-store.txt}` land on **`${RELEASE_BRANCH}`** — not on the default branch — **before** Step 7 triggers the pipeline that creates the tag :

```bash
git fetch origin "${RELEASE_BRANCH}"
git checkout -B "${RELEASE_BRANCH}" "origin/${RELEASE_BRANCH}"
```

Then :

```
/code-release-notes <NEW_VERSION>
```

`/code-release-notes` reads the current branch and pushes its commit there — see [../code-release-notes/SKILL.md § Step 2](../code-release-notes/SKILL.md). Read its `RELEASE_NOTES_RESULT` block :
- `status: "pushed"` → continue to Step 7.
- `status: "cancelled"` → the user backed out of the release notes. Treat as a release cancellation : do not trigger the pipeline, call `slack_status ":x: échec du déploiement"` (release abandoned at the notes step), and stop.
- `status: "failed"` → stop and surface the failure. Let the user fix the underlying issue (dirty tree, off-branch, push rejected) and re-invoke `/ci-release` once cleared.
- `status: "skipped"` → nothing to release (empty diff vs the latest tag). Stop the release cleanly.

The per-language files under `docs/release-notes/` live on the release branch and are *not* part of the release artefacts produced by the tag pipeline — they just need to be committed before the tag so the tagged commit history includes them, then they travel to the default branch via Step 10's back-merge. The actual technical changelog (`CHANGELOG.md`) is generated separately by `pysae-ai-tools code changelog release <tag>` later in the release pipeline (also on the release branch, also surfaced into the default branch by Step 10).

## Step 7 — Trigger and wait for the release pipeline

The pipeline always runs on the release branch — `PIPELINE_REF="${RELEASE_BRANCH}"` (set at Step 5). The `release.auto.yml` template runs the `release` job on `release/*` branches the exact same way it would on the default branch, and the resulting tag is created on the branch tip.

1. Create the pipeline:
   ```bash
   pysae-ai-tools ci run create --project-id ${PROJECT_ID} --ref ${RELEASE_BRANCH} --var RELEASE_VERSION=${NEW_VERSION}
   ```
   Extract `RELEASE_PIPELINE_ID` from output. Verify the `release` job exists in the pipeline jobs.

   The `release` job runs `pysae-ai-tools ci release run`, which **verifies the release before creating the tag** — it aborts (job fails, no tag) unless `CHANGELOG.md` has a non-empty `## [${NEW_VERSION}]` section **and** the `docs/release-notes/` files carry that version. **Every release mode keeps this check ON** — normal, partial and hotfix all generate their release notes at Step 6 (partial scopes them to the embedded tickets via the branch state). The `--no-check-release-notes` / `RELEASE_CHECK_RELEASE_NOTES=false` opt-out exists only for repos that don't follow the `docs/release-notes/` convention at all — never pass it for a Pysae app release.

   After pushing the tag, the same job also **creates (or updates) the GitLab release** for it — best-effort, so it never fails the job. The release description carries every configured language's user-facing notes, each under a flag heading (🇫🇷 Français / 🇬🇧 English / 🇮🇹 Italiano) and separated by a horizontal rule, with the changelog once at the bottom under a 📝 Changelog heading — minus the version heading. It needs a CI API context + token (`RELEASE_TOKEN`); nothing to do in local mode.

2. Wait for it to succeed (run in background in local mode):
   ```bash
   pysae-ai-tools ci run wait --project-id ${PROJECT_ID} --pipeline-id ${RELEASE_PIPELINE_ID}
   ```
   On failure → `slack_status ":x: échec du déploiement"` then delegate to `/ci-debug`.

3. Verify the tag was created:
   ```bash
   glab api "projects/${PROJECT_ID}/repository/tags/${NEW_VERSION}" | jq -r '.name'
   ```
   If the tag is missing despite a `success` status on the pipeline → `slack_status ":x: échec du déploiement"` and stop.

## Step 8 — Locate the tag pipeline

The tag creation triggers a separate pipeline that contains the manual `deploy_prod` job. Find it (don't wait for it to finish — Step 9 hands off to `/ci-deploy`, and GitLab's `needs:` graph gates `deploy_prod` on its upstream jobs):

```bash
TAG_PIPELINE=$(glab api "projects/${PROJECT_ID}/pipelines?ref=${NEW_VERSION}&per_page=1" | jq -r '.[0]')
TAG_PIPELINE_ID=$(echo "$TAG_PIPELINE" | jq -r '.id')
TAG_PIPELINE_URL=$(echo "$TAG_PIPELINE" | jq -r '.web_url')
```

If no tag pipeline appears within 30s, call `slack_status ":x: échec du déploiement"`, report the error to the user, and stop — the `release` job didn't push the tag properly.

**Display the tag pipeline link to the user** so they can keep an eye on the parallel jobs (tests, release assets) that aren't gating the deploy.

**Skip the previous full-pipeline wait.** Earlier versions of this skill ran `pysae-ai-tools ci run wait` here until the tag pipeline reached a terminal state (including `status=manual`). That delayed the prod deploy by the duration of jobs that `deploy_prod` doesn't actually depend on (smoke tests, lint, release assets, etc.). Trigger `/ci-deploy` immediately instead — GitLab will queue `deploy_prod` and start it as soon as its `needs:` are satisfied. `/ci-deploy` → `/ci-run` handles the per-job wait and failure surfacing, so any upstream `needs:` failure (e.g. broken build image) still ends up in `/ci-debug` via that path.

## Step 9 — Deploy to production and monitor rollout

**Before delegating**, flip the Slack status to "déploiement en cours":

```bash
slack_status ":rocket: validé, déploiement en cours"
```

Delegate to `/ci-deploy` with arguments: `prod <NEW_VERSION>` (+ app name if not default). It handles confirmation, job execution via `/ci-run`, and pipeline monitoring. **If `/ci-deploy` returns failure** (deploy job errored, `/ci-debug` triggered), call `slack_status ":x: échec du déploiement"` before propagating the failure back to the caller.

**Support release (`SUPPORT_MODE=true`) — mobile/stores/APK only, NEVER the web.** A support line ships an old version; deploying it to the single live web prod would **downgrade** it. The web-only refusal already happened at detection (a support release is gated to projects with a mobile track). When delegating to `/ci-deploy`, **restrict the tracks to the mobile ones** — drop `web` and `service`, keep `apple` / `android` / `android-enterprise` (+ stores). Pass the mobile-only intent explicitly so `/ci-deploy` never runs the web deploy step (e.g. forward only the mobile tracks / the build+stores levels, never the web level). The store release notes **are** wanted here (that's the point of a support release) — they were generated at Step 6 like any release.

**Prerelease (`IS_PRERELEASE=true`)** — a beta (`-alpha.N` / `-beta.N` / `-rc.N`) deploys the webapps to prod exactly like a normal release, but **must not publish to the app stores**. Store publication in `/ci-deploy` is already opt-in, so simply **never pass `stores` / `--stores`** when delegating; on top of that, `/ci-deploy` short-circuits store publication on a **beta** tag (it won't even prompt). Only the final `vX.Y.Z` release publishes to Google Play / the App Store. **A hotfix is not concerned by this** (`IS_PRERELEASE=false`): it is store-bound and publishes to the stores like a final release.

After `/ci-deploy` completes, monitor Kubernetes rollout and Datadog errors:

**Kubernetes**: list **all** deployments for the app from its config (`/project-configuration` → `.k8s.services[].deployment`, resolved for the env). Some apps have multiple deployments (e.g. `pysae/api` has flavors api-fast, api-ndp, api-gtfsrt, …):

```bash
# List all deployments matching the app pattern in the prod namespace
kubectl get deployments -n prod -l app.kubernetes.io/name=<K8S_DEPLOYMENT> --no-headers
# Or by name prefix:
kubectl get deployments -n prod --no-headers | grep "^prod-<deployment-prefix>"
```

For each deployment found:
- `kubectl rollout status deployment/<name> -n prod --timeout=10s`
- Check pod health and image tag
- Alert on CrashLoopBackOff/OOMKilled

**All deployments must be healthy** before declaring rollout complete.

**Datadog**: use `mcp__datadog__get_logs` to check for error spikes in the last 15 minutes. On errors: propose rollback.

**End conditions**:
- All deployments ready + no errors for 5 min → `slack_status ":white_check_mark: validé, déployé en production"`. Go to Step 10 (back-merge the release branch into `BASE_BRANCH`) **before** the final report.
- Critical errors detected (CrashLoopBackOff, OOMKilled, error spikes on Datadog) → `slack_status ":x: échec du déploiement"`, alert the user, and suggest rollback via `/ci-rollback`. Skip Step 10 — the release didn't reach a healthy state and shouldn't be back-merged.
- User says stop → `slack_status ":x: échec du déploiement"` (the release didn't reach a healthy prod state) and stop. Skip Step 10.

## Step 10 — Back-merge the release branch into `BASE_BRANCH`

**Skip only if Step 9 ended on critical errors or a user stop.** Every successful release — hotfix or not — ends with a back-merge so `BASE_BRANCH` picks up the release-notes commit (Step 6) and the technical `CHANGELOG.md` commit produced by the release pipeline (Step 7) that live on `${RELEASE_BRANCH}`.

**Support release (`SUPPORT_MODE=true`)**: `BASE_BRANCH` is the support branch, so the back-merge lands **on the support branch only — NEVER on `main`**. A fix on the v5 line stays on v5; if the same fix also applies to the main line, that's a **separate, manual forward-port** decision (out of scope here). Do not open or push anything to `main` from a support release.

The release branch and the tag have already been reviewed (pre-release review at Step 3 in normal mode, or the hotfix MR before the branch even existed). No second review needed — **merge directly** via git, then **delete the release branch (origin + local)** so it does not linger after shipping.

**If a worktree was used at Step 5** (`RELEASE_WORKTREE=true`) — the session is currently inside the worktree, on `${RELEASE_BRANCH}`. The back-merge runs on `${BASE_BRANCH}` in the main working directory, and a branch checked out in a worktree cannot be deleted, so **exit the worktree first** with `ExitWorktree` (action: remove — the release-notes commit is already pushed to origin, nothing local is lost). Then run the back-merge below from the main directory.

**Refresh from origin first — the CI moved the branch.** The release pipeline (Step 7) ran on origin and pushed a `CHANGELOG.md` commit **and** the tag onto `${RELEASE_BRANCH}`; your local checkout is now **behind**. Re-fetch the branch **and the new tag** before merging, and always merge the freshly-fetched `origin/${RELEASE_BRANCH}` — never a stale local `${RELEASE_BRANCH}` (which would lose the pipeline's CHANGELOG commit).

```bash
# Pull the post-pipeline state: the release branch's new CHANGELOG commit + the tag.
git fetch --tags origin "${BASE_BRANCH}" "${RELEASE_BRANCH}"
git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null \
  || echo "⚠️ tag ${NEW_VERSION} not fetched yet — the release pipeline may still be pushing it"
git checkout "${BASE_BRANCH}"
git pull --rebase origin "${BASE_BRANCH}"
git merge --no-ff "origin/${RELEASE_BRANCH}" \
  -m "chore(release): back-merge ${RELEASE_BRANCH} into ${BASE_BRANCH}"
git push origin "${BASE_BRANCH}"

# Cleanup: the release branch served its purpose (it carried the release-notes
# and CHANGELOG commits + the tag commit). Now that everything has landed on
# the base branch, drop the branch on origin and locally.
git push origin --delete "${RELEASE_BRANCH}" 2>/dev/null || true
git branch -D "${RELEASE_BRANCH}" 2>/dev/null || true
```

Notes on the typical shapes :
- **Normal / complete mode** — the release branch was created off the base-branch tip at Step 5, plus a release-notes commit (Step 6) and a `CHANGELOG.md` commit from the pipeline (Step 7). The merge replays those two commits on top of `${BASE_BRANCH}` — usually conflict-free unless someone pushed to it during the deploy window.
- **Hotfix mode** — the merge base is the tag the hotfix was branched off, so `git` only replays the hotfix commit(s) + `CHANGELOG.md` on top of the current `${BASE_BRANCH}`. Typically conflict-free when the touched files haven't drifted on the base branch.
- **Partial mode** — the cherry-picked code commits are byte-identical patches of commits **already on the base branch**, so the merge recognises them as present and brings essentially just the new `CHANGELOG.md` section + the consumed-entry deletions (no feature is added or removed on the base branch — that's the whole point of cherry-pick over revert). **One caveat: reconcile `changelogs/` explicitly.** The release pipeline deleted the *embedded* entries on the release branch, but a 3-way merge can keep them on the base branch (where they were added since the tag). After the merge, make sure `${BASE_BRANCH}` ends up with the **embedded entries removed** (they shipped in `${NEW_VERSION}`) and the **excluded ones kept** (they go out in a later release). For each embedded ticket whose `changelogs/<…>.md` still exists on the base branch, `git rm` it; never touch the excluded ones. Commit on top of the back-merge if anything changed:
  ```bash
  # EMBEDDED_IIDS = the tickets actually shipped in this partial release
  for f in changelogs/*; do
    iid=$(grep -oE '\(#([0-9]+)\)' "$f" | grep -oE '[0-9]+' | head -1)
    case " ${EMBEDDED_IIDS} " in *" ${iid} "*) git rm -q "$f" ;; esac
  done
  git diff --cached --quiet || git commit -m "chore(release): consume ${NEW_VERSION} changelog entries on ${BASE_BRANCH}"
  ```

If `git merge` reports « Already up to date » (rare, but possible if `${BASE_BRANCH}` already absorbed every commit on `${RELEASE_BRANCH}` via another path), it's a no-op — skip the `git push`, but **still run the branch cleanup** (the release branch did its job and should not linger).

**If the merge or the push to the base branch fails** (conflicts, branch protection, fetch error), do **not** try to repair it from here, do **not** open a back-merge MR, and **do NOT delete the release branch** — the user needs it intact to finish the back-merge by hand. Abort cleanly and surface the situation :

```bash
git merge --abort 2>/dev/null || true
```

> ⚠️ Back-merge `${RELEASE_BRANCH}` → `${BASE_BRANCH}` impossible automatiquement (`<conflit|push refusé|autre>`). Résous manuellement puis pousse sur `${BASE_BRANCH}` pour qu'elle rattrape la release `${NEW_VERSION}`. La branche `${RELEASE_BRANCH}` est conservée (origin + local) pour que tu puisses la merger à la main ; supprime-la une fois la résolution faite.

The release is considered fully shipped once the tag exists and the production deploy is healthy — the back-merge is mandatory hygiene but its delay does not block the user-facing release. The branch deletion is a clean-up step on top of the back-merge ; failures of the `git push --delete` or `git branch -D` are swallowed (`|| true`) because the back-merge is already on the base branch — a leftover release branch is cosmetic, not blocking.

**Final report**:
> **Release `<NEW_VERSION>` — `<app>`**
> - Release branch : `<RELEASE_BRANCH>`
> - Pipeline release : [link] — `success`
> - Pipeline tag : [link] — *jobs non-bloquants potentiellement encore en cours*
> - Deploy prod : [link] — `success`
> - Rollout : complete — X/Y deployments ready (list each)
> - Erreurs post-deploy : aucune
> - Back-merge `<RELEASE_BRANCH>` → `<BASE_BRANCH>` : `merged` / `already up to date` / `à résoudre manuellement (<raison>)`
> - Branche `<RELEASE_BRANCH>` : `supprimée (origin + local)` / `conservée (back-merge à résoudre manuellement)`
> - *(support release)* : ligne `<SUPPORT_LINE>` — déployé mobile/stores uniquement (pas de web) ; pas de back-merge vers `main`

(The tag pipeline status isn't asserted as `success` here: `/ci-deploy` only waits on `deploy_prod` and its `needs:`. Parallel jobs in the same tag pipeline — tests, release assets — may still be running or even fail without blocking the deploy. The link lets the user follow them if needed.)

## Rules

- **Confirmation required** for version (local) and prod deploy (via `/ci-deploy`) — skipped in CI
- **Default bump**: auto from changelogs, fallback patch
- **Default app**: `pysae/api`
- **Stop on pipeline failure** → `/ci-debug`
- **Alert immediately on rollout errors**
- **French** for communication, English for commands. Apply [french-writing-rules.md](../references/french-writing-rules.md).
- **Delegate deploy** to `/ci-deploy` — never duplicate deployment logic
- **Delegate version resolution** to `/code-get-next-version` — single source of truth for next-version + changelog confirmation
- **Delegate user-facing release notes** to `/code-release-notes` at Step 6 (local, direct invocation only) — `/code-review-pre-release` already handles it itself when it's the caller and signals so by passing `--skip-release-notes` (independent of `--skip-review`), so we never run the release-notes step twice. Notes are committed on the release branch (Step 5), never directly on the default branch.
- **Offer pre-release review** at Step 3 only on direct invocation (no `--skip-review`, no pre-arrested version, local mode). When delegating to `/code-review-pre-release`, stop the current run — that skill orchestrates the rest and will re-invoke `/ci-release` with `--skip-review` + a pre-arrested version, which naturally skips Step 3 (explicit anti-loop guard)
- **Apply custom pre-release instructions** at Step 5b — run the repo's `release.prompt` (`.pysae-ai-tools.yaml`) on `${RELEASE_BRANCH}` just before Step 6, empty by default (no-op). A free-form escape hatch for project-specific pre-tag preparation (e.g. pinning a dependency off a canary build); a resulting commit rides the Step 10 back-merge, and an unmet blocking pre-condition stops the release (exit 1, never a tag).
- **Always ship through a release branch.** Step 5 creates `release/${NEW_VERSION}` off the default branch tip (or reuses the existing one in hotfix mode), Step 6 commits the release notes on it, Step 7 triggers the pipeline on it, Steps 8–9 deploy from the resulting tag. **Step 10 back-merges the release branch into the default branch only after the prod rollout is validated** — never before — and **deletes the release branch on origin and locally** once the back-merge has landed. On a failed deploy, the release branch stays orphaned (no back-merge, no deletion) so a re-attempt or `/ci-rollback` can operate from it. On back-merge failure (conflicts, protected push) the skill stops, keeps the release branch intact for manual resolution, and tells the user to resolve and delete it themselves — no fallback MR.
- **Hotfix mode** changes only the *source* of the release branch: instead of creating it off the default branch, Step 5 reuses the `release/vX.Y.Z` branch already populated by `/code-implement --hotfix`. Step 6 (release notes) and every other step run exactly like a normal release — a hotfix ships user-facing release notes too. Steps 7–10 are identical to normal mode.
- **Support mode** changes only two things: `BASE_BRANCH` becomes the `support/vX[.Y].x` branch (Step 5 cut + Step 10 back-merge target) and the version is **clamped to the support range** (Step 4 forwards `--support-line` + `--ref <support-branch>`). It is **mobile-only** — gated to projects with a mobile track, deploys stores/APK but **never the web**, and **back-merges into the support branch only, never `main`**. It composes with the complete / hotfix / partial sub-modes (all relative to `BASE_BRANCH`).

$ARGUMENTS
