create-interview-cockpit 0.23.0 → 0.23.1
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 +1 -1
- package/template/client/src/api.ts +2 -0
- package/template/client/src/components/GhaConcurrencyPanel.tsx +281 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +487 -15
- package/template/client/src/components/WorkspaceSwitcher.tsx +6 -1
- package/template/client/src/ghaConcurrency.ts +216 -0
- package/template/client/src/githubActionsLab.ts +41 -0
- package/template/client/src/types.ts +17 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +157 -1
- package/template/server/src/google-drive.ts +25 -9
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// ─── Shared GitHub Actions concurrency helpers ─────────────────────────
|
|
2
|
+
// Used by the GitHub Actions Lab to evaluate `concurrency:` blocks against
|
|
3
|
+
// a simulated github context AND to enforce GitHub's queue/cancel rules on
|
|
4
|
+
// the actual `act` runs triggered from the lab UI.
|
|
5
|
+
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
|
|
8
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Subset of `github.*` context fields used when evaluating a concurrency
|
|
12
|
+
* group expression. Kept narrow on purpose so we don't oversell what the
|
|
13
|
+
* tiny expression engine below can resolve.
|
|
14
|
+
*/
|
|
15
|
+
export interface GhaConcurrencyContext {
|
|
16
|
+
event_name: string;
|
|
17
|
+
ref: string;
|
|
18
|
+
head_ref: string;
|
|
19
|
+
workflow: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParsedConcurrency {
|
|
23
|
+
groupExpr: string;
|
|
24
|
+
cancelExpr: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type GhaConcurrencyRunStatus =
|
|
28
|
+
| "pending"
|
|
29
|
+
| "running"
|
|
30
|
+
| "completed"
|
|
31
|
+
| "cancelled";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One record per Run button click. Records survive in memory for the
|
|
35
|
+
* lifetime of the modal so the user can see the full timeline of how
|
|
36
|
+
* GitHub's concurrency rules played out.
|
|
37
|
+
*/
|
|
38
|
+
export interface GhaConcurrencyRun {
|
|
39
|
+
id: string;
|
|
40
|
+
seq: number;
|
|
41
|
+
command: string;
|
|
42
|
+
/** github.event_name when the run was triggered. */
|
|
43
|
+
eventName: string;
|
|
44
|
+
/** Workflow file path used for the run. */
|
|
45
|
+
workflowPath: string;
|
|
46
|
+
/** Evaluated group key — empty string when no concurrency block exists. */
|
|
47
|
+
groupKey: string;
|
|
48
|
+
/** Evaluated cancel-in-progress flag — false when not declared. */
|
|
49
|
+
cancelInProgress: boolean;
|
|
50
|
+
/** Snapshot of the github.* context the run was evaluated against. */
|
|
51
|
+
context: GhaConcurrencyContext;
|
|
52
|
+
status: GhaConcurrencyRunStatus;
|
|
53
|
+
startedAt?: number;
|
|
54
|
+
endedAt?: number;
|
|
55
|
+
exitCode?: number;
|
|
56
|
+
cancelReason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract the workflow-level `concurrency` block. Tolerates both shapes
|
|
63
|
+
* GitHub accepts (`concurrency: my-group` and the long `group:` form).
|
|
64
|
+
* Returns null when the workflow doesn't declare concurrency.
|
|
65
|
+
*/
|
|
66
|
+
export function parseConcurrencyBlock(
|
|
67
|
+
yaml: string | undefined,
|
|
68
|
+
): ParsedConcurrency | null {
|
|
69
|
+
if (!yaml) return null;
|
|
70
|
+
let doc: unknown;
|
|
71
|
+
try {
|
|
72
|
+
doc = parseYaml(yaml);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!doc || typeof doc !== "object") return null;
|
|
77
|
+
const raw = (doc as Record<string, unknown>).concurrency;
|
|
78
|
+
if (raw == null) return null;
|
|
79
|
+
if (typeof raw === "string") {
|
|
80
|
+
return { groupExpr: raw, cancelExpr: "false" };
|
|
81
|
+
}
|
|
82
|
+
if (typeof raw === "object") {
|
|
83
|
+
const obj = raw as Record<string, unknown>;
|
|
84
|
+
const group = typeof obj.group === "string" ? obj.group : "";
|
|
85
|
+
const cancel = obj["cancel-in-progress"];
|
|
86
|
+
const cancelExpr =
|
|
87
|
+
typeof cancel === "boolean"
|
|
88
|
+
? String(cancel)
|
|
89
|
+
: typeof cancel === "string"
|
|
90
|
+
? cancel
|
|
91
|
+
: "false";
|
|
92
|
+
return { groupExpr: group, cancelExpr };
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Expression engine ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve `github.X` against the simulated context. Returns undefined for
|
|
101
|
+
* any other namespace so unknown references render as empty (matching
|
|
102
|
+
* GitHub's behaviour for undefined expressions inside `${{ ... }}`).
|
|
103
|
+
*/
|
|
104
|
+
function resolveGithubPath(
|
|
105
|
+
path: string,
|
|
106
|
+
ctx: GhaConcurrencyContext,
|
|
107
|
+
): string | undefined {
|
|
108
|
+
if (!path.startsWith("github.")) return undefined;
|
|
109
|
+
const key = path.slice("github.".length) as keyof GhaConcurrencyContext;
|
|
110
|
+
const value = ctx[key];
|
|
111
|
+
return typeof value === "string" ? value : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Evaluate one `${{ … }}` expression. Supports the subset real workflows
|
|
116
|
+
* use when defining concurrency keys:
|
|
117
|
+
* - github.X
|
|
118
|
+
* - 'string literal'
|
|
119
|
+
* - true / false / integer literals
|
|
120
|
+
* - X || Y (coalesce — first non-empty wins, GitHub semantics)
|
|
121
|
+
* - X == Y (string equality → 'true'/'false')
|
|
122
|
+
*/
|
|
123
|
+
function evalConcurrencyExpr(expr: string, ctx: GhaConcurrencyContext): string {
|
|
124
|
+
const trimmed = expr.trim();
|
|
125
|
+
if (!trimmed) return "";
|
|
126
|
+
|
|
127
|
+
// Equality binds tighter than `||` in our tiny grammar (matches the way
|
|
128
|
+
// real workflows are normally written: `event_name == 'pull_request'`).
|
|
129
|
+
if (trimmed.includes("==")) {
|
|
130
|
+
const [lhs, rhs] = trimmed.split("==").map((s) => s.trim());
|
|
131
|
+
const l = evalConcurrencyExpr(lhs, ctx);
|
|
132
|
+
const r = evalConcurrencyExpr(rhs, ctx);
|
|
133
|
+
return l === r ? "true" : "false";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Coalesce: walk left-to-right, return the first non-empty value.
|
|
137
|
+
if (trimmed.includes("||")) {
|
|
138
|
+
for (const part of trimmed.split("||")) {
|
|
139
|
+
const value = evalConcurrencyExpr(part, ctx);
|
|
140
|
+
if (value) return value;
|
|
141
|
+
}
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const literal = trimmed.match(/^'([^']*)'$/);
|
|
146
|
+
if (literal) return literal[1];
|
|
147
|
+
|
|
148
|
+
if (trimmed === "true" || trimmed === "false") return trimmed;
|
|
149
|
+
if (/^-?\d+$/.test(trimmed)) return trimmed;
|
|
150
|
+
|
|
151
|
+
const resolved = resolveGithubPath(trimmed, ctx);
|
|
152
|
+
return resolved ?? "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Replace every `${{ … }}` placeholder in `template` with the value its
|
|
157
|
+
* inner expression evaluates to against `ctx`.
|
|
158
|
+
*/
|
|
159
|
+
export function renderConcurrencyTemplate(
|
|
160
|
+
template: string,
|
|
161
|
+
ctx: GhaConcurrencyContext,
|
|
162
|
+
): string {
|
|
163
|
+
if (!template) return "";
|
|
164
|
+
return template.replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_match, inner) =>
|
|
165
|
+
evalConcurrencyExpr(String(inner), ctx),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compute the (groupKey, cancelInProgress) pair for a hypothetical run.
|
|
171
|
+
* When the workflow doesn't declare a concurrency block, returns
|
|
172
|
+
* (empty key, false) which means "no scheduling constraints — treat as
|
|
173
|
+
* its own group with no cancellation".
|
|
174
|
+
*/
|
|
175
|
+
export function evaluateConcurrencyFor(
|
|
176
|
+
parsed: ParsedConcurrency | null,
|
|
177
|
+
ctx: GhaConcurrencyContext,
|
|
178
|
+
): { groupKey: string; cancelInProgress: boolean } {
|
|
179
|
+
if (!parsed) return { groupKey: "", cancelInProgress: false };
|
|
180
|
+
const groupKey = renderConcurrencyTemplate(parsed.groupExpr, ctx);
|
|
181
|
+
const cancelRaw = renderConcurrencyTemplate(parsed.cancelExpr, ctx)
|
|
182
|
+
.trim()
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
return { groupKey, cancelInProgress: cancelRaw === "true" };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Derive a plausible default github.* context from a selected `act` event
|
|
189
|
+
* name. We don't have the real one act injects, but these defaults are
|
|
190
|
+
* close enough that the evaluated group keys feel realistic.
|
|
191
|
+
*/
|
|
192
|
+
export function defaultContextForEvent(
|
|
193
|
+
eventName: string,
|
|
194
|
+
workflowPath: string,
|
|
195
|
+
branch: string,
|
|
196
|
+
prNumber: number,
|
|
197
|
+
headRef: string,
|
|
198
|
+
): GhaConcurrencyContext {
|
|
199
|
+
const workflowName = workflowPath
|
|
200
|
+
.replace(/^\.github\/workflows\//, "")
|
|
201
|
+
.replace(/\.ya?ml$/, "");
|
|
202
|
+
if (eventName === "pull_request") {
|
|
203
|
+
return {
|
|
204
|
+
event_name: eventName,
|
|
205
|
+
ref: `refs/pull/${prNumber}/merge`,
|
|
206
|
+
head_ref: headRef,
|
|
207
|
+
workflow: workflowName,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
event_name: eventName,
|
|
212
|
+
ref: `refs/heads/${branch}`,
|
|
213
|
+
head_ref: "",
|
|
214
|
+
workflow: workflowName,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GithubActionsLabWorkspace } from "./types";
|
|
2
|
+
import type { GithubActionsLabEnvironment } from "./types";
|
|
2
3
|
|
|
3
4
|
// ─── Default Lab Template ────────────────────────────────────────────────
|
|
4
5
|
//
|
|
@@ -401,6 +402,41 @@ export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
|
|
|
401
402
|
|
|
402
403
|
// ─── Helpers (mirror infraLab.ts API surface) ────────────────────────────
|
|
403
404
|
|
|
405
|
+
function cloneGhaLabEnvironment(
|
|
406
|
+
environment?: GithubActionsLabEnvironment,
|
|
407
|
+
): GithubActionsLabEnvironment | undefined {
|
|
408
|
+
if (!environment || typeof environment !== "object") return undefined;
|
|
409
|
+
|
|
410
|
+
const cloneEntries = (entries: GithubActionsLabEnvironment["variables"]) =>
|
|
411
|
+
Array.isArray(entries)
|
|
412
|
+
? entries
|
|
413
|
+
.filter(
|
|
414
|
+
(entry) =>
|
|
415
|
+
entry &&
|
|
416
|
+
typeof entry.name === "string" &&
|
|
417
|
+
typeof entry.value === "string",
|
|
418
|
+
)
|
|
419
|
+
.map((entry) => ({
|
|
420
|
+
name: entry.name,
|
|
421
|
+
value: entry.value,
|
|
422
|
+
...(entry.enabled === false ? { enabled: false } : {}),
|
|
423
|
+
}))
|
|
424
|
+
: [];
|
|
425
|
+
|
|
426
|
+
const variables = cloneEntries(environment.variables);
|
|
427
|
+
const secrets = cloneEntries(environment.secrets);
|
|
428
|
+
const env = cloneEntries(environment.env);
|
|
429
|
+
if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
...(variables.length ? { variables } : {}),
|
|
435
|
+
...(secrets.length ? { secrets } : {}),
|
|
436
|
+
...(env.length ? { env } : {}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
404
440
|
export function cloneGhaLabWorkspace(
|
|
405
441
|
workspace?: GithubActionsLabWorkspace | null,
|
|
406
442
|
): GithubActionsLabWorkspace {
|
|
@@ -415,6 +451,7 @@ export function cloneGhaLabWorkspace(
|
|
|
415
451
|
)
|
|
416
452
|
? source.activeFile
|
|
417
453
|
: (Object.keys(sourceFiles)[0] ?? ".github/workflows/ci.yml");
|
|
454
|
+
const environment = cloneGhaLabEnvironment(source.environment);
|
|
418
455
|
|
|
419
456
|
return {
|
|
420
457
|
version: 1,
|
|
@@ -433,6 +470,7 @@ export function cloneGhaLabWorkspace(
|
|
|
433
470
|
...(source.includeRunHistoryInContext
|
|
434
471
|
? { includeRunHistoryInContext: true }
|
|
435
472
|
: {}),
|
|
473
|
+
...(environment ? { environment } : {}),
|
|
436
474
|
};
|
|
437
475
|
}
|
|
438
476
|
|
|
@@ -517,6 +555,9 @@ export function parseGhaLabWorkspace(
|
|
|
517
555
|
...(parsed.includeRunHistoryInContext === true
|
|
518
556
|
? { includeRunHistoryInContext: true }
|
|
519
557
|
: {}),
|
|
558
|
+
...(parsed.environment && typeof parsed.environment === "object"
|
|
559
|
+
? { environment: parsed.environment as GithubActionsLabEnvironment }
|
|
560
|
+
: {}),
|
|
520
561
|
});
|
|
521
562
|
} catch {
|
|
522
563
|
return null;
|
|
@@ -49,6 +49,21 @@ export interface InfraLabWorkspace {
|
|
|
49
49
|
files: Record<string, string>;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export interface GithubActionsLabEnvironmentEntry {
|
|
53
|
+
name: string;
|
|
54
|
+
value: string;
|
|
55
|
+
enabled?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GithubActionsLabEnvironment {
|
|
59
|
+
/** Values available to workflows as `${{ vars.NAME }}` through act's --var-file. */
|
|
60
|
+
variables?: GithubActionsLabEnvironmentEntry[];
|
|
61
|
+
/** Values available to workflows as `${{ secrets.NAME }}` through act's --secret-file. */
|
|
62
|
+
secrets?: GithubActionsLabEnvironmentEntry[];
|
|
63
|
+
/** Runner/container environment values available to shell steps as `$NAME`. */
|
|
64
|
+
env?: GithubActionsLabEnvironmentEntry[];
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export interface GithubActionsLabWorkspace {
|
|
53
68
|
version: 1;
|
|
54
69
|
label: string;
|
|
@@ -64,6 +79,8 @@ export interface GithubActionsLabWorkspace {
|
|
|
64
79
|
* (job statuses, durations, exit codes) instead of just the YAML.
|
|
65
80
|
*/
|
|
66
81
|
includeRunHistoryInContext?: boolean;
|
|
82
|
+
/** Local act inputs mirroring GitHub repository variables, secrets, and runner env. */
|
|
83
|
+
environment?: GithubActionsLabEnvironment;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
export interface WorkspaceMeta {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
|
package/template/cockpit.json
CHANGED
|
@@ -13,6 +13,19 @@ interface GithubActionsLabWorkspace {
|
|
|
13
13
|
files: Record<string, string>;
|
|
14
14
|
defaultEvent?: string;
|
|
15
15
|
defaultWorkflow?: string;
|
|
16
|
+
environment?: GithubActionsLabEnvironment;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GithubActionsLabEnvironmentEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
value: string;
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface GithubActionsLabEnvironment {
|
|
26
|
+
variables?: GithubActionsLabEnvironmentEntry[];
|
|
27
|
+
secrets?: GithubActionsLabEnvironmentEntry[];
|
|
28
|
+
env?: GithubActionsLabEnvironmentEntry[];
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
type OutputKind = "stdout" | "stderr" | "info";
|
|
@@ -69,8 +82,11 @@ export type GhaStreamMessage =
|
|
|
69
82
|
|
|
70
83
|
const MAX_FILE_COUNT = 60;
|
|
71
84
|
const MAX_TOTAL_SOURCE_BYTES = 1_000_000;
|
|
85
|
+
const MAX_GHA_ENV_ENTRY_COUNT = 80;
|
|
86
|
+
const MAX_GHA_ENV_TOTAL_BYTES = 100_000;
|
|
72
87
|
const MAX_LOG_CHARS = 400_000;
|
|
73
88
|
const SOURCE_MANIFEST = ".gha-source-files.json";
|
|
89
|
+
const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
74
90
|
|
|
75
91
|
// `act` accepts these events; we restrict to common ones so the console
|
|
76
92
|
// can't be used to run arbitrary subcommands.
|
|
@@ -155,6 +171,69 @@ function assertSafeRelativePath(filePath: string, label: string): void {
|
|
|
155
171
|
}
|
|
156
172
|
}
|
|
157
173
|
|
|
174
|
+
function parseEnvironmentEntries(
|
|
175
|
+
input: unknown,
|
|
176
|
+
label: string,
|
|
177
|
+
): GithubActionsLabEnvironmentEntry[] {
|
|
178
|
+
if (!Array.isArray(input)) return [];
|
|
179
|
+
if (input.length > MAX_GHA_ENV_ENTRY_COUNT) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${label} exceeds the ${MAX_GHA_ENV_ENTRY_COUNT} item limit`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let totalBytes = 0;
|
|
186
|
+
return input
|
|
187
|
+
.filter((entry): entry is Record<string, unknown> => {
|
|
188
|
+
return !!entry && typeof entry === "object";
|
|
189
|
+
})
|
|
190
|
+
.map((entry) => {
|
|
191
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
192
|
+
if (!name) return null;
|
|
193
|
+
if (!GHA_ENV_NAME_RE.test(name)) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`${label} name '${name}' is invalid. Use letters, numbers, and underscores; do not start with a number.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const value = typeof entry.value === "string" ? entry.value : "";
|
|
199
|
+
totalBytes += Buffer.byteLength(name, "utf8");
|
|
200
|
+
totalBytes += Buffer.byteLength(value, "utf8");
|
|
201
|
+
if (totalBytes > MAX_GHA_ENV_TOTAL_BYTES) {
|
|
202
|
+
throw new Error(`${label} values exceed the allowed size limit`);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
name,
|
|
206
|
+
value,
|
|
207
|
+
...(entry.enabled === false ? { enabled: false } : {}),
|
|
208
|
+
};
|
|
209
|
+
})
|
|
210
|
+
.filter((entry): entry is GithubActionsLabEnvironmentEntry => !!entry);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseWorkspaceEnvironment(
|
|
214
|
+
input: unknown,
|
|
215
|
+
): GithubActionsLabEnvironment | undefined {
|
|
216
|
+
if (!input || typeof input !== "object") return undefined;
|
|
217
|
+
const candidate = input as Record<string, unknown>;
|
|
218
|
+
const variables = parseEnvironmentEntries(
|
|
219
|
+
candidate.variables,
|
|
220
|
+
"GitHub Actions variable",
|
|
221
|
+
);
|
|
222
|
+
const secrets = parseEnvironmentEntries(
|
|
223
|
+
candidate.secrets,
|
|
224
|
+
"GitHub Actions secret",
|
|
225
|
+
);
|
|
226
|
+
const env = parseEnvironmentEntries(candidate.env, "GitHub Actions env");
|
|
227
|
+
if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
...(variables.length ? { variables } : {}),
|
|
232
|
+
...(secrets.length ? { secrets } : {}),
|
|
233
|
+
...(env.length ? { env } : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
158
237
|
function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
159
238
|
if (!input || typeof input !== "object") {
|
|
160
239
|
throw new Error("workspace payload is required");
|
|
@@ -188,6 +267,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
|
188
267
|
if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
|
|
189
268
|
throw new Error("workspace source exceeds the allowed size limit");
|
|
190
269
|
}
|
|
270
|
+
const environment = parseWorkspaceEnvironment(candidate.environment);
|
|
191
271
|
return {
|
|
192
272
|
version: 1,
|
|
193
273
|
label:
|
|
@@ -207,6 +287,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
|
|
|
207
287
|
typeof candidate.defaultWorkflow === "string"
|
|
208
288
|
? candidate.defaultWorkflow
|
|
209
289
|
: undefined,
|
|
290
|
+
...(environment ? { environment } : {}),
|
|
210
291
|
files,
|
|
211
292
|
};
|
|
212
293
|
}
|
|
@@ -273,6 +354,72 @@ async function syncWorkspaceToSession(
|
|
|
273
354
|
return workspaceDir;
|
|
274
355
|
}
|
|
275
356
|
|
|
357
|
+
function escapeDotenvValue(value: string): string {
|
|
358
|
+
return `"${value
|
|
359
|
+
.replace(/\\/g, "\\\\")
|
|
360
|
+
.replace(/\r/g, "")
|
|
361
|
+
.replace(/\n/g, "\\n")
|
|
362
|
+
.replace(/"/g, '\\"')}"`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatEnvironmentFile(
|
|
366
|
+
entries: GithubActionsLabEnvironmentEntry[],
|
|
367
|
+
): string {
|
|
368
|
+
return `${entries
|
|
369
|
+
.filter((entry) => entry.enabled !== false)
|
|
370
|
+
.map((entry) => `${entry.name}=${escapeDotenvValue(entry.value)}`)
|
|
371
|
+
.join("\n")}\n`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function buildActEnvironmentArgs(
|
|
375
|
+
runDir: string,
|
|
376
|
+
workspace: GithubActionsLabWorkspace,
|
|
377
|
+
): Promise<{ args: string[]; summary: string[] }> {
|
|
378
|
+
const args: string[] = [];
|
|
379
|
+
const summary: string[] = [];
|
|
380
|
+
const specs: Array<{
|
|
381
|
+
key: keyof GithubActionsLabEnvironment;
|
|
382
|
+
flag: string;
|
|
383
|
+
fileName: string;
|
|
384
|
+
label: string;
|
|
385
|
+
}> = [
|
|
386
|
+
{
|
|
387
|
+
key: "variables",
|
|
388
|
+
flag: "--var-file",
|
|
389
|
+
fileName: "act-vars.env",
|
|
390
|
+
label: "variable",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
key: "secrets",
|
|
394
|
+
flag: "--secret-file",
|
|
395
|
+
fileName: "act-secrets.env",
|
|
396
|
+
label: "secret",
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
key: "env",
|
|
400
|
+
flag: "--env-file",
|
|
401
|
+
fileName: "act-env.env",
|
|
402
|
+
label: "runner env",
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
for (const spec of specs) {
|
|
407
|
+
const entries = (workspace.environment?.[spec.key] ?? []).filter(
|
|
408
|
+
(entry) => entry.enabled !== false,
|
|
409
|
+
);
|
|
410
|
+
if (entries.length === 0) continue;
|
|
411
|
+
|
|
412
|
+
const filePath = path.join(runDir, spec.fileName);
|
|
413
|
+
await fs.writeFile(filePath, formatEnvironmentFile(entries), "utf8");
|
|
414
|
+
args.push(spec.flag, filePath);
|
|
415
|
+
summary.push(
|
|
416
|
+
`${entries.length} ${spec.label}${entries.length === 1 ? "" : "s"}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return { args, summary };
|
|
421
|
+
}
|
|
422
|
+
|
|
276
423
|
// ─── Command parsing ────────────────────────────────────────────────────
|
|
277
424
|
|
|
278
425
|
function splitCommand(command: string): string[] {
|
|
@@ -722,6 +869,8 @@ export async function streamGhaCommand(
|
|
|
722
869
|
const runDir = path.join(getGhaRunsDir(), runId);
|
|
723
870
|
const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
|
|
724
871
|
await fs.mkdir(runDir, { recursive: true });
|
|
872
|
+
const environmentArgs = await buildActEnvironmentArgs(runDir, workspace);
|
|
873
|
+
const actArgs = [...platformArgs.args, ...environmentArgs.args];
|
|
725
874
|
|
|
726
875
|
let logs = "";
|
|
727
876
|
let status: "completed" | "failed" = "completed";
|
|
@@ -737,12 +886,19 @@ export async function streamGhaCommand(
|
|
|
737
886
|
text: "[info] Using default Medium act runner image mappings. Pass -P/--platform to override.\n",
|
|
738
887
|
});
|
|
739
888
|
}
|
|
889
|
+
if (environmentArgs.summary.length > 0) {
|
|
890
|
+
emit({
|
|
891
|
+
type: "output",
|
|
892
|
+
kind: "info",
|
|
893
|
+
text: `[info] Injected ${environmentArgs.summary.join(", ")} into act via temporary files.\n`,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
740
896
|
|
|
741
897
|
// Track per-job status from act's prefixed stdout/stderr lines so the
|
|
742
898
|
// client can render a live DAG in addition to the raw console.
|
|
743
899
|
const tracker = new JobTracker((job) => emit({ type: "job", job }));
|
|
744
900
|
|
|
745
|
-
const child = spawn("act",
|
|
901
|
+
const child = spawn("act", actArgs, {
|
|
746
902
|
cwd: workspaceDir,
|
|
747
903
|
env: {
|
|
748
904
|
...process.env,
|
|
@@ -1163,6 +1163,27 @@ async function getOrCreateFolder(
|
|
|
1163
1163
|
return folderId;
|
|
1164
1164
|
}
|
|
1165
1165
|
|
|
1166
|
+
async function resolveExportFolderId(
|
|
1167
|
+
drive: drive_v3.Drive,
|
|
1168
|
+
ws: storage.WorkspaceMeta,
|
|
1169
|
+
targetFolderId?: string,
|
|
1170
|
+
): Promise<string> {
|
|
1171
|
+
const requestedTarget = targetFolderId?.trim();
|
|
1172
|
+
if (requestedTarget) return requestedTarget;
|
|
1173
|
+
|
|
1174
|
+
// If the workspace is currently scoped to a Drive subfolder (for example
|
|
1175
|
+
// Shared Questions/SBD), push back into that same folder. Falling back to
|
|
1176
|
+
// _export here made topic-level pushes from a selected subfolder land in
|
|
1177
|
+
// Shared Questions/_export instead of beside the original topic folders.
|
|
1178
|
+
const selectedSubfolder = ws.driveConfig?.subFolderId?.trim();
|
|
1179
|
+
if (selectedSubfolder) return selectedSubfolder;
|
|
1180
|
+
|
|
1181
|
+
if (!ws.driveConfig?.folderId) {
|
|
1182
|
+
throw new Error("No Drive folder linked to this workspace");
|
|
1183
|
+
}
|
|
1184
|
+
return getOrCreateFolder(drive, ws.driveConfig.folderId, EXPORT_FOLDER_NAME);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1166
1187
|
async function uploadFileToFolder(
|
|
1167
1188
|
drive: drive_v3.Drive,
|
|
1168
1189
|
folderId: string,
|
|
@@ -1342,7 +1363,6 @@ export async function exportWorkspace(
|
|
|
1342
1363
|
}
|
|
1343
1364
|
|
|
1344
1365
|
const drive = await getExportDriveClient();
|
|
1345
|
-
const { folderId } = ws.driveConfig;
|
|
1346
1366
|
const result: ExportResult = {
|
|
1347
1367
|
topicsExported: 0,
|
|
1348
1368
|
questionsExported: 0,
|
|
@@ -1350,10 +1370,9 @@ export async function exportWorkspace(
|
|
|
1350
1370
|
errors: [],
|
|
1351
1371
|
};
|
|
1352
1372
|
|
|
1353
|
-
// Use the chosen subfolder directly
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
(await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
|
|
1373
|
+
// Use the chosen subfolder directly. If none was passed, use the workspace's
|
|
1374
|
+
// selected Drive subfolder before falling back to root/_export.
|
|
1375
|
+
const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
|
|
1357
1376
|
const topics = await storage.getTopicsForWorkspace(workspaceId);
|
|
1358
1377
|
const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
|
|
1359
1378
|
|
|
@@ -1415,10 +1434,7 @@ export async function exportTopic(
|
|
|
1415
1434
|
}
|
|
1416
1435
|
|
|
1417
1436
|
const drive = await getExportDriveClient();
|
|
1418
|
-
const
|
|
1419
|
-
const exportFolderId =
|
|
1420
|
-
targetFolderId ??
|
|
1421
|
-
(await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
|
|
1437
|
+
const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
|
|
1422
1438
|
const topic = (await storage.getTopicsForWorkspace(workspaceId)).find(
|
|
1423
1439
|
(t) => t.id === topicId,
|
|
1424
1440
|
);
|