@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 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), failures called out above the
28
- per-task table with their exit code, the signal an exit above 128
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 footer line
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`, `GitlabPluginOptions` and `FetchFn`; nothing else. `fetchFn`, `append` and `sizeOf` are test seams:
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 names no scope.
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: no scope,
69
- whatever its `gh-readonly-queue/...` branch, compared with
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. `gitlab({ cacheScope: false })` (`GitlabPluginOptions`) turns it off;
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
- ## How it works
128
+ ## The sandbox runtime: `vx setup`
104
129
 
105
- `github()` contributes one observe-only telemetry sink through vx's
106
- `telemetry` seam. It receives the versioned `RunSummaryRecord` at run end
107
- and renders + appends the markdown in `flush()` — it holds no run handle,
108
- streams no per-event records (`wants: []`), and a slow or failing write can
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
- ## The PR check run
135
+ ```yaml
136
+ # GitHub Actions
137
+ - run: vx setup github
138
+ ```
112
139
 
113
- With `GITHUB_TOKEN` in the environment (plus `GITHUB_REPOSITORY` /
114
- `GITHUB_SHA`, both set by the runner) the plugin also creates one
115
- **completed check-run** on the built commit — conclusion `success`,
116
- `failure`, or `cancelled` for a run a signal stopped with nothing
117
- failed (a cancelled job), its output the same summary markdown — so the verdict shows in
118
- the PR's checks list, not just the workflow page. The workflow must grant
119
- the permission:
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
- permissions:
123
- checks: write
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
- Without the token the check is silently skipped (the job summary still
127
- writes); pass `checks: true` to warn instead, or `checks: false` to opt
128
- out entirely; a value that is not a boolean is refused. GitHub's own blips (`502`, `503`, `504`, a dropped
129
- connection) are retried twice, 200 then 800 ms apart, until the flush deadline, which warns the last answer. A failed POST warns and never fails the run — a `403` says
130
- to check `permissions: checks: write`, a rate limit (`429`, or a `403`
131
- saying so) says so and is not retried — and a slow API costs the run
132
- nothing past core's end-of-run flush deadline. On `pull_request` events
133
- `GITHUB_SHA` is the merge commit; GitHub still surfaces the check on the
134
- PR.
135
-
136
- On GitHub Enterprise Server the POST goes to `GITHUB_API_URL`. A host
137
- behind a private CA is trusted through `NODE_EXTRA_CA_CERTS` (a PEM
138
- file of the CA); an untrusted certificate is not retried and its warning
139
- names that variable.
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.646",
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.646"
36
+ "@vzn/vx": "^0.0.648"
38
37
  },
39
38
  "repository": {
40
39
  "type": "git",
@@ -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 (owner, 2026-10-17).
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 (owner, 2026-10-17). `scope`:
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 (owner, 2026-10-18): no
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
- if (env['GITHUB_EVENT_NAME'] === 'merge_group') {
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
- const branch = event?.repository?.default_branch
51
- if (typeof branch === 'string' && branch !== '' && ref === `refs/heads/${branch}`) return {}
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?.workflow_run
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 (owner, 2026-10-18): on GitLab CI it names the
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
- type BranchFacts,
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
- config(_workspace, ctx) {
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(env: Readonly<Record<string, string | undefined>>): BranchFacts {
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 type { RunSummaryRecord, VxPlugin } from '@vzn/vx'
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
- export function github(options?: GithubPluginOptions): VxPlugin | undefined {
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?: GitlabPluginOptions): VxPlugin | undefined {
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'