@samebase/alchemy-convex 0.6.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
5
5
  "keywords": [
6
6
  "alchemy",
package/src/Deployment.ts CHANGED
@@ -2,8 +2,14 @@
2
2
  // variables, and its functions, with the URLs that a frontend or a Worker
3
3
  // needs. This is the Convex plane of a stack: no deploy key leaves it.
4
4
  //
5
- // - `source` is the Convex functions directory, such as `./convex`. The
6
- // update pushes it with the Convex CLI that the app installed (Push.ts).
5
+ // - `rootDir` is the app directory, relative to the directory where the
6
+ // deploy runs (the directory of the stack file), such as `apps/web`, and
7
+ // "." by default. The push runs in it, and `source` is relative to it.
8
+ // The constructor writes it into the props, "." included, so that a reader
9
+ // of the state row finds the app directory of every deployment.
10
+ // - `source` is the Convex functions directory relative to `rootDir`, such
11
+ // as `./convex`. The update pushes it with the Convex CLI that the app
12
+ // installed (Push.ts).
7
13
  // The content identity of the push is a hash of the source files, the
8
14
  // app's package.json, convex.json, and lockfile, the deployment name, and
9
15
  // `env`. The same hash is a no-op. The hash is taken again after the push,
@@ -45,9 +51,11 @@
45
51
  //
46
52
  // Patterns from Confect `packages/alchemy/src/Deployment.ts`
47
53
  // (https://github.com/rjdellecese/confect, ISC license, see NOTICE).
54
+ import { posix, win32 } from "node:path";
48
55
  import { Resource } from "alchemy";
49
56
  import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
50
57
  import { isResolved } from "alchemy/Diff";
58
+ import type { InputProps } from "alchemy/Input";
51
59
  import * as Provider from "alchemy/Provider";
52
60
  import * as Effect from "effect/Effect";
53
61
  import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
@@ -79,10 +87,16 @@ interface DeploymentInputs {
79
87
  /** The project: a Convex.Project resource, or `{ projectId }`. Cannot change. */
80
88
  readonly project: { readonly projectId: number };
81
89
  /**
82
- * The Convex functions directory, such as `./convex`, or that directory
83
- * with a push command and more hashed files. The push runs in the nearest
84
- * directory above it with a package.json. Without `source`, the resource
85
- * pushes nothing.
90
+ * The app directory, relative to the directory where the deploy runs (the
91
+ * directory of the stack file), such as `apps/web`. Default ".". It must
92
+ * have a package.json. The push runs in it, and `source` is relative to
93
+ * it. State from 0.7 has no `rootDir`.
94
+ */
95
+ readonly rootDir?: string;
96
+ /**
97
+ * The Convex functions directory relative to `rootDir`, such as
98
+ * `./convex`, or that directory with a push command and more hashed
99
+ * files. Without `source`, the resource pushes nothing.
86
100
  */
87
101
  readonly source?: Source;
88
102
  /** Environment variables to set on the deployment before the push. */
@@ -134,10 +148,61 @@ export type Deployment = Resource<
134
148
  never,
135
149
  Providers
136
150
  >;
137
- export const Deployment = Resource<Deployment>("Convex.Deployment", {
151
+ /** The resource class. Stacks call {@link Deployment}, which writes `rootDir` into the props. */
152
+ export const DeploymentResource = Resource<Deployment>("Convex.Deployment", {
138
153
  defaultRemovalPolicy: "retain",
139
154
  });
140
155
 
156
+ /** The props of a `Convex.Deployment` call: Alchemy inputs, with `rootDir` a plain string. */
157
+ export type DeploymentInput = DeploymentProps extends infer P
158
+ ? P extends DeploymentProps
159
+ ? InputProps<P, "rootDir">
160
+ : never
161
+ : never;
162
+
163
+ /** `rootDir` in POSIX form, without "./" and a trailing slash, such as `apps/web` or ".". */
164
+ const normalizeRootDir = (rootDir: string) => {
165
+ const normalized = posix.normalize(rootDir.replaceAll("\\", "/"));
166
+ return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
167
+ };
168
+
169
+ /** `rootDir` is an absolute path. The declaration stops with it as a defect, before the plan. */
170
+ export class AbsoluteRootDir extends Schema.TaggedError<AbsoluteRootDir>()("AbsoluteRootDir", {
171
+ rootDir: Schema.String,
172
+ }) {
173
+ override get message() {
174
+ return `Convex.Deployment rootDir ${this.rootDir} is an absolute path. State keeps rootDir for every machine that runs the stack, so set it relative to the directory where the deploy runs, such as "apps/web".`;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * `Convex.Deployment("id", props)`. The props go to state with `rootDir` in
180
+ * POSIX form, and with "." when it is not set, so that every row names the
181
+ * app directory of its deployment. An absolute `rootDir` stops the
182
+ * declaration, so it never reaches the plan or state. The check dies,
183
+ * because the program of `Alchemy.Stack` can fail only with a ConfigError.
184
+ * `win32.isAbsolute` also answers true for a POSIX absolute path. The other
185
+ * fields keep it usable where Alchemy takes a resource class, such as
186
+ * `Provider.collection`.
187
+ */
188
+ export const Deployment = Object.assign(
189
+ (id: string, props: DeploymentInput) => {
190
+ const rootDir = props.rootDir ?? ".";
191
+ return win32.isAbsolute(rootDir)
192
+ ? Effect.die(new AbsoluteRootDir({ rootDir }))
193
+ : DeploymentResource(id, { ...props, rootDir: normalizeRootDir(rootDir) });
194
+ },
195
+ {
196
+ Type: DeploymentResource.Type,
197
+ Props: DeploymentResource.Props,
198
+ Self: DeploymentResource.Self,
199
+ Provider: DeploymentResource.Provider,
200
+ Aliases: DeploymentResource.Aliases,
201
+ ref: (id: string, options?: { stage?: string; stack?: string }) =>
202
+ DeploymentResource.ref(id, options),
203
+ },
204
+ );
205
+
141
206
  /** The props name another deployment than the one in state. */
142
207
  export class DeploymentIdentityChange extends Schema.TaggedError<DeploymentIdentityChange>()(
143
208
  "DeploymentIdentityChange",
@@ -336,7 +401,7 @@ const isRefused = (error: ConvexApiError) =>
336
401
 
337
402
  export const DeploymentProvider = () =>
338
403
  Provider.effect(
339
- Deployment,
404
+ DeploymentResource,
340
405
  Effect.gen(function* () {
341
406
  const api = yield* ManagementApi;
342
407
  const credentials = yield* Credentials;
@@ -517,10 +582,10 @@ export const DeploymentProvider = () =>
517
582
  });
518
583
 
519
584
  /** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
520
- const wantedHash = (news: DeploymentProps, deployment: string) =>
585
+ const wantedHash = (news: DeploymentProps, rootDir: string, deployment: string) =>
521
586
  news.source === undefined && Object.keys(news.env ?? {}).length === 0
522
587
  ? Effect.succeed(undefined)
523
- : pushHash({ source: news.source, deployment, env: news.env ?? {} });
588
+ : pushHash({ rootDir, source: news.source, deployment, env: news.env ?? {} });
524
589
 
525
590
  return {
526
591
  // `name`, `reference`, and the URLs change when an expired preview is
@@ -529,10 +594,21 @@ export const DeploymentProvider = () =>
529
594
  stables: ["projectId", "type", "previewName"],
530
595
 
531
596
  // Compares with the attributes: after a refused change, `olds` are
532
- // the refused props.
533
- diff: Effect.fn(function* ({ news, output }) {
597
+ // the refused props. Only `rootDir` compares with `olds`, because it
598
+ // is in the props only. A refused update leaves the row `updating`,
599
+ // and the next plan updates it again anyway.
600
+ diff: Effect.fn(function* ({ news, olds, output }) {
534
601
  if (!isResolved(news) || output === undefined) return undefined;
535
602
  yield* checkIdentity(output, news);
603
+ // State from 0.7 has no rootDir; the constructor always writes it.
604
+ const rootDir = news.rootDir ?? ".";
605
+ // Also here, so that a wrong rootDir stops the plan before Apply
606
+ // writes the new props to the row.
607
+ if (news.source !== undefined) yield* checkSource(rootDir, news.source);
608
+ // State from 0.7 has no rootDir, and another rootDir is another
609
+ // app directory. The update writes the props to state; it pushes
610
+ // only when the hash changed.
611
+ if (olds.rootDir !== rootDir) return { action: "update" } as const;
536
612
  const access = yield* accessFor(addressOf(news), changesEnv(news, output));
537
613
  // A preview expires, anyone can delete a deployment, and another
538
614
  // production deployment can become the default. Reconcile then
@@ -540,7 +616,7 @@ export const DeploymentProvider = () =>
540
616
  // URL of a deployment that is gone.
541
617
  const current = yield* live(access, output);
542
618
  if (current?.name !== output.name) return { action: "update" } as const;
543
- const hash = yield* wantedHash(news, output.name);
619
+ const hash = yield* wantedHash(news, rootDir, output.name);
544
620
  return hash === output.hash
545
621
  ? ({ action: "noop" } as const)
546
622
  : ({ action: "update" } as const);
@@ -581,8 +657,10 @@ export const DeploymentProvider = () =>
581
657
  reconcile: Effect.fn(function* ({ fqn, news, output }) {
582
658
  // Also here: with an unresolved prop in plan, diff could not check.
583
659
  if (output !== undefined) yield* checkIdentity(output, news);
584
- // The functions directory and the program, before any change.
585
- if (news.source !== undefined) yield* checkSource(news.source);
660
+ // The app directory, the functions directory, and the program,
661
+ // before any change.
662
+ const rootDir = news.rootDir ?? ".";
663
+ if (news.source !== undefined) yield* checkSource(rootDir, news.source);
586
664
  const address = addressOf(news);
587
665
  const access = yield* accessFor(address, changesEnv(news, output));
588
666
  const env = news.env ?? {};
@@ -597,7 +675,7 @@ export const DeploymentProvider = () =>
597
675
  const refuse = (name: string) =>
598
676
  new OwnedBySomeoneElse({
599
677
  message: `Project ${address.projectId} already has ${news.type} deployment ${name}${news.type === "prod" ? " with code" : ` named "${news.name}"`}. Re-run with --adopt to take it over${news.type === "prod" ? "" : ", or use another name"}.`,
600
- resourceType: Deployment.Type,
678
+ resourceType: DeploymentResource.Type,
601
679
  physicalName: name,
602
680
  });
603
681
 
@@ -662,7 +740,7 @@ export const DeploymentProvider = () =>
662
740
  // Variables set on another deployment stay there: they are not ours
663
741
  // on this one.
664
742
  const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
665
- const before = yield* wantedHash(news, target.name);
743
+ const before = yield* wantedHash(news, rootDir, target.name);
666
744
  if (before === undefined && previousEnvNames.length === 0) {
667
745
  return { ...target, hash: undefined, envNames: [] };
668
746
  }
@@ -672,6 +750,7 @@ export const DeploymentProvider = () =>
672
750
  const options = {
673
751
  deployment: target.name,
674
752
  url: target.url,
753
+ rootDir,
675
754
  source: news.source,
676
755
  env,
677
756
  previousEnvNames,
@@ -719,7 +798,7 @@ export const DeploymentProvider = () =>
719
798
  // such as convex/_generated, are part of the identity.
720
799
  return {
721
800
  ...target,
722
- hash: yield* wantedHash(news, target.name),
801
+ hash: yield* wantedHash(news, rootDir, target.name),
723
802
  envNames: Object.keys(env).sort(),
724
803
  };
725
804
  }),
package/src/Providers.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import * as Provider from "alchemy/Provider";
4
4
  import * as Layer from "effect/Layer";
5
5
  import { type Credentials, fromConfig } from "./Credentials.ts";
6
- import { Deployment, DeploymentProvider } from "./Deployment.ts";
6
+ import { DeploymentProvider, DeploymentResource } from "./Deployment.ts";
7
7
  import { ManagementApiLive } from "./ManagementApi.ts";
8
8
  import { Project, ProjectProvider } from "./Project.ts";
9
9
 
@@ -15,7 +15,7 @@ export class Providers extends Provider.ProviderCollection<Providers>()("Convex"
15
15
  * `CONVEX_DEPLOY_KEY`).
16
16
  */
17
17
  export const providers = (credentials: Layer.Layer<Credentials> = fromConfig()) =>
18
- Layer.effect(Providers, Provider.collection([Project, Deployment])).pipe(
18
+ Layer.effect(Providers, Provider.collection([Project, DeploymentResource])).pipe(
19
19
  Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())),
20
20
  Layer.provide(ManagementApiLive()),
21
21
  Layer.provide(credentials),
package/src/Push.ts CHANGED
@@ -48,13 +48,14 @@ import {
48
48
  export type EnvValue = string | Redacted.Redacted<string>;
49
49
 
50
50
  /**
51
- * The `source` prop: the Convex functions directory, such as `./convex`, or
52
- * that directory with a push command and more files for the content identity.
51
+ * The `source` prop: the Convex functions directory relative to the app
52
+ * directory (`rootDir`), such as `./convex`, or that directory with a push
53
+ * command and more files for the content identity.
53
54
  */
54
55
  export type Source =
55
56
  | string
56
57
  | {
57
- /** The Convex functions directory, such as `./convex`. */
58
+ /** The Convex functions directory relative to the app directory, such as `./convex`. */
58
59
  readonly path: string;
59
60
  /**
60
61
  * Replaces `convex deploy`: a program and its arguments, run in the app
@@ -109,17 +110,20 @@ export class ConvexCliNotFound extends Schema.TaggedError<ConvexCliNotFound>()(
109
110
  { cwd: Schema.String },
110
111
  ) {
111
112
  override get message() {
112
- return `Convex.Deployment found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), or set source to the functions directory of the app.`;
113
+ return `Convex.Deployment found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), or set rootDir to the directory of the app.`;
113
114
  }
114
115
  }
115
116
 
116
- /** No directory at or above the parent of `source` has a package.json. */
117
+ /** The app directory (`rootDir`) has no package.json. */
117
118
  export class ConvexAppNotFound extends Schema.TaggedError<ConvexAppNotFound>()(
118
119
  "ConvexAppNotFound",
119
- { source: Schema.String },
120
+ {
121
+ /** The app directory, resolved against the working directory of the deploy. */
122
+ rootDir: Schema.String,
123
+ },
120
124
  ) {
121
125
  override get message() {
122
- return `Convex.Deployment found no package.json at or above the parent of ${this.source}. Set source to the functions directory of a Convex app, such as "./convex".`;
126
+ return `Convex.Deployment found no package.json in ${this.rootDir}. Set rootDir to the directory of the Convex app, relative to the directory where the deploy runs, such as "apps/web".`;
123
127
  }
124
128
  }
125
129
 
@@ -227,32 +231,27 @@ const outputTail = (text: string, secrets: readonly string[]) =>
227
231
 
228
232
  const valueOf = (value: EnvValue) => (Redacted.isRedacted(value) ? Redacted.value(value) : value);
229
233
 
230
- /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
231
- const appDirectoryOf = (source: string) =>
232
- Effect.tryPromise({
233
- try: async () => {
234
- let directory = dirname(resolve(source));
235
- while (true) {
236
- try {
237
- await access(join(directory, "package.json"));
238
- return directory;
239
- } catch {
240
- const parent = dirname(directory);
241
- if (parent === directory) throw new Error("no package.json");
242
- directory = parent;
243
- }
244
- }
245
- },
246
- catch: () => new ConvexAppNotFound({ source }),
234
+ /**
235
+ * The app directory: `rootDir` resolved against the working directory of the
236
+ * deploy. It must have a package.json. There is no search above it.
237
+ */
238
+ const appDirectoryOf = (rootDir: string) => {
239
+ const directory = resolve(rootDir);
240
+ return Effect.tryPromise({
241
+ try: () => access(join(directory, "package.json")).then(() => directory),
242
+ catch: () => new ConvexAppNotFound({ rootDir: directory }),
247
243
  });
244
+ };
248
245
 
249
- /** `source` is not the functions directory that the Convex CLI pushes from this app. */
246
+ /** `source` is not the functions directory that the Convex CLI pushes from the app directory. */
250
247
  export class SourceMismatch extends Schema.TaggedError<SourceMismatch>()("SourceMismatch", {
248
+ /** The app directory, resolved. */
249
+ rootDir: Schema.String,
251
250
  source: Schema.String,
252
251
  functions: Schema.String,
253
252
  }) {
254
253
  override get message() {
255
- return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} in this app (the "functions" field of convex.json, or convex/ by default). Set source to ${this.functions}.`;
254
+ return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} from the app directory ${this.rootDir} (the "functions" field of convex.json, or convex/ by default). Set rootDir to the directory of the Convex app, and source to its functions directory relative to rootDir, such as "./convex".`;
256
255
  }
257
256
  }
258
257
 
@@ -269,8 +268,8 @@ const checkFunctionsDirectory = (cwd: string, path: string) =>
269
268
  ? undefined
270
269
  : Schema.decodeUnknownSync(Schema.fromJsonString(ConvexJson))(text).functions;
271
270
  const functions = resolve(cwd, configured ?? "convex");
272
- if (functions !== resolve(path)) {
273
- return yield* new SourceMismatch({ source: resolve(path), functions });
271
+ if (functions !== path) {
272
+ return yield* new SourceMismatch({ rootDir: cwd, source: path, functions });
274
273
  }
275
274
  });
276
275
 
@@ -300,6 +299,8 @@ export const installedCli = (cwd: string) =>
300
299
  * A change in a workspace package outside the app directory is not seen.
301
300
  */
302
301
  export const pushHash = (options: {
302
+ /** The app directory, relative to the working directory of the deploy or absolute. */
303
+ readonly rootDir: string;
303
304
  readonly source: Source | undefined;
304
305
  readonly deployment: string;
305
306
  readonly env: Readonly<Record<string, EnvValue>>;
@@ -309,28 +310,32 @@ export const pushHash = (options: {
309
310
  const files =
310
311
  source === undefined
311
312
  ? undefined
312
- : yield* appDirectoryOf(source.path).pipe(
313
- Effect.flatMap((cwd) =>
314
- hashDirectory({
313
+ : yield* appDirectoryOf(options.rootDir).pipe(
314
+ Effect.flatMap((cwd) => {
315
+ const functions = relative(cwd, resolve(cwd, source.path)).split("\\").join("/");
316
+ return hashDirectory({
315
317
  cwd,
316
318
  // By default every file of the app that git does not ignore,
317
- // such as a module outside `source` that a function imports.
318
- // `include` narrows it to the functions, the manifests, and
319
- // the listed globs.
319
+ // such as a module outside `source` that a function imports,
320
+ // and the functions when convex.json puts them outside
321
+ // rootDir. `include` narrows it to the functions, the
322
+ // manifests, and the listed globs.
320
323
  memo:
321
- source.include === undefined
322
- ? {}
323
- : {
324
+ source.include !== undefined
325
+ ? {
324
326
  include: [
325
- `${relative(cwd, resolve(source.path)).split("\\").join("/")}/**`,
327
+ `${functions}/**`,
326
328
  "package.json",
327
329
  "convex.json",
328
330
  ...source.include,
329
331
  ],
330
332
  lockfile: true,
331
- },
332
- }),
333
- ),
333
+ }
334
+ : functions === ".." || functions.startsWith("../")
335
+ ? { include: ["**/*", `${functions}/**`], lockfile: true }
336
+ : {},
337
+ });
338
+ }),
334
339
  );
335
340
  const env = Object.entries(options.env)
336
341
  .map(([name, value]): readonly [string, string] => [name, valueOf(value)])
@@ -469,7 +474,9 @@ export interface PushOptions {
469
474
  readonly deployment: string;
470
475
  /** Client URL of the deployment. */
471
476
  readonly url: string;
472
- /** The functions directory, or it with a push command. Without it, only the variables change. */
477
+ /** The app directory, relative to the working directory of the deploy or absolute. The push runs in it. */
478
+ readonly rootDir: string;
479
+ /** The functions directory relative to `rootDir`, or it with a push command. Without it, only the variables change. */
473
480
  readonly source: Source | undefined;
474
481
  readonly env: Readonly<Record<string, EnvValue>>;
475
482
  /** Names that an earlier push set on this deployment. Names that left `env` are removed. */
@@ -485,14 +492,14 @@ export interface PushOptions {
485
492
 
486
493
  /**
487
494
  * The app directory and the program of a push. Fails before any change when
488
- * `source` is not the functions directory of the app, or when the app has no
489
- * installed Convex CLI and no push command.
495
+ * `rootDir` has no package.json, when `source` is not the functions directory
496
+ * of the app, or when the app has no installed Convex CLI and no push command.
490
497
  */
491
- export const checkSource = (input: Source) =>
498
+ export const checkSource = (rootDir: string, input: Source) =>
492
499
  Effect.gen(function* () {
493
500
  const source = sourceOf(input);
494
- const cwd = yield* appDirectoryOf(source.path);
495
- yield* checkFunctionsDirectory(cwd, source.path);
501
+ const cwd = yield* appDirectoryOf(rootDir);
502
+ yield* checkFunctionsDirectory(cwd, resolve(cwd, source.path));
496
503
  const program = source.command ?? (yield* installedCli(cwd));
497
504
  return { cwd, program };
498
505
  });
@@ -510,7 +517,10 @@ export const push = (
510
517
  ) =>
511
518
  Effect.gen(function* () {
512
519
  const source = options.source === undefined ? undefined : sourceOf(options.source);
513
- const resolved = options.source === undefined ? undefined : yield* checkSource(options.source);
520
+ const resolved =
521
+ options.source === undefined
522
+ ? undefined
523
+ : yield* checkSource(options.rootDir, options.source);
514
524
  const cwd = resolved?.cwd;
515
525
  const program = resolved?.program;
516
526
  const env = Object.entries(options.env).sort(([a], [b]) => (a < b ? -1 : 1));
@@ -0,0 +1,208 @@
1
+ // The run record: what the stage output says about the run that wrote it.
2
+ // Alchemy state holds no time, author, commit, branch, or run (measured in
3
+ // the Samebase research of 2026-10-21), so a state layer adds this record to
4
+ // the output document under the reserved key `samebase.run`. The record
5
+ // depends on no store: it comes from the GitHub Actions variables, or from
6
+ // git on a laptop, and holds no secret. See docs/design/convex-state.md.
7
+ import { execFile } from "node:child_process";
8
+ import { readFile, realpath } from "node:fs/promises";
9
+ import { relative, sep } from "node:path";
10
+ import { promisify } from "node:util";
11
+ import * as Clock from "effect/Clock";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Schema from "effect/Schema";
14
+
15
+ /** The key of the record in the stage output document. */
16
+ export const RUN_RECORD_KEY = "samebase.run";
17
+
18
+ /** The name of the stack file. Alchemy resolves `--config` against the working directory and does not expose it to a state layer, so the record assumes the default name. */
19
+ export const STACK_FILE_NAME = "alchemy.run.ts";
20
+
21
+ /** Version 1 of the record. A reader validates `version` first. */
22
+ export interface RunRecord {
23
+ readonly version: 1;
24
+ /** GitHub `owner/name`. */
25
+ readonly repository: string;
26
+ /** Path of the stack file from the repository root, POSIX separators, such as `apps/samebase/alchemy.run.ts`. */
27
+ readonly stackFile: string;
28
+ /** The branch that was deployed: the head branch of a pull request, else the pushed branch. */
29
+ readonly branch: string;
30
+ /** The pull request whose head was deployed, or null for a push and for a laptop. */
31
+ readonly pullRequest: number | null;
32
+ /** The commit that was deployed: the head of the pull request, else GITHUB_SHA, else git HEAD. */
33
+ readonly headCommit: string;
34
+ /** The GitHub Actions run and attempt, or null for a laptop. */
35
+ readonly runUrl: string | null;
36
+ readonly source: "github-actions" | "laptop";
37
+ /** ISO 8601, when the record was written. */
38
+ readonly recordedAt: string;
39
+ }
40
+
41
+ export class RunRecordError extends Schema.TaggedError<RunRecordError>()("RunRecordError", {
42
+ reason: Schema.String,
43
+ }) {
44
+ override get message() {
45
+ return `The run record of the stage output cannot be made: ${this.reason}`;
46
+ }
47
+ }
48
+
49
+ /** Where the run happens: the working directory of the stack and the process environment. */
50
+ export interface RunEnvironment {
51
+ readonly cwd: string;
52
+ readonly env: Readonly<Record<string, string | undefined>>;
53
+ }
54
+
55
+ /** The part of the GitHub Actions event payload that the record reads. A push event has no `pull_request`. */
56
+ const EventPayload = Schema.Struct({
57
+ pull_request: Schema.optionalKey(
58
+ Schema.Struct({ number: Schema.Number, head: Schema.Struct({ sha: Schema.String }) }),
59
+ ),
60
+ });
61
+
62
+ /** `owner/name` of an https, ssh, or scp-like git URL with a host. A local path is not one. */
63
+ const REMOTE_URL = /^(?:[a-z+]+:\/\/[^/]+\/|[^/@:]+@[^:/]+:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
64
+
65
+ const execFileAsync = promisify(execFile);
66
+
67
+ const now = Clock.currentTimeMillis.pipe(Effect.map((millis) => new Date(millis).toISOString()));
68
+
69
+ const git = (cwd: string, args: readonly string[]) =>
70
+ Effect.tryPromise({
71
+ try: async () =>
72
+ (await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" })).stdout.trim(),
73
+ catch: (error) =>
74
+ new RunRecordError({
75
+ reason: `git ${args.join(" ")} failed in ${cwd} (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`,
76
+ }),
77
+ });
78
+
79
+ /** The stack file from the repository root: the working directory below the git top level, plus {@link STACK_FILE_NAME}. */
80
+ const stackFileOf = (cwd: string) =>
81
+ Effect.gen(function* () {
82
+ const top = yield* git(cwd, ["rev-parse", "--show-toplevel"]);
83
+ // git prints the real path, and the working directory can hold a symlink, such as macOS /tmp.
84
+ const [realTop, realCwd] = yield* Effect.tryPromise({
85
+ try: () => Promise.all([realpath(top), realpath(cwd)]),
86
+ catch: () => new RunRecordError({ reason: `${cwd} is gone` }),
87
+ });
88
+ const directory = relative(realTop, realCwd)
89
+ .split(sep)
90
+ .filter((part) => part !== "");
91
+ return [...directory, STACK_FILE_NAME].join("/");
92
+ });
93
+
94
+ const variable = (env: RunEnvironment["env"], name: string) =>
95
+ Effect.suspend(() => {
96
+ const value = env[name];
97
+ return value === undefined || value === ""
98
+ ? Effect.fail(
99
+ new RunRecordError({ reason: `GITHUB_ACTIONS is true, and ${name} is not set` }),
100
+ )
101
+ : Effect.succeed(value);
102
+ });
103
+
104
+ const fromGitHubActions = (where: RunEnvironment) =>
105
+ Effect.gen(function* () {
106
+ const { env } = where;
107
+ const repository = yield* variable(env, "GITHUB_REPOSITORY");
108
+ const refName = yield* variable(env, "GITHUB_REF_NAME");
109
+ const sha = yield* variable(env, "GITHUB_SHA");
110
+ const serverUrl = yield* variable(env, "GITHUB_SERVER_URL");
111
+ const runId = yield* variable(env, "GITHUB_RUN_ID");
112
+ const runAttempt = yield* variable(env, "GITHUB_RUN_ATTEMPT");
113
+ const eventPath = env["GITHUB_EVENT_PATH"];
114
+ const payload =
115
+ eventPath === undefined || eventPath === ""
116
+ ? undefined
117
+ : yield* Effect.tryPromise({
118
+ try: () => readFile(eventPath, "utf8"),
119
+ catch: () =>
120
+ new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} cannot be read` }),
121
+ }).pipe(
122
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(EventPayload))),
123
+ Effect.mapError((error) =>
124
+ error instanceof RunRecordError
125
+ ? error
126
+ : new RunRecordError({
127
+ reason: `GITHUB_EVENT_PATH ${eventPath} is not an event payload`,
128
+ }),
129
+ ),
130
+ );
131
+ const pullRequest = payload?.pull_request;
132
+ // GITHUB_SHA is the merge commit on a pull request event; the head of the pull request is what a person sees.
133
+ const headCommit = pullRequest?.head.sha ?? sha;
134
+ const headRef = env["GITHUB_HEAD_REF"];
135
+ const mergeRef = /^refs\/pull\/(\d+)\/merge$/.exec(env["GITHUB_REF"] ?? "");
136
+ const stackFile = yield* stackFileOf(where.cwd);
137
+ const record: RunRecord = {
138
+ version: 1,
139
+ repository,
140
+ stackFile,
141
+ branch: headRef === undefined || headRef === "" ? refName : headRef,
142
+ pullRequest: pullRequest?.number ?? (mergeRef === null ? null : Number(mergeRef[1])),
143
+ headCommit,
144
+ runUrl: `${serverUrl}/${repository}/actions/runs/${runId}/attempts/${runAttempt}`,
145
+ source: "github-actions",
146
+ recordedAt: yield* now,
147
+ };
148
+ return record;
149
+ });
150
+
151
+ const fromGit = (where: RunEnvironment) =>
152
+ Effect.gen(function* () {
153
+ const { cwd } = where;
154
+ const origin = yield* git(cwd, ["remote", "get-url", "origin"]);
155
+ const match = REMOTE_URL.exec(origin);
156
+ if (match === null) {
157
+ return yield* new RunRecordError({
158
+ reason: `the origin remote of ${cwd} is not a git URL with a host and owner/name: ${origin}`,
159
+ });
160
+ }
161
+ const branch = yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
162
+ if (branch === "HEAD") {
163
+ return yield* new RunRecordError({
164
+ reason: `the checkout at ${cwd} is on no branch (detached HEAD)`,
165
+ });
166
+ }
167
+ const record: RunRecord = {
168
+ version: 1,
169
+ repository: `${match[1]}/${match[2]}`,
170
+ stackFile: yield* stackFileOf(cwd),
171
+ branch,
172
+ pullRequest: null,
173
+ headCommit: yield* git(cwd, ["rev-parse", "HEAD"]),
174
+ runUrl: null,
175
+ source: "laptop",
176
+ recordedAt: yield* now,
177
+ };
178
+ return record;
179
+ });
180
+
181
+ /**
182
+ * The record of this run: from the GitHub Actions variables and the event
183
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
184
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
185
+ * missing; nothing is guessed.
186
+ */
187
+ export const runRecord = (where: RunEnvironment): Effect.Effect<RunRecord, RunRecordError> =>
188
+ where.env["GITHUB_ACTIONS"] === "true" ? fromGitHubActions(where) : fromGit(where);
189
+
190
+ /** A plain object, which a spread keeps whole. A Date, a Duration, or a Redacted value is an object too, and a spread loses it. */
191
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
192
+ if (typeof value !== "object" || value === null) return false;
193
+ const prototype: unknown = Object.getPrototypeOf(value);
194
+ return prototype === Object.prototype || prototype === null;
195
+ };
196
+
197
+ /**
198
+ * The output document of a stage: the stack's output plus the record under
199
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
200
+ * key: a string, an array, null, or an object that is not plain, such as a
201
+ * Date. A value that the stack itself put under the key stays.
202
+ */
203
+ export const withRunRecord = (output: unknown, record: RunRecord): object | undefined =>
204
+ isPlainObject(output)
205
+ ? RUN_RECORD_KEY in output
206
+ ? output
207
+ : { ...output, [RUN_RECORD_KEY]: record }
208
+ : undefined;