fg-devkit 1.9.1 → 1.12.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.
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
3
  # Interactive or scripted release for this package: bumps version with pnpm, pushes commits/tags,
4
- # and creates a GitHub Release with generated notes (requires gh CLI, authenticated).
4
+ # and creates a GitHub Release (requires gh CLI, authenticated).
5
+ #
6
+ # Repos with cliff.toml / CHANGELOG files run git-cliff generation and an interactive
7
+ # verification step before commit. Others use gh --generate-notes.
5
8
  #
6
9
  # Usage:
7
- # ./scripts/release.sh [patch|minor|major] # stable release (default mode)
8
- # ./scripts/release.sh [patch|minor|major] candidate # prerelease tag X.Y.Z-rc.N
9
- # ./scripts/release.sh candidate [patch|minor|major]
10
+ # ./scripts/release.sh [patch|minor|major] [--skip-release-notes]
11
+ # ./scripts/release.sh [patch|minor|major] candidate [--skip-release-notes]
10
12
  #
11
13
  # Candidate tags follow SemVer prerelease format: <version>-rc.<number> (e.g. 2.8.0-rc.1).
12
14
 
@@ -14,14 +16,17 @@ set -euo pipefail
14
16
 
15
17
  bump_type=""
16
18
  release_mode="release"
19
+ skip_release_notes="${FG_DEVKIT_SKIP_RELEASE_NOTES:-0}"
20
+ use_release_notes=0
17
21
 
18
22
  for arg in "$@"; do
19
23
  case "$arg" in
20
24
  patch | minor | major) bump_type="$arg" ;;
21
25
  release | candidate) release_mode="$arg" ;;
26
+ --skip-release-notes) skip_release_notes=1 ;;
22
27
  *)
23
28
  echo "Unknown argument: $arg"
24
- echo "Usage: ./scripts/release.sh [patch|minor|major] [release|candidate]"
29
+ echo "Usage: ./scripts/release.sh [patch|minor|major] [release|candidate] [--skip-release-notes]"
25
30
  exit 1
26
31
  ;;
27
32
  esac
@@ -80,8 +85,142 @@ sync_fg_devkit_package_files() {
80
85
  fi
81
86
  }
82
87
 
