@crossdelta/platform-sdk 0.26.3 → 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,18 @@ 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
+
8
20
  ## [0.26.3] - 2026-10-21
9
21
 
10
22
  ### 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
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.4",
4
4
  "description": "Platform toolkit for event-driven microservices — keeping code and infrastructure in lockstep.",
5
5
  "keywords": [
6
6
  "cli",