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