@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.js CHANGED
@@ -1,4 +1,4 @@
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';
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_VERIFY_PROMPTED_SECRET, APP_BUILD_SECRET_MAX_BYTES, computeBuildInputsHash, APP_BUILD_MAX_VALUES, APP_BUILD_RUN_DISCOVERY_PER_PAGE, githubRunToBuildRunRef, APP_BUILD_DEPLOYABLE_TRIGGERS, APP_BUILD_CHECKS_MAX_PARALLEL, APP_BUILD_RESULT_MAX_BYTES, APP_BUILD_WORKFLOW_BRANCH, APP_BUILD_RUNNERS, appBuildRunnerCapacity, APP_BUILD_RUNNER_HEADROOM_GIB, APP_BUILD_CHECKS_JOB_ID } from '@ever-works/contracts';
2
2
  import { Octokit } from 'octokit';
3
3
  import { unzipSync } from 'fflate';
4
4
  import { createRequire } from 'module';
@@ -37,6 +37,31 @@ function octokitActionsRunsPort(octokit) {
37
37
  );
38
38
  return asArray(data, "workflow_runs");
39
39
  },
40
+ async listWorkflowRunsConditional({ repository, workflowFile, perPage, etag }) {
41
+ try {
42
+ const response = await octokit.request(
43
+ "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs",
44
+ {
45
+ owner: repository.owner,
46
+ repo: repository.repo,
47
+ workflow_id: workflowFile,
48
+ per_page: perPage,
49
+ ...etag ? { headers: { "if-none-match": etag } } : {}
50
+ }
51
+ );
52
+ const next = response.headers?.etag;
53
+ return {
54
+ notModified: false,
55
+ ...typeof next === "string" ? { etag: next } : {},
56
+ runs: asArray(response.data, "workflow_runs")
57
+ };
58
+ } catch (error) {
59
+ if (error.status === 304) {
60
+ return { notModified: true, ...etag ? { etag } : {}, runs: [] };
61
+ }
62
+ throw error;
63
+ }
64
+ },
40
65
  async getWorkflowRun({ repository, runId }) {
41
66
  try {
42
67
  const { data } = await octokit.request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", {
@@ -213,7 +238,39 @@ function observeRun({ run, jobs, mode }) {
213
238
  }
214
239
  var RESULT_ARTIFACT_MAX_ZIP_BYTES = 65536;
215
240
  var RESULT_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
216
- var ALLOWED_KEYS = /* @__PURE__ */ new Set(["digest", "imageRepository", "tags", "secretCheck", "smoke"]);
241
+ var RESULT_SCHEMA_NAME = "ever-works-build-result";
242
+ var RESULT_SCHEMA_VERSION = 1;
243
+ var ALLOWED_KEYS = /* @__PURE__ */ new Set([
244
+ // T12's first shape.
245
+ "digest",
246
+ "imageRepository",
247
+ "tags",
248
+ "secretCheck",
249
+ "smoke",
250
+ // The generator's versioned shape (`resultStep` in workflow/generator.ts).
251
+ "schema",
252
+ "version",
253
+ "buildId",
254
+ "sha",
255
+ "verification",
256
+ "reusedDigest",
257
+ "localBuild"
258
+ ]);
259
+ var VERIFICATION_KEYS = /* @__PURE__ */ new Set(["jobs", "smoke"]);
260
+ var COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/i;
261
+ function normaliseResultDigest(raw) {
262
+ if (typeof raw !== "string") return "";
263
+ const trimmed = raw.trim();
264
+ const at = trimmed.lastIndexOf("@");
265
+ return at >= 0 ? trimmed.slice(at + 1) : trimmed;
266
+ }
267
+ function passRows(value) {
268
+ if (!Array.isArray(value)) return [];
269
+ return value.filter((row) => typeof row === "object" && row !== null).map((row) => ({
270
+ name: String(row.name ?? ""),
271
+ passed: row.passed === true || row.conclusion === "success"
272
+ }));
273
+ }
217
274
  function parseResultArtifact(value) {
218
275
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
219
276
  return { ok: false, refusal: "malformedJson" };
@@ -223,22 +280,55 @@ function parseResultArtifact(value) {
223
280
  if (unknown.length > 0) {
224
281
  return { ok: false, refusal: "unknownKeys" };
225
282
  }
226
- const digest = typeof bag.digest === "string" ? bag.digest.trim() : "";
227
- if (!RESULT_DIGEST_PATTERN.test(digest)) {
283
+ const versioned = "schema" in bag || "version" in bag;
284
+ if (versioned && (bag.schema !== RESULT_SCHEMA_NAME || bag.version !== RESULT_SCHEMA_VERSION)) {
285
+ return { ok: false, refusal: "unsupportedSchema" };
286
+ }
287
+ let verification = null;
288
+ if (bag.verification !== void 0) {
289
+ if (typeof bag.verification !== "object" || bag.verification === null || Array.isArray(bag.verification)) {
290
+ return { ok: false, refusal: "malformedJson" };
291
+ }
292
+ verification = bag.verification;
293
+ if (Object.keys(verification).some((key) => !VERIFICATION_KEYS.has(key))) {
294
+ return { ok: false, refusal: "unknownKeys" };
295
+ }
296
+ }
297
+ const digest = normaliseResultDigest(bag.digest);
298
+ if (digest.length > 0 || !versioned) {
299
+ if (!RESULT_DIGEST_PATTERN.test(digest)) {
300
+ return { ok: false, refusal: "badDigest" };
301
+ }
302
+ }
303
+ const reusedDigest = normaliseResultDigest(bag.reusedDigest);
304
+ if (reusedDigest.length > 0 && !RESULT_DIGEST_PATTERN.test(reusedDigest)) {
228
305
  return { ok: false, refusal: "badDigest" };
229
306
  }
230
- const result = { digest };
307
+ const result = {};
308
+ if (digest.length > 0) result.digest = digest;
309
+ if (versioned) result.schemaVersion = RESULT_SCHEMA_VERSION;
231
310
  if (typeof bag.imageRepository === "string" && bag.imageRepository.trim().length > 0) {
232
311
  result.imageRepository = bag.imageRepository.trim();
233
312
  }
234
313
  if (Array.isArray(bag.tags)) {
235
- result.tags = bag.tags.filter((tag) => typeof tag === "string");
314
+ result.tags = bag.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0);
236
315
  }
237
316
  if (bag.secretCheck === "passed" || bag.secretCheck === "failed" || bag.secretCheck === "not_needed") {
238
317
  result.secretCheck = bag.secretCheck;
239
318
  }
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 }));
319
+ if (typeof bag.buildId === "string" && bag.buildId.trim().length > 0) {
320
+ result.buildId = bag.buildId.trim();
321
+ }
322
+ if (typeof bag.sha === "string" && COMMIT_SHA_PATTERN.test(bag.sha.trim())) {
323
+ result.sha = bag.sha.trim().toLowerCase();
324
+ }
325
+ if (reusedDigest.length > 0) result.reusedDigest = reusedDigest;
326
+ if (typeof bag.localBuild === "boolean") result.localBuild = bag.localBuild;
327
+ if (Array.isArray(bag.smoke) || Array.isArray(verification?.smoke)) {
328
+ result.smoke = [...passRows(bag.smoke), ...passRows(verification?.smoke)];
329
+ }
330
+ if (Array.isArray(verification?.jobs)) {
331
+ result.verificationJobs = passRows(verification?.jobs);
242
332
  }
243
333
  return { ok: true, result };
244
334
  }
