@shipfox/api-definitions 11.0.0 → 12.2.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.
Files changed (174) hide show
  1. package/.turbo/turbo-build.log +13 -12
  2. package/CHANGELOG.md +106 -0
  3. package/dist/core/entities/index.d.ts +1 -0
  4. package/dist/core/entities/index.d.ts.map +1 -1
  5. package/dist/core/entities/index.js +1 -0
  6. package/dist/core/entities/index.js.map +1 -1
  7. package/dist/core/entities/sync-state.d.ts +5 -1
  8. package/dist/core/entities/sync-state.d.ts.map +1 -1
  9. package/dist/core/entities/sync-state.js +10 -0
  10. package/dist/core/entities/sync-state.js.map +1 -1
  11. package/dist/core/entities/validation-warning.d.ts +6 -0
  12. package/dist/core/entities/validation-warning.d.ts.map +1 -0
  13. package/dist/core/entities/validation-warning.js +3 -0
  14. package/dist/core/entities/validation-warning.js.map +1 -0
  15. package/dist/core/errors.d.ts +2 -2
  16. package/dist/core/errors.d.ts.map +1 -1
  17. package/dist/core/integrations.d.ts +1 -1
  18. package/dist/core/parse-definition.d.ts +9 -7
  19. package/dist/core/parse-definition.d.ts.map +1 -1
  20. package/dist/core/parse-definition.js +14 -2
  21. package/dist/core/parse-definition.js.map +1 -1
  22. package/dist/core/sync-definitions.d.ts +2 -0
  23. package/dist/core/sync-definitions.d.ts.map +1 -1
  24. package/dist/core/sync-definitions.js +6 -5
  25. package/dist/core/sync-definitions.js.map +1 -1
  26. package/dist/core/validate-definition.d.ts +9 -5
  27. package/dist/core/validate-definition.d.ts.map +1 -1
  28. package/dist/core/validate-definition.js +23 -2
  29. package/dist/core/validate-definition.js.map +1 -1
  30. package/dist/core/workflow-model/index.d.ts +1 -1
  31. package/dist/core/workflow-model/index.d.ts.map +1 -1
  32. package/dist/core/workflow-model/index.js.map +1 -1
  33. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +3 -1
  34. package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
  35. package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
  36. package/dist/core/workflow-model/normalize-if-condition.d.ts +0 -1
  37. package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -1
  38. package/dist/core/workflow-model/normalize-if-condition.js +0 -1
  39. package/dist/core/workflow-model/normalize-if-condition.js.map +1 -1
  40. package/dist/core/workflow-model/normalize-job-checkout.d.ts +15 -3
  41. package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -1
  42. package/dist/core/workflow-model/normalize-job-checkout.js +83 -4
  43. package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -1
  44. package/dist/core/workflow-model/normalize-job-listening.js +0 -1
  45. package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
  46. package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -1
  47. package/dist/core/workflow-model/normalize-job-success.js +0 -1
  48. package/dist/core/workflow-model/normalize-job-success.js.map +1 -1
  49. package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -1
  50. package/dist/core/workflow-model/normalize-jobs.js +315 -74
  51. package/dist/core/workflow-model/normalize-jobs.js.map +1 -1
  52. package/dist/core/workflow-model/normalize-step-gate.js +0 -1
  53. package/dist/core/workflow-model/normalize-step-gate.js.map +1 -1
  54. package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
  55. package/dist/core/workflow-model/normalize-triggers.js +0 -1
  56. package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
  57. package/dist/core/workflow-model/normalize-workflow-document.d.ts +3 -0
  58. package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
  59. package/dist/core/workflow-model/normalize-workflow-document.js +34 -4
  60. package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
  61. package/dist/core/workflow-model/parse-interpolation-field.d.ts +1 -1
  62. package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
  63. package/dist/core/workflow-model/parse-interpolation-field.js +27 -57
  64. package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
  65. package/dist/core/workflow-model/validate-literal-name.d.ts +12 -0
  66. package/dist/core/workflow-model/validate-literal-name.d.ts.map +1 -0
  67. package/dist/core/workflow-model/validate-literal-name.js +36 -0
  68. package/dist/core/workflow-model/validate-literal-name.js.map +1 -0
  69. package/dist/core/workflow-model/validate-predicate-expression.d.ts +1 -2
  70. package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
  71. package/dist/core/workflow-model/validate-predicate-expression.js +31 -90
  72. package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
  73. package/dist/core/workflow-model/validation-issue.d.ts +2 -1
  74. package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
  75. package/dist/core/workflow-model/validation-issue.js +8 -2
  76. package/dist/core/workflow-model/validation-issue.js.map +1 -1
  77. package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -1
  78. package/dist/core/workflow-model/workflow-field-label.js +16 -2
  79. package/dist/core/workflow-model/workflow-field-label.js.map +1 -1
  80. package/dist/core/workflow-yaml/invalid-workflow-yaml-error.d.ts.map +1 -1
  81. package/dist/db/db.d.ts +48 -10
  82. package/dist/db/db.d.ts.map +1 -1
  83. package/dist/db/schema/definitions.d.ts +1 -1
  84. package/dist/db/schema/outbox.d.ts +1 -1
  85. package/dist/db/schema/sync-states.d.ts +22 -3
  86. package/dist/db/schema/sync-states.d.ts.map +1 -1
  87. package/dist/db/schema/sync-states.js +3 -1
  88. package/dist/db/schema/sync-states.js.map +1 -1
  89. package/dist/db/sync-states.d.ts +2 -1
  90. package/dist/db/sync-states.d.ts.map +1 -1
  91. package/dist/db/sync-states.js +4 -0
  92. package/dist/db/sync-states.js.map +1 -1
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +3 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/presentation/dto/definition.d.ts.map +1 -1
  97. package/dist/presentation/dto/definition.js +2 -1
  98. package/dist/presentation/dto/definition.js.map +1 -1
  99. package/dist/presentation/routes/project-access.d.ts +1 -0
  100. package/dist/presentation/routes/project-access.d.ts.map +1 -1
  101. package/dist/presentation/routes/validate-definition.d.ts.map +1 -1
  102. package/dist/presentation/routes/validate-definition.js +5 -3
  103. package/dist/presentation/routes/validate-definition.js.map +1 -1
  104. package/dist/presentation/subscribers/index.d.ts +1 -0
  105. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  106. package/dist/presentation/subscribers/index.js +1 -0
  107. package/dist/presentation/subscribers/index.js.map +1 -1
  108. package/dist/presentation/subscribers/on-integration-connection-available.d.ts +5 -0
  109. package/dist/presentation/subscribers/on-integration-connection-available.d.ts.map +1 -0
  110. package/dist/presentation/subscribers/on-integration-connection-available.js +32 -0
  111. package/dist/presentation/subscribers/on-integration-connection-available.js.map +1 -0
  112. package/dist/presentation/subscribers/start-definition-sync.d.ts +1 -0
  113. package/dist/presentation/subscribers/start-definition-sync.d.ts.map +1 -1
  114. package/dist/presentation/subscribers/start-definition-sync.js +30 -17
  115. package/dist/presentation/subscribers/start-definition-sync.js.map +1 -1
  116. package/dist/temporal/activities/sync-activities.d.ts +6 -2
  117. package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
  118. package/dist/temporal/activities/sync-activities.js +8 -1
  119. package/dist/temporal/activities/sync-activities.js.map +1 -1
  120. package/dist/temporal/workflows/definition-sync.js +4 -1
  121. package/dist/temporal/workflows/definition-sync.js.map +1 -1
  122. package/dist/temporal/workflows/index.bundle.js +9029 -3
  123. package/dist/tsconfig.test.tsbuildinfo +1 -1
  124. package/drizzle/0002_lumpy_ben_parker.sql +1 -0
  125. package/drizzle/meta/0002_snapshot.json +534 -0
  126. package/drizzle/meta/_journal.json +7 -0
  127. package/package.json +17 -17
  128. package/src/core/entities/index.ts +1 -0
  129. package/src/core/entities/sync-state.ts +23 -0
  130. package/src/core/entities/validation-warning.ts +5 -0
  131. package/src/core/parse-definition-warnings.test.ts +33 -0
  132. package/src/core/parse-definition.test.ts +7 -1
  133. package/src/core/parse-definition.ts +28 -10
  134. package/src/core/sync-definitions.test.ts +39 -0
  135. package/src/core/sync-definitions.ts +14 -4
  136. package/src/core/validate-definition.test.ts +128 -0
  137. package/src/core/validate-definition.ts +39 -8
  138. package/src/core/workflow-model/README.md +2 -2
  139. package/src/core/workflow-model/index.ts +1 -0
  140. package/src/core/workflow-model/invalid-workflow-model-error.ts +8 -3
  141. package/src/core/workflow-model/normalize-if-condition.ts +0 -2
  142. package/src/core/workflow-model/normalize-job-checkout.ts +105 -8
  143. package/src/core/workflow-model/normalize-job-listening.ts +0 -1
  144. package/src/core/workflow-model/normalize-job-success.ts +0 -1
  145. package/src/core/workflow-model/normalize-jobs.ts +335 -90
  146. package/src/core/workflow-model/normalize-step-gate.ts +0 -1
  147. package/src/core/workflow-model/normalize-triggers.ts +0 -1
  148. package/src/core/workflow-model/normalize-workflow-document.test.ts +876 -202
  149. package/src/core/workflow-model/normalize-workflow-document.ts +29 -4
  150. package/src/core/workflow-model/parse-interpolation-field.ts +50 -79
  151. package/src/core/workflow-model/validate-literal-name.ts +64 -0
  152. package/src/core/workflow-model/validate-predicate-expression.test.ts +153 -15
  153. package/src/core/workflow-model/validate-predicate-expression.ts +52 -120
  154. package/src/core/workflow-model/validation-issue.ts +4 -0
  155. package/src/core/workflow-model/workflow-field-label.ts +16 -2
  156. package/src/core/workflow-yaml/README.md +2 -2
  157. package/src/db/schema/sync-states.ts +4 -1
  158. package/src/db/sync-states.test.ts +57 -0
  159. package/src/db/sync-states.ts +6 -0
  160. package/src/index.ts +12 -1
  161. package/src/presentation/dto/definition.ts +1 -0
  162. package/src/presentation/routes/list-definitions.test.ts +1 -0
  163. package/src/presentation/routes/validate-definition.test.ts +1 -0
  164. package/src/presentation/routes/validate-definition.ts +7 -1
  165. package/src/presentation/subscribers/index.ts +1 -0
  166. package/src/presentation/subscribers/on-integration-connection-available.test.ts +132 -0
  167. package/src/presentation/subscribers/on-integration-connection-available.ts +44 -0
  168. package/src/presentation/subscribers/start-definition-sync.ts +35 -19
  169. package/src/temporal/activities/sync-activities.test.ts +16 -0
  170. package/src/temporal/activities/sync-activities.ts +18 -3
  171. package/src/temporal/workflows/definition-sync.ts +1 -1
  172. package/test/agent-validation-catalog.ts +17 -0
  173. package/test/fixtures/valid-listening-job.yml +2 -1
  174. package/tsconfig.build.tsbuildinfo +1 -1
