code-foundry 0.29.0 → 0.30.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.
- package/.github/workflows/opencode-security.yml +48 -0
- package/.github/workflows/opencode-security_self-ci.yml +48 -0
- package/.github/workflows/release.yml +60 -53
- package/.github/workflows/release_self-ci.yml +2 -1
- package/CHANGELOG.md +18 -0
- package/docs/EXTENSIONS.md +19 -0
- package/package.json +1 -1
- package/src/cli.mjs +46 -7
- package/src/commands/doctor.mjs +14 -3
- package/src/commands/fleet.mjs +111 -0
- package/src/commands/release.mjs +98 -2
- package/src/commands/sync.mjs +18 -7
- package/src/lib/github-doctor.mjs +114 -0
- package/src/lib/overlay.mjs +29 -0
- package/src/lib/profile.mjs +26 -0
- package/src/lib/release-hook.mjs +40 -0
- package/src/lib/release-policy.mjs +75 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Code Foundry
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [staging]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
detect:
|
|
13
|
+
name: OpenCode Security / Detect
|
|
14
|
+
if: >-
|
|
15
|
+
github.event_name == 'workflow_dispatch' ||
|
|
16
|
+
startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')
|
|
17
|
+
runs-on: ubuntu-slim
|
|
18
|
+
outputs:
|
|
19
|
+
enabled: ${{ steps.detect.outputs.enabled }}
|
|
20
|
+
token: ${{ steps.detect.outputs.token }}
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v7
|
|
24
|
+
- name: Detect configuration and token
|
|
25
|
+
id: detect
|
|
26
|
+
env:
|
|
27
|
+
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
28
|
+
run: |
|
|
29
|
+
enabled=false
|
|
30
|
+
token=false
|
|
31
|
+
if grep -Eq '^opencode_security:\s*(true|auto)\s*$' .github/code-foundry.yml 2>/dev/null; then enabled=true; fi
|
|
32
|
+
if [ -n "$OPENCODE_API_KEY" ]; then token=true; fi
|
|
33
|
+
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
|
|
34
|
+
echo "token=$token" >> "$GITHUB_OUTPUT"
|
|
35
|
+
|
|
36
|
+
scan:
|
|
37
|
+
name: OpenCode Security / Scan
|
|
38
|
+
needs: detect
|
|
39
|
+
if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
|
|
40
|
+
uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@main
|
|
41
|
+
with:
|
|
42
|
+
source_ref: main
|
|
43
|
+
runner: ubuntu-latest
|
|
44
|
+
mode: standard
|
|
45
|
+
fail_on_severity: high
|
|
46
|
+
max_cost_usd: 1
|
|
47
|
+
secrets:
|
|
48
|
+
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Code Foundry
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [staging]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
detect:
|
|
13
|
+
name: OpenCode Security / Detect
|
|
14
|
+
if: >-
|
|
15
|
+
github.event_name == 'workflow_dispatch' ||
|
|
16
|
+
startsWith(github.event.pull_request.head.ref, 'release-please--branches--main')
|
|
17
|
+
runs-on: ubuntu-slim
|
|
18
|
+
outputs:
|
|
19
|
+
enabled: ${{ steps.detect.outputs.enabled }}
|
|
20
|
+
token: ${{ steps.detect.outputs.token }}
|
|
21
|
+
steps:
|
|
22
|
+
- name: Checkout
|
|
23
|
+
uses: actions/checkout@v7
|
|
24
|
+
- name: Detect configuration and token
|
|
25
|
+
id: detect
|
|
26
|
+
env:
|
|
27
|
+
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
28
|
+
run: |
|
|
29
|
+
enabled=false
|
|
30
|
+
token=false
|
|
31
|
+
if grep -Eq '^opencode_security:\s*(true|auto)\s*$' .github/code-foundry.yml 2>/dev/null; then enabled=true; fi
|
|
32
|
+
if [ -n "$OPENCODE_API_KEY" ]; then token=true; fi
|
|
33
|
+
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
|
|
34
|
+
echo "token=$token" >> "$GITHUB_OUTPUT"
|
|
35
|
+
|
|
36
|
+
scan:
|
|
37
|
+
name: OpenCode Security / Scan
|
|
38
|
+
needs: detect
|
|
39
|
+
if: needs.detect.outputs.enabled == 'true' && needs.detect.outputs.token == 'true'
|
|
40
|
+
uses: 0xPlayerOne/opencode-security/.github/workflows/opencode-security.yml@main
|
|
41
|
+
with:
|
|
42
|
+
source_ref: main
|
|
43
|
+
runner: ubuntu-latest
|
|
44
|
+
mode: standard
|
|
45
|
+
fail_on_severity: high
|
|
46
|
+
max_cost_usd: 1
|
|
47
|
+
secrets:
|
|
48
|
+
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
|
@@ -20,6 +20,7 @@ on:
|
|
|
20
20
|
default: v0.27.18
|
|
21
21
|
|
|
22
22
|
permissions:
|
|
23
|
+
actions: write
|
|
23
24
|
contents: write
|
|
24
25
|
issues: write
|
|
25
26
|
pull-requests: write
|
|
@@ -49,6 +50,13 @@ jobs:
|
|
|
49
50
|
uses: actions/checkout@v7
|
|
50
51
|
with:
|
|
51
52
|
filter: blob:none
|
|
53
|
+
- name: Checkout runtime
|
|
54
|
+
uses: actions/checkout@v7
|
|
55
|
+
with:
|
|
56
|
+
repository: ${{ inputs.runtime-repository }}
|
|
57
|
+
ref: ${{ inputs.runtime-ref }}
|
|
58
|
+
path: .code-foundry
|
|
59
|
+
sparse-checkout: src
|
|
52
60
|
- name: Detect release profile
|
|
53
61
|
id: profile
|
|
54
62
|
run: |
|
|
@@ -109,43 +117,7 @@ jobs:
|
|
|
109
117
|
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
|
110
118
|
run: |
|
|
111
119
|
set -euo pipefail
|
|
112
|
-
|
|
113
|
-
allowed_paths="$(
|
|
114
|
-
node <<'NODE'
|
|
115
|
-
const fs = require('node:fs')
|
|
116
|
-
const config = JSON.parse(fs.readFileSync('release-please-config.json', 'utf8'))
|
|
117
|
-
const common = [
|
|
118
|
-
'.release-please-manifest.json',
|
|
119
|
-
'CHANGELOG.md',
|
|
120
|
-
'Cargo.lock',
|
|
121
|
-
'Cargo.toml',
|
|
122
|
-
'bun.lock',
|
|
123
|
-
'bun.lockb',
|
|
124
|
-
'package-lock.json',
|
|
125
|
-
'package.json',
|
|
126
|
-
'pnpm-lock.yaml',
|
|
127
|
-
'pyproject.toml',
|
|
128
|
-
'uv.lock',
|
|
129
|
-
'version.txt',
|
|
130
|
-
'yarn.lock',
|
|
131
|
-
]
|
|
132
|
-
const allowed = new Set(common)
|
|
133
|
-
const addExtraFiles = (files = [], prefix = '') => {
|
|
134
|
-
for (const entry of files) {
|
|
135
|
-
const path = typeof entry === 'string' ? entry : entry.path
|
|
136
|
-
if (path) allowed.add(prefix ? `${prefix}/${path}` : path)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
addExtraFiles(config['extra-files'])
|
|
140
|
-
for (const [directory, packageConfig] of Object.entries(config.packages || {})) {
|
|
141
|
-
const prefix = directory === '.' ? '' : directory.replace(/\/$/, '')
|
|
142
|
-
for (const path of common) allowed.add(prefix ? `${prefix}/${path}` : path)
|
|
143
|
-
addExtraFiles(packageConfig['extra-files'], prefix)
|
|
144
|
-
}
|
|
145
|
-
process.stdout.write(JSON.stringify([...allowed]))
|
|
146
|
-
NODE
|
|
147
|
-
)"
|
|
148
|
-
|
|
120
|
+
node .code-foundry/src/cli.mjs release validate-prs
|
|
149
121
|
mapfile -t release_prs < <(
|
|
150
122
|
gh pr list \
|
|
151
123
|
--repo "$GITHUB_REPOSITORY" \
|
|
@@ -154,23 +126,7 @@ jobs:
|
|
|
154
126
|
--json number,title,headRefName \
|
|
155
127
|
--jq '.[] | select(.title | startswith("chore(main): release ")) | select(.headRefName | startswith("release-please--branches--main")) | .number'
|
|
156
128
|
)
|
|
157
|
-
[ "${#release_prs[@]}" -gt 0 ] || {
|
|
158
|
-
echo "Release Please reported a pull request, but no generated release PR was found." >&2
|
|
159
|
-
exit 1
|
|
160
|
-
}
|
|
161
|
-
|
|
162
129
|
for pr in "${release_prs[@]}"; do
|
|
163
|
-
mapfile -t changed_paths < <(gh pr diff "$pr" --repo "$GITHUB_REPOSITORY" --name-only)
|
|
164
|
-
[ "${#changed_paths[@]}" -gt 0 ] || {
|
|
165
|
-
echo "Generated release PR #$pr has no changed files." >&2
|
|
166
|
-
exit 1
|
|
167
|
-
}
|
|
168
|
-
for path in "${changed_paths[@]}"; do
|
|
169
|
-
jq -e --arg path "$path" 'index($path) != null' <<<"$allowed_paths" >/dev/null || {
|
|
170
|
-
echo "Refusing to auto-merge release PR #$pr with unexpected path: $path" >&2
|
|
171
|
-
exit 1
|
|
172
|
-
}
|
|
173
|
-
done
|
|
174
130
|
gh pr merge "$pr" \
|
|
175
131
|
--repo "$GITHUB_REPOSITORY" \
|
|
176
132
|
--admin \
|
|
@@ -219,6 +175,57 @@ jobs:
|
|
|
219
175
|
if: steps.staging.outputs.exists != 'true'
|
|
220
176
|
run: echo 'No staging branch exists; release reconciliation is not applicable.'
|
|
221
177
|
|
|
178
|
+
post-release:
|
|
179
|
+
name: Release / Post Hook
|
|
180
|
+
needs: release
|
|
181
|
+
if: needs.release.outputs.release_created == 'true'
|
|
182
|
+
runs-on: ${{ inputs.runner }}
|
|
183
|
+
timeout-minutes: 15
|
|
184
|
+
concurrency:
|
|
185
|
+
group: code-foundry-post-release-${{ github.repository }}-${{ needs.release.outputs.tag_name }}
|
|
186
|
+
cancel-in-progress: false
|
|
187
|
+
permissions:
|
|
188
|
+
actions: write
|
|
189
|
+
contents: read
|
|
190
|
+
env:
|
|
191
|
+
RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
|
192
|
+
steps:
|
|
193
|
+
- name: Checkout repository
|
|
194
|
+
uses: actions/checkout@v7
|
|
195
|
+
with:
|
|
196
|
+
ref: ${{ needs.release.outputs.tag_name }}
|
|
197
|
+
filter: blob:none
|
|
198
|
+
- name: Checkout runtime
|
|
199
|
+
uses: actions/checkout@v7
|
|
200
|
+
with:
|
|
201
|
+
repository: ${{ inputs.runtime-repository }}
|
|
202
|
+
ref: ${{ inputs.runtime-ref }}
|
|
203
|
+
path: .code-foundry
|
|
204
|
+
sparse-checkout: src
|
|
205
|
+
- name: Detect post-release hook
|
|
206
|
+
id: hook
|
|
207
|
+
run: |
|
|
208
|
+
node <<'NODE' >> "$GITHUB_OUTPUT"
|
|
209
|
+
const fs = require('node:fs')
|
|
210
|
+
const file = '.github/code-foundry.yml'
|
|
211
|
+
const config = fs.existsSync(file)
|
|
212
|
+
? Object.fromEntries(fs.readFileSync(file, 'utf8').split(/\r?\n/).flatMap((line) => {
|
|
213
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
214
|
+
return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
|
|
215
|
+
}))
|
|
216
|
+
: {}
|
|
217
|
+
const enabled = ['true', 'auto'].includes(config.post_release ?? 'false')
|
|
218
|
+
console.log(`enabled=${enabled}`)
|
|
219
|
+
console.log(`workflow=${config.post_release_workflow ?? ''}`)
|
|
220
|
+
console.log(`mode=${config.post_release_mode ?? 'auto'}`)
|
|
221
|
+
NODE
|
|
222
|
+
- name: Dispatch post-release hook once
|
|
223
|
+
if: steps.hook.outputs.enabled == 'true' && steps.hook.outputs.workflow != ''
|
|
224
|
+
run: node .code-foundry/src/cli.mjs release hook --tag '${{ needs.release.outputs.tag_name }}' --workflow '${{ steps.hook.outputs.workflow }}' --mode '${{ steps.hook.outputs.mode }}'
|
|
225
|
+
- name: Explain disabled post-release hook
|
|
226
|
+
if: steps.hook.outputs.enabled != 'true' || steps.hook.outputs.workflow == ''
|
|
227
|
+
run: echo 'No post-release workflow configured; artifact delivery is intentionally skipped.'
|
|
228
|
+
|
|
222
229
|
npm:
|
|
223
230
|
name: Release / Publish npm
|
|
224
231
|
needs: release
|
|
@@ -6,6 +6,7 @@ on:
|
|
|
6
6
|
workflow_dispatch:
|
|
7
7
|
|
|
8
8
|
permissions:
|
|
9
|
+
actions: write
|
|
9
10
|
contents: write
|
|
10
11
|
issues: write
|
|
11
12
|
pull-requests: write
|
|
@@ -18,5 +19,5 @@ jobs:
|
|
|
18
19
|
with:
|
|
19
20
|
runner: ubuntu-slim
|
|
20
21
|
runtime-repository: 0xPlayerOne/code-foundry
|
|
21
|
-
runtime-ref:
|
|
22
|
+
runtime-ref: main
|
|
22
23
|
secrets: inherit
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.29.0...v0.30.0) (2026-07-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **ci:** recommend stack-aware runners ([83df327](https://github.com/0xPlayerOne/code-foundry/commit/83df32777344898651d71af28496202337ae6788))
|
|
9
|
+
* **doctor:** validate GitHub release configuration ([877525c](https://github.com/0xPlayerOne/code-foundry/commit/877525c11f6713b235aa3945dd98d017bef5732e))
|
|
10
|
+
* **fleet:** add isolated repository upgrades ([76d1765](https://github.com/0xPlayerOne/code-foundry/commit/76d176593ea025a2090bd62f21b275d261d3a9d5))
|
|
11
|
+
* **release:** add exactly-once post-release hooks ([f4ffa13](https://github.com/0xPlayerOne/code-foundry/commit/f4ffa136ef972665fe269b6685f9c4b840fc95a9))
|
|
12
|
+
* **release:** add non-destructive recovery planning ([6c45592](https://github.com/0xPlayerOne/code-foundry/commit/6c45592358d9ceb0335acc6584ac565971433737))
|
|
13
|
+
* **security:** add opt-in OpenCode release scan ([34512c1](https://github.com/0xPlayerOne/code-foundry/commit/34512c1507519d2b37a9bd665243490fa888beb9))
|
|
14
|
+
* **sync:** formalize custom workflow overlays ([ee540f9](https://github.com/0xPlayerOne/code-foundry/commit/ee540f9a80c0b128100e795471042fc12aefc226))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Bug Fixes
|
|
18
|
+
|
|
19
|
+
* **release:** grant post-release dispatch permission ([c18a52d](https://github.com/0xPlayerOne/code-foundry/commit/c18a52dff2ac76e648eeb9fa1f92fab8a01dafec))
|
|
20
|
+
|
|
3
21
|
## [0.29.0](https://github.com/0xPlayerOne/code-foundry/compare/v0.28.0...v0.29.0) (2026-07-29)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Code Foundry extension points
|
|
2
|
+
|
|
3
|
+
Code Foundry uses an overlay model. The files in its documented baseline are managed by `sync`; repository-owned files outside that baseline remain yours.
|
|
4
|
+
|
|
5
|
+
## Managed files
|
|
6
|
+
|
|
7
|
+
The standard workflows, hooks, governance documents, language configuration, and release configuration are refreshed from the configured runtime. Keep repository-specific behavior in separate files.
|
|
8
|
+
|
|
9
|
+
## Custom workflows
|
|
10
|
+
|
|
11
|
+
Any workflow not named by the baseline is preserved automatically. This is the supported place for project-specific workflows such as Slither, search indexing, deployment, Docker publishing, or Vercel tasks.
|
|
12
|
+
|
|
13
|
+
Set `custom_workflows: preserve` in `.github/code-foundry.yml` (the default). Code Foundry intentionally has no prune mode for custom workflows; remove those files explicitly when they are no longer needed.
|
|
14
|
+
|
|
15
|
+
## Release and deployment hooks
|
|
16
|
+
|
|
17
|
+
Use `post_release`, `post_release_workflow`, and `post_release_mode` for a post-release artifact workflow. The hook receives `release-tag` and `delivery-key` inputs and is dispatched at most once per tag when a release token is available.
|
|
18
|
+
|
|
19
|
+
Keep deployment credentials, environment files, and project-specific secrets in the repository or organization configuration. Code Foundry never copies secret values or overwrites custom workflows in overlay mode.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
|
|
4
4
|
import { resolve } from 'node:path'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
5
6
|
import { fileURLToPath } from 'node:url'
|
|
6
7
|
import { doctor } from './commands/doctor.mjs'
|
|
7
|
-
import { reconcileRelease } from './commands/release.mjs'
|
|
8
|
+
import { dispatchPostReleaseHook, reconcileRelease, releaseRecoveryPlan, validateReleasePullRequestDiffs } from './commands/release.mjs'
|
|
9
|
+
import { discoverRepositories, upgradeFleet } from './commands/fleet.mjs'
|
|
8
10
|
import { syncRepository } from './commands/sync.mjs'
|
|
9
11
|
|
|
10
12
|
const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
11
13
|
|
|
12
|
-
/** @typedef {{ target: string, dryRun: boolean, force: boolean, github: boolean, base: string, head: string }} Options */
|
|
14
|
+
/** @typedef {{ target: string, root: string, dryRun: boolean, force: boolean, github: boolean, createPr: boolean, base: string, head: string, tag: string, workflow: string, mode: string, version: string, releaseSubcommand?: string, fleetSubcommand?: string }} Options */
|
|
13
15
|
/** @typedef {{ command: string, options: Options }} ParsedArgs */
|
|
14
16
|
|
|
15
17
|
const usage = `code-foundry — initialize and maintain agent-ready repositories
|
|
@@ -19,6 +21,9 @@ Usage:
|
|
|
19
21
|
npx code-foundry sync [--target PATH]
|
|
20
22
|
npx code-foundry doctor [--target PATH]
|
|
21
23
|
npx code-foundry release reconcile [--github] [--base BRANCH] [--head BRANCH]
|
|
24
|
+
npx code-foundry release hook --tag TAG --workflow WORKFLOW
|
|
25
|
+
npx code-foundry fleet status [--root PATH]
|
|
26
|
+
npx code-foundry fleet upgrade [--root PATH] [--dry-run] [--create-pr]
|
|
22
27
|
|
|
23
28
|
The repository configuration lives in .github/code-foundry.yml.
|
|
24
29
|
init detects the repository, creates that file, and renders the baseline.
|
|
@@ -31,6 +36,12 @@ Options:
|
|
|
31
36
|
--github Apply a verified fast-forward through GitHub
|
|
32
37
|
--base BRANCH Release source branch (default: main)
|
|
33
38
|
--head BRANCH Branch to reconcile (default: staging)
|
|
39
|
+
--tag TAG Published release tag for a post-release hook
|
|
40
|
+
--workflow FILE Workflow to dispatch for a post-release hook
|
|
41
|
+
--mode MODE auto, workflow-dispatch, release-event, or disabled
|
|
42
|
+
--root PATH Fleet root containing repositories (default: current directory)
|
|
43
|
+
--create-pr Create isolated upgrade branches and pull requests
|
|
44
|
+
--version TAG Runtime tag to report in fleet upgrade branches
|
|
34
45
|
-h, --help Show this help
|
|
35
46
|
`
|
|
36
47
|
|
|
@@ -45,11 +56,17 @@ function parseArgs(argv) {
|
|
|
45
56
|
const first = argv[0]
|
|
46
57
|
const hasCommand = Boolean(first && !first.startsWith('-'))
|
|
47
58
|
const command = hasCommand ? /** @type {string} */ (argv.shift()) : 'init'
|
|
48
|
-
|
|
59
|
+
/** @type {Options} */
|
|
60
|
+
const options = { target: process.cwd(), root: process.cwd(), dryRun: false, force: false, github: false, createPr: false, base: 'main', head: 'staging', tag: '', workflow: '', mode: 'auto', version: `v${readPackageVersion(packageRoot)}` }
|
|
49
61
|
|
|
50
62
|
if (command === 'release') {
|
|
51
|
-
const subcommand = argv.shift()
|
|
52
|
-
if (
|
|
63
|
+
const subcommand = argv.shift() ?? ''
|
|
64
|
+
if (!['reconcile', 'hook', 'validate-prs', 'recovery-plan'].includes(subcommand)) fail(`unknown release command: ${subcommand ?? '(missing)'}; use release reconcile, release hook, release validate-prs, or release recovery-plan`)
|
|
65
|
+
options.releaseSubcommand = subcommand
|
|
66
|
+
} else if (command === 'fleet') {
|
|
67
|
+
const subcommand = argv.shift() ?? ''
|
|
68
|
+
if (subcommand !== 'status' && subcommand !== 'upgrade') fail(`unknown fleet command: ${subcommand ?? '(missing)'}; use fleet status or fleet upgrade`)
|
|
69
|
+
options.fleetSubcommand = subcommand
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
while (argv.length) {
|
|
@@ -67,6 +84,12 @@ function parseArgs(argv) {
|
|
|
67
84
|
else if (arg === '--github') options.github = true
|
|
68
85
|
else if (arg === '--base') options.base = argv.shift() ?? fail('--base requires a branch')
|
|
69
86
|
else if (arg === '--head') options.head = argv.shift() ?? fail('--head requires a branch')
|
|
87
|
+
else if (arg === '--tag') options.tag = argv.shift() ?? fail('--tag requires a tag')
|
|
88
|
+
else if (arg === '--workflow') options.workflow = argv.shift() ?? fail('--workflow requires a workflow')
|
|
89
|
+
else if (arg === '--mode') options.mode = argv.shift() ?? fail('--mode requires a delivery mode')
|
|
90
|
+
else if (arg === '--root') options.root = argv.shift() ?? fail('--root requires a path')
|
|
91
|
+
else if (arg === '--create-pr') options.createPr = true
|
|
92
|
+
else if (arg === '--version') options.version = argv.shift() ?? fail('--version requires a tag')
|
|
70
93
|
else fail(`unknown option: ${arg}; run --help for the supported options`)
|
|
71
94
|
}
|
|
72
95
|
|
|
@@ -91,14 +114,25 @@ function main() {
|
|
|
91
114
|
}
|
|
92
115
|
} else if (command === 'doctor') {
|
|
93
116
|
try {
|
|
94
|
-
doctor(target)
|
|
117
|
+
doctor(target, { github: options.github })
|
|
95
118
|
} catch (error) {
|
|
96
119
|
console.error(`code-foundry: ${error instanceof Error ? error.message : String(error)}`)
|
|
97
120
|
process.exitCode = 1
|
|
98
121
|
}
|
|
99
122
|
} else if (command === 'release') {
|
|
100
123
|
try {
|
|
101
|
-
|
|
124
|
+
if (options.releaseSubcommand === 'hook') dispatchPostReleaseHook(target, options)
|
|
125
|
+
else if (options.releaseSubcommand === 'validate-prs') validateReleasePullRequestDiffs(target)
|
|
126
|
+
else if (options.releaseSubcommand === 'recovery-plan') releaseRecoveryPlan(target)
|
|
127
|
+
else reconcileRelease(target, options)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
fail(error instanceof Error ? error.message : String(error))
|
|
130
|
+
}
|
|
131
|
+
} else if (command === 'fleet') {
|
|
132
|
+
try {
|
|
133
|
+
const root = resolve(options.root)
|
|
134
|
+
if (options.fleetSubcommand === 'status') console.log(JSON.stringify(discoverRepositories(root), null, 2))
|
|
135
|
+
else upgradeFleet(root, packageRoot, { createPr: options.createPr, dryRun: options.dryRun, force: options.force, version: options.version })
|
|
102
136
|
} catch (error) {
|
|
103
137
|
fail(error instanceof Error ? error.message : String(error))
|
|
104
138
|
}
|
|
@@ -107,3 +141,8 @@ function main() {
|
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
main()
|
|
144
|
+
|
|
145
|
+
/** @param {string} root @returns {string} */
|
|
146
|
+
function readPackageVersion(root) {
|
|
147
|
+
try { return JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).version ?? '0.0.0' } catch { return '0.0.0' }
|
|
148
|
+
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -4,13 +4,15 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import { includesValue, readConfig } from '../lib/config.mjs'
|
|
7
|
-
import { resolveProfile } from '../lib/profile.mjs'
|
|
7
|
+
import { recommendRunners, resolveProfile } from '../lib/profile.mjs'
|
|
8
|
+
import { doctorGithub } from '../lib/github-doctor.mjs'
|
|
8
9
|
|
|
9
|
-
/** @param {string} root */
|
|
10
|
-
export function doctor(root) {
|
|
10
|
+
/** @param {string} root @param {{ github?: boolean }} [options] */
|
|
11
|
+
export function doctor(root, options = {}) {
|
|
11
12
|
const target = resolve(root)
|
|
12
13
|
const config = readConfig(join(target, '.github/code-foundry.yml'))
|
|
13
14
|
const profile = resolveProfile(target)
|
|
15
|
+
const recommendations = recommendRunners(target)
|
|
14
16
|
let errors = 0
|
|
15
17
|
/** @param {string} message */
|
|
16
18
|
const error = (message) => { console.error(`ERROR: ${message}`); errors += 1 }
|
|
@@ -27,6 +29,9 @@ export function doctor(root) {
|
|
|
27
29
|
}
|
|
28
30
|
const hooks = git(target, ['config', '--get', 'core.hooksPath'])
|
|
29
31
|
if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
|
|
32
|
+
if (['rust', 'python', 'solidity'].some((language) => profile.languages.split(',').includes(language)) && (config.unit_runner ?? recommendations.unit_runner) === 'ubuntu-slim') {
|
|
33
|
+
warn('unit_runner is ubuntu-slim for a native-toolchain repository; ubuntu-latest is recommended.')
|
|
34
|
+
}
|
|
30
35
|
|
|
31
36
|
const packageFile = join(target, 'package.json')
|
|
32
37
|
if (existsSync(packageFile)) {
|
|
@@ -55,6 +60,12 @@ export function doctor(root) {
|
|
|
55
60
|
if (includesValue(config.features ?? 'all', workflow) && !existsSync(join(target, `.github/workflows/${workflow}.yml`))) error(`missing enabled workflow: ${workflow}.yml`)
|
|
56
61
|
}
|
|
57
62
|
console.log('Remote CI, Test, Security, CodeQL, and release runtimes are loaded by reusable workflow wrappers.')
|
|
63
|
+
if (options.github) {
|
|
64
|
+
const github = doctorGithub(target)
|
|
65
|
+
for (const message of github.warnings) warn(`GitHub: ${message}`)
|
|
66
|
+
for (const message of github.errors) error(`GitHub: ${message}`)
|
|
67
|
+
console.log(`GitHub doctor inspected ${github.details.repository}.`)
|
|
68
|
+
}
|
|
58
69
|
if (errors) throw new Error(`Repository doctor found ${errors} error(s).`)
|
|
59
70
|
console.log('Repository doctor passed.')
|
|
60
71
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { syncRepository } from './sync.mjs'
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ path: string, repository: string, runtimeRef: string, dirty: boolean, configured: boolean }} FleetRepository */
|
|
10
|
+
|
|
11
|
+
/** @param {string} root @returns {FleetRepository[]} */
|
|
12
|
+
export function discoverRepositories(root) {
|
|
13
|
+
/** @type {FleetRepository[]} */
|
|
14
|
+
const result = []
|
|
15
|
+
const candidates = [root, ...children(root), ...children(join(root, 'NiftyLeague'))]
|
|
16
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
17
|
+
if (!existsSync(join(candidate, '.git'))) continue
|
|
18
|
+
const configured = existsSync(join(candidate, '.github/code-foundry.yml'))
|
|
19
|
+
const config = readConfig(join(candidate, '.github/code-foundry.yml'))
|
|
20
|
+
const remote = git(candidate, ['remote', 'get-url', 'origin'])
|
|
21
|
+
result.push({
|
|
22
|
+
path: candidate,
|
|
23
|
+
repository: normalizeRemote(remote),
|
|
24
|
+
runtimeRef: config.runtime_ref ?? '',
|
|
25
|
+
dirty: Boolean(git(candidate, ['status', '--porcelain'])),
|
|
26
|
+
configured,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return result.sort((a, b) => a.path.localeCompare(b.path))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} root @param {string} source @param {{ createPr?: boolean, dryRun?: boolean, force?: boolean, version: string }} options */
|
|
33
|
+
export function upgradeFleet(root, source, options) {
|
|
34
|
+
const repositories = discoverRepositories(root)
|
|
35
|
+
const report = []
|
|
36
|
+
for (const repository of repositories) {
|
|
37
|
+
if (!repository.configured) {
|
|
38
|
+
report.push({ path: repository.path, status: 'skipped', reason: 'missing .github/code-foundry.yml' })
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (repository.dirty && !options.force) {
|
|
42
|
+
report.push({ path: repository.path, status: 'skipped', reason: 'working tree is dirty' })
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!options.createPr || options.dryRun) {
|
|
46
|
+
const result = syncRepository({ target: repository.path, source, dryRun: options.dryRun, force: false })
|
|
47
|
+
report.push({ path: repository.path, status: options.dryRun ? 'preview' : 'synced', changed: result.changed })
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (!repository.repository) {
|
|
51
|
+
report.push({ path: repository.path, status: 'skipped', reason: 'origin is not a GitHub remote' })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
report.push(upgradeRepository(repository, source, options.version))
|
|
55
|
+
}
|
|
56
|
+
console.log(JSON.stringify(report, null, 2))
|
|
57
|
+
return report
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {FleetRepository} repository @param {string} source @param {string} version */
|
|
61
|
+
function upgradeRepository(repository, source, version) {
|
|
62
|
+
const branch = `codex/code-foundry-upgrade-${version.replace(/^v/, '')}`
|
|
63
|
+
const temporary = mkdtempSync(join(tmpdir(), 'code-foundry-fleet-'))
|
|
64
|
+
try {
|
|
65
|
+
const add = spawnSync('git', ['-C', repository.path, 'worktree', 'add', '-b', branch, temporary, 'HEAD'], { encoding: 'utf8' })
|
|
66
|
+
if (add.status !== 0) return { path: repository.path, status: 'skipped', reason: add.stderr.trim() || 'unable to create isolated worktree' }
|
|
67
|
+
const result = syncRepository({ target: temporary, source, force: false })
|
|
68
|
+
if (!result.changed.length) return { path: repository.path, status: 'unchanged', branch }
|
|
69
|
+
spawnSync('git', ['-C', temporary, 'add', '-A'], { stdio: 'ignore' })
|
|
70
|
+
const commit = spawnSync('git', ['-C', temporary, 'commit', '-m', `chore(code-foundry): upgrade runtime to ${version}`], { encoding: 'utf8' })
|
|
71
|
+
if (commit.status !== 0) return { path: repository.path, status: 'failed', reason: commit.stderr.trim() || 'commit failed' }
|
|
72
|
+
const push = spawnSync('git', ['-C', temporary, 'push', '-u', 'origin', branch], { encoding: 'utf8' })
|
|
73
|
+
if (push.status !== 0) return { path: repository.path, status: 'failed', reason: push.stderr.trim() || 'push failed' }
|
|
74
|
+
const pr = spawnSync('gh', ['pr', 'create', '--repo', repository.repository, '--base', 'staging', '--head', branch, '--title', `chore(code-foundry): upgrade to ${version}`, '--body', `Automated isolated Code Foundry runtime upgrade to ${version}.\n\nThe sync preserved protected repository-owned documents and custom workflows.`], { encoding: 'utf8' })
|
|
75
|
+
return pr.status === 0
|
|
76
|
+
? { path: repository.path, status: 'pr-created', branch, pullRequest: pr.stdout.trim() }
|
|
77
|
+
: { path: repository.path, status: 'failed', branch, reason: pr.stderr.trim() || 'pull request creation failed' }
|
|
78
|
+
} finally {
|
|
79
|
+
spawnSync('git', ['-C', repository.path, 'worktree', 'remove', '--force', temporary], { stdio: 'ignore' })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {string} root @returns {string[]} */
|
|
84
|
+
function children(root) {
|
|
85
|
+
try {
|
|
86
|
+
return readdirSync(root, { withFileTypes: true })
|
|
87
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !['node_modules', 'target'].includes(entry.name))
|
|
88
|
+
.map((entry) => join(root, entry.name))
|
|
89
|
+
} catch { return [] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {string} file @returns {Record<string, string>} */
|
|
93
|
+
function readConfig(file) {
|
|
94
|
+
try {
|
|
95
|
+
return Object.fromEntries(requireText(file).split(/\r?\n/).flatMap((line) => {
|
|
96
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
97
|
+
return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
|
|
98
|
+
}))
|
|
99
|
+
} catch { return {} }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} file */
|
|
103
|
+
function requireText(file) {
|
|
104
|
+
return readFileSync(file, 'utf8')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} root @param {string[]} args */
|
|
108
|
+
function git(root, args) { return spawnSync('git', args, { cwd: root, encoding: 'utf8' }).stdout?.trim() ?? '' }
|
|
109
|
+
|
|
110
|
+
/** @param {string} remote */
|
|
111
|
+
function normalizeRemote(remote) { return remote.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '') }
|
package/src/commands/release.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import { existsSync } from 'node:fs'
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
|
-
import { approvedReleaseFiles, classifyReconciliation, readReleaseConfig } from '../lib/release-policy.mjs'
|
|
6
|
+
import { approvedReleaseFiles, buildReleaseRecoveryPlan, classifyReconciliation, readReleaseConfig, selectGeneratedReleasePrs, validateReleasePullRequests } from '../lib/release-policy.mjs'
|
|
7
|
+
import { hasDeliveredHook, releaseDeliveryKey, selectHookDelivery } from '../lib/release-hook.mjs'
|
|
7
8
|
|
|
8
9
|
/** @typedef {{ target: string, dryRun: boolean, github: boolean, base: string, head: string }} ReleaseOptions */
|
|
9
10
|
|
|
@@ -45,6 +46,75 @@ export function reconcileRelease(root, options) {
|
|
|
45
46
|
return plan
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Dispatch a configured post-release workflow at most once for a tag.
|
|
51
|
+
* @param {string} root
|
|
52
|
+
* @param {{ tag: string, workflow: string, mode?: string, dryRun?: boolean }} options
|
|
53
|
+
*/
|
|
54
|
+
export function dispatchPostReleaseHook(root, options) {
|
|
55
|
+
const repository = process.env.GITHUB_REPOSITORY
|
|
56
|
+
if (!repository) throw new Error('GITHUB_REPOSITORY is required for post-release hooks.')
|
|
57
|
+
if (!options.tag) throw new Error('--tag is required for post-release hooks.')
|
|
58
|
+
const decision = selectHookDelivery({ mode: options.mode, tokenPresent: Boolean(process.env.RELEASE_PLEASE_TOKEN) })
|
|
59
|
+
console.log(JSON.stringify({ repository, tag: options.tag, workflow: options.workflow, ...decision }, null, 2))
|
|
60
|
+
if (decision.delivery === 'disabled' || decision.delivery === 'release-event') return decision
|
|
61
|
+
if (decision.delivery === 'unavailable') throw new Error(decision.reason)
|
|
62
|
+
if (!options.workflow) throw new Error('--workflow is required for workflow-dispatch hooks.')
|
|
63
|
+
const key = releaseDeliveryKey(repository, options.tag)
|
|
64
|
+
const runs = ghJson(root, ['run', 'list', '--repo', repository, '--workflow', options.workflow, '--limit', '100', '--json', 'headBranch,displayTitle,status'])
|
|
65
|
+
if (hasDeliveredHook(Array.isArray(runs) ? runs : [], options.tag)) {
|
|
66
|
+
console.log(`Post-release hook already delivered for ${options.tag}; skipping duplicate dispatch.`)
|
|
67
|
+
return { ...decision, deliveryKey: key, skipped: true }
|
|
68
|
+
}
|
|
69
|
+
if (options.dryRun) return { ...decision, deliveryKey: key, dispatched: false }
|
|
70
|
+
const result = spawnSync('gh', [
|
|
71
|
+
'workflow', 'run', options.workflow,
|
|
72
|
+
'--repo', repository,
|
|
73
|
+
'--ref', options.tag,
|
|
74
|
+
'--field', `release-tag=${options.tag}`,
|
|
75
|
+
'--field', `delivery-key=${key}`,
|
|
76
|
+
], { cwd: resolve(root), stdio: 'inherit', env: { ...process.env, GH_TOKEN: process.env.RELEASE_PLEASE_TOKEN } })
|
|
77
|
+
if (result.status !== 0) throw new Error(`Failed to dispatch post-release workflow ${options.workflow}.`)
|
|
78
|
+
return { ...decision, deliveryKey: key, dispatched: true }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {string} root */
|
|
82
|
+
export function validateReleasePullRequestDiffs(root) {
|
|
83
|
+
const repository = process.env.GITHUB_REPOSITORY
|
|
84
|
+
if (!repository) throw new Error('GITHUB_REPOSITORY is required for release PR validation.')
|
|
85
|
+
const prs = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title,headRefName'])
|
|
86
|
+
const generated = selectGeneratedReleasePrs(Array.isArray(prs) ? prs : [])
|
|
87
|
+
/** @type {Map<number, string[]>} */
|
|
88
|
+
const paths = new Map()
|
|
89
|
+
for (const pr of generated) {
|
|
90
|
+
const number = Number(pr.number)
|
|
91
|
+
const result = spawnSync('gh', ['pr', 'diff', String(number), '--repo', repository, '--name-only'], { cwd: resolve(root), encoding: 'utf8' })
|
|
92
|
+
paths.set(number, result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : [])
|
|
93
|
+
}
|
|
94
|
+
const validation = validateReleasePullRequests(Array.isArray(prs) ? prs : [], paths, approvedReleaseFiles(readReleaseConfig(root)))
|
|
95
|
+
console.log(JSON.stringify(validation, null, 2))
|
|
96
|
+
if (!validation.valid) throw new Error(validation.errors.join(' '))
|
|
97
|
+
return validation
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {string} root */
|
|
101
|
+
export function releaseRecoveryPlan(root) {
|
|
102
|
+
const repository = process.env.GITHUB_REPOSITORY
|
|
103
|
+
if (!repository) throw new Error('GITHUB_REPOSITORY is required for release recovery planning.')
|
|
104
|
+
const tags = ghJson(root, ['api', `repos/${repository}/tags?per_page=100`])
|
|
105
|
+
const releases = ghJson(root, ['release', 'list', '--repo', repository, '--limit', '100', '--json', 'tagName,name,isDraft,isPrerelease'])
|
|
106
|
+
const releasePrs = ghJson(root, ['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title'])
|
|
107
|
+
const packageVersions = localPackageVersions(root)
|
|
108
|
+
const plan = buildReleaseRecoveryPlan({
|
|
109
|
+
tags: Array.isArray(tags) ? tags.map((tag) => tag.name).filter(Boolean) : [],
|
|
110
|
+
releases: Array.isArray(releases) ? releases : [],
|
|
111
|
+
releasePrs: Array.isArray(releasePrs) ? releasePrs.filter((pr) => /^chore\(main\): release /.test(pr.title ?? '')) : [],
|
|
112
|
+
packageVersions,
|
|
113
|
+
})
|
|
114
|
+
console.log(JSON.stringify(plan, null, 2))
|
|
115
|
+
return plan
|
|
116
|
+
}
|
|
117
|
+
|
|
48
118
|
/** @param {string} root @param {string[]} args @returns {string} */
|
|
49
119
|
function git(root, args) {
|
|
50
120
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' })
|
|
@@ -58,6 +128,32 @@ function diffNames(root, from, to) {
|
|
|
58
128
|
return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
|
|
59
129
|
}
|
|
60
130
|
|
|
131
|
+
/** @param {string} root @param {string[]} args @returns {unknown} */
|
|
132
|
+
function ghJson(root, args) {
|
|
133
|
+
const result = spawnSync('gh', args, { cwd: resolve(root), encoding: 'utf8', env: { ...process.env, GH_TOKEN: process.env.RELEASE_PLEASE_TOKEN } })
|
|
134
|
+
if (result.status !== 0) return []
|
|
135
|
+
try { return JSON.parse(result.stdout) }
|
|
136
|
+
catch { return [] }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} root @returns {string[]} */
|
|
140
|
+
function localPackageVersions(root) {
|
|
141
|
+
const versions = []
|
|
142
|
+
const packageJson = join(resolve(root), 'package.json')
|
|
143
|
+
if (existsSync(packageJson)) {
|
|
144
|
+
try { versions.push(JSON.parse(readFileSync(packageJson, 'utf8')).version) } catch { /* doctor handles malformed manifests */ }
|
|
145
|
+
}
|
|
146
|
+
/** @type {Array<[string, RegExp]>} */
|
|
147
|
+
const manifests = [['Cargo.toml', /^version\s*=\s*["']([^"']+)["']/m], ['pyproject.toml', /^(?:version|version\s*)\s*=\s*["']([^"']+)["']/m]]
|
|
148
|
+
for (const [file, pattern] of manifests) {
|
|
149
|
+
const path = join(resolve(root), file)
|
|
150
|
+
if (!existsSync(path)) continue
|
|
151
|
+
const match = readFileSync(path, 'utf8').match(pattern)
|
|
152
|
+
if (match?.[1]) versions.push(match[1])
|
|
153
|
+
}
|
|
154
|
+
return versions.filter(Boolean)
|
|
155
|
+
}
|
|
156
|
+
|
|
61
157
|
/** @param {string} root */
|
|
62
158
|
export function releaseConfigExists(root) {
|
|
63
159
|
return existsSync(join(resolve(root), 'release-please-config.json'))
|
package/src/commands/sync.mjs
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { dirname, join, resolve } from 'node:path'
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
|
-
import { detectLanguages, detectPackageManager, detectProfile } from '../lib/profile.mjs'
|
|
6
|
+
import { detectLanguages, detectPackageManager, detectProfile, recommendRunners } from '../lib/profile.mjs'
|
|
7
7
|
import { configured, includesValue, readConfig } from '../lib/config.mjs'
|
|
8
8
|
import { buildReleaseConfig } from '../lib/release-manifest.mjs'
|
|
9
|
+
import { customWorkflowFiles, overlayPolicy } from '../lib/overlay.mjs'
|
|
9
10
|
|
|
10
11
|
const standardFiles = [
|
|
11
12
|
'.editorconfig', '.gitattributes', '.gitignore', 'release-please-config.json',
|
|
13
|
+
'docs/EXTENSIONS.md',
|
|
12
14
|
'.githooks/pre-commit', 'AGENTS.md', 'LICENSE', 'NOTICE', 'ruff.toml', '.prettierrc',
|
|
13
15
|
'.github/CODEOWNERS', '.github/CODE_OF_CONDUCT.md', '.github/CONTRIBUTING.md',
|
|
14
16
|
'.github/PULL_REQUEST_TEMPLATE.md', '.github/SECURITY.md', '.github/dependabot.yml',
|
|
@@ -16,7 +18,7 @@ const standardFiles = [
|
|
|
16
18
|
'.github/ISSUE_TEMPLATE/feature_request.yml',
|
|
17
19
|
'.github/workflows/ci.yml', '.github/workflows/codeql.yml', '.github/workflows/draft-pr.yml',
|
|
18
20
|
'.github/workflows/release-pr.yml', '.github/workflows/release.yml',
|
|
19
|
-
'.github/workflows/security.yml', '.github/workflows/test.yml',
|
|
21
|
+
'.github/workflows/security.yml', '.github/workflows/test.yml', '.github/workflows/opencode-security.yml',
|
|
20
22
|
]
|
|
21
23
|
|
|
22
24
|
const protectedFiles = new Set([
|
|
@@ -64,6 +66,7 @@ export function syncRepository(options) {
|
|
|
64
66
|
const runtimeRepository = configured(config.runtime_repository, '0xPlayerOne/code-foundry')
|
|
65
67
|
const runtimeRef = configured(config.runtime_ref, `v${readPackageVersion(source)}`)
|
|
66
68
|
const toolchain = configured(config.toolchain, 'auto')
|
|
69
|
+
const overlays = overlayPolicy(target, config)
|
|
67
70
|
if (!['auto', 'native', 'mise'].includes(toolchain)) {
|
|
68
71
|
throw new Error(`Unsupported toolchain: ${toolchain}; use auto, native, or mise.`)
|
|
69
72
|
}
|
|
@@ -71,7 +74,7 @@ export function syncRepository(options) {
|
|
|
71
74
|
const changed = []
|
|
72
75
|
|
|
73
76
|
for (const file of standardFiles) {
|
|
74
|
-
if (!shouldInclude(file, languages, features)) continue
|
|
77
|
+
if (!shouldInclude(file, languages, features, config)) continue
|
|
75
78
|
const sourceFile = sourcePath(source, file)
|
|
76
79
|
if (!existsSync(sourceFile)) throw new Error(`Template file missing: ${file}`)
|
|
77
80
|
const destination = join(target, file)
|
|
@@ -134,6 +137,10 @@ export function syncRepository(options) {
|
|
|
134
137
|
git(target, ['config', 'core.hooksPath', '.githooks'])
|
|
135
138
|
}
|
|
136
139
|
console.log(`${changed.length} baseline file(s) differ.`)
|
|
140
|
+
if (overlays.custom_workflows === 'preserve') {
|
|
141
|
+
const custom = customWorkflowFiles(target, standardFiles)
|
|
142
|
+
if (custom.length) console.log(`Preserved ${custom.length} repository-owned workflow(s).`)
|
|
143
|
+
}
|
|
137
144
|
return { changed, config }
|
|
138
145
|
}
|
|
139
146
|
|
|
@@ -152,11 +159,12 @@ function renderReleaseConfig(target, sourceFile) {
|
|
|
152
159
|
return `${JSON.stringify(buildReleaseConfig(target, merged), null, 2)}\n`
|
|
153
160
|
}
|
|
154
161
|
|
|
155
|
-
/** @param {string} file @param {string} languages @param {string} features */
|
|
156
|
-
function shouldInclude(file, languages, features) {
|
|
162
|
+
/** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
|
|
163
|
+
function shouldInclude(file, languages, features, config) {
|
|
157
164
|
if (file === 'ruff.toml') return includesValue(languages, 'python')
|
|
158
165
|
if (file === '.prettierrc') return includesValue(languages, 'typescript')
|
|
159
166
|
if (file === '.github/dependabot.yml') return includesValue(features, 'dependabot')
|
|
167
|
+
if (file === '.github/workflows/opencode-security.yml') return ['true', 'auto'].includes(config.opencode_security ?? 'false')
|
|
160
168
|
const workflow = file.match(/^\.github\/workflows\/([^/]+)\.yml$/)?.[1]
|
|
161
169
|
return !workflow || includesValue(features, workflow)
|
|
162
170
|
}
|
|
@@ -235,14 +243,17 @@ function buffersEqual(a, b) { return a.equals(b) }
|
|
|
235
243
|
function createDefaultConfig(root, source) {
|
|
236
244
|
const languages = detectLanguages(root).join(',')
|
|
237
245
|
const packageManager = detectPackageManager(root)
|
|
246
|
+
const runners = recommendRunners(root)
|
|
238
247
|
return {
|
|
239
248
|
version: '1', profile: detectProfile(root), languages, features: 'all', codeql: 'auto', dependency_review: 'auto', package_manager: packageManager,
|
|
240
249
|
runtime_repository: '0xPlayerOne/code-foundry', runtime_ref: `v${readPackageVersion(source)}`,
|
|
241
|
-
|
|
250
|
+
...runners,
|
|
242
251
|
toolchain: 'auto',
|
|
243
|
-
security_runner: 'ubuntu-slim', codeql_runner: 'ubuntu-latest', pr_runner: 'ubuntu-slim', release_runner: 'ubuntu-slim',
|
|
244
252
|
prune_standard: 'false', cache_packages: 'auto', cache_build: 'auto', coverage_minimum: '80', turbo_remote: 'auto',
|
|
245
253
|
release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node', npm_publish: 'false',
|
|
254
|
+
post_release: 'false', post_release_workflow: '', post_release_mode: 'auto',
|
|
255
|
+
opencode_security: 'false',
|
|
256
|
+
sync_mode: 'overlay', custom_workflows: 'preserve',
|
|
246
257
|
license: existsSync(join(root, 'LICENSE')) ? 'preserve' : 'gpl-3.0-or-later', git_workflow: 'staging-release', merge_strategy: 'rebase',
|
|
247
258
|
}
|
|
248
259
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
/** @param {string} root @returns {{ errors: string[], warnings: string[], details: Record<string, unknown> }} */
|
|
8
|
+
export function doctorGithub(root) {
|
|
9
|
+
const repository = process.env.GITHUB_REPOSITORY || remoteRepository(root)
|
|
10
|
+
if (!repository) throw new Error('Unable to determine GitHub repository; set GITHUB_REPOSITORY.')
|
|
11
|
+
if (!commandExists('gh')) throw new Error('GitHub-aware doctor requires the `gh` CLI.')
|
|
12
|
+
/** @type {string[]} */
|
|
13
|
+
const errors = []
|
|
14
|
+
/** @type {string[]} */
|
|
15
|
+
const warnings = []
|
|
16
|
+
/** @type {Record<string, any>} */
|
|
17
|
+
const details = { repository }
|
|
18
|
+
const protection = ghJson(['api', `repos/${repository}/branches/main/protection`])
|
|
19
|
+
if (!protection) warnings.push('main branch protection is not readable or is not configured.')
|
|
20
|
+
else {
|
|
21
|
+
const required = [
|
|
22
|
+
...(protection.required_status_checks?.contexts ?? []),
|
|
23
|
+
...(protection.required_status_checks?.checks ?? []).map(/** @param {any} check */ (check) => check.context),
|
|
24
|
+
].filter(Boolean)
|
|
25
|
+
details.requiredChecks = required
|
|
26
|
+
const duplicateNames = required.filter((name) => /\b([^/]+) \/ \1\b/i.test(name))
|
|
27
|
+
if (duplicateNames.length) errors.push(`required checks contain duplicate workflow prefixes: ${duplicateNames.join(', ')}`)
|
|
28
|
+
if (!protection.required_status_checks) warnings.push('main protection has no required status checks.')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sha = String(ghJson(['api', `repos/${repository}/git/ref/heads/main`])?.object?.sha ?? '')
|
|
32
|
+
const checks = sha ? ghJson(['api', `repos/${repository}/commits/${sha}/check-runs?per_page=100`])?.check_runs ?? [] : []
|
|
33
|
+
const observed = checks.map(/** @param {any} check */ (check) => check.name).filter(Boolean)
|
|
34
|
+
details.observedChecks = observed
|
|
35
|
+
if (!observed.length) warnings.push('no check runs were observed on the current main commit; exact check validation is deferred until CI runs.')
|
|
36
|
+
if (protection?.required_status_checks) {
|
|
37
|
+
const required = [
|
|
38
|
+
...(protection.required_status_checks.contexts ?? []),
|
|
39
|
+
...(protection.required_status_checks.checks ?? []).map(/** @param {any} check */ (check) => check.context),
|
|
40
|
+
].filter(Boolean)
|
|
41
|
+
const missing = required.filter((name) => observed.length && !observed.includes(name))
|
|
42
|
+
if (missing.length) warnings.push(`required checks not observed on current main: ${missing.join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const workflowIssues = inspectWorkflows(root)
|
|
46
|
+
errors.push(...workflowIssues.errors)
|
|
47
|
+
warnings.push(...workflowIssues.warnings)
|
|
48
|
+
|
|
49
|
+
const secrets = ghJson(['secret', 'list', '--repo', repository, '--json', 'name'])
|
|
50
|
+
const secretNames = Array.isArray(secrets) ? secrets.map(/** @param {any} secret */ (secret) => secret.name) : []
|
|
51
|
+
details.secrets = { releasePleaseTokenPresent: secretNames.includes('RELEASE_PLEASE_TOKEN') }
|
|
52
|
+
if (!details.secrets.releasePleaseTokenPresent) warnings.push('RELEASE_PLEASE_TOKEN is not configured; Release Please auto-merge and direct post-release dispatch are unavailable.')
|
|
53
|
+
|
|
54
|
+
const config = readConfig(root)
|
|
55
|
+
if (['workflow-dispatch', 'dispatch'].includes(config.post_release_mode) && config.post_release !== 'false' && !details.secrets.releasePleaseTokenPresent) {
|
|
56
|
+
errors.push('post-release workflow-dispatch mode requires RELEASE_PLEASE_TOKEN to be present.')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const prs = ghJson(['pr', 'list', '--repo', repository, '--state', 'open', '--base', 'main', '--json', 'number,title,headRefName'])
|
|
60
|
+
const openPrs = Array.isArray(prs) ? prs : []
|
|
61
|
+
details.openPromotionPrs = openPrs.filter(/** @param {any} pr */ (pr) => pr.headRefName === 'staging' || /promote staging/i.test(pr.title))
|
|
62
|
+
details.openReleasePrs = openPrs.filter(/** @param {any} pr */ (pr) => String(pr.headRefName).startsWith('release-please--') || /^chore\(main\): release /.test(pr.title))
|
|
63
|
+
if (details.openPromotionPrs.length > 1) errors.push('multiple staging promotion PRs are open.')
|
|
64
|
+
if (details.openReleasePrs.length > 1) errors.push('multiple Release Please PRs are open.')
|
|
65
|
+
return { errors, warnings, details }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {string} root @returns {{ errors: string[], warnings: string[] }} */
|
|
69
|
+
function inspectWorkflows(root) {
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const errors = []
|
|
72
|
+
/** @type {string[]} */
|
|
73
|
+
const warnings = []
|
|
74
|
+
const directory = join(root, '.github/workflows')
|
|
75
|
+
/** @type {string[]} */
|
|
76
|
+
let files = []
|
|
77
|
+
try { files = readdirSync(directory).filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) }
|
|
78
|
+
catch { return { errors: ['.github/workflows is missing.'], warnings } }
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const content = readFileSync(join(directory, file), 'utf8')
|
|
81
|
+
if (!/^permissions:\s*$/m.test(content) && /uses:.*\.github\/workflows\//.test(content)) warnings.push(`${file} does not declare top-level permissions.`)
|
|
82
|
+
if (file === 'release.yml' && /contents:\s+write/.test(content) && !/pull-requests:\s+write/.test(content)) errors.push('release.yml needs pull-requests: write for guarded Release Please PR handling.')
|
|
83
|
+
}
|
|
84
|
+
return { errors, warnings }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} root @returns {string} */
|
|
88
|
+
function remoteRepository(root) {
|
|
89
|
+
const result = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8' })
|
|
90
|
+
const value = result.status === 0 ? result.stdout.trim() : ''
|
|
91
|
+
return value.replace(/^git@github\.com:/, '').replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string[]} args @returns {any} */
|
|
95
|
+
function ghJson(args) {
|
|
96
|
+
const result = spawnSync('gh', args, { encoding: 'utf8' })
|
|
97
|
+
if (result.status !== 0) return null
|
|
98
|
+
try { return JSON.parse(result.stdout) }
|
|
99
|
+
catch { return null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} command */
|
|
103
|
+
function commandExists(command) { return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0 }
|
|
104
|
+
|
|
105
|
+
/** @param {string} root @returns {Record<string, string>} */
|
|
106
|
+
function readConfig(root) {
|
|
107
|
+
const file = join(root, '.github/code-foundry.yml')
|
|
108
|
+
try {
|
|
109
|
+
return Object.fromEntries(readFileSync(file, 'utf8').split(/\r?\n/).flatMap((line) => {
|
|
110
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/)
|
|
111
|
+
return match ? [[match[1], match[2].replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')]] : []
|
|
112
|
+
}))
|
|
113
|
+
} catch { return {} }
|
|
114
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** @param {string[]} standardFiles @param {string} file */
|
|
7
|
+
export function isManagedPath(standardFiles, file) {
|
|
8
|
+
return standardFiles.includes(file)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @param {string} root @param {string[]} standardFiles @returns {string[]} */
|
|
12
|
+
export function customWorkflowFiles(root, standardFiles) {
|
|
13
|
+
const directory = join(root, '.github/workflows')
|
|
14
|
+
if (!existsSync(directory)) return []
|
|
15
|
+
return readdirSync(directory)
|
|
16
|
+
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'))
|
|
17
|
+
.map((file) => `.github/workflows/${file}`)
|
|
18
|
+
.filter((file) => !isManagedPath(standardFiles, file))
|
|
19
|
+
.sort()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} root @param {Record<string, string>} config */
|
|
23
|
+
export function overlayPolicy(root, config) {
|
|
24
|
+
const mode = config.sync_mode ?? 'overlay'
|
|
25
|
+
const custom = config.custom_workflows ?? 'preserve'
|
|
26
|
+
if (!['overlay', 'strict'].includes(mode)) throw new Error(`Unsupported sync_mode: ${mode}; use overlay or strict.`)
|
|
27
|
+
if (custom !== 'preserve') throw new Error(`Unsupported custom_workflows: ${custom}; custom workflows are always preserved.`)
|
|
28
|
+
return { mode, custom_workflows: custom }
|
|
29
|
+
}
|
package/src/lib/profile.mjs
CHANGED
|
@@ -102,6 +102,32 @@ export function resolveProfile(root) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Recommend runners from detected workload cost. Slim is reserved for short
|
|
107
|
+
* metadata/security/PR jobs; native toolchains and browser/contract tests use
|
|
108
|
+
* the full runner.
|
|
109
|
+
* @param {string} root
|
|
110
|
+
*/
|
|
111
|
+
export function recommendRunners(root) {
|
|
112
|
+
const languages = detectLanguages(root)
|
|
113
|
+
const heavy = languages.some((language) => ['rust', 'python', 'solidity'].includes(language)) || hasBrowserProject(root)
|
|
114
|
+
return {
|
|
115
|
+
runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
116
|
+
ci_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
117
|
+
test_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
118
|
+
unit_runner: heavy ? 'ubuntu-latest' : 'ubuntu-slim',
|
|
119
|
+
security_runner: 'ubuntu-slim',
|
|
120
|
+
codeql_runner: 'ubuntu-latest',
|
|
121
|
+
pr_runner: 'ubuntu-slim',
|
|
122
|
+
release_runner: 'ubuntu-slim',
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} root */
|
|
127
|
+
function hasBrowserProject(root) {
|
|
128
|
+
return ['playwright.config.ts', 'playwright.config.js', 'cypress.config.ts', 'cypress.config.js'].some((file) => existsSync(join(root, file)))
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
/** @param {string} root */
|
|
106
132
|
function detectReleaseType(root) {
|
|
107
133
|
if (existsSync(join(root, 'package.json'))) return 'node'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
/** @typedef {'disabled'|'workflow-dispatch'|'release-event'|'unavailable'} HookDelivery */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Select exactly one post-release delivery mechanism.
|
|
9
|
+
* @param {{ mode?: string, tokenPresent: boolean, releaseEventEnabled?: boolean }} input
|
|
10
|
+
* @returns {{ delivery: HookDelivery, reason: string }}
|
|
11
|
+
*/
|
|
12
|
+
export function selectHookDelivery(input) {
|
|
13
|
+
const mode = input.mode ?? 'auto'
|
|
14
|
+
if (mode === 'false' || mode === 'disabled') return { delivery: 'disabled', reason: 'Post-release hooks are disabled by configuration.' }
|
|
15
|
+
if (mode === 'workflow-dispatch' || (mode === 'auto' && input.tokenPresent)) {
|
|
16
|
+
return input.tokenPresent
|
|
17
|
+
? { delivery: 'workflow-dispatch', reason: 'A PAT is available; use one explicit workflow dispatch.' }
|
|
18
|
+
: { delivery: 'unavailable', reason: 'workflow-dispatch requires a PAT.' }
|
|
19
|
+
}
|
|
20
|
+
if (mode === 'release-event' || (mode === 'auto' && input.releaseEventEnabled !== false)) {
|
|
21
|
+
return { delivery: 'release-event', reason: 'Use the published-release event; do not issue a fallback dispatch.' }
|
|
22
|
+
}
|
|
23
|
+
return { delivery: 'unavailable', reason: 'No supported post-release delivery mechanism is configured.' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} repository @param {string} tag @returns {string} */
|
|
27
|
+
export function releaseDeliveryKey(repository, tag) {
|
|
28
|
+
return createHash('sha256').update(`${repository}\0${tag}`).digest('hex').slice(0, 24)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A workflow dispatch is considered already delivered when a run exists for
|
|
33
|
+
* the release tag. Completed failures remain recorded so retries are explicit
|
|
34
|
+
* rather than accidentally duplicated by a second event path.
|
|
35
|
+
* @param {Array<{headBranch?: string, displayTitle?: string, status?: string}>} runs
|
|
36
|
+
* @param {string} tag
|
|
37
|
+
*/
|
|
38
|
+
export function hasDeliveredHook(runs, tag) {
|
|
39
|
+
return runs.some((run) => run.headBranch === tag || run.displayTitle?.includes(`release-tag=${tag}`))
|
|
40
|
+
}
|
|
@@ -70,6 +70,81 @@ export function unexpectedReleasePaths(paths, allowed) {
|
|
|
70
70
|
.filter((path) => !allowed.has(path))
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/** @param {{ releasePleaseToken?: string, githubToken?: string }} input */
|
|
74
|
+
export function selectReleaseCredential(input) {
|
|
75
|
+
if (input.releasePleaseToken) return { token: input.releasePleaseToken, source: 'release-please-token', autoMerge: true }
|
|
76
|
+
if (input.githubToken) return { token: input.githubToken, source: 'github-token', autoMerge: false }
|
|
77
|
+
return { token: '', source: 'missing', autoMerge: false }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {Array<{number?: number, title?: string, headRefName?: string}>} prs */
|
|
81
|
+
export function selectGeneratedReleasePrs(prs) {
|
|
82
|
+
return prs.filter((pr) => /^chore\(main\): release /.test(pr.title ?? '') && String(pr.headRefName ?? '').startsWith('release-please--branches--main'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validate all generated release PR diffs before any merge is attempted.
|
|
87
|
+
* @param {Array<{number?: number, title?: string, headRefName?: string}>} prs
|
|
88
|
+
* @param {Map<number, string[]>} changedPathsByPr
|
|
89
|
+
* @param {Set<string>} allowed
|
|
90
|
+
*/
|
|
91
|
+
export function validateReleasePullRequests(prs, changedPathsByPr, allowed) {
|
|
92
|
+
const errors = []
|
|
93
|
+
const generated = selectGeneratedReleasePrs(prs)
|
|
94
|
+
if (!generated.length) errors.push('Release Please reported a PR, but no generated release PR was found.')
|
|
95
|
+
for (const pr of generated) {
|
|
96
|
+
const number = Number(pr.number)
|
|
97
|
+
const paths = changedPathsByPr.get(number)
|
|
98
|
+
if (!Array.isArray(paths) || !paths.length) {
|
|
99
|
+
errors.push(`Generated release PR #${number} has no changed files or its diff is malformed.`)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
const unexpected = unexpectedReleasePaths(paths, allowed)
|
|
103
|
+
if (unexpected.length) errors.push(`Generated release PR #${number} contains unexpected paths: ${unexpected.join(', ')}`)
|
|
104
|
+
}
|
|
105
|
+
return { valid: errors.length === 0, generated, errors }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a non-destructive release recovery plan from independent release
|
|
110
|
+
* metadata sources.
|
|
111
|
+
* @param {{ tags: string[], releases: Array<{tagName?: string}>, releasePrs: Array<{number?: number,title?: string}>, packageVersions: string[] }} input
|
|
112
|
+
*/
|
|
113
|
+
export function buildReleaseRecoveryPlan(input) {
|
|
114
|
+
const tags = [...new Set(input.tags.filter((tag) => /^v?\d+\.\d+\.\d+/.test(tag)))]
|
|
115
|
+
/** @type {string[]} */
|
|
116
|
+
const releaseTags = [...new Set(input.releases.map((release) => release.tagName).filter((tag) => typeof tag === 'string'))]
|
|
117
|
+
const tagSet = new Set(tags)
|
|
118
|
+
const releaseSet = new Set(releaseTags)
|
|
119
|
+
const missingGitHubReleases = tags.filter((tag) => !releaseSet.has(tag) && !releaseSet.has(tag.replace(/^v/, '')))
|
|
120
|
+
const orphanGitHubReleases = releaseTags.filter((tag) => !tagSet.has(tag) && !tagSet.has(tag.replace(/^v/, '')))
|
|
121
|
+
const latestTag = [...tags].sort(compareVersions).at(-1) ?? ''
|
|
122
|
+
const latestPackageVersion = [...input.packageVersions].sort(compareVersions).at(-1) ?? ''
|
|
123
|
+
return {
|
|
124
|
+
latestTag,
|
|
125
|
+
latestPackageVersion,
|
|
126
|
+
missingGitHubReleases,
|
|
127
|
+
orphanGitHubReleases,
|
|
128
|
+
pendingReleasePullRequests: input.releasePrs,
|
|
129
|
+
packageVersionMismatch: Boolean(latestTag && latestPackageVersion && normalizeVersion(latestTag) !== normalizeVersion(latestPackageVersion)),
|
|
130
|
+
actions: [
|
|
131
|
+
...missingGitHubReleases.map((tag) => `create GitHub release metadata for ${tag}`),
|
|
132
|
+
...(latestTag && latestPackageVersion && normalizeVersion(latestTag) !== normalizeVersion(latestPackageVersion) ? [`review package/tag mismatch (${latestPackageVersion} vs ${latestTag})`] : []),
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {string} value */
|
|
138
|
+
function normalizeVersion(value) { return value.replace(/^v/, '').split('-')[0] }
|
|
139
|
+
|
|
140
|
+
/** @param {string} a @param {string} b */
|
|
141
|
+
function compareVersions(a, b) {
|
|
142
|
+
const left = normalizeVersion(a).split('.').map(Number)
|
|
143
|
+
const right = normalizeVersion(b).split('.').map(Number)
|
|
144
|
+
for (let index = 0; index < 3; index += 1) if ((left[index] ?? 0) !== (right[index] ?? 0)) return (left[index] ?? 0) - (right[index] ?? 0)
|
|
145
|
+
return a.localeCompare(b)
|
|
146
|
+
}
|
|
147
|
+
|
|
73
148
|
/**
|
|
74
149
|
* Classify the relationship between main and staging. A fast-forward is safe
|
|
75
150
|
* only when main is the ancestor of staging or staging is the ancestor of main
|