@@ -280,7 +370,11 @@ async function readResultArtifact(port, input) {
280
370
  } catch {
281
371
  return { ok: false, refusal: "malformedJson" };
282
372
  }
283
- return parseResultArtifact(decoded);
373
+ const parsed = parseResultArtifact(decoded);
374
+ if (parsed.ok && input.expectBuildId && parsed.result.buildId && parsed.result.buildId !== input.expectBuildId) {
375
+ return { ok: false, refusal: "buildMismatch" };
376
+ }
377
+ return parsed;
284
378
  }
285
379
 
286
380
  // src/runs/failure-classifier.ts
@@ -1286,7 +1380,7 @@ function resultStep(job, secretCheckPresent) {
1286
1380
  `jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
1287
1381
  ` --arg imageRepository "$EW_IMAGE" --arg digest "$digest" --arg secretCheck "$secret_check" \\`,
1288
1382
  ` --arg tag "$EW_IMAGE:sha-$EW_SHA" \\`,
1289
- ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
1383
+ ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,`,
1290
1384
  ` digest:$digest,tags:[$tag],secretCheck:$secretCheck,verification:{jobs:[],smoke:[]}}' > ${APP_BUILD_RESULT_ARTIFACT_FILE}`
1291
1385
  ] : [
1292
1386
  "set -euo pipefail",
@@ -1296,7 +1390,7 @@ function resultStep(job, secretCheckPresent) {
1296
1390
  `jq -n --arg buildId "\${{ inputs.ew_build_id }}" --arg sha "$EW_SHA" \\`,
1297
1391
  ` --arg imageRepository "$EW_IMAGE" --arg reusedDigest "$reused_digest" \\`,
1298
1392
  ` --argjson localBuild "$local_build" \\`,
1299
- ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,'`,
1393
+ ` '{schema:"ever-works-build-result",version:1,buildId:$buildId,sha:$sha,imageRepository:$imageRepository,`,
1300
1394
  ` reusedDigest:$reusedDigest,localBuild:$localBuild,verification:{jobs:[],smoke:[]}}' > ${APP_BUILD_RESULT_ARTIFACT_FILE}`
1301
1395
  ];
1302
1396
  return [" - name: Write result", " if: always()", " run: |", ...scalarBlock(script, 10)];
@@ -1370,7 +1464,8 @@ async function readBranchHead(writer, branch) {
1370
1464
  function repositoryWriteErrorCode(error) {
1371
1465
  const candidate = error;
1372
1466
  for (const value of [candidate?.code, candidate?.reason]) {
1373
- if (value === "nonFastForward" || value === "refRejectedByRule" || value === "pullRequestExists") return value;
1467
+ if (value === "nonFastForward" || value === "refRejectedByRule" || value === "pullRequestExists" || value === "workflowPermissionMissing")
1468
+ return value;
1374
1469
  }
1375
1470
  return null;
1376
1471
  }
@@ -1412,14 +1507,36 @@ async function writeWorkflow(input, writer, protection) {
1412
1507
  const removal = input.content === "";
1413
1508
  if (removal) {
1414
1509
  if (!existsOnTrackedBranch) return { state: "unchanged", storedWorkflowSha256: null };
1415
- return pullRequestDelivery(input, writer, path, contentSha256, handEdited, REMOVAL_COPY);
1510
+ return withWorkflowPermission(
1511
+ () => pullRequestDelivery(input, writer, path, contentSha256, handEdited, REMOVAL_COPY)
1512
+ );
1416
1513
  }
1417
1514
  const verdict = createdByAppWork && !handEdited && protection ? await isBranchProtected(protection, { owner, repo, branch: trackedBranch }) : { protected: false};
1418
1515
  const pullRequestPath = !createdByAppWork || handEdited || verdict.protected;
1419
1516
  if (pullRequestPath) {
1420
- return pullRequestDelivery(input, writer, path, contentSha256, handEdited);
1517
+ return withWorkflowPermission(() => pullRequestDelivery(input, writer, path, contentSha256, handEdited));
1518
+ }
1519
+ return withWorkflowPermission(() => directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch));
1520
+ }
1521
+ async function withWorkflowPermission(deliver) {
1522
+ try {
1523
+ return await deliver();
1524
+ } catch (error) {
1525
+ if (repositoryWriteErrorCode(error) !== "workflowPermissionMissing") throw error;
1526
+ const facts = error;
1527
+ return {
1528
+ state: "blocked",
1529
+ storedWorkflowSha256: null,
1530
+ blocked: {
1531
+ reason: "workflowPermissionMissing",
1532
+ detail: {
1533
+ cause: "workflowPermissionMissing",
1534
+ credential: typeof facts.credential === "string" ? facts.credential : "unknown",
1535
+ missing: typeof facts.missing === "string" ? facts.missing : "workflow"
1536
+ }
1537
+ }
1538
+ };
1421
1539
  }
1422
- return directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch);
1423
1540
  }