88
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
89
+
90
+ has_release_notes_config() {
91
+ [[ -f cliff.toml || -f cliff.user.toml || -f CHANGELOG.md || -f CHANGELOG.user.md ]]
92
+ }
93
+
94
+ generate_release_notes() {
95
+ local tag="$1"
96
+ if [[ "$skip_release_notes" == "1" ]]; then
97
+ return 0
98
+ fi
99
+ if ! has_release_notes_config; then
100
+ return 0
101
+ fi
102
+
103
+ local args=()
104
+ if [[ -n "$tag" ]]; then
105
+ args+=("--tag=${tag}")
106
+ fi
107
+
108
+ node "${script_dir}/generate-release-notes.mjs" "${args[@]}"
109
+ }
110
+
111
+ discard_release_notes_changes() {
112
+ for file in CHANGELOG.md CHANGELOG.user.md CHANGELOG.user.draft.md; do
113
+ if [[ ! -f "$file" ]]; then
114
+ continue
115
+ fi
116
+
117
+ if git ls-files --error-unmatch "$file" >/dev/null 2>&1; then
118
+ git checkout -- "$file"
119
+ else
120
+ rm -f "$file"
121
+ fi
122
+ done
123
+ }
124
+
125
+ verify_release_notes() {
126
+ if [[ "$skip_release_notes" == "1" ]]; then
127
+ return 0
128
+ fi
129
+ if ! has_release_notes_config; then
130
+ return 0
131
+ fi
132
+
133
+ set +e
134
+ FG_DEVKIT_RELEASE_TAG="${new_tag:-}" node "${script_dir}/verify-release-notes.mjs"
135
+ local code=$?
136
+ set -e
137
+
138
+ if [[ "$code" -eq 2 ]]; then
139
+ skip_release_notes=1
140
+ use_release_notes=0
141
+ discard_release_notes_changes
142
+ echo "Release notes skipped. Using GitHub-generated notes."
143
+ return 0
144
+ fi
145
+
146
+ return "$code"
147
+ }
148
+
149
+ stage_release_notes() {
150
+ if [[ "$use_release_notes" != "1" ]]; then
151
+ return 0
152
+ fi
153
+
154
+ for file in CHANGELOG.md CHANGELOG.user.md CHANGELOG.user.draft.md; do
155
+ if [[ -f "$file" ]]; then
156
+ git add "$file"
157
+ fi
158
+ done
159
+ }
160
+
161
+ run_release_notes_phase() {
162
+ local tag="$1"
163
+
164
+ if [[ "$skip_release_notes" == "1" ]]; then
165
+ echo "Skipping release notes."
166
+ return 0
167
+ fi
168
+
169
+ if ! has_release_notes_config; then
170
+ return 0
171
+ fi
172
+
173
+ use_release_notes=1
174
+ generate_release_notes "$tag"
175
+ verify_release_notes
176
+ stage_release_notes
177
+ }
178
+
179
+ create_github_release() {
180
+ local tag="$1"
181
+ local prerelease_flag="${2:-}"
182
+
183
+ if [[ "$use_release_notes" == "1" ]] && [[ -f CHANGELOG.md ]]; then
184
+ if [[ -n "$prerelease_flag" ]]; then
185
+ gh release create "$tag" \
186
+ --prerelease \
187
+ --title "$tag" \
188
+ --notes-file CHANGELOG.md
189
+ else
190
+ gh release create "$tag" \
191
+ --latest \
192
+ --title "$tag" \
193
+ --notes-file CHANGELOG.md
194
+ fi
195
+ return 0
196
+ fi
197
+
198
+ if [[ -n "$previous_tag" ]]; then
199
+ if [[ -n "$prerelease_flag" ]]; then
200
+ gh release create "$tag" \
201
+ --prerelease \
202
+ --generate-notes \
203
+ --notes-start-tag "$previous_tag"
204
+ else
205
+ gh release create "$tag" \
206
+ --latest \
207
+ --generate-notes \
208
+ --notes-start-tag "$previous_tag"
209
+ fi
210
+ else
211
+ if [[ -n "$prerelease_flag" ]]; then
212
+ gh release create "$tag" \
213
+ --prerelease \
214
+ --generate-notes
215
+ else
216
+ gh release create "$tag" \
217
+ --latest \
218
+ --generate-notes
219
+ fi
220
+ fi
221
+ }
222
+
83
223
  if [[ "$release_mode" == "candidate" ]]; then
84
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
85
224
  new_version="$(node "${script_dir}/resolve-candidate-version.mjs" "$bump_type")"
86
225
 
87
226
  node -e "
@@ -94,22 +233,16 @@ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
94
233
  sync_fg_devkit_package_files
95
234
 
96
235
  new_tag="v${new_version}"
236
+
237
+ run_release_notes_phase "$new_tag"
238
+
97
239
  git commit -m "${new_tag}"
98
240
  git tag "${new_tag}"
99
241
 
100
242
  git push
101
243
  git push --tags
102
244
 
103
- if [[ -n "$previous_tag" ]]; then
104
- gh release create "$new_tag" \
105
- --prerelease \
106
- --generate-notes \
107
- --notes-start-tag "$previous_tag"
108
- else
109
- gh release create "$new_tag" \
110
- --prerelease \
111
- --generate-notes
112
- fi
245
+ create_github_release "$new_tag" "--prerelease"
113
246
 
114
247
  echo "Candidate release created: ${new_tag}"
115
248
  exit 0
@@ -122,21 +255,14 @@ new_tag="v${new_version}"
122
255
 
123
256
  sync_fg_devkit_package_files
