From c53ee24bfc292ff93b3e7a01df0fd8b3db8dcc91 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:48:20 -0700 Subject: [PATCH 1/2] feat(ci): add CHANGELOG and exports map completeness gates Phase 2-3 of #104. Adds two new CI checks: - CHANGELOG gate: requires CHANGELOG.md update when SDK/CLI source changes - Exports map check: verifies package.json exports match barrel files Both are feature-flagged (vars.SQUAD_CHANGELOG_CHECK, vars.SQUAD_EXPORTS_CHECK) and can be skipped per-PR with labels (skip-changelog, skip-exports-check). Part 2 of 2 for repo health (Part 1: PR #108 added requirements spec). Refs #104 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-ci.yml | 115 +++++++++++++++++++++++++++++++++ scripts/check-exports-map.mjs | 44 +++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 scripts/check-exports-map.mjs diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 8cd153634..763404b70 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -109,6 +109,121 @@ jobs: - name: Run tests run: npm test + changelog-gate: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check feature flag + id: flag + 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 }}" + CHANGED=$(git diff --name-only "$BASE"..."$HEAD") + + 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" + + 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: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Check feature flag + id: flag + 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 }}" + 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 + publish-policy: runs-on: ubuntu-latest steps: diff --git a/scripts/check-exports-map.mjs b/scripts/check-exports-map.mjs new file mode 100644 index 000000000..fd6197f23 --- /dev/null +++ b/scripts/check-exports-map.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// check-exports-map.mjs -- Verify package.json exports match barrel files. +// Exit 0 if all barrels are mapped, exit 1 with details if any are missing. +// Uses only Node.js built-ins (fs, path). + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { resolve, join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SDK_ROOT = resolve(__dirname, '..', 'packages', 'squad-sdk'); +const SRC_DIR = join(SDK_ROOT, 'src'); +const PKG_PATH = join(SDK_ROOT, 'package.json'); + +const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')); +const exportsMap = pkg.exports || {}; + +const srcEntries = readdirSync(SRC_DIR, { withFileTypes: true }); +const barrelDirs = srcEntries + .filter((entry) => entry.isDirectory()) + .filter((entry) => existsSync(join(SRC_DIR, entry.name, 'index.ts'))) + .map((entry) => entry.name); + +const missing = []; + +for (const dir of barrelDirs) { + const exportKey = `./${dir}`; + if (!exportsMap[exportKey]) { + missing.push({ dir, expectedKey: exportKey }); + } +} + +if (missing.length === 0) { + console.log(`Exports map check passed: all ${barrelDirs.length} barrel directories have export entries.`); + process.exit(0); +} else { + console.error(`Exports map check FAILED: ${missing.length} barrel(s) missing from package.json exports.\n`); + for (const { dir, expectedKey } of missing) { + console.error(` MISSING: "${expectedKey}" (has src/${dir}/index.ts but no export entry)`); + } + console.error(`\nTo fix: add export entries to packages/squad-sdk/package.json "exports" for each missing barrel.`); + console.error('If this is intentional, add the skip-exports-check label to your PR.'); + process.exit(1); +} \ No newline at end of file From 50d6f96f5f58a5ac29c2e7e84d80603f089643ee Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:36:12 -0700 Subject: [PATCH 2/2] fix: address FIDO + Challenger review findings on PR #110 - Add test/check-exports-map.test.ts for script coverage - Add clarifying comments for feature flag defaults (undefined != false) - Improve error messages with skip label references - Document merge commit handling in CHANGELOG gate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-ci.yml | 17 +++++++++++ scripts/check-exports-map.mjs | 5 ++-- test/check-exports-map.test.ts | 53 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 test/check-exports-map.test.ts diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 763404b70..9ae8a2f9f 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -119,6 +119,11 @@ jobs: - 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" @@ -146,6 +151,10 @@ jobs: 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") SDK_CLI_CHANGED=$(echo "$CHANGED" | grep -E '^packages/squad-(sdk|cli)/src/' || true) @@ -182,6 +191,11 @@ jobs: - 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" @@ -210,6 +224,9 @@ jobs: 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. 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" diff --git a/scripts/check-exports-map.mjs b/scripts/check-exports-map.mjs index fd6197f23..0ce25470d 100644 --- a/scripts/check-exports-map.mjs +++ b/scripts/check-exports-map.mjs @@ -34,11 +34,12 @@ if (missing.length === 0) { console.log(`Exports map check passed: all ${barrelDirs.length} barrel directories have export entries.`); process.exit(0); } else { - console.error(`Exports map check FAILED: ${missing.length} barrel(s) missing from package.json exports.\n`); + console.error(`Exports map check FAILED: ${missing.length} barrel(s) missing from package.json exports.`); + console.error(`This is by design -- new barrel directories must have matching export entries.\n`); for (const { dir, expectedKey } of missing) { console.error(` MISSING: "${expectedKey}" (has src/${dir}/index.ts but no export entry)`); } console.error(`\nTo fix: add export entries to packages/squad-sdk/package.json "exports" for each missing barrel.`); - console.error('If this is intentional, add the skip-exports-check label to your PR.'); + console.error('To skip: add the "skip-exports-check" label to your PR to bypass this gate.'); process.exit(1); } \ No newline at end of file diff --git a/test/check-exports-map.test.ts b/test/check-exports-map.test.ts new file mode 100644 index 000000000..94cb71242 --- /dev/null +++ b/test/check-exports-map.test.ts @@ -0,0 +1,53 @@ +/** + * check-exports-map.mjs — Script execution test (#104) + * + * Validates that the exports map checker script: + * 1. Executes without crashing (exits 0 or 1, not a runtime error) + * 2. Produces structured output on stdout or stderr + * + * This does NOT test that exports are complete — the script itself + * catches real gaps (e.g., platform, remote, roles, streams, upstream). + * Those missing exports are expected; they are tracked separately. + */ + +import { describe, it, expect } from 'vitest'; +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; + +const SCRIPT_PATH = resolve(process.cwd(), 'scripts', 'check-exports-map.mjs'); + +function runScript(): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((res) => { + execFile('node', [SCRIPT_PATH], { cwd: process.cwd() }, (error, stdout, stderr) => { + const code = error ? error.code ?? (error as NodeJS.ErrnoException & { status?: number }).status ?? 1 : 0; + res({ code: typeof code === 'number' ? code : 1, stdout, stderr }); + }); + }); +} + +describe('check-exports-map.mjs', () => { + it('executes without crashing (exits 0 or 1)', async () => { + const { code } = await runScript(); + // Exit 0 = all barrels mapped, exit 1 = some missing. + // Both are valid outcomes. A crash would be a non-0/1 code or thrown error. + expect([0, 1]).toContain(code); + }); + + it('produces output describing the check result', async () => { + const { stdout, stderr } = await runScript(); + const combined = stdout + stderr; + // The script always prints either "passed" or "FAILED" in its output + expect(combined).toMatch(/Exports map check (passed|FAILED)/); + }); + + it('reports MISSING entries with expected format when barrels are unmapped', async () => { + const { code, stderr } = await runScript(); + if (code === 1) { + // When the check fails, each missing barrel is reported with a MISSING: prefix + expect(stderr).toContain('MISSING:'); + // The error message should mention the skip label escape hatch + expect(stderr).toContain('skip-exports-check'); + } + // If code === 0, all barrels are mapped and there is nothing to assert here + }); +});