@smoothbricks/cli 0.11.17 โ†’ 0.11.19

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 (60) hide show
  1. package/README.md +45 -10
  2. package/dist/cli.js +10 -7
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +6 -20
  5. package/dist/lib/deploy-tags.d.ts +17 -3
  6. package/dist/lib/deploy-tags.d.ts.map +1 -1
  7. package/dist/lib/deploy-tags.js +23 -4
  8. package/dist/monorepo/ci-workflow.d.ts +56 -0
  9. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  10. package/dist/monorepo/ci-workflow.js +199 -2
  11. package/dist/monorepo/managed-files.d.ts +28 -2
  12. package/dist/monorepo/managed-files.d.ts.map +1 -1
  13. package/dist/monorepo/managed-files.js +74 -22
  14. package/dist/release/github-release.d.ts +10 -0
  15. package/dist/release/github-release.d.ts.map +1 -1
  16. package/dist/release/github-release.js +11 -5
  17. package/dist/release/index.d.ts.map +1 -1
  18. package/dist/release/index.js +4 -5
  19. package/dist/secrets/commands.d.ts +52 -13
  20. package/dist/secrets/commands.d.ts.map +1 -1
  21. package/dist/secrets/commands.js +220 -34
  22. package/dist/secrets/index.d.ts +21 -1
  23. package/dist/secrets/index.d.ts.map +1 -1
  24. package/dist/secrets/index.js +110 -4
  25. package/dist/secrets/repository.d.ts +60 -0
  26. package/dist/secrets/repository.d.ts.map +1 -0
  27. package/dist/secrets/repository.js +145 -0
  28. package/dist/wrangler/cloudflare.d.ts +7 -0
  29. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  30. package/dist/wrangler/cloudflare.js +10 -0
  31. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  32. package/dist/wrangler/deploy-stage.js +50 -24
  33. package/dist/wrangler/stage-secrets.d.ts +57 -0
  34. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  35. package/dist/wrangler/stage-secrets.js +178 -0
  36. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  37. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  38. package/package.json +2 -2
  39. package/src/cli.ts +33 -10
  40. package/src/github-ci/index.test.ts +6 -7
  41. package/src/github-ci/index.ts +11 -19
  42. package/src/lib/deploy-tags.ts +22 -4
  43. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  44. package/src/monorepo/ci-workflow.ts +277 -2
  45. package/src/monorepo/managed-files.test.ts +96 -37
  46. package/src/monorepo/managed-files.ts +94 -23
  47. package/src/monorepo/package-policy.test.ts +1 -1
  48. package/src/release/__tests__/github-release.test.ts +8 -4
  49. package/src/release/github-release.ts +13 -5
  50. package/src/release/index.ts +4 -4
  51. package/src/secrets/commands.test.ts +28 -0
  52. package/src/secrets/commands.ts +237 -35
  53. package/src/secrets/index.test.ts +118 -1
  54. package/src/secrets/index.ts +108 -4
  55. package/src/secrets/repository.test.ts +98 -0
  56. package/src/secrets/repository.ts +164 -0
  57. package/src/wrangler/cloudflare.ts +18 -0
  58. package/src/wrangler/deploy-stage.test.ts +258 -11
  59. package/src/wrangler/deploy-stage.ts +70 -38
  60. package/src/wrangler/stage-secrets.ts +146 -0
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # macOS-only Linux compile gate. Everything in tooling depends only on
3
- # devenv.nix packages, so this hook must also work without devenv on PATH โ€”
4
- # but it may not pretend a check ran that never did. Nx task hashes can
5
- # differ between the bare shell and the linux-cross profile, so a bare miss
6
- # proves nothing on its own. Policy, in order:
7
- # 1. Bare `nx run-many -t cargo-lint-cross`: a hit is a prior real
8
- # `cargo clippy --target x86_64-unknown-linux-gnu`; pass with no toolchain.
9
- # 2. If a Nix-built devenv is on PATH, `bun run check:linux` enters the
10
- # linux-cross profile properly and its result decides the push.
11
- # 3. Otherwise refuse with the recovery command instead of a toolchain error
12
-
13
- # A Nix-built devenv binary (under /nix/store), skipping the repo wrapper
14
- # scripts that also answer to this name but cannot enter a profile alone.
15
- has_nix_devenv() {
16
- while IFS= read -r candidate; do
17
- case "$(realpath "$candidate" 2>/dev/null)" in
18
- /nix/store/*) return 0 ;;
19
- esac
20
- done < <(which -a devenv 2>/dev/null)
21
- return 1
22
- }
2
+ # macOS-only Linux cross-compile gate. This hook is a cache PROBE and nothing
3
+ # else: it reads the nx cache and never compiles. A hit means a real
4
+ # `cargo clippy --target x86_64-unknown-linux-gnu` already passed for exactly
5
+ # this tree, so the push is safe with no toolchain present. Anything else
6
+ # refuses the push and names the one command that fixes it.
7
+ #
8
+ # Why probe-only. The cross-clippy needs the linux-cross C toolchain, which
9
+ # lives in a devenv profile. A hook that entered that profile spent minutes
10
+ # compiling on a push the user expected to take a second, and it did so in an
11
+ # environment they never asked for. Pushing is not the place to discover that
12
+ # the tree has not been compiled for Linux; `bun run check:linux` is.
13
+ #
14
+ # CC_x86_64_unknown_linux_gnu is unset for the probe deliberately. The target's
15
+ # own guard reads it to decide whether the toolchain is present, so leaving it
16
+ # set would let a push made from inside an already-entered linux-cross shell
17
+ # fall through into a real multi-minute compile. It is not a declared input of
18
+ # the target, so unsetting it cannot change the task hash โ€” a warm entry still
19
+ # hits.
20
+ #
21
+ # Everything in tooling depends only on devenv.nix packages, so this hook also
22
+ # works with no devenv on PATH. It must never pretend a check ran that did not.
23
23
 
24
24
  cd "$(git rev-parse --show-toplevel)"
25
25
  TOOLING="$PWD/tooling"
@@ -31,17 +31,18 @@ case "$(uname -s)" in
31
31
  *) exit 0 ;;
32
32
  esac
33
33
 
34
- if nx run-many -t cargo-lint-cross; then
34
+ if env -u CC_x86_64_unknown_linux_gnu nx run-many -t cargo-lint-cross --output-style=static; then
35
35
  exit 0
36
36
  fi
37
37
 
38
- if has_nix_devenv; then
39
- exec bun run check:linux
40
- fi
41
-
42
38
  cat >&2 <<'EOF'
43
- pre-push: cargo-lint-cross is not cached for this tree and no Nix-built
44
- devenv is on PATH to enter the linux-cross profile. Run `bun run check:linux`
45
- from a shell with nix on PATH, wait for it to pass, then push again.
39
+
40
+ pre-push: the Linux cross-compile check is NOT cached for this tree, so this
41
+ push would ship code that has never been compiled for Linux. This hook only
42
+ reads the cache; it does not build. The "needs the linux-cross C toolchain"
43
+ line above is the probe refusing to compile, not a broken toolchain.
44
+
45
+ Run: bun run check:linux
46
+ Wait for it to pass, then push again.
46
47
  EOF
47
48
  exit 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.17",
3
+ "version": "0.11.19",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  ],
65
65
  "dependencies": {
66
66
  "@arethetypeswrong/core": "^0.18.2",
67
- "@smoothbricks/nx-plugin": "0.4.11",
67
+ "@smoothbricks/nx-plugin": "0.4.13",
68
68
  "@smoothbricks/validation": "0.1.8",
69
69
  "commander": "^14.0.3",
70
70
  "make-synchronized": "^0.8.0",
package/src/cli.ts CHANGED
@@ -532,23 +532,46 @@ function buildProgram(): Command {
532
532
  .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
533
533
  secrets
534
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 }) => {
535
+ .description(
536
+ 'Show every declared secret with the scopes holding it, and refuse when a workflow passes one none has',
537
+ )
538
+ .option(
539
+ '-R, --repo <owner/name|remote>',
540
+ "repository or remote name; defaults to the current branch's upstream remote",
541
+ )
542
+ .option(
543
+ '--env <environment>',
544
+ "also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
545
+ )
546
+ .action(async (options: { repo?: string; env?: string }) => {
538
547
  process.exitCode = secretsStatus(await findRepoRoot(), options);
539
548
  });
540
549
  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);
550
+ .command('set [name]')
551
+ .description('Set secrets from pasted values; with no name, prompts for every secret the target scope still lacks')
552
+ .option(
553
+ '-R, --repo <owner/name|remote>',
554
+ "repository or remote name; defaults to the current branch's upstream remote",
555
+ )
556
+ .option(
557
+ '--env <environment>',
558
+ "write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
559
+ )
560
+ .action(async (name: string | undefined, options: { repo?: string; env?: string }) => {
561
+ process.exitCode = await secretsSet(await findRepoRoot(), name, options);
546
562
  });
547
563
  secrets
548
564
  .command('sync')
549
565
  .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 }) => {
566
+ .option(
567
+ '-R, --repo <owner/name|remote>',
568
+ "repository or remote name; defaults to the current branch's upstream remote",
569
+ )
570
+ .option(
571
+ '--env <environment>',
572
+ "write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
573
+ )
574
+ .action(async (options: { repo?: string; env?: string }) => {
552
575
  process.exitCode = await secretsSync(await findRepoRoot(), options);
553
576
  });
554
577
 
@@ -847,8 +847,11 @@ describe('event-aware stage deployment', () => {
847
847
  ).toThrow(/same-repository/);
848
848
  });
849
849
 
850
- it('selects stage-derived deploy targets plus staging-only infrastructure on staging', async () => {
850
+ it('selects tagged stage projects plus staging-only infrastructure, and never an untagged one', async () => {
851
851
  const definitions: Record<string, unknown> = {
852
+ // A private wrangler project the plugin gave a deploy target and nobody tagged: deployable
853
+ // by hand, invisible to CI. Reading the command instead deployed whatever held a wrangler
854
+ // manifest, including a published library that ships one to document a binding.
852
855
  app: {
853
856
  targets: {
854
857
  deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
@@ -875,18 +878,15 @@ describe('event-aware stage deployment', () => {
875
878
  const loadProject = async (project: string) => definitions[project];
876
879
 
877
880
  await expect(selectStageDeployProjects(candidates, 'staging', undefined, loadProject)).resolves.toEqual([
878
- 'app',
879
881
  'app-backend',
880
882
  'e2e-mail-capture',
881
883
  'website',
882
884
  ]);
883
885
  await expect(selectStageDeployProjects(candidates, 'pr123', undefined, loadProject)).resolves.toEqual([
884
- 'app',
885
886
  'app-backend',
886
887
  'website',
887
888
  ]);
888
889
  await expect(selectStageDeployProjects(candidates, 'production', undefined, loadProject)).resolves.toEqual([
889
- 'app',
890
890
  'app-backend',
891
891
  'website',
892
892
  ]);
@@ -1303,15 +1303,14 @@ describe('selectStageDeployProjects with a required tag', () => {
1303
1303
  };
1304
1304
  const loadProject = async (project: string) => definitions[project];
1305
1305
 
1306
- it('keeps only projects carrying the tag, on top of the stage-derived rule', async () => {
1306
+ it('keeps only projects carrying the tag, on top of the stage rules', async () => {
1307
1307
  await expect(
1308
1308
  selectStageDeployProjects(Object.keys(definitions), 'production', 'production-push-deploy-target', loadProject),
1309
1309
  ).resolves.toEqual(['website']);
1310
1310
  });
1311
1311
 
1312
- it('selects the website on every stage once it is stage-derived', async () => {
1312
+ it('selects only the tagged project on every stage, required tag or not', async () => {
1313
1313
  await expect(selectStageDeployProjects(Object.keys(definitions), 'pr12', undefined, loadProject)).resolves.toEqual([
1314
- 'app',
1315
1314
  'website',
1316
1315
  ]);
1317
1316
  });
@@ -4,7 +4,7 @@ import { appendFile } from 'node:fs/promises';
4
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
5
5
  import { $ } from 'bun';
6
6
  import typia from 'typia';
7
- import { isStageDerivedDeploy, PERMANENT_DEPLOY_TAG, STAGING_DEPLOY_TAG } from '../lib/deploy-tags.js';
7
+ import { PERMANENT_DEPLOY_TAG, STAGING_DEPLOY_TAG, stageDeploysProject } from '../lib/deploy-tags.js';
8
8
  import {
9
9
  ciPushBranches,
10
10
  isNonEmpty,
@@ -35,16 +35,14 @@ export interface GithubActionsEventPayload {
35
35
  };
36
36
  }
37
37
 
38
+ /**
39
+ * What a deploy candidate is READ for: its tags. `nx show projects --withTarget
40
+ * deploy` already established the target, and the tags decide the rest, so a
41
+ * project whose `tags` is not a string array fails the parse instead of being
42
+ * silently untagged.
43
+ */
38
44
  interface NxProjectDeployTarget {
39
- tags?: unknown;
40
- targets?: {
41
- deploy?: {
42
- command?: unknown;
43
- options?: {
44
- command?: unknown;
45
- };
46
- };
47
- };
45
+ tags?: string[];
48
46
  }
49
47
 
50
48
  const parseGithubActionsEvent = typia.json.createIsParse<GithubActionsEventPayload>();
@@ -570,16 +568,10 @@ export async function selectStageDeployProjects(
570
568
  for (const project of candidates) {
571
569
  const definition = await loadProject(project);
572
570
  if (!isNxProjectDeployTarget(definition)) continue;
573
- const deploy = definition.targets?.deploy;
574
- const commandValue = deploy?.options?.command ?? deploy?.command;
575
- const tags = Array.isArray(definition.tags)
576
- ? definition.tags.filter((tag): tag is string => typeof tag === 'string')
577
- : [];
578
- const isStageDerived = isStageDerivedDeploy(tags, typeof commandValue === 'string' ? commandValue : undefined);
579
- const isStagingOnly = tags.includes(STAGING_DEPLOY_TAG);
571
+ const tags = definition.tags;
580
572
  // A required tag narrows the stage rules; it never selects a project they exclude.
581
- if (requireTag && !tags.includes(requireTag)) continue;
582
- if (isStageDerived || (stage === 'staging' && isStagingOnly)) {
573
+ if (requireTag && !tags?.includes(requireTag)) continue;
574
+ if (stageDeploysProject(tags, stage)) {
583
575
  selected.push(project);
584
576
  }
585
577
  }
@@ -1,6 +1,8 @@
1
1
  // The Nx tags that drive stage deploys. CI selection (github-ci), workflow generation (monorepo)
2
2
  // and the graph readers must agree on these, so they live here rather than with any one of them.
3
3
 
4
+ import type { DeploymentStage } from '../wrangler/stage.js';
5
+
4
6
  /** Deployed on every stage: one command that deploys whichever stage it is given. */
5
7
  const STAGE_DEPLOY_TAG = 'stage-deploy-target';
6
8
 
@@ -14,9 +16,25 @@ export const PERMANENT_DEPLOY_TAG = 'permanent-deploy-target';
14
16
  export const PRODUCTION_PUSH_DEPLOY_TAG = 'production-push-deploy-target';
15
17
 
16
18
  /**
17
- * Whether a deploy target is stage-derived (the tag, or the `smoo wrangler deploy-stage` command) rather than a set of
18
- * per-stage configurations; CI selection and workflow generation must agree on this.
19
+ * Whether the stage flow deploys a project, from its TAGS alone โ€” the one rule
20
+ * CI selection and workflow generation both read, so a generated deploy job and
21
+ * the projects that job deploys cannot disagree.
22
+ *
23
+ * Having a `deploy` target is not that answer. A deploy target says a project
24
+ * CAN be deployed; only a tag says CI does it. A published library ships a
25
+ * wrangler manifest to document a Durable Object binding for its consumers, and
26
+ * that manifest is structurally identical to a deployable worker's โ€” so the
27
+ * plugin infers `deploy` from one, and a repository that deploys nothing from CI
28
+ * would otherwise generate a deploy job, with cloud credentials, for it.
29
+ *
30
+ * The three tags compose as precedence, not as a set: `permanent` excludes a
31
+ * project from every stage, `staging` narrows it to one, `stage` generalizes it
32
+ * to all. `production-push` selects WITHIN this answer (see `--select-tag`) and
33
+ * never widens it.
19
34
  */
20
- export function isStageDerivedDeploy(tags: string[] | undefined, command: string | undefined): boolean {
21
- return tags?.includes(STAGE_DEPLOY_TAG) === true || command?.includes('smoo wrangler deploy-stage') === true;
35
+ export function stageDeploysProject(tags: string[] | undefined, stage: DeploymentStage): boolean {
36
+ if (tags === undefined) return false;
37
+ if (tags.includes(PERMANENT_DEPLOY_TAG)) return false;
38
+ if (tags.includes(STAGING_DEPLOY_TAG)) return stage === 'staging';
39
+ return tags.includes(STAGE_DEPLOY_TAG);
22
40
  }
@@ -7,6 +7,7 @@ import { readFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { format } from 'prettier';
10
+ import typia from 'typia';
10
11
  import type { PackageCargoGitOrigin } from '../../lib/json.js';
11
12
  import {
12
13
  type CiWorkflowDefinitionOptions,
@@ -898,3 +899,163 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
898
899
  expect(e2eJob).not.toContain('CARGO_REGISTRIES_EXAMPLE_TOKEN');
899
900
  });
900
901
  });
902
+
903
+ describe('renderCiWorkflowYaml with cross-built test archives', () => {
904
+ const darwin = {
905
+ triple: 'aarch64-apple-darwin',
906
+ path: 'target/nextest/archive-aarch64-apple-darwin.tar.zst',
907
+ };
908
+ const declared = options({
909
+ runsOn: [...nixosRunsOn],
910
+ macosRunsOn: ['macos-arm64', 'self-hosted'],
911
+ crossTestArchives: [darwin],
912
+ });
913
+
914
+ it('renders nothing for a repository that declares no cross archives', () => {
915
+ const bare = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
916
+
917
+ // An empty declaration is the same repository as an absent one: no step, no
918
+ // job, and above all no renumbering of the steps that were already there.
919
+ expect(renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn], crossTestArchives: [] }))).toBe(bare);
920
+ expect(bare).not.toContain('macos-cross-tests');
921
+ expect(bare).not.toContain('cross-target test archives');
922
+ expect(bare).not.toContain('cargo-cross-test');
923
+ });
924
+
925
+ it('builds and uploads each archive in Validate, then executes it in a job that needs Validate', () => {
926
+ const rendered = renderCiWorkflowYaml(declared);
927
+ const workflow = typia.assert<{
928
+ jobs: Record<string, { needs?: string; 'runs-on'?: unknown; 'timeout-minutes'?: number; steps: unknown[] }>;
929
+ }>(Bun.YAML.parse(rendered));
930
+ const validate = workflow.jobs.main;
931
+ const execute = workflow.jobs['macos-cross-tests'];
932
+
933
+ expect(execute?.needs).toBe('main');
934
+ expect(execute?.['runs-on']).toEqual(['macos-arm64', 'self-hosted']);
935
+ expect(execute?.['timeout-minutes']).toBe(30);
936
+ // The Linux job COMPILES the darwin binaries; that is what proves the cross
937
+ // build works at all.
938
+ expect(rendered).toContain(
939
+ 'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-aarch64-apple-darwin"',
940
+ );
941
+ expect(JSON.stringify(validate?.steps)).toContain('cross-test-archives-${{ github.run_id }}');
942
+ // ...and the macOS job only RUNS them: every step is checkout, the shell,
943
+ // the download, the archive run, or the cache save. Nothing invokes cargo,
944
+ // a toolchain install, or an SDK.
945
+ const executeSteps = execute?.steps ?? [];
946
+ expect(executeSteps).toContainEqual({
947
+ name: '๐Ÿงช Cross-Target Unit Tests',
948
+ run: 'smoo github-ci nx-run-many --targets "cargo-cross-test-aarch64-apple-darwin"',
949
+ });
950
+ const executeText = JSON.stringify(executeSteps);
951
+ expect(executeText).toContain('cross-test-archives-${{ github.run_id }}');
952
+ expect(executeText).toContain('actions/download-artifact');
953
+ expect(executeText).not.toContain('cargo ');
954
+ expect(executeText).not.toContain('rustup');
955
+ expect(executeText).not.toContain('cargo-cross-test-archive');
956
+ expect(executeText).not.toContain('--target build');
957
+ expect(executeText).not.toContain('SDK');
958
+ });
959
+
960
+ it('reuses the declared cross producer for the archive step, step-scoped', () => {
961
+ const rendered = renderCiWorkflowYaml(
962
+ options({
963
+ ...declared,
964
+ platformProducer: {
965
+ kind: 'linux-cross',
966
+ preflight: 'sh scripts/prepare-macos-sdk.sh',
967
+ env: { ACME_CROSS: '1', SDKROOT: '${{ runner.temp }}/apple-sdk/MacOSX.sdk' },
968
+ },
969
+ }),
970
+ );
971
+ const validate = rendered.slice(0, rendered.indexOf(' macos-cross-tests:'));
972
+
973
+ expect(validate).toContain('- name: Check cross-platform toolchain prerequisites');
974
+ expect(validate).toContain(' working-directory: .\n');
975
+ expect(validate).toContain(' set -euo pipefail\n sh scripts/prepare-macos-sdk.sh');
976
+ // Step-scoped, so Validate's host builds stay host builds: the pair appears
977
+ // once per cross step and never in the job's own env block.
978
+ expect(validate.match(/ACME_CROSS: "1"/g)).toHaveLength(2);
979
+ const jobEnv = validate.slice(validate.indexOf(' env:'), validate.indexOf(' steps:'));
980
+ expect(jobEnv).not.toContain('ACME_CROSS');
981
+ expect(jobEnv).not.toContain('SDKROOT');
982
+ // A declared producer with no preflight is a mechanism error at render time.
983
+ expect(() =>
984
+ renderCiWorkflowYaml(options({ ...declared, platformProducer: { kind: 'linux-cross', preflight: ' ' } })),
985
+ ).toThrow('nonempty toolchain preflight');
986
+ });
987
+
988
+ it('numbers the execute job download before the run and keeps the cleanup anchor after both', () => {
989
+ const execute = crossTestJob(renderCiWorkflowYaml(declared));
990
+
991
+ // Checkout 2, setup-devenv 3, download 4, run 5, cleanup 6.
992
+ expect(execute).toContain('# Step 4\n - name: ๐Ÿ“ฅ Download cross-target test archives');
993
+ expect(execute).toContain('# Step 5\n - name: ๐Ÿงช Cross-Target Unit Tests');
994
+ expect(execute).toContain('# Step 6');
995
+ expect(execute).toContain('uses: ./.github/actions/save-nix-devenv');
996
+ });
997
+
998
+ it('builds a non-darwin triple without an execution job it has no runner for', () => {
999
+ const rendered = renderCiWorkflowYaml(
1000
+ options({
1001
+ crossTestArchives: [
1002
+ { triple: 'x86_64-unknown-linux-musl', path: 'target/nextest/archive-x86_64-unknown-linux-musl.tar.zst' },
1003
+ ],
1004
+ }),
1005
+ );
1006
+
1007
+ expect(rendered).toContain(
1008
+ 'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-x86_64-unknown-linux-musl"',
1009
+ );
1010
+ expect(rendered).not.toContain('macos-cross-tests');
1011
+ });
1012
+
1013
+ it('downloads a nested cargo workspace archive back to the directory its target writes', () => {
1014
+ const execute = crossTestJob(
1015
+ renderCiWorkflowYaml(
1016
+ options({
1017
+ crossTestArchives: [{ ...darwin, path: `packages/ferris/${darwin.path}` }],
1018
+ }),
1019
+ ),
1020
+ );
1021
+
1022
+ expect(execute).toContain('path: packages/ferris/target/nextest');
1023
+ });
1024
+
1025
+ it('refuses declarations the Nx graph could not have produced, at render time', () => {
1026
+ expect(() =>
1027
+ renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: 'target/nextest/archive.tar.zst' }] })),
1028
+ ).toThrow('must be a repository-relative target/nextest/archive-aarch64-apple-darwin.tar.zst');
1029
+ expect(() =>
1030
+ renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: `../${darwin.path}` }] })),
1031
+ ).toThrow('must stay inside the repository');
1032
+ expect(() =>
1033
+ renderCiWorkflowYaml(
1034
+ options({ crossTestArchives: [{ triple: 'Bad Triple; rm -rf /', path: 'target/nextest/x.tar.zst' }] }),
1035
+ ),
1036
+ ).toThrow('must be a target triple');
1037
+ // Two cargo workspaces cannot share one artifact: upload-artifact roots it
1038
+ // at the least common ancestor, so the restored paths would be wrong for
1039
+ // both. Refuse here rather than on the runner.
1040
+ expect(() =>
1041
+ renderCiWorkflowYaml(
1042
+ options({
1043
+ crossTestArchives: [
1044
+ darwin,
1045
+ { triple: 'x86_64-apple-darwin', path: `packages/ferris/${cargoArchive('x86_64-apple-darwin')}` },
1046
+ ],
1047
+ }),
1048
+ ),
1049
+ ).toThrow('must share one directory');
1050
+ });
1051
+ });
1052
+
1053
+ function crossTestJob(rendered: string): string {
1054
+ const start = rendered.indexOf(' macos-cross-tests:');
1055
+ expect(start).toBeGreaterThan(-1);
1056
+ return rendered.slice(start);
1057
+ }
1058
+
1059
+ function cargoArchive(triple: string): string {
1060
+ return `target/nextest/archive-${triple}.tar.zst`;
1061
+ }