name: 'FerrFlow Release'
description: 'Universal semantic versioning for monorepos and classic repos'
author: 'FerrLabs'
branding:
icon: 'tag'
color: 'orange'
inputs:
version:
description: 'FerrFlow version to use (e.g. 1.2.3). Defaults to latest.'
required: false
default: 'latest'
mode:
description: 'Action mode: "release" runs the full release pipeline. "preview" posts a PR comment with version bump preview.'
required: false
default: 'release'
dry_run:
description: 'Run without creating releases or pushing changes'
required: false
default: 'false'
force_version:
description: 'Force a specific version, skipping commit analysis. Format: VERSION (single repo) or NAME@VERSION (monorepo)'
required: false
default: ''
bot:
description: 'Opt into the hosted FerrFlow bot identity (ferrflow[bot]). Requires permissions.id-token: write on the caller workflow. If false/unset, the caller''s token or GITHUB_TOKEN is used as before.'
required: false
default: 'false'
bot_endpoint:
description: 'Override the hosted bot token endpoint. Defaults to https://api.ferrlabs.com/api/v1/ferrflow/token.'
required: false
default: 'https://api.ferrlabs.com/api/v1/ferrflow/token'
bot_audience:
description: 'OIDC audience requested from the GitHub Actions runner. Must match the server-side expected audience.'
required: false
default: 'ferrflow.ferrlabs.com'
runs:
using: composite
steps:
- name: Install FerrFlow
shell: bash
run: |
set -euo pipefail
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
# Git Bash / MSYS / Cygwin on Windows runners all report a compound
# kernel name like "mingw64_nt-10.0". Normalise to "windows".
case "$OS" in
linux) PLATFORM="linux" ; EXT="tar.gz" ;;
darwin) PLATFORM="darwin" ; EXT="tar.gz" ;;
mingw*|msys*|cygwin*) PLATFORM="windows" ; EXT="zip" ;;
*) echo "Unsupported OS: $OS" && exit 1 ;;
esac
case "$ARCH" in
x86_64) ARCH_NAME="x64" ;;
aarch64|arm64) ARCH_NAME="arm64" ;;
*) echo "Unsupported architecture: $ARCH" && exit 1 ;;
esac
ARCHIVE="ferrflow-${PLATFORM}-${ARCH_NAME}.${EXT}"
if [ "${{ inputs.version }}" = "latest" ]; then
URL="https://github.com/FerrLabs/ferrflow/releases/latest/download/${ARCHIVE}"
else
URL="https://github.com/FerrLabs/ferrflow/releases/download/v${{ inputs.version }}/${ARCHIVE}"
fi
INSTALL_DIR="${RUNNER_TEMP:-$HOME/.local/bin}/ferrflow-bin"
mkdir -p "$INSTALL_DIR"
if [ "$PLATFORM" = "windows" ]; then
# Can't pipe into unzip (needs a seekable file), so download first.
TMP_ZIP="${RUNNER_TEMP:-/tmp}/ferrflow.zip"
curl --fail --location --silent --show-error --output "$TMP_ZIP" "$URL"
unzip -q -o "$TMP_ZIP" -d "$INSTALL_DIR"
rm -f "$TMP_ZIP"
# No chmod needed on Windows; the .exe is already executable.
else
curl --fail --location --silent --show-error "$URL" | tar -xz -C "$INSTALL_DIR"
chmod +x "$INSTALL_DIR/ferrflow"
fi
echo "$INSTALL_DIR" >> "$GITHUB_PATH"
- name: Exchange OIDC for FerrFlow bot token
if: inputs.bot == 'true'
shell: bash
env:
BOT_ENDPOINT: ${{ inputs.bot_endpoint }}
BOT_AUDIENCE: ${{ inputs.bot_audience }}
run: |
set -euo pipefail
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then
echo "::error::FerrFlow bot mode requires OIDC access. Add 'permissions: { id-token: write }' to your workflow (or the job) so the runner exposes an OIDC token endpoint."
exit 1
fi
# Request an OIDC JWT from the runner, scoped to the FerrFlow bot audience.
OIDC_RESPONSE=$(curl --fail -sSL \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${BOT_AUDIENCE}") || {
echo "::error::Failed to fetch OIDC token from the GitHub Actions runner."
exit 1
}
OIDC_JWT=$(printf '%s' "$OIDC_RESPONSE" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);if(!o.value){process.exit(2);}process.stdout.write(o.value);})')
if [ -z "$OIDC_JWT" ]; then
echo "::error::OIDC response from the runner did not contain a token value."
exit 1
fi
# Exchange the OIDC JWT for a short-lived GitHub App installation token.
HTTP_BODY_FILE="$(mktemp)"
HTTP_CODE=$(curl -sSL -o "$HTTP_BODY_FILE" -w "%{http_code}" \
-X POST "$BOT_ENDPOINT" \
-H "Authorization: Bearer $OIDC_JWT" \
-H "Content-Type: application/json" \
--data "$(node -e 'process.stdout.write(JSON.stringify({token: process.argv[1]}))' "$OIDC_JWT")") || HTTP_CODE="000"
case "$HTTP_CODE" in
200|201)
;;
401)
echo "::error::FerrFlow OIDC verification failed. The runner's OIDC token was rejected by the hosted bot service."
rm -f "$HTTP_BODY_FILE"
exit 1
;;
404)
echo "::error::FerrFlow App not installed on this repository's owner. Install at https://github.com/apps/ferrflow."
rm -f "$HTTP_BODY_FILE"
exit 1
;;
429)
echo "::error::FerrFlow hosted bot rate limit hit. Retry in a few minutes or use 'token:' fallback."
rm -f "$HTTP_BODY_FILE"
exit 1
;;
5*|000)
echo "::error::FerrFlow hosted bot unavailable. See status.ferrlabs.com or fall back to 'token:' input."
rm -f "$HTTP_BODY_FILE"
exit 1
;;
*)
echo "::error::FerrFlow hosted bot returned unexpected HTTP $HTTP_CODE."
rm -f "$HTTP_BODY_FILE"
exit 1
;;
esac
BOT_TOKEN=$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);if(!o.token){process.exit(2);}process.stdout.write(o.token);})' < "$HTTP_BODY_FILE")
BOT_EXPIRES=$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const o=JSON.parse(s);process.stdout.write(o.expires_at||"");})' < "$HTTP_BODY_FILE")
rm -f "$HTTP_BODY_FILE"
if [ -z "$BOT_TOKEN" ]; then
echo "::error::FerrFlow hosted bot response did not contain a token."
exit 1
fi
echo "::add-mask::$BOT_TOKEN"
echo "GITHUB_TOKEN=$BOT_TOKEN" >> "$GITHUB_ENV"
echo "FERRFLOW_TOKEN=$BOT_TOKEN" >> "$GITHUB_ENV"
echo "Authenticated as ferrflow[bot] (token expires at ${BOT_EXPIRES:-unknown})."
- name: Preview release
if: inputs.mode == 'preview'
shell: bash
run: ferrflow check --comment
- name: Run FerrFlow release
if: inputs.mode == 'release'
shell: bash
run: ferrflow ${{ inputs.dry_run == 'true' && '--dry-run' || '' }} release ${{ inputs.force_version != '' && format('--force-version {0}', inputs.force_version) || '' }}