@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 +12 -0
- package/bin/templates/workspace/.github/actions/pr-docs-only/action.yml +15 -0
- package/bin/templates/workspace/.github/actions/pr-docs-only/docs-only.js +81 -0
- package/bin/templates/workspace/.github/actions/pr-docs-only/index.js +69 -0
- package/bin/templates/workspace/.github/workflows/lint-and-tests.yml.hbs +46 -1
- package/package.json +1 -1
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
|