dtu-github-actions 0.7.0 → 0.7.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.
|
@@ -173,11 +173,15 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
|
|
|
173
173
|
"build.repository.name": { Value: repoFullName, IsSecret: false },
|
|
174
174
|
"build.repository.uri": { Value: `https://github.com/${repoFullName}`, IsSecret: false },
|
|
175
175
|
};
|
|
176
|
-
// Merge
|
|
176
|
+
// Merge job-level env: into Variables first, then step-level env: (step wins on conflict).
|
|
177
177
|
// The runner exports every Variable as a process env var for all steps, so this is the
|
|
178
|
-
// reliable mechanism to get DB_HOST
|
|
179
|
-
//
|
|
180
|
-
|
|
178
|
+
// reliable mechanism to get AGENT_CI_LOCAL, DB_HOST, DB_PORT etc. into the step subprocess
|
|
179
|
+
// and into the runner's expression engine (${{ env.AGENT_CI_LOCAL }}).
|
|
180
|
+
if (payload.env && typeof payload.env === "object") {
|
|
181
|
+
for (const [key, val] of Object.entries(payload.env)) {
|
|
182
|
+
Variables[key] = { Value: String(val), IsSecret: false };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
181
185
|
for (const step of payload.steps || []) {
|
|
182
186
|
if (step.Env && typeof step.Env === "object") {
|
|
183
187
|
for (const [key, val] of Object.entries(step.Env)) {
|
|
@@ -214,14 +218,15 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
|
|
|
214
218
|
},
|
|
215
219
|
};
|
|
216
220
|
}
|
|
217
|
-
// Collect env vars from
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
// Collect env vars from job-level and all steps (seen by the runner's expression engine).
|
|
222
|
+
// Job-level env is applied first, then step-level env wins on conflict.
|
|
223
|
+
const mergedEnv = {};
|
|
224
|
+
if (payload.env && typeof payload.env === "object") {
|
|
225
|
+
Object.assign(mergedEnv, payload.env);
|
|
226
|
+
}
|
|
222
227
|
for (const step of payload.steps || []) {
|
|
223
228
|
if (step.Env) {
|
|
224
|
-
Object.assign(
|
|
229
|
+
Object.assign(mergedEnv, step.Env);
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
232
|
const ContextData = {
|
|
@@ -230,9 +235,9 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
|
|
|
230
235
|
needs: { t: 2, d: [] }, // Empty needs context
|
|
231
236
|
strategy: { t: 2, d: [] }, // Empty strategy context
|
|
232
237
|
matrix: { t: 2, d: [] }, // Empty matrix context
|
|
233
|
-
// env context: merged from
|
|
234
|
-
// can substitute ${{ env.
|
|
235
|
-
...(Object.keys(
|
|
238
|
+
// env context: merged from job-level + step-level env: blocks so the runner's expression
|
|
239
|
+
// engine can substitute ${{ env.AGENT_CI_LOCAL }}, ${{ env.DB_HOST }} etc.
|
|
240
|
+
...(Object.keys(mergedEnv).length > 0 ? { env: toContextData(mergedEnv) } : {}),
|
|
236
241
|
};
|
|
237
242
|
const generatedJobId = crypto.randomUUID();
|
|
238
243
|
const mockToken = createMockJwt(planId, generatedJobId);
|
|
@@ -303,7 +308,7 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
|
|
|
303
308
|
// EnvironmentVariables is IList<TemplateToken> in the runner — each element is a MappingToken.
|
|
304
309
|
// The runner evaluates each MappingToken and merges into Global.EnvironmentVariables (last wins),
|
|
305
310
|
// which then populates ExpressionValues["env"] → subprocess env vars.
|
|
306
|
-
EnvironmentVariables: Object.keys(
|
|
311
|
+
EnvironmentVariables: Object.keys(mergedEnv).length > 0 ? [toTemplateTokenMapping(mergedEnv)] : [],
|
|
307
312
|
};
|
|
308
313
|
return {
|
|
309
314
|
MessageId: 1,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createJobResponse } from "./generators.js";
|
|
3
|
+
describe("createJobResponse", () => {
|
|
4
|
+
const basePayload = {
|
|
5
|
+
id: "1",
|
|
6
|
+
name: "test-job",
|
|
7
|
+
githubRepo: "owner/repo",
|
|
8
|
+
steps: [],
|
|
9
|
+
};
|
|
10
|
+
it("propagates job-level env into Variables", () => {
|
|
11
|
+
const payload = {
|
|
12
|
+
...basePayload,
|
|
13
|
+
env: { AGENT_CI_LOCAL: "true", MY_VAR: "hello" },
|
|
14
|
+
};
|
|
15
|
+
const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
|
|
16
|
+
const body = JSON.parse(response.Body);
|
|
17
|
+
const vars = body.Variables;
|
|
18
|
+
expect(vars.AGENT_CI_LOCAL).toEqual({ Value: "true", IsSecret: false });
|
|
19
|
+
expect(vars.MY_VAR).toEqual({ Value: "hello", IsSecret: false });
|
|
20
|
+
});
|
|
21
|
+
it("propagates job-level env into ContextData.env", () => {
|
|
22
|
+
const payload = {
|
|
23
|
+
...basePayload,
|
|
24
|
+
env: { AGENT_CI_LOCAL: "true" },
|
|
25
|
+
};
|
|
26
|
+
const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
|
|
27
|
+
const body = JSON.parse(response.Body);
|
|
28
|
+
// ContextData.env should be a ContextData object with type 2 (mapping)
|
|
29
|
+
expect(body.ContextData.env).toBeDefined();
|
|
30
|
+
expect(body.ContextData.env.t).toBe(2);
|
|
31
|
+
const entries = body.ContextData.env.d;
|
|
32
|
+
const localEntry = entries.find((e) => e.k === "AGENT_CI_LOCAL");
|
|
33
|
+
expect(localEntry).toBeDefined();
|
|
34
|
+
expect(localEntry.v).toEqual({ t: 0, s: "true" });
|
|
35
|
+
});
|
|
36
|
+
it("propagates job-level env into EnvironmentVariables", () => {
|
|
37
|
+
const payload = {
|
|
38
|
+
...basePayload,
|
|
39
|
+
env: { AGENT_CI_LOCAL: "true" },
|
|
40
|
+
};
|
|
41
|
+
const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
|
|
42
|
+
const body = JSON.parse(response.Body);
|
|
43
|
+
expect(body.EnvironmentVariables).toHaveLength(1);
|
|
44
|
+
const mapping = body.EnvironmentVariables[0];
|
|
45
|
+
expect(mapping.type).toBe(2);
|
|
46
|
+
const entry = mapping.map.find((e) => e.Key === "AGENT_CI_LOCAL");
|
|
47
|
+
expect(entry).toBeDefined();
|
|
48
|
+
expect(entry.Value).toBe("true");
|
|
49
|
+
});
|
|
50
|
+
it("step-level env overrides job-level env on conflict", () => {
|
|
51
|
+
const payload = {
|
|
52
|
+
...basePayload,
|
|
53
|
+
env: { SHARED: "from-job" },
|
|
54
|
+
steps: [{ name: "step1", run: "echo hi", Env: { SHARED: "from-step" } }],
|
|
55
|
+
};
|
|
56
|
+
const response = createJobResponse("1", payload, "http://localhost:3000", "plan-1");
|
|
57
|
+
const body = JSON.parse(response.Body);
|
|
58
|
+
// Variables should have the step-level value (last-write wins)
|
|
59
|
+
expect(body.Variables.SHARED).toEqual({ Value: "from-step", IsSecret: false });
|
|
60
|
+
// ContextData.env should also have the step-level value
|
|
61
|
+
const entries = body.ContextData.env.d;
|
|
62
|
+
const entry = entries.find((e) => e.k === "SHARED");
|
|
63
|
+
expect(entry.v).toEqual({ t: 0, s: "from-step" });
|
|
64
|
+
});
|
|
65
|
+
it("omits env from ContextData when no env is provided", () => {
|
|
66
|
+
const response = createJobResponse("1", basePayload, "http://localhost:3000", "plan-1");
|
|
67
|
+
const body = JSON.parse(response.Body);
|
|
68
|
+
expect(body.ContextData.env).toBeUndefined();
|
|
69
|
+
expect(body.EnvironmentVariables).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/dist/server.test.js
CHANGED
|
@@ -439,6 +439,51 @@ describe("Artifact v4 upload/download", () => {
|
|
|
439
439
|
expect(res.body.lockedUntil).toBeDefined();
|
|
440
440
|
expect(new Date(res.body.lockedUntil).getTime()).toBeGreaterThan(Date.now());
|
|
441
441
|
});
|
|
442
|
+
it("should let runner B steal runner A's job when seeded WITHOUT runnerName (generic pool bug)", async () => {
|
|
443
|
+
// REPRODUCTION for issue #103:
|
|
444
|
+
// When local-job.ts seeds a job without setting runnerName, the job lands
|
|
445
|
+
// in the generic state.jobs pool. If another runner (from a different
|
|
446
|
+
// concurrent workflow) polls before runner A, it steals the job — causing
|
|
447
|
+
// runner A to hang forever waiting for a job that will never arrive.
|
|
448
|
+
const runnerA = "agent-ci-repro-A";
|
|
449
|
+
const runnerB = "agent-ci-repro-B";
|
|
450
|
+
// Register both runners
|
|
451
|
+
await request("POST", "/_dtu/start-runner", {
|
|
452
|
+
runnerName: runnerA,
|
|
453
|
+
logDir: "/tmp/agent-ci-repro-A-logs",
|
|
454
|
+
timelineDir: "/tmp/agent-ci-repro-A-logs",
|
|
455
|
+
});
|
|
456
|
+
await request("POST", "/_dtu/start-runner", {
|
|
457
|
+
runnerName: runnerB,
|
|
458
|
+
logDir: "/tmp/agent-ci-repro-B-logs",
|
|
459
|
+
timelineDir: "/tmp/agent-ci-repro-B-logs",
|
|
460
|
+
});
|
|
461
|
+
// Seed a job intended for runner A, but WITHOUT runnerName (the bug).
|
|
462
|
+
// This goes into the generic state.jobs pool.
|
|
463
|
+
await request("POST", "/_dtu/seed", {
|
|
464
|
+
id: 4001,
|
|
465
|
+
name: "job-intended-for-A",
|
|
466
|
+
// BUG: no runnerName — job lands in generic pool
|
|
467
|
+
});
|
|
468
|
+
// Runner B creates a session and polls — it shouldn't get A's job,
|
|
469
|
+
// but because the job is in the generic pool, B steals it.
|
|
470
|
+
const sessionB = await request("POST", "/_apis/distributedtask/pools/1/sessions", {
|
|
471
|
+
agent: { name: runnerB },
|
|
472
|
+
});
|
|
473
|
+
const pollB = await request("GET", `/_apis/distributedtask/pools/1/messages?sessionId=${sessionB.body.sessionId}`);
|
|
474
|
+
// BUG CONFIRMED: runner B stole the job from the generic pool
|
|
475
|
+
expect(pollB.status).toBe(200);
|
|
476
|
+
const bodyB = JSON.parse(pollB.body.Body);
|
|
477
|
+
expect(bodyB.JobDisplayName).toBe("job-intended-for-A");
|
|
478
|
+
// Now runner A creates a session and polls — the job is gone
|
|
479
|
+
await request("POST", "/_apis/distributedtask/pools/1/sessions", {
|
|
480
|
+
agent: { name: runnerA },
|
|
481
|
+
});
|
|
482
|
+
// Runner A's poll will hang (long-poll timeout) because its job was stolen.
|
|
483
|
+
// We verify by checking state: no jobs remain for A.
|
|
484
|
+
const aHasJob = state.runnerJobs.has(runnerA) || state.jobs.size > 0;
|
|
485
|
+
expect(aHasJob).toBe(false); // A's job is gone — it will hang forever
|
|
486
|
+
}, 10_000);
|
|
442
487
|
it("should handle job request finish (PATCH with result + finishTime)", async () => {
|
|
443
488
|
const finishTime = new Date().toISOString();
|
|
444
489
|
const res = await request("PATCH", "/_apis/distributedtask/jobrequests", {
|