@crossdelta/platform-sdk 0.26.10 → 0.26.11

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,32 @@ 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.11] - 2026-10-28
9
+
10
+ ### Changed
11
+ - **A manual dispatch of the generated `build-and-deploy.yml` builds only the images GHCR lacks.**
12
+ It used to rebuild every scope; it now asks GHCR like the publish path does, rolls out every
13
+ scope's tag for this commit and skips the base worktree through the new `compare-base` input of
14
+ `generate-scope-matrix`.
15
+ - **CI installs skip the root `prepare` where no job reads a built package.** `setup-bun-install`
16
+ has a new `ignore-scripts` input (default `false`); the generated workflows set it in
17
+ `prepare-scopes`, the build job, the deploy and `lint`. The deploy builds the stack's packages
18
+ itself; `test` keeps `prepare`, since a package's tests may read its own build output, which
19
+ `^build` does not produce.
20
+ - **Apps export the Docker layer cache with `mode=min`.** Exporting their builder layers took one to
21
+ two minutes per build and rarely paid off; services keep `mode=max`.
22
+ - **A library's `devDependencies` no longer count towards a scope's checksum.** Only the scope's own
23
+ `devDependencies` do, so a test dependency of a library no longer rebuilds every scope that uses
24
+ the library. Every scope gets a new tag once.
25
+
26
+ ### Fixed
27
+ - **The image tag lookup logs its answer.** `check-image-tag-exists` and `generate-scope-matrix`
28
+ print the scope, the tag, whether the package exists and the HTTP status of every page read; an
29
+ API error carries its status.
30
+ - **`prepare-build-context` regenerates `bun.lock` without lifecycle scripts.** The install there
31
+ only rewrites the lockfile, and no dependency's script should run in a job that may later hold
32
+ deploy secrets.
33
+
8
34
  ## [0.26.10] - 2026-10-27
9
35
 
10
36
  ### Fixed
@@ -5,6 +5,11 @@
5
5
  * generate-scope-matrix/index.js requires this file by its relative path: moving it breaks that action.
6
6
  */
7
7
 
8
+ /**
9
+ * @typedef {{exists: boolean, packageFound: boolean, statuses: number[]}} GhcrLookup
10
+ * `packageFound` is false after a 404; `statuses` holds the HTTP status of every page read.
11
+ */
12
+
8
13
  const PAGE_SIZE = 100
9
14
 
10
15
  /**
@@ -29,6 +34,16 @@ const tagExistsInVersions = (versions, targetTag) =>
29
34
  return Array.isArray(tags) && tags.includes(targetTag)
30
35
  })
31
36
 
37
+ /**
38
+ * One log line for a lookup, so a run shows why the build job did or did not find a tag.
39
+ * @param {string} shortName - Scope short name
40
+ * @param {string} tag - The tag looked up
41
+ * @param {GhcrLookup} lookup
42
+ * @returns {string}
43
+ */
44
+ const describeLookup = (shortName, tag, { exists, packageFound, statuses }) =>
45
+ `${shortName}:${tag} ${exists ? 'exists' : 'missing'}${packageFound ? '' : ' (package not found)'}, HTTP ${statuses.join(' ')}`
46
+
32
47
  /**
33
48
  * Creates a GHCR client for one package owner and repository prefix.
34
49
  * @param {Object} options
@@ -37,9 +52,10 @@ const tagExistsInVersions = (versions, targetTag) =>
37
52
  * @param {string} options.token - Token with read:packages
38
53
  * @param {number} [options.maxPages=5] - Pages of 100 versions to inspect before giving up
39
54
  * @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".
55
+ * @returns {{lookupTag: (shortName: string, tag: string) => Promise<GhcrLookup>}}
56
+ * `lookupTag` resolves "missing" when the package does not exist yet (404) and rejects on any other API
57
+ * error, with its HTTP status as `error.status`, so a caller decides itself whether an unknown
58
+ * answer means "build".
43
59
  */
