@crossdelta/platform-sdk 0.26.0 → 0.26.2

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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * GHCR tag lookup shared by check-image-tag-exists and generate-scope-matrix.
3
+ *
4
+ * One API client for both, so the build job and the matrix ask GHCR the same question the same way.
5
+ * generate-scope-matrix/index.js requires this file by its relative path: moving it breaks that action.
6
+ */
7
+
8
+ const PAGE_SIZE = 100
9
+
10
+ /**
11
+ * Resolves the GHCR package prefix: the explicit input, else $GHCR_REPOSITORY_PREFIX, else the
12
+ * repository name, else 'platform'.
13
+ * @param {string} input - The action's repository-prefix input, possibly empty
14
+ * @param {Record<string, string | undefined>} env - Process environment
15
+ * @returns {string}
16
+ */
17
+ const resolveRepositoryPrefix = (input, env) =>
18
+ input || env.GHCR_REPOSITORY_PREFIX || env.GITHUB_REPOSITORY?.split('/')[1] || 'platform'
19
+
20
+ /**
21
+ * Checks if the target tag exists in a list of container versions.
22
+ * @param {Object[]} versions - Array of version objects from GitHub API
23
+ * @param {string} targetTag - The tag to search for
24
+ * @returns {boolean} True if tag is found
25
+ */
26
+ const tagExistsInVersions = (versions, targetTag) =>
27
+ versions.some((version) => {
28
+ const tags = version?.metadata?.container?.tags
29
+ return Array.isArray(tags) && tags.includes(targetTag)
30
+ })
31
+
32
+ /**
33
+ * Creates a GHCR client for one package owner and repository prefix.
34
+ * @param {Object} options
35
+ * @param {string} options.owner - GitHub organization that owns the container packages
36
+ * @param {string} options.prefix - Package prefix, e.g. 'platform' for 'platform/storefront'
37
+ * @param {string} options.token - Token with read:packages
38
+ * @param {number} [options.maxPages=5] - Pages of 100 versions to inspect before giving up
39
+ * @param {typeof fetch} [options.fetchImpl=fetch] - Injected for tests
40
+ * @returns {{tagExists: (shortName: string, tag: string) => Promise<boolean>}}
41
+ * `tagExists` resolves false when the package does not exist yet (404) and rejects on any other
42
+ * API error, so a caller decides itself whether an unknown answer means "build".
43
+ */
44
+ const createGhcrClient = ({ owner, prefix, token, maxPages = 5, fetchImpl = fetch }) => {
45
+ const headers = {
46
+ Authorization: `Bearer ${token}`,
47
+ Accept: 'application/vnd.github+json',
48
+ 'X-GitHub-Api-Version': '2022-11-28',
49
+ }
50
+
51
+ const fetchVersions = async (shortName, page) => {
52
+ const encodedPackage = encodeURIComponent(`${prefix}/${shortName}`)
53
+ const url = `https://api.github.com/orgs/${owner}/packages/container/${encodedPackage}/versions?per_page=${PAGE_SIZE}&page=${page}`
54
+ const response = await fetchImpl(url, { headers })
55
+
56
+ // 404 means the package doesn't exist yet (first build)
57
+ if (response.status === 404) {
58
+ return { notFound: true, versions: [], hasMore: false }
59
+ }
60
+
61
+ if (!response.ok) {
62
+ const body = await response.text()
63
+ throw new Error(`GitHub API error (${response.status}): ${body}`)
64
+ }
65
+
66
+ const data = await response.json()
67
+ const versions = Array.isArray(data) ? data : []
68
+ return { notFound: false, versions, hasMore: versions.length === PAGE_SIZE }
69
+ }
70
+
71
+ const tagExists = async (shortName, tag) => {
72
+ const targetTag = tag.trim()
73
+
74
+ for (let page = 1; page <= maxPages; page += 1) {
75
+ const { versions, hasMore, notFound } = await fetchVersions(shortName, page)
76
+ if (notFound) return false
77
+ if (tagExistsInVersions(versions, targetTag)) return true
78
+ if (!hasMore) break
79
+ }
80
+
81
+ return false
82
+ }
83
+
84
+ return { tagExists }
85
+ }
86
+
87
+ module.exports = { createGhcrClient, resolveRepositoryPrefix, tagExistsInVersions }
@@ -16,6 +16,7 @@
16
16
 
