From bf295adcd7d5b52b37919a6fb09fc08e4ea9b20c Mon Sep 17 00:00:00 2001 From: "Dina Berry (She/her)" Date: Tue, 31 Mar 2026 16:11:48 -0700 Subject: [PATCH] feat(ci): add concurrency controls to 5 workflows (Phase 3 item A1) Add concurrency blocks with cancel-in-progress to squad-ci, squad-heartbeat, squad-triage, squad-label-enforce, and squad-issue-assign workflows. Scope: .github/workflows/ only (squad repo CI). Template workflows for customer repos are a separate product concern. Test: 20 assertions covering all 5 workflows. Refs: diberry/squad#122 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-ci.yml | 5 ++ .github/workflows/squad-heartbeat.yml | 5 ++ .github/workflows/squad-issue-assign.yml | 5 ++ .github/workflows/squad-label-enforce.yml | 5 ++ .github/workflows/squad-triage.yml | 5 ++ test/ci-concurrency.test.cjs | 105 ++++++++++++++++++++++ 6 files changed, 130 insertions(+) create mode 100644 test/ci-concurrency.test.cjs diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index 773754e39..85819ab24 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -10,6 +10,11 @@ on: permissions: contents: read +# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # ── Path filter for conditional job execution ────────────────────────── # Detects which areas of the repo changed so downstream jobs can skip diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index b5c642a92..0e3826b0c 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -30,6 +30,11 @@ permissions: contents: read pull-requests: read +# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }} + cancel-in-progress: true + jobs: heartbeat: runs-on: ubuntu-latest diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 3badc21a3..ff3f309be 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -8,6 +8,11 @@ permissions: issues: write contents: read +# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }} + cancel-in-progress: true + jobs: assign-work: # Only trigger on squad:{member} labels (not the base "squad" label) diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml index 633d220df..d1bb96788 100644 --- a/.github/workflows/squad-label-enforce.yml +++ b/.github/workflows/squad-label-enforce.yml @@ -8,6 +8,11 @@ permissions: issues: write contents: read +# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }} + cancel-in-progress: true + jobs: enforce: runs-on: ubuntu-latest diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index 9dd890b6c..613d3da02 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -8,6 +8,11 @@ permissions: issues: write contents: read +# CI Hardening Phase 3 item A1: Prevent parallel runs from competing for resources +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number || github.ref }} + cancel-in-progress: true + jobs: triage: if: github.event.label.name == 'squad' diff --git a/test/ci-concurrency.test.cjs b/test/ci-concurrency.test.cjs new file mode 100644 index 000000000..e08ba8378 --- /dev/null +++ b/test/ci-concurrency.test.cjs @@ -0,0 +1,105 @@ +/** + * TDD test for CI Hardening Phase 3 item A1: + * Concurrency controls on all event-driven workflows. + * + * Validates that squad-ci, squad-heartbeat, squad-triage, + * squad-label-enforce, and squad-issue-assign workflows all have + * concurrency settings to prevent resource waste and race conditions. + * + * Scope: .github/workflows/ only (squad repo CI). + * Template workflows for customer repos are a separate product concern. + * + * Refs: diberry/squad#122 (Phase 3 item A1) + */ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows'); + +// Workflows that must have concurrency controls (per issue #122 item A1) +const CONCURRENCY_REQUIRED_WORKFLOWS = [ + 'squad-ci.yml', + 'squad-heartbeat.yml', + 'squad-triage.yml', + 'squad-label-enforce.yml', + 'squad-issue-assign.yml', +]; + +// Issue-triggered workflows must use github.event.issue.number for unique concurrency groups +const ISSUE_TRIGGERED_WORKFLOWS = [ + 'squad-heartbeat.yml', + 'squad-triage.yml', + 'squad-label-enforce.yml', + 'squad-issue-assign.yml', +]; + +// PR-triggered workflows use github.ref (unique per PR) +const PR_TRIGGERED_WORKFLOWS = [ + 'squad-ci.yml', +]; + +function readWorkflow(filename) { + const filePath = path.join(WORKFLOWS_DIR, filename); + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath, 'utf8'); +} + +describe('CI Hardening A1: Concurrency controls on workflows', () => { + for (const workflow of CONCURRENCY_REQUIRED_WORKFLOWS) { + it(`${workflow} has a concurrency block`, () => { + const content = readWorkflow(workflow); + if (!content) return; + assert.ok( + /^concurrency:/m.test(content), + `${workflow} must have a top-level concurrency: block` + ); + }); + + it(`${workflow} uses workflow+ref concurrency group`, () => { + const content = readWorkflow(workflow); + if (!content) return; + assert.ok( + content.includes("github.workflow") && (content.includes("github.event.issue.number") || content.includes("github.ref")), + `${workflow} concurrency group must use github.workflow and a unique identifier (issue number or ref)` + ); + }); + + it(`${workflow} has cancel-in-progress: true`, () => { + const content = readWorkflow(workflow); + if (!content) return; + assert.ok( + /cancel-in-progress:\s*true/m.test(content), + `${workflow} must have cancel-in-progress: true` + ); + }); + } + + describe('Semantic: issue-triggered workflows use issue-specific concurrency', () => { + for (const workflow of ISSUE_TRIGGERED_WORKFLOWS) { + it(`${workflow} uses github.event.issue.number`, () => { + const content = readWorkflow(workflow); + if (!content) return; + assert.ok( + content.includes("github.event.issue.number"), + `${workflow} must use github.event.issue.number for issue-specific concurrency` + ); + }); + } + }); + + describe('Semantic: PR-triggered workflows use ref-based concurrency', () => { + for (const workflow of PR_TRIGGERED_WORKFLOWS) { + it(`${workflow} does not use github.event.issue.number`, () => { + const content = readWorkflow(workflow); + if (!content) return; + assert.ok( + !content.includes("github.event.issue.number"), + `${workflow} must NOT use github.event.issue.number (PR refs are already unique)` + ); + }); + } + }); +});