# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# ══════════════════════════════════════════════════════════════════════
# ReleaseKit — Reusable Composite GitHub Action
# ══════════════════════════════════════════════════════════════════════
#
# Universal release orchestration action that works with any ecosystem
# supported by releasekit (Python/uv, Go, JS/pnpm, Rust/Cargo,
# Dart/pub, Java/Gradle).
#
# Usage in a workflow:
#
# - uses: ./.github/actions/releasekit # or firebase/genkit/py/tools/releasekit@main
# with:
# command: prepare
# workspace: py
#
# The action handles:
# 1. Installing Python + uv (releasekit's own runtime)
# 2. Installing releasekit from its directory
# 3. Configuring git identity for tag/PR operations
# 4. Building and running the releasekit command
# 5. Capturing structured outputs (exit code, release URL, tags)
# 6. Writing a GitHub Actions Job Summary with rollback links
#
# ══════════════════════════════════════════════════════════════════════
name: "ReleaseKit"
description: >
Release orchestration for multi-ecosystem monorepos — prepare,
release, publish, rollback, and more.
author: "Google"
branding:
icon: "package"
color: "blue"
# ── Inputs ────────────────────────────────────────────────────────────
inputs:
command:
description: |
The releasekit subcommand to run. One of:
publish, plan, prepare, release, rollback, check,
discover, version, changelog, promote, snapshot,
should-release, doctor, explain.
required: true
default: "plan"
workspace:
description: |
Workspace label (maps to [workspace.<label>] in releasekit.toml).
Examples: py, go, js, js-cli, dart, rust.
Leave empty for single-workspace repos.
required: false
default: ""
releasekit-dir:
description: |
Path to the releasekit tool directory (contains pyproject.toml
for releasekit itself). Relative to the repository root.
required: false
default: "py/tools/releasekit"
group:
description: "Release group to target (leave empty for all packages)."
required: false
default: ""
dry-run:
description: "Preview mode — log what would happen without side effects."
required: false
default: "false"
force:
description: "Skip confirmation prompts and preflight checks."
required: false
default: "false"
forge-backend:
description: "Forge backend: 'api' (GitHub REST API) or 'cli' (gh CLI)."
required: false
default: "api"
prerelease:
description: "Prerelease label (e.g. rc, beta, alpha). Empty for stable."
required: false
default: ""
bump-type:
description: "Override auto-detected bump: auto, patch, minor, major."
required: false
default: ""
tag:
description: "Git tag for rollback command (e.g. genkit-v1.2.0)."
required: false
default: ""
all-tags:
description: "Delete ALL tags pointing to the same commit (rollback only)."
required: false
default: "false"
yank:
description: "Also yank/deprecate versions from the package registry (rollback only)."
required: false
default: "false"
yank-reason:
description: |
Reason for yanking — shown to users installing the package.
Only used when yank=true. Supports spaces (e.g. "Critical security fix").
required: false
default: ""
check-url:
description: "URL to check for already-published versions."
required: false
default: ""
index-url:
description: "Custom registry URL (e.g. Test PyPI). Empty for production."
required: false
default: ""
concurrency:
description: "Max packages publishing simultaneously per dependency level."
required: false
default: "5"
max-retries:
description: "Retry failed publishes up to N times with exponential backoff."
required: false
default: "2"
python-version:
description: "Python version for releasekit's own runtime."
required: false
default: "3.12"
uv-version:
description: "uv version to install."
required: false
default: "latest"
extra-args:
description: |
Additional arguments passed directly to the releasekit CLI.
Arguments are split on whitespace. Shell quoting is NOT
interpreted, so arguments containing spaces must use the
--key=value format (e.g. --message="Hello world") rather
than --key "Hello world".
required: false
default: ""
write-summary:
description: "Write a GitHub Actions Job Summary with results and rollback links."
required: false
default: "true"
# ── Outputs ───────────────────────────────────────────────────────────
outputs:
exit-code:
description: "The exit code from the releasekit command."
value: ${{ steps.run.outputs.exit-code }}
release-url:
description: "URL of the created GitHub Release (release command only)."
value: ${{ steps.run.outputs.release-url }}
pr-url:
description: "URL of the created/updated Release PR (prepare command only)."
value: ${{ steps.run.outputs.pr-url }}
first-tag:
description: "First tag created or detected in the output."
value: ${{ steps.run.outputs.first-tag }}
has-bumps:
description: "'true' if the prepare command detected version bumps."
value: ${{ steps.run.outputs.has-bumps }}
plan-json:
description: "JSON output from the plan command (only when command=plan)."
value: ${{ steps.run.outputs.json }}
output:
description: "Full stdout from the releasekit command."
value: ${{ steps.run.outputs.output }}
# ── Composite Steps ──────────────────────────────────────────────────
runs:
using: "composite"
steps:
# ── 1. Setup ──────────────────────────────────────────────────────
- name: Install uv and setup Python
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: ${{ inputs.python-version }}
version: ${{ inputs.uv-version }}
- name: Install releasekit
shell: bash
working-directory: ${{ inputs.releasekit-dir }}
run: uv sync
- name: Configure git identity
shell: bash
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# ── 2. Run ────────────────────────────────────────────────────────
- name: "Run: releasekit ${{ inputs.command }}"
id: run
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ github.token }}
INPUT_COMMAND: ${{ inputs.command }}
INPUT_WORKSPACE: ${{ inputs.workspace }}
INPUT_GROUP: ${{ inputs.group }}
INPUT_DRY_RUN: ${{ inputs.dry-run }}
INPUT_FORCE: ${{ inputs.force }}
INPUT_FORGE_BACKEND: ${{ inputs.forge-backend }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
INPUT_BUMP_TYPE: ${{ inputs.bump-type }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_ALL_TAGS: ${{ inputs.all-tags }}
INPUT_YANK: ${{ inputs.yank }}
INPUT_YANK_REASON: ${{ inputs.yank-reason }}
INPUT_CHECK_URL: ${{ inputs.check-url }}
INPUT_INDEX_URL: ${{ inputs.index-url }}
INPUT_CONCURRENCY: ${{ inputs.concurrency }}
INPUT_MAX_RETRIES: ${{ inputs.max-retries }}
INPUT_EXTRA_ARGS: ${{ inputs.extra-args }}
RELEASEKIT_DIR: ${{ inputs.releasekit-dir }}
run: |
set +e
# ── Build command array (prevents injection) ──────────────
cmd=(uv run --directory "$RELEASEKIT_DIR" releasekit)
# Global flags.
if [[ -n "$INPUT_WORKSPACE" ]]; then
cmd+=(--workspace "$INPUT_WORKSPACE")
fi
# Subcommand.
cmd+=("$INPUT_COMMAND")
# Common flags.
if [[ -n "$INPUT_GROUP" ]]; then
cmd+=(--group "$INPUT_GROUP")
fi
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
cmd+=(--dry-run)
fi
if [[ "$INPUT_FORCE" == "true" ]]; then
cmd+=(--force)
fi
# Command-specific flags.
case "$INPUT_COMMAND" in
publish|prepare|release)
cmd+=(--forge-backend "$INPUT_FORGE_BACKEND")
;;
esac
case "$INPUT_COMMAND" in
plan)
cmd+=(--format json)
;;
prepare)
if [[ -n "$INPUT_BUMP_TYPE" && "$INPUT_BUMP_TYPE" != "auto" ]]; then
cmd+=(--bump "$INPUT_BUMP_TYPE")
fi
if [[ -n "$INPUT_PRERELEASE" ]]; then
cmd+=(--prerelease "$INPUT_PRERELEASE")
fi
;;
publish)
if [[ -n "$INPUT_CONCURRENCY" && "$INPUT_CONCURRENCY" != "0" ]]; then
cmd+=(--concurrency "$INPUT_CONCURRENCY")
fi
if [[ -n "$INPUT_MAX_RETRIES" && "$INPUT_MAX_RETRIES" != "0" ]]; then
cmd+=(--max-retries "$INPUT_MAX_RETRIES")
fi
if [[ -n "$INPUT_CHECK_URL" ]]; then
cmd+=(--check-url "$INPUT_CHECK_URL")
fi
if [[ -n "$INPUT_INDEX_URL" ]]; then
cmd+=(--index-url "$INPUT_INDEX_URL")
fi
;;
rollback)
if [[ -n "$INPUT_TAG" ]]; then
cmd+=("$INPUT_TAG")
fi
if [[ "$INPUT_ALL_TAGS" == "true" ]]; then
cmd+=(--all-tags)
fi
if [[ "$INPUT_YANK" == "true" ]]; then
cmd+=(--yank)
if [[ -n "$INPUT_YANK_REASON" ]]; then
cmd+=(--yank-reason "$INPUT_YANK_REASON")
fi
fi
;;
esac
# Extra args (split on whitespace).
if [[ -n "$INPUT_EXTRA_ARGS" ]]; then
read -ra extra <<< "$INPUT_EXTRA_ARGS"
cmd+=("${extra[@]}")
fi
# ── Execute ───────────────────────────────────────────────
echo "::group::Running: ${cmd[*]}"
OUTPUT=$("${cmd[@]}" 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
echo "::endgroup::"
# ── Parse outputs ─────────────────────────────────────────
echo "exit-code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
# Store full output (multiline-safe).
{
echo "output<<RELEASEKIT_EOF"
echo "$OUTPUT"
echo "RELEASEKIT_EOF"
} >> "$GITHUB_OUTPUT"
# Extract release URL.
RELEASE_URL=$(echo "$OUTPUT" | sed -n 's/.*release_url=//p' | tail -1)
echo "release-url=$RELEASE_URL" >> "$GITHUB_OUTPUT"
# Extract PR URL.
PR_URL=$(echo "$OUTPUT" | sed -n 's/.*Release PR: //p' | tail -1)
echo "pr-url=$PR_URL" >> "$GITHUB_OUTPUT"
# Detect bumps.
if [[ -n "$PR_URL" ]]; then
echo "has-bumps=true" >> "$GITHUB_OUTPUT"
else
echo "has-bumps=false" >> "$GITHUB_OUTPUT"
fi
# Extract first tag.
FIRST_TAG=$(echo "$OUTPUT" | grep -oE '[a-zA-Z_@/./-]+-v[0-9]+\.[0-9]+\.[0-9]+[^ ]*' | head -1 || true)
echo "first-tag=$FIRST_TAG" >> "$GITHUB_OUTPUT"
# When the command is 'plan', the output is already JSON (--format json
# was added above). Expose it as the plan-json output directly.
if [[ "$INPUT_COMMAND" == "plan" ]]; then
{
echo "json<<RELEASEKIT_EOF"
echo "$OUTPUT"
echo "RELEASEKIT_EOF"
} >> "$GITHUB_OUTPUT"
fi
exit $EXIT_CODE
# ── 4. Job Summary ────────────────────────────────────────────────
- name: Write job summary
if: always() && inputs.write-summary == 'true'
shell: bash
env:
INPUT_COMMAND: ${{ inputs.command }}
INPUT_WORKSPACE: ${{ inputs.workspace }}
INPUT_DRY_RUN: ${{ inputs.dry-run }}
INPUT_TAG: ${{ inputs.tag }}
RELEASE_URL: ${{ steps.run.outputs.release-url }}
PR_URL: ${{ steps.run.outputs.pr-url }}
FIRST_TAG: ${{ steps.run.outputs.first-tag }}
EXIT_CODE: ${{ steps.run.outputs.exit-code }}
run: |
REPO="${{ github.repository }}"
ROLLBACK_URL="https://github.com/${REPO}/actions/workflows/releasekit-rollback.yml"
if [[ "$INPUT_DRY_RUN" == "true" ]]; then
MODE="🔍 Dry Run"
else
MODE="🚀 Live"
fi
STATUS="✅ Success"
if [[ "$EXIT_CODE" != "0" ]]; then
STATUS="❌ Failed (exit $EXIT_CODE)"
fi
# Header.
case "$INPUT_COMMAND" in
prepare) ICON="📋"; TITLE="Prepare" ;;
release) ICON="🏷️"; TITLE="Release" ;;
publish) ICON="📦"; TITLE="Publish" ;;
rollback) ICON="🔄"; TITLE="Rollback" ;;
plan) ICON="📊"; TITLE="Plan" ;;
*) ICON="⚙️"; TITLE="$INPUT_COMMAND" ;;
esac
WS_LABEL="${INPUT_WORKSPACE:-default}"
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## ${ICON} ReleaseKit ${TITLE}
| | |
|---|---|
| **Workspace** | \`${WS_LABEL}\` |
| **Status** | ${STATUS} |
| **Mode** | ${MODE} |
EOF
# Command-specific details.
if [[ "$INPUT_COMMAND" == "prepare" && -n "$PR_URL" ]]; then
echo "| **Release PR** | [View PR](${PR_URL}) |" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ "$INPUT_COMMAND" == "release" || "$INPUT_COMMAND" == "publish" ]]; then
if [[ -n "$RELEASE_URL" ]]; then
echo "| **Release** | [View Release](${RELEASE_URL}) |" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "$FIRST_TAG" ]]; then
echo "| **Tag** | \`${FIRST_TAG}\` |" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "$INPUT_COMMAND" == "rollback" ]]; then
echo "| **Tag** | \`${INPUT_TAG}\` |" >> "$GITHUB_STEP_SUMMARY"
fi
# Rollback section (for release/publish commands).
if [[ "$INPUT_COMMAND" == "release" || "$INPUT_COMMAND" == "publish" ]]; then
TAG_FOR_ROLLBACK="${FIRST_TAG:-TAG}"
cat >> "$GITHUB_STEP_SUMMARY" << EOF
### 🔄 Rollback
If this release has issues, roll it back:
[](${ROLLBACK_URL})
Or via CLI:
\`\`\`bash
releasekit --workspace ${WS_LABEL} rollback ${TAG_FOR_ROLLBACK} --all-tags
\`\`\`
EOF
fi