@smoothbricks/cli 0.11.16 โ†’ 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.
Files changed (49) hide show
  1. package/README.md +43 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +44 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/secret-names.d.ts +35 -0
  8. package/dist/lib/secret-names.d.ts.map +1 -0
  9. package/dist/lib/secret-names.js +61 -0
  10. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  11. package/dist/monorepo/ci-workflow.js +15 -6
  12. package/dist/monorepo/publish-workflow.d.ts +10 -1
  13. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/publish-workflow.js +58 -20
  15. package/dist/secrets/commands.d.ts +45 -0
  16. package/dist/secrets/commands.d.ts.map +1 -0
  17. package/dist/secrets/commands.js +189 -0
  18. package/dist/secrets/index.d.ts +83 -0
  19. package/dist/secrets/index.d.ts.map +1 -0
  20. package/dist/secrets/index.js +149 -0
  21. package/dist/wrangler/deploy-stage.d.ts +13 -2
  22. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  23. package/dist/wrangler/deploy-stage.js +39 -54
  24. package/dist/wrangler/deployed-version.d.ts +44 -0
  25. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  26. package/dist/wrangler/deployed-version.js +87 -0
  27. package/dist/wrangler/live-version.d.ts +146 -0
  28. package/dist/wrangler/live-version.d.ts.map +1 -0
  29. package/dist/wrangler/live-version.js +326 -0
  30. package/package.json +2 -2
  31. package/src/cli.ts +46 -1
  32. package/src/github-ci/index.test.ts +92 -8
  33. package/src/github-ci/index.ts +7 -10
  34. package/src/lib/secret-names.ts +70 -0
  35. package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
  36. package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
  37. package/src/monorepo/ci-workflow.ts +17 -6
  38. package/src/monorepo/managed-files.test.ts +1 -1
  39. package/src/monorepo/publish-workflow.ts +65 -19
  40. package/src/monorepo/secret-references.test.ts +1 -1
  41. package/src/release/__tests__/private-npm-status.test.ts +2 -2
  42. package/src/secrets/commands.ts +210 -0
  43. package/src/secrets/index.test.ts +92 -0
  44. package/src/secrets/index.ts +183 -0
  45. package/src/wrangler/deploy-stage.test.ts +190 -12
  46. package/src/wrangler/deploy-stage.ts +72 -45
  47. package/src/wrangler/deployed-version.test.ts +150 -0
  48. package/src/wrangler/deployed-version.ts +130 -0
  49. package/src/wrangler/live-version.ts +363 -0
package/src/cli.ts CHANGED
@@ -6,7 +6,9 @@ import { cliPackageVersion } from './lib/cli-package.js';
6
6
  import { decode, findRepoRoot, printCommandOutput } from './lib/run.js';
7
7
  import { ensureChromium } from './playwright/index.js';
8
8
  import { resolvePrConflicts } from './pr/index.js';
9
+ import { secretsSet, secretsStatus, secretsSync } from './secrets/commands.js';
9
10
  import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
11
+ import { deployedVersion } from './wrangler/deployed-version.js';
10
12
  import { scaffold } from './wrangler/scaffold.js';
11
13
 
12
14
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
@@ -525,6 +527,31 @@ function buildProgram(): Command {
525
527
  await ensureChromium();
526
528
  });
527
529
 
530
+ const secrets = program
531
+ .command('secrets')
532
+ .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
533
+ secrets
534
+ .command('status')
535
+ .description('Show every declared secret and refuse when a workflow passes one the repository lacks')
536
+ .option('--repo <owner/name>', 'repository to read secrets from; defaults to the current checkout')
537
+ .action(async (options: { repo?: string }) => {
538
+ process.exitCode = secretsStatus(await findRepoRoot(), options);
539
+ });
540
+ secrets
541
+ .command('set <name>')
542
+ .description('Set one repository secret from a pasted value; the value is read without echo and never logged')
543
+ .option('--repo <owner/name>', 'repository to set the secret on')
544
+ .action(async (name: string, options: { repo?: string }) => {
545
+ process.exitCode = await secretsSet(name, options);
546
+ });
547
+ secrets
548
+ .command('sync')
549
+ .description('Push every secret smoo.secrets can fetch locally to the repository')
550
+ .option('--repo <owner/name>', 'repository to set the secrets on')
551
+ .action(async (options: { repo?: string }) => {
552
+ process.exitCode = await secretsSync(await findRepoRoot(), options);
553
+ });
554
+
528
555
  const wrangler = program.command('wrangler').description('Cloudflare wrangler project helpers');
529
556
  wrangler
530
557
  .command('scaffold <project>')