1424
1541
  async function directDelivery(input, writer, path, contentSha256, existsOnTrackedBranch) {
1425
1542
  const { trackedBranch } = input.repository;
@@ -1664,6 +1781,234 @@ function createBuildValueSecretSync(dependencies) {
1664
1781
  };
1665
1782
  }
1666
1783
 
1784
+ // src/repo/octokit-repository-writer.ts
1785
+ var WORKFLOWS_DIRECTORY = ".github/workflows/";
1786
+ var RepositoryWriteError = class extends Error {
1787
+ constructor(code, message, cause, credential, missing) {
1788
+ super(message);
1789
+ this.code = code;
1790
+ this.cause = cause;
1791
+ this.credential = credential;
1792
+ this.missing = missing;
1793
+ this.name = "RepositoryWriteError";
1794
+ }
1795
+ };
1796
+ function workflowPermissionMissingError(credential, cause) {
1797
+ const app = credential === "githubApp";
1798
+ return new RepositoryWriteError(
1799
+ "workflowPermissionMissing",
1800
+ 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.',
1801
+ cause,
1802
+ credential,
1803
+ app ? "workflows:write" : "workflow"
1804
+ );
1805
+ }
1806
+ var FULL_SHA2 = /^[0-9a-f]{40}$/i;
1807
+ function statusOf(error) {
1808
+ const status = error?.status;
1809
+ return typeof status === "number" ? status : void 0;
1810
+ }
1811
+ function messageOf(error) {
1812
+ const candidate = error;
1813
+ const parts = [];
1814
+ if (typeof candidate?.message === "string") parts.push(candidate.message);
1815
+ const data = candidate?.response?.data;
1816
+ if (typeof data?.message === "string") parts.push(data.message);
1817
+ if (Array.isArray(data?.errors)) {
1818
+ for (const entry of data.errors) {
1819
+ const message = entry?.message;
1820
+ if (typeof message === "string") parts.push(message);
1821
+ }
1822
+ }
1823
+ return parts.join(" | ");
1824
+ }
1825
+ var RULE_REFUSAL = /protected branch|ruleset|rule violation|required status|required review|push declined|cannot be updated/i;
1826
+ var WORKFLOW_REFUSAL = /workflow[^|]*\b(scope|permission)|\b(scope|permission)[^|]*workflow/i;
1827
+ var INTEGRATION_REFUSAL = /resource not accessible by integration/i;
1828
+ function scopesFromHeader(headers) {
1829
+ const header = headers?.["x-oauth-scopes"];
1830
+ if (typeof header !== "string") return null;
1831
+ return header.split(",").map((scope) => scope.trim()).filter(Boolean);
1832
+ }
1833
+ function octokitRepositoryWriter(octokit, repository, credential = {}) {
1834
+ const base = { owner: repository.owner, repo: repository.repo };
1835
+ const kind = credential.credential ?? "unknown";
1836
+ async function headOf(ref) {
1837
+ if (FULL_SHA2.test(ref)) return ref;
1838
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/git/ref/{ref}", {
1839
+ ...base,
1840
+ ref: `heads/${ref}`
1841
+ });
1842
+ const sha = data?.object?.sha;
1843
+ if (typeof sha !== "string") throw new Error(`github-actions-build: no head commit for ${ref}`);
1844
+ return sha;
1845
+ }
1846
+ async function assertCanWriteWorkflows() {
1847
+ if (kind === "githubApp") return;
1848
+ let scopes = credential.scopes ?? null;
1849
+ if (!scopes) {
1850
+ try {
1851
+ scopes = scopesFromHeader((await octokit.request("GET /user")).headers);
1852
+ } catch {
1853
+ scopes = null;
1854
+ }
1855
+ }
1856
+ if (scopes && !scopes.includes("workflow")) {
1857
+ throw workflowPermissionMissingError(kind === "pat" ? "pat" : "oauth");
1858
+ }
1859
+ }
1860
+ function translateWorkflowRefusal(error, touchesWorkflows) {
1861
+ if (!touchesWorkflows || error instanceof RepositoryWriteError) return error;
1862
+ const message = messageOf(error);
1863
+ if (WORKFLOW_REFUSAL.test(message)) {
1864
+ return workflowPermissionMissingError(/github app|integration/i.test(message) ? "githubApp" : kind, error);
1865
+ }
1866
+ return error;
1867
+ }
1868
+ async function commit(input, touchesWorkflows) {
1869
+ const { data: baseCommit } = await octokit.request("GET /repos/{owner}/{repo}/git/commits/{commit_sha}", {
1870
+ ...base,
1871
+ commit_sha: input.baseSha
1872
+ });
1873
+ const baseTree = baseCommit?.tree?.sha;
1874
+ if (typeof baseTree !== "string") {
1875
+ throw new Error(`github-actions-build: commit ${input.baseSha} has no tree`);
1876
+ }
1877
+ const tree = [];
1878
+ for (const file of input.files) {
1879
+ const { data: blob } = await octokit.request("POST /repos/{owner}/{repo}/git/blobs", {
1880
+ ...base,
1881
+ content: file.content,
1882
+ encoding: file.encoding === "base64" ? "base64" : "utf-8"
1883
+ });
1884
+ const sha = blob?.sha;
1885
+ if (typeof sha !== "string") throw new Error(`github-actions-build: no blob sha for ${file.path}`);
1886
+ tree.push({ path: file.path, mode: "100644", type: "blob", sha });
1887
+ }
1888
+ const { data: newTree } = await octokit.request("POST /repos/{owner}/{repo}/git/trees", {
1889
+ ...base,
1890
+ base_tree: baseTree,
1891
+ tree
1892
+ });
1893
+ const { data: created } = await octokit.request("POST /repos/{owner}/{repo}/git/commits", {
1894
+ ...base,
1895
+ message: input.message,
1896
+ tree: newTree.sha,
1897
+ parents: [input.baseSha]
1898
+ });
1899
+ const commitSha = created.sha;
1900
+ try {
1901
+ await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
1902
+ ...base,
1903
+ ref: `heads/${input.branch}`,
1904
+ sha: commitSha,
1905
+ // Never a force-push: a moved branch is `nonFastForward`.
1906
+ force: false
1907
+ });
1908
+ } catch (error) {
1909
+ const status = statusOf(error);
1910
+ const message = messageOf(error);
1911
+ if (status === 422 && /fast[ -]?forward/i.test(message)) {
1912
+ throw new RepositoryWriteError(
1913
+ "nonFastForward",
1914
+ `${input.branch} moved; retry from its new head`,
1915
+ error
1916
+ );
1917
+ }
1918
+ if ((status === 409 || status === 422 || status === 403) && RULE_REFUSAL.test(message)) {
1919
+ throw new RepositoryWriteError("refRejectedByRule", `${input.branch} refused the update`, error);
1920
+ }
1921
+ if (touchesWorkflows && status === 403 && INTEGRATION_REFUSAL.test(message)) {
1922
+ throw workflowPermissionMissingError("githubApp", error);
1923
+ }
1924
+ throw error;
1925
+ }
1926
+ return { commitSha };
1927
+ }
1928
+ return {
1929
+ async getFileContent(path, ref) {
1930
+ try {
1931
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/contents/{path}", {
1932
+ ...base,
1933
+ path,
1934
+ ...ref ?? repository.trackedBranch ? { ref: ref ?? repository.trackedBranch } : {}
1935
+ });
1936
+ const file = data;
1937
+ if (Array.isArray(data) || file?.type !== "file" || typeof file.content !== "string") return null;
1938
+ return {
1939
+ content: file.content,
1940
+ encoding: typeof file.encoding === "string" ? file.encoding : "base64"
1941
+ };
1942
+ } catch (error) {
1943
+ if (statusOf(error) === 404) return null;
1944
+ throw error;
1945
+ }
1946
+ },
1947
+ async commitFiles(input) {
1948
+ const touchesWorkflows = input.files.some((file) => file.path.startsWith(WORKFLOWS_DIRECTORY));
1949
+ if (touchesWorkflows) await assertCanWriteWorkflows();
1950
+ try {
1951
+ return await commit(input, touchesWorkflows);
1952
+ } catch (error) {
1953
+ throw translateWorkflowRefusal(error, touchesWorkflows);
1954
+ }
1955
+ },
1956
+ async createBranch(name, fromRef) {
1957
+ try {
1958
+ const { data } = await octokit.request("GET /repos/{owner}/{repo}/branches/{branch}", {
1959
+ ...base,
1960
+ branch: name
1961
+ });
1962
+ const branch = data;
1963
+ return {
1964
+ name: branch.name ?? name,
1965
+ commit: branch.commit?.sha ?? "",
1966
+ isDefault: false,
1967
+ isProtected: branch.protected === true
1968
+ };
1969
+ } catch (error) {
1970
+ if (statusOf(error) !== 404) throw error;
1971
+ }
1972
+ const sha = await headOf(fromRef);
1973
+ await octokit.request("POST /repos/{owner}/{repo}/git/refs", {
1974
+ ...base,
1975
+ ref: `refs/heads/${name}`,
1976
+ sha
1977
+ });
1978
+ return { name, commit: sha, isDefault: false };
1979
+ },
1980
+ async createPullRequest(options) {
1981
+ try {
1982
+ const { data } = await octokit.request("POST /repos/{owner}/{repo}/pulls", {
1983
+ ...base,
1984
+ title: options.title,
1985
+ head: options.headOwner ? `${options.headOwner}:${options.head}` : options.head,
1986
+ base: options.base,
1987
+ ...options.body ? { body: options.body } : {},
1988
+ ...options.draft ? { draft: true } : {}
1989
+ });
1990
+ const pr = data;
1991
+ return {
1992
+ number: pr.number,
1993
+ title: pr.title ?? options.title,
1994
+ state: pr.state === "closed" ? "closed" : "open",
1995
+ head: options.head,
1996
+ base: options.base,
1997
+ url: pr.html_url ?? "",
1998
+ createdAt: pr.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1999
+ updatedAt: pr.updated_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2000
+ ...pr.body ? { body: pr.body } : {}
2001
+ };
2002
+ } catch (error) {
2003
+ if (statusOf(error) === 422 && /pull request already exists/i.test(messageOf(error))) {
2004
+ throw new RepositoryWriteError("pullRequestExists", "an open pull request already exists", error);
2005
+ }
2006
+ throw error;
2007
+ }
2008
+ }
2009
+ };
2010
+ }
2011
+
1667
2012
  // src/settings.schema.ts