124
257
 
258
+ run_release_notes_phase "$new_tag"
259
+
125
260
  git commit -m "${new_tag}"
126
261
  git tag "${new_tag}"
127
262
 
128
263
  git push
129
264
  git push --tags
130
265
 
131
- if [[ -n "$previous_tag" ]]; then
132
- gh release create "$new_tag" \
133
- --latest \
134
- --generate-notes \
135
- --notes-start-tag "$previous_tag"
136
- else
137
- gh release create "$new_tag" \
138
- --latest \
139
- --generate-notes
140
- fi
266
+ create_github_release "$new_tag"
141
267
 
142
268
  echo "Release created: ${new_tag}"
@@ -16,6 +16,7 @@ const REQUIRED_SCRIPTS = {
16
16
  'lint:ts': 'pnpm exec fgd typecheck',
17
17
  lint: 'pnpm exec fgd lint --fix .',
18
18
  'lint-check': 'pnpm exec fgd lint .',
19
+ 'generate:release-notes': 'pnpm exec fgd generate-release-notes',
19
20
  }
20
21
 
21
22
  function main() {
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Only updates keys that already exist in package.json (does not add new dependencies from the policy).
6
6
  * Aligns managedDependencies, managedAnywhere (only where the package is already declared), optional
7
- * policy overrides into pnpm.overrides, and logs each change.
7
+ * policy overrides into pnpm.overrides, toolchain.packageManager into package.json, and logs each change.
8
8
  *
9
9
  * Refuses to run if the policy file contains any non-pinned versions.
10
10
  *
@@ -16,6 +16,10 @@ import path from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
17
 
18
18
  import { collectPinViolations } from './dependency-policy-pins.mjs'
19
+ import {
20
+ collectToolchainPolicyViolations,
21
+ resolveExpectedPackageManager,
22
+ } from './dependency-policy-toolchain.mjs'
19
23
  import { resolveDependencyPolicyPath } from './resolve-dependency-policy-path.mjs'
20
24
 
21
25
  const cwd = process.cwd()
@@ -44,6 +48,7 @@ const policyErrors = [
44
48
  collectPinViolations(`managedDependencies.${section}.`, deps),
45
49
  ),
46
50
  ...collectPinViolations('managedAnywhere.', policy.managedAnywhere ?? {}),
51
+ ...collectToolchainPolicyViolations(policy),
47
52
  ]
48
53
 
