From 879a5dbc9c7ca5e90d21abbaae2f2cee5e992aed Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Mon, 20 Jul 2026 21:49:45 +0200 Subject: [PATCH 01/15] fix(actions): pass untrusted context through env in composite actions Interpolating ${{ ... }} expressions directly into a run: body substitutes the value into the script text before bash runs, so a value with shell metacharacters (command substitution, ;, |, backticks) executes as code. - mkdocs-deploy: read github.event.release.tag_name and the mike alias via env instead of inline interpolation, and quote them. - python-setup/pip: drop eval for the extras/groups install (build argv arrays) and read install-groups/cache via env. - python-setup/uv, pixi: read install-groups / activate-environment via env and quote the value passed to pixi list. - release/pypi: read package, package-manager, install-groups, repository-url and skip-publish via env; build the twine --repository-url argument as an array. - deploy-docs workflow: set the git user from github.actor via env. The generated commands are unchanged. --- .github/workflows/deploy-docs.yml | 6 +- actions/mkdocs-deploy/action.yml | 26 +++++---- actions/python-setup/pip/action.yml | 34 +++++++----- actions/python-setup/pixi/action.yml | 8 ++- actions/python-setup/uv/action.yml | 10 ++-- actions/release/pypi/action.yml | 82 +++++++++++++++++----------- 6 files changed, 101 insertions(+), 65 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 0fcd5f8..0b8b988 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -34,9 +34,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/actions/mkdocs-deploy/action.yml b/actions/mkdocs-deploy/action.yml index e8e8735..f7c2f9c 100644 --- a/actions/mkdocs-deploy/action.yml +++ b/actions/mkdocs-deploy/action.yml @@ -189,23 +189,28 @@ 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 - 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::" @@ -247,13 +252,14 @@ runs: env: ACTIONS_DEPLOY_TOKEN: ${{ inputs.deploy-token }} PACKAGE_MANAGER: ${{ inputs.package-manager }} + MIKE_ALIAS: ${{ inputs.mike-alias }} run: | echo "::group::Deploying release documentation" if [ "$PACKAGE_MANAGER" = "pip" ]; then - mike deploy --push --update-aliases ${RELEASE_TAG_VERSION} ${{ inputs.mike-alias }} - mike set-default --push ${{ inputs.mike-alias }} + mike deploy --push --update-aliases "${RELEASE_TAG_VERSION}" "$MIKE_ALIAS" + mike set-default --push "$MIKE_ALIAS" else - $PACKAGE_MANAGER run mike deploy --push --update-aliases ${RELEASE_TAG_VERSION} ${{ inputs.mike-alias }} - $PACKAGE_MANAGER run mike set-default --push ${{ inputs.mike-alias }} + $PACKAGE_MANAGER run mike deploy --push --update-aliases "${RELEASE_TAG_VERSION}" "$MIKE_ALIAS" + $PACKAGE_MANAGER run mike set-default --push "$MIKE_ALIAS" fi echo "::endgroup::" \ No newline at end of file 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/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 From 8cef44a2a133f487b3191bae424bb18049f206eb Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Mon, 20 Jul 2026 21:49:55 +0200 Subject: [PATCH 02/15] chore(release/github): pin action-gh-release to a SHA and set actor via env - Pin softprops/action-gh-release to commit 3d0d988 (v3.0.2) instead of the mutable @v3 tag. This action receives the release token, so a moved tag would run untrusted code with it. Matches the SHA pinning already used for setup-uv and setup-pixi. - Configure the git user from github.actor via env rather than inline interpolation. --- actions/release/github/action.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 5da34d7..584c132 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -204,12 +204,14 @@ 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 @@ -599,7 +601,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 }} From f304da66cfb2271531ab7988c4f4ebef4078fea3 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Mon, 20 Jul 2026 21:49:55 +0200 Subject: [PATCH 03/15] ci: least-privilege token scope and pin setup-uv in test workflows - test-mkdocs-deploy, test-release/github: default the workflow token to contents: read. The jobs push only to the local fake remote, so no write scope is needed. - test-release/pypi: pin astral-sh/setup-uv to the SHA already used by the uv action (v8.3.2) instead of the mutable @v7 tag. --- .github/workflows/test-mkdocs-deploy.yml | 3 +-- .github/workflows/test-release-github.yml | 2 +- .github/workflows/test-release-pypi.yml | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-mkdocs-deploy.yml b/.github/workflows/test-mkdocs-deploy.yml index 5335eed..ceeb80d 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: diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 00f4ff4..3544c43 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read jobs: # Test basic functionality with different package managers 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 From 7f04c4e8bc346dd8b3c078b84d35c9eb59ce1f20 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Mon, 20 Jul 2026 23:17:49 +0200 Subject: [PATCH 04/15] fix(mkdocs-deploy): fail fast when a release trigger has no resolvable tag Quoting RELEASE_TAG_VERSION in the mike deploy command (part of the injection hardening) exposed a latent bug: when the release tag resolves to empty, the previous unquoted expansion silently collapsed the empty word and shifted the mike alias into the version slot, deploying a bogus version. Quoted, it now passes an empty version and mike errors with '' is not a valid version. Add an explicit guard so a release trigger with no resolvable tag (no release-tag input and no github.event.release.tag_name) fails fast with a clear message, and convert the corresponding test into a negative test that asserts this failure. --- .github/workflows/test-mkdocs-deploy.yml | 16 +++++++++++++++- actions/mkdocs-deploy/action.yml | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-mkdocs-deploy.yml b/.github/workflows/test-mkdocs-deploy.yml index ceeb80d..d49b327 100644 --- a/.github/workflows/test-mkdocs-deploy.yml +++ b/.github/workflows/test-mkdocs-deploy.yml @@ -368,7 +368,13 @@ jobs: release-tag: 'v4.5.6' deploy-token: ${{ secrets.GITHUB_TOKEN }} - - name: Test without explicit release tag (simulated) + # Negative test: a release trigger with no explicit release-tag and no real + # release event (github.event.release.tag_name is empty) cannot resolve a + # version, so the action must fail fast with a clear error rather than + # deploy a bogus version. + - name: Test release trigger without a resolvable tag fails fast + id: no-tag + continue-on-error: true env: GITHUB_EVENT_NAME: release uses: ./actions/mkdocs-deploy @@ -377,6 +383,14 @@ jobs: package-manager: 'uv' deploy-token: ${{ secrets.GITHUB_TOKEN }} + - name: Verify the unresolvable-tag deploy failed + run: | + if [ "${{ steps.no-tag.outcome }}" != "failure" ]; then + echo "::error::Expected the release deploy to fail when no release tag can be resolved" + exit 1 + fi + echo "[OK] Release trigger correctly fails fast when no tag is resolvable" + test-default-install-groups: name: Test Default install-groups Value runs-on: ubuntu-latest diff --git a/actions/mkdocs-deploy/action.yml b/actions/mkdocs-deploy/action.yml index f7c2f9c..1fd3c9d 100644 --- a/actions/mkdocs-deploy/action.yml +++ b/actions/mkdocs-deploy/action.yml @@ -210,6 +210,11 @@ runs: else 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: ${RELEASE_TAG_VERSION}" echo "::endgroup::" From 9e83361e4a1720e4803cb3c9c08043471b7b8a59 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 18:22:00 +0200 Subject: [PATCH 05/15] test(mkdocs-deploy): assert tag presence flips release success/failure The negative test previously asserted only outcome == failure, so it could go green even if the action failed for a reason other than the tag-resolution guard. Pair it with a positive control (same fixture/setup, WITH a tag) and assert that adding the tag is exactly what flips failure to success. Also drop the inert GITHUB_EVENT_NAME env (the guard reads github.event.release.tag_name from the real push event, not that variable). --- .github/workflows/test-mkdocs-deploy.yml | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-mkdocs-deploy.yml b/.github/workflows/test-mkdocs-deploy.yml index d49b327..b4bda4a 100644 --- a/.github/workflows/test-mkdocs-deploy.yml +++ b/.github/workflows/test-mkdocs-deploy.yml @@ -360,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' @@ -368,28 +371,36 @@ jobs: release-tag: 'v4.5.6' deploy-token: ${{ secrets.GITHUB_TOKEN }} - # Negative test: a release trigger with no explicit release-tag and no real - # release event (github.event.release.tag_name is empty) cannot resolve a - # version, so the action must fail fast with a clear error rather than - # deploy a bogus version. + # 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 - env: - GITHUB_EVENT_NAME: release uses: ./actions/mkdocs-deploy with: trigger: 'release' package-manager: 'uv' deploy-token: ${{ secrets.GITHUB_TOKEN }} - - name: Verify the unresolvable-tag deploy failed + # 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::Expected the release deploy to fail when no release tag can be resolved" + echo "::error::A release deploy WITHOUT a resolvable tag should fail fast in the guard" exit 1 fi - echo "[OK] Release trigger correctly fails fast when no tag is resolvable" + echo "[OK] Tag presence flips success/failure as expected" test-default-install-groups: name: Test Default install-groups Value From a70a7e4f5b13d7c6e5016e661dd2e5cbea5c75d5 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 18:22:31 +0200 Subject: [PATCH 06/15] ci(deploy-docs): drop unused pages: write permission mike deploys by pushing to the gh-pages branch over git (contents: write); it never calls the GitHub Pages deployment API, so pages: write was unused. Drop it for least privilege. --- .github/workflows/deploy-docs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 0b8b988..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: From 1ac327e0728c08b72cbcce149188e0a757acc441 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 18:22:31 +0200 Subject: [PATCH 07/15] ci(test-release-github): note contents: read relies on skip-github-release Document that the reduced token scope is safe only because every job sets skip-github-release: 'true' (Releases API never called) and pushes go to the fake remote, so a future real-release job would need contents: write. --- .github/workflows/test-release-github.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 3544c43..5b8c5ca 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -5,6 +5,10 @@ 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: read From 510fc7f7f06d18df044f9c55e5611cca65e40bc4 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 18:22:40 +0200 Subject: [PATCH 08/15] style(mkdocs-deploy): add trailing newline at end of file --- actions/mkdocs-deploy/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/mkdocs-deploy/action.yml b/actions/mkdocs-deploy/action.yml index 1fd3c9d..5fbbbdf 100644 --- a/actions/mkdocs-deploy/action.yml +++ b/actions/mkdocs-deploy/action.yml @@ -267,4 +267,4 @@ runs: $PACKAGE_MANAGER run mike deploy --push --update-aliases "${RELEASE_TAG_VERSION}" "$MIKE_ALIAS" $PACKAGE_MANAGER run mike set-default --push "$MIKE_ALIAS" fi - echo "::endgroup::" \ No newline at end of file + echo "::endgroup::" From 549d27251e5e0514ede61063275e85825711c705 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 20:36:34 +0200 Subject: [PATCH 09/15] fix(release/github): env-scope caller inputs in run blocks to close injection (SEC-8) Mirror the hardening applied to the sibling actions: move every caller-controlled input referenced inside a run: body (package-manager, install-groups, config-file, increment, prerelease-type, draft, package-manager-version, python-version) into an env: block and reference the quoted variable. Convert the commitizen command from an interpolated string (CZ_CMD="pixi run -e cz", executed via $CZ_CMD) into an argv array ("${CZ_CMD[@]}"), and drop the dead CZ_CMD build in the changelog- validation step. This removes the two real injection sinks (unquoted install-groups in an executed command string, and config-file inside dirname "$(...)") plus the free-form increment/prerelease-type flags. No behavior change for valid inputs; the generated commands are identical. Closes #29 --- actions/release/github/action.yml | 129 ++++++++++++++++-------------- 1 file changed, 70 insertions(+), 59 deletions(-) diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 584c132..642b5b7 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -151,27 +151,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 @@ -216,21 +224,14 @@ runs: - 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 @@ -274,28 +275,30 @@ 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 @@ -312,7 +315,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::" @@ -333,15 +336,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" @@ -352,7 +355,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::" @@ -364,11 +367,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 @@ -382,7 +385,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 @@ -396,10 +399,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 @@ -435,9 +438,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::" @@ -445,6 +448,8 @@ 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 }} run: | echo "::group::Folding refreshed lockfile into the version-bump commit" @@ -473,7 +478,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 @@ -542,12 +547,14 @@ runs: - name: Create GitHub Release shell: bash + env: + 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 + if [ "$DRAFT" = "true" ]; then echo " Type: Draft release" else echo " Type: Published release" @@ -612,6 +619,10 @@ runs: - name: Release summary shell: bash + env: + INCREMENT: ${{ inputs.increment }} + PRERELEASE_TYPE: ${{ inputs.prerelease-type }} + DRAFT: ${{ inputs.draft }} run: | echo "::group::Release Summary" echo "" @@ -620,11 +631,11 @@ runs: 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 "✓ 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" From 8b25004ff74e23b11d6e7645978b7218c6eb83f0 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 20:52:59 +0200 Subject: [PATCH 10/15] fix(release/github): env-scope step outputs re-interpolated into run bodies Round-2 review found a second-order injection the SEC-8 fix left open: the config-file input is sanitized at the bump step, but the derived WORK_DIR is written to a step output and then re-interpolated as WORK_DIR="${{ steps.bump_version.outputs.WORK_DIR }}" in a later run body, where a config-file like "$(cmd)/pyproject.toml" would surface as WORK_DIR="$(cmd)" and execute. Route every steps.bump_version.outputs.* (plus github.server_url / github.repository) through env: and reference the quoted variable. No run body in this action interpolates ${{ }} anymore. --- actions/release/github/action.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 642b5b7..a8fe0bf 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -450,6 +450,7 @@ runs: 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" @@ -458,8 +459,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, @@ -519,6 +518,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" @@ -541,19 +542,20 @@ 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 }}" + echo " Version: $NEW_VERSION" + echo " Tag: $NEW_VERSION" if [ "$DRAFT" = "true" ]; then echo " Type: Draft release" else @@ -561,7 +563,7 @@ runs: 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 @@ -572,11 +574,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" @@ -623,6 +627,9 @@ runs: 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 "" @@ -630,7 +637,7 @@ runs: echo " GitHub Release Created" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - echo "✓ Version: ${{ steps.bump_version.outputs.NEW_VERSION }}" + echo "✓ Version: $NEW_VERSION" echo "✓ Increment: $INCREMENT" if [ "$PRERELEASE_TYPE" != "none" ]; then echo "✓ Prerelease: $PRERELEASE_TYPE" @@ -641,7 +648,7 @@ runs: 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 From 424bea673844b42f5326f43db0d73b6b837c2c00 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 22:08:49 +0200 Subject: [PATCH 11/15] feat(release/github)!: make install-groups required and drop the default The install-groups input previously defaulted to 'groups: docs dev' (pip/uv format). For the pixi path that value is meaningless as an environment name, so a pixi caller who relied on the default silently got an invalid environment. Make the input required with no default so every caller must supply the correct value for its package manager (pip/uv: a groups:/extras: string; pixi: a single environment name). Update the five test jobs that relied on the old default (prerelease, python-versions, custom-branch, cross-platform, git-configuration) to pass install-groups: 'groups: docs dev' explicitly, preserving their prior behavior (commitizen lives in the dev group). BREAKING CHANGE: install-groups is now required; callers that omitted it and relied on the 'groups: docs dev' default must pass it explicitly. --- .github/workflows/test-release-github.yml | 5 +++++ actions/release/github/action.yml | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-release-github.yml b/.github/workflows/test-release-github.yml index 5b8c5ca..a4cfd31 100644 --- a/.github/workflows/test-release-github.yml +++ b/.github/workflows/test-release-github.yml @@ -268,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' @@ -462,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 }} @@ -538,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' @@ -618,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' @@ -760,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/actions/release/github/action.yml b/actions/release/github/action.yml index a8fe0bf..66d434c 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). From b568b51c3b8803f0a076d24a3063078d0c183d3f Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 22:58:34 +0200 Subject: [PATCH 12/15] feat(release/github): fail fast with a clear error when commitizen is unavailable install-groups is required with no default, but required is advisory for composite actions (not runtime-enforced), so a caller that omits it silently gets '' and the release fails deep in the bump step with a raw 'cz: not found' (or 'pixi run -e ""'). Add a preflight in the bump step that checks commitizen is available and, if not, emits a clear ::error:: pointing at install-groups. Checks availability (not emptiness), so the valid core-only case (empty install-groups when commitizen is a core dependency) still works. --- actions/release/github/action.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 8a76681..1c121ce 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -321,6 +321,18 @@ runs: echo "✓ Commitizen command: ${CZ_CMD[*]}" echo "::endgroup::" + # Preflight: commitizen must be available. install-groups is required (no + # default); if it was omitted or names a group that does not provide + # commitizen, fail here with a clear message instead of a downstream + # "cz: not found" / "pixi run -e ''" error. + echo "::group::Checking commitizen availability" + if ! "${CZ_CMD[@]}" version >/dev/null 2>&1; then + echo "::error::commitizen (cz) is not available. Pass 'install-groups' including the dependency group that provides commitizen (e.g. 'groups: dev'), or ensure commitizen is a core dependency." + exit 1 + fi + echo "✓ commitizen is available" + echo "::endgroup::" + # Determine and enter working directory if [ -n "$CONFIG_FILE" ]; then WORK_DIR=$(dirname "$CONFIG_FILE") From 544862f5a6493df8ba2429e074100529df99b3a4 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 23:01:17 +0200 Subject: [PATCH 13/15] docs(release/github): reflect required install-groups (no default) in inputs tables install-groups is now required with no default (breaking). Update the hand-written README inputs table and the generated reference doc's install-groups row + usage snippet to match. Left the pre-existing description drift in the other reference docs alone (unrelated to this change; regenerated on the next docs deploy). --- docs/reference/release-github.md | 4 ++-- docs/release/github/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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` | From 1ae17fdf93b1bb30fd37575cfde8a1ead30a69c5 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 23:17:29 +0200 Subject: [PATCH 14/15] fix(release/github): run the commitizen preflight from WORK_DIR and surface its error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the preflight to after 'cd "$WORK_DIR"' so it checks commitizen in the same directory every real cz command runs from — a monorepo config-file in a sub-package would otherwise be checked at the repo root and could false-positive for pixi. Also stop suppressing stderr (>/dev/null only) so a uv/pixi sync or network failure is not misreported as 'commitizen missing', and soften the comment: for uv the preflight rarely fires (uv run re-syncs default groups); it mainly guards pip and non-default groups. --- actions/release/github/action.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/actions/release/github/action.yml b/actions/release/github/action.yml index 1c121ce..4a48d2d 100644 --- a/actions/release/github/action.yml +++ b/actions/release/github/action.yml @@ -321,18 +321,6 @@ runs: echo "✓ Commitizen command: ${CZ_CMD[*]}" echo "::endgroup::" - # Preflight: commitizen must be available. install-groups is required (no - # default); if it was omitted or names a group that does not provide - # commitizen, fail here with a clear message instead of a downstream - # "cz: not found" / "pixi run -e ''" error. - echo "::group::Checking commitizen availability" - if ! "${CZ_CMD[@]}" version >/dev/null 2>&1; then - echo "::error::commitizen (cz) is not available. Pass 'install-groups' including the dependency group that provides commitizen (e.g. 'groups: dev'), or ensure commitizen is a core dependency." - exit 1 - fi - echo "✓ commitizen is available" - echo "::endgroup::" - # Determine and enter working directory if [ -n "$CONFIG_FILE" ]; then WORK_DIR=$(dirname "$CONFIG_FILE") @@ -342,6 +330,21 @@ runs: 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 From 384bed1c6bd1f9bfefa36cbba7ff75a3285ae963 Mon Sep 17 00:00:00 2001 From: Mostafa Farrag Date: Tue, 21 Jul 2026 23:40:50 +0200 Subject: [PATCH 15/15] docs(release/github): add required install-groups to all examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-groups is now required with no default, but the examples (all uv-based) omitted it — copying them would fail the new commitizen preflight for pip/pixi and silently rely on uv's auto-sync otherwise. Add install-groups: 'groups: dev' (the group that provides commitizen) to all 27 example invocations in EXAMPLES.md and getting-started.md. The version-pin snippet (no with: block) is left as-is. Kept the @github-release/v1 refs — this ships as a minor bump, not v2. --- docs/getting-started.md | 1 + docs/release/github/EXAMPLES.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) 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/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' ``` ---