dtu-github-actions 0.7.1 → 0.8.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.
@@ -188,6 +188,29 @@ describe("Action Tarball Cache", () => {
188
188
  const data = await res.json();
189
189
  expect(data.actions).toEqual({});
190
190
  });
191
+ it("should skip local actions without crashing", async () => {
192
+ const baseUrl = `http://localhost:${PORT}`;
193
+ const res = await fetch(`${baseUrl}/_apis/distributedtask/hubs/Hub/plans/Plan/actiondownloadinfo`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({
197
+ actions: [
198
+ { nameWithOwner: "./.github/actions/shared-node-cache", ref: "" },
199
+ { nameWithOwner: "", ref: "" },
200
+ { nameWithOwner: "actions/checkout", ref: "v4" },
201
+ ],
202
+ }),
203
+ });
204
+ expect(res.status).toBe(200);
205
+ const data = await res.json();
206
+ // Local and empty actions should be skipped
207
+ expect(data.actions["./.github/actions/shared-node-cache@"]).toBeUndefined();
208
+ expect(data.actions["@"]).toBeUndefined();
209
+ // Remote action should still be present
210
+ const checkoutInfo = data.actions["actions/checkout@v4"];
211
+ expect(checkoutInfo).toBeDefined();
212
+ expect(checkoutInfo.tarballUrl).toBe(`${baseUrl}/_dtu/action-tarball/actions/checkout/v4`);
213
+ });
191
214
  });
192
215
  // ── writeStepOutputLines group filtering ─────────────────────────────────────
193
216
  // The writeStepOutputLines function is internal to registerActionRoutes, so we
