ferrflow 4.1.0

Universal semantic versioning for monorepos and classic repos
Documentation
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) || '' }}