code-foundry 1.48.8 → 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.
@@ -220,14 +220,14 @@ Keep pull requests focused and reviewable. Include screenshots or recordings for
220
220
 
221
221
  ## Workflow and check behavior
222
222
 
223
- | Event | Expected automation |
224
- | -------------------------------------------------- | ------------------------------------------------------------------------------------- |
225
- | Draft pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |
226
- | Ready pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |
227
- | Exact Release Please pull request targeting `main` | Full validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |
228
- | Scheduled or manual validation | Full audit tier |
229
- | Push to a working branch | Draft PR workflow |
230
- | Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |
223
+ | Event | Expected automation |
224
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- |
225
+ | Draft pull request targeting `main` | No runner-heavy validation; run local checks before requesting review |
226
+ | Ready pull request targeting `main` | Audit validation: CI, full tests, Security, and CodeQL, ending in `Validation / Gate` |
227
+ | Exact Release Please PR targeting `main` | Release diff, configured CodeQL, build/package check, and either a verified receipt or full CI/tests |
228
+ | Scheduled or manual validation | Full audit tier |
229
+ | Push to a working branch | Draft PR workflow |
230
+ | Push to `main` | Release workflow; CodeQL skips release-only merges (audit covers drift) |
231
231
 
232
232
  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.
233
233
 
@@ -7,6 +7,16 @@
7
7
  "description": "Explicit JavaScript formatter. Omit to preserve repository scripts and the Oxfmt fallback.",
8
8
  "type": "string",
9
9
  "enum": ["oxfmt", "prettier", "bun"]
10
+ },
11
+ "release_qualification_receipts": {
12
+ "description": "Opt in to seven-day exact-tree qualification receipts for Release Please validation.",
13
+ "type": "string",
14
+ "enum": ["true", "false"]
15
+ },
16
+ "npm_publish_auth": {
17
+ "description": "npm publisher authentication: auto preserves token users and uses OIDC when no publish token is present; oidc forces trusted publishing; token requires NPM_TOKEN.",
18
+ "type": "string",
19
+ "enum": ["auto", "oidc", "token"]
10
20
  }
11
21
  },
12
22
  "additionalProperties": true
@@ -5,6 +5,7 @@ languages: typescript
5
5
  features: all
6
6
  draft_protection: true
7
7
  codeql: auto
8
+ release_qualification_receipts: false
8
9
  codeql_rust_shards: '["all"]'
9
10
  codeql_rust_threads: 1
10
11
  codeql_rust_max_parallel: 1
@@ -15,6 +16,7 @@ runtime_repository: 0xPlayerOne/code-foundry
15
16
  runtime_ref: v1.30.0
16
17
  release_type: node
17
18
  npm_publish: true
19
+ npm_publish_auth: oidc
18
20
  license: agpl-3.0-or-later
19
21
  git_workflow: direct
20
22
  merge_strategy: squash
@@ -23,6 +23,16 @@ on:
23
23
  required: false
24
24
  type: string
25
25
  default: task-result
26
+ release-build-only:
27
+ description: Run only the build lane after an exact successful source qualification receipt.
28
+ required: false
29
+ type: boolean
30
+ default: false
31
+ release-package-check:
32
+ description: Inspect the versioned npm package contents on release pull requests.
33
+ required: false
34
+ type: boolean
35
+ default: false
26
36
  secrets:
27
37
  TURBO_TOKEN:
28
38
  required: false
@@ -55,7 +65,7 @@ concurrency:
55
65
  jobs:
56
66
  format:
57
67
  name: Format
58
- if: vars.CI_BILLING_PAUSED != 'true'
68
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.release-build-only != true
59
69
  runs-on: ${{ inputs.runner }}
60
70
  timeout-minutes: 30
61
71
  steps:
@@ -124,7 +134,7 @@ jobs:
124
134
 
125
135
  lint:
126
136
  name: Lint
127
- if: vars.CI_BILLING_PAUSED != 'true'
137
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.release-build-only != true
128
138
  runs-on: ${{ inputs.runner }}
129
139
  timeout-minutes: 30
130
140
  steps:
@@ -188,7 +198,7 @@ jobs:
188
198
 
