@crossdelta/platform-sdk 0.22.0 → 0.22.1
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/CHANGELOG.md +14 -0
- package/bin/templates/workspace/.claude/rules/cloudevents.md +85 -0
- package/bin/templates/workspace/.github/README.md +67 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/action.yml +27 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/index.js +179 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/action.yml +21 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/index.js +370 -0
- package/bin/templates/workspace/.github/actions/prepare-build-context/action.yml +167 -0
- package/bin/templates/workspace/.github/actions/setup-bun-install/action.yml.hbs +57 -0
- package/bin/templates/workspace/.github/dependabot.yml +18 -0
- package/bin/templates/workspace/.github/workflows/build-and-deploy.yml.hbs +409 -0
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +83 -0
- package/bin/templates/workspace/.github/workflows/publish-packages.yml +228 -0
- package/bin/templates/workspace/.vscode/extensions.json +8 -0
- package/bin/templates/workspace/.vscode/settings.json +20 -0
- package/bin/templates/workspace/apps/.gitkeep +0 -0
- package/bin/templates/workspace/docs/.gitkeep +0 -0
- package/bin/templates/workspace/infra/services/.gitkeep +0 -0
- package/bin/templates/workspace/packages/.gitkeep +0 -0
- package/bin/templates/workspace/services/.gitkeep +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate Scope Matrix Action
|
|
3
|
+
*
|
|
4
|
+
* Discovers Docker-enabled scopes in a monorepo and generates a GitHub Actions
|
|
5
|
+
* matrix for building only the scopes that have changed between commits.
|
|
6
|
+
*
|
|
7
|
+
* A "scope" is a directory containing a Dockerfile. Scopes are discovered by
|
|
8
|
+
* scanning configured root directories (default: apps, services).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // In a workflow:
|
|
12
|
+
* - uses: ./.github/actions/generate-scope-matrix
|
|
13
|
+
* with:
|
|
14
|
+
* scope-roots: apps,services
|
|
15
|
+
*
|
|
16
|
+
* @outputs scopes - JSON array of scope objects [{name, shortName, dir, run}]
|
|
17
|
+
* @outputs scopes_count - Number of scopes in the matrix
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { appendFileSync } = require('node:fs')
|
|
21
|
+
const { spawnSync } = require('node:child_process')
|
|
22
|
+
const { existsSync, readdirSync, readFileSync, statSync } = require('node:fs')
|
|
23
|
+
const { basename, join, relative } = require('node:path')
|
|
24
|
+
|
|
25
|
+
/** Default directories to scan for scopes (Turborepo convention) */
|
|
26
|
+
const defaultRoots = ['apps', 'services']
|
|
27
|
+
|
|
28
|
+
/** Keys in GitHub event payload that contain changed file paths */
|
|
29
|
+
const changedKeys = ['added', 'modified', 'removed']
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds environment variable keys for GitHub Actions inputs.
|
|
33
|
+
* GitHub converts input names to uppercase and replaces special chars with underscores.
|
|
34
|
+
* @param {string} name - Input name (e.g., 'scope-roots')
|
|
35
|
+
* @returns {string[]} Possible environment variable keys
|
|
36
|
+
*/
|
|
37
|
+
const buildInputKeys = (name) => {
|
|
38
|
+
const trimmed = name.trim()
|
|
39
|
+
const upper = trimmed.toUpperCase()
|
|
40
|
+
const normalized = upper.replace(/[^A-Z0-9]+/g, '_')
|
|
41
|
+
return Array.from(new Set([`INPUT_${upper}`, `INPUT_${normalized}`]))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Retrieves a GitHub Actions input value from environment variables.
|
|
46
|
+
* @param {string} name - Input name as defined in action.yml
|
|
47
|
+
* @param {Object} options - Options
|
|
48
|
+
* @param {string} [options.defaultValue=''] - Default value if input is not set
|
|
49
|
+
* @returns {string} The input value
|
|
50
|
+
*/
|
|
51
|
+
const getInput = (name, { defaultValue = '' } = {}) => {
|
|
52
|
+
const keys = buildInputKeys(name)
|
|
53
|
+
const raw = keys.map((key) => process.env[key]).find((value) => typeof value === 'string')
|
|
54
|
+
const value = typeof raw === 'string' ? raw.trim() : defaultValue
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sets a GitHub Actions output value.
|
|
60
|
+
* Writes to GITHUB_OUTPUT file or logs to console if not in Actions environment.
|
|
61
|
+
* @param {string} name - Output name
|
|
62
|
+
* @param {string|object} value - Output value (objects are JSON stringified)
|
|
63
|
+
*/
|
|
64
|
+
const setOutput = (name, value) => {
|
|
65
|
+
const outputFile = process.env.GITHUB_OUTPUT
|
|
66
|
+
const stringValue = typeof value === 'string' ? value : JSON.stringify(value)
|
|
67
|
+
|
|
68
|
+
if (outputFile) {
|
|
69
|
+
appendFileSync(outputFile, `${name}=${stringValue}\n`)
|
|
70
|
+
} else {
|
|
71
|
+
console.log(`${name}=${stringValue}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Loads root directories to scan for scopes.
|
|
77
|
+
* Parses the 'scope-roots' input or uses defaults (apps, services).
|
|
78
|
+
* @returns {string[]} Array of root directory paths
|
|
79
|
+
*/
|
|
80
|
+
const loadRoots = () => {
|
|
81
|
+
const envRoots = getInput('scope-roots')
|
|
82
|
+
|
|
83
|
+
if (!envRoots) return defaultRoots
|
|
84
|
+
|
|
85
|
+
const parsed = envRoots
|
|
86
|
+
.split(/[^a-zA-Z0-9._-]+/)
|
|
87
|
+
.map((entry) => entry.trim())
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
|
|
90
|
+
return parsed.length > 0 ? parsed : defaultRoots
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Loads short names that should be forced into the matrix regardless of changes.
|
|
95
|
+
* Useful for deploying specific services manually.
|
|
96
|
+
* @returns {string[]} Array of short names to force-include
|
|
97
|
+
*/
|
|
98
|
+
const loadForcedShortNames = () => {
|
|
99
|
+
const envValue = getInput('force-scope-short-names')
|
|
100
|
+
|
|
101
|
+
if (!envValue) return []
|
|
102
|
+
|
|
103
|
+
return Array.from(
|
|
104
|
+
new Set(
|
|
105
|
+
envValue
|
|
106
|
+
.split(/[^a-zA-Z0-9._-]+/)
|
|
107
|
+
.map((entry) => entry.trim())
|
|
108
|
+
.filter(Boolean),
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolves the scope name from package.json or falls back to directory name.
|
|
115
|
+
* @param {string} entryPath - Path to the scope directory
|
|
116
|
+
* @param {string} fallback - Fallback name if package.json is not found
|
|
117
|
+
* @returns {string} The scope name
|
|
118
|
+
*/
|
|
119
|
+
const resolveScopeName = (entryPath, fallback) => {
|
|
120
|
+
const pkgPath = join(entryPath, 'package.json')
|
|
121
|
+
|
|
122
|
+
if (!existsSync(pkgPath)) return fallback
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const raw = readFileSync(pkgPath, 'utf8')
|
|
126
|
+
const pkg = JSON.parse(raw)
|
|
127
|
+
if (typeof pkg.name === 'string' && pkg.name.trim().length > 0) {
|
|
128
|
+
return pkg.name
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`Failed to read package.json for ${entryPath}:`, error)
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return fallback
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Processes a single directory entry and adds it to scopes if it contains a Dockerfile.
|
|
140
|
+
* @param {string} root - Root directory path
|
|
141
|
+
* @param {string} entry - Directory entry name
|
|
142
|
+
* @param {Map} scopes - Map to store discovered scopes
|
|
143
|
+
*/
|
|
144
|
+
const processScopeEntry = (root, entry, scopes) => {
|
|
145
|
+
const entryPath = join(root, entry)
|
|
146
|
+
|
|
147
|
+
const isDirectory = statSync(entryPath).isDirectory()
|
|
148
|
+
if (!isDirectory) return
|
|
149
|
+
|
|
150
|
+
const dockerfilePath = join(entryPath, 'Dockerfile')
|
|
151
|
+
const hasDockerfile = existsSync(dockerfilePath)
|
|
152
|
+
if (!hasDockerfile) return
|
|
153
|
+
|
|
154
|
+
const scopeName = resolveScopeName(entryPath, entry)
|
|
155
|
+
const isDuplicateScope = scopes.has(scopeName)
|
|
156
|
+
if (isDuplicateScope) return
|
|
157
|
+
|
|
158
|
+
const relativeDir = relative(process.cwd(), entryPath).replace(/\\/g, '/')
|
|
159
|
+
scopes.set(scopeName, {
|
|
160
|
+
name: scopeName,
|
|
161
|
+
dir: relativeDir,
|
|
162
|
+
shortName: basename(relativeDir),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Discovers all Docker-enabled scopes in the given root directories.
|
|
168
|
+
* @param {string[]} roots - Array of root directory paths to scan
|
|
169
|
+
* @returns {Map<string, {name: string, dir: string, shortName: string}>} Map of discovered scopes
|
|
170
|
+
*/
|
|
171
|
+
const discoverScopes = (roots) => {
|
|
172
|
+
const scopes = new Map()
|
|
173
|
+
|
|
174
|
+
for (const root of roots) {
|
|
175
|
+
let entries = []
|
|
176
|
+
try {
|
|
177
|
+
entries = readdirSync(root)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code === 'ENOENT') {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
processScopeEntry(root, entry, scopes)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return scopes
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Loads changed files from the GitHub event payload.
|
|
195
|
+
* @param {string} eventPath - Path to the GitHub event JSON file
|
|
196
|
+
* @returns {string[]} Array of changed file paths
|
|
197
|
+
*/
|
|
198
|
+
const loadEventChangedFiles = (eventPath) => {
|
|
199
|
+
if (!eventPath || !existsSync(eventPath)) {
|
|
200
|
+
return []
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const payloadRaw = readFileSync(eventPath, 'utf8')
|
|
205
|
+
const payload = JSON.parse(payloadRaw)
|
|
206
|
+
const commits = Array.isArray(payload.commits) ? payload.commits : []
|
|
207
|
+
const files = new Set()
|
|
208
|
+
|
|
209
|
+
commits.forEach((commit) => {
|
|
210
|
+
changedKeys.forEach((key) => {
|
|
211
|
+
if (!Array.isArray(commit[key])) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
commit[key].forEach((file) => {
|
|
215
|
+
files.add(file.replace(/\\/g, '/'))
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
return Array.from(files)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.warn('Unable to read changed files from event payload:', error)
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Gets the before and head commit refs from GitHub environment variables.
|
|
229
|
+
* @returns {{beforeRef: string|undefined, headRef: string|undefined}} Commit references
|
|
230
|
+
*/
|
|
231
|
+
const getCommitRefs = () => {
|
|
232
|
+
const beforeRef = process.env.GITHUB_EVENT_BEFORE
|
|
233
|
+
const headRef = process.env.GITHUB_SHA
|
|
234
|
+
|
|
235
|
+
return { beforeRef, headRef }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Runs git diff to check if a directory has changes between two commits.
|
|
240
|
+
* @param {string} base - Base commit reference
|
|
241
|
+
* @param {string} target - Target commit reference
|
|
242
|
+
* @param {string} dir - Directory to check for changes
|
|
243
|
+
* @returns {{ok: boolean, changed?: boolean}} Result with ok status and changed flag
|
|
244
|
+
*/
|
|
245
|
+
const runGitDiff = (base, target, dir) => {
|
|
246
|
+
const result = spawnSync('git', ['diff', '--name-only', base, target, '--', dir], { encoding: 'utf8' })
|
|
247
|
+
|
|
248
|
+
if (result.error || result.status !== 0) {
|
|
249
|
+
return { ok: false }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { ok: true, changed: result.stdout.trim().length > 0 }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolves the parent commit reference for a given commit.
|
|
257
|
+
* @param {string} ref - Commit reference
|
|
258
|
+
* @returns {string|undefined} Parent commit reference or undefined if not found
|
|
259
|
+
*/
|
|
260
|
+
const resolveParentRef = (ref) => {
|
|
261
|
+
if (!ref) {
|
|
262
|
+
return undefined
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const result = spawnSync('git', ['rev-parse', '--verify', `${ref}^`], { encoding: 'utf8' })
|
|
266
|
+
if (result.error || result.status !== 0) {
|
|
267
|
+
return undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return result.stdout.trim()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Checks if a directory has changes using git diff or event payload.
|
|
275
|
+
* Uses event payload first, then falls back to git diff.
|
|
276
|
+
* @param {string} dir - Directory to check
|
|
277
|
+
* @param {string[]} changedFilesFromEvent - Changed files from event payload
|
|
278
|
+
* @param {string} beforeRef - Before commit reference
|
|
279
|
+
* @param {string} headRef - Head commit reference
|
|
280
|
+
* @returns {boolean} True if directory has changes
|
|
281
|
+
*/
|
|
282
|
+
const hasGitDiffChanges = (dir, changedFilesFromEvent, beforeRef, headRef) => {
|
|
283
|
+
const normalizedDir = dir.replace(/\\/g, '/')
|
|
284
|
+
|
|
285
|
+
if (changedFilesFromEvent.length > 0) {
|
|
286
|
+
return changedFilesFromEvent.some((file) => file === normalizedDir || file.startsWith(`${normalizedDir}/`))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const diffCandidates = []
|
|
290
|
+
if (beforeRef && headRef && !/^0+$/.test(beforeRef)) {
|
|
291
|
+
diffCandidates.push({ base: beforeRef, target: headRef })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const parentRef = resolveParentRef(headRef)
|
|
295
|
+
if (parentRef) {
|
|
296
|
+
diffCandidates.push({ base: parentRef, target: headRef })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
for (const { base, target } of diffCandidates) {
|
|
300
|
+
const diffResult = runGitDiff(base, target, dir)
|
|
301
|
+
if (diffResult.ok) {
|
|
302
|
+
return diffResult.changed ?? true
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return true
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Wrapper to check if a scope directory has changes.
|
|
311
|
+
* @param {string} scopeDir - Scope directory path
|
|
312
|
+
* @param {string[]} changedFilesFromEvent - Changed files from event
|
|
313
|
+
* @param {string} beforeRef - Before commit ref
|
|
314
|
+
* @param {string} headRef - Head commit ref
|
|
315
|
+
* @returns {boolean} True if scope has changes
|
|
316
|
+
*/
|
|
317
|
+
const scopeHasChanges = (scopeDir, changedFilesFromEvent, beforeRef, headRef) =>
|
|
318
|
+
hasGitDiffChanges(scopeDir, changedFilesFromEvent, beforeRef, headRef)
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Checks if the force-all input is enabled.
|
|
322
|
+
* @returns {boolean} True if all scopes should be force-included
|
|
323
|
+
*/
|
|
324
|
+
const isForceAll = () => {
|
|
325
|
+
const value = getInput('force-all')
|
|
326
|
+
return value === 'true' || value === '1' || value === 'yes'
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Builds the final matrix array with change detection and forced scopes.
|
|
331
|
+
* @param {Map} scopeMap - Discovered scopes
|
|
332
|
+
* @param {string[]} changedFilesFromEvent - Changed files from event
|
|
333
|
+
* @param {string} beforeRef - Before commit ref
|
|
334
|
+
* @param {string} headRef - Head commit ref
|
|
335
|
+
* @param {string[]} forcedShortNames - Short names to force into matrix
|
|
336
|
+
* @returns {Array<{name: string, shortName: string, dir: string, run: boolean}>} Matrix entries
|
|
337
|
+
*/
|
|
338
|
+
const buildMatrix = (scopeMap, changedFilesFromEvent, beforeRef, headRef, forcedShortNames) => {
|
|
339
|
+
if (isForceAll()) {
|
|
340
|
+
return Array.from(scopeMap.values())
|
|
341
|
+
.map((scope) => ({ ...scope, run: true }))
|
|
342
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const enriched = Array.from(scopeMap.values())
|
|
346
|
+
.map((scope) => ({
|
|
347
|
+
...scope,
|
|
348
|
+
run: scopeHasChanges(scope.dir, changedFilesFromEvent, beforeRef, headRef),
|
|
349
|
+
}))
|
|
350
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
351
|
+
|
|
352
|
+
const forcedSet = new Set(forcedShortNames || [])
|
|
353
|
+
const base = enriched.filter((scope) => scope.run)
|
|
354
|
+
const forced = enriched.filter(
|
|
355
|
+
(scope) => forcedSet.has(scope.shortName) && !base.some((entry) => entry.shortName === scope.shortName),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return [...base, ...forced]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Main execution
|
|
362
|
+
const roots = loadRoots()
|
|
363
|
+
const scopeMap = discoverScopes(roots)
|
|
364
|
+
const changedFilesFromEvent = loadEventChangedFiles(process.env.GITHUB_EVENT_PATH)
|
|
365
|
+
const { beforeRef, headRef } = getCommitRefs()
|
|
366
|
+
const forcedShortNames = loadForcedShortNames()
|
|
367
|
+
const matrix = buildMatrix(scopeMap, changedFilesFromEvent, beforeRef, headRef, forcedShortNames)
|
|
368
|
+
|
|
369
|
+
setOutput('scopes', JSON.stringify(matrix))
|
|
370
|
+
setOutput('scopes_count', String(matrix.length))
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
name: Prepare Build Context
|
|
2
|
+
description: Flatten turbo prune output for Docker build
|
|
3
|
+
|
|
4
|
+
inputs:
|
|
5
|
+
scope-short-name:
|
|
6
|
+
description: Short name of the scope (e.g., orders, storefront)
|
|
7
|
+
required: true
|
|
8
|
+
scope-dir:
|
|
9
|
+
description: Original scope directory (e.g., services/orders, apps/storefront)
|
|
10
|
+
required: true
|
|
11
|
+
npm-token:
|
|
12
|
+
description: NPM token for @orderboss private registry — used to regenerate bun.lock
|
|
13
|
+
required: false
|
|
14
|
+
default: ''
|
|
15
|
+
|
|
16
|
+
outputs:
|
|
17
|
+
context-dir:
|
|
18
|
+
description: Path to the prepared build context
|
|
19
|
+
value: ${{ steps.prepare.outputs.context-dir }}
|
|
20
|
+
dockerfile:
|
|
21
|
+
description: Path to the Dockerfile relative to context-dir
|
|
22
|
+
value: ${{ steps.prepare.outputs.dockerfile }}
|
|
23
|
+
|
|
24
|
+
runs:
|
|
25
|
+
using: composite
|
|
26
|
+
steps:
|
|
27
|
+
- name: Prepare build context
|
|
28
|
+
id: prepare
|
|
29
|
+
shell: bash
|
|
30
|
+
run: |
|
|
31
|
+
set -euo pipefail
|
|
32
|
+
|
|
33
|
+
CONTEXT_DIR="out/${{ inputs.scope-short-name }}/full"
|
|
34
|
+
SCOPE_DIR="${{ inputs.scope-dir }}"
|
|
35
|
+
|
|
36
|
+
# Only flatten services — apps keep monorepo structure so
|
|
37
|
+
# relative paths (../../packages/ui) resolve correctly
|
|
38
|
+
if [[ "$SCOPE_DIR" == services/* ]]; then
|
|
39
|
+
if [ -d "$CONTEXT_DIR/$SCOPE_DIR" ]; then
|
|
40
|
+
cp -r "$CONTEXT_DIR/$SCOPE_DIR"/. "$CONTEXT_DIR/"
|
|
41
|
+
rm -rf "$CONTEXT_DIR/$SCOPE_DIR"
|
|
42
|
+
fi
|
|
43
|
+
rm -rf "$CONTEXT_DIR/apps" "$CONTEXT_DIR/services"
|
|
44
|
+
DOCKERFILE="Dockerfile"
|
|
45
|
+
else
|
|
46
|
+
DOCKERFILE="$SCOPE_DIR/Dockerfile"
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
cd "$CONTEXT_DIR"
|
|
50
|
+
|
|
51
|
+
# Ensure bun.lock is present — turbo prune may omit it for certain workspace configs.
|
|
52
|
+
# Copy from workspace root as a baseline; bun install below will regenerate it anyway.
|
|
53
|
+
if [ ! -f "bun.lock" ]; then
|
|
54
|
+
echo "bun.lock not in prune output — copying from workspace root"
|
|
55
|
+
cp "$GITHUB_WORKSPACE/bun.lock" bun.lock
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Replace workspace:* with npm versions for published packages
|
|
59
|
+
# so bun install fetches pre-built packages from registry (with dist/)
|
|
60
|
+
# Private packages stay as workspace deps (Bun resolves their .ts source)
|
|
61
|
+
if [ -d "packages" ]; then
|
|
62
|
+
for pkg_dir in packages/*/; do
|
|
63
|
+
[ -f "$pkg_dir/package.json" ] || continue
|
|
64
|
+
|
|
65
|
+
pkg_name=$(jq -r '.name' "$pkg_dir/package.json")
|
|
66
|
+
is_private=$(jq -r '.private // false' "$pkg_dir/package.json")
|
|
67
|
+
pkg_version=$(jq -r '.version // empty' "$pkg_dir/package.json")
|
|
68
|
+
|
|
69
|
+
if [ "$is_private" = "true" ] || [ -z "$pkg_version" ]; then
|
|
70
|
+
echo " keeping workspace dep: $pkg_name (private)"
|
|
71
|
+
continue
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# Use npm version if local version isn't published yet (race with publish workflow)
|
|
75
|
+
npm_version=$(npm view "$pkg_name" version 2>/dev/null || echo "")
|
|
76
|
+
if [ -n "$npm_version" ] && ! npm view "$pkg_name@$pkg_version" version >/dev/null 2>&1; then
|
|
77
|
+
echo " $pkg_name@$pkg_version not on npm yet, using ^$npm_version"
|
|
78
|
+
pkg_version="$npm_version"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
echo " replacing workspace:* → ^$pkg_version for $pkg_name"
|
|
82
|
+
|
|
83
|
+
# Replace in dependencies and devDependencies (root + scope package.json)
|
|
84
|
+
for pkg_file in package.json "$SCOPE_DIR/package.json"; do
|
|
85
|
+
[ -f "$pkg_file" ] || continue
|
|
86
|
+
for field in dependencies devDependencies; do
|
|
87
|
+
if jq -e ".${field}[\"${pkg_name}\"]" "$pkg_file" > /dev/null 2>&1; then
|
|
88
|
+
jq ".${field}[\"${pkg_name}\"] = \"^${pkg_version}\"" "$pkg_file" > "$pkg_file.tmp"
|
|
89
|
+
mv "$pkg_file.tmp" "$pkg_file"
|
|
90
|
+
fi
|
|
91
|
+
done
|
|
92
|
+
done
|
|
93
|
+
|
|
94
|
+
# Also update private packages that depend on this published package
|
|
95
|
+
for other_pkg in packages/*/; do
|
|
96
|
+
[ -f "$other_pkg/package.json" ] || continue
|
|
97
|
+
for field in dependencies devDependencies; do
|
|
98
|
+
if jq -e ".${field}[\"${pkg_name}\"]" "$other_pkg/package.json" > /dev/null 2>&1; then
|
|
99
|
+
jq ".${field}[\"${pkg_name}\"] = \"^${pkg_version}\"" "$other_pkg/package.json" > "$other_pkg/package.json.tmp"
|
|
100
|
+
mv "$other_pkg/package.json.tmp" "$other_pkg/package.json"
|
|
101
|
+
fi
|
|
102
|
+
done
|
|
103
|
+
done
|
|
104
|
+
|
|
105
|
+
# Remove published package dir (will be installed from npm)
|
|
106
|
+
rm -rf "$pkg_dir"
|
|
107
|
+
done
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Set workspace config for remaining private packages, or remove it
|
|
111
|
+
if [ -d "packages" ] && [ -n "$(ls -A packages/ 2>/dev/null)" ]; then
|
|
112
|
+
if [[ "$SCOPE_DIR" == services/* ]]; then
|
|
113
|
+
jq '.workspaces = ["packages/*"]' package.json > package.json.tmp
|
|
114
|
+
else
|
|
115
|
+
jq ".workspaces = [\"packages/*\", \"$SCOPE_DIR\"]" package.json > package.json.tmp
|
|
116
|
+
fi
|
|
117
|
+
mv package.json.tmp package.json
|
|
118
|
+
else
|
|
119
|
+
rm -rf packages
|
|
120
|
+
jq 'del(.workspaces)' package.json > package.json.tmp
|
|
121
|
+
mv package.json.tmp package.json
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# Ensure packages/ dir exists (Dockerfiles COPY it even when empty)
|
|
125
|
+
mkdir -p packages
|
|
126
|
+
|
|
127
|
+
# Strip prepare script — it runs husky + turbo (dev-only, not needed in Docker)
|
|
128
|
+
if jq -e '.scripts.prepare' package.json > /dev/null 2>&1; then
|
|
129
|
+
jq 'del(.scripts.prepare)' package.json > package.json.tmp
|
|
130
|
+
mv package.json.tmp package.json
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
# Regenerate bun.lock for the flattened workspace.
|
|
134
|
+
# package.json files were modified above (workspace:* → npm versions), so the
|
|
135
|
+
# original bun.lock is now inconsistent. We re-run bun install in the pruned
|
|
136
|
+
# context — bun's global cache is warm from the earlier setup-bun-install step,
|
|
137
|
+
# so this is fast and produces no new network traffic for already-cached packages.
|
|
138
|
+
#
|
|
139
|
+
# bun may delete bun.lock if it detects a workspace mismatch and then fails.
|
|
140
|
+
# Always back up first; restore if install leaves no valid lockfile behind.
|
|
141
|
+
# Service Dockerfiles install without --frozen-lockfile for this reason.
|
|
142
|
+
if [ -f "bun.lock" ]; then
|
|
143
|
+
echo "Regenerating bun.lock for modified workspace..."
|
|
144
|
+
cp bun.lock bun.lock.bak
|
|
145
|
+
set +e
|
|
146
|
+
NPM_TOKEN="${{ inputs.npm-token }}" bun install --no-progress 2>&1 | tee /tmp/bun-install.log; INSTALL_EXIT=${PIPESTATUS[0]}
|
|
147
|
+
set -e
|
|
148
|
+
if [ ! -f bun.lock ] || [ ! -s bun.lock ]; then
|
|
149
|
+
echo "::warning::bun install (exit $INSTALL_EXIT) produced no valid bun.lock — restoring original so Docker COPY does not fail"
|
|
150
|
+
mv bun.lock.bak bun.lock
|
|
151
|
+
elif [ "$INSTALL_EXIT" -ne 0 ]; then
|
|
152
|
+
echo "::warning::bun install exited with $INSTALL_EXIT but bun.lock exists — using regenerated lockfile"
|
|
153
|
+
rm -f bun.lock.bak
|
|
154
|
+
else
|
|
155
|
+
rm -f bun.lock.bak
|
|
156
|
+
echo "bun.lock regenerated"
|
|
157
|
+
fi
|
|
158
|
+
# Remove all node_modules created by the install; Docker installs fresh
|
|
159
|
+
find . -type d -name node_modules -prune -exec rm -rf {} + 2>/dev/null || true
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
# Create .dockerignore in context root so COPY . . never re-introduces
|
|
163
|
+
# node_modules (bun symlinks) even when no root .dockerignore is in scope.
|
|
164
|
+
printf '%s\n' '**/node_modules' '**/.git' '**/.turbo' '.env' '.env.local' '*.log' > ".dockerignore"
|
|
165
|
+
|
|
166
|
+
echo "context-dir=$CONTEXT_DIR" >> "$GITHUB_OUTPUT"
|
|
167
|
+
echo "dockerfile=$DOCKERFILE" >> "$GITHUB_OUTPUT"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Setup Bun and install dependencies
|
|
2
|
+
description: Installs Bun, optionally restores cache, and runs Bun install commands.
|
|
3
|
+
author: {{githubOwner}}
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
working-directory:
|
|
7
|
+
description: Directory to run the install command in.
|
|
8
|
+
default: .
|
|
9
|
+
install-command:
|
|
10
|
+
description: Command to install dependencies.
|
|
11
|
+
default: bun install --frozen-lockfile
|
|
12
|
+
enable-cache:
|
|
13
|
+
description: Whether to run the cache step.
|
|
14
|
+
default: 'false'
|
|
15
|
+
cache-key:
|
|
16
|
+
description: Cache key to use when caching is enabled.
|
|
17
|
+
default: ''
|
|
18
|
+
cache-restore-keys:
|
|
19
|
+
description: Restore keys for the cache step.
|
|
20
|
+
default: ''
|
|
21
|
+
cache-paths:
|
|
22
|
+
description: Newline-delimited paths to include in the cache.
|
|
23
|
+
default: |
|
|
24
|
+
~/.bun
|
|
25
|
+
node_modules
|
|
26
|
+
bun-version-file:
|
|
27
|
+
description: Path to a file containing the Bun version (passed to setup-bun).
|
|
28
|
+
default: 'package.json'
|
|
29
|
+
npm-token:
|
|
30
|
+
description: NPM token for private registry access.
|
|
31
|
+
required: false
|
|
32
|
+
default: ''
|
|
33
|
+
|
|
34
|
+
runs:
|
|
35
|
+
using: composite
|
|
36
|
+
steps:
|
|
37
|
+
- name: Install Bun
|
|
38
|
+
uses: oven-sh/setup-bun@v2
|
|
39
|
+
with:
|
|
40
|
+
bun-version-file: $\{{ inputs.bun-version-file }}
|
|
41
|
+
|
|
42
|
+
- name: Cache dependencies
|
|
43
|
+
if: $\{{ inputs.enable-cache == 'true' && inputs.cache-key != '' }}
|
|
44
|
+
uses: actions/cache@v5
|
|
45
|
+
with:
|
|
46
|
+
path: $\{{ inputs.cache-paths }}
|
|
47
|
+
key: $\{{ inputs.cache-key }}
|
|
48
|
+
restore-keys: $\{{ inputs.cache-restore-keys }}
|
|
49
|
+
|
|
50
|
+
- name: Install dependencies
|
|
51
|
+
shell: bash
|
|
52
|
+
env:
|
|
53
|
+
NPM_TOKEN: $\{{ inputs.npm-token }}
|
|
54
|
+
run: |
|
|
55
|
+
set -euo pipefail
|
|
56
|
+
cd "$\{{ inputs.working-directory }}"
|
|
57
|
+
$\{{ inputs.install-command }}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
registries:
|
|
3
|
+
npm-github:
|
|
4
|
+
type: npm-registry
|
|
5
|
+
url: https://npm.pkg.github.com
|
|
6
|
+
token: ${{secrets.NPM_TOKEN}}
|
|
7
|
+
replaces-base: true
|
|
8
|
+
|
|
9
|
+
updates:
|
|
10
|
+
- package-ecosystem: "bun"
|
|
11
|
+
directory: "/"
|
|
12
|
+
schedule:
|
|
13
|
+
interval: "daily"
|
|
14
|
+
open-pull-requests-limit: 10
|
|
15
|
+
labels:
|
|
16
|
+
- "dependencies"
|
|
17
|
+
registries:
|
|
18
|
+
- npm-github
|