@crossdelta/platform-sdk 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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:
@@ -295,6 +383,20 @@ jobs:
295
383
  pattern: changed-*
296
384
  path: .artifacts
297
385
 
386
+ # download-artifact puts each matching artifact into its own `changed-<scope>/` directory only
387
+ # when it matches several. A single match is extracted straight into `.artifacts/`, where no
388
+ # later step looks for it: the scope's image tag then came from GHCR instead of its marker, and
389
+ # the Studio smoke check and client broadcast were skipped (stage run 38151120673, 11.10.2026).
390
+ - name: Normalize change marker layout
391
+ run: |
392
+ if [ -f .artifacts/metadata.json ]; then
393
+ SHORT=$(jq -er '.shortName' .artifacts/metadata.json)
394
+ 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
398
+ fi
399
+
298
400
  - name: Resolve scope image tags
299
401
  id: resolve_scope_tags
300
402
  shell: bash
@@ -303,6 +405,7 @@ jobs:
303
405
  GH_TOKEN: $\{{ secrets.GITHUB_TOKEN }}
304
406
  REPO_OWNER: $\{{ github.repository_owner }}
305
407
  REPO_PREFIX: $\{{ github.event.repository.name }}
408
+ COMPUTED_TAGS: $\{{ needs.prepare-scopes.outputs.scope_tags || '{}' }}
306
409
  run: |
307
410
  set -euo pipefail
308
411
  RESULT="{}"
@@ -318,13 +421,22 @@ jobs:
318
421
  done
319
422
  fi
320
423
 
321
- # 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
322
425
  MISSING=""
323
426
  for scope_dir in apps/*/Dockerfile services/*/Dockerfile; do
324
427
  [ -f "$scope_dir" ] || continue
325
428
  SHORT=$(basename "$(dirname "$scope_dir")")
326
429
  echo "$RESULT" | jq -e --arg k "$SHORT" '.[$k]' >/dev/null 2>&1 && continue
327
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
+
328
440
  # Query GHCR for the latest version with a checksum tag (retry transient API errors)
329
441
  CHECKSUM_TAG=""
330
442
  for attempt in 1 2 3; do
@@ -371,6 +483,7 @@ jobs:
371
483
  # mid-update: DigitalOcean finishes the resize server-side anyway, while Pulumi never writes
372
484
  # the result back and leaves a stack lock and a pending operation behind.
373
485
  - name: Pulumi up
486
+ id: pulumi-up
374
487
  uses: pulumi/actions@v7
375
488
  timeout-minutes: 15
376
489
  with:
@@ -417,3 +530,8 @@ jobs:
417
530
  done
418
531
  curl "${curl_opts[@]}" "${STUDIO_URL}/health" || exit 1
419
532
 
533
+ # The topic carries no `realtime:` prefix: the REST endpoint adds it, and a client subscribed to
534
+ # `system:deploy` listens on `realtime:system:deploy`. With the prefix spelled out the message
535
+ # went to `realtime:realtime:system:deploy`, a channel nobody listened on (listener log, stage,
536
+ # 11.10.2026). `-f` makes a refused broadcast fail the command, so the warning below can fire.
537
+
@@ -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
@@ -26,8 +27,17 @@ jobs:
26
27
  - name: Run Linter
27
28
  run: bun run --if-present lint
28
29
 
30
+ - name: Component manifest
31
+ run: |
32
+ git fetch --depth=1 origin main
33
+ bun run --if-present manifest:check
34
+
35
+ - name: Pattern manifest
36
+ run: bun run --if-present patterns:check
37
+
29
38
  test:
30
39
  runs-on: ubuntu-latest
40
+ timeout-minutes: 20
31
41
  env:
32
42
  steps:
33
43
  - name: Checkout code
@@ -45,6 +55,7 @@ jobs:
45
55
 
46
56
  e2e:
47
57
  runs-on: ubuntu-latest
58
+ timeout-minutes: 30
48
59
  needs: test
49
60
  if: false # disabled until E2E tests are stable
50
61
  env:
@@ -16,9 +16,9 @@ on:
16
16
  default: all
17
17
  required: true
18
18
 
19
+ # id-token: write only where it is used: the publish job sets it itself.
19
20
  permissions:
20
21
  contents: read
21
- id-token: write
22
22
 
23
23
  jobs:
24
24
  determine-packages:
@@ -211,6 +211,25 @@ jobs:
211
211
  fi
212
212
  echo "✅ Workspace template complete"
213
213
 
