@octalmesh/seagull-core 0.0.2 → 0.1.1

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 (38) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +173 -39
  3. package/dist/index.mjs +218 -152
  4. package/package.json +6 -6
  5. package/src/config/loader.test.ts +505 -0
  6. package/src/config/loader.ts +31 -12
  7. package/src/config/publishing.test.ts +92 -0
  8. package/src/config/publishing.ts +9 -5
  9. package/src/config/resolve-config-file.test.ts +60 -0
  10. package/src/config/schema.test.ts +466 -0
  11. package/src/config/schema.ts +16 -10
  12. package/src/config/spec-format.test.ts +54 -0
  13. package/src/config/spec-format.ts +48 -0
  14. package/src/config/template.test.ts +168 -0
  15. package/src/config/types.ts +3 -3
  16. package/src/generator/generator.test.ts +59 -0
  17. package/src/generator/registry.test.ts +84 -0
  18. package/src/generator/types.ts +9 -14
  19. package/src/generators/openapi-generator-cli/openapi-generator-cli.generator.test.ts +259 -0
  20. package/src/generators/openapi-generator-cli/patchers/go-module.patcher.test.ts +119 -0
  21. package/src/generators/openapi-generator-cli/patchers/maven.patcher.test.ts +141 -0
  22. package/src/generators/openapi-generator-cli/patchers/npm.patcher.test.ts +132 -0
  23. package/src/generators/openapi-typescript/openapi-typescript.generator.test.ts +190 -0
  24. package/src/git/git.test.ts +234 -0
  25. package/src/git/git.ts +50 -4
  26. package/src/index.ts +8 -0
  27. package/src/process/exec.test.ts +103 -0
  28. package/src/process/exec.ts +1 -2
  29. package/src/process/resolve-bin.test.ts +43 -0
  30. package/src/readme/default-templates.test.ts +179 -0
  31. package/src/readme/default-templates.ts +5 -10
  32. package/src/readme/readme-renderer.test.ts +121 -0
  33. package/src/readme/readme-renderer.ts +3 -7
  34. package/src/redocly/redocly-sync.test.ts +190 -0
  35. package/src/redocly/redocly-sync.ts +3 -1
  36. package/src/test-support/fixtures.ts +65 -0
  37. package/src/version/version.test.ts +93 -0
  38. package/src/version/version.ts +4 -2
