code-foundry 1.20.2 → 1.25.3
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/CONTRIBUTING.md +5 -2
- package/.github/code-foundry.yml +1 -0
- package/.github/release-please-foundry.json +56 -0
- package/.github/workflows/cloudflare-delivery.yml +9 -0
- package/.github/workflows/cloudflare-deploy.yml +8 -1
- package/.github/workflows/eval.yml +116 -0
- package/.github/workflows/opencode-security_self-ci.yml +1 -1
- package/.github/workflows/qualified-foundry-publish.yml +6 -7
- package/.github/workflows/release.yml +58 -10
- package/.github/workflows/release_self-ci.yml +211 -8
- package/.github/workflows/validation-no-codeql.yml +34 -6
- package/.github/workflows/validation.yml +32 -5
- package/.github/workflows/validation_audit_self-ci.yml +1 -0
- package/.github/workflows/validation_self-ci.yml +1 -0
- package/.gitignore +1 -1
- package/AGENTS.md +5 -1
- package/CHANGELOG.md +95 -0
- package/README.md +23 -17
- package/docs/CONFIGURATION.md +189 -152
- package/docs/EVALS.md +139 -0
- package/docs/EXTENSIONS.md +28 -7
- package/docs/INITIALIZATION.md +13 -9
- package/docs/PERFORMANCE.md +67 -59
- package/docs/PUBLISHING.md +39 -14
- package/docs/README.md +37 -22
- package/docs/RELEASES.md +18 -9
- package/docs/WORKFLOWS.md +23 -4
- package/docs/agent-validation.md +6 -5
- package/docs/cloudflare-delivery.md +20 -8
- package/docs/consumer-qualification.md +11 -9
- package/docs/fleet-release-eligibility.md +14 -15
- package/docs/fleet-rollouts.md +3 -3
- package/docs/merge-queues.md +21 -21
- package/docs/product-quality.md +9 -10
- package/docs/qualified-publication.md +166 -93
- package/docs/release-integrity.md +7 -6
- package/docs/required-capabilities.md +24 -15
- package/package.json +1 -1
- package/src/commands/cloudflare-delivery.mjs +6 -1
- package/src/commands/qualified-publication.mjs +103 -12
- package/src/commands/release-integrity.mjs +46 -14
- package/src/commands/sync.mjs +30 -24
- package/src/lib/eval-envelope.mjs +234 -0
- package/src/lib/merge-queue.mjs +1 -0
- package/src/lib/product-quality.mjs +220 -16
- package/src/lib/task-policy.mjs +7 -0
- package/src/lib/validation-policy.mjs +10 -6
- package/src/runtime-core.mjs +166 -8
- package/src/runtime.mjs +3 -1
- package/src/templates/gitignore +3 -1
|
@@ -46,6 +46,24 @@ export function runCommand(command, args, cwd) {
|
|
|
46
46
|
)
|
|
47
47
|
return result.stdout
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Default tolerant runner: GitHub CLI failures surface as retryable misses
|
|
51
|
+
* instead of aborting the publication flow.
|
|
52
|
+
* @param {string} command
|
|
53
|
+
* @param {string[]} args
|
|
54
|
+
* @param {string} [cwd]
|
|
55
|
+
* @returns {{status: number, stdout: string}}
|
|
56
|
+
*/
|
|
57
|
+
function defaultRunTolerant(command, args, cwd) {
|
|
58
|
+
const result = spawnSync(command, args, {
|
|
59
|
+
cwd,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
timeout: 180_000,
|
|
62
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
63
|
+
env: { ...process.env, GH_HOST: 'github.com', GH_PROMPT_DISABLED: '1' },
|
|
64
|
+
})
|
|
65
|
+
return { status: result.error ? 1 : (result.status ?? 1), stdout: result.stdout ?? '' }
|
|
66
|
+
}
|
|
49
67
|
/** @param {Candidate} candidate */
|
|
50
68
|
export function validateCandidate(candidate) {
|
|
51
69
|
ensure(
|
|
@@ -225,10 +243,50 @@ export async function publishQualifiedArchive(candidate, reports, adapters = {})
|
|
|
225
243
|
return { ...before, status: 'published' }
|
|
226
244
|
}
|
|
227
245
|
|
|
246
|
+
/** @typedef {(command:string, args:string[], cwd?:string) => {status: number, stdout: string}} RunTolerant */
|
|
247
|
+
|
|
248
|
+
/** Poll the releases-by-tag endpoint through its eventual-consistency window.
|
|
249
|
+
* Immediately after `gh release edit --draft=false` the endpoint can still
|
|
250
|
+
* return 404; a strict verification in that window aborts an otherwise
|
|
251
|
+
* healthy publication. Bounded retries keep the flow strict: it only proceeds
|
|
252
|
+
* once the endpoint serves the published release with the exact tag.
|
|
253
|
+
* @param {Candidate} candidate
|
|
254
|
+
* @param {{runTolerant?: RunTolerant, delay?: (ms:number)=>Promise<void>, attempts?: number}} [adapters]
|
|
255
|
+
* @returns {Promise<Record<string, any>>}
|
|
256
|
+
*/
|
|
257
|
+
async function waitForPublishedRelease(candidate, adapters = {}) {
|
|
258
|
+
const runTolerant = adapters.runTolerant ?? defaultRunTolerant
|
|
259
|
+
const delay = adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
260
|
+
const attempts = adapters.attempts ?? 15
|
|
261
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
262
|
+
const result = runTolerant('gh', [
|
|
263
|
+
'api',
|
|
264
|
+
'--hostname',
|
|
265
|
+
'github.com',
|
|
266
|
+
`repos/${candidate.repository}/releases/tags/${encodeURIComponent(candidate.tag)}`,
|
|
267
|
+
])
|
|
268
|
+
if (result.status === 0) {
|
|
269
|
+
/** @type {Record<string, any> | null} */
|
|
270
|
+
let release = null
|
|
271
|
+
try {
|
|
272
|
+
release = JSON.parse(result.stdout)
|
|
273
|
+
} catch {
|
|
274
|
+
release = null
|
|
275
|
+
}
|
|
276
|
+
if (release?.draft === false && release?.tag_name === candidate.tag) return release
|
|
277
|
+
}
|
|
278
|
+
if (attempt === attempts) break
|
|
279
|
+
await delay(2_000)
|
|
280
|
+
}
|
|
281
|
+
throw new Error(
|
|
282
|
+
'The published release was not retrievable via the tag endpoint within the consistency window'
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
228
286
|
/** Stage only a previously-created draft: never move tags, overwrite assets, or
|
|
229
287
|
* treat an API/permissions failure as an absent release. Authentication needs
|
|
230
288
|
* immutable-settings read and release write; no setting is mutated.
|
|
231
|
-
* @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, verify?:Verify}} [adapters]
|
|
289
|
+
* @param {Candidate} candidate @param {string[]} reports @param {{run?:Run, runTolerant?:RunTolerant, delay?:(ms:number)=>Promise<void>, attempts?:number, verify?:Verify}} [adapters]
|
|
232
290
|
*/
|
|
233
291
|
export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
|
|
234
292
|
const run = adapters.run ?? runCommand
|
|
@@ -252,15 +310,28 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
|
|
|
252
310
|
)
|
|
253
311
|
}
|
|
254
312
|
checkTag()
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
Array.isArray(
|
|
262
|
-
|
|
263
|
-
|
|
313
|
+
const listEndpoint = `${prefix}/releases?per_page=100`
|
|
314
|
+
const findDraftRelease = () => {
|
|
315
|
+
const pages = JSON.parse(
|
|
316
|
+
run('gh', ['api', '--hostname', 'github.com', '--paginate', '--slurp', listEndpoint])
|
|
317
|
+
)
|
|
318
|
+
ensure(
|
|
319
|
+
Array.isArray(pages) && pages.every((page) => Array.isArray(page)),
|
|
320
|
+
'GitHub returned an invalid release list'
|
|
321
|
+
)
|
|
322
|
+
const release = pages.flat().find((entry) => entry?.tag_name === candidate.tag)
|
|
323
|
+
ensure(
|
|
324
|
+
Number.isSafeInteger(release?.id) &&
|
|
325
|
+
release.draft === true &&
|
|
326
|
+
release.tag_name === candidate.tag &&
|
|
327
|
+
Array.isArray(release.assets),
|
|
328
|
+
'An existing draft release with the exact tag is required'
|
|
329
|
+
)
|
|
330
|
+
return release
|
|
331
|
+
}
|
|
332
|
+
// GitHub's get-by-tag endpoint does not return draft releases. Enumerate the
|
|
333
|
+
// authenticated release list instead, including all pages, before any write.
|
|
334
|
+
const release = findDraftRelease()
|
|
264
335
|
const temporary = mkdtempSync(join(tmpdir(), 'foundry-release-receipt-'))
|
|
265
336
|
try {
|
|
266
337
|
const receipt = join(temporary, 'qualification.json')
|
|
@@ -288,7 +359,7 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
|
|
|
288
359
|
candidate.repository,
|
|
289
360
|
])
|
|
290
361
|
}
|
|
291
|
-
const uploaded =
|
|
362
|
+
const uploaded = findDraftRelease()
|
|
292
363
|
ensure(
|
|
293
364
|
uploaded.id === release.id && uploaded.draft === true && uploaded.tag_name === candidate.tag,
|
|
294
365
|
'Draft changed during staging'
|
|
@@ -319,7 +390,27 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
|
|
|
319
390
|
'--repo',
|
|
320
391
|
candidate.repository,
|
|
321
392
|
])
|
|
322
|
-
|
|
393
|
+
// GitHub's release index is eventually consistent right after a draft is
|
|
394
|
+
// published; a strict verification a second later can observe a 404 and
|
|
395
|
+
// abort the publication even though the release is live. Poll until the
|
|
396
|
+
// index serves the release, then verify; the verification cycle itself
|
|
397
|
+
// retries through the same window and still fails closed in the end.
|
|
398
|
+
await waitForPublishedRelease(candidate, {
|
|
399
|
+
runTolerant: adapters.runTolerant,
|
|
400
|
+
delay: adapters.delay,
|
|
401
|
+
attempts: 10,
|
|
402
|
+
})
|
|
403
|
+
/** @type {Record<string, any>} */
|
|
404
|
+
let identity
|
|
405
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
406
|
+
try {
|
|
407
|
+
identity = await (adapters.verify ?? verifier)(candidate)
|
|
408
|
+
break
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (attempt >= 10) throw error
|
|
411
|
+
await (adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(5_000)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
323
414
|
ensure(
|
|
324
415
|
identity.status === 'passed' &&
|
|
325
416
|
identity.immutable === true &&
|
|
@@ -7,7 +7,7 @@ import { basename, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
|
7
7
|
import { spawnSync } from 'node:child_process'
|
|
8
8
|
import { pathToFileURL } from 'node:url'
|
|
9
9
|
|
|
10
|
-
/** @typedef {(argv: string[]) => {status: number|null, stdout: string}} Runner */
|
|
10
|
+
/** @typedef {(argv: string[]) => {status: number|null, stdout: string, stderr?: string}} Runner */
|
|
11
11
|
|
|
12
12
|
/** @type {Runner} */
|
|
13
13
|
export function runGh(argv) {
|
|
@@ -18,7 +18,7 @@ export function runGh(argv) {
|
|
|
18
18
|
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_HOST: 'github.com' },
|
|
19
19
|
})
|
|
20
20
|
if (result.error) throw new Error(`GitHub CLI unavailable: ${result.error.name}`)
|
|
21
|
-
return { status: result.status, stdout: result.stdout ?? '' }
|
|
21
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** @param {string} repository */
|
|
@@ -56,7 +56,7 @@ function json(run, argv) {
|
|
|
56
56
|
const result = run(argv)
|
|
57
57
|
if (result.status !== 0)
|
|
58
58
|
throw new Error(
|
|
59
|
-
`GitHub verification command failed (${result.status ?? 'signal'}); state is unverified`
|
|
59
|
+
`GitHub verification command failed (${result.status ?? 'signal'}); state is unverified: ${String(result.stderr ?? '').slice(0, 300) || 'no stderr captured'}`
|
|
60
60
|
)
|
|
61
61
|
try {
|
|
62
62
|
return JSON.parse(result.stdout)
|
|
@@ -140,6 +140,15 @@ function tagCommit(run, repository, tag) {
|
|
|
140
140
|
return object.sha
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Resolve a release tag without requiring the release itself to be published.
|
|
144
|
+
* @param {string} repository @param {string} tag @param {Runner} [run]
|
|
145
|
+
*/
|
|
146
|
+
export function resolveTagCommit(repository, tag, run = runGh) {
|
|
147
|
+
validateRepository(repository)
|
|
148
|
+
validateTag(tag)
|
|
149
|
+
return tagCommit(run, repository, tag)
|
|
150
|
+
}
|
|
151
|
+
|
|
143
152
|
/** GitHub CLI performs signature/attestation verification, not merely a metadata check.
|
|
144
153
|
* @param {{repository: string, tag: string, root?: string, assets?: string[], expectedSha?: string}} options
|
|
145
154
|
* @param {Runner} [run]
|
|
@@ -188,6 +197,17 @@ export function verifyRelease(options, run = runGh) {
|
|
|
188
197
|
}
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
/** Sync pause used by the post-publication verifier while the release index
|
|
201
|
+
* catches up with a just-published release.
|
|
202
|
+
* @param {number} ms
|
|
203
|
+
*/
|
|
204
|
+
function sleepSync(ms) {
|
|
205
|
+
if (ms <= 0) return
|
|
206
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const RELEASE_VERIFICATION_ATTEMPTS = 6
|
|
210
|
+
|
|
191
211
|
/** @param {string[]} argv @param {Runner} [run] */
|
|
192
212
|
export function integrityCommand(argv, run = runGh) {
|
|
193
213
|
const [command, ...args] = argv
|
|
@@ -219,17 +239,29 @@ export function integrityCommand(argv, run = runGh) {
|
|
|
219
239
|
throw new Error('An argument is not supported by this integrity command')
|
|
220
240
|
if (command === 'settings') return verifyImmutableSetting(values['--repo'] ?? '', run)
|
|
221
241
|
if (command === 'manifest') return assetManifest(values['--root'] ?? process.cwd(), assets)
|
|
222
|
-
if (command === 'release')
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
if (command === 'release') {
|
|
243
|
+
const options = {
|
|
244
|
+
repository: values['--repo'] ?? '',
|
|
245
|
+
tag: values['--tag'] ?? '',
|
|
246
|
+
root: values['--root'],
|
|
247
|
+
expectedSha: values['--expected-sha'],
|
|
248
|
+
assets,
|
|
249
|
+
}
|
|
250
|
+
// The lane fires the instant GitHub marks the release published, while the
|
|
251
|
+
// releases-by-tag index can still lag behind. Retry through that window;
|
|
252
|
+
// every strict check still runs on every attempt and the command fails
|
|
253
|
+
// closed once the bounded window is exhausted.
|
|
254
|
+
let lastError
|
|
255
|
+
for (let attempt = 1; attempt <= RELEASE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
|
256
|
+
try {
|
|
257
|
+
return verifyRelease(options, run)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
lastError = error
|
|
260
|
+
if (attempt < RELEASE_VERIFICATION_ATTEMPTS) sleepSync(5_000)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw lastError
|
|
264
|
+
}
|
|
233
265
|
throw new Error('Use release-integrity settings, release, or manifest')
|
|
234
266
|
}
|
|
235
267
|
|
package/src/commands/sync.mjs
CHANGED
|
@@ -132,6 +132,10 @@ function synchronize(options) {
|
|
|
132
132
|
if (!['direct', 'staging-release'].includes(workflow)) {
|
|
133
133
|
throw new Error(`Unsupported git_workflow: ${workflow}; use direct or staging-release.`)
|
|
134
134
|
}
|
|
135
|
+
const draftProtection = configured(config.draft_protection, 'true')
|
|
136
|
+
if (!['true', 'false'].includes(draftProtection)) {
|
|
137
|
+
throw new Error(`Unsupported draft_protection: ${draftProtection}; use true or false.`)
|
|
138
|
+
}
|
|
135
139
|
const obsoleteConfigKeys = [
|
|
136
140
|
'opencode_security',
|
|
137
141
|
...(workflow === 'direct' ? ['staging_validation_mode'] : []),
|
|
@@ -632,13 +636,33 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
|
|
|
632
636
|
release: config.release_runner ?? config.runner,
|
|
633
637
|
}
|
|
634
638
|
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
639
|
+
// Generated PR callers protect drafts by default. Consumers that intentionally
|
|
640
|
+
// run gates while a PR is still draft can opt out in code-foundry.yml.
|
|
641
|
+
if (configured(config.draft_protection, 'true') === 'false') {
|
|
642
|
+
rendered = rendered.replaceAll(' && github.event.pull_request.draft == false', '')
|
|
643
|
+
}
|
|
635
644
|
// Consumer release callers are generic package release workflows. The
|
|
636
645
|
// installed-consumer qualification harness is specific to Code Foundry's
|
|
637
646
|
// own package and must remain in the self workflow rather than being
|
|
638
647
|
// rendered into every consumer's release caller.
|
|
639
648
|
if (workflow === 'release' && !selfRepository) {
|
|
640
|
-
|
|
641
|
-
|
|
649
|
+
// The self caller owns qualification, immutability preflight, draft
|
|
650
|
+
// recovery, staging, and qualified publication. Consumer callers must
|
|
651
|
+
// retain the ordinary reusable Release Please path instead of inheriting
|
|
652
|
+
// self-only jobs whose outputs and environments do not exist for them.
|
|
653
|
+
for (const job of ['qualification', 'preflight', 'recovery', 'stage', 'publish'])
|
|
654
|
+
rendered = removeWorkflowBlock(rendered, job)
|
|
655
|
+
rendered = rendered.replace(/^ needs: \[qualification, preflight\]\n/m, '')
|
|
656
|
+
rendered = rendered.replace(/^ config-file: \.github\/release-please-foundry\.json\n/m, '')
|
|
657
|
+
rendered = rendered.replace(/^ defer-publication: true\n/m, '')
|
|
658
|
+
rendered = rendered.replace(
|
|
659
|
+
/^ if: github\.ref == 'refs\/heads\/main' && \(vars\.CI_BILLING_PAUSED != 'true' \|\| \(github\.event_name == 'workflow_dispatch' && inputs\['release-while-paused'\] == true\)\)\n/m,
|
|
660
|
+
" if: vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)\n"
|
|
661
|
+
)
|
|
662
|
+
rendered = rendered.replace(
|
|
663
|
+
/(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,
|
|
664
|
+
'$1 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'
|
|
665
|
+
)
|
|
642
666
|
}
|
|
643
667
|
// The staging-release topology validates and scans pull requests against
|
|
644
668
|
// both main and the integration branch; direct repositories only ever
|
|
@@ -690,6 +714,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
|
|
|
690
714
|
'codeql-runner': config.codeql_runner ?? config.runner,
|
|
691
715
|
'unit-runner': config.unit_runner,
|
|
692
716
|
'performance-runner': config.performance_runner ?? config.test_runner ?? config.runner,
|
|
717
|
+
'eval-runner': config.eval_runner ?? config.runner,
|
|
693
718
|
}
|
|
694
719
|
for (const [input, value] of Object.entries(runnerInputs)) {
|
|
695
720
|
if (!value) continue
|
|
@@ -730,26 +755,6 @@ function removeWorkflowBlock(content, blockId) {
|
|
|
730
755
|
return lines.join('\n')
|
|
731
756
|
}
|
|
732
757
|
|
|
733
|
-
/**
|
|
734
|
-
* Remove a single root-job dependency while preserving the rest of the job.
|
|
735
|
-
* @param {string} content
|
|
736
|
-
* @param {string} jobId
|
|
737
|
-
* @param {string} dependency
|
|
738
|
-
* @returns {string}
|
|
739
|
-
*/
|
|
740
|
-
function removeWorkflowNeed(content, jobId, dependency) {
|
|
741
|
-
const lines = content.split('\n')
|
|
742
|
-
const start = lines.findIndex((line) => line === ` ${jobId}:`)
|
|
743
|
-
if (start === -1) return content
|
|
744
|
-
let end = start + 1
|
|
745
|
-
while (end < lines.length && !/^ [A-Za-z0-9_-]+:\s*$/.test(lines[end])) end += 1
|
|
746
|
-
const need = lines.findIndex(
|
|
747
|
-
(line, index) => index > start && index < end && line === ` needs: ${dependency}`
|
|
748
|
-
)
|
|
749
|
-
if (need >= 0) lines.splice(need, 1)
|
|
750
|
-
return lines.join('\n')
|
|
751
|
-
}
|
|
752
|
-
|
|
753
758
|
/**
|
|
754
759
|
* Dependabot updates land on the repository's integration branch. Direct
|
|
755
760
|
* repositories have no staging branch, so every update targets main. Cargo,
|
|
@@ -897,8 +902,8 @@ const DIRECT_DOC_REPLACEMENTS = {
|
|
|
897
902
|
'| Change | Target | Merge method | Merge gate |\n| ------------------------- | ------ | --------------------------------- | --------------------------------------- |\n| Working branch | `main` | Squash | All applicable required checks pass |\n| Release Please version PR | `main` | Squash (`release_merge_strategy`) | Validation gate and release policy pass |\n',
|
|
898
903
|
],
|
|
899
904
|
[
|
|
900
|
-
'Draft pull requests do not start runner-heavy validation. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner. Converting a pull request to draft cancels in-flight validation through the lightweight cancellation control.',
|
|
901
|
-
'Draft pull requests do not start validation. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner. Converting a pull request to draft runs only the lightweight cancellation control.',
|
|
905
|
+
'Draft pull requests do not start runner-heavy validation unless `draft_protection: false` is configured for generated callers. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner while protection is enabled. Converting a pull request to draft cancels in-flight validation through the lightweight cancellation control.',
|
|
906
|
+
'Draft pull requests do not start validation unless `draft_protection: false` is configured. The lightweight Draft Guard converts ordinary pull requests opened or reopened while ready back to draft; it never checks out pull-request code and it excludes Release Please version heads, whose release workflow owns their state. Marking a pull request ready for review starts the applicable validation tier, and each new commit on a ready pull request reruns that tier for the current head. Draft updates allocate no validation runner while protection is enabled. Converting a pull request to draft runs only the lightweight cancellation control.',
|
|
902
907
|
],
|
|
903
908
|
['1. Create a focused branch from `staging`.', '1. Create a focused branch from `main`.'],
|
|
904
909
|
],
|
|
@@ -1370,6 +1375,7 @@ function createDefaultConfig(root, source, configuredWorkflow) {
|
|
|
1370
1375
|
toolchain: 'auto',
|
|
1371
1376
|
...(stagingRelease ? { staging_validation_mode: 'fast' } : {}),
|
|
1372
1377
|
performance: 'auto',
|
|
1378
|
+
draft_protection: 'true',
|
|
1373
1379
|
performance_command: '',
|
|
1374
1380
|
performance_profile: '',
|
|
1375
1381
|
performance_budget_file: 'performance-package-budgets.json',
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared eval-report contract. Every `ci eval` run validates the consumer
|
|
5
|
+
* repository's eval report against this envelope before applying budgets.
|
|
6
|
+
* Deterministic probes (Layer 1) and model-agent runs (Layer 2) emit the same
|
|
7
|
+
* envelope, so task outcomes stay comparable across executors and revisions.
|
|
8
|
+
* See docs/EVALS.md for the full field reference.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const EVAL_REPORT_FILE = 'eval-results/result.json'
|
|
12
|
+
export const EVAL_SUMMARY_FILE = 'eval-results/summary.json'
|
|
13
|
+
export const EVAL_BUDGET_FILE_DEFAULT = 'eval-budgets.json'
|
|
14
|
+
export const EVAL_REPORT_SCHEMA_VERSION = 1
|
|
15
|
+
export const EVAL_SUMMARY_KIND = 'code-foundry-eval-summary'
|
|
16
|
+
|
|
17
|
+
const FAILURE_CLASSES = ['harness', 'task']
|
|
18
|
+
const STAT_FIELDS = ['mean', 'p50', 'p95', 'max']
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value @returns {value is number} */
|
|
21
|
+
function isNonNegativeInteger(value) {
|
|
22
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {unknown} value @returns {value is number} */
|
|
26
|
+
function isFiniteNumber(value) {
|
|
27
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {unknown} value @returns {boolean} */
|
|
31
|
+
function isStats(value) {
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
33
|
+
const record = /** @type {Record<string, unknown>} */ (value)
|
|
34
|
+
if (!isNonNegativeInteger(record.count)) return false
|
|
35
|
+
if (record.count === 0) {
|
|
36
|
+
return (
|
|
37
|
+
Object.keys(record).length === 1 || STAT_FIELDS.every((field) => record[field] === undefined)
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
return STAT_FIELDS.every((field) => isFiniteNumber(record[field]))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate one eval report envelope. Returns every violation instead of
|
|
45
|
+
* throwing, so one broken report lists all of its problems at once.
|
|
46
|
+
* @param {unknown} report @returns {{valid: boolean, errors: string[]}}
|
|
47
|
+
*/
|
|
48
|
+
export function validateEvalReport(report) {
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const errors = []
|
|
51
|
+
/** @param {string} message */
|
|
52
|
+
const push = (message) => errors.push(message)
|
|
53
|
+
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
|
54
|
+
return { valid: false, errors: ['report must be a JSON object'] }
|
|
55
|
+
}
|
|
56
|
+
const value = /** @type {Record<string, unknown>} */ (report)
|
|
57
|
+
if (value.schemaVersion !== EVAL_REPORT_SCHEMA_VERSION)
|
|
58
|
+
push(`schemaVersion: expected ${EVAL_REPORT_SCHEMA_VERSION}`)
|
|
59
|
+
if (value.revision !== undefined && typeof value.revision !== 'string')
|
|
60
|
+
push('revision: must be a string when present')
|
|
61
|
+
if (value.dependencyHash !== undefined && typeof value.dependencyHash !== 'string')
|
|
62
|
+
push('dependencyHash: must be a string when present')
|
|
63
|
+
|
|
64
|
+
const summary = value.summary
|
|
65
|
+
if (!summary || typeof summary !== 'object' || Array.isArray(summary)) {
|
|
66
|
+
push('summary: must be an object')
|
|
67
|
+
return { valid: errors.length === 0, errors }
|
|
68
|
+
}
|
|
69
|
+
const counts = /** @type {Record<string, unknown>} */ (summary)
|
|
70
|
+
for (const field of [
|
|
71
|
+
'taskCount',
|
|
72
|
+
'attempts',
|
|
73
|
+
'passed',
|
|
74
|
+
'failed',
|
|
75
|
+
'harnessFailures',
|
|
76
|
+
'toolCalls',
|
|
77
|
+
'evidenceErrors',
|
|
78
|
+
]) {
|
|
79
|
+
if (!isNonNegativeInteger(counts[field]))
|
|
80
|
+
push(`summary.${field}: must be a non-negative integer`)
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
isNonNegativeInteger(counts.passed) &&
|
|
84
|
+
isNonNegativeInteger(counts.failed) &&
|
|
85
|
+
isNonNegativeInteger(counts.attempts) &&
|
|
86
|
+
counts.passed + counts.failed > counts.attempts
|
|
87
|
+
)
|
|
88
|
+
push('summary.passed/failed: must not exceed attempts')
|
|
89
|
+
if (
|
|
90
|
+
isNonNegativeInteger(counts.harnessFailures) &&
|
|
91
|
+
isNonNegativeInteger(counts.attempts) &&
|
|
92
|
+
counts.harnessFailures > counts.attempts
|
|
93
|
+
)
|
|
94
|
+
push('summary.harnessFailures: must not exceed attempts')
|
|
95
|
+
if (isFiniteNumber(counts.successRate) && (counts.successRate < 0 || counts.successRate > 1))
|
|
96
|
+
push('summary.successRate: must be between 0 and 1')
|
|
97
|
+
for (const field of ['taskDurationMs', 'startupMs', 'stepDurationMs']) {
|
|
98
|
+
if (!isStats(counts[field])) push(`summary.${field}: must be a stats object`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tasks = value.tasks
|
|
102
|
+
if (!Array.isArray(tasks)) {
|
|
103
|
+
push('tasks: must be an array')
|
|
104
|
+
return { valid: errors.length === 0, errors }
|
|
105
|
+
}
|
|
106
|
+
for (const [index, task] of tasks.entries()) {
|
|
107
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) {
|
|
108
|
+
push(`tasks[${index}]: must be an object`)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const record = /** @type {Record<string, unknown>} */ (task)
|
|
112
|
+
if (typeof record.id !== 'string' || record.id.trim().length === 0)
|
|
113
|
+
push(`tasks[${index}].id: must be a non-empty string`)
|
|
114
|
+
if (!Array.isArray(record.attempts)) {
|
|
115
|
+
push(`tasks[${index}].attempts: must be an array`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
for (const [attemptIndex, attempt] of record.attempts.entries()) {
|
|
119
|
+
const path = `tasks[${index}].attempts[${attemptIndex}]`
|
|
120
|
+
if (!attempt || typeof attempt !== 'object') {
|
|
121
|
+
push(`${path}: must be an object`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const entry = /** @type {Record<string, unknown>} */ (attempt)
|
|
125
|
+
if (entry.status !== 'passed' && entry.status !== 'failed')
|
|
126
|
+
push(`${path}.status: must be passed or failed`)
|
|
127
|
+
if (entry.durationMs !== undefined && !isFiniteNumber(entry.durationMs))
|
|
128
|
+
push(`${path}.durationMs: must be a finite number`)
|
|
129
|
+
if (entry.startupMs !== undefined && !isFiniteNumber(entry.startupMs))
|
|
130
|
+
push(`${path}.startupMs: must be a finite number`)
|
|
131
|
+
if (entry.failure) {
|
|
132
|
+
const failure = entry.failure
|
|
133
|
+
if (!failure || typeof failure !== 'object') push(`${path}.failure: must be an object`)
|
|
134
|
+
else if (
|
|
135
|
+
typeof (/** @type {Record<string, unknown>} */ (failure).message) !== 'string' ||
|
|
136
|
+
/** @type {Record<string, unknown>} */ (failure).message === ''
|
|
137
|
+
)
|
|
138
|
+
push(`${path}.failure.message: must be a non-empty string`)
|
|
139
|
+
if (!FAILURE_CLASSES.includes(/** @type {string} */ (entry.failureClass)))
|
|
140
|
+
push(`${path}.failureClass: must be harness or task when a failure is present`)
|
|
141
|
+
} else if (entry.failureClass !== undefined) {
|
|
142
|
+
if (!FAILURE_CLASSES.includes(/** @type {string} */ (entry.failureClass)))
|
|
143
|
+
push(`${path}.failureClass: must be harness or task when present`)
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(entry.steps)) {
|
|
146
|
+
for (const [stepIndex, stepEntry] of entry.steps.entries()) {
|
|
147
|
+
const stepPath = `${path}.steps[${stepIndex}]`
|
|
148
|
+
if (!stepEntry || typeof stepEntry !== 'object') {
|
|
149
|
+
push(`${stepPath}: must be an object`)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
const step = /** @type {Record<string, unknown>} */ (stepEntry)
|
|
153
|
+
if (typeof step.tool !== 'string' || step.tool.trim().length === 0)
|
|
154
|
+
push(`${stepPath}.tool: must be a non-empty string`)
|
|
155
|
+
if (!['running', 'passed', 'failed'].includes(/** @type {string} */ (step.status)))
|
|
156
|
+
push(`${stepPath}.status: must be running, passed, or failed`)
|
|
157
|
+
if (step.durationMs !== undefined && !isFiniteNumber(step.durationMs))
|
|
158
|
+
push(`${stepPath}.durationMs: must be a finite number`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { valid: errors.length === 0, errors }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Evaluate the configured budget thresholds against one validated report.
|
|
168
|
+
* Unknown budget keys fail closed so a typo can never silently disable a gate.
|
|
169
|
+
* @param {{summary?: Record<string, unknown>}} report
|
|
170
|
+
* @param {Record<string, unknown>} budgets
|
|
171
|
+
* @returns {{passed: boolean, failures: string[]}}
|
|
172
|
+
*/
|
|
173
|
+
export function evaluateEvalBudgets(report, budgets) {
|
|
174
|
+
if (!report || typeof report !== 'object' || Array.isArray(report))
|
|
175
|
+
return { passed: false, failures: ['report must be a validated eval report object'] }
|
|
176
|
+
const failures = []
|
|
177
|
+
if (!budgets || typeof budgets !== 'object' || Array.isArray(budgets)) {
|
|
178
|
+
return { passed: false, failures: ['budgets must be a JSON object'] }
|
|
179
|
+
}
|
|
180
|
+
const known = new Set([
|
|
181
|
+
'successRate',
|
|
182
|
+
'taskP95Ms',
|
|
183
|
+
'startupP95Ms',
|
|
184
|
+
'stepP95Ms',
|
|
185
|
+
'maxHarnessFailures',
|
|
186
|
+
'maxEvidenceErrors',
|
|
187
|
+
'maxToolCalls',
|
|
188
|
+
])
|
|
189
|
+
for (const key of Object.keys(budgets)) {
|
|
190
|
+
if (!known.has(key)) failures.push(`budgets.${key}: unknown budget key`)
|
|
191
|
+
}
|
|
192
|
+
if (failures.length > 0) return { passed: false, failures }
|
|
193
|
+
/** @type {Record<string, unknown>} */
|
|
194
|
+
const summary = report.summary ?? {}
|
|
195
|
+
const rate = budgets.successRate
|
|
196
|
+
if (rate !== undefined) {
|
|
197
|
+
if (!isFiniteNumber(rate) || rate < 0 || rate > 1)
|
|
198
|
+
return { passed: false, failures: ['budgets.successRate: must be between 0 and 1'] }
|
|
199
|
+
if (isFiniteNumber(summary.successRate) && summary.successRate < rate)
|
|
200
|
+
failures.push(`successRate ${summary.successRate.toFixed(2)} is below budget ${rate}`)
|
|
201
|
+
}
|
|
202
|
+
const p95Fields = /** @type {const} */ ([
|
|
203
|
+
['taskP95Ms', 'taskDurationMs'],
|
|
204
|
+
['startupP95Ms', 'startupMs'],
|
|
205
|
+
['stepP95Ms', 'stepDurationMs'],
|
|
206
|
+
])
|
|
207
|
+
for (const [budgetField, summaryField] of p95Fields) {
|
|
208
|
+
const budget = /** @type {unknown} */ (budgets[budgetField])
|
|
209
|
+
if (budget === undefined) continue
|
|
210
|
+
if (!isFiniteNumber(budget) || budget <= 0)
|
|
211
|
+
return { passed: false, failures: [`budgets.${budgetField}: must be a positive number`] }
|
|
212
|
+
const stats = summary[summaryField]
|
|
213
|
+
if (!stats || typeof stats !== 'object') continue
|
|
214
|
+
const p95 = /** @type {unknown} */ (/** @type {Record<string, unknown>} */ (stats).p95)
|
|
215
|
+
const count = /** @type {Record<string, unknown>} */ (stats).count
|
|
216
|
+
if (!isFiniteNumber(p95) || count === 0) continue
|
|
217
|
+
if (p95 > budget) failures.push(`${summaryField}.p95 ${p95}ms is above budget ${budget}ms`)
|
|
218
|
+
}
|
|
219
|
+
const maxFields = /** @type {const} */ ([
|
|
220
|
+
['maxHarnessFailures', 'harnessFailures'],
|
|
221
|
+
['maxEvidenceErrors', 'evidenceErrors'],
|
|
222
|
+
['maxToolCalls', 'toolCalls'],
|
|
223
|
+
])
|
|
224
|
+
for (const [budgetField, summaryField] of maxFields) {
|
|
225
|
+
const budget = /** @type {unknown} */ (budgets[budgetField])
|
|
226
|
+
if (budget === undefined) continue
|
|
227
|
+
if (!isNonNegativeInteger(budget))
|
|
228
|
+
return { passed: false, failures: [`budgets.${budgetField}: must be a non-negative integer`] }
|
|
229
|
+
const measured = /** @type {unknown} */ (summary[summaryField])
|
|
230
|
+
if (isNonNegativeInteger(measured) && measured > budget)
|
|
231
|
+
failures.push(`${summaryField} ${measured} is above budget ${budget}`)
|
|
232
|
+
}
|
|
233
|
+
return { passed: failures.length === 0, failures }
|
|
234
|
+
}
|
package/src/lib/merge-queue.mjs
CHANGED
|
@@ -153,6 +153,7 @@ jobs:
|
|
|
153
153
|
unit-runner: ${runner('unit_runner', 'ubuntu-slim')}
|
|
154
154
|
performance-runner: ${runner('performance_runner')}
|
|
155
155
|
security-runner: ${runner('security_runner', 'ubuntu-slim')}
|
|
156
|
+
eval-runner: ${runner('eval_runner')}
|
|
156
157
|
codeql-runner: ${runner('codeql_runner')}
|
|
157
158
|
rust-shards: '${JSON.stringify(shards)}'
|
|
158
159
|
rust-threads: '${threads}'
|