@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.
- package/README.md +289 -0
- package/bin/smoo +4 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +141 -0
- package/dist/github-ci/index.d.ts +11 -0
- package/dist/github-ci/index.d.ts.map +1 -0
- package/dist/github-ci/index.js +174 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/json.d.ts +13 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +57 -0
- package/dist/lib/run.d.ts +5 -0
- package/dist/lib/run.d.ts.map +1 -0
- package/dist/lib/run.js +64 -0
- package/dist/lib/workspace.d.ts +23 -0
- package/dist/lib/workspace.d.ts.map +1 -0
- package/dist/lib/workspace.js +101 -0
- package/dist/monorepo/commit-msg.d.ts +2 -0
- package/dist/monorepo/commit-msg.d.ts.map +1 -0
- package/dist/monorepo/commit-msg.js +34 -0
- package/dist/monorepo/git-config.d.ts +2 -0
- package/dist/monorepo/git-config.d.ts.map +1 -0
- package/dist/monorepo/git-config.js +37 -0
- package/dist/monorepo/index.d.ts +19 -0
- package/dist/monorepo/index.d.ts.map +1 -0
- package/dist/monorepo/index.js +59 -0
- package/dist/monorepo/lockfile.d.ts +2 -0
- package/dist/monorepo/lockfile.d.ts.map +1 -0
- package/dist/monorepo/lockfile.js +34 -0
- package/dist/monorepo/managed-files.d.ts +8 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -0
- package/dist/monorepo/managed-files.js +130 -0
- package/dist/monorepo/nx-sync.d.ts +2 -0
- package/dist/monorepo/nx-sync.d.ts.map +1 -0
- package/dist/monorepo/nx-sync.js +5 -0
- package/dist/monorepo/package-hygiene.d.ts +3 -0
- package/dist/monorepo/package-hygiene.d.ts.map +1 -0
- package/dist/monorepo/package-hygiene.js +12 -0
- package/dist/monorepo/package-policy.d.ts +8 -0
- package/dist/monorepo/package-policy.d.ts.map +1 -0
- package/dist/monorepo/package-policy.js +279 -0
- package/dist/monorepo/packed-package.d.ts +2 -0
- package/dist/monorepo/packed-package.d.ts.map +1 -0
- package/dist/monorepo/packed-package.js +92 -0
- package/dist/monorepo/packs/index.d.ts +10 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -0
- package/dist/monorepo/packs/index.js +87 -0
- package/dist/monorepo/runtime.d.ts +2 -0
- package/dist/monorepo/runtime.d.ts.map +1 -0
- package/dist/monorepo/runtime.js +37 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +325 -0
- package/managed/raw/.git-format-staged.yml +47 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +66 -0
- package/managed/raw/tooling/git-hooks/commit-msg.sh +9 -0
- package/managed/raw/tooling/git-hooks/pre-commit.sh +19 -0
- package/managed/templates/github/actions/cache-nix-devenv/action.yml +79 -0
- package/managed/templates/github/actions/cache-node-modules/action.yml +13 -0
- package/managed/templates/github/actions/cache-nx/action.yml +21 -0
- package/managed/templates/github/actions/save-nix-devenv/action.yml +39 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +63 -0
- package/managed/templates/github/workflows/ci.yml +98 -0
- package/managed/templates/github/workflows/publish.yml +136 -0
- package/package.json +68 -0
- package/src/cli.ts +149 -0
- package/src/github-ci/index.ts +196 -0
- package/src/index.ts +1 -0
- package/src/lib/json.ts +68 -0
- package/src/lib/run.ts +76 -0
- package/src/lib/workspace.ts +125 -0
- package/src/monorepo/commit-msg.ts +35 -0
- package/src/monorepo/git-config.ts +43 -0
- package/src/monorepo/index.ts +78 -0
- package/src/monorepo/lockfile.ts +37 -0
- package/src/monorepo/managed-files.ts +153 -0
- package/src/monorepo/nx-sync.ts +6 -0
- package/src/monorepo/package-hygiene.ts +14 -0
- package/src/monorepo/package-policy.ts +307 -0
- package/src/monorepo/packed-package.ts +101 -0
- package/src/monorepo/packs/index.ts +115 -0
- package/src/monorepo/runtime.ts +41 -0
- 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,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
|