code-foundry 1.48.7 → 1.50.0

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.
@@ -164,6 +164,16 @@ function synchronize(options) {
164
164
  if (!['true', 'false'].includes(draftProtection)) {
165
165
  throw new Error(`Unsupported draft_protection: ${draftProtection}; use true or false.`)
166
166
  }
167
+ const qualificationReceipts = configured(config.release_qualification_receipts, 'false')
168
+ if (!['true', 'false'].includes(qualificationReceipts)) {
169
+ throw new Error(
170
+ `Unsupported release_qualification_receipts: ${qualificationReceipts}; use true or false.`
171
+ )
172
+ }
173
+ const npmPublishAuth = configured(config.npm_publish_auth, 'auto')
174
+ if (!['auto', 'oidc', 'token'].includes(npmPublishAuth)) {
175
+ throw new Error(`Unsupported npm_publish_auth: ${npmPublishAuth}; use auto, oidc, or token.`)
176
+ }
167
177
  // Validate the dependency-update bot policy before any sync writes so a
168
178
  // typo fails fast instead of leaving a half-switched updater behind.
169
179
  const dependencyUpdater = configured(config.dependency_updater, 'dependabot')
@@ -1094,6 +1104,7 @@ export function renderDevChannel(config) {
1094
1104
  * @param {boolean} selfRepository
1095
1105
  */
1096
1106
  function renderWorkflow(content, config, repository, ref, rustCodeql, file, selfRepository) {
1107
+ const npmPublishAuth = configured(config.npm_publish_auth, 'auto')
1097
1108
  const localPrefix = 'uses: ./.github/workflows/'
1098
1109
  const remotePrefix = `uses: ${repository}/.github/workflows/`
1099
1110
  let rendered = content.replaceAll(localPrefix, remotePrefix)
@@ -1162,7 +1173,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
1162
1173
  if (workflow === 'release' && !selfRepository) {
1163
1174
  rendered = rendered.replace(
1164
1175
  /^(\s+runtime-ref:)\s+.*$/m,
1165
- `$1 ${ref}\n git-workflow: ${isStagingRelease(config.git_workflow) ? 'staging-release' : 'direct'}`
1176
+ `$1 ${ref}\n git-workflow: ${isStagingRelease(config.git_workflow) ? 'staging-release' : 'direct'}\n npm-publish-auth: ${npmPublishAuth}`
1166
1177
  )
1167
1178
  }
1168
1179
  // Generated PR callers protect drafts by default. Consumers that intentionally
@@ -1170,6 +1181,22 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
1170
1181
  if (configured(config.draft_protection, 'true') === 'false') {
1171
1182
  rendered = rendered.replaceAll(' && github.event.pull_request.draft == false', '')
1172
1183
  }
1184
+ const integrationShards =
1185
+ config.integration_shards === undefined || config.integration_shards === ''
1186
+ ? 1
1187
+ : Number(config.integration_shards)
1188
+ if (!Number.isInteger(integrationShards) || integrationShards < 1 || integrationShards > 2) {
1189
+ throw new Error(`Unsupported integration_shards: ${config.integration_shards}; use 1 or 2.`)
1190
+ }
1191
+ if (integrationShards > 1 && configured(config.billing_lanes, 'false') === 'true') {
1192
+ throw new Error('integration_shards cannot be combined with billing_lanes.')
1193
+ }
1194
+ if (integrationShards > 1 && file === '.github/workflows/validation.yml') {
1195
+ rendered = rendered.replace(
1196
+ /^(\s+integration-total-shards:)\s+.*$/gm,
1197
+ `$1 ${integrationShards}`
1198
+ )
1199
+ }
1173
1200
  // E2E sharding fans the managed E2E job into one runner per shard; shards
1174
1201
  // stay isolated because each job owns a runner-local test database. Fail
1175
1202
  // closed on a malformed count rather than silently rendering a single shard.
@@ -1225,9 +1252,13 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
1225
1252
  )
1226
1253
  }
1227
1254
  }
1255
+ const npmSecrets =
1256
+ configured(config.npm_publish, 'false') !== 'true'
1257
+ ? ''
1258
+ : `${npmPublishAuth === 'oidc' ? '' : ' NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'} NPM_READ_TOKEN: \${{ secrets.NPM_READ_TOKEN }}\n`
1228
1259
  rendered = rendered.replace(
1229
1260
  /(\n secrets:\n CODE_FOUNDRY_TOKEN: \$\{\{ secrets\.CODE_FOUNDRY_TOKEN \}\}\n)/,
1230
- '$1 NPM_TOKEN: ${{ secrets.NPM_TOKEN }}\n'
1261
+ `$1${npmSecrets}`
1231
1262
  )
