@reventlessdev/reventless-spec 3.0.0-alpha.154 → 3.0.0-alpha.156

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/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.156 (2026-10-01)
7
+
8
+ * feat!: roles a plugin declares, groups a platform provides ([43daace](https://github.com/ReventlessDev/reventless-core/commit/43daace6ca588afe22562d079af1e6f1da81942e))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * AllowGroups and AdminGroup are removed — write AllowRoles over
13
+ the plugin's Roles.t, and use Role.admin / Platform.adminGroup(). Spec module
14
+ types declare `type role`, and their rules are Authorization.rule<role>; a spec
15
+ that writes its own rule declares `type role` (Reventless.Role.name for a rule
16
+ over Authorization.permission). Role.name is a private string: make one from a
17
+ string with Role.make.
18
+
19
+
20
+
21
+ # 3.0.0-alpha.155 (2026-10-01)
22
+
23
+ ### Features
24
+
25
+ * **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
26
+ * **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
27
+
28
+
6
29
  # 3.0.0-alpha.154 (2026-09-29)
7
30
 
8
31
  **Note:** Version bump only for package @reventlessdev/reventless-spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.154",
3
+ "version": "3.0.0-alpha.156",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -55,12 +55,15 @@ module type Spec = {
55
55
  the npm specifier for runtime dynamic imports. */
56
56
  let moduleUrl: string
57
57
 
58
+ /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
59
+ supplies; `Role.name` where the plugin declares none. */
60
+ type role
58
61
  /** Authorization rule evaluated at the GraphQL resolver entry before any
59
62
  command is dispatched. Auto-injected by `@@reventless.spec` and on
60
63
  structurally-detected inline spec modules — defaults to
61
64
  `AllowAuthenticated`; override at the file/module level with
62
65
  `@@reventless.authorize(<rule>)`. */
63
- let commandAuthorization: command => Authorization.permission
66
+ let commandAuthorization: command => Authorization.rule<role>
64
67
 
65
68
  /** The lifecycle enum this component's commands move a row through — the
66
69
  linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
@@ -56,11 +56,14 @@ module type Spec = {
56
56
  Auto-injected by `@@reventless.spec` defaulting to `None` — set it to name the box. */
57
57
  let externalSystem: option<string>
58
58
 
59
+ /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
60
+ supplies; `Role.name` where the plugin declares none. */
61
+ type role
59
62
  /** Authorization rule evaluated at the GraphQL resolver entry before any
60
63
  external input is translated. Auto-injected by `@@reventless.spec` and
61
64
  on structurally-detected inline spec modules — defaults to
62
65
  `AllowAuthenticated`. */
63
- let commandAuthorization: command => Authorization.permission
66
+ let commandAuthorization: command => Authorization.rule<role>
64
67
 
65
68
  /** The lifecycle enum this component's commands move a row through — the
66
69
  linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
@@ -138,3 +138,33 @@ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string>
138
138
  | _ => []
139
139
  }
140
140
  }
141
+
142
+ /**
143
+ The owner fields of every constructor that declares one, keyed by its tag.
144
+
145
+ Read off a slice's consumed events, this says which recorded facts name the
146
+ owner of the partition a command acts on. An empty dict means the union records
147
+ no owner, and the handler then has nothing to enforce — so, as with every reader
148
+ here, it must be handed the real schema: a permissive stand-in answers empty.
149
+ */
150
+ let fieldNamesByVariant = (schema: S.t<unknown>): dict<array<string>> => {
151
+ let byVariant = Dict.make()
152
+ let visit = (properties: dict<S.t<unknown>>) =>
153
+ switch (properties->Dict.get("TAG"), fieldNamesOfProperties(properties)) {
154
+ | (_, []) => ()
155
+ | (Some(String({const: ?Some(name)})), fields) => byVariant->Dict.set(name, fields)
156
+ | _ => ()
157
+ }
158
+ switch schema {
159
+ | AnyOf({anyOf}) =>
160
+ anyOf->Array.forEach(v =>
161
+ switch v {
162
+ | Object({properties}) => visit(properties)
163
+ | _ => ()
164
+ }
165
+ )
166
+ | Object({properties}) => visit(properties)
167
+ | _ => ()
168
+ }
169
+ byVariant
170
+ }
@@ -90,6 +90,41 @@ function variantFieldNames(schema, variant) {
90
90
  }
91
91
  }
92
92
 
93
+ function fieldNamesByVariant(schema) {
94
+ let byVariant = {};
95
+ let visit = properties => {
96
+ let match = properties["TAG"];
97
+ let match$1 = fieldNamesOfProperties(properties);
98
+ if (match$1.length === 0) {
99
+ return;
100
+ }
101
+ if (match === undefined) {
102
+ return;
103
+ }
104
+ if (match.type !== "string") {
105
+ return;
106
+ }
107
+ let name = match.const;
108
+ if (name !== undefined) {
109
+ byVariant[name] = match$1;
110
+ return;
111
+ }
112
+ };
113
+ switch (schema.type) {
114
+ case "object" :
115
+ visit(schema.properties);
116
+ break;
117
+ case "anyOf" :
118
+ schema.anyOf.forEach(v => {
119
+ if (v.type === "object") {
120
+ return visit(v.properties);
121
+ }
122
+ });
123
+ break;
124
+ }
125
+ return byVariant;
126
+ }
127
+
93
128
  export {
94
129
  ownerId,
95
130
  mark,
@@ -100,5 +135,6 @@ export {
100
135
  fieldNamesOfProperties,
101
136
  fieldNames,
102
137
  variantFieldNames,
138
+ fieldNamesByVariant,
103
139
  }
104
140
  /* ownerId Not a pure module */
@@ -194,6 +194,10 @@ type commandDef = {
194
194
  /** Access keys — any one of them — a caller needs to be *offered* this command.
195
195
  A hint derived from the server's rule, never the refusal itself. */
196
196
  requiredAccess: option<array<string>>,
197
+ /** The roles `requiredAccess` was mapped from, as the rule names them. Absent
198
+ where the rule names none; read by the deploy check that every role maps to
199
+ a group the platform provides. */
200
+ requiredRoles?: array<string>,
197
201
  /** The `@owner` command field the server stamps with the caller's identity; a
198
202
  client omits it from a form, since whatever it collects is discarded. */
199
203
  ownerField: option<string>,
@@ -249,6 +253,8 @@ type queryableDef = {
249
253
  /** Access keys — any one of them — a caller needs to be *offered* this view. A
250
254
  denied read comes back empty rather than erroring, hence the hint. */
251
255
  requiredAccess: option<array<string>>,
256
+ /** The roles `requiredAccess` was mapped from; see `commandDef`. */
257
+ requiredRoles?: array<string>,
252
258
  }
253
259
 
254
260
  /** One emitted event of a write side. `name` is the variant name, `schema` its
@@ -326,6 +332,8 @@ type inboundTranslationSliceDef = {
326
332
  externalSystem: option<string>,
327
333
  /** Chapter grouping band — see `queryableDef.chapter`. */
328
334
  chapter: option<string>,
335
+ /** The roles any of its commands' rules name; see `commandDef`. */
336
+ requiredRoles?: array<string>,
329
337
  }
330
338
 
331
339
  /** A published event of an extension point and the internal events producing it.
@@ -102,6 +102,7 @@ let commandDefSchema = Sury.$schema(s => ({
102
102
  allowedStatesSource: s.m(Sury.$option(Sury.string)),
103
103
  apiExposed: s.m(Sury.$option(Sury.boolean)),
104
104
  requiredAccess: s.m(Sury.$option(Sury.array(Sury.string))),
105
+ requiredRoles: s.m(Sury.$option(Sury.array(Sury.string))),
105
106
  ownerField: s.m(Sury.$option(Sury.string))
106
107
  }));
107
108
 
@@ -124,7 +125,8 @@ let queryableDefSchema = Sury.$schema(s => ({
124
125
  singleQueryField: s.m(Sury.$option(Sury.string)),
125
126
  idField: s.m(Sury.$option(Sury.string)),
126
127
  idFieldSource: s.m(Sury.$option(Sury.string)),
127
- requiredAccess: s.m(Sury.$option(Sury.array(Sury.string)))
128
+ requiredAccess: s.m(Sury.$option(Sury.array(Sury.string))),
129
+ requiredRoles: s.m(Sury.$option(Sury.array(Sury.string)))
128
130
  }));
129
131
 
130
132
  let eventDefSchema = Sury.$schema(s => ({
@@ -174,7 +176,8 @@ let inboundTranslationSliceDefSchema = Sury.$schema(s => ({
174
176
  commandTypes: s.m(Sury.array(Sury.string)),
175
177
  targetName: s.m(Sury.string),
176
178
  externalSystem: s.m(Sury.$option(Sury.string)),
177
- chapter: s.m(Sury.$option(Sury.string))
179
+ chapter: s.m(Sury.$option(Sury.string)),
180
+ requiredRoles: s.m(Sury.$option(Sury.array(Sury.string)))
178
181
  }));
179
182
 
180
183
  let publishedEventDefSchema = Sury.$schema(s => ({
@@ -190,12 +190,15 @@ module type Spec = {
190
190
  /** Optional composite-key configuration. `None` for single-key tables. */
191
191
  let subIdConfig: option<subIdConfig<state>>
192
192
 
193
+ /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
194
+ supplies; `Role.name` where the plugin declares none. */
195
+ type role
193
196
  /** Authorization rule evaluated at the GraphQL resolver entry before any
194
197
  query is resolved. Auto-injected by `@@reventless.spec` and on
195
198
  structurally-detected inline spec modules — defaults to
196
199
  `AllowAuthenticated`; override at the file/module level with
197
200
  `@@reventless.authorize(<rule>)`. */
198
- let authorization: Authorization.permission
201
+ let authorization: Authorization.rule<role>
199
202
 
200
203
  /** AutoUI visibility hint. Auto-injected by `@@reventless.spec` and on
201
204
  structurally-detected inline spec modules — defaults to
@@ -75,12 +75,15 @@ module type Spec = {
75
75
  /** Schema for the command type — used to extract DCB tags for the conditional read. */
76
76
  let commandSchema: S.t<command>
77
77
 
78
+ /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
79
+ supplies; `Role.name` where the plugin declares none. */
80
+ type role
78
81
  /** Authorization rule evaluated at the GraphQL resolver entry before any
79
82
  command is dispatched. Auto-injected by `@@reventless.spec` and on
80
83
  structurally-detected inline spec modules — defaults to
81
84
  `AllowAuthenticated`; override at the file/module level with
82
85
  `@@reventless.authorize(<rule>)`. */
83
- let commandAuthorization: command => Authorization.permission
86
+ let commandAuthorization: command => Authorization.rule<role>
84
87
 
85
88
  /** The lifecycle enum this component's commands move a row through — the
86
89
  linked view's own, e.g. `type lifecycleState = Customers.accountStatus`.
@@ -73,11 +73,14 @@ module type Spec = {
73
73
  /** Optional composite-key configuration. `None` for single-key tables. */
74
74
  let subIdConfig: option<ReadModel.subIdConfig<state>>
75
75
 
76
+ /** The roles this spec's rules name: its plugin's `Roles.t`, which the PPX
77
+ supplies; `Role.name` where the plugin declares none. */
78
+ type role
76
79
  /** Authorization rule evaluated at the GraphQL resolver entry before any
77
80
  query is resolved. Auto-injected by `@@reventless.spec` and on
78
81
  structurally-detected inline spec modules — defaults to
79
82
  `AllowAuthenticated`. */
80
- let authorization: Authorization.permission
83
+ let authorization: Authorization.rule<role>
81
84
 
82
85
  /** AutoUI visibility hint. Auto-injected by `@@reventless.spec` and on
83
86
  structurally-detected inline spec modules — defaults to
@@ -687,6 +687,9 @@ let observe = (
687
687
 
688
688
  scenarios->Array.filterMap(s =>
689
689
  switch s.whenElements->Array.get(0) {
690
+ // `thenRefused`: the caller did not own the row, which says nothing about
691
+ // the states the command is legal in.
692
+ | Some(_) if s.thenKind == "forbidden" => None
690
693
  | Some(command) if s.whenKind == "command" =>
691
694
  let idField = idFieldFor(command.name)
692
695
  let idValue = idField->Option.flatMap(field => command.values->valueOf(~field))
@@ -503,6 +503,9 @@ function observe(scenarios, map, idFieldFor) {
503
503
  if (command === undefined) {
504
504
  return;
505
505
  }
506
+ if (s.thenKind === "forbidden") {
507
+ return;
508
+ }
506
509
  if (s.whenKind !== "command") {
507
510
  return;
508
511
  }
@@ -8,7 +8,7 @@ reads it and writes back into it.
8
8
 
9
9
  Here rather than in either platform package because a manifest that works on one
10
10
  platform and silently fails on the other is the whole cost being avoided. It sits
11
- beside [Identity] and [AdminGroup] for the same reason those do: the vocabulary
11
+ beside [Identity] and [Role] for the same reason those do: the vocabulary
12
12
  the two platforms share about who is signed in.
13
13
 
14
14
  🚨 **The password field is a bootstrap credential, not a stored secret.** The file
@@ -237,6 +237,28 @@ let locate = (~given: option<string>=?, ()): result<located, string> =>
237
237
  }
238
238
  }
239
239
 
240
+ /**
241
+ The groups the accounts here belong to: the manifest's, else the template's, else
242
+ none. What a platform provides beyond its administrator group, for the check that
243
+ every role a plugin needs has a group.
244
+
245
+ Read, never seeded — this runs in a deploy or a start, which must not write the
246
+ file `locate` would. The template counts because it is the cast a fresh clone
247
+ provisions; a deploy from CI has no manifest and the same accounts to come.
248
+ */
249
+ let declaredGroups = (): array<string> => {
250
+ let template = NodePath.join([NodeProcess.cwd(), templateName])
251
+ let file = NodeFs.existsSync(defaultPath())
252
+ ? Some(defaultPath())
253
+ : NodeFs.existsSync(template)
254
+ ? Some(template)
255
+ : None
256
+ switch file->Option.map(parseFile) {
257
+ | Some(Ok(entries)) => entries->Array.flatMap(e => e.groups)
258
+ | Some(Error(_)) | None => []
259
+ }
260
+ }
261
+
240
262
  // ── Preparing ────────────────────────────────────────────────────────────────
241
263
 
242
264
  /** One account after preparation: what the file now declares, and whether this
@@ -246,6 +246,23 @@ function locate(given, param) {
246
246
  }
247
247
  }
248
248
 
249
+ function declaredGroups() {
250
+ let template = Nodepath.join(process.cwd(), templateName);
251
+ let file = Nodefs.existsSync(defaultPath()) ? defaultPath() : (
252
+ Nodefs.existsSync(template) ? template : undefined
253
+ );
254
+ let match = Stdlib_Option.map(file, parseFile);
255
+ if (match !== undefined) {
256
+ if (match.TAG === "Ok") {
257
+ return match._0.flatMap(e => e.groups);
258
+ } else {
259
+ return [];
260
+ }
261
+ } else {
262
+ return [];
263
+ }
264
+ }
265
+
249
266
  function prepare(file) {
250
267
  let e = parseFile(file);
251
268
  if (e.TAG !== "Ok") {
@@ -306,6 +323,7 @@ export {
306
323
  templateName,
307
324
  pathOf,
308
325
  locate,
326
+ declaredGroups,
309
327
  prepare,
310
328
  }
311
329
  /* entrySchema Not a pure module */
@@ -1,22 +1,49 @@
1
1
  // Provider-agnostic authorization rules. Evaluated against the
2
2
  // `Identity.t` resolved by an `Auth_Adapter.Provider.authenticate` call.
3
3
  //
4
- // `array<string>` for groups (not a parameterised variant) keeps the
5
- // framework decoupled from any application's specific group set —
6
- // applications can define their own typed `group` variant and convert via
7
- // a thin helper, see docs/analysis/authentication-authorization.md §4.2.
4
+ // A spec writes its rules over its plugin's own roles, `rule<Roles.t>`, so a
5
+ // misspelled role does not compile. The framework reads them as `permission`,
6
+ // the same rule over role names, which `Role.groupOf` maps to the group that
7
+ // stands for each role in this deployment.
8
8
 
9
- @schema
10
- type permission =
11
- | AllowGroups(array<string>)
9
+ type rule<'role> =
10
+ | AllowRoles(array<'role>)
12
11
  | AllowAuthenticated
13
12
  | AllowAnonymous
14
13
  | DenyAll
15
14
 
16
- let isAllowed = (rule: permission, identity: Identity.t): bool =>
15
+ /** A rule over role names: what the framework compares, maps and publishes. */
16
+ type permission = rule<Role.name>
17
+
18
+ /** A spec's rule as the framework reads it. Each role must be a case without a
19
+ payload, which is its name at run time; anything else is refused. */
20
+ @module("./authorizationNamed.mjs")
21
+ external named: rule<'role> => permission = "named"
22
+
23
+ /** The roles a rule names, before mapping. */
24
+ let rolesOf = (rule: permission): array<Role.name> =>
25
+ switch rule {
26
+ | AllowRoles(roles) => roles
27
+ | AllowAuthenticated | AllowAnonymous | DenyAll => []
28
+ }
29
+
30
+ /** The groups a rule admits, mapped. */
31
+ let groupsOf = (rule: permission): array<string> => rule->rolesOf->Array.map(Role.groupOf)
32
+
33
+ /**
34
+ Whether a rule admits a caller, given whether they are signed in and which roles
35
+ they hold. The one decision both `isAllowed` and a GWT scenario make, so the two
36
+ cannot read a rule differently.
37
+ */
38
+ let admits = (rule: permission, ~signedIn: bool, ~holds: Role.name => bool): bool =>
17
39
  switch rule {
18
40
  | DenyAll => false
19
41
  | AllowAnonymous => true
20
- | AllowAuthenticated => identity.userId !== "anonymous"
21
- | AllowGroups(groups) => groups->Array.some(group => identity.groups->Array.includes(group))
42
+ | AllowAuthenticated => signedIn
43
+ | AllowRoles(roles) => roles->Array.some(holds)
22
44
  }
45
+
46
+ let isAllowed = (rule: permission, identity: Identity.t): bool =>
47
+ admits(rule, ~signedIn=identity.userId !== "anonymous", ~holds=role =>
48
+ identity.groups->Array.includes(Role.groupOf(role))
49
+ )
@@ -1,24 +1,31 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
- import * as Sury from "sury";
3
+ import * as Role$Reventless from "./Role.res.mjs";
4
+ import * as AuthorizationNamedMjs from "./authorizationNamed.mjs";
4
5
 
5
- let permissionSchema = Sury.union([
6
- Sury.$schema(s => ({
7
- TAG: "AllowGroups",
8
- _0: s.m(Sury.array(Sury.string))
9
- })),
10
- Sury.literal("AllowAuthenticated"),
11
- Sury.literal("AllowAnonymous"),
12
- Sury.literal("DenyAll")
13
- ]);
6
+ function named(prim) {
7
+ return AuthorizationNamedMjs.named(prim);
8
+ }
14
9
 
15
- function isAllowed(rule, identity) {
10
+ function rolesOf(rule) {
11
+ if (typeof rule !== "object") {
12
+ return [];
13
+ } else {
14
+ return rule._0;
15
+ }
16
+ }
17
+
18
+ function groupsOf(rule) {
19
+ return rolesOf(rule).map(Role$Reventless.groupOf);
20
+ }
21
+
22
+ function admits(rule, signedIn, holds) {
16
23
  if (typeof rule === "object") {
17
- return rule._0.some(group => identity.groups.includes(group));
24
+ return rule._0.some(holds);
18
25
  }
19
26
  switch (rule) {
20
27
  case "AllowAuthenticated" :
21
- return identity.userId !== "anonymous";
28
+ return signedIn;
22
29
  case "AllowAnonymous" :
23
30
  return true;
24
31
  case "DenyAll" :
@@ -26,8 +33,15 @@ function isAllowed(rule, identity) {
26
33
  }
27
34
  }
28
35
 
36
+ function isAllowed(rule, identity) {
37
+ return admits(rule, identity.userId !== "anonymous", role => identity.groups.includes(Role$Reventless.groupOf(role)));
38
+ }
39
+
29
40
  export {
30
- permissionSchema,
41
+ named,
42
+ rolesOf,
43
+ groupsOf,
44
+ admits,
31
45
  isAllowed,
32
46
  }
33
- /* permissionSchema Not a pure module */
47
+ /* Role-Reventless Not a pure module */
@@ -0,0 +1,84 @@
1
+ /***
2
+ Where a deployment came from: the commit it was built from, whether the tree had
3
+ uncommitted changes, and the environment it was made for.
4
+
5
+ Read by the deploy while it runs, and recorded with the deployment, so a reader
6
+ can say which commit runs in which environment without asking whoever deployed.
7
+ A deployment from CI knows its commit from the CI's variables; one from a laptop
8
+ asks git. Neither gives up a timestamp in place of a commit: a timestamp is not
9
+ an answer to *which commit*, and a reader cannot tell it from one.
10
+ */
11
+
12
+ type t = {
13
+ commit: option<string>,
14
+ /** True only when the commit came from git and the working tree had changes:
15
+ what was deployed is not what the commit says. */
16
+ dirty: bool,
17
+ environment: option<string>,
18
+ purpose: option<string>,
19
+ tag: option<string>,
20
+ pullRequest: option<int>,
21
+ }
22
+
23
+ /** What git can say about the checkout the deploy runs in. */
24
+ type git = {
25
+ head: unit => option<string>,
26
+ dirty: unit => bool,
27
+ }
28
+
29
+ let nonEmpty = (value: option<string>): option<string> =>
30
+ value->Option.flatMap(v => v->String.trim == "" ? None : Some(v->String.trim))
31
+
32
+ /**
33
+ The provenance of a deployment from the process's variables, asking git only when
34
+ no CI variable names the commit.
35
+
36
+ `REVENTLESS_COMMIT` comes first because on a pull request `GITHUB_SHA` is the merge
37
+ commit GitHub made, not the head the review environment runs.
38
+ */
39
+ let fromEnv = (env: dict<string>, ~git: git): t => {
40
+ let get = name => env->Dict.get(name)->nonEmpty
41
+ let fromCi =
42
+ get("REVENTLESS_COMMIT")
43
+ ->Option.orElse(get("GITHUB_SHA"))
44
+ ->Option.orElse(get("CI_COMMIT_SHA"))
45
+ let (commit, dirty) = switch fromCi {
46
+ | Some(_) => (fromCi, false)
47
+ | None =>
48
+ switch git.head() {
49
+ | Some(_) as head => (head, git.dirty())
50
+ | None => (None, false)
51
+ }
52
+ }
53
+ {
54
+ commit,
55
+ dirty,
56
+ environment: get("REVENTLESS_ENVIRONMENT"),
57
+ purpose: get("REVENTLESS_ENVIRONMENT_PURPOSE"),
58
+ tag: get("GITHUB_REF_TYPE") == Some("tag") ? get("GITHUB_REF_NAME") : None,
59
+ pullRequest: get("REVENTLESS_PULL_REQUEST")->Option.flatMap(n => Int.fromString(n)),
60
+ }
61
+ }
62
+
63
+ let run = (~cwd: string, args: array<string>): option<string> =>
64
+ try {
65
+ Some(
66
+ NodeChildProcess.execFileSync(
67
+ "git",
68
+ args,
69
+ {cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]},
70
+ )->String.trim,
71
+ )
72
+ } catch {
73
+ | _ => None
74
+ }
75
+
76
+ /** Git in `cwd`. A directory that is not a checkout, or a machine with no git,
77
+ answers *no commit* and *not dirty*. */
78
+ let gitAt = (~cwd: string): git => {
79
+ head: () => run(~cwd, ["rev-parse", "HEAD"])->nonEmpty,
80
+ dirty: () => run(~cwd, ["status", "--porcelain"])->nonEmpty->Option.isSome,
81
+ }
82
+
83
+ /** The provenance of the deployment this process is making, in its working directory. */
84
+ let current = (): t => fromEnv(NodeProcess.env, ~git=gitAt(~cwd=NodeProcess.cwd()))
@@ -0,0 +1,86 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+ import * as Nodechild_process from "node:child_process";
7
+
8
+ function nonEmpty(value) {
9
+ return Stdlib_Option.flatMap(value, v => {
10
+ if (v.trim() === "") {
11
+ return;
12
+ } else {
13
+ return v.trim();
14
+ }
15
+ });
16
+ }
17
+
18
+ function fromEnv(env, git) {
19
+ let fromCi = Stdlib_Option.orElse(Stdlib_Option.orElse(nonEmpty(env["REVENTLESS_COMMIT"]), nonEmpty(env["GITHUB_SHA"])), nonEmpty(env["CI_COMMIT_SHA"]));
20
+ let match;
21
+ if (fromCi !== undefined) {
22
+ match = [
23
+ fromCi,
24
+ false
25
+ ];
26
+ } else {
27
+ let head = git.head();
28
+ match = head !== undefined ? [
29
+ head,
30
+ git.dirty()
31
+ ] : [
32
+ undefined,
33
+ false
34
+ ];
35
+ }
36
+ return {
37
+ commit: match[0],
38
+ dirty: match[1],
39
+ environment: nonEmpty(env["REVENTLESS_ENVIRONMENT"]),
40
+ purpose: nonEmpty(env["REVENTLESS_ENVIRONMENT_PURPOSE"]),
41
+ tag: Primitive_object.equal(nonEmpty(env["GITHUB_REF_TYPE"]), "tag") ? nonEmpty(env["GITHUB_REF_NAME"]) : undefined,
42
+ pullRequest: Stdlib_Option.flatMap(nonEmpty(env["REVENTLESS_PULL_REQUEST"]), n => Stdlib_Int.fromString(n, undefined))
43
+ };
44
+ }
45
+
46
+ function run(cwd, args) {
47
+ try {
48
+ return Nodechild_process.execFileSync("git", args, {
49
+ cwd: cwd,
50
+ encoding: "utf8",
51
+ stdio: [
52
+ "ignore",
53
+ "pipe",
54
+ "ignore"
55
+ ]
56
+ }).trim();
57
+ } catch (exn) {
58
+ return;
59
+ }
60
+ }
61
+
62
+ function gitAt(cwd) {
63
+ return {
64
+ head: () => nonEmpty(run(cwd, [
65
+ "rev-parse",
66
+ "HEAD"
67
+ ])),
68
+ dirty: () => Stdlib_Option.isSome(nonEmpty(run(cwd, [
69
+ "status",
70
+ "--porcelain"
71
+ ])))
72
+ };
73
+ }
74
+
75
+ function current() {
76
+ return fromEnv(process.env, gitAt(process.cwd()));
77
+ }
78
+
79
+ export {
80
+ nonEmpty,
81
+ fromEnv,
82
+ run,
83
+ gitAt,
84
+ current,
85
+ }
86
+ /* node:child_process Not a pure module */