Skip to content
Open
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
75 changes: 75 additions & 0 deletions .github/scripts/check-release-governance.mjs
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Missing workflow file crashes script instead of failing gracefully

readWorkflow() (line 9-10) calls fs.readFileSync without a try/catch. In the loop at line 51-71, if any of ci.yml, codeql.yml, gitleaks.yml, dependency-review.yml, or workflow-lint.yml is absent on a branch (e.g. a long-lived branch predating one of these workflows, or a fork that removed one), the script throws an uncaught ENOENT with a stack trace rather than emitting a governance 'fail()' message and exitCode=1. The effect is still a CI failure, so this is cosmetic, but wrapping readWorkflow in a try/catch that calls fail(${filename} could not be read: ...) would produce a legible governance error consistent with the script's own messaging contract.

Category: Runtime correctness

Why this matters: A stack trace from a missing-file ENOENT is a confusing CI signal for a governance check; the script already has a fail() channel designed exactly for this. Low impact because all five files exist on main and this branch.

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');
}
1 change: 1 addition & 0 deletions .github/workflows/ci-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ name: CI
on:
pull_request:
branches: [main, 'reconcile/**']
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:

permissions:
Expand All @@ -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-<ref>`; 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' }}
Expand Down
5 changes: 2 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 6 additions & 12 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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<X.Y.Z>-rc.<N> + rc/<SHA> marker
# atomically, then publishes to npm with --tag rc and creates a GitHub
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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*.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions .github/workflows/workflow-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}": [
Expand Down
Loading