@@ -12,6 +12,8 @@ import {normalizeDependencies, validateCycles} from './normalize-dependencies.js
12
12
  import {normalizeEnv} from './normalize-env.js';
13
13
  import {normalizeJobs} from './normalize-jobs.js';
14
14
  import {normalizeTriggers} from './normalize-triggers.js';
15
+ import {parseInterpolationField} from './parse-interpolation-field.js';
16
+ import {unescapeLiteralName, validateLiteralName} from './validate-literal-name.js';
15
17
 
16
18
  export function normalizeWorkflowDocument(
17
19
  document: WorkflowDocument,
@@ -20,6 +22,8 @@ export function normalizeWorkflowDocument(
20
22
  agentValidationCatalog: AgentValidationCatalog;
21
23
  integrationValidationContext?: IntegrationValidationContext | undefined;
22
24
  stepSourceLocations?: WorkflowStepSourceLocationMap | undefined;
25
+ /** Provide a fresh array for each call to collect non-fatal validation issues. */
26
+ warnings?: WorkflowModelValidationIssue[] | undefined;
23
27
  },
24
28
  ): WorkflowModel {
25
29
  const issues: WorkflowModelValidationIssue[] = [];
@@ -29,6 +33,14 @@ export function normalizeWorkflowDocument(
29
33
  agentValidationCatalog: options.agentValidationCatalog,
30
34
  integrationValidationContext: options.integrationValidationContext,
31
35
  };
36
+ validateLiteralName({
37
+ field: 'workflow.name',
38
+ dynamicField: 'run_name',
39
+ source: document.name,
40
+ path: ['name'],
41
+ message: 'Workflow name must be literal. Move runtime interpolation to run_name.',
42
+ issues,
43
+ });
32
44
  const jobIdBySourceName = mapJobIds(document, issues);
33
45
  const triggers = normalizeTriggers(document, issues);
34
46
  const jobs = normalizeJobs(
@@ -40,16 +52,29 @@ export function normalizeWorkflowDocument(
40
52
  );
41
53
  const dependencies = normalizeDependencies(document.jobs, jobIdBySourceName, issues);
42
54
  const workflowEnv = normalizeEnv({env: document.env, path: ['env'], issues});
55
+ const runName =
56
+ document.run_name === undefined
57
+ ? undefined
58
+ : (parseInterpolationField({
59
+ field: 'workflow.run_name',
60
+ source: document.run_name,
61
+ path: ['run_name'],
62
+ issues,
63
+ fillSite: 'run-creation',
64
+ }) ?? [{kind: 'literal' as const, value: document.run_name}]);
43
65
 
44
66
  validateCycles(document.jobs, jobIdBySourceName, issues);
45
67
 
46
- if (issues.length > 0) {
47
- throw new InvalidWorkflowModelError(issues);
48
- }
68
+ const warnings = issues.filter((issue) => issue.severity === 'warning');
69
+ options.warnings?.push(...warnings);
70
+
71
+ const errors = issues.filter((issue) => issue.severity !== 'warning');
72
+ if (errors.length > 0) throw new InvalidWorkflowModelError(errors);
49
73
 
50
74
  return {
51
75
  kind: 'workflow',
52
- name: document.name,
76
+ name: unescapeLiteralName(document.name),
77
+ ...(runName === undefined ? {} : {runName}),
53
78
  ...workflowEnv,
54
79
  triggers,
55
80
  jobs,
@@ -2,14 +2,14 @@ import {secretKeySchema, secretStoreSchema} from '@shipfox/api-secrets-dto';
2
2
  import {
3
3
  type AvailabilitySite,
4
4
  analyzeContextKeyAccess,
5
+ analyzeContextRootKeyAccess,
5
6
  createWorkflowExpression,
6
7
  type ExpressionTypeEnvironment,
7
- extractCelUntrustedPathAccesses,
8
8
  getWorkflowContextAvailability,
9
9
  getWorkflowContextDefinition,
10
10
  getWorkflowContextHost,
11
11
  getWorkflowContextTypeEnvironment,
12
- getWorkflowContextUntrustedPaths,
12
+ getWorkflowInterpolationFieldSelfReference,
13
13
  InvalidWorkflowExpressionError,
14
14
  InvalidWorkflowTemplateError,
15
15
  type PlanViolation,
@@ -21,9 +21,7 @@ import {
21
21
  type WorkflowTemplateExprSegment,
22
22
  type WorkflowTemplateSegment,
23
23
  workflowContextNames,
24
- workflowInterpolationFieldAcceptsContext,
25
24
  workflowInterpolationFieldAcceptsHost,
26
- workflowInterpolationFieldAcceptsTrustTier,
27
25
  } from '@shipfox/expression';
28
26
  import type {WorkflowFieldTemplate} from '../entities/workflow-model.js';
29
27
  import type {
@@ -39,11 +37,19 @@ export type StoredInterpolationField =
39
37
  | 'agent.prompt'
40
38
  | 'agent.model'
41
39
  | 'agent.provider'
40
+ | 'agent.thinking'
42
41
  | 'job.outputs'
43
- | 'job.name'
42
+ | 'workflow.run_name'
43
+ | 'job.execution_name'
44
44
  | 'job.runner'
45
45
  | 'step.name'
46
- | 'step.feedback';
46
+ | 'step.working_directory'
47
+ | 'step.feedback'
48
+ | 'checkout.project'
49
+ | 'checkout.connection'
50
+ | 'checkout.repository'
51
+ | 'checkout.ref'
52
+ | 'checkout.path';
47
53
 
48
54
  export function parseInterpolationField(params: {
49
55
  field: StoredInterpolationField;
@@ -78,6 +84,38 @@ export function parseInterpolationField(params: {
78
84
  return plan.plan.field.segments;
79
85
  }
80
86
 
87
+ function dynamicNameSelfReference(params: {
88
+ field: StoredInterpolationField;
89
+ source: string;
90
+ path: readonly WorkflowModelValidationIssuePathSegment[];
91
+ segment: WorkflowTemplateExprSegment;
92
+ }): WorkflowModelValidationIssue | undefined {
93
+ const target = getWorkflowInterpolationFieldSelfReference(params.field);
94
+ if (target === undefined) return undefined;
95
+
96
+ const access = analyzeContextRootKeyAccess(params.segment.expression, [target.root]);
97
+ const reference = access.references.find((entry) => entry.key === target.key);
98
+ const computedReference = access.violations.find(
99
+ (violation) =>
100
+ violation.root === target.root &&
101
+ (violation.key === undefined || violation.key === target.key),
102
+ );
103
+ if (reference === undefined && computedReference === undefined) return undefined;
104
+
105
+ return issue({
106
+ code: 'dynamic-name-self-reference',
107
+ message: `${fieldLabel(params.field)} interpolation cannot reference its own dynamic name.`,
108
+ path: params.path,
109
+ details: {
110
+ field: params.field,
111
+ source: params.source,
112
+ expression: params.segment.expression.source,
113
+ reference:
114
+ reference === undefined ? `${target.root}[computed]` : `${reference.root}.${reference.key}`,
115
+ },
116
+ });
117
+ }
118
+
81
119
  function parseTemplate(params: {
82
120
  field: WorkflowInterpolationField;
83
121
  source: string;
@@ -120,14 +158,6 @@ function validateExpressionSegment(params: {
120
158
  const knownRoots = contextRoots.filter(isWorkflowContextName);
121
159
  const unknownRoots = contextRoots.filter((root) => !isWorkflowContextName(root));
122
160
 
123
- const rejectedRoots = knownRoots.filter(
124
- (root) => !workflowInterpolationFieldAcceptsContext(params.field, root),
125
- );
126
- if (rejectedRoots.length > 0) {
127
- params.issues.push(untrustedContextIssue({...params, contextRoots, rejectedRoots}));
128
- return undefined;
129
- }
130
-
131
161
  const rejectedHostRoots = knownRoots.filter(
132
162
  (root) => !workflowInterpolationFieldAcceptsHost(params.field, getWorkflowContextHost(root)),
133
163
  );
@@ -151,6 +181,12 @@ function validateExpressionSegment(params: {
151
181
  return undefined;
152
182
  }
153
183
 
184
+ const selfReference = dynamicNameSelfReference(params);
185
+ if (selfReference !== undefined) {
186
+ params.issues.push(selfReference);
187
+ return undefined;
188
+ }
189
+
154
190
  const invalidReferenceIssue = validateContextKeyReferences({
155
191
  ...params,
156
192
  contextRoots,
@@ -176,14 +212,6 @@ function validateExpressionSegment(params: {
176
212
  return undefined;
177
213
  }
178
214
 
179
- const rejectedPathRoots = findUntrustedPathRoots(params.segment, params.field, knownRoots);
180
- if (rejectedPathRoots.length > 0) {
181
- params.issues.push(
182
- untrustedContextIssue({...params, contextRoots, rejectedRoots: rejectedPathRoots}),
183
- );
184
- return undefined;
185
- }
186
-
187
215
  if (unknownRoots.length > 0) {
188
216
  params.issues.push(
189
217
  issue({
@@ -258,63 +286,6 @@ function validateExpressionSegment(params: {
258
286
  }
259
287
  }
260
288
 
261
- function findUntrustedPathRoots(
262
- segment: WorkflowTemplateExprSegment,
263
- field: StoredInterpolationField,
264
- knownRoots: readonly WorkflowContextName[],
265
- ): readonly WorkflowContextName[] {
266
- if (!isTrustedOnlyField(field)) return [];
267
-
268
- const untrustedPathsByRoot = new Map<string, readonly string[]>();
269
- for (const root of knownRoots) {
270
- const paths = getWorkflowContextUntrustedPaths(root);
271
- if (paths === undefined || paths.length === 0) continue;
272
- untrustedPathsByRoot.set(root, paths);
273
- }
274
- if (knownRoots.includes('steps')) {
275
- untrustedPathsByRoot.set('steps', [
276
- ...new Set([...(untrustedPathsByRoot.get('steps') ?? []), 'outputs']),
277
- ]);
278
- }
279
- if (untrustedPathsByRoot.size === 0) return [];
280
-
281
- return extractCelUntrustedPathAccesses({
282
- source: segment.expression.source,
283
- untrustedPathsByRoot,
284
- }).filter(isWorkflowContextName);
285
- }
286
-
287
- function isTrustedOnlyField(field: StoredInterpolationField): boolean {
288
- return !workflowInterpolationFieldAcceptsTrustTier(field, 'untrusted');
289
- }
290
-
291
- function untrustedContextIssue(params: {
292
- field: WorkflowInterpolationField;
293
- source: string;
294
- path: readonly WorkflowModelValidationIssuePathSegment[];
295
- contextRoots: readonly string[];
296
- rejectedRoots: readonly WorkflowContextName[];
297
- }): WorkflowModelValidationIssue {
298
- return issue({
299
- code: 'untrusted-context-in-field',
300
- message:
301
- params.field === 'run'
302
- ? `Run command interpolation cannot use untrusted context ${formatList(
303
- params.rejectedRoots,
304
- )}. Bind untrusted values to env and reference the shell variable from run instead.`
305
- : `${fieldLabel(params.field)} interpolation cannot use untrusted context ${formatList(
306
- params.rejectedRoots,
307
- )}.`,
308
- path: params.path,
309
- details: {
310
- field: params.field,
311
- source: params.source,
312
- contextRoots: params.contextRoots,
313
- rejectedRoots: params.rejectedRoots,
314
- },
315
- });
316
- }
317
-
318
289
  function runnerContextInFieldIssue(params: {
319
290
  field: WorkflowInterpolationField;
320
291
  source: string;
@@ -0,0 +1,64 @@
1
+ import {InvalidWorkflowTemplateError, parseWorkflowTemplate} from '@shipfox/expression';
2
+ import type {
3
+ WorkflowModelValidationIssue,
4
+ WorkflowModelValidationIssuePathSegment,
5
+ } from './invalid-workflow-model-error.js';
6
+ import {issue} from './validation-issue.js';
7
+
8
+ /** Defense-in-depth for typed callers that bypass workflowDocumentSchema. */
9
+ export function validateLiteralName(params: {
10
+ field: 'workflow.name' | 'job.name';
11
+ dynamicField: 'run_name' | 'execution_name';
12
+ source: string;
13
+ path: readonly WorkflowModelValidationIssuePathSegment[];
14
+ message: string;
15
+ issues: WorkflowModelValidationIssue[];
16
+ }): void {
17
+ try {
18
+ const segments = parseWorkflowTemplate(params.source);
19
+ if (!segments.some((segment) => segment.kind === 'expr')) return;
20
+ } catch (error) {
21
+ params.issues.push(
22
+ literalNameIssue({
23
+ ...params,
24
+ reason:
25
+ error instanceof InvalidWorkflowTemplateError
26
+ ? error.reason
27
+ : 'Template source did not parse.',
28
+ }),
29
+ );
30
+ return;
31
+ }
32
+
33
+ params.issues.push(
34
+ literalNameIssue({
35
+ ...params,
36
+ reason: `Static names cannot contain interpolation expressions. Use ${params.dynamicField} for runtime interpolation.`,
37
+ }),
38
+ );
39
+ }
40
+
41
+ export function unescapeLiteralName(source: string): string {
42
+ return source.replaceAll('$${{', '${{');
43
+ }
44
+
45
+ function literalNameIssue(params: {
46
+ field: 'workflow.name' | 'job.name';
47
+ dynamicField: 'run_name' | 'execution_name';
48
+ source: string;
49
+ path: readonly WorkflowModelValidationIssuePathSegment[];
50
+ message: string;
51
+ reason: string;
52
+ }): WorkflowModelValidationIssue {
53
+ return issue({
54
+ code: 'invalid-interpolation-template',
55
+ message: params.message,
56
+ path: params.path,
57
+ details: {
58
+ field: params.field,
59
+ source: params.source,
60
+ dynamicField: params.dynamicField,
61
+ reason: params.reason,
62
+ },
63
+ });
64
+ }
@@ -1,11 +1,13 @@
1
- import type {ExpressionTypeEnvironment} from '@shipfox/expression';
1
+ import {
2
+ type ExpressionTypeEnvironment,
3
+ getWorkflowPredicateFieldMinimumFillTarget,
4
+ } from '@shipfox/expression';
2
5
  import type {WorkflowModelValidationIssue} from './invalid-workflow-model-error.js';
3
6
  import {validatePredicateExpression} from './validate-predicate-expression.js';
4
7
 
5
8
  function validate(params: {
6
9
  source: string;
7
10
  field: Parameters<typeof validatePredicateExpression>[0]['field'];
8
- site: Parameters<typeof validatePredicateExpression>[0]['site'];
9
11
  allowedJobReferences?: ReadonlySet<string>;
10
12
  typeOverlay?: ExpressionTypeEnvironment;
11
13
  }): {
@@ -16,7 +18,6 @@ function validate(params: {
16
18
  const expression = validatePredicateExpression({
17
19
  field: params.field,
18
20
  source: params.source,
19
- site: params.site,
20
21
  path: ['predicate'],
21
22
  invalidCode: 'invalid-job-success',
22
23
  invalidMessage: 'Predicate must be a valid CEL boolean expression.',
@@ -36,7 +37,7 @@ describe('validatePredicateExpression', () => {
36
37
  ['trigger.event == "push"', 'typed'],
37
38
  ['has(event.ref)', 'syntax'],
38
39
  ] as const)('accepts trigger filters at ingest: %s', (source, check) => {
39
- const result = validate({field: 'trigger.filter', source, site: 'ingest'});
40
+ const result = validate({field: 'trigger.filter', source});
40
41
 
41
42
  expect(result.issues).toEqual([]);
42
43
  expect(result.expression).toMatchObject({source, check});
@@ -46,7 +47,7 @@ describe('validatePredicateExpression', () => {
46
47
  ['event.ref', 'Predicate source must be boolean-shaped.'],
47
48
  ['trigger.event', 'must return bool'],
48
49
  ])('rejects non-boolean trigger filters: %s', (source, reason) => {
49
- const result = validate({field: 'trigger.filter', source, site: 'ingest'});
50
+ const result = validate({field: 'trigger.filter', source});
50
51
 
51
52
  expect(result.expression).toBeUndefined();
52
53
  expect(result.issues).toEqual([
@@ -64,8 +65,9 @@ describe('validatePredicateExpression', () => {
64
65
  'run.id == "run-1"',
65
66
  'inputs.env == "prod"',
66
67
  'jobs.build.status == "succeeded"',
68
+ 'vars.ENV == "prod"',
67
69
  ])('rejects trigger filter roots that are unavailable at ingest: %s', (source) => {
68
- const result = validate({field: 'trigger.filter', source, site: 'ingest'});
70
+ const result = validate({field: 'trigger.filter', source});
69
71
 
70
72
  expect(result.expression).toBeUndefined();
71
73
  expect(result.issues).toEqual([
@@ -78,9 +80,8 @@ describe('validatePredicateExpression', () => {
78
80
 
79
81
  it.each([
80
82
  ['runner.os == "linux"', 'runner-context-in-server-predicate'],
81
- ['vars.ENV == "prod"', 'vars-context-in-server-predicate'],
82
83
  ])('rejects forbidden server predicate roots: %s', (source, code) => {
83
- const result = validate({field: 'trigger.filter', source, site: 'ingest'});
84
+ const result = validate({field: 'trigger.filter', source});
84
85
 
85
86
  expect(result.expression).toBeUndefined();
86
87
  expect(result.issues).toEqual([
@@ -91,21 +92,98 @@ describe('validatePredicateExpression', () => {
91
92
  ]);
92
93
  });
93
94
 
95
+ it.each([
96
+ ['job.if', 'job-activation'],
97
+ ['step.if', 'step-dispatch'],
98
+ ['step.success', 'step-report'],
99
+ ['job.success', 'job-resolution'],
100
+ ['listener.on', 'job-activation'],
101
+ ['listener.until', 'job-activation'],
102
+ ] as const)('accepts vars in %s at its evaluation site', (field, site) => {
103
+ expect(getWorkflowPredicateFieldMinimumFillTarget(field)).toBe(site);
104
+ const result = validate({
105
+ field,
106
+ source: 'vars.ENABLED == "true"',
107
+ });
108
+
109
+ expect(result.issues).toEqual([]);
110
+ expect(result.expression).toMatchObject({source: 'vars.ENABLED == "true"'});
111
+ });
112
+
113
+ it.each([
114
+ ['step.success', 'step.status == "succeeded"'],
115
+ ['job.success', 'executions.all(e, e.status == "succeeded")'],
116
+ ['trigger.filter', 'event.action == "created"'],
117
+ ['listener.on', 'trigger.event == "pull_request"'],
118
+ ['listener.until', 'job.key == "await-review"'],
119
+ ['job.if', 'needs.exists(n, n.status == "succeeded")'],
120
+ ['step.if', 'execution.status == "running"'],
121
+ ] as const)('accepts the runtime context contract for %s', (field, source) => {
122
+ const result = validate({field, source});
123
+
124
+ expect(result.issues).toEqual([]);
125
+ expect(result.expression).toMatchObject({source});
126
+ });
127
+
128
+ it.each([
129
+ [
130
+ 'step.success',
131
+ 'step-report',
132
+ 'jobs.build.status == "succeeded"',
133
+ 'jobs',
134
+ 'Step gate success',
135
+ ],
136
+ ['job.success', 'job-resolution', 'step.status == "succeeded"', 'step', 'Job success'],
137
+ ['trigger.filter', 'ingest', 'run.id == "run-1"', 'run', 'Trigger filter'],
138
+ [
139
+ 'listener.on',
140
+ 'job-activation',
141
+ 'execution.status == "waiting"',
142
+ 'execution',
143
+ 'Listener on filter',
144
+ ],
145
+ [
146
+ 'listener.until',
147
+ 'job-activation',
148
+ 'execution.status == "waiting"',
149
+ 'execution',
150
+ 'Listener until filter',
151
+ ],
152
+ ['job.if', 'job-activation', 'execution.status == "running"', 'execution', 'Job if'],
153
+ ['step.if', 'step-dispatch', 'run.id == "run-1"', 'run', 'Step if'],
154
+ ] as const)('rejects roots omitted from the %s runtime context', (field, site, source, root, label) => {
155
+ const result = validate({field, source});
156
+
157
+ expect(result.expression).toBeUndefined();
158
+ expect(result.issues).toEqual([
159
+ expect.objectContaining({
160
+ code: 'context-unavailable-at-predicate-site',
161
+ message: expect.stringContaining(
162
+ `"${root}" that is not supplied when ${label} is evaluated at`,
163
+ ),
164
+ details: expect.objectContaining({
165
+ field,
166
+ source,
167
+ unavailableRoots: [root],
168
+ site,
169
+ }),
170
+ }),
171
+ ]);
172
+ });
173
+
94
174
  it.each([
95
175
  ['listener.on', 'event.action == "created"', undefined],
96
176
  ['listener.on', 'run.id == "run-1"', undefined],
177
+ ['listener.on', 'trigger.event == "pull_request"', undefined],
97
178
  ['listener.until', 'inputs.target == event.issue.number', undefined],
179
+ ['listener.until', 'vars.ENABLED == "true"', undefined],
98
180
  ['listener.until', 'job.key == "await-review"', undefined],
99
- ['listener.on', 'executions.all(execution, execution.status != "")', undefined],
100
- ['listener.until', 'execution.status == "waiting"', undefined],
101
- ['listener.on', 'matrix.os == "linux"', undefined],
102
181
  ['listener.until', 'jobs.build.outputs.pr_number == event.issue.number', new Set(['build'])],
103
182
  ['listener.until', 'has(jobs.build.outputs.pr_number)', new Set(['build'])],
104
183
  ] as const)('accepts listener filters at job activation: %s %s', (field, source, allowedJobs) => {
105
184
  const result = validate({
106
185
  field,
107
186
  source,
108
- site: 'job-activation',
109
187
  ...(allowedJobs === undefined ? {} : {allowedJobReferences: allowedJobs}),
110
188
  });
111
189
 
@@ -117,7 +195,7 @@ describe('validatePredicateExpression', () => {
117
195
  'step.status == "succeeded"',
118
196
  'steps.build.outputs.sha == "abc"',
119
197
  ])('rejects listener roots that are unavailable at job activation: %s', (source) => {
120
- const result = validate({field: 'listener.on', source, site: 'job-activation'});
198
+ const result = validate({field: 'listener.on', source});
121
199
 
122
200
  expect(result.expression).toBeUndefined();
123
201
  expect(result.issues).toEqual([
@@ -128,11 +206,72 @@ describe('validatePredicateExpression', () => {
128
206
  ]);
129
207
  });
130
208
 
209
+ it.each([
210
+ ['listener.on', 'executions.size() > 0', 'executions'],
211
+ ['listener.until', 'execution.status == "waiting"', 'execution'],
212
+ ['listener.on', 'needs.size() > 0', 'needs'],
213
+ ['listener.until', 'matrix.os == "linux"', 'matrix'],
214
+ ['job.if', 'job.key == "build"', 'job'],
215
+ ['job.if', 'executions.size() > 0', 'executions'],
216
+ ['step.if', 'run.id == "run-1"', 'run'],
217
+ ['step.success', 'execution.status == "failed"', 'execution'],
218
+ ['job.success', 'run.id == "run-1"', 'run'],
219
+ ] as const)('rejects %s context that its runtime evaluator does not supply: %s', (field, source, unavailableRoot) => {
220
+ const result = validate({field, source});
221
+
222
+ expect(result.expression).toBeUndefined();
223
+ expect(result.issues).toEqual([
224
+ expect.objectContaining({
225
+ code: 'context-unavailable-at-predicate-site',
226
+ details: expect.objectContaining({
227
+ field,
228
+ source,
229
+ unavailableRoots: [unavailableRoot],
230
+ }),
231
+ }),
232
+ ]);
233
+ });
234
+
235
+ it.each([
236
+ ['step.if', 'step.exit_code == 0'],
237
+ ['step.success', 'step.attempt > 1'],
238
+ ['job.success', 'executions.exists(e, e.failed)'],
239
+ ] as const)('rejects %s properties absent from its runtime shape: %s', (field, source) => {
240
+ const result = validate({field, source, typeOverlay: {}});
241
+
242
+ expect(result.expression).toBeUndefined();
243
+ expect(result.issues).toEqual([
244
+ expect.objectContaining({
245
+ code: 'invalid-job-success',
246
+ details: expect.objectContaining({
247
+ field,
248
+ source,
249
+ reason: expect.stringContaining('No such key'),
250
+ }),
251
+ }),
252
+ ]);
253
+ });
254
+
255
+ it('still checks typed properties when a predicate also uses open context', () => {
256
+ const result = validate({
257
+ field: 'step.success',
258
+ source: 'step.attempt > 1 && vars.RETRY == "true"',
259
+ });
260
+
261
+ expect(result.expression).toBeUndefined();
262
+ expect(result.issues).toEqual([
263
+ expect.objectContaining({
264
+ details: expect.objectContaining({
265
+ reason: expect.stringContaining('No such key: attempt'),
266
+ }),
267
+ }),
268
+ ]);
269
+ });
270
+
131
271
  it('rejects listener job references without a direct needs edge', () => {
132
272
  const result = validate({
133
273
  field: 'listener.on',
134
274
  source: 'jobs.build.outputs.pr_number == event.issue.number',
135
- site: 'job-activation',
136
275
  allowedJobReferences: new Set(['test']),
137
276
  });
138
277
 
@@ -149,7 +288,6 @@ describe('validatePredicateExpression', () => {
149
288
  const result = validate({
150
289
  field: 'job.success',
151
290
  source: 'jobs.build.outputs.ready',
152
- site: 'job-resolution',
153
291
  });
154
292
 
155
293
  expect(result.issues).toEqual([]);