@crossdelta/platform-sdk 0.26.3 → 0.26.5

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,29 @@ 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.5] - 2026-10-23
9
+
10
+ ### Changed
11
+ - **The workspace template's `publish-packages.yml` queues its runs and skips a version npm already
12
+ holds.** A `concurrency` group (`publish-packages`, no cancelling) stops two merges in a row from
13
+ publishing the same version at once, and the publish step reads `npm view "<name>@<version>"`
14
+ right before `npm publish`: a hit ends the step with a skip line, any other failure of `npm view`
15
+ publishes as before (the read ignores setup-node's placeholder token). On a push the matrix now
16
+ checks every public package against npm instead of only the changed ones: a queued run can be
17
+ replaced by a newer one, and the replaced push's version bump would otherwise never publish.
18
+
19
+ ## [0.26.4] - 2026-10-21
20
+
21
+ ### Changed
22
+ - **The workspace template's PR checks skip drafts and docs-only pull requests.** `lint-and-tests.yml`
23
+ runs `lint` and `test` only when the PR is not a draft and also starts on `ready_for_review`; on a
24
+ draft they are skipped as `lint (draft)` and `test (draft)`, so a late draft run cannot hide the
25
+ real check of the same commit. A new
26
+ action `pr-docs-only` lists the PR's files from the GitHub API (renames by their old name too); when
27
+ every file matches `docs/**/*.md`, both jobs skip every later step and still end as a success, so a
28
+ required check never waits. Any error runs the checks as before. The `test` job also runs the tests
29
+ under `.github/actions`, which no workspace covers.
30
+
8
31
  ## [0.26.3] - 2026-10-21
9
32
 
10
33
  ### Changed
@@ -0,0 +1,15 @@
1
+ name: PR docs only
2
+ description: Whether a pull_request event's pull request changes only files matching the patterns
3
+ inputs:
4
+ patterns:
5
+ description: Globs, one per line, that count as documentation (e.g. docs/**/*.md)
6
+ required: true
7
+ github-token:
8
+ description: Token with pull-requests read access to list the pull request's files
9
+ required: true
10
+ outputs:
11
+ docs_only:
12
+ description: "'true' if every changed file matches a pattern; 'false' otherwise and on any error"
13
+ runs:
14
+ using: node24
15
+ main: index.js
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Whether a pull request changes nothing but documentation, so lint, tests and the review can skip
3
+ * their work and still end green. Every doubt answers false: a check that runs for nothing costs
4
+ * minutes, a check skipped for code costs a broken main.
5
+ *
6
+ * `parseFileEntries` and `isPlainPath` mirror jackherd's merge guard
7
+ * (`packages/jackherd/kit/hooks/hooks/merge-guard/check-free.ts`, `plain-path.ts`): an action cannot
8
+ * import a workspace package. A change to one belongs in both.
9
+ */
10
+
11
+ const { posix } = require('node:path')
12
+
13
+ /** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
14
+ const MAX_LISTED_FILES = 3000
15
+
16
+ /** What `gh api …/pulls/<n>/files --jq` prints per file: the name, and the old name of a rename. */
17
+ const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
18
+
19
+ /**
20
+ * The action's `patterns` input, one glob per line.
21
+ * @param {string} input
22
+ * @returns {string[]}
23
+ */
24
+ const parsePatterns = (input) =>
25
+ input
26
+ .split('\n')
27
+ .map((line) => line.trim())
28
+ .filter((line) => line !== '')
29
+
30
+ const isNameOrNull = (value) => value === null || typeof value === 'string'
31
+
32
+ /**
33
+ * The names a listing carries, the old name of a rename too, from one `[filename, previous]` JSON
34
+ * pair per line. The pairs must be exactly as many as the pull request changes files, and fewer
35
+ * than GitHub's cap: a list cut short would hide the files missing from it. Anything else answers
36
+ * undefined.
37
+ * @param {string} stdout
38
+ * @param {number} changedFiles - `pull_request.changed_files` from the event
39
+ * @returns {string[] | undefined}
40
+ */
41
+ const parseFileEntries = (stdout, changedFiles) => {
42
+ if (!Number.isInteger(changedFiles) || changedFiles >= MAX_LISTED_FILES) return undefined
43
+ try {
44
+ const entries = stdout
45
+ .split('\n')
46
+ .filter((line) => line !== '')
47
+ .map((line) => JSON.parse(line))
48
+ const pairs = entries.filter(
49
+ (entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string' && isNameOrNull(entry[1]),
50
+ )
51
+ if (pairs.length !== entries.length || pairs.length !== changedFiles) return undefined
52
+ return pairs.flatMap(([filename, previous]) => (previous === null ? [filename] : [filename, previous]))
53
+ } catch {
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ const isControlCharacter = (character) => {
59
+ const code = character.charCodeAt(0)
60
+ return code < 0x20 || code === 0x7f
61
+ }
62
+
63
+ /** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
64
+ const isPlainPath = (file) =>
65
+ !file.includes('\\') &&
66
+ ![...file].some(isControlCharacter) &&
67
+ file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
68
+
69
+ /**
70
+ * True only for a known, non-empty file list, at least one pattern, and every file a plain path
71
+ * matching a pattern.
72
+ * @param {string[] | undefined} files
73
+ * @param {string[]} patterns
74
+ * @returns {boolean}
75
+ */
76
+ const isDocsOnly = (files, patterns) => {
77
+ if (!files || files.length === 0 || patterns.length === 0) return false
78
+ return files.every((file) => isPlainPath(file) && patterns.some((pattern) => posix.matchesGlob(file, pattern)))
79
+ }
80
+
81
+ module.exports = { FILE_ENTRY_QUERY, MAX_LISTED_FILES, isDocsOnly, parseFileEntries, parsePatterns }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PR Docs Only Action
3
+ *
4
+ * Sets `docs_only=true` when every file of the pull request, renames by their old name too, matches
5
+ * one of the `patterns`. Reads the file list from the GitHub API, never from the checkout. Any
6
+ * failure sets `false` and leaves the job green, so the checks run as before.
7
+ *
8
+ * @example
9
+ * - uses: ./.github/actions/pr-docs-only
10
+ * id: docs-only
11
+ * with:
12
+ * patterns: docs/**\/*.md
13
+ * github-token: ${{ github.token }}
14
+ *
15
+ * @outputs docs_only - 'true' if the pull request changes only matching files, 'false' otherwise
16
+ */
17
+
18
+ const { spawnSync } = require('node:child_process')
19
+ const { appendFileSync, readFileSync } = require('node:fs')
20
+ const { FILE_ENTRY_QUERY, isDocsOnly, parseFileEntries, parsePatterns } = require('./docs-only.js')
21
+
22
+ const input = (name) => (process.env[`INPUT_${name.toUpperCase()}`] ?? '').trim()
23
+
24
+ const setOutput = (name, value) => {
25
+ const outputFile = process.env.GITHUB_OUTPUT
26
+ if (outputFile) appendFileSync(outputFile, `${name}=${value}\n`)
27
+ else console.log(`${name}=${value}`)
28
+ }
29
+
30
+ const readPullRequest = () => {
31
+ try {
32
+ return JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH ?? '', 'utf-8')).pull_request
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ const listFiles = (pullRequest) => {
39
+ const repository = pullRequest.base?.repo?.full_name
40
+ const result = spawnSync(
41
+ 'gh',
42
+ ['api', `repos/${repository}/pulls/${pullRequest.number}/files?per_page=100`, '--paginate', '--jq', FILE_ENTRY_QUERY],
43
+ { encoding: 'utf-8', env: { ...process.env, GH_TOKEN: input('github-token') } },
44
+ )
45
+ if (result.status !== 0) {
46
+ console.log(`::warning::Could not list the pull request's files: ${result.stderr || result.error}`)
47
+ return undefined
48
+ }
49
+ return parseFileEntries(result.stdout, pullRequest.changed_files)
50
+ }
51
+
52
+ const classify = () => {
53
+ const pullRequest = readPullRequest()
54
+ const files = pullRequest ? listFiles(pullRequest) : undefined
55
+ const docsOnly = isDocsOnly(files, parsePatterns(input('patterns')))
56
+ if (docsOnly) console.log(`::notice::Only documentation changed (${pullRequest.changed_files} files): skipping the checks.`)
57
+ return docsOnly
58
+ }
59
+
60
+ const run = () => {
61
+ try {
62
+ setOutput('docs_only', String(classify()))
63
+ } catch (error) {
64
+ console.log(`::warning::Could not classify the pull request, running the checks: ${error}`)
65
+ setOutput('docs_only', 'false')
66
+ }
67
+ }
68
+
69
+ run()
@@ -4,20 +4,43 @@ on:
4
4
  pull_request:
5
5
  branches:
6
6
  - main
7
+ # ready_for_review starts the checks a draft skipped.
8
+ types: [opened, synchronize, reopened, ready_for_review]
7
9
 
10
+ # Draft runs get their own group, so a late draft event cannot cancel the run that marking the PR
11
+ # ready started.
8
12
  concurrency:
9
- group: $\{{ github.workflow }}-$\{{ github.ref }}
13
+ group: $\{{ github.workflow }}-$\{{ github.ref }}-$\{{ github.event.pull_request.draft }}
10
14
  cancel-in-progress: true
11
15
 
16
+ permissions:
17
+ contents: read
18
+ pull-requests: read
19
+
20
+ # A draft skips lint and test under their own check names: GitHub shows the newest check run per
21
+ # name, and a draft push followed at once by `gh pr ready` leaves two runs on one commit, so a
22
+ # skipped "lint" could land after, and hide, the real one. A docs-only PR runs them but skips every
23
+ # step after the file check, so both end as a success and a required check never waits for a run
24
+ # that never comes.
12
25
  jobs:
13
26
  lint:
27
+ name: $\{{ github.event.pull_request.draft && 'lint (draft)' || 'lint' }}
28
+ if: github.event.pull_request.draft == false
14
29
  runs-on: ubuntu-latest
15
30
  timeout-minutes: 10
16
31
  steps:
17
32
  - name: Checkout code
18
33
  uses: actions/checkout@v6
19
34
 
35
+ - name: Check for docs-only changes
36
+ id: docs-only
37
+ uses: ./.github/actions/pr-docs-only
38
+ with:
39
+ patterns: docs/**/*.md
40
+ github-token: $\{{ github.token }}
41
+
20
42
  - name: Setup Bun and install dependencies
43
+ if: steps.docs-only.outputs.docs_only != 'true'
21
44
  uses: ./.github/actions/setup-bun-install
22
45
  with:
23
46
  enable-cache: 'true'
@@ -25,17 +48,22 @@ jobs:
25
48
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
26
49
 
27
50
  - name: Run Linter
51
+ if: steps.docs-only.outputs.docs_only != 'true'
28
52
  run: bun run --if-present lint
29
53
 
30
54
  - name: Component manifest
55
+ if: steps.docs-only.outputs.docs_only != 'true'
31
56
  run: |
32
57
  git fetch --depth=1 origin main
33
58
  bun run --if-present manifest:check
34
59
 
35
60
  - name: Pattern manifest
61
+ if: steps.docs-only.outputs.docs_only != 'true'
36
62
  run: bun run --if-present patterns:check
37
63
 
38
64
  test:
65
+ name: $\{{ github.event.pull_request.draft && 'test (draft)' || 'test' }}
66
+ if: github.event.pull_request.draft == false
39
67
  runs-on: ubuntu-latest
40
68
  timeout-minutes: 20
41
69
  env:
@@ -47,7 +75,15 @@ jobs:
47
75
  with:
48
76
  fetch-depth: 2
49
77
 
78
+ - name: Check for docs-only changes
79
+ id: docs-only
80
+ uses: ./.github/actions/pr-docs-only
81
+ with:
82
+ patterns: docs/**/*.md
83
+ github-token: $\{{ github.token }}
84
+
50
85
  - name: Setup Bun and install dependencies
86
+ if: steps.docs-only.outputs.docs_only != 'true'
51
87
  uses: ./.github/actions/setup-bun-install
52
88
  with:
53
89
  enable-cache: 'true'
@@ -60,6 +96,7 @@ jobs:
60
96
  # Those paths test every package. A PR that touches no package runs no task and passes.
61
97
  - name: Decide test scope
62
98
  id: test-scope
99
+ if: steps.docs-only.outputs.docs_only != 'true'
63
100
  env:
64
101
  GLOBAL_PATHS: package.json bunfig.toml tsconfig.json .npmrc .bun-version patches/ .github/workflows/lint-and-tests.yml .github/actions/setup-bun-install/
65
102
  run: |
@@ -72,7 +109,15 @@ jobs:
72
109
  echo "affected_only=true" >> "$GITHUB_OUTPUT"
73
110
  fi
74
111
 
112
+ # The local actions are no workspace, so turbo never runs their tests; pr-docs-only decides
113
+ # whether this job tests anything at all. Runs from the directory to keep Bun's file scan small.
114
+ - name: Run action tests
115
+ if: steps.docs-only.outputs.docs_only != 'true'
116
+ working-directory: .github/actions
117
+ run: bun test --pass-with-no-tests
118
+
75
119
  - name: Run Tests
120
+ if: steps.docs-only.outputs.docs_only != 'true'
76
121
  env:
77
122
  AFFECTED_ONLY: $\{{ steps.test-scope.outputs.affected_only }}
78
123
  TURBO_SCM_BASE: HEAD^1
@@ -16,6 +16,11 @@ on:
16
16
  default: all
17
17
  required: true
18
18
 
19
+ # Runs queue one behind the other: two merges in a row must not publish the same version at once.
20
+ concurrency:
21
+ group: publish-packages
22
+ cancel-in-progress: false
23
+
19
24
  # id-token: write only where it is used: the publish job sets it itself.
20
25
  permissions:
21
26
  contents: read
@@ -32,38 +37,18 @@ jobs:
32
37
  with:
33
38
  fetch-depth: 0
34
39
 
35
- - name: Detect changed packages
36
- id: changed
37
- uses: tj-actions/changed-files@v47
38
- with:
39
- json: true
40
- safe_output: false
41
- files: packages/*/**
42
-
43
40
  - name: Build package matrix
44
41
  id: set-matrix
45
42
  env:
46
43
  INPUT_PACKAGE: ${{ github.event.inputs.package || '' }}
47
- CHANGED_FILES: ${{ steps.changed.outputs.all_changed_files }}
48
44
  run: |
49
45
  declare -a packages=()
50
- declare -A changed_packages
51
46
 
52
47
  # Compare semver: true if $1 > $2
53
48
  version_gt() {
54
49
  [ "$(printf '%s\n' "$1" "$2" | sort -V | tail -1)" = "$1" ] && [ "$1" != "$2" ]
55
50
  }
56
51
 
57
- # Extract package names from changed file paths (e.g. packages/platform-sdk/src/foo.ts → platform-sdk)
58
- if [ "${{ github.event_name }}" == "push" ] && [ -n "$CHANGED_FILES" ]; then
59
- CLEAN_FILES=$(echo "$CHANGED_FILES" | sed 's/\\"/"/g')
60
- for file in $(echo "$CLEAN_FILES" | jq -r '.[]' 2>/dev/null); do
61
- if [[ $file =~ ^packages/([^/]+)/ ]]; then
62
- changed_packages["${BASH_REMATCH[1]}"]=1
63
- fi
64
- done
65
- fi
66
-
67
52
  # Iterate all packages
68
53
  for dir in packages/*/; do
69
54
  [ ! -f "$dir/package.json" ] && continue
@@ -76,17 +61,13 @@ jobs:
76
61
  [ "$is_private" = "true" ] && continue
77
62
  [ -z "$name" ] && continue
78
63
 
79
- # Include package?
80
- should_include=false
81
- if [ -n "$INPUT_PACKAGE" ]; then
82
- [ "$INPUT_PACKAGE" = "all" ] || [ "$INPUT_PACKAGE" = "$slug" ] && should_include=true
83
- else
84
- [ -n "${changed_packages[$slug]}" ] && should_include=true
64
+ # A dispatch may name one package; a push checks every public package. A queued run can be
65
+ # replaced by a newer one, so a changed-files delta would lose the bump of the replaced push.
66
+ if [ -n "$INPUT_PACKAGE" ] && [ "$INPUT_PACKAGE" != "all" ] && [ "$INPUT_PACKAGE" != "$slug" ]; then
67
+ continue
85
68
  fi
86
-
87
- [ "$should_include" = true ] || continue
88
69
 
89
- # Skip if local version <= npm version
70
+ # Skip if local version <= npm version (this check, not a changed-files filter, selects what publishes)
90
71
  local_ver=$(jq -r '.version' "$dir/package.json")
91
72
  npm_ver=$(npm view "$name" version 2>/dev/null || echo "0.0.0")
92
73
  if ! version_gt "$local_ver" "$npm_ver"; then
@@ -214,7 +195,7 @@ jobs:
214
195
  # The kit is published for other people's repositories: nothing of this one may ride along. The check packs
215
196
  # the package as npm receives it and scans every text file against the kit's built-in patterns.
216
197
  - name: Setup Bun for the leak check
217
- if: matrix.package.name == 'jackherd' || matrix.package.name == '@crossdelta/agent-kit'
198
+ if: matrix.package.name == 'jackherd'
218
199
  # Pinned to a commit: this job holds id-token: write, which npm Trusted Publishing accepts.
219
200
  uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
220
201
  with:
@@ -225,11 +206,6 @@ jobs:
225
206
  working-directory: ${{ matrix.package.dir }}
226
207
  run: bun scripts/check-package-leaks.ts
227
208
 
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
-
233
209
  - name: Publish to npm
234
210
  working-directory: ${{ matrix.package.dir }}
235
211
  run: |
@@ -242,6 +218,13 @@ jobs:
242
218
  (if .peerDependencies[$n] then .peerDependencies[$n] = $v else . end)
243
219
  ' package.json > "$tmp" && mv "$tmp" package.json
244
220
  done
221
+ NAME=$(jq -r '.name' package.json)
245
222
  VERSION=$(jq -r '.version' package.json)
246
- echo "📦 Publishing ${{ matrix.package.name }}@$VERSION"
223
+ # Only a hit skips; any other npm view failure (network) falls through to the publish.
224
+ # Empty NODE_AUTH_TOKEN: setup-node's placeholder token must not turn a public read into a 401.
225
+ if [ "$(NODE_AUTH_TOKEN= npm view --registry=https://registry.npmjs.org "$NAME@$VERSION" version 2>/dev/null)" = "$VERSION" ]; then
226
+ echo "⏭️ $NAME@$VERSION is on npm already — skipping"
227
+ exit 0
228
+ fi
229
+ echo "📦 Publishing $NAME@$VERSION"
247
230
  npm publish --access=public --ignore-scripts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/platform-sdk",
3
- "version": "0.26.3",
3
+ "version": "0.26.5",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",