@ever-works/github-actions-build-plugin 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -42,6 +42,31 @@ function octokitActionsRunsPort(octokit) {
42
42
  );
43
43
  return asArray(data, "workflow_runs");
44
44
  },
45
+ async listWorkflowRunsConditional({ repository, workflowFile, perPage, etag }) {
46
+ try {
47
+ const response = await octokit.request(
48
+ "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs",
49
+ {
50
+ owner: repository.owner,
51
+ repo: repository.repo,
52
+ workflow_id: workflowFile,
53
+ per_page: perPage,
54
+ ...etag ? { headers: { "if-none-match": etag } } : {}
55
+ }
56
+ );
57
+ const next = response.headers?.etag;
58
+ return {
59
+ notModified: false,
60
+ ...typeof next === "string" ? { etag: next } : {},
61
+ runs: asArray(response.data, "workflow_runs")
62
+ };
63
+ } catch (error) {
64
+ if (error.status === 304) {
65
+ return { notModified: true, ...etag ? { etag } : {}, runs: [] };
66
+ }
67
+ throw error;
68
+ }
69
+ },
45
70
  async getWorkflowRun({ repository, runId }) {
46
71
  try {
47
72
  const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
@@ -218,7 +243,39 @@ function observeRun({ run, jobs, mode }) {
218
243
  }
219
244
  var RESULT_ARTIFACT_MAX_ZIP_BYTES = 65536;
220
245
  var RESULT_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
221
- var ALLOWED_KEYS = /* @__PURE__ */ new Set(["digest", "imageRepository", "tags", "secretCheck", "smoke"]);
246
+ var RESULT_SCHEMA_NAME = "ever-works-build-result";
247
+ var RESULT_SCHEMA_VERSION = 1;
248
+ var ALLOWED_KEYS = /* @__PURE__ */ new Set([
249
+ // T12's first shape.
250
+ "digest",
251
+ "imageRepository",
252
+ "tags",
253
+ "secretCheck",
254
+ "smoke",
255
+ // The generator's versioned shape (`resultStep` in workflow/generator.ts).
256
+ "schema",
257
+ "version",
258
+ "buildId",
259
+ "sha",
260
+ "verification",
261
+ "reusedDigest",
262
+ "localBuild"
263
+ ]);
264
+ var VERIFICATION_KEYS = /* @__PURE__ */ new Set(["jobs", "smoke"]);
265
+ var COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/i;
266
+ function normaliseResultDigest(raw) {
267
+ if (typeof raw !== "string") return "";
268
+ const trimmed = raw.trim();
269
+ const at = trimmed.lastIndexOf("@");
270
+ return at >= 0 ? trimmed.slice(at + 1) : trimmed;
271
+ }
272
+ function passRows(value) {
273
+ if (!Array.isArray(value)) return [];
274
+ return value.filter((row) => typeof row === "object" && row !== null).map((row) => ({
275
+ name: String(row.name ?? ""),
276
+ passed: row.passed === true || row.conclusion === "success"
277
+ }));
278
+ }
222
279
  function parseResultArtifact(value) {
223
280
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
224
281
  return { ok: false, refusal: "malformedJson" };
@@ -228,22 +285,55 @@ function parseResultArtifact(value) {
228
285
  if (unknown.length > 0) {
229
286
  return { ok: false, refusal: "unknownKeys" };
230
287
  }
231
- const digest = typeof bag.digest === "string" ? bag.digest.trim() : "";
232
- if (!RESULT_DIGEST_PATTERN.test(digest)) {
288
+ const versioned = "schema" in bag || "version" in bag;
289
+ if (versioned && (bag.schema !== RESULT_SCHEMA_NAME || bag.version !== RESULT_SCHEMA_VERSION)) {
290
+ return { ok: false, refusal: "unsupportedSchema" };
291
+ }
292
+ let verification = null;
293
+ if (bag.verification !== void 0) {
294
+ if (typeof bag.verification !== "object" || bag.verification === null || Array.isArray(bag.verification)) {
295
+ return { ok: false, refusal: "malformedJson" };
296
+ }
297
+ verification = bag.verification;
298
+ if (Object.keys(verification).some((key) => !VERIFICATION_KEYS.has(key))) {
299
+ return { ok: false, refusal: "unknownKeys" };
300
+ }
301
+ }
302
+ const digest = normaliseResultDigest(bag.digest);
303
+ if (digest.length > 0 || !versioned) {
304
+ if (!RESULT_DIGEST_PATTERN.test(digest)) {
305
+ return { ok: false, refusal: "badDigest" };
306
+ }
307
+ }
308
+ const reusedDigest = normaliseResultDigest(bag.reusedDigest);
309
+ if (reusedDigest.length > 0 && !RESULT_DIGEST_PATTERN.test(reusedDigest)) {
233
310
  return { ok: false, refusal: "badDigest" };
234
311
  }
235
- const result = { digest };
312
+ const result = {};
313
+ if (digest.length > 0) result.digest = digest;
314
+ if (versioned) result.schemaVersion = RESULT_SCHEMA_VERSION;
236
315
  if (typeof bag.imageRepository === "string" && bag.imageRepository.trim().length > 0) {
237
316
  result.imageRepository = bag.imageRepository.trim();
238
317
  }
239
318
  if (Array.isArray(bag.tags)) {
240
- result.tags = bag.tags.filter((tag) => typeof tag === "string");
319
+ result.tags = bag.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
241
320
  }
242
321
  if (bag.secretCheck === "passed" || bag.secretCheck === "failed" || bag.secretCheck === "not_needed") {
243
322
  result.secretCheck = bag.secretCheck;
244
323
  }
245
- if (Array.isArray(bag.smoke)) {
246
- result.smoke = bag.smoke.filter((row) => typeof row === "object" && row !== null).map((row) => ({ name: String(row.name ?? ""), passed: row.passed === true }));
324
+ if (typeof bag.buildId === "string" && bag.buildId.trim().length > 0) {
325
+ result.buildId = bag.buildId.trim();
326
+ }
327
+ if (typeof bag.sha === "string" && COMMIT_SHA_PATTERN.test(bag.sha.trim())) {
328
+ result.sha = bag.sha.trim().toLowerCase();
329
+ }
330
+ if (reusedDigest.length > 0) result.reusedDigest = reusedDigest;
331
+ if (typeof bag.localBuild === "boolean") result.localBuild = bag.localBuild;
332
+ if (Array.isArray(bag.smoke) || Array.isArray(verification?.smoke)) {
333
+ result.smoke = [...passRows(bag.smoke), ...passRows(verification?.smoke)];
334
+ }
335
+ if (Array.isArray(verification?.jobs)) {
336
+ result.verificationJobs = passRows(verification?.jobs);
247
337
  }
248
338
  return { ok: true, result };
249
339
  }
@@ -285,7 +375,11 @@ async function readResultArtifact(port, input) {
285
375
  } catch {
286
376
  return { ok: false, refusal: "malformedJson" };
287
377
  }
288
- return parseResultArtifact(decoded);
378
+ const parsed = parseResultArtifact(decoded);
379
+ if (parsed.ok && input.expectBuildId && parsed.result.buildId && parsed.result.buildId !== input.expectBuildId) {
380
+ return { ok: false, refusal: "buildMismatch" };
381
+ }
382
+ return parsed;
289
383
  }
290
384
 
291
385
  // src/runs/failure-classifier.ts
@@ -1291,7 +1385,7 @@ function resultStep(job, secretCheckPresent) {
1291
1385
  `jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
1292
1386
  ` --arg imageRepository "$EW_IMAGE" --arg digest "$digest" --arg secretCheck "$secret_check" \\`,
1293
1387
  ` --arg tag "$EW_IMAGE:sha-$EW_SHA" \\`,
1294
- ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
1388
+ ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,`,
1295
1389
  ` digest:$digest,tags:[$tag],secretCheck:$secretCheck,verification:{jobs:[],smoke:[]}}' > ${contracts.APP_BUILD_RESULT_ARTIFACT_FILE}`
1296
1390
  ] : [
1297
1391
  "set -euo pipefail",
@@ -1301,7 +1395,7 @@ function resultStep(job, secretCheckPresent) {
1301
1395
  `jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
1302
1396
  ` --arg imageRepository "$EW_IMAGE" --arg reusedDigest "$reused_digest" \\`,
1303
1397
  ` --argjson localBuild "$local_build" \\`,
1304
- ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
1398
+ ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,`,
1305
1399
  ` reusedDigest:$reusedDigest,localBuild:$localBuild,verification:{jobs:[],smoke:[]}}' > ${contracts.APP_BUILD_RESULT_ARTIFACT_FILE}`
1306
1400
  ];
1307
1401
  return [" - name: Write result", " if: always()", " run: |", ...scalarBlock(script, 10)];
@@ -1375,7 +1469,8 @@ async function readBranchHead(writer, branch) {
1375
1469
  function repositoryWriteErrorCode(error) {
1376
1470
  const candidate = error;
1377
1471
  for (const value of [candidate?.code, candidate?.reason]) {
1378
- if (value === "nonFastForward" || value === "refRejectedByRule" || value === "pullRequestExists") return value;
1472
+ if (value === "nonFastForward" || value === "refRejectedByRule" || value === "pullRequestExists" || value === "workflowPermissionMissing")
1473
+ return value;
1379
1474
  }
1380
1475
  return null;
1381
1476
  }
@@ -1417,14 +1512,36 @@ async function writeWorkflow(input, writer, protection) {
1417
1512
  const removal = input.content === "";
1418
1513
  if (removal) {
1419
1514
  if (!existsOnTrackedBranch) return { state: "unchanged", storedWorkflowSha256: null };
1420
- return pullRequestDelivery(input, writer, path, contentSha256, handEdited, REMOVAL_COPY);
1515
+ return withWorkflowPermission(
1516
+ () => pullRequestDelivery(input, writer, path, contentSha256, handEdited, REMOVAL_COPY)
1517
+ );
1421
1518
  }
1422
1519
  const verdict = createdByAppWork && !handEdited && protection ? await isBranchProtected(protection, { owner, repo, branch: trackedBranch }) : { protected: false};
1423
1520
  const pullRequestPath = !createdByAppWork || handEdited || verdict.protected;
1424
1521
  if (pullRequestPath) {
1425
- return pullRequestDelivery(input, writer, path, contentSha256, handEdited);
1522
+ return withWorkflowPermission(() => pullRequestDelivery(input, writer, path, contentSha256, handEdited));
1523
+ }
1524
+ return withWorkflowPermission(() => directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch));
1525
+ }
1526
+ async function withWorkflowPermission(deliver) {
1527
+ try {
1528
+ return await deliver();
1529
+ } catch (error) {
1530
+ if (repositoryWriteErrorCode(error) !== "workflowPermissionMissing") throw error;
1531
+ const facts = error;
1532
+ return {
1533
+ state: "blocked",
1534
+ storedWorkflowSha256: null,
1535
+ blocked: {
1536
+ reason: "workflowPermissionMissing",
1537
+ detail: {
1538
+ cause: "workflowPermissionMissing",
1539
+ credential: typeof facts.credential === "string" ? facts.credential : "unknown",
1540
+ missing: typeof facts.missing === "string" ? facts.missing : "workflow"
1541
+ }
1542
+ }
1543
+ };
1426
1544
  }
1427
- return directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch);
1428
1545
  }
1429
1546
  async function directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch) {
1430
1547
  const { trackedBranch } = input.repository;
@@ -1669,6 +1786,234 @@ function createBuildValueSecretSync(dependencies) {
1669
1786
  };
1670
1787
  }
1671
1788
 
1789
+ // src/repo/octokit-repository-writer.ts
1790
+ var WORKFLOWS_DIRECTORY = ".github/workflows/";
1791
+ var RepositoryWriteError = class extends Error {
1792
+ constructor(code, message, cause, credential, missing) {
1793
+ super(message);
1794
+ this.code = code;
1795
+ this.cause = cause;
1796
+ this.credential = credential;
1797
+ this.missing = missing;
1798
+ this.name = "RepositoryWriteError";
1799
+ }
1800
+ };
1801
+ function workflowPermissionMissingError(credential, cause) {
1802
+ const app = credential === "githubApp";
1803
+ return new RepositoryWriteError(
1804
+ "workflowPermissionMissing",
1805
+ app ? 'The GitHub App installation cannot write workflow files: grant it "Workflows: Read and write".' : 'The GitHub connection cannot write workflow files: reconnect GitHub and grant the "workflow" scope.',
1806
+ cause,
1807
+ credential,
1808
+ app ? "workflows:write" : "workflow"
1809
+ );
1810
+ }
1811
+ var FULL_SHA2 = /^[0-9a-f]{40}$/i;
1812
+ function statusOf(error) {
1813
+ const status = error?.status;
1814
+ return typeof status === "number" ? status : void 0;
1815
+ }
1816
+ function messageOf(error) {
1817
+ const candidate = error;
1818
+ const parts = [];
1819
+ if (typeof candidate?.message === "string") parts.push(candidate.message);
1820
+ const data = candidate?.response?.data;
1821
+ if (typeof data?.message === "string") parts.push(data.message);
1822
+ if (Array.isArray(data?.errors)) {
1823
+ for (const entry of data.errors) {
1824
+ const message = entry?.message;
1825
+ if (typeof message === "string") parts.push(message);
1826
+ }
1827
+ }
1828
+ return parts.join(" | ");
1829
+ }
1830
+ var RULE_REFUSAL = /protected branch|ruleset|rule violation|required status|required review|push declined|cannot be updated/i;
1831
+ var WORKFLOW_REFUSAL = /workflow[^|]*\b(scope|permission)|\b(scope|permission)[^|]*workflow/i;
1832
+ var INTEGRATION_REFUSAL = /resource not accessible by integration/i;
1833
+ function scopesFromHeader(headers) {
1834
+ const header = headers?.["x-oauth-scopes"];
1835
+ if (typeof header !== "string") return null;
1836
+ return header.split(",").map((scope) => scope.trim()).filter(Boolean);
1837
+ }
1838
+ function octokitRepositoryWriter(octokit, repository, credential = {}) {
1839
+ const base = { owner: repository.owner, repo: repository.repo };
1840
+ const kind = credential.credential ?? "unknown";
1841
+ async function headOf(ref) {
1842
+ if (FULL_SHA2.test(ref)) return ref;
1843
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", {
1844
+ ...base,
1845
+ ref: `heads/${ref}`
1846
+ });
1847
+ const sha = data?.object?.sha;
1848
+ if (typeof sha !== "string") throw new Error(`github-actions-build: no head commit for ${ref}`);
1849
+ return sha;
1850
+ }
1851
+ async function assertCanWriteWorkflows() {
1852
+ if (kind === "githubApp") return;
1853
+ let scopes = credential.scopes ?? null;
1854
+ if (!scopes) {
1855
+ try {
1856
+ scopes = scopesFromHeader((await octokit.request("GET /user")).headers);
1857
+ } catch {
1858
+ scopes = null;
1859
+ }
1860
+ }
1861
+ if (scopes && !scopes.includes("workflow")) {
1862
+ throw workflowPermissionMissingError(kind === "pat" ? "pat" : "oauth");
1863
+ }
1864
+ }
1865
+ function translateWorkflowRefusal(error, touchesWorkflows) {
1866
+ if (!touchesWorkflows || error instanceof RepositoryWriteError) return error;
1867
+ const message = messageOf(error);
1868
+ if (WORKFLOW_REFUSAL.test(message)) {
1869
+ return workflowPermissionMissingError(/github app|integration/i.test(message) ? "githubApp" : kind, error);
1870
+ }
1871
+ return error;
1872
+ }
1873
+ async function commit(input, touchesWorkflows) {
1874
+ const { data: baseCommit } = await octokit.request("GET /repos/{owner}/{repo}/git/commits/{commit_sha}", {
1875
+ ...base,
1876
+ commit_sha: input.baseSha
1877
+ });
1878
+ const baseTree = baseCommit?.tree?.sha;
1879
+ if (typeof baseTree !== "string") {
1880
+ throw new Error(`github-actions-build: commit ${input.baseSha} has no tree`);
1881
+ }
1882
+ const tree = [];
1883
+ for (const file of input.files) {
1884
+ const { data: blob } = await octokit.request("POST /repos/{owner}/{repo}/git/blobs", {
1885
+ ...base,
1886
+ content: file.content,
1887
+ encoding: file.encoding === "base64" ? "base64" : "utf-8"
1888
+ });
1889
+ const sha = blob?.sha;
1890
+ if (typeof sha !== "string") throw new Error(`github-actions-build: no blob sha for ${file.path}`);
1891
+ tree.push({ path: file.path, mode: "100644", type: "blob", sha });
1892
+ }
1893
+ const { data: newTree } = await octokit.request("POST /repos/{owner}/{repo}/git/trees", {
1894
+ ...base,
1895
+ base_tree: baseTree,
1896
+ tree
1897
+ });
1898
+ const { data: created } = await octokit.request("POST /repos/{owner}/{repo}/git/commits", {
1899
+ ...base,
1900
+ message: input.message,
1901
+ tree: newTree.sha,
1902
+ parents: [input.baseSha]
1903
+ });
1904
+ const commitSha = created.sha;
1905
+ try {
1906
+ await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
1907
+ ...base,
1908
+ ref: `heads/${input.branch}`,
1909
+ sha: commitSha,
1910
+ // Never a force-push: a moved branch is `nonFastForward`.
1911
+ force: false
1912
+ });
1913
+ } catch (error) {
1914
+ const status = statusOf(error);
1915
+ const message = messageOf(error);
1916
+ if (status === 422 && /fast[ -]?forward/i.test(message)) {
1917
+ throw new RepositoryWriteError(
1918
+ "nonFastForward",
1919
+ `${input.branch} moved; retry from its new head`,
1920
+ error
1921
+ );
1922
+ }
1923
+ if ((status === 409 || status === 422 || status === 403) && RULE_REFUSAL.test(message)) {
1924
+ throw new RepositoryWriteError("refRejectedByRule", `${input.branch} refused the update`, error);
1925
+ }
1926
+ if (touchesWorkflows && status === 403 && INTEGRATION_REFUSAL.test(message)) {
1927
+ throw workflowPermissionMissingError("githubApp", error);
1928
+ }
1929
+ throw error;
1930
+ }
1931
+ return { commitSha };
1932
+ }
1933
+ return {
1934
+ async getFileContent(path, ref) {
1935
+ try {
1936
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", {
1937
+ ...base,
1938
+ path,
1939
+ ...ref ?? repository.trackedBranch ? { ref: ref ?? repository.trackedBranch } : {}
1940
+ });
1941
+ const file = data;
1942
+ if (Array.isArray(data) || file?.type !== "file" || typeof file.content !== "string") return null;
1943
+ return {
1944
+ content: file.content,
1945
+ encoding: typeof file.encoding === "string" ? file.encoding : "base64"
1946
+ };
1947
+ } catch (error) {
1948
+ if (statusOf(error) === 404) return null;
1949
+ throw error;
1950
+ }
1951
+ },
1952
+ async commitFiles(input) {
1953
+ const touchesWorkflows = input.files.some((file) => file.path.startsWith(WORKFLOWS_DIRECTORY));
1954
+ if (touchesWorkflows) await assertCanWriteWorkflows();
1955
+ try {
1956
+ return await commit(input, touchesWorkflows);
1957
+ } catch (error) {
1958
+ throw translateWorkflowRefusal(error, touchesWorkflows);
1959
+ }
1960
+ },
1961
+ async createBranch(name, fromRef) {
1962
+ try {
1963
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", {
1964
+ ...base,
1965
+ branch: name
1966
+ });
1967
+ const branch = data;
1968
+ return {
1969
+ name: branch.name ?? name,
1970
+ commit: branch.commit?.sha ?? "",
1971
+ isDefault: false,
1972
+ isProtected: branch.protected === true
1973
+ };
1974
+ } catch (error) {
1975
+ if (statusOf(error) !== 404) throw error;
1976
+ }
1977
+ const sha = await headOf(fromRef);
1978
+ await octokit.request("POST /repos/{owner}/{repo}/git/refs", {
1979
+ ...base,
1980
+ ref: `refs/heads/${name}`,
1981
+ sha
1982
+ });
1983
+ return { name, commit: sha, isDefault: false };
1984
+ },
1985
+ async createPullRequest(options) {
1986
+ try {
1987
+ const { data } = await octokit.request("POST /repos/{owner}/{repo}/pulls", {
1988
+ ...base,
1989
+ title: options.title,
1990
+ head: options.headOwner ? `${options.headOwner}:${options.head}` : options.head,
1991
+ base: options.base,
1992
+ ...options.body ? { body: options.body } : {},
1993
+ ...options.draft ? { draft: true } : {}
1994
+ });
1995
+ const pr = data;
1996
+ return {
1997
+ number: pr.number,
1998
+ title: pr.title ?? options.title,
1999
+ state: pr.state === "closed" ? "closed" : "open",
2000
+ head: options.head,
2001
+ base: options.base,
2002
+ url: pr.html_url ?? "",
2003
+ createdAt: pr.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2004
+ updatedAt: pr.updated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2005
+ ...pr.body ? { body: pr.body } : {}
2006
+ };
2007
+ } catch (error) {
2008
+ if (statusOf(error) === 422 && /pull request already exists/i.test(messageOf(error))) {
2009
+ throw new RepositoryWriteError("pullRequestExists", "an open pull request already exists", error);
2010
+ }
2011
+ throw error;
2012
+ }
2013
+ }
2014
+ };
2015
+ }
2016
+
1672
2017
  // src/settings.schema.ts