214
+ # The kit is published for other people's repositories: nothing of this one may ride along. The check packs
215
+ # the package as npm receives it and scans every text file against the kit's built-in patterns.
216
+ - name: Setup Bun for the leak check
217
+ if: matrix.package.name == 'jackherd' || matrix.package.name == '@crossdelta/agent-kit'
218
+ # Pinned to a commit: this job holds id-token: write, which npm Trusted Publishing accepts.
219
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
220
+ with:
221
+ bun-version-file: package.json
222
+
223
+ - name: Leak check on the jackherd tarball
224
+ if: matrix.package.name == 'jackherd'
225
+ working-directory: ${{ matrix.package.dir }}
226
+ run: bun scripts/check-package-leaks.ts
227
+
228
+ - name: Leak check on the agent-kit shell tarball
229
+ if: matrix.package.name == '@crossdelta/agent-kit'
230
+ working-directory: ${{ matrix.package.dir }}
231
+ run: bun ../jackherd/scripts/check-package-leaks.ts .
232
+
214
233
  - name: Publish to npm
215
234
  working-directory: ${{ matrix.package.dir }}
216
235
  run: |
@@ -52,9 +52,9 @@ regardless of which file is open, because that cost is paid on every turn.
52
52
  Code style and testing conventions are not rules here: the `pf` generators carry their own, and they
53
53
  load whenever the generator writes code.
54
54
 
55
- ## Agent Kit
55
+ ## jackherd
56
56
 
57
- Skills, agents and general rules under `.claude/` come from `@crossdelta/agent-kit` and are listed in `.claude/agent-kit.lock.json`. Project values (languages, plan directory, models, checks) live in `.claude/agent-kit.json`. Update with `pf agents sync`; a conflicting local edit lands as `<file>.kit-new`. Put project knowledge into `.claude/project/<skill>.md` or a rule beside `.claude/rules/kit/`, so the kit files stay updatable. `pf plans` shows the plan progress of the current sprint.
57
+ Skills, agents and general rules under `.claude/` come from `jackherd` and are listed in `.jackherd/lock.json`. Project values (languages, plan directory, models, checks) live in `.jackherd/config.json`. Update with `pf agents sync`; a conflicting local edit lands as `<file>.kit-new`. Put project knowledge into `.claude/project/<skill>.md` or a rule beside `.claude/rules/kit/`, so the kit files stay updatable. `pf plans` shows the plan progress of the current sprint.
58
58
 
59
59
  ## Key Packages
60
60
 
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@crossdelta/cloudevents": "^0.10.0",
11
- "@crossdelta/infrastructure": "^0.16.0",
11
+ "@crossdelta/infrastructure": "^0.18.1",
12
12
  "{{scope}}/contracts": "workspace:*",
13
13
  "@pulumi/digitalocean": "^4.55.0",
14
14
  "@pulumi/kubernetes": "^4.21.0",
@@ -19,11 +19,11 @@
19
19
  "cwd": "infra"
20
20
  },
21
21
  "agents": {
22
- "command": "bunx @crossdelta/agent-kit",
23
- "description": "Agent kit: init, sync, check, plans"
22
+ "command": "bunx jackherd",
23
+ "description": "jackherd: init, sync, check, plans"
24
24
  },
25
25
  "plans": {
26
- "command": "bunx @crossdelta/agent-kit plans",
26
+ "command": "bunx jackherd plans",
27
27
  "description": "Live view of the sprint's lanes and their plan progress"
28
28
  }
29
29
  },
@@ -51,9 +51,9 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@biomejs/biome": "2.3.7",
54
- "@crossdelta/agent-kit": "latest",
55
54
  "@crossdelta/platform-sdk": "latest",
56
55
  "dotenv-cli": "^8.0.0",
56
+ "jackherd": "latest",
57
57
  "turbo": "^2.5.6"
58
58
  },
59
59
  "packageManager": "{{packageManagerVersion}}",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@crossdelta/cloudevents": "^0.10.0",
23
- "@crossdelta/infrastructure": "^0.16.0",
23
+ "@crossdelta/infrastructure": "^0.18.1",
24
24
  "zod": "^4.0.0"
25
25
  },
26
26
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/platform-sdk",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",
@@ -88,7 +88,6 @@
88
88
  "chokidar": "^5.0.0",
89
89
  "cli-table3": "^0.6.5",
90
90
  "commander": "^13.1.0",
91
- "dotenv": "^17.2.3",
92
91
  "enquirer": "^2.4.1",
93
92
  "execa": "^9.5.2",
94
93
  "fs-extra": "^11.3.0",
@@ -100,7 +99,7 @@
100
99
  "rimraf": "^6.1.2",
101
100
  "terminal-link": "^4.0.0",
102
101
  "zod": "^4.0.0",
103
- "@crossdelta/infrastructure": "^0.16.0"
102
+ "@crossdelta/infrastructure": "^0.18.1"
104
103
  },
105
104
  "peerDependencies": {
106
105
  "@nestjs/schematics": "^11.0.5",