44
60
  const createGhcrClient = ({ owner, prefix, token, maxPages = 5, fetchImpl = fetch }) => {
45
61
  const headers = {
@@ -55,33 +71,35 @@ const createGhcrClient = ({ owner, prefix, token, maxPages = 5, fetchImpl = fetc
55
71
 
56
72
  // 404 means the package doesn't exist yet (first build)
57
73
  if (response.status === 404) {
58
- return { notFound: true, versions: [], hasMore: false }
74
+ return { notFound: true, versions: [], hasMore: false, status: response.status }
59
75
  }
60
76
 
61
77
  if (!response.ok) {
62
78
  const body = await response.text()
63
- throw new Error(`GitHub API error (${response.status}): ${body}`)
79
+ throw Object.assign(new Error(`GitHub API error (${response.status}): ${body}`), { status: response.status })
64
80
  }
65
81
 
66
82
  const data = await response.json()
67
83
  const versions = Array.isArray(data) ? data : []
68
- return { notFound: false, versions, hasMore: versions.length === PAGE_SIZE }
84
+ return { notFound: false, versions, hasMore: versions.length === PAGE_SIZE, status: response.status }
69
85
  }
70
86
 
71
- const tagExists = async (shortName, tag) => {
87
+ const lookupTag = async (shortName, tag) => {
72
88
  const targetTag = tag.trim()
89
+ const statuses = []
73
90
 
74
91
  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
92
+ const { versions, hasMore, notFound, status } = await fetchVersions(shortName, page)
93
+ statuses.push(status)
94
+ if (notFound) return { exists: false, packageFound: false, statuses }
95
+ if (tagExistsInVersions(versions, targetTag)) return { exists: true, packageFound: true, statuses }
78
96
  if (!hasMore) break
79
97
  }
80
98
 
81
- return false
99
+ return { exists: false, packageFound: true, statuses }
82
100
  }
83
101
 
84
- return { tagExists }
102
+ return { lookupTag }
85
103
  }
86
104
 
87
- module.exports = { createGhcrClient, resolveRepositoryPrefix, tagExistsInVersions }
105
+ module.exports = { createGhcrClient, describeLookup, resolveRepositoryPrefix, tagExistsInVersions }
@@ -16,7 +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
+ const { createGhcrClient, describeLookup, resolveRepositoryPrefix } = require('./ghcr.js')
20
20
 
21
21
  /**
22
22
  * Builds environment variable keys for GitHub Actions inputs.
@@ -82,10 +82,11 @@ const ghcr = createGhcrClient({ owner: repositoryOwner, prefix: repositoryPrefix
82
82
  */
83
83
  const run = async () => {
84
84
  try {
85
- const exists = await ghcr.tagExists(scopeShortName, imageTag)
86
- setOutput('exists', String(exists))
85
+ const lookup = await ghcr.lookupTag(scopeShortName, imageTag)
86
+ console.log(`${describeLookup(scopeShortName, imageTag, lookup)} (${repositoryOwner}/${repositoryPrefix})`)
87
+ setOutput('exists', String(lookup.exists))
87
88
  } catch (error) {
88
- console.error('Failed to check image tag existence:', error)
89
+ console.error(`Failed to check image tag existence (HTTP ${error.status ?? 'none'}):`, error)
89
90
  setOutput('exists', 'false')
90
91
  exit(1)
91
92
  }
@@ -18,6 +18,13 @@ inputs:
18
18
  `github-token`. Ignored when force-all is true.
19
19
  required: false
20
20
  default: 'false'
21
+ compare-base:
22
+ description: >-
23
+ With only-unbuilt: when true, also compute every scope's tag at GITHUB_EVENT_BEFORE in a
24
+ temporary worktree and report `tags_changed`. False skips that worktree (a manual dispatch
25
+ deploys anyway); `tags_changed` is then 'false'.
26
+ required: false
27
+ default: 'true'
21
28
  github-token:
22
29
  description: Token with read:packages, used by only-unbuilt to query GHCR
23
30
  required: false
@@ -14,7 +14,8 @@
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`.
17
+ * its directory changed, and each entry carries that tag as `imageTag`. `compare-base: false`
18
+ * skips the tag comparison with the diff base.
18
19
  *
19
20
  * @outputs scopes - JSON array of scope objects [{name, shortName, dir, run, imageTag?}]
20
21
  * @outputs scopes_count - Number of scopes in the matrix
@@ -25,8 +26,8 @@ const { appendFileSync } = require('node:fs')
25
26
  const { spawnSync } = require('node:child_process')
26
27
  const { existsSync, readdirSync, readFileSync, statSync, symlinkSync } = require('node:fs')
27
28
  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')
29
+ const { createGhcrClient, describeLookup, resolveRepositoryPrefix } = require('../check-image-tag-exists/ghcr.js')
30
+ const { changedScopes, scopeTagMap, selectUnbuilt } = require('./select.js')
30
31
 
31
32
  /** Default directories to scan for scopes (Turborepo convention) */
32
33
  const defaultRoots = ['apps', 'services']
@@ -379,11 +380,16 @@ const lookupScopeTags = async (scopeMap) => {
379
380
 
380
381
  for (const scope of scopeMap.values()) {
381
382
  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'}`)
383
+ const exists = await ghcr
384
+ .lookupTag(scope.shortName, imageTag)
385
+ .then((lookup) => {
386
+ console.log(describeLookup(scope.shortName, imageTag, lookup))
387
+ return lookup.exists
388
+ })
389
+ .catch((error) => {
390
+ console.warn(`GHCR lookup for ${scope.shortName} failed (HTTP ${error.status ?? 'none'}), building it:`, error)
391
+ return false
392
+ })
387
393
  lookups.push({ scope, imageTag, exists })
388
394
  }
389
395
 
@@ -496,8 +502,29 @@ const buildMatrix = (scopeMap, changedFilesFromEvent, beforeRef, headRef, forced
496
502
  }
497
503
 
498
504
  /**
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
505
+ * The only-unbuilt matrix: every scope whose checksum tag GHCR lacks, every scope's computed tag,
506
+ * and, unless compare-base is off, the scopes whose tag differs from the base's.
507
+ * @param {Map} scopeMap - Discovered scopes
508
+ * @param {string[]} forcedShortNames - Short names included even when their image exists
509
+ * @param {string|undefined} beforeRef - Diff base commit
510
+ * @returns {Promise<{matrix: Object[], scopeTags: Record<string, string>, changedTags: string[]}>}
511
+ */
512
+ const resolveUnbuiltMatrix = async (scopeMap, forcedShortNames, beforeRef) => {
513
+ const lookups = await lookupScopeTags(scopeMap)
514
+ const scopeTags = scopeTagMap(lookups)
515
+ const matrix = selectUnbuilt(lookups, forcedShortNames)
516
+ if (!isEnabled('compare-base')) {
517
+ console.log('Tags not compared with a base (compare-base is off)')
518
+ return { matrix, scopeTags, changedTags: [] }
519
+ }
520
+ const baseScopeTags = beforeRef ? computeBaseScopeTags(scopeMap, beforeRef) : {}
521
+ const changedTags = changedScopes(scopeTags, baseScopeTags)
522
+ console.log(`Tags changed since ${beforeRef ?? 'no base'}: ${changedTags.join(' ') || 'none'}`)
523
+ return { matrix, scopeTags, changedTags }
524
+ }
525
+
526
+ /**
527
+ * Builds the matrix: with only-unbuilt, see resolveUnbuiltMatrix; otherwise the changed scopes (or
501
528
  * all with force-all), no tags and no tag changes.
502
529
  * @returns {Promise<{matrix: Object[], scopeTags: Record<string, string>, changedTags: string[]}>}
503
530
  */
@@ -507,12 +534,7 @@ const resolveMatrix = async () => {
507
534
  const { beforeRef, headRef } = getCommitRefs()
508
535
 
509
536
  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 }
537
+ return resolveUnbuiltMatrix(scopeMap, forcedShortNames, beforeRef)
516
538
  }