@@ -38,6 +38,19 @@ export function toContextData(obj) {
38
38
  // TemplateTokenJsonConverter uses "type" key (integer) NOT the contextData "t" key.
39
39
  // TokenType.Mapping = 2. Items are serialized as {Key: scalarToken, Value: templateToken}.
40
40
  // Strings without file/line/col are serialized as bare string values.
41
+ /**
42
+ * Convert a string value to the appropriate TemplateToken.
43
+ * If the value is a pure `${{ expr }}` expression, encode it as a
44
+ * BasicExpressionToken (type 6) so the runner evaluates it at execution time.
45
+ * Otherwise, return a bare string (StringToken).
46
+ */
47
+ function toTemplateTokenValue(v) {
48
+ const exprMatch = v.match(/^\$\{\{\s*([\s\S]+?)\s*\}\}$/);
49
+ if (exprMatch) {
50
+ return { type: 3, expr: exprMatch[1] };
51
+ }
52
+ return v;
53
+ }
41
54
  export function toTemplateTokenMapping(obj) {
42
55
  const entries = Object.entries(obj);
43
56
  if (entries.length === 0) {
@@ -45,7 +58,7 @@ export function toTemplateTokenMapping(obj) {
45
58
  }
46
59
  return {
47
60
  type: 2,
48
- map: entries.map(([k, v]) => ({ Key: k, Value: v })),
61
+ map: entries.map(([k, v]) => ({ Key: k, Value: toTemplateTokenValue(v) })),
49
62
  };
50
63
  }
51
64
  /**
@@ -96,6 +109,7 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
96
109
  id: step.Id || step.id || crypto.randomUUID(),
97
110
  name: step.Name || step.name || `step-${index}`,
98
111
  displayName: step.DisplayName || step.Name || step.name || `step-${index}`,
112
+ contextName: step.ContextName || step.contextName || undefined,
99
113
  type: (step.Type || "Action").toLowerCase(),
100
114
  reference: (() => {
101
115
  const refTypeSource = step.Reference?.Type || "Script";
@@ -128,6 +142,12 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
128
142
  const ownerName = payload.repository?.owner?.login || "redwoodjs";
129
143
  const repoName = payload.repository?.name || repoFullName.split("/")[1] || "";
130
144
  const workspacePath = `/home/runner/_work/${repoName}/${repoName}`;
145
+ const headSha = payload.headSha && payload.headSha !== "HEAD"
146
+ ? payload.headSha
147
+ : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
148
+ // realHeadSha is the actual HEAD commit SHA, even when headSha is unset
149
+ // (dirty workspace mode). Used for push event context (before/after).
150
+ const realHeadSha = payload.realHeadSha || headSha;
131
151
  const Variables = {
132
152
  // Standard GitHub Actions environment variables — always set by real runners.
133
153
  // CI=true is required by many scripts that branch on CI vs local (e.g. default DB_HOST).
@@ -145,6 +165,8 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
145
165
  GITHUB_RUN_NUMBER: { Value: "1", IsSecret: false },
146
166
  GITHUB_JOB: { Value: payload.name || "local-job", IsSecret: false },
147
167
  GITHUB_EVENT_NAME: { Value: "push", IsSecret: false },
168
+ GITHUB_API_URL: { Value: baseUrl, IsSecret: false },
169
+ GITHUB_SERVER_URL: { Value: "https://github.com", IsSecret: false },
148
170
  GITHUB_REF_NAME: { Value: "main", IsSecret: false },
149
171
  GITHUB_WORKFLOW: { Value: payload.workflowName || "local-workflow", IsSecret: false },
150
172
  GITHUB_WORKSPACE: { Value: workspacePath, IsSecret: false },
@@ -154,22 +176,12 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
154
176
  "system.github.repository": { Value: repoFullName, IsSecret: false },
155
177
  "github.repository": { Value: repoFullName, IsSecret: false },
156
178
  "github.actor": { Value: ownerName, IsSecret: false },
157
- "github.sha": {
158
- Value: payload.headSha && payload.headSha !== "HEAD"
159
- ? payload.headSha
160
- : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
161
- IsSecret: false,
162
- },
179
+ "github.sha": { Value: realHeadSha, IsSecret: false },
163
180
  "github.ref": { Value: "refs/heads/main", IsSecret: false },
164
181
  repository: { Value: repoFullName, IsSecret: false },
165
182
  GITHUB_REPOSITORY: { Value: repoFullName, IsSecret: false },
166
183
  GITHUB_ACTOR: { Value: ownerName, IsSecret: false },
167
- GITHUB_SHA: {
168
- Value: payload.headSha && payload.headSha !== "HEAD"
169
- ? payload.headSha
170
- : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
171
- IsSecret: false,
172
- },
184
+ GITHUB_SHA: { Value: realHeadSha, IsSecret: false },
173
185
  "build.repository.name": { Value: repoFullName, IsSecret: false },
174
186
  "build.repository.uri": { Value: `https://github.com/${repoFullName}`, IsSecret: false },
175
187
  };
@@ -192,12 +204,11 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
192
204
  const githubContext = {
193
205
  repository: repoFullName,
194
206
  actor: ownerName,
195
- sha: payload.headSha && payload.headSha !== "HEAD"
196
- ? payload.headSha
197
- : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
207
+ sha: realHeadSha,
198
208
  ref: "refs/heads/main",
209
+ event_name: "push",
199
210
  server_url: "https://github.com",
200
- api_url: `${baseUrl}/_apis`,
211
+ api_url: `${baseUrl}`,
201
212
  graphql_url: `${baseUrl}/_graphql`,
202
213
  workspace: workspacePath,
203
214
  action: "__run",
@@ -216,6 +227,8 @@ export function createJobResponse(jobId, payload, baseUrl, planId) {
216
227
  name: repoName,
217
228
  owner: { login: ownerName },
218
229
  },
230
+ before: payload.baseSha || "0000000000000000000000000000000000000000",
231
+ after: realHeadSha,
219
232
  };
220
233
  }
221
234
  // Collect env vars from job-level and all steps (seen by the runner's expression engine).
@@ -573,6 +573,11 @@ export function registerActionRoutes(app) {
573
573
  const result = { actions: {} };
574
574
  const baseUrl = getBaseUrl(req);
575
575
  for (const action of actions) {
576
+ // Local actions (RepositoryType: "self") are resolved from the workspace by the
577
+ // runner — they never need a tarball download. Skip them to avoid parsing errors.
578
+ if (!action.nameWithOwner || action.nameWithOwner.startsWith("./")) {
579
+ continue;
580
+ }
576
581
  const key = `${action.nameWithOwner}@${action.ref}`;
577
582
  // Strip sub-path from nameWithOwner (e.g. "actions/cache/save" → "actions/cache")
578
583
  // Sub-path actions share the same repo tarball as the parent action.
@@ -23,6 +23,10 @@ export function registerDtuRoutes(app) {
23
23
  Id: crypto.randomUUID(),
24
24
  }));
25
25
  const jobPayload = { ...payload, steps: mappedSteps };
26
+ // Track the original repo root for git operations (e.g. compare commits)
27
+ if (payload.repoRoot) {
28
+ state.repoRoot = payload.repoRoot;
29
+ }
26
30
  // Store the job for dispatch. Runner-targeted jobs go ONLY into runnerJobs
27
31
  // to prevent other runners from stealing them via the generic pool fallback.
28
32
  // Jobs without a runnerName go into the generic pool for any runner to pick up.
@@ -94,7 +94,116 @@ export function registerGithubRoutes(app) {
94
94
  };
95
95
  app.post("/actions/runner-registration", globalRunnerRegistrationHandler);
96
96
  app.post("/api/v3/actions/runner-registration", globalRunnerRegistrationHandler);
97
- // 7. Tarball route — actions/checkout downloads repos via this endpoint.
97
+ // 7. Compare commitsused by actions that detect changed files (e.g. Khan/actions@get-changed-files).
98
+ // Runs `git diff` on the original repo root and returns a GitHub-compatible response.
99
+ const compareHandler = (req, res) => {
100
+ const basehead = req.params.basehead;
101
+ // GitHub format: "base...head" (three dots) or "base..head" (two dots)
102
+ const parts = basehead.split(/\.{2,3}/);
103
+ const [base, head] = parts;
104
+ if (!base || !head) {
105
+ res.writeHead(422, { "Content-Type": "application/json" });
106
+ res.end(JSON.stringify({ message: "Invalid basehead format" }));
107
+ return;
108
+ }
109
+ const repoRoot = state.repoRoot;
110
+ if (!repoRoot) {
111
+ // No repo root available — return empty comparison
112
+ console.warn("[DTU] Compare: no repoRoot available, returning empty file list");
113
+ res.writeHead(200, { "Content-Type": "application/json" });
114
+ res.end(JSON.stringify({ status: "identical", files: [], total_commits: 0, commits: [] }));
115
+ return;
116
+ }
117
+ try {
118
+ const output = execSync(`git diff --name-status ${base} ${head}`, {
119
+ cwd: repoRoot,
120
+ stdio: "pipe",
121
+ timeout: 10000,
122
+ }).toString();
123
+ const statusMap = {
124
+ A: "added",
125
+ M: "modified",
126
+ D: "removed",
127
+ R: "renamed",
128
+ C: "copied",
129
+ T: "changed",
130
+ };
131
+ const files = output
132
+ .trim()
133
+ .split("\n")
134
+ .filter((line) => line.length > 0)
135
+ .map((line) => {
136
+ // Format: "M\tfilename" or "R100\told\tnew"
137
+ const parts = line.split("\t");
138
+ const rawStatus = parts[0];
139
+ const statusChar = rawStatus[0];
140
+ const filename = rawStatus.startsWith("R") ? parts[2] : parts[1];
141
+ const previousFilename = rawStatus.startsWith("R") ? parts[1] : undefined;
142
+ return {
143
+ sha: "0000000000000000000000000000000000000000",
144
+ filename,
145
+ status: statusMap[statusChar] || "modified",
146
+ ...(previousFilename ? { previous_filename: previousFilename } : {}),
147
+ additions: 0,
148
+ deletions: 0,
149
+ changes: 0,
150
+ };
151
+ });
152
+ res.writeHead(200, { "Content-Type": "application/json" });
153
+ res.end(JSON.stringify({
154
+ status: files.length > 0 ? "ahead" : "identical",
155
+ total_commits: 1,
156
+ commits: [],
157
+ files,
158
+ }));
159
+ }
160
+ catch (err) {
161
+ console.warn(`[DTU] Compare failed (${base}...${head}):`, err.message);
162
+ // Fall back to listing all tracked files as "added"
163
+ try {
164
+ const allFiles = execSync("git ls-files", {
165
+ cwd: repoRoot,
166
+ stdio: "pipe",
167
+ timeout: 10000,
168
+ }).toString();
169
+ const files = allFiles
170
+ .trim()
171
+ .split("\n")
172
+ .filter((f) => f.length > 0)
173
+ .map((filename) => ({
174
+ sha: "0000000000000000000000000000000000000000",
175
+ filename,
176
+ status: "added",
177
+ additions: 0,
178
+ deletions: 0,
179
+ changes: 0,
180
+ }));
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify({
183
+ status: "ahead",
184
+ total_commits: 1,
185
+ commits: [],
186
+ files,
187
+ }));
188
+ }
189
+ catch {
190
+ res.writeHead(500, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify({ message: "Failed to compute diff" }));
192
+ }
193
+ }
194
+ };
195
+ app.get("/repos/:owner/:repo/compare/:basehead", compareHandler);
196
+ app.get("/_apis/repos/:owner/:repo/compare/:basehead", compareHandler);
197
+ // 8. List pull requests associated with a commit — used by some changed-files actions
198
+ // when the push event has an all-zeros `before` (new branch push).
199
+ const listPrsForCommitHandler = (req, res) => {
200
+ console.log(`[DTU] List PRs for commit ${req.params.sha} (mock: returning empty)`);
201
+ res.writeHead(200, { "Content-Type": "application/json" });
202
+ res.end(JSON.stringify([]));
203
+ };
204
+ app.get("/repos/:owner/:repo/commits/:sha/pulls", listPrsForCommitHandler);
205
+ app.get("/_apis/repos/:owner/:repo/commits/:sha/pulls", listPrsForCommitHandler);
206
+ // 9. Tarball route — actions/checkout downloads repos via this endpoint.
98
207
  // Return an empty tar.gz since the workspace is already bind-mounted.
99
208
  const tarballHandler = (req, res) => {
100
209
  console.log(`[DTU] Serving empty tarball for ${req.url}`);
@@ -19,6 +19,7 @@ export declare const state: {
19
19
  planToLogDir: Map<string, string>;
20
20
  timelineToLogDir: Map<string, string>;
21
21
  currentInProgressStep: Map<string, string>;
22
+ repoRoot: string | undefined;
22
23
  virtualCachePatterns: Set<string>;
23
24
  caches: Map<string, {
24
25
  version: string;
@@ -37,6 +37,8 @@ export const state = {
37
37
  // timelineId → sanitized name of the currently in-progress step
38
38
  // (used as fallback when the feed recordId is a Job-level ID)
39
39
  currentInProgressStep: new Map(),
40
+ // Original repo root on the host (for git operations like compare)
41
+ repoRoot: undefined,
40
42
  // Substring patterns for cache keys that should always return a synthetic hit
41
43
  // with an empty archive (e.g. "pnpm" for bind-mounted pnpm stores).
42
44
  virtualCachePatterns: new Set(),
@@ -94,6 +96,7 @@ export const state = {
94
96
  this.planToLogDir.clear();
95
97
  this.timelineToLogDir.clear();
96
98
  this.currentInProgressStep.clear();
99
+ this.repoRoot = undefined;
97
100
  this.virtualCachePatterns.clear();
98
101
  this.caches.clear();
99
102
  this.pendingCaches.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dtu-github-actions",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Digital Twin Universe - GitHub Actions Mock and Simulation",
5
5
  "keywords": [
6
6
  "ci",