1673
2018
  var LARGER_RUNNER_LABEL_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
1674
2019
  var LARGER_RUNNER_MEMORY_MIN_GIB = 8;
@@ -1960,7 +2305,9 @@ var GitHubActionsBuildPlugin = class {
1960
2305
  buildInputsHash: contracts.computeBuildInputsHash(input.values ?? []),
1961
2306
  blocked: {
1962
2307
  reason: written.blocked?.reason ?? "workflowWriteFailed",
1963
- detail: { cause: written.blocked?.detail.cause ?? "unknown" }
2308
+ // The whole detail: `workflowPermissionMissing` carries which credential
2309
+ // and which permission, which is what the member's fix depends on.
2310
+ detail: { ...written.blocked?.detail ?? { cause: "unknown" } }
1964
2311
  }
1965
2312
  };
1966
2313
  }
@@ -2088,13 +2435,16 @@ var GitHubActionsBuildPlugin = class {
2088
2435
  const jobs = await port.listRunJobs({ repository, runId });
2089
2436
  const snapshot = observeRun({ run, jobs, mode: "build" });
2090
2437
  const finished = snapshot.status !== "queued" && snapshot.status !== "running";
2091
- const artifact = finished ? await readResultArtifact(port, { repository, runId }) : null;
2438
+ const artifact = finished ? await readResultArtifact(port, { repository, runId, expectBuildId: ref.buildId }) : null;
2092
2439
  const result = artifact?.ok ? artifact.result : null;
2093
2440
  const failure = snapshot.status === "failed" ? await this.classifyRunFailure({ port, repository, run, jobs, snapshot, result, redact }) : null;
2094
2441
  const pushLogDigest = result && snapshot.status === "succeeded" && isPushingTrigger(snapshot.trigger) ? await this.readPushLogDigest({ port, repository, jobs, sha: snapshot.commitSha }) : void 0;
2095
2442
  return {
2096
2443
  ...snapshot,
2097
- ...result ? {
2444
+ // A versioned result with no digest (a failed build, a pull request, a
2445
+ // verify job) still reports its secret check — it just claims no image.
2446
+ ...result?.secretCheck && !result.digest ? { secretCheck: result.secretCheck } : {},
2447
+ ...result?.digest ? {
2098
2448
  // `confirmed: false` without exception. This plugin reads the digest
2099
2449
  // the member's own CI claimed; confirming it against the registry is
2100
2450
  // `checkImageAccess` (T14), compared by `AppBuildsService.finalize`,
@@ -2249,6 +2599,78 @@ var GitHubActionsBuildPlugin = class {
2249
2599
  registryFetch() {
2250
2600
  return (url, init) => fetch(url, init);
2251
2601
  }
2602
+ /**
2603
+ * APW-05 T21a / T12 remainder — run discovery (plan §7.4a, `APW05-G01`).
2604
+ *
2605
+ * The newest runs of the build workflow, so a push or pull-request Build is
2606
+ * recorded even when no `workflow_run` delivery reaches the platform (a
2607
+ * personal fork connected through OAuth has no App installation to deliver
2608
+ * from). `If-None-Match` with the stored `etag` makes an idle repository cost
2609
+ * a 304 — which GitHub does not count against the rate limit.
2610
+ *
2611
+ * The mapping is `githubRunToBuildRunRef` from contracts, the SAME function
2612
+ * the webhook consumer uses, so a polled run and a delivered run are the same
2613
+ * `BuildRunRef` and converge on one Build row.
2614
+ */
2615
+ async listRecentRuns(input, auth) {
2616
+ const port = this.actionsPort(auth);
2617
+ const repository = { owner: input.repository.owner, repo: input.repository.name };
2618
+ const workflowFile = input.workflowPath.split("/").pop() || WORKFLOW_FILE_NAME;
2619
+ const perPage = Math.max(1, Math.min(contracts.APP_BUILD_RUN_DISCOVERY_PER_PAGE, Math.floor(input.perPage) || 1));
2620
+ const page = port.listWorkflowRunsConditional ? await port.listWorkflowRunsConditional({
2621
+ repository,
2622
+ workflowFile,
2623
+ perPage,
2624
+ ...input.etag ? { etag: input.etag } : {}
2625
+ }) : { notModified: false, runs: await port.listWorkflowRuns({ repository, workflowFile, perPage }) };
2626
+ if (page.notModified) {
2627
+ return { notModified: true, ...page.etag ? { etag: page.etag } : {}, runs: [] };
2628
+ }
2629
+ const runs = page.runs.map((run) => contracts.githubRunToBuildRunRef(run)).filter((run) => run !== null);
2630
+ return { notModified: false, ...page.etag ? { etag: page.etag } : {}, runs };
2631
+ }
2632
+ /**
2633
+ * APW-05 T16 remainder — the clone-free writer `prepareRepository` delivers the
2634
+ * workflow through, over this plugin's own Octokit (see
2635
+ * `repo/octokit-repository-writer.ts` on why the plugin supplies it).
2636
+ */
2637
+ repositoryWriter(repository, auth) {
2638
+ return octokitRepositoryWriter(this.octokitFor(auth), repository, {
2639
+ credential: auth.credential,
2640
+ scopes: auth.scopes
2641
+ });
2642
+ }
2643
+ /**
2644
+ * Plan §5 — a Rebuild's `commitSha` must be reachable from the tracked branch
2645
+ * (2026-10-17). `GET …/compare/{sha}...{branch}`: `ahead` or `identical` means
2646
+ * the commit is in the branch's history; `behind` / `diverged` means it is not
2647
+ * (a commit only a fork's network carries is `diverged`), and 404/422 means
2648
+ * GitHub does not know it here. Any other failure is a throw.
2649
+ */
2650
+ async isCommitReachable(input, auth) {
2651
+ try {
2652
+ const { data } = await this.octokitFor(auth).request("GET /repos/{owner}/{repo}/compare/{basehead}", {
2653
+ owner: input.repository.owner,
2654
+ repo: input.repository.repo,
2655
+ basehead: `${input.sha}...${input.branch}`,
2656
+ per_page: 1
2657
+ });
2658
+ const status = data?.status;
2659
+ return status === "ahead" || status === "identical";
2660
+ } catch (error) {
2661
+ const status = error?.status;
2662
+ if (status === 404 || status === 422) return false;
2663
+ throw error;
2664
+ }
2665
+ }
2666
+ /** §4.10 — the per-run prompted-value secret's removal (T12's `secret-sync.ts`, now on the contract). */
2667
+ async deleteVerifyPromptedSecret(repository, auth) {
2668
+ return createBuildValueSecretSync({ port: this.secretPort(auth, repository) }).deleteVerifyPromptedSecret();
2669
+ }
2670
+ /** The Octokit client for one call — a seam a spec overrides, like {@link actionsPort}. */
2671
+ octokitFor(auth) {
2672
+ return new octokit.Octokit({ auth: auth.token });
2673
+ }
2252
2674
  /** APW-05 T12 — the run's own page, which is where a member reads its logs. */
2253
2675
  async getLogsUrl(ref, auth) {
2254
2676
  const port = this.actionsPort(auth);