diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 0fcd5f8..3dbb915 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -12,7 +12,6 @@ on: permissions: contents: write - pages: write jobs: docs: @@ -34,9 +33,11 @@ jobs: run: python scripts/generate_action_docs.py - name: Configure Git + env: + GIT_ACTOR: ${{ github.actor }} run: | - git config --global user.name '${{ github.actor }}' - git config --global user.email '${{ github.actor }}@users.noreply.github.com' + git config --global user.name "$GIT_ACTOR" + git config --global user.email "${GIT_ACTOR}@users.noreply.github.com" - name: Deploy with mike run: | diff --git a/.github/workflows/test-mkdocs-deploy.yml b/.github/workflows/test-mkdocs-deploy.yml index a1130b9..05e6a31 100644 --- a/.github/workflows/test-mkdocs-deploy.yml +++ b/.github/workflows/test-mkdocs-deploy.yml @@ -4,8 +4,7 @@ on: push: permissions: - contents: write - pages: write + contents: read jobs: test-pull-request-trigger: @@ -361,7 +360,10 @@ jobs: cp tests/data/mkdocs-deploy/test-release-tag-resolution/uv.lock . cp -r tests/data/mkdocs-deploy/test-release-tag-resolution/docs . + # Positive control: a release trigger WITH a resolvable tag must succeed. - name: Test with explicit release tag + id: with-tag + continue-on-error: true uses: ./actions/mkdocs-deploy with: trigger: 'release' @@ -369,15 +371,37 @@ jobs: release-tag: 'v4.5.6' deploy-token: ${{ secrets.GITHUB_TOKEN }} - - name: Test without explicit release tag (simulated) - env: - GITHUB_EVENT_NAME: release + # Negative test: the SAME fixture and setup but with NO resolvable tag (no + # release-tag input, and github.event.release.tag_name is empty because this + # workflow is push-triggered). The action must fail fast in its tag-resolution + # guard rather than deploy a bogus version. + - name: Test release trigger without a resolvable tag fails fast + id: no-tag + continue-on-error: true uses: ./actions/mkdocs-deploy with: trigger: 'release' package-manager: 'uv' deploy-token: ${{ secrets.GITHUB_TOKEN }} + # Assert that the ONLY difference (presence of a resolvable tag) is what flips + # the result: tagged succeeds, untagged fails. Because both runs share the same + # fixture and setup, an unrelated regression that broke setup would also fail + # the positive control and be caught here instead of passing silently. + - name: Verify tag presence flips success/failure + run: | + echo "with-tag outcome: ${{ steps.with-tag.outcome }}" + echo "no-tag outcome: ${{ steps.no-tag.outcome }}" + if [ "${{ steps.with-tag.outcome }}" != "success" ]; then + echo "::error::Positive control failed: a release deploy WITH a tag should succeed" + exit 1 + fi + if [ "${{ steps.no-tag.outcome }}" != "failure" ]; then + echo "::error::A release deploy WITHOUT a resolvable tag should fail fast in the guard" + exit 1 + fi + echo "[OK] Tag presence flips success/failure as expected" + test-default-install-groups: name: Test Default install-groups Value runs-on: ubuntu-latest diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index c138f1f..87837c4 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -5,8 +5,12 @@ on: branches: [main] workflow_dispatch: +# contents: read is sufficient ONLY because every job below invokes +# ./actions/release/github with skip-github-release: 'true' (the GitHub Releases +# API is never called) and all pushes target the local fake remote. A job that +# creates a real GitHub release would need contents: write. permissions: - contents: write + contents: read jobs: # Test basic functionality with different package managers @@ -264,6 +268,7 @@ jobs: uses: ./actions/release/github with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: docs dev' increment: 'patch' prerelease-type: ${{ matrix.prerelease-type }} package-manager: 'uv' @@ -458,6 +463,7 @@ jobs: uses: ./actions/release/github with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: docs dev' increment: 'patch' package-manager: 'uv' python-version: ${{ matrix.python-version }} @@ -534,6 +540,7 @@ jobs: uses: ./actions/release/github with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: docs dev' increment: 'patch' package-manager: 'uv' python-version: '3.12' @@ -614,6 +621,7 @@ jobs: uses: ./actions/release/github with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: docs dev' increment: 'patch' package-manager: 'uv' python-version: '3.12' @@ -756,6 +764,7 @@ jobs: uses: ./actions/release/github with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: docs dev' increment: 'patch' package-manager: 'uv' python-version: '3.12' diff --git a/.github/workflows/test-release-pypi.yml b/.github/workflows/test-release-pypi.yml index ea3a42c..ef4ca32 100644 --- a/.github/workflows/test-release-pypi.yml +++ b/.github/workflows/test-release-pypi.yml @@ -239,7 +239,7 @@ jobs: # Generate a fresh lock so verify-lock='true' always sees an up-to-date file. - name: Generate fresh uv lock - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: false @@ -419,7 +419,7 @@ jobs: # Since we patched the version, the committed lock is now stale — regenerate. - name: Regenerate uv lock after version patch (uv only) if: matrix.package-manager == 'uv' - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: false diff --git a/actions/mkdocs-deploy/action.yml b/actions/mkdocs-deploy/action.yml index b446d09..fce6007 100644 --- a/actions/mkdocs-deploy/action.yml +++ b/actions/mkdocs-deploy/action.yml @@ -189,23 +189,33 @@ runs: - name: Configure Git shell: bash + env: + GIT_ACTOR: ${{ github.actor }} run: | echo "::group::Configuring Git" - git config --global user.name '${{ github.actor }}' - git config --global user.email '${{ github.actor }}@users.noreply.github.com' + git config --global user.name "$GIT_ACTOR" + git config --global user.email "${GIT_ACTOR}@users.noreply.github.com" echo "::endgroup::" - name: Set release tag environment variable if: ${{ inputs.trigger == 'release' }} shell: bash + env: + INPUT_RELEASE_TAG: ${{ inputs.release-tag }} + EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }} run: | echo "::group::Setting release tag" - if [ -n "${{ inputs.release-tag }}" ]; then - export RELEASE_TAG_VERSION="${{ inputs.release-tag }}" + if [ -n "$INPUT_RELEASE_TAG" ]; then + RELEASE_TAG_VERSION="$INPUT_RELEASE_TAG" else - export RELEASE_TAG_VERSION="${{ github.event.release.tag_name }}" + RELEASE_TAG_VERSION="$EVENT_RELEASE_TAG" + fi + if [ -z "$RELEASE_TAG_VERSION" ]; then + echo "::error::trigger is 'release' but no release tag could be resolved." + echo "::error::Provide the 'release-tag' input, or run on a release event so github.event.release.tag_name is set." + exit 1 fi - echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION}" >> $GITHUB_ENV + echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION}" >> "$GITHUB_ENV" echo "Release tag: ${RELEASE_TAG_VERSION}" echo "::endgroup::" @@ -243,16 +253,11 @@ runs: MIKE_ALIAS: ${{ inputs.mike-alias }} run: | echo "::group::Deploying release documentation" + # A resolvable release tag is guaranteed here: the "Set release tag" step + # fails fast (exit 1) when neither release-tag nor github.event.release.tag_name + # is set, so this deploy always has a real version. mike_push.sh handles the + # package-manager dispatch and retries the push on a concurrent gh-pages update. SCRIPT="${{ github.action_path }}/scripts/mike_push.sh" - if [ -n "$RELEASE_TAG_VERSION" ]; then - bash "$SCRIPT" deploy --push --update-aliases "$RELEASE_TAG_VERSION" "$MIKE_ALIAS" - else - # No resolvable release tag (e.g. a simulated/dispatch release): deploy - # the alias name as the version, keeping --update-aliases so mike may - # reassign a pre-existing alias of that name instead of erroring - # ("version 'latest' already exists"). Matches the prior unquoted form - # `mike deploy --push --update-aliases `. - bash "$SCRIPT" deploy --push --update-aliases "$MIKE_ALIAS" - fi + bash "$SCRIPT" deploy --push --update-aliases "$RELEASE_TAG_VERSION" "$MIKE_ALIAS" bash "$SCRIPT" set-default --push "$MIKE_ALIAS" echo "::endgroup::" diff --git a/actions/python-setup/pip/action.yml b/actions/python-setup/pip/action.yml index fc8588f..5c0ce25 100644 --- a/actions/python-setup/pip/action.yml +++ b/actions/python-setup/pip/action.yml @@ -44,10 +44,12 @@ runs: - name: Validate cache requirements if: inputs.cache != '' shell: bash + env: + CACHE: ${{ inputs.cache }} run: | echo "::group::Validating cache requirements" if [ ! -f pyproject.toml ] && [ ! -f requirements.txt ]; then - echo "::error::Cache is enabled (cache='${{ inputs.cache }}') but no pyproject.toml or requirements.txt found." + echo "::error::Cache is enabled (cache='${CACHE}') but no pyproject.toml or requirements.txt found." echo "::error::Either create a pyproject.toml/requirements.txt file or set cache: '' to disable caching." exit 1 fi @@ -63,28 +65,32 @@ runs: - name: Upgrade pip shell: bash + env: + INSTALL_GROUPS: ${{ inputs.install-groups }} run: | echo "::group::Upgrading pip" python -m pip install --upgrade pip pip_version=$(pip --version | awk '{print $2}') echo "pip upgraded to $pip_version" - if [[ "${{ inputs.install-groups }}" == *"groups:"* ]]; then + if [[ "$INSTALL_GROUPS" == *"groups:"* ]]; then echo "Note: installing PEP 735 dependency groups requires pip >= 25.1." fi echo "::endgroup::" - name: Install dependencies shell: bash + env: + INSTALL_GROUPS: ${{ inputs.install-groups }} run: | echo "::group::Installing dependencies" if [ -f pyproject.toml ]; then echo "Found pyproject.toml, installing package..." - + # Check if install-groups are specified - if [ -n "${{ inputs.install-groups }}" ]; then - echo "Processing install-groups: ${{ inputs.install-groups }}" - - input_str="${{ inputs.install-groups }}" + if [ -n "$INSTALL_GROUPS" ]; then + echo "Processing install-groups: $INSTALL_GROUPS" + + input_str="$INSTALL_GROUPS" dependency_groups=() optional_deps=() @@ -155,9 +161,9 @@ runs: extras_list="$extras_list,$extra" fi done - install_cmd="pip install .[$extras_list]" - echo "Installing package with optional dependencies: $install_cmd" - eval "$install_cmd" + install_target=".[$extras_list]" + echo "Installing package with optional dependencies: pip install $install_target" + pip install "$install_target" else echo "Installing package without optional dependencies..." pip install . @@ -166,9 +172,9 @@ runs: # Install dependency groups if any (requires pip with PEP 735 support) if [ ${#dependency_groups[@]} -gt 0 ]; then # Build command with multiple --group flags - dep_groups_cmd="pip install ." + dep_groups_args=() for group in "${dependency_groups[@]}"; do - dep_groups_cmd="$dep_groups_cmd --group $group" + dep_groups_args+=("--group" "$group") done # Dependency groups (pip install --group) are a PEP 735 feature @@ -181,8 +187,8 @@ runs: # unsupported branch is fatal. group_help="$(pip install --help)" if grep -q -- "--group" <<<"$group_help"; then - echo "Installing dependency groups: $dep_groups_cmd" - eval "$dep_groups_cmd" + echo "Installing dependency groups: pip install . ${dep_groups_args[*]}" + pip install . "${dep_groups_args[@]}" else groups_list=$(IFS=,; echo "${dependency_groups[*]}") echo "::error::pip $pip_version does not support the --group flag (PEP 735 dependency groups)." diff --git a/actions/python-setup/pixi/action.yml b/actions/python-setup/pixi/action.yml index 63e86f6..fe180d9 100644 --- a/actions/python-setup/pixi/action.yml +++ b/actions/python-setup/pixi/action.yml @@ -86,16 +86,20 @@ runs: - name: List environment info shell: bash + env: + ACTIVATE_ENV: ${{ inputs.activate-environment }} run: | echo "::group::Pixi environment information" - echo "Active environment: ${{ inputs.activate-environment }}" + echo "Active environment: $ACTIVATE_ENV" echo "Available environments:" pixi info echo "::endgroup::" - name: List installed packages shell: bash + env: + ACTIVATE_ENV: ${{ inputs.activate-environment }} run: | echo "::group::Installed packages summary" - pixi list -e ${{ inputs.activate-environment }} + pixi list -e "$ACTIVATE_ENV" echo "::endgroup::" \ No newline at end of file diff --git a/actions/python-setup/uv/action.yml b/actions/python-setup/uv/action.yml index b7092bd..a1f63da 100644 --- a/actions/python-setup/uv/action.yml +++ b/actions/python-setup/uv/action.yml @@ -75,6 +75,8 @@ runs: - name: Install Dependencies shell: bash + env: + INSTALL_GROUPS: ${{ inputs.install-groups }} run: | echo "::group::Preparing uv sync command" @@ -88,10 +90,10 @@ runs: installed_extras=() # Check if install-groups is provided - if [[ -n "${{ inputs.install-groups }}" ]]; then - echo "Processing install-groups: ${{ inputs.install-groups }}" - - input_str="${{ inputs.install-groups }}" + if [[ -n "$INSTALL_GROUPS" ]]; then + echo "Processing install-groups: $INSTALL_GROUPS" + + input_str="$INSTALL_GROUPS" # Extract groups section if present if [[ "$input_str" =~ groups:[[:space:]]*([^,]*)(,|$) ]]; then diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 25d24a9..4a48d2d 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -93,8 +93,10 @@ inputs: Note: When using pixi, specify only one environment name as it's used for both environments and activate-environment parameters. - required: false - default: 'groups: docs dev' + + Required: there is no default, so every caller must pass this explicitly + (pip/uv: a "groups:"/"extras:" string; pixi: a single environment name). + required: true config-file: description: | Path to a commitizen configuration file (pyproject.toml). @@ -151,27 +153,35 @@ runs: - name: Display release configuration shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + PACKAGE_MANAGER_VERSION: ${{ inputs.package-manager-version }} + PYTHON_VERSION: ${{ inputs.python-version }} + INCREMENT: ${{ inputs.increment }} + PRERELEASE_TYPE: ${{ inputs.prerelease-type }} + DRAFT: ${{ inputs.draft }} + INSTALL_GROUPS: ${{ inputs.install-groups }} run: | echo "::group::Release Configuration" echo "=== GitHub Release Action Configuration ===" - echo "✓ Package manager: ${{ inputs.package-manager }}" - if [ "${{ inputs.package-manager }}" != "pip" ]; then - echo "✓ Package manager version: ${{ inputs.package-manager-version }}" - fi - echo "✓ Python version: ${{ inputs.python-version }}" - echo "✓ Version increment: ${{ inputs.increment }}" - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - echo "✓ Prerelease type: ${{ inputs.prerelease-type }}" + echo "✓ Package manager: $PACKAGE_MANAGER" + if [ "$PACKAGE_MANAGER" != "pip" ]; then + echo "✓ Package manager version: $PACKAGE_MANAGER_VERSION" + fi + echo "✓ Python version: $PYTHON_VERSION" + echo "✓ Version increment: $INCREMENT" + if [ "$PRERELEASE_TYPE" != "none" ]; then + echo "✓ Prerelease type: $PRERELEASE_TYPE" else echo "✗ Prerelease type: none (standard release)" fi - if [ "${{ inputs.draft }}" = "true" ]; then + if [ "$DRAFT" = "true" ]; then echo "✓ Draft release: Yes" else echo "✗ Draft release: No (will be published)" fi - if [ -n "${{ inputs.install-groups }}" ]; then - echo "✓ Install groups: ${{ inputs.install-groups }}" + if [ -n "$INSTALL_GROUPS" ]; then + echo "✓ Install groups: $INSTALL_GROUPS" else echo "✗ Install groups: none (core dependencies only)" fi @@ -204,31 +214,26 @@ runs: - name: Configure Git shell: bash + env: + GIT_ACTOR: ${{ github.actor }} run: | echo "::group::Configuring Git for version bump" echo "Setting git user configuration..." - git config --global user.name '${{ github.actor }}' - git config --global user.email '${{ github.actor }}@users.noreply.github.com' - echo "✓ Git configured with user: ${{ github.actor }}" + git config --global user.name "$GIT_ACTOR" + git config --global user.email "${GIT_ACTOR}@users.noreply.github.com" + echo "✓ Git configured with user: $GIT_ACTOR" echo "::endgroup::" - name: Validate changelog file shell: bash + env: + CONFIG_FILE: ${{ inputs.config-file }} run: | echo "::group::Validating changelog file" - # Build commitizen command based on package manager - if [ "${{ inputs.package-manager }}" = "pip" ]; then - CZ_CMD="cz" - elif [ "${{ inputs.package-manager }}" = "pixi" ]; then - CZ_CMD="pixi run -e ${{ inputs.install-groups }} cz" - else - CZ_CMD="${{ inputs.package-manager }} run cz" - fi - # Determine and enter working directory - if [ -n "${{ inputs.config-file }}" ]; then - WORK_DIR=$(dirname "${{ inputs.config-file }}") + if [ -n "$CONFIG_FILE" ]; then + WORK_DIR=$(dirname "$CONFIG_FILE") else WORK_DIR="." fi @@ -295,34 +300,51 @@ runs: - name: Generate changelog and bump version with commitizen id: bump_version shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + INSTALL_GROUPS: ${{ inputs.install-groups }} + CONFIG_FILE: ${{ inputs.config-file }} + INCREMENT: ${{ inputs.increment }} + PRERELEASE_TYPE: ${{ inputs.prerelease-type }} run: | echo "::group::Preparing commitizen command" - # Build commitizen command based on package manager - if [ "${{ inputs.package-manager }}" = "pip" ]; then - CZ_CMD="cz" - echo "Using pip package manager" - echo "✓ Commitizen command: cz" - elif [ "${{ inputs.package-manager }}" = "pixi" ]; then - CZ_CMD="pixi run -e ${{ inputs.install-groups }} cz" - echo "Using pixi package manager" - echo "✓ Commitizen command: pixi run -e ${{ inputs.install-groups }} cz" + # Build the commitizen command as an argv array based on package manager + if [ "$PACKAGE_MANAGER" = "pip" ]; then + CZ_CMD=(cz) + elif [ "$PACKAGE_MANAGER" = "pixi" ]; then + CZ_CMD=(pixi run -e "$INSTALL_GROUPS" cz) else - CZ_CMD="${{ inputs.package-manager }} run cz" - echo "Using ${{ inputs.package-manager }} package manager" - echo "✓ Commitizen command: ${{ inputs.package-manager }} run cz" + CZ_CMD=("$PACKAGE_MANAGER" run cz) fi + echo "Using $PACKAGE_MANAGER package manager" + echo "✓ Commitizen command: ${CZ_CMD[*]}" echo "::endgroup::" # Determine and enter working directory - if [ -n "${{ inputs.config-file }}" ]; then - WORK_DIR=$(dirname "${{ inputs.config-file }}") + if [ -n "$CONFIG_FILE" ]; then + WORK_DIR=$(dirname "$CONFIG_FILE") else WORK_DIR="." fi echo "Working directory: $WORK_DIR" cd "$WORK_DIR" + # Preflight (run from WORK_DIR — the same directory every cz command below + # uses). install-groups is required with no default; if commitizen is not + # runnable here, fail with a clear message instead of a downstream + # "cz: not found" / "pixi run -e ''". For uv this rarely fires (uv run + # re-syncs the default groups); it mainly guards pip (PATH) and commitizen + # placed in a non-default group. stderr is left visible so a real + # sync/network failure is not misreported as "commitizen missing". + echo "::group::Checking commitizen availability" + if ! "${CZ_CMD[@]}" version >/dev/null; then + echo "::error::commitizen (cz) could not be run. Ensure 'install-groups' includes the group that provides commitizen (e.g. 'groups: dev'), or that commitizen is a core dependency (see the error above)." + exit 1 + fi + echo "✓ commitizen is available" + echo "::endgroup::" + # Read changelog_file from commitizen config (needed for recovery path) CHANGELOG_FILE="" if [ -f "pyproject.toml" ]; then @@ -356,7 +378,7 @@ runs: fi echo "::group::Getting current version" - CURRENT_VERSION=$($CZ_CMD version --project) + CURRENT_VERSION=$("${CZ_CMD[@]}" version --project) echo "✓ Current version: $CURRENT_VERSION" echo "::endgroup::" @@ -377,15 +399,15 @@ runs: fi echo "::group::Calculating next version" - echo "Increment type: ${{ inputs.increment }}" - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - echo "Prerelease type: ${{ inputs.prerelease-type }}" + echo "Increment type: $INCREMENT" + if [ "$PRERELEASE_TYPE" != "none" ]; then + echo "Prerelease type: $PRERELEASE_TYPE" echo "Running dry-run to calculate version..." - NEXT_VERSION=$($CZ_CMD bump --dry-run --yes --increment ${{ inputs.increment }} --prerelease ${{ inputs.prerelease-type }} | grep "tag to create" | awk '{print $NF}') + NEXT_VERSION=$("${CZ_CMD[@]}" bump --dry-run --yes --increment "$INCREMENT" --prerelease "$PRERELEASE_TYPE" | grep "tag to create" | awk '{print $NF}') else echo "Prerelease type: none (standard release)" echo "Running dry-run to calculate version..." - NEXT_VERSION=$($CZ_CMD bump --dry-run --yes --increment ${{ inputs.increment }} | grep "tag to create" | awk '{print $NF}') + NEXT_VERSION=$("${CZ_CMD[@]}" bump --dry-run --yes --increment "$INCREMENT" | grep "tag to create" | awk '{print $NF}') fi echo "✓ Next version will be: $NEXT_VERSION" echo " Version change: $CURRENT_VERSION → $NEXT_VERSION" @@ -396,7 +418,7 @@ runs: # cz bump includes it in the version-bump commit. if [ "$IS_FIRST_RELEASE" = "true" ]; then echo "::group::Generating initial changelog" - $CZ_CMD changelog --unreleased-version="$NEXT_VERSION" + "${CZ_CMD[@]}" changelog --unreleased-version="$NEXT_VERSION" git add "$CHANGELOG_FILE" echo "✓ Initial changelog generated and staged" echo "::endgroup::" @@ -408,11 +430,11 @@ runs: # Run cz bump - it handles changelog generation automatically # If it fails, we'll recover by generating full changelog and retrying set +e # Don't exit on error - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - $CZ_CMD bump --yes --increment ${{ inputs.increment }} --prerelease ${{ inputs.prerelease-type }} + if [ "$PRERELEASE_TYPE" != "none" ]; then + "${CZ_CMD[@]}" bump --yes --increment "$INCREMENT" --prerelease "$PRERELEASE_TYPE" BUMP_EXIT_CODE=$? else - $CZ_CMD bump --yes --increment ${{ inputs.increment }} + "${CZ_CMD[@]}" bump --yes --increment "$INCREMENT" BUMP_EXIT_CODE=$? fi set -e # Re-enable exit on error @@ -426,7 +448,7 @@ runs: git checkout -- "$CHANGELOG_FILE" # Generate full changelog - $CZ_CMD changelog --unreleased-version="$NEXT_VERSION" + "${CZ_CMD[@]}" changelog --unreleased-version="$NEXT_VERSION" # cz bump --files-only still triggers an incremental changelog update when # update_changelog_on_bump = true, and that update fails with exit 16 @@ -440,10 +462,10 @@ runs: fi # Retry bump with --files-only (skip changelog since we just generated it) - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - $CZ_CMD bump --yes --increment ${{ inputs.increment }} --prerelease ${{ inputs.prerelease-type }} --files-only + if [ "$PRERELEASE_TYPE" != "none" ]; then + "${CZ_CMD[@]}" bump --yes --increment "$INCREMENT" --prerelease "$PRERELEASE_TYPE" --files-only else - $CZ_CMD bump --yes --increment ${{ inputs.increment }} --files-only + "${CZ_CMD[@]}" bump --yes --increment "$INCREMENT" --files-only fi if [ "$TOGGLED_CL" = "true" ]; then @@ -479,9 +501,9 @@ runs: echo "=== Version Bump Summary ===" echo "Previous version: $CURRENT_VERSION" echo "New version: $NEW_VERSION" - echo "Increment type: ${{ inputs.increment }}" - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - echo "Prerelease: ${{ inputs.prerelease-type }}" + echo "Increment type: $INCREMENT" + if [ "$PRERELEASE_TYPE" != "none" ]; then + echo "Prerelease: $PRERELEASE_TYPE" fi echo "==========================" echo "::endgroup::" @@ -489,6 +511,9 @@ runs: - name: Fold refreshed lockfile into the version-bump commit if: ${{ inputs.package-manager == 'uv' || inputs.package-manager == 'pixi' }} shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + NEW_VERSION: ${{ steps.bump_version.outputs.NEW_VERSION }} run: | echo "::group::Folding refreshed lockfile into the version-bump commit" @@ -497,8 +522,6 @@ runs: REPO_ROOT=$(git rev-parse --show-toplevel) cd "$REPO_ROOT" - NEW_VERSION="${{ steps.bump_version.outputs.NEW_VERSION }}" - # Safety gate: only fold when HEAD is the commit the new tag points to. # If the bump step's recovery path ran (cz bump --files-only, which does # not commit or tag) or anything else left HEAD off the bump commit, @@ -517,7 +540,7 @@ runs: fi # Pick the lockfile + command for the active package manager. - case "${{ inputs.package-manager }}" in + case "$PACKAGE_MANAGER" in uv) LOCK_FILE="uv.lock" ; LOCK_CMD="uv lock" ;; pixi) LOCK_FILE="pixi.lock" ; LOCK_CMD="pixi lock" ;; esac @@ -558,6 +581,8 @@ runs: - name: Push changes and tags shell: bash + env: + NEW_VERSION: ${{ steps.bump_version.outputs.NEW_VERSION }} run: | echo "::group::Pushing changes and tags to repository" @@ -580,25 +605,28 @@ runs: echo "=== Git Push Summary ===" echo "✓ Version bump committed and pushed" echo "✓ Git tag created and pushed" - echo " Tag: ${{ steps.bump_version.outputs.NEW_VERSION }}" + echo " Tag: $NEW_VERSION" echo "=======================" echo "::endgroup::" - name: Create GitHub Release shell: bash + env: + NEW_VERSION: ${{ steps.bump_version.outputs.NEW_VERSION }} + DRAFT: ${{ inputs.draft }} run: | echo "::group::Creating GitHub Release" echo "Release details:" - echo " Version: ${{ steps.bump_version.outputs.NEW_VERSION }}" - echo " Tag: ${{ steps.bump_version.outputs.NEW_VERSION }}" - if [ "${{ inputs.draft }}" = "true" ]; then + echo " Version: $NEW_VERSION" + echo " Tag: $NEW_VERSION" + if [ "$DRAFT" = "true" ]; then echo " Type: Draft release" else echo " Type: Published release" fi # Check if this is a prerelease - VERSION="${{ steps.bump_version.outputs.NEW_VERSION }}" + VERSION="$NEW_VERSION" if [[ "$VERSION" == *"-"* ]] || [[ "$VERSION" == *"alpha"* ]] || [[ "$VERSION" == *"beta"* ]] || [[ "$VERSION" == *"rc"* ]]; then echo " Prerelease: Yes" else @@ -609,11 +637,13 @@ runs: - name: Extract release notes from changelog id: release_notes shell: bash + env: + NEW_VERSION: ${{ steps.bump_version.outputs.NEW_VERSION }} + CHANGELOG_FILE: ${{ steps.bump_version.outputs.CHANGELOG_FILE }} + WORK_DIR: ${{ steps.bump_version.outputs.WORK_DIR }} run: | echo "::group::Extracting release notes from changelog" - VERSION="${{ steps.bump_version.outputs.NEW_VERSION }}" - CHANGELOG_FILE="${{ steps.bump_version.outputs.CHANGELOG_FILE }}" - WORK_DIR="${{ steps.bump_version.outputs.WORK_DIR }}" + VERSION="$NEW_VERSION" cd "$WORK_DIR" echo "Extracting notes for version $VERSION from $CHANGELOG_FILE" @@ -645,7 +675,7 @@ runs: - name: Publish GitHub Release if: ${{ inputs.skip-github-release != 'true' }} - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: token: ${{ inputs.github-token }} name: ${{ steps.bump_version.outputs.NEW_VERSION }} @@ -656,6 +686,13 @@ runs: - name: Release summary shell: bash + env: + INCREMENT: ${{ inputs.increment }} + PRERELEASE_TYPE: ${{ inputs.prerelease-type }} + DRAFT: ${{ inputs.draft }} + NEW_VERSION: ${{ steps.bump_version.outputs.NEW_VERSION }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} run: | echo "::group::Release Summary" echo "" @@ -663,18 +700,18 @@ runs: echo " GitHub Release Created" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - echo "✓ Version: ${{ steps.bump_version.outputs.NEW_VERSION }}" - echo "✓ Increment: ${{ inputs.increment }}" - if [ "${{ inputs.prerelease-type }}" != "none" ]; then - echo "✓ Prerelease: ${{ inputs.prerelease-type }}" + echo "✓ Version: $NEW_VERSION" + echo "✓ Increment: $INCREMENT" + if [ "$PRERELEASE_TYPE" != "none" ]; then + echo "✓ Prerelease: $PRERELEASE_TYPE" fi - if [ "${{ inputs.draft }}" = "true" ]; then + if [ "$DRAFT" = "true" ]; then echo "✓ Status: Draft (not published)" else echo "✓ Status: Published" fi echo "" - echo "View release: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.bump_version.outputs.NEW_VERSION }}" + echo "View release: ${SERVER_URL}/${REPOSITORY}/releases/tag/${NEW_VERSION}" echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "::endgroup::" \ No newline at end of file diff --git a/actions/release/pypi/action.yml b/actions/release/pypi/action.yml index 28598fa..287e3d9 100644 --- a/actions/release/pypi/action.yml +++ b/actions/release/pypi/action.yml @@ -83,26 +83,32 @@ runs: steps: - name: Display release configuration shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + PYTHON_VERSION: ${{ inputs.python-version }} + INSTALL_GROUPS: ${{ inputs.install-groups }} + PACKAGE: ${{ inputs.package }} + PYPI_REPOSITORY_URL: ${{ inputs.pypi-repository-url }} run: | echo "::group::PyPI Release Configuration" echo "=== PyPI Release Action Configuration ===" - echo "✓ Package manager: ${{ inputs.package-manager }}" - echo "✓ Python version: ${{ inputs.python-version }}" - if [ -n "${{ inputs.install-groups }}" ]; then - echo "✓ Install groups: ${{ inputs.install-groups }}" + echo "✓ Package manager: $PACKAGE_MANAGER" + echo "✓ Python version: $PYTHON_VERSION" + if [ -n "$INSTALL_GROUPS" ]; then + echo "✓ Install groups: $INSTALL_GROUPS" else echo "✗ Install groups: none (core dependencies only)" fi - if [ -n "${{ inputs.package }}" ]; then - echo "✓ Target package: ${{ inputs.package }}" + if [ -n "$PACKAGE" ]; then + echo "✓ Target package: $PACKAGE" else echo "✗ Target package: not set (whole workspace / single-package repo)" fi # pypi configs - if [ -n "${{ inputs.pypi-repository-url }}" ]; then - echo "✓ PyPI repository: ${{ inputs.pypi-repository-url }}" + if [ -n "$PYPI_REPOSITORY_URL" ]; then + echo "✓ PyPI repository: $PYPI_REPOSITORY_URL" else echo "✓ PyPI repository: Official PyPI (https://pypi.org)" fi @@ -142,6 +148,10 @@ runs: - name: Build package shell: bash + env: + PACKAGE_MANAGER: ${{ inputs.package-manager }} + PACKAGE: ${{ inputs.package }} + INSTALL_GROUPS: ${{ inputs.install-groups }} run: | echo "::group::Building package" @@ -155,34 +165,34 @@ runs: | xargs dirname } - if [ "${{ inputs.package-manager }}" = "pip" ]; then - if [ -n "${{ inputs.package }}" ]; then - PKG_DIR=$(find_pkg_dir "${{ inputs.package }}") + if [ "$PACKAGE_MANAGER" = "pip" ]; then + if [ -n "$PACKAGE" ]; then + PKG_DIR=$(find_pkg_dir "$PACKAGE") if [ -z "$PKG_DIR" ]; then - echo "::error::Package '${{ inputs.package }}' not found in workspace (no pyproject.toml with name = \"${{ inputs.package }}\")" + echo "::error::Package '$PACKAGE' not found in workspace (no pyproject.toml with name = \"$PACKAGE\")" exit 1 fi - echo "Building workspace package '${{ inputs.package }}' from: $PKG_DIR" + echo "Building workspace package '$PACKAGE' from: $PKG_DIR" python -m build "$PKG_DIR" --outdir dist/ else python -m build fi - elif [ "${{ inputs.package-manager }}" = "uv" ]; then - if [ -n "${{ inputs.package }}" ]; then - uv build --package "${{ inputs.package }}" + elif [ "$PACKAGE_MANAGER" = "uv" ]; then + if [ -n "$PACKAGE" ]; then + uv build --package "$PACKAGE" else uv build fi else - PIXI_ENV="${{ inputs.install-groups }}" + PIXI_ENV="$INSTALL_GROUPS" PIXI_ENV="${PIXI_ENV:-default}" - if [ -n "${{ inputs.package }}" ]; then - PKG_DIR=$(find_pkg_dir "${{ inputs.package }}") + if [ -n "$PACKAGE" ]; then + PKG_DIR=$(find_pkg_dir "$PACKAGE") if [ -z "$PKG_DIR" ]; then - echo "::error::Package '${{ inputs.package }}' not found in workspace (no pyproject.toml with name = \"${{ inputs.package }}\")" + echo "::error::Package '$PACKAGE' not found in workspace (no pyproject.toml with name = \"$PACKAGE\")" exit 1 fi - echo "Building workspace package '${{ inputs.package }}' from: $PKG_DIR" + echo "Building workspace package '$PACKAGE' from: $PKG_DIR" pixi run -e "$PIXI_ENV" python -m build "$PKG_DIR" --outdir dist/ else pixi run -e "$PIXI_ENV" python -m build @@ -200,23 +210,26 @@ runs: env: TWINE_USERNAME: ${{ inputs.pypi-username }} TWINE_PASSWORD: ${{ inputs.pypi-password }} + PACKAGE_MANAGER: ${{ inputs.package-manager }} + INSTALL_GROUPS: ${{ inputs.install-groups }} + PYPI_REPOSITORY_URL: ${{ inputs.pypi-repository-url }} run: | echo "::group::Publishing package to PyPI" # Build optional repository URL argument - REPO_ARGS="" - if [ -n "${{ inputs.pypi-repository-url }}" ]; then - REPO_ARGS="--repository-url ${{ inputs.pypi-repository-url }}" + REPO_ARGS=() + if [ -n "$PYPI_REPOSITORY_URL" ]; then + REPO_ARGS=(--repository-url "$PYPI_REPOSITORY_URL") fi - if [ "${{ inputs.package-manager }}" = "pip" ]; then - twine upload $REPO_ARGS dist/* - elif [ "${{ inputs.package-manager }}" = "uv" ]; then - uvx twine upload $REPO_ARGS dist/* + if [ "$PACKAGE_MANAGER" = "pip" ]; then + twine upload "${REPO_ARGS[@]}" dist/* + elif [ "$PACKAGE_MANAGER" = "uv" ]; then + uvx twine upload "${REPO_ARGS[@]}" dist/* else - PIXI_ENV="${{ inputs.install-groups }}" + PIXI_ENV="$INSTALL_GROUPS" PIXI_ENV="${PIXI_ENV:-default}" - pixi run -e "$PIXI_ENV" twine upload $REPO_ARGS dist/* + pixi run -e "$PIXI_ENV" twine upload "${REPO_ARGS[@]}" dist/* fi echo "✓ Package published successfully" @@ -224,6 +237,9 @@ runs: - name: Release summary shell: bash + env: + SKIP_PUBLISH: ${{ inputs.skip-publish }} + PYPI_REPOSITORY_URL: ${{ inputs.pypi-repository-url }} run: | echo "::group::PyPI Release Summary" echo "" @@ -232,12 +248,12 @@ runs: echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "✓ Package built successfully" - if [ "${{ inputs.skip-publish }}" = "true" ]; then + if [ "$SKIP_PUBLISH" = "true" ]; then echo "✗ Publish: Skipped (skip-publish=true)" else echo "✓ Package published to PyPI" - if [ -n "${{ inputs.pypi-repository-url }}" ]; then - echo "✓ Repository: ${{ inputs.pypi-repository-url }}" + if [ -n "$PYPI_REPOSITORY_URL" ]; then + echo "✓ Repository: $PYPI_REPOSITORY_URL" else echo "✓ Repository: Official PyPI (https://pypi.org)" fi diff --git a/docs/getting-started.md b/docs/getting-started.md index 276a1b0..cbc4668 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -146,6 +146,7 @@ jobs: - uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: ${{ inputs.increment }} ``` diff --git a/docs/reference/release-github.md b/docs/reference/release-github.md index 1f534ef..50061cf 100644 --- a/docs/reference/release-github.md +++ b/docs/reference/release-github.md @@ -13,7 +13,7 @@ draft: 'false' package-manager: 'uv' python-version: '3.12' - install-groups: 'groups: docs dev' + install-groups: # required config-file: '' skip-github-release: 'false' ``` @@ -29,6 +29,6 @@ | `package-manager` | Package manager to use for setting up Python environment before release | No | `uv` | | `package-manager-version` | Version of the uv/pixi package manager to install (forwarded to its `version` input); ignored for pip. | No | `latest` | | `python-version` | Python version to install (passed to actions/setup-python) | No | `3.12` | -| `install-groups` | Dependency groups or environments to install | No | `groups: docs dev` | +| `install-groups` | Dependency groups or environments to install | Yes | - | | `config-file` | Path to a commitizen configuration file (pyproject.toml) | No | - | | `skip-github-release` | Skip creating the GitHub release via API | No | `false` | diff --git a/docs/release/github/EXAMPLES.md b/docs/release/github/EXAMPLES.md index 6315795..2d97eed 100644 --- a/docs/release/github/EXAMPLES.md +++ b/docs/release/github/EXAMPLES.md @@ -40,6 +40,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' ``` **Behavior:** @@ -90,6 +91,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: ${{ inputs.increment }} ``` @@ -193,6 +195,7 @@ release = "cz bump" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'patch' ``` @@ -213,6 +216,7 @@ git commit -m "fix: patch memory leak" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'minor' ``` @@ -233,6 +237,7 @@ git commit -m "feat: implement OAuth login" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'major' ``` @@ -259,6 +264,7 @@ BREAKING CHANGE: The old auth methods are no longer supported" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'patch' prerelease-type: 'alpha' ``` @@ -277,6 +283,7 @@ BREAKING CHANGE: The old auth methods are no longer supported" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'minor' prerelease-type: 'beta' ``` @@ -295,6 +302,7 @@ BREAKING CHANGE: The old auth methods are no longer supported" uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'major' prerelease-type: 'rc' ``` @@ -338,6 +346,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'minor' prerelease-type: ${{ inputs.stage }} draft: 'false' @@ -350,6 +359,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'minor' prerelease-type: 'none' ``` @@ -365,6 +375,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' release-branch: 'develop' increment: 'patch' ``` @@ -376,6 +387,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' release-branch: 'release/v2.0' increment: 'patch' ``` @@ -408,6 +420,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' release-branch: ${{ inputs.branch }} increment: 'patch' ``` @@ -506,6 +519,7 @@ dependencies = [ uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'minor' draft: 'true' ``` @@ -535,6 +549,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' draft: 'true' - name: Notify Team @@ -606,6 +621,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: ${{ inputs.increment }} package-manager: 'uv' python-version: '3.12' @@ -656,6 +672,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'patch' prerelease-type: 'beta' @@ -701,6 +718,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' python-version: '3.12' # Use latest for release increment: 'patch' ``` @@ -746,6 +764,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' release-branch: ${{ github.ref_name }} increment: ${{ steps.type.outputs.increment }} prerelease-type: ${{ steps.type.outputs.prerelease }} @@ -825,6 +844,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: ${{ inputs.increment }} config-file: '${{ inputs.package }}/pyproject.toml' ``` @@ -842,6 +862,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' increment: 'patch' ``` @@ -930,6 +951,7 @@ git tag -d 1.2.3 # Delete local tag uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' draft: 'true' # Safe to retry ``` @@ -967,6 +989,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' ``` ### Hotfix Release Pattern @@ -992,6 +1015,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' release-branch: ${{ github.ref_name }} increment: 'patch' draft: 'false' @@ -1047,6 +1071,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' - name: Upload Release Assets uses: softprops/action-gh-release@v2 @@ -1089,6 +1114,7 @@ jobs: uses: serapeum-org/github-actions/actions/release/github@github-release/v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} + install-groups: 'groups: dev' ``` --- diff --git a/docs/release/github/README.md b/docs/release/github/README.md index e9ab391..8d2e216 100644 --- a/docs/release/github/README.md +++ b/docs/release/github/README.md @@ -74,7 +74,7 @@ jobs: | `package-manager` | Package manager: `pip`, `uv`, `pixi` | No | `uv` | | `package-manager-version` | Version of the `uv`/`pixi` package manager (forwarded to its `version` input); ignored for `pip`. | No | `latest` | | `python-version` | Python version to install | No | `3.12` | -| `install-groups` | Dependency groups to install (see below) | No | `groups: docs dev` | +| `install-groups` | Dependency groups to install (see below) — **required, no default**; must include the group that provides commitizen | Yes | _(none)_ | | `config-file` | Path to a per-package `pyproject.toml` (monorepo use) | No | `''` (root) | | `skip-github-release` | Skip GitHub Releases API call (testing only) | No | `false` |