@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 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 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}"
@@ -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
- CHANGED=$(git diff --name-only "$GITHUB_EVENT_BEFORE" "$GITHUB_SHA" -- infra/ | head -1)
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 }}" = "workflow_run" ] || [ "$\{{ github.event_name }}" = "workflow_dispatch" ] || [ "$\{{ steps.infra-check.outputs.changed }}" = "true" ]; then
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' || github.event_name == 'workflow_run' }}
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
- - name: Prune
186
- run: |
187
- bunx turbo prune --scope=$\{{ matrix.scope.name }} --docker --out-dir out/$\{{ matrix.scope.shortName }}
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
- cd "out/$\{{ matrix.scope.shortName }}"
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
- run: echo "IMAGE_TAG=$\{{ steps.scope-checksum.outputs.scope_hash }}" >> $GITHUB_ENV
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' || needs.prepare-scopes.outputs.infrastructure_changed == 'true')
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, query GHCR for latest checksum tag
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
- run: bun run --if-present test
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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/platform-sdk",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",