specsync 4.1.0

Bidirectional spec-to-code validation with schema column checking — 11 languages, single binary
name: 'spec-sync'
description: 'Validate module specs against source code with SpecSync'
branding:
  icon: 'check-circle'
  color: 'green'

inputs:
  version:
    description: 'SpecSync version to use (e.g. "1.0.0" or "latest")'
    required: false
    default: 'latest'
  strict:
    description: 'Treat warnings as errors'
    required: false
    default: 'false'
  require-coverage:
    description: 'Minimum file coverage percentage (0-100)'
    required: false
    default: '0'
  root:
    description: 'Project root directory'
    required: false
    default: '.'
  args:
    description: 'Additional arguments to pass to specsync check'
    required: false
    default: ''
  lifecycle-enforce:
    description: 'Run lifecycle enforcement checks (--all). Fails CI if specs violate lifecycle rules.'
    required: false
    default: 'false'
  comment:
    description: 'Post spec drift results as a PR comment (requires pull_request event and write permissions)'
    required: false
    default: 'false'
  token:
    description: 'GitHub token for posting PR comments (defaults to GITHUB_TOKEN)'
    required: false
    default: '${{ github.token }}'

runs:
  using: 'composite'
  steps:
    - name: Download SpecSync
      shell: bash
      env:
        SPECSYNC_VERSION: ${{ inputs.version }}
      run: |
        set -euo pipefail

        # Detect OS
        case "$RUNNER_OS" in
          Linux)   OS="linux" ;;
          macOS)   OS="macos" ;;
          Windows) OS="windows" ;;
          *)
            echo "::error::Unsupported runner OS: $RUNNER_OS"
            exit 1
            ;;
        esac

        # Detect architecture
        ARCH="$(uname -m)"
        case "$ARCH" in
          x86_64|amd64)  ARCH="x86_64" ;;
          aarch64|arm64) ARCH="aarch64" ;;
          *)
            echo "::error::Unsupported architecture: $ARCH"
            exit 1
            ;;
        esac

        REPO="CorvidLabs/spec-sync"

        # Determine download URL
        if [ "$SPECSYNC_VERSION" = "latest" ]; then
          BASE_URL="https://github.com/${REPO}/releases/latest/download"
        else
          BASE_URL="https://github.com/${REPO}/releases/download/v${SPECSYNC_VERSION}"
        fi

        # Download and install
        INSTALL_DIR="${RUNNER_TEMP}/specsync"
        mkdir -p "$INSTALL_DIR"

        if [ "$OS" = "windows" ]; then
          ARCHIVE="specsync-${OS}-${ARCH}.exe.zip"
          curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${INSTALL_DIR}/specsync.zip"

          # Verify checksum if available
          if curl -fsSL "${BASE_URL}/${ARCHIVE}.sha256" -o "${INSTALL_DIR}/specsync.sha256" 2>/dev/null; then
            echo "Verifying checksum..."
            cd "$INSTALL_DIR"
            EXPECTED=$(awk '{print $1}' specsync.sha256)
            ACTUAL=$(shasum -a 256 specsync.zip | awk '{print $1}')
            if [ "$EXPECTED" != "$ACTUAL" ]; then
              echo "::error::Checksum verification failed! Expected: $EXPECTED, Got: $ACTUAL"
              exit 1
            fi
            echo "::notice::Checksum verified"
            cd -
          fi

          unzip -o "${INSTALL_DIR}/specsync.zip" -d "$INSTALL_DIR"
          mv "${INSTALL_DIR}/specsync-${OS}-${ARCH}.exe" "${INSTALL_DIR}/specsync.exe"
        else
          ARCHIVE="specsync-${OS}-${ARCH}.tar.gz"
          curl -fsSL "${BASE_URL}/${ARCHIVE}" -o "${INSTALL_DIR}/${ARCHIVE}"

          # Verify checksum if available
          if curl -fsSL "${BASE_URL}/${ARCHIVE}.sha256" -o "${INSTALL_DIR}/${ARCHIVE}.sha256" 2>/dev/null; then
            echo "Verifying checksum..."
            cd "$INSTALL_DIR"
            shasum -a 256 -c "${ARCHIVE}.sha256"
            echo "::notice::Checksum verified"
            cd -
          fi

          tar xz -C "$INSTALL_DIR" -f "${INSTALL_DIR}/${ARCHIVE}"
          mv "${INSTALL_DIR}/specsync-${OS}-${ARCH}" "${INSTALL_DIR}/specsync"
          chmod +x "${INSTALL_DIR}/specsync"
        fi

        # Add to PATH
        echo "${INSTALL_DIR}" >> "$GITHUB_PATH"
        echo "::notice::SpecSync installed (${OS}/${ARCH}) from ${BASE_URL}/${ARCHIVE}"

    - name: Run SpecSync
      shell: bash
      id: specsync
      working-directory: ${{ inputs.root }}
      env:
        INPUT_STRICT: ${{ inputs.strict }}
        INPUT_REQUIRE_COVERAGE: ${{ inputs.require-coverage }}
        INPUT_ARGS: ${{ inputs.args }}
        INPUT_COMMENT: ${{ inputs.comment }}
        INPUT_LIFECYCLE_ENFORCE: ${{ inputs.lifecycle-enforce }}
      run: |
        set -euo pipefail

        # Always use --force in CI — hash cache is not committed, so
        # every CI run validates all specs from scratch.
        CMD="specsync check --force"

        if [ "$INPUT_STRICT" = "true" ]; then
          CMD="$CMD --strict"
        fi

        if [ "$INPUT_REQUIRE_COVERAGE" != "0" ]; then
          CMD="$CMD --require-coverage $INPUT_REQUIRE_COVERAGE"
        fi

        if [ -n "$INPUT_ARGS" ]; then
          CMD="$CMD $INPUT_ARGS"
        fi

        echo "::group::SpecSync Check"
        echo "Running: $CMD"
        EXIT_CODE=0
        eval "$CMD" || EXIT_CODE=$?
        echo "::endgroup::"

        # If lifecycle enforcement is enabled, run it (may override exit code)
        if [ "$INPUT_LIFECYCLE_ENFORCE" = "true" ]; then
          echo "::group::SpecSync Lifecycle Enforce"
          specsync lifecycle enforce --all || EXIT_CODE=$?
          echo "::endgroup::"
        fi

        # If comment mode is enabled, generate the rich comment body
        # Uses the same `specsync comment` pipeline as our own CI workflow
        # for identical output between the marketplace action and direct usage.
        if [ "$INPUT_COMMENT" = "true" ]; then
          COMMENT_OUTPUT=$(specsync comment 2>/dev/null) || true
          {
            echo "SPECSYNC_MARKDOWN<<SPECSYNC_EOF"
            echo "$COMMENT_OUTPUT"
            echo "SPECSYNC_EOF"
          } >> "$GITHUB_ENV"
        fi

        exit $EXIT_CODE

    - name: Post PR Comment
      if: inputs.comment == 'true' && github.event_name == 'pull_request' && always()
      shell: bash
      env:
        GH_TOKEN: ${{ inputs.token }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
        REPO: ${{ github.repository }}
      run: |
        set -euo pipefail

        if [ -z "${SPECSYNC_MARKDOWN:-}" ]; then
          echo "No drift output to comment"
          exit 0
        fi

        COMMENT_BODY="${SPECSYNC_MARKDOWN}

---
<sub>Posted by [SpecSync](https://github.com/CorvidLabs/spec-sync) via GitHub Actions</sub>"

        # Check for existing SpecSync comment and update it, or create new
        EXISTING_COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
          --jq '.[] | select(.body | contains("Posted by [SpecSync]")) | .id' | head -1)

        if [ -n "$EXISTING_COMMENT_ID" ]; then
          gh api "repos/${REPO}/issues/comments/${EXISTING_COMMENT_ID}" \
            -X PATCH -f body="$COMMENT_BODY" > /dev/null
          echo "::notice::Updated existing SpecSync PR comment"
        else
          gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
            -f body="$COMMENT_BODY" > /dev/null
          echo "::notice::Posted SpecSync PR comment"
        fi