Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions generators/cli/changes/unreleased/test-generated-crates.yml
Original file line number Diff line number Diff line change
@@ -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
64 changes: 58 additions & 6 deletions generators/cli/src/__test__/emitPublishWorkflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo, repoUrl });
await emitPublishWorkflow({ outputDir, binaryName, npmPublishInfo, repoUrl, generatedCrateDirs });
return readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8");
}

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -264,8 +290,8 @@ describe("emitCiWorkflow", () => {
await rm(tmpDir, { recursive: true, force: true });
});

async function emitAndRead(binaryName = "acme"): Promise<string> {
await emitCiWorkflow({ outputDir, binaryName });
async function emitAndRead(binaryName = "acme", generatedCrateDirs: readonly string[] = []): Promise<string> {
await emitCiWorkflow({ outputDir, binaryName, generatedCrateDirs });
return readFile(path.join(outputDir, ".github", "workflows", "ci.yml"), "utf-8");
}

Expand Down Expand Up @@ -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/");
});
});
134 changes: 101 additions & 33 deletions generators/cli/src/emitPublishWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ const TARGETS: ReadonlyArray<{
* publish info is configured.
*
*/
export async function emitCiWorkflow(args: { outputDir: string; binaryName: string }): Promise<void> {
const { outputDir, binaryName } = args;
export async function emitCiWorkflow(args: {
outputDir: string;
binaryName: string;
generatedCrateDirs?: readonly string[];
}): Promise<void> {
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);
}

Expand All @@ -55,19 +59,89 @@ export async function emitPublishWorkflow(args: {
binaryName: string;
npmPublishInfo: ResolvedNpmPublishInfo;
repoUrl: string | undefined;
generatedCrateDirs?: readonly string[];
}): Promise<void> {
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]
Expand All @@ -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

Expand All @@ -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

Expand All @@ -110,18 +182,18 @@ 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)}
`;
}

function constructWorkflowYaml(args: {
binaryName: string;
npmPublishInfo: ResolvedNpmPublishInfo;
repoUrl: string | undefined;
generatedCrateDirs: readonly string[];
}): string {
const { binaryName, npmPublishInfo, repoUrl } = args;
const { useOidc } = npmPublishInfo;
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand All @@ -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: |
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 11 additions & 2 deletions generators/cli/src/runPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down