Skip to content

Commit 0f4790b

Browse files
feat(cli): --pack builds a source zip artifact for Go SDKs (#17293)
* feat(cli): --pack builds a source zip artifact for Go SDKs * fix(cli): reject on zip output stream errors during go source packaging
1 parent e668416 commit 0f4790b

5 files changed

Lines changed: 75 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
- summary: |
2+
`fern generate --pack` now produces a source zip artifact for Go SDKs in `fern-dist/`
3+
(`<output-dir>-source.zip`). Go modules have no binary package format, so the zip contains the
4+
module source (excluding `fern-dist/` and `.git/`) and can be shared internally and referenced
5+
with a `replace` directive in `go.mod`. The zip is built in-process, so it works identically in
6+
host and docker pack modes without any toolchain.
7+
type: feat

packages/cli/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
"@types/pngjs": "catalog:",
117117
"@types/semver": "catalog:",
118118
"@types/tar": "catalog:",
119+
"@types/yazl": "catalog:",
119120
"@types/url-join": "4.0.1",
120121
"@types/ws": "catalog:",
121122
"@types/yargs": "^17.0.28",
@@ -144,6 +145,7 @@
144145
"vitest": "catalog:",
145146
"ws": "catalog:",
146147
"yaml": "catalog:",
147-
"yargs": "^17.4.1"
148+
"yargs": "^17.4.1",
149+
"yazl": "catalog:"
148150
}
149151
}

packages/cli/cli/src/commands/generate/__test__/packLocalOutput.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { generatorsYml } from "@fern-api/configuration-loader";
22
import { AbsoluteFilePath } from "@fern-api/fs-utils";
33
import { createMockTaskContext } from "@fern-api/task-context";
4-
import { mkdir, mkdtemp, writeFile } from "fs/promises";
4+
import { mkdir, mkdtemp, readdir, stat, writeFile } from "fs/promises";
55
import { tmpdir } from "os";
66
import path from "path";
77
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -94,7 +94,10 @@ describe("packLocalOutputForGroup", () => {
9494
expect(commands[2]).toContain("npm pack");
9595
});
9696

97-
it("does not run any command for go generators", async () => {
97+
it("zips the module source for go generators without running any toolchain command", async () => {
98+
await writeFile(path.join(outputDir, "go.mod"), "module example.com/test\n");
99+
await mkdir(path.join(outputDir, "client"), { recursive: true });
100+
await writeFile(path.join(outputDir, "client", "client.go"), "package client\n");
98101
const group = {
99102
groupName: "test",
100103
audiences: { type: "all" },
@@ -103,6 +106,10 @@ describe("packLocalOutputForGroup", () => {
103106

104107
await packLocalOutputForGroup({ group, context: createMockTaskContext() });
105108
expect(loggingExecaMock).not.toHaveBeenCalled();
109+
const distFiles = await readdir(path.join(outputDir, "fern-dist"));
110+
expect(distFiles).toEqual([`${path.basename(outputDir)}-source.zip`]);
111+
const zipStat = await stat(path.join(outputDir, "fern-dist", `${path.basename(outputDir)}-source.zip`));
112+
expect(zipStat.size).toBeGreaterThan(0);
106113
});
107114

108115
it("packs the non-test csproj for csharp generators", async () => {

packages/cli/cli/src/commands/generate/packLocalOutput.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { assertNever, ContainerRunner } from "@fern-api/core-utils";
33
import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils";
44
import { loggingExeca } from "@fern-api/logging-execa";
55
import { TaskContext } from "@fern-api/task-context";
6+
import { createWriteStream } from "fs";
67
import { copyFile, mkdir, readdir, readFile, rename, rmdir } from "fs/promises";
8+
import { basename } from "path";
9+
import { ZipFile } from "yazl";
710

811
/** Directory (inside each generator's local output) where packaged artifacts are written. */
912
export const PACK_OUTPUT_DIRECTORY = "fern-dist";
@@ -149,12 +152,19 @@ async function packOutputForLanguage({
149152
logArtifacts({ distDir, context });
150153
return;
151154
}
152-
case "go":
153-
context.logger.warn(
154-
"Go SDKs are distributed as source modules, so there is no package artifact to build. " +
155-
"Share the output directory itself (e.g. as a zip), or reference it with a 'replace' directive in go.mod."
156-
);
155+
case "go": {
156+
// Go modules have no binary package format ('go get' always fetches source), so the
157+
// shareable artifact is a zip of the module source. Consumers unzip it and reference
158+
// it with a 'replace' directive in go.mod.
159+
await mkdir(distDir, { recursive: true });
160+
const zipName = `${basename(outputPath)}-source.zip`;
161+
await zipDirectory({
162+
sourceDir: outputPath,
163+
zipPath: join(distDir, RelativeFilePath.of(zipName))
164+
});
165+
logArtifacts({ distDir, context });
157166
return;
167+
}
158168
case "swift":
159169
context.logger.warn(
160170
"Swift SDKs are distributed as source packages (Swift Package Manager), so there is no package artifact to build. " +
@@ -217,6 +227,41 @@ async function runPackCommands({
217227
}
218228
}
219229

230+
/**
231+
* Zips the contents of a directory (excluding fern-dist itself and any .git directory) into
232+
* `zipPath`. Runs in-process, so it behaves identically in host and docker pack modes and
233+
* requires no language toolchain.
234+
*/
235+
async function zipDirectory({
236+
sourceDir,
237+
zipPath
238+
}: {
239+
sourceDir: AbsoluteFilePath;
240+
zipPath: AbsoluteFilePath;
241+
}): Promise<void> {
242+
const zip = new ZipFile();
243+
const addEntries = async (dir: AbsoluteFilePath, prefix: string): Promise<void> => {
244+
for (const entry of await readdir(dir, { withFileTypes: true })) {
245+
if (prefix === "" && (entry.name === PACK_OUTPUT_DIRECTORY || entry.name === ".git")) {
246+
continue;
247+
}
248+
const entryPath = join(dir, RelativeFilePath.of(entry.name));
249+
const entryName = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
250+
if (entry.isDirectory()) {
251+
await addEntries(entryPath, entryName);
252+
} else if (entry.isFile()) {
253+
zip.addFile(entryPath, entryName);
254+
}
255+
}
256+
};
257+
await addEntries(sourceDir, "");
258+
zip.end();
259+
await new Promise<void>((resolve, reject) => {
260+
zip.outputStream.on("error", reject);
261+
zip.outputStream.pipe(createWriteStream(zipPath)).on("close", resolve).on("error", reject);
262+
});
263+
}
264+
220265
async function removeDistDirIfEmpty(outputPath: AbsoluteFilePath): Promise<void> {
221266
const distDir = join(outputPath, RelativeFilePath.of(PACK_OUTPUT_DIRECTORY));
222267
try {

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)