From 332f944f0bc184b6121a90f2b08c8ba8bb94c740 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 17 Oct 2026 20:31:17 -0500 Subject: [PATCH 1/2] fix(setup): run Charter through the package manager in the generated workflow (#380) The charter-governance.yml template ran a bare `npx charter`, which fetches the unrelated `charter` registry package when @stackbilt/cli is not installed. Steps now run `pnpm exec charter` (pnpm) or `npm exec --no --package=@stackbilt/cli -- charter` (npm). Neither fetches anything, and the npm form keeps `charter validate --ci` in the step so doctor's gate-enforcement check still recognizes it. The template has no Yarn form, so none is added. The new templates are the current safe KNOWN_WORKFLOWS entries. The 003ec8d templates move to the history fixture, still safe (same permissions and triggers) with a refreshChanges that says what changes; the guard tests now check that a safe historical template differs from the current one only in how Charter is run. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/ci-integration.md | 4 +- .../fixtures/charter-governance-history.json | 12 +++++ packages/cli/src/__tests__/setup.test.ts | 33 ++++++++++-- .../src/__tests__/workflow-mcp-backup.test.ts | 52 ++++++++++++++++--- packages/cli/src/commands/github-workflow.ts | 24 ++++++--- packages/cli/src/commands/setup.ts | 25 ++++++--- 7 files changed, 127 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f24b76..376b3f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Fixed +- **The GitHub workflow that `charter setup --ci github` and `charter bootstrap --ci github` generate no longer runs a bare `npx charter`** (`#380`). When `@stackbilt/cli` was not installed, `npx charter` fetched the unrelated `charter` registry package (no executable, so the step failed with `could not determine executable to run`). Each step now runs the CLI the install step put in `node_modules`: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither form fetches anything; without a local `@stackbilt/cli` the step fails. `setup` and `bootstrap` add it to `devDependencies`. The npm form still contains `charter validate --ci`, so doctor's gate-enforcement check recognizes it. The template has no Yarn form (it supports npm and pnpm only), so a Yarn repo gets the npm form. The previous template (003ec8d, with `npx charter`) has the same permissions and triggers, so `setup`/`bootstrap` refresh it without a warning, as before. Its `KNOWN_WORKFLOWS` entry stays `safe` and records what the refresh changes. - **`charter adf populate`, and so every `charter bootstrap`, re-added stack constraints to a `CONSTRAINTS` section you had edited** (`#371`; 2.1.0 listed this as still open under `#354`). Deleting a bullet from `core.adf` and re-running bootstrap brought a stack rule back (for example the Cloudflare `No Node.js-specific APIs in Worker handlers` rule), because populate appended any detected stack constraint it did not find. Populate now remembers what it has added or offered in a new file, `.ai/.populate.json` (`{ "version": 1, "constraints": { "core.adf": { "added": [...], "offered": [...] } } }`, sorted, written only when it changes). Commit it with the rest of `.ai/`; bootstrap lists it under what it created, and its `git add .ai` next step already includes it. The rules: - A constraint recorded as added or offered is never added again, whether it is still in the section or you deleted it. Deleting the one rule populate added to a plain Worker repo now sticks, and a deleted `CONSTRAINTS` section is not recreated once the file has a record. - New constraints go only into a section that is untouched: the template's bullets, in order, followed by nothing but bullets populate recorded as added. A missing section is created only while there is no record. It goes right after whichever of `GUIDE` and `CONTEXT` comes later, which in the scaffold's order is its own place, rather than at the end of the file (when populate adds a missing `CONTEXT` in the same run, that one is appended at the end and `CONSTRAINTS` follows it); its header is written as the scaffold writes it (`⚠️ CONSTRAINTS [load-bearing]:`). diff --git a/docs/ci-integration.md b/docs/ci-integration.md index 3ac83ddf..ea89e76e 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -16,7 +16,7 @@ Re-running `charter setup --ci github` or `charter bootstrap --ci github` never - **Missing:** the workflow is created. - **Identical to the current template:** left alone. -- **The current npm or pnpm template:** refreshed. A switch between them is named in the output (`package manager npm → pnpm; adds pnpm/action-setup@v4`), since the pnpm form runs the third-party `pnpm/action-setup` action. Copies that differ only in a BOM, CRLF line endings, trailing whitespace or trailing blank lines count as the template. +- **The current npm or pnpm template, or the one before it:** refreshed. The previous template (score-badge steps, run with `npx charter`) has the same permissions and triggers, so it is refreshed without a warning ([#380](https://github.com/Stackbilt-dev/charter/issues/380)). A switch between them is named in the output (`package manager npm → pnpm; adds pnpm/action-setup@v4`), since the pnpm form runs the third-party `pnpm/action-setup` action. Copies that differ only in a BOM, CRLF line endings, trailing whitespace or trailing blank lines count as the template. - **An older Charter template:** kept, with a warning. Every template before the score-badge steps ran on `pull_request` only with `permissions: contents: read`, and refreshing one changes `contents: read → write`, adds the `push` and `workflow_dispatch` triggers, and adds a step that commits `.charter/badge.json` and pushes to the repo. That gives the workflow's `GITHUB_TOKEN` write access, and on a protected default branch the push step fails unless the token may push. Review it, then run the command again with `--force` to upgrade. The one template that pinned `@stackbilt/cli@x.y.z` is one of these, so a pinned workflow is kept whatever version it pins. - **Anything else (edited by hand):** kept byte for byte, with a warning stating what the current template grants (`contents: write` and `pull-requests: write`; runs on `push`, `pull_request` and `workflow_dispatch`; commits and pushes to the repo). Charter does not try to describe what your edit changed. @@ -34,6 +34,8 @@ On every push and pull request, the CI workflow runs: If any step exits `1`, the check fails and the merge is blocked. +Each step runs the `@stackbilt/cli` that the install step put in `node_modules`, through the repo's package manager: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither fetches anything. A bare `npx charter` would fetch the unrelated `charter` package from the registry whenever the CLI is not installed. `charter setup` and `charter bootstrap` add `@stackbilt/cli` to `devDependencies`; without it the steps fail. The template has no Yarn form; a Yarn repo gets the npm one. + ## Score Badge Refresh The generated GitHub workflow refreshes `.charter/badge.json` on pushes to `main` or `master`, then commits the file only when the Shields payload changed. Shields renders from the committed JSON at the raw GitHub URL; the workflow is what keeps that JSON current after Charter, ADF, or governance changes land. diff --git a/packages/cli/src/__tests__/fixtures/charter-governance-history.json b/packages/cli/src/__tests__/fixtures/charter-governance-history.json index 34297e75..6c67b97b 100644 --- a/packages/cli/src/__tests__/fixtures/charter-governance-history.json +++ b/packages/cli/src/__tests__/fixtures/charter-governance-history.json @@ -33,5 +33,17 @@ "commit": "0fdea60", "packageManager": "pnpm", "text": "name: Governance Check\n\non:\n pull_request:\n branches: [main, master]\n\npermissions:\n contents: read\n pull-requests: write\n\njobs:\n governance:\n name: Charter\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - uses: pnpm/action-setup@v4\n with:\n version: 9\n\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install --frozen-lockfile\n\n - name: Validate Commits\n run: npx charter validate --ci --format text\n\n - name: Drift Scan\n run: npx charter drift --ci --format text\n\n - name: ADF Wiring & Pointer Integrity\n run: npx charter doctor --adf-only --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: ADF Evidence\n run: npx charter adf evidence --auto-measure --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: Audit Report\n run: npx charter audit --format json > /tmp/audit.json\n if: always()\n" + }, + { + "commit": "003ec8d", + "packageManager": "npm", + "safe": true, + "text": "name: Governance Check\n\non:\n push:\n branches: [main, master]\n pull_request:\n branches: [main, master]\n workflow_dispatch:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n governance:\n name: Charter\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n\n - name: Install dependencies\n run: |\n if [ -f package-lock.json ]; then\n npm ci\n else\n npm install\n fi\n\n - name: Validate Commits\n run: npx charter validate --ci --format text\n\n - name: Drift Scan\n run: npx charter drift --ci --format text\n\n - name: ADF Wiring & Pointer Integrity\n run: npx charter doctor --adf-only --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: ADF Evidence\n run: npx charter adf evidence --auto-measure --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: Audit Report\n run: npx charter audit --format json > /tmp/audit.json\n if: always()\n\n - name: Refresh Score Badge\n run: npx charter score --badge --write\n if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'\n\n - name: Commit Score Badge\n if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'\n run: |\n if [ -n \"$(git status --porcelain -- .charter/badge.json)\" ]; then\n git config user.name \"github-actions[bot]\"\n git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n git add .charter/badge.json\n git commit -m \"chore(charter): refresh score badge [skip ci]\" -m \"Governed-By: charter-score-badge\"\n git push\n fi\n" + }, + { + "commit": "003ec8d", + "packageManager": "pnpm", + "safe": true, + "text": "name: Governance Check\n\non:\n push:\n branches: [main, master]\n pull_request:\n branches: [main, master]\n workflow_dispatch:\n\npermissions:\n contents: write\n pull-requests: write\n\njobs:\n governance:\n name: Charter\n runs-on: ubuntu-latest\n\n steps:\n - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - uses: pnpm/action-setup@v4\n with:\n version: 9\n\n - uses: actions/setup-node@v4\n with:\n node-version: '20'\n cache: 'pnpm'\n\n - name: Install dependencies\n run: pnpm install --frozen-lockfile\n\n - name: Validate Commits\n run: npx charter validate --ci --format text\n\n - name: Drift Scan\n run: npx charter drift --ci --format text\n\n - name: ADF Wiring & Pointer Integrity\n run: npx charter doctor --adf-only --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: ADF Evidence\n run: npx charter adf evidence --auto-measure --ci --format text\n if: hashFiles('.ai/manifest.adf') != ''\n\n - name: Audit Report\n run: npx charter audit --format json > /tmp/audit.json\n if: always()\n\n - name: Refresh Score Badge\n run: npx charter score --badge --write\n if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'\n\n - name: Commit Score Badge\n if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'\n run: |\n if [ -n \"$(git status --porcelain -- .charter/badge.json)\" ]; then\n git config user.name \"github-actions[bot]\"\n git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n git add .charter/badge.json\n git commit -m \"chore(charter): refresh score badge [skip ci]\" -m \"Governed-By: charter-score-badge\"\n git push\n fi\n" } ] diff --git a/packages/cli/src/__tests__/setup.test.ts b/packages/cli/src/__tests__/setup.test.ts index b0a6e7b8..9e277711 100644 --- a/packages/cli/src/__tests__/setup.test.ts +++ b/packages/cli/src/__tests__/setup.test.ts @@ -2,7 +2,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { detectStack, getGithubWorkflow, loadPackageContexts, syncPackageManifest } from '../commands/setup'; +import { detectStack, getGithubWorkflow, loadPackageContexts, syncPackageManifest, workflowCharterCommand } from '../commands/setup'; +import { checkGateEnforcement } from '../commands/doctor-gate-enforcement'; import { StackPresetSchema, VALID_PRESETS } from '../commands/setup.contract'; const originalCwd = process.cwd(); @@ -117,9 +118,33 @@ describe('syncPackageManifest', () => { it('github workflow template enforces ADF routing checks', () => { const workflow = getGithubWorkflow('pnpm'); expect(workflow).toContain('ADF Wiring & Pointer Integrity'); - expect(workflow).toContain('npx charter doctor --adf-only --ci --format text'); + expect(workflow).toContain('pnpm exec charter doctor --adf-only --ci --format text'); expect(workflow).toContain('ADF Evidence'); - expect(workflow).toContain('npx charter adf evidence --auto-measure --ci --format text'); + expect(workflow).toContain('pnpm exec charter adf evidence --auto-measure --ci --format text'); + }); + + it('github workflow runs Charter through the package manager, never a bare npx charter (#380)', () => { + expect(workflowCharterCommand('pnpm')).toBe('pnpm exec charter'); + expect(workflowCharterCommand('npm')).toBe('npm exec --no --package=@stackbilt/cli -- charter'); + for (const pm of ['npm', 'pnpm'] as const) { + const workflow = getGithubWorkflow(pm); + expect(workflow).not.toContain('npx'); + const runs = workflow.split('\n').filter(l => /^\s+run: /.test(l) && /\bcharter\b/.test(l)).map(l => l.trim()); + expect(runs).toHaveLength(6); + for (const run of runs) expect(run.startsWith(`run: ${workflowCharterCommand(pm)} `)).toBe(true); + } + }); + + it('doctor recognizes the generated workflow as the CI validate gate for npm and pnpm (#380)', () => { + for (const pm of ['npm', 'pnpm'] as const) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'charter-setup-gate-')); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, '.github', 'workflows'), { recursive: true }); + fs.writeFileSync(path.join(dir, '.github', 'workflows', 'charter-governance.yml'), getGithubWorkflow(pm)); + process.chdir(dir); + expect(checkGateEnforcement()).toMatchObject({ status: 'PASS' }); + process.chdir(originalCwd); + } }); it('github workflow template refreshes the score badge on push', () => { @@ -127,7 +152,7 @@ describe('syncPackageManifest', () => { expect(workflow).toContain('push:'); expect(workflow).toContain('contents: write'); expect(workflow).toContain('Refresh Score Badge'); - expect(workflow).toContain('npx charter score --badge --write'); + expect(workflow).toContain('pnpm exec charter score --badge --write'); expect(workflow).toContain('Commit Score Badge'); expect(workflow).toContain('git status --porcelain -- .charter/badge.json'); expect(workflow).toContain('Governed-By: charter-score-badge'); diff --git a/packages/cli/src/__tests__/workflow-mcp-backup.test.ts b/packages/cli/src/__tests__/workflow-mcp-backup.test.ts index 2eb5eb63..3e88a557 100644 --- a/packages/cli/src/__tests__/workflow-mcp-backup.test.ts +++ b/packages/cli/src/__tests__/workflow-mcp-backup.test.ts @@ -16,7 +16,7 @@ vi.mock('../git-helpers', async (importOriginal) => { }); import { bootstrapCommand } from '../commands/bootstrap'; -import { getGithubWorkflow, setupCommand } from '../commands/setup'; +import { getGithubWorkflow, setupCommand, workflowCharterCommand } from '../commands/setup'; import { applyGovernanceWorkflow, KNOWN_WORKFLOWS, normalizeWorkflow, TEMPLATE_PERMISSIONS, workflowHash } from '../commands/github-workflow'; const TEXT: CLIOptions = { configPath: '.charter', format: 'text', ciMode: false, yes: false }; @@ -161,10 +161,12 @@ describe('#374 .mcp.json: CRLF', () => { }); // Every historical template (not the current ones), as setup.ts generated it. -const HISTORY: Array<{ commit: string; packageManager: 'npm' | 'pnpm'; text: string }> = JSON.parse( +// `safe: true` marks one with the current permissions and triggers (#380). +const HISTORY: Array<{ commit: string; packageManager: 'npm' | 'pnpm'; safe?: boolean; text: string }> = JSON.parse( fs.readFileSync(path.join(__dirname, 'fixtures', 'charter-governance-history.json'), 'utf-8'), ); const PRE_BADGE = 'contents: read → write; adds push and workflow_dispatch triggers; adds a step that commits and pushes to the repo'; +const PM_CLI = 'runs Charter as pnpm exec charter (pnpm) or npm exec --no --package=@stackbilt/cli -- charter (npm) instead of npx charter'; const TEMPLATE_LINE = 'the current Charter template grants contents: write and pull-requests: write, runs on push, pull_request and workflow_dispatch, and has a step that commits and pushes to the repo'; // Hand edits that a YAML diff could misread; none may be described as a change. @@ -191,16 +193,29 @@ describe('#374 known workflow versions', () => { for (const k of KNOWN_WORKFLOWS) { expect(k.hash).toMatch(/^[0-9a-f]{64}$/); expect(typeof k.safe).toBe('boolean'); - expect(k.origin).toMatch(/^[0-9a-f]{7}[ ,]/); + expect(k.origin).toMatch(/^(?:[0-9a-f]{7}|#\d+)[ ,]/); if (!k.safe) expect(k.refreshChanges.trim().length).toBeGreaterThan(0); } }); - it('the history fixture and the known list match one to one, and every historical template is unsafe', () => { + it('the history fixture and the known list match one to one, and every pre-badge template is unsafe', () => { const fixtureHashes = HISTORY.map(h => workflowHash(h.text)); const current = [workflowHash(getGithubWorkflow('npm')), workflowHash(getGithubWorkflow('pnpm'))]; expect(new Set([...fixtureHashes, ...current])).toEqual(new Set(KNOWN_WORKFLOWS.map(k => k.hash))); - for (const h of HISTORY) { + expect(fixtureHashes.length + current.length).toBe(KNOWN_WORKFLOWS.length); + for (const h of HISTORY.filter(e => e.safe)) { + // A safe historical template grants exactly what the current one does (#380). + const k = KNOWN_WORKFLOWS.find(e => e.hash === workflowHash(h.text)); + expect(k).toMatchObject({ safe: true, packageManager: h.packageManager, refreshChanges: PM_CLI }); + expect(k?.origin.startsWith(h.commit)).toBe(true); + const head = (t: string): string => t.slice(0, t.indexOf('\njobs:')); + expect(head(h.text)).toBe(head(getGithubWorkflow(h.packageManager))); + expect(h.text).toContain('git push'); + expect(h.text).toContain('npx charter validate'); + // ...and the refresh changes only how Charter is run. + expect(h.text.replace(/npx charter /g, '{{CLI}} ')).toBe(getGithubWorkflow(h.packageManager).split(`${workflowCharterCommand(h.packageManager)} `).join('{{CLI}} ')); + } + for (const h of HISTORY.filter(e => !e.safe)) { const k = KNOWN_WORKFLOWS.find(e => e.hash === workflowHash(h.text)); expect(k).toMatchObject({ safe: false, packageManager: h.packageManager, refreshChanges: PRE_BADGE }); expect(k?.origin.startsWith(h.commit)).toBe(true); @@ -302,7 +317,19 @@ describe('#374 bootstrap --ci github: the workflow', () => { expect(fs.existsSync('.charter/.backup')).toBe(false); }); - for (const h of HISTORY) { + for (const h of HISTORY.filter(e => e.safe)) { + it(`refreshes the ${h.commit} ${h.packageManager} template under --yes, silently (same permissions, #380)`, async () => { + write(WF, h.text); + if (h.packageManager === 'pnpm') write('pnpm-lock.yaml', ''); + expect(await bootstrapCommand(YES, ['--yes', '--ci', 'github', ...BOOT])).toBe(0); + expect(read(WF)).toBe(getGithubWorkflow(h.packageManager)); + expect(out).toContain(` Updated ${P(WF)}`); + expect(out.some(l => l.includes('Warning: Kept'))).toBe(false); + expect(fs.existsSync('.charter/.backup')).toBe(false); + }); + } + + for (const h of HISTORY.filter(e => !e.safe)) { it(`keeps the ${h.commit} ${h.packageManager} template under --yes with the static message`, async () => { write(WF, h.text); if (h.packageManager === 'pnpm') write('pnpm-lock.yaml', ''); @@ -470,7 +497,18 @@ describe('#374 setup --ci github: the workflow', () => { expect(warning).toContain('To replace only the workflow, delete it and re-run'); }); - for (const h of HISTORY) { + for (const h of HISTORY.filter(e => e.safe)) { + it(`setup refreshes the ${h.commit} ${h.packageManager} template without --force (#380)`, async () => { + write(WF, h.text); + if (h.packageManager === 'pnpm') write('pnpm-lock.yaml', ''); + expect(await setupCommand(YES, SETUP)).toBe(0); + expect(read(WF)).toBe(getGithubWorkflow(h.packageManager)); + expect(out.some(l => l.includes('Warning: Kept'))).toBe(false); + expect(fs.existsSync('.charter/.backup')).toBe(false); + }); + } + + for (const h of HISTORY.filter(e => !e.safe)) { it(`keeps the ${h.commit} ${h.packageManager} template with the static message`, async () => { write(WF, h.text); if (h.packageManager === 'pnpm') write('pnpm-lock.yaml', ''); diff --git a/packages/cli/src/commands/github-workflow.ts b/packages/cli/src/commands/github-workflow.ts index bbdaba66..a3656acd 100644 --- a/packages/cli/src/commands/github-workflow.ts +++ b/packages/cli/src/commands/github-workflow.ts @@ -4,7 +4,8 @@ * * `bootstrap --ci github` and `setup --ci github` overwrite the workflow * without `--force` only when it is a known Charter template marked `safe` - * (the current npm or pnpm form). An older Charter template is kept, with a + * (the current npm or pnpm form, or an earlier one with the same permissions + * and triggers). An older Charter template is kept, with a * warning that states what refreshing it changes; a workflow that matches no * Charter template is kept, with a warning that states what the current * template grants. No claim is made about what a hand edit changed: the YAML @@ -22,19 +23,26 @@ export const TEMPLATE_PERMISSIONS = 'the current Charter template grants contents: write and pull-requests: write, runs on push, pull_request and workflow_dispatch, ' + 'and has a step that commits and pushes to the repo'; +/** What refreshing a 003ec8d template to the current one changes. Permissions and triggers are the same, so it is safe. */ +const PM_CLI_CHANGES = 'runs Charter as pnpm exec charter (pnpm) or npm exec --no --package=@stackbilt/cli -- charter (npm) instead of npx charter'; + /** What refreshing any pre-badge template (contents: read, pull_request only) to the current one changes. */ const PRE_BADGE_CHANGES = 'contents: read → write; adds push and workflow_dispatch triggers; adds a step that commits and pushes to the repo'; export interface KnownWorkflow { /** sha256 of the template after `normalizeWorkflow`. */ hash: string; - /** The first commit in setup.ts's history that generated it, and what set it apart. */ + /** The first commit in setup.ts's history that generated it (or, before it merges, its issue), and what set it apart. */ origin: string; /** The package manager its install steps are for. */ packageManager: 'npm' | 'pnpm'; /** Whether refreshing it to the current template changes nothing CI may do. */ safe: boolean; - /** For an entry that is not safe: what refreshing it changes, shown as is in the warning. */ + /** + * What refreshing it to the current template changes. For an entry that is + * not safe it is shown as is in the warning; for a safe one it only documents + * the change (a safe refresh is silent). + */ refreshChanges: string; } @@ -43,11 +51,15 @@ export interface KnownWorkflow { * history of `getGithubWorkflow` / `GITHUB_WORKFLOW` in setup.ts (each * template's npm and pnpm form). When the template changes, add the new * hashes (a test fails until the current ones are listed), mark the old ones - * unsafe if the new template grants more, and describe the change by hand. + * unsafe if the new template grants more, describe the change by hand, and add + * the old templates to the history fixture. */ export const KNOWN_WORKFLOWS: readonly KnownWorkflow[] = [ - { hash: '59b8380e5926f47656fb4870effe234090528bd9b1f0705d5e283e009e0ff225', origin: '003ec8d, current (score-badge steps)', packageManager: 'npm', safe: true, refreshChanges: '' }, - { hash: '1ff96c6454952dbc630b6cc079cd1fd7840f6b18673098bec030595968a302cd', origin: '003ec8d, current (score-badge steps)', packageManager: 'pnpm', safe: true, refreshChanges: '' }, + { hash: '66c71b0e133ffc5d555b941302346211d5b62ea43c8f1b0dc95e16d05f7cdf67', origin: '#380, current (Charter run through the package manager)', packageManager: 'npm', safe: true, refreshChanges: '' }, + { hash: 'afd0be1c5f55dc3628a0012f65e8cf51a3f672030c9af017470ceeb654374fcb', origin: '#380, current (Charter run through the package manager)', packageManager: 'pnpm', safe: true, refreshChanges: '' }, + // Same permissions and triggers as the current template: refreshed silently. + { hash: '59b8380e5926f47656fb4870effe234090528bd9b1f0705d5e283e009e0ff225', origin: '003ec8d (score-badge steps, npx charter)', packageManager: 'npm', safe: true, refreshChanges: PM_CLI_CHANGES }, + { hash: '1ff96c6454952dbc630b6cc079cd1fd7840f6b18673098bec030595968a302cd', origin: '003ec8d (score-badge steps, npx charter)', packageManager: 'pnpm', safe: true, refreshChanges: PM_CLI_CHANGES }, // Everything below ran on pull_request only, with contents: read. { hash: '38f58106b0dd4c9a0363d971bfd10c23f1a5067c1998224abd5ed9a70337f98f', origin: '0fdea60 (ADF wiring and evidence steps)', packageManager: 'npm', safe: false, refreshChanges: PRE_BADGE_CHANGES }, { hash: '87c27e54791175c4a3937490b9f7f03f841269b6d01b1fe303352ab0ebb5748f', origin: '0fdea60 (ADF wiring and evidence steps)', packageManager: 'pnpm', safe: false, refreshChanges: PRE_BADGE_CHANGES }, diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 31951261..33aa438a 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -18,7 +18,20 @@ import packageJson from '../../package.json'; const CLI_VERSION = packageJson.version; +/** + * How the generated workflow runs Charter (#380): through the repo's package + * manager, from the @stackbilt/cli the install step put in node_modules. A bare + * `npx charter` names the unrelated `charter` registry package, which npx + * fetches when the CLI is not installed. Neither form fetches anything: with no + * local @stackbilt/cli the step fails. The npm form keeps `charter validate` in + * the step, which doctor's CI-gate check looks for. + */ +export function workflowCharterCommand(packageManager: 'npm' | 'pnpm'): string { + return packageManager === 'pnpm' ? 'pnpm exec charter' : 'npm exec --no --package=@stackbilt/cli -- charter'; +} + export function getGithubWorkflow(packageManager: 'npm' | 'pnpm'): string { + const cli = workflowCharterCommand(packageManager); const installStep = packageManager === 'pnpm' ? ` - uses: pnpm/action-setup@v4 with: @@ -69,25 +82,25 @@ jobs: ${installStep} - name: Validate Commits - run: npx charter validate --ci --format text + run: ${cli} validate --ci --format text - name: Drift Scan - run: npx charter drift --ci --format text + run: ${cli} drift --ci --format text - name: ADF Wiring & Pointer Integrity - run: npx charter doctor --adf-only --ci --format text + run: ${cli} doctor --adf-only --ci --format text if: hashFiles('.ai/manifest.adf') != '' - name: ADF Evidence - run: npx charter adf evidence --auto-measure --ci --format text + run: ${cli} adf evidence --auto-measure --ci --format text if: hashFiles('.ai/manifest.adf') != '' - name: Audit Report - run: npx charter audit --format json > /tmp/audit.json + run: ${cli} audit --format json > /tmp/audit.json if: always() - name: Refresh Score Badge - run: npx charter score --badge --write + run: ${cli} score --badge --write if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - name: Commit Score Badge From 3d98fc057262d62d4c43e6679dce4a0679a7930b Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Sat, 17 Oct 2026 20:43:42 -0500 Subject: [PATCH 2/2] docs(#380): say the npm form installs nothing (it still queries the registry); qualify the devDependencies claim Review of #388: with no local CLI, npm exec --no still resolves @stackbilt/cli's metadata before refusing, so 'fetches nothing' was too strong. setup does not add the dependency under --no-dependency-sync or without a root package.json. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/ci-integration.md | 4 ++-- packages/cli/src/commands/setup.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 376b3f57..6becfb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on Keep a Changelog and follows Semantic Versioning. ### Fixed -- **The GitHub workflow that `charter setup --ci github` and `charter bootstrap --ci github` generate no longer runs a bare `npx charter`** (`#380`). When `@stackbilt/cli` was not installed, `npx charter` fetched the unrelated `charter` registry package (no executable, so the step failed with `could not determine executable to run`). Each step now runs the CLI the install step put in `node_modules`: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither form fetches anything; without a local `@stackbilt/cli` the step fails. `setup` and `bootstrap` add it to `devDependencies`. The npm form still contains `charter validate --ci`, so doctor's gate-enforcement check recognizes it. The template has no Yarn form (it supports npm and pnpm only), so a Yarn repo gets the npm form. The previous template (003ec8d, with `npx charter`) has the same permissions and triggers, so `setup`/`bootstrap` refresh it without a warning, as before. Its `KNOWN_WORKFLOWS` entry stays `safe` and records what the refresh changes. +- **The GitHub workflow that `charter setup --ci github` and `charter bootstrap --ci github` generate no longer runs a bare `npx charter`** (`#380`). When `@stackbilt/cli` was not installed, `npx charter` fetched the unrelated `charter` registry package (no executable, so the step failed with `could not determine executable to run`). Each step now runs the CLI the install step put in `node_modules`: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither form installs anything; without a local `@stackbilt/cli` the step fails. `setup` and `bootstrap` add it to `devDependencies`, except under `--no-dependency-sync` or in a repo with no root `package.json`. The npm form still contains `charter validate --ci`, so doctor's gate-enforcement check recognizes it. The template has no Yarn form (it supports npm and pnpm only), so a Yarn repo gets the npm form. The previous template (003ec8d, with `npx charter`) has the same permissions and triggers, so `setup`/`bootstrap` refresh it without a warning, as before. Its `KNOWN_WORKFLOWS` entry stays `safe` and records what the refresh changes. - **`charter adf populate`, and so every `charter bootstrap`, re-added stack constraints to a `CONSTRAINTS` section you had edited** (`#371`; 2.1.0 listed this as still open under `#354`). Deleting a bullet from `core.adf` and re-running bootstrap brought a stack rule back (for example the Cloudflare `No Node.js-specific APIs in Worker handlers` rule), because populate appended any detected stack constraint it did not find. Populate now remembers what it has added or offered in a new file, `.ai/.populate.json` (`{ "version": 1, "constraints": { "core.adf": { "added": [...], "offered": [...] } } }`, sorted, written only when it changes). Commit it with the rest of `.ai/`; bootstrap lists it under what it created, and its `git add .ai` next step already includes it. The rules: - A constraint recorded as added or offered is never added again, whether it is still in the section or you deleted it. Deleting the one rule populate added to a plain Worker repo now sticks, and a deleted `CONSTRAINTS` section is not recreated once the file has a record. - New constraints go only into a section that is untouched: the template's bullets, in order, followed by nothing but bullets populate recorded as added. A missing section is created only while there is no record. It goes right after whichever of `GUIDE` and `CONTEXT` comes later, which in the scaffold's order is its own place, rather than at the end of the file (when populate adds a missing `CONTEXT` in the same run, that one is appended at the end and `CONSTRAINTS` follows it); its header is written as the scaffold writes it (`⚠️ CONSTRAINTS [load-bearing]:`). diff --git a/docs/ci-integration.md b/docs/ci-integration.md index ea89e76e..461f83b5 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -16,7 +16,7 @@ Re-running `charter setup --ci github` or `charter bootstrap --ci github` never - **Missing:** the workflow is created. - **Identical to the current template:** left alone. -- **The current npm or pnpm template, or the one before it:** refreshed. The previous template (score-badge steps, run with `npx charter`) has the same permissions and triggers, so it is refreshed without a warning ([#380](https://github.com/Stackbilt-dev/charter/issues/380)). A switch between them is named in the output (`package manager npm → pnpm; adds pnpm/action-setup@v4`), since the pnpm form runs the third-party `pnpm/action-setup` action. Copies that differ only in a BOM, CRLF line endings, trailing whitespace or trailing blank lines count as the template. +- **The current npm or pnpm template, or the one before it:** refreshed. The previous template (score-badge steps, run with `npx charter`) has the same permissions and triggers, so it is refreshed without a warning ([#380](https://github.com/Stackbilt-dev/charter/issues/380)). A switch between the npm and pnpm forms is named in the output (`package manager npm → pnpm; adds pnpm/action-setup@v4`), since the pnpm form runs the third-party `pnpm/action-setup` action. Copies that differ only in a BOM, CRLF line endings, trailing whitespace or trailing blank lines count as the template. - **An older Charter template:** kept, with a warning. Every template before the score-badge steps ran on `pull_request` only with `permissions: contents: read`, and refreshing one changes `contents: read → write`, adds the `push` and `workflow_dispatch` triggers, and adds a step that commits `.charter/badge.json` and pushes to the repo. That gives the workflow's `GITHUB_TOKEN` write access, and on a protected default branch the push step fails unless the token may push. Review it, then run the command again with `--force` to upgrade. The one template that pinned `@stackbilt/cli@x.y.z` is one of these, so a pinned workflow is kept whatever version it pins. - **Anything else (edited by hand):** kept byte for byte, with a warning stating what the current template grants (`contents: write` and `pull-requests: write`; runs on `push`, `pull_request` and `workflow_dispatch`; commits and pushes to the repo). Charter does not try to describe what your edit changed. @@ -34,7 +34,7 @@ On every push and pull request, the CI workflow runs: If any step exits `1`, the check fails and the merge is blocked. -Each step runs the `@stackbilt/cli` that the install step put in `node_modules`, through the repo's package manager: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither fetches anything. A bare `npx charter` would fetch the unrelated `charter` package from the registry whenever the CLI is not installed. `charter setup` and `charter bootstrap` add `@stackbilt/cli` to `devDependencies`; without it the steps fail. The template has no Yarn form; a Yarn repo gets the npm one. +Each step runs the `@stackbilt/cli` that the install step put in `node_modules`, through the repo's package manager: `pnpm exec charter …` for pnpm, and `npm exec --no --package=@stackbilt/cli -- charter …` for npm. Neither installs anything. A bare `npx charter` would fetch the unrelated `charter` package from the registry whenever the CLI is not installed. `charter setup` and `charter bootstrap` add `@stackbilt/cli` to `devDependencies` (not under `--no-dependency-sync`, nor in a repo with no root `package.json`); without it the steps fail. The template has no Yarn form; a Yarn repo gets the npm one. ## Score Badge Refresh diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 33aa438a..a591c23b 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -22,7 +22,7 @@ const CLI_VERSION = packageJson.version; * How the generated workflow runs Charter (#380): through the repo's package * manager, from the @stackbilt/cli the install step put in node_modules. A bare * `npx charter` names the unrelated `charter` registry package, which npx - * fetches when the CLI is not installed. Neither form fetches anything: with no + * fetches when the CLI is not installed. Neither form installs anything: with no * local @stackbilt/cli the step fails. The npm form keeps `charter validate` in * the step, which doctor's CI-gate check looks for. */