@@ -537,11 +564,29 @@ function buildProgram(): Command {
537
564
  .command('deploy-stage')
538
565
  .requiredOption('--stage <stage>', 'staging, production, or prN')
539
566
  .option('--config <path>', 'deploy a build-generated flat wrangler.json instead of ./wrangler.toml')
540
- .action(async (options: { stage: string; config?: string }) => {
567
+ .option('--version-endpoint <url>', 'URL served by this worker whose trimmed body is the running version tag')
568
+ .action(async (options: { stage: string; config?: string; versionEndpoint?: string }) => {
541
569
  await deployStage(process.cwd(), {
542
570
  stage: options.stage,
543
571
  ...(options.config ? { config: resolve(options.config) } : {}),
572
+ ...(options.versionEndpoint ? { versionEndpoint: options.versionEndpoint } : {}),
573
+ });
574
+ });
575
+ wrangler
576
+ .command('deployed-version')
577
+ .description('Print the version tag serving all traffic for this project\u2019s worker on a stage')
578
+ .requiredOption('--stage <stage>', 'staging, production, or prN')
579
+ .option('--config <path>', 'resolve the worker name from a build-generated flat wrangler.json')
580
+ .option('--refresh', 'ask Cloudflare even when a fresh cached answer exists')
581
+ .action(async (options: { stage: string; config?: string; refresh?: boolean }) => {
582
+ const report = await deployedVersion(process.cwd(), {
583
+ stage: options.stage,
584
+ ...(options.config ? { config: resolve(options.config) } : {}),
585
+ ...(options.refresh ? { refresh: true } : {}),
544
586
  });
587
+ // The tag alone on stdout: this is read by humans and by `$(...)`, and a version deployed
588
+ // outside Nx genuinely has no tag, which `untagged` says without pretending to be one.
589
+ console.log(report.versionTag ?? 'untagged');
545
590
  });
546
591
  wrangler
547
592
  .command('cleanup-pr')
@@ -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, string | undefined]> = [];
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, target, mode, stage, selectTag) => {
979
- listCalls.push([target, mode, stage, selectTag]);
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([['deploy', 'run-many', 'pr123', undefined]]);
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, string | undefined]> = [];
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, target, mode, stage, selectTag) => {
1090
- listCalls.push([target, mode, stage, selectTag]);
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([['deploy', 'run-many', 'production', 'production-push-deploy-target']]);
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', () => {
@@ -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 ?? listNxProjectsWithTarget;
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, 'deploy', mode, stage, options.selectTag);
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 listNxProjectsWithTarget(
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', target);
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.`);
@@ -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:axe/minigraf.git'],
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/axe/minigraf.git'],
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
 
@@ -103,13 +103,13 @@ describe('publish workflow definition', () => {
103
103
 
104
104
  it('a private forge release with cross-built Apple targets publishes from one job', () => {
105
105
  const rendered = renderPublishWorkflowYaml({
106
- repoName: 'axe.sc/axe',
106
+ repoName: 'acme/app',
107
107
  actionsProvider: 'forgejo',
108
108
  runsOn: ['nixos-latest-x64', 'self-hosted'],
109
109
  platformTargetGlobs: ['*-macos', '*-linux'],
110
110
  macosPlatformArchitectures: ['arm64'],
111
111
  platformProducer: { kind: 'linux-cross', preflight: 'sh scripts/prepare-macos-sdk.sh', env: { AXE_CROSS: '1' } },
112
- privateNpm: { scope: '@axe.sc', readTokenEnv: 'READ_ENV', publishTokenEnv: 'PUBLISH_ENV' },
112
+ privateNpm: { scope: '@acme', readTokenEnv: 'READ_ENV', publishTokenEnv: 'PUBLISH_ENV' },
113
113
  });
114
114
 
115
115
  // One job: no producer job to hand outputs over from, so no transfer at all.
@@ -202,18 +202,18 @@ describe('publish workflow definition', () => {
202
202
  );
203
203
  const finalJob = platform.slice(platform.indexOf(' publish-on-linux:'));
204
204
 
205
- expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 16 }, (_, index) => index + 1));
206
- expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 19 }, (_, index) => index + 1));
205
+ expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 17 }, (_, index) => index + 1));
206
+ expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 20 }, (_, index) => index + 1));
207
207
  expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 10 }, (_, index) => index + 1));
208
208
  expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
209
- expect(singleJob).toContain('# Step 14\n - name: ๐Ÿท๏ธ Tag release');
210
- expect(singleJob).toContain('# Step 15\n - name: ๐Ÿ“ฆ Publish release (${{ steps.version.outputs.mode }})');
211
- expect(singleJob).toContain('# Step 16\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
212
- expect(linuxCandidate).toContain('# Step 7\n - name: ๐Ÿ”’ Capture candidate release SHA');
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');
213
213
  expect(linuxCandidate).toContain(
214
- '# Step 8\n - name: โœ… Check managed monorepo files (${{ steps.version.outputs.mode }})',
214
+ '# Step 7\n - name: โœ… Check managed monorepo files (${{ steps.plan.outputs.mode }})',
215
215
  );
216
- expect(linuxCandidate).toContain('# Step 19\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
216
+ expect(linuxCandidate).toContain('# Step 20\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
217
217
  expect(macosPlatform).toContain('# Step 6\n - name: ๐Ÿ”ข Version release');
218
218
  expect(macosPlatform).toContain('# Step 7\n - name: ๐ŸŽ Build selected macOS and iOS release outputs');
219
219
  expect(macosPlatform).toContain('# Step 10\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
@@ -311,14 +311,22 @@ describe('publish workflow definition', () => {
311
311
  expect(singleJob.match(/- name: ๐Ÿท๏ธ Tag release/g)).toHaveLength(1);
312
312
  expect(singleJob).toContain(' run: smoo release tag --dry-run "${{ inputs.dry_run }}"');
313
313
  expect(singleJob.indexOf('- name: ๐Ÿงฏ Repair pending releases')).toBeLessThan(
314
- singleJob.indexOf('- name: ๐Ÿ”ข Version release'),
314
+ singleJob.indexOf('- name: ๐Ÿงญ Plan release'),
315
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'));
316
321
  expect(singleJob.indexOf('- name: ๐Ÿ”ข Version release')).toBeLessThan(singleJob.indexOf('- name: ๐Ÿ”จ Build'));
317
- expect(singleJob.indexOf('- name: ๐Ÿงช Unit Tests')).toBeLessThan(singleJob.indexOf('- name: ๐Ÿท๏ธ Tag release'));
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'");
318
326
  expect(singleJob.indexOf('- name: ๐Ÿท๏ธ Tag release')).toBeLessThan(singleJob.indexOf('- name: ๐Ÿ“ฆ Publish release'));
319
327
  // The Release section starts at tagging now, not at publishing.
320
328
  expect(singleJob).toContain(
321
- '# --- Release ------------------------------------------------------------\n\n # Step 14\n - name: ๐Ÿท๏ธ Tag release',
329
+ '# --- Release ------------------------------------------------------------\n\n # Step 15\n - name: ๐Ÿท๏ธ Tag release',
322
330
  );
323
331
  });
324
332
 
@@ -919,6 +927,8 @@ class WorkflowScenarioState {
919
927
  tests: [],
920
928
  validates: 0,
921
929
  };
930
+ planned = false;
931
+ gatesBeforeVersion: { lints: string[]; tests: string[] } = { lints: [], tests: [] };
922
932
 
923
933
  constructor(private readonly config: WorkflowScenarioConfig) {
924
934
  for (const gap of [...config.repairs, ...config.current]) {
@@ -960,8 +970,16 @@ class WorkflowScenarioState {
960
970
  this.repaired.add(gap.tag);
961
971
  }
962
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
+ },
963
980
  versionRelease: async ({ bump, dryRun }) => {
964
981
  this.versionObservedNxVersionActions = this.nxVersionActions;
982
+ this.gatesBeforeVersion = { lints: [...this.validationState.lints], tests: [...this.validationState.tests] };
965
983
  expect(bump).toBe(this.config.bump);
966
984
  expect(dryRun).toBe(this.config.dryRun);
967
985
  return this.config.version;
@@ -1271,8 +1289,8 @@ it('keeps job-local step anchors contiguous once Cargo credentials add a setup s
1271
1289
 
1272
1290
  // Each job gains exactly one step over the credential-free counts, and the
1273
1291
  // hand-numbered platform renderers must renumber with it.
1274
- expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 17 }, (_, index) => index + 1));
1275
- expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 20 }, (_, index) => index + 1));
1292
+ expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 18 }, (_, index) => index + 1));
1293
+ expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 21 }, (_, index) => index + 1));
1276
1294
  expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 11 }, (_, index) => index + 1));
1277
1295
  expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
1278
1296
  });
@@ -156,12 +156,21 @@ permissions:
156
156
  ${options.deploy ? ' deployments: write\n' : ''} statuses: write
157
157
 
158
158
  concurrency:
159
- # One in-flight run per ref. Pushes to the staging push branch queue behind a
160
- # running workflow instead of canceling it, so a newer push never cancels a
161
- # production deployment mid-flight. Pull requests and other branches keep
162
- # canceling superseded runs.
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: \${{ github.ref != ${stagingRefLiteral(options)} }}
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))} run: smoo github-ci nx-deploy --stage production --mode run-many --select-tag ${PRODUCTION_PUSH_DEPLOY_TAG} --name "Deploy Production" --step ${numbers.middle}
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
  }
@@ -578,7 +578,7 @@ describe('release platform families', () => {
578
578
  it('excluding every Apple family renders the single-job Linux publish shape', () => {
579
579
  const globs = releasePlatformTargetGlobsFor(['*-macos', '*-linux'], ['*-macos']);
580
580
  const rendered = renderPublishWorkflowYaml({
581
- repoName: 'axe.sc/axe',
581
+ repoName: 'acme/app',
582
582
  platformTargetGlobs: globs,
583
583
  macosPlatformArchitectures: [],
584
584
  runsOn: ['nixos-latest-x64', 'self-hosted'],