17
17
  const { appendFileSync } = require('node:fs')
18
18
  const { exit } = require('node:process')
19
+ const { createGhcrClient, resolveRepositoryPrefix } = require('./ghcr.js')
19
20
 
20
21
  /**
21
22
  * Builds environment variable keys for GitHub Actions inputs.
@@ -71,103 +72,17 @@ const githubToken = getInput('github-token', { required: true })
71
72
 
72
73
  // Optional inputs - defaults from environment for portability
73
74
  const repositoryOwner = getInput('repository-owner', { defaultValue: process.env.GITHUB_REPOSITORY_OWNER })
74
- const repositoryPrefixInput = getInput('repository-prefix')
75
- const repositoryPrefix =
76
- repositoryPrefixInput || process.env.GHCR_REPOSITORY_PREFIX || process.env.GITHUB_REPOSITORY?.split('/')[1] || 'platform'
75
+ const repositoryPrefix = resolveRepositoryPrefix(getInput('repository-prefix'), process.env)
77
76
  const maxPages = Number(getInput('max-pages', { defaultValue: '5' }))
78
77
 
79
- // GitHub API request headers
80
- const headers = {
81
- Authorization: `Bearer ${githubToken}`,
82
- Accept: 'application/vnd.github+json',
83
- 'X-GitHub-Api-Version': '2022-11-28',
84
- }
85
-
86
- // Construct the full package name (e.g., 'platform/storefront')
87
- const packageName = `${repositoryPrefix}/${scopeShortName}`
88
- const encodedPackage = encodeURIComponent(packageName)
89
-
90
- /**
91
- * Fetches a page of container versions from the GitHub Packages API.
92
- * @param {number} page - Page number (1-indexed)
93
- * @returns {Promise<Object>} Result with versions array, hasMore flag, and notFound flag
94
- */
95
- const fetchVersions = async (page) => {
96
- const url = `https://api.github.com/orgs/${repositoryOwner}/packages/container/${encodedPackage}/versions?per_page=100&page=${page}`
97
- const response = await fetch(url, { headers })
98
-
99
- // 404 means the package doesn't exist yet (first build)
100
- if (response.status === 404) {
101
- return { notFound: true, versions: [], hasMore: false }
102
- }
103
-
104
- if (!response.ok) {
105
- const body = await response.text()
106
- throw new Error(`GitHub API error (${response.status}): ${body}`)
107
- }
108
-
109
- const data = await response.json()
110
- const versions = Array.isArray(data) ? data : []
111
- return {
112
- versions,
113
- hasMore: versions.length === 100, // Full page means there might be more
114
- notFound: false,
115
- }
116
- }
117
-
118
- /**
119
- * Checks if the target tag exists in a list of container versions.
120
- * @param {Object[]} versions - Array of version objects from GitHub API
121
- * @param {string} targetTag - The tag to search for
122
- * @returns {boolean} True if tag is found
123
- */
124
- const tagExistsInVersions = (versions, targetTag) => {
125
- for (const version of versions) {
126
- const tags = version?.metadata?.container?.tags
127
- if (!Array.isArray(tags)) continue
128
- if (tags.includes(targetTag)) {
129
- return true
130
- }
131
- }
132
- return false
133
- }
134
-
135
- /**
136
- * Checks if the image tag exists in GHCR by paginating through all versions.
137
- * @returns {Promise<boolean>} True if tag exists
138
- */
139
- const check = async () => {
140
- const targetTag = imageTag.trim()
141
-
142
- // Paginate through versions until we find the tag or run out of pages
143
- for (let page = 1; page <= maxPages; page += 1) {
144
- const { versions, hasMore, notFound } = await fetchVersions(page)
145
-
146
- // Package doesn't exist yet - tag definitely doesn't exist
147
- if (notFound) {
148
- return false
149
- }
150
-
151
- // Check if target tag is in this page
152
- if (tagExistsInVersions(versions, targetTag)) {
153
- return true
154
- }
155
-
156
- // No more pages to check
157
- if (!hasMore) {
158
- break
159
- }
160
- }
161
-
162
- return false
163
- }
78
+ const ghcr = createGhcrClient({ owner: repositoryOwner, prefix: repositoryPrefix, token: githubToken, maxPages })
164
79
 
165
80
  /**
166
81
  * Main entry point - runs the check and outputs the result.
167
82
  */