517
539
 
518
540
  const changedFilesFromEvent = loadEventChangedFiles(process.env.GITHUB_EVENT_PATH)
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Usage: DEPLOY_IMAGES='<json>' RUNNING_IMAGES='<json>' node rollout-changes.js
3
+ *
4
+ * Prints, as a JSON array, the short names of the scopes the deploy rolls out to an image other than
5
+ * the one the stack runs. Both variables map short name → image; a scope the stack reports no image
6
+ * for counts as changed, so an empty RUNNING_IMAGES ('{}') lists every scope of DEPLOY_IMAGES.
7
+ */
8
+
9
+ const { changedScopes } = require('./select.js')
10
+
11
+ /**
12
+ * Parses an environment variable holding a JSON object of short name → image.
13
+ * @param {string} name - Variable name
14
+ * @returns {Record<string, string>}
15
+ */
16
+ const readImages = (name) => {
17
+ const value = JSON.parse(process.env[name] || '{}')
18
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
19
+ throw new Error(`${name} is not a JSON object`)
20
+ }
21
+ return value
22
+ }
23
+
24
+ console.log(JSON.stringify(changedScopes(readImages('DEPLOY_IMAGES'), readImages('RUNNING_IMAGES'))))
@@ -9,27 +9,32 @@
9
9
  const { createHash } = require('node:crypto')