189
199
  type-check:
190
200
  name: Type-Check
191
- if: vars.CI_BILLING_PAUSED != 'true'
201
+ if: vars.CI_BILLING_PAUSED != 'true' && inputs.release-build-only != true
192
202
  permissions:
193
203
  contents: read
194
204
  issues: read
@@ -279,6 +289,7 @@ jobs:
279
289
  path: .github/.code-foundry
280
290
  sparse-checkout: |
281
291
  .github/actions
292
+ src/commands/qualification-receipt.mjs
282
293
  src/lib
283
294
  src/runtime-core.mjs
284
295
  src/runtime.mjs
@@ -287,6 +298,11 @@ jobs:
287
298
  mkdir -p .github/actions
288
299
  mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
289
300
  cp -R "$RUNNER_TEMP/code-foundry/.github/actions/." .github/actions/
301
+ - name: Set up Node.js 24.18.0 for package inspection
302
+ if: inputs.release-package-check == true
303
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
304
+ with:
305
+ node-version: 24.18.0
290
306
  - name: Detect
291
307
  id: applicability
292
308
  env:
@@ -296,8 +312,13 @@ jobs:
296
312
  HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
297
313
  BEFORE_SHA: ${{ github.event.before }}
298
314
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci should_run build >> "$GITHUB_OUTPUT"
315
+ - name: Verify versioned package build contract
316
+ if: inputs.release-package-check == true
317
+ env:
318
+ FOUNDRY_BUILD_TASK_APPLICABLE: ${{ steps.applicability.outputs.applicable }}
319
+ run: node "$RUNNER_TEMP/code-foundry/src/commands/qualification-receipt.mjs" package-contract
299
320
  - name: Setup
300
- if: steps.applicability.outputs.applicable == 'true' && steps.applicability.outputs.affected != 'false'
321
+ if: steps.applicability.outputs.applicable == 'true' && (inputs.release-build-only == true || steps.applicability.outputs.affected != 'false')
301
322
  uses: ./.github/actions/setup
302
323
  with:
303
324
  install: 'true'
@@ -305,8 +326,23 @@ jobs:
305
326
  task: build
306
327
  - name: Build
307
328
  id: execute
308
- if: steps.applicability.outputs.applicable == 'true' && steps.applicability.outputs.affected != 'false'
329
+ if: steps.applicability.outputs.applicable == 'true' && (inputs.release-build-only == true || steps.applicability.outputs.affected != 'false')
309
330
  run: node "$RUNNER_TEMP/code-foundry/src/runtime.mjs" ci build
331
+ - name: Inspect versioned npm package contents
332
+ if: inputs.release-package-check == true && hashFiles('package.json') != ''
333
+ run: |
334
+ npm pack --dry-run --ignore-scripts --json > "$RUNNER_TEMP/foundry-package-inspection.json"
335
+ node --input-type=module -e '
336
+ import { readFileSync } from "node:fs";
337
+ import { join } from "node:path";
338
+ import { pathToFileURL } from "node:url";
339
+
340
+ const runtime = join(process.env.RUNNER_TEMP, "code-foundry", "src/lib/node-package-performance.mjs");
341
+ const { verifyNpmPackPackage } = await import(pathToFileURL(runtime).href);
342
+ const manifest = JSON.parse(readFileSync("package.json", "utf8"));
343
+ const report = JSON.parse(readFileSync(process.argv[1], "utf8"));
344
+ verifyNpmPackPackage(report, manifest);
345
+ ' "$RUNNER_TEMP/foundry-package-inspection.json"
310
346
  - name: Retain task receipt
311
347
  if: >-
312
348
  always() &&
@@ -36,11 +36,11 @@ jobs:
36
36
  exit 1
37
37
  fi
38
38
 
