@smoothbricks/cli 0.1.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.
Files changed (86) hide show
  1. package/README.md +289 -0
  2. package/bin/smoo +4 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +141 -0
  6. package/dist/github-ci/index.d.ts +11 -0
  7. package/dist/github-ci/index.d.ts.map +1 -0
  8. package/dist/github-ci/index.js +174 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/lib/json.d.ts +13 -0
  13. package/dist/lib/json.d.ts.map +1 -0
  14. package/dist/lib/json.js +57 -0
  15. package/dist/lib/run.d.ts +5 -0
  16. package/dist/lib/run.d.ts.map +1 -0
  17. package/dist/lib/run.js +64 -0
  18. package/dist/lib/workspace.d.ts +23 -0
  19. package/dist/lib/workspace.d.ts.map +1 -0
  20. package/dist/lib/workspace.js +101 -0
  21. package/dist/monorepo/commit-msg.d.ts +2 -0
  22. package/dist/monorepo/commit-msg.d.ts.map +1 -0
  23. package/dist/monorepo/commit-msg.js +34 -0
  24. package/dist/monorepo/git-config.d.ts +2 -0
  25. package/dist/monorepo/git-config.d.ts.map +1 -0
  26. package/dist/monorepo/git-config.js +37 -0
  27. package/dist/monorepo/index.d.ts +19 -0
  28. package/dist/monorepo/index.d.ts.map +1 -0
  29. package/dist/monorepo/index.js +59 -0
  30. package/dist/monorepo/lockfile.d.ts +2 -0
  31. package/dist/monorepo/lockfile.d.ts.map +1 -0
  32. package/dist/monorepo/lockfile.js +34 -0
  33. package/dist/monorepo/managed-files.d.ts +8 -0
  34. package/dist/monorepo/managed-files.d.ts.map +1 -0
  35. package/dist/monorepo/managed-files.js +130 -0
  36. package/dist/monorepo/nx-sync.d.ts +2 -0
  37. package/dist/monorepo/nx-sync.d.ts.map +1 -0
  38. package/dist/monorepo/nx-sync.js +5 -0
  39. package/dist/monorepo/package-hygiene.d.ts +3 -0
  40. package/dist/monorepo/package-hygiene.d.ts.map +1 -0
  41. package/dist/monorepo/package-hygiene.js +12 -0
  42. package/dist/monorepo/package-policy.d.ts +8 -0
  43. package/dist/monorepo/package-policy.d.ts.map +1 -0
  44. package/dist/monorepo/package-policy.js +279 -0
  45. package/dist/monorepo/packed-package.d.ts +2 -0
  46. package/dist/monorepo/packed-package.d.ts.map +1 -0
  47. package/dist/monorepo/packed-package.js +92 -0
  48. package/dist/monorepo/packs/index.d.ts +10 -0
  49. package/dist/monorepo/packs/index.d.ts.map +1 -0
  50. package/dist/monorepo/packs/index.js +87 -0
  51. package/dist/monorepo/runtime.d.ts +2 -0
  52. package/dist/monorepo/runtime.d.ts.map +1 -0
  53. package/dist/monorepo/runtime.js +37 -0
  54. package/dist/release/index.d.ts +24 -0
  55. package/dist/release/index.d.ts.map +1 -0
  56. package/dist/release/index.js +325 -0
  57. package/managed/raw/.git-format-staged.yml +47 -0
  58. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +66 -0
  59. package/managed/raw/tooling/git-hooks/commit-msg.sh +9 -0
  60. package/managed/raw/tooling/git-hooks/pre-commit.sh +19 -0
  61. package/managed/templates/github/actions/cache-nix-devenv/action.yml +79 -0
  62. package/managed/templates/github/actions/cache-node-modules/action.yml +13 -0
  63. package/managed/templates/github/actions/cache-nx/action.yml +21 -0
  64. package/managed/templates/github/actions/save-nix-devenv/action.yml +39 -0
  65. package/managed/templates/github/actions/setup-devenv/action.yml +63 -0
  66. package/managed/templates/github/workflows/ci.yml +98 -0
  67. package/managed/templates/github/workflows/publish.yml +136 -0
  68. package/package.json +68 -0
  69. package/src/cli.ts +149 -0
  70. package/src/github-ci/index.ts +196 -0
  71. package/src/index.ts +1 -0
  72. package/src/lib/json.ts +68 -0
  73. package/src/lib/run.ts +76 -0
  74. package/src/lib/workspace.ts +125 -0
  75. package/src/monorepo/commit-msg.ts +35 -0
  76. package/src/monorepo/git-config.ts +43 -0
  77. package/src/monorepo/index.ts +78 -0
  78. package/src/monorepo/lockfile.ts +37 -0
  79. package/src/monorepo/managed-files.ts +153 -0
  80. package/src/monorepo/nx-sync.ts +6 -0
  81. package/src/monorepo/package-hygiene.ts +14 -0
  82. package/src/monorepo/package-policy.ts +307 -0
  83. package/src/monorepo/packed-package.ts +101 -0
  84. package/src/monorepo/packs/index.ts +115 -0
  85. package/src/monorepo/runtime.ts +41 -0
  86. package/src/release/index.ts +394 -0
