[VOL-5495] Add publish/release actions for merge

Signed-off-by: Eric Ball <eball@linuxfoundation.org>
Change-Id: I05ef8897523e7c3d128767c3795b7e11f4b0169a
diff --git a/.github/actions/docker-publish-action/README.md b/.github/actions/docker-publish-action/README.md
new file mode 100644
index 0000000..c82e340
--- /dev/null
+++ b/.github/actions/docker-publish-action/README.md
@@ -0,0 +1,80 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# Docker Publish Action
+
+A composite GitHub Action that builds and pushes Docker images tagged with a branch name and git tags.
+
+## Description
+
+Builds Docker images using `make docker-build` and pushes them with `make docker-push`. Images are tagged with the caller-specified branch name and any git tags pointing at HEAD. Golang-style version tags (e.g., `v1.2.3`) have the leading `v` stripped automatically.
+
+## Usage
+
+```yaml
+- uses: ./.github/actions/docker-publish-action
+  with:
+    docker-registry: "docker.io"
+    docker-repository: "myorg"
+    image-name: "myapp"
+    branch-tag: ${{ github.ref_name }}
+    docker-username: ${{ secrets.DOCKER_USERNAME }}
+    docker-password: ${{ secrets.DOCKER_PASSWORD }}
+```
+
+### Inputs
+
+| Input | Required | Default | Description |
+|-------|----------|---------|-------------|
+| `docker-registry` | No | `docker.io` | Docker registry URL |
+| `docker-repository` | Yes | — | Docker repository prefix |
+| `image-name` | Yes | — | Name of the Docker image |
+| `branch-tag` | Yes | — | Branch name to use as a Docker image tag |
+| `extra-environment-vars` | No | `""` | Extra environment variables for make commands |
+| `docker-username` | Yes | — | Docker registry username |
+| `docker-password` | Yes | — | Docker registry password |
+
+### Example Workflow
+
+```yaml
+name: Publish Docker Image
+
+on:
+  push:
+    branches: [main]
+    tags: ["v*"]
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - uses: ./.github/actions/docker-publish-action
+        with:
+          docker-registry: "docker.io"
+          docker-repository: "voltha"
+          image-name: "voltha-northbound"
+          branch-tag: ${{ github.ref_name }}
+          docker-username: ${{ secrets.DOCKER_USERNAME }}
+          docker-password: ${{ secrets.DOCKER_PASSWORD }}
+```
+
+## How It Works
+
+1. Logs in to the configured Docker registry
+2. Discovers git tags pointing at the current HEAD commit
+3. Runs `make docker-build` with `DOCKER_TAG` set to the `branch-tag` input
+4. Runs `make docker-build` for each git tag (stripping the `v` prefix)
+5. Runs `make docker-push` for the branch tag and each git tag
+6. Logs out of the Docker registry
+
+## Requirements
+
+- The repository must have a `Makefile` with `docker-build` and `docker-push` targets
+- These targets must respect `DOCKER_TAG`, `DOCKER_REGISTRY`, `DOCKER_REPOSITORY`, and `IMAGE_NAME` environment variables
+- The checkout step should use `fetch-depth: 0` to ensure git tags are available
diff --git a/.github/actions/docker-publish-action/action.yaml b/.github/actions/docker-publish-action/action.yaml
new file mode 100644
index 0000000..63e62bd
--- /dev/null
+++ b/.github/actions/docker-publish-action/action.yaml
@@ -0,0 +1,144 @@
+---
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+# SPDX-License-Identifier: Apache-2.0
+
+name: Docker Publish
+description: "Build and push Docker images tagged with branch name and git tags"
+
+inputs:
+  docker-registry:
+    description: "Docker registry URL"
+    required: false
+    default: "docker.io"
+  docker-repository:
+    description: "Docker repository prefix"
+    required: true
+  image-name:
+    description: "Name of the Docker image"
+    required: true
+  branch-tag:
+    description: "Branch name to use as a Docker image tag"
+    required: true
+  extra-environment-vars:
+    description: "Extra environment variables for make commands"
+    required: false
+    default: ""
+  docker-username:
+    description: "Docker registry username"
+    required: true
+  docker-password:
+    description: "Docker registry password"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    - name: Log in to Docker registry
+      uses: docker/login-action@v3
+      with:
+        registry: ${{ inputs.docker-registry }}
+        username: ${{ inputs.docker-username }}
+        password: ${{ inputs.docker-password }}
+
+    - name: Get git tags on HEAD
+      id: git-tags
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        TAGS=$(git tag --points-at HEAD || true)
+        echo "tags<<EOF" >> "$GITHUB_OUTPUT"
+        echo "$TAGS" >> "$GITHUB_OUTPUT"
+        echo "EOF" >> "$GITHUB_OUTPUT"
+
+    - name: Build Docker image with branch tag
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        DOCKER_REGISTRY="${{ inputs.docker-registry }}"
+        DOCKER_REPOSITORY="${{ inputs.docker-repository }}"
+        IMAGE_NAME="${{ inputs.image-name }}"
+        BRANCH_NAME="${{ inputs.branch-tag }}"
+
+        echo "Building image with branch tag: ${BRANCH_NAME}"
+        ${{ inputs.extra-environment-vars }} \
+        DOCKER_TAG="${BRANCH_NAME}" \
+        DOCKER_REGISTRY="${DOCKER_REGISTRY}" \
+        DOCKER_REPOSITORY="${DOCKER_REPOSITORY}" \
+        IMAGE_NAME="${IMAGE_NAME}" \
+          make docker-build
+
+    - name: Build Docker images for git tags
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        DOCKER_REGISTRY="${{ inputs.docker-registry }}"
+        DOCKER_REPOSITORY="${{ inputs.docker-repository }}"
+        IMAGE_NAME="${{ inputs.image-name }}"
+
+        TAGS='${{ steps.git-tags.outputs.tags }}'
+        if [ -z "$TAGS" ]; then
+          echo "No git tags on HEAD, skipping tag builds"
+          exit 0
+        fi
+
+        while IFS= read -r tag; do
+          [ -z "$tag" ] && continue
+          # Strip leading 'v' from golang-style version tags
+          DOCKER_TAG="${tag#v}"
+          echo "Building image with tag: ${DOCKER_TAG}"
+          ${{ inputs.extra-environment-vars }} \
+          DOCKER_TAG="${DOCKER_TAG}" \
+          DOCKER_REGISTRY="${DOCKER_REGISTRY}" \
+          DOCKER_REPOSITORY="${DOCKER_REPOSITORY}" \
+          IMAGE_NAME="${IMAGE_NAME}" \
+            make docker-build
+        done <<< "$TAGS"
+
+    - name: Push Docker image with branch tag
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        DOCKER_REGISTRY="${{ inputs.docker-registry }}"
+        DOCKER_REPOSITORY="${{ inputs.docker-repository }}"
+        IMAGE_NAME="${{ inputs.image-name }}"
+        BRANCH_NAME="${{ inputs.branch-tag }}"
+
+        echo "Pushing image with branch tag: ${BRANCH_NAME}"
+        ${{ inputs.extra-environment-vars }} \
+        DOCKER_TAG="${BRANCH_NAME}" \
+        DOCKER_REGISTRY="${DOCKER_REGISTRY}" \
+        DOCKER_REPOSITORY="${DOCKER_REPOSITORY}" \
+        IMAGE_NAME="${IMAGE_NAME}" \
+          make docker-push
+
+    - name: Push Docker images for git tags
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        DOCKER_REGISTRY="${{ inputs.docker-registry }}"
+        DOCKER_REPOSITORY="${{ inputs.docker-repository }}"
+        IMAGE_NAME="${{ inputs.image-name }}"
+
+        TAGS='${{ steps.git-tags.outputs.tags }}'
+        if [ -z "$TAGS" ]; then
+          echo "No git tags on HEAD, skipping tag pushes"
+          exit 0
+        fi
+
+        while IFS= read -r tag; do
+          [ -z "$tag" ] && continue
+          # Strip leading 'v' from golang-style version tags
+          DOCKER_TAG="${tag#v}"
+          echo "Pushing image with tag: ${DOCKER_TAG}"
+          ${{ inputs.extra-environment-vars }} \
+          DOCKER_TAG="${DOCKER_TAG}" \
+          DOCKER_REGISTRY="${DOCKER_REGISTRY}" \
+          DOCKER_REPOSITORY="${DOCKER_REPOSITORY}" \
+          IMAGE_NAME="${IMAGE_NAME}" \
+            make docker-push
+        done <<< "$TAGS"
+
+    - name: Log out of Docker registry
+      if: always()
+      shell: bash
+      run: docker logout "${{ inputs.docker-registry }}"
diff --git a/.github/actions/github-release-action/README.md b/.github/actions/github-release-action/README.md
new file mode 100644
index 0000000..db48efc
--- /dev/null
+++ b/.github/actions/github-release-action/README.md
@@ -0,0 +1,68 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# GitHub Release Action
+
+A composite GitHub Action that creates GitHub releases with artifacts using the `gh` CLI tool.
+
+## Description
+
+Builds release artifacts via `make`, generates SHA256 checksums, and creates a GitHub release with all artifacts attached. Supports draft releases for pre-release versions.
+
+## Usage
+
+```yaml
+- uses: ./.github/actions/github-release-action
+  with:
+    github-token: ${{ secrets.GITHUB_TOKEN }}
+```
+
+### Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `release-targets` | Make targets to build release artifacts | No | `release` |
+| `artifact-glob` | Glob pattern for release artifacts | No | `release/*` |
+| `github-token` | GitHub token for authentication | Yes | — |
+| `draft` | Create a draft release | No | `false` |
+
+### Example Workflow
+
+```yaml
+name: Release
+on:
+  push:
+    tags:
+      - "v*"
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - uses: ./.github/actions/github-release-action
+        with:
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          release-targets: "build package"
+          artifact-glob: "dist/*"
+```
+
+## How It Works
+
+1. Finds a SemVer tag on HEAD (`vX.Y.Z`, `X.Y.Z`, or `vX.Y.Z-devN`)
+2. Builds release artifacts using `make <release-targets>`
+3. Generates SHA256 checksums for all matched artifacts
+4. Creates a GitHub release with artifacts and checksums attached
+5. Marks the release as draft if the tag contains `-dev` or `draft` input is `true`
+
+## Requirements
+
+- Repository must have a SemVer tag on the current commit
+- A `Makefile` with the specified release target(s)
+- `gh` CLI (pre-installed on GitHub Actions runners)
+- `GITHUB_TOKEN` with permissions to create releases
diff --git a/.github/actions/github-release-action/action.yaml b/.github/actions/github-release-action/action.yaml
new file mode 100644
index 0000000..5548106
--- /dev/null
+++ b/.github/actions/github-release-action/action.yaml
@@ -0,0 +1,80 @@
+---
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+# SPDX-License-Identifier: Apache-2.0
+
+name: "GitHub Release Action"
+description: "Creates GitHub releases with artifacts using the gh CLI tool"
+
+inputs:
+  release-targets:
+    description: "Make targets to build release artifacts"
+    required: false
+    default: "release"
+  artifact-glob:
+    description: "Glob pattern for release artifacts"
+    required: false
+    default: "release/*"
+  github-token:
+    description: "GitHub token for authentication"
+    required: true
+  draft:
+    description: "Create a draft release"
+    required: false
+    default: "false"
+
+runs:
+  using: "composite"
+  steps:
+    - name: Create GitHub Release
+      shell: bash
+      env:
+        GITHUB_TOKEN: ${{ inputs.github-token }}
+        RELEASE_TARGETS: ${{ inputs.release-targets }}
+        ARTIFACT_GLOB: ${{ inputs.artifact-glob }}
+        DRAFT_INPUT: ${{ inputs.draft }}
+      run: |
+        set -eu -o pipefail
+
+        # Determine version from git tags on HEAD
+        VERSION=""
+        for tag in $(git tag --points-at HEAD); do
+          if echo "$tag" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+(-dev[0-9]+)?$'; then
+            VERSION="$tag"
+            break
+          fi
+        done
+
+        if [ -z "$VERSION" ]; then
+          echo "ERROR: No SemVer tag (vX.Y.Z, X.Y.Z, or vX.Y.Z-devN) found on HEAD"
+          exit 1
+        fi
+
+        echo "Version: $VERSION"
+
+        # Build release artifacts
+        make $RELEASE_TARGETS
+
+        # Generate SHA256 checksums for all artifacts
+        CHECKSUM_FILE="checksums-sha256.txt"
+        sha256sum $ARTIFACT_GLOB > "$CHECKSUM_FILE"
+        echo "Generated checksums:"
+        cat "$CHECKSUM_FILE"
+
+        # Determine if this should be a draft release
+        DRAFT_FLAG=""
+        if [[ "$VERSION" == *"-dev"* ]] || [[ "$DRAFT_INPUT" == "true" ]]; then
+          DRAFT_FLAG="--draft"
+          echo "Creating draft release"
+        fi
+
+        # Get release notes from latest commit message
+        RELEASE_NOTES="$(git log -1 --pretty=%B)"
+
+        # Create GitHub release
+        gh release create "$VERSION" \
+          $ARTIFACT_GLOB \
+          "$CHECKSUM_FILE" \
+          --title "$VERSION" \
+          --notes "$RELEASE_NOTES" \
+          --generate-notes \
+          $DRAFT_FLAG
diff --git a/.github/actions/maven-publish-action/README.md b/.github/actions/maven-publish-action/README.md
new file mode 100644
index 0000000..68ae27b
--- /dev/null
+++ b/.github/actions/maven-publish-action/README.md
@@ -0,0 +1,74 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# Maven Publish Action
+
+Build and publish Maven (Java) artifacts to a remote repository.
+
+## Description
+
+A composite GitHub Action that sets up JDK, optionally imports a GPG signing
+key, configures Maven authentication, and executes Maven goals to publish
+artifacts. Based on Jenkins JJB Maven release patterns.
+
+## Usage
+
+```yaml
+- uses: ./.github/actions/maven-publish-action
+  with:
+    server-username: ${{ secrets.OSSRH_USERNAME }}
+    server-password: ${{ secrets.OSSRH_PASSWORD }}
+```
+
+### Inputs
+
+| Input | Required | Default | Description |
+|-------|----------|---------|-------------|
+| `java-version` | No | `11` | Java version to use |
+| `java-distribution` | No | `temurin` | Java distribution |
+| `maven-goals` | No | `-Prelease clean deploy` | Maven goals to execute |
+| `maven-settings-file` | No | `""` | Path to custom Maven settings.xml |
+| `gpg-private-key` | No | `""` | GPG private key for signing |
+| `gpg-passphrase` | No | `""` | GPG passphrase |
+| `server-id` | No | `ossrh` | Maven server ID for deployment |
+| `server-username` | **Yes** | — | Maven server username |
+| `server-password` | **Yes** | — | Maven server password |
+
+### Example Workflow
+
+```yaml
+name: Maven Publish
+on:
+  push:
+    tags:
+      - "v*"
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: ./.github/actions/maven-publish-action
+        with:
+          java-version: "17"
+          server-username: ${{ secrets.OSSRH_USERNAME }}
+          server-password: ${{ secrets.OSSRH_PASSWORD }}
+          gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+          gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
+```
+
+## How It Works
+
+1. Sets up JDK using `actions/setup-java@v4` with the specified distribution and version
+2. Imports the GPG private key for artifact signing (if provided)
+3. Configures Maven settings with server credentials (custom or generated)
+4. Executes Maven with the specified goals using `MAVEN_USERNAME` and `MAVEN_PASSWORD` environment variables for authentication
+
+## Requirements
+
+- Repository must contain a valid `pom.xml`
+- Server credentials must be provided (typically via GitHub Secrets)
+- GPG key required only if the Maven profile performs artifact signing
diff --git a/.github/actions/maven-publish-action/action.yaml b/.github/actions/maven-publish-action/action.yaml
new file mode 100644
index 0000000..1946815
--- /dev/null
+++ b/.github/actions/maven-publish-action/action.yaml
@@ -0,0 +1,96 @@
+---
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+# SPDX-License-Identifier: Apache-2.0
+
+name: Maven Publish
+description: "Build and publish Maven (Java) artifacts to a remote repository"
+
+inputs:
+  java-version:
+    description: "Java version to use"
+    required: false
+    default: "11"
+  java-distribution:
+    description: "Java distribution"
+    required: false
+    default: "temurin"
+  maven-goals:
+    description: "Maven goals to execute"
+    required: false
+    default: "-Prelease clean deploy"
+  maven-settings-file:
+    description: "Path to custom Maven settings.xml"
+    required: false
+    default: ""
+  gpg-private-key:
+    description: "GPG private key for signing"
+    required: false
+    default: ""
+  gpg-passphrase:
+    description: "GPG passphrase"
+    required: false
+    default: ""
+  server-id:
+    description: "Maven server ID for deployment"
+    required: false
+    default: "ossrh"
+  server-username:
+    description: "Maven server username"
+    required: true
+  server-password:
+    description: "Maven server password"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    - name: Set up JDK
+      uses: actions/setup-java@v4
+      with:
+        distribution: ${{ inputs.java-distribution }}
+        java-version: ${{ inputs.java-version }}
+        server-id: ${{ inputs.server-id }}
+        server-username: MAVEN_USERNAME
+        server-password: MAVEN_PASSWORD
+        settings-path: ${{ github.workspace }}
+
+    - name: Import GPG key
+      if: inputs.gpg-private-key != ''
+      shell: bash
+      env:
+        GPG_PRIVATE_KEY: ${{ inputs.gpg-private-key }}
+        GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
+      run: |
+        set -eu -o pipefail
+        echo "Importing GPG private key..."
+        echo "$GPG_PRIVATE_KEY" | gpg --batch --import
+        echo "GPG key imported successfully"
+        if [ -n "$GPG_PASSPHRASE" ]; then
+          echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
+          gpgconf --kill gpg-agent
+          gpgconf --launch gpg-agent
+        fi
+
+    - name: Apply custom Maven settings
+      if: inputs.maven-settings-file != ''
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        echo "Using custom Maven settings: ${{ inputs.maven-settings-file }}"
+        cp "${{ inputs.maven-settings-file }}" "${{ github.workspace }}/settings.xml"
+
+    - name: Run Maven
+      shell: bash
+      env:
+        MAVEN_USERNAME: ${{ inputs.server-username }}
+        MAVEN_PASSWORD: ${{ inputs.server-password }}
+        GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
+      run: |
+        set -eu -o pipefail
+        MAVEN_ARGS=(-s "${{ github.workspace }}/settings.xml")
+        if [ -n "$GPG_PASSPHRASE" ]; then
+          MAVEN_ARGS+=(-Dgpg.passphrase="$GPG_PASSPHRASE")
+        fi
+        read -r -a MAVEN_GOALS <<< "${{ inputs.maven-goals }}"
+        echo "Running: mvn ${MAVEN_ARGS[*]} ${MAVEN_GOALS[*]}"
+        mvn "${MAVEN_ARGS[@]}" "${MAVEN_GOALS[@]}"
diff --git a/.github/actions/pypi-publish-action/README.md b/.github/actions/pypi-publish-action/README.md
new file mode 100644
index 0000000..3eddfd5
--- /dev/null
+++ b/.github/actions/pypi-publish-action/README.md
@@ -0,0 +1,73 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# PyPI Publish Action
+
+A composite GitHub Action that builds and publishes Python packages to PyPI on SemVer release tags.
+
+## Description
+
+Publishes Python packages to PyPI when the current HEAD has a valid SemVer release tag (vX.Y.Z or X.Y.Z). Supports publishing multiple modules from a single repository using pipe-separated directory paths.
+
+## Usage
+
+```yaml
+- uses: ./.github/actions/pypi-publish-action
+  with:
+    pypi-username: ${{ secrets.PYPI_USERNAME }}
+    pypi-password: ${{ secrets.PYPI_PASSWORD }}
+```
+
+### Inputs
+
+| Input | Required | Default | Description |
+|-------|----------|---------|-------------|
+| `pypi-index` | No | `pypi` | PyPI repository name to publish to |
+| `pypi-username` | Yes | — | PyPI username |
+| `pypi-password` | Yes | — | PyPI password or API token |
+| `module-dirs` | No | `.` | Pipe-separated list of directories containing Python modules to publish |
+| `prep-commands` | No | `""` | Commands to run before building |
+
+### Example Workflow
+
+```yaml
+name: Publish to PyPI
+
+on:
+  push:
+    tags: ["v*"]
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+
+      - uses: ./.github/actions/pypi-publish-action
+        with:
+          pypi-username: ${{ secrets.PYPI_USERNAME }}
+          pypi-password: ${{ secrets.PYPI_PASSWORD }}
+          module-dirs: "lib/core|lib/utils"
+          prep-commands: "make generate"
+```
+
+## How It Works
+
+1. Gets the git tag on HEAD and verifies it is a SemVer release version (vX.Y.Z or X.Y.Z).
+2. Exits gracefully if no valid release tag is found.
+3. Runs any specified prep commands.
+4. For each module directory: verifies `setup.py` exists, installs build dependencies, builds with `python3 -m build`, and uploads with `twine upload`.
+
+## Requirements
+
+- Each module directory must contain a `setup.py`.
+- The checkout step should use `fetch-depth: 0` to ensure git tags are available.
+- Python must be available in the runner environment.
diff --git a/.github/actions/pypi-publish-action/action.yaml b/.github/actions/pypi-publish-action/action.yaml
new file mode 100644
index 0000000..229f834
--- /dev/null
+++ b/.github/actions/pypi-publish-action/action.yaml
@@ -0,0 +1,91 @@
+---
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+# SPDX-License-Identifier: Apache-2.0
+
+name: PyPI Publish
+description: "Build and publish Python packages to PyPI on SemVer release tags"
+
+inputs:
+  pypi-index:
+    description: "PyPI repository name to publish to"
+    required: false
+    default: "pypi"
+  pypi-username:
+    description: "PyPI username"
+    required: true
+  pypi-password:
+    description: "PyPI password or API token"
+    required: true
+  module-dirs:
+    description: "Pipe-separated list of directories containing Python modules to publish"
+    required: false
+    default: "."
+  prep-commands:
+    description: "Commands to run before building"
+    required: false
+    default: ""
+
+runs:
+  using: "composite"
+  steps:
+    - name: Get and verify SemVer release tag
+      id: tag-check
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        TAG=$(git tag --points-at HEAD | head -n1 || true)
+        if [ -z "$TAG" ]; then
+          echo "No git tag on HEAD, skipping publish"
+          echo "is-release=false" >> "$GITHUB_OUTPUT"
+          exit 0
+        fi
+        # Match vX.Y.Z or X.Y.Z (SemVer release, no pre-release suffix)
+        if [[ "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+          echo "Found SemVer release tag: $TAG"
+          echo "is-release=true" >> "$GITHUB_OUTPUT"
+          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+        else
+          echo "Tag '$TAG' is not a SemVer release version, skipping publish"
+          echo "is-release=false" >> "$GITHUB_OUTPUT"
+        fi
+
+    - name: Run prep commands
+      if: steps.tag-check.outputs.is-release == 'true' && inputs.prep-commands != ''
+      shell: bash
+      run: |
+        set -eu -o pipefail
+        echo "Running prep commands..."
+        ${{ inputs.prep-commands }}
+
+    - name: Build and publish packages
+      if: steps.tag-check.outputs.is-release == 'true'
+      shell: bash
+      env:
+        TWINE_USERNAME: ${{ inputs.pypi-username }}
+        TWINE_PASSWORD: ${{ inputs.pypi-password }}
+      run: |
+        set -eu -o pipefail
+
+        echo "Installing build dependencies..."
+        pip install --upgrade pip setuptools wheel twine build
+
+        IFS='|' read -ra DIRS <<< "${{ inputs.module-dirs }}"
+        for dir in "${DIRS[@]}"; do
+          dir=$(echo "$dir" | xargs)  # trim whitespace
+          echo "::group::Publishing module in: $dir"
+
+          if [ ! -f "$dir/setup.py" ]; then
+            echo "ERROR: setup.py not found in $dir"
+            exit 1
+          fi
+
+          echo "Building source distribution..."
+          cd "$dir"
+          python3 -m build
+
+          echo "Uploading to PyPI repository: ${{ inputs.pypi-index }}"
+          twine upload -r "${{ inputs.pypi-index }}" dist/*
+
+          cd "$GITHUB_WORKSPACE"
+          echo "::endgroup::"
+        done