10
10
 
11
11
  /**
12
- * @typedef {{name: string, dependencies: string[]}} WorkspaceManifest
12
+ * @typedef {{name: string, dependencies: string[], devDependencies: string[]}} WorkspaceManifest
13
13
  */
14
14
 
15
- const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']
15
+ const CONSUMER_DEPENDENCY_FIELDS = ['dependencies', 'peerDependencies', 'optionalDependencies']
16
16
  const PRUNED_TREES = ['json', 'full']
17
17
  const WORKSPACE_BLOCK_INDENT = ' '
18
18
 
19
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.
20
+ * Reduces a parsed package.json to its name and the names of what it depends on: `dependencies`
21
+ * holds every field a consumer installs, `devDependencies` the package's own tooling and tests. The
22
+ * version spec does not matter, so a workspace linked by version instead of `workspace:` stays in
23
+ * the closure.
23
24
  * @param {Object} packageJson - Parsed package.json
24
25
  * @returns {WorkspaceManifest}
25
26
  */
26
27
  const workspaceManifest = (packageJson) => ({
27
28
  name: packageJson.name,
28
- dependencies: DEPENDENCY_FIELDS.flatMap((field) => Object.keys(packageJson[field] ?? {})),
29
+ dependencies: CONSUMER_DEPENDENCY_FIELDS.flatMap((field) => Object.keys(packageJson[field] ?? {})),
30
+ devDependencies: Object.keys(packageJson.devDependencies ?? {}),
29
31
  })
30
32
 
