@trebired/git-host 2.1.0 → 2.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 (152) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +231 -2
  3. package/bin/git-host-actions-runner-darwin-arm64 +0 -0
  4. package/bin/git-host-actions-runner-darwin-x64 +0 -0
  5. package/bin/git-host-actions-runner-linux-arm64-gnu +0 -0
  6. package/bin/git-host-actions-runner-linux-x64-gnu +0 -0
  7. package/dist/api/forge/actions.d.ts +6 -3
  8. package/dist/api/forge/actions.d.ts.map +1 -1
  9. package/dist/api/forge/actions.js +46 -10
  10. package/dist/api/forge/actions.js.map +1 -1
  11. package/dist/api/forge/route.d.ts +10 -0
  12. package/dist/api/forge/route.d.ts.map +1 -1
  13. package/dist/api/forge/route.js +6 -0
  14. package/dist/api/forge/route.js.map +1 -1
  15. package/dist/api/forge/socket.d.ts.map +1 -1
  16. package/dist/api/forge/socket.js +4 -148
  17. package/dist/api/forge/socket.js.map +1 -1
  18. package/dist/api/forge/socket_subscription.d.ts +10 -0
  19. package/dist/api/forge/socket_subscription.d.ts.map +1 -0
  20. package/dist/api/forge/socket_subscription.js +202 -0
  21. package/dist/api/forge/socket_subscription.js.map +1 -0
  22. package/dist/api/handler/action.d.ts.map +1 -1
  23. package/dist/api/handler/action.js +24 -18
  24. package/dist/api/handler/action.js.map +1 -1
  25. package/dist/api/socket/linguist_connection.d.ts +9 -0
  26. package/dist/api/socket/linguist_connection.d.ts.map +1 -0
  27. package/dist/api/socket/linguist_connection.js +167 -0
  28. package/dist/api/socket/linguist_connection.js.map +1 -0
  29. package/dist/api/socket/server.d.ts.map +1 -1
  30. package/dist/api/socket/server.js +4 -119
  31. package/dist/api/socket/server.js.map +1 -1
  32. package/dist/core/actions/artifacts.d.ts +21 -0
  33. package/dist/core/actions/artifacts.d.ts.map +1 -0
  34. package/dist/core/actions/artifacts.js +121 -0
  35. package/dist/core/actions/artifacts.js.map +1 -0
  36. package/dist/core/actions/expressions.d.ts +15 -0
  37. package/dist/core/actions/expressions.d.ts.map +1 -0
  38. package/dist/core/actions/expressions.js +279 -0
  39. package/dist/core/actions/expressions.js.map +1 -0
  40. package/dist/core/actions/go_runner.d.ts +6 -0
  41. package/dist/core/actions/go_runner.d.ts.map +1 -0
  42. package/dist/core/actions/go_runner.js +106 -0
  43. package/dist/core/actions/go_runner.js.map +1 -0
  44. package/dist/core/actions/local_runner.d.ts +28 -0
  45. package/dist/core/actions/local_runner.d.ts.map +1 -0
  46. package/dist/core/actions/local_runner.js +121 -0
  47. package/dist/core/actions/local_runner.js.map +1 -0
  48. package/dist/core/actions/node_runner.d.ts +4 -0
  49. package/dist/core/actions/node_runner.d.ts.map +1 -0
  50. package/dist/core/actions/node_runner.js +175 -0
  51. package/dist/core/actions/node_runner.js.map +1 -0
  52. package/dist/core/actions/normalize.d.ts +7 -0
  53. package/dist/core/actions/normalize.d.ts.map +1 -0
  54. package/dist/core/actions/normalize.js +401 -0
  55. package/dist/core/actions/normalize.js.map +1 -0
  56. package/dist/core/actions/planner.d.ts +13 -0
  57. package/dist/core/actions/planner.d.ts.map +1 -0
  58. package/dist/core/actions/planner.js +106 -0
  59. package/dist/core/actions/planner.js.map +1 -0
  60. package/dist/core/actions/redaction.d.ts +5 -0
  61. package/dist/core/actions/redaction.d.ts.map +1 -0
  62. package/dist/core/actions/redaction.js +28 -0
  63. package/dist/core/actions/redaction.js.map +1 -0
  64. package/dist/core/actions/release_assets.d.ts +31 -0
  65. package/dist/core/actions/release_assets.d.ts.map +1 -0
  66. package/dist/core/actions/release_assets.js +114 -0
  67. package/dist/core/actions/release_assets.js.map +1 -0
  68. package/dist/core/actions/runner/launch.d.ts +5 -0
  69. package/dist/core/actions/runner/launch.d.ts.map +1 -0
  70. package/dist/core/actions/runner/launch.js +100 -0
  71. package/dist/core/actions/runner/launch.js.map +1 -0
  72. package/dist/core/actions/runner/protocol.d.ts +7 -0
  73. package/dist/core/actions/runner/protocol.d.ts.map +1 -0
  74. package/dist/core/actions/runner/protocol.js +66 -0
  75. package/dist/core/actions/runner/protocol.js.map +1 -0
  76. package/dist/core/actions/runner.d.ts +6 -0
  77. package/dist/core/actions/runner.d.ts.map +1 -0
  78. package/dist/core/actions/runner.js +13 -0
  79. package/dist/core/actions/runner.js.map +1 -0
  80. package/dist/core/{actions_runner.d.ts → actions/types.d.ts} +38 -6
  81. package/dist/core/actions/types.d.ts.map +1 -0
  82. package/dist/core/actions/types.js +2 -0
  83. package/dist/core/actions/types.js.map +1 -0
  84. package/dist/core/{actions_workflows.d.ts → actions/workflows.d.ts} +4 -4
  85. package/dist/core/actions/workflows.d.ts.map +1 -0
  86. package/dist/core/{actions_workflows.js → actions/workflows.js} +40 -100
  87. package/dist/core/actions/workflows.js.map +1 -0
  88. package/dist/core/actions/workspace.d.ts +7 -0
  89. package/dist/core/actions/workspace.d.ts.map +1 -0
  90. package/dist/core/actions/workspace.js +47 -0
  91. package/dist/core/actions/workspace.js.map +1 -0
  92. package/dist/core/activity.d.ts.map +1 -1
  93. package/dist/core/activity.js +57 -39
  94. package/dist/core/activity.js.map +1 -1
  95. package/dist/core/archive_cache_filesystem.d.ts.map +1 -1
  96. package/dist/core/archive_cache_filesystem.js +39 -33
  97. package/dist/core/archive_cache_filesystem.js.map +1 -1
  98. package/dist/core/create/git/forge.d.ts.map +1 -1
  99. package/dist/core/create/git/forge.js +12 -2
  100. package/dist/core/create/git/forge.js.map +1 -1
  101. package/dist/core/create_git_host/remote_methods.js.map +1 -1
  102. package/dist/core/forge_actions.d.ts +10 -2
  103. package/dist/core/forge_actions.d.ts.map +1 -1
  104. package/dist/core/forge_actions.js +1005 -160
  105. package/dist/core/forge_actions.js.map +1 -1
  106. package/dist/core/git_forge/storage_memory.d.ts.map +1 -1
  107. package/dist/core/git_forge/storage_memory.js +72 -2
  108. package/dist/core/git_forge/storage_memory.js.map +1 -1
  109. package/dist/core/repository/parsers.d.ts.map +1 -1
  110. package/dist/core/repository/parsers.js +45 -32
  111. package/dist/core/repository/parsers.js.map +1 -1
  112. package/dist/core/run_git/process.d.ts.map +1 -1
  113. package/dist/core/run_git/process.js +19 -18
  114. package/dist/core/run_git/process.js.map +1 -1
  115. package/dist/core/working_tree/read.d.ts.map +1 -1
  116. package/dist/core/working_tree/read.js +26 -32
  117. package/dist/core/working_tree/read.js.map +1 -1
  118. package/dist/index.d.ts +1 -1
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/types/forge/activity.d.ts +52 -0
  121. package/dist/types/forge/activity.d.ts.map +1 -0
  122. package/dist/types/forge/activity.js +2 -0
  123. package/dist/types/forge/activity.js.map +1 -0
  124. package/dist/types/forge/api.d.ts +116 -0
  125. package/dist/types/forge/api.d.ts.map +1 -0
  126. package/dist/types/forge/api.js +2 -0
  127. package/dist/types/forge/api.js.map +1 -0
  128. package/dist/types/forge/releases.d.ts +98 -0
  129. package/dist/types/forge/releases.d.ts.map +1 -0
  130. package/dist/types/forge/releases.js +2 -0
  131. package/dist/types/forge/releases.js.map +1 -0
  132. package/dist/types/forge/social_forks.d.ts +51 -0
  133. package/dist/types/forge/social_forks.d.ts.map +1 -0
  134. package/dist/types/forge/social_forks.js +2 -0
  135. package/dist/types/forge/social_forks.js.map +1 -0
  136. package/dist/types/forge/workflows.d.ts +332 -0
  137. package/dist/types/forge/workflows.d.ts.map +1 -0
  138. package/dist/types/forge/workflows.js +2 -0
  139. package/dist/types/forge/workflows.js.map +1 -0
  140. package/dist/types/forge.d.ts +5 -475
  141. package/dist/types/forge.d.ts.map +1 -1
  142. package/dist/types/host/archive.d.ts.map +1 -1
  143. package/dist/types/host/options.d.ts.map +1 -1
  144. package/dist/types/host/service.d.ts.map +1 -1
  145. package/dist/types/index.d.ts +1 -1
  146. package/dist/types/index.d.ts.map +1 -1
  147. package/package.json +37 -8
  148. package/dist/core/actions_runner.d.ts.map +0 -1
  149. package/dist/core/actions_runner.js +0 -414
  150. package/dist/core/actions_runner.js.map +0 -1
  151. package/dist/core/actions_workflows.d.ts.map +0 -1
  152. package/dist/core/actions_workflows.js.map +0 -1