39
- read -r draft head_ref head_sha updated_at < <(
39
+ read -r draft head_ref head_sha updated_at node_id < <(
40
40
  gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
41
- --jq '[.draft, .head.ref, .head.sha, .updated_at] | @tsv'
41
+ --jq '[.draft, .head.ref, .head.sha, .updated_at, .node_id] | @tsv'
42
42
  )
43
- if [ -z "$draft" ] || [ -z "$head_ref" ] || [ -z "$head_sha" ] || [ -z "$updated_at" ]; then
43
+ if [ -z "$draft" ] || [ -z "$head_ref" ] || [ -z "$head_sha" ] || [ -z "$updated_at" ] || [ -z "$node_id" ]; then
44
44
  echo '::error title=Draft guard cannot verify current state::The GitHub API response did not include the required pull-request metadata.'
45
45
  exit 1
46
46
  fi
@@ -70,9 +70,13 @@ jobs:
70
70
  exit 1
71
71
  fi
72
72
 
73
- gh api \
74
- --method PATCH \
75
- "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
76
- -F draft=true \
77
- --silent >/dev/null
73
+ result=$(gh api graphql \
74
+ -f query='mutation($pullRequestId: ID!) { convertPullRequestToDraft(input: {pullRequestId: $pullRequestId}) { pullRequest { id isDraft } } }' \
75
+ -f pullRequestId="$node_id" \
76
+ --jq '.data.convertPullRequestToDraft.pullRequest | [.id, .isDraft] | @tsv')
77
+ read -r returned_id is_draft <<< "$result"
78
+ if [ "$returned_id" != "$node_id" ] || [ "$is_draft" != true ]; then
79
+ echo '::error title=Draft guard conversion failed::GraphQL did not confirm the expected pull request is a draft.'
80
+ exit 1
81
+ fi
78
82
  echo "Converted ordinary pull request #$PR_NUMBER to draft; validation waits for ready_for_review."
@@ -0,0 +1,201 @@
1
+ name: Code Foundry Qualification Receipt
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ phase:
7
+ description: Produce evidence after a full audit or verify it for a release PR.
8
+ required: true
9
+ type: string
10
+ mode:
11
+ description: Validation tier associated with this receipt operation.
12
+ required: true
13
+ type: string
14
+ enabled:
15
+ description: Consumer opt-in for qualification receipt reuse.
16
+ required: false
17
+ type: boolean
18
+ default: false
19
+ codeql-enabled:
20
+ description: Whether the consumer requires CodeQL in its audit and release lanes.
21
+ required: false
22
+ type: boolean
23
+ default: true
24
+ runtime-repository:
25
+ description: Repository containing the Code Foundry runtime.
26
+ required: true
27
+ type: string
28
+ runtime-ref:
29
+ description: Runtime commit, tag, or ref used by source validation.
30
+ required: true
31
+ type: string
32
+ job-results-json:
33
+ description: Normalized component results for the successful full audit.
34
+ required: false
35
+ type: string
36
+ default: '{}'
37
+ required-job-names-json:
38
+ description: Exact aggregate gate job names to verify from the source run's complete attempt-job listing.
39
+ required: false
40
+ type: string
41
+ default: '[]'
42
+ outputs:
43
+ reuse:
44
+ description: True only when a matching recent source receipt was verified.
45
+ value: ${{ jobs.verify.outputs.reuse }}
46
+
47
+ permissions:
48
+ actions: read
49
+ contents: read
50
+ pull-requests: read
51
+
52
+ jobs:
53
+ verify:
54
+ name: Verify source receipt
55
+ if: >-
56
+ vars.CI_BILLING_PAUSED != 'true' && inputs.enabled == true &&
57
+ inputs.phase == 'verify' && inputs.mode == 'release'
58
+ runs-on: ubuntu-slim
59
+ timeout-minutes: 15
60
+ outputs:
61
+ reuse: ${{ steps.verify.outputs.reuse }}
62
+ steps:
63
+ - name: Checkout release PR source
64
+ id: source
65
+ continue-on-error: true
66
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
67
+ with:
68
+ persist-credentials: false
69
+ fetch-depth: 0
70
+ - name: Checkout runtime
71
+ id: runtime
72
+ if: steps.source.outcome == 'success'
73
+ continue-on-error: true
74
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
75
+ with:
76
+ persist-credentials: false
77
+ repository: ${{ inputs.runtime-repository }}
78
+ ref: ${{ inputs.runtime-ref }}
79
+ path: .github/.code-foundry
80
+ sparse-checkout: |
81
+ src/commands/qualification-receipt.mjs
82
+ src/lib
83
+ src/runtime-core.mjs
84
+ src/runtime.mjs
85
+ - name: Install runtime
86
+ id: install
87
+ if: steps.runtime.outcome == 'success'
88
+ continue-on-error: true
89
+ run: mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
90
+ - name: Set up Node.js 24.18.0
91
+ id: node
92
+ if: steps.install.outcome == 'success'
93
+ continue-on-error: true
94
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
95
+ with:
96
+ node-version: 24.18.0
97
+ - name: Check version-only release diff
98
+ id: release-policy
99
+ if: steps.node.outcome == 'success'
100
+ continue-on-error: true
101
+ env:
102
+ FOUNDRY_BASE_SHA: ${{ github.event.pull_request.base.sha }}
103
+ FOUNDRY_HEAD_REF: ${{ github.event.pull_request.head.ref }}
104
+ FOUNDRY_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
105
+ FOUNDRY_REPOSITORY: ${{ github.repository }}
106
+ RUNTIME_PATH: ${{ runner.temp }}/code-foundry
107
+ run: node "$RUNTIME_PATH/src/runtime.mjs" validation release_diff
108
+ - name: Verify source qualification receipt
109
+ id: verify
110
+ if: always()
111
+ continue-on-error: true
112
+ env:
113
+ SOURCE_READY: ${{ steps.source.outcome }}
114
+ RUNTIME_READY: ${{ steps.install.outcome }}
115
+ NODE_READY: ${{ steps.node.outcome }}
116
+ POLICY_READY: ${{ steps.release-policy.outcome }}
117
+ GITHUB_TOKEN: ${{ github.token }}
118
+ RUNTIME_PATH: ${{ runner.temp }}/code-foundry
119
+ RUNTIME_REPOSITORY: ${{ inputs.runtime-repository }}
120
+ FOUNDRY_MODE: ${{ inputs.mode }}
121
+ FOUNDRY_CODEQL_ENABLED: ${{ inputs.codeql-enabled }}
122
+ FOUNDRY_REQUIRED_JOB_NAMES_JSON: ${{ inputs.required-job-names-json }}
123
+ run: |
124
+ if [ "$SOURCE_READY" = success ] && [ "$RUNTIME_READY" = success ] && [ "$NODE_READY" = success ] && [ "$POLICY_READY" = success ] && [ -f "$RUNTIME_PATH/src/commands/qualification-receipt.mjs" ]; then
125
+ node "$RUNTIME_PATH/src/commands/qualification-receipt.mjs" verify >> "$GITHUB_OUTPUT"
126
+ else
127
+ echo 'reuse=false' >> "$GITHUB_OUTPUT"
128
+ echo 'reason=qualification or version-only release policy unavailable; using full validation' >> "$GITHUB_OUTPUT"
129
+ fi
130
+
131
+ produce:
132
+ name: Produce source receipt
133
+ if: >-
134
+ vars.CI_BILLING_PAUSED != 'true' && inputs.enabled == true &&
135
+ inputs.phase == 'produce' && inputs.mode == 'audit'
136
+ runs-on: ubuntu-slim
137
+ timeout-minutes: 10
138
+ steps:
139
+ - name: Checkout tested source tree
140
+ id: source
141
+ continue-on-error: true
142
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
143
+ with:
144
+ persist-credentials: false
145
+ ref: ${{ github.sha }}
146
+ fetch-depth: 2
147
+ - name: Checkout runtime
148
+ id: runtime
149
+ if: steps.source.outcome == 'success'
150
+ continue-on-error: true
151
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
152
+ with:
153
+ persist-credentials: false
154
+ repository: ${{ inputs.runtime-repository }}
155
+ ref: ${{ inputs.runtime-ref }}
156
+ path: .github/.code-foundry
157
+ sparse-checkout: |
158
+ src/commands/qualification-receipt.mjs
159
+ src/lib
160
+ - name: Install runtime
161
+ id: install
162
+ if: steps.runtime.outcome == 'success'
163
+ continue-on-error: true
164
+ run: mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
165
+ - name: Set up Node.js 24.18.0
166
+ id: node
167
+ if: steps.install.outcome == 'success'
168
+ continue-on-error: true
169
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
170
+ with:
171
+ node-version: 24.18.0
172
+ - name: Create qualification receipt
173
+ id: produce
174
+ if: always()
175
+ continue-on-error: true
176
+ env:
177
+ SOURCE_READY: ${{ steps.source.outcome }}
178
+ RUNTIME_READY: ${{ steps.install.outcome }}
179
+ NODE_READY: ${{ steps.node.outcome }}
180
+ GITHUB_TOKEN: ${{ github.token }}
181
+ RUNTIME_PATH: ${{ runner.temp }}/code-foundry
182
+ RUNTIME_REPOSITORY: ${{ inputs.runtime-repository }}
183
+ FOUNDRY_MODE: ${{ inputs.mode }}
184
+ FOUNDRY_JOB_RESULTS_JSON: ${{ inputs.job-results-json }}
185
+ FOUNDRY_REQUIRED_JOB_NAMES_JSON: ${{ inputs.required-job-names-json }}
186
+ run: |
187
+ if [ "$SOURCE_READY" = success ] && [ "$RUNTIME_READY" = success ] && [ "$NODE_READY" = success ] && [ -f "$RUNTIME_PATH/src/commands/qualification-receipt.mjs" ]; then
188
+ node "$RUNTIME_PATH/src/commands/qualification-receipt.mjs" produce >> "$GITHUB_OUTPUT"
189
+ else
190
+ echo 'reuse=false' >> "$GITHUB_OUTPUT"
191
+ echo 'reason=receipt setup unavailable' >> "$GITHUB_OUTPUT"
192
+ fi
193
+ - name: Upload qualification receipt
194
+ if: steps.produce.outputs.reuse == 'true'
195
+ continue-on-error: true
196
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
197
+ with:
198
+ name: code-foundry-qualification-v2-${{ github.run_id }}-${{ github.run_attempt }}
199
+ path: qualification-receipt.json
200
+ if-no-files-found: error
201
+ retention-days: 7
@@ -16,9 +16,6 @@ on:
16
16
  required: false
17
17
  type: boolean
18
18
  default: false
19
- secrets:
20
- NPM_TOKEN:
21
- required: false
22
19
 
23
20
  permissions:
24
21
  attestations: read
@@ -88,7 +85,6 @@ jobs:
88
85
  contents: read
89
86
  id-token: write
90
87
  env:
91
- HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }}
92
88
  RELEASE_TAG: ${{ inputs.tag }}