1232
1263
  }
1233
1264
  // The staging-release topology validates and scans pull requests against
@@ -1286,6 +1317,19 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
1286
1317
  if (!value) continue
1287
1318
  rendered = rendered.replace(new RegExp(`^(\\s+${input}:)\\s+.*$`, 'm'), `$1 ${value}`)
1288
1319
  }
1320
+ if (
1321
+ workflow === 'validation' &&
1322
+ configured(config.release_qualification_receipts, 'false') === 'true'
1323
+ ) {
1324
+ const qualified = rendered.replace(
1325
+ /^(\s+mode:)\s+(\$\{\{\s*needs\.mode\.outputs\.mode\s*\}\})\s*$/m,
1326
+ '$1 $2\n release-qualification-receipts: true'
1327
+ )
1328
+ if (qualified === rendered) {
1329
+ throw new Error('Validation caller cannot render release-qualification-receipts opt-in.')
1330
+ }
1331
+ rendered = qualified
1332
+ }
1289
1333
  // Every Rust CodeQL lane in the caller must carry the configured shard
1290
1334
  // list. A SARIF category is derived from the shard string, so a caller that
1291
1335
  // renders the configuration into the pull-request lane while a second lane
@@ -1525,8 +1569,8 @@ const DIRECT_DOC_REPLACEMENTS = {
1525
1569
  '7. Push to the fork and open a pull request targeting `main`.',
1526
1570
  ],
1527
1571
  [
1528
- '| Event | Expected automation |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| Draft pull request targeting `staging` | No runner-heavy validation; run local checks before requesting review |\n| Ready pull request targeting `staging` | Fast validation: CI plus unit tests, ending in `Validation / Gate` |\n| Draft ordinary pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |\n| Ready ordinary pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please pull request targeting `main` | Full validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `staging` | Promotion PR workflow; canonical validation waits for the PR event |\n| Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |\n',
1529
- '| Event | Expected automation |\n| -------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| Draft pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |\n| Ready pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please pull request targeting `main` | Full validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |\n',
1572
+ '| Event | Expected automation |\n| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| Draft pull request targeting `staging` | No runner-heavy validation; run local checks before requesting review |\n| Ready pull request targeting `staging` | Fast validation: CI plus unit tests, ending in `Validation / Gate` |\n| Draft ordinary pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |\n| Ready ordinary pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please PR targeting `main` | Release diff, configured CodeQL, build/package check, and either a verified receipt or full CI/tests |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `staging` | Promotion PR workflow; canonical validation waits for the PR event |\n| Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |\n',
1573
+ '| Event | Expected automation |\n| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| Draft pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |\n| Ready pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |\n| Exact Release Please PR targeting `main` | Release diff, configured CodeQL, build/package check, and either a verified receipt or full CI/tests |\n| Scheduled or manual validation | Full audit tier |\n| Push to a working branch | Draft PR workflow |\n| Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |\n',
1530
1574
  ],
