Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/actions/setup-squad-node/action.yml
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +3 to +4

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment says this composite action is already used across several workflows (squad-ci.yml, ci-rerun.yml, squad-npm-publish.yml), but currently it’s only referenced from squad-insider-publish. Either update those workflows to use this action or adjust the comment to reflect current usage so it doesn’t mislead maintainers.

Suggested change
# used across squad-ci.yml, ci-rerun.yml, squad-npm-publish.yml,
# and squad-insider-publish.yml.
# currently used by squad-insider-publish.yml and intended for reuse across
# other Squad workflows (e.g., squad-ci.yml, ci-rerun.yml, squad-npm-publish.yml).

Copilot uses AI. Check for mistakes.
#
# 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
30 changes: 30 additions & 0 deletions .github/workflows/squad-heartbeat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +9 to +13

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow header indicates it’s maintained in multiple mirrored template locations, but only the active workflow was updated here. To prevent squad upgrade/template sync from overwriting these changes (and to ship the hardening to templates), apply the same edits to the canonical template under .squad-templates/workflows/squad-heartbeat.yml and re-run the template sync script so mirrors stay byte-identical.

Copilot uses AI. Check for mistakes.
# 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
Expand All @@ -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));
Comment on lines +48 to +50

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling core.setSecret() on low-entropy values like remaining/limit. Masking short/common strings can unintentionally redact large parts of workflow logs. Since the script doesn’t print these numbers, it’s safer to remove the setSecret calls and keep logs state-based.

Suggested change
// Security: mask numeric values to prevent fingerprinting via CI logs
core.setSecret(String(remaining));
core.setSecret(String(limit));

Copilot uses AI. Check for mistakes.
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})`);
Comment on lines +51 to +57

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate-limit check logs the exact reset timestamp (resetDate) in warning/info messages. If you want to avoid leaking derived API values in public logs, consider removing the reset time (or coarsening it) and keep messages strictly state-based.

Suggested change
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})`);
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; some GitHub API operations may be delayed until reset.`);
} else {
core.info(`✅ Rate limit: OK — above safe threshold for this run.`);

Copilot uses AI. Check for mistakes.
}

- name: Ralph — Repair orphaned squad labels
uses: actions/github-script@v7
with:
Expand Down
74 changes: 28 additions & 46 deletions .github/workflows/squad-insider-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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: |
Expand All @@ -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

Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/squad-issue-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment on lines +27 to +29

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling core.setSecret() on low-entropy values like remaining/limit. Masking short/common strings can unintentionally redact large portions of the workflow logs (e.g., 0, 10, 100). Since these values aren’t being printed anyway, it’s safer to remove the setSecret calls and keep logs state-based.

Suggested change
// Security: mask numeric values to prevent fingerprinting via CI logs
core.setSecret(String(remaining));
core.setSecret(String(limit));

Copilot uses AI. Check for mistakes.
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})`);
}

Comment on lines +30 to +38

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate-limit check logs the exact reset timestamp (resetDate) in warning/info messages. If the goal is “state-only” logging to reduce fingerprinting in public workflow logs, consider removing the reset time (or coarsening it) and avoid emitting derived values from the API response.

Suggested change
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})`);
}
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; GitHub API rate limit may be hit soon.`);
} else {
core.info(`✅ Rate limit: OK — above safe threshold; proceeding with workflow.`);
}

Copilot uses AI. Check for mistakes.
- name: Identify assigned member and trigger work
uses: actions/github-script@v7
with:
Expand Down
71 changes: 69 additions & 2 deletions .github/workflows/squad-npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-'))
);
Comment on lines +76 to +80

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The missingIntegrity check will currently fail on a normal npm workspace lockfile: workspace packages typically appear as "link": true with resolved: "packages/..." (no file: prefix) and no integrity field (see current package-lock entries for node_modules/@bradygaster/squad-*). Because the predicate only exempts file: resolutions, it will incorrectly treat these local links as “registry resolved” and abort preflight. Update the check to treat link: true and/or non-URL relative resolved paths as local (and only enforce sha512- integrity for actual registry URLs).

Suggested change
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-'))
);
const missingIntegrity = Object.keys(pkgs).filter(k => {
const pkg = pkgs[k] || {};
const resolved = pkg.resolved;
const integrity = pkg.integrity || '';
// Only enforce integrity for actual registry URLs (http/https), not local links.
const isRegistryUrl = typeof resolved === 'string' && (/^https?:\/\//).test(resolved);
const isWorkspaceLink = pkg.link === true || (resolved && !isRegistryUrl && !resolved.startsWith('file:'));
if (!k.includes('node_modules/@bradygaster/squad-')) return false;
if (!resolved || isWorkspaceLink || !isRegistryUrl) return false;
return !integrity.startsWith('sha512-');
});

Copilot uses AI. Check for mistakes.
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..."
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/squad-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment on lines +26 to +28

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling core.setSecret() on low-entropy values like remaining/limit. Masking short/common strings can unintentionally redact large portions of the workflow logs (e.g., if the value is 0, 10, 100, etc.). Since these values aren’t being printed anyway, it’s safer to drop the setSecret calls and rely on not logging the numbers.

Suggested change
// Security: mask numeric values to prevent fingerprinting via CI logs
core.setSecret(String(remaining));
core.setSecret(String(limit));
// Security: avoid logging raw numeric rate limit values to prevent fingerprinting via CI logs

Copilot uses AI. Check for mistakes.
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})`);
Comment on lines +29 to +35

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate-limit check logs the exact reset timestamp (resetDate) in warning/info messages. If the goal is “state-only” logging to reduce fingerprinting in public workflow logs, consider removing the reset time (or coarsening it, e.g., only date/hour) and avoid emitting derived values from the API response.

Suggested change
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})`);
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 before reset window.`);
} else {
core.info(`✅ Rate limit: OK — above safe threshold before reset window.`);

Copilot uses AI. Check for mistakes.
}

- name: Triage issue via Lead agent
uses: actions/github-script@v7
with:
Expand Down
Loading