@crossdelta/platform-sdk 0.14.0 → 0.15.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.
Files changed (24) hide show
  1. package/bin/cli.js +714 -169
  2. package/bin/templates/workspace/.github/README.md +70 -0
  3. package/bin/templates/workspace/.github/actions/check-image-tag-exists/action.yml +27 -0
  4. package/bin/templates/workspace/.github/actions/check-image-tag-exists/index.js +179 -0
  5. package/bin/templates/workspace/.github/actions/check-path-changes/action.yml +21 -0
  6. package/bin/templates/workspace/.github/actions/check-path-changes/index.js +192 -0
  7. package/bin/templates/workspace/.github/actions/detect-skipped-services/action.yml +38 -0
  8. package/bin/templates/workspace/.github/actions/generate-scope-matrix/action.yml +17 -0
  9. package/bin/templates/workspace/.github/actions/generate-scope-matrix/index.js +355 -0
  10. package/bin/templates/workspace/.github/actions/prepare-build-context/action.yml +49 -0
  11. package/bin/templates/workspace/.github/actions/resolve-scope-tags/action.yml +31 -0
  12. package/bin/templates/workspace/.github/actions/resolve-scope-tags/index.js +398 -0
  13. package/bin/templates/workspace/.github/actions/setup-bun-install/action.yml.hbs +57 -0
  14. package/bin/templates/workspace/.github/copilot-chat-configuration.json +49 -0
  15. package/bin/templates/workspace/.github/copilot-instructions.md.hbs +72 -0
  16. package/bin/templates/workspace/.github/dependabot.yml +18 -0
  17. package/bin/templates/workspace/.github/workflows/build-and-deploy.yml.hbs +232 -0
  18. package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +32 -0
  19. package/bin/templates/workspace/.github/workflows/publish-packages.yml +188 -0
  20. package/bin/templates/workspace/apps/.gitkeep +0 -0
  21. package/bin/templates/workspace/docs/.gitkeep +0 -0
  22. package/bin/templates/workspace/infra/services/.gitkeep +0 -0
  23. package/bin/templates/workspace/packages/.gitkeep +0 -0
  24. package/package.json +2 -2
@@ -0,0 +1,355 @@
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
+ * Builds the final matrix array with change detection and forced scopes.
322
+ * @param {Map} scopeMap - Discovered scopes
323
+ * @param {string[]} changedFilesFromEvent - Changed files from event
324
+ * @param {string} beforeRef - Before commit ref
325
+ * @param {string} headRef - Head commit ref
326
+ * @param {string[]} forcedShortNames - Short names to force into matrix
327
+ * @returns {Array<{name: string, shortName: string, dir: string, run: boolean}>} Matrix entries
328
+ */
329
+ const buildMatrix = (scopeMap, changedFilesFromEvent, beforeRef, headRef, forcedShortNames) => {
330
+ const enriched = Array.from(scopeMap.values())
331
+ .map((scope) => ({
332
+ ...scope,
333
+ run: scopeHasChanges(scope.dir, changedFilesFromEvent, beforeRef, headRef),
334
+ }))
335
+ .sort((a, b) => a.name.localeCompare(b.name))
336
+
337
+ const forcedSet = new Set(forcedShortNames || [])
338
+ const base = enriched.filter((scope) => scope.run)
339
+ const forced = enriched.filter(
340
+ (scope) => forcedSet.has(scope.shortName) && !base.some((entry) => entry.shortName === scope.shortName),
341
+ )
342
+
343
+ return [...base, ...forced]
344
+ }
345
+
346
+ // Main execution
347
+ const roots = loadRoots()
348
+ const scopeMap = discoverScopes(roots)
349
+ const changedFilesFromEvent = loadEventChangedFiles(process.env.GITHUB_EVENT_PATH)
350
+ const { beforeRef, headRef } = getCommitRefs()
351
+ const forcedShortNames = loadForcedShortNames()
352
+ const matrix = buildMatrix(scopeMap, changedFilesFromEvent, beforeRef, headRef, forcedShortNames)
353
+
354
+ setOutput('scopes', JSON.stringify(matrix))
355
+ setOutput('scopes_count', String(matrix.length))
@@ -0,0 +1,49 @@
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
+
12
+ outputs:
13
+ context-dir:
14
+ description: Path to the prepared build context
15
+ value: ${{ steps.prepare.outputs.context-dir }}
16
+
17
+ runs:
18
+ using: composite
19
+ steps:
20
+ - name: Prepare build context
21
+ id: prepare
22
+ shell: bash
23
+ run: |
24
+ set -euo pipefail
25
+
26
+ CONTEXT_DIR="out/${{ inputs.scope-short-name }}/full"
27
+ SCOPE_DIR="${{ inputs.scope-dir }}"
28
+
29
+ # Copy bun.lock from json folder
30
+ cp "out/${{ inputs.scope-short-name }}/json/bun.lock" "$CONTEXT_DIR/"
31
+
32
+ # Flatten: move service/app files to root of contexti
33
+ if [ -d "$CONTEXT_DIR/$SCOPE_DIR" ]; then
34
+ # Copy all files and directories from the scope dir to context root
35
+ cp -r "$CONTEXT_DIR/$SCOPE_DIR"/. "$CONTEXT_DIR/"
36
+
37
+ # Remove the nested scope dir
38
+ rm -rf "$CONTEXT_DIR/$SCOPE_DIR"
39
+ fi
40
+
41
+ # Remove packages and apps/services dirs (dependencies come from registry)
42
+ rm -rf "$CONTEXT_DIR/packages" "$CONTEXT_DIR/apps" "$CONTEXT_DIR/services"
43
+
44
+ # Clean up root package.json workspaces
45
+ cd "$CONTEXT_DIR"
46
+ jq 'del(.workspaces)' package.json > package.json.tmp
47
+ mv package.json.tmp package.json
48
+
49
+ echo "context-dir=$CONTEXT_DIR" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,31 @@
1
+ name: Resolve scope tags
2
+ description: Determine checksum tags for deployable scopes, using downloaded metadata where available
3
+ inputs:
4
+ markers-dir:
5
+ description: Directory containing downloaded change markers (default .artifacts)
6
+ required: false
7
+ default: .artifacts
8
+ scope-roots:
9
+ description: List of directories to scan for scopes (defaults to apps,services)
10
+ required: false
11
+ deployable-scopes:
12
+ description: Optional comma/space separated short-name list to restrict scope resolution
13
+ required: false
14
+ allow-missing-scopes:
15
+ description: Short names allowed to skip if no tag can be resolved
16
+ required: false
17
+ github-token:
18
+ description: GitHub token with read:packages scope to query GHCR
19
+ required: true
20
+ repository-owner:
21
+ description: Organization or user owning GHCR packages (defaults to repo owner)
22
+ required: false
23
+ repository-prefix:
24
+ description: Prefix for GHCR packages (defaults to $GHCR_REPOSITORY_PREFIX or "platform")
25
+ required: false
26
+ outputs:
27
+ scope_image_tags:
28
+ description: JSON map of shortName -> checksum tag
29
+ runs:
30
+ using: node20
31
+ main: index.js