31
33
  /**
32
- * Collects the workspaces a scope reaches through its own dependencies, transitively, itself included.
34
+ * Collects the workspaces a scope reaches, transitively, itself included. The scope's own
35
+ * devDependencies count; a library's do not, since a consumer never installs them: a test
36
+ * dependency of a library on `ui` would otherwise rebuild every service using that library on
37
+ * each `ui` change.
33
38
  * @param {string} scopeDir - The scope's workspace directory, e.g. 'apps/web'
34
39
  * @param {Map<string, WorkspaceManifest>} manifestsByDir - Every workspace of the pruned tree by directory
35
40
  * @returns {Set<string>} Workspace directories
@@ -47,7 +52,8 @@ const dependencyClosure = (scopeDir, manifestsByDir) => {
47
52
  const dir = pending.pop()
48
53
  if (reached.has(dir)) continue
49
54
  reached.add(dir)
50
- for (const name of manifestsByDir.get(dir).dependencies) {
55
+ const { dependencies, devDependencies } = manifestsByDir.get(dir)
56
+ for (const name of dir === scopeDir ? [...dependencies, ...devDependencies] : dependencies) {
51
57
  const dependencyDir = dirsByName.get(name)
52
58
  if (dependencyDir) pending.push(dependencyDir)
53
59
  }
@@ -33,15 +33,17 @@ const selectUnbuilt = (lookups, forcedShortNames = []) => {
33
33
  const scopeTagMap = (lookups) => Object.fromEntries(lookups.map(({ scope, imageTag }) => [scope.shortName, imageTag]))
34
34
 
35
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
36
+ * Lists the scopes whose value in `current` differs from the one in `reference`. Two callers: the
37
+ * matrix compares this commit's tags with the diff base's (a revert lands on an image GHCR already
38
+ * holds, so the matrix stays empty, and this list still makes it deploy); the deploy compares the
39
+ * images it rolls out with the ones the stack runs, to decide which follow-up steps run.
40
+ * @param {Record<string, string>} current - Short name → tag or image now
41
+ * @param {Record<string, string>} reference - Short name → tag or image to compare with; a scope missing here counts as changed
40
42
  * @returns {string[]} Short names, sorted
41
43
  */
42
- const changedScopeTags = (scopeTags, baseScopeTags) =>
43
- Object.keys(scopeTags)
44
- .filter((shortName) => scopeTags[shortName] !== baseScopeTags[shortName])
44
+ const changedScopes = (current, reference) =>
45
+ Object.keys(current)
46
+ .filter((shortName) => current[shortName] !== reference[shortName])
45
47
  .sort()
46
48
 
47
- module.exports = { changedScopeTags, scopeTagMap, selectUnbuilt }
49
+ module.exports = { changedScopes, scopeTagMap, selectUnbuilt }
@@ -151,7 +151,9 @@ runs:
151
151
  # Bounded: bun install has hung indefinitely after a 404 on a tarball (see above), and an
152
152
  # unbounded hang here holds the job until the runner's 6-hour limit. A timeout turns it
153
153
  # into the failure path below, which restores the original lockfile.
154
- NPM_TOKEN="${{ inputs.npm-token }}" timeout 300 bun install --no-progress 2>&1 | tee /tmp/bun-install.log; INSTALL_EXIT=${PIPESTATUS[0]}
154
+ # Without lifecycle scripts: this install only regenerates bun.lock, and a later step of
155
+ # the build job may hold deploy secrets that a dependency's script must not reach.
156
+ NPM_TOKEN="${{ inputs.npm-token }}" timeout 300 bun install --no-progress --ignore-scripts 2>&1 | tee /tmp/bun-install.log; INSTALL_EXIT=${PIPESTATUS[0]}
155
157
  [ "$INSTALL_EXIT" -eq 124 ] && echo "::warning::bun install timed out after 300 s"
156
158
  set -e
157
159
  if [ ! -f bun.lock ] || [ ! -s bun.lock ]; then
@@ -9,6 +9,12 @@ inputs:
9
9
  install-command:
10
10
  description: Command to install dependencies.
11
11
  default: bun install --frozen-lockfile
12
+ ignore-scripts:
13
+ description: >-
14
+ When 'true', runs the install command with --ignore-scripts: no root `prepare` (which builds
15
+ every package) and no dependency lifecycle scripts. For jobs that build what they need
16
+ themselves, or need no built package at all.
17
+ default: 'false'
12
18
  enable-cache:
13
19
  description: Whether to run the cache step.
14
20
  default: 'false'
@@ -42,13 +48,36 @@ runs:
42
48
  with:
43
49
  bun-version-file: $\{{ inputs.bun-version-file }}
44
50
 
