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