code-foundry 1.8.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/code-foundry.yml +2 -0
- package/.github/workflows/validation_self-ci.yml +1 -1
- package/.gitignore +1 -0
- package/CHANGELOG.md +14 -0
- package/README.md +4 -2
- package/docs/CONFIGURATION.md +36 -2
- package/docs/WORKFLOWS.md +18 -11
- package/package.json +1 -1
- package/src/commands/sync.mjs +7 -1
- package/src/lib/node-package-performance.mjs +224 -0
- package/src/runtime.mjs +110 -16
- package/src/templates/gitignore +1 -0
- package/src/templates/workflows/validation-audit.yml +43 -0
package/.github/code-foundry.yml
CHANGED
|
@@ -73,7 +73,7 @@ jobs:
|
|
|
73
73
|
mode: ${{ needs.mode.outputs.mode }}
|
|
74
74
|
runtime-repository: 0xPlayerOne/code-foundry
|
|
75
75
|
# Pull requests validate the proposed runtime commit. Scheduled and
|
|
76
|
-
# manually dispatched audits
|
|
76
|
+
# manually dispatched audits use a separate default-branch-pinned caller,
|
|
77
77
|
# so this caller-selected ref never runs with default-branch cache access.
|
|
78
78
|
runtime-ref: ${{ github.sha }}
|
|
79
79
|
ci-runner: ubuntu-latest
|
package/.gitignore
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.9.0...v1.9.1) (2026-09-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **ci:** restore protected consumer audits ([#503](https://github.com/0xPlayerOne/code-foundry/issues/503)) ([d045cc4](https://github.com/0xPlayerOne/code-foundry/commit/d045cc4a5543d2096e4d6f33c5e74aa40604c887))
|
|
9
|
+
|
|
10
|
+
## [1.9.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.8.0...v1.9.0) (2026-09-08)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **performance:** standardize package audit contracts ([#501](https://github.com/0xPlayerOne/code-foundry/issues/501)) ([ab30081](https://github.com/0xPlayerOne/code-foundry/commit/ab3008120772922a1affd1e5648a99b97b02e455))
|
|
16
|
+
|
|
3
17
|
## [1.8.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.7.0...v1.8.0) (2026-09-08)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -35,8 +35,10 @@ contract. For normal updates, edit that file and run `npx code-foundry sync`.
|
|
|
35
35
|
|
|
36
36
|
## What it installs
|
|
37
37
|
|
|
38
|
-
- Short workflow callers for
|
|
39
|
-
and Release.
|
|
38
|
+
- Short workflow callers for pull-request validation, protected scheduled/manual
|
|
39
|
+
audits, Draft PR, Release PR, and Release.
|
|
40
|
+
- A deterministic performance lane with ordered command support, stable result
|
|
41
|
+
artifacts, and an optional shared Node package budget profile.
|
|
40
42
|
- A small `.githooks/pre-commit` launcher with language-aware formatting and
|
|
41
43
|
linting.
|
|
42
44
|
- An optional `.mise.toml`/`mise.lock`, repository profile configuration, and
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -37,7 +37,9 @@ repository manifests and source
|
|
|
37
37
|
| `toolchain` | `auto`, `native`, `mise` | Environment setup policy; defaults to `auto` |
|
|
38
38
|
| `staging_validation_mode` | `fast`, `audit` | Validation tier for pull requests targeting `staging`; defaults to `fast` |
|
|
39
39
|
| `performance` | `auto`, `true`, `false` | Run a deterministic performance task when a supported entrypoint exists; defaults to `auto` |
|
|
40
|
-
| `performance_command` | JSON
|
|
40
|
+
| `performance_command` | JSON argv array or array of argv arrays | One or more ordered commands for non-package harnesses; package scripts take precedence |
|
|
41
|
+
| `performance_profile` | empty, `node-package` | Optional shared package import, memory, archive, and dependency budget harness |
|
|
42
|
+
| `performance_budget_file` | repository path | Budget policy for the shared package harness; defaults to `performance-package-budgets.json` |
|
|
41
43
|
| `features` | `all` or a list | Standard workflow callers |
|
|
42
44
|
| `codeql` | `auto`, `true`, `false` | CodeQL policy; public repositories default to enabled, non-public repositories default to disabled |
|
|
43
45
|
| `codeql_rust_shards` | JSON array of paths | Rust scan scopes; `["all"]` keeps the safe single full scan |
|
|
@@ -62,7 +64,8 @@ Supported features are `ci`, `codeql`, `security`, `test`, `draft-pr`,
|
|
|
62
64
|
|
|
63
65
|
The shared Test workflow exposes a deterministic `Performance` job. In JavaScript
|
|
64
66
|
repositories it discovers `performance:check` first and `perf:check` second. Other
|
|
65
|
-
repositories can declare
|
|
67
|
+
repositories can declare one argv array, or an ordered array of argv arrays, without
|
|
68
|
+
shell interpolation:
|
|
66
69
|
|
|
67
70
|
```yaml
|
|
68
71
|
performance: true
|
|
@@ -70,6 +73,37 @@ performance_command: '["python3","scripts/performance_audit.py","--check"]'
|
|
|
70
73
|
performance_runner: ubuntu-latest
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
The `node-package` profile adds a shared, repository-configured audit:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
performance: true
|
|
80
|
+
performance_profile: node-package
|
|
81
|
+
performance_budget_file: performance-package-budgets.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"schemaVersion": 1,
|
|
87
|
+
"importTarget": "./dist/index.js",
|
|
88
|
+
"controlImport": "typebox",
|
|
89
|
+
"samples": 7,
|
|
90
|
+
"budgets": {
|
|
91
|
+
"coldImportP95Ms": 150,
|
|
92
|
+
"coldImportRssMaxBytes": 25000000,
|
|
93
|
+
"packedBytes": 500000,
|
|
94
|
+
"productionDependencyCount": 20
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Supported budgets are `coldImportP50Ms`, `coldImportP95Ms`,
|
|
100
|
+
`coldImportRssMaxBytes`, `coldImportRelativeP50`, `packedBytes`,
|
|
101
|
+
`unpackedBytes`, `packageFileCount`, `packageMapFileCount`, and
|
|
102
|
+
`productionDependencyCount`. The profile writes
|
|
103
|
+
`performance-results/node-package.json`. Every performance run also writes
|
|
104
|
+
`performance-results/summary.json`, including repository-owned scripts and
|
|
105
|
+
configured commands, so artifact consumers have one stable status contract.
|
|
106
|
+
|
|
73
107
|
`performance: false` disables discovery. Performance budgets, fixtures, mock
|
|
74
108
|
providers, and live endpoint credentials remain repository-owned. The shared job
|
|
75
109
|
owns checkout, pinned setup, billing controls, concurrency, and artifact upload;
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Standard triggers
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Validation uses two callers so pull-request code and default-branch-capable
|
|
6
|
+
audit events never share a caller-selected runtime ref. The canonical
|
|
7
|
+
`validation.yml` caller handles pull requests only:
|
|
7
8
|
|
|
8
9
|
```yaml
|
|
9
10
|
pull_request:
|
|
10
11
|
branches: [main, staging] # staging-release topology
|
|
11
12
|
# direct topology: branches: [main]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The separate `validation-audit.yml` caller is pinned to the configured released
|
|
16
|
+
runtime and handles scheduled and manual audits:
|
|
17
|
+
|
|
18
|
+
```yaml
|
|
12
19
|
schedule:
|
|
13
20
|
- cron: '31 6 * * 1'
|
|
14
21
|
workflow_dispatch:
|
|
@@ -76,15 +83,15 @@ repositories can opt in or out without a code change.
|
|
|
76
83
|
|
|
77
84
|
## Standard workflow responsibilities
|
|
78
85
|
|
|
79
|
-
| Workflow | Responsibility
|
|
80
|
-
| ---------- |
|
|
81
|
-
| CI | Format, lint, type-check, and build
|
|
82
|
-
| Test | Unit, performance, integration, E2E, and smoke tests
|
|
83
|
-
| Security | Profile, audits, and public-only Dependency Review
|
|
84
|
-
| CodeQL | GitHub-native code scanning, kept separate from CI
|
|
85
|
-
| Draft PR | Create/update development pull requests
|
|
86
|
-
| Release PR | Promote `staging` into `main` (staging-release topology only)
|
|
87
|
-
| Release | Release Please, GitHub release, and optional npm publication
|
|
86
|
+
| Workflow | Responsibility |
|
|
87
|
+
| ---------- | ----------------------------------------------------------------- |
|
|
88
|
+
| CI | Format, lint, type-check, and build |
|
|
89
|
+
| Test | Unit, standardized performance, integration, E2E, and smoke tests |
|
|
90
|
+
| Security | Profile, audits, and public-only Dependency Review |
|
|
91
|
+
| CodeQL | GitHub-native code scanning, kept separate from CI |
|
|
92
|
+
| Draft PR | Create/update development pull requests |
|
|
93
|
+
| Release PR | Promote `staging` into `main` (staging-release topology only) |
|
|
94
|
+
| Release | Release Please, GitHub release, and optional npm publication |
|
|
88
95
|
|
|
89
96
|
Use concise job names such as `CI / Format`, `Test / Unit`, and
|
|
90
97
|
`CodeQL / Analyze (Python)`. Per-language CodeQL analyzers (Rust shards
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-foundry",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
|
|
5
5
|
"homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
|
|
6
6
|
"bugs": {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -42,6 +42,7 @@ const standardFiles = [
|
|
|
42
42
|
'.github/ISSUE_TEMPLATE/config.yml',
|
|
43
43
|
'.github/ISSUE_TEMPLATE/feature_request.yml',
|
|
44
44
|
'.github/workflows/validation.yml',
|
|
45
|
+
'.github/workflows/validation-audit.yml',
|
|
45
46
|
'.github/workflows/draft-pr.yml',
|
|
46
47
|
'.github/workflows/release-pr.yml',
|
|
47
48
|
'.github/workflows/release.yml',
|
|
@@ -473,6 +474,9 @@ function sourcePath(source, file) {
|
|
|
473
474
|
return existsSync(rootTemplate) ? rootTemplate : join(source, 'src/templates/gitignore')
|
|
474
475
|
}
|
|
475
476
|
if (file.startsWith('.github/workflows/')) {
|
|
477
|
+
if (file === '.github/workflows/validation-audit.yml') {
|
|
478
|
+
return join(source, 'src/templates/workflows/validation-audit.yml')
|
|
479
|
+
}
|
|
476
480
|
const name = file.slice('.github/workflows/'.length, -4)
|
|
477
481
|
return join(source, '.github/workflows', `${name}_self-ci.yml`)
|
|
478
482
|
}
|
|
@@ -535,7 +539,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql) {
|
|
|
535
539
|
if (workflow === 'test' && config.unit_runner) {
|
|
536
540
|
rendered = rendered.replace(/^(\s+unit-runner:)\s+.*$/m, `$1 ${config.unit_runner}`)
|
|
537
541
|
}
|
|
538
|
-
if (workflow === 'validation') {
|
|
542
|
+
if (workflow === 'validation' || workflow === 'validation-audit') {
|
|
539
543
|
// The mode classifier is a normal runner job in the caller rather than a
|
|
540
544
|
// reusable-workflow input. Keep it on the configured default runner so
|
|
541
545
|
// consumers that cannot use ubuntu-slim do not fail before validation.
|
|
@@ -1092,6 +1096,8 @@ function createDefaultConfig(root, source, configuredWorkflow) {
|
|
|
1092
1096
|
staging_validation_mode: 'fast',
|
|
1093
1097
|
performance: 'auto',
|
|
1094
1098
|
performance_command: '',
|
|
1099
|
+
performance_profile: '',
|
|
1100
|
+
performance_budget_file: 'performance-package-budgets.json',
|
|
1095
1101
|
prune_standard: 'false',
|
|
1096
1102
|
cache_packages: 'auto',
|
|
1097
1103
|
cache_build: 'auto',
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
import { pathToFileURL } from 'node:url'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
|
|
8
|
+
const METRICS = new Set([
|
|
9
|
+
'coldImportP50Ms',
|
|
10
|
+
'coldImportP95Ms',
|
|
11
|
+
'coldImportRssMaxBytes',
|
|
12
|
+
'coldImportRelativeP50',
|
|
13
|
+
'packedBytes',
|
|
14
|
+
'unpackedBytes',
|
|
15
|
+
'packageFileCount',
|
|
16
|
+
'packageMapFileCount',
|
|
17
|
+
'productionDependencyCount',
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
/** @param {number[]} values @param {number} percent */
|
|
21
|
+
export function percentile(values, percent) {
|
|
22
|
+
if (values.length === 0) throw new Error('percentile requires at least one value')
|
|
23
|
+
// oxlint-disable-next-line unicorn/no-array-sort -- The project typechecks against ES2022.
|
|
24
|
+
const ordered = [...values].sort((a, b) => a - b)
|
|
25
|
+
const position = ((ordered.length - 1) * percent) / 100
|
|
26
|
+
const lower = Math.floor(position)
|
|
27
|
+
const upper = Math.min(lower + 1, ordered.length - 1)
|
|
28
|
+
const fraction = position - lower
|
|
29
|
+
return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} root @param {string} budgetFile @param {string} resultsDirectory */
|
|
33
|
+
export function runNodePackagePerformance(
|
|
34
|
+
root,
|
|
35
|
+
budgetFile = 'performance-package-budgets.json',
|
|
36
|
+
resultsDirectory = 'performance-results'
|
|
37
|
+
) {
|
|
38
|
+
const packageJson = readJson(resolve(root, 'package.json'), 'package.json')
|
|
39
|
+
const policy = readJson(resolve(root, budgetFile), budgetFile)
|
|
40
|
+
if (policy.schemaVersion !== 1) throw new Error(`${budgetFile} must declare schemaVersion 1.`)
|
|
41
|
+
|
|
42
|
+
const samples = policy.samples ?? 7
|
|
43
|
+
if (!Number.isInteger(samples) || samples < 3 || samples > 50)
|
|
44
|
+
throw new Error(`${budgetFile} samples must be an integer from 3 through 50.`)
|
|
45
|
+
const budgets = policy.budgets
|
|
46
|
+
if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets))
|
|
47
|
+
throw new Error(`${budgetFile} budgets must be an object.`)
|
|
48
|
+
for (const [name, maximum] of Object.entries(budgets)) {
|
|
49
|
+
if (!METRICS.has(name)) throw new Error(`${budgetFile} contains unknown metric ${name}.`)
|
|
50
|
+
if (typeof maximum !== 'number' || !Number.isFinite(maximum) || maximum < 0)
|
|
51
|
+
throw new Error(`${budgetFile} budget ${name} must be a non-negative finite number.`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const importTarget = resolve(root, policy.importTarget ?? packageEntry(packageJson))
|
|
55
|
+
if (!existsSync(importTarget))
|
|
56
|
+
throw new Error(`Node package performance import target does not exist: ${importTarget}`)
|
|
57
|
+
const imports = measureImports(root, pathToFileURL(importTarget).href, samples)
|
|
58
|
+
const control = policy.controlImport
|
|
59
|
+
? measureImports(root, String(policy.controlImport), samples)
|
|
60
|
+
: null
|
|
61
|
+
const pack = npmJson(root, ['pack', '--dry-run', '--json', '--ignore-scripts'], 'npm pack')
|
|
62
|
+
const packed = Array.isArray(pack) ? pack[0] : pack
|
|
63
|
+
if (!packed || typeof packed !== 'object') throw new Error('npm pack returned no package result.')
|
|
64
|
+
/** @type {Array<{path?: string}>} */
|
|
65
|
+
const files = Array.isArray(packed.files) ? packed.files : []
|
|
66
|
+
const productionDependencyCount = npmProductionDependencyCount(root)
|
|
67
|
+
const metrics = {
|
|
68
|
+
coldImportP50Ms: round(
|
|
69
|
+
percentile(
|
|
70
|
+
imports.map((sample) => sample.durationMs),
|
|
71
|
+
50
|
|
72
|
+
)
|
|
73
|
+
),
|
|
74
|
+
coldImportP95Ms: round(
|
|
75
|
+
percentile(
|
|
76
|
+
imports.map((sample) => sample.durationMs),
|
|
77
|
+
95
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
coldImportRssMaxBytes: Math.max(...imports.map((sample) => sample.rssDeltaBytes)),
|
|
81
|
+
...(control
|
|
82
|
+
? {
|
|
83
|
+
coldImportRelativeP50: round(
|
|
84
|
+
percentile(
|
|
85
|
+
imports.map((sample) => sample.durationMs),
|
|
86
|
+
50
|
|
87
|
+
) /
|
|
88
|
+
percentile(
|
|
89
|
+
control.map((sample) => sample.durationMs),
|
|
90
|
+
50
|
|
91
|
+
)
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
packedBytes: numberField(packed, 'size'),
|
|
96
|
+
unpackedBytes: numberField(packed, 'unpackedSize'),
|
|
97
|
+
packageFileCount: files.length,
|
|
98
|
+
packageMapFileCount: files.filter(
|
|
99
|
+
(file) => file && typeof file === 'object' && String(file.path ?? '').endsWith('.map')
|
|
100
|
+
).length,
|
|
101
|
+
productionDependencyCount,
|
|
102
|
+
}
|
|
103
|
+
const failures = Object.entries(budgets).flatMap(([name, maximum]) => {
|
|
104
|
+
const value = metrics[/** @type {keyof typeof metrics} */ (name)]
|
|
105
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return [`${name}: metric unavailable`]
|
|
106
|
+
return value > maximum ? [`${name}: ${value} > ${maximum}`] : []
|
|
107
|
+
})
|
|
108
|
+
const result = {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
kind: 'code-foundry-node-package-performance',
|
|
111
|
+
recordedAt: new Date().toISOString(),
|
|
112
|
+
package: typeof packageJson.name === 'string' ? packageJson.name : null,
|
|
113
|
+
importTarget: String(policy.importTarget ?? packageEntry(packageJson)),
|
|
114
|
+
controlImport: policy.controlImport ?? null,
|
|
115
|
+
samples,
|
|
116
|
+
metrics,
|
|
117
|
+
budgets,
|
|
118
|
+
failures,
|
|
119
|
+
passed: failures.length === 0,
|
|
120
|
+
}
|
|
121
|
+
const output = resolve(root, resultsDirectory, 'node-package.json')
|
|
122
|
+
mkdirSync(dirname(output), { recursive: true })
|
|
123
|
+
writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`)
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {string} root @param {string} specifier @param {number} samples */
|
|
128
|
+
function measureImports(root, specifier, samples) {
|
|
129
|
+
return Array.from({ length: samples }, () => {
|
|
130
|
+
const source = [
|
|
131
|
+
'const before = process.memoryUsage().rss',
|
|
132
|
+
'const started = performance.now()',
|
|
133
|
+
`await import(${JSON.stringify(specifier)})`,
|
|
134
|
+
'const durationMs = performance.now() - started',
|
|
135
|
+
'const rssDeltaBytes = Math.max(0, process.memoryUsage().rss - before)',
|
|
136
|
+
'process.stdout.write(JSON.stringify({ durationMs, rssDeltaBytes }))',
|
|
137
|
+
].join(';')
|
|
138
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
|
139
|
+
cwd: root,
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
env: process.env,
|
|
142
|
+
})
|
|
143
|
+
if (result.error) throw result.error
|
|
144
|
+
if (result.status !== 0)
|
|
145
|
+
throw new Error(`cold import failed for ${specifier}: ${result.stderr.trim()}`)
|
|
146
|
+
const value = JSON.parse(result.stdout)
|
|
147
|
+
if (
|
|
148
|
+
typeof value.durationMs !== 'number' ||
|
|
149
|
+
!Number.isFinite(value.durationMs) ||
|
|
150
|
+
typeof value.rssDeltaBytes !== 'number' ||
|
|
151
|
+
!Number.isFinite(value.rssDeltaBytes)
|
|
152
|
+
)
|
|
153
|
+
throw new Error(`cold import returned invalid metrics for ${specifier}.`)
|
|
154
|
+
return value
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @param {Record<string, any>} packageJson */
|
|
159
|
+
function packageEntry(packageJson) {
|
|
160
|
+
const rootExport = packageJson.exports?.['.'] ?? packageJson.exports
|
|
161
|
+
if (typeof rootExport === 'string') return rootExport
|
|
162
|
+
if (rootExport && typeof rootExport === 'object') {
|
|
163
|
+
for (const key of ['import', 'default', 'node', 'require']) {
|
|
164
|
+
if (typeof rootExport[key] === 'string') return rootExport[key]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const key of ['module', 'main']) {
|
|
168
|
+
if (typeof packageJson[key] === 'string') return packageJson[key]
|
|
169
|
+
}
|
|
170
|
+
throw new Error('Unable to infer a package import target; set importTarget in the budget file.')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {string} root */
|
|
174
|
+
function npmProductionDependencyCount(root) {
|
|
175
|
+
const result = spawnSync('npm', ['ls', '--omit=dev', '--all', '--parseable', '--silent'], {
|
|
176
|
+
cwd: root,
|
|
177
|
+
encoding: 'utf8',
|
|
178
|
+
env: process.env,
|
|
179
|
+
})
|
|
180
|
+
if (result.error || result.status !== 0) return null
|
|
181
|
+
return Math.max(
|
|
182
|
+
0,
|
|
183
|
+
result.stdout
|
|
184
|
+
.split(/\r?\n/)
|
|
185
|
+
.map((line) => line.trim())
|
|
186
|
+
.filter(Boolean).length - 1
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @param {string} root @param {string[]} args @param {string} label */
|
|
191
|
+
function npmJson(root, args, label) {
|
|
192
|
+
const result = spawnSync('npm', args, { cwd: root, encoding: 'utf8', env: process.env })
|
|
193
|
+
if (result.error) throw result.error
|
|
194
|
+
if (result.status !== 0) throw new Error(`${label} failed: ${result.stderr.trim()}`)
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(result.stdout)
|
|
197
|
+
} catch {
|
|
198
|
+
throw new Error(`${label} did not return valid JSON.`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} file @param {string} label */
|
|
203
|
+
function readJson(file, label) {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${label} must contain valid JSON: ${error instanceof Error ? error.message : error}`,
|
|
209
|
+
{ cause: error }
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param {Record<string, any>} value @param {string} field */
|
|
215
|
+
function numberField(value, field) {
|
|
216
|
+
if (typeof value[field] !== 'number' || !Number.isFinite(value[field]))
|
|
217
|
+
throw new Error(`npm pack result is missing numeric ${field}.`)
|
|
218
|
+
return value[field]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {number} value */
|
|
222
|
+
function round(value) {
|
|
223
|
+
return Math.round(value * 1000) / 1000
|
|
224
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
|
|
4
|
-
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { resolve } from 'node:path'
|
|
6
6
|
import { spawnSync } from 'node:child_process'
|
|
7
7
|
import { detectPackageManager, resolveProfile } from './lib/profile.mjs'
|
|
@@ -9,6 +9,7 @@ import { configured, readConfig } from './lib/config.mjs'
|
|
|
9
9
|
import { classifyTestFiles } from './lib/test-discovery.mjs'
|
|
10
10
|
import { classifyValidationMode, evaluateValidationGate } from './lib/validation-policy.mjs'
|
|
11
11
|
import { readReleaseConfig, validateGeneratedReleaseDiff } from './lib/release-policy.mjs'
|
|
12
|
+
import { runNodePackagePerformance } from './lib/node-package-performance.mjs'
|
|
12
13
|
|
|
13
14
|
const root = process.cwd()
|
|
14
15
|
const config = readConfig(resolve(root, '.github/code-foundry.yml'))
|
|
@@ -56,23 +57,118 @@ function performanceEnabled() {
|
|
|
56
57
|
return configured(config.performance, 'auto') !== 'false'
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function
|
|
60
|
+
function performanceCommands() {
|
|
60
61
|
const raw = configured(config.performance_command, '').trim()
|
|
61
|
-
if (!raw) return
|
|
62
|
+
if (!raw) return []
|
|
62
63
|
let command
|
|
63
64
|
try {
|
|
64
65
|
command = JSON.parse(raw)
|
|
65
66
|
} catch {
|
|
66
|
-
throw new Error('performance_command must be a JSON array of
|
|
67
|
+
throw new Error('performance_command must be a JSON argv array or an array of argv arrays.')
|
|
67
68
|
}
|
|
69
|
+
if (!Array.isArray(command) || command.length === 0)
|
|
70
|
+
throw new Error('performance_command must be a non-empty JSON array.')
|
|
71
|
+
const commands = command.every((argument) => typeof argument === 'string') ? [command] : command
|
|
68
72
|
if (
|
|
69
|
-
!
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
!commands.every(
|
|
74
|
+
(argv) =>
|
|
75
|
+
Array.isArray(argv) &&
|
|
76
|
+
argv.length > 0 &&
|
|
77
|
+
argv.every((argument) => typeof argument === 'string' && argument.length > 0)
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
throw new Error(
|
|
81
|
+
'performance_command must contain one argv array or only non-empty argv arrays of non-empty strings.'
|
|
82
|
+
)
|
|
83
|
+
return /** @type {string[][]} */ (commands)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function performanceProfile() {
|
|
87
|
+
const profile = configured(config.performance_profile, '').trim()
|
|
88
|
+
if (!profile) return ''
|
|
89
|
+
if (profile !== 'node-package')
|
|
90
|
+
throw new Error(`Unsupported performance_profile: ${profile}. Expected node-package.`)
|
|
91
|
+
return profile
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const performanceResultsDirectory = 'performance-results'
|
|
95
|
+
|
|
96
|
+
/** @returns {{source: string, argv: string[]}[]} */
|
|
97
|
+
function selectedPerformanceCommands() {
|
|
98
|
+
const name = ['performance:check', 'perf:check'].find((candidate) => hasScript(candidate))
|
|
99
|
+
if (name) {
|
|
100
|
+
const [manager, args] = packageCommand(['run', name])
|
|
101
|
+
return manager ? [{ source: `package-script:${name}`, argv: [manager, ...args] }] : []
|
|
102
|
+
}
|
|
103
|
+
return performanceCommands().map((argv) => ({ source: 'configuration', argv }))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {string} startedAt @param {'passed'|'failed'} status @param {{source: string, argv: string[], status: number}[]} commands @param {string[]} artifacts @param {string|null} error */
|
|
107
|
+
function writePerformanceSummary(startedAt, status, commands, artifacts, error = null) {
|
|
108
|
+
const directory = resolve(root, performanceResultsDirectory)
|
|
109
|
+
mkdirSync(directory, { recursive: true })
|
|
110
|
+
writeFileSync(
|
|
111
|
+
resolve(directory, 'summary.json'),
|
|
112
|
+
`${JSON.stringify(
|
|
113
|
+
{
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
kind: 'code-foundry-performance-summary',
|
|
116
|
+
status,
|
|
117
|
+
startedAt,
|
|
118
|
+
completedAt: new Date().toISOString(),
|
|
119
|
+
commands,
|
|
120
|
+
profile: performanceProfile() || null,
|
|
121
|
+
artifacts,
|
|
122
|
+
error,
|
|
123
|
+
},
|
|
124
|
+
null,
|
|
125
|
+
2
|
|
126
|
+
)}\n`
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runPerformance() {
|
|
131
|
+
if (!performanceEnabled()) return
|
|
132
|
+
const startedAt = new Date().toISOString()
|
|
133
|
+
/** @type {{source: string, argv: string[], status: number}[]} */
|
|
134
|
+
const records = []
|
|
135
|
+
/** @type {string[]} */
|
|
136
|
+
const artifacts = []
|
|
137
|
+
try {
|
|
138
|
+
for (const command of selectedPerformanceCommands()) {
|
|
139
|
+
const result = spawnSync(command.argv[0], command.argv.slice(1), {
|
|
140
|
+
cwd: root,
|
|
141
|
+
stdio: 'inherit',
|
|
142
|
+
env: process.env,
|
|
143
|
+
})
|
|
144
|
+
if (result.error) throw result.error
|
|
145
|
+
const status = result.status ?? 1
|
|
146
|
+
records.push({ ...command, status })
|
|
147
|
+
if (status !== 0) {
|
|
148
|
+
writePerformanceSummary(startedAt, 'failed', records, artifacts, `command exited ${status}`)
|
|
149
|
+
process.exitCode = status
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (performanceProfile() === 'node-package') {
|
|
154
|
+
const result = runNodePackagePerformance(
|
|
155
|
+
root,
|
|
156
|
+
configured(config.performance_budget_file, 'performance-package-budgets.json'),
|
|
157
|
+
performanceResultsDirectory
|
|
158
|
+
)
|
|
159
|
+
artifacts.push(`${performanceResultsDirectory}/node-package.json`)
|
|
160
|
+
if (!result.passed) {
|
|
161
|
+
writePerformanceSummary(startedAt, 'failed', records, artifacts, result.failures.join('; '))
|
|
162
|
+
process.exitCode = 1
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
writePerformanceSummary(startedAt, 'passed', records, artifacts)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
169
|
+
writePerformanceSummary(startedAt, 'failed', records, artifacts, message)
|
|
170
|
+
throw error
|
|
74
171
|
}
|
|
75
|
-
return command
|
|
76
172
|
}
|
|
77
173
|
|
|
78
174
|
/** @param {string} task */
|
|
@@ -299,7 +395,9 @@ function relevant(task) {
|
|
|
299
395
|
if (task === 'performance') {
|
|
300
396
|
if (!performanceEnabled()) return false
|
|
301
397
|
return Boolean(
|
|
302
|
-
(scripted && scripted.some((candidate) => hasScript(candidate))) ||
|
|
398
|
+
(scripted && scripted.some((candidate) => hasScript(candidate))) ||
|
|
399
|
+
performanceCommands().length > 0 ||
|
|
400
|
+
performanceProfile()
|
|
303
401
|
)
|
|
304
402
|
}
|
|
305
403
|
if (scripted && scripted.some((candidate) => hasScript(candidate)))
|
|
@@ -465,11 +563,7 @@ function ci(task) {
|
|
|
465
563
|
return
|
|
466
564
|
}
|
|
467
565
|
if (task === 'performance') {
|
|
468
|
-
|
|
469
|
-
if (runScript(['performance:check', 'perf:check'])) return
|
|
470
|
-
const command = performanceCommand()
|
|
471
|
-
if (command) run(command[0], command.slice(1))
|
|
472
|
-
return
|
|
566
|
+
return runPerformance()
|
|
473
567
|
}
|
|
474
568
|
/** @type {Record<string, string[]>} */
|
|
475
569
|
const scriptsByTask = {
|
package/src/templates/gitignore
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Code Foundry Audit
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '31 6 * * 1'
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: code-foundry-audit-${{ github.event_name }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
# Default-branch-capable events use an immutable released runtime for both
|
|
16
|
+
# reusable workflow code and runtime code. They never accept a caller-
|
|
17
|
+
# controlled runtime ref and cannot execute pull-request code with access to
|
|
18
|
+
# the protected default-branch cache.
|
|
19
|
+
validation:
|
|
20
|
+
name: Validation
|
|
21
|
+
if: vars.CI_BILLING_PAUSED != 'true'
|
|
22
|
+
permissions:
|
|
23
|
+
actions: read
|
|
24
|
+
contents: read
|
|
25
|
+
packages: read
|
|
26
|
+
security-events: write
|
|
27
|
+
uses: ./.github/workflows/validation.yml
|
|
28
|
+
with:
|
|
29
|
+
mode: audit
|
|
30
|
+
runtime-repository: 0xPlayerOne/code-foundry
|
|
31
|
+
runtime-ref: main
|
|
32
|
+
ci-runner: ubuntu-latest
|
|
33
|
+
test-runner: ubuntu-latest
|
|
34
|
+
unit-runner: ubuntu-slim
|
|
35
|
+
performance-runner: ubuntu-latest
|
|
36
|
+
security-runner: ubuntu-slim
|
|
37
|
+
codeql-runner: ubuntu-latest
|
|
38
|
+
rust-shards: '["all"]'
|
|
39
|
+
rust-threads: '1'
|
|
40
|
+
rust-max-parallel: 1
|
|
41
|
+
secrets:
|
|
42
|
+
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
43
|
+
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|