51
+ # An install without scripts caches a node_modules whose dependencies never ran their lifecycle
52
+ # scripts, and Bun does not run them later for a package already in place. Such caches live under
53
+ # their own prefix, which a restore key of an install with scripts never matches.
54
+ - name: Resolve cache keys
55
+ id: cache-keys
56
+ if: $\{{ inputs.enable-cache == 'true' && inputs.cache-key != '' }}
57
+ shell: bash
58
+ env:
59
+ CACHE_KEY: $\{{ inputs.cache-key }}
60
+ RESTORE_KEYS: $\{{ inputs.cache-restore-keys }}
61
+ IGNORE_SCRIPTS: $\{{ inputs.ignore-scripts }}
62
+ run: |
63
+ PREFIX=""
64
+ [ "$IGNORE_SCRIPTS" = "true" ] && PREFIX="noscripts-"
65
+ echo "key=${PREFIX}${CACHE_KEY}" >> "$GITHUB_OUTPUT"
66
+ {
67
+ echo "restore-keys<<RESTORE_KEYS_END"
68
+ while IFS= read -r line; do
69
+ [ -n "$line" ] && echo "${PREFIX}${line}"
70
+ done <<<"$RESTORE_KEYS"
71
+ echo "RESTORE_KEYS_END"
72
+ } >> "$GITHUB_OUTPUT"
73
+
45
74
  - name: Cache dependencies
46
75
  if: $\{{ inputs.enable-cache == 'true' && inputs.cache-key != '' }}
47
76
  uses: actions/cache@v5
48
77
  with:
49
78
  path: $\{{ inputs.cache-paths }}
50
- key: $\{{ inputs.cache-key }}
51
- restore-keys: $\{{ inputs.cache-restore-keys }}
79
+ key: $\{{ steps.cache-keys.outputs.key }}
80
+ restore-keys: $\{{ steps.cache-keys.outputs.restore-keys }}
52
81
 
53
82
  # A Bun other than the pinned one writes a lockfile the pinned Bun in the Docker image refuses
54
83
  # as changed, so a drifted binary fails here instead of in the image build.
@@ -67,7 +96,12 @@ runs:
67
96
  shell: bash
68
97
  env:
69
98
  NPM_TOKEN: $\{{ inputs.npm-token }}
99
+ IGNORE_SCRIPTS: $\{{ inputs.ignore-scripts }}
70
100
  run: |
71
101
  set -euo pipefail
72
102
  cd "$\{{ inputs.working-directory }}"
73
- $\{{ inputs.install-command }}
103
+ if [ "$IGNORE_SCRIPTS" = "true" ]; then
104
+ $\{{ inputs.install-command }} --ignore-scripts
105
+ else
106
+ $\{{ inputs.install-command }}
107
+ fi
@@ -197,18 +197,22 @@ jobs:
197
197
  # A private workspace package or a lockfile line reaches a scope without
198
198
  # touching its directory. On those paths the matrix asks GHCR whether each scope's checksum
199
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.
200
+ # pruned tree then builds and deploys nothing. A manual dispatch asks GHCR too: it rolls out
201
+ # this commit's tags and builds only the images GHCR lacks.
201
202
  - name: Decide matrix mode
202
203
  id: matrix-mode
203
204
  run: |
204
205
  EVENT="$\{{ github.event_name }}"
205
206
  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
+ if [ "$EVENT" = "workflow_dispatch" ] || [ "$EVENT" = "workflow_run" ] || [ -n "$LOCK_CHANGED" ]; then
207
208
  echo "only_unbuilt=true" >> "$GITHUB_OUTPUT"
208
209
  else
209
210
  echo "only_unbuilt=false" >> "$GITHUB_OUTPUT"
210
211
  fi
211
212
 
213
+ # No job here reads a built workspace package: prepare-scopes and build only prune, the image
214
+ # installs and builds inside Docker, and the deploy builds the stack's packages itself. The root
215
+ # `prepare` (every package built, about 40 s) is therefore skipped in all three.
212
216
  - name: Setup Bun and install dependencies
213
217
  if: steps.matrix-mode.outputs.only_unbuilt == 'true'
214
218
  uses: ./.github/actions/setup-bun-install
@@ -217,14 +221,17 @@ jobs:
217
221
  cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
218
222
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
219
223
  npm-token: $\{{ github.token }}
