takos-runtime-service 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +29 -0
- package/src/__tests__/middleware/rate-limit.test.ts +33 -0
- package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
- package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
- package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
- package/src/__tests__/routes/cli-proxy.test.ts +72 -0
- package/src/__tests__/routes/git-http.test.ts +218 -0
- package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
- package/src/__tests__/routes/sessions/store.test.ts +72 -0
- package/src/__tests__/routes/workspace-scope.test.ts +45 -0
- package/src/__tests__/runtime/action-registry.test.ts +208 -0
- package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
- package/src/__tests__/runtime/actions/executor.test.ts +131 -0
- package/src/__tests__/runtime/composite-expression.test.ts +294 -0
- package/src/__tests__/runtime/file-parsers.test.ts +129 -0
- package/src/__tests__/runtime/logging.test.ts +65 -0
- package/src/__tests__/runtime/paths.test.ts +236 -0
- package/src/__tests__/runtime/secrets.test.ts +247 -0
- package/src/__tests__/runtime/validation.test.ts +516 -0
- package/src/__tests__/setup.ts +126 -0
- package/src/__tests__/shared/errors.test.ts +117 -0
- package/src/__tests__/storage/r2.test.ts +106 -0
- package/src/__tests__/utils/audit-log.test.ts +163 -0
- package/src/__tests__/utils/error-message.test.ts +38 -0
- package/src/__tests__/utils/sandbox-env.test.ts +74 -0
- package/src/app.ts +245 -0
- package/src/index.ts +1 -0
- package/src/middleware/rate-limit.ts +91 -0
- package/src/middleware/space-scope.ts +95 -0
- package/src/routes/actions/action-types.ts +20 -0
- package/src/routes/actions/execution.ts +229 -0
- package/src/routes/actions/index.ts +17 -0
- package/src/routes/actions/job-lifecycle.ts +242 -0
- package/src/routes/actions/job-queries.ts +52 -0
- package/src/routes/cli/proxy.ts +105 -0
- package/src/routes/git/http.ts +565 -0
- package/src/routes/git/init.ts +88 -0
- package/src/routes/repos/branches.ts +160 -0
- package/src/routes/repos/content.ts +209 -0
- package/src/routes/repos/read.ts +130 -0
- package/src/routes/repos/repo-validation.ts +136 -0
- package/src/routes/repos/write.ts +274 -0
- package/src/routes/runtime/exec.ts +147 -0
- package/src/routes/runtime/tools.ts +113 -0
- package/src/routes/sessions/execution.ts +263 -0
- package/src/routes/sessions/files.ts +326 -0
- package/src/routes/sessions/session-routes.ts +241 -0
- package/src/routes/sessions/session-utils.ts +88 -0
- package/src/routes/sessions/snapshot.ts +208 -0
- package/src/routes/sessions/storage.ts +329 -0
- package/src/runtime/actions/action-registry.ts +450 -0
- package/src/runtime/actions/action-result-converter.ts +31 -0
- package/src/runtime/actions/builtin/artifacts.ts +292 -0
- package/src/runtime/actions/builtin/cache-operations.ts +358 -0
- package/src/runtime/actions/builtin/checkout.ts +58 -0
- package/src/runtime/actions/builtin/index.ts +5 -0
- package/src/runtime/actions/builtin/setup-node.ts +86 -0
- package/src/runtime/actions/builtin/tar-parser.ts +175 -0
- package/src/runtime/actions/composite-executor.ts +192 -0
- package/src/runtime/actions/composite-expression.ts +190 -0
- package/src/runtime/actions/executor.ts +578 -0
- package/src/runtime/actions/file-parsers.ts +51 -0
- package/src/runtime/actions/job-manager.ts +213 -0
- package/src/runtime/actions/process-spawner.ts +275 -0
- package/src/runtime/actions/secrets.ts +162 -0
- package/src/runtime/command.ts +120 -0
- package/src/runtime/exec-runner.ts +309 -0
- package/src/runtime/git-http-backend.ts +145 -0
- package/src/runtime/git.ts +98 -0
- package/src/runtime/heartbeat.ts +57 -0
- package/src/runtime/logging.ts +26 -0
- package/src/runtime/paths.ts +264 -0
- package/src/runtime/secure-fs.ts +82 -0
- package/src/runtime/tools/network.ts +161 -0
- package/src/runtime/tools/worker.ts +335 -0
- package/src/runtime/validation.ts +292 -0
- package/src/shared/config.ts +149 -0
- package/src/shared/errors.ts +65 -0
- package/src/shared/temp-id.ts +10 -0
- package/src/storage/r2.ts +287 -0
- package/src/types/hono.d.ts +23 -0
- package/src/utils/audit-log.ts +92 -0
- package/src/utils/process-kill.ts +18 -0
- package/src/utils/sandbox-env.ts +136 -0
- package/src/utils/temp-dir.ts +74 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import type { Dirent } from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
import { parse as parseYaml } from 'yaml';
|
|
7
|
+
import type { ActionRuns, ActionOutputDefinition } from './composite-executor.js';
|
|
8
|
+
import { cloneAndCheckout } from '../git.js';
|
|
9
|
+
import { createLogger } from 'takos-common/logger';
|
|
10
|
+
|
|
11
|
+
const logger = createLogger({ service: 'takos-runtime' });
|
|
12
|
+
|
|
13
|
+
// ===========================================================================
|
|
14
|
+
// --- Action metadata loading ---
|
|
15
|
+
// ===========================================================================
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface ActionInputDefinition {
|
|
22
|
+
description?: string;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
default?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActionMetadata {
|
|
28
|
+
name?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
inputs?: Record<string, ActionInputDefinition>;
|
|
31
|
+
outputs?: Record<string, ActionOutputDefinition>;
|
|
32
|
+
runs?: ActionRuns;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Action metadata loading
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const ALLOWED_ACTION_KEYS = new Set([
|
|
40
|
+
'name', 'author', 'description', 'branding',
|
|
41
|
+
'inputs', 'outputs', 'runs',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export async function loadActionMetadata(actionDir: string): Promise<ActionMetadata> {
|
|
45
|
+
const actionYmlPath = path.join(actionDir, 'action.yml');
|
|
46
|
+
const actionYamlPath = path.join(actionDir, 'action.yaml');
|
|
47
|
+
|
|
48
|
+
let actionContent: string;
|
|
49
|
+
try {
|
|
50
|
+
actionContent = await fs.readFile(actionYmlPath, 'utf-8');
|
|
51
|
+
} catch {
|
|
52
|
+
actionContent = await fs.readFile(actionYamlPath, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parsed = parseYaml(actionContent);
|
|
56
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
57
|
+
throw new Error('Invalid action.yml format: expected a YAML mapping');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const record = parsed as Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
// Reject unexpected top-level keys to mitigate malicious YAML payloads
|
|
63
|
+
for (const key of Object.keys(record)) {
|
|
64
|
+
if (!ALLOWED_ACTION_KEYS.has(key)) {
|
|
65
|
+
throw new Error(`Invalid action.yml: unexpected top-level key '${key}'`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Validate runs structure
|
|
70
|
+
if (record.runs !== undefined) {
|
|
71
|
+
if (typeof record.runs !== 'object' || record.runs === null || Array.isArray(record.runs)) {
|
|
72
|
+
throw new Error('Invalid action.yml: "runs" must be an object');
|
|
73
|
+
}
|
|
74
|
+
const runs = record.runs as Record<string, unknown>;
|
|
75
|
+
if (typeof runs.using !== 'string') {
|
|
76
|
+
throw new Error('Invalid action.yml: "runs.using" must be a string');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate inputs structure
|
|
81
|
+
if (record.inputs !== undefined) {
|
|
82
|
+
if (typeof record.inputs !== 'object' || record.inputs === null || Array.isArray(record.inputs)) {
|
|
83
|
+
throw new Error('Invalid action.yml: "inputs" must be an object');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate outputs structure
|
|
88
|
+
if (record.outputs !== undefined) {
|
|
89
|
+
if (typeof record.outputs !== 'object' || record.outputs === null || Array.isArray(record.outputs)) {
|
|
90
|
+
throw new Error('Invalid action.yml: "outputs" must be an object');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return record as ActionMetadata;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Action reference parsing
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export function parseActionRef(action: string): { owner: string; repo: string; actionPath: string; ref: string } {
|
|
102
|
+
const atIndex = action.indexOf('@');
|
|
103
|
+
const refPart = atIndex >= 0 ? action.slice(atIndex + 1) : 'main';
|
|
104
|
+
const pathPart = atIndex >= 0 ? action.slice(0, atIndex) : action;
|
|
105
|
+
const parts = pathPart.split('/');
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
owner: parts[0] || '',
|
|
109
|
+
repo: parts[1] || '',
|
|
110
|
+
actionPath: parts.slice(2).join('/'),
|
|
111
|
+
ref: refPart || 'main',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Action component validation
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export function validateActionComponent(value: string, label: string): void {
|
|
120
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(value)) {
|
|
121
|
+
throw new Error(`Invalid action ${label}: ${value}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Input resolution
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function normalizeInputValue(value: unknown): string {
|
|
130
|
+
if (value === null || value === undefined) return '';
|
|
131
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
132
|
+
return String(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveInputs(
|
|
136
|
+
definitions: Record<string, ActionInputDefinition> | undefined,
|
|
137
|
+
provided: Record<string, unknown>
|
|
138
|
+
): { resolvedInputs: Record<string, string>; missing: string[] } {
|
|
139
|
+
const resolvedInputs: Record<string, string> = {};
|
|
140
|
+
const missing: string[] = [];
|
|
141
|
+
const providedMap = new Map<string, { key: string; value: unknown }>();
|
|
142
|
+
|
|
143
|
+
for (const [key, value] of Object.entries(provided || {})) {
|
|
144
|
+
providedMap.set(key.toLowerCase(), { key, value });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const definedKeys = new Set<string>();
|
|
148
|
+
|
|
149
|
+
if (definitions) {
|
|
150
|
+
for (const [name, def] of Object.entries(definitions)) {
|
|
151
|
+
const normalized = name.toLowerCase();
|
|
152
|
+
definedKeys.add(normalized);
|
|
153
|
+
|
|
154
|
+
let value = providedMap.get(normalized)?.value;
|
|
155
|
+
if (value === undefined) {
|
|
156
|
+
if (def && Object.prototype.hasOwnProperty.call(def, 'default')) {
|
|
157
|
+
value = def.default;
|
|
158
|
+
} else if (def?.required) {
|
|
159
|
+
missing.push(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (value !== undefined) {
|
|
164
|
+
resolvedInputs[name] = normalizeInputValue(value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(provided || {})) {
|
|
170
|
+
if (!definedKeys.has(key.toLowerCase())) {
|
|
171
|
+
resolvedInputs[key] = normalizeInputValue(value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { resolvedInputs, missing };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildInputEnv(inputs: Record<string, string>): Record<string, string> {
|
|
179
|
+
const env: Record<string, string> = {};
|
|
180
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
181
|
+
env[`INPUT_${key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase()}`] = value;
|
|
182
|
+
}
|
|
183
|
+
return env;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ===========================================================================
|
|
187
|
+
// --- Action cache & marketplace fetching ---
|
|
188
|
+
// ===========================================================================
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Constants
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export const ACTION_CACHE_DIR = path.join(os.tmpdir(), 'takos-actions-cache');
|
|
195
|
+
const ACTION_CACHE_MAX_ENTRIES = 30;
|
|
196
|
+
const ACTION_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB
|
|
197
|
+
const GET_DIR_SIZE_MAX_DEPTH = 10;
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Module-level state
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const actionRepoCache = new Map<string, Promise<string>>();
|
|
204
|
+
/** Per-action mutex to prevent concurrent fetch race conditions. */
|
|
205
|
+
const actionFetchLocks = new Map<string, Promise<string>>();
|
|
206
|
+
let actionCachePrunePromise: Promise<void> | null = null;
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Symlink safety
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
async function removeEscapingSymlinks(dir: string, boundary: string): Promise<void> {
|
|
213
|
+
const resolvedBoundary = path.resolve(boundary);
|
|
214
|
+
let entries: Array<Dirent>;
|
|
215
|
+
try {
|
|
216
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
217
|
+
} catch {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const entryPath = path.join(dir, entry.name);
|
|
223
|
+
try {
|
|
224
|
+
const lstats = await fs.lstat(entryPath);
|
|
225
|
+
if (lstats.isSymbolicLink()) {
|
|
226
|
+
const target = await fs.realpath(entryPath).catch(() => null);
|
|
227
|
+
const isWithinBoundary = target !== null
|
|
228
|
+
&& (target === resolvedBoundary || target.startsWith(resolvedBoundary + path.sep));
|
|
229
|
+
if (!isWithinBoundary) {
|
|
230
|
+
await fs.unlink(entryPath).catch((e) => {
|
|
231
|
+
logger.warn('Failed to unlink escaping symlink (non-critical)', { module: 'action-registry', path: entryPath, error: e });
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} else if (lstats.isDirectory()) {
|
|
235
|
+
await removeEscapingSymlinks(entryPath, boundary);
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {
|
|
238
|
+
logger.warn('Failed to stat entry during symlink cleanup (non-critical)', { module: 'action-registry', path: entryPath, error: e });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Cache size management
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
function evictActionRepoCache(): void {
|
|
248
|
+
if (actionRepoCache.size <= ACTION_CACHE_MAX_ENTRIES) return;
|
|
249
|
+
const toDelete = actionRepoCache.size - ACTION_CACHE_MAX_ENTRIES;
|
|
250
|
+
let deleted = 0;
|
|
251
|
+
for (const key of actionRepoCache.keys()) {
|
|
252
|
+
if (deleted >= toDelete) break;
|
|
253
|
+
actionRepoCache.delete(key);
|
|
254
|
+
deleted++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function getDirectorySize(
|
|
259
|
+
targetPath: string,
|
|
260
|
+
depth: number = 0,
|
|
261
|
+
visited: Set<string> = new Set()
|
|
262
|
+
): Promise<number> {
|
|
263
|
+
if (depth >= GET_DIR_SIZE_MAX_DEPTH) {
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let realPath: string;
|
|
268
|
+
try {
|
|
269
|
+
realPath = await fs.realpath(targetPath);
|
|
270
|
+
} catch {
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
if (visited.has(realPath)) {
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
visited.add(realPath);
|
|
277
|
+
|
|
278
|
+
let total = 0;
|
|
279
|
+
let entries: Array<Dirent>;
|
|
280
|
+
try {
|
|
281
|
+
entries = await fs.readdir(targetPath, { withFileTypes: true });
|
|
282
|
+
} catch {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
const entryPath = path.join(targetPath, entry.name);
|
|
288
|
+
try {
|
|
289
|
+
if (entry.isSymbolicLink()) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (entry.isDirectory()) {
|
|
293
|
+
total += await getDirectorySize(entryPath, depth + 1, visited);
|
|
294
|
+
} else if (entry.isFile()) {
|
|
295
|
+
const stats = await fs.stat(entryPath);
|
|
296
|
+
total += stats.size;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// Ignore inaccessible entries
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return total;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function pruneActionCache(keepPaths: string[] = []): Promise<void> {
|
|
307
|
+
if (actionCachePrunePromise) {
|
|
308
|
+
await actionCachePrunePromise;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
actionCachePrunePromise = (async () => {
|
|
313
|
+
let entries: Array<Dirent>;
|
|
314
|
+
try {
|
|
315
|
+
entries = await fs.readdir(ACTION_CACHE_DIR, { withFileTypes: true });
|
|
316
|
+
} catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const cacheEntries: Array<{ path: string; mtime: number; size: number }> = [];
|
|
321
|
+
|
|
322
|
+
for (const entry of entries) {
|
|
323
|
+
if (!entry.isDirectory()) continue;
|
|
324
|
+
const entryPath = path.join(ACTION_CACHE_DIR, entry.name);
|
|
325
|
+
if (keepPaths.includes(entryPath)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const stats = await fs.stat(entryPath);
|
|
330
|
+
const size = await getDirectorySize(entryPath);
|
|
331
|
+
cacheEntries.push({ path: entryPath, mtime: stats.mtimeMs, size });
|
|
332
|
+
} catch {
|
|
333
|
+
// Ignore entries we cannot stat
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (cacheEntries.length === 0) return;
|
|
338
|
+
|
|
339
|
+
cacheEntries.sort((a, b) => a.mtime - b.mtime);
|
|
340
|
+
|
|
341
|
+
let totalSize = cacheEntries.reduce((sum, entry) => sum + entry.size, 0);
|
|
342
|
+
let totalEntries = cacheEntries.length;
|
|
343
|
+
|
|
344
|
+
for (const entry of cacheEntries) {
|
|
345
|
+
if (totalEntries <= ACTION_CACHE_MAX_ENTRIES && totalSize <= ACTION_CACHE_MAX_BYTES) {
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
await fs.rm(entry.path, { recursive: true, force: true });
|
|
350
|
+
} catch {
|
|
351
|
+
// Ignore removal errors
|
|
352
|
+
}
|
|
353
|
+
totalEntries -= 1;
|
|
354
|
+
totalSize -= entry.size;
|
|
355
|
+
}
|
|
356
|
+
})();
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
await actionCachePrunePromise;
|
|
360
|
+
} finally {
|
|
361
|
+
actionCachePrunePromise = null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Marketplace repo fetching
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
export interface ActionRefInfo {
|
|
370
|
+
owner: string;
|
|
371
|
+
repo: string;
|
|
372
|
+
actionPath: string;
|
|
373
|
+
ref: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function fetchMarketplaceRepo(
|
|
377
|
+
actionRef: ActionRefInfo,
|
|
378
|
+
env: Record<string, string>
|
|
379
|
+
): Promise<string> {
|
|
380
|
+
const cacheKey = `${actionRef.owner}/${actionRef.repo}@${actionRef.ref}`;
|
|
381
|
+
|
|
382
|
+
// Check if a resolved path is already cached
|
|
383
|
+
const cachedPromise = actionRepoCache.get(cacheKey);
|
|
384
|
+
if (cachedPromise) {
|
|
385
|
+
return cachedPromise;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Use a per-action mutex to prevent concurrent fetches of the same action
|
|
389
|
+
// from racing on filesystem operations.
|
|
390
|
+
const existingLock = actionFetchLocks.get(cacheKey);
|
|
391
|
+
if (existingLock) {
|
|
392
|
+
return existingLock;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const fetchPromise = (async () => {
|
|
396
|
+
await fs.mkdir(ACTION_CACHE_DIR, { recursive: true });
|
|
397
|
+
const hash = createHash('sha256').update(cacheKey).digest('hex').slice(0, 16);
|
|
398
|
+
const repoDir = path.join(ACTION_CACHE_DIR, `${actionRef.owner}-${actionRef.repo}-${hash}`);
|
|
399
|
+
|
|
400
|
+
const gitDir = path.join(repoDir, '.git');
|
|
401
|
+
const gitExists = await fs.stat(gitDir).then(() => true).catch(() => false);
|
|
402
|
+
|
|
403
|
+
if (!gitExists) {
|
|
404
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
405
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
406
|
+
|
|
407
|
+
const cloneResult = await cloneAndCheckout({
|
|
408
|
+
repoUrl: `https://github.com/${actionRef.owner}/${actionRef.repo}.git`,
|
|
409
|
+
targetDir: repoDir,
|
|
410
|
+
ref: actionRef.ref,
|
|
411
|
+
shallow: true,
|
|
412
|
+
env,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (!cloneResult.success) {
|
|
416
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
417
|
+
throw new Error(`Failed to fetch action ${cacheKey}: ${cloneResult.output}`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
await removeEscapingSymlinks(repoDir, repoDir);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const now = new Date();
|
|
425
|
+
await fs.utimes(repoDir, now, now);
|
|
426
|
+
} catch {
|
|
427
|
+
// Ignore utimes errors
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
await pruneActionCache([repoDir]);
|
|
431
|
+
return repoDir;
|
|
432
|
+
})();
|
|
433
|
+
|
|
434
|
+
actionFetchLocks.set(cacheKey, fetchPromise);
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const result = await fetchPromise;
|
|
438
|
+
// Cache the resolved path for future lookups
|
|
439
|
+
actionRepoCache.set(cacheKey, Promise.resolve(result));
|
|
440
|
+
evictActionRepoCache();
|
|
441
|
+
return result;
|
|
442
|
+
} catch (err) {
|
|
443
|
+
// Don't cache failures — allow retry
|
|
444
|
+
actionRepoCache.delete(cacheKey);
|
|
445
|
+
throw err;
|
|
446
|
+
} finally {
|
|
447
|
+
// Always release the fetch lock
|
|
448
|
+
actionFetchLocks.delete(cacheKey);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExecutorStepResult } from './executor.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append a step result's stdout/stderr to accumulator arrays.
|
|
5
|
+
*/
|
|
6
|
+
export function appendOutput(
|
|
7
|
+
result: ExecutorStepResult,
|
|
8
|
+
stdoutParts: string[],
|
|
9
|
+
stderrParts: string[]
|
|
10
|
+
): void {
|
|
11
|
+
if (result.stdout) stdoutParts.push(result.stdout);
|
|
12
|
+
if (result.stderr) stderrParts.push(result.stderr);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a combined ExecutorStepResult from accumulated stdout/stderr parts.
|
|
17
|
+
*/
|
|
18
|
+
export function buildCombinedResult(
|
|
19
|
+
stdoutParts: string[],
|
|
20
|
+
stderrParts: string[],
|
|
21
|
+
outputs: Record<string, string>,
|
|
22
|
+
conclusion: 'success' | 'failure'
|
|
23
|
+
): ExecutorStepResult {
|
|
24
|
+
return {
|
|
25
|
+
exitCode: conclusion === 'success' ? 0 : 1,
|
|
26
|
+
stdout: stdoutParts.join('\n').trimEnd(),
|
|
27
|
+
stderr: stderrParts.join('\n').trimEnd(),
|
|
28
|
+
outputs,
|
|
29
|
+
conclusion,
|
|
30
|
+
};
|
|
31
|
+
}
|