@@ -0,0 +1,325 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { Writable } from 'node:stream';
6
+ import { $ } from 'bun';
7
+ import { decode, run, runStatus } from '../lib/run.js';
8
+ import { listPublicPackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
9
+ export async function releaseVersion(root, options) {
10
+ const bump = releaseBumpArg(options.bump);
11
+ const projects = listPublicPackages(root)
12
+ .map((pkg) => pkg.name)
13
+ .join(',');
14
+ const state = await getReleaseState(root);
15
+ if (state.allPublished) {
16
+ console.log('Current package versions are already published; skipping version bump.');
17
+ return;
18
+ }
19
+ const headBeforeVersioning = await gitHead(root);
20
+ // Nx owns local release mutation: package versions, bun.lock updates, the
21
+ // release commit, and annotated tags. smoo owns the remote push so retries do
22
+ // not push an older checkout when Nx skips the release commit.
23
+ const nxArgs = ['release', 'version'];
24
+ if (bump !== 'auto') {
25
+ nxArgs.push(bump);
26
+ }
27
+ nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
28
+ if (options.dryRun) {
29
+ nxArgs.push('--dryRun');
30
+ }
31
+ await run('nx', nxArgs, root);
32
+ if (!options.dryRun) {
33
+ // Guard against future Nx/config regressions that leave release files, such
34
+ // as bun.lock, outside the release commit after tagging/pushing.
35
+ await assertCleanGitTree(root);
36
+ const headAfterVersioning = await gitHead(root);
37
+ if (headAfterVersioning === headBeforeVersioning) {
38
+ console.log('Nx did not create a release commit; skipping git push.');
39
+ return;
40
+ }
41
+ await pushReleaseCommit(root);
42
+ }
43
+ }
44
+ export async function releasePublish(root, options) {
45
+ const tag = releaseNpmTagArg(options);
46
+ const unpublishedPackages = await listUnpublishedPackages(root);
47
+ if (unpublishedPackages.length === 0) {
48
+ console.log('Current package versions are already published; skipping npm publish.');
49
+ return;
50
+ }
51
+ // npm package versions are immutable. Publishing only missing name@version
52
+ // pairs makes auth/network retries idempotent after a partial publish failure.
53
+ for (const pkg of unpublishedPackages) {
54
+ await publishPackedPackage(root, pkg, tag, options.dryRun === true);
55
+ }
56
+ }
57
+ export async function releaseGithubRelease(root, options) {
58
+ if (options.dryRun) {
59
+ console.log('Dry run; skipping GitHub Release creation.');
60
+ return;
61
+ }
62
+ const npmTag = releaseNpmTagArg(options);
63
+ const tags = options.tags ? options.tags.split(/\s+/).filter(Boolean) : await gitTagsAtHead(root);
64
+ if (tags.length === 0) {
65
+ throw new Error('No release tags found. Pass --tags or run from a tagged release commit.');
66
+ }
67
+ const latestFlag = npmTag === 'latest' ? 'true' : 'false';
68
+ for (const tag of tags) {
69
+ const exists = (await runStatus('gh', ['release', 'view', tag], root, true)) === 0;
70
+ if (exists) {
71
+ await run('gh', ['release', 'edit', tag, '--title', tag, `--latest=${latestFlag}`], root);
72
+ }
73
+ else {
74
+ await run('gh', ['release', 'create', tag, '--title', tag, '--generate-notes', `--latest=${latestFlag}`], root);
75
+ }
76
+ }
77
+ }
78
+ export async function releaseTrustPublisher(root, options) {
79
+ const repository = githubRepositoryFromRootPackage(root);
80
+ const workflow = 'publish.yml';
81
+ const packages = listPublicPackages(root);
82
+ if (packages.length === 0) {
83
+ throw new Error('No npm:public packages found.');
84
+ }
85
+ if (!options.dryRun) {
86
+ const packageStates = await Promise.all(packages.map(async (pkg) => ((await npmPackageExists(pkg.name)) ? null : pkg.name)));
87
+ const missingPackages = packageStates.filter((name) => name !== null);
88
+ if (missingPackages.length > 0) {
89
+ throw new Error('npm trusted publishing can only be configured after packages exist on the registry. ' +
90
+ 'Bootstrap the first publish with a temporary NPM_TOKEN, then rerun trust-publisher. ' +
91
+ `Missing packages: ${missingPackages.join(', ')}`);
92
+ }
93
+ }
94
+ if (!options.dryRun && !options.skipLogin) {
95
+ await runLatestNpm(root, ['login', '--auth-type=web']);
96
+ }
97
+ for (const pkg of packages) {
98
+ console.log(`${pkg.name}: trusting GitHub Actions ${repository}/${workflow}`);
99
+ const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
100
+ if (options.dryRun) {
101
+ args.push('--dry-run');
102
+ }
103
+ const otp = options.dryRun ? undefined : (options.otp ?? (await promptForNpmOtp(pkg.name)));
104
+ await runLatestNpm(root, args, otp ? { NPM_CONFIG_OTP: otp } : undefined);
105
+ }
106
+ }
107
+ export async function printReleaseState(root) {
108
+ console.log(JSON.stringify(await getReleaseState(root), null, 2));
109
+ }
110
+ async function getReleaseState(root) {
111
+ const packages = listPublicPackages(root);
112
+ const states = await Promise.all(packages.map(async (pkg) => {
113
+ const published = await npmVersionExists(pkg.name, pkg.version);
114
+ return { name: pkg.name, version: pkg.version, published };
115
+ }));
116
+ return { packages: states, allPublished: states.every((state) => state.published) };
117
+ }
118
+ async function listUnpublishedPackages(root) {
119
+ const packages = listPublicPackages(root);
120
+ const states = await Promise.all(packages.map(async (pkg) => ({ pkg, published: await npmVersionExists(pkg.name, pkg.version) })));
121
+ return states.filter((state) => !state.published).map((state) => state.pkg);
122
+ }
123
+ async function publishPackedPackage(root, pkg, tag, dryRun) {
124
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-publish-'));
125
+ const tarball = join(tempDir, `${safeTarballPrefix(pkg.name)}-${pkg.version}.tgz`);
126
+ try {
127
+ console.log(`${pkg.name}@${pkg.version}: packing with bun pm pack`);
128
+ await run('bun', ['pm', 'pack', '--filename', tarball, '--ignore-scripts', '--quiet'], join(root, pkg.path));
129
+ await assertNoWorkspaceProtocolInTarball(root, tarball, pkg);
130
+ // npm CLI owns authentication here: trusted publishing OIDC when configured,
131
+ // or the temporary NODE_AUTH_TOKEN bootstrap path below before trust exists.
132
+ // Bun still produces the tarball so workspace:* dependencies are resolved the
133
+ // same way smoo validates packed packages before release.
134
+ const args = ['publish', tarball, '--access', 'public', '--tag', tag, '--provenance'];
135
+ if (dryRun) {
136
+ args.push('--dry-run');
137
+ }
138
+ await runLatestNpmPublish(root, args);
139
+ }
140
+ finally {
141
+ await rm(tempDir, { recursive: true, force: true });
142
+ }
143
+ }
144
+ function safeTarballPrefix(name) {
145
+ return name.replace(/^@/, '').replace(/[^a-zA-Z0-9._-]+/g, '-');
146
+ }
147
+ async function assertNoWorkspaceProtocolInTarball(root, tarball, pkg) {
148
+ const result = await $ `tar -xOf ${tarball} package/package.json`.cwd(root).quiet().nothrow();
149
+ if (result.exitCode !== 0) {
150
+ throw new Error(`${pkg.name}: unable to inspect packed package.json before publish.`);
151
+ }
152
+ const manifest = decode(result.stdout);
153
+ if (manifest.includes('workspace:')) {
154
+ throw new Error(`${pkg.name}: packed package.json still contains workspace: dependency references.`);
155
+ }
156
+ }
157
+ async function npmVersionExists(name, version) {
158
+ const result = await $ `bun pm view ${`${name}@${version}`} version`.cwd(process.cwd()).quiet().nothrow();
159
+ return result.exitCode === 0 && decode(result.stdout).trim() === version;
160
+ }
161
+ async function npmPackageExists(name) {
162
+ const result = await $ `bun pm view ${name} name`.cwd(process.cwd()).quiet().nothrow();
163
+ return result.exitCode === 0;
164
+ }
165
+ async function gitTagsAtHead(root) {
166
+ const result = await $ `git tag --points-at HEAD`.cwd(root).quiet().nothrow();
167
+ if (result.exitCode !== 0) {
168
+ return [];
169
+ }
170
+ return decode(result.stdout)
171
+ .split('\n')
172
+ .map((tag) => tag.trim())
173
+ .filter(Boolean);
174
+ }
175
+ async function assertCleanGitTree(root) {
176
+ const result = await $ `git status --porcelain --untracked-files=no`.cwd(root).quiet().nothrow();
177
+ if (result.exitCode !== 0) {
178
+ throw new Error('Unable to inspect git status after release versioning.');
179
+ }
180
+ const status = decode(result.stdout).trim();
181
+ if (status) {
182
+ throw new Error(`nx release version left tracked changes after tagging:\n${status}`);
183
+ }
184
+ }
185
+ async function gitHead(root) {
186
+ const result = await $ `git rev-parse HEAD`.cwd(root).quiet().nothrow();
187
+ if (result.exitCode !== 0) {
188
+ throw new Error('Unable to resolve git HEAD before release versioning.');
189
+ }
190
+ return decode(result.stdout).trim();
191
+ }
192
+ async function pushReleaseCommit(root) {
193
+ const branch = await releaseBranch(root);
194
+ const remote = await releaseRemote(root, branch);
195
+ await run('git', ['push', '--follow-tags', '--atomic', remote, `HEAD:refs/heads/${branch}`], root);
196
+ }
197
+ async function releaseBranch(root) {
198
+ const githubBranch = process.env.GITHUB_REF_NAME?.trim();
199
+ if (githubBranch) {
200
+ return githubBranch;
201
+ }
202
+ const result = await $ `git branch --show-current`.cwd(root).quiet().nothrow();
203
+ const branch = decode(result.stdout).trim();
204
+ if (result.exitCode === 0 && branch) {
205
+ return branch;
206
+ }
207
+ throw new Error('Unable to resolve current git branch for release push.');
208
+ }
209
+ async function releaseRemote(root, branch) {
210
+ const configured = await gitConfigValue(root, `branch.${branch}.remote`);
211
+ if (configured) {
212
+ return configured;
213
+ }
214
+ if (await gitRemoteExists(root, 'origin')) {
215
+ return 'origin';
216
+ }
217
+ const result = await $ `git remote`.cwd(root).quiet().nothrow();
218
+ if (result.exitCode !== 0) {
219
+ throw new Error('Unable to list git remotes for release push.');
220
+ }
221
+ const remotes = decode(result.stdout)
222
+ .split('\n')
223
+ .map((remote) => remote.trim())
224
+ .filter(Boolean);
225
+ if (remotes.length === 1 && remotes[0]) {
226
+ return remotes[0];
227
+ }
228
+ throw new Error('Unable to choose git remote for release push. Configure branch upstream or add origin.');
229
+ }
230
+ async function gitConfigValue(root, key) {
231
+ const result = await $ `git config --get ${key}`.cwd(root).quiet().nothrow();
232
+ const value = decode(result.stdout).trim();
233
+ return result.exitCode === 0 && value ? value : null;
234
+ }
235
+ async function gitRemoteExists(root, remote) {
236
+ const result = await $ `git remote get-url ${remote}`.cwd(root).quiet().nothrow();
237
+ return result.exitCode === 0;
238
+ }
239
+ function releaseBumpArg(bump = 'auto') {
240
+ if (!['auto', 'patch', 'minor', 'major', 'prerelease'].includes(bump)) {
241
+ throw new Error(`Invalid --bump "${bump}". Expected auto, patch, minor, major, or prerelease.`);
242
+ }
243
+ return bump;
244
+ }
245
+ function releaseNpmTagArg(options) {
246
+ const bump = releaseBumpArg(options.bump);
247
+ const derivedTag = bump === 'prerelease' ? 'next' : 'latest';
248
+ const explicitTag = options.tag ?? options.npmTag;
249
+ if (!explicitTag) {
250
+ return derivedTag;
251
+ }
252
+ if (explicitTag !== derivedTag) {
253
+ throw new Error(`--bump ${bump} publishes with npm dist-tag ${derivedTag}, not ${explicitTag}.`);
254
+ }
255
+ return explicitTag;
256
+ }
257
+ function githubRepositoryFromRootPackage(root) {
258
+ const pkg = readPackageJson(join(root, 'package.json'));
259
+ const repository = pkg ? repositoryInfo(pkg.json) : null;
260
+ if (!repository) {
261
+ throw new Error('Root package.json must define repository.url before configuring npm trusted publishing.');
262
+ }
263
+ return githubRepositoryFromUrl(repository.url);
264
+ }
265
+ function githubRepositoryFromUrl(url) {
266
+ const normalized = url
267
+ .replace(/^git\+/, '')
268
+ .replace(/#.*$/, '')
269
+ .replace(/\.git$/, '');
270
+ const https = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/.exec(normalized);
271
+ if (https?.[1] && https[2]) {
272
+ return `${https[1]}/${https[2]}`;
273
+ }
274
+ const ssh = /^git@github\.com:([^/]+)\/([^/]+)$/.exec(normalized);
275
+ if (ssh?.[1] && ssh[2]) {
276
+ return `${ssh[1]}/${ssh[2]}`;
277
+ }
278
+ const shorthand = /^github:([^/]+)\/([^/]+)$/.exec(normalized);
279
+ if (shorthand?.[1] && shorthand[2]) {
280
+ return `${shorthand[1]}/${shorthand[2]}`;
281
+ }
282
+ throw new Error(`Root package.json repository.url must be a GitHub repository URL, got ${url}`);
283
+ }
284
+ async function runLatestNpm(root, npmArgs, env) {
285
+ await run('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
286
+ }
287
+ async function runLatestNpmPublish(root, npmArgs) {
288
+ const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN;
289
+ if (!token || process.env.NPM_CONFIG_USERCONFIG) {
290
+ await runLatestNpm(root, npmArgs);
291
+ return;
292
+ }
293
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-npm-auth-'));
294
+ const npmrc = join(tempDir, '.npmrc');
295
+ try {
296
+ await writeFile(npmrc, `registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`);
297
+ await runLatestNpm(root, npmArgs, { NPM_CONFIG_USERCONFIG: npmrc });
298
+ }
299
+ finally {
300
+ await rm(tempDir, { recursive: true, force: true });
301
+ }
302
+ }
303
+ async function promptForNpmOtp(packageName) {
304
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
305
+ throw new Error(`npm trust requires a one-time password for ${packageName}. Pass --otp <code> in non-interactive shells.`);
306
+ }
307
+ const mutedOutput = new Writable({
308
+ write(_chunk, _encoding, callback) {
309
+ callback();
310
+ },
311
+ });
312
+ const rl = createInterface({ input: process.stdin, output: mutedOutput, terminal: true });
313
+ process.stdout.write(`Enter npm OTP for ${packageName}: `);
314
+ try {
315
+ const otp = (await rl.question('')).trim();
316
+ process.stdout.write('\n');
317
+ if (!otp) {
318
+ throw new Error(`npm OTP is required for ${packageName}.`);
319
+ }
320
+ return otp;
321
+ }
322
+ finally {
323
+ rl.close();
324
+ }
325
+ }
@@ -0,0 +1,47 @@
1
+ # git-format-staged configuration for SmoothBricks-style monorepos
2
+
3
+ formatters:
4
+ eslint:
5
+ command: eslint-stdout '{}'
6
+ patterns:
7
+ - '*.astro'
8
+ - '!.*'
9
+ - '!.*/**'
10
+
11
+ biome:
12
+ command: biome check --files-ignore-unknown=true --use-editorconfig=true '--stdin-file-path={}' --fix
13
+ patterns:
14
+ - '*.js'
15
+ - '*.ts'
16
+ - '*.jsx'
17
+ - '*.tsx'
18
+ - '*.json'
19
+ - '*.jsonc'
20
+ - '*.html'
21
+ - '*.css'
22
+ - '*.graphql'
23
+
24
+ prettier:
25
+ command: prettier --ignore-unknown --stdin-filepath '{}'
26
+ patterns:
27
+ - '*'
28
+ - '!.*'
29
+ - '!*.js'
30
+ - '!*.ts'
31
+ - '!*.jsx'
32
+ - '!*.tsx'
33
+ - '!*.json'
34
+ - '!*.jsonc'
35
+ - '!*.html'
36
+ - '!*.css'
37
+ - '!*.graphql'
38
+ - '!*.nix'
39
+
40
+ alejandra:
41
+ command: alejandra
42
+ patterns:
43
+ - '*.nix'
44
+
45
+ settings:
46
+ update_working_tree: true
47
+ show_commands: true
@@ -0,0 +1,66 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ NIX_STORE_NAR="${NIX_STORE_NAR:-/tmp/nix-store.nar}"
5
+ nix_store_cmd="/nix/var/nix/profiles/default/bin/nix-store"
6
+ # Resolve from this script's location, not the caller's cwd. GitHub Actions
7
+ # runs this from tooling/direnv today, but direct cwd-changing helpers are easy
8
+ # to misuse and break on repeated calls.
9
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ repo_root="$(cd "$script_dir/../.." && pwd)"
11
+
12
+ clear_devenv_cache_state() {
13
+ rm -rf "$repo_root/tooling/direnv/.devenv" "$repo_root/tooling/direnv/.direnv"
14
+ }
15
+
16
+ add_repo_paths() {
17
+ {
18
+ echo "$repo_root/tooling/direnv/.devenv/profile/bin"
19
+ echo "$repo_root/node_modules/.bin"
20
+ echo "$repo_root/tooling"
21
+ } >> "$GITHUB_PATH"
22
+ }
23
+
24
+ restore_nix_store() {
25
+ if [ -s "$NIX_STORE_NAR" ]; then
26
+ if ! sudo "$nix_store_cmd" --import --quiet < "$NIX_STORE_NAR"; then
27
+ # .devenv/.direnv contain absolute /nix/store references. If importing the
28
+ # matching store closure fails, clearing them prevents incoherent restores.
29
+ clear_devenv_cache_state
30
+ exit 1
31
+ fi
32
+ # The restored NAR is only the import source. Cleanup exports a fresh NAR for
33
+ # the next cache save, so remove this workspace scratch file before release
34
+ # steps run git cleanliness checks.
35
+ rm -f "$NIX_STORE_NAR"
36
+ else
37
+ echo "No NAR file found; clearing devenv cache state"
38
+ clear_devenv_cache_state
39
+ fi
40
+ }
41
+
42
+ install_devenv() {
43
+ sudo mkdir -p /nix/var/nix/gcroots/ci
44
+ if ! command -v devenv >/dev/null 2>&1; then
45
+ nix profile add --accept-flake-config nixpkgs#devenv
46
+ fi
47
+ echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
48
+ sudo ln -sf "$HOME/.nix-profile" /nix/var/nix/gcroots/ci/profile
49
+ }
50
+
51
+ build_devenv_shell() {
52
+ devenv shell --verbose -- date
53
+ # Add repo-local tools only after the shell exists; cleanup steps use an
54
+ # explicit PATH because failures before this point must still refresh caches.
55
+ add_repo_paths
56
+ }
57
+
58
+ case "${1:-}" in
59
+ restore-store) restore_nix_store ;;
60
+ install-devenv) install_devenv ;;
61
+ build-shell) build_devenv_shell ;;
62
+ *)
63
+ echo "Usage: $0 {restore-store|install-devenv|build-shell}" >&2
64
+ exit 1
65
+ ;;
66
+ esac
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ cd "$(git rev-parse --show-toplevel)"
3
+ TOOLING="$PWD/tooling"
4
+
5
+ export PATH="$TOOLING/git-hooks:$TOOLING:$PWD/node_modules/.bin:$TOOLING/direnv/.devenv/profile/bin:$PATH"
6
+
7
+ set -e -o pipefail
8
+
9
+ smoo monorepo validate-commit-msg "$1"
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ cd "$(git rev-parse --show-toplevel)"
3
+ TOOLING="$PWD/tooling"
4
+
5
+ export PATH="$TOOLING/git-hooks:$TOOLING:$PWD/node_modules/.bin:$TOOLING/direnv/.devenv/profile/bin:$PATH"
6
+
7
+ set -e -o pipefail
8
+
9
+ # Format exactly what is staged so the commit includes the formatter output.
10
+ git-format-staged
11
+
12
+ # Adding a workspace package is rare and easy to leave half-wired. Run the
13
+ # full monorepo validator only for newly staged package manifests; --fail-fast
14
+ # keeps pre-commit output focused on the first blocking setup issue.
15
+ if ! git diff --cached --quiet --diff-filter=A -- \
16
+ 'packages/*/package.json' \
17
+ 'targets/*/package.json'; then
18
+ smoo monorepo validate --fail-fast
19
+ fi
@@ -0,0 +1,79 @@
1
+ name: Cache Nix profiles, devenv and direnv
2
+ description: Restore cached Nix/devenv state for SmoothBricks GitHub Actions jobs.
3
+
4
+ inputs:
5
+ segment:
6
+ description: 'Cache segment to operate on: nix or devenv.'
7
+ required: false
8
+ default: nix
9
+ mode:
10
+ description: Restore or save the Nix/devenv cache.
11
+ required: false
12
+ default: restore
13
+
14
+ outputs:
15
+ cache-hit:
16
+ description: Whether the restore step found an exact primary-key match.
17
+ value: ${{ steps.restore-nix.outputs.cache-hit == 'true' || steps.restore-devenv.outputs.cache-hit == 'true' }}
18
+
19
+ runs:
20
+ using: composite
21
+ steps:
22
+ # The large cache is the Nix profile plus exported store NAR. Its key tracks
23
+ # the expensive shell closure inputs only; tiny direnv/setup script changes
24
+ # should not invalidate a 1GB+ store cache.
25
+ - name: Restore Nix profiles, devenv and direnv
26
+ id: restore-nix
27
+ if: ${{ inputs.segment == 'nix' && inputs.mode == 'restore' }}
28
+ uses: actions/cache/restore@v5.0.5
29
+ with:
30
+ path: |
31
+ ${{ env.NIX_STORE_NAR }}
32
+ ~/.nix-profile
33
+ ~/.local/state/nix/profiles
34
+ ~/.cache/nix
35
+ key:
36
+ ${{ runner.os }}-nix-v3-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix',
37
+ 'tooling/direnv/devenv.lock') }}
38
+ restore-keys: |
39
+ ${{ runner.os }}-nix-v3-
40
+
41
+ - name: Save Nix profiles and store NAR
42
+ if: ${{ inputs.segment == 'nix' && inputs.mode == 'save' }}
43
+ uses: actions/cache/save@v5.0.5
44
+ with:
45
+ path: |
46
+ ${{ env.NIX_STORE_NAR }}
47
+ ~/.nix-profile
48
+ ~/.local/state/nix/profiles
49
+ ~/.cache/nix
50
+ key:
51
+ ${{ runner.os }}-nix-v3-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix',
52
+ 'tooling/direnv/devenv.lock') }}
53
+
54
+ # .devenv/.direnv are small, but they contain absolute /nix/store pointers.
55
+ # Workflows restore this segment only after an exact Nix cache hit.
56
+ - name: Restore devenv and direnv state
57
+ id: restore-devenv
58
+ if: ${{ inputs.segment == 'devenv' && inputs.mode == 'restore' }}
59
+ uses: actions/cache/restore@v5.0.5
60
+ with:
61
+ path: |
62
+ ${{ github.workspace }}/tooling/direnv/.devenv
63
+ ${{ github.workspace }}/tooling/direnv/.direnv
64
+ key:
65
+ ${{ runner.os }}-devenv-v3-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix',
66
+ 'tooling/direnv/devenv.lock', 'tooling/direnv/envrc.sh', 'tooling/direnv/setup-environment.ts') }}
67
+ restore-keys: |
68
+ ${{ runner.os }}-devenv-v3-
69
+
70
+ - name: Save devenv and direnv state
71
+ if: ${{ inputs.segment == 'devenv' && inputs.mode == 'save' }}
72
+ uses: actions/cache/save@v5.0.5
73
+ with:
74
+ path: |
75
+ ${{ github.workspace }}/tooling/direnv/.devenv
76
+ ${{ github.workspace }}/tooling/direnv/.direnv
77
+ key:
78
+ ${{ runner.os }}-devenv-v3-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix',
79
+ 'tooling/direnv/devenv.lock', 'tooling/direnv/envrc.sh', 'tooling/direnv/setup-environment.ts') }}
@@ -0,0 +1,13 @@
1
+ name: Cache node_modules
2
+ description: Restore cached Bun workspace dependencies.
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Cache node_modules
8
+ uses: actions/cache@v5.0.5
9
+ with:
10
+ path: node_modules
11
+ key: ${{ runner.os }}-node-{{NODE_MODULES_CACHE_KEY}}
12
+ restore-keys: |
13
+ ${{ runner.os }}-node-
@@ -0,0 +1,21 @@
1
+ name: Restore Nx cache
2
+ description: Restore cached Nx task outputs without publishing new cache entries.
3
+
4
+ outputs:
5
+ cache-hit:
6
+ description: Whether an exact Nx cache key was restored.
7
+ value: ${{ steps.restore.outputs.cache-hit }}
8
+
9
+ runs:
10
+ using: composite
11
+ steps:
12
+ - name: Restore Nx cache
13
+ id: restore
14
+ uses: actions/cache/restore@v5.0.5
15
+ with:
16
+ path: |
17
+ .nx/cache
18
+ .nx/workspace-data/*.db*
19
+ key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}
20
+ restore-keys: |
21
+ ${{ runner.os }}-nx-db-v1-
@@ -0,0 +1,39 @@
1
+ name: Save Nix/devenv caches
2
+ description: Cleanup the Nix store and explicitly save Nix/devenv cache segments.
3
+
4
+ inputs:
5
+ nix-cache-hit:
6
+ description: Exact-hit output from setup-devenv's Nix cache restore.
7
+ required: false
8
+ default: 'false'
9
+ devenv-cache-hit:
10
+ description: Exact-hit output from setup-devenv's devenv cache restore.
11
+ required: false
12
+ default: 'false'
13
+
14
+ runs:
15
+ using: composite
16
+ steps:
17
+ - name: 🧹 Cleanup and cache Nix store
18
+ id: cleanup-cache
19
+ shell: bash
20
+ working-directory: tooling/direnv
21
+ # build-shell normally adds repo paths to GITHUB_PATH, but this cleanup
22
+ # must still run when build-shell fails before that happens.
23
+ run:
24
+ PATH="$PWD/.devenv/profile/bin:$PWD/../../node_modules/.bin:$PWD/../../tooling:$PATH" ../../tooling/smoo
25
+ github-ci cleanup-cache
26
+
27
+ - name: 🧊 Save Nix profiles and store NAR
28
+ if: ${{ steps.cleanup-cache.outputs.cache-ready == 'true' && inputs.nix-cache-hit != 'true' }}
29
+ uses: ./.github/actions/cache-nix-devenv
30
+ with:
31
+ segment: nix
32
+ mode: save
33
+
34
+ - name: 🧊 Save devenv and direnv
35
+ if: ${{ steps.cleanup-cache.outputs.cache-ready == 'true' && inputs.devenv-cache-hit != 'true' }}
36
+ uses: ./.github/actions/cache-nix-devenv
37
+ with:
38
+ segment: devenv
39
+ mode: save