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>"
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