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