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.
- package/dist/server/routes/actions/action-tarball.test.js +23 -0
- package/dist/server/routes/actions/generators.js +30 -17
- package/dist/server/routes/actions/index.js +5 -0
- package/dist/server/routes/dtu.js +4 -0
- package/dist/server/routes/github.js +110 -1
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +3 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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}
|
|
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.
|
|
97
|
+
// 7. Compare commits — used 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}`);
|
package/dist/server/store.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/store.js
CHANGED
|
@@ -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();
|