49
54
  if (policyErrors.length > 0) {
@@ -136,6 +141,12 @@ for (const [name, version] of Object.entries(getRelevantPolicyOverrides(policy.o
136
141
  }
137
142
  }
138
143
 
144
+ const expectedPackageManager = resolveExpectedPackageManager(policy, path.resolve(scriptDir, '..'))
145
+ if (expectedPackageManager !== undefined && pkg.packageManager !== expectedPackageManager) {
146
+ changes.push(`packageManager: ${pkg.packageManager ?? '(missing)'} -> ${expectedPackageManager}`)
147
+ pkg.packageManager = expectedPackageManager
148
+ }
149
+
139
150
  if (changes.length === 0) {
140
151
  console.log('[sync-deps] package.json already matches dependency policy')
141
152
  process.exit(0)
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Copy default cliff configs into a consumer repo when missing and ensure the
4
+ * generate-release-notes npm script is present.
5
+ *
6
+ * Usage:
7
+ * node node_modules/fg-devkit/scripts/sync-release-notes-config.mjs
8
+ * pnpm exec fg-devkit sync-release-notes-config
9
+ */
10
+
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fgRoot } from './release-notes-common.mjs'
14
+
15
+ const REQUIRED_SCRIPT = 'generate:release-notes'
16
+ const REQUIRED_SCRIPT_VALUE = 'pnpm exec fgd generate-release-notes'
17
+
18
+ const CONFIG_FILES = ['cliff.toml', 'cliff.user.toml']
19
+
20
+ function copyIfMissing(fileName, cwd) {
21
+ const target = path.join(cwd, fileName)
22
+ if (fs.existsSync(target)) {
23
+ return null
24
+ }
25
+
26
+ const source = path.join(fgRoot, 'configs', fileName)
27
+ if (!fs.existsSync(source)) {
28
+ console.error(`[sync-release-notes-config] Missing template: ${source}`)
29
+ process.exit(1)
30
+ }
31
+
32
+ fs.copyFileSync(source, target)
33
+ return fileName
34
+ }
35
+
36
+ function ensureUserChangelog(cwd) {
37
+ const target = path.join(cwd, 'CHANGELOG.user.md')
38
+ if (fs.existsSync(target)) {
39
+ return null
40
+ }
41
+
42
+ fs.writeFileSync(target, '')
43
+ return 'CHANGELOG.user.md'
44
+ }
45
+
46
+ function main() {
47
+ const cwd = process.cwd()
48
+ const packageJsonPath = path.join(cwd, 'package.json')
49
+
50
+ if (!fs.existsSync(packageJsonPath)) {
51
+ console.error(`[sync-release-notes-config] package.json not found at ${packageJsonPath}`)
52
+ process.exit(1)
53
+ }
54
+
55
+ const copied = []
56
+ for (const fileName of CONFIG_FILES) {
57
+ const result = copyIfMissing(fileName, cwd)
58
+ if (result) {
59
+ copied.push(result)
60
+ }
61
+ }
62
+
63
+ const createdChangelog = ensureUserChangelog(cwd)
64
+ if (createdChangelog) {
65
+ copied.push(createdChangelog)
66
+ }
67
+
68
+ let pkg
69
+ try {
70
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
71
+ } catch (err) {
72
+ console.error(`[sync-release-notes-config] Failed to parse package.json: ${err.message}`)
73
+ process.exit(1)
74
+ }
75
+
76
+ pkg.scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
77
+ let scriptUpdated = false
78
+ if (pkg.scripts[REQUIRED_SCRIPT] !== REQUIRED_SCRIPT_VALUE) {
79
+ pkg.scripts[REQUIRED_SCRIPT] = REQUIRED_SCRIPT_VALUE
80
+ scriptUpdated = true
81
+ }
82
+
83
+ if (scriptUpdated) {
84
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
85
+ }
86
+
87
+ if (copied.length === 0 && !scriptUpdated) {
88
+ console.log('[sync-release-notes-config] Release-notes config already up to date.')
89
+ return
90
+ }
91
+
92
+ if (copied.length > 0) {
93
+ console.log('[sync-release-notes-config] Added files:')
94
+ for (const fileName of copied) {
95
+ console.log(` - ${fileName}`)
96
+ }
97
+ }
98
+
99
+ if (scriptUpdated) {
100
+ console.log(`[sync-release-notes-config] Updated script: ${REQUIRED_SCRIPT}`)
101
+ }
102
+ }
103
+
104
+ main()
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Aligns scaffold/vue/package.json dependency versions with dependency-policy.vue-workspace.json.
4
- * Also pins scaffold devDependency `fg-devkit` to the fg-devkit package version at publish time.
4
+ * Also pins scaffold devDependency `fg-devkit` to the fg-devkit package version at publish time,
5
+ * and syncs toolchain.packageManager into scaffold package.json and Dockerfile.local.
5
6
  *
6
7
  * Invoked automatically after refresh-dependency-policy-from-repos.mjs writes the policy, and
7
8
  * can be run manually: node …/sync-scaffold-vue-deps.mjs [policy-file]
@@ -12,13 +13,27 @@ import path from 'node:path'
12
13
  import { fileURLToPath } from 'node:url'
13
14
 
14
15
  import { resolveDependencyPolicyPath } from './resolve-dependency-policy-path.mjs'
16
+ import {
17
+ extractPnpmVersion,
18
+ resolveExpectedPackageManager,
19
+ syncPolicyToolchainFromFgDevkit,
20
+ } from './dependency-policy-toolchain.mjs'
15
21
 
16
22
  /**
17
23
  * @param {{ fgDevkitRoot: string, policyPath: string }} options
18
24
  */
19
25
  export function syncScaffoldVueDepsFromPolicy(options) {
20
26
  const { fgDevkitRoot, policyPath } = options
21
- const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'))
27
+ let policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'))
28
+ const { policy: syncedPolicy, changed: toolchainChanged } = syncPolicyToolchainFromFgDevkit(
29
+ policy,
30
+ fgDevkitRoot,
31
+ )
32
+ policy = syncedPolicy
33
+ if (toolchainChanged) {
34
+ fs.writeFileSync(policyPath, `${JSON.stringify(policy, null, 2)}\n`)
35
+ console.log('[sync-scaffold-vue-deps] Updated toolchain in', policyPath)
36
+ }
22
37
  const scaffoldPkgPath = path.join(fgDevkitRoot, 'scaffold/vue/package.json')
23
38
  if (!fs.existsSync(scaffoldPkgPath)) {
24
39
  console.warn('[sync-scaffold-vue-deps] No scaffold package at', scaffoldPkgPath)
@@ -53,6 +68,24 @@ export function syncScaffoldVueDepsFromPolicy(options) {
53
68
  applySection('dependencies')
54
69
  applySection('devDependencies')
55
70
 
71
+ const expectedPackageManager = resolveExpectedPackageManager(policy, fgDevkitRoot)
72
+ if (expectedPackageManager !== undefined) {
73
+ pkg.packageManager = expectedPackageManager
74
+ const dockerfilePath = path.join(fgDevkitRoot, 'scaffold/vue/Dockerfile.local')
75
+ const pnpmVersion = extractPnpmVersion(expectedPackageManager)
76
+ if (pnpmVersion && fs.existsSync(dockerfilePath)) {
77
+ const dockerfile = fs.readFileSync(dockerfilePath, 'utf8')
78
+ const updatedDockerfile = dockerfile.replace(
79
+ /corepack prepare pnpm@[^\s]+ --activate/,
80
+ `corepack prepare pnpm@${pnpmVersion} --activate`,
81
+ )
82
+ if (updatedDockerfile !== dockerfile) {
83
+ fs.writeFileSync(dockerfilePath, updatedDockerfile)
84
+ console.log('[sync-scaffold-vue-deps] Updated', dockerfilePath)
85
+ }
86
+ }
87
+ }
88
+
56
89
  fs.writeFileSync(scaffoldPkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
57
90
  console.log('[sync-scaffold-vue-deps] Updated', scaffoldPkgPath)
58
91
  }
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive verification step before committing release notes.
4
+ *
5
+ * Usage:
6
+ * node node_modules/fg-devkit/scripts/verify-release-notes.mjs
7
+ * pnpm exec fg-devkit verify-release-notes
8
+ *
9
+ * Exit codes: 0 = approved, 1 = aborted/error, 2 = skip release notes
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process'
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import confirm from '@inquirer/confirm'
16
+ import select from '@inquirer/select'
17
+ import {
18
+ hasReleaseNotesConfig,
19
+ readPreviewFiles,
20
+ runSyncUserChangelog,
21
+ } from './release-notes-common.mjs'
22
+
23
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
24
+ const cwd = process.cwd()
25
+
26
+ function showPreview() {
27
+ const previews = readPreviewFiles(cwd)
28
+
29
+ if (previews.length === 0) {
30
+ console.log('[fg-devkit] No changelog files to preview yet.')
31
+ return
32
+ }
33
+
34
+ for (const { file, content } of previews) {
35
+ const divider = '─'.repeat(Math.max(8, 50 - file.length))
36
+ console.log(`\n── ${file} ${divider}`)
37
+ console.log(content.trimEnd())
38
+ }
39
+ }
40
+
41
+ function runGenerateReleaseNotes() {
42
+ const generateScript = path.join(scriptDir, 'generate-release-notes.mjs')
43
+ const tagArg = process.env.FG_DEVKIT_RELEASE_TAG
44
+ const args = [generateScript]
45
+ if (tagArg) {
46
+ args.push(`--tag=${tagArg}`)
47
+ }
48
+
49
+ const r = spawnSync(process.execPath, args, {
50
+ stdio: 'inherit',
51
+ cwd,
52
+ env: process.env,
53
+ })
54
+
55
+ return r.status ?? 1
56
+ }
57
+
58
+ function openEditor(fileName) {
59
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? 'nano'
60
+ const filePath = path.join(cwd, fileName)
61
+ const r = spawnSync(editor, [filePath], {
62
+ stdio: 'inherit',
63
+ cwd,
64
+ env: process.env,
65
+ shell: false,
66
+ })
67
+
68
+ if (r.error) {
69
+ console.error(`[fg-devkit] Could not open ${editor}: ${r.error.message}`)
70
+ return 1
71
+ }
72
+
73
+ return r.status ?? 1
74
+ }
75
+
76
+ async function main() {
77
+ if (!hasReleaseNotesConfig(cwd)) {
78
+ console.log('[fg-devkit] No release-notes config; skipping verification.')
79
+ return 0
80
+ }
81
+
82
+ while (true) {
83
+ showPreview()
84
+
85
+ const action = await select({
86
+ message: 'Granska release notes innan commit',
87
+ choices: [
88
+ { name: 'Godkänn och fortsätt', value: 'accept' },
89
+ { name: 'Hoppa över release notes (GitHub-genererade notes)', value: 'skip' },
90
+ { name: 'Redigera CHANGELOG.user.draft.md i $EDITOR', value: 'edit-draft' },
91
+ { name: 'Redigera CHANGELOG.user.md i $EDITOR', value: 'edit-user' },
92
+ { name: 'Synka user changelog efter redigering', value: 'sync' },
93
+ { name: 'Generera om från commits', value: 'regen' },
94
+ { name: 'Avbryt release', value: 'abort' },
95
+ ],
96
+ default: 'accept',
97
+ loop: false,
98
+ })
99
+
100
+ if (action === 'abort') {
101
+ console.log('[fg-devkit] Release avbruten.')
102
+ return 1
103
+ }
104
+
105
+ if (action === 'skip') {
106
+ console.log('[fg-devkit] Release notes hoppas över.')
107
+ return 2
108
+ }
109
+
110
+ if (action === 'regen') {
111
+ const code = runGenerateReleaseNotes()
112
+ if (code !== 0) {
113
+ return code
114
+ }
115
+ continue
116
+ }
117
+
118
+ if (action === 'edit-draft') {
119
+ const code = openEditor('CHANGELOG.user.draft.md')
120
+ if (code !== 0) {
121
+ return code
122
+ }
123
+ const syncCode = runSyncUserChangelog(cwd)
124
+ if (syncCode !== 0) {
125
+ return syncCode
126
+ }
127
+ continue
128
+ }
129
+
130
+ if (action === 'edit-user') {
131
+ const code = openEditor('CHANGELOG.user.md')
132
+ if (code !== 0) {
133
+ return code
134
+ }
135
+ continue
136
+ }
137
+
138
+ if (action === 'sync') {
139
+ const syncCode = runSyncUserChangelog(cwd)
140
+ if (syncCode !== 0) {
141
+ return syncCode
142
+ }
143
+ continue
144
+ }
145
+
146
+ const ok = await confirm({
147
+ message: 'Commit:a version + changelogs och skapa GitHub-release?',
148
+ default: true,
149
+ })
150
+
151
+ if (ok) {
152
+ return 0
153
+ }
154
+ }
155
+ }
156
+
157
+ const code = await main()
158
+ process.exit(code)