code-foundry 1.25.3 → 1.28.2

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.
@@ -36,6 +36,11 @@ on:
36
36
  required: false
37
37
  type: string
38
38
  default: '.'
39
+ turbo-filter:
40
+ description: 'Optional Turbo package name override; auto detects it from working-directory by default.'
41
+ required: false
42
+ type: string
43
+ default: auto
39
44
  build-script:
40
45
  required: false
41
46
  type: string
@@ -115,16 +120,143 @@ concurrency:
115
120
  cancel-in-progress: false
116
121
 
117
122
  jobs:
118
- candidate:
123
+ affected:
124
+ name: Check Turbo delivery impact
119
125
  if: >-
126
+ inputs['turbo-filter'] != '' &&
127
+ (github.event_name == 'pull_request' || github.event_name == 'push') &&
120
128
  vars.CI_BILLING_PAUSED != 'true' &&
121
129
  (inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
130
+ (github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
122
131
  github.event_name != 'pull_request_target' &&
123
132
  (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
124
133
  runs-on: ubuntu-latest
134
+ timeout-minutes: 10
135
+ permissions:
136
+ contents: read
137
+ outputs:
138
+ should_deploy: ${{ steps.check.outputs.should_deploy || steps.resolve.outputs.should_deploy }}
139
+ steps:
140
+ - name: Checkout full history
141
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
142
+ with:
143
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
144
+ fetch-depth: 0
145
+ persist-credentials: false
146
+ - name: Resolve Turbo package
147
+ id: resolve
148
+ working-directory: ${{ inputs.install-working-directory }}
149
+ env:
150
+ TURBO_FILTER: ${{ inputs.turbo-filter }}
151
+ WORKSPACE: ${{ github.workspace }}
152
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
153
+ run: |
154
+ node --input-type=module <<'NODE'
155
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
156
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
157
+ const output = (name, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`)
158
+ const conservative = (reason) => {
159
+ console.log(`${reason}; deploying conservatively.`)
160
+ output('turbo_enabled', 'false')
161
+ output('should_deploy', 'true')
162
+ }
163
+ const filter = process.env.TURBO_FILTER
164
+ if (!filter || /[\r\n\0]/.test(filter)) throw new Error('Invalid turbo-filter')
165
+ const workspace = process.env.WORKSPACE
166
+ const appDirectory = resolve(workspace, process.env.WORKING_DIRECTORY)
167
+ const workspaceRelative = relative(workspace, appDirectory)
168
+ const outsideWorkspace =
169
+ isAbsolute(workspaceRelative) ||
170
+ workspaceRelative === '..' ||
171
+ workspaceRelative.startsWith(`..${sep}`)
172
+ if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
173
+ conservative('Turbo config not found')
174
+ } else if (outsideWorkspace) {
175
+ conservative('working-directory is outside the workspace')
176
+ } else if (filter !== 'auto') {
177
+ output('turbo_package', filter)
178
+ output('turbo_enabled', 'true')
179
+ } else {
180
+ const manifest = resolve(appDirectory, 'package.json')
181
+ let packageName = ''
182
+ try {
183
+ packageName = JSON.parse(readFileSync(manifest, 'utf8')).name ?? ''
184
+ } catch {
185
+ packageName = ''
186
+ }
187
+ if (typeof packageName !== 'string' || !packageName || /[\s\r\n\0]/.test(packageName)) {
188
+ conservative('Application package name could not be resolved')
189
+ } else {
190
+ console.log(`Resolved Turbo package: ${packageName}`)
191
+ output('turbo_package', packageName)
192
+ output('turbo_enabled', 'true')
193
+ }
194
+ }
195
+ NODE
196
+ - name: Set up Bun
197
+ if: steps.resolve.outputs.turbo_enabled == 'true'
198
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
199
+ with:
200
+ bun-version: ${{ inputs.bun-version }}
201
+ - name: Install dependencies for Turbo
202
+ if: steps.resolve.outputs.turbo_enabled == 'true'
203
+ working-directory: ${{ inputs.install-working-directory }}
204
+ run: bun install --frozen-lockfile --ignore-scripts
205
+ - name: Check affected build task
206
+ if: steps.resolve.outputs.turbo_enabled == 'true'
207
+ id: check
208
+ working-directory: ${{ inputs.install-working-directory }}
209
+ env:
210
+ TURBO_PACKAGE: ${{ steps.resolve.outputs.turbo_package }}
211
+ TURBO_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
212
+ TURBO_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
213
+ run: |
214
+ set -euo pipefail
215
+ [[ "$TURBO_PACKAGE" != *$'\n'* && "$TURBO_PACKAGE" != *$'\r'* && "$TURBO_PACKAGE" != *$'\0'* ]]
216
+ [[ "$TURBO_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]
217
+ if [[ ! "$TURBO_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ || "$TURBO_BASE_SHA" =~ ^0+$ ]]; then
218
+ echo 'No comparable base commit; deploying conservatively.'
219
+ echo 'should_deploy=true' >> "$GITHUB_OUTPUT"
220
+ exit 0
221
+ fi
222
+ bunx --bun --no-install turbo query affected \
223
+ --packages \
224
+ --base="$TURBO_BASE_SHA" \
225
+ --head="$TURBO_HEAD_SHA" \
226
+ --no-update-notifier > "$RUNNER_TEMP/turbo-affected.json"
227
+ node --input-type=module <<'NODE'
228
+ import { appendFileSync, readFileSync } from 'node:fs'
229
+ const result = JSON.parse(readFileSync(process.env.RUNNER_TEMP + '/turbo-affected.json', 'utf8'))
230
+ const items = result?.data?.affectedPackages?.items ?? result?.data?.affectedTasks?.items
231
+ if (!Array.isArray(items)) throw new Error('Turbo affected query returned no package/task items')
232
+ const packageName = process.env.TURBO_PACKAGE
233
+ const affected = items.some((item) => item?.name === packageName || item?.package?.name === packageName || item?.fullName?.startsWith(`${packageName}#`))
234
+ console.log(`${packageName}: ${affected ? 'affected' : 'unaffected'}`)
235
+ appendFileSync(process.env.GITHUB_OUTPUT, `should_deploy=${affected}\n`)
236
+ NODE
237
+
238
+ candidate:
239
+ needs: affected
240
+ if: >-
241
+ always() &&
242
+ vars.CI_BILLING_PAUSED != 'true' &&
243
+ (inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
244
+ (github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
245
+ github.event_name != 'pull_request_target' &&
246
+ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
247
+ (
248
+ inputs['turbo-filter'] == '' ||
249
+ needs.affected.result == 'skipped' ||
250
+ needs.affected.outputs.should_deploy == 'true'
251
+ )
252
+ runs-on: ubuntu-latest
125
253
  timeout-minutes: 30
254
+ # The explicit deployment record below is the sole GitHub deployment record.
255
+ # Keep the environment for approvals, secrets, and branch protection without
256
+ # creating a second, competing PR timeline entry.
126
257
  environment:
127
258
  name: Preview
259
+ deployment: false
128
260
  url: ${{ steps.upload.outputs.url }}
129
261
  defaults:
130
262
  run:
@@ -251,6 +383,7 @@ jobs:
251
383
  timeout-minutes: 30
252
384
  environment:
253
385
  name: Production
386
+ deployment: false
254
387
  url: ${{ inputs.production-url }}
255
388
  defaults:
256
389
  run:
@@ -21,6 +21,11 @@ on:
21
21
  required: false
22
22
  type: string
23
23
  default: '.'
24
+ turbo-filter:
25
+ description: 'Optional Turbo package name override; auto detects it from working-directory by default.'
26
+ required: false
27
+ type: string
28
+ default: auto
24
29
  build-script:
25
30
  required: false
26
31
  type: string
@@ -69,17 +74,144 @@ concurrency:
69
74
  cancel-in-progress: false
70
75
 
71
76
  jobs:
77
+ affected:
78
+ name: Check Turbo deployment impact
79
+ if: >-
80
+ inputs['turbo-filter'] != '' &&
81
+ (github.event_name == 'pull_request' || github.event_name == 'push') &&
82
+ vars.CI_BILLING_PAUSED != 'true' &&
83
+ (inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
84
+ (github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
85
+ github.event_name != 'pull_request_target' &&
86
+ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
87
+ runs-on: ${{ inputs.runner }}
88
+ timeout-minutes: 10
89
+ permissions:
90
+ contents: read
91
+ outputs:
92
+ should_deploy: ${{ steps.check.outputs.should_deploy || steps.resolve.outputs.should_deploy }}
93
+ steps:
94
+ - name: Checkout full history
95
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
96
+ with:
97
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
98
+ fetch-depth: 0
99
+ persist-credentials: false
100
+ - name: Resolve Turbo package
101
+ id: resolve
102
+ working-directory: ${{ inputs.install-working-directory }}
103
+ env:
104
+ TURBO_FILTER: ${{ inputs.turbo-filter }}
105
+ WORKSPACE: ${{ github.workspace }}
106
+ WORKING_DIRECTORY: ${{ inputs.working-directory }}
107
+ run: |
108
+ node --input-type=module <<'NODE'
109
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
110
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
111
+ const output = (name, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`)
112
+ const conservative = (reason) => {
113
+ console.log(`${reason}; deploying conservatively.`)
114
+ output('turbo_enabled', 'false')
115
+ output('should_deploy', 'true')
116
+ }
117
+ const filter = process.env.TURBO_FILTER
118
+ if (!filter || /[\r\n\0]/.test(filter)) throw new Error('Invalid turbo-filter')
119
+ const workspace = process.env.WORKSPACE
120
+ const appDirectory = resolve(workspace, process.env.WORKING_DIRECTORY)
121
+ const workspaceRelative = relative(workspace, appDirectory)
122
+ const outsideWorkspace =
123
+ isAbsolute(workspaceRelative) ||
124
+ workspaceRelative === '..' ||
125
+ workspaceRelative.startsWith(`..${sep}`)
126
+ if (!existsSync('turbo.json') && !existsSync('turbo.jsonc')) {
127
+ conservative('Turbo config not found')
128
+ } else if (outsideWorkspace) {
129
+ conservative('working-directory is outside the workspace')
130
+ } else if (filter !== 'auto') {
131
+ output('turbo_package', filter)
132
+ output('turbo_enabled', 'true')
133
+ } else {
134
+ const manifest = resolve(appDirectory, 'package.json')
135
+ let packageName = ''
136
+ try {
137
+ packageName = JSON.parse(readFileSync(manifest, 'utf8')).name ?? ''
138
+ } catch {
139
+ packageName = ''
140
+ }
141
+ if (typeof packageName !== 'string' || !packageName || /[\s\r\n\0]/.test(packageName)) {
142
+ conservative('Application package name could not be resolved')
143
+ } else {
144
+ console.log(`Resolved Turbo package: ${packageName}`)
145
+ output('turbo_package', packageName)
146
+ output('turbo_enabled', 'true')
147
+ }
148
+ }
149
+ NODE
150
+ - name: Set up Bun
151
+ if: steps.resolve.outputs.turbo_enabled == 'true'
152
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
153
+ with:
154
+ bun-version: ${{ inputs.bun-version }}
155
+ - name: Install dependencies for Turbo
156
+ if: steps.resolve.outputs.turbo_enabled == 'true'
157
+ working-directory: ${{ inputs.install-working-directory }}
158
+ run: bun install --frozen-lockfile --ignore-scripts
159
+ - name: Check affected build task
160
+ if: steps.resolve.outputs.turbo_enabled == 'true'
161
+ id: check
162
+ working-directory: ${{ inputs.install-working-directory }}
163
+ env:
164
+ TURBO_PACKAGE: ${{ steps.resolve.outputs.turbo_package }}
165
+ TURBO_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
166
+ TURBO_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
167
+ run: |
168
+ set -euo pipefail
169
+ [[ "$TURBO_PACKAGE" != *$'\n'* && "$TURBO_PACKAGE" != *$'\r'* && "$TURBO_PACKAGE" != *$'\0'* ]]
170
+ [[ "$TURBO_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]
171
+ if [[ ! "$TURBO_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ || "$TURBO_BASE_SHA" =~ ^0+$ ]]; then
172
+ echo 'No comparable base commit; deploying conservatively.'
173
+ echo 'should_deploy=true' >> "$GITHUB_OUTPUT"
174
+ exit 0
175
+ fi
176
+ bunx --bun --no-install turbo query affected \
177
+ --packages \
178
+ --base="$TURBO_BASE_SHA" \
179
+ --head="$TURBO_HEAD_SHA" \
180
+ --no-update-notifier > "$RUNNER_TEMP/turbo-affected.json"
181
+ node --input-type=module <<'NODE'
182
+ import { appendFileSync, readFileSync } from 'node:fs'
183
+ const result = JSON.parse(readFileSync(process.env.RUNNER_TEMP + '/turbo-affected.json', 'utf8'))
184
+ const items = result?.data?.affectedPackages?.items ?? result?.data?.affectedTasks?.items
185
+ if (!Array.isArray(items)) throw new Error('Turbo affected query returned no package/task items')
186
+ const packageName = process.env.TURBO_PACKAGE
187
+ const affected = items.some((item) => item?.name === packageName || item?.package?.name === packageName || item?.fullName?.startsWith(`${packageName}#`))
188
+ console.log(`${packageName}: ${affected ? 'affected' : 'unaffected'}`)
189
+ appendFileSync(process.env.GITHUB_OUTPUT, `should_deploy=${affected}\n`)
190
+ NODE
191
+
72
192
  deploy:
73
193
  name: Cloudflare Deploy / ${{ inputs.mode }}
194
+ needs: affected
74
195
  if: >-
196
+ always() &&
75
197
  vars.CI_BILLING_PAUSED != 'true' &&
76
198
  (inputs['draft-protection'] != true || github.event_name != 'pull_request' || github.event.pull_request.draft == false) &&
199
+ (github.event_name != 'pull_request' || inputs.mode != 'preview' || !startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')) &&
77
200
  github.event_name != 'pull_request_target' &&
78
- (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
201
+ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
202
+ (
203
+ inputs['turbo-filter'] == '' ||
204
+ needs.affected.result == 'skipped' ||
205
+ needs.affected.outputs.should_deploy == 'true'
206
+ )
79
207
  runs-on: ${{ inputs.runner }}
80
208
  timeout-minutes: 20
209
+ # The explicit deployment record below is the sole GitHub deployment record.
210
+ # Keep the environment for approvals, secrets, and branch protection without
211
+ # creating a second, competing PR timeline entry.
81
212
  environment:
82
213
  name: ${{ inputs.environment || (inputs.mode == 'production' && 'Production' || 'Preview') }}
214
+ deployment: false
83
215
  url: ${{ steps.deploy.outputs.url }}
84
216
  outputs:
85
217
  url: ${{ steps.deploy.outputs.url }}
@@ -182,10 +314,17 @@ jobs:
182
314
  DEPLOYMENT_ID: ${{ steps.start.outputs.id }}
183
315
  DEPLOY_URL: ${{ steps.deploy.outputs.url }}
184
316
  JOB_STATUS: ${{ job.status }}
317
+ MODE: ${{ inputs.mode }}
185
318
  run: |
186
319
  set -euo pipefail
187
320
  state=failure
188
321
  if [ "$JOB_STATUS" = success ]; then state=success; fi
189
322
  args=(-f "state=$state" -F auto_inactive=false)
323
+ # Preview environments are persistent per application. Once a new
324
+ # preview succeeds, retire the previous successful preview record;
325
+ # failed previews must not take a working preview offline.
326
+ if [ "$MODE" = preview ] && [ "$state" = success ]; then
327
+ args+=(-F auto_inactive=true)
328
+ fi
190
329
  if [ -n "$DEPLOY_URL" ]; then args+=(-f "environment_url=$DEPLOY_URL"); fi
191
330
  gh api -X POST "repos/$GITHUB_REPOSITORY/deployments/$DEPLOYMENT_ID/statuses" "${args[@]}" --silent
@@ -76,7 +76,7 @@ jobs:
76
76
  strategy:
77
77
  fail-fast: false
78
78
  matrix:
79
- node: ['20', '22', '24']
79
+ node: ['24', '26']
80
80
  env:
81
81
  PACKAGE: ${{ needs.pack.outputs.filename }}
82
82
  SOURCE_SHA: ${{ needs.pack.outputs.source-sha }}
@@ -162,7 +162,7 @@ jobs:
162
162
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
163
163
  --name "qualification-candidate-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" \
164
164
  --dir "$RUNNER_TEMP/candidate"
165
- for node in 20 22 24; do
165
+ for node in 24 26; do
166
166
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
167
167
  --name "consumer-qualification-$GITHUB_RUN_ATTEMPT-node-$node" \
168
168
  --dir "$RUNNER_TEMP/reports/$node"
@@ -182,7 +182,7 @@ jobs:
182
182
  sourceSha: env.SOURCE_SHA, asset: env.PACKAGE,
183
183
  directory: join(env.RUNNER_TEMP, 'candidate'),
184
184
  }
185
- const result = qualifyArchive(candidate, ['20', '22', '24'].map(node => join(env.RUNNER_TEMP, 'reports', node, 'report.json')))
185
+ const result = qualifyArchive(candidate, ['24', '26'].map(node => join(env.RUNNER_TEMP, 'reports', node, 'report.json')))
186
186
  assert.equal(result.digest, `sha256:${env.CANDIDATE_SHA256}`)
187
187
  const artifact = `qualification-candidate-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`
188
188
  writeFileSync(join(env.RUNNER_TEMP, 'qualification.json'), JSON.stringify({ ...result, run_id: env.GITHUB_RUN_ID, run_attempt: env.GITHUB_RUN_ATTEMPT }, null, 2) + '\n')
@@ -33,7 +33,7 @@ jobs:
33
33
  strategy:
34
34
  fail-fast: false
35
35
  matrix:
36
- node: ['20', '22', '24']
36
+ node: ['24', '26']
37
37
  steps:
38
38
  - name: Checkout trusted qualification runtime
39
39
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -113,7 +113,7 @@ jobs:
113
113
  GH_TOKEN: ${{ github.token }}
114
114
  run: |
115
115
  set -euo pipefail
116
- for node in 20 22 24; do
116
+ for node in 24 26; do
117
117
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
118
118
  --name "qualified-$GITHUB_RUN_ATTEMPT-node-$node" \
119
119
  --dir "$RUNNER_TEMP/reports/$node"
@@ -130,9 +130,8 @@ jobs:
130
130
  node src/commands/qualified-publication.mjs publish \
131
131
  "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$GITHUB_SHA" "$RELEASE_ASSET" \
132
132
  "$RUNNER_TEMP/candidate" \
133
- "$RUNNER_TEMP/reports/20/report.json" \
134
- "$RUNNER_TEMP/reports/22/report.json" \
135
- "$RUNNER_TEMP/reports/24/report.json" > "$RUNNER_TEMP/publication.json"
133
+ "$RUNNER_TEMP/reports/24/report.json" \
134
+ "$RUNNER_TEMP/reports/26/report.json" > "$RUNNER_TEMP/publication.json"
136
135
  - name: Retain publication identity
137
136
  if: always()
138
137
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -22,7 +22,13 @@ concurrency:
22
22
  jobs:
23
23
  qualification:
24
24
  name: Qualify consumers
25
- if: github.ref == 'refs/heads/main' && (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true))
25
+ # Qualification feeds staging and publication, so it runs only when this
26
+ # push created a release or recovery found a stuck one. Feature merges
27
+ # only pay for the cheap release-please and recovery probes below; the
28
+ # content they merge was already audited on its pull request, and the
29
+ # release merge re-qualifies the exact tree it publishes.
30
+ needs: [release, recovery]
31
+ if: github.ref == 'refs/heads/main' && (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) && (needs.release.outputs.release_created == 'true' || needs.recovery.outputs.found == 'true')
26
32
  uses: ./.github/workflows/consumer-qualification.yml
27
33
  with:
28
34
  billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
@@ -65,7 +71,11 @@ jobs:
65
71
 
66
72
  release:
67
73
  name: Release
68
- needs: [qualification, preflight]
74
+ # Release Please consumes no qualification outputs; it runs first so the
75
+ # qualification below only executes when a release (or a stuck draft) needs
76
+ # it. Version pull requests keep flowing even when the candidate is broken;
77
+ # publication stays gated on qualification success in the stage job.
78
+ needs: [preflight]
69
79
  if: github.ref == 'refs/heads/main' && (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true))
70
80
  uses: ./.github/workflows/release.yml
71
81
  with:
@@ -87,8 +97,8 @@ jobs:
87
97
 
88
98
  recovery:
89
99
  name: Recover qualified draft identity
90
- needs: [qualification, release]
91
- if: (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) && needs.qualification.result == 'success' && needs.release.result == 'success'
100
+ needs: [release]
101
+ if: (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) && needs.release.result == 'success'
92
102
  runs-on: ubuntu-24.04
93
103
  timeout-minutes: 5
94
104
  permissions:
@@ -99,7 +109,9 @@ jobs:
99
109
  sha: ${{ steps.recover.outputs.sha }}
100
110
  env:
101
111
  RELEASE_CREATED: ${{ needs.release.outputs.release_created }}
102
- SOURCE_SHA: ${{ needs.qualification.outputs.source-sha }}
112
+ # The qualification source SHA is git rev-parse HEAD of the push, so
113
+ # recovery computes it directly instead of depending on qualification.
114
+ SOURCE_SHA: ${{ github.sha }}
103
115
  steps:
104
116
  - name: Checkout trusted recovery runtime
105
117
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -156,7 +168,10 @@ jobs:
156
168
  stage:
157
169
  name: Stage qualified release
158
170
  needs: [qualification, release, recovery]
159
- if: (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) && github.ref == 'refs/heads/main' && (needs.release.outputs.release_created == 'true' || needs.recovery.outputs.found == 'true')
171
+ # Qualification only runs when this push created a release or recovery
172
+ # found a stuck one; staging additionally requires its success so an
173
+ # unqualified tree can never reach publication.
174
+ if: (vars.CI_BILLING_PAUSED != 'true' || (github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true)) && github.ref == 'refs/heads/main' && needs.qualification.result == 'success' && (needs.release.outputs.release_created == 'true' || needs.recovery.outputs.found == 'true')
160
175
  runs-on: ubuntu-24.04
161
176
  timeout-minutes: 15
162
177
  permissions:
@@ -203,7 +218,7 @@ jobs:
203
218
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
204
219
  --name "$CANDIDATE_ARTIFACT" --dir "$RUNNER_TEMP/candidate"
205
220
  test "$(sha256sum "$RUNNER_TEMP/candidate/$PACKAGE" | cut -d' ' -f1)" = "$CANDIDATE_SHA256"
206
- for node in 20 22 24; do
221
+ for node in 24 26; do
207
222
  gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" \
208
223
  --name "consumer-qualification-$GITHUB_RUN_ATTEMPT-node-$node" \
209
224
  --dir "$RUNNER_TEMP/reports/$node"
@@ -216,9 +231,8 @@ jobs:
216
231
  node src/commands/qualified-publication.mjs stage \
217
232
  "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$SOURCE_SHA" "$PACKAGE" \
218
233
  "$RUNNER_TEMP/candidate" \
219
- "$RUNNER_TEMP/reports/20/report.json" \
220
- "$RUNNER_TEMP/reports/22/report.json" \
221
- "$RUNNER_TEMP/reports/24/report.json" > "$RUNNER_TEMP/staging.json"
234
+ "$RUNNER_TEMP/reports/24/report.json" \
235
+ "$RUNNER_TEMP/reports/26/report.json" > "$RUNNER_TEMP/staging.json"
222
236
  - name: Retain staging identity
223
237
  if: always()
224
238
  uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -64,7 +64,12 @@ jobs:
64
64
  validation:
65
65
  name: Validation
66
66
  needs: mode
67
- if: vars.CI_BILLING_PAUSED != 'true' && github.event_name == 'pull_request' && github.event.pull_request.draft == false
67
+ # Bot-authored pull requests (Dependabot and friends) validate only on an
68
+ # explicit ready transition: dependency pushes never allocate validation
69
+ # runners by themselves. A human pushing to a bot branch also runs, since
70
+ # the sender is no longer a bot. Release Please heads are managed by the
71
+ # release workflow and always validate.
72
+ if: vars.CI_BILLING_PAUSED != 'true' && github.event_name == 'pull_request' && github.event.pull_request.draft == false && (github.event.pull_request.user.type != 'Bot' || github.event.sender.type != 'Bot' || startsWith(github.event.pull_request.head.ref, 'release-please--branches--main'))
68
73
  # Reusable workflows can only maintain or reduce the caller job's scopes.
69
74
  # The audit tier needs security-events: write for CodeQL uploads.
70
75
  permissions:
package/AGENTS.md CHANGED
@@ -17,6 +17,15 @@ They complement `CONTRIBUTING.md`. More specific instructions in nested `AGENTS.
17
17
 
18
18
  This repository may contain TypeScript, Rust, Python, or any combination of them. Detect the active stack from the files present; do not assume every check applies.
19
19
 
20
+ ## Contributing back
21
+
22
+ Consumers are encouraged to help improve this open-source project. Open a small,
23
+ focused pull request for bug fixes, performance improvements, documentation, or
24
+ other narrowly scoped changes. For larger feature requests or architectural
25
+ changes, create an issue first so the proposal can be discussed and scoped.
26
+ Contributions should help make the tool as performant, reliable, and helpful as
27
+ possible for everyone.
28
+
20
29
  ## Read before acting
21
30
 
22
31
  Before editing:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.28.2](https://github.com/0xPlayerOne/code-foundry/compare/v1.28.1...v1.28.2) (2026-09-10)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release:** pass one qualification report per required node ([#597](https://github.com/0xPlayerOne/code-foundry/issues/597)) ([5ceb92c](https://github.com/0xPlayerOne/code-foundry/commit/5ceb92cab3afc97145ef068e6b5e33399d99b9e5))
9
+
10
+ ## [1.28.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.28.0...v1.28.1) (2026-09-10)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **cloudflare:** remove duplicate deployment records ([#595](https://github.com/0xPlayerOne/code-foundry/issues/595)) ([d9a2a4b](https://github.com/0xPlayerOne/code-foundry/commit/d9a2a4b0363bb9a9ded91d3368bdb7b9b580330f))
16
+
17
+ ## [1.28.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.27.0...v1.28.0) (2026-09-10)
18
+
19
+
20
+ ### Features
21
+
22
+ * **release:** qualify only when a release or a stuck draft needs it ([#591](https://github.com/0xPlayerOne/code-foundry/issues/591)) ([4cce045](https://github.com/0xPlayerOne/code-foundry/commit/4cce045228648e6e705ad1e89c59eaf20a72a538))
23
+
24
+ ## [1.27.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.26.0...v1.27.0) (2026-09-10)
25
+
26
+
27
+ ### Features
28
+
29
+ * **validation:** downgrade docs-only PRs to fast and gate bot pushes ([#590](https://github.com/0xPlayerOne/code-foundry/issues/590)) ([df774f5](https://github.com/0xPlayerOne/code-foundry/commit/df774f5d1e4a8a6c5f174219eb2b24ef012f6fd3))
30
+
31
+ ## [1.26.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.25.3...v1.26.0) (2026-09-10)
32
+
33
+
34
+ ### Features
35
+
36
+ * **node:** support Node 24 and 26, drop 20 and 22 ([#589](https://github.com/0xPlayerOne/code-foundry/issues/589)) ([ee3e294](https://github.com/0xPlayerOne/code-foundry/commit/ee3e2943889ffd06a8d4cd4ccb75a3f36c46c0fd))
37
+
3
38
  ## [1.25.3](https://github.com/0xPlayerOne/code-foundry/compare/v1.25.2...v1.25.3) (2026-09-10)
4
39
 
5
40
 
package/README.md CHANGED
@@ -135,7 +135,7 @@ is merged; npm publication is opt-in through `npm_publish: true` and supports
135
135
  npm trusted publishing or an `NPM_TOKEN` fallback.
136
136
 
137
137
  Code Foundry's own release caller is stricter: it qualifies the package across
138
- Node 20, 22, and 24, stages the exact qualified archive, publishes the
138
+ Node 24 and 26, stages the exact qualified archive, publishes the
139
139
  immutable GitHub Release, and publishes that archive through the verified
140
140
  publisher. See
141
141
  [Consumer qualification](docs/consumer-qualification.md) and [Qualified
@@ -37,7 +37,7 @@ registry version and its provenance link before treating the setup as complete.
37
37
  The self-hosted package follows a stricter contract than generated consumer
38
38
  callers:
39
39
 
40
- 1. Pack the candidate once and qualify that archive across Node 20, 22, and 24.
40
+ 1. Pack the candidate once and qualify that archive across Node 24 and 26.
41
41
  2. Create a draft GitHub Release and attach the exact qualified archive plus its
42
42
  qualification receipt.
43
43
  3. Publish the immutable GitHub Release after verifying the tag, source commit,
@@ -50,6 +50,11 @@ the bytes selected from the same workflow run and attempt. See [Consumer
50
50
  qualification](consumer-qualification.md) and [Qualified publication](qualified-publication.md)
51
51
  for the complete contract, retries, and recovery rules.
52
52
 
53
+ The main-push pipeline runs Release Please first, then qualifies only when the
54
+ push created a release or recovery found a stuck draft. Feature merges pay for
55
+ the cheap release-please and recovery probes; the runner-heavy matrix runs on
56
+ release merges, which re-qualify the exact tree they publish.
57
+
53
58
  ## GitHub Releases and GitHub Packages
54
59
 
55
60
  A GitHub Release is metadata attached to a Git tag. It is independent of npm and
package/docs/WORKFLOWS.md CHANGED
@@ -31,6 +31,21 @@ pull request starts it again for the current head. Audit-mode runs additionally
31
31
  execute the eval lane (`Validation / Eval`) for repositories that ship an eval
32
32
  harness; see [Evals](EVALS.md).
33
33
 
34
+ Two waste-avoidance rules keep validation minutes honest without weakening
35
+ confidence:
36
+
37
+ - **Docs-only pull requests run the fast tier.** When an audit-classified pull
38
+ request changes only markdown, `docs/`, and license roots, the mode
39
+ classifier downgrades it to fast. Any code, lockfile, workflow,
40
+ configuration, or packaging change — or any diff that cannot be computed
41
+ deterministically — keeps the audit tier. Scheduled and manual audits always
42
+ run the full tier.
43
+ - **Bot-authored pull requests validate only on ready transitions.**
44
+ Dependabot and other bot pushes never allocate validation runners; a
45
+ maintainer marks the (Guard-drafted) pull request ready — or pushes to its
46
+ branch, which changes the sender — to run validation. Release Please heads
47
+ are exempt because the release workflow merges them through its own lane.
48
+
34
49
  The separate `validation-audit.yml` caller is pinned to the configured released
35
50
  runtime and handles scheduled and manual audits:
36
51
 
@@ -37,7 +37,18 @@ jobs:
37
37
  ```
38
38
 
39
39
  `artifact-path` is relative to `working-directory`; `install-working-directory`
40
- selects the lockfile root. The selected output tree is hashed before and after
40
+ selects the lockfile root. For Turbo monorepos, filtering is automatic when
41
+ `working-directory` points to the application package and the lockfile root
42
+ contains `turbo.json` or `turbo.jsonc`. The workflow resolves the package name,
43
+ then compares pull-request base/head commits or push before/after commits with
44
+ `turbo query affected --packages`; shared-package changes therefore retain
45
+ dependent app deployments. Unaffected app calls skip the build and Cloudflare
46
+ deployment. Set `turbo-filter` only to override the detected package name; set
47
+ it to an empty string to disable filtering. Full history is fetched for the
48
+ comparison. Manual dispatches deploy conservatively because they do not provide
49
+ a reliable comparison range.
50
+
51
+ The selected output tree is hashed before and after
41
52
  upload; changes during upload fail. Include all built code/assets in that tree
42
53
  and disable duplicate custom build steps. This digest identifies the declared
43
54
  local build tree, not a Cloudflare-signed digest of every uploaded configuration
@@ -71,12 +82,16 @@ not sufficient.
71
82
  ## Approvals, ordering, and evidence
72
83
 
73
84
  Create and protect the fixed `Preview` and `Production` GitHub environments
74
- before adoption. The workflow references them at the **job** level; naming an
75
- environment does not itself configure reviewers or branch restrictions. Configure
76
- required reviewers and branch restrictions on `Production` separately. Production
77
- execution requires the current default-branch commit and
78
- rechecks freshness after approval and before final promotion. Fork PRs and
79
- `pull_request_target` execution are excluded.
85
+ before adoption. The workflow references them at the **job** level and disables
86
+ GitHub's implicit deployment record; the explicit application record is the sole
87
+ entry in deployment history. Required reviewers, wait timers, and branch
88
+ restrictions still apply. GitHub custom deployment protection rules are not
89
+ compatible with `deployment: false`; use the explicit record without that rule or
90
+ keep automatic deployment records enabled. Configure required reviewers and
91
+ branch restrictions on `Production` separately. Production execution requires the
92
+ current default-branch commit and rechecks freshness after approval and before
93
+ final promotion. Fork PRs, release-please PRs, and `pull_request_target` execution
94
+ are excluded from preview delivery.
80
95
 
81
96
  Per-repository/Worker/mode concurrency never cancels a running promotion. GitHub
82
97
  concurrency is not a FIFO queue; stale-source rejection is still necessary.
@@ -88,14 +103,15 @@ and new production workflows active.
88
103
 
89
104
  Outputs include preview URL, exact version ID, source SHA, declared build-tree
90
105
  SHA-256 digest, Cloudflare deployment ID, and GitHub application deployment ID.
91
- Explicit application deployment records receive in-progress and success/failure
92
- statuses, in addition to GitHub's environment-job records. For pull requests,
93
- the deployment record is associated with `github.event.pull_request.head.sha`,
94
- not GitHub Actions' merge commit, so GitHub can display the preview in the PR's
95
- Deployments section. Direct pushes use `github.sha`. Sanitized candidate and
96
- production JSON evidence is uploaded even on failure. Binding values, API bodies,
97
- and credentials are not copied into those reports. Forced runner termination may
98
- prevent final status steps; the GitHub job still reflects cancellation/failure.
106
+ The workflows disable GitHub's automatic environment deployment object and use
107
+ one explicit application deployment record, which receives in-progress and
108
+ success/failure statuses. For pull requests, that record is associated with
109
+ `github.event.pull_request.head.sha`, not GitHub Actions' merge commit, so GitHub
110
+ can display the preview in the PR's Deployments section. Direct pushes use
111
+ `github.sha`. Sanitized candidate and production JSON evidence is uploaded even
112
+ on failure. Binding values, API bodies, and credentials are not copied into those
113
+ reports. Forced runner termination may prevent final status steps; the GitHub job
114
+ still reflects cancellation/failure.
99
115
 
100
116
  ## Stateful resources and isolation
101
117
 
@@ -171,18 +187,24 @@ the deployment API intentionally changes version routing only.
171
187
 
172
188
  ## Legacy workflow hardening
173
189
 
174
- `cloudflare-deploy.yml` uses real job environments, non-cancelling concurrency,
175
- structured Wrangler output, exact/local Wrangler selection, reusable outputs, and
176
- in-progress/failure deployment records. Its preview deployment records use the
177
- pull request head SHA when called from a PR, so completed previews appear in that
178
- PR's Deployments section; direct pushes use the workflow SHA. Its compatibility
190
+ `cloudflare-deploy.yml` uses real job environments with automatic deployment
191
+ history disabled, non-cancelling concurrency, structured Wrangler output,
192
+ exact/local Wrangler selection, reusable outputs, and one explicit
193
+ in-progress/failure deployment record. Preview records use the pull request head
194
+ SHA when called from a PR, so completed previews appear in that PR's Deployments
195
+ section; direct pushes use `github.sha`. Preview delivery skips release-please PRs,
196
+ which are merged and closed by release automation rather than being application
197
+ previews. Its compatibility
179
198
  default remains `latest`; callers should prefer `local` or an exact version for
180
199
  reproducibility. A production URL can be supplied with `deployment-url` when API
181
- output contains only route patterns. It is still a **direct, unverified deployment**;
182
- adopt `cloudflare-delivery.yml` for candidate verification and
183
- guarded promotion. Consumers pinned to an older Code Foundry release must update
184
- their reusable workflow reference; existing deployments are not retroactively
185
- re-associated with the PR head commit.
200
+ output contains only route patterns. Turbo filtering also applies to production
201
+ pushes, which use the previous and current commit to skip unaffected
202
+ applications. It is still a **direct, unverified
203
+ deployment**; adopt `cloudflare-delivery.yml` for candidate verification and
204
+ guarded promotion.
205
+ Consumers pinned to an older Code Foundry release must update their reusable
206
+ workflow reference; existing deployments are not retroactively re-associated
207
+ with the PR head commit.
186
208
 
187
209
  ## References and testing
188
210
 
@@ -6,7 +6,7 @@ Release-candidate compatibility checks against the distributable package.
6
6
  **Scope:** Package installation, CLI initialization/synchronization, generated workflow contracts.
7
7
 
8
8
  `consumer-qualification.yml` packs the checked-out candidate once, shares that
9
- archive across Node 20, 22, and 24 using an artifact scoped to the same workflow
9
+ archive across Node 24 and 26 using an artifact scoped to the same workflow
10
10
  run **and attempt**, and installs it offline with lifecycle scripts disabled,
11
11
  and executes its public CLI from the installed package. The harness does not
12
12
  import the source checkout's initializer as a substitute for package testing.
@@ -16,11 +16,7 @@ guard, and `--force` cannot bypass source identity or qualification.
16
16
  "repository": "owner/code-foundry",
17
17
  "workflow": ".github/workflows/release_self-ci.yml",
18
18
  "branch": "main",
19
- "required_jobs": [
20
- "Qualify consumers / Node 20",
21
- "Qualify consumers / Node 22",
22
- "Qualify consumers / Node 24"
23
- ]
19
+ "required_jobs": ["Qualify consumers / Node 24", "Qualify consumers / Node 26"]
24
20
  }
25
21
  ```
26
22
 
@@ -9,7 +9,7 @@ consumer release callers keep the ordinary `release.yml` behavior.
9
9
  The self-release pipeline is intentionally staged:
10
10
 
11
11
  1. `consumer-qualification.yml` packs the candidate once and qualifies it across
12
- Node 20, 22, and 24.
12
+ Node 24 and 26.
13
13
  2. Release Please creates a draft release for the qualified source.
14
14
  3. The staging job attaches the exact archive and a digest-bound qualification
15
15
  receipt, publishes the immutable GitHub Release, and verifies its identity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.25.3",
3
+ "version": "1.28.2",
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": {
@@ -42,7 +42,7 @@
42
42
  "build": "node --check src/cli.mjs",
43
43
  "format:check": "oxfmt --check .",
44
44
  "fmt": "oxfmt .",
45
- "lint": "oxlint",
45
+ "lint": "oxlint --deny-warnings",
46
46
  "performance:check": "node scripts/performance-check.mjs",
47
47
  "test": "node --test",
48
48
  "test:unit": "node --test",
@@ -57,7 +57,7 @@
57
57
  "typescript": "^7.0.2"
58
58
  },
59
59
  "engines": {
60
- "node": ">=20"
60
+ "node": ">=24"
61
61
  },
62
62
  "packageManager": "bun@1.4.0"
63
63
  }
@@ -9,6 +9,11 @@ import { packageManagerForLockfile } from '../lib/lockfiles.mjs'
9
9
  import { doctorGithub } from '../lib/github-doctor.mjs'
10
10
  import { isGeneratedEventCaller } from './sync.mjs'
11
11
 
12
+ /** @param {string} message */
13
+ function warn(message) {
14
+ console.warn(`WARN: ${message}`)
15
+ }
16
+
12
17
  /** @param {string} root @param {{ github?: boolean }} [options] */
13
18
  export function doctor(root, options = {}) {
14
19
  const target = resolve(root)
@@ -21,8 +26,6 @@ export function doctor(root, options = {}) {
21
26
  console.error(`ERROR: ${message}`)
22
27
  errors += 1
23
28
  }
24
- /** @param {string} message */
25
- const warn = (message) => console.warn(`WARN: ${message}`)
26
29
 
27
30
  const toolchain = config.toolchain ?? 'auto'
28
31
  if (!['auto', 'native', 'mise'].includes(toolchain)) error(`unsupported toolchain: ${toolchain}`)
@@ -33,7 +33,7 @@ export function discoverRepositories(root) {
33
33
  gitWorkflow: config.git_workflow ?? 'direct',
34
34
  })
35
35
  }
36
- return result.sort((a, b) => a.path.localeCompare(b.path))
36
+ return result.toSorted((a, b) => a.path.localeCompare(b.path))
37
37
  }
38
38
 
39
39
  /** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string, exclude?: string[] }} options */
@@ -16,7 +16,7 @@ import { join, resolve } from 'node:path'
16
16
  import { pathToFileURL } from 'node:url'
17
17
  import { tmpdir } from 'node:os'
18
18
 
19
- export const REQUIRED_NODES = ['20', '22', '24']
19
+ export const REQUIRED_NODES = ['24', '26']
20
20
  export const REQUIRED_FIXTURES = [
21
21
  'npm-direct',
22
22
  'pnpm-direct',
@@ -256,7 +256,7 @@ export async function publishQualifiedArchive(candidate, reports, adapters = {})
256
256
  */
257
257
  async function waitForPublishedRelease(candidate, adapters = {}) {
258
258
  const runTolerant = adapters.runTolerant ?? defaultRunTolerant
259
- const delay = adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
259
+ const delay = adapters.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms)))
260
260
  const attempts = adapters.attempts ?? 15
261
261
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
262
262
  const result = runTolerant('gh', [
@@ -408,7 +408,7 @@ export async function stageQualifiedRelease(candidate, reports, adapters = {}) {
408
408
  break
409
409
  } catch (error) {
410
410
  if (attempt >= 10) throw error
411
- await (adapters.delay ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(5_000)
411
+ await (adapters.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms))))(5_000)
412
412
  }
413
413
  }
414
414
  ensure(
@@ -354,7 +354,7 @@ function synchronize(options) {
354
354
  const mergedManifest = {}
355
355
  for (const directory of [
356
356
  ...new Set([...Object.keys(releaseManifest), ...Object.keys(existingManifest)]),
357
- ].sort()) {
357
+ ].toSorted()) {
358
358
  mergedManifest[directory] = existingManifest[directory] ?? releaseManifest[directory]
359
359
  }
360
360
  const content = `${JSON.stringify(mergedManifest, null, 2)}\n`
@@ -652,7 +652,7 @@ function renderWorkflow(content, config, repository, ref, rustCodeql, file, self
652
652
  // self-only jobs whose outputs and environments do not exist for them.
653
653
  for (const job of ['qualification', 'preflight', 'recovery', 'stage', 'publish'])
654
654
  rendered = removeWorkflowBlock(rendered, job)
655
- rendered = rendered.replace(/^ needs: \[qualification, preflight\]\n/m, '')
655
+ rendered = rendered.replace(/^ needs: \[preflight\]\n/m, '')
656
656
  rendered = rendered.replace(/^ config-file: \.github\/release-please-foundry\.json\n/m, '')
657
657
  rendered = rendered.replace(/^ defer-publication: true\n/m, '')
658
658
  rendered = rendered.replace(
@@ -0,0 +1,39 @@
1
+ // @ts-check
2
+
3
+ import { execFileSync } from 'node:child_process'
4
+
5
+ /** Docs-only allowlist: markdown, docs/, and license roots. Code, lockfiles,
6
+ * workflows, configuration, and packaging always keep the audit tier. */
7
+ const DOCS_ONLY_PATTERNS = [/\.md$/i, /^docs\//, /^LICENSE/i]
8
+
9
+ /** @param {string} path */
10
+ export function isDocsOnlyPath(path) {
11
+ return DOCS_ONLY_PATTERNS.some((pattern) => pattern.test(path))
12
+ }
13
+
14
+ /**
15
+ * Report whether a pull request changes docs only. Fail-closed: any git
16
+ * failure, unresolvable base, or non-docs path keeps the audit tier.
17
+ * @param {string} root
18
+ * @param {string} baseRef
19
+ */
20
+ export function docsOnlyPullRequest(root, baseRef) {
21
+ try {
22
+ execFileSync('git', ['fetch', '--no-tags', '--depth=100', 'origin', baseRef], {
23
+ cwd: root,
24
+ stdio: 'pipe',
25
+ })
26
+ const output = execFileSync('git', ['diff', '--name-only', 'FETCH_HEAD...HEAD'], {
27
+ cwd: root,
28
+ encoding: 'utf8',
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ })
31
+ const paths = output
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter((line) => line.length > 0)
35
+ return paths.length > 0 && paths.every(isDocsOnlyPath)
36
+ } catch {
37
+ return false
38
+ }
39
+ }
@@ -277,16 +277,20 @@ export function verifiedPullRequest(pr, required) {
277
277
  return false
278
278
  const checks = pr.statusCheckRollup
279
279
  if (!Array.isArray(checks) || !checks.length) return false
280
- /** @param {any} check */
281
- const state = (check) =>
282
- check.status === 'COMPLETED' ? check.conclusion : (check.state ?? check.status)
283
- if (checks.some((check) => !['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(state(check))))
280
+ if (checks.some((check) => !['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(checkState(check))))
284
281
  return false
285
282
  return required.every((name) =>
286
- checks.some((check) => (check.name ?? check.context) === name && state(check) === 'SUCCESS')
283
+ checks.some(
284
+ (check) => (check.name ?? check.context) === name && checkState(check) === 'SUCCESS'
285
+ )
287
286
  )
288
287
  }
289
288
 
289
+ /** @param {any} check */
290
+ function checkState(check) {
291
+ return check.status === 'COMPLETED' ? check.conclusion : (check.state ?? check.status)
292
+ }
293
+
290
294
  /** @param {string} text */
291
295
  function scalarConfig(text) {
292
296
  return Object.fromEntries(
@@ -16,7 +16,7 @@ export function customWorkflowFiles(root, standardFiles) {
16
16
  .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'))
17
17
  .map((file) => `.github/workflows/${file}`)
18
18
  .filter((file) => !isManagedPath(standardFiles, file))
19
- .sort()
19
+ .toSorted()
20
20
  }
21
21
 
22
22
  /** @param {string} root @param {Record<string, string>} config */
@@ -61,7 +61,7 @@ export function detectReleasePackages(root) {
61
61
  })
62
62
  }
63
63
  })
64
- return packages.sort((a, b) => a.directory.localeCompare(b.directory))
64
+ return packages.toSorted((a, b) => a.directory.localeCompare(b.directory))
65
65
  }
66
66
 
67
67
  /**
@@ -295,8 +295,8 @@ export function buildReleaseRecoveryPlan(input) {
295
295
  const orphanGitHubReleases = releaseTags.filter(
296
296
  (tag) => !tagSet.has(tag) && !tagSet.has(tag.replace(/^v/, ''))
297
297
  )
298
- const latestTag = [...tags].sort(compareVersions).at(-1) ?? ''
299
- const latestPackageVersion = [...input.packageVersions].sort(compareVersions).at(-1) ?? ''
298
+ const latestTag = [...tags].toSorted(compareVersions).at(-1) ?? ''
299
+ const latestPackageVersion = [...input.packageVersions].toSorted(compareVersions).at(-1) ?? ''
300
300
  return {
301
301
  latestTag,
302
302
  latestPackageVersion,
@@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process'
7
7
  import { detectPackageManager, resolveProfile } from './lib/profile.mjs'
8
8
  import { configured, readConfig } from './lib/config.mjs'
9
9
  import { classifyTestFiles } from './lib/test-discovery.mjs'
10
+ import { docsOnlyPullRequest } from './lib/docs-only.mjs'
10
11
  import { classifyValidationMode, evaluateValidationGate } from './lib/validation-policy.mjs'
11
12
  import { readReleaseConfig, validateGeneratedReleaseDiff } from './lib/release-policy.mjs'
12
13
  import { runNodePackagePerformance } from './lib/node-package-performance.mjs'
@@ -237,7 +238,8 @@ function runEval() {
237
238
  report = JSON.parse(readFileSync(reportFile, 'utf8'))
238
239
  } catch (error) {
239
240
  throw new Error(
240
- `Eval report is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
241
+ `Eval report is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
242
+ { cause: error }
241
243
  )
242
244
  }
243
245
  const envelope = validateEvalReport(report)
@@ -315,17 +317,37 @@ function runPerformance() {
315
317
  }
316
318
  }
317
319
 
320
+ /**
321
+ * Audit-mode pull requests whose diff is docs-only run the fast tier instead.
322
+ * Scheduled and manual audits always keep the full tier; release pull requests
323
+ * are classified separately and always run their lane.
324
+ * @param {string} mode
325
+ */
326
+ function downgradeDocsOnlyPullRequest(mode) {
327
+ if (mode !== 'audit') return mode
328
+ if ((process.env.FOUNDRY_EVENT_NAME ?? '') !== 'pull_request') return mode
329
+ const baseRef = process.env.FOUNDRY_BASE_REF ?? ''
330
+ if (!baseRef) return mode
331
+ try {
332
+ return docsOnlyPullRequest(root, baseRef) ? 'fast' : mode
333
+ } catch {
334
+ return mode
335
+ }
336
+ }
337
+
318
338
  /** @param {string} task */
319
339
  function validation(task) {
320
340
  if (task === 'mode') {
321
341
  writeOutput(
322
342
  'mode',
323
- classifyValidationMode({
324
- eventName: process.env.FOUNDRY_EVENT_NAME ?? '',
325
- baseRef: process.env.FOUNDRY_BASE_REF ?? '',
326
- headRef: process.env.FOUNDRY_HEAD_REF ?? '',
327
- stagingMode: config.staging_validation_mode,
328
- })
343
+ downgradeDocsOnlyPullRequest(
344
+ classifyValidationMode({
345
+ eventName: process.env.FOUNDRY_EVENT_NAME ?? '',
346
+ baseRef: process.env.FOUNDRY_BASE_REF ?? '',
347
+ headRef: process.env.FOUNDRY_HEAD_REF ?? '',
348
+ stagingMode: config.staging_validation_mode,
349
+ })
350
+ )
329
351
  )
330
352
  return
331
353
  }
@@ -692,10 +714,11 @@ function ci(task) {
692
714
  hasRootJavascriptProject() &&
693
715
  hasOxlintSetup()
694
716
  ) {
695
- // Oxlint is the baseline's linter. Repositories that use a different
696
- // linter keep full control through their own `lint` script, which the
697
- // runScript fallback above already honors.
698
- runTool('oxlint', [])
717
+ // Oxlint is the baseline's linter. Warnings are denied so lint debt
718
+ // never accumulates silently. Repositories that use a different linter
719
+ // keep full control through their own `lint` script, which the runScript
720
+ // fallback above already honors.
721
+ runTool('oxlint', ['--deny-warnings'])
699
722
  }
700
723
  if (hasLanguage('python') && hasRootPythonProject()) runTool('ruff', ['check', '.'])
701
724
  if (hasLanguage('rust') && hasRootRustProject())