93
89
  RELEASE_ASSET: ${{ inputs.asset }}
94
90
  steps:
@@ -98,16 +94,9 @@ jobs:
98
94
  persist-credentials: false
99
95
  ref: ${{ github.sha }}
100
96
  - name: Setup Node for trusted publishing
101
- if: env.HAS_NPM_TOKEN != 'true'
102
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
103
- with:
104
- node-version: '24'
105
- - name: Setup Node for token publishing
106
- if: env.HAS_NPM_TOKEN == 'true'
107
97
  uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
108
98
  with:
109
99
  node-version: '24'
110
- registry-url: https://registry.npmjs.org
111
100
  - name: Retrieve only this run attempt's evidence
112
101
  env:
113
102
  GH_TOKEN: ${{ github.token }}
@@ -124,7 +113,6 @@ jobs:
124
113
  - name: Reverify and publish the tested tarball without lifecycle hooks
125
114
  env:
126
115
  GH_TOKEN: ${{ github.token }}
127
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
128
116
  run: |
129
117
  set -euo pipefail
130
118
  node src/commands/qualified-publication.mjs publish \
@@ -43,6 +43,11 @@ on:
43
43
  required: false
44
44
  type: boolean
45
45
  default: false
46
+ npm-publish-auth:
47
+ description: Select automatic token compatibility or force trusted OIDC publication.
48
+ required: false
49
+ type: string
50
+ default: auto
46
51
  outputs:
47
52
  release_created:
48
53
  description: Whether Release Please created a release (possibly a draft).
@@ -63,6 +68,8 @@ on:
63
68
  required: false
64
69
  NPM_TOKEN:
65
70
  required: false
71
+ NPM_READ_TOKEN:
72
+ required: false
66
73
 
67
74
  permissions:
68
75
  actions: write
@@ -271,11 +278,12 @@ jobs:
271
278
  // Writes intermittently hit GitHub's secondary rate limiting from
272
279
  // busy automation accounts (403 with the generic not-accessible
273
280
  // message); back off and retry before giving up.
274
- const gh = (args) => {
281
+ const gh = (args, options = {}) => {
275
282
  for (const wait of [0, 10, 30]) {
276
283
  try {
277
284
  if (wait) sleep(wait);
278
285
  return execFileSync('gh', args, {
286
+ ...options,
279
287
  encoding: 'utf8',
280
288
  maxBuffer: 32 * 1024 * 1024,
281
289
  });
@@ -316,6 +324,18 @@ jobs:
316
324
  const latestTag = versionedTags[0] ?? '';
317
325
  const prefix = latestTag.replace(/v?\d+\.\d+\.\d+.*$/, '') || 'v';
318
326
 
327
+ // Include drafts and every page before deciding an object is absent.
328
+ const releasePages = JSON.parse(gh([
329
+ 'api', `repos/${repo}/releases`, '-X', 'GET', '-f', 'per_page=100',
330
+ '--paginate', '--slurp',
331
+ ]));
332
+ if (!Array.isArray(releasePages) || releasePages.some((page) =>
333
+ !Array.isArray(page) || page.some((release) =>
334
+ !release || typeof release.tag_name !== 'string' || !release.tag_name))) {
335
+ throw new Error('Invalid existing release listing; refusing release creation');
336
+ }
337
+ const existingReleaseTags = new Set(releasePages.flat().map((release) => release.tag_name));
338
+
319
339
  let ensured = 0;
320
340
  let failures = 0;
321
341
  let lastTag = '';
@@ -324,12 +344,7 @@ jobs:
324
344
  const version = /^chore\(main\): release (\S+)/.exec(pull.title)?.[1];
325
345
  if (!version) continue;
326
346
  const tag = `${prefix}v${version}`;
327
- // Draft releases are invisible to get-release-by-tag, so scan
328
- // the list endpoint (which returns drafts) for the tag.
329
- const releasesForCheck = JSON.parse(gh([
330
- 'api', `repos/${repo}/releases`, '-X', 'GET', '-f', 'per_page=100',
331
- ]));
332
- if (releasesForCheck.some((release) => release.tag_name === tag)) {
347
+ if (existingReleaseTags.has(tag)) {
333
348
  console.log(`${tag}: release object already exists`);
334
349
  } else {
335
350
  // No release object for this tag: create it. The tag may also
@@ -354,6 +369,7 @@ jobs:
354
369
  prerelease: version.includes('-'),
355
370
  });
356
371
  gh(['api', `repos/${repo}/releases`, '-X', 'POST', '--input', '-'], { input: body });
372
+ existingReleaseTags.add(tag);
357
373
  console.log(`${tag}: release object created`);
358
374
  } catch (error) {
359
375
  failures += 1;
@@ -834,24 +850,30 @@ jobs:
834
850
  contents: read
835
851
  id-token: write
836
852
  env:
837
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
853
+ NPM_PUBLISH_AUTH: ${{ inputs['npm-publish-auth'] }}
854
+ HAS_NPM_TOKEN: ${{ secrets.NPM_TOKEN != '' }}
855
+ HAS_NPM_READ_TOKEN: ${{ secrets.NPM_READ_TOKEN != '' }}
838
856
  steps:
839
857
  - name: Checkout release
840
858
  uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
841
859
  with:
842
860
  persist-credentials: false
843
861
  ref: ${{ needs.release.outputs.tag_name }}
844
- - name: Setup Node (trusted)
845
- if: env.NPM_TOKEN == ''
846
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
847
- with:
848
- node-version: 24
849
- - name: Setup Node (token)
850
- if: env.NPM_TOKEN != ''
862
+ - name: Validate npm publication authentication
863
+ run: |
864
+ set -euo pipefail
865
+ case "$NPM_PUBLISH_AUTH" in
866
+ auto|oidc|token) ;;
867
+ *) echo "Unsupported npm-publish-auth: $NPM_PUBLISH_AUTH" >&2; exit 1 ;;
868
+ esac
869
+ if [ "$NPM_PUBLISH_AUTH" = token ] && [ "$HAS_NPM_TOKEN" != true ]; then
870
+ echo 'npm-publish-auth token requires the NPM_TOKEN secret.' >&2
871
+ exit 1
872
+ fi
873
+ - name: Setup Node
851
874
  uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
852
875
  with:
853
876
  node-version: 24
854
- registry-url: https://registry.npmjs.org
855
877
  - name: Setup Bun (lockfile managers)
856
878
  # The install step below dispatches on lockfile: bun, pnpm, yarn, or
857
879
  # npm. Stock runners only provide Node, so provision Bun (oven-sh)
@@ -865,8 +887,18 @@ jobs:
865
887
  - name: Install dependencies
866
888
  # npm publish runs prepublishOnly (usually a build), which needs
867
889
  # devDependencies present. Install with the project's own manager.
890
+ env:
891
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_READ_TOKEN || (inputs['npm-publish-auth'] != 'oidc' && secrets.NPM_TOKEN) || '' }}
868
892
  run: |
