Skip to content
Merged
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
48 changes: 45 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,17 @@ jobs:
- name: Build
run: pnpm run build

# Enforces unified workspace versioning — every packages/*/package.json must match the tag.
- name: Verify workspace versions match tag
# Enforces unified workspace versioning: every packages/*/package.json must match the tag.
# If Charter adopts independent package versions, replace this with per-package release metadata.
- name: Verify tag and workspace versions
shell: bash
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid tag format: ${TAG}. Expected v<major>.<minor>.<patch>"
exit 1
fi

EXPECTED="${TAG#v}"
FAIL=0
for p in packages/*/package.json; do
Expand All @@ -143,7 +149,43 @@ jobs:
done
if [[ $FAIL -ne 0 ]]; then exit 1; fi

- name: Verify packed manifests
run: pnpm run publish:check

# Auth is OIDC via npm trusted publishers — no NPM_TOKEN needed.
# See: https://docs.npmjs.com/trusted-publishers
- name: Publish to npm
run: npm publish --workspaces --access public --provenance
shell: bash
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
VERSION="${TAG#v}"
mkdir -p release-tarballs

pack_package() {
local package_dir="$1"
(cd "${package_dir}" && pnpm pack --pack-destination ../../release-tarballs)
}

pack_package packages/types
pack_package packages/core
pack_package packages/adf
pack_package packages/git
pack_package packages/classify
pack_package packages/validate
pack_package packages/drift
pack_package packages/blast
pack_package packages/surface
pack_package packages/ci
pack_package packages/cli

npm publish "release-tarballs/stackbilt-types-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-core-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-adf-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-git-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-classify-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-validate-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-drift-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-blast-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-surface-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-ci-${VERSION}.tgz" --access public --provenance
npm publish "release-tarballs/stackbilt-cli-${VERSION}.tgz" --access public --provenance
33 changes: 20 additions & 13 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pnpm run clean
pnpm run typecheck
pnpm run build
pnpm run test
pnpm run publish:check
```

## Phase 2: Version Bump
Expand All @@ -62,23 +63,29 @@ done

## Phase 3: Artifact Validation (Required)

1. Dry-run packed contents per package:
1. Verify packed package manifests do not contain `workspace:` dependency specifiers:

```bash
pnpm --filter @stackbilt/types pack --dry-run
pnpm --filter @stackbilt/core pack --dry-run
pnpm --filter @stackbilt/adf pack --dry-run
pnpm --filter @stackbilt/git pack --dry-run
pnpm --filter @stackbilt/classify pack --dry-run
pnpm --filter @stackbilt/validate pack --dry-run
pnpm --filter @stackbilt/drift pack --dry-run
pnpm --filter @stackbilt/blast pack --dry-run
pnpm --filter @stackbilt/surface pack --dry-run
pnpm --filter @stackbilt/ci pack --dry-run
pnpm --filter @stackbilt/cli pack --dry-run
pnpm run publish:check
```

2. Verify CLI behavior before publish:
2. Dry-run packed contents per package:

```bash
(cd packages/types && pnpm pack --dry-run)
(cd packages/core && pnpm pack --dry-run)
(cd packages/adf && pnpm pack --dry-run)
(cd packages/git && pnpm pack --dry-run)
(cd packages/classify && pnpm pack --dry-run)
(cd packages/validate && pnpm pack --dry-run)
(cd packages/drift && pnpm pack --dry-run)
(cd packages/blast && pnpm pack --dry-run)
(cd packages/surface && pnpm pack --dry-run)
(cd packages/ci && pnpm pack --dry-run)
(cd packages/cli && pnpm pack --dry-run)
```

3. Verify CLI behavior before publish:

```bash
node packages/cli/dist/bin.js --version
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Claude Code can query `getProjectContext`, `getArchitecturalDecisions`, `getProj
```bash
charter # Repo risk/value snapshot
charter bootstrap --ci github # One-command onboarding
charter bootstrap --security-sensitive # SECURITY.md + hard security drift denies
charter doctor # Environment/config health check
charter validate # Commit governance (trailers)
charter drift # Pattern drift scanning
Expand Down
2 changes: 2 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ One-command repo onboarding. Orchestrates detect + setup + ADF init + install +
```bash
npx charter bootstrap # interactive
npx charter bootstrap --preset worker --ci github --yes # fully automated
npx charter bootstrap --preset worker --security-sensitive # security posture baseline
npx charter bootstrap --skip-install --skip-doctor # minimal
```

- `--ci github` — generate GitHub Actions governance workflow
- `--preset <worker|frontend|backend|fullstack|docs>` — stack preset
- `--security-sensitive` — generate `SECURITY.md`, seed hard-fail drift denies in `.charter/patterns/security-deny.json`, and warn in `doctor` when no `security*` or `l4*` test file exists
- `--skip-install` — skip dependency installation phase
- `--skip-doctor` — skip health check phase
- `-y, --yes` — accept all prompts
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"docs:oss:check": "node scripts/docs-sync.mjs --check --config .docsync.oss.json",
"docs:oss:auto": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json",
"docs:oss:auto:dry-run": "node scripts/docs-oss-auto-sync.mjs --config .docsync.oss.json --dry-run --no-push",
"publish:check": "node scripts/assert-packages-publishable.mjs",
"verify:adf": "bash -lc \"node packages/cli/dist/bin.js doctor --adf-only --ci --format json && node packages/cli/dist/bin.js adf evidence --auto-measure --ci --format json\"",
"charter:detect": "charter setup --detect-only --format json",
"charter:setup": "charter setup --preset fullstack --ci github --yes",
Expand Down
3 changes: 3 additions & 0 deletions packages/ci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/classify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"dependencies": {
"@stackbilt/types": "workspace:^"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
"publishConfig": {
"access": "public"
},
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"access": "public"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs",
"build": "pnpm exec tsc -p tsconfig.json"
},
"dependencies": {
Expand Down
48 changes: 48 additions & 0 deletions packages/cli/src/__tests__/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { bootstrapCommand } from '../commands/bootstrap';
import { doctorCommand } from '../commands/doctor';
import { driftCommand } from '../commands/drift';
import type { CLIOptions } from '../index';

const baseOptions: CLIOptions = {
Expand Down Expand Up @@ -122,4 +124,50 @@ STATE:
expect(manifest).toContain('agent.adf');
expect(manifest).toContain('persona.adf');
});

it('seeds security-sensitive bootstrap files and warns when security tests are absent', async () => {
const exitCode = await bootstrapCommand(
{ ...baseOptions, yes: true },
['--yes', '--preset', 'worker', '--security-sensitive', '--skip-install', '--skip-doctor'],
);

expect(exitCode).toBe(0);
expect(fs.existsSync('SECURITY.md')).toBe(true);
expect(fs.existsSync(path.join('.charter', 'patterns', 'security-deny.json'))).toBe(true);

logs = [];
await doctorCommand({ ...baseOptions, format: 'json' }, []);
const report = JSON.parse(logs[0]);
const securityCheck = report.checks.find((check: { name: string }) => check.name === 'security test coverage');
expect(securityCheck.status).toBe('WARN');

fs.mkdirSync('tests', { recursive: true });
fs.writeFileSync(path.join('tests', 'security-l4.test.ts'), 'export {};');

logs = [];
await doctorCommand({ ...baseOptions, format: 'json' }, []);
const updatedReport = JSON.parse(logs[0]);
const updatedSecurityCheck = updatedReport.checks.find((check: { name: string }) => check.name === 'security test coverage');
expect(updatedSecurityCheck.status).toBe('PASS');
});

it('treats security deny drift matches as CI policy violations', async () => {
await bootstrapCommand(
{ ...baseOptions, yes: true },
['--yes', '--preset', 'worker', '--security-sensitive', '--skip-install', '--skip-doctor'],
);
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync(path.join('src', 'verify.ts'), 'export function verify(computed: string, signature: string) { return computed === signature; }\n');

logs = [];
const exitCode = await driftCommand({ ...baseOptions, format: 'json', ciMode: true }, ['--path', '.']);
const report = JSON.parse(logs[0]);

expect(exitCode).toBe(1);
expect(report.status).toBe('FAIL');
expect(report.securityBlockers).toBeGreaterThan(0);
expect(report.violations.some((violation: { severity: string; patternName: string }) =>
violation.severity === 'BLOCKER' && violation.patternName.includes('Timing-Sensitive Equality')
)).toBe(true);
});
});
7 changes: 5 additions & 2 deletions packages/cli/src/commands/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
const skipInstall = args.includes('--skip-install');
const skipDoctor = args.includes('--skip-doctor');
const force = args.includes('--force');
const securitySensitive = args.includes('--security-sensitive');
const nonInteractive = options.yes;
const setupOverwrite = options.yes || force;

Expand Down Expand Up @@ -125,7 +126,7 @@ export async function bootstrapCommand(options: CLIOptions, args: string[]): Pro
// ========================================================================
// Phase 2: Setup
// ========================================================================
const setupResult = runSetupPhase(options, selectedPreset, detection, contexts, ciTarget, packageManager, setupOverwrite);
const setupResult = runSetupPhase(options, selectedPreset, detection, contexts, ciTarget, packageManager, setupOverwrite, securitySensitive);
result.steps.push(setupResult.step);
warnings += setupResult.step.warnings.length;

Expand Down Expand Up @@ -472,7 +473,8 @@ function runSetupPhase(
contexts: ReturnType<typeof loadPackageContexts>,
ciTarget: string | undefined,
packageManager: 'npm' | 'pnpm',
force: boolean
force: boolean,
securitySensitive: boolean
): { step: StepResult } {
const warnings: string[] = [];
const created: string[] = [];
Expand All @@ -489,6 +491,7 @@ function runSetupPhase(
react: detection.signals.hasReact,
vite: detection.signals.hasVite,
},
securitySensitive,
});

if (initResult.created) {
Expand Down
51 changes: 51 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P
status: policyCount > 0 ? 'PASS' : 'WARN',
details: policyCount > 0 ? `${policyCount} markdown policy file(s).` : 'No policy markdown files found.',
});

const securityDenyPath = path.join(options.configPath, 'patterns', 'security-deny.json');
if (fs.existsSync(securityDenyPath)) {
const securityTestFiles = findSecurityTestFiles('.');
checks.push({
name: 'security test coverage',
status: securityTestFiles.length > 0 ? 'PASS' : 'WARN',
details: securityTestFiles.length > 0
? `${securityTestFiles.length} security test file(s): ${securityTestFiles.slice(0, 5).join(', ')}`
: 'Security-sensitive repo has no **/security* or **/l4* test file. Add L4/security regression tests.',
});
}
}

// ADF readiness checks
Expand Down Expand Up @@ -343,6 +355,45 @@ export async function doctorCommand(options: CLIOptions, args: string[] = []): P
return EXIT_CODE.SUCCESS;
}

function findSecurityTestFiles(rootPath: string): string[] {
const matches: string[] = [];
const skipDirs = new Set(['.git', 'node_modules', 'dist', 'coverage', '.ai', '.charter']);

function walk(dir: string): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = path.relative(rootPath, fullPath) || entry.name;
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
walk(fullPath);
}
continue;
}

if (entry.isFile() && /^(security|l4)/i.test(entry.name) && isTestLikePath(relPath)) {
matches.push(relPath);
}
}
}

walk(rootPath);
return matches.sort();
}

function isTestLikePath(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
return normalized.includes('/test/')
|| normalized.includes('/tests/')
|| /\.(test|spec)\.[cm]?[jt]sx?$/.test(normalized);
}


function validateJSONConfig(configFile: string): DoctorResult['checks'][number] {
try {
Expand Down
Loading
Loading