224
+ ignore-scripts: 'true'
220
225
 
226
+ # A dispatch deploys in any case (infra-eval sets infrastructure_changed), so it needs no tag
227
+ # comparison with a base worktree.
221
228
  - name: Generate scope matrix
222
229
  id: scope-generator
223
230
  uses: ./.github/actions/generate-scope-matrix
224
231
  with:
225
232
  scope-roots: $\{{ env.SCOPE_ROOTS }}
226
- force-all: $\{{ github.event_name == 'workflow_dispatch' }}
227
233
  only-unbuilt: $\{{ steps.matrix-mode.outputs.only_unbuilt }}
234
+ compare-base: $\{{ github.event_name != 'workflow_dispatch' }}
228
235
  github-token: $\{{ secrets.GITHUB_TOKEN }}
229
236
 
230
237
  build:
@@ -261,6 +268,7 @@ jobs:
261
268
  cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
262
269
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
263
270
  npm-token: $\{{ github.token }}
271
+ ignore-scripts: 'true'
264
272
 
265
273
  # The same script computes the tag prepare-scopes looked up (only-unbuilt), so both agree. It
266
274
  # also leaves the pruned tree in out/<short-name>, which the Docker build below uses.
@@ -329,7 +337,10 @@ jobs:
329
337
  $\{{ env.REGISTRY }}/$\{{ matrix.scope.shortName }}:$\{{ env.IMAGE_TAG }}
330
338
  $\{{ env.REGISTRY }}/$\{{ matrix.scope.shortName }}:latest
331
339
  cache-from: type=gha,scope=$\{{ matrix.scope.shortName }}
332
- cache-to: type=gha,scope=$\{{ matrix.scope.shortName }},mode=max
340
+ # Apps export only the final stage: exporting their builder layers took one to two minutes
341
+ # per build and saved at most the half-minute install, since a client build rarely hits the
342
+ # cache. Services export in seconds and keep mode=max.
343
+ cache-to: type=gha,scope=$\{{ matrix.scope.shortName }},mode=$\{{ startsWith(matrix.scope.dir, 'apps/') && 'min' || 'max' }}
333
344
  build-args: |
334
345
  BUN_VERSION=$\{{ env.BUN_VERSION }}
335
346
  secrets: |
@@ -392,9 +403,7 @@ jobs:
392
403
  if [ -f .artifacts/metadata.json ]; then
393
404
  SHORT=$(jq -er '.shortName' .artifacts/metadata.json)
394
405
  mkdir -p ".artifacts/changed-${SHORT}"
395
- for marker_file in metadata.json scope.txt; do
396
- if [ -f ".artifacts/${marker_file}" ]; then mv ".artifacts/${marker_file}" ".artifacts/changed-${SHORT}/"; fi
397
- done
406
+ find .artifacts -maxdepth 1 -type f -exec mv {} ".artifacts/changed-${SHORT}/" \;
398
407
  fi
399
408
 
400
409
  - name: Resolve scope image tags
@@ -458,7 +467,7 @@ jobs:
458
467
  done
459
468
 
460
469
  if [ -n "$MISSING" ]; then
461
- echo "::error::No checksum-tagged image found in GHCR for:$MISSING. Refusing to deploy :latest — trigger a build for these scopes first (workflow_dispatch builds all)."
470
+ echo "::error::No checksum-tagged image found in GHCR for:$MISSING. Refusing to deploy :latest — trigger a build for these scopes first (workflow_dispatch builds every missing image)."
462
471
  exit 1
463
472
  fi
464
473
 
@@ -472,6 +481,31 @@ jobs:
472
481
  cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
473
482
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
474
483
  npm-token: $\{{ github.token }}