168
83
  const run = async () => {
169
84
  try {
170
- const exists = await check()
85
+ const exists = await ghcr.tagExists(scopeShortName, imageTag)
171
86
  setOutput('exists', String(exists))
172
87
  } catch (error) {
173
88
  console.error('Failed to check image tag existence:', error)
@@ -11,11 +11,29 @@ inputs:
11
11
  description: When true, include ALL discovered scopes regardless of changes
12
12
  required: false
13
13
  default: 'false'
14
+ only-unbuilt:
15
+ description: >-
16
+ When true, include every scope whose checksum tag is not in GHCR yet, whatever changed.
17
+ Needs `bun install` before this step (the checksum prunes with the workspace's turbo) and
18
+ `github-token`. Ignored when force-all is true.
19
+ required: false
20
+ default: 'false'
21
+ github-token:
22
+ description: Token with read:packages, used by only-unbuilt to query GHCR
23
+ required: false
14
24
  outputs:
15
25
  scopes:
16
26
  description: JSON array describing scopes for the matrix strategy
17
27
  scopes_count:
18
28
  description: Number of scopes detected in the matrix
29
+ scope_tags:
30
+ description: >-
31
+ JSON object mapping every scope's short name to the checksum tag computed for this commit
32
+ (only-unbuilt); `{}` otherwise
33
+ tags_changed:
34
+ description: >-
35
+ 'true' when a scope's checksum tag differs from the one at the diff base (GITHUB_EVENT_BEFORE),
36
+ even if its image exists, as after a revert (only-unbuilt); 'false' otherwise
19
37
  runs:
20
38
  using: node24
21
39
  main: index.js
@@ -13,14 +13,20 @@
13
13
  * with:
14
14
  * scope-roots: apps,services
15
15
  *
16
- * @outputs scopes - JSON array of scope objects [{name, shortName, dir, run}]
16
+ * With `only-unbuilt`, a scope is in the matrix when GHCR lacks its checksum tag instead of when
17
+ * its directory changed, and each entry carries that tag as `imageTag`.
18
+ *
19
+ * @outputs scopes - JSON array of scope objects [{name, shortName, dir, run, imageTag?}]
17
20
  * @outputs scopes_count - Number of scopes in the matrix
21
+ * @outputs tags_changed - 'true' when a scope's tag differs from the diff base's (only-unbuilt)
18
22
  */
19
23
 
20
24
  const { appendFileSync } = require('node:fs')
21
25
  const { spawnSync } = require('node:child_process')
22
- const { existsSync, readdirSync, readFileSync, statSync } = require('node:fs')
26
+ const { existsSync, readdirSync, readFileSync, statSync, symlinkSync } = require('node:fs')
23
27
  const { basename, join, relative } = require('node:path')
28
+ const { createGhcrClient, resolveRepositoryPrefix } = require('../check-image-tag-exists/ghcr.js')
29
+ const { changedScopeTags, scopeTagMap, selectUnbuilt } = require('./select.js')
24
30
 
25
31
  /** Default directories to scan for scopes (Turborepo convention) */
26
32
  const defaultRoots = ['apps', 'services']
@@ -317,13 +323,144 @@ const hasGitDiffChanges = (dir, changedFilesFromEvent, beforeRef, headRef) => {
317
323
  const scopeHasChanges = (scopeDir, changedFilesFromEvent, beforeRef, headRef) =>
318
324
  hasGitDiffChanges(scopeDir, changedFilesFromEvent, beforeRef, headRef)
319
325
 
326
+ /**
327
+ * Checks if a boolean input is enabled.
328
+ * @param {string} name - Input name as defined in action.yml
329
+ * @returns {boolean} True for 'true', '1' or 'yes'
330
+ */
331
+ const isEnabled = (name) => {
332
+ const value = getInput(name)
333
+ return value === 'true' || value === '1' || value === 'yes'
334
+ }
335
+
320
336
  /**
321
337
  * Checks if the force-all input is enabled.
322
338
  * @returns {boolean} True if all scopes should be force-included
323
339
  */
324
- const isForceAll = () => {
325
- const value = getInput('force-all')
326
- return value === 'true' || value === '1' || value === 'yes'
340
+ const isForceAll = () => isEnabled('force-all')
341
+
342
+ /**
343
+ * Prunes a scope and computes its checksum tag through the script the build job runs.
344
+ * @param {{name: string, shortName: string, dir: string}} scope - Scope to checksum
345
+ * @param {string} [cwd=process.cwd()] - Workspace root to prune from
346
+ * @returns {string} The scope's image tag
347
+ */
348
+ const computeScopeChecksum = (scope, cwd = process.cwd()) => {
349
+ const script = join(__dirname, 'scope-checksum.sh')
350
+ // The GHCR token stays out of turbo's environment: the prune needs no credentials.
351
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^INPUT_GITHUB[-_]TOKEN$/i.test(key)))
352
+ const result = spawnSync('bash', [script, scope.name, scope.shortName, scope.dir], {
353
+ cwd,
354
+ encoding: 'utf8',
355
+ env,
356
+ stdio: ['ignore', 'pipe', 'inherit'],
357
+ })
358
+
359
+ if (result.error || result.status !== 0) {
360
+ throw new Error(`Checksum for ${scope.name} failed (exit ${result.status}): ${result.error ?? ''}`)
361
+ }
362
+
363
+ return result.stdout.trim()
364
+ }
365
+
366
+ /**
367
+ * Looks up every scope's checksum tag in GHCR. An unanswered lookup counts as missing, so the
368
+ * scope is built: the build job checks the tag again before it pushes.
369
+ * @param {Map} scopeMap - Discovered scopes
370
+ * @returns {Promise<Array<{scope: Object, imageTag: string, exists: boolean}>>}
371
+ */
372
+ const lookupScopeTags = async (scopeMap) => {
373
+ const ghcr = createGhcrClient({
374
+ owner: process.env.GITHUB_REPOSITORY_OWNER,
375
+ prefix: resolveRepositoryPrefix('', process.env),
376
+ token: getInput('github-token'),
377
+ })
378
+ const lookups = []
379
+
380
+ for (const scope of scopeMap.values()) {
381
+ const imageTag = computeScopeChecksum(scope)
382
+ const exists = await ghcr.tagExists(scope.shortName, imageTag).catch((error) => {
383
+ console.warn(`GHCR lookup for ${scope.shortName} failed, building it:`, error)
384
+ return false
385
+ })
386
+ console.log(`${scope.shortName} ${imageTag} ${exists ? 'exists' : 'missing'}`)
387
+ lookups.push({ scope, imageTag, exists })
388
+ }
389
+
390
+ return lookups
391
+ }
392
+
393
+ /**
394
+ * Runs git and throws with its stderr when it fails.
395
+ * @param {string[]} args - git arguments
396
+ */
397
+ const runGit = (args) => {
398
+ const result = spawnSync('git', args, { encoding: 'utf8' })
399
+ if (result.error || result.status !== 0) {
400
+ throw new Error(`git ${args.join(' ')} failed: ${result.error ?? result.stderr}`)
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Checks out the base commit into a temporary worktree that borrows this checkout's node_modules
406
+ * for turbo.
407
+ * @param {string} baseDir - Worktree directory
408
+ * @param {string} baseRef - Commit to check out
409
+ * @returns {boolean} False when the worktree could not be created (it is then removed again)
410
+ */
411
+ const addBaseTree = (baseDir, baseRef) => {
412
+ try {
413
+ runGit(['worktree', 'add', '--detach', '--force', baseDir, baseRef])
414
+ } catch (error) {
415
+ console.warn(`No base tree at ${baseRef}, counting every scope as changed:`, error.message)
416
+ return false
417
+ }
418
+ try {
419
+ symlinkSync(join(process.cwd(), 'node_modules'), join(baseDir, 'node_modules'))
420
+ return true
421
+ } catch (error) {
422
+ console.warn('No node_modules in the base tree, counting every scope as changed:', error.message)
423
+ removeBaseTree(baseDir)
424
+ return false
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Removes the temporary worktree; a failure only warns, the tags are computed by then.
430
+ * @param {string} baseDir - Worktree directory
431
+ */
432
+ const removeBaseTree = (baseDir) => {
433
+ try {
434
+ runGit(['worktree', 'remove', '--force', baseDir])
435
+ } catch (error) {
436
+ console.warn('Could not remove the base tree:', error.message)
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Computes every scope's checksum tag at the base commit, in a temporary worktree under out/. A
442
+ * scope the base cannot checksum (absent there, renamed) is left out and so counts as changed;
443
+ * without a base tree every scope does, so the deploy runs.
444
+ * @param {Map} scopeMap - Discovered scopes
445
+ * @param {string} baseRef - Diff base commit
446
+ * @returns {Record<string, string>} Short name → tag at the base
447
+ */
448
+ const computeBaseScopeTags = (scopeMap, baseRef) => {
449
+ const baseDir = join(process.cwd(), 'out', '.base-tree')
450
+ if (!addBaseTree(baseDir, baseRef)) return {}
451
+ try {
452
+ const tags = {}
453
+ for (const scope of scopeMap.values()) {
454
+ try {
455
+ tags[scope.shortName] = computeScopeChecksum(scope, baseDir)
456
+ } catch (error) {
457
+ console.warn(`No base tag for ${scope.shortName}, counting it as changed:`, error.message)
458
+ }
459
+ }
460
+ return tags
461
+ } finally {
462
+ removeBaseTree(baseDir)
463
+ }
327
464
  }
328
465
 
329
466
  /**
@@ -358,13 +495,39 @@ const buildMatrix = (scopeMap, changedFilesFromEvent, beforeRef, headRef, forced
358
495
  return [...base, ...forced]
359
496
  }
360
497
 
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)
498
+ /**
499
+ * Builds the matrix: with only-unbuilt, every scope whose checksum tag GHCR lacks, every scope's
500
+ * computed tag and the scopes whose tag differs from the base's; otherwise the changed scopes (or
501
+ * all with force-all), no tags and no tag changes.
502
+ * @returns {Promise<{matrix: Object[], scopeTags: Record<string, string>, changedTags: string[]}>}
503
+ */
504
+ const resolveMatrix = async () => {
505
+ const scopeMap = discoverScopes(loadRoots())
506
+ const forcedShortNames = loadForcedShortNames()
507
+ const { beforeRef, headRef } = getCommitRefs()
508
+
509
+ if (isEnabled('only-unbuilt') && !isForceAll()) {
510
+ const lookups = await lookupScopeTags(scopeMap)
511
+ const scopeTags = scopeTagMap(lookups)
512
+ const baseScopeTags = beforeRef ? computeBaseScopeTags(scopeMap, beforeRef) : {}
513
+ const changedTags = changedScopeTags(scopeTags, baseScopeTags)
514
+ console.log(`Tags changed since ${beforeRef ?? 'no base'}: ${changedTags.join(' ') || 'none'}`)
515
+ return { matrix: selectUnbuilt(lookups, forcedShortNames), scopeTags, changedTags }
516
+ }
517
+
518
+ const changedFilesFromEvent = loadEventChangedFiles(process.env.GITHUB_EVENT_PATH)
519
+ const matrix = buildMatrix(scopeMap, changedFilesFromEvent, beforeRef, headRef, forcedShortNames)
520
+ return { matrix, scopeTags: {}, changedTags: [] }
521
+ }
368
522
 
369
- setOutput('scopes', JSON.stringify(matrix))
370
- setOutput('scopes_count', String(matrix.length))
523
+ resolveMatrix()
524
+ .then(({ matrix, scopeTags, changedTags }) => {
525
+ setOutput('scopes', JSON.stringify(matrix))
526
+ setOutput('scopes_count', String(matrix.length))
527
+ setOutput('scope_tags', JSON.stringify(scopeTags))
528
+ setOutput('tags_changed', String(changedTags.length > 0))
529
+ })
530
+ .catch((error) => {
531
+ console.error('Failed to generate the scope matrix:', error)
532
+ process.exit(1)
533
+ })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Usage: node scope-checksum.js <pruned-out-dir> <scope-dir>
3
+ *
4
+ * Prints the checksum of a pruned scope (out/<short-name>) over its git-tracked files, without the
5
+ * workspaces the scope does not reach: their directories under json/ and full/ and their blocks in
6
+ * bun.lock. Called by scope-checksum.sh from the workspace root after `turbo prune`.
7
+ */
8
+
9
+ const { execFileSync } = require('node:child_process')
10
+ const { createHash } = require('node:crypto')
11
+ const { lstatSync, readdirSync, readFileSync } = require('node:fs')
12
+ const { join, relative, sep } = require('node:path')
13
+ const {
14
+ checksumOf,
15
+ foreignWorkspaces,
16
+ isForeignPath,
17
+ isSourcePath,
18
+ stripLockWorkspaces,
19
+ workspaceManifest,
20
+ } = require('./scope-tree.js')
21
+
22
+ /**
23
+ * Lists the regular files below a directory, like `find -type f`: symlinks are not followed or listed.
24
+ * @param {string} root - Directory to walk
25
+ * @returns {string[]} Paths relative to root, '/'-separated
26
+ */
27
+ const listFiles = (root) => {
28
+ const files = []
29
+ const walk = (dir) => {
30
+ for (const entry of readdirSync(dir)) {
31
+ const path = join(dir, entry)
32
+ const stats = lstatSync(path)
33
+ if (stats.isDirectory()) walk(path)
34
+ else if (stats.isFile()) files.push(relative(root, path).split(sep).join('/'))
35
+ }
36
+ }
37
+ walk(root)
38
+ return files
39
+ }
40
+
41
+ /**
42
+ * Reads every workspace manifest of the pruned tree's json/ half, the root manifest excluded.
43
+ * @param {string} outDir - out/<short-name>
44
+ * @param {string[]} files - Files of outDir
45
+ * @returns {Map<string, import('./scope-tree.js').WorkspaceManifest>}
46
+ */
47
+ const readManifests = (outDir, files) =>
48
+ new Map(
49
+ files
50
+ .filter((path) => path.startsWith('json/') && path.endsWith('/package.json') && !path.includes('/node_modules/'))
51
+ .filter((path) => path !== 'json/package.json')
52
+ .map((path) => [
53
+ path.slice('json/'.length, -'/package.json'.length),
54
+ workspaceManifest(JSON.parse(readFileSync(join(outDir, path), 'utf8'))),
55
+ ]),
56
+ )
57
+
58
+ const sha256 = (content) => createHash('sha256').update(content).digest('hex')
59
+
60
+ /**
61
+ * Lists the files git tracks in the current directory, which must be the workspace root.
62
+ * @returns {Set<string>} '/'-separated paths
63
+ */
64
+ const readTrackedPaths = () =>
65
+ new Set(execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }).split('\0').filter(Boolean))
66
+
67
+ const [outDir, scopeDir] = process.argv.slice(2)
68
+ const files = listFiles(outDir)
69
+ const trackedPaths = readTrackedPaths()
70
+ const foreignDirs = foreignWorkspaces(scopeDir, readManifests(outDir, files))
71
+ const entries = files
72
+ .filter((path) => isSourcePath(path, trackedPaths))
73
+ .filter((path) => !isForeignPath(path, foreignDirs))
74
+ .map((path) => {
75
+ const content = readFileSync(join(outDir, path))
76
+ const isLock = path === 'bun.lock' || path.endsWith('/bun.lock')
77
+ return { path, digest: sha256(isLock ? stripLockWorkspaces(content.toString('utf8'), foreignDirs) : content) }
78
+ })
79
+
80
+ console.error(`${scopeDir}: left out of the checksum: ${foreignDirs.join(', ') || 'nothing'}`)
81
+ console.log(checksumOf(entries))
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: scope-checksum.sh <scope-name> <scope-short-name> <scope-dir>
3
+ #
4
+ # Prunes one scope into out/<short-name> and prints its checksum, which is the scope's image tag.
5
+ # Workspaces the scope does not depend on (scope-checksum.js) stay in the build context but not in
6
+ # the checksum.
7
+ # The build job and generate-scope-matrix (only-unbuilt) both call this script, so the tag the
8
+ # matrix looks up in GHCR is the tag the build job pushes. Run from the workspace root after
9
+ # `bun install`: `bunx` must resolve the workspace's own turbo, another version prunes differently.
10
+ set -euo pipefail
11
+
12
+ SCOPE_NAME="$1"
13
+ SHORT_NAME="$2"
14
+ SCOPE_DIR="$3"
15
+ OUT_DIR="out/${SHORT_NAME}"
16
+
17
+ bunx turbo prune --scope="${SCOPE_NAME}" --docker --out-dir "${OUT_DIR}" >&2
18
+ # turbo prune omits the bun patchedDependencies patch files. Copy them into the
19
+ # pruned context so `bun install` can apply them (rxdb patch for studio/storefront).
20
+ # Done before the scope checksum so a patch change busts the image tag.
21
+ if [ -d patches ]; then
22
+ cp -rT patches "${OUT_DIR}/full/patches"
23
+ fi
24
+
25
+ node "$(dirname "$0")/scope-checksum.js" "${OUT_DIR}" "${SCOPE_DIR}"