fix(test): update consult tests to match current consult-mode behavior #206
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | |
| " |