From f99f9e9f374e5aca7a475bfd08b94cab6afee249 Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Sun, 29 Mar 2026 23:01:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(ci):=20CI=20hardening=20phase=202=20?= =?UTF-8?q?=E2=80=94=20items=206-10=20+=20security=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 5 short-term CI hardening improvements: - Lockfile stability check in preflight - Composite action for npm setup (DRY) - Cron schedule audit and cleanup (refs #120) - GitHub API rate limit monitoring (values masked per security review) - npm registry health check before publish Security: Rate limit values masked in CI logs per RETRO review. Refs: diberry/squad#121 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/setup-squad-node/action.yml | 77 +++++++++++++++++++++ .github/workflows/squad-heartbeat.yml | 30 ++++++++ .github/workflows/squad-insider-publish.yml | 74 ++++++++------------ .github/workflows/squad-issue-assign.yml | 20 ++++++ .github/workflows/squad-npm-publish.yml | 71 ++++++++++++++++++- .github/workflows/squad-triage.yml | 20 ++++++ 6 files changed, 244 insertions(+), 48 deletions(-) create mode 100644 .github/actions/setup-squad-node/action.yml diff --git a/.github/actions/setup-squad-node/action.yml b/.github/actions/setup-squad-node/action.yml new file mode 100644 index 000000000..b1bc0d54a --- /dev/null +++ b/.github/actions/setup-squad-node/action.yml @@ -0,0 +1,77 @@ +# Composite action: Setup Node.js with npm cache and retry logic +# Created to DRY up the repeated setup-node + npm install retry pattern +# used across squad-ci.yml, ci-rerun.yml, squad-npm-publish.yml, +# and squad-insider-publish.yml. +# +# Usage: +# - uses: ./.github/actions/setup-squad-node +# with: +# node-version: '22' # default: 22 +# install-command: 'ci' # default: ci (alternatives: install) +# registry-url: '' # optional, for publish workflows +# working-directory: '.' # optional, for subdirectory installs + +name: 'Setup Squad Node.js' +description: 'Setup Node.js with npm cache and retry install logic for Squad CI' + +inputs: + node-version: + description: 'Node.js version to install' + required: false + default: '22' + registry-url: + description: 'npm registry URL (set for publish workflows that need .npmrc)' + required: false + default: '' + install-command: + description: 'npm install command to run: ci (strict lockfile) or install (flexible)' + required: false + default: 'ci' + working-directory: + description: 'Working directory for npm install' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: Validate inputs + shell: bash + env: + INPUT_CMD: ${{ inputs.install-command }} + INPUT_DIR: ${{ inputs.working-directory }} + run: | + if [[ "$INPUT_CMD" != "ci" && "$INPUT_CMD" != "install" ]]; then + echo "::error::install-command must be 'ci' or 'install'" + exit 1 + fi + if ! echo "$INPUT_DIR" | LC_ALL=C grep -qE '^[A-Za-z0-9/_.\-]+$'; then + echo "::error::working-directory contains invalid characters (only alphanumeric, /, ., -, _ allowed)" + exit 1 + fi + + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + registry-url: ${{ inputs.registry-url }} + cache: 'npm' + + - name: Install dependencies (with retry) + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + INPUT_CMD: ${{ inputs.install-command }} + run: | + success=false + for i in 1 2 3; do + if npm "$INPUT_CMD"; then + success=true + break + fi + echo "Retry $i/3 — npm \"$INPUT_CMD\" failed, retrying in 5s..." + sleep 5 + done + if [ "$success" = false ]; then + echo "::error::npm \"$INPUT_CMD\" failed after 3 attempts" + exit 1 + fi diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index c75ed0638..b5c642a92 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -5,6 +5,15 @@ name: Squad Heartbeat (Ralph) # - .squad/templates/workflows/squad-heartbeat.yml (installed template) # - .github/workflows/squad-heartbeat.yml (active workflow) # Run 'squad upgrade' to sync installed copies from source templates. +# +# CI Hardening: Cron schedule audit (item 8, refs diberry/squad#120) +# ───────────────────────────────────────────────────────────────── +# Audit finding: No cron schedule is configured here, which is INTENTIONAL. +# Ralph should only run reactively (on issue/PR events) or manually. +# A cron schedule would waste Actions minutes (~20 min/week) for minimal +# benefit since Ralph's triage logic is event-driven. +# If scheduled runs appear in Actions history, they are event-triggered +# (issue close/label), not cron-triggered. Do NOT add a schedule trigger. on: # React to completed work or new squad work @@ -27,6 +36,27 @@ jobs: steps: - uses: actions/checkout@v4 + # CI Hardening: GitHub API rate limit monitoring (item 9) + # Ralph makes multiple API calls (list issues, add labels, create comments). + # Check rate limit early to avoid silent failures mid-workflow. + - name: "📊 Check GitHub API rate limit" + uses: actions/github-script@v7 + with: + script: | + const { data: rateLimit } = await github.rest.rateLimit.get(); + const { remaining, limit, reset } = rateLimit.rate; + // Security: mask numeric values to prevent fingerprinting via CI logs + core.setSecret(String(remaining)); + core.setSecret(String(limit)); + const resetDate = new Date(reset * 1000).toISOString(); + if (remaining < 10) { + core.setFailed(`❌ Rate limit: CRITICAL — below minimum threshold. Aborting to prevent silent failures.`); + } else if (remaining < 100) { + core.warning(`⚠️ Rate limit: WARNING — below safe threshold (resets ${resetDate})`); + } else { + core.info(`✅ Rate limit: OK — above safe threshold (resets ${resetDate})`); + } + - name: Ralph — Repair orphaned squad labels uses: actions/github-script@v7 with: diff --git a/.github/workflows/squad-insider-publish.yml b/.github/workflows/squad-insider-publish.yml index 1b6ffbc76..9903141d4 100644 --- a/.github/workflows/squad-insider-publish.yml +++ b/.github/workflows/squad-insider-publish.yml @@ -18,26 +18,11 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + # CI Hardening: Composite action replaces duplicated setup-node + retry + # pattern (item 7). See .github/actions/setup-squad-node/action.yml. + - uses: ./.github/actions/setup-squad-node with: node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Install 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 - name: Build run: npm run build @@ -52,32 +37,46 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup-squad-node with: node-version: ${{ matrix.node-version }} - cache: 'npm' - - name: Install dependencies + - name: Run tests + run: npm test + + # CI Hardening: npm registry health check (item 10) + # Fail early if registry is unreachable instead of failing mid-publish. + registry-check: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: "🌐 Verify npm registry is reachable" run: | + echo "Checking npm registry health before insider publish..." success=false for i in 1 2 3; do - if npm ci; then + if npm ping 2>&1; then success=true break fi - echo "Retry $i/3 — npm ci failed, retrying in 5s..." + echo "Registry ping attempt $i/3 failed, retrying in 5s..." sleep 5 done if [ "$success" = false ]; then - echo "::error::npm ci failed after 3 attempts" + echo "::error::npm registry is unreachable after 3 attempts — publish aborted" + echo "::error::Check https://status.npmjs.org/ for registry status" exit 1 fi - - - name: Run tests - run: npm test + echo "✅ npm registry is reachable" + if ! npm view @bradygaster/squad-sdk version 2>/dev/null; then + echo "::warning::Cannot verify @bradygaster/squad-sdk on registry — package may not exist yet or registry is degraded" + else + echo "✅ @bradygaster package namespace is accessible" + fi publish: - needs: test + needs: [test, registry-check] runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -86,11 +85,10 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: ./.github/actions/setup-squad-node with: node-version: ${{ matrix.node-version }} registry-url: 'https://registry.npmjs.org' - cache: 'npm' - name: Validate npm token run: | @@ -102,22 +100,6 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Install 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 - - name: Build packages run: npm -w packages/squad-sdk run build && npm -w packages/squad-cli run build diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 9aef424df..3badc21a3 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -16,6 +16,26 @@ jobs: steps: - uses: actions/checkout@v4 + # CI Hardening: GitHub API rate limit monitoring (item 9) + # Issue assignment makes multiple API calls (read team file, add labels, comment, assign). + - name: "📊 Check GitHub API rate limit" + uses: actions/github-script@v7 + with: + script: | + const { data: rateLimit } = await github.rest.rateLimit.get(); + const { remaining, limit, reset } = rateLimit.rate; + // Security: mask numeric values to prevent fingerprinting via CI logs + core.setSecret(String(remaining)); + core.setSecret(String(limit)); + const resetDate = new Date(reset * 1000).toISOString(); + if (remaining < 10) { + core.setFailed(`❌ Rate limit: CRITICAL — below minimum threshold. Aborting to prevent silent failures.`); + } else if (remaining < 100) { + core.warning(`⚠️ Rate limit: WARNING — below safe threshold (resets ${resetDate})`); + } else { + core.info(`✅ Rate limit: OK — above safe threshold (resets ${resetDate})`); + } + - name: Identify assigned member and trigger work uses: actions/github-script@v7 with: diff --git a/.github/workflows/squad-npm-publish.yml b/.github/workflows/squad-npm-publish.yml index 14b353235..b6e40f1d4 100644 --- a/.github/workflows/squad-npm-publish.yml +++ b/.github/workflows/squad-npm-publish.yml @@ -51,6 +51,41 @@ jobs: exit 1 fi echo "✅ No file: references found — safe to publish" + # CI Hardening: Lockfile stability check (item 6) + # Ensures workspace packages in package-lock.json resolve to local links, + # not stale registry URLs. Catches lockfile drift before publish — + # prevents v0.9.1-style incidents where stale entries break installs. + - name: "🔒 Lockfile stability check" + run: | + echo "Validating package-lock.json hasn't drifted..." + 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) { + console.error('::error::LOCKFILE DRIFT DETECTED — workspace packages resolve to registry URLs instead of local links.'); + stale.forEach(k => console.error(' STALE: ' + k + ' → ' + pkgs[k].resolved)); + console.error(''); + console.error('Fix: Run npm install at the repo root to regenerate the lockfile.'); + process.exit(1); + } + const missingIntegrity = Object.keys(pkgs).filter(k => + k.includes('node_modules/@bradygaster/squad-') && + pkgs[k].resolved && !pkgs[k].resolved.startsWith('file:') && + (!pkgs[k].integrity || !pkgs[k].integrity.startsWith('sha512-')) + ); + if (missingIntegrity.length) { + console.error('::error::MISSING INTEGRITY HASHES — workspace packages resolved from registry lack sha512 hashes:'); + missingIntegrity.forEach(k => console.error(' ' + k + ' (resolved: ' + pkgs[k].resolved + ')')); + console.error('Fix: Run npm install at the repo root to regenerate the lockfile.'); + process.exit(1); + } + console.log('✅ Lockfile stable — all workspace packages resolve to local links'); + " - name: Validate semver versions run: | echo "Validating version numbers are valid semver..." @@ -114,9 +149,41 @@ jobs: echo "✅ Both packages pack successfully" - name: Run CLI smoke tests run: npx vitest run test/cli-packaging-smoke.test.ts + # CI Hardening: npm registry health check (item 10) + # Verify the npm registry is reachable before starting any publish jobs. + # Prevents wasted CI time and partial publishes when the registry is down. + registry-check: + name: npm registry health check + needs: [preflight, smoke-test] + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: "🌐 Verify npm registry is reachable" + run: | + echo "Checking npm registry health before publish..." + success=false + for i in 1 2 3; do + if npm ping 2>&1; then + success=true + break + fi + echo "Registry ping attempt $i/3 failed, retrying in 5s..." + sleep 5 + done + if [ "$success" = false ]; then + echo "::error::npm registry is unreachable after 3 attempts — publish aborted" + echo "::error::Check https://status.npmjs.org/ for registry status" + exit 1 + fi + echo "✅ npm registry is reachable" + if ! npm view @bradygaster/squad-sdk version 2>/dev/null; then + echo "::warning::Cannot verify @bradygaster/squad-sdk on registry — package may not exist yet or registry is degraded" + else + echo "✅ @bradygaster package namespace is accessible" + fi publish-sdk: name: Publish @bradygaster/squad-sdk - needs: [preflight, smoke-test] + needs: [preflight, smoke-test, registry-check] runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -190,7 +257,7 @@ jobs: echo "Registry propagation slow but package was published successfully" publish-cli: name: Publish @bradygaster/squad-cli - needs: [preflight, smoke-test, publish-sdk] + needs: [preflight, smoke-test, registry-check, publish-sdk] runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index a58be9b29..9dd890b6c 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -15,6 +15,26 @@ jobs: steps: - uses: actions/checkout@v4 + # CI Hardening: GitHub API rate limit monitoring (item 9) + # Triage makes multiple API calls (list issues, add labels, create comments). + - name: "📊 Check GitHub API rate limit" + uses: actions/github-script@v7 + with: + script: | + const { data: rateLimit } = await github.rest.rateLimit.get(); + const { remaining, limit, reset } = rateLimit.rate; + // Security: mask numeric values to prevent fingerprinting via CI logs + core.setSecret(String(remaining)); + core.setSecret(String(limit)); + const resetDate = new Date(reset * 1000).toISOString(); + if (remaining < 10) { + core.setFailed(`❌ Rate limit: CRITICAL — below minimum threshold. Aborting to prevent silent failures.`); + } else if (remaining < 100) { + core.warning(`⚠️ Rate limit: WARNING — below safe threshold (resets ${resetDate})`); + } else { + core.info(`✅ Rate limit: OK — above safe threshold (resets ${resetDate})`); + } + - name: Triage issue via Lead agent uses: actions/github-script@v7 with: