@sdods/integrations 0.7.2 → 0.8.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/dist/github.d.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import { Octokit } from '@octokit/rest';
2
2
  import type { RunRecord } from '@sdods/contracts';
3
+ import { type PruneResult } from './evidence.js';
4
+ import { type CommandRunner } from './gif.js';
3
5
  import type { CreateIssueInput, GitHubConfig, IntegrationContext, IntegrationProvider, IntegrationSecrets, IssueLink, IssueRef, NotifyAction, NotifyResult, ProviderTestOptions, ProviderTestResult, RunSummaryInput } from './types.js';
4
6
  export declare const PR_COMMENT_MARKER = "<!-- sdods:run-summary -->";
5
7
  export interface GitHubProviderOptions {
6
8
  baseUrl?: string;
7
9
  octokit?: Octokit;
10
+ /** Octokit for the evidence repository (default: one built from `evidence.tokenEnv`, else `octokit`) */
11
+ evidenceOctokit?: Octokit;
12
+ /** web origin for evidence links (default `GITHUB_SERVER_URL`, then https://github.com) */
13
+ serverUrl?: string;
14
+ /** runs ffmpeg for GIF previews; injected by tests */
15
+ runCommand?: CommandRunner;
16
+ /** backoff between evidence ref-update attempts; injected by tests */
17
+ sleep?: (ms: number) => Promise<void>;
8
18
  }
9
19
  export declare class GitHubProvider implements IntegrationProvider<GitHubConfig> {
10
20
  private readonly options;
@@ -13,15 +23,33 @@ export declare class GitHubProvider implements IntegrationProvider<GitHubConfig>
13
23
  private octokit;
14
24
  private owner;
15
25
  private repo;
26
+ private evidence?;
27
+ private evidenceError?;
28
+ private gif?;
16
29
  constructor(options?: GitHubProviderOptions);
17
30
  init(config: GitHubConfig, secrets: IntegrationSecrets): Promise<void>;
31
+ /** `integrations.github.evidence.host: branch` — off unless configured. */
32
+ private initEvidence;
18
33
  test(opts?: ProviderTestOptions): Promise<ProviderTestResult>;
34
+ /**
35
+ * Rewrite the evidence branch without the runs uploaded more than `olderThan` ago
36
+ * (default `evidence.retainDays`). Used by `sdods integrations evidence prune`.
37
+ */
38
+ pruneEvidence(opts?: {
39
+ olderThan?: string;
40
+ dryRun?: boolean;
41
+ }): Promise<PruneResult>;
19
42
  /**
20
43
  * Issues are created with `labels`; a label the repository lacks is a configuration error that
21
44
  * nothing reported. Missing labels fail the test, and `createMissingLabels` creates them.
22
45
  */
23
46
  private checkLabels;
24
47
  onRunFinished(summary: RunSummaryInput, ctx: IntegrationContext): Promise<NotifyResult>;
48
+ /**
49
+ * Commit the screenshots, video and GIF preview of the scenarios about to get an issue to the
50
+ * evidence branch. Any failure is a warning: the issues then use the links they had before.
51
+ */
52
+ private publishEvidence;
25
53
  /**
26
54
  * Search the repository for an open issue whose body carries this scenario's fingerprint marker
27
55
  * and record it as the scenario's link. A failed search falls back to the local links.
package/dist/github.js CHANGED
@@ -1,12 +1,17 @@
1
- import { readFileSync } from 'node:fs';
2
- import { basename, extname } from 'node:path';
1
+ import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, extname, join } from 'node:path';
3
4
  import { Octokit } from '@octokit/rest';
4
5
  import { TRACE_CREDENTIALS_WARNING, artifactDisplayPath, gherkinBlock, selectIssueScreenshots, truncate, } from './context.js';
5
6
  import { IssueDedupe, fingerprintMarker, issueTitle } from './dedupe.js';
7
+ import { GitHubBranchEvidence, evidenceKey, formatBytes, parseAge, scenarioSlug, } from './evidence.js';
8
+ import { GifPreviewer } from './gif.js';
6
9
  export const PR_COMMENT_MARKER = '<!-- sdods:run-summary -->';
7
10
  const ANNOTATION_BATCH = 50;
8
11
  /** colour for labels created by `sdods integrations test --create-labels` */
9
12
  const LABEL_COLOR = 'd93f0b';
13
+ /** evidence key of a scenario's GIF preview */
14
+ const PREVIEW_REL = '#preview.gif';
10
15
  export class GitHubProvider {
11
16
  options;
12
17
  name = 'github';
@@ -14,6 +19,9 @@ export class GitHubProvider {
14
19
  octokit;
15
20
  owner = '';
16
21
  repo = '';
22
+ evidence;
23
+ evidenceError;
24
+ gif;
17
25
  constructor(options = {}) {
18
26
  this.options = options;
19
27
  }
@@ -34,17 +42,76 @@ export class GitHubProvider {
34
42
  baseUrl: this.options.baseUrl ?? process.env.GITHUB_API_URL,
35
43
  userAgent: 'sdods',
36
44
  });
45
+ this.initEvidence(config, secrets);
46
+ }
47
+ /** `integrations.github.evidence.host: branch` — off unless configured. */
48
+ initEvidence(config, secrets) {
49
+ const ev = config.evidence;
50
+ if (ev?.host !== 'branch')
51
+ return;
52
+ const [owner, repo] = ev.repo ? ev.repo.split('/') : [this.owner, this.repo];
53
+ let octokit = this.options.evidenceOctokit;
54
+ if (!octokit && ev.tokenEnv && !this.options.octokit) {
55
+ if (!secrets.evidenceToken) {
56
+ // issues are still created, with the links used without an evidence host
57
+ this.evidenceError = `evidence host needs a token in $${ev.tokenEnv}`;
58
+ return;
59
+ }
60
+ octokit = new Octokit({
61
+ auth: secrets.evidenceToken,
62
+ baseUrl: this.options.baseUrl ?? process.env.GITHUB_API_URL,
63
+ userAgent: 'sdods',
64
+ });
65
+ }
66
+ this.evidence = new GitHubBranchEvidence({
67
+ octokit: octokit ?? this.octokit,
68
+ owner: owner,
69
+ repo: repo,
70
+ branch: ev.branch,
71
+ serverUrl: this.options.serverUrl ?? process.env.GITHUB_SERVER_URL,
72
+ retainDays: ev.retainDays,
73
+ sleep: this.options.sleep,
74
+ });
75
+ if (ev.gifPreview)
76
+ this.gif = new GifPreviewer(this.options.runCommand);
37
77
  }
38
78
  async test(opts = {}) {
39
79
  let detail;
80
+ let isPrivate;
40
81
  try {
41
82
  const { data } = await this.octokit.rest.repos.get({ owner: this.owner, repo: this.repo });
42
83
  detail = `repo ${data.full_name} reachable (${data.private ? 'private' : 'public'})`;
84
+ isPrivate = data.private;
43
85
  }
44
86
  catch (e) {
45
87
  return { ok: false, detail: e.message };
46
88
  }
47
- return this.checkLabels(detail, opts);
89
+ const result = await this.checkLabels(detail, opts);
90
+ if (this.config.evidence?.host !== 'branch')
91
+ return result;
92
+ const evidence = this.evidence
93
+ ? await this.evidence.check(isPrivate)
94
+ : { ok: false, detail: this.evidenceError ?? 'evidence host not initialised' };
95
+ return {
96
+ ...result,
97
+ ok: result.ok && evidence.ok,
98
+ detail: `${result.detail}; ${evidence.detail}`,
99
+ evidence,
100
+ };
101
+ }
102
+ /**
103
+ * Rewrite the evidence branch without the runs uploaded more than `olderThan` ago
104
+ * (default `evidence.retainDays`). Used by `sdods integrations evidence prune`.
105
+ */
106
+ async pruneEvidence(opts = {}) {
107
+ if (this.config.evidence?.host !== 'branch')
108
+ throw new Error('integrations.github.evidence.host is not "branch" for this project.');
109
+ if (!this.evidence)
110
+ throw new Error(`GitHub ${this.evidenceError ?? 'evidence host not initialised'}.`);
111
+ const olderThanMs = opts.olderThan
112
+ ? parseAge(opts.olderThan)
113
+ : this.config.evidence.retainDays * 86_400_000;
114
+ return this.evidence.prune({ olderThanMs, dryRun: opts.dryRun });
48
115
  }
49
116
  /**
50
117
  * Issues are created with `labels`; a label the repository lacks is a configuration error that
@@ -115,8 +182,8 @@ export class GitHubProvider {
115
182
  if (this.config.prComment && ctx.ci.isPullRequest && ctx.ci.prNumber) {
116
183
  actions.push(await this.upsertPrComment(summary, ctx));
117
184
  }
118
- const candidates = this.failuresToReport(summary);
119
- for (const scenario of candidates) {
185
+ const decided = [];
186
+ for (const scenario of this.failuresToReport(summary)) {
120
187
  // The local link file does not survive a fresh CI runner, so an open issue carrying the
121
188
  // fingerprint marker is looked up in the repository before creating another one.
122
189
  const dedupe = new IssueDedupe(ctx.store, {
@@ -124,7 +191,15 @@ export class GitHubProvider {
124
191
  ? undefined
125
192
  : () => this.findOpenIssueByFingerprint(scenario, run, ctx),
126
193
  });
127
- const decision = await dedupe.decide(run.projectSlug, this.name, scenario.fingerprint);
194
+ decided.push({
195
+ scenario,
196
+ decision: await dedupe.decide(run.projectSlug, this.name, scenario.fingerprint),
197
+ });
198
+ }
199
+ // every new issue's evidence goes up in one commit, before the issues are created
200
+ const toCreate = decided.filter((d) => d.decision.action === 'create').map((d) => d.scenario);
201
+ const evidence = ctx.dryRun || !toCreate.length ? undefined : await this.publishEvidence(toCreate, run, ctx);
202
+ for (const { scenario, decision } of decided) {
128
203
  if (decision.action === 'create') {
129
204
  if (ctx.dryRun) {
130
205
  actions.push({
@@ -141,6 +216,7 @@ export class GitHubProvider {
141
216
  scenario,
142
217
  screenshots: selectIssueScreenshots(scenario),
143
218
  reportUrl: summary.reportUrl,
219
+ evidence,
144
220
  }, ctx);
145
221
  actions.push({
146
222
  provider: this.name,
@@ -209,6 +285,74 @@ export class GitHubProvider {
209
285
  }
210
286
  return { provider: this.name, actions };
211
287
  }
288
+ /**
289
+ * Commit the screenshots, video and GIF preview of the scenarios about to get an issue to the
290
+ * evidence branch. Any failure is a warning: the issues then use the links they had before.
291
+ */
292
+ async publishEvidence(scenarios, run, ctx) {
293
+ if (this.evidenceError) {
294
+ ctx.logger.warn(`github: ${this.evidenceError}; issues link evidence without it`);
295
+ return undefined;
296
+ }
297
+ const host = this.evidence;
298
+ const ev = this.config.evidence;
299
+ if (!host || !ev)
300
+ return undefined;
301
+ host.logger = ctx.logger;
302
+ const files = [];
303
+ let tmp;
304
+ const add = (scenario, key, localPath, kind, taken, wanted = basename(localPath)) => {
305
+ let name = wanted;
306
+ for (let i = 2; taken.has(name); i++)
307
+ name = `${i}-${wanted}`;
308
+ taken.add(name);
309
+ files.push({
310
+ key: evidenceKey(scenario.fingerprint, key),
311
+ fingerprint: scenario.fingerprint,
312
+ scenarioDir: scenarioSlug(scenario.scenarioName, scenario.fingerprint),
313
+ name,
314
+ localPath,
315
+ kind,
316
+ bytes: statSync(localPath).size,
317
+ });
318
+ };
319
+ try {
320
+ for (const scenario of scenarios) {
321
+ const taken = new Set();
322
+ for (const shot of selectIssueScreenshots(scenario)) {
323
+ const local = ctx.artifactPath(shot, run);
324
+ if (local)
325
+ add(scenario, shot.relPath, local, 'screenshot', taken);
326
+ }
327
+ // the trace is never offered: it carries credentials (#101)
328
+ const video = scenario.videoPath
329
+ ? ctx.artifactPath({ relPath: scenario.videoPath }, run)
330
+ : null;
331
+ if (!video)
332
+ continue;
333
+ if (this.gif) {
334
+ tmp ??= mkdtempSync(join(tmpdir(), 'sdods-gif-'));
335
+ const gif = await this.gif.make(video, join(tmp, `${files.length}.gif`), ctx.logger);
336
+ if (gif)
337
+ add(scenario, PREVIEW_REL, gif, 'preview', taken, 'preview.gif');
338
+ }
339
+ add(scenario, scenario.videoPath, video, 'video', taken);
340
+ }
341
+ if (!files.length)
342
+ return undefined;
343
+ const published = await host.publishRun({ id: run.id, projectSlug: run.projectSlug }, files, ev);
344
+ ctx.logger.info(`github evidence: ${published.urls.size} file(s) on ${host.fullName}@${host.branch}${published.skipped.length ? `, ${published.skipped.length} skipped by the size caps` : ''}`);
345
+ return published;
346
+ }
347
+ catch (e) {
348
+ ctx.logger.warn(`github: evidence upload to ${host.fullName}@${host.branch} failed, issues link evidence without it: ${e.message}`);
349
+ return undefined;
350
+ }
351
+ finally {
352
+ if (tmp)
353
+ rmSync(tmp, { recursive: true, force: true });
354
+ }
355
+ }
212
356
  /**
213
357
  * Search the repository for an open issue whose body carries this scenario's fingerprint marker
214
358
  * and record it as the scenario's link. A failed search falls back to the local links.
@@ -420,15 +564,17 @@ export class GitHubProvider {
420
564
  const { scenario, run } = input;
421
565
  const title = issueTitle(scenario.featureName, scenario.scenarioName, scenario.browser);
422
566
  const shotLines = [];
567
+ const hosted = (relPath) => input.evidence?.urls.get(evidenceKey(scenario.fingerprint, relPath));
423
568
  for (const shot of input.screenshots) {
424
- let url = ctx.artifactUrl(shot, run);
425
- if (this.config.uploadToRelease) {
569
+ // precedence: evidence branch, then release asset, then SDODS_PUBLIC_URL, then CI artifacts
570
+ let url = hosted(shot.relPath) ?? ctx.artifactUrl(shot, run);
571
+ if (this.config.uploadToRelease && !hosted(shot.relPath)) {
426
572
  const uploaded = await this.uploadReleaseAsset(shot, run, ctx).catch(() => null);
427
573
  if (uploaded)
428
574
  url = uploaded;
429
575
  }
430
576
  if (url)
431
- shotLines.push(`- ${shot.phase ?? 'screenshot'}${shot.stepIndex != null ? ` (step ${shot.stepIndex})` : ''}: ${url.endsWith('.png') ? `![${shot.phase}](${url})` : url}`);
577
+ shotLines.push(`- ${shot.phase ?? 'screenshot'}${shot.stepIndex != null ? ` (step ${shot.stepIndex})` : ''}: ${/\.png(\?raw=true)?$/.test(url) ? `![${shot.phase}](${url})` : url}`);
432
578
  }
433
579
  const body = [
434
580
  `**Project** \`${run.projectSlug}\` · **Environment** \`${run.env}\` · **Browser** \`${scenario.browser ?? scenario.layer}\` · **Run** \`${run.id}\``,
@@ -447,7 +593,8 @@ export class GitHubProvider {
447
593
  '```',
448
594
  '',
449
595
  ...(shotLines.length ? ['### Screenshots', ...shotLines, ''] : []),
450
- ...(await this.mediaLines(scenario, run, ctx)),
596
+ ...(await this.mediaLines(scenario, run, ctx, input.evidence)),
597
+ ...skippedEvidenceLines(scenario, input.evidence),
451
598
  ...(input.reportUrl || ctx.ci.runUrl
452
599
  ? [
453
600
  '### Links',
@@ -502,13 +649,17 @@ export class GitHubProvider {
502
649
  * render masked, but anything the application prints on screen (an OTP, a token in a URL bar
503
650
  * shown in a page) is visible in it, so projects with such screens should set `evidence.video: off`.
504
651
  */
505
- async mediaLines(scenario, run, ctx) {
652
+ async mediaLines(scenario, run, ctx, evidence) {
506
653
  const lines = [];
654
+ const preview = evidence?.urls.get(evidenceKey(scenario.fingerprint, PREVIEW_REL));
655
+ if (preview)
656
+ lines.push(`- Preview (last ${this.gif?.seconds ?? 5} s): ![video preview](${preview})`);
507
657
  if (scenario.videoPath) {
508
658
  const relPath = scenario.videoPath;
509
659
  const local = artifactDisplayPath(relPath, run);
510
- let url = ctx.artifactUrl({ relPath }, run);
511
- if (this.config.uploadToRelease) {
660
+ const hostedVideo = evidence?.urls.get(evidenceKey(scenario.fingerprint, relPath));
661
+ let url = hostedVideo ?? ctx.artifactUrl({ relPath }, run);
662
+ if (this.config.uploadToRelease && !hostedVideo) {
512
663
  const uploaded = await this.uploadReleaseAsset({ relPath }, run, ctx).catch(() => null);
513
664
  if (uploaded)
514
665
  url = uploaded;
@@ -627,6 +778,17 @@ export class GitHubProvider {
627
778
  };
628
779
  }
629
780
  }
781
+ /** Files the evidence host did not take, so a reader knows why they are only linked. */
782
+ function skippedEvidenceLines(scenario, evidence) {
783
+ const mine = evidence?.skipped.filter((s) => s.fingerprint === scenario.fingerprint) ?? [];
784
+ if (!mine.length)
785
+ return [];
786
+ return [
787
+ '### Evidence not uploaded',
788
+ ...mine.map((s) => `- \`${s.name}\` (${formatBytes(s.bytes)}): ${s.reason}`),
789
+ '',
790
+ ];
791
+ }
630
792
  export function issueNumber(key) {
631
793
  const m = /#?(\d+)$/.exec(key.trim());
632
794
  if (!m)
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ export * from './dedupe.js';
4
4
  export * from './secrets.js';
5
5
  export * from './context.js';
6
6
  export * from './github.js';
7
+ export * from './evidence.js';
8
+ export * from './gif.js';
7
9
  export * from './jira.js';
8
10
  export * from './registry.js';
9
11
  export * from './summary.js';
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ export * from './dedupe.js';
4
4
  export * from './secrets.js';
5
5
  export * from './context.js';
6
6
  export * from './github.js';
7
+ export * from './evidence.js';
8
+ export * from './gif.js';
7
9
  export * from './jira.js';
8
10
  export * from './registry.js';
9
11
  export * from './summary.js';
package/dist/registry.js CHANGED
@@ -11,15 +11,19 @@ export async function getProviders(project, opts = {}) {
11
11
  const gh = project.integrations.github;
12
12
  if (gh && want('github')) {
13
13
  const provider = new GitHubProvider();
14
+ const names = {
15
+ tokenEnv: gh.tokenEnv,
16
+ evidenceTokenEnv: gh.evidence?.host === 'branch' ? gh.evidence.tokenEnv : undefined,
17
+ };
14
18
  const entry = {
15
19
  name: 'github',
16
20
  provider,
17
21
  enabled: gh.enabled,
18
- secrets: secretPresence({ tokenEnv: gh.tokenEnv }, env),
22
+ secrets: secretPresence(names, env),
19
23
  };
20
24
  if (gh.enabled && opts.init !== false) {
21
25
  try {
22
- await provider.init(gh, readSecrets({ tokenEnv: gh.tokenEnv }, env));
26
+ await provider.init(gh, readSecrets(names, env));
23
27
  }
24
28
  catch (e) {
25
29
  entry.initError = e.message;
package/dist/secrets.d.ts CHANGED
@@ -3,10 +3,12 @@ import type { CiInfo, IntegrationSecrets } from './types.js';
3
3
  export declare function readSecrets(names: {
4
4
  tokenEnv?: string;
5
5
  emailEnv?: string;
6
+ evidenceTokenEnv?: string;
6
7
  }, env?: NodeJS.ProcessEnv): IntegrationSecrets;
7
8
  export declare function secretPresence(names: {
8
9
  tokenEnv?: string;
9
10
  emailEnv?: string;
11
+ evidenceTokenEnv?: string;
10
12
  }, env?: NodeJS.ProcessEnv): Record<string, boolean>;
11
13
  /** Detect CI context (GitHub Actions and GitLab CI; everything else is "other"). */
12
14
  export declare function detectCi(env?: NodeJS.ProcessEnv): CiInfo;
package/dist/secrets.js CHANGED
@@ -3,6 +3,7 @@ export function readSecrets(names, env = process.env) {
3
3
  return {
4
4
  token: names.tokenEnv ? env[names.tokenEnv] : undefined,
5
5
  email: names.emailEnv ? env[names.emailEnv] : undefined,
6
+ ...(names.evidenceTokenEnv ? { evidenceToken: env[names.evidenceTokenEnv] } : {}),
6
7
  };
7
8
  }
8
9
  export function secretPresence(names, env = process.env) {
@@ -11,6 +12,8 @@ export function secretPresence(names, env = process.env) {
11
12
  out[names.tokenEnv] = Boolean(env[names.tokenEnv]);
12
13
  if (names.emailEnv)
13
14
  out[names.emailEnv] = Boolean(env[names.emailEnv]);
15
+ if (names.evidenceTokenEnv)
16
+ out[names.evidenceTokenEnv] = Boolean(env[names.evidenceTokenEnv]);
14
17
  return out;
15
18
  }
16
19
  /** Detect CI context (GitHub Actions and GitLab CI; everything else is "other"). */
package/dist/types.d.ts CHANGED
@@ -117,6 +117,33 @@ export interface CreateIssueInput {
117
117
  screenshots: ScreenshotRef[];
118
118
  diffUrl?: string;
119
119
  reportUrl?: string;
120
+ /** files already committed to the evidence branch for this run (`integrations.github.evidence`) */
121
+ evidence?: PublishedEvidence;
122
+ }
123
+ /** One local file offered to an evidence host. */
124
+ export interface EvidenceFile {
125
+ /** lookup key for the issue body: `evidenceKey(fingerprint, relPath)` */
126
+ key: string;
127
+ fingerprint: string;
128
+ /** directory under `runs/<runId>/` */
129
+ scenarioDir: string;
130
+ name: string;
131
+ localPath: string;
132
+ kind: 'screenshot' | 'preview' | 'video';
133
+ bytes: number;
134
+ }
135
+ export interface SkippedEvidence {
136
+ key: string;
137
+ fingerprint: string;
138
+ name: string;
139
+ bytes: number;
140
+ reason: string;
141
+ }
142
+ export interface PublishedEvidence {
143
+ /** evidence key → link that renders inline for readers of the evidence repository */
144
+ urls: Map<string, string>;
145
+ skipped: SkippedEvidence[];
146
+ commitSha?: string;
120
147
  }
121
148
  export interface NotifyAction {
122
149
  provider: ProviderName;
@@ -138,6 +165,11 @@ export interface ProviderTestResult {
138
165
  missing: string[];
139
166
  created: string[];
140
167
  };
168
+ /** the evidence host, when `integrations.github.evidence.host` is `branch` */
169
+ evidence?: {
170
+ ok: boolean;
171
+ detail: string;
172
+ };
141
173
  }
142
174
  export interface ProviderTestOptions {
143
175
  /** create configured labels the target is missing (GitHub) */
@@ -146,6 +178,8 @@ export interface ProviderTestOptions {
146
178
  export interface IntegrationSecrets {
147
179
  token?: string;
148
180
  email?: string;
181
+ /** token for `integrations.github.evidence.tokenEnv`, when set */
182
+ evidenceToken?: string;
149
183
  }
150
184
  export interface IntegrationProvider<C = unknown> {
151
185
  readonly name: ProviderName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/integrations",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "SDODS integrations: GitHub checks and issues, Jira issues and links.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -18,7 +18,7 @@
18
18
  "node": ">=22"
19
19
  },
20
20
  "dependencies": {
21
- "@sdods/contracts": "0.7.2",
21
+ "@sdods/contracts": "0.8.0",
22
22
  "@octokit/rest": "^22.0.1",
23
23
  "zod": "^4.5.4"
24
24
  },