@codepress/design-kit 0.3.0 → 0.4.0
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/README.md +83 -15
- package/package.json +1 -1
- package/schema/design-kit.config.schema.json +10 -0
- package/skills/design-kit-setup/SKILL.md +8 -5
- package/src/ci.mjs +463 -0
- package/src/cli.mjs +37 -1
- package/src/init.mjs +221 -38
- package/src/templates/design-kit-skill.md +10 -5
package/README.md
CHANGED
|
@@ -79,11 +79,19 @@ components, and draws only what is new.
|
|
|
79
79
|
|
|
80
80
|
Nothing needs re-running after setup.
|
|
81
81
|
|
|
82
|
-
- **It rebuilds itself.** The workflow `init`
|
|
83
|
-
(`.github/workflows/design-kit.yml`)
|
|
84
|
-
|
|
85
|
-
`gh workflow run design-kit.yml`.
|
|
86
|
-
|
|
82
|
+
- **It rebuilds itself, only when the UI changed.** The workflow `init`
|
|
83
|
+
wrote (`.github/workflows/design-kit.yml`) checks every day, and on demand
|
|
84
|
+
from "Run workflow" in the Actions tab or with
|
|
85
|
+
`gh workflow run design-kit.yml`. It builds only when a path in the
|
|
86
|
+
config's `watch` list changed since the newest kit's commit, and at least
|
|
87
|
+
every 75 days (`DESIGN_KIT_MAX_AGE_DAYS` in the workflow) so the kit never
|
|
88
|
+
expires. Any other day it stops after a few seconds, and the run's summary
|
|
89
|
+
says why it built or skipped. "Run workflow" with `force` builds anyway.
|
|
90
|
+
- **Kits are kept 90 days, and older ones deleted.** 90 days is the most a
|
|
91
|
+
public repository allows; a private one may raise `retention-days` in the
|
|
92
|
+
workflow to 400 (raise `DESIGN_KIT_MAX_AGE_DAYS` with it). After each
|
|
93
|
+
upload the workflow deletes the branch's older kits, so only the newest is
|
|
94
|
+
stored. A failed delete is a warning, never a failed run.
|
|
87
95
|
- **It captures what exists at that commit.** Every build captures the
|
|
88
96
|
stories and pages that exist at the commit it runs on. It never writes
|
|
89
97
|
stories, so a new component shows up in the kit once it has a story. With
|
|
@@ -97,7 +105,46 @@ Nothing needs re-running after setup.
|
|
|
97
105
|
- **Re-run `init` only to start over** (`--force`). To pick up a new release
|
|
98
106
|
of this package, update the pinned version in the workflow
|
|
99
107
|
(`DESIGN_KIT_PACKAGE`); the "Run workflow" form's `package` input tries
|
|
100
|
-
another version for one run.
|
|
108
|
+
another version for one run (and always builds).
|
|
109
|
+
|
|
110
|
+
### What makes it rebuild: `watch`
|
|
111
|
+
|
|
112
|
+
`watch` in `design-kit.config.json` lists the folders and files, relative to
|
|
113
|
+
the repository root, whose change rebuilds the kit. `init` writes:
|
|
114
|
+
|
|
115
|
+
- the Storybook folder and the app's folder (when either is the repository
|
|
116
|
+
root, its UI folders such as `src`, `app` and `.storybook` and files such
|
|
117
|
+
as `package.json` and `tailwind.config.ts`, since watching the whole root
|
|
118
|
+
would rebuild on every commit);
|
|
119
|
+
- the workspace packages they depend on, directly or through each other,
|
|
120
|
+
since shared UI usually lives there in a monorepo;
|
|
121
|
+
- global styles and tokens at the root (`styles`, `tokens`, `theme`, a
|
|
122
|
+
Tailwind or PostCSS config);
|
|
123
|
+
- `design-kit.config.json` itself, the lockfile and the workflow.
|
|
124
|
+
|
|
125
|
+
A pages-only repository (no Storybook) gets the app's folder and its
|
|
126
|
+
workspace dependencies. Edit the list freely: add a folder the UI reads from
|
|
127
|
+
(fixtures, a shared assets package), or drop one that changes often without
|
|
128
|
+
changing what renders. Paths only, no globs. Without `watch`, any commit
|
|
129
|
+
rebuilds. The installed `design-kit` skill reads the same list when it counts
|
|
130
|
+
the UI commits since a kit, so its caption matches what triggers a rebuild.
|
|
131
|
+
|
|
132
|
+
### Updating a setup made by an older version
|
|
133
|
+
|
|
134
|
+
An older workflow keeps working as it is. To switch to the one above, run:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npx @codepress/design-kit@latest init --workflow-only --force
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
It rewrites only `.github/workflows/design-kit.yml`, at the new pinned
|
|
141
|
+
version, and leaves the config, the skill and `AGENTS.md` alone. When the
|
|
142
|
+
config has no `watch` list, it prints the one `init` would write; paste it
|
|
143
|
+
into `design-kit.config.json`, or the kit rebuilds after any commit. If you
|
|
144
|
+
edited the old workflow, copy the new `check` and `cleanup` jobs, the `id:
|
|
145
|
+
upload` and `retention-days: 90` on the upload step, the build job's
|
|
146
|
+
`artifact-id` output and `DESIGN_KIT_MAX_AGE_DAYS` into it instead. To
|
|
147
|
+
refresh the skill as well, run `init --force`, which rewrites the config too.
|
|
101
148
|
|
|
102
149
|
## Set up in your repository
|
|
103
150
|
|
|
@@ -117,14 +164,18 @@ other), the package manager and lockfile, the dev or start script and the
|
|
|
117
164
|
app's local URL, an existing Playwright, and whether the app switches to dark
|
|
118
165
|
with a class or a `data-theme` attribute. Then it writes:
|
|
119
166
|
|
|
120
|
-
- `design-kit.config.json`: the `stories` section when
|
|
121
|
-
`pages` section with `TODO` selectors to fill in, and
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
167
|
+
- `design-kit.config.json`: the `watch` list, the `stories` section when
|
|
168
|
+
Storybook is found, a `pages` section with `TODO` selectors to fill in, and
|
|
169
|
+
the `theme`.
|
|
170
|
+
- `.github/workflows/design-kit.yml`: a daily check (and `workflow_dispatch`)
|
|
171
|
+
on `ubuntu-latest`. A `check` job runs `design-kit ci-check`, which skips
|
|
172
|
+
when no `watch` path changed since the newest `design-kit` artifact's
|
|
173
|
+
commit and that kit is under 75 days old; otherwise the build installs
|
|
174
|
+
dependencies, this package at a pinned version and Chromium, builds
|
|
175
|
+
Storybook and captures the stories, captures the pages once no `TODO` is
|
|
176
|
+
left in the config, and uploads the `design-kit` artifact for 90 days. A
|
|
177
|
+
`cleanup` job, the only one allowed to delete artifacts, then runs
|
|
178
|
+
`design-kit ci-cleanup` to delete the branch's older kits. A run with nothing to
|
|
128
179
|
capture yet (no stories, pages still `TODO`) ends with a warning and
|
|
129
180
|
uploads nothing. The page capture gets
|
|
130
181
|
`DESIGN_KIT_USERNAME` and `DESIGN_KIT_PASSWORD` from repository secrets for
|
|
@@ -244,17 +295,34 @@ value>]` then replaces `slots`.
|
|
|
244
295
|
## Commands
|
|
245
296
|
|
|
246
297
|
```bash
|
|
247
|
-
design-kit init [--root dir] [--dry-run] [--force]
|
|
298
|
+
design-kit init [--root dir] [--dry-run] [--force] [--workflow-only]
|
|
248
299
|
design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
|
|
249
300
|
[--only id,... | --only-file f] [--base previous-kit]
|
|
250
301
|
design-kit capture pages [--config f] [--out dir] [--base-url url] [--only name,...]
|
|
251
302
|
[--ready name=selector] [--screenshots dir] [--timeout ms]
|
|
252
303
|
design-kit kit info|find|show|compose ... [--kit dir]
|
|
253
304
|
design-kit validate [--config f]
|
|
305
|
+
design-kit ci-check [--config f] [--since sha --kit-created iso | --no-kit]
|
|
306
|
+
[--force] [--package spec] [--max-age-days n]
|
|
307
|
+
design-kit ci-cleanup --keep artifact-id
|
|
254
308
|
design-kit setup-guide
|
|
255
309
|
design-kit storybook-guide
|
|
256
310
|
```
|
|
257
311
|
|
|
312
|
+
`ci-check` and `ci-cleanup` are the workflow's steps. `ci-check` finds the
|
|
313
|
+
newest unexpired `design-kit` artifact on the branch (through `gh`, from
|
|
314
|
+
`GITHUB_REPOSITORY` and `GITHUB_REF_NAME`, or `--repo` and `--branch`), or
|
|
315
|
+
takes it from `--since` and `--kit-created`. It fetches that commit (shallow,
|
|
316
|
+
no file contents) and compares the git tree or blob id of each `watch` path
|
|
317
|
+
there and at `HEAD`; a missing path is a value of its own, so a deleted path
|
|
318
|
+
counts as changed. It prints `build=true` or `build=false` and the reason
|
|
319
|
+
(`changed: src`, `watched files unchanged since 3f2a9c1`, `refresh before
|
|
320
|
+
expiry: …`, `no kit yet on this branch`, `forced …`, `an explicit package was
|
|
321
|
+
given …`) and writes them to `$GITHUB_OUTPUT` and the step summary. A kit
|
|
322
|
+
that cannot be looked up or fetched builds. `ci-cleanup` deletes the
|
|
323
|
+
branch's kits older than `--keep` and warns about any it could not delete;
|
|
324
|
+
both always exit 0 unless the config is invalid.
|
|
325
|
+
|
|
258
326
|
`setup-guide` prints the agent skill for setting a kit up in a repository
|
|
259
327
|
that has none, as one reviewable pull request: run `init`, decide between
|
|
260
328
|
Storybook stories and page capture, wire sign-in through repository secrets,
|
package/package.json
CHANGED
|
@@ -21,6 +21,16 @@
|
|
|
21
21
|
"minLength": 1,
|
|
22
22
|
"description": "Where captures write the kit when --out is not given."
|
|
23
23
|
},
|
|
24
|
+
"watch": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1,
|
|
29
|
+
"pattern": "^(?![/\\\\])(?!.*(^|/)\\.\\.(/|$))(?!.*[*?\\[])",
|
|
30
|
+
"description": "A folder or file, relative to the repository root (not to this file): no leading slash, no .., no glob."
|
|
31
|
+
},
|
|
32
|
+
"description": "The paths whose change rebuilds the kit in CI (`design-kit ci-check`), relative to the repository root. A run where none of them changed since the newest kit's commit builds nothing. Without it, any change in the repository rebuilds. `init` writes the Storybook and app folders, the workspace packages they use, global styles, this file, the lockfile and the workflow."
|
|
33
|
+
},
|
|
24
34
|
"theme": { "$ref": "#/$defs/theme" },
|
|
25
35
|
"stories": { "$ref": "#/$defs/stories" },
|
|
26
36
|
"pages": { "$ref": "#/$defs/pages" }
|
|
@@ -28,11 +28,14 @@ npx --yes @codepress/design-kit init
|
|
|
28
28
|
It detects Storybook and its version, the framework, the package manager, the
|
|
29
29
|
dev script, Playwright and the dark-theme mechanism, then writes:
|
|
30
30
|
|
|
31
|
-
- `design-kit.config.json`: the
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
- `design-kit.config.json`: the `watch` list (the UI folders, the workspace
|
|
32
|
+
packages the app uses, the lockfile and the workflow), the stories (when
|
|
33
|
+
Storybook is found), a `pages` skeleton with `TODO` selectors, and the
|
|
34
|
+
theme;
|
|
35
|
+
- `.github/workflows/design-kit.yml`: a daily check (and manual dispatch) that
|
|
36
|
+
builds only when a `watch` path changed since the last kit or that kit is 75
|
|
37
|
+
days old, uploads the `design-kit` artifact for 90 days and deletes the
|
|
38
|
+
older ones. Check the `watch` list covers every folder the UI renders from;
|
|
36
39
|
- `.claude/skills/design-kit/SKILL.md` and `.agents/skills/design-kit`: how
|
|
37
40
|
agents fetch the kit, compose mockups and caption them;
|
|
38
41
|
- one line in `AGENTS.md` (or `CLAUDE.md`) pointing at that skill.
|
package/src/ci.mjs
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CI side of the workflow `design-kit init` writes:
|
|
3
|
+
*
|
|
4
|
+
* - `design-kit ci-check` decides whether a run builds the kit. It builds
|
|
5
|
+
* only when a path in the config's `watch` list has changed since the
|
|
6
|
+
* newest kit's commit (git tree and blob ids, so no file is read), when
|
|
7
|
+
* there is no kit, when the newest kit is close to expiring, or when the
|
|
8
|
+
* run asked for it (`--force`, `--package`).
|
|
9
|
+
* - `design-kit ci-cleanup` deletes the older kits on the branch once a new
|
|
10
|
+
* one is uploaded. A failed delete is a warning, never a failure.
|
|
11
|
+
*
|
|
12
|
+
* Where kits are stored is behind two functions, `findNewestKit` and
|
|
13
|
+
* `deleteOlderKits` (GitHub Actions artifacts, through `gh`), so another
|
|
14
|
+
* store changes only those and the workflow's upload step.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { appendFileSync } from "node:fs";
|
|
18
|
+
import { posix } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { CONFIG_NAME, ConfigError, loadConfig } from "./config.mjs";
|
|
21
|
+
|
|
22
|
+
export const ARTIFACT = "design-kit";
|
|
23
|
+
/** Rebuild a kit this old even with no UI change, so it never expires. */
|
|
24
|
+
export const DEFAULT_MAX_AGE_DAYS = 75;
|
|
25
|
+
/** What `watch` means when a config has none: the whole repository. */
|
|
26
|
+
export const DEFAULT_WATCH = ["."];
|
|
27
|
+
const DAY_MS = 86_400_000;
|
|
28
|
+
const PAGES = 5;
|
|
29
|
+
|
|
30
|
+
// ── storage: GitHub Actions artifacts ───────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Runs `gh` and returns its stdout. */
|
|
33
|
+
export function runGh(args, { env = process.env } = {}) {
|
|
34
|
+
return execFileSync("gh", args, {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
env,
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
38
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every `design-kit` artifact of the repository, newest first. */
|
|
43
|
+
function listKits({ repo, gh }) {
|
|
44
|
+
const artifacts = [];
|
|
45
|
+
for (let page = 1; page <= PAGES; page += 1) {
|
|
46
|
+
const body = JSON.parse(
|
|
47
|
+
gh([
|
|
48
|
+
"api",
|
|
49
|
+
`repos/${repo}/actions/artifacts?name=${ARTIFACT}&per_page=100&page=${page}`,
|
|
50
|
+
]),
|
|
51
|
+
);
|
|
52
|
+
const batch = body?.artifacts ?? [];
|
|
53
|
+
artifacts.push(...batch);
|
|
54
|
+
if (batch.length < 100) break;
|
|
55
|
+
}
|
|
56
|
+
return artifacts
|
|
57
|
+
.filter((artifact) => artifact.name === ARTIFACT)
|
|
58
|
+
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The newest unexpired kit on `branch`: `{id, sha, createdAt, runId}`, or
|
|
63
|
+
* null when there is none. Throws when the store cannot be read.
|
|
64
|
+
*/
|
|
65
|
+
export function findNewestKit({ repo, branch, gh = runGh }) {
|
|
66
|
+
const kit = listKits({ repo, gh }).find(
|
|
67
|
+
(artifact) =>
|
|
68
|
+
!artifact.expired && artifact.workflow_run?.head_branch === branch,
|
|
69
|
+
);
|
|
70
|
+
if (!kit?.workflow_run?.head_sha) return null;
|
|
71
|
+
return {
|
|
72
|
+
id: String(kit.id),
|
|
73
|
+
sha: kit.workflow_run.head_sha,
|
|
74
|
+
createdAt: kit.created_at,
|
|
75
|
+
runId: kit.workflow_run.id ? String(kit.workflow_run.id) : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deletes the kits on `branch` older than the one `keep` names (an artifact
|
|
81
|
+
* id). Returns `{deleted: [ids], failed: [{id, error}], keptFound}`; never
|
|
82
|
+
* throws for a single failed delete. When the kept kit is not in the listing
|
|
83
|
+
* (not listed yet, or on another branch), nothing is deleted: "older" has
|
|
84
|
+
* nothing to be measured against.
|
|
85
|
+
*/
|
|
86
|
+
export function deleteOlderKits({ repo, branch, keep, gh = runGh }) {
|
|
87
|
+
const kits = listKits({ repo, gh }).filter(
|
|
88
|
+
(artifact) => artifact.workflow_run?.head_branch === branch,
|
|
89
|
+
);
|
|
90
|
+
const kept = kits.find((artifact) => String(artifact.id) === String(keep));
|
|
91
|
+
const deleted = [];
|
|
92
|
+
const failed = [];
|
|
93
|
+
if (!kept) return { deleted, failed, keptFound: false };
|
|
94
|
+
for (const artifact of kits) {
|
|
95
|
+
if (String(artifact.id) === String(keep)) continue;
|
|
96
|
+
// Only kits uploaded before the one just kept: a newer one, or one
|
|
97
|
+
// uploaded in the same instant, is never removed.
|
|
98
|
+
if (!(Date.parse(artifact.created_at) < Date.parse(kept.created_at)))
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
gh([
|
|
102
|
+
"api",
|
|
103
|
+
"--method",
|
|
104
|
+
"DELETE",
|
|
105
|
+
`repos/${repo}/actions/artifacts/${artifact.id}`,
|
|
106
|
+
]);
|
|
107
|
+
deleted.push(String(artifact.id));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failed.push({ id: String(artifact.id), error: errorText(error) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { deleted, failed, keptFound: Boolean(kept) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function errorText(error) {
|
|
116
|
+
const stderr = error?.stderr ? String(error.stderr).trim() : "";
|
|
117
|
+
return (stderr || error?.message || String(error)).split("\n")[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── git: ids of the watched paths ───────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function git(cwd, args, env = process.env) {
|
|
123
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
env,
|
|
126
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
127
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A `watch` entry as git names it: forward slashes, `.`/`..` segments and
|
|
133
|
+
* repeated or trailing slashes resolved, "" for the repository root.
|
|
134
|
+
*/
|
|
135
|
+
export function normalizeWatchPath(path) {
|
|
136
|
+
const normalized = posix.normalize(String(path).replace(/\\/g, "/"));
|
|
137
|
+
return normalized.replace(/^\.\/?$/, "").replace(/\/+$/, "");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `{path: id}` for each watched path at `rev`: the git tree id of a folder,
|
|
142
|
+
* the blob id of a file, or "missing" when the path does not exist there.
|
|
143
|
+
* Each path is listed on its own (`git ls-tree <rev> -- <path>`), so
|
|
144
|
+
* overlapping entries such as `src` and `src/theme.css` cannot hide each
|
|
145
|
+
* other. A path that is absent lists nothing; a tree git cannot read throws,
|
|
146
|
+
* so the run builds instead of mistaking it for an absent path. Only trees
|
|
147
|
+
* are read, so it works in a blobless (`--filter=blob:none`) clone.
|
|
148
|
+
*/
|
|
149
|
+
export function watchedIds(repoDir, rev, paths) {
|
|
150
|
+
const ids = {};
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
const key = normalizeWatchPath(path);
|
|
153
|
+
if (key === "") {
|
|
154
|
+
ids[path] = git(repoDir, [
|
|
155
|
+
"rev-parse",
|
|
156
|
+
"--verify",
|
|
157
|
+
`${rev}^{tree}`,
|
|
158
|
+
]).trim();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const out = git(repoDir, ["ls-tree", "-z", "--full-tree", rev, "--", key]);
|
|
162
|
+
let id = "missing";
|
|
163
|
+
for (const entry of out.split("\0").filter(Boolean)) {
|
|
164
|
+
const tab = entry.indexOf("\t");
|
|
165
|
+
if (entry.slice(tab + 1) === key) id = entry.slice(0, tab).split(" ")[2];
|
|
166
|
+
}
|
|
167
|
+
ids[path] = id;
|
|
168
|
+
}
|
|
169
|
+
return ids;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True when `sha` is a commit in this clone. */
|
|
173
|
+
function hasCommit(repoDir, sha) {
|
|
174
|
+
try {
|
|
175
|
+
// No lazy fetch from a partial clone's remote: one shallow fetch below
|
|
176
|
+
// brings the commit and its trees together.
|
|
177
|
+
git(repoDir, ["cat-file", "-e", `${sha}^{commit}`], {
|
|
178
|
+
...process.env,
|
|
179
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Makes `sha` available: already here, or a shallow, blobless fetch. */
|
|
188
|
+
export function ensureCommit(repoDir, sha) {
|
|
189
|
+
if (hasCommit(repoDir, sha)) return;
|
|
190
|
+
git(repoDir, [
|
|
191
|
+
"fetch",
|
|
192
|
+
"--quiet",
|
|
193
|
+
"--no-tags",
|
|
194
|
+
"--depth=1",
|
|
195
|
+
"--filter=blob:none",
|
|
196
|
+
"origin",
|
|
197
|
+
sha,
|
|
198
|
+
]);
|
|
199
|
+
if (!hasCommit(repoDir, sha)) throw new Error(`${sha} is not a commit`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── the decision ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
const short = (sha) => String(sha).slice(0, 7);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The reasons to build that need no git: `force`, an explicit package, no
|
|
208
|
+
* kit, or a kit close to expiring. Null when none applies.
|
|
209
|
+
*/
|
|
210
|
+
function buildWithoutComparing({
|
|
211
|
+
force,
|
|
212
|
+
packageSpec,
|
|
213
|
+
kit,
|
|
214
|
+
lookupError,
|
|
215
|
+
now,
|
|
216
|
+
maxAgeDays,
|
|
217
|
+
}) {
|
|
218
|
+
if (force) return { build: true, reason: "forced (the force input is set)" };
|
|
219
|
+
if (packageSpec)
|
|
220
|
+
return {
|
|
221
|
+
build: true,
|
|
222
|
+
reason: `an explicit package was given (${packageSpec})`,
|
|
223
|
+
};
|
|
224
|
+
if (!kit)
|
|
225
|
+
return {
|
|
226
|
+
build: true,
|
|
227
|
+
reason: lookupError
|
|
228
|
+
? `could not look up the newest kit (${lookupError}), so building`
|
|
229
|
+
: "no kit yet on this branch",
|
|
230
|
+
};
|
|
231
|
+
const ageDays = (now - Date.parse(kit.createdAt)) / DAY_MS;
|
|
232
|
+
if (!(ageDays < maxAgeDays))
|
|
233
|
+
return {
|
|
234
|
+
build: true,
|
|
235
|
+
reason: Number.isFinite(ageDays)
|
|
236
|
+
? `refresh before expiry: the newest kit (${short(kit.sha)}) is ${Math.floor(ageDays)} days old, and the limit is ${maxAgeDays}`
|
|
237
|
+
: `refresh before expiry: the newest kit (${short(kit.sha)}) has no usable date`,
|
|
238
|
+
};
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Whether to build, and why. Pure: every input is already looked up.
|
|
244
|
+
* `kit` is `{sha, createdAt}` or null; `before` and `after` are
|
|
245
|
+
* `watchedIds` at the kit's commit and at HEAD, unless `fetchError` says
|
|
246
|
+
* why they could not be read.
|
|
247
|
+
*/
|
|
248
|
+
export function decide({
|
|
249
|
+
force = false,
|
|
250
|
+
packageSpec = "",
|
|
251
|
+
kit = null,
|
|
252
|
+
lookupError,
|
|
253
|
+
now = Date.now(),
|
|
254
|
+
maxAgeDays = DEFAULT_MAX_AGE_DAYS,
|
|
255
|
+
fetchError,
|
|
256
|
+
before = {},
|
|
257
|
+
after = {},
|
|
258
|
+
}) {
|
|
259
|
+
const early = buildWithoutComparing({
|
|
260
|
+
force,
|
|
261
|
+
packageSpec,
|
|
262
|
+
kit,
|
|
263
|
+
lookupError,
|
|
264
|
+
now,
|
|
265
|
+
maxAgeDays,
|
|
266
|
+
});
|
|
267
|
+
if (early) return early;
|
|
268
|
+
if (fetchError)
|
|
269
|
+
return {
|
|
270
|
+
build: true,
|
|
271
|
+
reason: `could not read the kit's commit ${short(kit.sha)} (${fetchError}), so building`,
|
|
272
|
+
};
|
|
273
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
274
|
+
const changed = paths.filter((path) => before[path] !== after[path]);
|
|
275
|
+
if (changed.length)
|
|
276
|
+
return { build: true, reason: `changed: ${changed.join(", ")}` };
|
|
277
|
+
return {
|
|
278
|
+
build: false,
|
|
279
|
+
reason: `watched files unchanged since ${short(kit.sha)}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Looks everything up and decides. `kit` given (`{sha, createdAt}`, or null
|
|
285
|
+
* for "no kit") skips the store lookup; otherwise `findKit()` is called.
|
|
286
|
+
* `head` is the commit being built (default HEAD). Returns
|
|
287
|
+
* `{build, reason, kit}`.
|
|
288
|
+
*/
|
|
289
|
+
export function ciCheck({
|
|
290
|
+
repoDir,
|
|
291
|
+
head = "HEAD",
|
|
292
|
+
watch = DEFAULT_WATCH,
|
|
293
|
+
force = false,
|
|
294
|
+
packageSpec = "",
|
|
295
|
+
kit,
|
|
296
|
+
findKit,
|
|
297
|
+
now = Date.now(),
|
|
298
|
+
maxAgeDays = DEFAULT_MAX_AGE_DAYS,
|
|
299
|
+
}) {
|
|
300
|
+
const inputs = { force, packageSpec, now, maxAgeDays };
|
|
301
|
+
let lookupError;
|
|
302
|
+
if (kit === undefined && !force && !packageSpec) {
|
|
303
|
+
try {
|
|
304
|
+
kit = findKit();
|
|
305
|
+
} catch (error) {
|
|
306
|
+
kit = null;
|
|
307
|
+
lookupError = errorText(error);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
kit ??= null;
|
|
311
|
+
const early = buildWithoutComparing({ ...inputs, kit, lookupError });
|
|
312
|
+
if (early) return { ...early, kit };
|
|
313
|
+
let fetchError;
|
|
314
|
+
let before;
|
|
315
|
+
let after;
|
|
316
|
+
try {
|
|
317
|
+
ensureCommit(repoDir, kit.sha);
|
|
318
|
+
before = watchedIds(repoDir, kit.sha, watch);
|
|
319
|
+
after = watchedIds(repoDir, head, watch);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
fetchError = errorText(error);
|
|
322
|
+
}
|
|
323
|
+
const missing = after
|
|
324
|
+
? Object.keys(after).filter((path) => after[path] === "missing")
|
|
325
|
+
: [];
|
|
326
|
+
return {
|
|
327
|
+
...decide({ ...inputs, kit, before, after, fetchError }),
|
|
328
|
+
kit,
|
|
329
|
+
missing,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── the commands ────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function maxAgeFrom(value, env) {
|
|
336
|
+
const raw = value ?? env.DESIGN_KIT_MAX_AGE_DAYS;
|
|
337
|
+
if (raw === undefined || raw === "") return DEFAULT_MAX_AGE_DAYS;
|
|
338
|
+
const days = Number(raw);
|
|
339
|
+
if (!Number.isFinite(days) || days < 0)
|
|
340
|
+
throw new ConfigError(
|
|
341
|
+
`--max-age-days (or DESIGN_KIT_MAX_AGE_DAYS) takes a number of days, got ${raw}`,
|
|
342
|
+
);
|
|
343
|
+
return days;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function repoAndBranch(options, env) {
|
|
347
|
+
const repo = options.repo ?? env.GITHUB_REPOSITORY;
|
|
348
|
+
const branch = options.branch ?? env.GITHUB_REF_NAME;
|
|
349
|
+
if (!repo || !branch)
|
|
350
|
+
throw new ConfigError(
|
|
351
|
+
"--repo <owner/name> and --branch <name> are needed outside GitHub Actions",
|
|
352
|
+
);
|
|
353
|
+
return { repo, branch };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function emit(values, env) {
|
|
357
|
+
// One line per value: a newline (from a dispatch input in the reason)
|
|
358
|
+
// must not add a second `build=` line to $GITHUB_OUTPUT.
|
|
359
|
+
const lines = Object.entries(values).map(
|
|
360
|
+
([key, value]) => `${key}=${String(value).replace(/[\r\n]+/g, " ")}`,
|
|
361
|
+
);
|
|
362
|
+
for (const line of lines) console.log(line);
|
|
363
|
+
if (env.GITHUB_OUTPUT)
|
|
364
|
+
appendFileSync(env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function summary(text, env) {
|
|
368
|
+
if (env.GITHUB_STEP_SUMMARY)
|
|
369
|
+
appendFileSync(env.GITHUB_STEP_SUMMARY, `${text}\n`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* `design-kit ci-check`: prints `build=true|false` and `reason=…` (and
|
|
374
|
+
* writes them to $GITHUB_OUTPUT and the step summary when set).
|
|
375
|
+
*/
|
|
376
|
+
export function ciCheckCommand(options, { env = process.env, gh, now } = {}) {
|
|
377
|
+
const config = loadConfig(options.config);
|
|
378
|
+
const watch = config.raw.watch?.length ? config.raw.watch : DEFAULT_WATCH;
|
|
379
|
+
if ((options.since === undefined) !== (options["kit-created"] === undefined))
|
|
380
|
+
throw new ConfigError("--since and --kit-created go together");
|
|
381
|
+
const kit =
|
|
382
|
+
options.since !== undefined
|
|
383
|
+
? { sha: options.since, createdAt: options["kit-created"] }
|
|
384
|
+
: options["no-kit"]
|
|
385
|
+
? null
|
|
386
|
+
: undefined;
|
|
387
|
+
if (kit?.createdAt && Number.isNaN(Date.parse(kit.createdAt)))
|
|
388
|
+
throw new ConfigError(
|
|
389
|
+
`--kit-created takes an ISO 8601 date, got ${kit.createdAt}`,
|
|
390
|
+
);
|
|
391
|
+
const repoDir = git(config.configDir, [
|
|
392
|
+
"rev-parse",
|
|
393
|
+
"--show-toplevel",
|
|
394
|
+
]).trim();
|
|
395
|
+
const result = ciCheck({
|
|
396
|
+
repoDir,
|
|
397
|
+
watch,
|
|
398
|
+
force: Boolean(options.force),
|
|
399
|
+
packageSpec: options.package ?? "",
|
|
400
|
+
kit,
|
|
401
|
+
findKit: () =>
|
|
402
|
+
findNewestKit({
|
|
403
|
+
...repoAndBranch(options, env),
|
|
404
|
+
gh: gh ?? ((args) => runGh(args, { env })),
|
|
405
|
+
}),
|
|
406
|
+
now: now ?? Date.now(),
|
|
407
|
+
maxAgeDays: maxAgeFrom(options["max-age-days"], env),
|
|
408
|
+
});
|
|
409
|
+
if (result.missing?.length)
|
|
410
|
+
console.log(
|
|
411
|
+
`::warning title=Design kit::These watch paths do not exist at this commit, so they never trigger a rebuild: ${result.missing.join(", ")}. Fix or remove them in ${CONFIG_NAME}.`,
|
|
412
|
+
);
|
|
413
|
+
emit(
|
|
414
|
+
{
|
|
415
|
+
build: String(result.build),
|
|
416
|
+
reason: result.reason,
|
|
417
|
+
"kit-sha": result.kit?.sha ?? "",
|
|
418
|
+
},
|
|
419
|
+
env,
|
|
420
|
+
);
|
|
421
|
+
summary(
|
|
422
|
+
`Design kit: ${result.build ? "building" : "nothing to build"}, ${result.reason}.`,
|
|
423
|
+
env,
|
|
424
|
+
);
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* `design-kit ci-cleanup --keep <artifact id>`: deletes the branch's older
|
|
430
|
+
* kits. Always exits 0; each failed delete is a workflow warning.
|
|
431
|
+
*/
|
|
432
|
+
export function ciCleanupCommand(options, { env = process.env, gh } = {}) {
|
|
433
|
+
if (!options.keep) throw new ConfigError("--keep <artifact id> is required");
|
|
434
|
+
const { repo, branch } = repoAndBranch(options, env);
|
|
435
|
+
let result;
|
|
436
|
+
try {
|
|
437
|
+
result = deleteOlderKits({
|
|
438
|
+
repo,
|
|
439
|
+
branch,
|
|
440
|
+
keep: options.keep,
|
|
441
|
+
gh: gh ?? ((args) => runGh(args, { env })),
|
|
442
|
+
});
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.log(
|
|
445
|
+
`::warning title=Design kit::Could not list the older kits, so none were deleted: ${errorText(error)}`,
|
|
446
|
+
);
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
if (!result.keptFound) {
|
|
450
|
+
console.log(
|
|
451
|
+
`::warning title=Design kit::The kit just uploaded (${options.keep}) is not listed on ${branch} yet, so no older kit was deleted. The next build deletes them.`,
|
|
452
|
+
);
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
for (const { id, error } of result.failed)
|
|
456
|
+
console.log(
|
|
457
|
+
`::warning title=Design kit::Could not delete the older kit ${id}: ${error}`,
|
|
458
|
+
);
|
|
459
|
+
const text = `Design kit: deleted ${result.deleted.length} older kit${result.deleted.length === 1 ? "" : "s"} on ${branch}${result.failed.length ? `; ${result.failed.length} could not be deleted` : ""}.`;
|
|
460
|
+
console.log(text);
|
|
461
|
+
summary(text, env);
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -27,7 +27,8 @@ const USAGE = `usage: design-kit <command> [options]
|
|
|
27
27
|
|
|
28
28
|
init set a kit up in this repository: the config, a daily
|
|
29
29
|
GitHub Actions build, and a skill for its agents
|
|
30
|
-
[--root <dir>] [--dry-run] [--force]
|
|
30
|
+
[--root <dir>] [--dry-run] [--force] [--workflow-only]
|
|
31
|
+
--workflow-only --force rewrites just the workflow
|
|
31
32
|
capture stories capture every story of a Storybook 7-9 static build
|
|
32
33
|
[--config <file>] [--out <dir>] [--storybook-dir <dir>]
|
|
33
34
|
[--only <id,...> | --only-file <file>] [--base <previous kit>]
|
|
@@ -39,6 +40,15 @@ const USAGE = `usage: design-kit <command> [options]
|
|
|
39
40
|
[--kit <dir>] default: DESIGN_KIT_DIR, the config's "out", ./design-kit
|
|
40
41
|
validate check design-kit.config.json against the v1 schema
|
|
41
42
|
[--config <file>]
|
|
43
|
+
ci-check in CI: print build=true|false and the reason. Builds
|
|
44
|
+
when a "watch" path changed since the newest kit, there
|
|
45
|
+
is no kit, it is DESIGN_KIT_MAX_AGE_DAYS (75) old, or
|
|
46
|
+
--force or --package is given
|
|
47
|
+
[--config <file>] [--since <sha> --kit-created <iso> | --no-kit]
|
|
48
|
+
[--force] [--package <spec>] [--max-age-days <n>]
|
|
49
|
+
[--repo <owner/name>] [--branch <name>]
|
|
50
|
+
ci-cleanup in CI: delete the branch's kits older than --keep
|
|
51
|
+
--keep <artifact id> [--repo <owner/name>] [--branch <name>]
|
|
42
52
|
setup-guide print the guide for setting a kit up in a repository as
|
|
43
53
|
one reviewable pull request (an agent skill)
|
|
44
54
|
storybook-guide print the guide for adding Storybook, with stories for
|
|
@@ -179,14 +189,40 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
179
189
|
root: "value",
|
|
180
190
|
"dry-run": "flag",
|
|
181
191
|
force: "flag",
|
|
192
|
+
"workflow-only": "flag",
|
|
182
193
|
});
|
|
183
194
|
const { init } = await import("./init.mjs");
|
|
184
195
|
return init({
|
|
185
196
|
root: options.root ?? ".",
|
|
186
197
|
dryRun: Boolean(options["dry-run"]),
|
|
187
198
|
force: Boolean(options.force),
|
|
199
|
+
workflowOnly: Boolean(options["workflow-only"]),
|
|
188
200
|
});
|
|
189
201
|
}
|
|
202
|
+
if (command === "ci-check") {
|
|
203
|
+
const options = parse(argv.slice(1), {
|
|
204
|
+
config: "value",
|
|
205
|
+
since: "value",
|
|
206
|
+
"kit-created": "value",
|
|
207
|
+
"no-kit": "flag",
|
|
208
|
+
force: "flag",
|
|
209
|
+
package: "value",
|
|
210
|
+
"max-age-days": "value",
|
|
211
|
+
repo: "value",
|
|
212
|
+
branch: "value",
|
|
213
|
+
});
|
|
214
|
+
const { ciCheckCommand } = await import("./ci.mjs");
|
|
215
|
+
return ciCheckCommand(options, { env });
|
|
216
|
+
}
|
|
217
|
+
if (command === "ci-cleanup") {
|
|
218
|
+
const options = parse(argv.slice(1), {
|
|
219
|
+
keep: "value",
|
|
220
|
+
repo: "value",
|
|
221
|
+
branch: "value",
|
|
222
|
+
});
|
|
223
|
+
const { ciCleanupCommand } = await import("./ci.mjs");
|
|
224
|
+
return ciCleanupCommand(options, { env });
|
|
225
|
+
}
|
|
190
226
|
if (command === "setup-guide") {
|
|
191
227
|
parse(argv.slice(1), {});
|
|
192
228
|
process.stdout.write(readFileSync(SETUP_GUIDE, "utf8"));
|
package/src/init.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `design-kit init`: sets a design kit up in a repository. It looks at the
|
|
3
3
|
* project (Storybook, framework, package manager, dev script, Playwright,
|
|
4
4
|
* theme) and writes design-kit.config.json, a daily GitHub Actions workflow
|
|
5
|
-
* that
|
|
6
|
-
* one line in AGENTS.md or
|
|
7
|
-
* --force and never writes a
|
|
5
|
+
* that rebuilds the kit when the watched UI files change and uploads it, a
|
|
6
|
+
* design-kit skill for the repository's agents and one line in AGENTS.md or
|
|
7
|
+
* CLAUDE.md. It never overwrites a file without --force and never writes a
|
|
8
|
+
* secret. `--workflow-only` rewrites just the workflow.
|
|
8
9
|
*/
|
|
9
10
|
import {
|
|
10
11
|
existsSync,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from "node:fs";
|
|
21
22
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
22
23
|
|
|
24
|
+
import { DEFAULT_MAX_AGE_DAYS } from "./ci.mjs";
|
|
23
25
|
import { CONFIG_NAME } from "./config.mjs";
|
|
24
26
|
|
|
25
27
|
const PACKAGE = JSON.parse(
|
|
@@ -34,6 +36,8 @@ export const WORKFLOW_PATH = ".github/workflows/design-kit.yml";
|
|
|
34
36
|
export const SKILL_DIR = ".claude/skills/design-kit";
|
|
35
37
|
export const AGENTS_SKILL_DIR = ".agents/skills/design-kit";
|
|
36
38
|
const TODO = "TODO";
|
|
39
|
+
/** How long a kit artifact is kept: the most a public repository allows. */
|
|
40
|
+
export const RETENTION_DAYS = 90;
|
|
37
41
|
|
|
38
42
|
const IGNORED_DIRS = new Set([
|
|
39
43
|
"node_modules",
|
|
@@ -549,12 +553,117 @@ function posix(path) {
|
|
|
549
553
|
return path.split(sep).join("/");
|
|
550
554
|
}
|
|
551
555
|
|
|
556
|
+
/** Folders at a package root that hold what the UI renders or loads. */
|
|
557
|
+
const ROOT_UI_DIRS = new Set([
|
|
558
|
+
".storybook",
|
|
559
|
+
"app",
|
|
560
|
+
"assets",
|
|
561
|
+
"components",
|
|
562
|
+
"design-tokens",
|
|
563
|
+
"lib",
|
|
564
|
+
"pages",
|
|
565
|
+
"public",
|
|
566
|
+
"src",
|
|
567
|
+
"stories",
|
|
568
|
+
"styles",
|
|
569
|
+
"theme",
|
|
570
|
+
"tokens",
|
|
571
|
+
]);
|
|
572
|
+
/** Global styles and tokens that can sit at the repository root. */
|
|
573
|
+
const ROOT_STYLE_DIRS = new Set(["design-tokens", "styles", "theme", "tokens"]);
|
|
574
|
+
const UI_CONFIG_FILE =
|
|
575
|
+
/^(tailwind|postcss|vite|next|astro|remix|svelte|nuxt|uno|panda)\.config\.[cm]?[jt]s$/;
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* What to watch for one package folder: the folder itself, or, when the
|
|
579
|
+
* package is the repository root, its UI folders and config files (watching
|
|
580
|
+
* the whole root would rebuild on every commit).
|
|
581
|
+
*/
|
|
582
|
+
function packageWatch(dir, repoRoot) {
|
|
583
|
+
if (dir !== repoRoot) return [posix(relative(repoRoot, dir))];
|
|
584
|
+
const paths = [];
|
|
585
|
+
for (const entry of safeReaddir(dir)) {
|
|
586
|
+
if (entry.isDirectory() && ROOT_UI_DIRS.has(entry.name))
|
|
587
|
+
paths.push(entry.name);
|
|
588
|
+
else if (
|
|
589
|
+
entry.isFile() &&
|
|
590
|
+
(UI_CONFIG_FILE.test(entry.name) ||
|
|
591
|
+
entry.name === "package.json" ||
|
|
592
|
+
entry.name === "index.html")
|
|
593
|
+
)
|
|
594
|
+
paths.push(entry.name);
|
|
595
|
+
}
|
|
596
|
+
return paths.sort();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The workspace packages `dirs` depend on, directly or through each other. */
|
|
600
|
+
function workspaceDependencies(dirs, found) {
|
|
601
|
+
const all = [
|
|
602
|
+
...new Set([...workspacePackages(found.repoRoot), ...found.workspaces]),
|
|
603
|
+
];
|
|
604
|
+
const byName = new Map();
|
|
605
|
+
for (const dir of all) {
|
|
606
|
+
const name = readJson(join(dir, "package.json"))?.name;
|
|
607
|
+
if (name && !byName.has(name)) byName.set(name, dir);
|
|
608
|
+
}
|
|
609
|
+
const seen = new Set(dirs);
|
|
610
|
+
const queue = [...dirs];
|
|
611
|
+
const dependencies = [];
|
|
612
|
+
while (queue.length) {
|
|
613
|
+
const dir = queue.shift();
|
|
614
|
+
for (const name of Object.keys(deps(readJson(join(dir, "package.json"))))) {
|
|
615
|
+
const dep = byName.get(name);
|
|
616
|
+
if (dep && !seen.has(dep)) {
|
|
617
|
+
seen.add(dep);
|
|
618
|
+
dependencies.push(dep);
|
|
619
|
+
queue.push(dep);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return dependencies;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The config's `watch` list: the paths, relative to the repository root,
|
|
628
|
+
* whose change rebuilds the kit. The Storybook and app folders, the
|
|
629
|
+
* workspace packages they depend on (shared UI usually lives there), global
|
|
630
|
+
* styles and tokens at the root, the config, the lockfile and the workflow.
|
|
631
|
+
*/
|
|
632
|
+
export function watchDefaults(found) {
|
|
633
|
+
const rel = (path) => posix(relative(found.repoRoot, path));
|
|
634
|
+
const roots = [
|
|
635
|
+
...(found.storybook ? [found.storybook.dir] : []),
|
|
636
|
+
found.framework?.dir ?? found.project,
|
|
637
|
+
];
|
|
638
|
+
const packages = [...new Set(roots)];
|
|
639
|
+
const paths = [];
|
|
640
|
+
for (const dir of [...packages, ...workspaceDependencies(packages, found)])
|
|
641
|
+
paths.push(...packageWatch(dir, found.repoRoot));
|
|
642
|
+
for (const entry of safeReaddir(found.repoRoot))
|
|
643
|
+
if (
|
|
644
|
+
(entry.isDirectory() && ROOT_STYLE_DIRS.has(entry.name)) ||
|
|
645
|
+
(entry.isFile() && UI_CONFIG_FILE.test(entry.name))
|
|
646
|
+
)
|
|
647
|
+
paths.push(entry.name);
|
|
648
|
+
paths.push(rel(join(found.project, CONFIG_NAME)));
|
|
649
|
+
const pm = found.packageManager;
|
|
650
|
+
if (pm.lockfile) paths.push(rel(join(pm.installDir, pm.lockfile)));
|
|
651
|
+
paths.push(WORKFLOW_PATH);
|
|
652
|
+
const unique = [...new Set(paths.filter(Boolean))];
|
|
653
|
+
// A path inside another watched folder adds nothing.
|
|
654
|
+
return unique.filter(
|
|
655
|
+
(path) =>
|
|
656
|
+
!unique.some((other) => other !== path && path.startsWith(`${other}/`)),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
552
660
|
/** design-kit.config.json, as an object. */
|
|
553
661
|
export function buildConfig(found) {
|
|
554
662
|
const config = {
|
|
555
663
|
$schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
|
|
556
664
|
version: 1,
|
|
557
665
|
out: "design-kit",
|
|
666
|
+
watch: watchDefaults(found),
|
|
558
667
|
theme: {
|
|
559
668
|
attribute: found.theme.attribute,
|
|
560
669
|
dark: found.theme.dark,
|
|
@@ -634,13 +743,18 @@ export function buildWorkflow(found) {
|
|
|
634
743
|
|
|
635
744
|
return `name: Design kit
|
|
636
745
|
|
|
637
|
-
# Captures this repository's design kit
|
|
638
|
-
#
|
|
639
|
-
#
|
|
640
|
-
#
|
|
641
|
-
#
|
|
746
|
+
# Captures this repository's design kit: ${found.storybook ? "every Storybook story and " : ""}the pages
|
|
747
|
+
# listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts they
|
|
748
|
+
# load. It uploads the result as the \`design-kit\` artifact, which agents
|
|
749
|
+
# download to draw mockups from the app's real markup.
|
|
750
|
+
#
|
|
751
|
+
# It runs daily, but builds only when a path in the config's "watch" list
|
|
752
|
+
# changed since the newest kit's commit, when there is no kit yet, or when
|
|
753
|
+
# the newest kit is DESIGN_KIT_MAX_AGE_DAYS old, so a kit never expires
|
|
754
|
+
# unreplaced. After an upload it deletes the branch's older kits.
|
|
642
755
|
#
|
|
643
|
-
# Written by \`design-kit init\` (${PACKAGE.name})
|
|
756
|
+
# Written by \`design-kit init\` (${PACKAGE.name}); \`init --workflow-only
|
|
757
|
+
# --force\` rewrites just this file. Edit it freely.
|
|
644
758
|
|
|
645
759
|
on:
|
|
646
760
|
schedule:
|
|
@@ -648,11 +762,11 @@ on:
|
|
|
648
762
|
workflow_dispatch:
|
|
649
763
|
inputs:
|
|
650
764
|
force:
|
|
651
|
-
description: Build even when
|
|
765
|
+
description: Build even when no watched file changed since the newest kit
|
|
652
766
|
type: boolean
|
|
653
767
|
default: false
|
|
654
768
|
package:
|
|
655
|
-
description: "The ${PACKAGE.name} to
|
|
769
|
+
description: "The ${PACKAGE.name} to build with, as an npm package spec (default: the pinned version below). Always builds."
|
|
656
770
|
type: string
|
|
657
771
|
default: ""
|
|
658
772
|
|
|
@@ -667,30 +781,46 @@ env:
|
|
|
667
781
|
DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
|
|
668
782
|
PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
|
|
669
783
|
CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
|
|
784
|
+
# Rebuild a kit this many days old even with no UI change. Kits are kept
|
|
785
|
+
# ${RETENTION_DAYS} days (retention-days below), so this refreshes the kit before it
|
|
786
|
+
# expires.
|
|
787
|
+
DESIGN_KIT_MAX_AGE_DAYS: "${DEFAULT_MAX_AGE_DAYS}"
|
|
670
788
|
|
|
671
789
|
jobs:
|
|
672
790
|
check:
|
|
673
|
-
name:
|
|
791
|
+
name: Did the watched files change?
|
|
674
792
|
runs-on: ubuntu-latest
|
|
675
|
-
timeout-minutes:
|
|
793
|
+
timeout-minutes: 10
|
|
676
794
|
permissions:
|
|
795
|
+
contents: read
|
|
677
796
|
actions: read
|
|
678
797
|
outputs:
|
|
679
|
-
build: \${{ steps.
|
|
798
|
+
build: \${{ steps.check.outputs.build }}
|
|
680
799
|
steps:
|
|
681
|
-
|
|
800
|
+
# Only the config is checked out and no file contents are fetched: the
|
|
801
|
+
# check compares the git tree and blob ids of the watched paths at the
|
|
802
|
+
# newest kit's commit and at this one.
|
|
803
|
+
- uses: actions/checkout@v5
|
|
804
|
+
with:
|
|
805
|
+
fetch-depth: 1
|
|
806
|
+
filter: blob:none
|
|
807
|
+
sparse-checkout: ${at(CONFIG_NAME)}
|
|
808
|
+
sparse-checkout-cone-mode: false
|
|
809
|
+
|
|
810
|
+
- uses: actions/setup-node@v5
|
|
811
|
+
with:
|
|
812
|
+
node-version: "22"
|
|
813
|
+
|
|
814
|
+
- id: check
|
|
682
815
|
env:
|
|
683
816
|
GH_TOKEN: \${{ github.token }}
|
|
684
817
|
FORCE: \${{ inputs.force || 'false' }}
|
|
818
|
+
PACKAGE_INPUT: \${{ inputs.package || '' }}
|
|
685
819
|
run: |
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if [
|
|
689
|
-
|
|
690
|
-
echo "build=false" >> "$GITHUB_OUTPUT"
|
|
691
|
-
else
|
|
692
|
-
echo "build=true" >> "$GITHUB_OUTPUT"
|
|
693
|
-
fi
|
|
820
|
+
args=()
|
|
821
|
+
if [ "$FORCE" = "true" ]; then args+=(--force); fi
|
|
822
|
+
if [ -n "$PACKAGE_INPUT" ]; then args+=(--package "$PACKAGE_INPUT"); fi
|
|
823
|
+
npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-check --config "$CONFIG" "\${args[@]}"
|
|
694
824
|
|
|
695
825
|
build:
|
|
696
826
|
name: Build the design kit
|
|
@@ -698,6 +828,8 @@ jobs:
|
|
|
698
828
|
if: needs.check.outputs.build == 'true'
|
|
699
829
|
runs-on: ubuntu-latest
|
|
700
830
|
timeout-minutes: 60
|
|
831
|
+
outputs:
|
|
832
|
+
artifact-id: \${{ steps.upload.outputs.artifact-id }}
|
|
701
833
|
steps:
|
|
702
834
|
- uses: actions/checkout@v5
|
|
703
835
|
with:
|
|
@@ -745,13 +877,37 @@ ${storiesStep}
|
|
|
745
877
|
run: |
|
|
746
878
|
echo "::warning title=Design kit::Nothing to capture yet, so no kit was uploaded: ${at(CONFIG_NAME)} has no stories and its pages still have ${TODO} settings. Fill in the pages (or add Storybook) and run this workflow again."
|
|
747
879
|
|
|
748
|
-
-
|
|
880
|
+
- id: upload
|
|
881
|
+
if: hashFiles('${at("design-kit")}/manifest.json') != ''
|
|
749
882
|
uses: actions/upload-artifact@v5
|
|
750
883
|
with:
|
|
751
884
|
name: design-kit
|
|
752
885
|
path: ${at("design-kit")}
|
|
753
|
-
|
|
886
|
+
# The most a public repository allows. A private repository may
|
|
887
|
+
# raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
|
|
888
|
+
retention-days: ${RETENTION_DAYS}
|
|
754
889
|
if-no-files-found: error
|
|
890
|
+
|
|
891
|
+
# Deletes this branch's older kits once the new one is uploaded. The only
|
|
892
|
+
# job that may delete artifacts; a failed delete is a warning.
|
|
893
|
+
cleanup:
|
|
894
|
+
name: Delete older kits
|
|
895
|
+
needs: build
|
|
896
|
+
if: needs.build.outputs.artifact-id != ''
|
|
897
|
+
runs-on: ubuntu-latest
|
|
898
|
+
timeout-minutes: 5
|
|
899
|
+
permissions:
|
|
900
|
+
actions: write
|
|
901
|
+
steps:
|
|
902
|
+
- uses: actions/setup-node@v5
|
|
903
|
+
with:
|
|
904
|
+
node-version: "22"
|
|
905
|
+
|
|
906
|
+
- continue-on-error: true
|
|
907
|
+
env:
|
|
908
|
+
GH_TOKEN: \${{ github.token }}
|
|
909
|
+
KEEP: \${{ needs.build.outputs.artifact-id }}
|
|
910
|
+
run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
|
|
755
911
|
`;
|
|
756
912
|
}
|
|
757
913
|
|
|
@@ -759,19 +915,11 @@ ${storiesStep}
|
|
|
759
915
|
export function buildSkill(found) {
|
|
760
916
|
const pm = found.packageManager;
|
|
761
917
|
const projectDir = posix(relative(found.repoRoot, found.project)) || ".";
|
|
762
|
-
const watched = found.storybook
|
|
763
|
-
? [...new Set([found.storybook.dir, found.framework?.dir ?? found.project])]
|
|
764
|
-
: [found.framework?.dir ?? found.project];
|
|
765
|
-
const watchedPaths =
|
|
766
|
-
watched
|
|
767
|
-
.map((dir) => posix(relative(found.repoRoot, dir)) || ".")
|
|
768
|
-
.join(" ") || ".";
|
|
769
918
|
const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
|
|
770
919
|
const values = {
|
|
771
920
|
CLI: cli,
|
|
772
921
|
CONFIG: projectDir === "." ? CONFIG_NAME : `${projectDir}/${CONFIG_NAME}`,
|
|
773
922
|
PROJECT_DIR: projectDir,
|
|
774
|
-
WATCHED_PATHS: watchedPaths,
|
|
775
923
|
STORIES_LINE: found.storybook
|
|
776
924
|
? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
|
|
777
925
|
: `# (no Storybook: this kit has pages only)`,
|
|
@@ -902,6 +1050,7 @@ function describe(found) {
|
|
|
902
1050
|
? `${found.playwright.name}${found.playwright.version ? ` ${found.playwright.version}` : ""}`
|
|
903
1051
|
: "none (the workflow installs its own)",
|
|
904
1052
|
],
|
|
1053
|
+
["Rebuilds on", watchDefaults(found).join(", ")],
|
|
905
1054
|
[
|
|
906
1055
|
"Theme",
|
|
907
1056
|
found.theme.evidence
|
|
@@ -942,10 +1091,10 @@ function todo(found) {
|
|
|
942
1091
|
return items;
|
|
943
1092
|
}
|
|
944
1093
|
|
|
945
|
-
function summary(found, plan, { dryRun }) {
|
|
1094
|
+
function summary(found, plan, { dryRun, workflowOnly, notes }) {
|
|
946
1095
|
const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
|
|
947
1096
|
const lines = [
|
|
948
|
-
`design-kit init${dryRun ? " --dry-run" : ""}: ${found.project}`,
|
|
1097
|
+
`design-kit init${workflowOnly ? " --workflow-only" : ""}${dryRun ? " --dry-run" : ""}: ${found.project}`,
|
|
949
1098
|
"",
|
|
950
1099
|
"Found",
|
|
951
1100
|
];
|
|
@@ -968,24 +1117,58 @@ function summary(found, plan, { dryRun }) {
|
|
|
968
1117
|
lines.push(` ${rel(write.path)}${note}`);
|
|
969
1118
|
}
|
|
970
1119
|
lines.push("", "Still to decide");
|
|
971
|
-
for (const item of todo(found)) lines.push(` - ${item}`);
|
|
1120
|
+
for (const item of notes ?? todo(found)) lines.push(` - ${item}`);
|
|
972
1121
|
return lines.join("\n");
|
|
973
1122
|
}
|
|
974
1123
|
|
|
975
1124
|
// ── the command ─────────────────────────────────────────────────────────────
|
|
976
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* `init --workflow-only`: rewrites .github/workflows/design-kit.yml from
|
|
1128
|
+
* this version and nothing else. The config is left alone; when it has no
|
|
1129
|
+
* `watch` list, the summary prints the one init would write.
|
|
1130
|
+
*/
|
|
1131
|
+
function initWorkflowOnly(found, { dryRun, force, log }) {
|
|
1132
|
+
const configFile = join(found.project, CONFIG_NAME);
|
|
1133
|
+
if (!exists(configFile))
|
|
1134
|
+
throw new InitError(
|
|
1135
|
+
`${configFile} does not exist, so there is no kit to build yet. Run init without --workflow-only.`,
|
|
1136
|
+
);
|
|
1137
|
+
const path = join(found.repoRoot, WORKFLOW_PATH);
|
|
1138
|
+
if (!force && exists(path))
|
|
1139
|
+
throw new InitError(
|
|
1140
|
+
`${WORKFLOW_PATH} already exists. Nothing was written; pass --force with --workflow-only to replace it.`,
|
|
1141
|
+
);
|
|
1142
|
+
const plan = {
|
|
1143
|
+
writes: [{ path, kind: "file", content: buildWorkflow(found) }],
|
|
1144
|
+
};
|
|
1145
|
+
if (!dryRun) apply(plan.writes[0]);
|
|
1146
|
+
const notes = [];
|
|
1147
|
+
const raw = readJson(configFile);
|
|
1148
|
+
if (!Array.isArray(raw?.watch) || !raw.watch.length)
|
|
1149
|
+
notes.push(
|
|
1150
|
+
`${CONFIG_NAME} has no "watch" list, so the kit rebuilds after any commit. To rebuild only when the UI changes, add:\n "watch": ${JSON.stringify(watchDefaults(found))}`,
|
|
1151
|
+
);
|
|
1152
|
+
else notes.push(`Nothing: ${CONFIG_NAME} already has a "watch" list.`);
|
|
1153
|
+
log(summary(found, plan, { dryRun, workflowOnly: true, notes }));
|
|
1154
|
+
return 0;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
977
1157
|
/**
|
|
978
1158
|
* Runs init. Options: `root` (the project folder, default the working
|
|
979
|
-
* directory), `dryRun`, `force`, `
|
|
980
|
-
*
|
|
1159
|
+
* directory), `dryRun`, `force`, `workflowOnly` (write just the workflow, for
|
|
1160
|
+
* a repository set up by an older version), `log`. Returns the exit code: 0
|
|
1161
|
+
* done, 1 already set up or another file in the way.
|
|
981
1162
|
*/
|
|
982
1163
|
export function init({
|
|
983
1164
|
root = ".",
|
|
984
1165
|
dryRun = false,
|
|
985
1166
|
force = false,
|
|
1167
|
+
workflowOnly = false,
|
|
986
1168
|
log = console.log,
|
|
987
1169
|
} = {}) {
|
|
988
1170
|
const found = detectProject(root);
|
|
1171
|
+
if (workflowOnly) return initWorkflowOnly(found, { dryRun, force, log });
|
|
989
1172
|
const plan = planInit(found);
|
|
990
1173
|
if (!force) {
|
|
991
1174
|
const config = join(found.project, CONFIG_NAME);
|
|
@@ -25,9 +25,10 @@ is missing from every kit until it gets one.
|
|
|
25
25
|
## 1. Fetch the newest kit
|
|
26
26
|
|
|
27
27
|
The workflow runs once a day and on demand (`workflow_dispatch`), and uploads
|
|
28
|
-
the `design-kit` artifact (kept
|
|
29
|
-
|
|
30
|
-
the newest
|
|
28
|
+
the `design-kit` artifact (kept 90 days; older kits are deleted). A day when
|
|
29
|
+
none of the config's `watch` paths changed skips and uploads nothing, yet
|
|
30
|
+
still succeeds, so look up the newest artifact rather than the newest run,
|
|
31
|
+
then download it once per kit commit:
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
34
|
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
|
|
@@ -51,11 +52,15 @@ working files in a scratch folder, never in the repository.
|
|
|
51
52
|
## 2. Check staleness
|
|
52
53
|
|
|
53
54
|
The kit is a snapshot of `manifest.sha`, up to a day behind. Check whether UI
|
|
54
|
-
code changed since
|
|
55
|
+
code changed since, over the same `watch` paths that make the workflow
|
|
56
|
+
rebuild (read from the config, so the two always agree):
|
|
55
57
|
|
|
56
58
|
```bash
|
|
57
59
|
KIT_SHA=$(node -p 'require(process.argv[1]).sha' "$KIT/manifest.json")
|
|
58
|
-
git
|
|
60
|
+
TOP=$(git rev-parse --show-toplevel)
|
|
61
|
+
git fetch -q origin "$BRANCH" &&
|
|
62
|
+
node -e 'const w = require(process.argv[1]).watch; process.stdout.write((w?.length ? w : ["."]).map((p) => `:(top)${p}`).join("\0"))' "$TOP/{{CONFIG}}" |
|
|
63
|
+
xargs -0 git log --oneline "$KIT_SHA..origin/$BRANCH" --
|
|
59
64
|
```
|
|
60
65
|
|
|
61
66
|
When the list is not empty, say so on the mockup: "Kit from `<sha7>`; N UI
|