diff --git a/.github/workflow-templates/ci.properties.json b/.github/workflow-templates/ci.properties.json new file mode 100644 index 00000000..9c37f183 --- /dev/null +++ b/.github/workflow-templates/ci.properties.json @@ -0,0 +1,6 @@ +{ + "name": "CI Pipeline", + "description": "Runs lint, typecheck, tests, and build on every push and pull request. Replace the pnpm setup block with your package manager.", + "iconName": "example-icon", + "categories": ["Continuous integration", "JavaScript", "TypeScript"] +} diff --git a/.github/workflow-templates/ci.yml b/.github/workflow-templates/ci.yml new file mode 100644 index 00000000..751cbbe0 --- /dev/null +++ b/.github/workflow-templates/ci.yml @@ -0,0 +1,77 @@ +--- +name: CI + +# yamllint disable-line rule:truthy +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality-gate: + name: Quality Gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # --- Replace with your package manager setup --- + # - name: Setup pnpm + # uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + # - name: Setup Node.js + # uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + # with: + # node-version: '22' + # cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: quality-gate + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # --- Replace with your package manager setup --- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test + + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [quality-gate, unit-tests] + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # --- Replace with your package manager setup --- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build diff --git a/plans/115-gitleaks-license-pre-existing-2026-08-11.md b/plans/115-gitleaks-license-pre-existing-2026-08-11.md new file mode 100644 index 00000000..03df54b3 --- /dev/null +++ b/plans/115-gitleaks-license-pre-existing-2026-08-11.md @@ -0,0 +1,37 @@ +# Plan 115 — Gitleaks License Failure (Pre-existing Infrastructure Issue) + +Date: 2026-08-11 + +## Issue + +`Security Scan` workflow's **Secret Detection with GitLeaks** step fails on every PR: + +``` +🛑 missing gitleaks license. Go grab one at gitleaks.io and store it as a +GitHub Secret named GITLEAKS_LICENSE. +``` + +Root cause chain (from CI logs, run 31520667156): + +1. `gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e` (# v3.0.0) is pinned in `.github/workflows/security-scan.yml` (line 143). +2. The action tries to look up the repo owner (`Get user [d-oit]`) to validate license-free usage. +3. That API call fails with `self-signed certificate` — the runner cannot reach `api.github.com`. +4. gitleaks then **enforces** license validation → no `GITLEAKS_LICENSE` secret is configured → hard failure. + +## Verification + +- `security-scan.yml` on `main` is identical to the PR branch (not introduced by any PR). +- `gh secret list` shows **no** `GITLEAKS_LICENSE` secret → pre-existing. +- This check is **not** in the required status checks (only `Codacy Static Code Analysis` is required per branch rules). + +## Options + +| Option | Effort | Notes | +|--------|--------|-------| +| Configure `GITLEAKS_LICENSE` repo secret | Low (needs gitleaks.io account + license) | Cleanest fix; unblocks the step | +| Pin gitleaks-action to v2.x | Low | v2 does not require a license; loses v3 features | +| Add `continue-on-error: true` to the gitleaks step | Trivial | TruffleHog fallback already exists at line 149-155; keeps scan non-blocking | + +## Recommendation + +Option 1 (configure the secret) is the proper fix. As an interim, Option 3 (non-blocking with the existing TruffleHog fallback) prevents the red check without losing secret scanning coverage. Requires maintainer decision — no autonomous change made here. diff --git a/src/lib/__tests__/workflows.test.ts b/src/lib/__tests__/workflows.test.ts index 71a26deb..0317d3eb 100644 --- a/src/lib/__tests__/workflows.test.ts +++ b/src/lib/__tests__/workflows.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll } from 'vitest' -import { readFileSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import { join } from 'path' import { parse } from 'yaml' @@ -15,6 +15,14 @@ const loadWorkflow = (fileName: string): Workflow => { return parse(workflowContent) } +/** + * Assert that a workflow declares exactly the expected top-level permissions. + * Shared by all workflow suites to avoid duplicated permission assertions. + */ +const expectWorkflowPermissions = (workflow: Workflow, expected: object): void => { + expect(workflow.permissions).toEqual(expected) +} + describe('GitHub Actions Workflows', () => { describe('Dependabot Auto-Merge Workflow', () => { let workflow: Workflow @@ -33,7 +41,7 @@ describe('GitHub Actions Workflows', () => { }) it('should have proper permissions', () => { - expect(workflow.permissions).toEqual({ contents: 'read' }) + expectWorkflowPermissions(workflow, { contents: 'read' }) }) it('should have auto-merge job with label requirement', () => { @@ -96,7 +104,7 @@ describe('GitHub Actions Workflows', () => { }) it('should have proper permissions', () => { - expect(workflow.permissions).toEqual({ contents: 'read' }) + expectWorkflowPermissions(workflow, { contents: 'read' }) }) it('should have required jobs', () => { @@ -151,7 +159,7 @@ describe('GitHub Actions Workflows', () => { }) it('should have proper permissions', () => { - expect(workflow.permissions).toEqual({ + expectWorkflowPermissions(workflow, { contents: 'read', 'security-events': 'write' }) @@ -170,4 +178,138 @@ describe('GitHub Actions Workflows', () => { expect(trivy.name).toBe('Trivy Filesystem Security Scan') }) }) + + describe('Cleanup Workflow', () => { + let workflow: Workflow + + beforeAll(() => { + workflow = loadWorkflow('cleanup.yml') + }) + + it('should be valid YAML', () => { + expect(workflow).toBeDefined() + expect(workflow.name).toBe('Automated Cleanup') + }) + + it('should run on schedule and be manually dispatchable', () => { + expect(workflow.on).toHaveProperty('schedule') + expect(workflow.on).toHaveProperty('workflow_dispatch') + }) + + it('should have proper permissions', () => { + expectWorkflowPermissions(workflow, { + contents: 'read', + 'pull-requests': 'write' + }) + }) + + it('should have a detect-unused job with timeout', () => { + const job = workflow.jobs['detect-unused'] + expect(job).toBeDefined() + expect(job['timeout-minutes']).toBe(15) + }) + + it('should have all jobs with explicit timeouts', () => { + const jobs = workflow.jobs + for (const [name, job] of Object.entries(jobs)) { + expect(job['timeout-minutes'], `job ${name}`).toBeDefined() + } + }) + }) + + describe('Stale Issues Workflow', () => { + let workflow: Workflow + + beforeAll(() => { + workflow = loadWorkflow('stale.yml') + }) + + it('should be valid YAML', () => { + expect(workflow).toBeDefined() + expect(workflow.name).toBe('Stale Issues and PRs') + }) + + it('should run on a daily schedule', () => { + expect(workflow.on).toHaveProperty('schedule') + expect(workflow.on.schedule[0].cron).toBe('0 0 * * *') + }) + + it('should have proper permissions', () => { + expectWorkflowPermissions(workflow, { + contents: 'write', + issues: 'write', + 'pull-requests': 'write' + }) + }) + + it('should use actions/stale with a timeout', () => { + const job = workflow.jobs.stale + expect(job).toBeDefined() + expect(job['timeout-minutes']).toBe(15) + const step = job.steps[0] + expect(step.uses).toContain('actions/stale') + }) + }) + + describe('Labeler Workflow', () => { + let workflow: Workflow + + beforeAll(() => { + workflow = loadWorkflow('labeler.yml') + }) + + it('should be valid YAML', () => { + expect(workflow).toBeDefined() + expect(workflow.name).toBe('Pull Request Labeler') + }) + + it('should trigger on pull_request_target events', () => { + expect(workflow.on).toHaveProperty('pull_request_target') + const types = workflow.on.pull_request_target.types + expect(types).toContain('opened') + expect(types).toContain('synchronize') + }) + + it('should have proper permissions', () => { + expectWorkflowPermissions(workflow, { + contents: 'read', + 'pull-requests': 'write' + }) + }) + + it('should use actions/labeler', () => { + const job = workflow.jobs.labeler + expect(job).toBeDefined() + const labelerStep = job.steps.find((step: { uses?: string }) => + step.uses?.includes('actions/labeler') + ) + expect(labelerStep).toBeDefined() + }) + }) + + describe('Workflow Template', () => { + it('should have a valid CI template with matching properties file', () => { + const templatePath = join( + process.cwd(), + '.github/workflow-templates/ci.yml' + ) + expect(existsSync(templatePath)).toBe(true) + const template = parse(readFileSync(templatePath, 'utf-8')) + expect(template).toBeDefined() + expect(template.name).toBe('CI') + expect(template.jobs).toHaveProperty('quality-gate') + expect(template.jobs).toHaveProperty('unit-tests') + expect(template.jobs).toHaveProperty('build') + + const propertiesPath = join( + process.cwd(), + '.github/workflow-templates/ci.properties.json' + ) + const properties = JSON.parse( + readFileSync(propertiesPath, 'utf-8') + ) as { name: string; description: string } + expect(properties.name).toBe('CI Pipeline') + expect(properties.description.length).toBeGreaterThan(0) + }) + }) }) \ No newline at end of file