869
893
  set -euo pipefail
894
+ npmrc=''
895
+ if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
896
+ npmrc="$(mktemp)"
897
+ chmod 600 "$npmrc"
898
+ printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$npmrc"
899
+ export NPM_CONFIG_USERCONFIG="$npmrc"
900
+ fi
901
+ trap '[ -z "$npmrc" ] || rm -f "$npmrc"' EXIT
870
902
  if [ -f bun.lock ] || [ -f bun.lockb ]; then bun install --frozen-lockfile
871
903
  elif [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile
872
904
  elif [ -f yarn.lock ]; then yarn install --immutable
@@ -874,10 +906,16 @@ jobs:
874
906
  elif [ -f package.json ]; then npm install --no-audit --fund=false --package-lock=false
875
907
  fi
876
908
  - name: Publish npm with token
877
- if: env.NPM_TOKEN != ''
909
+ if: inputs['npm-publish-auth'] == 'token' || (inputs['npm-publish-auth'] == 'auto' && env.HAS_NPM_TOKEN == 'true')
878
910
  env:
879
- NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }}
880
- run: npm publish --provenance --access public
911
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
912
+ run: |
913
+ set -euo pipefail
914
+ npmrc="$(mktemp)"
915
+ chmod 600 "$npmrc"
916
+ printf '//registry.npmjs.org/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$npmrc"
917
+ trap 'rm -f "$npmrc"' EXIT
918
+ NPM_CONFIG_USERCONFIG="$npmrc" npm publish --provenance --access public
881
919
  - name: Publish npm with trusted publishing