1668
2013
  var LARGER_RUNNER_LABEL_PATTERN = "^[A-Za-z0-9._-]{1,64}$";
1669
2014
  var LARGER_RUNNER_MEMORY_MIN_GIB = 8;
@@ -1955,7 +2300,9 @@ var GitHubActionsBuildPlugin = class {
1955
2300
  buildInputsHash: computeBuildInputsHash(input.values ?? []),
1956
2301
  blocked: {
1957
2302
  reason: written.blocked?.reason ?? "workflowWriteFailed",
1958
- detail: { cause: written.blocked?.detail.cause ?? "unknown" }
2303
+ // The whole detail: `workflowPermissionMissing` carries which credential
2304
+ // and which permission, which is what the member's fix depends on.
2305
+ detail: { ...written.blocked?.detail ?? { cause: "unknown" } }
1959
2306
  }
1960
2307
  };
1961
2308
  }
@@ -2083,13 +2430,16 @@ var GitHubActionsBuildPlugin = class {
2083
2430
  const jobs = await port.listRunJobs({ repository, runId });
2084
2431
  const snapshot = observeRun({ run, jobs, mode: "build" });
2085
2432
  const finished = snapshot.status !== "queued" && snapshot.status !== "running";
2086
- const artifact = finished ? await readResultArtifact(port, { repository, runId }) : null;
2433
+ const artifact = finished ? await readResultArtifact(port, { repository, runId, expectBuildId: ref.buildId }) : null;
2087
2434
  const result = artifact?.ok ? artifact.result : null;
2088
2435
  const failure = snapshot.status === "failed" ? await this.classifyRunFailure({ port, repository, run, jobs, snapshot, result, redact }) : null;
2089
2436
  const pushLogDigest = result && snapshot.status === "succeeded" && isPushingTrigger(snapshot.trigger) ? await this.readPushLogDigest({ port, repository, jobs, sha: snapshot.commitSha }) : void 0;
2090
2437
  return {
2091
2438
  ...snapshot,
2092
- ...result ? {
2439
+ // A versioned result with no digest (a failed build, a pull request, a
2440
+ // verify job) still reports its secret check — it just claims no image.
2441
+ ...result?.secretCheck && !result.digest ? { secretCheck: result.secretCheck } : {},
2442
+ ...result?.digest ? {
2093
2443
  // `confirmed: false` without exception. This plugin reads the digest
2094
2444
  // the member's own CI claimed; confirming it against the registry is
2095
2445
  // `checkImageAccess` (T14), compared by `AppBuildsService.finalize`,
@@ -2244,6 +2594,78 @@ var GitHubActionsBuildPlugin = class {
2244
2594
  registryFetch() {
2245
2595
  return (url, init) => fetch(url, init);
2246
2596
  }
2597
+ /**
2598
+ * APW-05 T21a / T12 remainder — run discovery (plan §7.4a, `APW05-G01`).
2599
+ *
2600
+ * The newest runs of the build workflow, so a push or pull-request Build is
2601
+ * recorded even when no `workflow_run` delivery reaches the platform (a
2602
+ * personal fork connected through OAuth has no App installation to deliver
2603
+ * from). `If-None-Match` with the stored `etag` makes an idle repository cost
2604
+ * a 304 — which GitHub does not count against the rate limit.
2605
+ *
2606
+ * The mapping is `githubRunToBuildRunRef` from contracts, the SAME function
2607
+ * the webhook consumer uses, so a polled run and a delivered run are the same
2608
+ * `BuildRunRef` and converge on one Build row.
2609
+ */
2610
+ async listRecentRuns(input, auth) {
2611
+ const port = this.actionsPort(auth);
2612
+ const repository = { owner: input.repository.owner, repo: input.repository.name };
2613
+ const workflowFile = input.workflowPath.split("/").pop() || WORKFLOW_FILE_NAME;
2614
+ const perPage = Math.max(1, Math.min(APP_BUILD_RUN_DISCOVERY_PER_PAGE, Math.floor(input.perPage) || 1));
2615
+ const page = port.listWorkflowRunsConditional ? await port.listWorkflowRunsConditional({
2616
+ repository,
2617
+ workflowFile,
2618
+ perPage,
2619
+ ...input.etag ? { etag: input.etag } : {}
2620
+ }) : { notModified: false, runs: await port.listWorkflowRuns({ repository, workflowFile, perPage }) };
2621
+ if (page.notModified) {
2622
+ return { notModified: true, ...page.etag ? { etag: page.etag } : {}, runs: [] };
2623
+ }
2624
+ const runs = page.runs.map((run) => githubRunToBuildRunRef(run)).filter((run) => run !== null);
2625
+ return { notModified: false, ...page.etag ? { etag: page.etag } : {}, runs };
2626
+ }
2627
+ /**
2628
+ * APW-05 T16 remainder — the clone-free writer `prepareRepository` delivers the
2629
+ * workflow through, over this plugin's own Octokit (see
2630
+ * `repo/octokit-repository-writer.ts` on why the plugin supplies it).
2631
+ */
2632
+ repositoryWriter(repository, auth) {
2633
+ return octokitRepositoryWriter(this.octokitFor(auth), repository, {
2634
+ credential: auth.credential,
2635
+ scopes: auth.scopes
2636
+ });
2637
+ }
2638
+ /**
2639
+ * Plan §5 — a Rebuild's `commitSha` must be reachable from the tracked branch
2640
+ * (2026-10-17). `GET …/compare/{sha}...{branch}`: `ahead` or `identical` means
2641
+ * the commit is in the branch's history; `behind` / `diverged` means it is not
2642
+ * (a commit only a fork's network carries is `diverged`), and 404/422 means
2643
+ * GitHub does not know it here. Any other failure is a throw.
2644
+ */
2645
+ async isCommitReachable(input, auth) {
2646
+ try {
2647
+ const { data } = await this.octokitFor(auth).request("GET /repos/{owner}/{repo}/compare/{basehead}", {
2648
+ owner: input.repository.owner,
2649
+ repo: input.repository.repo,
2650
+ basehead: `${input.sha}...${input.branch}`,
2651
+ per_page: 1
2652
+ });
2653
+ const status = data?.status;
2654
+ return status === "ahead" || status === "identical";
2655
+ } catch (error) {
2656
+ const status = error?.status;
2657
+ if (status === 404 || status === 422) return false;
2658
+ throw error;
2659
+ }
2660
+ }
2661
+ /** §4.10 — the per-run prompted-value secret's removal (T12's `secret-sync.ts`, now on the contract). */
2662
+ async deleteVerifyPromptedSecret(repository, auth) {
2663
+ return createBuildValueSecretSync({ port: this.secretPort(auth, repository) }).deleteVerifyPromptedSecret();
2664
+ }
2665
+ /** The Octokit client for one call — a seam a spec overrides, like {@link actionsPort}. */
2666
+ octokitFor(auth) {
2667
+ return new Octokit({ auth: auth.token });
2668
+ }
2247
2669
  /** APW-05 T12 — the run's own page, which is where a member reads its logs. */
2248
2670
  async getLogsUrl(ref, auth) {
2249
2671
  const port = this.actionsPort(auth);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ever-works/github-actions-build-plugin",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "GitHub Actions build plugin for Ever Works - turns a commit into an image through a workflow Ever Works writes into the repository (APW-05 plan §4.3)",
5
5
  "author": {
6
6
  "name": "Ever Co. LTD",
@@ -33,16 +33,16 @@
33
33
  "test:coverage": "vitest run --coverage"
34
34
  },
35
35
  "dependencies": {
36
- "@ever-works/contracts": "^1.0.6",
36
+ "@ever-works/contracts": "^1.0.7",
37
37
  "fflate": "^0.8.3",
38
38
  "libsodium-wrappers": "^0.7.15",
39
39
  "octokit": "^4.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@ever-works/plugin": "^1.2.5"
42
+ "@ever-works/plugin": "^1.2.6"
43
43
  },
44
44
  "devDependencies": {
45
- "@ever-works/plugin": "^1.2.5",
45
+ "@ever-works/plugin": "^1.2.6",
46
46
  "@types/libsodium-wrappers": "^0.7.14",
47
47
  "@types/node": "^26.6.4",
48
48
  "ajv": "^8.18.0",
@@ -80,8 +80,8 @@
80
80
  "url": "https://github.com/ever-works/ever-works/issues"
81
81
  },
82
82
  "everworksRelease": {
83
- "contentHash": "sha256:40be91f9e0f3a33cc4fd004e20ec654d1764b1ff08827937f79af550406864fd",
84
- "commit": "19905c70cf05be3bc523db030597046067cd322a",
85
- "source": "https://github.com/ever-works/ever-works/tree/19905c70cf05be3bc523db030597046067cd322a/packages/plugins/github-actions-build"
83
+ "contentHash": "sha256:059c27cd51df7113427cab5ec39e002c6500325d72f3f868073227280751a09e",
84
+ "commit": "48bb7f0a20744cbdfae87675743421804e100af6",
85
+ "source": "https://github.com/ever-works/ever-works/tree/48bb7f0a20744cbdfae87675743421804e100af6/packages/plugins/github-actions-build"
86
86
  }
87
87
  }