@buildinternet/releases-api-types 0.36.0 → 0.37.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/releases-api-types",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Wire protocol — request/response types for the Releases registry API. Shared by web, workers, and clients.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api-types.ts CHANGED
@@ -309,10 +309,23 @@ export {
309
309
  } from "./schemas/account-profile.js";
310
310
  export {
311
311
  ReleasesJsonConfigSchema,
312
+ ReleasesJsonDomainReleaseSchema,
313
+ ReleasesJsonDomainSchema,
312
314
  ReleasesJsonProductSchema,
315
+ ReleasesJsonRegistriesSchema,
316
+ ReleasesJsonRepoProductSchema,
317
+ ReleasesJsonRepoReleaseSchema,
318
+ ReleasesJsonRepoSchema,
313
319
  SyncWellKnownResponseSchema,
314
320
  } from "./schemas/well-known.js";
315
- export type { ReleasesJsonConfig, ReleasesJsonProduct } from "./schemas/well-known.js";
321
+ export type {
322
+ ReleasesJsonConfig,
323
+ ReleasesJsonDomain,
324
+ ReleasesJsonDomainRelease,
325
+ ReleasesJsonProduct,
326
+ ReleasesJsonRepo,
327
+ ReleasesJsonRepoRelease,
328
+ } from "./schemas/well-known.js";
316
329
  export {
317
330
  SourceListItemSchema,
318
331
  SourceWithOrgSchema,
@@ -766,6 +779,12 @@ export interface WhatsChangedEntry {
766
779
  breaking: BreakingLevel;
767
780
  migrationNotes: string | null;
768
781
  url: string | null;
782
+ /**
783
+ * Absolute canonical web URL — the slugged `/release/<id>-<slug>` form
784
+ * (#1906), built from `WEB_BASE_URL`. Distinct from `url` (the upstream
785
+ * source URL); lets an upgrade-planning agent link the release detail page.
786
+ */
787
+ webUrl: string | null;
769
788
  }
770
789
 
771
790
  /**
@@ -1,66 +1,163 @@
1
- import { describe, it, expect } from "bun:test";
2
- import { ReleasesJsonConfigSchema } from "./well-known.js";
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ ReleasesJsonConfigSchema,
4
+ ReleasesJsonDomainSchema,
5
+ ReleasesJsonRepoSchema,
6
+ } from "./well-known.js";
3
7
 
4
- describe("ReleasesJsonConfigSchema", () => {
5
- it("accepts an empty object (no-op file)", () => {
6
- expect(ReleasesJsonConfigSchema.safeParse({}).success).toBe(true);
8
+ const location = (n: number) => ({ url: `https://acme.com/releases/${n}` });
9
+
10
+ describe("releases.json v2 schemas", () => {
11
+ it("accepts the minimal domain manifest", () => {
12
+ expect(
13
+ ReleasesJsonDomainSchema.parse({
14
+ version: 2,
15
+ releases: [{ url: "https://acme.com/updates", feed: "https://acme.com/updates.xml" }],
16
+ }),
17
+ ).toEqual({
18
+ version: 2,
19
+ releases: [{ url: "https://acme.com/updates", feed: "https://acme.com/updates.xml" }],
20
+ });
7
21
  });
8
22
 
9
- it("accepts a full org-scope file", () => {
10
- const r = ReleasesJsonConfigSchema.safeParse({
23
+ it("accepts the full domain shape with advisory taxonomy", () => {
24
+ const parsed = ReleasesJsonDomainSchema.parse({
11
25
  $schema: "https://releases.sh/schemas/releases.json",
12
- version: 1,
26
+ version: 2,
13
27
  name: "Acme",
14
28
  description: "CI for teams.",
15
- category: "developer-tools",
29
+ category: "future-category",
16
30
  avatar: "https://acme.com/logo.png",
17
- tags: ["ci", "observability"],
31
+ tags: ["future-tag"],
18
32
  social: { twitter: "acmehq", github: "acme" },
19
- notice: { message: "Docs moved", href: "https://acme.com/docs" },
33
+ products: [
34
+ {
35
+ name: "Acme Cloud",
36
+ slug: "acme-cloud",
37
+ kind: "future-kind",
38
+ category: "future-category",
39
+ description: "Managed CI runners.",
40
+ website: "https://acme.com/cloud",
41
+ docs: "https://docs.acme.com/cloud",
42
+ support: "https://acme.com/support",
43
+ social: { twitter: "acmecloud" },
44
+ archived: true,
45
+ releases: [{ github: "acme/cloud", canonical: true }],
46
+ },
47
+ ],
48
+ registries: {
49
+ "releases.sh": {
50
+ org: "org_abc123",
51
+ verification: "verification-token",
52
+ futureKey: true,
53
+ },
54
+ "example.com": { anything: "goes" },
55
+ },
20
56
  });
21
- expect(r.success).toBe(true);
57
+
58
+ expect(parsed.products?.[0]?.kind).toBe("future-kind");
59
+ expect(parsed.registries?.["example.com"]).toEqual({ anything: "goes" });
22
60
  });
23
61
 
24
- it("accepts a product-scope file", () => {
25
- const r = ReleasesJsonConfigSchema.safeParse({
26
- product: { name: "Acme Cloud", slug: "acme-cloud", category: "cloud", kind: "platform" },
27
- });
28
- expect(r.success).toBe(true);
62
+ it("accepts the repo variant including github self", () => {
63
+ expect(
64
+ ReleasesJsonRepoSchema.safeParse({
65
+ version: 2,
66
+ product: { name: "Acme Cloud", slug: "acme-cloud" },
67
+ releases: [{ github: "self", canonical: true }],
68
+ registries: { "releases.sh": { product: "prd_abc123" } },
69
+ }).success,
70
+ ).toBe(true);
29
71
  });
30
72
 
31
- it("rejects a non-https avatar", () => {
32
- expect(ReleasesJsonConfigSchema.safeParse({ avatar: "http://acme.com/x.png" }).success).toBe(
33
- false,
34
- );
73
+ it("requires the version 2 literal with no v1 compatibility", () => {
74
+ expect(ReleasesJsonConfigSchema.safeParse({ releases: [location(1)] }).success).toBe(false);
75
+ expect(ReleasesJsonConfigSchema.safeParse({ version: 1 }).success).toBe(false);
76
+ expect(ReleasesJsonConfigSchema.safeParse({ version: 3 }).success).toBe(false);
35
77
  });
36
78
 
37
- it("rejects a product with no name", () => {
38
- expect(ReleasesJsonConfigSchema.safeParse({ product: { slug: "x" } }).success).toBe(false);
79
+ it("requires at least one locator per release location", () => {
80
+ expect(
81
+ ReleasesJsonDomainSchema.safeParse({ version: 2, releases: [{ title: "Updates" }] }).success,
82
+ ).toBe(false);
39
83
  });
40
84
 
41
- it("rejects an unknown product kind", () => {
85
+ it("allows github self only in repo files", () => {
42
86
  expect(
43
- ReleasesJsonConfigSchema.safeParse({ product: { name: "Acme Cloud", kind: "saas" } }).success,
87
+ ReleasesJsonDomainSchema.safeParse({ version: 2, releases: [{ github: "self" }] }).success,
44
88
  ).toBe(false);
89
+ expect(
90
+ ReleasesJsonRepoSchema.safeParse({ version: 2, releases: [{ github: "self" }] }).success,
91
+ ).toBe(true);
45
92
  });
46
93
 
47
- it("rejects an over-long notice message", () => {
48
- const r = ReleasesJsonConfigSchema.safeParse({ notice: { message: "x".repeat(281) } });
49
- expect(r.success).toBe(false);
94
+ it("enforces product and release-location caps", () => {
95
+ expect(
96
+ ReleasesJsonDomainSchema.safeParse({
97
+ version: 2,
98
+ products: Array.from({ length: 25 }, (_, i) => ({ name: `Product ${i}` })),
99
+ }).success,
100
+ ).toBe(false);
101
+ expect(
102
+ ReleasesJsonDomainSchema.safeParse({
103
+ version: 2,
104
+ products: [{ name: "Cloud", releases: Array.from({ length: 9 }, (_, i) => location(i)) }],
105
+ }).success,
106
+ ).toBe(false);
107
+ expect(
108
+ ReleasesJsonDomainSchema.safeParse({
109
+ version: 2,
110
+ releases: Array.from({ length: 33 }, (_, i) => location(i)),
111
+ }).success,
112
+ ).toBe(false);
113
+ expect(
114
+ ReleasesJsonDomainSchema.safeParse({
115
+ version: 2,
116
+ releases: Array.from({ length: 25 }, (_, i) => location(i)),
117
+ products: [
118
+ { name: "Cloud", releases: Array.from({ length: 8 }, (_, i) => location(i + 25)) },
119
+ ],
120
+ }).success,
121
+ ).toBe(false);
50
122
  });
51
123
 
52
- it("accepts an integer version", () => {
53
- expect(ReleasesJsonConfigSchema.safeParse({ version: 1, name: "Acme" }).success).toBe(true);
124
+ it("allows at most one canonical location per scope", () => {
125
+ const canonical = (n: number) => ({ ...location(n), canonical: true });
126
+ expect(
127
+ ReleasesJsonDomainSchema.safeParse({
128
+ version: 2,
129
+ releases: [canonical(1), canonical(2)],
130
+ }).success,
131
+ ).toBe(false);
132
+ expect(
133
+ ReleasesJsonDomainSchema.safeParse({
134
+ version: 2,
135
+ products: [{ name: "Cloud", releases: [canonical(1), canonical(2)] }],
136
+ }).success,
137
+ ).toBe(false);
54
138
  });
55
139
 
56
- it("rejects a zero, negative, or non-integer version", () => {
57
- expect(ReleasesJsonConfigSchema.safeParse({ version: 0 }).success).toBe(false);
58
- expect(ReleasesJsonConfigSchema.safeParse({ version: -1 }).success).toBe(false);
59
- expect(ReleasesJsonConfigSchema.safeParse({ version: 1.5 }).success).toBe(false);
140
+ it("rejects removed v1 identity fields", () => {
141
+ expect(
142
+ ReleasesJsonDomainSchema.safeParse({ version: 2, website: "https://acme.com" }).success,
143
+ ).toBe(false);
144
+ expect(
145
+ ReleasesJsonDomainSchema.safeParse({ version: 2, notice: { message: "Moved" } }).success,
146
+ ).toBe(false);
60
147
  });
61
148
 
62
- it("strips unknown top-level keys", () => {
63
- const r = ReleasesJsonConfigSchema.parse({ name: "Acme", bogus: 1 });
64
- expect("bogus" in r).toBe(false);
149
+ it("requires typed stable ids for the releases.sh registry", () => {
150
+ expect(
151
+ ReleasesJsonDomainSchema.safeParse({
152
+ version: 2,
153
+ registries: { "releases.sh": { org: "prd_wrong" } },
154
+ }).success,
155
+ ).toBe(false);
156
+ expect(
157
+ ReleasesJsonRepoSchema.safeParse({
158
+ version: 2,
159
+ registries: { "releases.sh": { product: "org_wrong" } },
160
+ }).success,
161
+ ).toBe(false);
65
162
  });
66
163
  });
@@ -1,54 +1,172 @@
1
1
  import { z } from "zod";
2
- import { KIND_VALUES } from "@buildinternet/releases-core/kinds";
3
- import { NoticeSchema } from "./shared.js";
4
2
 
5
- /** A single social handle/URL. Bare handles allowed; URLs must be https. */
3
+ const MAX_PRODUCTS = 24;
4
+ const MAX_PRODUCT_RELEASES = 8;
5
+ const MAX_FILE_RELEASES = 32;
6
+
7
+ /** A single social handle/URL. Bare handles are intentionally accepted. */
6
8
  const SocialValueSchema = z.string().min(1).max(200);
9
+ const SocialSchema = z.record(z.string().min(1).max(40), SocialValueSchema);
7
10
 
8
- /** Product-scope block: declares the hosting repo's source belongs to this product. */
9
- export const ReleasesJsonProductSchema = z
10
- .object({
11
- name: z.string().min(1).max(120),
12
- slug: z.string().min(1).max(120).optional(),
13
- description: z.string().max(2000).optional(),
14
- // Accepted leniently as a string; resolved/validated against CATEGORIES at apply time.
15
- category: z.string().min(1).max(120).optional(),
16
- kind: z.enum(KIND_VALUES).optional(),
11
+ const HttpsUrlSchema = z
12
+ .url()
13
+ .refine((url) => url.startsWith("https://"), "locator must be an https URL");
14
+
15
+ const ReleaseLocationFields = {
16
+ url: HttpsUrlSchema.optional(),
17
+ feed: HttpsUrlSchema.optional(),
18
+ appstore: HttpsUrlSchema.optional(),
19
+ file: HttpsUrlSchema.optional(),
20
+ title: z.string().min(1).max(200).optional(),
21
+ canonical: z.boolean().optional(),
22
+ };
23
+
24
+ function hasLocator(value: {
25
+ url?: string;
26
+ feed?: string;
27
+ github?: string;
28
+ appstore?: string;
29
+ file?: string;
30
+ }): boolean {
31
+ return Boolean(value.url || value.feed || value.github || value.appstore || value.file);
32
+ }
33
+
34
+ /** A domain manifest can name a concrete GitHub repository, but never `self`. */
35
+ export const ReleasesJsonDomainReleaseSchema = z
36
+ .strictObject({
37
+ ...ReleaseLocationFields,
38
+ github: z
39
+ .string()
40
+ .regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, "github must be owner/repo")
41
+ .optional(),
17
42
  })
18
- .strict();
43
+ .refine(hasLocator, "at least one release locator is required");
44
+
45
+ /** A repo manifest additionally accepts `github: "self"`. */
46
+ export const ReleasesJsonRepoReleaseSchema = z
47
+ .strictObject({
48
+ ...ReleaseLocationFields,
49
+ github: z
50
+ .union([
51
+ z.literal("self"),
52
+ z.string().regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/, "github must be owner/repo"),
53
+ ])
54
+ .optional(),
55
+ })
56
+ .refine(hasLocator, "at least one release locator is required");
57
+
58
+ function releasesArray<T extends z.ZodType>(item: T, max: number) {
59
+ return z
60
+ .array(item)
61
+ .max(max)
62
+ .refine(
63
+ (items) =>
64
+ items.filter((entry) => Boolean((entry as { canonical?: boolean }).canonical)).length <= 1,
65
+ "at most one canonical release location is allowed per scope",
66
+ );
67
+ }
68
+
69
+ const DomainProductReleasesSchema = releasesArray(
70
+ ReleasesJsonDomainReleaseSchema,
71
+ MAX_PRODUCT_RELEASES,
72
+ );
73
+ const RepoReleasesSchema = releasesArray(ReleasesJsonRepoReleaseSchema, MAX_FILE_RELEASES);
74
+ const DomainReleasesSchema = releasesArray(ReleasesJsonDomainReleaseSchema, MAX_FILE_RELEASES);
19
75
 
20
76
  /**
21
- * One file name, two hosting scopes. Org-identity keys are honored only from a
22
- * domain `.well-known/releases.json`; `product` is honored only from a repo-root
23
- * file. The server enforces which keys it honors based on the host the file came
24
- * from — this schema only validates shape.
77
+ * Product declaration in a domain manifest. Taxonomy strings are deliberately
78
+ * lenient here; the reconciler resolves known values and ignores the rest.
25
79
  */
26
- export const ReleasesJsonConfigSchema = z
80
+ export const ReleasesJsonProductSchema = z.strictObject({
81
+ name: z.string().min(1).max(120),
82
+ slug: z.string().min(1).max(120).optional(),
83
+ kind: z.string().min(1).max(120).optional(),
84
+ category: z.string().min(1).max(120).optional(),
85
+ description: z.string().max(2000).optional(),
86
+ website: z.url().optional(),
87
+ docs: z.url().optional(),
88
+ support: z.url().optional(),
89
+ social: SocialSchema.optional(),
90
+ archived: z.boolean().optional(),
91
+ releases: DomainProductReleasesSchema.optional(),
92
+ });
93
+
94
+ /** Repo-scope product binding. Slug is only a creation suggestion. */
95
+ export const ReleasesJsonRepoProductSchema = z.strictObject({
96
+ name: z.string().min(1).max(120),
97
+ slug: z.string().min(1).max(120).optional(),
98
+ });
99
+
100
+ const ReleasesRegistrySchema = z
101
+ .object({
102
+ org: z
103
+ .string()
104
+ .regex(/^org_[A-Za-z0-9]+$/)
105
+ .optional(),
106
+ product: z
107
+ .string()
108
+ .regex(/^prd_[A-Za-z0-9]+$/)
109
+ .optional(),
110
+ verification: z.string().min(1).max(500).optional(),
111
+ })
112
+ .catchall(z.unknown());
113
+
114
+ /** Registry-specific extensions are forward-compatible by design. */
115
+ export const ReleasesJsonRegistriesSchema = z
27
116
  .object({
28
- $schema: z.url().optional(),
29
- // Manifest format version. Currently 1; optional — a missing value is read as
30
- // v1. Bumped only on a breaking change to this file's shape, so a consumer can
31
- // branch on it without fetching the JSON Schema. Lenient/fail-open: the
32
- // reconciler ignores it entirely, so an unexpected value never fails the sync.
33
- version: z.number().int().positive().max(1000).optional(),
34
- // Org scope
117
+ "releases.sh": ReleasesRegistrySchema.optional(),
118
+ })
119
+ .catchall(z.unknown());
120
+
121
+ /** Top-level fields shared by every manifest scope. */
122
+ const BaseFields = {
123
+ $schema: z.url().optional(),
124
+ version: z.literal(2),
125
+ registries: ReleasesJsonRegistriesSchema.optional(),
126
+ };
127
+
128
+ const CommonFields = {
129
+ ...BaseFields,
130
+ releases: DomainReleasesSchema.optional(),
131
+ };
132
+
133
+ /** Domain-hosted variant: flat org identity plus product and release declarations. */
134
+ export const ReleasesJsonDomainSchema = z
135
+ .strictObject({
136
+ ...CommonFields,
35
137
  name: z.string().min(1).max(120).optional(),
36
138
  description: z.string().max(2000).optional(),
37
139
  category: z.string().min(1).max(120).optional(),
38
- avatar: z
39
- .url()
40
- .refine((u) => u.startsWith("https://"), "avatar must be an https URL")
41
- .optional(),
140
+ avatar: HttpsUrlSchema.optional(),
42
141
  tags: z.array(z.string().min(1).max(60)).max(50).optional(),
43
- social: z.record(z.string().min(1).max(40), SocialValueSchema).optional(),
44
- notice: NoticeSchema.optional(),
45
- // Source/product scope
46
- product: ReleasesJsonProductSchema.optional(),
142
+ social: SocialSchema.optional(),
143
+ products: z.array(ReleasesJsonProductSchema).max(MAX_PRODUCTS).optional(),
47
144
  })
48
- .strip();
145
+ .refine(
146
+ (manifest) =>
147
+ (manifest.releases?.length ?? 0) +
148
+ (manifest.products?.reduce((sum, product) => sum + (product.releases?.length ?? 0), 0) ??
149
+ 0) <=
150
+ MAX_FILE_RELEASES,
151
+ `at most ${MAX_FILE_RELEASES} release locations are allowed per file`,
152
+ );
153
+
154
+ /** Repo-root variant: product binding plus repo-scoped release declarations. */
155
+ export const ReleasesJsonRepoSchema = z.strictObject({
156
+ ...BaseFields,
157
+ product: ReleasesJsonRepoProductSchema.optional(),
158
+ releases: RepoReleasesSchema.optional(),
159
+ });
160
+
161
+ /** Public schema accepts either hosting scope; reconcilers use the scoped schema. */
162
+ export const ReleasesJsonConfigSchema = z.union([ReleasesJsonDomainSchema, ReleasesJsonRepoSchema]);
49
163
 
50
164
  export type ReleasesJsonConfig = z.infer<typeof ReleasesJsonConfigSchema>;
165
+ export type ReleasesJsonDomain = z.infer<typeof ReleasesJsonDomainSchema>;
166
+ export type ReleasesJsonRepo = z.infer<typeof ReleasesJsonRepoSchema>;
51
167
  export type ReleasesJsonProduct = z.infer<typeof ReleasesJsonProductSchema>;
168
+ export type ReleasesJsonDomainRelease = z.infer<typeof ReleasesJsonDomainReleaseSchema>;
169
+ export type ReleasesJsonRepoRelease = z.infer<typeof ReleasesJsonRepoReleaseSchema>;
52
170
 
53
171
  /** Response shape of POST /v1/orgs/:slug/sync-well-known. */
54
172
  export const SyncWellKnownResponseSchema = z.object({