@vzn/vx-ci 0.0.646 → 0.0.648
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 +98 -46
- package/package.json +4 -5
- package/src/cache-scope.ts +83 -17
- package/src/gitlab.ts +52 -17
- package/src/index.ts +30 -7
- package/src/plugin.ts +30 -101
- package/src/setup.ts +208 -0
- package/src/sink.ts +5 -28
- package/src/summary.ts +129 -10
- package/src/checks.ts +0 -199
package/README.md
CHANGED
|
@@ -24,12 +24,23 @@ export default defineWorkspace({
|
|
|
24
24
|
That's the whole setup. On a GitHub Actions runner (`GITHUB_STEP_SUMMARY`
|
|
25
25
|
set) every `vx run` appends a summary block, on its own line after
|
|
26
26
|
anything already written in the step: verdict headline, stats
|
|
27
|
-
(tasks / executed / cache hits / duration
|
|
28
|
-
|
|
27
|
+
(tasks / executed / cache hits / duration, and the ideal time an executor says
|
|
28
|
+
the run could have taken, as `@vzn/vx-dte` replays it), failures called out above the
|
|
29
|
+
per-task table (rows by status, failures first, then the longest first) with their exit code, the signal an exit above 128
|
|
29
30
|
stands for (`exit 137 (128 + SIGKILL)`, as the run's own frame and
|
|
30
31
|
`vx last` say it), a timeout as `timed out, exit 143`, a persistent task
|
|
31
32
|
that never became ready as `never ready (timed out)`, a sandboxed task's
|
|
32
|
-
violation count, and the tasks each failure blocked. A
|
|
33
|
+
violation count, and the tasks each failure blocked. A server a
|
|
34
|
+
DTE run carried to its dependants' workers reads `served`,
|
|
35
|
+
with no duration of its own. When an executor
|
|
36
|
+
ran the run on other machines (`@vzn/vx-dte`'s workers), a Workers table
|
|
37
|
+
shows each one's status, when it was asked for, its total time (warm-up
|
|
38
|
+
plus online), its warm-up, its time online after the warm-up and the part
|
|
39
|
+
of that with no task on it (a task counts from its send to its result,
|
|
40
|
+
restore and save included), its tasks, the cores
|
|
41
|
+
its tasks were expected to use (their last run's) beside the cores it used, its peak RAM of its memory, and
|
|
42
|
+
its utilization, under a line
|
|
43
|
+
of the expected and used share of all their core-time (the expected share over the workers whose executor reports one). Tasks the executor left to the run's own machine get a `this machine` row, and a line splits the executed count between workers and this machine. A footer line
|
|
33
44
|
names the vx version, the command (what follows `--` counted, not
|
|
34
45
|
quoted), tasks passed and outputs restored.
|
|
35
46
|
Anywhere else (`GITHUB_ACTIONS` not `true`: laptops, other CI)
|
|
@@ -41,15 +52,12 @@ checked and nothing loads, so declaring it unconditionally is safe.
|
|
|
41
52
|
The options type is `GithubPluginOptions`; `renderJobSummary` renders the
|
|
42
53
|
summary lines the plugin posts (the site's CI guide sample is rendered from it).
|
|
43
54
|
The package exports `github`, `gitlab`, `renderJobSummary` and the types
|
|
44
|
-
`GithubPluginOptions
|
|
45
|
-
they replace the Checks API transport, the summary writer and its size probe.
|
|
55
|
+
`GithubPluginOptions` and `GitlabPluginOptions`; nothing else.
|
|
46
56
|
|
|
47
57
|
```ts
|
|
48
58
|
github({
|
|
49
59
|
summaryFile: '/path/override.md', // default: $GITHUB_STEP_SUMMARY
|
|
50
60
|
title: 'build & test', // default: 'vx run'
|
|
51
|
-
checks: true, // default: on with GITHUB_TOKEN; true warns when it is missing, false opts out
|
|
52
|
-
checkName: 'ci', // default: 'vx', the check run's name
|
|
53
61
|
cacheScope: false, // default: on; scope plugin cache entries by ref
|
|
54
62
|
})
|
|
55
63
|
```
|
|
@@ -59,20 +67,32 @@ github({
|
|
|
59
67
|
On Actions (`GITHUB_ACTIONS=true`) the plugin's `config` stage names the
|
|
60
68
|
run's branch through `ctx.setBranch`, from the ref (`GITHUB_REF`,
|
|
61
69
|
`GITHUB_REF_NAME`, `GITHUB_HEAD_REF`, `GITHUB_REPOSITORY`) and the
|
|
62
|
-
`GITHUB_EVENT_PATH` payload. A push to the default branch
|
|
70
|
+
`GITHUB_EVENT_PATH` payload. A push to the default branch (the
|
|
71
|
+
workspace's `baseBranch`, else the event's) names no scope.
|
|
63
72
|
A push to, or a pull request from, a branch names
|
|
64
73
|
`<owner>/<repo>:<branch>` of the repository the branch lives in, so a
|
|
65
74
|
fork's pull request names the fork; so does a `pull_request_target` or
|
|
66
75
|
`workflow_run` run on a PR's behalf (by `GITHUB_EVENT_NAME`, though its
|
|
67
76
|
ref is main's), and an `issue_comment` on a PR, which names no branch,
|
|
68
|
-
`pr-<n>`. A merge queue's run (`merge_group`) is trusted
|
|
69
|
-
|
|
77
|
+
`pr-<n>`. A merge queue's run (`merge_group`) is trusted, whatever its
|
|
78
|
+
`gh-readonly-queue/...` branch: no scope, compared with
|
|
70
79
|
`merge_group.base_sha`. A pull request also names its base commit
|
|
71
80
|
(`pull_request.base.sha`), the base of a bare `--affected`. The scope
|
|
72
81
|
keys only what plugins store themselves (`ctx.cache`, run records):
|
|
73
82
|
task keys never carry it, and the cache server decides from the CI OIDC
|
|
74
83
|
token where a task's entry lands. `VX_CACHE_SCOPE` overrides it.
|
|
75
84
|
|
|
85
|
+
It also names the refs a remote cache reads, in order (`refs`): a pull
|
|
86
|
+
request's merge ref (`refs/pull/<n>/merge`, the one scope its token
|
|
87
|
+
writes), then the run's own branch (a pull request's head, a
|
|
88
|
+
`workflow_run` a pull request started included, else `GITHUB_REF`), then
|
|
89
|
+
the branch it merges into (`GITHUB_BASE_REF`, or that `workflow_run`'s
|
|
90
|
+
pull request's base), then the default branch (the
|
|
91
|
+
workspace's `baseBranch`, else the event's). A merge queue's run reads
|
|
92
|
+
only its base branch (`merge_group.base_ref`, else the queue ref's
|
|
93
|
+
`<base>`): it is about to become that branch, and the server writes its
|
|
94
|
+
entries there.
|
|
95
|
+
|
|
76
96
|
## GitLab CI
|
|
77
97
|
|
|
78
98
|
```ts
|
|
@@ -95,50 +115,82 @@ fork's `CI_MERGE_REQUEST_SOURCE_PROJECT_PATH`) and its diff base
|
|
|
95
115
|
(`CI_MERGE_REQUEST_DIFF_BASE_SHA`), the base of a bare `--affected`.
|
|
96
116
|
A merge train's pipeline (`CI_MERGE_REQUEST_EVENT_TYPE=merge_train`)
|
|
97
117
|
builds what lands on the target, so it is trusted: no scope. As on
|
|
98
|
-
Actions, the scope keys only plugin entries.
|
|
118
|
+
Actions, the scope keys only plugin entries. A remote reads the tag, else
|
|
119
|
+
the merge request's source branch, else `CI_COMMIT_REF_NAME`, then the
|
|
120
|
+
target (`CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, an external pull
|
|
121
|
+
request's `CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME`), then the default branch
|
|
122
|
+
(`baseBranch`, else `CI_DEFAULT_BRANCH`); a merge train reads only its
|
|
123
|
+
target. `gitlab({ cacheScope: false })` (`GitlabPluginOptions`) turns it off;
|
|
99
124
|
`VX_CACHE_SCOPE` overrides it. Anywhere else `gitlab()` returns
|
|
100
125
|
`undefined`, so declaring both plugins is safe. Merge request comments
|
|
101
126
|
and reports are not part of it yet.
|
|
102
127
|
|
|
103
|
-
##
|
|
128
|
+
## The sandbox runtime: `vx setup`
|
|
104
129
|
|
|
105
|
-
`
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
never fail or stall the run (core's crash-isolation + flush deadline).
|
|
130
|
+
A task that declares `exec.sandbox` needs the OS sandbox on the machine
|
|
131
|
+
that runs it. On Linux that is bubblewrap, socat, strace and ripgrep,
|
|
132
|
+
with bwrap allowed its user namespaces. One step before vx runs sets it
|
|
133
|
+
up:
|
|
110
134
|
|
|
111
|
-
|
|
135
|
+
```yaml
|
|
136
|
+
# GitHub Actions
|
|
137
|
+
- run: vx setup github
|
|
138
|
+
```
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
```yaml
|
|
141
|
+
# GitLab CI
|
|
142
|
+
script:
|
|
143
|
+
- vx setup gitlab
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It installs what is missing (apt-get by sudo, or apk), lets bwrap make
|
|
147
|
+
user namespaces where AppArmor restricts them (Ubuntu 24+; Ubuntu 26's
|
|
148
|
+
`bwrap-userns-restrict` profile is replaced, as it strips the namespace
|
|
149
|
+
the sandbox nests), and proves bwrap starts a sandbox, failing the step
|
|
150
|
+
if it cannot. On macOS the sandbox is built in and it does nothing.
|
|
151
|
+
|
|
152
|
+
The packages it fetched stay in a directory the job's cache can keep:
|
|
153
|
+
`~/.cache/vx/setup-debs` on Actions, `.vx-setup-debs` under
|
|
154
|
+
`CI_PROJECT_DIR` on GitLab, or `--debs <dir>`. When that directory holds them, they install
|
|
155
|
+
with no index fetch; when they do not install (an older image's) or
|
|
156
|
+
leave a tool missing (one that image had already), they are fetched
|
|
157
|
+
again. The directory holds a `*` `.gitignore`, so no task's inputs
|
|
158
|
+
read it (on GitLab it sits in the checkout). On Actions the step's outputs, written to
|
|
159
|
+
`GITHUB_OUTPUT`, are `fetched` and `key`
|
|
160
|
+
(`vx-setup-debs-<RUNNER_OS>-<RUNNER_ARCH>-<image>-<version>`, the
|
|
161
|
+
runner image's, which the restore prefix below finds), so a cache step
|
|
162
|
+
saves only what it fetched. Save only from the default branch's own
|
|
163
|
+
runs: the restored debs install as root with `dpkg -i`, which checks no
|
|
164
|
+
signature, and a restore takes the newest key of the prefix, so a
|
|
165
|
+
pull request's code saving there would reach every later job:
|
|
120
166
|
|
|
121
167
|
```yaml
|
|
122
|
-
|
|
123
|
-
|
|
168
|
+
- uses: actions/cache/restore@v5
|
|
169
|
+
with:
|
|
170
|
+
path: ~/.cache/vx/setup-debs
|
|
171
|
+
key: vx-setup-debs-${{ runner.os }}-${{ runner.arch }}
|
|
172
|
+
restore-keys: vx-setup-debs-${{ runner.os }}-${{ runner.arch }}-
|
|
173
|
+
- id: setup
|
|
174
|
+
run: vx setup github
|
|
175
|
+
- if: steps.setup.outputs.fetched == 'true' && github.ref == 'refs/heads/main'
|
|
176
|
+
uses: actions/cache/save@v5
|
|
177
|
+
with:
|
|
178
|
+
path: ~/.cache/vx/setup-debs
|
|
179
|
+
key: ${{ steps.setup.outputs.key }}
|
|
124
180
|
```
|
|
125
181
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
names
|
|
140
|
-
|
|
141
|
-
Both artifacts are bounded by GitHub's own limits, because exceeding
|
|
142
|
-
either loses the whole thing rather than its tail: the check-run output
|
|
143
|
-
at 65 535 bytes, the job summary at 1 MiB counted in bytes (about 19 000 task rows; fewer when task names are not ASCII), cut on a character boundary. GitHub's cap is the step's whole summary file, so the page fits in what earlier writers in the step left; with no room it is skipped with a warning.
|
|
144
|
-
Past either, what is written ends with a line saying it was truncated.
|
|
182
|
+
The verb is the plugin's: `vx setup github` exists where `github()` is
|
|
183
|
+
declared and the job is on Actions, `vx setup gitlab` where `gitlab()` is
|
|
184
|
+
and the job is on GitLab CI. Core has no `setup` verb.
|
|
185
|
+
|
|
186
|
+
## How it works
|
|
187
|
+
|
|
188
|
+
`github()` contributes one observe-only telemetry sink through vx's
|
|
189
|
+
`telemetry` hook. It receives the versioned `RunSummaryRecord` at run end
|
|
190
|
+
and renders + appends the markdown in `flush()` — it holds no run handle,
|
|
191
|
+
streams no per-event records (`wants: []`), and a slow or failing write can
|
|
192
|
+
never fail or stall the run (core's crash-isolation + flush deadline).
|
|
193
|
+
|
|
194
|
+
The job summary is bounded by GitHub's own limit, because exceeding it
|
|
195
|
+
loses the whole page rather than its tail: 1 MiB counted in bytes (about 19 000 task rows; fewer when task names are not ASCII), cut on a character boundary. GitHub's cap is the step's whole summary file, so the page fits in what earlier writers in the step left; with no room it is skipped with a warning.
|
|
196
|
+
Past it, what is written ends with a line saying it was truncated.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-ci",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.648",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "GitHub Actions and GitLab CI integration for vx — a job summary on Actions and the branch cache scope on both.",
|
|
6
6
|
"keywords": [
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
"monorepo",
|
|
10
10
|
"github-actions",
|
|
11
11
|
"gitlab-ci",
|
|
12
|
-
"job-summary"
|
|
13
|
-
"checks"
|
|
12
|
+
"job-summary"
|
|
14
13
|
],
|
|
15
14
|
"type": "module",
|
|
16
15
|
"main": "./src/index.ts",
|
|
@@ -28,13 +27,13 @@
|
|
|
28
27
|
"LICENSE"
|
|
29
28
|
],
|
|
30
29
|
"engines": {
|
|
31
|
-
"bun": ">=1.4"
|
|
30
|
+
"bun": ">=1.4.4"
|
|
32
31
|
},
|
|
33
32
|
"publishConfig": {
|
|
34
33
|
"access": "public"
|
|
35
34
|
},
|
|
36
35
|
"peerDependencies": {
|
|
37
|
-
"@vzn/vx": "^0.0.
|
|
36
|
+
"@vzn/vx": "^0.0.648"
|
|
38
37
|
},
|
|
39
38
|
"repository": {
|
|
40
39
|
"type": "git",
|
package/src/cache-scope.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// The cache scope and base commit a GitHub Actions run names through
|
|
2
2
|
// `setBranch`: none on the default branch, a branch its own scope, the one
|
|
3
|
-
// a laptop on that branch derives (
|
|
3
|
+
// a laptop on that branch derives (2026-10-17).
|
|
4
4
|
import { readFile } from 'node:fs/promises'
|
|
5
5
|
import { type BranchFacts, branchCacheScope } from '@vzn/vx'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* What an Actions run knows of its branch (
|
|
8
|
+
* What an Actions run knows of its branch (2026-10-17). `scope`:
|
|
9
9
|
* none on a push to the default branch, outside Actions, or when the ref
|
|
10
10
|
* is unknown; else `<owner>/<repo>:<branch>`, the repository and branch a
|
|
11
11
|
* pull request comes from, or the branch or tag pushed. A fork's pull
|
|
@@ -15,18 +15,73 @@ import { type BranchFacts, branchCacheScope } from '@vzn/vx'
|
|
|
15
15
|
* `workflow_run` a PR started) is the PR's, whatever `GITHUB_REF` says: a
|
|
16
16
|
* workflow that checks out the PR's head there would otherwise share main's
|
|
17
17
|
* scope. `issue_comment` names no branch, so it writes `pr-<n>`.
|
|
18
|
-
* A merge queue's run (`merge_group`) is trusted (
|
|
19
|
-
* scope, compared with `merge_group.base_sha`.
|
|
18
|
+
* A merge queue's run (`merge_group`) is trusted (2026-10-18): no
|
|
19
|
+
* scope, compared with `merge_group.base_sha`. A push to a branch named
|
|
20
|
+
* like a queue ref is not one: anyone who can push can name one.
|
|
20
21
|
* `base`: a pull request's base commit (`pull_request.base.sha`), what its
|
|
21
|
-
* merge checkout is compared with, no git call needed.
|
|
22
|
+
* merge checkout is compared with, no git call needed. `refs`: what a
|
|
23
|
+
* remote reads (`githubRefs`).
|
|
22
24
|
*/
|
|
23
25
|
export async function githubBranch(
|
|
24
26
|
env: Readonly<Record<string, string | undefined>>,
|
|
27
|
+
baseBranch?: string,
|
|
25
28
|
): Promise<BranchFacts> {
|
|
26
29
|
if (env['GITHUB_ACTIONS'] !== 'true') return {}
|
|
27
|
-
const ref = env['GITHUB_REF'] ?? ''
|
|
28
30
|
const event = await eventOf(env['GITHUB_EVENT_PATH'])
|
|
29
|
-
|
|
31
|
+
const refs = githubRefs(env, event, baseBranch)
|
|
32
|
+
const facts = scopeOf(env, event, baseBranch)
|
|
33
|
+
return refs.length === 0 ? facts : { ...facts, refs }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The queue ref GitHub names a merge queue's run: `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`. */
|
|
37
|
+
const QUEUE = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The refs an Actions run reads, in order (2026-10-25): a merge
|
|
41
|
+
* queue's run only its base branch, the code it is about to become; any
|
|
42
|
+
* other a pull request's merge ref (`refs/pull/<n>/merge`, the one scope
|
|
43
|
+
* its token writes), then its own branch (a pull request's head, a
|
|
44
|
+
* `workflow_run` a pull request started included, else `GITHUB_REF`), then
|
|
45
|
+
* the branch it merges into, then the default branch (`baseBranch`, else
|
|
46
|
+
* the event's).
|
|
47
|
+
*/
|
|
48
|
+
function githubRefs(
|
|
49
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
50
|
+
event: ActionsEvent | undefined,
|
|
51
|
+
baseBranch: string | undefined,
|
|
52
|
+
): string[] {
|
|
53
|
+
const head = (b: string | undefined) => (b === undefined ? undefined : `refs/heads/${b}`)
|
|
54
|
+
const ref = stringOf(env['GITHUB_REF'])
|
|
55
|
+
if (queued(env)) {
|
|
56
|
+
const base = stringOf(event?.merge_group?.base_ref) ?? head(QUEUE.exec(ref ?? '')?.[1])
|
|
57
|
+
return base === undefined ? [] : [base]
|
|
58
|
+
}
|
|
59
|
+
const merge = /^refs\/pull\/\d+\//.test(ref ?? '') ? ref : undefined
|
|
60
|
+
// A workflow_run a pull request started is the PR's, as its scope is.
|
|
61
|
+
const run = prRun(env['GITHUB_EVENT_NAME'], event)
|
|
62
|
+
const own = head(stringOf(env['GITHUB_HEAD_REF']) ?? stringOf(run?.head_branch)) ?? ref
|
|
63
|
+
const into = head(
|
|
64
|
+
stringOf(env['GITHUB_BASE_REF']) ?? stringOf(run?.pull_requests?.[0]?.base?.ref),
|
|
65
|
+
)
|
|
66
|
+
const trunk = head(baseBranch ?? stringOf(event?.repository?.default_branch))
|
|
67
|
+
return [...new Set([merge, own, into, trunk].filter((r): r is string => r !== undefined))]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A merge queue's run: a `merge_group` event only. Anyone who can push can
|
|
72
|
+
* name a branch like a queue ref, so the cache server renames only a
|
|
73
|
+
* `merge_group` run's ref to its base, and only it reads just that base.
|
|
74
|
+
*/
|
|
75
|
+
const queued = (env: Readonly<Record<string, string | undefined>>): boolean =>
|
|
76
|
+
env['GITHUB_EVENT_NAME'] === 'merge_group'
|
|
77
|
+
|
|
78
|
+
function scopeOf(
|
|
79
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
80
|
+
event: ActionsEvent | undefined,
|
|
81
|
+
baseBranch: string | undefined,
|
|
82
|
+
): BranchFacts {
|
|
83
|
+
const ref = env['GITHUB_REF'] ?? ''
|
|
84
|
+
if (queued(env)) {
|
|
30
85
|
const base = stringOf(event?.merge_group?.base_sha)
|
|
31
86
|
return { trusted: true, ...(base !== undefined ? { base } : {}) }
|
|
32
87
|
}
|
|
@@ -47,8 +102,10 @@ export async function githubBranch(
|
|
|
47
102
|
if (onBehalf !== undefined) return withBase(onBehalf)
|
|
48
103
|
const name = env['GITHUB_REF_NAME']
|
|
49
104
|
if (ref === '' || name === undefined || name === '' || repo === undefined) return {}
|
|
50
|
-
|
|
51
|
-
|
|
105
|
+
// The workspace's `baseBranch` first, as the refs read it: a run on it
|
|
106
|
+
// has no scope even where the event names no default branch.
|
|
107
|
+
const branch = baseBranch ?? stringOf(event?.repository?.default_branch)
|
|
108
|
+
if (branch !== undefined && ref === `refs/heads/${branch}`) return {}
|
|
52
109
|
return { scope: branchCacheScope(repo, name) }
|
|
53
110
|
}
|
|
54
111
|
|
|
@@ -60,12 +117,12 @@ interface ActionsEvent {
|
|
|
60
117
|
base?: { sha?: unknown }
|
|
61
118
|
}
|
|
62
119
|
issue?: { number?: unknown; pull_request?: unknown }
|
|
63
|
-
merge_group?: { base_sha?: unknown }
|
|
120
|
+
merge_group?: { base_sha?: unknown; base_ref?: unknown }
|
|
64
121
|
workflow_run?: {
|
|
65
122
|
event?: unknown
|
|
66
123
|
head_branch?: unknown
|
|
67
124
|
head_repository?: { full_name?: unknown }
|
|
68
|
-
pull_requests?: { number?: unknown }[]
|
|
125
|
+
pull_requests?: { number?: unknown; base?: { ref?: unknown } }[]
|
|
69
126
|
}
|
|
70
127
|
}
|
|
71
128
|
|
|
@@ -95,12 +152,8 @@ function prOf(
|
|
|
95
152
|
const n = event.issue.number
|
|
96
153
|
return typeof n === 'number' && Number.isInteger(n) && n > 0 ? `pr-${n}` : 'pr-target'
|
|
97
154
|
}
|
|
98
|
-
const run = event
|
|
99
|
-
if (
|
|
100
|
-
name === 'workflow_run' &&
|
|
101
|
-
typeof run?.event === 'string' &&
|
|
102
|
-
run.event.startsWith('pull_request')
|
|
103
|
-
) {
|
|
155
|
+
const run = prRun(name, event)
|
|
156
|
+
if (run !== undefined) {
|
|
104
157
|
const branch = stringOf(run.head_branch)
|
|
105
158
|
return branch === undefined
|
|
106
159
|
? 'pr-target'
|
|
@@ -109,6 +162,19 @@ function prOf(
|
|
|
109
162
|
return undefined
|
|
110
163
|
}
|
|
111
164
|
|
|
165
|
+
/** A `workflow_run` run's triggering run, when a pull request started it. */
|
|
166
|
+
function prRun(
|
|
167
|
+
name: string | undefined,
|
|
168
|
+
event: ActionsEvent | undefined,
|
|
169
|
+
): NonNullable<ActionsEvent['workflow_run']> | undefined {
|
|
170
|
+
const run = event?.workflow_run
|
|
171
|
+
return name === 'workflow_run' &&
|
|
172
|
+
typeof run?.event === 'string' &&
|
|
173
|
+
run.event.startsWith('pull_request')
|
|
174
|
+
? run
|
|
175
|
+
: undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
112
178
|
/** The event payload the runner writes; undefined when it cannot be read. */
|
|
113
179
|
async function eventOf(eventPath: string | undefined): Promise<ActionsEvent | undefined> {
|
|
114
180
|
if (eventPath === undefined || eventPath === '') return undefined
|
package/src/gitlab.ts
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
|
-
// The `gitlab()` plugin (
|
|
1
|
+
// The `gitlab()` plugin (2026-10-18): on GitLab CI it names the
|
|
2
2
|
// run's branch through `setBranch`, as `github()` does on Actions. Off
|
|
3
3
|
// GitLab CI (`GITLAB_CI` not 'true') the factory returns undefined, which
|
|
4
4
|
// `plugins` skips, so declaring it costs nothing on a laptop.
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
branchCacheScope,
|
|
8
|
-
definePlugin,
|
|
9
|
-
type PluginOptionKinds,
|
|
10
|
-
refuseUnknownOptions,
|
|
11
|
-
type VxPlugin,
|
|
12
|
-
} from '@vzn/vx'
|
|
5
|
+
import { type BranchFacts, branchCacheScope, definePlugin, type VxPlugin } from '@vzn/vx'
|
|
6
|
+
import path from 'node:path'
|
|
13
7
|
|
|
14
8
|
export interface GitlabPluginOptions {
|
|
15
9
|
/**
|
|
@@ -22,17 +16,24 @@ export interface GitlabPluginOptions {
|
|
|
22
16
|
cacheScope?: boolean
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
const GITLAB_PLUGIN_KEYS: PluginOptionKinds<GitlabPluginOptions> = {
|
|
26
|
-
cacheScope: 'boolean',
|
|
27
|
-
}
|
|
28
|
-
|
|
29
19
|
export function gitlab(options: GitlabPluginOptions = {}): VxPlugin | undefined {
|
|
30
20
|
if (process.env['GITLAB_CI'] !== 'true') return undefined
|
|
31
|
-
refuseUnknownOptions('gitlab()', options, GITLAB_PLUGIN_KEYS)
|
|
32
21
|
return definePlugin(import.meta, {
|
|
33
|
-
|
|
22
|
+
commands: {
|
|
23
|
+
// In the project: GitLab's `cache: paths` keeps only what is under it.
|
|
24
|
+
setup: {
|
|
25
|
+
description: 'gitlab: install and prove the sandbox runtime on this job',
|
|
26
|
+
run: async (argv) =>
|
|
27
|
+
(await import('./setup.js')).runSetup(
|
|
28
|
+
'gitlab',
|
|
29
|
+
argv,
|
|
30
|
+
path.join(process.env['CI_PROJECT_DIR'] ?? process.cwd(), '.vx-setup-debs'),
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
config(workspace, ctx) {
|
|
34
35
|
if (options.cacheScope === false) return
|
|
35
|
-
ctx.setBranch(gitlabBranch(process.env))
|
|
36
|
+
ctx.setBranch(gitlabBranch(process.env, workspace.baseBranch))
|
|
36
37
|
},
|
|
37
38
|
})
|
|
38
39
|
}
|
|
@@ -43,9 +44,43 @@ export function gitlab(options: GitlabPluginOptions = {}): VxPlugin | undefined
|
|
|
43
44
|
* `CI_MERGE_REQUEST_SOURCE_PROJECT_PATH`) and its diff base; a merge
|
|
44
45
|
* train's pipeline builds what lands on the target, so it is trusted; a
|
|
45
46
|
* branch or tag pipeline names its ref, none on the default branch.
|
|
47
|
+
* `refs`, what a remote reads (2026-10-25): a merge train's only its
|
|
48
|
+
* target branch; any other its own ref (the tag, else a merge request's
|
|
49
|
+
* source branch, else the branch), then
|
|
50
|
+
* a merge request's or external pull request's target, then the default
|
|
51
|
+
* branch.
|
|
46
52
|
*/
|
|
47
|
-
function gitlabBranch(
|
|
53
|
+
function gitlabBranch(
|
|
54
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
55
|
+
baseBranch?: string,
|
|
56
|
+
): BranchFacts {
|
|
48
57
|
const value = (key: string): string | undefined => env[key] || undefined
|
|
58
|
+
const head = (b: string | undefined) => (b === undefined ? undefined : `refs/heads/${b}`)
|
|
59
|
+
const tag = value('CI_COMMIT_TAG')
|
|
60
|
+
const target = head(
|
|
61
|
+
value('CI_MERGE_REQUEST_TARGET_BRANCH_NAME') ??
|
|
62
|
+
value('CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_NAME'),
|
|
63
|
+
)
|
|
64
|
+
const train = value('CI_MERGE_REQUEST_EVENT_TYPE') === 'merge_train'
|
|
65
|
+
const refs = [
|
|
66
|
+
...new Set(
|
|
67
|
+
(train
|
|
68
|
+
? [target]
|
|
69
|
+
: [
|
|
70
|
+
tag !== undefined
|
|
71
|
+
? `refs/tags/${tag}`
|
|
72
|
+
: head(value('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') ?? value('CI_COMMIT_REF_NAME')),
|
|
73
|
+
target,
|
|
74
|
+
head(baseBranch ?? value('CI_DEFAULT_BRANCH')),
|
|
75
|
+
]
|
|
76
|
+
).filter((r): r is string => r !== undefined),
|
|
77
|
+
),
|
|
78
|
+
]
|
|
79
|
+
const facts = scopeOf(value)
|
|
80
|
+
return refs.length === 0 ? facts : { ...facts, refs }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function scopeOf(value: (key: string) => string | undefined): BranchFacts {
|
|
49
84
|
const base = value('CI_MERGE_REQUEST_DIFF_BASE_SHA')
|
|
50
85
|
const withBase = (facts: BranchFacts): BranchFacts =>
|
|
51
86
|
base === undefined ? facts : { ...facts, base }
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
// appends a job summary: verdict, stats, failures, and the per-task table.
|
|
10
10
|
// Anywhere else the plugin declines and costs nothing.
|
|
11
11
|
import { createRequire } from 'node:module'
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
type PluginOptionKinds,
|
|
14
|
+
refuseUnknownOptions,
|
|
15
|
+
type RunSummaryRecord,
|
|
16
|
+
UserError,
|
|
17
|
+
type VxPlugin,
|
|
18
|
+
} from '@vzn/vx'
|
|
13
19
|
import type { GithubPluginOptions } from './plugin.js'
|
|
14
20
|
import type { GitlabPluginOptions } from './gitlab.js'
|
|
15
21
|
|
|
@@ -21,12 +27,33 @@ export type { GithubPluginOptions, GitlabPluginOptions }
|
|
|
21
27
|
// factories are synchronous.
|
|
22
28
|
const load = createRequire(import.meta.url)
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
// Options are checked here, on CI or not: a typo checked only on CI passed
|
|
31
|
+
// on a laptop and failed the first CI run (2026-10-28).
|
|
32
|
+
const GITHUB_KEYS: PluginOptionKinds<GithubPluginOptions> = {
|
|
33
|
+
summaryFile: 'string',
|
|
34
|
+
title: 'string',
|
|
35
|
+
cacheScope: 'boolean',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const GITLAB_KEYS: PluginOptionKinds<GitlabPluginOptions> = {
|
|
39
|
+
cacheScope: 'boolean',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function github(options: GithubPluginOptions = {}): VxPlugin | undefined {
|
|
43
|
+
refuseUnknownOptions('github()', options, GITHUB_KEYS)
|
|
44
|
+
// '' passes the kind check but reads wrong: `??` keeps it, so summaryFile
|
|
45
|
+
// declined instead of using GITHUB_STEP_SUMMARY and title rendered an
|
|
46
|
+
// empty heading.
|
|
47
|
+
for (const key of ['summaryFile', 'title'] as const) {
|
|
48
|
+
if (options[key] === '')
|
|
49
|
+
throw new UserError(`github() option "${key}" must be a non-empty string, got ""`)
|
|
50
|
+
}
|
|
25
51
|
if (process.env['GITHUB_ACTIONS'] !== 'true') return undefined
|
|
26
52
|
return (load('./plugin.js') as typeof import('./plugin.js')).github(options)
|
|
27
53
|
}
|
|
28
54
|
|
|
29
|
-
export function gitlab(options
|
|
55
|
+
export function gitlab(options: GitlabPluginOptions = {}): VxPlugin | undefined {
|
|
56
|
+
refuseUnknownOptions('gitlab()', options, GITLAB_KEYS)
|
|
30
57
|
if (process.env['GITLAB_CI'] !== 'true') return undefined
|
|
31
58
|
return (load('./gitlab.js') as typeof import('./gitlab.js')).gitlab(options)
|
|
32
59
|
}
|
|
@@ -34,7 +61,3 @@ export function gitlab(options?: GitlabPluginOptions): VxPlugin | undefined {
|
|
|
34
61
|
export function renderJobSummary(summary: RunSummaryRecord, title?: string): string {
|
|
35
62
|
return (load('./summary.js') as typeof import('./summary.js')).renderJobSummary(summary, title)
|
|
36
63
|
}
|
|
37
|
-
|
|
38
|
-
// The Checks API helpers are the plugin's own; only the transport type
|
|
39
|
-
// the options name is public (1.0 freezes what this file exports).
|
|
40
|
-
export { type FetchFn } from './checks.js'
|