@crossdelta/platform-sdk 0.26.1 → 0.26.3
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 +9 -0
- 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 +118 -20
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +35 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to `@crossdelta/platform-sdk` will be documented in this fil
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.26.3] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **The workspace template's PR tests run only for affected packages.** `lint-and-tests.yml` runs
|
|
12
|
+
`turbo run test --affected` against the PR's base (the merge commit's first parent, checkout depth
|
|
13
|
+
2). A change to a root file every package reads (`package.json`, `bunfig.toml`, `tsconfig.json`,
|
|
14
|
+
`.npmrc`, `.bun-version`, `patches/`, the workflow itself or `setup-bun-install`) still tests every
|
|
15
|
+
package, because `--affected` does not count them. A PR that touches no package runs no task and passes.
|
|
16
|
+
|
|
8
17
|
## [0.26.1] - 2026-10-17
|
|
9
18
|
|
|
10
19
|
### Fixed
|
|
@@ -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}"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure functions behind a scope's checksum: which workspaces of a pruned tree the scope reaches,
|
|
3
|
+
* and how the remaining files hash into the image tag.
|
|
4
|
+
*
|
|
5
|
+
* `turbo prune` also copies every workspace the root package.json depends on (tool packages such
|
|
6
|
+
* as a CLI), so without this a release of such a package changed every scope's tag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { createHash } = require('node:crypto')
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{name: string, dependencies: string[]}} WorkspaceManifest
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
|
|
16
|
+
const PRUNED_TREES = ['json', 'full']
|
|
17
|
+
const WORKSPACE_BLOCK_INDENT = ' '
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reduces a parsed package.json to its name and the names of everything it depends on. Every
|
|
21
|
+
* dependency field counts and the version spec does not matter, so a workspace linked by version
|
|
22
|
+
* instead of `workspace:` stays in the closure.
|
|
23
|
+
* @param {Object} packageJson - Parsed package.json
|
|
24
|
+
* @returns {WorkspaceManifest}
|
|
25
|
+
*/
|
|
26
|
+
const workspaceManifest = (packageJson) => ({
|
|
27
|
+
name: packageJson.name,
|
|
28
|
+
dependencies: DEPENDENCY_FIELDS.flatMap((field) => Object.keys(packageJson[field] ?? {})),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collects the workspaces a scope reaches through its own dependencies, transitively, itself included.
|
|
33
|
+
* @param {string} scopeDir - The scope's workspace directory, e.g. 'apps/web'
|
|
34
|
+
* @param {Map<string, WorkspaceManifest>} manifestsByDir - Every workspace of the pruned tree by directory
|
|
35
|
+
* @returns {Set<string>} Workspace directories
|
|
36
|
+
*/
|
|
37
|
+
const dependencyClosure = (scopeDir, manifestsByDir) => {
|
|
38
|
+
if (!manifestsByDir.has(scopeDir)) {
|
|
39
|
+
throw new Error(`No manifest for ${scopeDir} in the pruned tree`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dirsByName = new Map([...manifestsByDir].map(([dir, manifest]) => [manifest.name, dir]))
|
|
43
|
+
const reached = new Set()
|
|
44
|
+
const pending = [scopeDir]
|
|
45
|
+
|
|
46
|
+
while (pending.length > 0) {
|
|
47
|
+
const dir = pending.pop()
|
|
48
|
+
if (reached.has(dir)) continue
|
|
49
|
+
reached.add(dir)
|
|
50
|
+
for (const name of manifestsByDir.get(dir).dependencies) {
|
|
51
|
+
const dependencyDir = dirsByName.get(name)
|
|
52
|
+
if (dependencyDir) pending.push(dependencyDir)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return reached
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lists the workspaces of a pruned tree the scope does not reach.
|
|
61
|
+
* @param {string} scopeDir - The scope's workspace directory
|
|
62
|
+
* @param {Map<string, WorkspaceManifest>} manifestsByDir - Every workspace of the pruned tree by directory
|
|
63
|
+
* @returns {string[]} Workspace directories, sorted
|
|
64
|
+
*/
|
|
65
|
+
const foreignWorkspaces = (scopeDir, manifestsByDir) => {
|
|
66
|
+
const closure = dependencyClosure(scopeDir, manifestsByDir)
|
|
67
|
+
return [...manifestsByDir.keys()].filter((dir) => !closure.has(dir)).sort()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Checks if a path of the pruned tree belongs to a foreign workspace.
|
|
72
|
+
* @param {string} path - Path relative to out/<scope>, without './'
|
|
73
|
+
* @param {string[]} foreignDirs - Foreign workspace directories
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
const isForeignPath = (path, foreignDirs) =>
|
|
77
|
+
PRUNED_TREES.some((tree) => foreignDirs.some((dir) => path.startsWith(`${tree}/${dir}/`)))
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Checks if a file of the pruned tree is a copy of a git-tracked source file. `turbo prune` also
|
|
81
|
+
* copies ignored files such as `dist/` and `.turbo/` logs, which the CI's `bun install` builds anew
|
|
82
|
+
* on every run; hashing them changed every scope's tag on every run. Files outside json/ and full/
|
|
83
|
+
* (the pruned bun.lock) are turbo's own output and always count.
|
|
84
|
+
* @param {string} path - Path relative to out/<scope>, without './'
|
|
85
|
+
* @param {Set<string>} trackedPaths - `git ls-files` of the workspace root
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
const isSourcePath = (path, trackedPaths) => {
|
|
89
|
+
const tree = PRUNED_TREES.find((name) => path.startsWith(`${name}/`))
|
|
90
|
+
return tree === undefined || trackedPaths.has(path.slice(tree.length + 1))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Removes the blocks of the given workspaces from the `workspaces` section of a bun.lock. The rest
|
|
95
|
+
* of the file is returned byte for byte.
|
|
96
|
+
* @param {string} lockText - bun.lock content
|
|
97
|
+
* @param {string[]} foreignDirs - Workspace directories whose blocks go
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
const stripLockWorkspaces = (lockText, foreignDirs) => {
|
|
101
|
+
const blockStarts = new Set(foreignDirs.map((dir) => `${WORKSPACE_BLOCK_INDENT}"${dir}": {`))
|
|
102
|
+
const blockEnd = new RegExp(`^${WORKSPACE_BLOCK_INDENT}},?$`)
|
|
103
|
+
const kept = []
|
|
104
|
+
let inWorkspaces = false
|
|
105
|
+
let skipping = false
|
|
106
|
+
|
|
107
|
+
for (const line of lockText.split('\n')) {
|
|
108
|
+
if (line === ' "workspaces": {') inWorkspaces = true
|
|
109
|
+
else if (inWorkspaces && /^ {2}},?$/.test(line)) inWorkspaces = false
|
|
110
|
+
|
|
111
|
+
if (skipping) {
|
|
112
|
+
if (blockEnd.test(line)) skipping = false
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (inWorkspaces && blockStarts.has(line)) {
|
|
116
|
+
skipping = true
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
kept.push(line)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return kept.join('\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Hashes file digests the way `find . -type f | sort | xargs sha256sum | sha256sum` does, with the
|
|
127
|
+
* paths sorted by bytes so the order does not depend on the runner's locale.
|
|
128
|
+
* @param {Array<{path: string, digest: string}>} entries - Paths relative to out/<scope>, without './'
|
|
129
|
+
* @returns {string} Hex sha256
|
|
130
|
+
*/
|
|
131
|
+
const checksumOf = (entries) => {
|
|
132
|
+
const listing = entries
|
|
133
|
+
.map(({ path, digest }) => ({ path: `./${path}`, digest }))
|
|
134
|
+
.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)))
|
|
135
|
+
.map(({ path, digest }) => `${digest} ${path}\n`)
|
|
136
|
+
.join('')
|
|
137
|
+
return createHash('sha256').update(listing).digest('hex')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
checksumOf,
|
|
142
|
+
dependencyClosure,
|
|
143
|
+
foreignWorkspaces,
|
|
144
|
+
isForeignPath,
|
|
145
|
+
isSourcePath,
|
|
146
|
+
stripLockWorkspaces,
|
|
147
|
+
workspaceManifest,
|
|
148
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure selection for the only-unbuilt matrix: which scopes still need an image.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{name: string, dir: string, shortName: string}} Scope
|
|
7
|
+
* @typedef {{scope: Scope, imageTag: string, exists: boolean}} TagLookup
|
|
8
|
+
* @typedef {Scope & {run: true, imageTag: string}} MatrixEntry
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the scopes whose checksum tag GHCR does not hold yet, plus every forced scope.
|
|
13
|
+
* Each entry carries the `imageTag` it looked up, so the build job can warn when its own checksum differs.
|
|
14
|
+
* @param {TagLookup[]} lookups - One entry per discovered scope; `exists` is false when GHCR could not answer
|
|
15
|
+
* @param {string[]} [forcedShortNames=[]] - Short names included even when their image exists
|
|
16
|
+
* @returns {MatrixEntry[]} Matrix entries sorted by scope name
|
|
17
|
+
*/
|
|
18
|
+
const selectUnbuilt = (lookups, forcedShortNames = []) => {
|
|
19
|
+
const forced = new Set(forcedShortNames)
|
|
20
|
+
return lookups
|
|
21
|
+
.filter(({ scope, exists }) => !exists || forced.has(scope.shortName))
|
|
22
|
+
.map(({ scope, imageTag }) => ({ ...scope, run: true, imageTag }))
|
|
23
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maps every scope's short name to the tag computed for this commit, whether it was built or not.
|
|
28
|
+
* The deploy reads it for scopes without a build marker, so an image that already existed (after a
|
|
29
|
+
* revert, say) deploys by this commit's tag instead of by GHCR's newest checksum tag.
|
|
30
|
+
* @param {TagLookup[]} lookups - One entry per discovered scope
|
|
31
|
+
* @returns {Record<string, string>} Short name → image tag
|
|
32
|
+
*/
|
|
33
|
+
const scopeTagMap = (lookups) => Object.fromEntries(lookups.map(({ scope, imageTag }) => [scope.shortName, imageTag]))
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lists the scopes whose tag differs from the one computed at the diff base. A revert lands on an
|
|
37
|
+
* image GHCR already holds, so the matrix stays empty; this list is what still makes it deploy.
|
|
38
|
+
* @param {Record<string, string>} scopeTags - Short name → tag at this commit
|
|
39
|
+
* @param {Record<string, string>} baseScopeTags - Short name → tag at the base; a scope missing here counts as changed
|
|
40
|
+
* @returns {string[]} Short names, sorted
|
|
41
|
+
*/
|
|
42
|
+
const changedScopeTags = (scopeTags, baseScopeTags) =>
|
|
43
|
+
Object.keys(scopeTags)
|
|
44
|
+
.filter((shortName) => scopeTags[shortName] !== baseScopeTags[shortName])
|
|
45
|
+
.sort()
|
|
46
|
+
|
|
47
|
+
module.exports = { changedScopeTags, scopeTagMap, selectUnbuilt }
|
|
@@ -95,22 +95,71 @@ jobs:
|
|
|
95
95
|
needs: gate
|
|
96
96
|
if: needs.gate.outputs.proceed == 'true'
|
|
97
97
|
runs-on: ubuntu-latest
|
|
98
|
+
permissions:
|
|
99
|
+
actions: read
|
|
100
|
+
contents: read
|
|
101
|
+
packages: read
|
|
98
102
|
outputs:
|
|
99
103
|
scopes: $\{{ steps.scope-generator.outputs.scopes }}
|
|
100
104
|
scope_count: $\{{ steps.scope-generator.outputs.scopes_count }}
|
|
105
|
+
scope_tags: $\{{ steps.scope-generator.outputs.scope_tags }}
|
|
106
|
+
tags_changed: $\{{ steps.scope-generator.outputs.tags_changed }}
|
|
101
107
|
infrastructure_changed: $\{{ steps.infra-eval.outputs.changed }}
|
|
108
|
+
force_deploy: $\{{ steps.commit-range.outputs.force_deploy }}
|
|
102
109
|
steps:
|
|
103
110
|
- uses: actions/checkout@v6
|
|
104
111
|
with:
|
|
105
112
|
fetch-depth: 0
|
|
106
113
|
ref: $\{{ env.WORKFLOW_HEAD_SHA }}
|
|
107
114
|
|
|
115
|
+
# A workflow_run event carries no `before`. Its base is the head of the last successful run on
|
|
116
|
+
# main whose Prepare Scopes job ran, so a push of several commits or a commit whose publish run
|
|
117
|
+
# failed still shows its changes. A run the gate stopped does not count: it never looked at the
|
|
118
|
+
# diff. Manual dispatches do not count either: they may have targeted another environment.
|
|
119
|
+
#
|
|
120
|
+
# An image in GHCR is not an image on stage: a failed run since that base may have pushed images
|
|
121
|
+
# it never deployed, and the next run would find them built and skip the deploy. Any failed or
|
|
122
|
+
# cancelled run since the base, or no base at all (HEAD^ fallback), therefore forces a deploy.
|
|
108
123
|
- name: Set commit range
|
|
124
|
+
id: commit-range
|
|
125
|
+
env:
|
|
126
|
+
GH_TOKEN: $\{{ github.token }}
|
|
127
|
+
EVENT_NAME: $\{{ github.event_name }}
|
|
128
|
+
WORKFLOW_FILE: build-and-deploy.yml
|
|
129
|
+
PREPARE_JOB_NAME: 🧪 Prepare Scopes
|
|
109
130
|
run: |
|
|
110
131
|
set -euo pipefail
|
|
111
132
|
HEAD_SHA="${WORKFLOW_HEAD_SHA}"
|
|
112
133
|
BASE_SHA="${WORKFLOW_BASE_SHA_INPUT}"
|
|
113
134
|
NULL_SHA="0000000000000000000000000000000000000000"
|
|
135
|
+
FORCE_DEPLOY=false
|
|
136
|
+
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
|
137
|
+
RUNS=$(gh run list -R "$GITHUB_REPOSITORY" --workflow "$WORKFLOW_FILE" --branch main --status completed --limit 30 \
|
|
138
|
+
--json databaseId,headSha,conclusion,event --jq '.[] | "\(.databaseId) \(.headSha) \(.conclusion) \(.event)"' \
|
|
139
|
+
|| echo "::warning::Could not list earlier runs.")
|
|
140
|
+
while read -r RUN_ID RUN_SHA RUN_CONCLUSION RUN_EVENT; do
|
|
141
|
+
[ -n "$RUN_ID" ] || continue
|
|
142
|
+
[ "$RUN_EVENT" != "workflow_dispatch" ] || continue
|
|
143
|
+
git merge-base --is-ancestor "$RUN_SHA" "$HEAD_SHA" 2>/dev/null || continue
|
|
144
|
+
if [ "$RUN_CONCLUSION" != "success" ]; then
|
|
145
|
+
FORCE_DEPLOY=true
|
|
146
|
+
echo "Run $RUN_ID at $RUN_SHA ended $RUN_CONCLUSION: deploying regardless of the matrix"
|
|
147
|
+
continue
|
|
148
|
+
fi
|
|
149
|
+
PREPARED=$(gh run view "$RUN_ID" -R "$GITHUB_REPOSITORY" --json jobs \
|
|
150
|
+
--jq "[.jobs[] | select(.name == \"$PREPARE_JOB_NAME\" and .conclusion == \"success\")] | length" || echo 0)
|
|
151
|
+
if [ "$PREPARED" != "0" ]; then
|
|
152
|
+
BASE_SHA="$RUN_SHA"
|
|
153
|
+
echo "Base: last prepared run $RUN_ID at $RUN_SHA"
|
|
154
|
+
break
|
|
155
|
+
fi
|
|
156
|
+
done <<< "$RUNS"
|
|
157
|
+
if [ -z "$BASE_SHA" ]; then
|
|
158
|
+
FORCE_DEPLOY=true
|
|
159
|
+
echo "::warning::No earlier prepared run on main found; diffing against HEAD^ and deploying."
|
|
160
|
+
fi
|
|
161
|
+
fi
|
|
162
|
+
echo "force_deploy=$FORCE_DEPLOY" >> "$GITHUB_OUTPUT"
|
|
114
163
|
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "$NULL_SHA" ]; then
|
|
115
164
|
if git rev-parse "${HEAD_SHA}^" >/dev/null 2>&1; then
|
|
116
165
|
BASE_SHA=$(git rev-parse "${HEAD_SHA}^")
|
|
@@ -121,10 +170,15 @@ jobs:
|
|
|
121
170
|
echo "GITHUB_SHA=$HEAD_SHA" >> "$GITHUB_ENV"
|
|
122
171
|
echo "GITHUB_EVENT_BEFORE=$BASE_SHA" >> "$GITHUB_ENV"
|
|
123
172
|
|
|
173
|
+
# The stack's own directory plus the workspace packages it imports. Those packages only ever
|
|
174
|
+
# change on the workflow_run path: a push touching packages/ is handed to the publish run.
|
|
124
175
|
- name: Check for infrastructure changes
|
|
125
176
|
id: infra-check
|
|
177
|
+
env:
|
|
178
|
+
INFRA_PATHS: infra/ packages/infrastructure/ packages/contracts/ packages/cloudevents/
|
|
126
179
|
run: |
|
|
127
|
-
|
|
180
|
+
# shellcheck disable=SC2086 # INFRA_PATHS is a space-separated pathspec list
|
|
181
|
+
CHANGED=$(git diff --name-only "$GITHUB_EVENT_BEFORE" "$GITHUB_SHA" -- $INFRA_PATHS | head -1)
|
|
128
182
|
if [ -n "$CHANGED" ]; then
|
|
129
183
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
130
184
|
else
|
|
@@ -134,18 +188,44 @@ jobs:
|
|
|
134
188
|
- name: Evaluate infrastructure change
|
|
135
189
|
id: infra-eval
|
|
136
190
|
run: |
|
|
137
|
-
if [ "$\{{ github.event_name }}" = "
|
|
191
|
+
if [ "$\{{ github.event_name }}" = "workflow_dispatch" ] || [ "$\{{ steps.infra-check.outputs.changed }}" = "true" ]; then
|
|
138
192
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
|
139
193
|
else
|
|
140
194
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
|
141
195
|
fi
|
|
142
196
|
|
|
197
|
+
# A private workspace package or a lockfile line reaches a scope without
|
|
198
|
+
# touching its directory. On those paths the matrix asks GHCR whether each scope's checksum
|
|
199
|
+
# tag exists instead of diffing directories; a tool-package bump that changes no scope's
|
|
200
|
+
# pruned tree then builds and deploys nothing. A manual dispatch keeps building every scope.
|
|
201
|
+
- name: Decide matrix mode
|
|
202
|
+
id: matrix-mode
|
|
203
|
+
run: |
|
|
204
|
+
EVENT="$\{{ github.event_name }}"
|
|
205
|
+
LOCK_CHANGED=$(git diff --name-only "$GITHUB_EVENT_BEFORE" "$GITHUB_SHA" -- bun.lock | head -1)
|
|
206
|
+
if [ "$EVENT" != "workflow_dispatch" ] && { [ "$EVENT" = "workflow_run" ] || [ -n "$LOCK_CHANGED" ]; }; then
|
|
207
|
+
echo "only_unbuilt=true" >> "$GITHUB_OUTPUT"
|
|
208
|
+
else
|
|
209
|
+
echo "only_unbuilt=false" >> "$GITHUB_OUTPUT"
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
- name: Setup Bun and install dependencies
|
|
213
|
+
if: steps.matrix-mode.outputs.only_unbuilt == 'true'
|
|
214
|
+
uses: ./.github/actions/setup-bun-install
|
|
215
|
+
with:
|
|
216
|
+
enable-cache: 'true'
|
|
217
|
+
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
218
|
+
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
219
|
+
npm-token: $\{{ github.token }}
|
|
220
|
+
|
|
143
221
|
- name: Generate scope matrix
|
|
144
222
|
id: scope-generator
|
|
145
223
|
uses: ./.github/actions/generate-scope-matrix
|
|
146
224
|
with:
|
|
147
225
|
scope-roots: $\{{ env.SCOPE_ROOTS }}
|
|
148
|
-
force-all: $\{{ github.event_name == 'workflow_dispatch'
|
|
226
|
+
force-all: $\{{ github.event_name == 'workflow_dispatch' }}
|
|
227
|
+
only-unbuilt: $\{{ steps.matrix-mode.outputs.only_unbuilt }}
|
|
228
|
+
github-token: $\{{ secrets.GITHUB_TOKEN }}
|
|
149
229
|
|
|
150
230
|
build:
|
|
151
231
|
if: needs.prepare-scopes.outputs.scope_count && needs.prepare-scopes.outputs.scope_count != '0'
|
|
@@ -182,27 +262,30 @@ jobs:
|
|
|
182
262
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
183
263
|
npm-token: $\{{ github.token }}
|
|
184
264
|
|
|
185
|
-
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
# turbo prune omits the bun patchedDependencies patch files. Copy them into the
|
|
189
|
-
# pruned context so `bun install` can apply them (rxdb patch for studio/storefront).
|
|
190
|
-
# Done before the scope checksum so a patch change busts the image tag.
|
|
191
|
-
if [ -d patches ]; then
|
|
192
|
-
cp -rT patches "out/$\{{ matrix.scope.shortName }}/full/patches"
|
|
193
|
-
fi
|
|
194
|
-
|
|
195
|
-
- name: Compute scope checksum
|
|
265
|
+
# The same script computes the tag prepare-scopes looked up (only-unbuilt), so both agree. It
|
|
266
|
+
# also leaves the pruned tree in out/<short-name>, which the Docker build below uses.
|
|
267
|
+
- name: Prune and compute scope checksum
|
|
196
268
|
id: scope-checksum
|
|
269
|
+
env:
|
|
270
|
+
SCOPE_NAME: $\{{ matrix.scope.name }}
|
|
271
|
+
SCOPE_SHORT_NAME: $\{{ matrix.scope.shortName }}
|
|
272
|
+
SCOPE_DIR: $\{{ matrix.scope.dir }}
|
|
197
273
|
run: |
|
|
198
274
|
set -euo pipefail
|
|
199
|
-
|
|
200
|
-
HASH=$(find . -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum)
|
|
201
|
-
HASH=${HASH%% *}
|
|
275
|
+
HASH=$(bash .github/actions/generate-scope-matrix/scope-checksum.sh "$SCOPE_NAME" "$SCOPE_SHORT_NAME" "$SCOPE_DIR")
|
|
202
276
|
echo "scope_hash=$HASH" >> $GITHUB_OUTPUT
|
|
203
277
|
|
|
278
|
+
# The tag names what this job built. A matrix tag that differs means the pruned tree changed
|
|
279
|
+
# between the two jobs; the image then carries this job's tag, never the looked-up one.
|
|
204
280
|
- name: Use scope checksum as image tag
|
|
205
|
-
|
|
281
|
+
env:
|
|
282
|
+
MATRIX_TAG: $\{{ matrix.scope.imageTag }}
|
|
283
|
+
SCOPE_HASH: $\{{ steps.scope-checksum.outputs.scope_hash }}
|
|
284
|
+
run: |
|
|
285
|
+
if [ -n "$MATRIX_TAG" ] && [ "$MATRIX_TAG" != "$SCOPE_HASH" ]; then
|
|
286
|
+
echo "::warning::$\{{ matrix.scope.shortName }}: checksum $SCOPE_HASH differs from the matrix tag $MATRIX_TAG"
|
|
287
|
+
fi
|
|
288
|
+
echo "IMAGE_TAG=$SCOPE_HASH" >> $GITHUB_ENV
|
|
206
289
|
|
|
207
290
|
- name: Check if image tag exists
|
|
208
291
|
id: tag-check
|
|
@@ -275,12 +358,17 @@ jobs:
|
|
|
275
358
|
# deploy the newest checksum image GHCR holds for them — an older commit, possibly a stage
|
|
276
359
|
# build. That happened on 2026-09-17 (run 35254568199): four app builds cancelled, deploy
|
|
277
360
|
# went ahead on the previous commit's images.
|
|
361
|
+
# `tags_changed` covers a revert: its tag is an older image GHCR still holds, so nothing is
|
|
362
|
+
# built, but stage runs the reverted one.
|
|
278
363
|
if: |
|
|
279
364
|
always() &&
|
|
280
365
|
needs.gate.outputs.proceed == 'true' &&
|
|
281
366
|
needs.prepare-scopes.result == 'success' &&
|
|
282
367
|
(needs.build.result == 'success' || needs.build.result == 'skipped') &&
|
|
283
|
-
(needs.prepare-scopes.outputs.scope_count != '0' ||
|
|
368
|
+
(needs.prepare-scopes.outputs.scope_count != '0' ||
|
|
369
|
+
needs.prepare-scopes.outputs.tags_changed == 'true' ||
|
|
370
|
+
needs.prepare-scopes.outputs.force_deploy == 'true' ||
|
|
371
|
+
needs.prepare-scopes.outputs.infrastructure_changed == 'true')
|
|
284
372
|
name: 🌍 Deploy ($\{{ github.event.inputs.environment || 'stage' }})
|
|
285
373
|
environment: $\{{ github.event.inputs.environment || 'stage' }}
|
|
286
374
|
steps:
|
|
@@ -317,6 +405,7 @@ jobs:
|
|
|
317
405
|
GH_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
|
|
318
406
|
REPO_OWNER: $\{{ github.repository_owner }}
|
|
319
407
|
REPO_PREFIX: $\{{ github.event.repository.name }}
|
|
408
|
+
COMPUTED_TAGS: $\{{ needs.prepare-scopes.outputs.scope_tags || '{}' }}
|
|
320
409
|
run: |
|
|
321
410
|
set -euo pipefail
|
|
322
411
|
RESULT="{}"
|
|
@@ -332,13 +421,22 @@ jobs:
|
|
|
332
421
|
done
|
|
333
422
|
fi
|
|
334
423
|
|
|
335
|
-
# 2. For scopes without markers,
|
|
424
|
+
# 2. For scopes without markers: this commit's computed tag, else GHCR's latest checksum tag
|
|
336
425
|
MISSING=""
|
|
337
426
|
for scope_dir in apps/*/Dockerfile services/*/Dockerfile; do
|
|
338
427
|
[ -f "$scope_dir" ] || continue
|
|
339
428
|
SHORT=$(basename "$(dirname "$scope_dir")")
|
|
340
429
|
echo "$RESULT" | jq -e --arg k "$SHORT" '.[$k]' >/dev/null 2>&1 && continue
|
|
341
430
|
|
|
431
|
+
# A scope prepare-scopes found already built carries this commit's tag: after a revert
|
|
432
|
+
# that is an older image, and GHCR's newest checksum tag would roll out the reverted one.
|
|
433
|
+
COMPUTED_TAG=$(jq -r --arg k "$SHORT" '.[$k] // empty' <<<"$COMPUTED_TAGS")
|
|
434
|
+
if [ -n "$COMPUTED_TAG" ]; then
|
|
435
|
+
RESULT=$(echo "$RESULT" | jq --arg k "$SHORT" --arg v "$REGISTRY/$SHORT:$COMPUTED_TAG" '. + {($k): $v}')
|
|
436
|
+
echo " $SHORT → $COMPUTED_TAG (computed for this commit)"
|
|
437
|
+
continue
|
|
438
|
+
fi
|
|
439
|
+
|
|
342
440
|
# Query GHCR for the latest version with a checksum tag (retry transient API errors)
|
|
343
441
|
CHECKSUM_TAG=""
|
|
344
442
|
for attempt in 1 2 3; do
|
|
@@ -12,6 +12,7 @@ concurrency:
|
|
|
12
12
|
jobs:
|
|
13
13
|
lint:
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
|
+
timeout-minutes: 10
|
|
15
16
|
steps:
|
|
16
17
|
- name: Checkout code
|
|
17
18
|
uses: actions/checkout@v6
|
|
@@ -36,10 +37,15 @@ jobs:
|
|
|
36
37
|
|
|
37
38
|
test:
|
|
38
39
|
runs-on: ubuntu-latest
|
|
40
|
+
timeout-minutes: 20
|
|
39
41
|
env:
|
|
40
42
|
steps:
|
|
43
|
+
# A pull_request run checks out the PR merged into its base; depth 2 brings that merge
|
|
44
|
+
# commit's first parent, the base, which the test scope is diffed against.
|
|
41
45
|
- name: Checkout code
|
|
42
46
|
uses: actions/checkout@v6
|
|
47
|
+
with:
|
|
48
|
+
fetch-depth: 2
|
|
43
49
|
|
|
44
50
|
- name: Setup Bun and install dependencies
|
|
45
51
|
uses: ./.github/actions/setup-bun-install
|
|
@@ -48,11 +54,39 @@ jobs:
|
|
|
48
54
|
cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
|
|
49
55
|
cache-restore-keys: bun-v4-$\{{ runner.os }}-
|
|
50
56
|
|
|
57
|
+
# `turbo run test --affected` tests the changed packages and their dependents, all of them
|
|
58
|
+
# for a turbo.json or lockfile change. It ignores the root files every package reads, though:
|
|
59
|
+
# an override, the Bun version or the test script in the root package.json selects nothing.
|
|
60
|
+
# Those paths test every package. A PR that touches no package runs no task and passes.
|
|
61
|
+
- name: Decide test scope
|
|
62
|
+
id: test-scope
|
|
63
|
+
env:
|
|
64
|
+
GLOBAL_PATHS: package.json bunfig.toml tsconfig.json .npmrc .bun-version patches/ .github/workflows/lint-and-tests.yml .github/actions/setup-bun-install/
|
|
65
|
+
run: |
|
|
66
|
+
# shellcheck disable=SC2086 # GLOBAL_PATHS is a space-separated pathspec list
|
|
67
|
+
CHANGED=$(git diff --name-only HEAD^1 HEAD -- $GLOBAL_PATHS | head -1)
|
|
68
|
+
if [ -n "$CHANGED" ]; then
|
|
69
|
+
echo "::notice::${CHANGED} changed: testing every package."
|
|
70
|
+
echo "affected_only=false" >> "$GITHUB_OUTPUT"
|
|
71
|
+
else
|
|
72
|
+
echo "affected_only=true" >> "$GITHUB_OUTPUT"
|
|
73
|
+
fi
|
|
74
|
+
|
|
51
75
|
- name: Run Tests
|
|
52
|
-
|
|
76
|
+
env:
|
|
77
|
+
AFFECTED_ONLY: $\{{ steps.test-scope.outputs.affected_only }}
|
|
78
|
+
TURBO_SCM_BASE: HEAD^1
|
|
79
|
+
TURBO_SCM_HEAD: HEAD
|
|
80
|
+
run: |
|
|
81
|
+
if [ "$AFFECTED_ONLY" = "true" ]; then
|
|
82
|
+
bun run --if-present test --affected
|
|
83
|
+
else
|
|
84
|
+
bun run --if-present test
|
|
85
|
+
fi
|
|
53
86
|
|
|
54
87
|
e2e:
|
|
55
88
|
runs-on: ubuntu-latest
|
|
89
|
+
timeout-minutes: 30
|
|
56
90
|
needs: test
|
|
57
91
|
if: false # disabled until E2E tests are stable
|
|
58
92
|
env:
|