@@ -1,35 +1,40 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
2
3
  import { GitHostError } from "../errors.js";
3
4
  import { text } from "../utils/text.js";
4
- import { executeActionsRunner, resolveActionsWorkspaceRoot } from "./actions_runner.js";
5
- import { listRepositoryWorkflows, readRepositoryWorkflow, resolveRepositoryWorkflowRoot } from "./actions_workflows.js";
5
+ import { uploadArtifact, downloadArtifact } from "./actions/artifacts.js";
6
+ import { resolveWorkflowBoolean, resolveWorkflowString } from "./actions/expressions.js";
7
+ import { materializeJobWorkspace, runShellCommand, setupRuntime } from "./actions/local_runner.js";
8
+ import { normalizeEnv } from "./actions/normalize.js";
9
+ import { assertAcyclicWorkflow, planWorkflowJobs, resolveRefName } from "./actions/planner.js";
10
+ import { createRunRedactor } from "./actions/redaction.js";
11
+ import { publishReleaseAsset } from "./actions/release_assets.js";
12
+ import { resolveActionsWorkspaceRoot, resolveReleaseAssetsRoot } from "./actions/workspace.js";
13
+ import { listRepositoryWorkflows, matchesWorkflowTrigger, readRepositoryWorkflow, resolveRepositoryWorkflowRoot } from "./actions/workflows.js";
6
14
  import { runGit } from "./run_git.js";
7
15
  const ACTIVITY_LISTENER_SYMBOL = Symbol.for("@trebired/git-host/actions-activity-listeners");
8
16
  const ACTIVITY_WRAPPED_SYMBOL = Symbol.for("@trebired/git-host/actions-activity-wrapped");
9
17
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 1000;
18
+ const DEFAULT_LOCAL_RUNNER_LABELS = [
19
+ "bun",
20
+ "linux",
21
+ "local",
22
+ "node",
23
+ "ubuntu",
24
+ "ubuntu-22.04",
25
+ "ubuntu-latest",
26
+ ];
10
27
  const TERMINAL_RUN_STATUSES = new Set(["cancelled", "failed", "skipped", "success"]);
28
+ const TERMINAL_JOB_STATUSES = new Set(["cancelled", "failed", "skipped", "success"]);
11
29
  function nowIso() {
12
30
  return new Date().toISOString();
13
31
  }
14
- function toDateSortValue(value) {
15
- return text(value);
16
- }
17
- function matchesWorkflowTrigger(workflow, triggerKind, context) {
18
- if (!workflow.enabled || text(workflow.trigger) !== text(triggerKind))
19
- return false;
20
- const branches = Array.isArray(workflow.source?.branches) ? workflow.source?.branches.map((entry) => text(entry)).filter(Boolean) : [];
21
- const tags = Array.isArray(workflow.source?.tags) ? workflow.source?.tags.map((entry) => text(entry)).filter(Boolean) : [];
22
- const branch = text(context.branch);
23
- const tag = text(context.tag_name, text(context.tag));
24
- if (branches.length && !branches.includes(branch))
25
- return false;
26
- if (tags.length && !tags.includes(tag))
27
- return false;
28
- return true;
29
- }
30
32
  function isTerminalRunStatus(status) {
31
33
  return TERMINAL_RUN_STATUSES.has(status);
32
34
  }
