@ever-works/github-actions-build-plugin 1.0.0
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/index.cjs +2329 -0
- package/dist/index.d.cts +1474 -0
- package/dist/index.d.ts +1474 -0
- package/dist/index.js +2271 -0
- package/package.json +87 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var contracts = require('@ever-works/contracts');
|
|
6
|
+
var octokit = require('octokit');
|
|
7
|
+
var fflate = require('fflate');
|
|
8
|
+
var module$1 = require('module');
|
|
9
|
+
|
|
10
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
11
|
+
// src/github-actions-build.plugin.ts
|
|
12
|
+
|
|
13
|
+
// src/runs/actions-runs.port.ts
|
|
14
|
+
function octokitActionsRunsPort(octokit) {
|
|
15
|
+
const asArray = (data, key) => {
|
|
16
|
+
const bag = data;
|
|
17
|
+
const list = bag?.[key];
|
|
18
|
+
return Array.isArray(list) ? list : [];
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
async dispatchWorkflow({ repository, workflowFile, ref, inputs }) {
|
|
22
|
+
await octokit.request("POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", {
|
|
23
|
+
owner: repository.owner,
|
|
24
|
+
repo: repository.repo,
|
|
25
|
+
workflow_id: workflowFile,
|
|
26
|
+
ref,
|
|
27
|
+
inputs
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
async listWorkflowRuns({ repository, workflowFile, perPage, headSha, event }) {
|
|
31
|
+
const params = {
|
|
32
|
+
owner: repository.owner,
|
|
33
|
+
repo: repository.repo,
|
|
34
|
+
workflow_id: workflowFile,
|
|
35
|
+
per_page: perPage
|
|
36
|
+
};
|
|
37
|
+
if (headSha) params.head_sha = headSha;
|
|
38
|
+
if (event) params.event = event;
|
|
39
|
+
const { data } = await octokit.request(
|
|
40
|
+
"GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs",
|
|
41
|
+
params
|
|
42
|
+
);
|
|
43
|
+
return asArray(data, "workflow_runs");
|
|
44
|
+
},
|
|
45
|
+
async getWorkflowRun({ repository, runId }) {
|
|
46
|
+
try {
|
|
47
|
+
const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
|
|
48
|
+
owner: repository.owner,
|
|
49
|
+
repo: repository.repo,
|
|
50
|
+
run_id: runId
|
|
51
|
+
});
|
|
52
|
+
return data ?? null;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.status === 404) return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async listRunJobs({ repository, runId }) {
|
|
59
|
+
const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs", {
|
|
60
|
+
owner: repository.owner,
|
|
61
|
+
repo: repository.repo,
|
|
62
|
+
run_id: runId,
|
|
63
|
+
per_page: 100
|
|
64
|
+
});
|
|
65
|
+
return asArray(data, "jobs");
|
|
66
|
+
},
|
|
67
|
+
async cancelWorkflowRun({ repository, runId }) {
|
|
68
|
+
try {
|
|
69
|
+
await octokit.request("POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel", {
|
|
70
|
+
owner: repository.owner,
|
|
71
|
+
repo: repository.repo,
|
|
72
|
+
run_id: runId
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.status === 409) return;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async listRunArtifacts({ repository, runId }) {
|
|
80
|
+
const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", {
|
|
81
|
+
owner: repository.owner,
|
|
82
|
+
repo: repository.repo,
|
|
83
|
+
run_id: runId,
|
|
84
|
+
per_page: 100
|
|
85
|
+
});
|
|
86
|
+
return asArray(data, "artifacts");
|
|
87
|
+
},
|
|
88
|
+
async downloadJobLogTail({ repository, jobId, maxBytes }) {
|
|
89
|
+
const response = await octokit.request("GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs", {
|
|
90
|
+
owner: repository.owner,
|
|
91
|
+
repo: repository.repo,
|
|
92
|
+
job_id: jobId,
|
|
93
|
+
// A request, not a guarantee — the reader cuts again on arrival.
|
|
94
|
+
headers: { range: `bytes=-${maxBytes}` }
|
|
95
|
+
});
|
|
96
|
+
const data = response.data;
|
|
97
|
+
if (data === null || data === void 0) return null;
|
|
98
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
99
|
+
return { bytes, partial: response.status === 206 };
|
|
100
|
+
},
|
|
101
|
+
async downloadArtifactZip({ repository, artifactId }) {
|
|
102
|
+
const { data } = await octokit.request(
|
|
103
|
+
"GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}",
|
|
104
|
+
{
|
|
105
|
+
owner: repository.owner,
|
|
106
|
+
repo: repository.repo,
|
|
107
|
+
artifact_id: artifactId,
|
|
108
|
+
archive_format: "zip"
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
return new Uint8Array(data);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/runs/run-correlator.ts
|
|
117
|
+
var DISPATCH_CLOCK_SKEW_MS = 5e3;
|
|
118
|
+
var ADOPTION_WINDOW_MS = 3e5;
|
|
119
|
+
var CORRELATION_PAGE_SIZE = 20;
|
|
120
|
+
function titleNamesBuild(run, buildId) {
|
|
121
|
+
const title = (run.display_title ?? "").trim();
|
|
122
|
+
return title.length > 0 && buildId.length > 0 && title.includes(buildId);
|
|
123
|
+
}
|
|
124
|
+
function createdAtMs(run) {
|
|
125
|
+
const raw = run.created_at ?? run.run_started_at ?? null;
|
|
126
|
+
if (!raw) return null;
|
|
127
|
+
const at = Date.parse(raw);
|
|
128
|
+
return Number.isNaN(at) ? null : at;
|
|
129
|
+
}
|
|
130
|
+
async function correlateDispatchedRun(port, input) {
|
|
131
|
+
const runs = await port.listWorkflowRuns({
|
|
132
|
+
repository: input.repository,
|
|
133
|
+
workflowFile: input.workflowFile,
|
|
134
|
+
perPage: CORRELATION_PAGE_SIZE
|
|
135
|
+
});
|
|
136
|
+
const named = runs.filter((run) => titleNamesBuild(run, input.buildId));
|
|
137
|
+
if (named.length === 0) {
|
|
138
|
+
return { providerRunId: null, reason: "noMatch" };
|
|
139
|
+
}
|
|
140
|
+
const earliest = input.dispatchedAtMs - DISPATCH_CLOCK_SKEW_MS;
|
|
141
|
+
const latest = input.dispatchedAtMs + ADOPTION_WINDOW_MS;
|
|
142
|
+
for (const run of named) {
|
|
143
|
+
const created = createdAtMs(run);
|
|
144
|
+
if (created === null) continue;
|
|
145
|
+
if (created >= earliest && created <= latest) {
|
|
146
|
+
return { providerRunId: String(run.id) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { providerRunId: null, reason: "outsideWindow" };
|
|
150
|
+
}
|
|
151
|
+
var BUILD_JOB_NAME = "build";
|
|
152
|
+
function isCheckJob(job) {
|
|
153
|
+
return (job.name ?? "").startsWith(contracts.APP_BUILD_CHECK_NAME_PREFIX);
|
|
154
|
+
}
|
|
155
|
+
function jobBillableMinutes(job) {
|
|
156
|
+
if (!job.started_at || !job.completed_at) return 0;
|
|
157
|
+
const started = Date.parse(job.started_at);
|
|
158
|
+
const completed = Date.parse(job.completed_at);
|
|
159
|
+
if (Number.isNaN(started) || Number.isNaN(completed) || completed <= started) return 0;
|
|
160
|
+
return Math.ceil((completed - started) / 6e4);
|
|
161
|
+
}
|
|
162
|
+
function toBuildRunStatus(status, conclusion) {
|
|
163
|
+
if (status !== "completed") {
|
|
164
|
+
return status === "in_progress" ? "running" : "queued";
|
|
165
|
+
}
|
|
166
|
+
switch (conclusion) {
|
|
167
|
+
case "success":
|
|
168
|
+
return "succeeded";
|
|
169
|
+
case "cancelled":
|
|
170
|
+
return "cancelled";
|
|
171
|
+
case null:
|
|
172
|
+
case void 0:
|
|
173
|
+
return "running";
|
|
174
|
+
default:
|
|
175
|
+
return "failed";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function toTrigger(run, mode) {
|
|
179
|
+
if (run.event === "push") return "push";
|
|
180
|
+
if (run.event === "pull_request") return "pull_request";
|
|
181
|
+
return mode === "verify" ? "verification" : "manual";
|
|
182
|
+
}
|
|
183
|
+
function failingStep(job) {
|
|
184
|
+
const step = (job?.steps ?? []).find(
|
|
185
|
+
(candidate) => candidate.conclusion === "failure" || candidate.conclusion === "timed_out"
|
|
186
|
+
);
|
|
187
|
+
if (!step) return null;
|
|
188
|
+
return { name: (step.name ?? "").trim(), number: Number(step.number ?? 0) };
|
|
189
|
+
}
|
|
190
|
+
function observeRun({ run, jobs, mode }) {
|
|
191
|
+
const buildJob2 = jobs.find((job) => job.name === BUILD_JOB_NAME);
|
|
192
|
+
const checkJobs = jobs.filter(isCheckJob);
|
|
193
|
+
const status = buildJob2 ? toBuildRunStatus(buildJob2.status ?? null, buildJob2.conclusion ?? null) : toBuildRunStatus(run.status ?? null, run.conclusion ?? null);
|
|
194
|
+
const billableMinutes = jobs.reduce((total, job) => total + jobBillableMinutes(job), 0);
|
|
195
|
+
const checksBillableMinutes = checkJobs.reduce((total, job) => total + jobBillableMinutes(job), 0);
|
|
196
|
+
const pullRequestNumber = (run.pull_requests ?? []).find((pr) => typeof pr?.number === "number")?.number;
|
|
197
|
+
const snapshot = {
|
|
198
|
+
providerRunId: String(run.id),
|
|
199
|
+
runAttempt: Number(run.run_attempt ?? 1),
|
|
200
|
+
status,
|
|
201
|
+
trigger: toTrigger(run, mode),
|
|
202
|
+
branch: run.head_branch ?? "",
|
|
203
|
+
// A pull-request run's `head_sha` IS the PR head — the commit the member
|
|
204
|
+
// pushed, and the one the image is built from.
|
|
205
|
+
commitSha: run.head_sha ?? ""
|
|
206
|
+
};
|
|
207
|
+
const conclusion = buildJob2 ? buildJob2.conclusion : run.conclusion;
|
|
208
|
+
if (conclusion) snapshot.conclusion = conclusion;
|
|
209
|
+
if (typeof pullRequestNumber === "number") snapshot.pullRequestNumber = pullRequestNumber;
|
|
210
|
+
if (run.run_started_at) snapshot.startedAt = run.run_started_at;
|
|
211
|
+
if (run.status === "completed" && run.updated_at) snapshot.completedAt = run.updated_at;
|
|
212
|
+
if (billableMinutes > 0) snapshot.billableMinutes = billableMinutes;
|
|
213
|
+
if (checkJobs.length > 0) snapshot.checksBillableMinutes = checksBillableMinutes;
|
|
214
|
+
const runnerLabel = (buildJob2?.labels ?? []).find((label) => label.trim().length > 0);
|
|
215
|
+
if (runnerLabel) snapshot.runnerLabel = runnerLabel;
|
|
216
|
+
if (run.html_url) snapshot.logsUrl = run.html_url;
|
|
217
|
+
return snapshot;
|
|
218
|
+
}
|
|
219
|
+
var RESULT_ARTIFACT_MAX_ZIP_BYTES = 65536;
|
|
220
|
+
var RESULT_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
221
|
+
var ALLOWED_KEYS = /* @__PURE__ */ new Set(["digest", "imageRepository", "tags", "secretCheck", "smoke"]);
|
|
222
|
+
function parseResultArtifact(value) {
|
|
223
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
224
|
+
return { ok: false, refusal: "malformedJson" };
|
|
225
|
+
}
|
|
226
|
+
const bag = value;
|
|
227
|
+
const unknown = Object.keys(bag).filter((key) => !ALLOWED_KEYS.has(key));
|
|
228
|
+
if (unknown.length > 0) {
|
|
229
|
+
return { ok: false, refusal: "unknownKeys" };
|
|
230
|
+
}
|
|
231
|
+
const digest = typeof bag.digest === "string" ? bag.digest.trim() : "";
|
|
232
|
+
if (!RESULT_DIGEST_PATTERN.test(digest)) {
|
|
233
|
+
return { ok: false, refusal: "badDigest" };
|
|
234
|
+
}
|
|
235
|
+
const result = { digest };
|
|
236
|
+
if (typeof bag.imageRepository === "string" && bag.imageRepository.trim().length > 0) {
|
|
237
|
+
result.imageRepository = bag.imageRepository.trim();
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(bag.tags)) {
|
|
240
|
+
result.tags = bag.tags.filter((tag) => typeof tag === "string");
|
|
241
|
+
}
|
|
242
|
+
if (bag.secretCheck === "passed" || bag.secretCheck === "failed" || bag.secretCheck === "not_needed") {
|
|
243
|
+
result.secretCheck = bag.secretCheck;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(bag.smoke)) {
|
|
246
|
+
result.smoke = bag.smoke.filter((row) => typeof row === "object" && row !== null).map((row) => ({ name: String(row.name ?? ""), passed: row.passed === true }));
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, result };
|
|
249
|
+
}
|
|
250
|
+
async function readResultArtifact(port, input) {
|
|
251
|
+
const artifacts = await port.listRunArtifacts(input);
|
|
252
|
+
const artifact = artifacts.find((candidate) => candidate.name === contracts.APP_BUILD_RESULT_ARTIFACT_NAME);
|
|
253
|
+
if (!artifact) {
|
|
254
|
+
return { ok: false, refusal: "absent" };
|
|
255
|
+
}
|
|
256
|
+
if (artifact.expired === true) {
|
|
257
|
+
return { ok: false, refusal: "expired" };
|
|
258
|
+
}
|
|
259
|
+
if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > RESULT_ARTIFACT_MAX_ZIP_BYTES) {
|
|
260
|
+
return { ok: false, refusal: "zipTooLarge" };
|
|
261
|
+
}
|
|
262
|
+
const zip = await port.downloadArtifactZip({
|
|
263
|
+
repository: input.repository,
|
|
264
|
+
artifactId: artifact.id
|
|
265
|
+
});
|
|
266
|
+
if (zip.byteLength > RESULT_ARTIFACT_MAX_ZIP_BYTES) {
|
|
267
|
+
return { ok: false, refusal: "zipTooLarge" };
|
|
268
|
+
}
|
|
269
|
+
let entries;
|
|
270
|
+
try {
|
|
271
|
+
entries = fflate.unzipSync(zip);
|
|
272
|
+
} catch {
|
|
273
|
+
return { ok: false, refusal: "malformedJson" };
|
|
274
|
+
}
|
|
275
|
+
const entry = entries[contracts.APP_BUILD_RESULT_ARTIFACT_FILE];
|
|
276
|
+
if (!entry) {
|
|
277
|
+
return { ok: false, refusal: "entryMissing" };
|
|
278
|
+
}
|
|
279
|
+
if (entry.byteLength > contracts.APP_BUILD_RESULT_MAX_BYTES) {
|
|
280
|
+
return { ok: false, refusal: "entryTooLarge" };
|
|
281
|
+
}
|
|
282
|
+
let decoded;
|
|
283
|
+
try {
|
|
284
|
+
decoded = JSON.parse(new TextDecoder().decode(entry));
|
|
285
|
+
} catch {
|
|
286
|
+
return { ok: false, refusal: "malformedJson" };
|
|
287
|
+
}
|
|
288
|
+
return parseResultArtifact(decoded);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/runs/failure-classifier.ts
|
|
292
|
+
var EXCERPT_MAX_LINES = 20;
|
|
293
|
+
var EXCERPT_MAX_LINE_CHARS = 300;
|
|
294
|
+
var MISSING_VALUE = /EW_MISSING:([A-Za-z0-9_]+)/g;
|
|
295
|
+
var SECRET_IN_IMAGE = /EW_SECRET_IN_IMAGE:([A-Za-z0-9_]+)/g;
|
|
296
|
+
var GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
297
|
+
var BUILDKIT_STEP = /^#\d+\s+\[([^\]]*?)\s+(\d+)\/(\d+)\]\s+(.*)$/;
|
|
298
|
+
var SIGNALS = [
|
|
299
|
+
{ cls: "timeout", patterns: [/exceeded the maximum execution time/i] },
|
|
300
|
+
{
|
|
301
|
+
cls: "outOfMemory",
|
|
302
|
+
patterns: [
|
|
303
|
+
/exit code:\s*137/i,
|
|
304
|
+
/^\s*Killed\s*$/im,
|
|
305
|
+
/JavaScript heap out of memory/i,
|
|
306
|
+
/Reached heap limit/i,
|
|
307
|
+
/\bENOMEM\b/i
|
|
308
|
+
]
|
|
309
|
+
},
|
|
310
|
+
{ cls: "diskFull", patterns: [/No space left on device/i, /\bENOSPC\b/i] },
|
|
311
|
+
{
|
|
312
|
+
cls: "dependencyDownloadFailed",
|
|
313
|
+
patterns: [
|
|
314
|
+
/\bETIMEDOUT\b/i,
|
|
315
|
+
/\bECONNRESET\b/i,
|
|
316
|
+
/\bEAI_AGAIN\b/i,
|
|
317
|
+
/TLS handshake timeout/i,
|
|
318
|
+
/429 Too Many Requests/i,
|
|
319
|
+
/\btoomanyrequests\b/i
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{ cls: "dockerfileError", patterns: [/ERROR: failed to solve/i, /dockerfile parse error/i] }
|
|
323
|
+
];
|
|
324
|
+
function namesIn(log, pattern) {
|
|
325
|
+
const names = /* @__PURE__ */ new Set();
|
|
326
|
+
for (const match of log.matchAll(pattern)) {
|
|
327
|
+
if (match[1]) names.add(match[1]);
|
|
328
|
+
}
|
|
329
|
+
return [...names];
|
|
330
|
+
}
|
|
331
|
+
function lineIndexOf(lines, patterns) {
|
|
332
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
333
|
+
if (patterns.some((pattern) => pattern.test(lines[index]))) return index;
|
|
334
|
+
}
|
|
335
|
+
return -1;
|
|
336
|
+
}
|
|
337
|
+
function buildExcerpt(lines, matchedIndex, redact) {
|
|
338
|
+
const end = matchedIndex >= 0 ? matchedIndex + 1 : lines.length;
|
|
339
|
+
const start = Math.max(0, end - EXCERPT_MAX_LINES);
|
|
340
|
+
return lines.slice(start, end).map((line) => line.slice(0, EXCERPT_MAX_LINE_CHARS)).map((line) => maskSecretShapes(redact(line)));
|
|
341
|
+
}
|
|
342
|
+
function maskSecretShapes(line) {
|
|
343
|
+
return line.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "***").replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "***").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "$1 ***").replace(/\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|APIKEY|API_KEY)[A-Z0-9_]*)\s*[=:]\s*\S+/gi, "$1=***").replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:/@]+:[^\s:/@]+@/gi, "$1***:***@").replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, (run) => GIT_OBJECT_ID.test(run) ? run : "***");
|
|
344
|
+
}
|
|
345
|
+
function classifyFailure(input, redact = (text) => text) {
|
|
346
|
+
const log = input.log ?? "";
|
|
347
|
+
const lines = log.split(/\r?\n/);
|
|
348
|
+
const missing = namesIn(log, MISSING_VALUE);
|
|
349
|
+
if (missing.length > 0) {
|
|
350
|
+
return {
|
|
351
|
+
class: "missingBuildValue",
|
|
352
|
+
detail: { names: missing },
|
|
353
|
+
excerpt: buildExcerpt(lines, lineIndexOf(lines, [/EW_MISSING:/]), redact)
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const leaked = namesIn(log, SECRET_IN_IMAGE);
|
|
357
|
+
if (leaked.length > 0) {
|
|
358
|
+
return {
|
|
359
|
+
class: "secretInImage",
|
|
360
|
+
detail: { names: leaked },
|
|
361
|
+
excerpt: buildExcerpt(lines, lineIndexOf(lines, [/EW_SECRET_IN_IMAGE:/]), redact)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const timedOutByConclusion = input.jobConclusion === "timed_out";
|
|
365
|
+
const timedOutByMessage = lineIndexOf(lines, [/exceeded the maximum execution time/i]);
|
|
366
|
+
if (timedOutByConclusion || timedOutByMessage >= 0) {
|
|
367
|
+
return {
|
|
368
|
+
class: "timeout",
|
|
369
|
+
...typeof input.minutes === "number" ? { detail: { minutes: input.minutes } } : {},
|
|
370
|
+
excerpt: buildExcerpt(lines, timedOutByMessage, redact)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (input.runConclusion === "startup_failure" || input.jobCount === 0) {
|
|
374
|
+
return { class: "workflowInvalid", excerpt: buildExcerpt(lines, -1, redact) };
|
|
375
|
+
}
|
|
376
|
+
for (const { cls, patterns } of SIGNALS) {
|
|
377
|
+
if (cls === "timeout") continue;
|
|
378
|
+
const index = lineIndexOf(lines, patterns);
|
|
379
|
+
if (index < 0) continue;
|
|
380
|
+
if (cls === "dockerfileError") {
|
|
381
|
+
return {
|
|
382
|
+
class: "dockerfileError",
|
|
383
|
+
detail: dockerfileDetail(lines),
|
|
384
|
+
excerpt: buildExcerpt(lines, index, redact)
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { class: cls, excerpt: buildExcerpt(lines, index, redact) };
|
|
388
|
+
}
|
|
389
|
+
if ((input.failingStepName ?? "").toLowerCase().includes("verify in the runner") && input.verification && input.verification.failed > 0) {
|
|
390
|
+
return {
|
|
391
|
+
class: "verificationFailed",
|
|
392
|
+
detail: { failed: input.verification.failed, total: input.verification.total },
|
|
393
|
+
excerpt: buildExcerpt(lines, -1, redact)
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
return { class: "unknown", excerpt: buildExcerpt(lines, -1, redact) };
|
|
397
|
+
}
|
|
398
|
+
function dockerfileDetail(lines) {
|
|
399
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
400
|
+
const match = BUILDKIT_STEP.exec(lines[index].trim());
|
|
401
|
+
if (!match) continue;
|
|
402
|
+
return {
|
|
403
|
+
dockerfile: match[1],
|
|
404
|
+
step: Number(match[2]),
|
|
405
|
+
total: Number(match[3]),
|
|
406
|
+
command: match[4].slice(0, 120)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/runs/log-tail.ts
|
|
413
|
+
var LOG_TAIL_MAX_BYTES = 2 * 1024 * 1024;
|
|
414
|
+
async function readJobLogTail(port, input) {
|
|
415
|
+
if (typeof port.downloadJobLogTail !== "function") {
|
|
416
|
+
return "";
|
|
417
|
+
}
|
|
418
|
+
let tail;
|
|
419
|
+
try {
|
|
420
|
+
tail = await port.downloadJobLogTail({ ...input, maxBytes: LOG_TAIL_MAX_BYTES });
|
|
421
|
+
} catch {
|
|
422
|
+
return "";
|
|
423
|
+
}
|
|
424
|
+
if (!tail || tail.bytes.byteLength === 0) return "";
|
|
425
|
+
const bounded = tail.bytes.byteLength > LOG_TAIL_MAX_BYTES ? tail.bytes.slice(tail.bytes.byteLength - LOG_TAIL_MAX_BYTES) : tail.bytes;
|
|
426
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bounded);
|
|
427
|
+
if (!tail.partial) return text;
|
|
428
|
+
const firstBreak = text.indexOf("\n");
|
|
429
|
+
return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/runs/push-digest.ts
|
|
433
|
+
var PUSH_STEP_SCRIPT_FIRST_LINE = 'docker push --all-tags "$EW_IMAGE"';
|
|
434
|
+
var GROUP_PREFIX = "##[group]";
|
|
435
|
+
var PUSH_STEP_HEADER = `${GROUP_PREFIX}Run ${PUSH_STEP_SCRIPT_FIRST_LINE}`;
|
|
436
|
+
var RUNNER_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z /;
|
|
437
|
+
var PUSH_DIGEST_LINE = /^(\S+): digest: (sha256:[a-f0-9]{64}) size: \d+$/;
|
|
438
|
+
var FULL_SHA = /^[a-f0-9]{40}$/;
|
|
439
|
+
function extractPushLogDigest(log, sha) {
|
|
440
|
+
if (!FULL_SHA.test(sha) || log.length === 0) return void 0;
|
|
441
|
+
const tag = `sha-${sha}`;
|
|
442
|
+
const digests = /* @__PURE__ */ new Set();
|
|
443
|
+
let inPushSection = false;
|
|
444
|
+
for (const line of log.split(/\r?\n/).map(contentOf)) {
|
|
445
|
+
if (line.startsWith(GROUP_PREFIX)) {
|
|
446
|
+
inPushSection = line === PUSH_STEP_HEADER;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (!inPushSection) continue;
|
|
450
|
+
const match = PUSH_DIGEST_LINE.exec(line);
|
|
451
|
+
if (match && match[1] === tag) digests.add(match[2]);
|
|
452
|
+
}
|
|
453
|
+
return digests.size === 1 ? [...digests][0] : void 0;
|
|
454
|
+
}
|
|
455
|
+
function contentOf(raw) {
|
|
456
|
+
return raw.replace(/^\uFEFF/, "").replace(RUNNER_TIMESTAMP, "").trimEnd();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/registry/ghcr-access.ts
|
|
460
|
+
var GITHUB_API_HOST = "https://api.github.com";
|
|
461
|
+
var GHCR_HOST = "https://ghcr.io";
|
|
462
|
+
var REQUIRED_PULL_TOKEN_SCOPE = "read:packages";
|
|
463
|
+
var GHCR_TOKEN_USERNAME = "x-access-token";
|
|
464
|
+
var MANIFEST_ACCEPT = [
|
|
465
|
+
"application/vnd.oci.image.index.v1+json",
|
|
466
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
467
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
468
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
469
|
+
].join(", ");
|
|
470
|
+
async function checkPullToken(token, fetchImpl) {
|
|
471
|
+
const response = await fetchImpl(`${GITHUB_API_HOST}/user`, {
|
|
472
|
+
method: "GET",
|
|
473
|
+
headers: {
|
|
474
|
+
authorization: `Bearer ${token}`,
|
|
475
|
+
accept: "application/vnd.github+json",
|
|
476
|
+
"x-github-api-version": "2022-11-28"
|
|
477
|
+
},
|
|
478
|
+
redirect: "manual"
|
|
479
|
+
});
|
|
480
|
+
if (response.status === 401 || response.status === 403) {
|
|
481
|
+
return { ok: false, refusal: "pullTokenCannotRead" };
|
|
482
|
+
}
|
|
483
|
+
const raw = response.headers.get("x-oauth-scopes");
|
|
484
|
+
if (raw === null) {
|
|
485
|
+
return { ok: false, refusal: "pullTokenFineGrained" };
|
|
486
|
+
}
|
|
487
|
+
const scopes = raw.split(",").map((scope) => scope.trim()).filter((scope) => scope.length > 0);
|
|
488
|
+
if (scopes.length !== 1 || scopes[0] !== REQUIRED_PULL_TOKEN_SCOPE) {
|
|
489
|
+
return { ok: false, refusal: "pullTokenTooBroad" };
|
|
490
|
+
}
|
|
491
|
+
return { ok: true, expiresAt: response.headers.get("github-authentication-token-expiration") };
|
|
492
|
+
}
|
|
493
|
+
async function requestRegistryToken(name, fetchImpl, pullToken) {
|
|
494
|
+
const query = new URLSearchParams({ service: "ghcr.io", scope: `repository:${name}:pull` });
|
|
495
|
+
const headers = { accept: "application/json" };
|
|
496
|
+
if (pullToken) {
|
|
497
|
+
headers.authorization = `Basic ${Buffer.from(`${GHCR_TOKEN_USERNAME}:${pullToken}`, "utf-8").toString("base64")}`;
|
|
498
|
+
}
|
|
499
|
+
const response = await fetchImpl(`${GHCR_HOST}/token?${query.toString()}`, {
|
|
500
|
+
method: "GET",
|
|
501
|
+
headers,
|
|
502
|
+
redirect: "manual"
|
|
503
|
+
});
|
|
504
|
+
if (response.status !== 200) return null;
|
|
505
|
+
let body;
|
|
506
|
+
try {
|
|
507
|
+
body = await response.json();
|
|
508
|
+
} catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const candidate = body;
|
|
512
|
+
const token = candidate?.token ?? candidate?.access_token;
|
|
513
|
+
return typeof token === "string" && token.length > 0 ? token : null;
|
|
514
|
+
}
|
|
515
|
+
function headManifest(path, registryToken, fetchImpl) {
|
|
516
|
+
return fetchImpl(`${GHCR_HOST}${path}`, {
|
|
517
|
+
method: "HEAD",
|
|
518
|
+
headers: { accept: MANIFEST_ACCEPT, authorization: `Bearer ${registryToken}` },
|
|
519
|
+
redirect: "manual"
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async function checkImageAccess(input, fetchImpl) {
|
|
523
|
+
const reference = parseGhcrReference(input.imageRepository, input.tag);
|
|
524
|
+
if (!reference) {
|
|
525
|
+
return { visibility: "unknown", readable: false };
|
|
526
|
+
}
|
|
527
|
+
const path = registryManifestPath(reference);
|
|
528
|
+
const anonymousToken = await requestRegistryToken(reference.name, fetchImpl);
|
|
529
|
+
const anonymous = anonymousToken ? await headManifest(path, anonymousToken, fetchImpl) : null;
|
|
530
|
+
if (anonymous?.status === 200) {
|
|
531
|
+
const digest2 = anonymous.headers.get("docker-content-digest");
|
|
532
|
+
return {
|
|
533
|
+
visibility: "public",
|
|
534
|
+
readable: true,
|
|
535
|
+
...digest2 ? { digest: digest2 } : {}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (!input.pullToken) {
|
|
539
|
+
return { visibility: "private", readable: false };
|
|
540
|
+
}
|
|
541
|
+
const tokenCheck = await checkPullToken(input.pullToken, fetchImpl);
|
|
542
|
+
const registryToken = await requestRegistryToken(reference.name, fetchImpl, input.pullToken);
|
|
543
|
+
const authenticated = registryToken ? await headManifest(path, registryToken, fetchImpl) : null;
|
|
544
|
+
const readable = authenticated?.status === 200;
|
|
545
|
+
const digest = readable ? authenticated.headers.get("docker-content-digest") : null;
|
|
546
|
+
return {
|
|
547
|
+
visibility: "private",
|
|
548
|
+
readable,
|
|
549
|
+
tokenScopesOk: tokenCheck.ok,
|
|
550
|
+
tokenExpiresAt: tokenCheck.ok ? tokenCheck.expiresAt : null,
|
|
551
|
+
...digest ? { digest } : {}
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function parseGhcrReference(imageRepository, tag) {
|
|
555
|
+
const repository = (imageRepository ?? "").trim().toLowerCase();
|
|
556
|
+
const reference = (tag ?? "").trim();
|
|
557
|
+
if (repository.length === 0 || reference.length === 0) return null;
|
|
558
|
+
const prefix = "ghcr.io/";
|
|
559
|
+
if (!repository.startsWith(prefix)) return null;
|
|
560
|
+
const name = repository.slice(prefix.length);
|
|
561
|
+
if (!/^[a-z0-9._-]+(\/[a-z0-9._-]+)+$/.test(name)) return null;
|
|
562
|
+
if (!/^[A-Za-z0-9._-]+$/.test(reference)) return null;
|
|
563
|
+
return { name, tag: reference };
|
|
564
|
+
}
|
|
565
|
+
function registryManifestPath(reference) {
|
|
566
|
+
return `/v2/${reference.name}/manifests/${reference.tag}`;
|
|
567
|
+
}
|
|
568
|
+
var RUNNER_TOO_SMALL = "runnerTooSmall";
|
|
569
|
+
function largerRunnerOf(input) {
|
|
570
|
+
const label = (input.settings?.largerRunnerLabel ?? "").trim();
|
|
571
|
+
const memoryGiB = Number(input.settings?.largerRunnerMemoryGiB ?? 0);
|
|
572
|
+
if (label.length === 0 || !Number.isFinite(memoryGiB) || memoryGiB <= 0) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
const vcpu = Number(input.settings?.largerRunnerVcpu ?? 0);
|
|
576
|
+
return {
|
|
577
|
+
label,
|
|
578
|
+
runnerClass: "github-larger",
|
|
579
|
+
vcpu: Number.isFinite(vcpu) && vcpu > 0 ? vcpu : 0,
|
|
580
|
+
memoryGiB,
|
|
581
|
+
usableMemoryGiB: memoryGiB - contracts.APP_BUILD_RUNNER_HEADROOM_GIB
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function hostedRunner(key) {
|
|
585
|
+
const runner = contracts.APP_BUILD_RUNNERS[key];
|
|
586
|
+
return {
|
|
587
|
+
label: runner.label,
|
|
588
|
+
runnerClass: runner.runnerClass,
|
|
589
|
+
vcpu: runner.vcpu,
|
|
590
|
+
memoryGiB: runner.memoryGiB,
|
|
591
|
+
usableMemoryGiB: contracts.appBuildRunnerCapacity(runner).memoryGiB
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function selectRunner(input) {
|
|
595
|
+
const runner = input.visibility === "public" ? hostedRunner("githubPublic") : largerRunnerOf(input) ?? hostedRunner("githubPrivate");
|
|
596
|
+
const declaredMemory = input.resources?.memoryGiB;
|
|
597
|
+
const needsMemory = typeof declaredMemory === "number" && Number.isFinite(declaredMemory) && declaredMemory > 0;
|
|
598
|
+
if (needsMemory && declaredMemory > runner.usableMemoryGiB) {
|
|
599
|
+
return {
|
|
600
|
+
ok: false,
|
|
601
|
+
blocked: { reason: RUNNER_TOO_SMALL, needed: declaredMemory, max: runner.usableMemoryGiB }
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const declaredVcpu = input.resources?.vcpu;
|
|
605
|
+
const overDeclaresCpu = typeof declaredVcpu === "number" && Number.isFinite(declaredVcpu) && runner.vcpu > 0 && declaredVcpu > runner.vcpu;
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
runner: overDeclaresCpu ? { ...runner, cpuOverDeclared: { needed: declaredVcpu, max: runner.vcpu } } : runner
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
var RESOLVED_ON = "2026-09-18";
|
|
612
|
+
var ACTION_PINS = {
|
|
613
|
+
/** `actions/checkout@v7.0.1`. */
|
|
614
|
+
checkout: {
|
|
615
|
+
action: "actions/checkout",
|
|
616
|
+
tag: "v7.0.1",
|
|
617
|
+
sha: "3d3c42e5aac5ba805825da76410c181273ba90b1",
|
|
618
|
+
resolvedOn: RESOLVED_ON
|
|
619
|
+
},
|
|
620
|
+
/** `docker/setup-buildx-action@v4.4.1`. */
|
|
621
|
+
setupBuildx: {
|
|
622
|
+
action: "docker/setup-buildx-action",
|
|
623
|
+
tag: "v4.4.1",
|
|
624
|
+
sha: "f87e5991a6d7451dcb8d9637bfbc97413f497069",
|
|
625
|
+
resolvedOn: RESOLVED_ON
|
|
626
|
+
},
|
|
627
|
+
/** `docker/login-action@v4.6.0`. */
|
|
628
|
+
login: {
|
|
629
|
+
action: "docker/login-action",
|
|
630
|
+
tag: "v4.6.0",
|
|
631
|
+
sha: "dbcb813823bdd20940b903addbd779551569679f",
|
|
632
|
+
resolvedOn: RESOLVED_ON
|
|
633
|
+
},
|
|
634
|
+
/** `docker/build-push-action@v7.4.0`. */
|
|
635
|
+
buildPush: {
|
|
636
|
+
action: "docker/build-push-action",
|
|
637
|
+
tag: "v7.4.0",
|
|
638
|
+
sha: "c3c9e263c25d99ce0380d002d59b67737d91b0dc",
|
|
639
|
+
resolvedOn: RESOLVED_ON
|
|
640
|
+
},
|
|
641
|
+
/** `actions/upload-artifact@v7.0.1`. */
|
|
642
|
+
uploadArtifact: {
|
|
643
|
+
action: "actions/upload-artifact",
|
|
644
|
+
tag: "v7.0.1",
|
|
645
|
+
sha: "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a",
|
|
646
|
+
resolvedOn: RESOLVED_ON
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
var ACTION_PIN_SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
650
|
+
function actionPin(pin) {
|
|
651
|
+
return `${pin.action}@${pin.sha} # ${pin.tag}`;
|
|
652
|
+
}
|
|
653
|
+
function actionPinReference(pin) {
|
|
654
|
+
return `${pin.action}@${pin.sha}`;
|
|
655
|
+
}
|
|
656
|
+
function canonicalActionPins() {
|
|
657
|
+
const keys = Object.keys(ACTION_PINS).sort();
|
|
658
|
+
const canonical = {};
|
|
659
|
+
for (const key of keys) canonical[key] = actionPinReference(ACTION_PINS[key]);
|
|
660
|
+
return canonical;
|
|
661
|
+
}
|
|
662
|
+
var GENERATOR_RESERVED_LITERALS = [contracts.APP_BUILD_RESTRICTED_VALUE_LITERAL, contracts.APP_BUILD_CHECK_NAME_PREFIX];
|
|
663
|
+
var CANONICAL_INPUT_FIELDS = [
|
|
664
|
+
"generator",
|
|
665
|
+
"bootstrap",
|
|
666
|
+
"trackedBranch",
|
|
667
|
+
"repository",
|
|
668
|
+
"appSpecHash",
|
|
669
|
+
"build",
|
|
670
|
+
"values",
|
|
671
|
+
"runner",
|
|
672
|
+
"settings",
|
|
673
|
+
"pins",
|
|
674
|
+
"verifyEnabled",
|
|
675
|
+
"checks",
|
|
676
|
+
"verifyRunnerScriptSha256"
|
|
677
|
+
];
|
|
678
|
+
function checkTimeoutMinutes(timeoutSeconds) {
|
|
679
|
+
return Math.max(1, Math.ceil(timeoutSeconds / 60));
|
|
680
|
+
}
|
|
681
|
+
function canonicalCheck(check) {
|
|
682
|
+
return {
|
|
683
|
+
name: check.name,
|
|
684
|
+
required: check.required,
|
|
685
|
+
timeoutMinutes: checkTimeoutMinutes(check.timeoutSeconds),
|
|
686
|
+
commandSha256: contracts.sha256Hex(check.command)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function canonicaliseWorkflowInputs(inputs) {
|
|
690
|
+
const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
691
|
+
return {
|
|
692
|
+
...inputs,
|
|
693
|
+
build: inputs.build === null ? null : {
|
|
694
|
+
...inputs.build,
|
|
695
|
+
args: [...inputs.build.args].sort(
|
|
696
|
+
(left, right) => compare(left.name, right.name) || compare(left.value ?? "", right.value ?? "") || compare(left.fromEnv ?? "", right.fromEnv ?? "")
|
|
697
|
+
),
|
|
698
|
+
services: [...inputs.build.services].map((service) => ({
|
|
699
|
+
...service,
|
|
700
|
+
env: [...service.env].sort((left, right) => compare(left.name, right.name))
|
|
701
|
+
})).sort((left, right) => compare(left.name, right.name))
|
|
702
|
+
},
|
|
703
|
+
values: [...inputs.values].sort(
|
|
704
|
+
(left, right) => compare(left.name, right.name) || Number(left.secret) - Number(right.secret) || Number(left.fromBuildService) - Number(right.fromBuildService)
|
|
705
|
+
),
|
|
706
|
+
checks: [...inputs.checks].sort((left, right) => compare(left.name, right.name)),
|
|
707
|
+
pins: canonicalActionPins()
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
function canonicalWorkflowJson(inputs) {
|
|
711
|
+
const canonical = canonicaliseWorkflowInputs(inputs);
|
|
712
|
+
const ordered = {};
|
|
713
|
+
for (const field of CANONICAL_INPUT_FIELDS) ordered[field] = canonical[field];
|
|
714
|
+
return JSON.stringify(ordered);
|
|
715
|
+
}
|
|
716
|
+
function computeWorkflowInputsHash(inputs) {
|
|
717
|
+
return `sha256:${contracts.sha256Hex(canonicalWorkflowJson(inputs))}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/workflow/checks-job.ts
|
|
721
|
+
var CHECK_COMMAND_B64_ENV = "EW_CHECK_COMMAND_B64";
|
|
722
|
+
function checkCommandB64(command) {
|
|
723
|
+
return Buffer.from(command, "utf8").toString("base64");
|
|
724
|
+
}
|
|
725
|
+
function checksJobName(name) {
|
|
726
|
+
return `${contracts.APP_BUILD_CHECK_NAME_PREFIX}${name}`;
|
|
727
|
+
}
|
|
728
|
+
function expressionStringLiteral(value) {
|
|
729
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
730
|
+
}
|
|
731
|
+
function checksJobCondition(trackedBranch) {
|
|
732
|
+
return [
|
|
733
|
+
`github.event_name == 'pull_request'`,
|
|
734
|
+
`github.event.pull_request.head.repo.full_name == github.repository`,
|
|
735
|
+
`github.event.pull_request.base.ref == ${expressionStringLiteral(trackedBranch)}`
|
|
736
|
+
].join(" && ");
|
|
737
|
+
}
|
|
738
|
+
function checkMatrixRow(check) {
|
|
739
|
+
return {
|
|
740
|
+
name: check.name,
|
|
741
|
+
required: check.required,
|
|
742
|
+
timeoutMinutes: checkTimeoutMinutes(check.timeoutSeconds),
|
|
743
|
+
commandB64: checkCommandB64(check.command)
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function checksMatrix(checks) {
|
|
747
|
+
return checks.slice(0, contracts.APP_BUILD_CHECKS_MAX).map(checkMatrixRow);
|
|
748
|
+
}
|
|
749
|
+
function checksJob(input) {
|
|
750
|
+
const rows = checksMatrix(input.checks);
|
|
751
|
+
const lines = [];
|
|
752
|
+
lines.push(` ${contracts.APP_BUILD_CHECKS_JOB_ID}:`);
|
|
753
|
+
lines.push(` if: ${checksJobCondition(input.trackedBranch)}`);
|
|
754
|
+
lines.push(` name: ${yamlString(checksJobName("${{ matrix.check.name }}"))}`);
|
|
755
|
+
lines.push(` runs-on: ${yamlString(input.runnerLabel)}`);
|
|
756
|
+
lines.push(` timeout-minutes: \${{ matrix.check.timeoutMinutes }}`);
|
|
757
|
+
lines.push(` continue-on-error: \${{ !matrix.check.required }}`);
|
|
758
|
+
lines.push(" permissions: { contents: read }");
|
|
759
|
+
lines.push(" strategy:");
|
|
760
|
+
lines.push(" fail-fast: false");
|
|
761
|
+
lines.push(` max-parallel: ${contracts.APP_BUILD_CHECKS_MAX_PARALLEL}`);
|
|
762
|
+
lines.push(" matrix:");
|
|
763
|
+
lines.push(" check:");
|
|
764
|
+
for (const row of rows) {
|
|
765
|
+
lines.push(
|
|
766
|
+
` - { name: ${yamlString(row.name)}, required: ${row.required}, timeoutMinutes: ${row.timeoutMinutes}, commandB64: ${yamlString(row.commandB64)} }`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
lines.push(" steps:");
|
|
770
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.checkout)}`);
|
|
771
|
+
lines.push(" with:");
|
|
772
|
+
lines.push(
|
|
773
|
+
` ref: ${yamlString("${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}")}`
|
|
774
|
+
);
|
|
775
|
+
lines.push(" fetch-depth: 1");
|
|
776
|
+
lines.push(" persist-credentials: false");
|
|
777
|
+
lines.push(" lfs: false");
|
|
778
|
+
lines.push(" - name: Run check");
|
|
779
|
+
lines.push(" env:");
|
|
780
|
+
lines.push(` ${CHECK_COMMAND_B64_ENV}: ${yamlString("${{ matrix.check.commandB64 }}")}`);
|
|
781
|
+
lines.push(
|
|
782
|
+
` run: printf '%s' "$${CHECK_COMMAND_B64_ENV}" | base64 -d > "$RUNNER_TEMP/ew-check.sh" && bash -e "$RUNNER_TEMP/ew-check.sh"`
|
|
783
|
+
);
|
|
784
|
+
return lines;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/workflow/verify-runner.sh.ts
|
|
788
|
+
var EMBEDDED_VERIFY_RUNNER_SCRIPT = [
|
|
789
|
+
"set -euo pipefail",
|
|
790
|
+
'echo "EW_VERIFY_NOT_IMPLEMENTED: the runner verification script is not embedded yet (APW-05 T15, plan \xA74.10)." >&2',
|
|
791
|
+
"exit 70"
|
|
792
|
+
].join("\n");
|
|
793
|
+
|
|
794
|
+
// src/workflow/generator.ts
|
|
795
|
+
function yamlString(value) {
|
|
796
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(
|
|
797
|
+
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g,
|
|
798
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
799
|
+
);
|
|
800
|
+
return `"${escaped}"`;
|
|
801
|
+
}
|
|
802
|
+
function scalarBlock(lines, indent) {
|
|
803
|
+
const pad = " ".repeat(indent);
|
|
804
|
+
return lines.map((line) => line.length === 0 ? "" : `${pad}${line}`);
|
|
805
|
+
}
|
|
806
|
+
function branchSlug(branch) {
|
|
807
|
+
return branch.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-{2,}/g, "-").slice(0, contracts.APP_BUILD_BRANCH_SLUG_MAX_CHARS);
|
|
808
|
+
}
|
|
809
|
+
function buildImageRepository(repository) {
|
|
810
|
+
return `ghcr.io/${repository.owner.toLowerCase()}/${repository.repo.toLowerCase()}/${contracts.APP_BUILD_IMAGE_NAME}`;
|
|
811
|
+
}
|
|
812
|
+
function secretNamesFor(values) {
|
|
813
|
+
return values.filter((value) => value.secret && !value.fromBuildService).map((value) => `${contracts.APP_BUILD_SECRET_PREFIX}${value.name}`).sort();
|
|
814
|
+
}
|
|
815
|
+
function fromEnvNames(build) {
|
|
816
|
+
return build.args.map((arg) => arg.fromEnv).filter((name) => typeof name === "string");
|
|
817
|
+
}
|
|
818
|
+
function normaliseBuildBlock(build) {
|
|
819
|
+
return {
|
|
820
|
+
strategy: build.strategy,
|
|
821
|
+
context: build.context?.trim() || ".",
|
|
822
|
+
dockerfile: build.dockerfile?.trim() || "Dockerfile",
|
|
823
|
+
...build.target?.trim() ? { target: build.target.trim() } : {},
|
|
824
|
+
args: build.args.map((arg) => ({
|
|
825
|
+
name: arg.name,
|
|
826
|
+
...arg.value === void 0 ? {} : { value: arg.value },
|
|
827
|
+
...arg.fromEnv === void 0 ? {} : { fromEnv: arg.fromEnv }
|
|
828
|
+
})),
|
|
829
|
+
services: build.services.map((service) => ({
|
|
830
|
+
name: service.name,
|
|
831
|
+
image: service.image,
|
|
832
|
+
...service.port === void 0 ? {} : { port: service.port },
|
|
833
|
+
env: (service.env ?? []).map((entry) => ({ name: entry.name, value: entry.value }))
|
|
834
|
+
})),
|
|
835
|
+
resources: {
|
|
836
|
+
cpu: build.resources.cpu,
|
|
837
|
+
...build.resources.memoryGiB === void 0 ? {} : { memoryGiB: build.resources.memoryGiB },
|
|
838
|
+
timeoutMinutes: build.resources.timeoutMinutes
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function canonicalInputsFor(input, verifyRunnerScript) {
|
|
843
|
+
const bootstrap = input.bootstrap === true;
|
|
844
|
+
const build = input.build ? normaliseBuildBlock(input.build) : null;
|
|
845
|
+
return {
|
|
846
|
+
generator: 1,
|
|
847
|
+
bootstrap,
|
|
848
|
+
trackedBranch: input.trackedBranch,
|
|
849
|
+
repository: input.repository,
|
|
850
|
+
appSpecHash: input.appSpecHash,
|
|
851
|
+
build: bootstrap ? null : build,
|
|
852
|
+
values: input.values.map((value) => ({
|
|
853
|
+
name: value.name,
|
|
854
|
+
secret: value.secret,
|
|
855
|
+
fromBuildService: value.fromBuildService
|
|
856
|
+
})),
|
|
857
|
+
runner: input.runner,
|
|
858
|
+
settings: {
|
|
859
|
+
reclaimDisk: input.settings?.reclaimDisk !== false,
|
|
860
|
+
// Plan §4.5/§4.4: attestations is **effective** — a private repository
|
|
861
|
+
// never gets the permissions, whatever the setting says (`APW05-G07`).
|
|
862
|
+
attestations: input.settings?.attestations === true && input.repository.visibility === "public",
|
|
863
|
+
allowBuildValuesOnPullRequests: input.settings?.allowBuildValuesOnPullRequests === true,
|
|
864
|
+
verificationPromptedValuesRequireApproval: input.settings?.verificationPromptedValuesRequireApproval !== false
|
|
865
|
+
},
|
|
866
|
+
pins: {},
|
|
867
|
+
verifyEnabled: input.verifyEnabled,
|
|
868
|
+
checks: (input.checks ?? []).slice(0, contracts.APP_BUILD_CHECKS_MAX).map(canonicalCheck),
|
|
869
|
+
verifyRunnerScriptSha256: contracts.sha256Hex(verifyRunnerScript)
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
function workflowHeader(inputsHash) {
|
|
873
|
+
return [
|
|
874
|
+
"# Generated by Ever Works from .works/works.yml. Do not edit: hand edits are never overwritten;",
|
|
875
|
+
"# Ever Works proposes changes to this file as pull requests.",
|
|
876
|
+
`# ever-works-build generator=1 inputs=${inputsHash}`
|
|
877
|
+
];
|
|
878
|
+
}
|
|
879
|
+
function checksOnlyFile(input) {
|
|
880
|
+
if (input.bootstrap === true) return false;
|
|
881
|
+
const strategy = input.build?.strategy ?? null;
|
|
882
|
+
return strategy === null || strategy === "image" || strategy === "none";
|
|
883
|
+
}
|
|
884
|
+
function generateWorkflow(input) {
|
|
885
|
+
const verifyRunnerScript = input.verifyRunnerScript ?? EMBEDDED_VERIFY_RUNNER_SCRIPT;
|
|
886
|
+
const canonical = canonicalInputsFor(input, verifyRunnerScript);
|
|
887
|
+
const inputHash = computeWorkflowInputsHash(canonical);
|
|
888
|
+
const build = canonical.build;
|
|
889
|
+
const bootstrap = canonical.bootstrap;
|
|
890
|
+
const slug = branchSlug(canonical.trackedBranch);
|
|
891
|
+
const image = buildImageRepository(canonical.repository);
|
|
892
|
+
const runnerLabel = canonical.runner.label;
|
|
893
|
+
const secretNames = secretNamesFor(input.values);
|
|
894
|
+
const checks = bootstrap ? [] : input.checks ?? [];
|
|
895
|
+
if (checksOnlyFile(input)) {
|
|
896
|
+
if (checks.length === 0) return "";
|
|
897
|
+
return checksOnlyWorkflow({ inputHash, trackedBranch: canonical.trackedBranch, checks, runnerLabel });
|
|
898
|
+
}
|
|
899
|
+
const lines = [...workflowHeader(inputHash)];
|
|
900
|
+
lines.push("name: Ever Works build");
|
|
901
|
+
lines.push(
|
|
902
|
+
`run-name: ${yamlString("Ever Works build ${{ inputs.ew_build_id || github.event.pull_request.head.sha || github.sha }}")}`
|
|
903
|
+
);
|
|
904
|
+
lines.push("on:");
|
|
905
|
+
if (!bootstrap) {
|
|
906
|
+
lines.push(" push:");
|
|
907
|
+
lines.push(` branches: [${yamlString(canonical.trackedBranch)}]`);
|
|
908
|
+
lines.push(" pull_request:");
|
|
909
|
+
lines.push(` branches: [${yamlString(canonical.trackedBranch)}]`);
|
|
910
|
+
lines.push(" types: [opened, synchronize, reopened]");
|
|
911
|
+
}
|
|
912
|
+
lines.push(" workflow_dispatch:");
|
|
913
|
+
lines.push(" inputs:");
|
|
914
|
+
lines.push(" ew_build_id:");
|
|
915
|
+
lines.push(" type: string");
|
|
916
|
+
lines.push(" required: true");
|
|
917
|
+
lines.push(" ew_sha:");
|
|
918
|
+
lines.push(" type: string");
|
|
919
|
+
lines.push(" required: true");
|
|
920
|
+
lines.push(" ew_mode:");
|
|
921
|
+
lines.push(" type: choice");
|
|
922
|
+
lines.push(" options: [build, verify]");
|
|
923
|
+
lines.push(" default: build");
|
|
924
|
+
lines.push(` ${contracts.APP_BUILD_VERIFY_PLAN_INPUT}:`);
|
|
925
|
+
lines.push(" type: string");
|
|
926
|
+
lines.push(" required: false");
|
|
927
|
+
lines.push(` default: ${yamlString("")}`);
|
|
928
|
+
lines.push(" ew_reuse_digest:");
|
|
929
|
+
lines.push(" type: string");
|
|
930
|
+
lines.push(" required: false");
|
|
931
|
+
lines.push(` default: ${yamlString("")}`);
|
|
932
|
+
lines.push("permissions: {}");
|
|
933
|
+
lines.push(...concurrencyLines("dispatch"));
|
|
934
|
+
lines.push("jobs:");
|
|
935
|
+
if (!bootstrap && build) {
|
|
936
|
+
lines.push(...buildJob({ canonical, build, image, slug, runnerLabel, secretNames, verifyRunnerScript }));
|
|
937
|
+
}
|
|
938
|
+
lines.push(...verifyJob({ build, image, runnerLabel, verifyRunnerScript }));
|
|
939
|
+
if (checks.length > 0) {
|
|
940
|
+
lines.push(...checksJob({ checks, trackedBranch: canonical.trackedBranch, runnerLabel }));
|
|
941
|
+
}
|
|
942
|
+
return `${lines.join("\n")}
|
|
943
|
+
`;
|
|
944
|
+
}
|
|
945
|
+
function concurrencyLines(shape) {
|
|
946
|
+
if (shape === "checksOnly") {
|
|
947
|
+
return [
|
|
948
|
+
"concurrency:",
|
|
949
|
+
` group: ${yamlString(
|
|
950
|
+
"ever-works-build-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}"
|
|
951
|
+
)}`,
|
|
952
|
+
// `true` as the draft has it: a checks-only file runs for pull requests
|
|
953
|
+
// only, so a newer run of the same pull request always supersedes the one
|
|
954
|
+
// in flight — which is what the full file's `github.event_name ==
|
|
955
|
+
// 'pull_request'` expression amounts to for that trigger set.
|
|
956
|
+
" cancel-in-progress: true"
|
|
957
|
+
];
|
|
958
|
+
}
|
|
959
|
+
return [
|
|
960
|
+
"concurrency:",
|
|
961
|
+
` group: ${yamlString(
|
|
962
|
+
"${{ inputs.ew_mode == 'verify' && format('verify-{0}', inputs.ew_build_id) || format('ever-works-build-{0}', github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref_name) }}"
|
|
963
|
+
)}`,
|
|
964
|
+
` cancel-in-progress: \${{ github.event_name == 'pull_request' }}`
|
|
965
|
+
];
|
|
966
|
+
}
|
|
967
|
+
function checksOnlyWorkflow(input) {
|
|
968
|
+
const lines = [...workflowHeader(input.inputHash)];
|
|
969
|
+
lines.push("name: Ever Works build");
|
|
970
|
+
lines.push("on:");
|
|
971
|
+
lines.push(" pull_request:");
|
|
972
|
+
lines.push(` branches: [${yamlString(input.trackedBranch)}]`);
|
|
973
|
+
lines.push(" types: [opened, synchronize, reopened]");
|
|
974
|
+
lines.push("permissions: {}");
|
|
975
|
+
lines.push(...concurrencyLines("checksOnly"));
|
|
976
|
+
lines.push("jobs:");
|
|
977
|
+
lines.push(
|
|
978
|
+
...checksJob({
|
|
979
|
+
checks: input.checks,
|
|
980
|
+
trackedBranch: input.trackedBranch,
|
|
981
|
+
runnerLabel: input.runnerLabel
|
|
982
|
+
})
|
|
983
|
+
);
|
|
984
|
+
return `${lines.join("\n")}
|
|
985
|
+
`;
|
|
986
|
+
}
|
|
987
|
+
function buildJob(job) {
|
|
988
|
+
const { canonical, build, image, slug, runnerLabel, secretNames } = job;
|
|
989
|
+
const lines = [];
|
|
990
|
+
const attestations = canonical.settings.attestations;
|
|
991
|
+
const fromEnv = build.args.map((arg) => arg.fromEnv).filter((name) => typeof name === "string");
|
|
992
|
+
const restricted = !canonical.settings.allowBuildValuesOnPullRequests;
|
|
993
|
+
lines.push(" build:");
|
|
994
|
+
lines.push(
|
|
995
|
+
` if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && inputs.ew_mode != 'verify'`
|
|
996
|
+
);
|
|
997
|
+
lines.push(` runs-on: ${yamlString(runnerLabel)}`);
|
|
998
|
+
lines.push(` timeout-minutes: ${build.resources.timeoutMinutes}`);
|
|
999
|
+
lines.push(
|
|
1000
|
+
` permissions: { contents: read, packages: write${attestations ? ", attestations: write, id-token: write" : ""} }`
|
|
1001
|
+
);
|
|
1002
|
+
if (build.services.length > 0) {
|
|
1003
|
+
lines.push(" services:");
|
|
1004
|
+
for (const service of build.services) lines.push(...serviceLines(service));
|
|
1005
|
+
}
|
|
1006
|
+
lines.push(" env:");
|
|
1007
|
+
lines.push(` EW_IMAGE: ${yamlString(image)}`);
|
|
1008
|
+
lines.push(
|
|
1009
|
+
` EW_SHA: ${yamlString("${{ inputs.ew_sha || github.event.pull_request.head.sha || github.sha }}")}`
|
|
1010
|
+
);
|
|
1011
|
+
lines.push(` EW_SECRET_NAMES: ${yamlString(secretNames.join(" "))}`);
|
|
1012
|
+
lines.push(" steps:");
|
|
1013
|
+
if (fromEnv.length > 0) {
|
|
1014
|
+
lines.push(" - name: Check build values");
|
|
1015
|
+
if (restricted) lines.push(` if: github.event_name != 'pull_request'`);
|
|
1016
|
+
lines.push(" env:");
|
|
1017
|
+
for (const name of fromEnv) {
|
|
1018
|
+
lines.push(
|
|
1019
|
+
` ${contracts.APP_BUILD_SECRET_PREFIX}${name}: ${yamlString(`\${{ secrets.${contracts.APP_BUILD_SECRET_PREFIX}${name} }}`)}`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
lines.push(" run: |");
|
|
1023
|
+
const checkNames = fromEnv.map((name) => `${contracts.APP_BUILD_SECRET_PREFIX}${name}`).join(" ");
|
|
1024
|
+
lines.push(
|
|
1025
|
+
...scalarBlock(
|
|
1026
|
+
[
|
|
1027
|
+
"set -euo pipefail",
|
|
1028
|
+
`for name in ${checkNames}; do`,
|
|
1029
|
+
' [ -n "${!name:-}" ] || { echo "EW_MISSING:${name#EW_}"; exit 78; }',
|
|
1030
|
+
"done"
|
|
1031
|
+
],
|
|
1032
|
+
10
|
|
1033
|
+
)
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.checkout)}`);
|
|
1037
|
+
lines.push(" with:");
|
|
1038
|
+
lines.push(` ref: ${yamlString("${{ env.EW_SHA }}")}`);
|
|
1039
|
+
lines.push(" fetch-depth: 1");
|
|
1040
|
+
lines.push(" persist-credentials: false");
|
|
1041
|
+
lines.push(" lfs: false");
|
|
1042
|
+
if (canonical.settings.reclaimDisk) {
|
|
1043
|
+
lines.push(" - name: Reclaim runner disk");
|
|
1044
|
+
lines.push(
|
|
1045
|
+
" run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL"
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
const waitPorts = build.services.filter((service) => contracts.resolveBuildServiceKind(service.image) === null).map((service) => service.port).filter((port) => typeof port === "number");
|
|
1049
|
+
if (waitPorts.length > 0) {
|
|
1050
|
+
lines.push(" - name: Wait for the build services");
|
|
1051
|
+
lines.push(" run: |");
|
|
1052
|
+
lines.push(
|
|
1053
|
+
...scalarBlock(
|
|
1054
|
+
[
|
|
1055
|
+
"set -euo pipefail",
|
|
1056
|
+
`for port in ${waitPorts.join(" ")}; do`,
|
|
1057
|
+
" deadline=$((SECONDS + 60))",
|
|
1058
|
+
' until (echo > "/dev/tcp/127.0.0.1/$port") 2>/dev/null; do',
|
|
1059
|
+
' [ "$SECONDS" -lt "$deadline" ] || { echo "EW_SERVICE_TIMEOUT:$port"; exit 78; }',
|
|
1060
|
+
" done",
|
|
1061
|
+
"done"
|
|
1062
|
+
],
|
|
1063
|
+
10
|
|
1064
|
+
)
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.setupBuildx)}`);
|
|
1068
|
+
if (build.services.length > 0) {
|
|
1069
|
+
lines.push(" with:");
|
|
1070
|
+
lines.push(` buildkitd-flags: ${yamlString("--allow-insecure-entitlement network.host")}`);
|
|
1071
|
+
}
|
|
1072
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.login)}`);
|
|
1073
|
+
lines.push(" with:");
|
|
1074
|
+
lines.push(" registry: ghcr.io");
|
|
1075
|
+
lines.push(` username: ${yamlString("${{ github.actor }}")}`);
|
|
1076
|
+
lines.push(` password: ${yamlString("${{ secrets.GITHUB_TOKEN }}")}`);
|
|
1077
|
+
lines.push(" - id: build");
|
|
1078
|
+
lines.push(` if: inputs.ew_mode != 'verify'`);
|
|
1079
|
+
lines.push(` uses: ${actionPin(ACTION_PINS.buildPush)}`);
|
|
1080
|
+
lines.push(" with:");
|
|
1081
|
+
lines.push(` context: ${yamlString(build.context)}`);
|
|
1082
|
+
lines.push(` file: ${yamlString(`${build.context}/${build.dockerfile}`)}`);
|
|
1083
|
+
if (build.target) lines.push(` target: ${yamlString(build.target)}`);
|
|
1084
|
+
lines.push(" load: true");
|
|
1085
|
+
lines.push(" push: false");
|
|
1086
|
+
lines.push(" provenance: false");
|
|
1087
|
+
if (build.services.length > 0) {
|
|
1088
|
+
lines.push(" network: host");
|
|
1089
|
+
lines.push(" allow: network.host");
|
|
1090
|
+
}
|
|
1091
|
+
if (build.args.length > 0) {
|
|
1092
|
+
lines.push(" build-args: |");
|
|
1093
|
+
lines.push(
|
|
1094
|
+
...scalarBlock(
|
|
1095
|
+
build.args.map(
|
|
1096
|
+
(arg) => arg.fromEnv === void 0 ? `${arg.name}=${arg.value ?? ""}` : `${arg.name}=${restrictedValueExpression(arg.fromEnv, restricted)}`
|
|
1097
|
+
),
|
|
1098
|
+
12
|
|
1099
|
+
)
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
lines.push(" tags: |");
|
|
1103
|
+
lines.push(
|
|
1104
|
+
...scalarBlock(
|
|
1105
|
+
[
|
|
1106
|
+
`\${{ env.EW_IMAGE }}:sha-\${{ env.EW_SHA }}`,
|
|
1107
|
+
`\${{ env.EW_IMAGE }}:\${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'branch-${slug}' }}`
|
|
1108
|
+
],
|
|
1109
|
+
12
|
|
1110
|
+
)
|
|
1111
|
+
);
|
|
1112
|
+
lines.push(" labels: |");
|
|
1113
|
+
lines.push(
|
|
1114
|
+
...scalarBlock(
|
|
1115
|
+
[
|
|
1116
|
+
"org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}",
|
|
1117
|
+
"org.opencontainers.image.revision=${{ env.EW_SHA }}",
|
|
1118
|
+
`io.ever-works.app-spec-hash=${canonical.appSpecHash}`
|
|
1119
|
+
],
|
|
1120
|
+
12
|
|
1121
|
+
)
|
|
1122
|
+
);
|
|
1123
|
+
lines.push(" cache-from: type=registry,ref=${{ env.EW_IMAGE }}:buildcache");
|
|
1124
|
+
lines.push(
|
|
1125
|
+
` cache-to: \${{ github.event_name == 'push' && format('type=registry,ref={0}:buildcache,mode=max', env.EW_IMAGE) || '' }}`
|
|
1126
|
+
);
|
|
1127
|
+
if (secretNames.length > 0) {
|
|
1128
|
+
lines.push(" - name: Check the image for secret build values");
|
|
1129
|
+
lines.push(" id: secretcheck");
|
|
1130
|
+
lines.push(` if: inputs.ew_mode != 'verify'`);
|
|
1131
|
+
lines.push(" env:");
|
|
1132
|
+
for (const name of secretNames) {
|
|
1133
|
+
lines.push(` ${name}: ${yamlString(`\${{ secrets.${name} }}`)}`);
|
|
1134
|
+
}
|
|
1135
|
+
lines.push(" run: |");
|
|
1136
|
+
lines.push(
|
|
1137
|
+
...scalarBlock(
|
|
1138
|
+
[
|
|
1139
|
+
"set -euo pipefail",
|
|
1140
|
+
`docker image inspect "$EW_IMAGE:sha-$EW_SHA" > "$RUNNER_TEMP/ew-image.json"`,
|
|
1141
|
+
`docker history --no-trunc --format '{{.CreatedBy}}' "$EW_IMAGE:sha-$EW_SHA" >> "$RUNNER_TEMP/ew-image.json"`,
|
|
1142
|
+
"for name in $EW_SECRET_NAMES; do",
|
|
1143
|
+
' value="${!name:-}"',
|
|
1144
|
+
' [ "${#value}" -ge 8 ] || continue',
|
|
1145
|
+
' if grep -qF -- "$value" "$RUNNER_TEMP/ew-image.json"; then echo "EW_SECRET_IN_IMAGE:${name#EW_}"; exit 79; fi',
|
|
1146
|
+
"done",
|
|
1147
|
+
'echo "secret-check: passed"'
|
|
1148
|
+
],
|
|
1149
|
+
10
|
|
1150
|
+
)
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
lines.push(" - name: Push");
|
|
1154
|
+
lines.push(` if: inputs.ew_mode != 'verify'`);
|
|
1155
|
+
lines.push(" run: |");
|
|
1156
|
+
lines.push(
|
|
1157
|
+
...scalarBlock(
|
|
1158
|
+
[
|
|
1159
|
+
'docker push --all-tags "$EW_IMAGE"',
|
|
1160
|
+
`docker image inspect --format '{{index .RepoDigests 0}}' "$EW_IMAGE:sha-$EW_SHA" > ew-digest.txt`
|
|
1161
|
+
],
|
|
1162
|
+
10
|
|
1163
|
+
)
|
|
1164
|
+
);
|
|
1165
|
+
lines.push(...verifyInRunnerStep(job.verifyRunnerScript));
|
|
1166
|
+
lines.push(...resultStep("build", secretNames.length > 0));
|
|
1167
|
+
lines.push(...uploadArtifactStep());
|
|
1168
|
+
return lines;
|
|
1169
|
+
}
|
|
1170
|
+
function restrictedValueExpression(name, restricted) {
|
|
1171
|
+
const secret = `secrets.${contracts.APP_BUILD_SECRET_PREFIX}${name}`;
|
|
1172
|
+
return restricted ? `\${{ github.event_name == 'pull_request' && '${contracts.APP_BUILD_RESTRICTED_VALUE_LITERAL}' || ${secret} }}` : `\${{ ${secret} }}`;
|
|
1173
|
+
}
|
|
1174
|
+
function serviceLines(service) {
|
|
1175
|
+
const kind = contracts.resolveBuildServiceKind(service.image);
|
|
1176
|
+
const lines = [` ${service.name}:`, ` image: ${yamlString(service.image)}`];
|
|
1177
|
+
const env = kind === null ? Object.fromEntries(service.env.map((entry) => [entry.name, entry.value])) : contracts.resolveBuildServiceEnv(kind, service.env);
|
|
1178
|
+
const names = Object.keys(env).sort();
|
|
1179
|
+
if (names.length > 0) {
|
|
1180
|
+
lines.push(" env:");
|
|
1181
|
+
for (const name of names) lines.push(` ${name}: ${yamlString(env[name])}`);
|
|
1182
|
+
}
|
|
1183
|
+
const port = contracts.evaluateBuildServicePort(service);
|
|
1184
|
+
if (!port.ok) {
|
|
1185
|
+
lines.push(" ports: []");
|
|
1186
|
+
return lines;
|
|
1187
|
+
}
|
|
1188
|
+
lines.push(` ports: [${yamlString(port.published)}]`);
|
|
1189
|
+
if (kind === null) return lines;
|
|
1190
|
+
const health = contracts.BUILD_SERVICE_DEFAULTS[kind].health;
|
|
1191
|
+
const command = health.kind === "command" ? health.command.replace("<user>", env.POSTGRES_USER ?? "").replace("<db>", env.POSTGRES_DB ?? "") : `curl -fsS http://localhost:${contracts.BUILD_SERVICE_DEFAULTS[kind].containerPort}${health.kind === "http" ? health.path : ""}`;
|
|
1192
|
+
lines.push(
|
|
1193
|
+
` options: ${yamlString(
|
|
1194
|
+
`--health-cmd "${command}" --health-interval 10s --health-timeout 5s --health-retries 5`
|
|
1195
|
+
)}`
|
|
1196
|
+
);
|
|
1197
|
+
return lines;
|
|
1198
|
+
}
|
|
1199
|
+
function verifyJob(job) {
|
|
1200
|
+
const { build, image, runnerLabel, verifyRunnerScript } = job;
|
|
1201
|
+
const buildTimeout = build?.resources.timeoutMinutes ?? 0;
|
|
1202
|
+
const services = build?.services ?? [];
|
|
1203
|
+
const lines = [];
|
|
1204
|
+
lines.push(" verify:");
|
|
1205
|
+
lines.push(` if: github.event_name == 'workflow_dispatch' && inputs.ew_mode == 'verify'`);
|
|
1206
|
+
lines.push(` runs-on: ${yamlString(runnerLabel)}`);
|
|
1207
|
+
lines.push(` timeout-minutes: ${buildTimeout + contracts.APP_BUILD_VERIFY_TIMEOUT_MINUTES}`);
|
|
1208
|
+
lines.push(" permissions: { contents: read, packages: read }");
|
|
1209
|
+
lines.push(" env:");
|
|
1210
|
+
lines.push(` EW_IMAGE: ${yamlString(image)}`);
|
|
1211
|
+
lines.push(` EW_SHA: ${yamlString("${{ inputs.ew_sha }}")}`);
|
|
1212
|
+
lines.push(` EW_REUSE_DIGEST: ${yamlString("${{ inputs.ew_reuse_digest }}")}`);
|
|
1213
|
+
lines.push(" steps:");
|
|
1214
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.checkout)}`);
|
|
1215
|
+
lines.push(" with:");
|
|
1216
|
+
lines.push(` ref: ${yamlString("${{ inputs.ew_sha }}")}`);
|
|
1217
|
+
lines.push(" fetch-depth: 1");
|
|
1218
|
+
lines.push(" persist-credentials: false");
|
|
1219
|
+
lines.push(" lfs: false");
|
|
1220
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.setupBuildx)}`);
|
|
1221
|
+
if (services.length > 0) {
|
|
1222
|
+
lines.push(" with:");
|
|
1223
|
+
lines.push(` buildkitd-flags: ${yamlString("--allow-insecure-entitlement network.host")}`);
|
|
1224
|
+
}
|
|
1225
|
+
lines.push(` - uses: ${actionPin(ACTION_PINS.login)}`);
|
|
1226
|
+
lines.push(" with:");
|
|
1227
|
+
lines.push(" registry: ghcr.io");
|
|
1228
|
+
lines.push(` username: ${yamlString("${{ github.actor }}")}`);
|
|
1229
|
+
lines.push(` password: ${yamlString("${{ secrets.GITHUB_TOKEN }}")}`);
|
|
1230
|
+
lines.push(" - name: Take the image to verify");
|
|
1231
|
+
lines.push(" run: |");
|
|
1232
|
+
lines.push(
|
|
1233
|
+
...scalarBlock(
|
|
1234
|
+
[
|
|
1235
|
+
"set -euo pipefail",
|
|
1236
|
+
'plan="$RUNNER_TEMP/ew-verify-plan.json"',
|
|
1237
|
+
`plan_b64="$(printf '%s' "\${{ inputs.${contracts.APP_BUILD_VERIFY_PLAN_INPUT} }}" | tr '_-' '/+')"`,
|
|
1238
|
+
'case $(( ${#plan_b64} % 4 )) in 2) plan_b64="${plan_b64}==" ;; 3) plan_b64="${plan_b64}=" ;; esac',
|
|
1239
|
+
`printf '%s' "$plan_b64" | base64 -d > "$plan"`,
|
|
1240
|
+
`printf 'EW_VERIFY_BUILD=%s\\n' "$(jq -r '.build // empty | @base64' "$plan")" >> "$GITHUB_ENV"`,
|
|
1241
|
+
'if [ -n "$EW_REUSE_DIGEST" ]; then',
|
|
1242
|
+
' docker pull "$EW_IMAGE@$EW_REUSE_DIGEST"',
|
|
1243
|
+
" exit 0",
|
|
1244
|
+
"fi",
|
|
1245
|
+
'[ -n "$EW_VERIFY_BUILD" ] || { echo "EW_VERIFY_NO_BUILD"; exit 78; }',
|
|
1246
|
+
`context="$(jq -r '.build.context // "."' "$plan")"`,
|
|
1247
|
+
`dockerfile="$(jq -r '.build.dockerfile // "Dockerfile"' "$plan")"`,
|
|
1248
|
+
`target="$(jq -r '.build.target // empty' "$plan")"`,
|
|
1249
|
+
'buildx_args=(--load --file "$context/$dockerfile" --cache-from "$EW_IMAGE:buildcache" --tag ew-verify:local)',
|
|
1250
|
+
'if [ -n "$target" ]; then buildx_args+=(--target "$target"); fi',
|
|
1251
|
+
"while IFS= read -r entry; do",
|
|
1252
|
+
' [ -n "$entry" ] || continue',
|
|
1253
|
+
' buildx_args+=(--build-arg "$entry")',
|
|
1254
|
+
`done < <(jq -r '(.build.args // [])[] | select(.value != null) | "\\(.name)=\\(.value)"' "$plan")`,
|
|
1255
|
+
'docker buildx build "${buildx_args[@]}" "$context"'
|
|
1256
|
+
],
|
|
1257
|
+
10
|
|
1258
|
+
)
|
|
1259
|
+
);
|
|
1260
|
+
lines.push(...verifyInRunnerStep(verifyRunnerScript));
|
|
1261
|
+
lines.push(...resultStep("verify", false));
|
|
1262
|
+
lines.push(...uploadArtifactStep());
|
|
1263
|
+
return lines;
|
|
1264
|
+
}
|
|
1265
|
+
function verifyInRunnerStep(verifyRunnerScript) {
|
|
1266
|
+
return [
|
|
1267
|
+
" - name: Verify in the runner",
|
|
1268
|
+
` if: inputs.${contracts.APP_BUILD_VERIFY_PLAN_INPUT} != ''`,
|
|
1269
|
+
` timeout-minutes: ${contracts.APP_BUILD_VERIFY_TIMEOUT_MINUTES}`,
|
|
1270
|
+
" run: |",
|
|
1271
|
+
...scalarBlock(verifyRunnerScript.replace(/\n+$/, "").split("\n"), 10)
|
|
1272
|
+
];
|
|
1273
|
+
}
|
|
1274
|
+
function resultStep(job, secretCheckPresent) {
|
|
1275
|
+
const script = job === "build" ? [
|
|
1276
|
+
"set -euo pipefail",
|
|
1277
|
+
'digest=""',
|
|
1278
|
+
`[ ! -f ew-digest.txt ] || digest="$(tr -d '[:space:]' < ew-digest.txt)"`,
|
|
1279
|
+
`secret_check="not_needed"`,
|
|
1280
|
+
...secretCheckPresent ? [
|
|
1281
|
+
`if [ -n "$EW_SECRET_NAMES" ]; then`,
|
|
1282
|
+
` secret_check="passed"`,
|
|
1283
|
+
` [ "\${{ steps.secretcheck.outcome }}" != "failure" ] || secret_check="failed"`,
|
|
1284
|
+
`fi`
|
|
1285
|
+
] : [],
|
|
1286
|
+
`jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
|
|
1287
|
+
` --arg imageRepository "$EW_IMAGE" --arg digest "$digest" --arg secretCheck "$secret_check" \\`,
|
|
1288
|
+
` --arg tag "$EW_IMAGE:sha-$EW_SHA" \\`,
|
|
1289
|
+
` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
|
|
1290
|
+
` digest:$digest,tags:[$tag],secretCheck:$secretCheck,verification:{jobs:[],smoke:[]}}' > ${contracts.APP_BUILD_RESULT_ARTIFACT_FILE}`
|
|
1291
|
+
] : [
|
|
1292
|
+
"set -euo pipefail",
|
|
1293
|
+
`reused_digest="$EW_REUSE_DIGEST"`,
|
|
1294
|
+
`local_build=true`,
|
|
1295
|
+
`[ -z "$reused_digest" ] || local_build=false`,
|
|
1296
|
+
`jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
|
|
1297
|
+
` --arg imageRepository "$EW_IMAGE" --arg reusedDigest "$reused_digest" \\`,
|
|
1298
|
+
` --argjson localBuild "$local_build" \\`,
|
|
1299
|
+
` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
|
|
1300
|
+
` reusedDigest:$reusedDigest,localBuild:$localBuild,verification:{jobs:[],smoke:[]}}' > ${contracts.APP_BUILD_RESULT_ARTIFACT_FILE}`
|
|
1301
|
+
];
|
|
1302
|
+
return [" - name: Write result", " if: always()", " run: |", ...scalarBlock(script, 10)];
|
|
1303
|
+
}
|
|
1304
|
+
function uploadArtifactStep() {
|
|
1305
|
+
return [
|
|
1306
|
+
` - uses: ${actionPin(ACTION_PINS.uploadArtifact)}`,
|
|
1307
|
+
" if: always()",
|
|
1308
|
+
" with:",
|
|
1309
|
+
` name: ${yamlString(contracts.APP_BUILD_RESULT_ARTIFACT_NAME)}`,
|
|
1310
|
+
` path: ${yamlString(contracts.APP_BUILD_RESULT_ARTIFACT_FILE)}`,
|
|
1311
|
+
` retention-days: ${contracts.APP_BUILD_RESULT_ARTIFACT_RETENTION_DAYS}`,
|
|
1312
|
+
" if-no-files-found: ignore"
|
|
1313
|
+
];
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// src/repo/branch-protection.ts
|
|
1317
|
+
var BRANCH_RULES_PAGE_SIZE = 100;
|
|
1318
|
+
var BRANCH_RULES_MAX_PAGES = 10;
|
|
1319
|
+
var BRANCH_PROTECTION_RULE_TYPES = ["pull_request", "required_status_checks"];
|
|
1320
|
+
function isActiveRule(rule) {
|
|
1321
|
+
return rule.enforcement === void 0 || rule.enforcement === "active";
|
|
1322
|
+
}
|
|
1323
|
+
async function isBranchProtected(port, coordinates) {
|
|
1324
|
+
const classic = await port.readBranchProtection(coordinates);
|
|
1325
|
+
if (!classic.ok && classic.status === 403) return { protected: true, decision: "forbidden" };
|
|
1326
|
+
if (classic.ok) {
|
|
1327
|
+
if (classic.data?.required_pull_request_reviews !== void 0 && classic.data?.required_pull_request_reviews !== null) {
|
|
1328
|
+
return { protected: true, decision: "requiredPullRequestReviews" };
|
|
1329
|
+
}
|
|
1330
|
+
if (classic.data?.required_status_checks !== void 0 && classic.data?.required_status_checks !== null) {
|
|
1331
|
+
return { protected: true, decision: "requiredStatusChecks" };
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
for (let page = 1; page <= BRANCH_RULES_MAX_PAGES; page += 1) {
|
|
1335
|
+
const rules = await port.readBranchRules({ ...coordinates, page });
|
|
1336
|
+
if (!rules.ok) return { protected: false, decision: "none" };
|
|
1337
|
+
const page1 = rules.data ?? [];
|
|
1338
|
+
for (const rule of page1) {
|
|
1339
|
+
if (!isActiveRule(rule)) continue;
|
|
1340
|
+
if (rule.type === "pull_request") return { protected: true, decision: "rulesetPullRequest" };
|
|
1341
|
+
if (rule.type === "required_status_checks") return { protected: true, decision: "rulesetStatusChecks" };
|
|
1342
|
+
}
|
|
1343
|
+
if (page1.length < BRANCH_RULES_PAGE_SIZE) break;
|
|
1344
|
+
}
|
|
1345
|
+
return { protected: false, decision: "none" };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// src/repo/workflow-writer.ts
|
|
1349
|
+
var WORKFLOW_COMMIT_MESSAGE_ADD = "Add Ever Works build workflow";
|
|
1350
|
+
var WORKFLOW_COMMIT_MESSAGE_UPDATE = "Update Ever Works build workflow";
|
|
1351
|
+
var WORKFLOW_COMMIT_MESSAGE_REMOVE = "Remove Ever Works build workflow";
|
|
1352
|
+
var WORKFLOW_PULL_REQUEST_TITLE = "Add Ever Works build workflow";
|
|
1353
|
+
var WORKFLOW_REMOVAL_PULL_REQUEST_TITLE = "Remove the Ever Works build workflow";
|
|
1354
|
+
var WORKFLOW_PULL_REQUEST_BODY = "Ever Works generated this workflow from the App spec. It builds the image for this App Work on GitHub-hosted runners and publishes it to GHCR. Hand edits to this file are never overwritten: Ever Works proposes its changes as pull requests.";
|
|
1355
|
+
var WORKFLOW_REMOVAL_PULL_REQUEST_BODY = "Ever Works generated this workflow from the App spec. The App spec no longer declares a build or a check, so this pull request removes every job from it. Merge it to remove the workflow, or delete the file outright \u2014 Ever Works never writes to the tracked branch to remove a file.";
|
|
1356
|
+
var WORKFLOW_WRITE_MAX_ATTEMPTS = 3;
|
|
1357
|
+
var WORKFLOW_READ_BACK_ATTEMPTS = 2;
|
|
1358
|
+
function decodeFileContent(file) {
|
|
1359
|
+
return file.encoding === "base64" ? Buffer.from(file.content, "base64").toString("utf-8") : file.content;
|
|
1360
|
+
}
|
|
1361
|
+
async function readFileSha256(writer, path, ref) {
|
|
1362
|
+
const file = await writer.getFileContent(path, ref);
|
|
1363
|
+
if (!file) return null;
|
|
1364
|
+
return contracts.sha256Hex(decodeFileContent(file));
|
|
1365
|
+
}
|
|
1366
|
+
async function readBranchHead(writer, branch) {
|
|
1367
|
+
const existing = await writer.createBranch(branch, branch);
|
|
1368
|
+
return existing.commit;
|
|
1369
|
+
}
|
|
1370
|
+
function repositoryWriteErrorCode(error) {
|
|
1371
|
+
const candidate = error;
|
|
1372
|
+
for (const value of [candidate?.code, candidate?.reason]) {
|
|
1373
|
+
if (value === "nonFastForward" || value === "refRejectedByRule" || value === "pullRequestExists") return value;
|
|
1374
|
+
}
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
var PULL_REQUEST_ALREADY_EXISTS = /pull request already exists/i;
|
|
1378
|
+
function isPullRequestAlreadyExistsError(error) {
|
|
1379
|
+
if (repositoryWriteErrorCode(error) === "pullRequestExists") return true;
|
|
1380
|
+
const candidate = error;
|
|
1381
|
+
if (candidate?.status !== 422) return false;
|
|
1382
|
+
const messages = [candidate.message, candidate.response?.data?.message];
|
|
1383
|
+
const errors = candidate.response?.data?.errors;
|
|
1384
|
+
if (Array.isArray(errors)) {
|
|
1385
|
+
for (const entry of errors) messages.push(entry?.message);
|
|
1386
|
+
}
|
|
1387
|
+
return messages.some((message) => typeof message === "string" && PULL_REQUEST_ALREADY_EXISTS.test(message));
|
|
1388
|
+
}
|
|
1389
|
+
function commitFile(path, content) {
|
|
1390
|
+
return { path, content, encoding: "utf-8" };
|
|
1391
|
+
}
|
|
1392
|
+
var DELIVERY_COPY = {
|
|
1393
|
+
commitMessage: WORKFLOW_COMMIT_MESSAGE_UPDATE,
|
|
1394
|
+
title: WORKFLOW_PULL_REQUEST_TITLE,
|
|
1395
|
+
body: WORKFLOW_PULL_REQUEST_BODY
|
|
1396
|
+
};
|
|
1397
|
+
var REMOVAL_COPY = {
|
|
1398
|
+
commitMessage: WORKFLOW_COMMIT_MESSAGE_REMOVE,
|
|
1399
|
+
title: WORKFLOW_REMOVAL_PULL_REQUEST_TITLE,
|
|
1400
|
+
body: WORKFLOW_REMOVAL_PULL_REQUEST_BODY
|
|
1401
|
+
};
|
|
1402
|
+
async function writeWorkflow(input, writer, protection) {
|
|
1403
|
+
const path = input.workflowPath ?? contracts.APP_BUILD_WORKFLOW_PATH;
|
|
1404
|
+
const { owner, repo, trackedBranch, createdByAppWork } = input.repository;
|
|
1405
|
+
const contentSha256 = contracts.sha256Hex(input.content);
|
|
1406
|
+
const currentSha256 = await readFileSha256(writer, path);
|
|
1407
|
+
const existsOnTrackedBranch = currentSha256 !== null;
|
|
1408
|
+
if (existsOnTrackedBranch && currentSha256 === contentSha256) {
|
|
1409
|
+
return { state: "unchanged", storedWorkflowSha256: contentSha256 };
|
|
1410
|
+
}
|
|
1411
|
+
const handEdited = existsOnTrackedBranch && currentSha256 !== input.lastWrittenWorkflowSha256 && currentSha256 !== contentSha256;
|
|
1412
|
+
const removal = input.content === "";
|
|
1413
|
+
if (removal) {
|
|
1414
|
+
if (!existsOnTrackedBranch) return { state: "unchanged", storedWorkflowSha256: null };
|
|
1415
|
+
return pullRequestDelivery(input, writer, path, contentSha256, handEdited, REMOVAL_COPY);
|
|
1416
|
+
}
|
|
1417
|
+
const verdict = createdByAppWork && !handEdited && protection ? await isBranchProtected(protection, { owner, repo, branch: trackedBranch }) : { protected: false};
|
|
1418
|
+
const pullRequestPath = !createdByAppWork || handEdited || verdict.protected;
|
|
1419
|
+
if (pullRequestPath) {
|
|
1420
|
+
return pullRequestDelivery(input, writer, path, contentSha256, handEdited);
|
|
1421
|
+
}
|
|
1422
|
+
return directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch);
|
|
1423
|
+
}
|
|
1424
|
+
async function directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch) {
|
|
1425
|
+
const { trackedBranch } = input.repository;
|
|
1426
|
+
const message = existsOnTrackedBranch ? WORKFLOW_COMMIT_MESSAGE_UPDATE : WORKFLOW_COMMIT_MESSAGE_ADD;
|
|
1427
|
+
let attempts = 0;
|
|
1428
|
+
while (attempts < WORKFLOW_WRITE_MAX_ATTEMPTS) {
|
|
1429
|
+
attempts += 1;
|
|
1430
|
+
const baseSha = input.resolveTrackedHead ? await input.resolveTrackedHead() : await readBranchHead(writer, trackedBranch);
|
|
1431
|
+
const commit = {
|
|
1432
|
+
branch: trackedBranch,
|
|
1433
|
+
baseSha,
|
|
1434
|
+
message,
|
|
1435
|
+
files: [commitFile(path, input.content)]
|
|
1436
|
+
};
|
|
1437
|
+
try {
|
|
1438
|
+
const { commitSha } = await writer.commitFiles(commit);
|
|
1439
|
+
const readBack = await readBackMatches(writer, path, commitSha, contentSha256);
|
|
1440
|
+
if (!readBack) return workflowWriteFailed("readBackMismatch");
|
|
1441
|
+
return { state: "committed", commitSha, storedWorkflowSha256: contentSha256 };
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
const code = repositoryWriteErrorCode(error);
|
|
1444
|
+
if (code === "refRejectedByRule") {
|
|
1445
|
+
return pullRequestDelivery(input, writer, path, contentSha256, false);
|
|
1446
|
+
}
|
|
1447
|
+
if (code === "nonFastForward" && attempts < WORKFLOW_WRITE_MAX_ATTEMPTS) continue;
|
|
1448
|
+
if (code === "nonFastForward") return workflowWriteFailed("nonFastForward");
|
|
1449
|
+
throw error;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
return workflowWriteFailed("nonFastForward");
|
|
1453
|
+
}
|
|
1454
|
+
async function pullRequestDelivery(input, writer, path, contentSha256, handEdited, copy = DELIVERY_COPY) {
|
|
1455
|
+
const { trackedBranch } = input.repository;
|
|
1456
|
+
try {
|
|
1457
|
+
const branch = await writer.createBranch(contracts.APP_BUILD_WORKFLOW_BRANCH, trackedBranch);
|
|
1458
|
+
const branchSha256 = await readFileSha256(writer, path, branch.name);
|
|
1459
|
+
if (branchSha256 !== contentSha256) {
|
|
1460
|
+
const { commitSha } = await writer.commitFiles({
|
|
1461
|
+
branch: branch.name,
|
|
1462
|
+
baseSha: branch.commit,
|
|
1463
|
+
message: copy.commitMessage,
|
|
1464
|
+
files: [commitFile(path, input.content)]
|
|
1465
|
+
});
|
|
1466
|
+
const readBack = await readBackMatches(writer, path, commitSha, contentSha256);
|
|
1467
|
+
if (!readBack) return workflowWriteFailed("readBackMismatch");
|
|
1468
|
+
}
|
|
1469
|
+
try {
|
|
1470
|
+
const pullRequest = await writer.createPullRequest({
|
|
1471
|
+
title: copy.title,
|
|
1472
|
+
head: contracts.APP_BUILD_WORKFLOW_BRANCH,
|
|
1473
|
+
base: trackedBranch,
|
|
1474
|
+
body: copy.body
|
|
1475
|
+
});
|
|
1476
|
+
return {
|
|
1477
|
+
state: handEdited ? "editedByHand" : "pullRequestOpened",
|
|
1478
|
+
pullRequestNumber: pullRequest.number,
|
|
1479
|
+
pullRequestUrl: pullRequest.url,
|
|
1480
|
+
storedWorkflowSha256: null
|
|
1481
|
+
};
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
if (!isPullRequestAlreadyExistsError(error)) throw error;
|
|
1484
|
+
return {
|
|
1485
|
+
state: handEdited ? "editedByHand" : "pullRequestUpdated",
|
|
1486
|
+
...input.pullRequestNumber != null ? { pullRequestNumber: input.pullRequestNumber } : {},
|
|
1487
|
+
...input.pullRequestUrl ? { pullRequestUrl: input.pullRequestUrl } : {},
|
|
1488
|
+
storedWorkflowSha256: null
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
if (repositoryWriteErrorCode(error) === "refRejectedByRule") return workflowWriteFailed("branchRules");
|
|
1493
|
+
throw error;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
async function readBackMatches(writer, path, commitSha, contentSha256) {
|
|
1497
|
+
for (let attempt = 0; attempt < WORKFLOW_READ_BACK_ATTEMPTS; attempt += 1) {
|
|
1498
|
+
if (await readFileSha256(writer, path, commitSha) === contentSha256) return true;
|
|
1499
|
+
}
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
function workflowWriteFailed(cause) {
|
|
1503
|
+
return {
|
|
1504
|
+
state: "blocked",
|
|
1505
|
+
storedWorkflowSha256: null,
|
|
1506
|
+
blocked: { reason: "workflowWriteFailed", detail: { cause } }
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
var GITHUB_SECRET_NAME_PATTERN = /^[A-Z_][A-Z0-9_]{0,254}$/;
|
|
1510
|
+
function isValidGitHubSecretName(name) {
|
|
1511
|
+
return GITHUB_SECRET_NAME_PATTERN.test(name) && !name.startsWith("GITHUB_");
|
|
1512
|
+
}
|
|
1513
|
+
function buildSecretName(valueName) {
|
|
1514
|
+
return `${contracts.APP_BUILD_SECRET_PREFIX}${valueName}`;
|
|
1515
|
+
}
|
|
1516
|
+
var sodiumPromise;
|
|
1517
|
+
function loadSodium() {
|
|
1518
|
+
sodiumPromise ??= (async () => {
|
|
1519
|
+
try {
|
|
1520
|
+
const imported = await import('libsodium-wrappers');
|
|
1521
|
+
if (imported.default) return imported.default;
|
|
1522
|
+
} catch {
|
|
1523
|
+
}
|
|
1524
|
+
const require2 = module$1.createRequire(typeof __filename === "string" ? __filename : (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
|
|
1525
|
+
return require2("libsodium-wrappers");
|
|
1526
|
+
})();
|
|
1527
|
+
return sodiumPromise;
|
|
1528
|
+
}
|
|
1529
|
+
async function sealSecretValue(value, publicKey) {
|
|
1530
|
+
const _sodium = await loadSodium();
|
|
1531
|
+
await _sodium.ready;
|
|
1532
|
+
const binaryKey = _sodium.from_base64(publicKey, _sodium.base64_variants.ORIGINAL);
|
|
1533
|
+
const binarySecret = _sodium.from_string(value);
|
|
1534
|
+
const sealed = _sodium.crypto_box_seal(binarySecret, binaryKey);
|
|
1535
|
+
return _sodium.to_base64(sealed, _sodium.base64_variants.ORIGINAL);
|
|
1536
|
+
}
|
|
1537
|
+
function byteLength(value) {
|
|
1538
|
+
return Buffer.byteLength(value, "utf-8");
|
|
1539
|
+
}
|
|
1540
|
+
function isSecretLimitError(error) {
|
|
1541
|
+
const candidate = error;
|
|
1542
|
+
const message = typeof candidate?.message === "string" ? candidate.message : "";
|
|
1543
|
+
if (!/secret/i.test(message)) return false;
|
|
1544
|
+
if (candidate?.status === 422 && /limit/i.test(message)) return true;
|
|
1545
|
+
return /secret(s)? limit/i.test(message);
|
|
1546
|
+
}
|
|
1547
|
+
function isSecretNotFoundError(error) {
|
|
1548
|
+
return error?.status === 404;
|
|
1549
|
+
}
|
|
1550
|
+
function createBuildValueSecretSync(dependencies) {
|
|
1551
|
+
const { port, logger } = dependencies;
|
|
1552
|
+
const seal = dependencies.seal ?? sealSecretValue;
|
|
1553
|
+
async function put(name, value, publicKey) {
|
|
1554
|
+
if (!isValidGitHubSecretName(name)) {
|
|
1555
|
+
throw new Error(
|
|
1556
|
+
`Invalid secret name "${name}": must be 1-255 characters, contain only uppercase letters, digits, and underscores, start with a letter or underscore, and must not begin with "GITHUB_".`
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
await port.putRepoSecret({
|
|
1560
|
+
name,
|
|
1561
|
+
encryptedValue: await seal(value, publicKey.key),
|
|
1562
|
+
keyId: publicKey.key_id
|
|
1563
|
+
});
|
|
1564
|
+
logger?.debug?.("build value sealed and written", { name });
|
|
1565
|
+
}
|
|
1566
|
+
return {
|
|
1567
|
+
async syncBuildValues(input) {
|
|
1568
|
+
const buildInputsHash = contracts.computeBuildInputsHash(
|
|
1569
|
+
input.values.map((value) => ({ name: value.name, fingerprint: value.fingerprint }))
|
|
1570
|
+
);
|
|
1571
|
+
if (input.values.length > contracts.APP_BUILD_MAX_VALUES) {
|
|
1572
|
+
return {
|
|
1573
|
+
secretsWritten: [],
|
|
1574
|
+
secretsRemoved: [],
|
|
1575
|
+
buildInputsHash,
|
|
1576
|
+
blocked: {
|
|
1577
|
+
reason: "tooManyBuildValues",
|
|
1578
|
+
detail: { count: input.values.length, max: contracts.APP_BUILD_MAX_VALUES }
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
for (const value of input.values) {
|
|
1583
|
+
const name = buildSecretName(value.name);
|
|
1584
|
+
if (name === contracts.APP_BUILD_VERIFY_PROMPTED_SECRET) {
|
|
1585
|
+
return {
|
|
1586
|
+
secretsWritten: [],
|
|
1587
|
+
secretsRemoved: [],
|
|
1588
|
+
buildInputsHash,
|
|
1589
|
+
blocked: { reason: "buildValueNameReserved", detail: { name } }
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
if (byteLength(value.value) > contracts.APP_BUILD_SECRET_MAX_BYTES) {
|
|
1593
|
+
return {
|
|
1594
|
+
secretsWritten: [],
|
|
1595
|
+
secretsRemoved: [],
|
|
1596
|
+
buildInputsHash,
|
|
1597
|
+
blocked: { reason: "buildValueTooLarge", detail: { name } }
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
const written = [];
|
|
1602
|
+
const publicKey = await port.getRepoPublicKey();
|
|
1603
|
+
try {
|
|
1604
|
+
for (const value of input.values) {
|
|
1605
|
+
const name = buildSecretName(value.name);
|
|
1606
|
+
await put(name, value.value, publicKey);
|
|
1607
|
+
written.push(name);
|
|
1608
|
+
}
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
if (isSecretLimitError(error)) {
|
|
1611
|
+
return {
|
|
1612
|
+
secretsWritten: [],
|
|
1613
|
+
secretsRemoved: [],
|
|
1614
|
+
buildInputsHash,
|
|
1615
|
+
blocked: { reason: "secretLimitReached", detail: { count: input.values.length } }
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
throw error;
|
|
1619
|
+
}
|
|
1620
|
+
const referenced = new Set(written);
|
|
1621
|
+
const removed = [];
|
|
1622
|
+
for (const name of new Set(input.previouslyWrittenSecretNames)) {
|
|
1623
|
+
if (referenced.has(name)) continue;
|
|
1624
|
+
try {
|
|
1625
|
+
await port.deleteRepoSecret({ name });
|
|
1626
|
+
logger?.debug?.("build value secret removed", { name });
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
if (!isSecretNotFoundError(error)) throw error;
|
|
1629
|
+
logger?.debug?.("build value secret already absent", { name });
|
|
1630
|
+
}
|
|
1631
|
+
removed.push(name);
|
|
1632
|
+
}
|
|
1633
|
+
logger?.info?.("build values synced", { written: written.length, removed: removed.length });
|
|
1634
|
+
return { secretsWritten: written, secretsRemoved: removed, buildInputsHash };
|
|
1635
|
+
},
|
|
1636
|
+
async writeVerifyPromptedSecret(values) {
|
|
1637
|
+
const payload = {};
|
|
1638
|
+
for (const value of values) payload[value.name] = value.value;
|
|
1639
|
+
const json = JSON.stringify(payload);
|
|
1640
|
+
const bytes = byteLength(json);
|
|
1641
|
+
if (bytes > contracts.APP_BUILD_SECRET_MAX_BYTES) {
|
|
1642
|
+
return {
|
|
1643
|
+
name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET,
|
|
1644
|
+
bytes,
|
|
1645
|
+
blocked: { reason: "buildValueTooLarge", detail: { name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET } }
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
await put(contracts.APP_BUILD_VERIFY_PROMPTED_SECRET, json, await port.getRepoPublicKey());
|
|
1649
|
+
return { name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET, bytes };
|
|
1650
|
+
},
|
|
1651
|
+
async deleteVerifyPromptedSecret() {
|
|
1652
|
+
try {
|
|
1653
|
+
await port.deleteRepoSecret({ name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET });
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if (!isSecretNotFoundError(error)) throw error;
|
|
1656
|
+
logger?.debug?.("verification prompted secret already absent", {
|
|
1657
|
+
name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET
|
|
1658
|
+
});
|
|
1659
|
+
return { deleted: false };
|
|
1660
|
+
}
|
|
1661
|
+
logger?.debug?.("verification prompted secret removed", { name: contracts.APP_BUILD_VERIFY_PROMPTED_SECRET });
|
|
1662
|
+
return { deleted: true };
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// src/settings.schema.ts
|
|
1668
|
+
var LARGER_RUNNER_LABEL_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
|
|
1669
|
+
var LARGER_RUNNER_MEMORY_MIN_GIB = 8;
|
|
1670
|
+
var LARGER_RUNNER_MEMORY_MAX_GIB = 256;
|
|
1671
|
+
var LARGER_RUNNER_VCPU_MIN = 2;
|
|
1672
|
+
var LARGER_RUNNER_VCPU_MAX = 64;
|
|
1673
|
+
var GITHUB_ACTIONS_BUILD_SETTING_KEYS = [
|
|
1674
|
+
"largerRunnerLabel",
|
|
1675
|
+
"largerRunnerMemoryGiB",
|
|
1676
|
+
"largerRunnerVcpu",
|
|
1677
|
+
"reclaimDisk",
|
|
1678
|
+
"attestations",
|
|
1679
|
+
"pullToken",
|
|
1680
|
+
"pullTokenExpiresAt",
|
|
1681
|
+
"allowBuildValuesOnPullRequests",
|
|
1682
|
+
"verificationPromptedValuesRequireApproval"
|
|
1683
|
+
];
|
|
1684
|
+
var GITHUB_ACTIONS_BUILD_PLATFORM_MANAGED_KEYS = ["pullToken", "pullTokenExpiresAt"];
|
|
1685
|
+
var PREPARATION_STATE_KEYS = [
|
|
1686
|
+
"workflowSha256",
|
|
1687
|
+
"workflowPullRequestNumber",
|
|
1688
|
+
"webhookId",
|
|
1689
|
+
"runsEtag",
|
|
1690
|
+
"repositoryBlock"
|
|
1691
|
+
];
|
|
1692
|
+
var GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS = {
|
|
1693
|
+
largerRunnerLabel: "",
|
|
1694
|
+
largerRunnerMemoryGiB: 0,
|
|
1695
|
+
largerRunnerVcpu: 0,
|
|
1696
|
+
reclaimDisk: true,
|
|
1697
|
+
attestations: false,
|
|
1698
|
+
allowBuildValuesOnPullRequests: false,
|
|
1699
|
+
verificationPromptedValuesRequireApproval: true
|
|
1700
|
+
};
|
|
1701
|
+
var gitHubActionsBuildSettingsSchema = {
|
|
1702
|
+
type: "object",
|
|
1703
|
+
properties: {
|
|
1704
|
+
largerRunnerLabel: {
|
|
1705
|
+
type: "string",
|
|
1706
|
+
title: "Larger runner label",
|
|
1707
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.largerRunnerLabel,
|
|
1708
|
+
pattern: LARGER_RUNNER_LABEL_PATTERN,
|
|
1709
|
+
description: "Label of a larger GitHub-hosted runner to use for private repositories, when one is configured. Leave blank to use the standard runner."
|
|
1710
|
+
},
|
|
1711
|
+
largerRunnerMemoryGiB: {
|
|
1712
|
+
type: "integer",
|
|
1713
|
+
title: "Larger runner memory (GiB)",
|
|
1714
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.largerRunnerMemoryGiB,
|
|
1715
|
+
minimum: LARGER_RUNNER_MEMORY_MIN_GIB,
|
|
1716
|
+
maximum: LARGER_RUNNER_MEMORY_MAX_GIB,
|
|
1717
|
+
description: 'Memory the larger runner provides. Required when a larger runner label is set \u2014 the memory check cannot run without it. 0 means "no larger runner".'
|
|
1718
|
+
},
|
|
1719
|
+
largerRunnerVcpu: {
|
|
1720
|
+
type: "integer",
|
|
1721
|
+
title: "Larger runner vCPU",
|
|
1722
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.largerRunnerVcpu,
|
|
1723
|
+
minimum: LARGER_RUNNER_VCPU_MIN,
|
|
1724
|
+
maximum: LARGER_RUNNER_VCPU_MAX,
|
|
1725
|
+
description: "CPU the larger runner provides. Informational: a declared CPU above it produces a warning, never a blocked Build."
|
|
1726
|
+
},
|
|
1727
|
+
reclaimDisk: {
|
|
1728
|
+
type: "boolean",
|
|
1729
|
+
title: "Reclaim runner disk before building",
|
|
1730
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.reclaimDisk,
|
|
1731
|
+
description: "Removes preinstalled toolchains the build does not need, so a large image fits on the runner."
|
|
1732
|
+
},
|
|
1733
|
+
attestations: {
|
|
1734
|
+
type: "boolean",
|
|
1735
|
+
title: "Publish build provenance attestations",
|
|
1736
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.attestations,
|
|
1737
|
+
description: "Public repositories only. On a private repository this is ignored: the generated workflow grants the attestation permissions only when the repository is public.",
|
|
1738
|
+
"x-scope": "work"
|
|
1739
|
+
},
|
|
1740
|
+
pullToken: {
|
|
1741
|
+
type: "string",
|
|
1742
|
+
title: "Registry pull token",
|
|
1743
|
+
description: "Written by Ever Works after the token is validated against the built image; never typed here and never returned by an endpoint.",
|
|
1744
|
+
"x-secret": true,
|
|
1745
|
+
"x-scope": "work",
|
|
1746
|
+
"x-widget": "password",
|
|
1747
|
+
"x-platformManaged": true
|
|
1748
|
+
},
|
|
1749
|
+
pullTokenExpiresAt: {
|
|
1750
|
+
type: "string",
|
|
1751
|
+
title: "Registry pull token expires at",
|
|
1752
|
+
description: "Recorded by Ever Works from the token provider when the token was saved. Read-only.",
|
|
1753
|
+
"x-scope": "work",
|
|
1754
|
+
"x-platformManaged": true
|
|
1755
|
+
},
|
|
1756
|
+
allowBuildValuesOnPullRequests: {
|
|
1757
|
+
type: "boolean",
|
|
1758
|
+
title: "Send stored build values to pull requests",
|
|
1759
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.allowBuildValuesOnPullRequests,
|
|
1760
|
+
description: "When on, a pull request from this repository receives the stored build values \u2014 which means whatever code the pull request contains can read them. Leave off unless the build genuinely needs a stored value on a pull request.",
|
|
1761
|
+
"x-scope": "work"
|
|
1762
|
+
},
|
|
1763
|
+
verificationPromptedValuesRequireApproval: {
|
|
1764
|
+
type: "boolean",
|
|
1765
|
+
title: "Require approval before a verification uses prompted values",
|
|
1766
|
+
default: GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS.verificationPromptedValuesRequireApproval,
|
|
1767
|
+
description: "When on, a verification receives owner-set prompted values only when the change under verification touches no build-affecting file, or the owner approved it.",
|
|
1768
|
+
"x-scope": "work"
|
|
1769
|
+
}
|
|
1770
|
+
},
|
|
1771
|
+
// plan §4.4: `largerRunnerMemoryGiB` is "required when the label is set (the
|
|
1772
|
+
// memory check needs it)". JSON Schema is the only place this can be stated
|
|
1773
|
+
// before a Build exists, and the platform's validator runs with
|
|
1774
|
+
// `useDefaults: false`, so an absent key stays absent and this `required`
|
|
1775
|
+
// really fires.
|
|
1776
|
+
allOf: [
|
|
1777
|
+
{
|
|
1778
|
+
if: { properties: { largerRunnerLabel: { minLength: 1 } }, required: ["largerRunnerLabel"] },
|
|
1779
|
+
then: { required: ["largerRunnerMemoryGiB"] }
|
|
1780
|
+
}
|
|
1781
|
+
]
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
// src/github-actions-build.plugin.ts
|
|
1785
|
+
var GitHubActionsBuildPlugin = class {
|
|
1786
|
+
constructor() {
|
|
1787
|
+
this.id = "github-actions-build";
|
|
1788
|
+
this.name = "GitHub Actions builds";
|
|
1789
|
+
this.version = "1.0.0";
|
|
1790
|
+
/**
|
|
1791
|
+
* Plan §4.3: the `build` category — appended to `PLUGIN_CATEGORIES` by APW-05
|
|
1792
|
+
* T3 (`packages/plugin/src/contracts/plugin-manifest.types.ts`), so the
|
|
1793
|
+
* loader's own `isPluginCategory` gate accepts this manifest.
|
|
1794
|
+
*/
|
|
1795
|
+
this.category = "build";
|
|
1796
|
+
/** Plan §4.3: exactly one capability. `isBuildPlugin` resolves this plugin on it. */
|
|
1797
|
+
this.capabilities = ["build"];
|
|
1798
|
+
/** Plan §4.3. `AppBuildKind` — a closed union in `@ever-works/contracts`. */
|
|
1799
|
+
this.buildKind = "github-actions";
|
|
1800
|
+
/**
|
|
1801
|
+
* Plan §4.1/R-13: `dockerfile` only.
|
|
1802
|
+
*
|
|
1803
|
+
* `auto` is deliberately absent: the zero-config builder is a provider-internal
|
|
1804
|
+
* choice this plugin does not have, so the strategy gate leaves that Build
|
|
1805
|
+
* unstarted (`strategyNotSupported`) rather than half-built. `image` and `none`
|
|
1806
|
+
* are absent for the same reason — they need no build at all, and the prepare
|
|
1807
|
+
* runner handles them before a build plugin is asked (plan §7.2).
|
|
1808
|
+
*/
|
|
1809
|
+
this.supportedStrategies = ["dockerfile"];
|
|
1810
|
+
/** Work-scoped settings, like the other per-Work provider plugins. */
|
|
1811
|
+
this.configurationMode = "user-required";
|
|
1812
|
+
/** Plan §4.4, exactly. */
|
|
1813
|
+
this.settingsSchema = gitHubActionsBuildSettingsSchema;
|
|
1814
|
+
}
|
|
1815
|
+
async onLoad(context) {
|
|
1816
|
+
context.logger.log("GitHub Actions build plugin loaded");
|
|
1817
|
+
}
|
|
1818
|
+
async onUnload() {
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* The Actions API this plugin talks to, bound per call from the caller's auth.
|
|
1822
|
+
*
|
|
1823
|
+
* `protected` and a method, not a constructor seam: the loader constructs a
|
|
1824
|
+
* plugin with **no arguments** (`PluginLoaderService.loadPluginModule` —
|
|
1825
|
+
* `new PluginClass()`), so anything a spec wants to replace has to be
|
|
1826
|
+
* replaceable on the instance. A spec subclasses and overrides this; production
|
|
1827
|
+
* gets Octokit, which is the official SDK and already this package's dependency.
|
|
1828
|
+
*
|
|
1829
|
+
* A fresh client per call, deliberately: `auth.token` is resolved per call by
|
|
1830
|
+
* the facade (plan §4.1) and may be a different member's installation token
|
|
1831
|
+
* from one call to the next. Caching one would be caching a credential.
|
|
1832
|
+
*/
|
|
1833
|
+
actionsPort(auth) {
|
|
1834
|
+
return octokitActionsRunsPort(new octokit.Octokit({ auth: auth.token }));
|
|
1835
|
+
}
|
|
1836
|
+
/** The clock, as a seam, so the correlation window is provable without waiting. */
|
|
1837
|
+
now() {
|
|
1838
|
+
return Date.now();
|
|
1839
|
+
}
|
|
1840
|
+
getManifest() {
|
|
1841
|
+
return {
|
|
1842
|
+
id: this.id,
|
|
1843
|
+
name: this.name,
|
|
1844
|
+
version: this.version,
|
|
1845
|
+
description: "Builds the App Work's container image on GitHub-hosted runners, through the workflow Ever Works writes into the repository's tracked branch",
|
|
1846
|
+
category: this.category,
|
|
1847
|
+
capabilities: [...this.capabilities],
|
|
1848
|
+
author: { name: "Ever Works Team" },
|
|
1849
|
+
license: "AGPL-3.0",
|
|
1850
|
+
builtIn: true,
|
|
1851
|
+
autoEnable: true,
|
|
1852
|
+
visibility: "user-only"
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
// IBuildPlugin -----------------------------------------------------------
|
|
1856
|
+
// Every member is declared and none pretends: see the class docstring for the
|
|
1857
|
+
// task each one waits on.
|
|
1858
|
+
/**
|
|
1859
|
+
* APW-05 §4.6 — put the workflow (and the build values) where a Build can use them.
|
|
1860
|
+
*
|
|
1861
|
+
* Four steps, in this order, and the order is the point: nothing is written to a
|
|
1862
|
+
* member's repository until it is known the Build could run at all.
|
|
1863
|
+
*
|
|
1864
|
+
* 1. **Select the runner** (T11). A declared memory the chosen runner cannot
|
|
1865
|
+
* give is `runnerTooSmall`, and it BLOCKS here — before a commit, before a
|
|
1866
|
+
* secret, before anything reaches GitHub. The alternative is writing a
|
|
1867
|
+
* workflow that is guaranteed to be OOM-killed minutes into its first run.
|
|
1868
|
+
* 2. **Generate the file** (T8), with the checks job (T41) when the spec
|
|
1869
|
+
* declares checks and the verify job when the App Work can verify.
|
|
1870
|
+
* 3. **Deliver it** (T9): unchanged, committed, or a pull request for a
|
|
1871
|
+
* repository that is not ours to write into (R-4). A hand-edited file is
|
|
1872
|
+
* `editedByHand` and is never overwritten.
|
|
1873
|
+
* 4. **Sync the build values** (T10), and only then — a secret written for a
|
|
1874
|
+
* workflow that was never delivered is a secret nobody will clean up.
|
|
1875
|
+
*
|
|
1876
|
+
* ## The two failure shapes, kept apart
|
|
1877
|
+
*
|
|
1878
|
+
* A `blocked` result is a Build that will not start and a member who is told
|
|
1879
|
+
* why. A THROW is this plugin failing at its own job. `writeWorkflow`'s own
|
|
1880
|
+
* `'blocked'` state is mapped onto `PrepareRepositoryResult.blocked` rather than
|
|
1881
|
+
* passed through, because that state is the writer's and is not one of the five
|
|
1882
|
+
* the contract's `workflow.state` allows — the writer's docstring says so, and
|
|
1883
|
+
* storing it would put a value in the database that no reader understands.
|
|
1884
|
+
*
|
|
1885
|
+
* ## `contentSha256` is stored only after a matching read-back
|
|
1886
|
+
*
|
|
1887
|
+
* `storedWorkflowSha256` is `null` until the writer has PROVEN the tracked
|
|
1888
|
+
* branch holds the generated bytes (FR-8). Reporting the hash of what we sent,
|
|
1889
|
+
* rather than of what is there, is how a failed write becomes an unnoticed
|
|
1890
|
+
* hand-edit detection failure on the next preparation.
|
|
1891
|
+
*/
|
|
1892
|
+
async prepareRepository(input, auth, writer) {
|
|
1893
|
+
const settings = input.settings ?? {};
|
|
1894
|
+
const selection = selectRunner({
|
|
1895
|
+
visibility: input.repository.visibility,
|
|
1896
|
+
resources: resourcesOf(input.build ?? null),
|
|
1897
|
+
settings
|
|
1898
|
+
});
|
|
1899
|
+
if (!selection.ok) {
|
|
1900
|
+
return {
|
|
1901
|
+
workflow: { state: "unchanged", contentSha256: "" },
|
|
1902
|
+
secretsWritten: [],
|
|
1903
|
+
secretsRemoved: [],
|
|
1904
|
+
buildInputsHash: contracts.computeBuildInputsHash(input.values ?? []),
|
|
1905
|
+
blocked: {
|
|
1906
|
+
reason: selection.blocked.reason,
|
|
1907
|
+
detail: { needed: selection.blocked.needed, max: selection.blocked.max }
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
const content = generateWorkflow({
|
|
1912
|
+
trackedBranch: input.repository.trackedBranch,
|
|
1913
|
+
repository: {
|
|
1914
|
+
owner: input.repository.owner,
|
|
1915
|
+
repo: input.repository.repo,
|
|
1916
|
+
visibility: input.repository.visibility
|
|
1917
|
+
},
|
|
1918
|
+
appSpecHash: input.appSpecHash,
|
|
1919
|
+
build: input.build ?? null,
|
|
1920
|
+
values: input.values ?? [],
|
|
1921
|
+
runner: { label: selection.runner.label, class: selection.runner.runnerClass },
|
|
1922
|
+
settings: {
|
|
1923
|
+
reclaimDisk: settings.reclaimDisk,
|
|
1924
|
+
attestations: settings.attestations,
|
|
1925
|
+
allowBuildValuesOnPullRequests: settings.allowBuildValuesOnPullRequests,
|
|
1926
|
+
verificationPromptedValuesRequireApproval: settings.verificationPromptedValuesRequireApproval
|
|
1927
|
+
},
|
|
1928
|
+
// A Build that has no `build` block cannot be verified in the runner; the
|
|
1929
|
+
// verify job would have nothing to run.
|
|
1930
|
+
verifyEnabled: Boolean(input.build),
|
|
1931
|
+
checks: input.checks ?? []
|
|
1932
|
+
});
|
|
1933
|
+
const written = await writeWorkflow(
|
|
1934
|
+
{
|
|
1935
|
+
repository: {
|
|
1936
|
+
owner: input.repository.owner,
|
|
1937
|
+
repo: input.repository.repo,
|
|
1938
|
+
trackedBranch: input.repository.trackedBranch,
|
|
1939
|
+
createdByAppWork: input.repository.createdByAppWork
|
|
1940
|
+
},
|
|
1941
|
+
content,
|
|
1942
|
+
lastWrittenWorkflowSha256: input.lastWrittenWorkflowSha256,
|
|
1943
|
+
// §3.1b's recorded pull request, echoed back when GitHub reports the head's
|
|
1944
|
+
// pull request still open (ACC-05-02). Never what decides that it is open.
|
|
1945
|
+
pullRequestNumber: input.workflowPullRequestNumber ?? null,
|
|
1946
|
+
pullRequestUrl: input.workflowPullRequestUrl ?? null
|
|
1947
|
+
},
|
|
1948
|
+
writer
|
|
1949
|
+
);
|
|
1950
|
+
if (written.state === "blocked") {
|
|
1951
|
+
return {
|
|
1952
|
+
workflow: { state: "unchanged", contentSha256: written.storedWorkflowSha256 ?? "" },
|
|
1953
|
+
secretsWritten: [],
|
|
1954
|
+
secretsRemoved: [],
|
|
1955
|
+
buildInputsHash: contracts.computeBuildInputsHash(input.values ?? []),
|
|
1956
|
+
blocked: {
|
|
1957
|
+
reason: written.blocked?.reason ?? "workflowWriteFailed",
|
|
1958
|
+
detail: { cause: written.blocked?.detail.cause ?? "unknown" }
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
const sync = createBuildValueSecretSync({ port: this.secretPort(auth, input.repository) });
|
|
1963
|
+
const synced = await sync.syncBuildValues({
|
|
1964
|
+
values: input.values ?? [],
|
|
1965
|
+
previouslyWrittenSecretNames: input.previouslyWrittenSecretNames ?? []
|
|
1966
|
+
});
|
|
1967
|
+
return {
|
|
1968
|
+
workflow: {
|
|
1969
|
+
state: written.state,
|
|
1970
|
+
...written.commitSha ? { commitSha: written.commitSha } : {},
|
|
1971
|
+
...written.pullRequestUrl ? { pullRequestUrl: written.pullRequestUrl } : {},
|
|
1972
|
+
// Only a matching read-back sets this — see the docstring.
|
|
1973
|
+
contentSha256: written.storedWorkflowSha256 ?? ""
|
|
1974
|
+
},
|
|
1975
|
+
secretsWritten: synced.secretsWritten ?? [],
|
|
1976
|
+
secretsRemoved: synced.secretsRemoved ?? [],
|
|
1977
|
+
buildInputsHash: synced.buildInputsHash ?? contracts.computeBuildInputsHash(input.values ?? []),
|
|
1978
|
+
...synced.blocked ? { blocked: synced.blocked } : {}
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* The repository-secret operations T10's sync needs, over Octokit.
|
|
1983
|
+
*
|
|
1984
|
+
* A seam for the same reason {@link actionsPort} is one: the loader constructs
|
|
1985
|
+
* this class with no arguments, so a spec replaces it on the instance.
|
|
1986
|
+
*/
|
|
1987
|
+
secretPort(auth, repository) {
|
|
1988
|
+
const octokit$1 = new octokit.Octokit({ auth: auth.token });
|
|
1989
|
+
const base = { owner: repository.owner, repo: repository.repo };
|
|
1990
|
+
return {
|
|
1991
|
+
getRepoPublicKey: async () => {
|
|
1992
|
+
const { data } = await octokit$1.request("GET /repos/{owner}/{repo}/actions/secrets/public-key", base);
|
|
1993
|
+
return data;
|
|
1994
|
+
},
|
|
1995
|
+
putRepoSecret: async ({ name, encryptedValue, keyId }) => {
|
|
1996
|
+
await octokit$1.request("PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
|
1997
|
+
...base,
|
|
1998
|
+
secret_name: name,
|
|
1999
|
+
encrypted_value: encryptedValue,
|
|
2000
|
+
key_id: keyId
|
|
2001
|
+
});
|
|
2002
|
+
},
|
|
2003
|
+
deleteRepoSecret: async ({ name }) => {
|
|
2004
|
+
await octokit$1.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", {
|
|
2005
|
+
...base,
|
|
2006
|
+
secret_name: name
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* APW-05 T12 — dispatch the generated workflow on the App Work's tracked branch.
|
|
2013
|
+
*
|
|
2014
|
+
* ## `providerRunId` is `null` here, and that is not a failure
|
|
2015
|
+
*
|
|
2016
|
+
* `POST .../dispatches` answers **204 with no body**: GitHub says nothing about
|
|
2017
|
+
* the run it just created. So this method dispatches, records the instant, and
|
|
2018
|
+
* makes ONE correlation attempt; the run is usually not listed that fast, and a
|
|
2019
|
+
* `null` id is the normal answer. `app-build-watch` polls `getBuild`, which
|
|
2020
|
+
* correlates again on every poll. Blocking here until a run appeared would hold
|
|
2021
|
+
* the caller's request open for an arbitrary provider delay.
|
|
2022
|
+
*
|
|
2023
|
+
* ## The branch is the tracked branch, never the caller's ref
|
|
2024
|
+
*
|
|
2025
|
+
* FR-14: a Build may only run for the App Work's tracked branch. `input.ref` is
|
|
2026
|
+
* what to BUILD; `repository.trackedBranch` is where the workflow file lives and
|
|
2027
|
+
* the only `ref` a dispatch is allowed to name. Passing `input.ref` would let a
|
|
2028
|
+
* Build run a workflow file from a branch nobody reviewed.
|
|
2029
|
+
*/
|
|
2030
|
+
async startBuild(input, auth) {
|
|
2031
|
+
const port = this.actionsPort(auth);
|
|
2032
|
+
const repository = {
|
|
2033
|
+
owner: input.repository.owner,
|
|
2034
|
+
repo: input.repository.repo
|
|
2035
|
+
};
|
|
2036
|
+
const inputs = {
|
|
2037
|
+
ew_build_id: input.buildId,
|
|
2038
|
+
ew_sha: input.sha,
|
|
2039
|
+
ew_mode: input.mode
|
|
2040
|
+
};
|
|
2041
|
+
if (input.reuseImageDigest) inputs.ew_reuse_digest = input.reuseImageDigest;
|
|
2042
|
+
if (input.verification?.json) inputs.ew_verify_plan = input.verification.json;
|
|
2043
|
+
const dispatchedAtMs = this.now();
|
|
2044
|
+
await port.dispatchWorkflow({
|
|
2045
|
+
repository,
|
|
2046
|
+
workflowFile: WORKFLOW_FILE_NAME,
|
|
2047
|
+
ref: input.repository.trackedBranch,
|
|
2048
|
+
inputs
|
|
2049
|
+
});
|
|
2050
|
+
const correlated = await correlateDispatchedRun(port, {
|
|
2051
|
+
repository,
|
|
2052
|
+
workflowFile: WORKFLOW_FILE_NAME,
|
|
2053
|
+
buildId: input.buildId,
|
|
2054
|
+
dispatchedAtMs
|
|
2055
|
+
});
|
|
2056
|
+
return {
|
|
2057
|
+
providerRunId: correlated.providerRunId,
|
|
2058
|
+
dispatchedAt: new Date(dispatchedAtMs).toISOString()
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* APW-05 T12 — observe one run.
|
|
2063
|
+
*
|
|
2064
|
+
* Correlates first when the ref carries no `providerRunId`: a Build dispatched
|
|
2065
|
+
* moments ago has none, and the watch job's whole purpose is to find it. `null`
|
|
2066
|
+
* means "no such run" — never "I could not tell", which the contract says is a
|
|
2067
|
+
* throw, and which is why a failed API call is not caught here.
|
|
2068
|
+
*
|
|
2069
|
+
* `redact` is APW-07's redactor for this App Work, and it is USED: every line
|
|
2070
|
+
* of every excerpt goes through it, and through the secret-shape mask after
|
|
2071
|
+
* it, before it leaves this method (FR-38).
|
|
2072
|
+
*/
|
|
2073
|
+
async getBuild(ref, auth, redact) {
|
|
2074
|
+
const port = this.actionsPort(auth);
|
|
2075
|
+
const repository = {
|
|
2076
|
+
owner: ref.repository.owner,
|
|
2077
|
+
repo: ref.repository.repo
|
|
2078
|
+
};
|
|
2079
|
+
const runId = await this.resolveRunId(port, ref, repository);
|
|
2080
|
+
if (runId === null) return null;
|
|
2081
|
+
const run = await port.getWorkflowRun({ repository, runId });
|
|
2082
|
+
if (!run) return null;
|
|
2083
|
+
const jobs = await port.listRunJobs({ repository, runId });
|
|
2084
|
+
const snapshot = observeRun({ run, jobs, mode: "build" });
|
|
2085
|
+
const finished = snapshot.status !== "queued" && snapshot.status !== "running";
|
|
2086
|
+
const artifact = finished ? await readResultArtifact(port, { repository, runId }) : null;
|
|
2087
|
+
const result = artifact?.ok ? artifact.result : null;
|
|
2088
|
+
const failure = snapshot.status === "failed" ? await this.classifyRunFailure({ port, repository, run, jobs, snapshot, result, redact }) : null;
|
|
2089
|
+
const pushLogDigest = result && snapshot.status === "succeeded" && isPushingTrigger(snapshot.trigger) ? await this.readPushLogDigest({ port, repository, jobs, sha: snapshot.commitSha }) : void 0;
|
|
2090
|
+
return {
|
|
2091
|
+
...snapshot,
|
|
2092
|
+
...result ? {
|
|
2093
|
+
// `confirmed: false` without exception. This plugin reads the digest
|
|
2094
|
+
// the member's own CI claimed; confirming it against the registry is
|
|
2095
|
+
// `checkImageAccess` (T14), compared by `AppBuildsService.finalize`,
|
|
2096
|
+
// and a plugin that marked its own input confirmed would make plan
|
|
2097
|
+
// §4.8's "never believed" untrue. `pushLogDigest` is evidence the
|
|
2098
|
+
// agent weighs only when the registry cannot be read; it confirms
|
|
2099
|
+
// nothing here either.
|
|
2100
|
+
image: {
|
|
2101
|
+
repository: result.imageRepository ?? "",
|
|
2102
|
+
digest: result.digest,
|
|
2103
|
+
tags: [...result.tags ?? []],
|
|
2104
|
+
confirmed: false,
|
|
2105
|
+
...pushLogDigest ? { pushLogDigest } : {}
|
|
2106
|
+
},
|
|
2107
|
+
...result.secretCheck ? { secretCheck: result.secretCheck } : {}
|
|
2108
|
+
} : {},
|
|
2109
|
+
...failure ? { failure } : {}
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* APW-05 T13 — why a failed run failed, as `BuildSnapshot.failure`.
|
|
2114
|
+
*
|
|
2115
|
+
* ## Which job's log
|
|
2116
|
+
*
|
|
2117
|
+
* The `build` job decides the Build's status (`run-observer.ts`, R-9), so when
|
|
2118
|
+
* it failed it is the one worth reading. Only when it did NOT fail — a Build
|
|
2119
|
+
* the run marked failed for some other reason — does any other failing job
|
|
2120
|
+
* stand in. A failed `Ever Works check:` job is deliberately last: it never
|
|
2121
|
+
* flips a Build to `failed`, so if it is the only failure the run's own
|
|
2122
|
+
* conclusion is what actually needs explaining.
|
|
2123
|
+
*
|
|
2124
|
+
* No failing job at all is not an error: `jobCount` then carries the run into
|
|
2125
|
+
* the classifier's `workflowInvalid` row, which is exactly what a run that
|
|
2126
|
+
* produced no jobs is.
|
|
2127
|
+
*
|
|
2128
|
+
* ## Why the artifact's smoke rows are passed in
|
|
2129
|
+
*
|
|
2130
|
+
* `verificationFailed` is a class the LOG cannot show: the smoke rows live in
|
|
2131
|
+
* the result artifact, which `getBuild` has already read by this point. Passing
|
|
2132
|
+
* them costs nothing and is the difference between "your build failed" and
|
|
2133
|
+
* "your app built, and then did not answer".
|
|
2134
|
+
*/
|
|
2135
|
+
async classifyRunFailure(input) {
|
|
2136
|
+
const { port, repository, run, jobs, snapshot, result, redact } = input;
|
|
2137
|
+
const failed = (job) => Boolean(job.conclusion) && job.conclusion !== "success";
|
|
2138
|
+
const buildJob2 = jobs.find((job) => job.name === BUILD_JOB_NAME);
|
|
2139
|
+
const failingJob = buildJob2 && failed(buildJob2) ? buildJob2 : jobs.find(failed);
|
|
2140
|
+
const log = failingJob ? await readJobLogTail(port, { repository, jobId: failingJob.id }) : "";
|
|
2141
|
+
const smoke = result?.smoke ?? null;
|
|
2142
|
+
return classifyFailure(
|
|
2143
|
+
{
|
|
2144
|
+
log,
|
|
2145
|
+
jobConclusion: failingJob?.conclusion ?? null,
|
|
2146
|
+
runConclusion: run.conclusion ?? null,
|
|
2147
|
+
jobCount: jobs.length,
|
|
2148
|
+
failingStepName: failingStep(failingJob)?.name ?? null,
|
|
2149
|
+
...typeof snapshot.billableMinutes === "number" ? { minutes: snapshot.billableMinutes } : {},
|
|
2150
|
+
...smoke ? { verification: { failed: smoke.filter((row) => !row.passed).length, total: smoke.length } } : {}
|
|
2151
|
+
},
|
|
2152
|
+
redact
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* APW-05 T14 remainder — the digest the build job's `Push` step logged for
|
|
2157
|
+
* this Build's `sha-<sha>` tag, or `undefined`.
|
|
2158
|
+
*
|
|
2159
|
+
* The BUILD job's log, because that is the job holding the Push step; read as
|
|
2160
|
+
* the same bounded tail T13 reads (the Push step is near the end of the job,
|
|
2161
|
+
* followed only by the result write and the upload). An unreadable or expired
|
|
2162
|
+
* log is `''` from `readJobLogTail`, so this never fails an observation — it
|
|
2163
|
+
* only costs the fallback. Which lines count is `runs/push-digest.ts`'s rule.
|
|
2164
|
+
*/
|
|
2165
|
+
async readPushLogDigest(input) {
|
|
2166
|
+
const buildJob2 = input.jobs.find((job) => job.name === BUILD_JOB_NAME);
|
|
2167
|
+
if (!buildJob2) return void 0;
|
|
2168
|
+
const log = await readJobLogTail(input.port, { repository: input.repository, jobId: buildJob2.id });
|
|
2169
|
+
return extractPushLogDigest(log, input.sha);
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* The run this ref addresses: the one it names, or the one correlation finds.
|
|
2173
|
+
*
|
|
2174
|
+
* Shared by `getBuild`, `cancelBuild` and `getLogsUrl` so all three agree about
|
|
2175
|
+
* which run a ref means — a cancel that addressed a different run from the
|
|
2176
|
+
* observation would stop the wrong Build.
|
|
2177
|
+
*/
|
|
2178
|
+
async resolveRunId(port, ref, repository) {
|
|
2179
|
+
if (ref.providerRunId) {
|
|
2180
|
+
const parsed = Number(ref.providerRunId);
|
|
2181
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2182
|
+
}
|
|
2183
|
+
const dispatchedAtMs = ref.dispatchedAt ? Date.parse(ref.dispatchedAt) : this.now();
|
|
2184
|
+
const correlated = await correlateDispatchedRun(port, {
|
|
2185
|
+
repository,
|
|
2186
|
+
workflowFile: WORKFLOW_FILE_NAME,
|
|
2187
|
+
buildId: ref.buildId,
|
|
2188
|
+
dispatchedAtMs: Number.isNaN(dispatchedAtMs) ? this.now() : dispatchedAtMs
|
|
2189
|
+
});
|
|
2190
|
+
return correlated.providerRunId === null ? null : Number(correlated.providerRunId);
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* APW-05 T12 — stop the run.
|
|
2194
|
+
*
|
|
2195
|
+
* A ref that resolves to no run is a no-op rather than a throw: the caller asked
|
|
2196
|
+
* for the Build to stop, and a Build with no run has stopped. The port swallows
|
|
2197
|
+
* GitHub's 409 ("not cancellable") for the same reason — a finished run is not a
|
|
2198
|
+
* failed cancel.
|
|
2199
|
+
*/
|
|
2200
|
+
async cancelBuild(ref, auth) {
|
|
2201
|
+
const port = this.actionsPort(auth);
|
|
2202
|
+
const repository = {
|
|
2203
|
+
owner: ref.repository.owner,
|
|
2204
|
+
repo: ref.repository.repo
|
|
2205
|
+
};
|
|
2206
|
+
const runId = await this.resolveRunId(port, ref, repository);
|
|
2207
|
+
if (runId === null) return;
|
|
2208
|
+
await port.cancelWorkflowRun({ repository, runId });
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* APW-05 T14 — can this installation read the image, and is its pull token the
|
|
2212
|
+
* right shape?
|
|
2213
|
+
*
|
|
2214
|
+
* Declared now that it does something. It stayed undeclared while it did not,
|
|
2215
|
+
* because the contract makes it optional and a caller materialises the member
|
|
2216
|
+
* before calling it — so a throwing placeholder would have told a reader the
|
|
2217
|
+
* capability was wired when it was not.
|
|
2218
|
+
*
|
|
2219
|
+
* This is the **confirming** half of plan §4.8's "only ever confirmed, never
|
|
2220
|
+
* believed". `getBuild` reports the digest the member's own CI claimed with
|
|
2221
|
+
* `confirmed: false`, always; comparing it against the digest this method reads
|
|
2222
|
+
* from the registry is what makes it true, and that comparison belongs to the
|
|
2223
|
+
* caller — `AppBuildsService.finalize` — which is the only party holding both
|
|
2224
|
+
* the Build row and the installation's `pullToken` setting (`getBuild(ref, auth)`
|
|
2225
|
+
* is given neither).
|
|
2226
|
+
*
|
|
2227
|
+
* The registry is read through GHCR's own token exchange (`registry/ghcr-access.ts`):
|
|
2228
|
+
* a bare manifest `HEAD` is 401 for every image on GHCR, public or not, so a
|
|
2229
|
+
* public image is only ever seen as public through the anonymous registry token.
|
|
2230
|
+
* The pull token reaches `ghcr.io/token` (as Basic) and `api.github.com/user`,
|
|
2231
|
+
* and nothing else.
|
|
2232
|
+
*/
|
|
2233
|
+
async checkImageAccess(input) {
|
|
2234
|
+
return checkImageAccess(input, this.registryFetch());
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* The registry/API HTTP seam.
|
|
2238
|
+
*
|
|
2239
|
+
* `redirect: 'manual'` is set by `ghcr-access.ts` on every request and passed
|
|
2240
|
+
* straight through here: a 3xx followed with the `Authorization` header still
|
|
2241
|
+
* attached is how a pull token reaches a host that is neither `api.github.com`
|
|
2242
|
+
* nor `ghcr.io`, and that module's spec watches every call for exactly that.
|
|
2243
|
+
*/
|
|
2244
|
+
registryFetch() {
|
|
2245
|
+
return (url, init) => fetch(url, init);
|
|
2246
|
+
}
|
|
2247
|
+
/** APW-05 T12 — the run's own page, which is where a member reads its logs. */
|
|
2248
|
+
async getLogsUrl(ref, auth) {
|
|
2249
|
+
const port = this.actionsPort(auth);
|
|
2250
|
+
const repository = {
|
|
2251
|
+
owner: ref.repository.owner,
|
|
2252
|
+
repo: ref.repository.repo
|
|
2253
|
+
};
|
|
2254
|
+
const runId = await this.resolveRunId(port, ref, repository);
|
|
2255
|
+
if (runId === null) return null;
|
|
2256
|
+
const run = await port.getWorkflowRun({ repository, runId });
|
|
2257
|
+
return run?.html_url ?? null;
|
|
2258
|
+
}
|
|
2259
|
+
};
|
|
2260
|
+
function resourcesOf(build) {
|
|
2261
|
+
const resources = build?.resources;
|
|
2262
|
+
if (!resources) return void 0;
|
|
2263
|
+
return {
|
|
2264
|
+
...typeof resources.memoryGiB === "number" ? { memoryGiB: resources.memoryGiB } : {},
|
|
2265
|
+
...typeof resources.cpu === "number" ? { vcpu: resources.cpu } : {}
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
function isPushingTrigger(trigger) {
|
|
2269
|
+
return contracts.APP_BUILD_DEPLOYABLE_TRIGGERS.includes(trigger);
|
|
2270
|
+
}
|
|
2271
|
+
var WORKFLOW_FILE_NAME = contracts.APP_BUILD_WORKFLOW_PATH.split("/").pop();
|
|
2272
|
+
function notImplemented(member, owner) {
|
|
2273
|
+
return new Error(`github-actions-build: ${member} is not implemented yet \u2014 ${owner} owns it.`);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
exports.ACTION_PINS = ACTION_PINS;
|
|
2277
|
+
exports.ACTION_PIN_SHA_PATTERN = ACTION_PIN_SHA_PATTERN;
|
|
2278
|
+
exports.BRANCH_PROTECTION_RULE_TYPES = BRANCH_PROTECTION_RULE_TYPES;
|
|
2279
|
+
exports.BRANCH_RULES_MAX_PAGES = BRANCH_RULES_MAX_PAGES;
|
|
2280
|
+
exports.BRANCH_RULES_PAGE_SIZE = BRANCH_RULES_PAGE_SIZE;
|
|
2281
|
+
exports.CANONICAL_INPUT_FIELDS = CANONICAL_INPUT_FIELDS;
|
|
2282
|
+
exports.EMBEDDED_VERIFY_RUNNER_SCRIPT = EMBEDDED_VERIFY_RUNNER_SCRIPT;
|
|
2283
|
+
exports.GENERATOR_RESERVED_LITERALS = GENERATOR_RESERVED_LITERALS;
|
|
2284
|
+
exports.GITHUB_ACTIONS_BUILD_PLATFORM_MANAGED_KEYS = GITHUB_ACTIONS_BUILD_PLATFORM_MANAGED_KEYS;
|
|
2285
|
+
exports.GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS = GITHUB_ACTIONS_BUILD_SETTING_DEFAULTS;
|
|
2286
|
+
exports.GITHUB_ACTIONS_BUILD_SETTING_KEYS = GITHUB_ACTIONS_BUILD_SETTING_KEYS;
|
|
2287
|
+
exports.GITHUB_SECRET_NAME_PATTERN = GITHUB_SECRET_NAME_PATTERN;
|
|
2288
|
+
exports.GitHubActionsBuildPlugin = GitHubActionsBuildPlugin;
|
|
2289
|
+
exports.LARGER_RUNNER_LABEL_PATTERN = LARGER_RUNNER_LABEL_PATTERN;
|
|
2290
|
+
exports.LARGER_RUNNER_MEMORY_MAX_GIB = LARGER_RUNNER_MEMORY_MAX_GIB;
|
|
2291
|
+
exports.LARGER_RUNNER_MEMORY_MIN_GIB = LARGER_RUNNER_MEMORY_MIN_GIB;
|
|
2292
|
+
exports.LARGER_RUNNER_VCPU_MAX = LARGER_RUNNER_VCPU_MAX;
|
|
2293
|
+
exports.LARGER_RUNNER_VCPU_MIN = LARGER_RUNNER_VCPU_MIN;
|
|
2294
|
+
exports.PREPARATION_STATE_KEYS = PREPARATION_STATE_KEYS;
|
|
2295
|
+
exports.WORKFLOW_COMMIT_MESSAGE_ADD = WORKFLOW_COMMIT_MESSAGE_ADD;
|
|
2296
|
+
exports.WORKFLOW_COMMIT_MESSAGE_UPDATE = WORKFLOW_COMMIT_MESSAGE_UPDATE;
|
|
2297
|
+
exports.WORKFLOW_PULL_REQUEST_BODY = WORKFLOW_PULL_REQUEST_BODY;
|
|
2298
|
+
exports.WORKFLOW_PULL_REQUEST_TITLE = WORKFLOW_PULL_REQUEST_TITLE;
|
|
2299
|
+
exports.WORKFLOW_READ_BACK_ATTEMPTS = WORKFLOW_READ_BACK_ATTEMPTS;
|
|
2300
|
+
exports.WORKFLOW_WRITE_MAX_ATTEMPTS = WORKFLOW_WRITE_MAX_ATTEMPTS;
|
|
2301
|
+
exports.actionPin = actionPin;
|
|
2302
|
+
exports.actionPinReference = actionPinReference;
|
|
2303
|
+
exports.branchSlug = branchSlug;
|
|
2304
|
+
exports.buildImageRepository = buildImageRepository;
|
|
2305
|
+
exports.buildSecretName = buildSecretName;
|
|
2306
|
+
exports.canonicalActionPins = canonicalActionPins;
|
|
2307
|
+
exports.canonicalCheck = canonicalCheck;
|
|
2308
|
+
exports.canonicalInputsFor = canonicalInputsFor;
|
|
2309
|
+
exports.canonicalWorkflowJson = canonicalWorkflowJson;
|
|
2310
|
+
exports.canonicaliseWorkflowInputs = canonicaliseWorkflowInputs;
|
|
2311
|
+
exports.checkTimeoutMinutes = checkTimeoutMinutes;
|
|
2312
|
+
exports.computeWorkflowInputsHash = computeWorkflowInputsHash;
|
|
2313
|
+
exports.createBuildValueSecretSync = createBuildValueSecretSync;
|
|
2314
|
+
exports.default = GitHubActionsBuildPlugin;
|
|
2315
|
+
exports.fromEnvNames = fromEnvNames;
|
|
2316
|
+
exports.generateWorkflow = generateWorkflow;
|
|
2317
|
+
exports.gitHubActionsBuildSettingsSchema = gitHubActionsBuildSettingsSchema;
|
|
2318
|
+
exports.isBranchProtected = isBranchProtected;
|
|
2319
|
+
exports.isSecretLimitError = isSecretLimitError;
|
|
2320
|
+
exports.isValidGitHubSecretName = isValidGitHubSecretName;
|
|
2321
|
+
exports.loadSodium = loadSodium;
|
|
2322
|
+
exports.normaliseBuildBlock = normaliseBuildBlock;
|
|
2323
|
+
exports.notImplemented = notImplemented;
|
|
2324
|
+
exports.repositoryWriteErrorCode = repositoryWriteErrorCode;
|
|
2325
|
+
exports.sealSecretValue = sealSecretValue;
|
|
2326
|
+
exports.secretNamesFor = secretNamesFor;
|
|
2327
|
+
exports.workflowHeader = workflowHeader;
|
|
2328
|
+
exports.writeWorkflow = writeWorkflow;
|
|
2329
|
+
exports.yamlString = yamlString;
|