882
- if: env.NPM_TOKEN == ''
920
+ if: inputs['npm-publish-auth'] == 'oidc' || (inputs['npm-publish-auth'] == 'auto' && env.HAS_NPM_TOKEN != 'true')
883
921
  run: npm publish --provenance --access public
@@ -256,5 +256,3 @@ jobs:
256
256
  tag: ${{ needs.release.outputs.tag_name || needs.recovery.outputs.tag }}
257
257
  asset: ${{ needs.qualification.outputs.filename }}
258
258
  billing-pause-bypass: ${{ github.event_name == 'workflow_dispatch' && inputs['release-while-paused'] == true }}
259
- secrets:
260
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -140,7 +140,9 @@ jobs:
140
140
  dependency-audit:
141
141
  name: Dependency Audit (${{ matrix.entry.display }})
142
142
  needs: profile
143
- if: vars.CI_BILLING_PAUSED != 'true' && needs.profile.result == 'success'
143
+ # Avoid expanding an empty matrix; skip only the explicitly empty result.
144
+ # Missing or malformed output still fails closed during matrix expansion.
145
+ if: vars.CI_BILLING_PAUSED != 'true' && needs.profile.result == 'success' && needs.profile.outputs.audit_matrix != '[]'
144
146
  strategy:
145
147
  fail-fast: false
146
148
  max-parallel: 8