@smoothbricks/cli 0.11.15 โ 0.11.17
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 +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/json.d.ts +9 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +65 -53
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- package/dist/monorepo/managed-files.d.ts +9 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -2
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +110 -22
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +26 -4
- package/dist/release/private-npm.d.ts +5 -0
- package/dist/release/private-npm.d.ts.map +1 -1
- package/dist/release/private-npm.js +26 -5
- package/dist/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +39 -54
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
- package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
- package/package.json +2 -2
- package/src/cli.ts +46 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/json.ts +9 -0
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.test.ts +26 -0
- package/src/monorepo/managed-files.ts +36 -2
- package/src/monorepo/publish-workflow.ts +135 -21
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/private-npm-status.test.ts +37 -0
- package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
- package/src/release/index.ts +27 -2
- package/src/release/private-npm.ts +30 -5
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
|
@@ -954,7 +954,7 @@ describe('event-aware stage deployment', () => {
|
|
|
954
954
|
|
|
955
955
|
it('deploys app/backend, publishes PR metadata, and emits the resolved stage', async () => {
|
|
956
956
|
const nxCalls: string[][] = [];
|
|
957
|
-
const listCalls: Array<[string, string, string
|
|
957
|
+
const listCalls: Array<[string, string, string | undefined]> = [];
|
|
958
958
|
const summaries: string[] = [];
|
|
959
959
|
const deployments: Array<[string, string]> = [];
|
|
960
960
|
const outputs: string[] = [];
|
|
@@ -975,8 +975,8 @@ describe('event-aware stage deployment', () => {
|
|
|
975
975
|
repository: { full_name: 'owner/repo' },
|
|
976
976
|
pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
|
|
977
977
|
},
|
|
978
|
-
listProjects: async (_root,
|
|
979
|
-
listCalls.push([
|
|
978
|
+
listProjects: async (_root, mode, stage, selectTag) => {
|
|
979
|
+
listCalls.push([mode, stage, selectTag]);
|
|
980
980
|
return ['app', 'app-backend'];
|
|
981
981
|
},
|
|
982
982
|
runNx: async (args) => {
|
|
@@ -995,7 +995,7 @@ describe('event-aware stage deployment', () => {
|
|
|
995
995
|
},
|
|
996
996
|
);
|
|
997
997
|
|
|
998
|
-
expect(listCalls).toEqual([['
|
|
998
|
+
expect(listCalls).toEqual([['run-many', 'pr123', undefined]]);
|
|
999
999
|
expect(nxCalls).toHaveLength(1);
|
|
1000
1000
|
expect(nxCalls[0]).toContain('--projects=app,app-backend');
|
|
1001
1001
|
expect(nxCalls[0]).toContain('--exclude=tag:permanent-deploy-target,tag:staging-deploy-target');
|
|
@@ -1077,7 +1077,7 @@ describe('event-aware stage deployment', () => {
|
|
|
1077
1077
|
});
|
|
1078
1078
|
|
|
1079
1079
|
it('passes --select-tag through to the project selection of the production deploy', async () => {
|
|
1080
|
-
const listCalls: Array<[string, string, string
|
|
1080
|
+
const listCalls: Array<[string, string, string | undefined]> = [];
|
|
1081
1081
|
const nxCalls: string[][] = [];
|
|
1082
1082
|
|
|
1083
1083
|
await githubCiNxDeploy(
|
|
@@ -1086,8 +1086,8 @@ describe('event-aware stage deployment', () => {
|
|
|
1086
1086
|
{
|
|
1087
1087
|
processEnv: {},
|
|
1088
1088
|
setStatus: async () => {},
|
|
1089
|
-
listProjects: async (_root,
|
|
1090
|
-
listCalls.push([
|
|
1089
|
+
listProjects: async (_root, mode, stage, selectTag) => {
|
|
1090
|
+
listCalls.push([mode, stage, selectTag]);
|
|
1091
1091
|
return ['website'];
|
|
1092
1092
|
},
|
|
1093
1093
|
runNx: async (args) => {
|
|
@@ -1097,7 +1097,7 @@ describe('event-aware stage deployment', () => {
|
|
|
1097
1097
|
},
|
|
1098
1098
|
);
|
|
1099
1099
|
|
|
1100
|
-
expect(listCalls).toEqual([['
|
|
1100
|
+
expect(listCalls).toEqual([['run-many', 'production', 'production-push-deploy-target']]);
|
|
1101
1101
|
expect(nxCalls).toHaveLength(1);
|
|
1102
1102
|
expect(nxCalls[0]).toContain('--projects=website');
|
|
1103
1103
|
expect(nxCalls[0]).toContain('--stage=production');
|
|
@@ -1193,6 +1193,90 @@ describe('event-aware stage deployment', () => {
|
|
|
1193
1193
|
|
|
1194
1194
|
expect(statuses).toEqual(['pending', 'success']);
|
|
1195
1195
|
});
|
|
1196
|
+
|
|
1197
|
+
it('deploys the whole selection in one run-many, leaving the order to the graph', async () => {
|
|
1198
|
+
const nxCalls: string[][] = [];
|
|
1199
|
+
await githubCiNxDeploy(
|
|
1200
|
+
'/repo',
|
|
1201
|
+
{ stage: 'staging' },
|
|
1202
|
+
{
|
|
1203
|
+
processEnv: {},
|
|
1204
|
+
setStatus: async () => {},
|
|
1205
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1206
|
+
runNx: async (args) => {
|
|
1207
|
+
nxCalls.push(args);
|
|
1208
|
+
return 0;
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
);
|
|
1212
|
+
expect(nxCalls.map((args) => args.find((arg) => arg.startsWith('--projects=')))).toEqual([
|
|
1213
|
+
'--projects=app-backend,website',
|
|
1214
|
+
]);
|
|
1215
|
+
expect(nxCalls[0]).toContain('--stage=staging');
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('publishes nothing when the deploy fails', async () => {
|
|
1219
|
+
const statuses: string[] = [];
|
|
1220
|
+
const summaries: string[] = [];
|
|
1221
|
+
const deployments: Array<[string, string]> = [];
|
|
1222
|
+
const outputs: string[] = [];
|
|
1223
|
+
await expect(
|
|
1224
|
+
githubCiNxDeploy(
|
|
1225
|
+
'/repo',
|
|
1226
|
+
{ mode: 'run-many' },
|
|
1227
|
+
{
|
|
1228
|
+
processEnv: { GITHUB_EVENT_NAME: 'pull_request', GITHUB_OUTPUT: '/output', GITHUB_STEP_SUMMARY: '/summary' },
|
|
1229
|
+
github: { previewUrls: ['https://app.{stage}.example.com'] },
|
|
1230
|
+
eventPayload: {
|
|
1231
|
+
action: 'opened',
|
|
1232
|
+
repository: { full_name: 'owner/repo' },
|
|
1233
|
+
pull_request: { number: 12, head: { repo: { full_name: 'owner/repo' } } },
|
|
1234
|
+
},
|
|
1235
|
+
setStatus: async (status) => {
|
|
1236
|
+
statuses.push(status);
|
|
1237
|
+
},
|
|
1238
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1239
|
+
runNx: async () => 1,
|
|
1240
|
+
appendSummary: async (_path, content) => {
|
|
1241
|
+
summaries.push(content);
|
|
1242
|
+
},
|
|
1243
|
+
appendOutput: async (_path, content) => {
|
|
1244
|
+
outputs.push(content);
|
|
1245
|
+
},
|
|
1246
|
+
publishDeployment: async (stage, url) => {
|
|
1247
|
+
deployments.push([stage, url]);
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
),
|
|
1251
|
+
).rejects.toThrow(/--projects=app-backend,website .*failed with exit code 1/);
|
|
1252
|
+
expect(statuses).toEqual(['pending', 'failure']);
|
|
1253
|
+
expect(outputs).toEqual([]);
|
|
1254
|
+
expect(summaries).toEqual([]);
|
|
1255
|
+
expect(deployments).toEqual([]);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('verifies and deploys the whole selection with one run-many each', async () => {
|
|
1259
|
+
const nxCalls: string[][] = [];
|
|
1260
|
+
await githubCiNxDeploy(
|
|
1261
|
+
'/repo',
|
|
1262
|
+
{ stage: 'production', verify: true },
|
|
1263
|
+
{
|
|
1264
|
+
processEnv: {},
|
|
1265
|
+
setStatus: async () => {},
|
|
1266
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1267
|
+
runNx: async (args) => {
|
|
1268
|
+
nxCalls.push(args);
|
|
1269
|
+
return 0;
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
);
|
|
1273
|
+
expect(nxCalls.map((args) => `${args[2]} ${args[3]}`)).toEqual([
|
|
1274
|
+
'build --projects=app-backend,website',
|
|
1275
|
+
'lint --projects=app-backend,website',
|
|
1276
|
+
'test --projects=app-backend,website',
|
|
1277
|
+
'deploy --projects=app-backend,website',
|
|
1278
|
+
]);
|
|
1279
|
+
});
|
|
1196
1280
|
});
|
|
1197
1281
|
|
|
1198
1282
|
describe('resolveDeploymentStage with a configured push branch', () => {
|
package/src/github-ci/index.ts
CHANGED
|
@@ -338,7 +338,6 @@ export interface GithubCiNxDeployOptions {
|
|
|
338
338
|
export interface GithubCiNxDeployDependencies {
|
|
339
339
|
listProjects?: (
|
|
340
340
|
root: string,
|
|
341
|
-
target: string,
|
|
342
341
|
mode: 'affected' | 'run-many',
|
|
343
342
|
stage: DeploymentStage,
|
|
344
343
|
selectTag?: string,
|
|
@@ -371,9 +370,9 @@ export async function githubCiNxDeploy(
|
|
|
371
370
|
((state: 'pending' | 'success' | 'failure') =>
|
|
372
371
|
state === 'pending' ? createGithubStatus(name, step) : updateGithubStatus(name, state, step));
|
|
373
372
|
const mode = resolveNxSmartMode(options.mode ?? 'run-many');
|
|
374
|
-
const listProjects = dependencies.listProjects ??
|
|
373
|
+
const listProjects = dependencies.listProjects ?? listNxDeployProjects;
|
|
375
374
|
const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
|
|
376
|
-
const projects = await listProjects(root,
|
|
375
|
+
const projects = await listProjects(root, mode, stage, options.selectTag);
|
|
377
376
|
if (projects.length === 0) {
|
|
378
377
|
console.log(`No ${mode} deploy projects; skipping ${stage}.`);
|
|
379
378
|
await setStatus('pending');
|
|
@@ -387,8 +386,8 @@ export async function githubCiNxDeploy(
|
|
|
387
386
|
: undefined;
|
|
388
387
|
await setStatus('pending');
|
|
389
388
|
|
|
390
|
-
const projectList = projects.join(',');
|
|
391
389
|
const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
|
|
390
|
+
const projectList = projects.join(',');
|
|
392
391
|
for (const target of targets) {
|
|
393
392
|
const nxArgs = [
|
|
394
393
|
'run-many',
|
|
@@ -398,6 +397,8 @@ export async function githubCiNxDeploy(
|
|
|
398
397
|
`--exclude=${deployExclusions(stage)}`,
|
|
399
398
|
`--parallel=${NX_PARALLEL}`,
|
|
400
399
|
];
|
|
400
|
+
// Deploy ordering is a `dependsOn` edge in the project graph, not a second round here: one run-many
|
|
401
|
+
// already sequences any depth of `a:deploy -> b:deploy -> c:deploy`.
|
|
401
402
|
if (target === 'deploy') {
|
|
402
403
|
nxArgs.push(`--stage=${stage}`);
|
|
403
404
|
}
|
|
@@ -540,22 +541,18 @@ function previewUrlFromTemplate(template: string, stage: string): string {
|
|
|
540
541
|
return template.replaceAll('{stage}', stage);
|
|
541
542
|
}
|
|
542
543
|
|
|
543
|
-
async function
|
|
544
|
+
async function listNxDeployProjects(
|
|
544
545
|
root: string,
|
|
545
|
-
target: string,
|
|
546
546
|
mode: 'affected' | 'run-many',
|
|
547
547
|
stage: DeploymentStage,
|
|
548
548
|
selectTag?: string,
|
|
549
549
|
): Promise<string[]> {
|
|
550
550
|
const listArgs = ['show', 'projects'];
|
|
551
551
|
if (mode === 'affected') listArgs.push('--affected');
|
|
552
|
-
listArgs.push('--withTarget',
|
|
553
|
-
if (target === 'deploy') listArgs.push(`--exclude=${deployExclusions(stage)}`);
|
|
554
|
-
listArgs.push('--json');
|
|
552
|
+
listArgs.push('--withTarget', 'deploy', `--exclude=${deployExclusions(stage)}`, '--json');
|
|
555
553
|
const candidates = nxProjectList(await runText('nx', listArgs, root)).sort((left, right) =>
|
|
556
554
|
left.localeCompare(right),
|
|
557
555
|
);
|
|
558
|
-
if (target !== 'deploy') return candidates;
|
|
559
556
|
return selectStageDeployProjects(candidates, stage, selectTag, async (project) => {
|
|
560
557
|
const parsed = parseNxProjectDeployTarget(await runText('nx', ['show', 'project', project, '--json'], root));
|
|
561
558
|
if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
|
package/src/lib/json.ts
CHANGED
|
@@ -77,6 +77,15 @@ export interface PackageSmooGithub {
|
|
|
77
77
|
/** Toolchain environment on the Linux foreign-platform producer, such as an SDK path. */
|
|
78
78
|
env?: StringMap;
|
|
79
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* Platform target families the RELEASE workflow must not produce, as target
|
|
82
|
+
* globs (`*-macos`, `*-ios`, `*-linux`). A repository whose Nx graph carries
|
|
83
|
+
* a family it does not release from `publish.yml` โ because another workflow
|
|
84
|
+
* owns it, or because the leg is not production yet โ names it here, and the
|
|
85
|
+
* publish workflow renders without that job instead of carrying a leg whose
|
|
86
|
+
* failure blocks every release.
|
|
87
|
+
*/
|
|
88
|
+
releasePlatformFamiliesExcluded?: string[];
|
|
80
89
|
/**
|
|
81
90
|
* Sibling source checkouts a repo path-depends on (Cargo path
|
|
82
91
|
* dependencies, nix path inputs). The CI workflow clones each entry beside
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule for naming the repository secret behind an environment variable, so
|
|
3
|
+
* the workflow renderer, the reconciler and an operator all derive the same
|
|
4
|
+
* name instead of maintaining a map.
|
|
5
|
+
*
|
|
6
|
+
* GitHub refuses to create a secret whose name starts with `GITHUB_` (hence
|
|
7
|
+
* `RepositorySecretName`'s pattern), which is why a Worker's
|
|
8
|
+
* `GITHUB_CLIENT_SECRET` cannot be stored under its own name. Prefixing with
|
|
9
|
+
* the repository owner is the workaround operators already reach for by hand
|
|
10
|
+
* (`ACME_GITHUB_CLIENT_SECRET` for `acme/app`); making it the convention means
|
|
11
|
+
* nothing has to declare it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Names GitHub reserves: it rejects `gh secret set GITHUB_*` outright. */
|
|
15
|
+
export function isReservedRepositorySecretName(envName: string): boolean {
|
|
16
|
+
return /^GITHUB_/i.test(envName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The repository secret an env name lives in. Identity for every name GitHub
|
|
21
|
+
* accepts; owner-prefixed for the reserved ones. `owner` comes from the
|
|
22
|
+
* repository URL, so a fork or a rename derives its own names rather than
|
|
23
|
+
* inheriting a hardcoded prefix.
|
|
24
|
+
*/
|
|
25
|
+
export function conventionalRepositorySecretName(envName: string, owner: string): string {
|
|
26
|
+
if (!isReservedRepositorySecretName(envName)) return envName;
|
|
27
|
+
const prefix = owner
|
|
28
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
29
|
+
.replace(/^_+|_+$/g, '')
|
|
30
|
+
.toUpperCase();
|
|
31
|
+
if (prefix.length === 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Cannot derive a repository secret name for ${envName}: GitHub reserves the GITHUB_ prefix and the repository owner is empty.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return `${prefix}_${envName}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the whole env -> secret mapping for a set of declared env names.
|
|
41
|
+
* Explicit entries win: an exception stays expressible, but a repository that
|
|
42
|
+
* follows the convention declares nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function repositorySecretMapping(
|
|
45
|
+
envNames: readonly string[],
|
|
46
|
+
owner: string,
|
|
47
|
+
declared: Readonly<Record<string, string>> = {},
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
const mapping: Record<string, string> = {};
|
|
50
|
+
for (const envName of [...envNames].sort((left, right) => left.localeCompare(right))) {
|
|
51
|
+
mapping[envName] = declared[envName] ?? conventionalRepositorySecretName(envName, owner);
|
|
52
|
+
}
|
|
53
|
+
for (const [envName, secretName] of Object.entries(declared)) {
|
|
54
|
+
mapping[envName] = secretName;
|
|
55
|
+
}
|
|
56
|
+
return mapping;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Repository owner from a repository URL โ `acme` from
|
|
61
|
+
* `https://github.com/acme/app.git`, and the same for an ssh or forge
|
|
62
|
+
* URL. The owner is derived rather than configured so a fork or a rename
|
|
63
|
+
* carries its own prefix instead of inheriting one.
|
|
64
|
+
*/
|
|
65
|
+
export function repositoryOwnerFromUrl(url: string): string | null {
|
|
66
|
+
const withoutProtocol = url.replace(/^[a-z+]+:\/\//i, '').replace(/^git@/i, '');
|
|
67
|
+
const path = withoutProtocol.replace(/^[^/:]+[/:]/, '');
|
|
68
|
+
const owner = path.split('/')[0];
|
|
69
|
+
return owner && owner.length > 0 ? owner.replace(/\.git$/i, '') : null;
|
|
70
|
+
}
|
|
@@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { format } from 'prettier';
|
|
9
10
|
import type { PackageCargoGitOrigin } from '../../lib/json.js';
|
|
10
11
|
import {
|
|
11
12
|
type CiWorkflowDefinitionOptions,
|
|
@@ -396,9 +397,9 @@ describe('CI workflow definition', () => {
|
|
|
396
397
|
for (const sshOrigins of [
|
|
397
398
|
[],
|
|
398
399
|
// scp syntax is not a URL prefix git can rewrite from a Cargo pin.
|
|
399
|
-
['forgejo@forge.example.net:
|
|
400
|
+
['forgejo@forge.example.net:acme/widgets.git'],
|
|
400
401
|
// A path would rewrite one repository, not the forge.
|
|
401
|
-
['ssh://forge.example.net:2223/
|
|
402
|
+
['ssh://forge.example.net:2223/acme/widgets.git'],
|
|
402
403
|
// A password in a declared origin is a secret in package.json.
|
|
403
404
|
['ssh://forgejo:hunter2@forge.example.net:2223/'],
|
|
404
405
|
['https://git.example.net/'],
|
|
@@ -769,6 +770,30 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
|
|
|
769
770
|
expect(productionJob).not.toContain('needs.e2e-deployment');
|
|
770
771
|
});
|
|
771
772
|
|
|
773
|
+
it('renders a production job the repository Prettier config keeps byte for byte', async () => {
|
|
774
|
+
const withoutE2e = renderCiWorkflowYaml(
|
|
775
|
+
options({ deploy: true, deployProvider: 'cloudflare', pushBranches: ['trunk'], productionOnPush: true }),
|
|
776
|
+
);
|
|
777
|
+
for (const workflow of [rendered, withoutE2e]) {
|
|
778
|
+
const productionJob = workflow.slice(workflow.indexOf(' deploy-production:'));
|
|
779
|
+
expect(productionJob).toContain(' # prettier-ignore\n if: ${{ !cancelled() ');
|
|
780
|
+
expect(productionJob).toContain(
|
|
781
|
+
' # prettier-ignore\n run: smoo github-ci nx-deploy --stage production ',
|
|
782
|
+
);
|
|
783
|
+
// A consuming repo's commit hook formats staged YAML with Prettier; a rewrapped line reads as drift forever.
|
|
784
|
+
await expect(
|
|
785
|
+
format(workflow, { parser: 'yaml', printWidth: 120, proseWrap: 'always', singleQuote: true }),
|
|
786
|
+
).resolves.toBe(workflow);
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('cancels superseded pushes when the workflow has no deploy job to protect', () => {
|
|
791
|
+
const validateOnly = renderCiWorkflowYaml(options());
|
|
792
|
+
|
|
793
|
+
expect(validateOnly).toContain('cancel-in-progress: true');
|
|
794
|
+
expect(validateOnly).not.toContain("cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}");
|
|
795
|
+
});
|
|
796
|
+
|
|
772
797
|
it('keeps a protected staging environment off CI runs that do not deploy', () => {
|
|
773
798
|
const validateOnly = renderCiWorkflowYaml(options({ environments: { staging: 'staging' } }));
|
|
774
799
|
|
|
@@ -101,6 +101,60 @@ describe('publish workflow definition', () => {
|
|
|
101
101
|
expect(rendered).toContain("default: ''");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('a private forge release with cross-built Apple targets publishes from one job', () => {
|
|
105
|
+
const rendered = renderPublishWorkflowYaml({
|
|
106
|
+
repoName: 'acme/app',
|
|
107
|
+
actionsProvider: 'forgejo',
|
|
108
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
109
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
110
|
+
macosPlatformArchitectures: ['arm64'],
|
|
111
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh scripts/prepare-macos-sdk.sh', env: { AXE_CROSS: '1' } },
|
|
112
|
+
privateNpm: { scope: '@acme', readTokenEnv: 'READ_ENV', publishTokenEnv: 'PUBLISH_ENV' },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// One job: no producer job to hand outputs over from, so no transfer at all.
|
|
116
|
+
const jobsYaml = rendered.slice(rendered.indexOf('jobs:\n') + 'jobs:\n'.length);
|
|
117
|
+
expect(jobsYaml.match(/^ {2}[a-z][a-z0-9-]*:$/gm)).toEqual([' publish:']);
|
|
118
|
+
// The hand-off steps specifically; the failure-only trace-DB upload stays.
|
|
119
|
+
expect(rendered).not.toContain('Upload validated release state');
|
|
120
|
+
expect(rendered).not.toContain('Upload validated build outputs');
|
|
121
|
+
expect(rendered).not.toContain('Upload macOS platform outputs');
|
|
122
|
+
expect(rendered).not.toContain('Download candidate artifacts');
|
|
123
|
+
expect(rendered).not.toContain('git bundle');
|
|
124
|
+
// It still builds the Apple targets, with the producer's toolchain env and
|
|
125
|
+
// its preflight ahead of setup.
|
|
126
|
+
expect(rendered).toContain('--targets "*-macos"');
|
|
127
|
+
expect(rendered).toMatch(/^ {6}AXE_CROSS: ['"]1['"]$/m);
|
|
128
|
+
expect(rendered).toContain('Check cross-platform toolchain prerequisites');
|
|
129
|
+
expect(rendered.indexOf('Check cross-platform toolchain prerequisites')).toBeLessThan(
|
|
130
|
+
rendered.indexOf('Setup Nix/devenv'),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('keeps the publishing hand-off when provenance needs the hosted runner', () => {
|
|
135
|
+
const publicWithMac = renderPublishWorkflowYaml({
|
|
136
|
+
repoName: '@smoothbricks/codebase',
|
|
137
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
138
|
+
macosPlatformArchitectures: ['arm64'],
|
|
139
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh prepare.sh' },
|
|
140
|
+
});
|
|
141
|
+
const forgePublicWithMac = renderPublishWorkflowYaml({
|
|
142
|
+
repoName: '@smoothbricks/codebase',
|
|
143
|
+
actionsProvider: 'forgejo',
|
|
144
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
145
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
146
|
+
macosPlatformArchitectures: ['arm64'],
|
|
147
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh prepare.sh' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// No private registry declared: packages go to npmjs, which mints
|
|
151
|
+
// provenance from the publishing job and refuses a self-hosted runner.
|
|
152
|
+
for (const rendered of [publicWithMac, forgePublicWithMac]) {
|
|
153
|
+
expect(rendered).toContain('publish-on-linux:');
|
|
154
|
+
expect(rendered).toContain('Download candidate artifacts');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
104
158
|
it('preserves the single Ubuntu job and renders no artifact transfer when no Apple targets exist', () => {
|
|
105
159
|
const current = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
106
160
|
const explicitlyEmpty = renderPublishWorkflowYaml({
|
|
@@ -148,18 +202,18 @@ describe('publish workflow definition', () => {
|
|
|
148
202
|
);
|
|
149
203
|
const finalJob = platform.slice(platform.indexOf(' publish-on-linux:'));
|
|
150
204
|
|
|
151
|
-
expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length:
|
|
152
|
-
expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length:
|
|
205
|
+
expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 17 }, (_, index) => index + 1));
|
|
206
|
+
expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 20 }, (_, index) => index + 1));
|
|
153
207
|
expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 10 }, (_, index) => index + 1));
|
|
154
208
|
expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
|
|
155
|
-
expect(singleJob).toContain('# Step
|
|
156
|
-
expect(singleJob).toContain('# Step
|
|
157
|
-
expect(singleJob).toContain('# Step
|
|
158
|
-
expect(linuxCandidate).toContain('# Step
|
|
209
|
+
expect(singleJob).toContain('# Step 15\n - name: ๐ท๏ธ Tag release');
|
|
210
|
+
expect(singleJob).toContain('# Step 16\n - name: ๐ฆ Publish release (${{ steps.version.outputs.mode }})');
|
|
211
|
+
expect(singleJob).toContain('# Step 17\n - name: ๐งน Cleanup and cache Nix/devenv');
|
|
212
|
+
expect(linuxCandidate).toContain('# Step 11\n - name: ๐ Capture candidate release SHA');
|
|
159
213
|
expect(linuxCandidate).toContain(
|
|
160
|
-
'# Step
|
|
214
|
+
'# Step 7\n - name: โ
Check managed monorepo files (${{ steps.plan.outputs.mode }})',
|
|
161
215
|
);
|
|
162
|
-
expect(linuxCandidate).toContain('# Step
|
|
216
|
+
expect(linuxCandidate).toContain('# Step 20\n - name: ๐งน Cleanup and cache Nix/devenv');
|
|
163
217
|
expect(macosPlatform).toContain('# Step 6\n - name: ๐ข Version release');
|
|
164
218
|
expect(macosPlatform).toContain('# Step 7\n - name: ๐ Build selected macOS and iOS release outputs');
|
|
165
219
|
expect(macosPlatform).toContain('# Step 10\n - name: ๐งน Cleanup and cache Nix/devenv');
|
|
@@ -257,14 +311,22 @@ describe('publish workflow definition', () => {
|
|
|
257
311
|
expect(singleJob.match(/- name: ๐ท๏ธ Tag release/g)).toHaveLength(1);
|
|
258
312
|
expect(singleJob).toContain(' run: smoo release tag --dry-run "${{ inputs.dry_run }}"');
|
|
259
313
|
expect(singleJob.indexOf('- name: ๐งฏ Repair pending releases')).toBeLessThan(
|
|
260
|
-
singleJob.indexOf('- name:
|
|
314
|
+
singleJob.indexOf('- name: ๐งญ Plan release'),
|
|
261
315
|
);
|
|
316
|
+
// Lint and test hash the source, so they run BEFORE the version commit and
|
|
317
|
+
// reuse the task hashes ci already computed. Build stays after it: an
|
|
318
|
+
// artifact built from pre-bump sources would carry the previous version.
|
|
319
|
+
expect(singleJob.indexOf('- name: ๐งญ Plan release')).toBeLessThan(singleJob.indexOf('- name: ๐ Lint'));
|
|
320
|
+
expect(singleJob.indexOf('- name: ๐งช Unit Tests')).toBeLessThan(singleJob.indexOf('- name: ๐ข Version release'));
|
|
262
321
|
expect(singleJob.indexOf('- name: ๐ข Version release')).toBeLessThan(singleJob.indexOf('- name: ๐จ Build'));
|
|
263
|
-
expect(singleJob.indexOf('- name:
|
|
322
|
+
expect(singleJob.indexOf('- name: ๐จ Build')).toBeLessThan(singleJob.indexOf('- name: ๐ท๏ธ Tag release'));
|
|
323
|
+
// The gates select from the plan, not from a version step that has not run.
|
|
324
|
+
expect(singleJob).toContain('--targets lint --projects "${{ steps.plan.outputs.projects }}"');
|
|
325
|
+
expect(singleJob).toContain("if: steps.plan.outputs.mode != 'none'");
|
|
264
326
|
expect(singleJob.indexOf('- name: ๐ท๏ธ Tag release')).toBeLessThan(singleJob.indexOf('- name: ๐ฆ Publish release'));
|
|
265
327
|
// The Release section starts at tagging now, not at publishing.
|
|
266
328
|
expect(singleJob).toContain(
|
|
267
|
-
'# --- Release ------------------------------------------------------------\n\n # Step
|
|
329
|
+
'# --- Release ------------------------------------------------------------\n\n # Step 15\n - name: ๐ท๏ธ Tag release',
|
|
268
330
|
);
|
|
269
331
|
});
|
|
270
332
|
|
|
@@ -865,6 +927,8 @@ class WorkflowScenarioState {
|
|
|
865
927
|
tests: [],
|
|
866
928
|
validates: 0,
|
|
867
929
|
};
|
|
930
|
+
planned = false;
|
|
931
|
+
gatesBeforeVersion: { lints: string[]; tests: string[] } = { lints: [], tests: [] };
|
|
868
932
|
|
|
869
933
|
constructor(private readonly config: WorkflowScenarioConfig) {
|
|
870
934
|
for (const gap of [...config.repairs, ...config.current]) {
|
|
@@ -906,8 +970,16 @@ class WorkflowScenarioState {
|
|
|
906
970
|
this.repaired.add(gap.tag);
|
|
907
971
|
}
|
|
908
972
|
},
|
|
973
|
+
planRelease: async ({ bump }) => {
|
|
974
|
+
// The plan runs before anything is written, and the gates that follow
|
|
975
|
+
// it must therefore have seen no version commit yet.
|
|
976
|
+
this.planned = true;
|
|
977
|
+
expect(bump).toBe(this.config.bump);
|
|
978
|
+
return this.config.version;
|
|
979
|
+
},
|
|
909
980
|
versionRelease: async ({ bump, dryRun }) => {
|
|
910
981
|
this.versionObservedNxVersionActions = this.nxVersionActions;
|
|
982
|
+
this.gatesBeforeVersion = { lints: [...this.validationState.lints], tests: [...this.validationState.tests] };
|
|
911
983
|
expect(bump).toBe(this.config.bump);
|
|
912
984
|
expect(dryRun).toBe(this.config.dryRun);
|
|
913
985
|
return this.config.version;
|
|
@@ -1217,8 +1289,8 @@ it('keeps job-local step anchors contiguous once Cargo credentials add a setup s
|
|
|
1217
1289
|
|
|
1218
1290
|
// Each job gains exactly one step over the credential-free counts, and the
|
|
1219
1291
|
// hand-numbered platform renderers must renumber with it.
|
|
1220
|
-
expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length:
|
|
1221
|
-
expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length:
|
|
1292
|
+
expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 18 }, (_, index) => index + 1));
|
|
1293
|
+
expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 21 }, (_, index) => index + 1));
|
|
1222
1294
|
expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 11 }, (_, index) => index + 1));
|
|
1223
1295
|
expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
|
|
1224
1296
|
});
|
|
@@ -156,12 +156,21 @@ permissions:
|
|
|
156
156
|
${options.deploy ? ' deployments: write\n' : ''} statuses: write
|
|
157
157
|
|
|
158
158
|
concurrency:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
#
|
|
162
|
-
# canceling
|
|
159
|
+
${
|
|
160
|
+
options.deploy
|
|
161
|
+
? ` # One in-flight run per ref. Pushes to the staging push branch queue behind a
|
|
162
|
+
# running workflow instead of canceling it, so a newer push never cancels the
|
|
163
|
+
# deploy job mid-flight. Pull requests and other branches keep canceling
|
|
164
|
+
# superseded runs.
|
|
165
|
+
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
166
|
+
cancel-in-progress: \${{ github.ref != ${stagingRefLiteral(options)} }}`
|
|
167
|
+
: ` # This workflow validates and never deploys, so there is no in-flight
|
|
168
|
+
# deployment for a newer push to protect: every ref cancels its superseded
|
|
169
|
+
# runs. Queuing them instead serializes the staging branch, and a burst of
|
|
170
|
+
# pushes then reports the newest commit one full run per queued push late.
|
|
163
171
|
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
164
|
-
cancel-in-progress:
|
|
172
|
+
cancel-in-progress: true`
|
|
173
|
+
}
|
|
165
174
|
|
|
166
175
|
defaults:
|
|
167
176
|
run:
|
|
@@ -1025,6 +1034,7 @@ function renderProductionDeployJob(options: CiWorkflowDefinitionOptions): string
|
|
|
1025
1034
|
needs: ${needs}
|
|
1026
1035
|
${renderRunsOnLine(options.runsOn)}
|
|
1027
1036
|
timeout-minutes: 30
|
|
1037
|
+
# prettier-ignore
|
|
1028
1038
|
if: \${{ !cancelled() && github.event_name == 'push' && github.ref == ${stagingRefLiteral(options)} && needs.main.result == 'success'${e2eGate} }}
|
|
1029
1039
|
${environmentLine(options.environments?.production)} env:
|
|
1030
1040
|
GH_TOKEN: \${{ github.token }}
|
|
@@ -1032,7 +1042,8 @@ ${remoteCacheJobEnvLines(options.remoteCache)}${cargoCredentialJobEnvLines(optio
|
|
|
1032
1042
|
${renderCiWorkflowSteps(followUpSetupSteps(options, numbers), options)}
|
|
1033
1043
|
# Step ${numbers.middle}
|
|
1034
1044
|
- name: ๐ Deploy Production
|
|
1035
|
-
${renderOptionalLines(deployStepSecretEnvLines(options))}
|
|
1045
|
+
${renderOptionalLines(deployStepSecretEnvLines(options))} # prettier-ignore
|
|
1046
|
+
run: smoo github-ci nx-deploy --stage production --mode run-many --select-tag ${PRODUCTION_PUSH_DEPLOY_TAG} --name "Deploy Production" --step ${numbers.middle}
|
|
1036
1047
|
|
|
1037
1048
|
${renderCiWorkflowSteps(followUpCleanupStep(numbers), options)}`;
|
|
1038
1049
|
}
|
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
managedFileTargetsForTest,
|
|
21
21
|
platformTargetGlobsForTest,
|
|
22
22
|
reinsertInlineLocalBlocksForTest,
|
|
23
|
+
releasePlatformTargetGlobsFor,
|
|
23
24
|
renderManagedWorkflowForTest,
|
|
24
25
|
splitLocalSectionForTest,
|
|
25
26
|
validateDevenvModuleImport,
|
|
26
27
|
} from './managed-files.js';
|
|
28
|
+
import { renderPublishWorkflowYaml } from './publish-workflow.js';
|
|
27
29
|
|
|
28
30
|
const MANAGED = '# managed content\npath merge=driver\n';
|
|
29
31
|
|
|
@@ -457,6 +459,7 @@ const context = (overrides: Partial<ManagedFileContext>): ManagedFileContext =>
|
|
|
457
459
|
nodeModulesCacheKey: 'key',
|
|
458
460
|
repoName: '@scope/repo',
|
|
459
461
|
platformTargetGlobs: [],
|
|
462
|
+
releasePlatformTargetGlobs: [],
|
|
460
463
|
macosPlatformArchitectures: [],
|
|
461
464
|
...overrides,
|
|
462
465
|
});
|
|
@@ -565,3 +568,26 @@ describe('CI workflow rendering by repo shape', () => {
|
|
|
565
568
|
expect(rendered).toContain('GIT_CRYPT_KEY_B64: ${{ secrets.GIT_CRYPT_KEY_B64 }}');
|
|
566
569
|
});
|
|
567
570
|
});
|
|
571
|
+
|
|
572
|
+
describe('release platform families', () => {
|
|
573
|
+
it('an excluded family is not produced by the release workflow, and the rest still are', () => {
|
|
574
|
+
expect(releasePlatformTargetGlobsFor(['*-macos', '*-ios', '*-linux'], ['*-macos', '*-ios'])).toEqual(['*-linux']);
|
|
575
|
+
expect(releasePlatformTargetGlobsFor(PLATFORM_TARGET_GLOBS, undefined)).toEqual([...PLATFORM_TARGET_GLOBS]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('excluding every Apple family renders the single-job Linux publish shape', () => {
|
|
579
|
+
const globs = releasePlatformTargetGlobsFor(['*-macos', '*-linux'], ['*-macos']);
|
|
580
|
+
const rendered = renderPublishWorkflowYaml({
|
|
581
|
+
repoName: 'acme/app',
|
|
582
|
+
platformTargetGlobs: globs,
|
|
583
|
+
macosPlatformArchitectures: [],
|
|
584
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
585
|
+
actionsProvider: 'forgejo',
|
|
586
|
+
});
|
|
587
|
+
expect(rendered).not.toContain('cross-platform:');
|
|
588
|
+
expect(rendered).not.toContain('macos-platform:');
|
|
589
|
+
expect(rendered).not.toContain('SDKROOT');
|
|
590
|
+
expect(rendered).not.toContain('--targets "*-macos"');
|
|
591
|
+
expect(rendered).toContain(' publish:');
|
|
592
|
+
});
|
|
593
|
+
});
|