@shipfox/api-definitions 10.2.0 → 12.0.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/.turbo/turbo-build.log +13 -12
- package/CHANGELOG.md +91 -0
- package/dist/core/entities/index.d.ts +1 -0
- package/dist/core/entities/index.d.ts.map +1 -1
- package/dist/core/entities/index.js +1 -0
- package/dist/core/entities/index.js.map +1 -1
- package/dist/core/entities/sync-state.d.ts +4 -0
- package/dist/core/entities/sync-state.d.ts.map +1 -1
- package/dist/core/entities/sync-state.js +10 -0
- package/dist/core/entities/sync-state.js.map +1 -1
- package/dist/core/entities/validation-warning.d.ts +6 -0
- package/dist/core/entities/validation-warning.d.ts.map +1 -0
- package/dist/core/entities/validation-warning.js +3 -0
- package/dist/core/entities/validation-warning.js.map +1 -0
- package/dist/core/parse-definition.d.ts +9 -7
- package/dist/core/parse-definition.d.ts.map +1 -1
- package/dist/core/parse-definition.js +14 -2
- package/dist/core/parse-definition.js.map +1 -1
- package/dist/core/sync-definitions.d.ts +2 -0
- package/dist/core/sync-definitions.d.ts.map +1 -1
- package/dist/core/sync-definitions.js +6 -5
- package/dist/core/sync-definitions.js.map +1 -1
- package/dist/core/validate-definition.d.ts +9 -5
- package/dist/core/validate-definition.d.ts.map +1 -1
- package/dist/core/validate-definition.js +23 -2
- package/dist/core/validate-definition.js.map +1 -1
- package/dist/core/workflow-model/index.d.ts +1 -1
- package/dist/core/workflow-model/index.d.ts.map +1 -1
- package/dist/core/workflow-model/index.js.map +1 -1
- package/dist/core/workflow-model/invalid-workflow-model-error.d.ts +3 -1
- package/dist/core/workflow-model/invalid-workflow-model-error.d.ts.map +1 -1
- package/dist/core/workflow-model/invalid-workflow-model-error.js.map +1 -1
- package/dist/core/workflow-model/normalize-if-condition.d.ts +0 -1
- package/dist/core/workflow-model/normalize-if-condition.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-if-condition.js +0 -1
- package/dist/core/workflow-model/normalize-if-condition.js.map +1 -1
- package/dist/core/workflow-model/normalize-job-checkout.d.ts +15 -3
- package/dist/core/workflow-model/normalize-job-checkout.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-job-checkout.js +83 -4
- package/dist/core/workflow-model/normalize-job-checkout.js.map +1 -1
- package/dist/core/workflow-model/normalize-job-listening.js +0 -1
- package/dist/core/workflow-model/normalize-job-listening.js.map +1 -1
- package/dist/core/workflow-model/normalize-job-success.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-job-success.js +0 -1
- package/dist/core/workflow-model/normalize-job-success.js.map +1 -1
- package/dist/core/workflow-model/normalize-jobs.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-jobs.js +310 -45
- package/dist/core/workflow-model/normalize-jobs.js.map +1 -1
- package/dist/core/workflow-model/normalize-step-gate.js +0 -1
- package/dist/core/workflow-model/normalize-step-gate.js.map +1 -1
- package/dist/core/workflow-model/normalize-triggers.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-triggers.js +0 -1
- package/dist/core/workflow-model/normalize-triggers.js.map +1 -1
- package/dist/core/workflow-model/normalize-workflow-document.d.ts +3 -0
- package/dist/core/workflow-model/normalize-workflow-document.d.ts.map +1 -1
- package/dist/core/workflow-model/normalize-workflow-document.js +34 -4
- package/dist/core/workflow-model/normalize-workflow-document.js.map +1 -1
- package/dist/core/workflow-model/parse-interpolation-field.d.ts +1 -1
- package/dist/core/workflow-model/parse-interpolation-field.d.ts.map +1 -1
- package/dist/core/workflow-model/parse-interpolation-field.js +27 -57
- package/dist/core/workflow-model/parse-interpolation-field.js.map +1 -1
- package/dist/core/workflow-model/validate-literal-name.d.ts +12 -0
- package/dist/core/workflow-model/validate-literal-name.d.ts.map +1 -0
- package/dist/core/workflow-model/validate-literal-name.js +36 -0
- package/dist/core/workflow-model/validate-literal-name.js.map +1 -0
- package/dist/core/workflow-model/validate-predicate-expression.d.ts +1 -2
- package/dist/core/workflow-model/validate-predicate-expression.d.ts.map +1 -1
- package/dist/core/workflow-model/validate-predicate-expression.js +31 -90
- package/dist/core/workflow-model/validate-predicate-expression.js.map +1 -1
- package/dist/core/workflow-model/validation-issue.d.ts +2 -1
- package/dist/core/workflow-model/validation-issue.d.ts.map +1 -1
- package/dist/core/workflow-model/validation-issue.js +8 -2
- package/dist/core/workflow-model/validation-issue.js.map +1 -1
- package/dist/core/workflow-model/workflow-field-label.d.ts.map +1 -1
- package/dist/core/workflow-model/workflow-field-label.js +16 -2
- package/dist/core/workflow-model/workflow-field-label.js.map +1 -1
- package/dist/db/db.d.ts +38 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/schema/sync-states.d.ts +19 -0
- package/dist/db/schema/sync-states.d.ts.map +1 -1
- package/dist/db/schema/sync-states.js +3 -1
- package/dist/db/schema/sync-states.js.map +1 -1
- package/dist/db/sync-states.d.ts +2 -1
- package/dist/db/sync-states.d.ts.map +1 -1
- package/dist/db/sync-states.js +4 -0
- package/dist/db/sync-states.js.map +1 -1
- package/dist/presentation/dto/definition.d.ts.map +1 -1
- package/dist/presentation/dto/definition.js +2 -1
- package/dist/presentation/dto/definition.js.map +1 -1
- package/dist/presentation/routes/project-access.d.ts +1 -0
- package/dist/presentation/routes/project-access.d.ts.map +1 -1
- package/dist/presentation/routes/validate-definition.d.ts.map +1 -1
- package/dist/presentation/routes/validate-definition.js +5 -3
- package/dist/presentation/routes/validate-definition.js.map +1 -1
- package/dist/temporal/activities/sync-activities.d.ts +6 -2
- package/dist/temporal/activities/sync-activities.d.ts.map +1 -1
- package/dist/temporal/activities/sync-activities.js +8 -1
- package/dist/temporal/activities/sync-activities.js.map +1 -1
- package/dist/temporal/workflows/definition-sync.js +4 -1
- package/dist/temporal/workflows/definition-sync.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +9029 -3
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_lumpy_ben_parker.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +534 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -14
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/sync-state.ts +23 -0
- package/src/core/entities/validation-warning.ts +5 -0
- package/src/core/parse-definition-warnings.test.ts +33 -0
- package/src/core/parse-definition.test.ts +7 -1
- package/src/core/parse-definition.ts +28 -10
- package/src/core/sync-definitions.test.ts +39 -0
- package/src/core/sync-definitions.ts +14 -4
- package/src/core/validate-definition.test.ts +128 -0
- package/src/core/validate-definition.ts +39 -8
- package/src/core/workflow-model/README.md +2 -2
- package/src/core/workflow-model/index.ts +1 -0
- package/src/core/workflow-model/invalid-workflow-model-error.ts +8 -2
- package/src/core/workflow-model/normalize-if-condition.ts +0 -2
- package/src/core/workflow-model/normalize-job-checkout.ts +105 -8
- package/src/core/workflow-model/normalize-job-listening.ts +0 -1
- package/src/core/workflow-model/normalize-job-success.ts +0 -1
- package/src/core/workflow-model/normalize-jobs.ts +330 -49
- package/src/core/workflow-model/normalize-step-gate.ts +0 -1
- package/src/core/workflow-model/normalize-triggers.ts +0 -1
- package/src/core/workflow-model/normalize-workflow-document.test.ts +759 -185
- package/src/core/workflow-model/normalize-workflow-document.ts +29 -4
- package/src/core/workflow-model/parse-interpolation-field.ts +50 -79
- package/src/core/workflow-model/validate-literal-name.ts +64 -0
- package/src/core/workflow-model/validate-predicate-expression.test.ts +153 -15
- package/src/core/workflow-model/validate-predicate-expression.ts +52 -120
- package/src/core/workflow-model/validation-issue.ts +4 -0
- package/src/core/workflow-model/workflow-field-label.ts +16 -2
- package/src/core/workflow-yaml/README.md +2 -2
- package/src/db/schema/sync-states.ts +4 -1
- package/src/db/sync-states.test.ts +57 -0
- package/src/db/sync-states.ts +6 -0
- package/src/presentation/dto/definition.ts +1 -0
- package/src/presentation/routes/create-definition.test.ts +3 -3
- package/src/presentation/routes/get-definition.test.ts +1 -1
- package/src/presentation/routes/list-definitions.test.ts +2 -1
- package/src/presentation/routes/validate-definition.test.ts +1 -0
- package/src/presentation/routes/validate-definition.ts +7 -1
- package/src/temporal/activities/sync-activities.test.ts +16 -0
- package/src/temporal/activities/sync-activities.ts +18 -3
- package/src/temporal/workflows/definition-sync.ts +1 -1
- package/test/agent-validation-catalog.ts +17 -0
- package/test/fixtures/valid-listening-job.yml +2 -1
- 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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
| '
|
|
42
|
+
| 'workflow.run_name'
|
|
43
|
+
| 'job.execution_name'
|
|
44
44
|
| 'job.runner'
|
|
45
45
|
| 'step.name'
|
|
46
|
-
| 'step.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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([]);
|