@@ -0,0 +1,505 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { stringify as stringifyYaml } from "yaml";
7
+
8
+ import { loadConfig } from "./loader";
9
+
10
+ function baseConfig(): Record<string, unknown> {
11
+ return {
12
+ configVersion: 1,
13
+ vars: {
14
+ org: "octalmesh",
15
+ platform: "web",
16
+ repository: { owner: "OctalMesh", repo: "ows-contracts" },
17
+ },
18
+ paths: { dist: "dist" },
19
+ docs: {
20
+ server: { host: "localhost", port: 8080 },
21
+ metadata: {
22
+ title: "OWS Docs",
23
+ description: "desc",
24
+ favicon: "fav.ico",
25
+ baseServerUrl: "https://octalmesh.com",
26
+ },
27
+ },
28
+ publishing: {
29
+ branch: "sdk/svc-{service}/{id}",
30
+ tag: "svc-{service}-{id}-v{version}",
31
+ repositoryUrl:
32
+ "https://github.com/{vars.repository.owner}/{vars.repository.repo}",
33
+ npm: { registry: "https://npm.pkg.github.com", access: "public" },
34
+ maven: {
35
+ repositoryId: "github",
36
+ repositoryUrl:
37
+ "https://maven.pkg.github.com/{vars.repository.owner}/{vars.repository.repo}",
38
+ },
39
+ },
40
+ generators: {
41
+ "ts-client": {
42
+ tool: "openapi-generator",
43
+ generator: "typescript-fetch",
44
+ lang: "typescript",
45
+ kind: "client",
46
+ package: "@{vars.org}/{service}-client",
47
+ additionalProperties: { supportsES6: true },
48
+ },
49
+ "ts-server": {
50
+ tool: "openapi-typescript",
51
+ lang: "typescript",
52
+ kind: "server",
53
+ package: "@{vars.org}/{service}-server",
54
+ },
55
+ "java-client": {
56
+ tool: "openapi-generator",
57
+ generator: "java",
58
+ lang: "java",
59
+ kind: "client",
60
+ maven: {
61
+ groupId: "com.{vars.org}.{service}",
62
+ artifactId: "{service}-client",
63
+ },
64
+ },
65
+ },
66
+ contracts: [
67
+ {
68
+ name: "auth",
69
+ title: "Auth Service API",
70
+ entrypoint: "specs/auth/openapi.yaml",
71
+ artifacts: ["ts-client", "ts-server"],
72
+ },
73
+ {
74
+ name: "catalog",
75
+ title: "Catalog Service API",
76
+ entrypoint: "specs/catalog/openapi.yaml",
77
+ artifacts: [
78
+ {
79
+ generator: "ts-client",
80
+ as: "ts-client-legacy",
81
+ overrides: {
82
+ package: "@{vars.org}/{service}-client-legacy",
83
+ additionalProperties: { legacy: true },
84
+ },
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ };
90
+ }
91
+
92
+ describe("loadConfig", () => {
93
+ let dir: string;
94
+
95
+ beforeEach(async () => {
96
+ dir = await mkdtemp(path.join(tmpdir(), "seagull-loader-"));
97
+ });
98
+
99
+ afterEach(async () => {
100
+ await rm(dir, { recursive: true, force: true });
101
+ });
102
+
103
+ async function writeConfig(
104
+ config: Record<string, unknown>,
105
+ filename = "seagull.yaml",
106
+ ): Promise<string> {
107
+ const configPath = path.join(dir, filename);
108
+
109
+ await writeFile(configPath, stringifyYaml(config));
110
+
111
+ return configPath;
112
+ }
113
+
114
+ it("throws a readable, multi-issue error for an invalid config", async () => {
115
+ const configPath = await writeConfig({ configVersion: 1 });
116
+
117
+ expect(() => loadConfig(configPath)).toThrow(/Invalid seagull\.yaml/);
118
+ });
119
+
120
+ it("loads a minimal valid config and resolves every top-level field", async () => {
121
+ const configPath = await writeConfig(baseConfig());
122
+ const config = loadConfig(configPath);
123
+
124
+ expect(config.configVersion).toBe(1);
125
+ expect(config.rootDir).toBe(dir);
126
+ expect(config.vars).toEqual({
127
+ org: "octalmesh",
128
+ platform: "web",
129
+ repository: { owner: "OctalMesh", repo: "ows-contracts" },
130
+ });
131
+ expect(config.paths.dist).toBe(path.join(dir, "dist"));
132
+ expect(config.paths.specs).toBe(path.join(dir, "dist", "specs"));
133
+ expect(config.paths.docs).toBe(path.join(dir, "dist", "docs"));
134
+ expect(config.paths.sdk).toBe(path.join(dir, "dist", "sdk"));
135
+ expect(config.paths.specFormat).toEqual(["json"]);
136
+ expect(config.contracts).toHaveLength(2);
137
+ expect(config.allArtifacts).toHaveLength(3);
138
+ });
139
+
140
+ it("respects an explicit paths.specFormat override, accepting a single value or a list", async () => {
141
+ const cfg = baseConfig();
142
+
143
+ (cfg.paths as Record<string, unknown>) = {
144
+ dist: "dist",
145
+ specFormat: "yaml",
146
+ };
147
+
148
+ const configPath = await writeConfig(cfg);
149
+ const config = loadConfig(configPath);
150
+
151
+ expect(config.paths.specFormat).toEqual(["yaml"]);
152
+
153
+ (cfg.paths as Record<string, unknown>) = {
154
+ dist: "dist",
155
+ specFormat: ["yaml", "json"],
156
+ };
157
+
158
+ const configPath2 = await writeConfig(cfg);
159
+ const config2 = loadConfig(configPath2);
160
+
161
+ expect(config2.paths.specFormat).toEqual(["yaml", "json"]);
162
+ });
163
+
164
+ it("respects explicit paths.specs/docs/sdk overrides instead of dist-relative defaults", async () => {
165
+ const cfg = baseConfig();
166
+
167
+ (cfg.paths as Record<string, unknown>) = {
168
+ dist: "dist",
169
+ specs: "custom-specs",
170
+ docs: "custom-docs",
171
+ sdk: "custom-sdk",
172
+ };
173
+
174
+ const configPath = await writeConfig(cfg);
175
+ const config = loadConfig(configPath);
176
+
177
+ expect(config.paths.specs).toBe(path.join(dir, "custom-specs"));
178
+ expect(config.paths.docs).toBe(path.join(dir, "custom-docs"));
179
+ expect(config.paths.sdk).toBe(path.join(dir, "custom-sdk"));
180
+ });
181
+
182
+ it("resolves each contract's entrypoint to an absolute path, and a rootDir-relative one", async () => {
183
+ const configPath = await writeConfig(baseConfig());
184
+ const config = loadConfig(configPath);
185
+
186
+ const auth = config.contracts.find((c) => c.name === "auth")!;
187
+
188
+ expect(auth.entrypoint).toBe(path.join(dir, "specs/auth/openapi.yaml"));
189
+ expect(auth.entrypointRelative).toBe(
190
+ path.join("specs", "auth", "openapi.yaml"),
191
+ );
192
+ });
193
+
194
+ it("interpolates templated fields on every artifact (package, additionalProperties)", async () => {
195
+ const configPath = await writeConfig(baseConfig());
196
+ const config = loadConfig(configPath);
197
+
198
+ const auth = config.contracts.find((c) => c.name === "auth")!;
199
+ const tsClient = auth.artifacts.find((a) => a.id === "ts-client")!;
200
+
201
+ expect(tsClient.package).toBe("@octalmesh/auth-client");
202
+ expect(tsClient.additionalProperties).toEqual({ supportsES6: true });
203
+ });
204
+
205
+ it("computes each artifact's outputDir as <sdkDir>/<contract>/<id>", async () => {
206
+ const configPath = await writeConfig(baseConfig());
207
+ const config = loadConfig(configPath);
208
+
209
+ const auth = config.contracts.find((c) => c.name === "auth")!;
210
+ const tsServer = auth.artifacts.find((a) => a.id === "ts-server")!;
211
+
212
+ expect(tsServer.outputDir).toBe(
213
+ path.join(dir, "dist", "sdk", "auth", "ts-server"),
214
+ );
215
+ });
216
+
217
+ it("resolves the root-level publishing block per artifact, interpolating branch/repositoryUrl/registries but not the tag template", async () => {
218
+ const configPath = await writeConfig(baseConfig());
219
+ const config = loadConfig(configPath);
220
+
221
+ const auth = config.contracts.find((c) => c.name === "auth")!;
222
+ const tsClient = auth.artifacts.find((a) => a.id === "ts-client")!;
223
+
224
+ expect(tsClient.branch).toBe("sdk/svc-auth/ts-client");
225
+ expect(tsClient.publishing.branch).toBe("sdk/svc-auth/ts-client");
226
+ expect(tsClient.publishing.repositoryUrl).toBe(
227
+ "https://github.com/OctalMesh/ows-contracts",
228
+ );
229
+ expect(tsClient.publishing.npmRegistry).toBe("https://npm.pkg.github.com");
230
+ expect(tsClient.publishing.npmAccess).toBe("public");
231
+ expect(tsClient.publishing.mavenRepositoryId).toBe("github");
232
+ expect(tsClient.publishing.tagTemplate).toBe(
233
+ "svc-{service}-{id}-v{version}",
234
+ );
235
+ });
236
+
237
+ it("applies an artifact-ref's 'as' to rename the artifact id (and its branch/outputDir)", async () => {
238
+ const configPath = await writeConfig(baseConfig());
239
+ const config = loadConfig(configPath);
240
+
241
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
242
+
243
+ expect(catalog.artifacts).toHaveLength(1);
244
+ expect(catalog.artifacts[0]!.id).toBe("ts-client-legacy");
245
+ expect(catalog.artifacts[0]!.branch).toBe(
246
+ "sdk/svc-catalog/ts-client-legacy",
247
+ );
248
+ expect(catalog.artifacts[0]!.outputDir).toBe(
249
+ path.join(dir, "dist", "sdk", "catalog", "ts-client-legacy"),
250
+ );
251
+ });
252
+
253
+ it("merges an artifact-ref's overrides onto the base generator (own fields win)", async () => {
254
+ const configPath = await writeConfig(baseConfig());
255
+ const config = loadConfig(configPath);
256
+
257
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
258
+ const legacy = catalog.artifacts[0]!;
259
+
260
+ expect(legacy.package).toBe("@octalmesh/catalog-client-legacy");
261
+ expect(legacy.additionalProperties).toEqual({
262
+ supportsES6: true,
263
+ legacy: true,
264
+ });
265
+ });
266
+
267
+ it("throws a descriptive error when a contract references an unknown generator id", async () => {
268
+ const cfg = baseConfig();
269
+
270
+ (cfg.contracts as Record<string, unknown>[])[0]!.artifacts = [
271
+ "does-not-exist",
272
+ ];
273
+
274
+ const configPath = await writeConfig(cfg);
275
+
276
+ expect(() => loadConfig(configPath)).toThrow(
277
+ /Contract "auth" references unknown generator "does-not-exist" \(available: java-client, ts-client, ts-server\)/,
278
+ );
279
+ });
280
+
281
+ it("throws a descriptive error when a resolved branch would start with '-'", async () => {
282
+ const cfg = baseConfig();
283
+
284
+ cfg.vars = { ...(cfg.vars as Record<string, unknown>), org: "octalmesh" };
285
+ (cfg.publishing as Record<string, unknown>).branch =
286
+ "-{vars.org}/{service}/{id}";
287
+
288
+ const configPath = await writeConfig(cfg);
289
+
290
+ expect(() => loadConfig(configPath)).toThrow(
291
+ /Invalid git publishing\.branch for artifact "auth\/ts-client" "-octalmesh\/auth\/ts-client": must not start with "-"/,
292
+ );
293
+ });
294
+
295
+ it("throws when a template placeholder can't be resolved", async () => {
296
+ const cfg = baseConfig();
297
+
298
+ (cfg.generators as Record<string, Record<string, unknown>>)[
299
+ "ts-client"
300
+ ]!.package = "@{vars.missing}/{service}-client";
301
+
302
+ const configPath = await writeConfig(cfg);
303
+
304
+ expect(() => loadConfig(configPath)).toThrow(
305
+ /Unknown template placeholder "\{vars\.missing\}"/,
306
+ );
307
+ });
308
+
309
+ describe("publishing overrides", () => {
310
+ it("lets a generator-level 'publishing' override win over the root block", async () => {
311
+ const cfg = baseConfig();
312
+
313
+ (cfg.generators as Record<string, Record<string, unknown>>)[
314
+ "ts-client"
315
+ ]!.publishing = {
316
+ npm: { registry: "https://registry.internal.example.com" },
317
+ };
318
+
319
+ const configPath = await writeConfig(cfg);
320
+ const config = loadConfig(configPath);
321
+
322
+ const auth = config.contracts.find((c) => c.name === "auth")!;
323
+ const tsClient = auth.artifacts.find((a) => a.id === "ts-client")!;
324
+ const tsServer = auth.artifacts.find((a) => a.id === "ts-server")!;
325
+
326
+ expect(tsClient.publishing.npmRegistry).toBe(
327
+ "https://registry.internal.example.com",
328
+ );
329
+ expect(tsClient.publishing.npmAccess).toBe("public");
330
+ expect(tsServer.publishing.npmRegistry).toBe(
331
+ "https://npm.pkg.github.com",
332
+ );
333
+ });
334
+
335
+ it("lets an artifact-ref override's 'publishing' win over both the generator's and the root's", async () => {
336
+ const cfg = baseConfig();
337
+
338
+ (cfg.generators as Record<string, Record<string, unknown>>)[
339
+ "ts-client"
340
+ ]!.publishing = { branch: "generator-level/{service}/{id}" };
341
+
342
+ (cfg.contracts as Record<string, unknown>[])[1]!.artifacts = [
343
+ {
344
+ generator: "ts-client",
345
+ overrides: {
346
+ publishing: { branch: "artifact-level/{service}/{id}" },
347
+ },
348
+ },
349
+ ];
350
+
351
+ const configPath = await writeConfig(cfg);
352
+ const config = loadConfig(configPath);
353
+
354
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
355
+
356
+ expect(catalog.artifacts[0]!.branch).toBe(
357
+ "artifact-level/catalog/ts-client",
358
+ );
359
+ });
360
+
361
+ it("deep-merges 'maven' when an artifact-ref override's own 'maven' partially overlaps the generator's", async () => {
362
+ const cfg = baseConfig();
363
+
364
+ (cfg.contracts as Record<string, unknown>[])[1]!.artifacts = [
365
+ {
366
+ generator: "java-client",
367
+ overrides: {
368
+ maven: {
369
+ groupId: "com.{vars.org}.{service}",
370
+ artifactId: "{service}-client-v2",
371
+ },
372
+ },
373
+ },
374
+ ];
375
+
376
+ const configPath = await writeConfig(cfg);
377
+ const config = loadConfig(configPath);
378
+
379
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
380
+
381
+ expect(catalog.artifacts[0]!.maven).toEqual({
382
+ groupId: "com.octalmesh.catalog",
383
+ artifactId: "catalog-client-v2",
384
+ });
385
+ });
386
+
387
+ it("deep-merges 'npm' and 'maven' when both a generator-level and an artifact-level publishing override set them", async () => {
388
+ const cfg = baseConfig();
389
+
390
+ (cfg.generators as Record<string, Record<string, unknown>>)[
391
+ "ts-client"
392
+ ]!.publishing = {
393
+ npm: {
394
+ registry: "https://generator-level.example.com",
395
+ access: "public",
396
+ },
397
+ };
398
+
399
+ (cfg.contracts as Record<string, unknown>[])[1]!.artifacts = [
400
+ {
401
+ generator: "ts-client",
402
+ overrides: {
403
+ publishing: { npm: { access: "restricted" } },
404
+ },
405
+ },
406
+ ];
407
+
408
+ const configPath = await writeConfig(cfg);
409
+ const config = loadConfig(configPath);
410
+
411
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
412
+
413
+ expect(catalog.artifacts[0]!.publishing.npmRegistry).toBe(
414
+ "https://generator-level.example.com",
415
+ );
416
+ expect(catalog.artifacts[0]!.publishing.npmAccess).toBe("restricted");
417
+ });
418
+
419
+ it("deep-merges 'maven' in a publishing override on top of the root's maven repository config", async () => {
420
+ const cfg = baseConfig();
421
+
422
+ (cfg.contracts as Record<string, unknown>[])[1]!.artifacts = [
423
+ {
424
+ generator: "ts-client",
425
+ overrides: {
426
+ publishing: { maven: { repositoryId: "internal" } },
427
+ },
428
+ },
429
+ ];
430
+
431
+ const configPath = await writeConfig(cfg);
432
+ const config = loadConfig(configPath);
433
+
434
+ const catalog = config.contracts.find((c) => c.name === "catalog")!;
435
+
436
+ expect(catalog.artifacts[0]!.publishing.mavenRepositoryId).toBe(
437
+ "internal",
438
+ );
439
+ expect(catalog.artifacts[0]!.publishing.mavenRepositoryUrl).toBe(
440
+ "https://maven.pkg.github.com/OctalMesh/ows-contracts",
441
+ );
442
+ });
443
+ });
444
+
445
+ describe("readme templates", () => {
446
+ it("resolves 'readme' relative to the config's rootDir when set", async () => {
447
+ const cfg = baseConfig();
448
+
449
+ (cfg.generators as Record<string, Record<string, unknown>>)[
450
+ "ts-client"
451
+ ]!.readme = "readme-templates/ts-client.md";
452
+
453
+ const configPath = await writeConfig(cfg);
454
+ const config = loadConfig(configPath);
455
+
456
+ const auth = config.contracts.find((c) => c.name === "auth")!;
457
+ const tsClient = auth.artifacts.find((a) => a.id === "ts-client")!;
458
+
459
+ expect(tsClient.readmeTemplate).toBe(
460
+ path.join(dir, "readme-templates/ts-client.md"),
461
+ );
462
+ });
463
+
464
+ it("leaves readmeTemplate undefined when no 'readme' is configured", async () => {
465
+ const configPath = await writeConfig(baseConfig());
466
+ const config = loadConfig(configPath);
467
+
468
+ const auth = config.contracts.find((c) => c.name === "auth")!;
469
+ const tsServer = auth.artifacts.find((a) => a.id === "ts-server")!;
470
+
471
+ expect(tsServer.readmeTemplate).toBeUndefined();
472
+ });
473
+ });
474
+
475
+ describe("allArtifacts", () => {
476
+ it("flattens every (contract, artifact) pair across all contracts, in config order", async () => {
477
+ const configPath = await writeConfig(baseConfig());
478
+ const config = loadConfig(configPath);
479
+
480
+ expect(
481
+ config.allArtifacts.map((e) => `${e.contract.name}/${e.artifact.id}`),
482
+ ).toEqual([
483
+ "auth/ts-client",
484
+ "auth/ts-server",
485
+ "catalog/ts-client-legacy",
486
+ ]);
487
+
488
+ for (const entry of config.allArtifacts) {
489
+ expect(entry.contract).toBe(
490
+ config.contracts.find((c) => c.name === entry.contract.name),
491
+ );
492
+ }
493
+ });
494
+ });
495
+
496
+ describe("config file discovery filenames", () => {
497
+ it("loads correctly regardless of which recognized filename is used", async () => {
498
+ await mkdir(dir, { recursive: true });
499
+ const configPath = await writeConfig(baseConfig(), ".seagull.yaml");
500
+ const config = loadConfig(configPath);
501
+
502
+ expect(config.contracts).toHaveLength(2);
503
+ });
504
+ });
505
+ });
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import { z } from "zod";
6
6
 
7
+ import { assertSafeRefName } from "../git/git";
7
8
  import {
8
9
  type ArtifactRefInput,
9
10
  type ContractInput,
@@ -29,7 +30,9 @@ import type {
29
30
  * @returns The validated (but not yet resolved) raw config.
30
31
  */
31
32
  function readRawConfig(configPath: string): RootConfigInput {
32
- const raw: unknown = parseYaml(readFileSync(configPath, "utf8"));
33
+ const raw: unknown = parseYaml(readFileSync(configPath, "utf8"), {
34
+ merge: true,
35
+ });
33
36
  const result = rootConfigSchema.safeParse(raw);
34
37
 
35
38
  if (!result.success) {
@@ -170,18 +173,29 @@ function resolveArtifactRef(
170
173
  * @param rootPublishing - The required root-level `publishing:` config
171
174
  * from the config file.
172
175
  * @param context - The flattened template context for this artifact
173
- * (`service`, `id`, `github.*`, `vars.*`).
176
+ * (`service`, `id`, `vars.*`).
177
+ * @param artifactLabel - `"<contract>/<artifact id>"`, used only to
178
+ * identify the offending artifact in the error
179
+ * thrown when `branch` resolves unsafely - see
180
+ * {@link assertSafeRefName}.
174
181
  * @returns The fully resolved publishing conventions for this artifact.
175
182
  */
176
183
  function resolvePublishing(
177
184
  generatorPublishing: PublishingOverrideInput | undefined,
178
185
  rootPublishing: PublishingInput,
179
186
  context: Record<string, string>,
187
+ artifactLabel: string,
180
188
  ): ResolvedPublishing {
181
189
  const merged = applyPublishingOverride(rootPublishing, generatorPublishing);
190
+ const branch = interpolate(merged.branch, context);
191
+
192
+ assertSafeRefName(
193
+ branch,
194
+ `publishing.branch for artifact "${artifactLabel}"`,
195
+ );
182
196
 
183
197
  return {
184
- branch: interpolate(merged.branch, context),
198
+ branch,
185
199
  tagTemplate: merged.tag,
186
200
  repositoryUrl: interpolate(merged.repositoryUrl, context),
187
201
  npmRegistry: interpolate(merged.npm.registry, context),
@@ -203,7 +217,7 @@ function resolvePublishing(
203
217
  * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
204
218
  * @param contractName - The owning contract's name.
205
219
  * @param contractContext - The flattened template context for this contract
206
- * (`service`, `github.*`, `vars.*` - not yet `id`).
220
+ * (`service`, `vars.*` - not yet `id`).
207
221
  * @param rootPublishing - The required root-level `publishing:` config from
208
222
  * the config file.
209
223
  * @returns The fully resolved artifact.
@@ -219,7 +233,12 @@ function resolveArtifact(
219
233
  ): ResolvedArtifact {
220
234
  const context = { ...contractContext, id };
221
235
  const resolved = interpolateDeep(def, context);
222
- const publishing = resolvePublishing(def.publishing, rootPublishing, context);
236
+ const publishing = resolvePublishing(
237
+ def.publishing,
238
+ rootPublishing,
239
+ context,
240
+ `${contractName}/${id}`,
241
+ );
223
242
 
224
243
  return {
225
244
  id,
@@ -250,8 +269,6 @@ function resolveArtifact(
250
269
  * resolved relative to this.
251
270
  * @param sdkDir - Absolute path to the SDK output root (`<dist>/sdk`).
252
271
  * @param generators - The full `generators:` map from the raw config.
253
- * @param githubCtx - `{ owner, repo }`, exposed to templates as
254
- * `{github.owner}`/`{github.repo}`.
255
272
  * @param vars - The `vars:` tree from the raw config, exposed as
256
273
  * `{vars.*}`.
257
274
  * @param rootPublishing - The required root-level `publishing:` config from the
@@ -263,13 +280,11 @@ function resolveContract(
263
280
  rootDir: string,
264
281
  sdkDir: string,
265
282
  generators: RootConfigInput["generators"],
266
- githubCtx: { owner: string; repo: string },
267
283
  vars: RootConfigInput["vars"],
268
284
  rootPublishing: PublishingInput,
269
285
  ): ResolvedContract {
270
286
  const context = buildTemplateContext({
271
287
  service: input.name,
272
- github: githubCtx,
273
288
  vars,
274
289
  });
275
290
 
@@ -332,7 +347,6 @@ export function loadConfig(configPath: string): ResolvedConfig {
332
347
  rootDir,
333
348
  sdkDir,
334
349
  raw.generators,
335
- raw.github,
336
350
  raw.vars,
337
351
  raw.publishing,
338
352
  ),
@@ -341,8 +355,13 @@ export function loadConfig(configPath: string): ResolvedConfig {
341
355
  return {
342
356
  configVersion: raw.configVersion,
343
357
  rootDir,
344
- paths: { dist: distDir, specs: specsDir, docs: docsDir, sdk: sdkDir },
345
- github: raw.github,
358
+ paths: {
359
+ dist: distDir,
360
+ specs: specsDir,
361
+ docs: docsDir,
362
+ sdk: sdkDir,
363
+ specFormat: raw.paths.specFormat,
364
+ },
346
365
  vars: raw.vars,
347
366
  docs: raw.docs,
348
367
  contracts,