Repository navigation
feat(ci): CI hardening phase 2 — items 6-10 + security hardening #701
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| # | ||
| # 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 | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||
| # 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)); | ||||||||||||||||||||||||||||
|
Comment on lines
+48
to
+50
|
||||||||||||||||||||||||||||
| // Security: mask numeric values to prevent fingerprinting via CI logs | |
| core.setSecret(String(remaining)); | |
| core.setSecret(String(limit)); |
Copilot
AI
Mar 30, 2026
There was a problem hiding this comment.
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.
| 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.`); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||
| // Security: mask numeric values to prevent fingerprinting via CI logs | |
| core.setSecret(String(remaining)); | |
| core.setSecret(String(limit)); |
Copilot
AI
Mar 30, 2026
There was a problem hiding this comment.
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.
| 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.`); | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||
| 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-'); | |
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||||||||||||||||||||||||
| // 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
AI
Mar 30, 2026
There was a problem hiding this comment.
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.
| 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.`); |
There was a problem hiding this comment.
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.