@crossdelta/platform-sdk 0.26.2 → 0.26.4

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,27 @@ 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.4] - 2026-10-21
9
+
10
+ ### Changed
11
+ - **The workspace template's PR checks skip drafts and docs-only pull requests.** `lint-and-tests.yml`
12
+ runs `lint` and `test` only when the PR is not a draft and also starts on `ready_for_review`; on a
13
+ draft they are skipped as `lint (draft)` and `test (draft)`, so a late draft run cannot hide the
14
+ real check of the same commit. A new
15
+ action `pr-docs-only` lists the PR's files from the GitHub API (renames by their old name too); when
16
+ every file matches `docs/**/*.md`, both jobs skip every later step and still end as a success, so a
17
+ required check never waits. Any error runs the checks as before. The `test` job also runs the tests
18
+ under `.github/actions`, which no workspace covers.
19
+
20
+ ## [0.26.3] - 2026-10-21
21
+
22
+ ### Changed
23
+ - **The workspace template's PR tests run only for affected packages.** `lint-and-tests.yml` runs
24
+ `turbo run test --affected` against the PR's base (the merge commit's first parent, checkout depth
25
+ 2). A change to a root file every package reads (`package.json`, `bunfig.toml`, `tsconfig.json`,
26
+ `.npmrc`, `.bun-version`, `patches/`, the workflow itself or `setup-bun-install`) still tests every
27
+ package, because `--affected` does not count them. A PR that touches no package runs no task and passes.
28
+
8
29
  ## [0.26.1] - 2026-10-17
9
30
 
10
31
  ### Fixed
@@ -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,33 +48,86 @@ 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:
42
70
  steps:
71
+ # A pull_request run checks out the PR merged into its base; depth 2 brings that merge
72
+ # commit's first parent, the base, which the test scope is diffed against.
43
73
  - name: Checkout code
44
74
  uses: actions/checkout@v6
75
+ with:
76
+ fetch-depth: 2
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 }}
45
84
 
46
85
  - name: Setup Bun and install dependencies
86
+ if: steps.docs-only.outputs.docs_only != 'true'
47
87
  uses: ./.github/actions/setup-bun-install
48
88
  with:
49
89
  enable-cache: 'true'
50
90
  cache-key: bun-v4-$\{{ runner.os }}-$\{{ hashFiles('bun.lock', 'bunfig.toml') }}
51
91
  cache-restore-keys: bun-v4-$\{{ runner.os }}-
52
92
 
93
+ # `turbo run test --affected` tests the changed packages and their dependents, all of them
94
+ # for a turbo.json or lockfile change. It ignores the root files every package reads, though:
95
+ # an override, the Bun version or the test script in the root package.json selects nothing.
96
+ # Those paths test every package. A PR that touches no package runs no task and passes.
97
+ - name: Decide test scope
98
+ id: test-scope
99
+ if: steps.docs-only.outputs.docs_only != 'true'
100
+ env:
101
+ GLOBAL_PATHS: package.json bunfig.toml tsconfig.json .npmrc .bun-version patches/ .github/workflows/lint-and-tests.yml .github/actions/setup-bun-install/
102
+ run: |
103
+ # shellcheck disable=SC2086 # GLOBAL_PATHS is a space-separated pathspec list
104
+ CHANGED=$(git diff --name-only HEAD^1 HEAD -- $GLOBAL_PATHS | head -1)
105
+ if [ -n "$CHANGED" ]; then
106
+ echo "::notice::${CHANGED} changed: testing every package."
107
+ echo "affected_only=false" >> "$GITHUB_OUTPUT"
108
+ else
109
+ echo "affected_only=true" >> "$GITHUB_OUTPUT"
110
+ fi
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
+
53
119
  - name: Run Tests
54
- run: bun run --if-present test
120
+ if: steps.docs-only.outputs.docs_only != 'true'
121
+ env:
122
+ AFFECTED_ONLY: $\{{ steps.test-scope.outputs.affected_only }}
123
+ TURBO_SCM_BASE: HEAD^1
124
+ TURBO_SCM_HEAD: HEAD
125
+ run: |
126
+ if [ "$AFFECTED_ONLY" = "true" ]; then
127
+ bun run --if-present test --affected
128
+ else
129
+ bun run --if-present test
130
+ fi
55
131
 
56
132
  e2e:
57
133
  runs-on: ubuntu-latest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossdelta/platform-sdk",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",