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 all step-level env: vars into the job Variables.
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=mysql, DB_PORT=3306 etc. into the step subprocess.
179
- // (Step-scoped env would require full template compilation; job-level Variables are sufficient
180
- // for DB credentials / CI flags that are consistent across steps.)
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 all steps (job-level env context seen by the runner's expression engine).
218
- // Step-level `env:` blocks in the workflow YAML need to be exposed via ContextData.env so the
219
- // runner can evaluate them. We merge all step envs — slightly broader than per-step scoping but
220
- // correct for typical use (DB_HOST, DB_PORT, CI flags etc.).
221
- const mergedStepEnv = {};
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(mergedStepEnv, step.Env);
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 all step-level env: blocks so the runner's expression engine
234
- // can substitute ${{ env.DB_HOST }} etc. during step execution.
235
- ...(Object.keys(mergedStepEnv).length > 0 ? { env: toContextData(mergedStepEnv) } : {}),
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(mergedStepEnv).length > 0 ? [toTemplateTokenMapping(mergedStepEnv)] : [],
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
+ });
@@ -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", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dtu-github-actions",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Digital Twin Universe - GitHub Actions Mock and Simulation",
5
5
  "keywords": [
6
6
  "ci",