diff --git a/.github/commit-scopes.txt b/.github/commit-scopes.txt
new file mode 100644
index 0000000..85b6f68
--- /dev/null
+++ b/.github/commit-scopes.txt
@@ -0,0 +1,4 @@
+# Allowed commit scopes, one per line (blank lines and lines starting with `#` are ignored).
+# Used by `.github/scripts/check-commit-messages`.
+core
+tests/core
diff --git a/.github/scripts/add-commit-pr-id b/.github/scripts/add-commit-pr-id
new file mode 100755
index 0000000..ea6a432
--- /dev/null
+++ b/.github/scripts/add-commit-pr-id
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 Vates SAS
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# ==============================================================================
+# Make the title of every commit of the current branch end with ` (#)`.
+# Commits whose title already ends with this ID only are left untouched, so the script can be run
+# several times.
+#
+# Usage: add-commit-pr-id [--force]
+#
+# - ``: base of the pull request to check and modify.
+# - ``: head of the pull request to check and modify.
+# - ``: number of the pull request.
+# - `--force`: replace the existing pull request IDs of the titles.
+#
+# Every trailing ` (#)` of a title is considered to be a pull request ID. Without `--force`,
+# the commits that already end with another ID are ignored (and listed), the others are rewritten:
+# an ignored commit is probably in the range because the base is wrong, or the range contains
+# commits of other pull requests.
+# ==============================================================================
+
+set -euo pipefail
+
+SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
+readonly SCRIPT_DIR
+
+readonly AMEND_SCRIPT_PATH="${SCRIPT_DIR}/amend-commit-pr-id"
+
+source "${SCRIPT_DIR}/lib/git.sh"
+source "${SCRIPT_DIR}/lib/pr-id.sh"
+
+# ==============================================================================
+
+readonly FORCE_OPTION="--force"
+
+FORCE=false
+ARGS=()
+for arg in "$@"; do
+ if [ "$arg" = "$FORCE_OPTION" ]; then
+ FORCE=true
+ else
+ ARGS+=("$arg")
+ fi
+done
+readonly FORCE
+readonly ARGS
+
+if [ "${#ARGS[@]}" -ne 3 ]; then
+ echo "Usage: $0 [${FORCE_OPTION}] " >&2
+ exit 2
+fi
+
+BASE_SHA=$(resolve_commit "${ARGS[0]}")
+readonly BASE_SHA
+
+HEAD_SHA=$(resolve_commit "${ARGS[1]}")
+readonly HEAD_SHA
+
+readonly PR_NUMBER=${ARGS[2]}
+if ! is_pr_number "$PR_NUMBER"; then
+ echo "Invalid pull request number: \`${PR_NUMBER}\`." >&2
+ exit 2
+fi
+
+# ------------------------------------------------------------------------------
+
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ echo "The working tree has uncommitted changes: commit or stash them first." >&2
+ exit 1
+fi
+
+if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then
+ echo "\`HEAD\` is not \`${HEAD_SHA}\`: checkout the head of the pull request first." >&2
+ exit 1
+fi
+
+# ------------------------------------------------------------------------------
+
+if ! MERGE_BASE_SHA=$(git merge-base "$BASE_SHA" "$HEAD_SHA"); then
+ echo "No common ancestor between \`${BASE_SHA}\` and \`${HEAD_SHA}\`." >&2
+ exit 2
+fi
+readonly MERGE_BASE_SHA
+
+readonly COMMIT_RANGE="${MERGE_BASE_SHA}..${HEAD_SHA}"
+if [ -n "$(git rev-list --merges "${MERGE_BASE_SHA}..${HEAD_SHA}")" ]; then
+ echo "Merge commits found in \`${COMMIT_RANGE}\`: this script only supports linear history." >&2
+ exit 1
+fi
+
+mapfile -t hashes < <(git log --format=%H "${MERGE_BASE_SHA}..${HEAD_SHA}")
+
+# ------------------------------------------------------------------------------
+
+PR_ID_SUFFIX=$(get_pr_id_suffix "$PR_NUMBER")
+readonly PR_ID_SUFFIX
+
+to_rewrite=0
+to_ignore=0
+for hash in "${hashes[@]}"; do
+ subject=$(git log -1 --format=%s "$hash")
+ if [ "$(with_pr_id "$subject" "$PR_ID_SUFFIX")" = "$subject" ]; then
+ continue
+ fi
+
+ # The title already ends with a pull request ID other than the requested one.
+ if has_pr_id "$subject" && ! $FORCE; then
+ to_ignore=$((to_ignore + 1))
+ echo "Commit ${hash} ignored, it already has another pull request ID: \"${subject}\"." >&2
+ continue
+ fi
+
+ to_rewrite=$((to_rewrite + 1))
+done
+
+if [ "$to_rewrite" -eq 0 ]; then
+ if [ "$to_ignore" -eq 0 ]; then
+ echo "Nothing to do: every commit of \`${COMMIT_RANGE}\` already ends with \"${PR_ID_SUFFIX}\"."
+ else
+ echo "Nothing to do: ${to_ignore} commit(s) of \`${COMMIT_RANGE}\` ignored with another pull request ID, the others already end with \"${PR_ID_SUFFIX}\"."
+ fi
+ exit 0
+fi
+
+# ------------------------------------------------------------------------------
+
+exec_options=()
+if $FORCE; then
+ exec_options+=("$FORCE_OPTION")
+fi
+
+readonly PREVIOUS_HEAD=$HEAD_SHA
+printf -v EXEC_COMMAND '%q ' "$AMEND_SCRIPT_PATH" "${exec_options[@]}" "$PR_ID_SUFFIX"
+readonly EXEC_COMMAND
+
+if ! rebase_output=$(GIT_SEQUENCE_EDITOR=: git rebase --interactive --no-autosquash --exec "$EXEC_COMMAND" "$MERGE_BASE_SHA" 2>&1); then
+ echo "$rebase_output" >&2
+ git rebase --abort || true
+ echo "Rewrite failed, branch restored (previous HEAD: ${PREVIOUS_HEAD})." >&2
+ exit 1
+fi
+
+echo "${to_rewrite} commit title(s) updated with \"${PR_ID_SUFFIX}\" (previous HEAD: ${PREVIOUS_HEAD})."
+if [ "$to_ignore" -ne 0 ]; then
+ echo "${to_ignore} commit(s) ignored because they already have another pull request ID."
+fi
diff --git a/.github/scripts/amend-commit-pr-id b/.github/scripts/amend-commit-pr-id
new file mode 100755
index 0000000..b8fcf44
--- /dev/null
+++ b/.github/scripts/amend-commit-pr-id
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 Vates SAS
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# ==============================================================================
+# Amend the title of `HEAD` so that it ends with the given pull request ID suffix, and only with it.
+# The title is left untouched if it already ends like that.
+#
+# Usage: amend-commit-pr-id [--force]
+#
+# - `--force`: replace the pull request IDs already at the end of the title. Without it, a title
+# that already ends with a pull request ID is left untouched.
+#
+# Run by `git rebase --exec` from `add-commit-pr-id`, once for each commit it replays. It does
+# none of the checks of `add-commit-pr-id`: do not use it directly.
+# ==============================================================================
+
+set -euo pipefail
+
+source "$(dirname "$0")/lib/pr-id.sh"
+
+# ==============================================================================
+
+readonly FORCE_OPTION="--force"
+
+FORCE=false
+if [ "${1:-}" = "$FORCE_OPTION" ]; then
+ FORCE=true
+ shift
+fi
+readonly FORCE
+
+if [ "$#" -ne 1 ]; then
+ echo "Usage: $0 [${FORCE_OPTION}] " >&2
+ exit 2
+fi
+
+readonly PR_ID_SUFFIX=$1
+
+# ------------------------------------------------------------------------------
+
+subject=$(git log -1 --format=%s)
+if has_pr_id "$subject" && ! $FORCE; then
+ exit 0
+fi
+
+new_subject=$(with_pr_id "$subject" "$PR_ID_SUFFIX")
+if [ "$new_subject" != "$subject" ]; then
+ # Only the first line of the message (the title) changes, the body is kept as is.
+ { echo "$new_subject"; git log -1 --format=%B | tail -n +2; } | git commit --amend --allow-empty --no-verify -q -F -
+fi
diff --git a/.github/scripts/check-commit-messages b/.github/scripts/check-commit-messages
new file mode 100755
index 0000000..2a7383c
--- /dev/null
+++ b/.github/scripts/check-commit-messages
@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 Vates SAS
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# ==============================================================================
+# Check the commit message conventions of a pull request.
+#
+# Usage: check-commit-messages
+#
+# - ``: base of the range to check.
+# - ``: head of the range to check.
+# - ``: number of the pull request.
+#
+# Rules:
+# - Format: `(): `, with type in `feat`, `fix`, `docs` or `chore`.
+# - `feat`, `fix` and `docs` require a scope. `chore` accepts an optional one.
+# - A scope must be in the allowed list of `.github/commit-scopes.txt`.
+# - The subject is at most 70 characters long, counting the ` (#)` suffix (already there
+# or added at merge). It is separated from the type/scope by exactly one space, starts with a
+# lowercase letter or a non-letter character, and does not end with a period.
+# - If the commit has a body, the second line of the message must be blank.
+# - A commit that only touches files in `tests/` must have a scope starting with `tests/`
+# (even for `chore`), and a scope starting with `tests/` is only allowed for such commits.
+# - A `chore` commit touching files in `src/` or `tests/` only triggers a warning (never a failure).
+# - A subject ends with at most one pull request ID.
+# - If the pull request has several commits, each one should end with ` (#)`.
+# With a single commit nothing is checked: the ID is added when the pull request is merged.
+# ==============================================================================
+
+set -euo pipefail
+
+SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
+readonly SCRIPT_DIR
+
+readonly SCOPES_FILE_PATH="${SCRIPT_DIR}/../commit-scopes.txt"
+
+readonly MAX_SUBJECT_LENGTH=70
+
+source "${SCRIPT_DIR}/lib/git.sh"
+source "${SCRIPT_DIR}/lib/pr-id.sh"
+
+# ==============================================================================
+
+errors=0
+
+report_error() {
+ echo "::error::$1"
+ errors=$((errors + 1))
+}
+
+report_warning() {
+ echo "::warning::$1"
+}
+
+# ------------------------------------------------------------------------------
+
+if [ "$#" -ne 3 ]; then
+ echo "Usage: $0 " >&2
+ exit 2
+fi
+
+BASE_SHA=$(resolve_commit "$1")
+readonly BASE_SHA
+
+HEAD_SHA=$(resolve_commit "$2")
+readonly HEAD_SHA
+
+readonly PR_NUMBER=$3
+
+if ! is_pr_number "$PR_NUMBER"; then
+ report_error "Invalid pull request number: \`${PR_NUMBER}\`."
+ exit 2
+fi
+
+# Read the allowed scopes, one per line, ignoring comments and blank lines.
+mapfile -t scopes < <(grep -vE '^[[:space:]]*(#|$)' "$SCOPES_FILE_PATH" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
+if [ "${#scopes[@]}" -eq 0 ]; then
+ report_error "No allowed scope found in \`${SCOPES_FILE_PATH}\`."
+ exit 2
+fi
+
+# ------------------------------------------------------------------------------
+
+# Print the reason why a message (without PR ID) does not match `COMMIT_REGEX`.
+get_format_error() {
+ local message=$1
+ if ! [[ $message =~ ^(feat|fix|docs|chore)($|[^[:alnum:]_-]) ]]; then
+ echo "the type must be \`feat\`, \`fix\`, \`docs\` or \`chore\` "
+ elif [[ $message =~ ^(feat|fix|docs)($|[^\(]) ]]; then
+ echo "this type requires a scope, one of [${DISPLAYED_SCOPES%, }]"
+ elif [[ $message =~ ^[a-z]+\( ]] && ! [[ $message =~ ^[a-z]+${SCOPE_REGEX} ]]; then
+ echo "invalid scope, expected one of [${DISPLAYED_SCOPES%, }]"
+ else
+ echo "expected \`: \` (colon and one space) after the type or the scope, then a summary"
+ fi
+}
+
+# ------------------------------------------------------------------------------
+
+# Escape the regex special characters of each scope, then join them with `|`.
+ALLOWED_SCOPES=$(printf '%s\n' "${scopes[@]}" | sed -E 's/[][\\.^$*+?(){}|]/\\&/g' | paste -sd'|')
+readonly ALLOWED_SCOPES
+
+DISPLAYED_SCOPES=$(printf "\`%s\`, " "${scopes[@]}")
+readonly DISPLAYED_SCOPES
+
+readonly SUBJECT_REGEX='.*[^ ]'
+readonly SCOPE_REGEX="\\((${ALLOWED_SCOPES})\\)"
+readonly COMMIT_REGEX="^((feat|fix|docs)${SCOPE_REGEX}|chore(${SCOPE_REGEX})?): ${SUBJECT_REGEX}$"
+
+# ------------------------------------------------------------------------------
+
+mapfile -t hashes < <(git log --no-merges --format=%H "${BASE_SHA}..${HEAD_SHA}")
+
+# ------------------------------------------------------------------------------
+
+get_changed_files() {
+ git diff-tree --root --no-commit-id --name-only -r "$1"
+}
+
+is_touching_only_tests() {
+ local files
+ files=$(get_changed_files "$1")
+ [ -n "$files" ] && ! grep -qv '^tests/' <<< "$files"
+}
+
+is_touching_src_or_tests() {
+ local files
+ files=$(get_changed_files "$1")
+ grep -qE '^(src|tests)/' <<< "$files"
+}
+
+# ------------------------------------------------------------------------------
+
+PR_ID_SUFFIX=$(get_pr_id_suffix "$PR_NUMBER")
+readonly PR_ID_SUFFIX
+
+for hash in "${hashes[@]}"; do
+ subject=$(git log -1 --format=%s "$hash")
+ # The ID is optional here: the format is checked without it.
+ message=$(strip_pr_id "$subject")
+
+ if has_pr_id "$message"; then
+ report_error "Commit subject must not end with several pull request IDs: \"${subject}\"."
+ continue
+ fi
+
+ if ! [[ $message =~ $COMMIT_REGEX ]]; then
+ reason=$(get_format_error "$message")
+ report_error "Invalid commit format, ${reason}: \"${subject}\"."
+ continue
+ fi
+
+ subject_length=$((${#message} + ${#PR_ID_SUFFIX}))
+ if [ "$subject_length" -gt "$MAX_SUBJECT_LENGTH" ]; then
+ report_error "Commit subject must be at most ${MAX_SUBJECT_LENGTH} characters long including \`${PR_ID_SUFFIX}\`, got ${subject_length}: \"${subject}\"."
+ fi
+
+ summary=${message#*: }
+ if [[ $summary == " "* ]]; then
+ report_error "Commit subject must be separated from the type by exactly one space: \"${subject}\"."
+ fi
+ if [[ $summary =~ ^[[:upper:]] ]]; then
+ report_error "Commit subject must not start with an uppercase letter: \"${subject}\"."
+ fi
+ if [[ $summary == *. ]]; then
+ report_error "Commit subject must not end with a period: \"${subject}\"."
+ fi
+
+ scope=""
+ if [[ $message =~ ^[a-z]+\(([^\)]+)\) ]]; then
+ scope=${BASH_REMATCH[1]}
+ fi
+ if is_touching_only_tests "$hash"; then
+ if [[ $scope != tests/* ]]; then
+ report_error "Commit only touches \`tests/\`, its scope must start with \`tests/\`: \"${subject}\"."
+ fi
+ elif [[ $scope == tests/* ]]; then
+ report_error "Scope \`${scope}\` is only allowed for commits that only touch \`tests/\`: \"${subject}\"."
+ fi
+
+ if [[ $message == chore* ]] && is_touching_src_or_tests "$hash"; then
+ report_warning "Commit \`chore\` touches files in \`src/\` or \`tests/\`, consider \`feat\`, \`fix\` or \`docs\`: \"${subject}\"."
+ fi
+
+ if [ "${#hashes[@]}" -gt 1 ] && [[ $subject != *"$PR_ID_SUFFIX" ]]; then
+ report_warning "Commit message must end with \"${PR_ID_SUFFIX}\": \"${subject}\"."
+ fi
+
+ second_line=$(git log -1 --format=%B "$hash" | sed -n 2p)
+ if [ -n "$second_line" ]; then
+ report_error "Commit message must have a blank line between the subject and the body: \"${subject}\"."
+ fi
+done
+
+if [ "$errors" -ne 0 ]; then
+ exit 1
+fi
+echo "${#hashes[@]} commit message(s) checked: OK."
diff --git a/.github/scripts/lib/git.sh b/.github/scripts/lib/git.sh
new file mode 100644
index 0000000..0a13b01
--- /dev/null
+++ b/.github/scripts/lib/git.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 Vates SAS
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# ==============================================================================
+# Git helpers.
+# ==============================================================================
+
+# Print the full SHA of the commit a revision points to, or fail with the exit code 2 (invalid
+# argument) if there is none.
+resolve_commit() {
+ if ! git rev-parse --verify --quiet "$1^{commit}"; then
+ echo "Unknown commit: \`$1\`." >&2
+ exit 2
+ fi
+}
diff --git a/.github/scripts/lib/pr-id.sh b/.github/scripts/lib/pr-id.sh
new file mode 100644
index 0000000..d4205a2
--- /dev/null
+++ b/.github/scripts/lib/pr-id.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+#
+# Copyright (C) 2026 Vates SAS
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+# ==============================================================================
+# Helpers to handle the pull request ID (` (#)`) at the end of a commit title.
+# ==============================================================================
+
+# Matches one pull request ID, with its leading space, at the end of a title.
+readonly PR_ID_REGEX=' \(#[0-9]+\)$'
+
+# Succeed if the argument is a valid pull request number.
+is_pr_number() {
+ [[ $1 =~ ^[0-9]+$ ]]
+}
+
+# Print the suffix to add to a commit title for the given pull request number.
+get_pr_id_suffix() {
+ echo " (#$1)"
+}
+
+# True if a commit title ends with a pull request ID.
+has_pr_id() {
+ [[ $1 =~ $PR_ID_REGEX ]]
+}
+
+# Print a commit title without its last pull request ID, if any.
+strip_pr_id() {
+ local subject=$1
+ if [[ $subject =~ $PR_ID_REGEX ]]; then
+ subject=${subject%"${BASH_REMATCH[0]}"}
+ fi
+ echo "$subject"
+}
+
+# Print a commit title without its trailing pull request IDs.
+strip_pr_ids() {
+ local subject=$1
+ while has_pr_id "$subject"; do
+ subject=$(strip_pr_id "$subject")
+ done
+ echo "$subject"
+}
+
+# Print a commit title with its trailing pull request IDs replaced by the given suffix.
+with_pr_id() {
+ local subject=$1
+ local pr_id_suffix=$2
+ echo "$(strip_pr_ids "$subject")${pr_id_suffix}"
+}
diff --git a/.github/workflows/add-pr-id.yml b/.github/workflows/add-pr-id.yml
new file mode 100644
index 0000000..11359b3
--- /dev/null
+++ b/.github/workflows/add-pr-id.yml
@@ -0,0 +1,136 @@
+# Comment `/add-pr-id` on a pull request to add its ` (#)` suffix to the title of every commit.
+# `issue_comment` workflows always run the version of this file (and of the script) of the default
+# branch, never the one of the pull request.
+
+name: Add pull request ID
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ contents: write
+ pull-requests: write
+
+# Ensure we only run one job for one update on a PR.
+concurrency:
+ group: add-pr-id-${{ github.event.issue.number }}
+ cancel-in-progress: false
+
+jobs:
+ add-pr-id:
+ name: Add pull request ID
+ # Check command name and permissions to run the job.
+ if: >-
+ github.event.issue.pull_request &&
+ github.event.comment.body == '/add-pr-id' &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.issue.number }}
+ steps:
+ - name: Check commenter write permission
+ env:
+ COMMENTER: ${{ github.event.comment.user.login }}
+ run: |
+ role=$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${COMMENTER}/permission" --jq .role_name)
+ case "$role" in
+ admin|maintain|write) ;;
+ *)
+ gh pr comment "$PR_NUMBER" --body "\`/add-pr-id\` requires write access to this repository."
+ exit 1
+ ;;
+ esac
+
+ - name: Get pull request
+ id: pr
+ run: |
+ gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" --jq '
+ "base_sha=\(.base.sha)",
+ "head_sha=\(.head.sha)",
+ "head_ref=\(.head.ref)",
+ "is_same_repo=\(.head.repo.full_name == env.GITHUB_REPOSITORY)",
+ "state=\(.state)"
+ ' >> "$GITHUB_OUTPUT"
+
+ - name: Refuse unsupported pull request
+ if: ${{ steps.pr.outputs.is_same_repo != 'true' || steps.pr.outputs.state != 'open' }}
+ run: |
+ gh pr comment "$PR_NUMBER" --body "\`/add-pr-id\` only works on open pull requests whose branch is in this repository."
+ exit 1
+
+ # /!\ The job code is run from the default branch, not from the pull request.
+ - name: Checkout tools
+ uses: actions/checkout@v6
+ with:
+ path: tools
+
+ - name: Checkout pull request
+ uses: actions/checkout@v6
+ with:
+ path: pr
+ ref: ${{ steps.pr.outputs.head_sha }}
+ fetch-depth: 0
+
+ - name: Add the ID to the commits
+ id: add
+ working-directory: pr
+ env:
+ BASE_SHA: ${{ steps.pr.outputs.base_sha }}
+ HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ # With a single commit the ID is added by GitHub at merge: nothing to do (see
+ # `check-commit-messages`).
+ if [ "$(git rev-list --count "${BASE_SHA}..${HEAD_SHA}")" -le 1 ]; then
+ echo "The pull request has a single commit: its ID is added when it is merged, nothing to do." | tee ../output.txt
+ echo "pushed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if ! ../tools/.github/scripts/add-commit-pr-id "$BASE_SHA" "$HEAD_SHA" "$PR_NUMBER" 2>&1 | tee ../output.txt; then
+ echo "failed=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ "$(git rev-parse HEAD)" = "$HEAD_SHA" ]; then
+ echo "pushed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "pushed=true" >> "$GITHUB_OUTPUT"
+
+ - name: Push
+ id: push
+ if: ${{ steps.add.outputs.pushed == 'true' }}
+ working-directory: pr
+ env:
+ HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
+ HEAD_REF: ${{ steps.pr.outputs.head_ref }}
+ # Fails if somebody pushed to the branch in the meantime.
+ run: git push "--force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA}" origin "HEAD:refs/heads/${HEAD_REF}"
+
+ - name: Report
+ if: ${{ always() && steps.add.conclusion != 'skipped' }}
+ env:
+ FAILED: ${{ steps.add.outputs.failed }}
+ PUSH_OUTCOME: ${{ steps.push.outcome }}
+ run: |
+ if [ "$FAILED" = "true" ]; then
+ status="Nothing was changed."
+ elif [ "$PUSH_OUTCOME" = "failure" ]; then
+ status="The branch was updated in the meantime, nothing was pushed: run \`/add-pr-id\` again."
+ else
+ status="Done."
+ fi
+ {
+ echo "\`/add-pr-id\`: ${status}"
+ echo
+ echo '```'
+ cat output.txt
+ echo '```'
+ } > comment.md
+ gh pr comment "$PR_NUMBER" --body-file comment.md
diff --git a/.github/workflows/commit-conventions.yml b/.github/workflows/commit-conventions.yml
new file mode 100644
index 0000000..2f43d9a
--- /dev/null
+++ b/.github/workflows/commit-conventions.yml
@@ -0,0 +1,25 @@
+name: Commit conventions
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+
+jobs:
+ commit-messages:
+ name: Commit messages
+ if: ${{ !github.event.pull_request.draft }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Check commit messages
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: .github/scripts/check-commit-messages "$BASE_SHA" "$HEAD_SHA" "$PR_NUMBER"