35
+ function isTerminalJobStatus(status) {
36
+ return TERMINAL_JOB_STATUSES.has(status);
37
+ }
33
38
  function ensureActionsStorage(storage) {
34
39
  if (!storage) {
35
40
  throw new GitHostError("forge_actions_not_configured", "Actions storage is required to use repository workflows.");
@@ -38,18 +43,166 @@ function ensureActionsStorage(storage) {
38
43
  }
39
44
  function normalizeRunner(options) {
40
45
  const runner = options?.runner || {};
46
+ const labels = Array.isArray(options?.localRunnerLabels) && options?.localRunnerLabels.length
47
+ ? options.localRunnerLabels.map((entry) => text(entry)).filter(Boolean)
48
+ : DEFAULT_LOCAL_RUNNER_LABELS;
41
49
  return {
42
- capabilities: Array.isArray(runner.capabilities) ? runner.capabilities.map((entry) => text(entry)).filter(Boolean) : ["go-runner", "shell", "snapshot", "streaming-logs"],
50
+ capabilities: Array.isArray(runner.capabilities)
51
+ ? runner.capabilities.map((entry) => text(entry)).filter(Boolean)
52
+ : ["artifacts", "env", "expressions", "matrix", "needs", "secrets", "socket-events", "snapshot", "uses"],
43
53
  host: text(runner.host, "local-host"),
44
- id: text(runner.id, "go-runner"),
45
- kind: text(runner.kind, "go-runner"),
54
+ id: text(runner.id, "local-runner"),
55
+ kind: text(runner.kind, "local"),
56
+ labels,
46
57
  platform_version: text(runner.platform_version, "@trebired/git-host"),
47
58
  };
48
59
  }
60
+ function aggregateJobStatus(statuses) {
61
+ if (statuses.some((status) => status === "failed"))
62
+ return "failed";
63
+ if (statuses.some((status) => status === "cancelled"))
64
+ return "cancelled";
65
+ if (statuses.every((status) => status === "skipped"))
66
+ return "skipped";
67
+ if (statuses.every((status) => status === "success"))
68
+ return "success";
69
+ return "queued";
70
+ }
71
+ function normalizeTriggerContext(input) {
72
+ return input && typeof input === "object" ? { ...input } : {};
73
+ }
74
+ function resolveGithubRef(run) {
75
+ if (text(run.ref).startsWith("refs/"))
76
+ return text(run.ref);
77
+ if (run.branch)
78
+ return `refs/heads/${run.branch}`;
79
+ return text(run.ref, "HEAD");
80
+ }
81
+ function buildExpressionContext(input) {
82
+ const githubRef = resolveGithubRef(input.run);
83
+ const eventName = input.run.trigger_kind === "manual" ? "workflow_dispatch" : input.run.trigger_kind;
84
+ return {
85
+ env: {
86
+ ...(input.execution.env || {}),
87
+ ...(input.extraEnv || {}),
88
+ },
89
+ github: {
90
+ actor: text(input.execution.actor?.id, input.run.created_by),
91
+ event: {
92
+ ...input.triggerContext,
93
+ inputs: input.execution.inputs,
94
+ },
95
+ event_name: eventName,
96
+ ref: githubRef,
97
+ ref_name: resolveRefName(githubRef),
98
+ repository: input.run.repository_id,
99
+ run_id: input.run.id,
100
+ sha: input.run.commit_hash,
101
+ workflow: input.workflow.name,
102
+ workflow_ref: input.run.workflow_id,
103
+ },
104
+ job: {
105
+ status: "queued",
106
+ },
107
+ ...(input.matrix ? { matrix: input.matrix } : {}),
108
+ ...(input.needs ? { needs: input.needs } : {}),
109
+ secrets: input.execution.secrets,
110
+ };
111
+ }
112
+ function resolveEnvLayer(layer, context) {
113
+ if (!layer)
114
+ return {};
115
+ const next = {};
116
+ for (const [key, value] of Object.entries(layer)) {
117
+ const envContext = {
118
+ ...context,
119
+ env: {
120
+ ...(context.env || {}),
121
+ ...next,
122
+ },
123
+ };
124
+ next[key] = resolveWorkflowString(value, envContext);
125
+ }
126
+ return next;
127
+ }
128
+ function mergeRuntimeEnv(input) {
129
+ let env = {
130
+ ...process.env,
131
+ ...(input.actions?.env || {}),
132
+ ...(input.execution.env || {}),
133
+ };
134
+ let context = buildExpressionContext({
135
+ execution: input.execution,
136
+ extraEnv: env,
137
+ ...(input.matrix ? { matrix: input.matrix } : {}),
138
+ run: input.run,
139
+ triggerContext: input.triggerContext,
140
+ workflow: input.workflow,
141
+ });
142
+ env = {
143
+ ...env,
144
+ ...resolveEnvLayer(input.workflow.env, context),
145
+ };
146
+ context = {
147
+ ...context,
148
+ env,
149
+ };
150
+ env = {
151
+ ...env,
152
+ ...resolveEnvLayer(input.jobEnv, context),
153
+ };
154
+ context = {
155
+ ...context,
156
+ env,
157
+ };
158
+ env = {
159
+ ...env,
160
+ ...resolveEnvLayer(input.stepEnv, context),
161
+ ...(input.execution.secrets || {}),
162
+ };
163
+ return env;
164
+ }
165
+ function validateDispatchInputs(workflow, provided) {
166
+ const inputs = workflow.on?.workflow_dispatch?.inputs || [];
167
+ const next = {};
168
+ for (const input of inputs) {
169
+ const value = provided?.[input.name];
170
+ if (value === undefined || value === null || value === "") {
171
+ if (input.default !== undefined) {
172
+ next[input.name] = input.default;
173
+ continue;
174
+ }
175
+ if (input.required) {
176
+ throw new GitHostError("forge_invalid_workflow_definition", `Manual workflow input "${input.name}" is required.`, {
177
+ input: input.name,
178
+ workflowId: workflow.id,
179
+ });
180
+ }
181
+ continue;
182
+ }
183
+ if (input.type === "boolean") {
184
+ if (typeof value === "boolean") {
185
+ next[input.name] = value;
186
+ continue;
187
+ }
188
+ if (value === "true" || value === "false") {
189
+ next[input.name] = value === "true";
190
+ continue;
191
+ }
192
+ throw new GitHostError("forge_invalid_workflow_definition", `Manual workflow input "${input.name}" must be a boolean.`, {
193
+ input: input.name,
194
+ value,
195
+ });
196
+ }
197
+ next[input.name] = String(value);
198
+ }
199
+ return next;
200
+ }
49
201
  function createGitForgeActionsRuntime(options) {
50
202
  const storage = ensureActionsStorage(options.storage);
51
203
  const runListeners = new Map();
52
204
  const runSequences = new Map();
205
+ const runExecutionContexts = new Map();
53
206
  const queuedRuns = [];
54
207
  const activeRuns = new Map();
55
208
  const runner = normalizeRunner(options.actions);
@@ -133,7 +286,17 @@ function createGitForgeActionsRuntime(options) {
133
286
  }
134
287
  return run;
135
288
  }
136
- async function updateRunStep(runId, stepId, input) {
289
+ async function updateJob(runId, jobRunId, input) {
290
+ const job = await storage.updateWorkflowRunJob(runId, jobRunId, input);
291
+ if (!job) {
292
+ throw new GitHostError("forge_resource_not_found", `Workflow job run "${jobRunId}" was not found.`, {
293
+ jobRunId,
294
+ runId,
295
+ });
296
+ }
297
+ return job;
298
+ }
299
+ async function updateStep(runId, stepId, input) {
137
300
  const step = await storage.updateWorkflowRunStep(runId, stepId, input);
138
301
  if (!step) {
139
302
  throw new GitHostError("forge_resource_not_found", `Workflow run step "${stepId}" was not found.`, {
@@ -143,27 +306,76 @@ function createGitForgeActionsRuntime(options) {
143
306
  }
144
307
  return step;
145
308
  }
146
- function scheduleQueueProcessing() {
147
- if (processing)
148
- return;
149
- queueMicrotask(() => {
150
- void processQueue();
309
+ async function readWorkflowAtRef(repositoryId, repositoryPath, ref, workflowId) {
310
+ const workflowRoot = await resolveRepositoryWorkflowRoot(options.actions, repositoryId);
311
+ return await readRepositoryWorkflow({
312
+ ref,
313
+ repositoryId,
314
+ repositoryPath,
315
+ workflowId,
316
+ workflowRoot,
151
317
  });
152
318
  }
153
- async function markQueuedSteps(runId, fromIndex, status) {
154
- const steps = await storage.listWorkflowRunSteps(runId);
319
+ async function resolveExecutionContext(repositoryId, workflow, input) {
320
+ const resolved = options.actions?.resolveExecutionContext
321
+ ? await options.actions.resolveExecutionContext({
322
+ actor: input.actor,
323
+ repositoryId,
324
+ runInput: input,
325
+ triggerContext: input.triggerContext,
326
+ triggerKind: input.triggerKind,
327
+ workflow,
328
+ })
329
+ : null;
330
+ const manualExecution = input.executionContext || {};
331
+ return {
332
+ ...(resolved?.actor || manualExecution.actor ? { actor: { ...(resolved?.actor || {}), ...(manualExecution.actor || {}) } } : {}),
333
+ env: normalizeEnv({
334
+ ...(resolved?.env || {}),
335
+ ...(manualExecution.env || {}),
336
+ ...(input.env || {}),
337
+ }) || {},
338
+ inputs: validateDispatchInputs(workflow, input.inputs),
339
+ ...(resolved?.metadata || manualExecution.metadata ? { metadata: { ...(resolved?.metadata || {}), ...(manualExecution.metadata || {}) } } : {}),
340
+ secrets: normalizeEnv({
341
+ ...(resolved?.secrets || {}),
342
+ ...(manualExecution.secrets || {}),
343
+ ...(input.secrets || {}),
344
+ }) || {},
345
+ };
346
+ }
347
+ async function markQueuedStepsForJob(runId, jobRunId, status) {
348
+ const steps = await storage.listWorkflowRunSteps(runId, {
349
+ jobRunId,
350
+ });
155
351
  const now = nowIso();
156
352
  await Promise.all(steps
157
- .filter((step) => step.index > fromIndex && step.status === "queued")
353
+ .filter((step) => step.status === "queued")
158
354
  .map((step) => storage.updateWorkflowRunStep(runId, step.id, {
159
355
  finished_at: now,
160
356
  status,
161
357
  })));
162
358
  }
359
+ async function markQueuedJobsAndSteps(runId, status) {
360
+ const jobs = await storage.listWorkflowRunJobs(runId);
361
+ const now = nowIso();
362
+ await Promise.all(jobs.map(async (job) => {
363
+ if (job.status === "queued") {
364
+ await storage.updateWorkflowRunJob(runId, job.id, {
365
+ finished_at: now,
366
+ status,
367
+ summary: status === "cancelled" ? "Cancelled before starting." : "Skipped before starting.",
368
+ });
369
+ }
370
+ await markQueuedStepsForJob(runId, job.id, status === "cancelled" ? "cancelled" : "skipped");
371
+ }));
372
+ }
163
373
  async function finalizeRun(run, input) {
164
374
  const finished = await updateRun(run.repository_id, run.id, {
165
- current_step: input.currentStep === undefined ? null : input.currentStep,
166
- current_step_index: input.currentStepIndex === undefined ? null : input.currentStepIndex,
375
+ current_job: null,
376
+ current_job_id: null,
377
+ current_step: null,
378
+ current_step_index: null,
167
379
  finished_at: nowIso(),
168
380
  status: input.status,
169
381
  summary: input.summary,
@@ -175,59 +387,141 @@ function createGitForgeActionsRuntime(options) {
175
387
  });
176
388
  return finished;
177
389
  }
178
- async function readWorkflowAtRef(repositoryId, repositoryPath, ref, workflowId) {
179
- const workflowRoot = await resolveRepositoryWorkflowRoot(options.actions, repositoryId);
180
- return await readRepositoryWorkflow({
181
- ref,
182
- repositoryId,
183
- repositoryPath,
184
- workflowId,
185
- workflowRoot,
390
+ async function resolveConcurrencyGroup(repositoryId, workflow, input, runTarget, execution) {
391
+ if (!workflow.concurrency?.group)
392
+ return null;
393
+ const temporaryRun = {
394
+ branch: runTarget.branch,
395
+ commit_hash: runTarget.commitHash,
396
+ created_at: nowIso(),
397
+ created_by: text(input.actor.id, "system"),
398
+ current_step: null,
399
+ current_step_index: null,
400
+ finished_at: null,
401
+ id: "pending",
402
+ ref: runTarget.ref,
403
+ repository_id: repositoryId,
404
+ runner: null,
405
+ started_at: null,
406
+ status: "queued",
407
+ summary: "",
408
+ trigger_context: input.triggerContext,
409
+ trigger_kind: input.triggerKind,
410
+ workflow_id: workflow.id,
411
+ };
412
+ const context = buildExpressionContext({
413
+ execution,
414
+ run: temporaryRun,
415
+ triggerContext: input.triggerContext,
416
+ workflow,
186
417
  });
418
+ return resolveWorkflowString(workflow.concurrency.group, context);
419
+ }
420
+ async function cancelConflictingRuns(repositoryId, runId, concurrencyGroup, actor) {
421
+ const active = await storage.listWorkflowRuns(repositoryId, {
422
+ status: ["queued", "running", "starting"],
423
+ });
424
+ for (const entry of active) {
425
+ if (entry.id === runId)
426
+ continue;
427
+ if (text(entry.concurrency_group) !== text(concurrencyGroup))
428
+ continue;
429
+ await runtime.cancelWorkflowRun(repositoryId, entry.id, actor);
430
+ }
187
431
  }
188
432
  async function queueWorkflowRun(repositoryId, workflow, input) {
433
+ assertAcyclicWorkflow(workflow);
434
+ const triggerContext = normalizeTriggerContext(input.triggerContext);
189
435
  const target = await resolveRunTarget(repositoryId, input);
190
436
  const createdAt = nowIso();
191
437
  const createdBy = text(input.actor.id, text(input.actor.name, "system"));
438
+ const execution = await resolveExecutionContext(repositoryId, workflow, {
439
+ ...input,
440
+ triggerContext,
441
+ });
442
+ const concurrencyGroup = await resolveConcurrencyGroup(repositoryId, workflow, {
443
+ ...input,
444
+ triggerContext,
445
+ }, target, execution);
192
446
  const run = await storage.createWorkflowRun({
193
447
  branch: target.branch,
194
448
  commit_hash: target.commitHash,
449
+ ...(workflow.concurrency ? { concurrency_cancel_in_progress: workflow.concurrency.cancel_in_progress === true } : {}),
450
+ ...(concurrencyGroup ? { concurrency_group: concurrencyGroup } : {}),
195
451
  created_at: createdAt,
196
452
  created_by: createdBy,
453
+ current_job: null,
454
+ current_job_id: null,
197
455
  current_step: null,
198
456
  current_step_index: null,
457
+ execution_context: {
458
+ ...(execution.actor ? { actor: execution.actor } : {}),
459
+ env: execution.env,
460
+ inputs: execution.inputs,
461
+ ...(execution.metadata ? { metadata: execution.metadata } : {}),
462
+ secret_names: Object.keys(execution.secrets),
463
+ },
199
464
  finished_at: null,
200
465
  id: randomUUID(),
201
466
  ref: target.ref,
202
- release_id: input.releaseId == null ? null : text(input.releaseId),
467
+ release_id: text(triggerContext.release_id) || null,
203
468
  repository_id: repositoryId,
204
469
  runner: null,
205
470
  started_at: null,
206
471
  status: "queued",
207
472
  summary: "Workflow run queued.",
208
473
  trigger_context: {
209
- ...(input.triggerContext || {}),
474
+ ...triggerContext,
475
+ event_name: input.triggerKind === "manual" ? "workflow_dispatch" : input.triggerKind,
476
+ inputs: execution.inputs,
210
477
  workflow_definition_path: workflow.definition_path,
211
478
  },
212
479
  trigger_kind: input.triggerKind,
213
480
  workflow_id: workflow.id,
214
481
  });
215
- await Promise.all(workflow.steps.map(async (step, index) => {
216
- await storage.createWorkflowRunStep({
217
- command: step.run,
218
- exit_code: null,
482
+ const plannedJobs = planWorkflowJobs(workflow);
483
+ for (const plannedJob of plannedJobs) {
484
+ const jobRun = await storage.createWorkflowRunJob({
485
+ current_step: null,
486
+ current_step_index: null,
219
487
  finished_at: null,
220
488
  id: randomUUID(),
221
- index,
222
- kind: "shell",
223
- metadata: step.env ? { env: step.env, shell: text(step.shell) } : (text(step.shell) ? { shell: text(step.shell) } : undefined),
224
- name: text(step.name, `Step ${index + 1}`),
225
- output_preview: "",
489
+ index: plannedJob.index,
490
+ job_id: plannedJob.job.id,
491
+ ...(plannedJob.matrix ? { matrix: plannedJob.matrix } : {}),
492
+ name: plannedJob.name,
493
+ needs: plannedJob.job.needs,
226
494
  run_id: run.id,
495
+ runner: null,
496
+ runs_on: plannedJob.job.runs_on,
227
497
  started_at: null,
228
498
  status: "queued",
499
+ summary: "Job queued.",
229
500
  });
230
- }));
501
+ for (const [index, step] of plannedJob.job.steps.entries()) {
502
+ await storage.createWorkflowRunStep({
503
+ command: text(step.run, step.uses),
504
+ exit_code: null,
505
+ finished_at: null,
506
+ id: randomUUID(),
507
+ index,
508
+ job_run_id: jobRun.id,
509
+ kind: step.kind || (step.uses ? "uses" : "shell"),
510
+ metadata: {
511
+ env: step.env,
512
+ if: step.if,
513
+ shell: step.shell,
514
+ with: step.with,
515
+ },
516
+ name: text(step.name, `Step ${index + 1}`),
517
+ output_preview: "",
518
+ run_id: run.id,
519
+ started_at: null,
520
+ status: "queued",
521
+ uses: text(step.uses) || null,
522
+ });
523
+ }
524
+ }
231
525
  await emitRunEvent(run, {
232
526
  metadata: {
233
527
  branch: run.branch,
@@ -238,6 +532,10 @@ function createGitForgeActionsRuntime(options) {
238
532
  summary: run.summary,
239
533
  type: "run.accepted",
240
534
  });
535
+ runExecutionContexts.set(run.id, execution);
536
+ if (run.concurrency_group && workflow.concurrency?.cancel_in_progress) {
537
+ await cancelConflictingRuns(repositoryId, run.id, run.concurrency_group, input.actor);
538
+ }
241
539
  queuedRuns.push({
242
540
  repositoryId,
243
541
  runId: run.id,
@@ -245,95 +543,585 @@ function createGitForgeActionsRuntime(options) {
245
543
  scheduleQueueProcessing();
246
544
  return run;
247
545
  }
248
- async function handleRunnerEvent(run, stepsById, event) {
249
- if (event.type === "run.status") {
250
- const updatedRun = await updateRun(run.repository_id, run.id, {
251
- status: event.status,
252
- summary: text(event.summary, run.summary),
546
+ function scheduleQueueProcessing() {
547
+ if (processing)
548
+ return;
549
+ queueMicrotask(() => {
550
+ void processQueue();
551
+ });
552
+ }
553
+ function supportedRunnerLabels() {
554
+ return new Set(runner.labels || []);
555
+ }
556
+ function assertJobRunnerSupported(jobRun) {
557
+ const labels = supportedRunnerLabels();
558
+ const unsupported = jobRun.runs_on.filter((entry) => !labels.has(text(entry)));
559
+ if (unsupported.length) {
560
+ throw new GitHostError("forge_actions_runner_failed", `Job "${jobRun.name}" requested unsupported runner labels: ${unsupported.join(", ")}.`, {
561
+ jobRunId: jobRun.id,
562
+ labels: unsupported,
253
563
  });
254
- await emitRunEvent(updatedRun, {
255
- status: updatedRun.status,
256
- summary: updatedRun.summary,
257
- type: "run.status",
564
+ }
565
+ }
566
+ async function emitJobStarted(run, jobRun) {
567
+ await emitRunEvent(run, {
568
+ job_id: jobRun.job_id,
569
+ job_name: jobRun.name,
570
+ job_run_id: jobRun.id,
571
+ status: "running",
572
+ summary: `Running job ${jobRun.name}.`,
573
+ type: "job.started",
574
+ });
575
+ }
576
+ async function emitJobFinished(run, jobRun) {
577
+ await emitRunEvent(run, {
578
+ job_id: jobRun.job_id,
579
+ job_name: jobRun.name,
580
+ job_run_id: jobRun.id,
581
+ status: jobRun.status,
582
+ summary: jobRun.summary,
583
+ type: "job.finished",
584
+ });
585
+ }
586
+ async function finishStep(run, jobRun, stepRun, input) {
587
+ const finished = await updateStep(run.id, stepRun.id, {
588
+ exit_code: input.exitCode === undefined ? stepRun.exit_code : input.exitCode,
589
+ finished_at: nowIso(),
590
+ output_preview: text(input.outputPreview, stepRun.output_preview),
591
+ status: input.status,
592
+ });
593
+ await emitRunEvent(run, {
594
+ command: finished.command,
595
+ job_id: jobRun.job_id,
596
+ job_name: jobRun.name,
597
+ job_run_id: jobRun.id,
598
+ metadata: {
599
+ exit_code: finished.exit_code,
600
+ },
601
+ status: finished.status,
602
+ step_id: finished.id,
603
+ step_index: finished.index,
604
+ step_name: finished.name,
605
+ summary: input.summary,
606
+ type: "step.finished",
607
+ });
608
+ return finished;
609
+ }
610
+ async function skipJob(run, jobRun, summary) {
611
+ await markQueuedStepsForJob(run.id, jobRun.id, "skipped");
612
+ const finished = await updateJob(run.id, jobRun.id, {
613
+ finished_at: nowIso(),
614
+ status: "skipped",
615
+ summary,
616
+ });
617
+ await emitJobFinished(run, finished);
618
+ return finished;
619
+ }
620
+ async function cancelJob(run, jobRun, summary) {
621
+ await markQueuedStepsForJob(run.id, jobRun.id, "cancelled");
622
+ const finished = await updateJob(run.id, jobRun.id, {
623
+ finished_at: nowIso(),
624
+ status: "cancelled",
625
+ summary,
626
+ });
627
+ await emitJobFinished(run, finished);
628
+ return finished;
629
+ }
630
+ async function executePublishReleaseAssetStep(input) {
631
+ const assetName = text(input.stepWith.name);
632
+ const pathSpec = text(input.stepWith.path);
633
+ const format = text(input.stepWith.format, "tar.gz") === "zip" ? "zip" : "tar.gz";
634
+ if (!assetName || !pathSpec) {
635
+ throw new GitHostError("forge_actions_runner_failed", "actions/publish-release-asset requires with.name and with.path.", {
636
+ uses: input.stepRun.uses,
258
637
  });
259
- return updatedRun;
260
638
  }
261
- const step = event.step_id ? stepsById.get(event.step_id) : undefined;
262
- if (!step)
263
- return run;
264
- if (event.type === "step.started") {
265
- const startedStep = await updateRunStep(run.id, step.id, {
639
+ const tagName = text(input.stepWith.tag, resolveRefName(resolveGithubRef(input.run)));
640
+ if (!tagName) {
641
+ throw new GitHostError("forge_actions_runner_failed", "actions/publish-release-asset could not resolve a tag name; pass with.tag explicitly.", {
642
+ uses: input.stepRun.uses,
643
+ });
644
+ }
645
+ const { asset, release } = await publishReleaseAsset({
646
+ assetName,
647
+ format,
648
+ releaseAssetsRoot: resolveReleaseAssetsRoot(options.actions),
649
+ releaseId: text(input.run.release_id) || undefined,
650
+ releases: options.releases,
651
+ repositoryId: input.run.repository_id,
652
+ sourcePath: path.resolve(input.workspacePath, pathSpec),
653
+ tagName,
654
+ });
655
+ await emitRunEvent(input.run, {
656
+ job_id: input.jobRun.job_id,
657
+ job_name: input.jobRun.name,
658
+ job_run_id: input.jobRun.id,
659
+ metadata: {
660
+ asset_id: asset.id,
661
+ asset_name: asset.name,
662
+ release_id: release.id,
663
+ size: asset.size,
664
+ tag_name: release.tag_name,
665
+ },
666
+ status: "success",
667
+ step_id: input.stepRun.id,
668
+ step_index: input.stepRun.index,
669
+ step_name: input.stepRun.name,
670
+ summary: `Published release asset ${asset.name}.`,
671
+ type: "release_asset.published",
672
+ });
673
+ return {
674
+ outputPreview: `Published release asset ${asset.name} to release ${release.tag_name}.\n`,
675
+ summary: `Published release asset ${asset.name}.`,
676
+ };
677
+ }
678
+ async function executeUsesStep(input) {
679
+ const uses = text(input.stepRun.uses);
680
+ if (uses === "actions/checkout" || uses === "actions/checkout@v4") {
681
+ const targetRef = text(input.stepWith.ref);
682
+ const targetPath = text(input.stepWith.path, ".");
683
+ if (targetRef && targetRef !== input.run.ref && targetRef !== resolveGithubRef(input.run)) {
684
+ throw new GitHostError("forge_actions_runner_failed", "actions/checkout only supports the workflow snapshot ref in v1.", {
685
+ requestedRef: targetRef,
686
+ runRef: input.run.ref,
687
+ });
688
+ }
689
+ if (targetPath && targetPath !== ".") {
690
+ throw new GitHostError("forge_actions_runner_failed", "actions/checkout path overrides are not supported in v1.", {
691
+ path: targetPath,
692
+ });
693
+ }
694
+ return {
695
+ outputPreview: "Checked out workflow snapshot.\n",
696
+ summary: "Checked out workflow snapshot.",
697
+ };
698
+ }
699
+ if (uses === "actions/setup-node" || uses === "actions/setup-node@v4") {
700
+ const version = await setupRuntime("node", input.workspacePath);
701
+ return {
702
+ outputPreview: text(version.stdout, version.stderr),
703
+ summary: "Node runtime is available.",
704
+ };
705
+ }
706
+ if (uses === "oven-sh/setup-bun" || uses === "oven-sh/setup-bun@v2") {
707
+ const version = await setupRuntime("bun", input.workspacePath);
708
+ return {
709
+ outputPreview: text(version.stdout, version.stderr),
710
+ summary: "Bun runtime is available.",
711
+ };
712
+ }
713
+ if (uses === "actions/upload-artifact" || uses === "actions/upload-artifact@v4") {
714
+ const artifactName = text(input.stepWith.name);
715
+ const pathSpec = text(input.stepWith.path);
716
+ if (!artifactName || !pathSpec) {
717
+ throw new GitHostError("forge_actions_runner_failed", "actions/upload-artifact requires with.name and with.path.", {
718
+ uses,
719
+ });
720
+ }
721
+ const stored = uploadArtifact({
722
+ artifactName,
723
+ artifactsRoot: input.artifactsRoot,
724
+ pathSpec,
725
+ workspacePath: input.workspacePath,
726
+ });
727
+ const artifact = await storage.createWorkflowRunArtifact({
728
+ created_at: nowIso(),
729
+ file_count: stored.fileCount,
730
+ id: randomUUID(),
731
+ job_run_id: input.jobRun.id,
732
+ name: artifactName,
733
+ path: stored.path,
734
+ repository_id: input.run.repository_id,
735
+ run_id: input.run.id,
736
+ size: stored.size,
737
+ step_id: input.stepRun.id,
738
+ });
739
+ await emitRunEvent(input.run, {
740
+ artifact_id: artifact.id,
741
+ artifact_name: artifact.name,
742
+ job_id: input.jobRun.job_id,
743
+ job_name: input.jobRun.name,
744
+ job_run_id: input.jobRun.id,
745
+ metadata: {
746
+ file_count: artifact.file_count,
747
+ size: artifact.size,
748
+ },
749
+ status: "success",
750
+ step_id: input.stepRun.id,
751
+ step_index: input.stepRun.index,
752
+ step_name: input.stepRun.name,
753
+ summary: `Uploaded artifact ${artifact.name}.`,
754
+ type: "artifact.uploaded",
755
+ });
756
+ return {
757
+ outputPreview: `Uploaded artifact ${artifactName} (${stored.fileCount} files).\n`,
758
+ summary: `Uploaded artifact ${artifactName}.`,
759
+ };
760
+ }
761
+ if (uses === "actions/download-artifact" || uses === "actions/download-artifact@v4") {
762
+ const artifactName = text(input.stepWith.name);
763
+ if (!artifactName) {
764
+ throw new GitHostError("forge_actions_runner_failed", "actions/download-artifact requires with.name.", {
765
+ uses,
766
+ });
767
+ }
768
+ const artifacts = await storage.listWorkflowRunArtifacts(input.run.id, {
769
+ name: artifactName,
770
+ });
771
+ const artifact = artifacts[0];
772
+ if (!artifact) {
773
+ throw new GitHostError("forge_actions_runner_failed", `Artifact "${artifactName}" was not found for this run.`, {
774
+ artifactName,
775
+ runId: input.run.id,
776
+ });
777
+ }
778
+ downloadArtifact({
779
+ artifact,
780
+ artifactsRoot: input.artifactsRoot,
781
+ destinationPath: text(input.stepWith.path, "."),
782
+ workspacePath: input.workspacePath,
783
+ });
784
+ await emitRunEvent(input.run, {
785
+ artifact_id: artifact.id,
786
+ artifact_name: artifact.name,
787
+ job_id: input.jobRun.job_id,
788
+ job_name: input.jobRun.name,
789
+ job_run_id: input.jobRun.id,
790
+ status: "success",
791
+ step_id: input.stepRun.id,
792
+ step_index: input.stepRun.index,
793
+ step_name: input.stepRun.name,
794
+ summary: `Downloaded artifact ${artifact.name}.`,
795
+ type: "artifact.downloaded",
796
+ });
797
+ return {
798
+ outputPreview: `Downloaded artifact ${artifactName}.\n`,
799
+ summary: `Downloaded artifact ${artifactName}.`,
800
+ };
801
+ }
802
+ if (uses === "actions/publish-release-asset" || uses === "actions/publish-release-asset@v1") {
803
+ return await executePublishReleaseAssetStep(input);
804
+ }
805
+ throw new GitHostError("forge_actions_runner_failed", `Unsupported action "${uses}".`, {
806
+ uses,
807
+ });
808
+ }
809
+ async function executeJob(input) {
810
+ const workflowJob = input.workflow.jobs.find((entry) => entry.id === input.jobRun.job_id);
811
+ if (!workflowJob) {
812
+ throw new GitHostError("forge_invalid_workflow_definition", `Workflow job "${input.jobRun.job_id}" no longer exists.`, {
813
+ jobId: input.jobRun.job_id,
814
+ workflowId: input.workflow.id,
815
+ });
816
+ }
817
+ const baseContext = buildExpressionContext({
818
+ execution: input.execution,
819
+ ...(input.jobRun.matrix ? { matrix: input.jobRun.matrix } : {}),
820
+ needs: input.needs,
821
+ run: input.run,
822
+ triggerContext: normalizeTriggerContext(input.run.trigger_context),
823
+ workflow: input.workflow,
824
+ });
825
+ const dependencyStatus = Object.values(input.needs).map((entry) => entry.result);
826
+ if (!workflowJob.if && dependencyStatus.some((status) => status !== "success")) {
827
+ return await skipJob(input.run, input.jobRun, "Skipped because a dependency did not complete successfully.");
828
+ }
829
+ if (workflowJob.if && !resolveWorkflowBoolean(workflowJob.if, baseContext, true)) {
830
+ return await skipJob(input.run, input.jobRun, `Skipped by if condition for job ${input.jobRun.name}.`);
831
+ }
832
+ if (input.activeState.cancelRequested) {
833
+ return await cancelJob(input.run, input.jobRun, "Cancelled before job start.");
834
+ }
835
+ try {
836
+ assertJobRunnerSupported(input.jobRun);
837
+ }
838
+ catch (error) {
839
+ await markQueuedStepsForJob(input.run.id, input.jobRun.id, "skipped");
840
+ const failedJob = await updateJob(input.run.id, input.jobRun.id, {
841
+ finished_at: nowIso(),
842
+ status: "failed",
843
+ summary: error instanceof Error ? error.message : `Job ${input.jobRun.name} failed before start.`,
844
+ });
845
+ await emitJobFinished(input.run, failedJob);
846
+ return failedJob;
847
+ }
848
+ const workspaceRoot = resolveActionsWorkspaceRoot(options.actions, input.run.repository_id, input.run.id);
849
+ const workspacePath = path.join(workspaceRoot, "jobs", input.jobRun.id, "workspace");
850
+ await materializeJobWorkspace({
851
+ commitHash: input.run.commit_hash,
852
+ repositoryPath: input.repositoryPath,
853
+ workspacePath,
854
+ });
855
+ let jobRun = await updateJob(input.run.id, input.jobRun.id, {
856
+ runner,
857
+ started_at: nowIso(),
858
+ status: "running",
859
+ summary: `Running job ${input.jobRun.name}.`,
860
+ });
861
+ await updateRun(input.run.repository_id, input.run.id, {
862
+ current_job: jobRun.name,
863
+ current_job_id: jobRun.id,
864
+ status: "running",
865
+ summary: `Running job ${jobRun.name}.`,
866
+ });
867
+ await emitJobStarted(input.run, jobRun);
868
+ const stepRuns = await storage.listWorkflowRunSteps(input.run.id, {
869
+ jobRunId: jobRun.id,
870
+ });
871
+ for (const stepRun of stepRuns) {
872
+ if (input.activeState.cancelRequested) {
873
+ await finishStep(input.run, jobRun, stepRun, {
874
+ status: "cancelled",
875
+ summary: `Cancelled before step ${stepRun.name}.`,
876
+ });
877
+ continue;
878
+ }
879
+ const stepContext = {
880
+ ...baseContext,
881
+ env: mergeRuntimeEnv({
882
+ actions: options.actions,
883
+ execution: input.execution,
884
+ jobEnv: workflowJob.env,
885
+ ...(input.jobRun.matrix ? { matrix: input.jobRun.matrix } : {}),
886
+ run: input.run,
887
+ stepEnv: normalizeEnv(stepRun.metadata?.env),
888
+ triggerContext: normalizeTriggerContext(input.run.trigger_context),
889
+ workflow: input.workflow,
890
+ }),
891
+ job: {
892
+ status: jobRun.status,
893
+ },
894
+ };
895
+ const stepIf = text(stepRun.metadata?.if);
896
+ if (stepIf && !resolveWorkflowBoolean(stepIf, stepContext, true)) {
897
+ await finishStep(input.run, jobRun, stepRun, {
898
+ status: "skipped",
899
+ summary: `Skipped step ${stepRun.name}.`,
900
+ });
901
+ continue;
902
+ }
903
+ const redactor = createRunRedactor(options.actions, input.run, stepRun, input.execution.secrets);
904
+ const resolvedCommand = stepRun.kind === "shell"
905
+ ? resolveWorkflowString(stepRun.command, stepContext)
906
+ : resolveWorkflowString(text(stepRun.uses), stepContext);
907
+ const startedStep = await updateStep(input.run.id, stepRun.id, {
266
908
  started_at: nowIso(),
267
909
  status: "running",
268
910
  });
269
- stepsById.set(step.id, startedStep);
270
- const updatedRun = await updateRun(run.repository_id, run.id, {
911
+ jobRun = await updateJob(input.run.id, jobRun.id, {
912
+ current_step: startedStep.name,
913
+ current_step_index: startedStep.index,
914
+ });
915
+ await updateRun(input.run.repository_id, input.run.id, {
916
+ current_job: jobRun.name,
917
+ current_job_id: jobRun.id,
271
918
  current_step: startedStep.name,
272
919
  current_step_index: startedStep.index,
273
920
  status: "running",
274
- summary: `Running ${startedStep.name}.`,
921
+ summary: `Running ${jobRun.name} / ${startedStep.name}.`,
275
922
  });
276
- await emitRunEvent(updatedRun, {
277
- command: text(event.command, startedStep.command),
923
+ await emitRunEvent(input.run, {
924
+ command: await redactor(resolvedCommand),
925
+ job_id: jobRun.job_id,
926
+ job_name: jobRun.name,
927
+ job_run_id: jobRun.id,
278
928
  status: "running",
279
929
  step_id: startedStep.id,
280
930
  step_index: startedStep.index,
281
931
  step_name: startedStep.name,
282
932
  type: "step.started",
283
933
  });
284
- return updatedRun;
285
- }
286
- if (event.type === "step.output") {
287
- let chunk = text(event.chunk);
288
- if (chunk && options.actions?.redactOutput) {
289
- chunk = await options.actions.redactOutput({
290
- chunk,
291
- run,
292
- step,
293
- stream: event.stream || "stdout",
294
- });
934
+ if (startedStep.kind === "uses") {
935
+ const resolvedWith = Object.fromEntries(Object.entries((startedStep.metadata?.with && typeof startedStep.metadata.with === "object")
936
+ ? startedStep.metadata.with
937
+ : {})
938
+ .map(([key, value]) => [key, resolveWorkflowString(String(value), stepContext)]));
939
+ try {
940
+ const result = await executeUsesStep({
941
+ artifactsRoot: input.artifactsRoot,
942
+ execution: input.execution,
943
+ jobRun,
944
+ run: input.run,
945
+ stepWith: resolvedWith,
946
+ stepRun: startedStep,
947
+ workspacePath,
948
+ });
949
+ const preview = await redactor(result.outputPreview || "");
950
+ if (preview) {
951
+ await emitRunEvent(input.run, {
952
+ chunk: preview,
953
+ job_id: jobRun.job_id,
954
+ job_name: jobRun.name,
955
+ job_run_id: jobRun.id,
956
+ status: "running",
957
+ step_id: startedStep.id,
958
+ step_index: startedStep.index,
959
+ step_name: startedStep.name,
960
+ stream: "stdout",
961
+ type: "step.output",
962
+ });
963
+ await emitRunEvent(input.run, {
964
+ chunk: preview,
965
+ job_id: jobRun.job_id,
966
+ job_name: jobRun.name,
967
+ job_run_id: jobRun.id,
968
+ status: "running",
969
+ step_id: startedStep.id,
970
+ step_index: startedStep.index,
971
+ step_name: startedStep.name,
972
+ stream: "stdout",
973
+ type: "job.output",
974
+ });
975
+ }
976
+ await finishStep(input.run, jobRun, startedStep, {
977
+ outputPreview: preview,
978
+ status: "success",
979
+ summary: result.summary,
980
+ });
981
+ continue;
982
+ }
983
+ catch (error) {
984
+ const summary = error instanceof Error ? error.message : "Action step failed.";
985
+ await finishStep(input.run, jobRun, startedStep, {
986
+ outputPreview: summary,
987
+ status: input.activeState.cancelRequested ? "cancelled" : "failed",
988
+ summary,
989
+ });
990
+ await markQueuedStepsForJob(input.run.id, jobRun.id, input.activeState.cancelRequested ? "cancelled" : "skipped");
991
+ jobRun = await updateJob(input.run.id, jobRun.id, {
992
+ current_step: null,
993
+ current_step_index: null,
994
+ finished_at: nowIso(),
995
+ status: input.activeState.cancelRequested ? "cancelled" : "failed",
996
+ summary,
997
+ });
998
+ await emitJobFinished(input.run, jobRun);
999
+ return jobRun;
1000
+ }
295
1001
  }
296
- await emitRunEvent(run, {
297
- chunk,
298
- status: "running",
299
- step_id: step.id,
300
- step_index: step.index,
301
- step_name: step.name,
302
- stream: event.stream || "stdout",
303
- type: "step.output",
1002
+ const shell = text(startedStep.metadata?.shell, text(options.actions?.shell, "bash"));
1003
+ const runtimeEnv = mergeRuntimeEnv({
1004
+ actions: options.actions,
1005
+ execution: input.execution,
1006
+ jobEnv: workflowJob.env,
1007
+ ...(input.jobRun.matrix ? { matrix: input.jobRun.matrix } : {}),
1008
+ run: input.run,
1009
+ stepEnv: normalizeEnv(startedStep.metadata?.env),
1010
+ triggerContext: normalizeTriggerContext(input.run.trigger_context),
1011
+ workflow: input.workflow,
304
1012
  });
305
- return run;
306
- }
307
- if (event.type === "step.heartbeat") {
308
- await emitRunEvent(run, {
309
- status: "running",
310
- step_id: step.id,
311
- step_index: step.index,
312
- step_name: step.name,
313
- type: "step.heartbeat",
1013
+ const result = await runShellCommand({
1014
+ command: resolvedCommand,
1015
+ cwd: workspacePath,
1016
+ env: runtimeEnv,
1017
+ heartbeatIntervalMs: Math.max(250, Number(options.actions?.heartbeatIntervalMs) || DEFAULT_HEARTBEAT_INTERVAL_MS),
1018
+ onHeartbeat: async () => {
1019
+ await emitRunEvent(input.run, {
1020
+ job_id: jobRun.job_id,
1021
+ job_name: jobRun.name,
1022
+ job_run_id: jobRun.id,
1023
+ status: "running",
1024
+ step_id: startedStep.id,
1025
+ step_index: startedStep.index,
1026
+ step_name: startedStep.name,
1027
+ type: "step.heartbeat",
1028
+ });
1029
+ await emitRunEvent(input.run, {
1030
+ job_id: jobRun.job_id,
1031
+ job_name: jobRun.name,
1032
+ job_run_id: jobRun.id,
1033
+ status: "running",
1034
+ step_id: startedStep.id,
1035
+ step_index: startedStep.index,
1036
+ step_name: startedStep.name,
1037
+ type: "job.heartbeat",
1038
+ });
1039
+ },
1040
+ onOutput: async (stream, chunk) => {
1041
+ const redacted = await redactor(chunk, stream);
1042
+ await emitRunEvent(input.run, {
1043
+ chunk: redacted,
1044
+ job_id: jobRun.job_id,
1045
+ job_name: jobRun.name,
1046
+ job_run_id: jobRun.id,
1047
+ status: "running",
1048
+ step_id: startedStep.id,
1049
+ step_index: startedStep.index,
1050
+ step_name: startedStep.name,
1051
+ stream,
1052
+ type: "step.output",
1053
+ });
1054
+ await emitRunEvent(input.run, {
1055
+ chunk: redacted,
1056
+ job_id: jobRun.job_id,
1057
+ job_name: jobRun.name,
1058
+ job_run_id: jobRun.id,
1059
+ status: "running",
1060
+ step_id: startedStep.id,
1061
+ step_index: startedStep.index,
1062
+ step_name: startedStep.name,
1063
+ stream,
1064
+ type: "job.output",
1065
+ });
1066
+ },
1067
+ onSpawn: (child) => {
1068
+ input.activeState.child = child;
1069
+ },
1070
+ shell: shell || "bash",
1071
+ });
1072
+ const redactedPreview = await redactor(result.outputPreview);
1073
+ if (input.activeState.cancelRequested || result.exitCode === 130) {
1074
+ await finishStep(input.run, jobRun, startedStep, {
1075
+ exitCode: result.exitCode,
1076
+ outputPreview: redactedPreview,
1077
+ status: "cancelled",
1078
+ summary: `Cancelled during step ${startedStep.name}.`,
1079
+ });
1080
+ await markQueuedStepsForJob(input.run.id, jobRun.id, "cancelled");
1081
+ jobRun = await updateJob(input.run.id, jobRun.id, {
1082
+ current_step: null,
1083
+ current_step_index: null,
1084
+ finished_at: nowIso(),
1085
+ status: "cancelled",
1086
+ summary: `Cancelled during job ${jobRun.name}.`,
1087
+ });
1088
+ await emitJobFinished(input.run, jobRun);
1089
+ return jobRun;
1090
+ }
1091
+ if (result.exitCode !== 0) {
1092
+ await finishStep(input.run, jobRun, startedStep, {
1093
+ exitCode: result.exitCode,
1094
+ outputPreview: redactedPreview,
1095
+ status: "failed",
1096
+ summary: `Failed step ${startedStep.name}.`,
1097
+ });
1098
+ await markQueuedStepsForJob(input.run.id, jobRun.id, "skipped");
1099
+ jobRun = await updateJob(input.run.id, jobRun.id, {
1100
+ current_step: null,
1101
+ current_step_index: null,
1102
+ finished_at: nowIso(),
1103
+ status: "failed",
1104
+ summary: `Job ${jobRun.name} failed in step ${startedStep.name}.`,
1105
+ });
1106
+ await emitJobFinished(input.run, jobRun);
1107
+ return jobRun;
1108
+ }
1109
+ await finishStep(input.run, jobRun, startedStep, {
1110
+ exitCode: result.exitCode,
1111
+ outputPreview: redactedPreview,
1112
+ status: "success",
1113
+ summary: `Completed step ${startedStep.name}.`,
314
1114
  });
315
- return run;
316
1115
  }
317
- const finishedStep = await updateRunStep(run.id, step.id, {
318
- exit_code: typeof event.exit_code === "number" ? event.exit_code : step.exit_code,
1116
+ jobRun = await updateJob(input.run.id, jobRun.id, {
1117
+ current_step: null,
1118
+ current_step_index: null,
319
1119
  finished_at: nowIso(),
320
- output_preview: text(event.output_preview, step.output_preview),
321
- status: event.status || step.status,
1120
+ status: "success",
1121
+ summary: `Job ${jobRun.name} completed successfully.`,
322
1122
  });
323
- stepsById.set(step.id, finishedStep);
324
- await emitRunEvent(run, {
325
- command: finishedStep.command,
326
- metadata: {
327
- exit_code: finishedStep.exit_code,
328
- },
329
- status: finishedStep.status,
330
- step_id: finishedStep.id,
331
- step_index: finishedStep.index,
332
- step_name: finishedStep.name,
333
- summary: text(event.summary),
334
- type: "step.finished",
335
- });
336
- return run;
1123
+ await emitJobFinished(input.run, jobRun);
1124
+ return jobRun;
337
1125
  }
338
1126
  async function executeRun(repositoryId, runId) {
339
1127
  let run = await readRequiredRun(repositoryId, runId);
@@ -343,7 +1131,7 @@ function createGitForgeActionsRuntime(options) {
343
1131
  runner,
344
1132
  started_at: nowIso(),
345
1133
  status: "starting",
346
- summary: "Preparing workflow workspace.",
1134
+ summary: "Preparing workflow run.",
347
1135
  });
348
1136
  await emitRunEvent(run, {
349
1137
  metadata: {
@@ -354,63 +1142,98 @@ function createGitForgeActionsRuntime(options) {
354
1142
  type: "run.status",
355
1143
  });
356
1144
  const repositoryPath = await readRepositoryPath(repositoryId);
357
- const steps = await storage.listWorkflowRunSteps(run.id);
358
- const stepsById = new Map(steps.map((step) => [step.id, step]));
1145
+ const workflow = await readWorkflowAtRef(repositoryId, repositoryPath, run.ref, run.workflow_id);
1146
+ const execution = runExecutionContexts.get(run.id) || {
1147
+ actor: (run.execution_context?.actor && typeof run.execution_context.actor === "object") ? run.execution_context.actor : undefined,
1148
+ env: normalizeEnv(run.execution_context?.env) || {},
1149
+ inputs: (run.trigger_context?.inputs && typeof run.trigger_context.inputs === "object")
1150
+ ? run.trigger_context.inputs
1151
+ : {},
1152
+ metadata: (run.execution_context?.metadata && typeof run.execution_context.metadata === "object") ? run.execution_context.metadata : undefined,
1153
+ secrets: {},
1154
+ };
359
1155
  const activeState = {
360
1156
  cancelRequested: false,
1157
+ child: null,
361
1158
  };
362
1159
  activeRuns.set(run.id, activeState);
363
1160
  try {
364
- const execution = executeActionsRunner({
365
- actions: options.actions,
366
- heartbeatIntervalMs: Math.max(250, Number(options.actions?.heartbeatIntervalMs) || DEFAULT_HEARTBEAT_INTERVAL_MS),
367
- onEvent: async (event) => {
368
- run = await handleRunnerEvent(run, stepsById, event);
369
- },
370
- repositoryPath,
371
- run,
372
- steps,
373
- workspaceRoot: resolveActionsWorkspaceRoot(options.actions, run.repository_id, run.id),
374
- });
375
- activeState.child = execution.child;
376
- const result = await execution.completed;
377
- const persistedSteps = await storage.listWorkflowRunSteps(run.id);
378
- const failedStep = persistedSteps.find((step) => step.status === "failed");
379
- if (activeState.cancelRequested || result.cancelled) {
380
- await markQueuedSteps(run.id, result.lastStepIndex, "cancelled");
1161
+ const artifactsRoot = path.join(resolveActionsWorkspaceRoot(options.actions, run.repository_id, run.id), "artifacts");
1162
+ const allJobs = await storage.listWorkflowRunJobs(run.id);
1163
+ const pendingJobs = Array.from(allJobs).sort((left, right) => left.index - right.index);
1164
+ const completedByJobId = new Map();
1165
+ while (pendingJobs.length) {
1166
+ if (activeState.cancelRequested) {
1167
+ await markQueuedJobsAndSteps(run.id, "cancelled");
1168
+ return await finalizeRun(run, {
1169
+ eventType: "run.cancelled",
1170
+ status: "cancelled",
1171
+ summary: "Workflow run cancelled.",
1172
+ });
1173
+ }
1174
+ const nextIndex = pendingJobs.findIndex((job) => ((job.needs || []).every((need) => {
1175
+ const results = completedByJobId.get(need) || [];
1176
+ const expected = allJobs.filter((entry) => entry.job_id === need).length;
1177
+ return results.length === expected && results.every((entry) => isTerminalJobStatus(entry.status));
1178
+ })));
1179
+ if (nextIndex < 0) {
1180
+ throw new GitHostError("forge_invalid_workflow_definition", `Workflow "${workflow.id}" has no runnable job order.`, {
1181
+ workflowId: workflow.id,
1182
+ });
1183
+ }
1184
+ const nextJob = pendingJobs.splice(nextIndex, 1)[0];
1185
+ const needs = Object.fromEntries((nextJob.needs || []).map((need) => {
1186
+ const statuses = (completedByJobId.get(need) || []).map((entry) => entry.status);
1187
+ return [need, { result: aggregateJobStatus(statuses) }];
1188
+ }));
1189
+ const finishedJob = await executeJob({
1190
+ activeState,
1191
+ artifactsRoot,
1192
+ execution,
1193
+ jobRun: nextJob,
1194
+ needs,
1195
+ repositoryPath,
1196
+ run,
1197
+ workflow,
1198
+ });
1199
+ const existing = completedByJobId.get(finishedJob.job_id) || [];
1200
+ existing.push(finishedJob);
1201
+ completedByJobId.set(finishedJob.job_id, existing);
1202
+ }
1203
+ const jobs = await storage.listWorkflowRunJobs(run.id);
1204
+ if (jobs.some((job) => job.status === "failed")) {
1205
+ const failedJob = jobs.find((job) => job.status === "failed");
1206
+ await markQueuedJobsAndSteps(run.id, "skipped");
1207
+ return await finalizeRun(run, {
1208
+ eventType: "run.failed",
1209
+ status: "failed",
1210
+ summary: text(failedJob?.summary, "Workflow run failed."),
1211
+ });
1212
+ }
1213
+ if (jobs.some((job) => job.status === "cancelled")) {
1214
+ await markQueuedJobsAndSteps(run.id, "cancelled");
381
1215
  return await finalizeRun(run, {
382
- currentStep: null,
383
- currentStepIndex: null,
384
1216
  eventType: "run.cancelled",
385
1217
  status: "cancelled",
386
1218
  summary: "Workflow run cancelled.",
387
1219
  });
388
1220
  }
389
- if (failedStep || result.exitCode !== 0) {
390
- await markQueuedSteps(run.id, failedStep ? failedStep.index : result.lastStepIndex, "skipped");
1221
+ if (jobs.every((job) => job.status === "skipped")) {
391
1222
  return await finalizeRun(run, {
392
- currentStep: null,
393
- currentStepIndex: null,
394
- eventType: "run.failed",
395
- status: "failed",
396
- summary: failedStep
397
- ? `Workflow run failed in step ${failedStep.name}.`
398
- : `Workflow run failed in step ${text(result.lastStepName, "unknown")}.`,
1223
+ eventType: "run.finished",
1224
+ status: "skipped",
1225
+ summary: "Workflow run was skipped.",
399
1226
  });
400
1227
  }
401
1228
  return await finalizeRun(run, {
402
- currentStep: null,
403
- currentStepIndex: null,
404
1229
  eventType: "run.finished",
405
1230
  status: "success",
406
1231
  summary: "Workflow run completed successfully.",
407
1232
  });
408
1233
  }
409
1234
  catch (error) {
410
- await markQueuedSteps(run.id, -1, activeState.cancelRequested ? "cancelled" : "skipped");
1235
+ await markQueuedJobsAndSteps(run.id, activeState.cancelRequested ? "cancelled" : "skipped");
411
1236
  return await finalizeRun(run, {
412
- currentStep: null,
413
- currentStepIndex: null,
414
1237
  eventType: activeState.cancelRequested ? "run.cancelled" : "run.failed",
415
1238
  status: activeState.cancelRequested ? "cancelled" : "failed",
416
1239
  summary: activeState.cancelRequested
@@ -426,6 +1249,7 @@ function createGitForgeActionsRuntime(options) {
426
1249
  catch { }
427
1250
  }
428
1251
  activeRuns.delete(run.id);
1252
+ runExecutionContexts.delete(run.id);
429
1253
  }
430
1254
  }
431
1255
  async function processQueue() {
@@ -457,7 +1281,6 @@ function createGitForgeActionsRuntime(options) {
457
1281
  const workflows = await listRepositoryWorkflows({
458
1282
  filters: {
459
1283
  enabled: true,
460
- trigger: triggerKind,
461
1284
  },
462
1285
  ref: target.ref,
463
1286
  repositoryId,
@@ -473,7 +1296,6 @@ function createGitForgeActionsRuntime(options) {
473
1296
  branch: target.branch || undefined,
474
1297
  commitHash: target.commitHash,
475
1298
  ref: target.ref,
476
- releaseId: text(context.release_id) || null,
477
1299
  triggerContext: context,
478
1300
  triggerKind,
479
1301
  }));
@@ -532,7 +1354,7 @@ function createGitForgeActionsRuntime(options) {
532
1354
  return entry;
533
1355
  };
534
1356
  }
535
- return {
1357
+ const runtime = {
536
1358
  bindActivityStorage,
537
1359
  async listWorkflows(repositoryId, filters) {
538
1360
  const repositoryPath = await readRepositoryPath(repositoryId);
@@ -551,6 +1373,11 @@ function createGitForgeActionsRuntime(options) {
551
1373
  async runWorkflow(repositoryId, workflowId, input) {
552
1374
  const target = await resolveRunTarget(repositoryId, input);
553
1375
  const workflow = await readWorkflowAtRef(repositoryId, target.repositoryPath, target.ref, workflowId);
1376
+ if (!workflow.on?.workflow_dispatch && workflow.trigger !== "manual") {
1377
+ throw new GitHostError("forge_invalid_workflow_definition", `Workflow "${workflowId}" does not support manual dispatch.`, {
1378
+ workflowId,
1379
+ });
1380
+ }
554
1381
  return await queueWorkflowRun(repositoryId, workflow, {
555
1382
  ...input,
556
1383
  branch: target.branch || undefined,
@@ -564,6 +1391,14 @@ function createGitForgeActionsRuntime(options) {
564
1391
  if (isTerminalRunStatus(run.status))
565
1392
  return run;
566
1393
  const active = activeRuns.get(run.id);
1394
+ await emitRunEvent(run, {
1395
+ metadata: {
1396
+ actor_id: actor.id,
1397
+ },
1398
+ status: "running",
1399
+ summary: `Cancellation requested by ${text(actor.id)}.`,
1400
+ type: "run.cancellation_requested",
1401
+ });
567
1402
  if (active) {
568
1403
  active.cancelRequested = true;
569
1404
  if (active.child && !active.child.killed) {
@@ -576,6 +1411,7 @@ function createGitForgeActionsRuntime(options) {
576
1411
  summary: `Cancellation requested by ${text(actor.id)}.`,
577
1412
  });
578
1413
  }
1414
+ await markQueuedJobsAndSteps(run.id, "cancelled");
579
1415
  const cancelled = await updateRun(repositoryId, runId, {
580
1416
  finished_at: nowIso(),
581
1417
  status: "cancelled",
@@ -594,9 +1430,17 @@ function createGitForgeActionsRuntime(options) {
594
1430
  async readWorkflowRun(repositoryId, runId) {
595
1431
  return await readRequiredRun(repositoryId, runId);
596
1432
  },
597
- async listWorkflowRunSteps(repositoryId, runId) {
1433
+ async listWorkflowRunJobs(repositoryId, runId, filters) {
1434
+ await readRequiredRun(repositoryId, runId);
1435
+ return await storage.listWorkflowRunJobs(runId, filters);
1436
+ },
1437
+ async listWorkflowRunSteps(repositoryId, runId, filters) {
1438
+ await readRequiredRun(repositoryId, runId);
1439
+ return await storage.listWorkflowRunSteps(runId, filters);
1440
+ },
1441
+ async listWorkflowRunArtifacts(repositoryId, runId, filters) {
598
1442
  await readRequiredRun(repositoryId, runId);
599
- return await storage.listWorkflowRunSteps(runId);
1443
+ return await storage.listWorkflowRunArtifacts(runId, filters);
600
1444
  },
601
1445
  async listWorkflowRunEvents(repositoryId, runId, filters) {
602
1446
  await readRequiredRun(repositoryId, runId);
@@ -620,6 +1464,7 @@ function createGitForgeActionsRuntime(options) {
620
1464
  };
621
1465
  },
622
1466
  };
1467
+ return runtime;
623
1468
  }
624
1469
  export { createGitForgeActionsRuntime, isTerminalRunStatus };
625
1470
  //# sourceMappingURL=forge_actions.js.map