diff --git a/.github/scripts/check-release-governance.mjs b/.github/scripts/check-release-governance.mjs new file mode 100644 index 0000000000..a351620b72 --- /dev/null +++ b/.github/scripts/check-release-governance.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const readWorkflow = (filename) => + parse(fs.readFileSync(path.join(repoRoot, '.github/workflows', filename), 'utf8')); +const workflow = readWorkflow('publish.yml'); + +const fail = (message) => { + process.stderr.write(`release governance check failed: ${message}\n`); + process.exitCode = 1; +}; + +const triggers = workflow.on; +if (!triggers || typeof triggers !== 'object') { + fail('publish.yml must declare structured triggers'); +} else { + const push = triggers.push; + if (!push || typeof push !== 'object') { + fail('publish.yml must retain stable tag publishing'); + } else { + if ('branches' in push || 'branches-ignore' in push) { + fail('publish.yml must not publish from branch pushes'); + } + const tags = Array.isArray(push.tags) ? push.tags : []; + if (!tags.includes('v*') || !tags.includes('!v*-rc.*')) { + fail('publish.yml must accept stable tags and reject self-produced RC tags'); + } + } + if (!('workflow_dispatch' in triggers)) { + fail('publish.yml must retain an explicit manual RC trigger'); + } + if ('pull_request' in triggers || 'pull_request_target' in triggers) { + fail('publish.yml must never publish from pull request events'); + } +} + +const publishJob = workflow.jobs?.publish; +const environment = + typeof publishJob?.environment === 'string' + ? publishJob.environment + : publishJob?.environment?.name; +if (environment !== 'internal-release') { + fail('the publish job must require the internal-release environment'); +} + +for (const filename of [ + 'ci.yml', + 'codeql.yml', + 'gitleaks.yml', + 'dependency-review.yml', + 'workflow-lint.yml', +]) { + const candidate = readWorkflow(filename); + const pullRequest = candidate.on?.pull_request; + if (!pullRequest || typeof pullRequest !== 'object') { + fail(`${filename} must run for pull requests`); + continue; + } + const branches = Array.isArray(pullRequest.branches) ? pullRequest.branches : []; + if (!branches.includes('main')) { + fail(`${filename} must run for main-targeted pull requests`); + } + if ('paths' in pullRequest || 'paths-ignore' in pullRequest) { + fail(`${filename} must not omit required checks through path filters`); + } +} + +if (!process.exitCode) { + process.stdout.write('release governance check passed\n'); +} diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 0106049ae3..069e44e82d 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -20,6 +20,7 @@ jobs: cache: npm cache-dependency-path: package-lock.json - run: npm ci + - run: npm run check:release-governance - run: npx prettier --check . lint: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36421d8890..ec32cc96db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,6 @@ name: CI on: pull_request: branches: [main, 'reconcile/**'] - paths-ignore: ['**.md', 'docs/**', 'LICENSE'] workflow_call: permissions: @@ -16,9 +15,9 @@ permissions: # prefix that could resolve to the caller's name would share a concurrency group # with the caller → deadlock. A literal prefix is immune. Direct `pull_request` # invocations use `CI-`; invocations from a reusable-workflow caller fall -# into a per-run-unique group that never serializes with the caller. `push` to -# main is handled by publish.yml (RC mode), which calls this workflow once -# before publishing. +# into a per-run-unique group that never serializes with the caller. A manual +# RC dispatch or stable tag in publish.yml calls this workflow once before the +# protected release job can begin. concurrency: group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 04d6d8dd26..4c7b7aa53d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,13 +3,12 @@ name: CodeQL # Static analysis (SAST) for TypeScript/JavaScript and Python sources. # Findings upload to the GitHub Security tab as SARIF. # -# Advisory only on first introduction — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md. -# Promote to a required check after baseline triage (operator decision). +# Required on every main-targeted pull request. Scheduled and main-push runs +# continue to catch findings that appear between pull requests. on: pull_request: branches: [main] - paths-ignore: ['**.md', 'docs/**', 'LICENSE'] push: branches: [main] schedule: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62679fe7c1..fb9d452883 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,7 +6,7 @@ name: Publish # double-publish race this unification closes. # # Two release modes, both routed through this file: -# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch. +# • Release candidate (rc) — triggered only by workflow_dispatch on `main`. # The RC path computes the next rc version, applies it in-CI, pushes a # detached release commit with v-rc. + rc/ marker # atomically, then publishes to npm with --tag rc and creates a GitHub @@ -28,11 +28,6 @@ name: Publish on: push: - branches: [main] - paths-ignore: - - '**.md' - - 'docs/**' - - 'LICENSE' tags: # Negative-globbed exclusion of RC tags this workflow itself produces # (see the SELF-TRIGGER INVARIANT in the header comment). @@ -65,9 +60,9 @@ on: # Workflow-level deny-all; each job declares the minimum it needs. permissions: {} -# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. The -# release-PR-skip in rc-guard is the load-bearing invariant that prevents -# an RC main-push and a stable tag-push colliding on the same release commit. +# Manual RC dispatches and stable tag pushes use distinct refs and may run in +# parallel. The release-PR skip remains a defense against a manually dispatched +# RC colliding with a stable tag on the same release commit. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -118,9 +113,6 @@ jobs: ;; push) case "${GH_REF}" in - refs/heads/main) - MODE="rc" - ;; refs/tags/v*) # The trigger filter already excluded v*-rc.* tags. Anything # reaching here is either a stable semver or a malformed v*. @@ -280,6 +272,8 @@ jobs: if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }} runs-on: ubuntu-latest timeout-minutes: 20 + environment: + name: internal-release permissions: # contents: write — RC path needs it for `git push --atomic` (v-tag + # marker). Stable path runs in the same job and inherits the grant; it diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 302ba9d597..2cec04abaf 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -6,14 +6,12 @@ name: Workflow Lint # - zizmor: security misconfigurations — unpinned actions, dangerous # `${{ }}` interpolation, missing per-job permissions, etc. # -# Scoped to PRs that touch .github/** only — keeps off the typical PR -# critical path. +# Runs on every main-targeted PR because both jobs are required merge gates. +# Path filtering would leave unrelated PRs waiting on checks that never start. on: pull_request: branches: [main] - paths: - - '.github/**' permissions: contents: read diff --git a/package-lock.json b/package-lock.json index 0969cc7a73..da02f5a889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "yaml": "2.8.3" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 8a732cb149..fbec2025bf 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "prepare": "husky", "format": "prettier --write .", "format:check": "prettier --check .", + "check:release-governance": "node .github/scripts/check-release-governance.mjs", "lint": "eslint .", "lint:fix": "eslint --fix .", "gitnexus:refresh": "gitnexus analyze --embeddings --skills", @@ -20,7 +21,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "yaml": "2.8.3" }, "lint-staged": { "*.{ts,tsx}": [