Skip to content

fix(test): update consult tests to match current consult-mode behavior #206

fix(test): update consult tests to match current consult-mode behavior

fix(test): update consult tests to match current consult-mode behavior #206

Workflow file for this run

name: Squad CI
on:
pull_request:
branches: [dev, preview, main, insider]
types: [opened, synchronize, reopened, edited]
push:
branches: [dev, insider]
permissions:
contents: read
jobs:
# ── Path filter for conditional job execution ──────────────────────────
# Detects which areas of the repo changed so downstream jobs can skip
# when their inputs haven't changed (e.g., skip docs-quality on code-only PRs).
changes:
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed paths
id: filter
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
CHANGED=$(git diff --name-only "$BASE"..."$HEAD")
else
# On push, compare against parent commit
CHANGED=$(git diff --name-only HEAD~1...HEAD 2>/dev/null || echo "docs/")
fi
if echo "$CHANGED" | grep -qE '^(docs/|README\.md|\.markdownlint|\.cspell|cspell\.json)'; then
echo "docs=true" >> "$GITHUB_OUTPUT"
else
echo "docs=false" >> "$GITHUB_OUTPUT"
fi
docs-quality:
needs: changes
if: "!cancelled() && (github.event_name == 'push' || needs.changes.outputs.docs == 'true')"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install docs tools
run: |
success=false
for i in 1 2 3; do
if npm install --no-save markdownlint-cli2 cspell; then
success=true
break
fi
echo "Retry $i/3 — npm install failed, retrying in 5s..."
sleep 5
done
if [ "$success" = false ]; then
echo "::error::npm install failed after 3 attempts"
exit 1
fi
- name: Lint docs markdown
run: npx markdownlint-cli2
- name: Spell check docs
run: npx cspell --no-progress --dot "docs/src/content/**/*.md" "README.md"
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Fix stale lockfile entries
run: |
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
const pkgs = lock.packages || {};
const stale = Object.keys(pkgs).filter(k =>
k.includes('/node_modules/@bradygaster/squad-') &&
pkgs[k].resolved && pkgs[k].resolved.startsWith('https://')
);
if (stale.length) {
stale.forEach(k => { console.log('Removing: ' + k); delete pkgs[k]; });
fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n');
} else {
console.log('Lockfile clean');
}
"
- name: Install dependencies
run: |
success=false
for i in 1 2 3; do
if npm install; then
success=true
break
fi
echo "Retry $i/3 — npm install failed, retrying in 5s..."
sleep 5
done
if [ "$success" = false ]; then
echo "::error::npm install failed after 3 attempts"
exit 1
fi
- name: Install docs dependencies
run: |
success=false
for i in 1 2 3; do
if npm ci; then
success=true
break
fi
echo "Retry $i/3 — npm ci failed, retrying in 5s..."
sleep 5
done
if [ "$success" = false ]; then
echo "::error::npm ci failed after 3 attempts"
exit 1
fi
working-directory: docs
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: "🔒 Source tree canary check"
run: |
echo "Verifying critical source files exist..."
MISSING=0
for f in \
"packages/squad-sdk/src/index.ts" \
"packages/squad-cli/src/cli/index.ts" \
"packages/squad-sdk/package.json" \
"packages/squad-cli/package.json"; do
if [ ! -f "$f" ]; then
echo "::error::MISSING critical file: $f"
MISSING=$((MISSING + 1))
fi
done
if [ $MISSING -gt 0 ]; then
echo "::error::$MISSING critical source files missing — possible accidental deletion"
exit 1
fi
echo "✅ All critical source files present"
- name: "🔒 Large deletion guard"
if: github.event_name == 'pull_request'
run: |
DELETED=$(git diff --diff-filter=D --name-only origin/${{ github.base_ref }}...HEAD | wc -l)
echo "Files deleted in this PR: $DELETED"
if [ "$DELETED" -gt 50 ]; then
echo "::error::This PR deletes $DELETED files (threshold: 50). If intentional, add the 'large-deletion-approved' label."
LABELS=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -q "large-deletion-approved"; then
echo "✅ Large deletion approved via label"
else
exit 1
fi
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Build
run: npm run build
- name: Run tests
run: npm test
# ════════════════════════════════════════════════════════════════════════
# Skip Labels Reference
# ────────────────────────────────────────────────────────────────────────
# The following PR labels can be used to bypass specific health gates.
# Add them via the GitHub UI or `gh pr edit --add-label <label>`.
#
# skip-changelog — skip the changelog-gate job
# skip-exports-check — skip the exports-map-check job
# skip-samples-ci — skip the samples-build job
# skip-workspace-check — skip the workspace-integrity job
# skip-version-check — skip the prerelease-version-guard job
# skip-export-smoke — skip the export-smoke-test job
# large-deletion-approved — bypass the large-deletion guard (test job)
#
# These labels are compatible with sync-squad-labels.yml — add them
# to the label sync config to auto-create them in new repos.
# ════════════════════════════════════════════════════════════════════════
changelog-gate:
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Identify the merge-base: git merge-base dev HEAD
# 2. Check for SDK/CLI source changes:
# git diff --name-only <merge-base>...HEAD | grep -E '^packages/squad-(sdk|cli)/src/'
# 3. If any match, verify CHANGELOG.md is also in the diff:
# git diff --name-only <merge-base>...HEAD | grep -E '^CHANGELOG\.md$'
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check feature flag
id: flag
# Default: gate is ENABLED. When vars.SQUAD_CHANGELOG_CHECK is
# undefined (not set in repo/org variables), the bash comparison
# [ "" = "false" ] evaluates to false, so skip stays "false" and
# the gate runs. Set vars.SQUAD_CHANGELOG_CHECK to "false" to
# explicitly disable.
run: |
if [ "${{ vars.SQUAD_CHANGELOG_CHECK }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "CHANGELOG gate disabled via vars.SQUAD_CHANGELOG_CHECK"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
LABELS=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -q "skip-changelog"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping CHANGELOG gate (skip-changelog label present)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Require CHANGELOG update for SDK/CLI source changes
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Three-dot diff (base...head) finds the merge-base automatically,
# so it works correctly even when the PR branch contains merge
# commits from syncing with the base branch. It compares against
# the common ancestor, not the literal base SHA.
CHANGED=$(git diff --name-only "$BASE"..."$HEAD")
# Change detection regex: ^packages/squad-(sdk|cli)/src/
# Matches any file under packages/squad-sdk/src/ or packages/squad-cli/src/
# This intentionally excludes config files, tests, and docs — only source changes
# require a CHANGELOG entry.
SDK_CLI_CHANGED=$(echo "$CHANGED" | grep -E '^packages/squad-(sdk|cli)/src/' || true)
if [ -z "$SDK_CLI_CHANGED" ]; then
echo "No SDK/CLI source changes detected -- CHANGELOG gate not applicable"
exit 0
fi
echo "SDK/CLI source files changed:"
echo "$SDK_CLI_CHANGED"
# Regex: ^CHANGELOG\.md$ — exact match on root CHANGELOG.md only
CHANGELOG_CHANGED=$(echo "$CHANGED" | grep -E '^CHANGELOG\.md$' || true)
if [ -z "$CHANGELOG_CHANGED" ]; then
echo ""
echo "::error::CHANGELOG.md was not updated but SDK/CLI source files were changed."
echo "::error::Please add a CHANGELOG.md entry describing your changes."
echo "::error::If this is intentional, add the 'skip-changelog' label to your PR."
exit 1
fi
echo "CHANGELOG.md updated -- gate passed"
exports-map-check:
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Check for SDK source changes:
# git diff --name-only $(git merge-base dev HEAD)...HEAD | grep -E '^packages/squad-sdk/src/'
# 2. If any match, run the exports map script:
# node scripts/check-exports-map.mjs
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check feature flag
id: flag
# Default: gate is ENABLED. When vars.SQUAD_EXPORTS_CHECK is
# undefined (not set in repo/org variables), the bash comparison
# [ "" = "false" ] evaluates to false, so skip stays "false" and
# the gate runs. Set vars.SQUAD_EXPORTS_CHECK to "false" to
# explicitly disable.
run: |
if [ "${{ vars.SQUAD_EXPORTS_CHECK }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Exports map check disabled via vars.SQUAD_EXPORTS_CHECK"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
LABELS=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name' 2>/dev/null || echo "")
if echo "$LABELS" | grep -q "skip-exports-check"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping exports map check (skip-exports-check label present)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Check for SDK source changes
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
id: changes
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Three-dot diff (base...head) finds the merge-base automatically,
# so it works correctly even when the PR branch contains merge
# commits from syncing with the base branch.
# Change detection regex: ^packages/squad-sdk/src/
# Matches any file under the SDK source directory.
# Config or test-only changes don't require exports validation.
SDK_CHANGED=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^packages/squad-sdk/src/' || true)
if [ -z "$SDK_CHANGED" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No SDK source changes detected -- exports check not applicable"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "SDK source files changed:"
echo "$SDK_CHANGED"
fi
- name: Verify exports map matches barrel files
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true' && steps.changes.outputs.skip != 'true'
run: node scripts/check-exports-map.mjs
samples-build:
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Build the SDK: npm run build -w packages/squad-sdk
# 2. Loop over samples: for d in samples/*/; do (cd "$d" && npm install && npm run build); done
# 3. Or test a single sample: cd samples/<name> && npm install && npm run build && npm test
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
package-lock.json
samples/**/package-lock.json
- name: Check feature flag
id: flag
# Default: gate is ENABLED. Set vars.SQUAD_SAMPLES_CI to "false"
# to explicitly disable.
run: |
if [ "${{ vars.SQUAD_SAMPLES_CI }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Samples build gate disabled via vars.SQUAD_SAMPLES_CI"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
SKIP="false"
LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}'
if echo "$LABELS" | grep -q "skip-samples-ci"; then
SKIP="true"
echo "Skipping samples build gate (skip-samples-ci label present)"
fi
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
- name: Check for SDK source changes
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
id: changes
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Change detection regex: ^packages/squad-sdk/src/
# Only SDK source changes trigger sample rebuilds — doc or config changes are skipped.
SDK_CHANGED=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^packages/squad-sdk/src/' || true)
if [ -z "$SDK_CHANGED" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No SDK source changes detected -- samples build not applicable"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "SDK source files changed:"
echo "$SDK_CHANGED"
fi
- name: Install root dependencies and build SDK
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true' && steps.changes.outputs.skip != 'true'
run: |
npm ci --ignore-scripts
node packages/squad-cli/scripts/patch-esm-imports.mjs
npm run build -w packages/squad-sdk
- name: Build and test samples
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true' && steps.changes.outputs.skip != 'true'
run: |
FAILED=0
PASSED=0
SKIPPED=0
for sample_dir in samples/*/; do
sample_dir="${sample_dir%/}"
sample=$(basename "$sample_dir")
if [ ! -f "$sample_dir/package.json" ]; then
echo "::notice::[$sample] No package.json -- skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi
HAS_BUILD=$(node -e "const p=require('./$sample_dir/package.json'); process.exit(p.scripts?.build ? 0 : 1)" 2>/dev/null && echo "true" || echo "false")
HAS_TEST=$(node -e "const p=require('./$sample_dir/package.json'); process.exit(p.scripts?.test ? 0 : 1)" 2>/dev/null && echo "true" || echo "false")
if [ "$HAS_BUILD" = "false" ] && [ "$HAS_TEST" = "false" ]; then
echo "::notice::[$sample] No build or test scripts -- skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo ""
echo "========================================="
echo "[$sample] Installing dependencies..."
echo "========================================="
if ! (cd "$sample_dir" && npm install --ignore-scripts 2>&1); then
echo "::error::[$sample] npm install failed"
FAILED=$((FAILED + 1))
continue
fi
if [ "$HAS_BUILD" = "true" ]; then
echo "[$sample] Running build..."
if ! (cd "$sample_dir" && npm run build 2>&1); then
echo "::error::[$sample] npm run build failed"
FAILED=$((FAILED + 1))
continue
fi
fi
if [ "$HAS_TEST" = "true" ]; then
echo "[$sample] Running tests..."
if ! (cd "$sample_dir" && npm test 2>&1); then
echo "::error::[$sample] npm test failed"
FAILED=$((FAILED + 1))
continue
fi
fi
echo "::notice::[$sample] Passed"
PASSED=$((PASSED + 1))
done
echo ""
echo "========================================="
echo "Samples build summary: $PASSED passed, $FAILED failed, $SKIPPED skipped"
echo "========================================="
if [ "$FAILED" -gt 0 ]; then
echo "::error::$FAILED sample(s) failed build/test validation"
exit 1
fi
publish-policy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Enforce workspace-scoped npm publish
run: |
echo "Scanning workflow files for bare npm publish commands..."
VIOLATIONS=0
for wf in .github/workflows/*.yml; do
# Strip comment lines, then check for npm publish without -w/--workspace
BARE=$(grep -n 'npm.*publish' "$wf" | grep -v '#' | grep -v '\-w ' | grep -v '\-\-workspace' | grep -v 'echo ' | grep -v 'grep ' | grep -v 'name:' || true)
if [ -n "$BARE" ]; then
echo "::error file=$wf::Bare npm publish found (missing -w/--workspace):"
echo "$BARE"
VIOLATIONS=1
fi
done
if [ "$VIOLATIONS" -eq 1 ]; then
echo ""
echo "::error::PUBLISH POLICY VIOLATION — all npm publish commands must be workspace-scoped (-w or --workspace)"
echo "See: https://github.com/bradygaster/squad/issues/557"
exit 1
fi
echo "✅ All npm publish commands are workspace-scoped"
workspace-integrity:
# ──────────────────────────────────────────────────────────────────────
# Workspace Integrity Check
# Purpose: Verify workspace packages resolve to local file: links,
# not stale registry versions in the lockfile.
# Catches: npm silently resolving a published registry copy instead
# of the local workspace symlink due to version mismatches.
# Why: Added after PR #640 prerelease version incident where
# >=0.9.0 didn't match 0.9.1-build.4, so npm pulled the
# stale published SDK from the registry.
# Cost: Zero-install — reads package-lock.json only.
# ──────────────────────────────────────────────────────────────────────
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Inspect lockfile entries for workspace packages:
# node -e "const l=JSON.parse(require('fs').readFileSync('package-lock.json','utf8'));
# Object.entries(l.packages||{}).filter(([k])=>k.includes('@bradygaster/squad-'))
# .forEach(([k,v])=>console.log(k, v.resolved||v.version, v.link?'(link)':''))"
# 2. All entries should show "(link)" — any with https:// URLs are stale.
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check feature flag
id: flag
# Default: gate is ENABLED. When vars.SQUAD_WORKSPACE_CHECK is
# undefined (not set in repo/org variables), the bash comparison
# [ "" = "false" ] evaluates to false, so skip stays "false" and
# the gate runs. Set vars.SQUAD_WORKSPACE_CHECK to "false" to
# explicitly disable.
run: |
if [ "${{ vars.SQUAD_WORKSPACE_CHECK }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Workspace integrity check disabled via vars.SQUAD_WORKSPACE_CHECK"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}'
if echo "$LABELS" | grep -q "skip-workspace-check"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping workspace integrity check (skip-workspace-check label present)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Verify workspace packages resolve locally
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
run: |
node -e "
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
const pkgs = lock.packages || {};
const problems = [];
for (const [key, val] of Object.entries(pkgs)) {
if (!key.includes('node_modules/@bradygaster/squad-')) continue;
if (val.resolved && val.resolved.startsWith('https://')) {
problems.push({ path: key, resolved: val.resolved });
}
}
if (problems.length > 0) {
console.error('::error::WORKSPACE INTEGRITY FAILURE — npm resolved registry packages instead of local workspace copies.');
console.error('::error::This likely means a version mismatch between workspace packages (see PR #640).');
console.error('');
problems.forEach(p => {
console.error(' STALE: ' + p.path + (p.resolved ? ' → ' + p.resolved : ' (version: ' + p.version + ', not a workspace link)'));
});
console.error('');
console.error('To fix: ensure all workspace package version ranges match local versions,');
console.error('then run npm install at the repo root to regenerate the lockfile.');
process.exit(1);
}
console.log('✅ All workspace packages resolve to local file: links');
"
prerelease-version-guard:
# ──────────────────────────────────────────────────────────────────────
# Prerelease Version Guard
# Purpose: Prevent prerelease version strings (-build, -alpha, -beta,
# -rc) from being committed to dev or main.
# Catches: Forgotten prerelease suffixes that break semver range
# resolution in workspace dependencies.
# Why: Added after PR #640 prerelease version incident where a
# -build.N suffix caused npm to skip the local workspace
# copy during dependency resolution.
# Cost: Zero-install — reads package.json files only.
# ──────────────────────────────────────────────────────────────────────
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Scan for prerelease versions:
# node -e "require('fs').readdirSync('packages').forEach(d=>{
# const p=require('./packages/'+d+'/package.json');
# if(/-/.test(p.version)) console.log(p.name+'@'+p.version+' ← prerelease!')})"
# 2. A clean run should produce no output.
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check feature flag
id: flag
# Default: gate is ENABLED. When vars.SQUAD_VERSION_CHECK is
# undefined (not set in repo/org variables), the bash comparison
# [ "" = "false" ] evaluates to false, so skip stays "false" and
# the gate runs. Set vars.SQUAD_VERSION_CHECK to "false" to
# explicitly disable.
run: |
if [ "${{ vars.SQUAD_VERSION_CHECK }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Prerelease version guard disabled via vars.SQUAD_VERSION_CHECK"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}'
if echo "$LABELS" | grep -q "skip-version-check"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping prerelease version guard (skip-version-check label present)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Scan packages for prerelease versions
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
run: |
node -e "
const fs = require('fs');
const path = require('path');
const pkgDirs = fs.readdirSync('packages', { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
const violations = [];
for (const dir of pkgDirs) {
const pkgPath = path.join('packages', dir, 'package.json');
if (!fs.existsSync(pkgPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
// Version regex: /-/ — matches any hyphen in the version string.
// In semver, a hyphen after the patch number indicates a prerelease
// suffix (e.g. 1.0.0-alpha.1, 0.9.1-build.4). Stable releases
// like 1.0.0 contain no hyphen and pass this check.
if (pkg.version && /-/.test(pkg.version)) {
violations.push({ name: pkg.name, version: pkg.version, path: pkgPath });
}
}
if (violations.length > 0) {
console.error('::error::PRERELEASE VERSION DETECTED — packages with prerelease versions cannot merge to dev/main.');
console.error('');
violations.forEach(v => {
console.error(' ' + v.name + '@' + v.version + ' (' + v.path + ')');
});
console.error('');
console.error('Prerelease suffixes (-build, -alpha, -beta, -rc) must be removed before merging.');
console.error('To fix: update the version field in each listed package.json to a release version.');
console.error('To skip: add the \"skip-version-check\" label to your PR.');
process.exit(1);
}
console.log('✅ All package versions are release versions (no prerelease suffixes)');
pkgDirs.forEach(dir => {
const pkgPath = path.join('packages', dir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.version) console.log(' ' + pkg.name + '@' + pkg.version);
}
});
"
export-smoke-test:
# ──────────────────────────────────────────────────────────────────────
# Export Smoke Test
# Purpose: Verify that subpath exports actually resolve after build.
# The exports-map-check validates config (barrel files match
# export entries); this gate validates built artifacts exist
# and are importable.
# Catches: Missing dist/ files for declared subpath exports — e.g. a
# new export added to package.json but the build doesn't
# produce the referenced .js file.
# Why: Added after PR #640 prerelease version incident to
# strengthen build artifact validation.
# Cost: Requires install + SDK build (~30s).
# ──────────────────────────────────────────────────────────────────────
# ── Local testing ──────────────────────────────────────────────────────
# To test this gate locally:
# 1. Build the SDK: npm run build -w packages/squad-sdk
# 2. Run the smoke test inline:
# node -e "const p=require('./packages/squad-sdk/package.json');
# Object.entries(p.exports||{}).forEach(async([k,v])=>{
# const f=typeof v==='string'?v:(v.import||v.default);
# try{await import('./packages/squad-sdk/'+f);console.log('✅',k)}
# catch(e){console.error('❌',k,e.message)}})"
# 3. To create a test PR to verify this gate:
# gh pr edit <number> --add-label skip-export-smoke
# ──────────────────────────────────────────────────────────────────────
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check feature flag
id: flag
# Default: gate is ENABLED. When vars.SQUAD_EXPORT_SMOKE is
# undefined (not set in repo/org variables), the bash comparison
# [ "" = "false" ] evaluates to false, so skip stays "false" and
# the gate runs. Set vars.SQUAD_EXPORT_SMOKE to "false" to
# explicitly disable.
run: |
if [ "${{ vars.SQUAD_EXPORT_SMOKE }}" = "false" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Export smoke test disabled via vars.SQUAD_EXPORT_SMOKE"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check skip label
if: steps.flag.outputs.skip == 'false'
id: label
run: |
LABELS='${{ toJSON(github.event.pull_request.labels.*.name) }}'
if echo "$LABELS" | grep -q "skip-export-smoke"; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping export smoke test (skip-export-smoke label present)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for SDK source changes
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true'
id: changes
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Three-dot diff (base...head) finds the merge-base automatically,
# so it works correctly even when the PR branch contains merge
# commits from syncing with the base branch.
# Change detection regex: ^packages/squad-sdk/(src/|package\.json)
# Matches SDK source files OR the SDK package.json (which contains the
# exports map). This is broader than other gates because changes to
# the exports map in package.json also need smoke testing.
SDK_CHANGED=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^packages/squad-sdk/(src/|package\.json)' || true)
if [ -z "$SDK_CHANGED" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "No SDK source/config changes detected -- export smoke test not applicable"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "SDK files changed:"
echo "$SDK_CHANGED"
fi
- name: Install and build SDK
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true' && steps.changes.outputs.skip != 'true'
run: |
npm ci --ignore-scripts
node packages/squad-cli/scripts/patch-esm-imports.mjs
npm run build -w packages/squad-sdk
- name: Smoke test all subpath exports
if: steps.flag.outputs.skip == 'false' && steps.label.outputs.skip != 'true' && steps.changes.outputs.skip != 'true'
run: |
node --input-type=module -e "
import fs from 'fs';
import path from 'path';
import { pathToFileURL } from 'url';
const pkg = JSON.parse(fs.readFileSync('packages/squad-sdk/package.json', 'utf8'));
const exportsMap = pkg.exports || {};
const failures = [];
let passed = 0;
for (const [subpath, targets] of Object.entries(exportsMap)) {
const importPath = subpath === '.'
? '@bradygaster/squad-sdk'
: '@bradygaster/squad-sdk/' + subpath.slice(2);
const filePath = typeof targets === 'string'
? targets
: (targets.import || targets.default);
if (!filePath) {
failures.push({ subpath, importPath, error: 'No import target defined' });
continue;
}
const resolvedPath = path.resolve('packages/squad-sdk', filePath);
// Phase 1: File existence check
if (!fs.existsSync(resolvedPath)) {
failures.push({ subpath, importPath, filePath, error: 'File not found: ' + resolvedPath });
continue;
}
// Phase 2: Dynamic import() — verifies the module actually loads
// without syntax errors, missing dependencies, or broken re-exports.
try {
await import(pathToFileURL(resolvedPath).href);
passed++;
console.log(' ✅ ' + importPath + ' → ' + filePath + ' (exists + imports OK)');
} catch (e) {
failures.push({ subpath, importPath, filePath, error: 'import() failed: ' + e.message });
}
}
console.log('');
if (failures.length > 0) {
console.error('::error::EXPORT SMOKE TEST FAILED — ' + failures.length + ' subpath export(s) do not resolve to built artifacts.');
console.error('');
failures.forEach(f => {
console.error(' ❌ ' + (f.importPath || f.subpath) + ': ' + f.error);
});
console.error('');
console.error('This means consumers importing these subpaths will get runtime errors.');
console.error('To fix: ensure the build produces all files referenced in package.json exports.');
console.error('To skip: add the \\\"skip-export-smoke\\\" label to your PR.');
process.exit(1);
}
console.log('✅ All ' + passed + ' subpath exports resolve and import successfully');
"