diff --git a/generators/cli/changes/unreleased/drop-third-party-rust-setup-action.yml b/generators/cli/changes/unreleased/drop-third-party-rust-setup-action.yml new file mode 100644 index 000000000000..61db81e9fc51 --- /dev/null +++ b/generators/cli/changes/unreleased/drop-third-party-rust-setup-action.yml @@ -0,0 +1,12 @@ +- summary: | + The generated `ci.yml` no longer uses `actions-rust-lang/setup-rust-toolchain`. + Organizations that restrict GitHub Actions to a first-party allowlist cannot + run third-party actions, and an unrunnable action fails the workflow before + any job starts — leaving no option but to fernignore `ci.yml` and + hand-maintain it, including the npm publish jobs. The toolchain is now taken + from the runner (with a rustup fallback for containers and self-hosted + runners), matching the step cargo-dist emits into `release.yml`. Caching came + from that action wrapping `Swatinem/rust-cache`, so the cargo registry and + `target/` are now cached with `actions/cache` instead — keyed per rust target, + since the publish matrix cross-compiles five of them. + type: fix diff --git a/generators/cli/changes/unreleased/test-generated-crates.yml b/generators/cli/changes/unreleased/test-generated-crates.yml new file mode 100644 index 000000000000..baddb02a5caf --- /dev/null +++ b/generators/cli/changes/unreleased/test-generated-crates.yml @@ -0,0 +1,8 @@ +- summary: | + The generated `ci.yml` `test` job now runs the generated model and SDK + crates' tests. Those crates are path dependencies of the CLI crate rather + than workspace members, so `cargo test` never built their test targets — + the serialization tests shipped with every generated CLI had never run. + Each crate is now tested by `--manifest-path`, including the nested type + partition crates. + type: fix diff --git a/generators/cli/src/__test__/emitPublishWorkflow.test.ts b/generators/cli/src/__test__/emitPublishWorkflow.test.ts index a4a515f198e5..1afd191aa84c 100644 --- a/generators/cli/src/__test__/emitPublishWorkflow.test.ts +++ b/generators/cli/src/__test__/emitPublishWorkflow.test.ts @@ -27,9 +27,10 @@ describe("emitPublishWorkflow", () => { async function emitAndRead( npmPublishInfo: ResolvedNpmPublishInfo, binaryName = "acme", - repoUrl: string | undefined = undefined + repoUrl: string | undefined = undefined, + generatedCrateDirs: readonly string[] = [] ): Promise { - await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo, repoUrl }); + await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo, repoUrl, generatedCrateDirs }); return readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8"); } @@ -58,6 +59,31 @@ describe("emitPublishWorkflow", () => { expect(yaml).toContain('node-version: "lts/Krypton"'); }); + // `target/` holds per-triple artifacts, so the cross-compiling publish + // legs must not share one cache entry with each other or with the + // host-target build jobs. + it("keys the publish matrix cache per rust target", async () => { + const yaml = await emitAndRead(baseInfo); + + expect(yaml).toContain( + "key: cargo-${{ runner.os }}-${{ matrix.rust-target }}-${{ hashFiles('**/Cargo.lock') }}" + ); + }); + + // The generated crates are path dependencies, not workspace members, so + // plain `cargo test` never builds their test targets. + it("tests the generated crates by manifest path", async () => { + const yaml = await emitAndRead(baseInfo, "acme", undefined, [ + "acme-types", + "acme-sdk", + "acme-types/crates/acme-types-users" + ]); + + expect(yaml).toContain("run: cargo test --manifest-path acme-types/Cargo.toml"); + expect(yaml).toContain("run: cargo test --manifest-path acme-sdk/Cargo.toml"); + expect(yaml).toContain("run: cargo test --manifest-path acme-types/crates/acme-types-users/Cargo.toml"); + }); + it("includes backport detection for stable releases", async () => { const yaml = await emitAndRead(baseInfo); @@ -264,8 +290,8 @@ describe("emitCiWorkflow", () => { await rm(tmpDir, { recursive: true, force: true }); }); - async function emitAndRead(binaryName = "acme"): Promise { - await emitCiWorkflow({ outputDir, binaryName }); + async function emitAndRead(binaryName = "acme", generatedCrateDirs: readonly string[] = []): Promise { + await emitCiWorkflow({ outputDir, binaryName, generatedCrateDirs }); return readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8"); } @@ -295,10 +321,36 @@ describe("emitCiWorkflow", () => { expect(yaml).toContain("on: [push]"); }); - it("uses actions/checkout@v6 and actions-rust-lang/setup-rust-toolchain@v1", async () => { + it("uses actions/checkout@v6 and no third-party actions", async () => { const yaml = await emitAndRead(); expect(yaml).toContain("actions/checkout@v6"); - expect(yaml).toContain("actions-rust-lang/setup-rust-toolchain@v1"); + // Organizations that allowlist first-party actions only cannot run + // third-party ones, and an unrunnable action fails the workflow before + // any job starts. + expect(yaml).not.toContain("actions-rust-lang/"); + expect(yaml).toContain("https://sh.rustup.rs"); + }); + + it("tests the generated crates by manifest path", async () => { + const yaml = await emitAndRead("acme", ["acme-types", "acme-sdk"]); + + expect(yaml).toContain("run: cargo test --manifest-path acme-types/Cargo.toml"); + expect(yaml).toContain("run: cargo test --manifest-path acme-sdk/Cargo.toml"); + }); + + it("omits generated-crate test steps when there are none", async () => { + const yaml = await emitAndRead(); + + expect(yaml).not.toContain("--manifest-path"); + }); + + it("caches the cargo registry and target dir with actions/cache", async () => { + const yaml = await emitAndRead(); + + expect(yaml).toContain("uses: actions/cache@v4"); + expect(yaml).toContain("key: cargo-${{ runner.os }}-check-${{ hashFiles('**/Cargo.lock') }}"); + expect(yaml).toContain("restore-keys:"); + expect(yaml).not.toContain("Swatinem/"); }); }); diff --git a/generators/cli/src/emitPublishWorkflow.ts b/generators/cli/src/emitPublishWorkflow.ts index 402d24d58dbe..3e3c168c3de2 100644 --- a/generators/cli/src/emitPublishWorkflow.ts +++ b/generators/cli/src/emitPublishWorkflow.ts @@ -25,11 +25,15 @@ const TARGETS: ReadonlyArray<{ * publish info is configured. * */ -export async function emitCiWorkflow(args: { outputDir: string; binaryName: string }): Promise { - const { outputDir, binaryName } = args; +export async function emitCiWorkflow(args: { + outputDir: string; + binaryName: string; + generatedCrateDirs?: readonly string[]; +}): Promise { + const { outputDir, binaryName, generatedCrateDirs = [] } = args; const workflowsDir = path.join(outputDir, ".github", "workflows"); await mkdir(workflowsDir, { recursive: true }); - const yaml = constructBuildTestYaml({ binaryName }); + const yaml = constructBuildTestYaml({ binaryName, generatedCrateDirs }); await writeFile(path.join(workflowsDir, "ci.yml"), yaml); } @@ -55,19 +59,89 @@ export async function emitPublishWorkflow(args: { binaryName: string; npmPublishInfo: ResolvedNpmPublishInfo; repoUrl: string | undefined; + generatedCrateDirs?: readonly string[]; }): Promise { - const { outputDir, binaryName, npmPublishInfo, repoUrl } = args; + const { outputDir, binaryName, npmPublishInfo, repoUrl, generatedCrateDirs = [] } = args; const workflowsDir = path.join(outputDir, ".github", "workflows"); await mkdir(workflowsDir, { recursive: true }); - const yaml = constructWorkflowYaml({ binaryName, npmPublishInfo, repoUrl }); + const yaml = constructWorkflowYaml({ binaryName, npmPublishInfo, repoUrl, generatedCrateDirs }); await writeFile(path.join(workflowsDir, "ci.yml"), yaml); } +/** + * Install a Rust toolchain without a third-party action. + * + * `actions-rust-lang/setup-rust-toolchain` is the obvious choice here, but + * organizations that restrict GitHub Actions to a first-party allowlist + * cannot run it — the workflow fails at startup, before any job, which + * leaves the customer no option but to take `ci.yml` out of generation and + * hand-maintain it. GitHub-hosted runners ship a stable toolchain already, + * so the step is a no-op there; the rustup fallback covers containers and + * self-hosted runners. Same shape as the equivalent step cargo-dist emits + * into `release.yml`, which is why that file needs no allowlist exception. + */ +const RUST_SETUP_STEP = ` - name: Set up Rust + shell: bash + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi +`; + +/** + * Cache the cargo registry and the build directory between runs. + * + * `actions-rust-lang/setup-rust-toolchain` did this implicitly by wrapping + * `Swatinem/rust-cache`; both are third-party, so caching is done here with + * `actions/cache` instead. `restore-keys` gives a prefix match when + * `Cargo.lock` changes, so a dependency bump still starts from the previous + * run's artifacts rather than from nothing. + * + * `target/` holds per-triple artifacts, hence `cacheKeySuffix` — the publish + * matrix passes its target so the five cross-compiles don't fight over one + * cache entry. + */ +function rustCacheStep(cacheKeySuffix: string): string { + return ` - name: Cache cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: cargo-\${{ runner.os }}-${cacheKeySuffix}-\${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + cargo-\${{ runner.os }}-${cacheKeySuffix}- +`; +} + +/** + * `cargo test` steps for the generated model/SDK crates. + * + * Those crates are path dependencies of the CLI crate, not workspace members, + * and `cargo test` only builds test targets for the packages it is invoked on + * — so the generated serialization tests never run without this. Each crate + * needs its own `--manifest-path`, including the nested type partitions, which + * are path dependencies of the types facade rather than members of it. + */ +function generatedCrateTestSteps(generatedCrateDirs: readonly string[]): string { + return generatedCrateDirs + .map((crateDir) => { + const manifestPath = crateDir.split(path.sep).join("/"); + return ` + - name: Test ${manifestPath} + run: cargo test --manifest-path ${manifestPath}/Cargo.toml`; + }) + .join(""); +} + /** * Build+test-only workflow YAML — the `check`, `compile`, and `test` * jobs with no publish steps. */ -function constructBuildTestYaml(args: { binaryName: string }): string { +function constructBuildTestYaml(args: { binaryName: string; generatedCrateDirs: readonly string[] }): string { return `name: ci on: [push] @@ -86,9 +160,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("check")} - name: Check run: cargo check @@ -98,9 +171,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("compile")} - name: Compile run: cargo build @@ -110,11 +182,10 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("test")} - name: Test - run: cargo test + run: cargo test${generatedCrateTestSteps(args.generatedCrateDirs)} `; } @@ -122,6 +193,7 @@ function constructWorkflowYaml(args: { binaryName: string; npmPublishInfo: ResolvedNpmPublishInfo; repoUrl: string | undefined; + generatedCrateDirs: readonly string[]; }): string { const { binaryName, npmPublishInfo, repoUrl } = args; const { useOidc } = npmPublishInfo; @@ -170,9 +242,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("check")} - name: Check run: cargo check @@ -182,9 +253,8 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("compile")} - name: Compile run: cargo build @@ -194,11 +264,10 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} +${rustCacheStep("test")} - name: Test - run: cargo test + run: cargo test${generatedCrateTestSteps(args.generatedCrateDirs)} # The npm packages take their version from the release tag, while the # binary reports the version in Cargo.toml. Publishing when they disagree @@ -212,9 +281,7 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - +${RUST_SETUP_STEP} - name: Check tag matches crate version shell: bash run: | @@ -256,11 +323,12 @@ ${matrixIncludes} - name: Checkout repo uses: actions/checkout@v6 - - name: Set up Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - target: \${{ matrix.rust-target }} +${RUST_SETUP_STEP} + - name: Add Rust target + shell: bash + run: rustup target add \${{ matrix.rust-target }} +${rustCacheStep("${{ matrix.rust-target }}")} - name: Set up Node.js uses: actions/setup-node@v6 with: diff --git a/generators/cli/src/runPipeline.ts b/generators/cli/src/runPipeline.ts index 7835ede99fe8..df0b69956416 100644 --- a/generators/cli/src/runPipeline.ts +++ b/generators/cli/src/runPipeline.ts @@ -249,15 +249,24 @@ export async function runPipeline(args: { } if (outputConfig.isGithubOutput) { + // The generated crates are path dependencies rather than workspace + // members, so the `test` job has to name each manifest for its tests + // to run at all. + const generatedCrateDirs = [ + typesCrateName, + sdkCrateName, + ...typePartitionCrates.map(({ relativeDir }) => relativeDir) + ].filter((crateDir): crateDir is string => crateDir != null); if (outputConfig.npmPublishInfo != null) { await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo: outputConfig.npmPublishInfo, - repoUrl: outputConfig.repoUrl + repoUrl: outputConfig.repoUrl, + generatedCrateDirs }); } else { - await emitCiWorkflow({ outputDir, binaryName }); + await emitCiWorkflow({ outputDir, binaryName, generatedCrateDirs }); } // Emit cargo-dist release workflow unconditionally for GitHub output. // This provides curl|bash installation via GitHub Release assets