484
+ ignore-scripts: 'true'
485
+
486
+ # The scopes this deploy moves to another image than the stack runs, from the stack's
487
+ # `scopeImages` output of the last `up`. The steps that follow a new image (the Studio smoke
488
+ # check among them) hang off this list rather than off a build marker: a dispatch that builds
489
+ # nothing still runs them for the images it changes, and an image built anew but already
490
+ # running skips them. Each changed scope sets `changed_<short-name>=true`; a workspace without
491
+ # a scope never sets its output and skips its steps. A stack that cannot be read fails the step;
492
+ # before the first `up` that writes the output, every scope counts as changed.
493
+ - name: Compare with the running images
494
+ id: rollout
495
+ env:
496
+ PULUMI_ACCESS_TOKEN: $\{{ secrets.PULUMI_ACCESS_TOKEN }}
497
+ DEPLOY_IMAGES: $\{{ steps.resolve_scope_tags.outputs.scope_image_tags }}
498
+ run: |
499
+ set -euo pipefail
500
+ OUTPUTS=$(pulumi stack output --json --cwd infra --stack "$PULUMI_STACK" --non-interactive)
501
+ if ! jq -e 'has("scopeImages")' <<<"$OUTPUTS" >/dev/null; then
502
+ echo "::notice::The stack has no scopeImages output yet; every scope counts as changed."
503
+ fi
504
+ RUNNING_IMAGES=$(jq -c '.scopeImages // {}' <<<"$OUTPUTS")
505
+ export RUNNING_IMAGES
506
+ CHANGED=$(node .github/actions/generate-scope-matrix/rollout-changes.js)
507
+ echo "Rolled out to a new image: ${CHANGED}"
508
+ jq -r '.[] | "changed_\(.)=true"' <<<"$CHANGED" >> "$GITHUB_OUTPUT"
475
509
 
476
510
  - name: Build infrastructure dependencies
477
511
  run: |
@@ -498,18 +532,9 @@ jobs:
498
532
  DIGITALOCEAN_TOKEN: $\{{ secrets.DIGITALOCEAN_TOKEN }}
499
533
  SCOPE_IMAGE_TAGS: $\{{ steps.resolve_scope_tags.outputs.scope_image_tags }}
500
534
 
501
- - name: Check if Studio was deployed
502
- id: studio-deployed
503
- run: |
504
- if [ -f .artifacts/changed-studio/metadata.json ]; then
505
- echo "deployed=true" >> "$GITHUB_OUTPUT"
506
- else
507
- echo "deployed=false" >> "$GITHUB_OUTPUT"
508
- fi
509
-
510
535
  - name: Get Pulumi outputs
511
536
  id: pulumi-outputs
512
- if: steps.studio-deployed.outputs.deployed == 'true'
537
+ if: steps.rollout.outputs.changed_studio == 'true'
513
538
  working-directory: infra
514
539
  run: |
515
540
  URL=$(pulumi stack output studioUrl --stack "$PULUMI_STACK" --non-interactive)
@@ -519,7 +544,7 @@ jobs:
519
544
 
520
545
  - name: Smoke-check new Studio deployment
521
546
  id: studio-smoke
522
- if: steps.studio-deployed.outputs.deployed == 'true'
547
+ if: steps.rollout.outputs.changed_studio == 'true'
523
548
  env:
524
549
  STUDIO_URL: $\{{ steps.pulumi-outputs.outputs.studio_url }}
525
550
  run: |
@@ -39,6 +39,7 @@ jobs:
39
39
  patterns: docs/**/*.md
40
40
  github-token: $\{{ github.token }}
41
41
 
42
+ # Lint reads sources only, so the root `prepare` (every package built) is skipped.
42
43
  - name: Setup Bun and install dependencies
43
44
  if: steps.docs-only.outputs.docs_only != 'true'
44
45
  uses: ./.github/actions/setup-bun-install
@@ -46,6 +47,7 @@ jobs:
46
47
  enable-cache: 'true'
47
48
  cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
48
49
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
50
+ ignore-scripts: 'true'
49
51
 
50
52
  - name: Run Linter
51
53
  if: steps.docs-only.outputs.docs_only != 'true'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/platform-sdk",
3
- "version": "0.26.10",
3
+ "version": "0.26.11",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",