1531
1575
  [
1532
1576
  '| Change | Target | Merge method | Merge gate |\n| ---------------------------- | --------- | ----------------------------------------------- | --------------------------------------------------------- |\n| Working branch | `staging` | Squash | All applicable required checks pass |\n| `staging` → `main` promotion | `main` | Rebase (`merge_strategy`) | Current staging checks, release review, and rollout notes |\n| Release Please version PR | `main` | Rebase (`release_merge_strategy`, fails closed) | Validation gate and release policy pass |\n',
@@ -2149,6 +2193,7 @@ function createDefaultConfig(root, source, configuredWorkflow) {
2149
2193
  languages,
2150
2194
  features: 'all',
2151
2195
  codeql: 'auto',
2196
+ release_qualification_receipts: 'false',
2152
2197
  dependency_review: 'auto',
2153
2198
  package_manager: packageManager,
2154
2199
  codeql_rust_shards: '["all"]',
@@ -2176,6 +2221,7 @@ function createDefaultConfig(root, source, configuredWorkflow) {
2176
2221
  monorepo_tool: 'auto',
2177
2222
  release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
2178
2223
  npm_publish: 'false',
2224
+ npm_publish_auth: 'auto',
2179
2225
  post_release: 'false',
2180
2226
  post_release_workflow: '',
2181
2227
  post_release_mode: 'auto',
@@ -144,6 +144,7 @@ export function doctorGithub(root) {
144
144
  : undefined
145
145
  const credentialChecks = {
146
146
  npmTokenPresent: secretNames.includes('NPM_TOKEN'),
147
+ npmReadTokenPresent: secretNames.includes('NPM_READ_TOKEN'),
147
148
  turboTokenPresent: secretNames.includes('TURBO_TOKEN'),
148
149
  turboTeamPresent: Boolean(
149
150
  repositoryVariables.some(
@@ -153,10 +154,30 @@ export function doctorGithub(root) {
153
154
  opencodeApiKeyPresent: secretNames.includes('OPENCODE_API_KEY'),
154
155
  }
155
156
  details.credentials = credentialChecks
156
- if (config.npm_publish === 'true' && !credentialChecks.npmTokenPresent)
157
+ const npmPublishAuth = config.npm_publish_auth ?? 'auto'
158
+ details.credentials.npmPublishAuth = npmPublishAuth
159
+ if (!['auto', 'oidc', 'token'].includes(npmPublishAuth)) {
160
+ errors.push('npm_publish_auth must be auto, oidc, or token.')
161
+ } else if (config.npm_publish === 'true' && npmPublishAuth === 'token') {
162
+ if (!credentialChecks.npmTokenPresent)
163
+ errors.push('npm_publish_auth is token but NPM_TOKEN is not configured.')
164
+ } else if (
165
+ config.npm_publish === 'true' &&
166
+ npmPublishAuth === 'auto' &&
167
+ !credentialChecks.npmTokenPresent
168
+ ) {
157
169
  warnings.push(
158
- 'npm_publish is enabled but NPM_TOKEN is not configured; npm trusted publishing must be configured for tokenless publication.'
170
+ 'npm_publish_auth is auto and NPM_TOKEN is absent; publication uses OIDC, so npm trust must match this repository and the generated release.yml caller.'
159
171
  )
172
+ } else if (
173
+ config.npm_publish === 'true' &&
174
+ npmPublishAuth === 'oidc' &&
175
+ credentialChecks.npmTokenPresent
176
+ ) {
177
+ warnings.push(
178
+ 'npm_publish_auth is oidc; NPM_TOKEN is not used for publishing or install. Use NPM_READ_TOKEN only when private dependencies require read access.'
179
+ )
180
+ }
160
181
  if (
161
182
  ['true', 'auto'].includes(config.turbo_remote ?? 'false') &&
162
183
  (!credentialChecks.turboTokenPresent || !credentialChecks.turboTeamPresent)
@@ -17,6 +17,62 @@ const METRICS = new Set([
17
17
  'productionDependencyCount',
18
18
  ])
19
19
 
20
+ /**
21
+ * npm 12 keys pack reports by package name; older npm versions return an array.
22
+ * Count reports before validating their shape so an extra malformed entry cannot be ignored.
23
+ * @param {unknown} value
24
+ * @returns {{filename: string, size: number, unpackedSize: number, files: Array<{path: string}>, name?: string, version?: string}}
25
+ */
26
+ export function singleNpmPackReport(value) {
27
+ let reports
28
+ if (Array.isArray(value)) reports = value
29
+ else if (value && typeof value === 'object') {
30
+ const object = /** @type {Record<string, unknown>} */ (value)
31
+ reports = typeof object.filename === 'string' ? [object] : Object.values(object)
32
+ } else reports = []
33
+ if (reports.length !== 1) throw new Error('Expected one npm pack report.')
34
+ const packed = reports[0]
35
+ if (!packed || typeof packed !== 'object' || Array.isArray(packed))
36
+ throw new Error('npm pack returned no package result.')
37
+ if (typeof packed.filename !== 'string' || packed.filename.length === 0)
38
+ throw new Error('npm pack result is missing filename.')
39
+ for (const field of ['size', 'unpackedSize'])
40
+ if (typeof packed[field] !== 'number' || !Number.isFinite(packed[field]) || packed[field] < 0)
41
+ throw new Error(`npm pack result is missing non-negative numeric ${field}.`)
42
+ if (
43
+ !Array.isArray(packed.files) ||
44
+ !packed.files.every(
45
+ (/** @type {unknown} */ file) =>
46
+ file !== null &&
47
+ typeof file === 'object' &&
48
+ !Array.isArray(file) &&
49
+ typeof (/** @type {{path?: unknown}} */ (file).path) === 'string'
50
+ )
51
+ )
52
+ throw new Error('npm pack result is missing a valid files list.')
53
+ return packed
54
+ }
55
+
56
+ /**
57
+ * Verify the single npm pack report used to inspect a versioned root package.
58
+ * Supports both npm's legacy array and npm 12's package-name keyed object.
59
+ * @param {unknown} value
60
+ * @param {{name?:unknown,version?:unknown}} manifest
61
+ */
62
+ export function verifyNpmPackPackage(value, manifest) {
63
+ const packed = singleNpmPackReport(value)
64
+ if (
65
+ typeof manifest?.name !== 'string' ||
66
+ typeof manifest.version !== 'string' ||
67
+ packed.name !== manifest.name ||
68
+ packed.version !== manifest.version ||
69
+ !packed.files.some((file) => file.path === 'package.json')
70
+ ) {
71
+ throw new Error('npm pack result does not match the root package manifest.')
72
+ }
73
+ return packed
74
+ }
75
+
20
76
  /** @param {number[]} values @param {number} percent */
21
77
  export function percentile(values, percent) {
22
78
  if (values.length === 0) throw new Error('percentile requires at least one value')
@@ -59,8 +115,7 @@ export function runNodePackagePerformance(
59
115
  ? measureImports(root, String(policy.controlImport), samples)
60
116
  : null
61
117
  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.')
118
+ const packed = singleNpmPackReport(pack)
64
119
  /** @type {Array<{path?: string}>} */
65
120
  const files = Array.isArray(packed.files) ? packed.files : []
66
121
  const productionDependencyCount = npmProductionDependencyCount(root)
@@ -17,6 +17,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
17
17
  import { pathToFileURL } from 'node:url'
18
18
  import { tmpdir } from 'node:os'
19
19
  import { gzipSync } from 'node:zlib'
20
+ import { singleNpmPackReport } from './node-package-performance.mjs'
20
21
 
21
22
  /** @typedef {Record<string, any>} Profile */
22
23
  /** @param {unknown} condition @param {string} message @returns {asserts condition} */
@@ -602,24 +603,23 @@ export function checkPackage(root, profile, output) {
602
603
  // Pack outside the consumer repository so test installations cannot become package input.
603
604
  const temporary = mkdtempSync(join(tmpdir(), 'foundry-package-quality-'))
604
605
  try {
605
- const pack = JSON.parse(
606
- runQualityCommand(
607
- ['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
608
- root,
609
- output
606
+ const pack = singleNpmPackReport(
607
+ JSON.parse(
608
+ runQualityCommand(
609
+ ['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
610
+ root,
611
+ output
612
+ )
610
613
  )
611
614
  )
615
+ requireValue(/^[A-Za-z0-9._-]+\.tgz$/.test(pack.filename), 'Expected one package archive')
612
616
  requireValue(
613
- Array.isArray(pack) && pack.length === 1 && /^[A-Za-z0-9._-]+\.tgz$/.test(pack[0].filename),
614
- 'Expected one package archive'
615
- )
616
- requireValue(
617
- pack[0].files.every(
617
+ pack.files.every(
618
618
  (/** @type {{path:string}} */ file) => !/^\.code-foundry(?:\/|$)/.test(file.path)
619
619
  ),
620
620
  'Exclude .code-foundry quality evidence from the published package'
621
621
  )
622
- const archive = ownedPath(temporary, pack[0].filename)
622
+ const archive = ownedPath(temporary, pack.filename)
623
623
  const archiveSha256 = createHash('sha256').update(readFileSync(archive)).digest('hex')
624
624
  const consumer = join(temporary, 'consumer')
625
625
  mkdirSync(consumer)
@@ -82,13 +82,9 @@ export function classifyValidationMode(input) {
82
82
  const REQUIRED_JOBS_BY_MODE = {
83
83
  fast: ['ci', 'test'],
84
84
  audit: ['ci', 'test', 'security', 'codeql', 'eval'],
85
- // Release Please pull requests change version metadata only, and the gate
86
- // validates that diff against the release policy before anything publishes.
87
- // The content tree was already fully audited by the pull requests that
88
- // merged into main, and the scheduled audit lane re-covers drift. The
89
- // release tier therefore requires the fast suite plus CodeQL, whose
90
- // per-pull-request code scanning results some repository rulesets require
91
- // to keep the release from deadlocking.
85
+ // Release Please uses the full CI and unit-test fallback unless the
86
+ // orchestrator has verified an exact short-lived source receipt. CodeQL
87
+ // remains required because some rulesets require a PR analysis to merge.
92
88
  release: ['ci', 'test', 'codeql'],
93
89
  }
94
90
 
@@ -96,9 +92,13 @@ const REQUIRED_JOBS_BY_MODE = {
96
92
  * Job ids that the aggregate gate requires for a mode. Expected skips never
97
93
  * appear here: anything outside the returned list is not part of the gate.
98
94
  * @param {string} mode
95
+ * @param {{sourceQualification?:boolean}} [options]
99
96
  * @returns {string[]}
100
97
  */
101
- export function requiredValidationJobs(mode) {
98
+ export function requiredValidationJobs(mode, options = {}) {
99
+ if (mode === 'release' && options.sourceQualification === true) {
100
+ return ['ci', 'codeql', 'qualification']
101
+ }
102
102
  const jobs = REQUIRED_JOBS_BY_MODE[/** @type {'fast'|'audit'|'release'} */ (mode)]
103
103
  if (!jobs) {
104
104
  throw new Error(
@@ -114,14 +114,14 @@ export function requiredValidationJobs(mode) {
114
114
  * non-required jobs never fail the gate. Every required job must report
115
115
  * exactly `success`; failure, cancellation, an unexpected skip, a missing
116
116
  * result, or an unknown result fails the gate (fail closed).
117
- * @param {{ mode: string, results?: Record<string, string | null | undefined> }} input
117
+ * @param {{ mode: string, results?: Record<string, string | null | undefined>, sourceQualification?:boolean }} input
118
118
  * @returns {{ valid: boolean, required: string[], failures: Array<{ job: string, result: string }> }}
119
119
  */
120
120
  export function evaluateValidationGate(input) {
121
- const { mode, results = {} } = input
121
+ const { mode, results = {}, sourceQualification = false } = input
122
122
  let required
123
123
  try {
124
- required = requiredValidationJobs(mode)
124
+ required = requiredValidationJobs(mode, { sourceQualification })
125
125
  } catch (error) {
126
126
  return {
127
127
  valid: false,
@@ -380,7 +380,9 @@ function validation(task) {
380
380
  security: process.env.FOUNDRY_SECURITY,
381
381
  codeql: process.env.FOUNDRY_CODEQL,
382
382
  eval: process.env.FOUNDRY_EVAL,
383
+ qualification: process.env.FOUNDRY_QUALIFICATION,
383
384
  },
385
+ sourceQualification: process.env.FOUNDRY_SOURCE_QUALIFICATION === 'true',
384
386
  })
385
387
  if (gate.valid) {
386
388
  console.log(`Validation gate passed for ${mode} mode.`)
@@ -404,6 +406,28 @@ function validation(task) {
404
406
  { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
405
407
  )
406
408
  : undefined
409
+ if (diff && diff.status !== 0) {
410
+ // Git can relay arbitrary remote messages, URLs and credentials on stderr. Emit only
411
+ // bounded, recognised failure facts; never replay remote text as workflow annotations.
412
+ const stderr = (diff.stderr ?? '').slice(0, 8192)
413
+ const missingBlob = /could not fetch ([0-9a-f]{40,64}) from promisor remote/i.exec(stderr)
414
+ const reason = missingBlob
415
+ ? `historical blob ${missingBlob[1]} unavailable from promisor remote`
416
+ : /authentication failed|could not read (?:username|password)|terminal prompts disabled/i.test(
417
+ stderr
418
+ )
419
+ ? 'authentication unavailable'
420
+ : /could not read from remote repository/i.test(stderr)
421
+ ? 'remote repository unavailable'
422
+ : /bad object|unknown revision|invalid symmetric difference|ambiguous argument/i.test(
423
+ stderr
424
+ )
425
+ ? 'revision unavailable'
426
+ : 'Git error details omitted'
427
+ console.error(
428
+ `::error::Git content diff failed (status ${diff.status ?? 'unavailable'}): ${reason}.`
429
+ )
430
+ }
407
431
  const result = validateGeneratedReleaseDiff({
408
432
  headRef: process.env.FOUNDRY_HEAD_REF ?? '',
409
433
  headRepo: process.env.FOUNDRY_HEAD_REPO ?? '',