@volter/twin-github 0.1.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -36
- package/client/github-mirror.css +277 -319
- package/client/github-mirror.d.ts +418 -0
- package/client/github-mirror.js +485 -0
- package/client/github-mirror.tsx +153 -357
- package/client/pulls-rest.ts +159 -0
- package/client/pulls-workspace.tsx +841 -0
- package/dist/client/github-mirror.bundle.js +239 -0
- package/dist/client/github-mirror.css +916 -0
- package/dist/client/github-mirror.d.ts +418 -0
- package/dist/client/github-mirror.js +485 -0
- package/dist/client/github-mirror.tsx +1315 -0
- package/dist/client/pulls-rest.d.ts +42 -0
- package/dist/client/pulls-rest.js +140 -0
- package/dist/client/pulls-rest.ts +159 -0
- package/dist/client/pulls-workspace.bundle.js +22 -0
- package/dist/client/pulls-workspace.d.ts +114 -0
- package/dist/client/pulls-workspace.js +418 -0
- package/dist/client/pulls-workspace.tsx +841 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/generated/graphql-sdl.gen.json +1 -0
- package/dist/src/generated/graphql.gen.json +1 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/github-budget.d.ts +69 -0
- package/dist/src/github-budget.js +172 -0
- package/dist/src/github-capabilities.d.ts +5 -0
- package/dist/src/github-capabilities.js +4468 -0
- package/dist/src/github-conformance.d.ts +43 -0
- package/dist/src/github-conformance.js +76 -0
- package/dist/src/github-connector.d.ts +307 -0
- package/dist/src/github-connector.js +1398 -0
- package/dist/src/github-events.d.ts +41 -0
- package/dist/src/github-events.js +232 -0
- package/dist/src/github-git-http.d.ts +49 -0
- package/dist/src/github-git-http.js +185 -0
- package/dist/src/github-git-plane.d.ts +114 -0
- package/dist/src/github-git-plane.js +407 -0
- package/dist/src/github-mirror-state.d.ts +2 -0
- package/dist/src/github-mirror-state.js +335 -0
- package/dist/src/github-mirror-ui.d.ts +20 -0
- package/dist/src/github-mirror-ui.js +101 -0
- package/dist/src/github-server.d.ts +14 -0
- package/dist/src/github-server.js +240 -0
- package/dist/src/github-shared.d.ts +12 -0
- package/dist/src/github-shared.js +21 -0
- package/dist/src/github-twin.d.ts +1411 -0
- package/dist/src/github-twin.js +4084 -0
- package/dist/src/github-ui-conformance.d.ts +4 -0
- package/dist/src/github-ui-conformance.js +105 -0
- package/dist/src/github-ui-structure.d.ts +18 -0
- package/dist/src/github-ui-structure.js +251 -0
- package/dist/src/graphql-wire.d.ts +22 -0
- package/dist/src/graphql-wire.js +89 -0
- package/dist/src/index.d.ts +17 -0
- package/dist/src/index.js +104 -0
- package/dist/src/manifest.d.ts +8 -0
- package/dist/src/manifest.js +597 -0
- package/dist/src/npm-registry.d.ts +7 -0
- package/dist/src/npm-registry.js +47 -0
- package/dist/src/screens/app-installation.d.ts +3 -0
- package/dist/src/screens/app-installation.js +166 -0
- package/dist/src/screens/app-manifest.d.ts +3 -0
- package/dist/src/screens/app-manifest.js +81 -0
- package/dist/src/screens/oauth.d.ts +15 -0
- package/dist/src/screens/oauth.js +257 -0
- package/dist/src/screens/session.d.ts +8 -0
- package/dist/src/screens/session.js +170 -0
- package/dist/src/semantics/actions.d.ts +2 -0
- package/dist/src/semantics/actions.js +413 -0
- package/dist/src/semantics/activity.d.ts +4 -0
- package/dist/src/semantics/activity.js +161 -0
- package/dist/src/semantics/apps.d.ts +2 -0
- package/dist/src/semantics/apps.js +144 -0
- package/dist/src/semantics/branches.d.ts +2 -0
- package/dist/src/semantics/branches.js +136 -0
- package/dist/src/semantics/checks.d.ts +2 -0
- package/dist/src/semantics/checks.js +176 -0
- package/dist/src/semantics/code-scanning-upload.d.ts +2 -0
- package/dist/src/semantics/code-scanning-upload.js +97 -0
- package/dist/src/semantics/codespaces.d.ts +2 -0
- package/dist/src/semantics/codespaces.js +58 -0
- package/dist/src/semantics/commits.d.ts +2 -0
- package/dist/src/semantics/commits.js +109 -0
- package/dist/src/semantics/contents.d.ts +2 -0
- package/dist/src/semantics/contents.js +131 -0
- package/dist/src/semantics/deployments.d.ts +2 -0
- package/dist/src/semantics/deployments.js +127 -0
- package/dist/src/semantics/gists.d.ts +2 -0
- package/dist/src/semantics/gists.js +86 -0
- package/dist/src/semantics/git.d.ts +2 -0
- package/dist/src/semantics/git.js +235 -0
- package/dist/src/semantics/graphql.d.ts +7 -0
- package/dist/src/semantics/graphql.js +512 -0
- package/dist/src/semantics/index.d.ts +5 -0
- package/dist/src/semantics/index.js +62 -0
- package/dist/src/semantics/issues.d.ts +2 -0
- package/dist/src/semantics/issues.js +456 -0
- package/dist/src/semantics/keys.d.ts +2 -0
- package/dist/src/semantics/keys.js +66 -0
- package/dist/src/semantics/labels.d.ts +2 -0
- package/dist/src/semantics/labels.js +100 -0
- package/dist/src/semantics/meta.d.ts +12 -0
- package/dist/src/semantics/meta.js +144 -0
- package/dist/src/semantics/notifications.d.ts +4 -0
- package/dist/src/semantics/notifications.js +62 -0
- package/dist/src/semantics/orgs.d.ts +2 -0
- package/dist/src/semantics/orgs.js +420 -0
- package/dist/src/semantics/packages.d.ts +2 -0
- package/dist/src/semantics/packages.js +55 -0
- package/dist/src/semantics/pages.d.ts +2 -0
- package/dist/src/semantics/pages.js +176 -0
- package/dist/src/semantics/projects.d.ts +2 -0
- package/dist/src/semantics/projects.js +239 -0
- package/dist/src/semantics/pulls.d.ts +2 -0
- package/dist/src/semantics/pulls.js +462 -0
- package/dist/src/semantics/push-reactions.d.ts +101 -0
- package/dist/src/semantics/push-reactions.js +513 -0
- package/dist/src/semantics/releases.d.ts +19 -0
- package/dist/src/semantics/releases.js +231 -0
- package/dist/src/semantics/repo-invitations.d.ts +2 -0
- package/dist/src/semantics/repo-invitations.js +98 -0
- package/dist/src/semantics/repos.d.ts +17 -0
- package/dist/src/semantics/repos.js +390 -0
- package/dist/src/semantics/rulesets.d.ts +2 -0
- package/dist/src/semantics/rulesets.js +111 -0
- package/dist/src/semantics/search.d.ts +2 -0
- package/dist/src/semantics/search.js +84 -0
- package/dist/src/semantics/security.d.ts +2 -0
- package/dist/src/semantics/security.js +177 -0
- package/dist/src/semantics/shared.d.ts +61 -0
- package/dist/src/semantics/shared.js +151 -0
- package/dist/src/semantics/users.d.ts +2 -0
- package/dist/src/semantics/users.js +255 -0
- package/dist/src/semantics/webhooks.d.ts +2 -0
- package/dist/src/semantics/webhooks.js +107 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.SOURCE.md +56 -0
- package/dist/test-fixtures/github-a11y-reference.pr-list.json +1447 -0
- package/dist/test-fixtures/github-comment-schema.SOURCE.md +11 -0
- package/dist/test-fixtures/github-comment-schema.json +702 -0
- package/dist/test-fixtures/github-known-deviations.json +94 -0
- package/dist/test-fixtures/github-openapi-operations.SOURCE.md +76 -0
- package/dist/test-fixtures/github-openapi-operations.json +362 -0
- package/dist/test-fixtures/github-pull-schema.SOURCE.md +37 -0
- package/dist/test-fixtures/github-pull-schema.json +3601 -0
- package/dist/test-fixtures/github-review-schema.SOURCE.md +12 -0
- package/dist/test-fixtures/github-review-schema.json +236 -0
- package/package.json +20 -11
- package/src/cli.ts +7 -6
- package/src/generated/graphql-sdl.gen.json +1 -0
- package/src/generated/graphql.gen.json +1 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/github-a11y-snapshot.uitest.ts +5 -5
- package/src/github-budget.ts +4 -4
- package/src/github-capabilities.ts +1993 -556
- package/src/github-conformance.ts +12 -7
- package/src/github-connector.ts +108 -96
- package/src/github-events.ts +225 -95
- package/src/github-git-http.ts +48 -84
- package/src/github-git-plane.ts +247 -385
- package/src/github-journey.uitest.ts +28 -44
- package/src/github-mirror-state.ts +23 -61
- package/src/github-mirror-ui.ts +16 -10
- package/src/github-server.ts +158 -60
- package/src/github-shared.ts +1 -1
- package/src/github-twin.ts +1525 -4494
- package/src/github-ui-conformance.ts +7 -8
- package/src/github-ui-structure.ts +19 -18
- package/src/graphql-wire.ts +115 -0
- package/src/index.ts +50 -7
- package/src/manifest.ts +605 -0
- package/src/npm-registry.ts +43 -0
- package/src/screens/app-installation.tsx +217 -0
- package/src/screens/app-manifest.tsx +97 -0
- package/src/screens/oauth.tsx +258 -0
- package/src/screens/session.tsx +193 -0
- package/src/semantics/actions.ts +395 -0
- package/src/semantics/activity.ts +171 -0
- package/src/semantics/apps.ts +133 -0
- package/src/semantics/branches.ts +133 -0
- package/src/semantics/checks.ts +163 -0
- package/src/semantics/code-scanning-upload.ts +92 -0
- package/src/semantics/codespaces.ts +58 -0
- package/src/semantics/commits.ts +109 -0
- package/src/semantics/contents.ts +112 -0
- package/src/semantics/deployments.ts +116 -0
- package/src/semantics/gists.ts +85 -0
- package/src/semantics/git.ts +226 -0
- package/src/semantics/graphql.ts +505 -0
- package/src/semantics/index.ts +68 -0
- package/src/semantics/issues.ts +434 -0
- package/src/semantics/keys.ts +66 -0
- package/src/semantics/labels.ts +91 -0
- package/src/semantics/meta.ts +139 -0
- package/src/semantics/notifications.ts +58 -0
- package/src/semantics/orgs.ts +383 -0
- package/src/semantics/packages.ts +59 -0
- package/src/semantics/pages.ts +154 -0
- package/src/semantics/projects.ts +246 -0
- package/src/semantics/pulls.ts +421 -0
- package/src/semantics/push-reactions.ts +471 -0
- package/src/semantics/releases.ts +213 -0
- package/src/semantics/repo-invitations.ts +112 -0
- package/src/semantics/repos.ts +373 -0
- package/src/semantics/rulesets.ts +107 -0
- package/src/semantics/search.ts +83 -0
- package/src/semantics/security.ts +153 -0
- package/src/semantics/shared.ts +181 -0
- package/src/semantics/users.ts +252 -0
- package/src/semantics/webhooks.ts +101 -0
- package/test-fixtures/github-known-deviations.json +7 -8
- package/test-fixtures/github-openapi-operations.json +46 -227
- package/src/github-graphql.ts +0 -398
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// What GitHub does when a branch moves, which no API call asks for:
|
|
2
|
+
// - a workflow file committed under .github/workflows on the default branch is a workflow, named by its `name:`
|
|
3
|
+
// (docs.github.com/actions/writing-workflows/about-workflows), and a push of a branch or a tag runs every workflow of
|
|
4
|
+
// the pushed commit whose `on:` includes push and whose branch and tag filters take the ref
|
|
5
|
+
// (docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs);
|
|
6
|
+
// - secret scanning reads every pushed file and raises an alert for each token it recognises, with where it was found
|
|
7
|
+
// (docs.github.com/code-security/secret-scanning/introduction/about-secret-scanning);
|
|
8
|
+
// - Dependabot, once its alerts are on, reads the default branch's package.json manifests and raises an alert for a dependency an advisory
|
|
9
|
+
// names, and closes it as fixed once the manifest no longer asks for a vulnerable version
|
|
10
|
+
// (docs.github.com/code-security/dependabot/dependabot-alerts/about-dependabot-alerts);
|
|
11
|
+
// - a run a runner finishes keeps what its workflow's steps uploaded: an artifact per actions/upload-artifact step
|
|
12
|
+
// (by its `name`) and a cache per actions/cache step (by its `key`) (docs.github.com/actions/using-workflows/
|
|
13
|
+
// storing-workflow-data-as-artifacts, docs.github.com/actions/using-workflows/caching-dependencies-to-speed-up-workflows);
|
|
14
|
+
// - a direct push to a branch whose rulesets require pull requests is evaluated against them (directPushRules).
|
|
15
|
+
//
|
|
16
|
+
// Where the documentation stops and the twin decides: a workflow's `on:` and its steps are read by pattern, not by a
|
|
17
|
+
// YAML parser, so a trigger or step written in an unusual form is not seen; the recognised tokens are GitHub's
|
|
18
|
+
// personal access tokens (classic and fine-grained), AWS access key ids and Stripe live secret keys; the advisory table
|
|
19
|
+
// is three real advisories (lodash, minimist, axios); an artifact's size is its name's length in KiB, a cache's its
|
|
20
|
+
// key's.
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { actionsId, diffFilesInPlane, readGitTagFromPlane, planeAncestors, toGithubIssue, GITHUB_WORLD_USER, planeEntryAt, readGitBlobFromPlane, readGitTreeFromPlane, readGitCommitFromPlane, recordRuleSuite, rulesetsFor, } from "../github-twin.js";
|
|
23
|
+
import { gh, put } from "./shared.js";
|
|
24
|
+
const ZERO = '0'.repeat(40);
|
|
25
|
+
const WORKFLOW_PATH = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
26
|
+
/** Secret patterns GitHub's secret scanning recognises, with its type names. */
|
|
27
|
+
const SECRETS = [
|
|
28
|
+
{ type: 'github_personal_access_token', display: 'GitHub Personal Access Token', pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
29
|
+
{ type: 'github_personal_access_token', display: 'GitHub Personal Access Token', pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
|
|
30
|
+
{ type: 'aws_access_key_id', display: 'Amazon AWS Access Key ID', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
31
|
+
{ type: 'stripe_api_key', display: 'Stripe API Key', pattern: /\bsk_live_[0-9A-Za-z]{24,}\b/g },
|
|
32
|
+
];
|
|
33
|
+
/** Advisories Dependabot knows: the package, the first fixed version, the advisory. */
|
|
34
|
+
const ADVISORIES = [
|
|
35
|
+
{ name: 'lodash', fixed: '4.17.21', ghsa: 'GHSA-35jh-r3h4-6jhm', severity: 'high', summary: 'Command Injection in lodash' },
|
|
36
|
+
{ name: 'minimist', fixed: '1.2.6', ghsa: 'GHSA-xvch-5gv4-984h', severity: 'critical', summary: 'Prototype Pollution in minimist' },
|
|
37
|
+
{ name: 'axios', fixed: '1.6.0', ghsa: 'GHSA-wf5p-g6vw-rhxx', severity: 'medium', summary: 'Axios Cross-Site Request Forgery Vulnerability' },
|
|
38
|
+
];
|
|
39
|
+
const versionParts = (v) => v.replace(/^[^0-9]*/, '').split(/[.+-]/).slice(0, 3).map((x) => Number.parseInt(x, 10) || 0);
|
|
40
|
+
const below = (a, b) => {
|
|
41
|
+
const x = versionParts(a);
|
|
42
|
+
const y = versionParts(b);
|
|
43
|
+
for (let i = 0; i < 3; i++)
|
|
44
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
45
|
+
return (x[i] ?? 0) < (y[i] ?? 0);
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const text = (b64) => Buffer.from(b64, 'base64').toString('utf8');
|
|
49
|
+
/** A file's text at a commit, or undefined. */
|
|
50
|
+
async function fileAt(repo, commit, path, root) {
|
|
51
|
+
const hit = await planeEntryAt(repo, commit, path, root);
|
|
52
|
+
if (!hit || hit.kind !== 'file')
|
|
53
|
+
return undefined;
|
|
54
|
+
const blob = await readGitBlobFromPlane(repo, hit.entry.sha, root);
|
|
55
|
+
return blob ? { text: text(blob.content_b64), sha: hit.entry.sha } : undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Every file path in a commit's tree. */
|
|
58
|
+
async function allPaths(repo, commit, root) {
|
|
59
|
+
const c = await readGitCommitFromPlane(repo, commit, root);
|
|
60
|
+
if (!c)
|
|
61
|
+
return [];
|
|
62
|
+
const out = [];
|
|
63
|
+
const walk = async (tree, prefix) => {
|
|
64
|
+
for (const e of (await readGitTreeFromPlane(repo, tree, root)) ?? []) {
|
|
65
|
+
const p = prefix ? `${prefix}/${e.path}` : e.path;
|
|
66
|
+
if (e.type === 'tree')
|
|
67
|
+
await walk(e.sha, p);
|
|
68
|
+
else if (e.type === 'blob')
|
|
69
|
+
out.push(p);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
await walk(c.tree_sha, '');
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** The files a push added or changed, and those it removed. */
|
|
76
|
+
async function changed(repo, before, after, root) {
|
|
77
|
+
if (before === ZERO)
|
|
78
|
+
return { touched: await allPaths(repo, after, root), removed: [] };
|
|
79
|
+
const diff = (await diffFilesInPlane(repo, before, after, root)) ?? [];
|
|
80
|
+
return { touched: diff.filter((f) => f.status !== 'removed').map((f) => f.filename), removed: diff.filter((f) => f.status === 'removed').map((f) => f.filename) };
|
|
81
|
+
}
|
|
82
|
+
const workflowName = (source, path) => /^name:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(source)?.[1] ?? path;
|
|
83
|
+
/** Whether a workflow's `on:` includes `event` (`on: push`, `on: [push, …]`, or a `push` key under `on:`). */
|
|
84
|
+
function listensTo(source, event) {
|
|
85
|
+
const on = /^on:(.*)$/m.exec(source);
|
|
86
|
+
if (!on)
|
|
87
|
+
return false;
|
|
88
|
+
const word = new RegExp(`(?<![\\w-])${event}(?![\\w-])`);
|
|
89
|
+
if (word.test(on[1]))
|
|
90
|
+
return true;
|
|
91
|
+
for (const line of source.slice((on.index ?? 0) + on[0].length).split('\n').slice(1)) {
|
|
92
|
+
if (line.trim() && !/^[ \t]/.test(line))
|
|
93
|
+
break;
|
|
94
|
+
if (new RegExp(`^[ \\t]+-?\\s*${event}\\s*:?\\s*$`).test(line) || new RegExp(`^[ \\t]+${event}\\s*:`).test(line))
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
/** The list a workflow's `on: push:` gives under `key` (`branches`, `tags`, `branches-ignore`, `tags-ignore`), inline
|
|
100
|
+
* (`[a, b]`) or as `- a` lines, or undefined when it gives none. */
|
|
101
|
+
function pushFilter(source, key) {
|
|
102
|
+
const lines = source.split('\n');
|
|
103
|
+
const on = lines.findIndex((l) => /^on:\s*$/.test(l));
|
|
104
|
+
if (on === -1)
|
|
105
|
+
return undefined;
|
|
106
|
+
const push = lines.findIndex((l, i) => i > on && /^[ \t]+push:\s*$/.test(l));
|
|
107
|
+
if (push === -1)
|
|
108
|
+
return undefined;
|
|
109
|
+
const depth = (l) => /^[ \t]*/.exec(l)[0].length;
|
|
110
|
+
for (let i = push + 1; i < lines.length && (!lines[i].trim() || depth(lines[i]) > depth(lines[push])); i++) {
|
|
111
|
+
const m = new RegExp(`^([ \\t]+)${key}:\\s*(.*)$`).exec(lines[i]);
|
|
112
|
+
if (!m)
|
|
113
|
+
continue;
|
|
114
|
+
const unquote = (v) => v.trim().replace(/^["']|["']$/g, '');
|
|
115
|
+
if (m[2].trim().startsWith('['))
|
|
116
|
+
return m[2].trim().slice(1, -1).split(',').map(unquote).filter(Boolean);
|
|
117
|
+
const out = [];
|
|
118
|
+
for (let j = i + 1; j < lines.length && /^[ \t]+-\s*/.test(lines[j]) && depth(lines[j]) > m[1].length; j++)
|
|
119
|
+
out.push(unquote(lines[j].replace(/^[ \t]+-\s*/, '')));
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
/** A ref name against a workflow filter's glob: `*` is any run of characters but `/`, `**` any run at all
|
|
125
|
+
* (docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet). */
|
|
126
|
+
const globMatch = (pattern, name) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*').replace(/\?/g, '.?')}$`).test(name);
|
|
127
|
+
/** Whether a push of `ref` runs a workflow: its `on:` includes push, and its branch and tag filters let the ref
|
|
128
|
+
* through. Defining only branch filters runs it for no tag, only tag filters for no branch, neither for both
|
|
129
|
+
* (docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions#onpushbranchestagsbranches-ignoretags-ignore). */
|
|
130
|
+
function onPushOf(source, ref) {
|
|
131
|
+
if (!listensTo(source, 'push'))
|
|
132
|
+
return false;
|
|
133
|
+
const [branches, branchesIgnore, tags, tagsIgnore] = ['branches', 'branches-ignore', 'tags', 'tags-ignore'].map((k) => pushFilter(source, k));
|
|
134
|
+
const isTag = ref.startsWith('refs/tags/');
|
|
135
|
+
const name = ref.replace(/^refs\/(heads|tags)\//, '');
|
|
136
|
+
const [only, ignore, otherOnly, otherIgnore] = isTag ? [tags, tagsIgnore, branches, branchesIgnore] : [branches, branchesIgnore, tags, tagsIgnore];
|
|
137
|
+
if (!only && !ignore)
|
|
138
|
+
return !otherOnly && !otherIgnore;
|
|
139
|
+
if (only)
|
|
140
|
+
return only.some((p) => globMatch(p, name));
|
|
141
|
+
return !ignore.some((p) => globMatch(p, name));
|
|
142
|
+
}
|
|
143
|
+
/** A workflow's jobs, in file order: each job's id (its key under `jobs:`) and the name GitHub shows for it, its `name:`
|
|
144
|
+
* or else its id (docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idname). */
|
|
145
|
+
export function jobsOf(source) {
|
|
146
|
+
const at = /^jobs:\s*$/m.exec(source);
|
|
147
|
+
if (!at)
|
|
148
|
+
return [];
|
|
149
|
+
const out = [];
|
|
150
|
+
let indent;
|
|
151
|
+
let child; // the indentation of a job's own keys
|
|
152
|
+
for (const line of source.slice((at.index ?? 0) + at[0].length).split('\n').slice(1)) {
|
|
153
|
+
if (line.trim() && !/^[ \t]/.test(line))
|
|
154
|
+
break;
|
|
155
|
+
if (!line.trim())
|
|
156
|
+
continue;
|
|
157
|
+
const depth = /^[ \t]*/.exec(line)[0].length;
|
|
158
|
+
const key = /^[ \t]+([A-Za-z_][\w-]*):\s*$/.exec(line);
|
|
159
|
+
if (key && (indent === undefined || depth === indent)) {
|
|
160
|
+
indent = depth;
|
|
161
|
+
child = undefined;
|
|
162
|
+
out.push({ id: key[1], name: key[1] });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
child ??= depth;
|
|
166
|
+
const name = /^[ \t]+name:\s*["']?([^"'\n]+?)["']?\s*$/.exec(line);
|
|
167
|
+
if (name && depth === child && out.length)
|
|
168
|
+
out.at(-1).name = name[1];
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** The permissions a workflow grants its GITHUB_TOKEN: its `permissions:` blocks (`contents: write`, or `read-all` /
|
|
173
|
+
* `write-all`), over the default a repository created today has, read access to contents and packages
|
|
174
|
+
* (docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token). */
|
|
175
|
+
export function tokenPermissions(source) {
|
|
176
|
+
const out = { contents: 'read', metadata: 'read', packages: 'read' };
|
|
177
|
+
const lines = source.split('\n');
|
|
178
|
+
for (const [i, line] of lines.entries()) {
|
|
179
|
+
const m = /^([ \t]*)permissions:\s*(\S*)\s*$/.exec(line);
|
|
180
|
+
if (!m)
|
|
181
|
+
continue;
|
|
182
|
+
if (m[2] === 'write-all' || m[2] === 'read-all')
|
|
183
|
+
for (const k of ['actions', 'checks', 'contents', 'deployments', 'issues', 'packages', 'pages', 'pull-requests', 'statuses'])
|
|
184
|
+
out[k] = m[2].slice(0, -4);
|
|
185
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
186
|
+
const kv = /^([ \t]+)([a-z-]+):\s*(read|write|none)\s*$/.exec(lines[j]);
|
|
187
|
+
if (!kv || kv[1].length <= m[1].length)
|
|
188
|
+
break;
|
|
189
|
+
out[kv[2]] = kv[3];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** The runner labels a workflow's job asks for (`runs-on: ubuntu-latest`, or a list of them), read from its file. */
|
|
195
|
+
function runsOn(st, repo, wf) {
|
|
196
|
+
const defaultBranch = st.repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
197
|
+
const file = st.contents.find((c) => c.repository === repo && c.path === wf.path && (c.branch ?? defaultBranch) === defaultBranch);
|
|
198
|
+
const source = file ? Buffer.from(file.content_b64 ?? '', 'base64').toString('utf8') : '';
|
|
199
|
+
const value = /^\s+runs-on:\s*(.+?)\s*$/m.exec(source)?.[1];
|
|
200
|
+
if (!value)
|
|
201
|
+
return [];
|
|
202
|
+
return value.startsWith('[') ? value.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean) : [value.replace(/^["']|["']$/g, '')];
|
|
203
|
+
}
|
|
204
|
+
/** A queued run of a workflow, with a job per job of its file at the run's commit (one named for the workflow when
|
|
205
|
+
* the file cannot be read): what a dispatch, a push or a pull request starts. A run from a fork that needs a
|
|
206
|
+
* maintainer's approval first is created waiting, `action_required`, with no jobs until it is approved
|
|
207
|
+
* (approveRun). */
|
|
208
|
+
export async function queueRun(ctx, st, repo, wf, branch, headSha, event, actor, opts = {}) {
|
|
209
|
+
let runNumber = 0;
|
|
210
|
+
for (const r of st.workflowRuns)
|
|
211
|
+
if (r.repository === repo && r.workflow_id === wf.id)
|
|
212
|
+
runNumber = Math.max(runNumber, r.run_number);
|
|
213
|
+
runNumber += 1;
|
|
214
|
+
const runId = actionsId(`${repo}:run:${wf.id}:${runNumber}`);
|
|
215
|
+
const at = ctx.occurredAt;
|
|
216
|
+
const waiting = opts.awaitingApproval === true;
|
|
217
|
+
await put(ctx, { operation: `workflow_run.${event === 'push' ? 'push' : event === 'pull_request' ? 'pull_request' : 'dispatch'}`, subjectType: 'workflow_run', subjectId: `run:${runId}`, fields: { repository: repo, workflow_id: wf.id, name: wf.name, run_number: runNumber, event, status: waiting ? 'completed' : 'queued', conclusion: waiting ? 'action_required' : null, head_sha: headSha, head_branch: branch, created_at: at, updated_at: at, ...(actor ? { actor_login: actor } : {}), ...(opts.headRepo && opts.headRepo !== repo ? { head_repository: opts.headRepo } : {}) } });
|
|
218
|
+
if (!waiting)
|
|
219
|
+
await queueJobs(ctx, st, repo, wf, runId, runNumber, branch, headSha);
|
|
220
|
+
return runId;
|
|
221
|
+
}
|
|
222
|
+
/** The check run an Actions job reports on its commit: named for the job, under the GitHub Actions app's check suite,
|
|
223
|
+
* with the job's status and conclusion, and the job's id (a job's check_run_url names it)
|
|
224
|
+
* (docs.github.com/rest/guides/using-the-rest-api-to-interact-with-checks: GitHub Actions reports each job as a check
|
|
225
|
+
* run; docs.github.com/rest/actions/workflow-jobs). */
|
|
226
|
+
export function actionsCheckRun(job, status, conclusion, at) {
|
|
227
|
+
return {
|
|
228
|
+
operation: status === 'queued' ? 'check_run.create' : 'check_run.update', subjectType: 'check_run', subjectId: `check:${job.id}`,
|
|
229
|
+
fields: { repository: job.repository, head_sha: job.head_sha, name: job.name, status, conclusion, app_slug: 'github-actions', details_url: `https://github.com/${job.repository}/actions/runs/${job.run_id}/job/${job.id}`, ...(status === 'queued' ? { started_at: at, completed_at: null } : { completed_at: at }) },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/** A run's jobs, queued: one per job of the workflow file at the run's commit. The first keeps the id a run's single
|
|
233
|
+
* job has always had. */
|
|
234
|
+
export async function queueJobs(ctx, st, repo, wf, runId, runNumber, branch, headSha) {
|
|
235
|
+
const file = await fileAt(repo, headSha, wf.path, ctx.root);
|
|
236
|
+
const jobs = file ? jobsOf(file.text) : [];
|
|
237
|
+
const named = jobs.length ? jobs : [{ id: '', name: wf.name }];
|
|
238
|
+
for (const [i, job] of named.entries()) {
|
|
239
|
+
const jobId = actionsId(i === 0 ? `${repo}:job:${wf.id}:${runNumber}` : `${repo}:job:${wf.id}:${runNumber}:${job.id}`);
|
|
240
|
+
await put(ctx, { operation: 'workflow_job.create', subjectType: 'workflow_job', subjectId: `job:${jobId}`, fields: { run_id: runId, repository: repo, name: job.name, status: 'queued', conclusion: null, head_sha: headSha, steps: [{ name: 'set up job', status: 'queued', number: 1 }], labels: runsOn(st, repo, wf), workflow_name: wf.name, head_branch: branch, created_at: ctx.occurredAt } });
|
|
241
|
+
await put(ctx, actionsCheckRun({ id: jobId, repository: repo, head_sha: headSha, name: job.name, run_id: runId }, 'queued', null, ctx.occurredAt));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** The issue numbers texts link with GitHub's closing keywords, `close`, `closes`, `closed`, `fix`, `fixes`, `fixed`,
|
|
245
|
+
* `resolve`, `resolves` or `resolved` before `#n` (docs.github.com/issues/tracking-your-work-with-issues/using-issues/
|
|
246
|
+
* linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword). */
|
|
247
|
+
export function closingIssueNumbers(texts) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const text of texts)
|
|
250
|
+
for (const m of text.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi))
|
|
251
|
+
if (!out.includes(Number(m[1])))
|
|
252
|
+
out.push(Number(m[1]));
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** Whether a pull request's author needs a maintainer to approve its workflow runs: by default a first-time
|
|
256
|
+
* contributor from a fork, "users who have never had a commit or pull request merged into the repository"
|
|
257
|
+
* (docs.github.com/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/
|
|
258
|
+
* managing-github-actions-settings-for-a-repository#controlling-changes-from-forks-to-workflows-in-public-repositories).
|
|
259
|
+
* Where the documentation stops and the twin decides: someone with write access to the repository (its owner, an
|
|
260
|
+
* owner of its organization, a collaborator with write or more) is not a first-time contributor. */
|
|
261
|
+
export function needsApproval(st, repo, author) {
|
|
262
|
+
if (!author || canWrite(st, repo, author))
|
|
263
|
+
return false;
|
|
264
|
+
return !st.prs.some((p) => p.repository === repo && p.is_pull_request && p.merged && p.user_login === author);
|
|
265
|
+
}
|
|
266
|
+
/** Whether `login` has write access to `repo`: its owner, an owner of its organization, or a collaborator with write
|
|
267
|
+
* or more. */
|
|
268
|
+
export function canWrite(st, repo, login) {
|
|
269
|
+
const owner = repo.split('/')[0];
|
|
270
|
+
if (login.toLowerCase() === owner.toLowerCase())
|
|
271
|
+
return true;
|
|
272
|
+
if (st.orgMembers.some((m) => m.org === owner && m.login === login && m.role === 'admin' && m.state === 'active'))
|
|
273
|
+
return true;
|
|
274
|
+
return st.collaborators.some((c) => c.repository === repo && c.login === login && ['push', 'write', 'maintain', 'admin'].includes(c.permission));
|
|
275
|
+
}
|
|
276
|
+
/** A pull request opened, reopened or pushed to runs the base repository's workflows whose `on:` includes
|
|
277
|
+
* pull_request, as its head commit has them (docs.github.com/actions/writing-workflows/choosing-when-your-workflow-
|
|
278
|
+
* runs/events-that-trigger-workflows#pull_request: by default on opened, synchronize and reopened). A first-time
|
|
279
|
+
* contributor's run from a fork waits for approval (needsApproval). */
|
|
280
|
+
export async function reactToPullRequest(ctx, repo, number, action) {
|
|
281
|
+
if (!['opened', 'synchronize', 'reopened'].includes(action))
|
|
282
|
+
return;
|
|
283
|
+
const { root, state, caller } = gh(ctx);
|
|
284
|
+
const pr = state().prs.find((p) => p.repository === repo && p.number === number && p.is_pull_request);
|
|
285
|
+
if (!pr?.head_sha || (pr.state ?? 'open') !== 'open')
|
|
286
|
+
return;
|
|
287
|
+
const fromFork = pr.head_repo !== undefined && pr.head_repo !== repo;
|
|
288
|
+
const waiting = fromFork && needsApproval(state(), repo, pr.user_login);
|
|
289
|
+
for (const wf of state().workflows.filter((w) => w.repository === repo && w.state === 'active')) {
|
|
290
|
+
const file = await fileAt(repo, pr.head_sha, wf.path, root);
|
|
291
|
+
if (file && listensTo(file.text, 'pull_request'))
|
|
292
|
+
await queueRun(ctx, state(), repo, wf, pr.head_ref ?? '', pr.head_sha, 'pull_request', caller ?? pr.user_login ?? undefined, { awaitingApproval: waiting, ...(pr.head_repo ? { headRepo: pr.head_repo } : {}) });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** A direct push to a branch whose rulesets require pull requests fails those rules, and the push is refused when
|
|
296
|
+
* one is active (docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/
|
|
297
|
+
* available-rules-for-rulesets#require-a-pull-request-before-merging). In evaluate mode the push lands and its rule
|
|
298
|
+
* suite records what would have failed (docs.github.com/.../managing-rulesets/managing-rulesets-for-a-repository#viewing-insights-for-rulesets). */
|
|
299
|
+
export function directPushRules(st, repo, branch) {
|
|
300
|
+
const evaluations = [];
|
|
301
|
+
let refused;
|
|
302
|
+
for (const enforcement of ['active', 'evaluate']) {
|
|
303
|
+
for (const failed of rulesetsFor(st, repo, branch, enforcement).map((set) => pullRequestRuleFails(set, enforcement)))
|
|
304
|
+
if (failed) {
|
|
305
|
+
evaluations.push(failed);
|
|
306
|
+
if (enforcement === 'active')
|
|
307
|
+
refused = failed.details;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return { refused, evaluations };
|
|
311
|
+
}
|
|
312
|
+
/** A push that landed past rules only evaluated is on the record as the rule suite it would have failed. */
|
|
313
|
+
async function recordEvaluatedPush(ctx, repo, ref, before, after, evaluations) {
|
|
314
|
+
const { root, caller } = gh(ctx);
|
|
315
|
+
await recordRuleSuite(repo, { repository: repo, ref, actor_login: caller ?? GITHUB_WORLD_USER, before_sha: before, after_sha: after, pushed_at: ctx.occurredAt, result: 'fail', rules: evaluations }, ctx.occurredAt, root, (w) => put(ctx, w));
|
|
316
|
+
}
|
|
317
|
+
/** A token secret scanning recognises in a pushed file: an alert, with where it was found, unless one already holds it. */
|
|
318
|
+
async function secretFound(ctx, repo, path, file, s, m, after) {
|
|
319
|
+
const { state } = gh(ctx);
|
|
320
|
+
const at = ctx.occurredAt;
|
|
321
|
+
const digest = createHash('sha256').update(m[0]).digest('hex');
|
|
322
|
+
const alerts = state().secretScanningAlerts.filter((a) => a.repository === repo);
|
|
323
|
+
if (alerts.some((a) => a._secret === digest))
|
|
324
|
+
return;
|
|
325
|
+
const number = Math.max(0, ...alerts.map((a) => a.number)) + 1;
|
|
326
|
+
const line = file.text.slice(0, m.index ?? 0).split('\n').length;
|
|
327
|
+
const column = (m.index ?? 0) - file.text.slice(0, m.index ?? 0).lastIndexOf('\n');
|
|
328
|
+
await put(ctx, {
|
|
329
|
+
operation: 'secret_scanning_alert.create', subjectType: 'secret_scanning_alert', subjectId: `secretscan:${repo}#${number}`,
|
|
330
|
+
fields: { number, repository: repo, state: 'open', secret_type: s.type, secret_type_display_name: s.display, created_at: at, updated_at: at, _secret: digest,
|
|
331
|
+
locations: [{ path, start_line: line, end_line: line, start_column: column, end_column: column + m[0].length, blob_sha: file.sha, commit_sha: after }] },
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** A ruleset's pull_request rule, which a direct push fails. */
|
|
335
|
+
function pullRequestRuleFails(set, enforcement) {
|
|
336
|
+
if (!set.rules.some((r) => r.type === 'pull_request'))
|
|
337
|
+
return undefined;
|
|
338
|
+
return { ruleset_id: set.id, ruleset_name: set.name, rule_type: 'pull_request', enforcement, result: 'fail', details: 'Changes must be made through a pull request.' };
|
|
339
|
+
}
|
|
340
|
+
/** GitHub's reactions to a branch of `repo` moving from `before` to `after`. */
|
|
341
|
+
export async function reactToPush(ctx, repo, ref, before, after) {
|
|
342
|
+
const { root, state, caller } = gh(ctx);
|
|
343
|
+
// a pushed tag runs the workflows whose push filters take it, as the tagged commit has them (an annotated tag is
|
|
344
|
+
// peeled to its commit); the run's branch is the tag's name
|
|
345
|
+
if (ref.startsWith('refs/tags/')) {
|
|
346
|
+
if (after === ZERO)
|
|
347
|
+
return;
|
|
348
|
+
const commit = (await readGitTagFromPlane(repo, after, root))?.object_sha ?? after;
|
|
349
|
+
for (const wf of state().workflows.filter((w) => w.repository === repo && w.state === 'active')) {
|
|
350
|
+
const file = await fileAt(repo, commit, wf.path, root);
|
|
351
|
+
if (file && onPushOf(file.text, ref))
|
|
352
|
+
await queueRun(ctx, state(), repo, wf, ref.slice('refs/tags/'.length), commit, 'push', caller);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!ref.startsWith('refs/heads/'))
|
|
357
|
+
return;
|
|
358
|
+
const branch = ref.slice('refs/heads/'.length);
|
|
359
|
+
// A branch's files, as the Contents API keeps a receipt for each: a move writes the files it touched and drops
|
|
360
|
+
// those it removed, however it was made (an API commit, the Git Data API, a git push), and a deleted branch
|
|
361
|
+
// drops them all. The repository's language, size and licence, its code search and a workflow's runners read the
|
|
362
|
+
// default branch's.
|
|
363
|
+
const receipt = (path) => `content:${repo}#${branch}:${path}`;
|
|
364
|
+
const drop = (path) => put(ctx, { operation: 'content_file.delete', subjectType: 'content_file', subjectId: receipt(path), fields: { deleted: true, repository: repo, path, branch } });
|
|
365
|
+
if (after === ZERO) {
|
|
366
|
+
for (const c of state().contents.filter((x) => x.repository === repo && x.branch === branch))
|
|
367
|
+
await drop(c.path);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const defaultBranch = state().repos.find((r) => r.full_name === repo)?.default_branch ?? 'main';
|
|
371
|
+
// a push that landed past rules only evaluated is on the record as the rule suite it would have failed
|
|
372
|
+
const rules = directPushRules(state(), repo, branch);
|
|
373
|
+
if (rules.evaluations.length)
|
|
374
|
+
await recordEvaluatedPush(ctx, repo, ref, before, after, rules.evaluations);
|
|
375
|
+
const { touched, removed } = await changed(repo, before, after, root);
|
|
376
|
+
for (const path of touched) {
|
|
377
|
+
const hit = await planeEntryAt(repo, after, path, root);
|
|
378
|
+
const blob = hit?.kind === 'file' ? await readGitBlobFromPlane(repo, hit.entry.sha, root) : null;
|
|
379
|
+
if (hit?.kind === 'file' && blob)
|
|
380
|
+
await put(ctx, { operation: 'content_file.update', subjectType: 'content_file', subjectId: receipt(path), fields: { repository: repo, path, branch, sha: hit.entry.sha, content_b64: blob.content_b64, size: blob.size, deleted: false } });
|
|
381
|
+
}
|
|
382
|
+
for (const path of removed)
|
|
383
|
+
await drop(path);
|
|
384
|
+
const at = ctx.occurredAt;
|
|
385
|
+
// secret scanning reads every pushed file, on any branch
|
|
386
|
+
for (const path of touched) {
|
|
387
|
+
const file = await fileAt(repo, after, path, root);
|
|
388
|
+
if (!file)
|
|
389
|
+
continue;
|
|
390
|
+
for (const s of SECRETS) {
|
|
391
|
+
for (const m of file.text.matchAll(s.pattern))
|
|
392
|
+
await secretFound(ctx, repo, path, file, s, m, after);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// workflows live on the default branch
|
|
396
|
+
for (const path of branch === defaultBranch ? [...touched, ...removed].filter((p) => WORKFLOW_PATH.test(p)) : []) {
|
|
397
|
+
// a workflow keeps its id when its repository is transferred and renamed
|
|
398
|
+
const existing = state().workflows.find((w) => w.repository === repo && w.path === path);
|
|
399
|
+
const id = existing?.id ?? actionsId(`${repo}:workflow:${path}`);
|
|
400
|
+
const file = removed.includes(path) ? undefined : await fileAt(repo, after, path, root);
|
|
401
|
+
await put(ctx, {
|
|
402
|
+
operation: file ? 'workflow.register' : 'workflow.delete', subjectType: 'workflow', subjectId: `workflow:${id}`,
|
|
403
|
+
fields: { repository: repo, name: file ? workflowName(file.text, path) : (existing?.name ?? path), path, state: file ? 'active' : 'deleted', created_at: existing?.created_at ?? at, updated_at: at },
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// a push to any branch runs every workflow of the pushed commit that listens for pushes to it
|
|
407
|
+
for (const wf of state().workflows.filter((w) => w.repository === repo && w.state === 'active')) {
|
|
408
|
+
const file = await fileAt(repo, after, wf.path, root);
|
|
409
|
+
if (file && onPushOf(file.text, ref))
|
|
410
|
+
await queueRun(ctx, state(), repo, wf, branch, after, 'push', caller);
|
|
411
|
+
}
|
|
412
|
+
if (branch !== defaultBranch)
|
|
413
|
+
return;
|
|
414
|
+
// a commit pushed to the default branch closes the issues its message links with a closing keyword
|
|
415
|
+
// (docs.github.com/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue)
|
|
416
|
+
if (before !== ZERO) {
|
|
417
|
+
const held = await planeAncestors(repo, before, root);
|
|
418
|
+
const messages = [];
|
|
419
|
+
for (const sha of (await planeAncestors(repo, after, root)).keys())
|
|
420
|
+
if (!held.has(sha))
|
|
421
|
+
messages.push((await readGitCommitFromPlane(repo, sha, root))?.message ?? '');
|
|
422
|
+
for (const n of closingIssueNumbers(messages)) {
|
|
423
|
+
const issue = state().issues.find((i) => i.repository === repo && i.number === n);
|
|
424
|
+
// the close is the issue's move by the push (manifest.ts): a git push is another wire's (`external`)
|
|
425
|
+
const by = ctx.call.operation.id;
|
|
426
|
+
if (!issue || (issue.state ?? 'open') !== 'open' || ctx.legal('issue', 'state', by, issue.state ?? 'open', 'closed', issue.id, by === 'git-receive-pack' ? 'external' : 'api'))
|
|
427
|
+
continue;
|
|
428
|
+
await put(ctx, { operation: 'issue.update', subjectType: 'issue', subjectId: issue.id, fields: { number: n, repository: repo, state: 'closed', state_reason: 'completed', updated_at: at } });
|
|
429
|
+
const now = state();
|
|
430
|
+
const closed = now.issues.find((i) => i.id === issue.id);
|
|
431
|
+
const { announce } = await import("./shared.js");
|
|
432
|
+
await announce(ctx, [{ event: 'issues', action: 'closed', repository: repo, number: n, ...(closed ? { issue: toGithubIssue(closed, now.milestones, undefined, now.comments) } : {}) }]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Dependabot, when its alerts are on, reads the manifests the push changed
|
|
436
|
+
if (dependabotOn(ctx, repo))
|
|
437
|
+
await dependabotReads(ctx, repo, after, [...touched, ...removed].filter((p) => p === 'package.json' || p.endsWith('/package.json')), removed);
|
|
438
|
+
}
|
|
439
|
+
/** Whether Dependabot alerts are on for a repository: off until someone with admin access turns them on
|
|
440
|
+
* (docs.github.com/code-security/dependabot/dependabot-alerts/configuring-dependabot-alerts,
|
|
441
|
+
* docs.github.com/rest/repos/repos#enable-vulnerability-alerts). */
|
|
442
|
+
export function dependabotOn(ctx, repo) {
|
|
443
|
+
return ctx.tree().some((r) => r.type === 'vulnerability_alerts' && r.repository === repo && r.enabled === true && r.deleted !== true);
|
|
444
|
+
}
|
|
445
|
+
/** Dependabot reads package.json manifests at a commit of the default branch: an alert opens for a dependency an
|
|
446
|
+
* advisory names at a vulnerable version, and an open one is fixed once the manifest no longer asks for one
|
|
447
|
+
* (docs.github.com/code-security/dependabot/dependabot-alerts/about-dependabot-alerts). */
|
|
448
|
+
export async function dependabotReads(ctx, repo, commit, manifests, removed = []) {
|
|
449
|
+
const { root, state } = gh(ctx);
|
|
450
|
+
const at = ctx.occurredAt;
|
|
451
|
+
for (const manifestPath of manifests) {
|
|
452
|
+
const file = removed.includes(manifestPath) ? undefined : await fileAt(repo, commit, manifestPath, root);
|
|
453
|
+
let deps = {};
|
|
454
|
+
try {
|
|
455
|
+
const pkg = file ? JSON.parse(file.text) : {};
|
|
456
|
+
deps = { ...(pkg.devDependencies ?? {}), ...(pkg.dependencies ?? {}) };
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
deps = {};
|
|
460
|
+
}
|
|
461
|
+
for (const adv of ADVISORIES) {
|
|
462
|
+
const alerts = state().dependabotAlerts.filter((a) => a.repository === repo);
|
|
463
|
+
const current = alerts.find((a) => a.package_name === adv.name && a.manifest_path === manifestPath && a.state !== 'fixed');
|
|
464
|
+
const wanted = deps[adv.name];
|
|
465
|
+
const vulnerable = wanted !== undefined && below(wanted, adv.fixed);
|
|
466
|
+
if (vulnerable && !current) {
|
|
467
|
+
const number = Math.max(0, ...alerts.map((a) => a.number)) + 1;
|
|
468
|
+
await put(ctx, { operation: 'dependabot_alert.create', subjectType: 'dependabot_alert', subjectId: `dependabot:${repo}#${number}`,
|
|
469
|
+
fields: { number, repository: repo, state: 'open', package_name: adv.name, ecosystem: 'npm', ghsa_id: adv.ghsa, severity: adv.severity, summary: adv.summary, manifest_path: manifestPath, created_at: at, updated_at: at } });
|
|
470
|
+
}
|
|
471
|
+
else if (!vulnerable && current && current.state === 'open') {
|
|
472
|
+
await put(ctx, { operation: 'dependabot_alert.update', subjectType: 'dependabot_alert', subjectId: `dependabot:${repo}#${current.number}`, fields: { ...current, state: 'fixed', updated_at: at } });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Every package.json at a commit, for Dependabot's first read when its alerts are turned on. */
|
|
478
|
+
export async function manifestsAt(repo, commit, root) {
|
|
479
|
+
return (await allPaths(repo, commit, root)).filter((p) => p === 'package.json' || p.endsWith('/package.json'));
|
|
480
|
+
}
|
|
481
|
+
/** An actions/cache step's cache, saved under its key when the job ends. */
|
|
482
|
+
async function saveCache(repo, run, step, write, nextId, at) {
|
|
483
|
+
const key = /\bkey:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(step)?.[1];
|
|
484
|
+
if (!key)
|
|
485
|
+
return;
|
|
486
|
+
const id = nextId('cache');
|
|
487
|
+
await write({ operation: 'actions_cache.create', subjectType: 'actions_cache', subjectId: `cache:${id}`, fields: { repository: repo, key, ref: `refs/heads/${run.head_branch ?? 'main'}`, size_in_bytes: key.length * 1024, created_at: at, last_accessed_at: at } });
|
|
488
|
+
}
|
|
489
|
+
/** What a run's workflow steps uploaded, read from the workflow file at the run's commit: its artifacts, uploaded while
|
|
490
|
+
* it runs, and its caches, saved when its job ends. */
|
|
491
|
+
export async function runOutputs(repo, run, st, root, write, nextId, at, what = { artifacts: true, caches: true }) {
|
|
492
|
+
const wf = st.workflows.find((w) => w.id === run.workflow_id);
|
|
493
|
+
if (!wf)
|
|
494
|
+
return;
|
|
495
|
+
const file = await fileAt(repo, run.head_sha, wf.path, root);
|
|
496
|
+
if (!file)
|
|
497
|
+
return;
|
|
498
|
+
// each `uses:` step and the `with:` block under it
|
|
499
|
+
const steps = file.text.split(/\n(?=\s*-\s)/);
|
|
500
|
+
for (const step of steps) {
|
|
501
|
+
const uses = /uses:\s*([^\s@]+)@/.exec(step)?.[1];
|
|
502
|
+
// an artifact is uploaded by its step, while the run is under way; upload-pages-artifact's is named github-pages
|
|
503
|
+
// unless its step names it (github.com/actions/upload-pages-artifact)
|
|
504
|
+
if (what.artifacts && (uses === 'actions/upload-artifact' || uses === 'actions/upload-pages-artifact')) {
|
|
505
|
+
const named = step.includes('with:') ? /\bname:\s*["']?([^"'\n]+?)["']?\s*$/m.exec(step.slice(step.indexOf('with:')))?.[1] : undefined;
|
|
506
|
+
const name = named ?? (uses === 'actions/upload-pages-artifact' ? 'github-pages' : 'artifact');
|
|
507
|
+
const id = nextId('artifact');
|
|
508
|
+
await write({ operation: 'actions_artifact.create', subjectType: 'actions_artifact', subjectId: `artifact:${id}`, fields: { repository: repo, name, size_in_bytes: name.length * 1024, run_id: run.id, expired: false, created_at: at } });
|
|
509
|
+
}
|
|
510
|
+
if (what.caches && uses === 'actions/cache')
|
|
511
|
+
await saveCache(repo, run, step, write, nextId, at);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
3
|
+
/** GitHub's generated release notes for `tag` (docs.github.com/repositories/releasing-projects-on-github/automatically-
|
|
4
|
+
* generated-release-notes, docs.github.com/rest/releases/releases#generate-release-notes-content-for-a-release): the
|
|
5
|
+
* pull requests merged since the previous release, each with its author, the contributors whose first merged pull
|
|
6
|
+
* request is among them, and the full changelog's link. The previous release is the latest published one other than
|
|
7
|
+
* this tag's, unless `previous` names a tag. Where the documentation stops and the twin decides: a repository's
|
|
8
|
+
* release.yml categories are not read. */
|
|
9
|
+
export declare function generatedNotes(ctx: SemanticsContext, tag: string, target: string | undefined, previous: string | undefined): Promise<{
|
|
10
|
+
name: string;
|
|
11
|
+
body: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** A release asset's upload as it arrived: its raw bytes and their media type, which the server hands the upload
|
|
14
|
+
* operation (github-server.ts) since GitHub reads this body as the file, not as JSON. */
|
|
15
|
+
export declare const assetUploads: AsyncLocalStorage<{
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
type: string | null;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function releasesSemantics(): Record<string, Semantics>;
|