@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.
- package/CHANGELOG.md +13 -0
- package/bin/cli.mjs +56 -56
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/ghcr.js +87 -0
- package/bin/templates/workspace/.github/actions/check-image-tag-exists/index.js +4 -89
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/action.yml +18 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/index.js +177 -14
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/scope-checksum.js +81 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/scope-checksum.sh +25 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/scope-tree.js +148 -0
- package/bin/templates/workspace/.github/actions/generate-scope-matrix/select.js +47 -0
- package/bin/templates/workspace/.github/workflows/build-and-deploy.yml.hbs +138 -20
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +11 -0
- package/bin/templates/workspace/.github/workflows/publish-packages.yml +20 -1
- package/bin/templates/workspace/CLAUDE.md +2 -2
- package/bin/templates/workspace/infra/package.json.hbs +1 -1
- package/bin/templates/workspace/package.json.hbs +4 -4
- package/bin/templates/workspace/packages/contracts/package.json.hbs +1 -1
- package/package.json +2 -3
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
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
|
-
|
|
370
|
-
|
|
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}"
|