@vzn/vx-ci 0.0.642 → 0.0.644

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
@@ -2,9 +2,10 @@
2
2
 
3
3
  Website: <https://vznjs.github.io/vx/> · this plugin: <https://vznjs.github.io/vx/plugins/vx-ci/> · source: <https://github.com/vznjs/vx>
4
4
 
5
- GitHub Actions integration for [`@vzn/vx`](https://vznjs.github.io/vx/) — a
6
- telemetry plugin that writes every `vx run` as a **job summary** on the
7
- workflow run page.
5
+ GitHub Actions and GitLab CI integration for [`@vzn/vx`](https://vznjs.github.io/vx/):
6
+ `github()` writes every `vx run` as a **job summary** on the workflow run
7
+ page and names the run's cache scope; `gitlab()` names the run's cache
8
+ scope on GitLab CI ([below](#gitlab-ci)).
8
9
 
9
10
  ```sh
10
11
  npm install -D @vzn/vx @vzn/vx-ci # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
@@ -39,8 +40,8 @@ checked and nothing loads, so declaring it unconditionally is safe.
39
40
 
40
41
  The options type is `GithubPluginOptions`; `renderJobSummary` renders the
41
42
  summary lines the plugin posts (the site's CI guide sample is rendered from it).
42
- The package exports `github`, `renderJobSummary` and the types `GithubPluginOptions`
43
- and `FetchFn`; nothing else. `fetchFn`, `append` and `sizeOf` are test seams:
43
+ The package exports `github`, `gitlab`, `renderJobSummary` and the types
44
+ `GithubPluginOptions`, `GitlabPluginOptions` and `FetchFn`; nothing else. `fetchFn`, `append` and `sizeOf` are test seams:
44
45
  they replace the Checks API transport, the summary writer and its size probe.
45
46
 
46
47
  ```ts
@@ -55,16 +56,56 @@ github({
55
56
 
56
57
  ## Cache scope
57
58
 
58
- On Actions (`GITHUB_ACTIONS=true`), when `.vx/workspace.ts` sets no `cache.scope`, the plugin's
59
- `config` stage sets it from the ref (`GITHUB_REF`, `GITHUB_REF_NAME`, and
60
- the default branch from the `GITHUB_EVENT_PATH` payload): a push to the default branch sets
61
- none (reads and writes the default keys), a pull request becomes
62
- `pr-<n>` (so is a `pull_request_target`, `issue_comment` or `workflow_run`
63
- run on a PR's behalf, by `GITHUB_EVENT_NAME`, though its ref is main's) and any other branch or tag `ref-<name>`, which read the
64
- default keys, then their own, and write only their own. A PR never writes what main
65
- reads. Whether a run writes at all is `cache.policy`'s call. `VX_CACHE_SCOPE` or a `cache.scope` in `.vx/workspace.ts` overrides it. This is a client-side convention: the real boundary is a cache
66
- token the server limits, so give PR jobs one that cannot write the
67
- default keys (`docs/security.md` § Cache poisoning).
59
+ On Actions (`GITHUB_ACTIONS=true`) the plugin's `config` stage names the
60
+ run's branch through `ctx.setBranch`, from the ref (`GITHUB_REF`,
61
+ `GITHUB_REF_NAME`, `GITHUB_HEAD_REF`, `GITHUB_REPOSITORY`) and the
62
+ `GITHUB_EVENT_PATH` payload. A push to the default branch names no scope (reads and writes
63
+ the default keys). A push to, or a pull request from, a branch names
64
+ `<owner>/<repo>:<branch>` of the repository the branch lives in, so a
65
+ fork's pull request names the fork; so does a `pull_request_target` or
66
+ `workflow_run` run on a PR's behalf (by `GITHUB_EVENT_NAME`, though its
67
+ 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, so it reads and writes only
70
+ the default keys (`x-vx-cache-scope: trusted`), compared with
71
+ `merge_group.base_sha`. A pull request also names its base commit
72
+ (`pull_request.base.sha`), so vx diffs the merge checkout from it with
73
+ no git call. A scoped run's changed tasks read the scope's remote entry,
74
+ the rest the default keys, and every task it builds is saved to the
75
+ remote under the scope; the local cache never knows a scope. A
76
+ PR never writes what main reads, and a laptop on the branch derives the
77
+ same scope. Whether a run writes at all is `cache.policy`'s call.
78
+ `VX_CACHE_SCOPE` overrides it. This is a client-side convention: the
79
+ real boundary is a cache token the server limits, so give PR jobs one
80
+ that cannot write the default keys (`docs/security.md` § Cache
81
+ poisoning).
82
+
83
+ ## GitLab CI
84
+
85
+ ```ts
86
+ // .vx/workspace.ts
87
+ import { defineWorkspace } from '@vzn/vx/config'
88
+ import { github, gitlab } from '@vzn/vx-ci'
89
+
90
+ export default defineWorkspace({ plugins: [github(), gitlab()] })
91
+ ```
92
+
93
+ On GitLab CI (`GITLAB_CI=true`) `gitlab()`'s `config` stage names the
94
+ run's branch through `ctx.setBranch`, from GitLab's predefined
95
+ variables, as `github()` does on Actions. The default branch
96
+ (`CI_DEFAULT_BRANCH`) names no scope. Another branch or a tag (`CI_COMMIT_TAG`) names
97
+ `<project path>:<ref>` (`CI_PROJECT_PATH`, `CI_COMMIT_REF_NAME`), so a
98
+ subgroup's project is `group/sub/app:feat-x`, the scope a laptop on
99
+ `feat-x` derives. A merge request pipeline names its source branch
100
+ (`CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`) in the project it lives in (a
101
+ fork's `CI_MERGE_REQUEST_SOURCE_PROJECT_PATH`) and its diff base
102
+ (`CI_MERGE_REQUEST_DIFF_BASE_SHA`), so vx diffs from it with no git
103
+ call. A merge train's pipeline (`CI_MERGE_REQUEST_EVENT_TYPE=merge_train`)
104
+ builds what lands on the target, so it is trusted: no scope, only the
105
+ default keys. `gitlab({ cacheScope: false })` (`GitlabPluginOptions`) turns it off;
106
+ `VX_CACHE_SCOPE` overrides it. Anywhere else `gitlab()` returns
107
+ `undefined`, so declaring both plugins is safe. Merge request comments
108
+ and reports are not part of it yet.
68
109
 
69
110
  ## How it works
70
111
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@vzn/vx-ci",
3
- "version": "0.0.642",
3
+ "version": "0.0.644",
4
4
  "license": "MIT",
5
- "description": "GitHub Actions integration for vx — a telemetry plugin that writes the run as a GitHub Actions job summary.",
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": [
7
7
  "vx",
8
8
  "vx-plugin",
9
9
  "monorepo",
10
10
  "github-actions",
11
+ "gitlab-ci",
11
12
  "job-summary",
12
13
  "checks"
13
14
  ],
@@ -33,7 +34,7 @@
33
34
  "access": "public"
34
35
  },
35
36
  "peerDependencies": {
36
- "@vzn/vx": "^0.0.642"
37
+ "@vzn/vx": "^0.0.644"
37
38
  },
38
39
  "repository": {
39
40
  "type": "git",
@@ -1,49 +1,99 @@
1
- // The cache scope a GitHub Actions run gets when the workspace names none:
2
- // the default branch writes the default keys, anything else its own scope.
1
+ // The cache scope and base commit a GitHub Actions run names through
2
+ // `setBranch`: the default branch writes the default keys, a branch its own
3
+ // scope, the one a laptop on that branch reads (owner, 2026-10-17).
3
4
  import { readFile } from 'node:fs/promises'
5
+ import { type BranchFacts, branchCacheScope } from '@vzn/vx'
4
6
 
5
7
  /**
6
- * `undefined` (the default keys) on a push to the
7
- * default branch, outside Actions, or when the ref is unknown; else
8
- * `pr-<n>` for a pull request and `ref-<name>` for any other branch or tag.
9
- * An event that runs in the default branch's context on a pull request's
10
- * behalf (`pull_request_target`, `issue_comment` on a PR, a `workflow_run`
11
- * a PR started) is the PR's, whatever `GITHUB_REF` says: a workflow that
12
- * checks out the PR's head there would otherwise write the default keys.
8
+ * What an Actions run knows of its branch (owner, 2026-10-17). `scope`:
9
+ * none on a push to the default branch, outside Actions, or when the ref
10
+ * is unknown; else `<owner>/<repo>:<branch>`, the repository and branch a
11
+ * pull request comes from, or the branch or tag pushed. A fork's pull
12
+ * request names the fork, a scope a laptop on the base repository never
13
+ * derives. An event that runs in the default branch's context on a pull
14
+ * request's behalf (`pull_request_target`, `issue_comment` on a PR, a
15
+ * `workflow_run` a PR started) is the PR's, whatever `GITHUB_REF` says: a
16
+ * workflow that checks out the PR's head there would otherwise write the
17
+ * default keys. `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, only the default keys, compared with `merge_group.base_sha`.
20
+ * `base`: a pull request's base commit (`pull_request.base.sha`), what its
21
+ * merge checkout is compared with, no git call needed.
13
22
  */
14
- export async function githubCacheScope(
23
+ export async function githubBranch(
15
24
  env: Readonly<Record<string, string | undefined>>,
16
- ): Promise<string | undefined> {
17
- if (env['GITHUB_ACTIONS'] !== 'true') return undefined
25
+ ): Promise<BranchFacts> {
26
+ if (env['GITHUB_ACTIONS'] !== 'true') return {}
18
27
  const ref = env['GITHUB_REF'] ?? ''
19
- const pr = /^refs\/pull\/(\d+)\//.exec(ref)
20
- if (pr !== null) return `pr-${pr[1]}`
21
28
  const event = await eventOf(env['GITHUB_EVENT_PATH'])
22
- const onBehalf = prOf(env['GITHUB_EVENT_NAME'], event)
23
- if (onBehalf !== undefined) return onBehalf
29
+ if (env['GITHUB_EVENT_NAME'] === 'merge_group') {
30
+ const base = stringOf(event?.merge_group?.base_sha)
31
+ return { trusted: true, ...(base !== undefined ? { base } : {}) }
32
+ }
33
+ const repo = stringOf(event?.repository?.full_name) ?? env['GITHUB_REPOSITORY']
34
+ const base = stringOf(event?.pull_request?.base?.sha)
35
+ const withBase = (scope: string | undefined): { scope?: string; base?: string } => ({
36
+ ...(scope !== undefined ? { scope } : {}),
37
+ ...(base !== undefined ? { base } : {}),
38
+ })
39
+ if (/^refs\/pull\/\d+\//.test(ref)) {
40
+ const head = event?.pull_request?.head
41
+ const branch = env['GITHUB_HEAD_REF'] || stringOf(head?.ref)
42
+ if (branch !== undefined)
43
+ return withBase(headScope(branch, stringOf(head?.repo?.full_name), repo))
44
+ return withBase('pr-target')
45
+ }
46
+ const onBehalf = prOf(env['GITHUB_EVENT_NAME'], event, repo)
47
+ if (onBehalf !== undefined) return withBase(onBehalf)
24
48
  const name = env['GITHUB_REF_NAME']
25
- if (ref === '' || name === undefined || name === '') return undefined
49
+ if (ref === '' || name === undefined || name === '' || repo === undefined) return {}
26
50
  const branch = event?.repository?.default_branch
27
- if (typeof branch === 'string' && branch !== '' && ref === `refs/heads/${branch}`)
28
- return undefined
29
- // `cache.scope` takes letters, digits and . _ - / @; a ref may hold more.
30
- return `ref-${name.replace(/[^\w.@/-]/g, '_').slice(0, 124)}`
51
+ if (typeof branch === 'string' && branch !== '' && ref === `refs/heads/${branch}`) return {}
52
+ return { scope: branchCacheScope(repo, name) }
31
53
  }
32
54
 
33
55
  interface ActionsEvent {
34
- repository?: { default_branch?: unknown }
35
- pull_request?: { number?: unknown }
56
+ repository?: { default_branch?: unknown; full_name?: unknown }
57
+ pull_request?: {
58
+ number?: unknown
59
+ head?: { ref?: unknown; repo?: { full_name?: unknown } }
60
+ base?: { sha?: unknown }
61
+ }
36
62
  issue?: { number?: unknown; pull_request?: unknown }
37
- workflow_run?: { event?: unknown; pull_requests?: { number?: unknown }[] }
63
+ merge_group?: { base_sha?: unknown }
64
+ workflow_run?: {
65
+ event?: unknown
66
+ head_branch?: unknown
67
+ head_repository?: { full_name?: unknown }
68
+ pull_requests?: { number?: unknown }[]
69
+ }
38
70
  }
39
71
 
40
- /** The PR scope of an event that runs in the base branch's context for a PR; `pr-target` when its number is unknown. */
41
- function prOf(name: string | undefined, event: ActionsEvent | undefined): string | undefined {
42
- const scope = (n: unknown): string =>
43
- typeof n === 'number' && Number.isInteger(n) && n > 0 ? `pr-${n}` : 'pr-target'
44
- if (name === 'pull_request_target') return scope(event?.pull_request?.number)
72
+ function stringOf(v: unknown): string | undefined {
73
+ return typeof v === 'string' && v !== '' ? v : undefined
74
+ }
75
+
76
+ /** A head branch's scope: the repository it lives in (a fork's own), then the branch. */
77
+ function headScope(branch: string, headRepo: string | undefined, repo: string | undefined): string {
78
+ return branchCacheScope(headRepo ?? repo ?? 'unknown/unknown', branch)
79
+ }
80
+
81
+ /** The scope of an event that runs in the base branch's context for a PR; `pr-target` when its head is unknown. */
82
+ function prOf(
83
+ name: string | undefined,
84
+ event: ActionsEvent | undefined,
85
+ repo: string | undefined,
86
+ ): string | undefined {
87
+ if (name === 'pull_request_target') {
88
+ const head = event?.pull_request?.head
89
+ const branch = stringOf(head?.ref)
90
+ return branch === undefined
91
+ ? 'pr-target'
92
+ : headScope(branch, stringOf(head?.repo?.full_name), repo)
93
+ }
45
94
  if (name === 'issue_comment' && event?.issue?.pull_request != null) {
46
- return scope(event.issue.number)
95
+ const n = event.issue.number
96
+ return typeof n === 'number' && Number.isInteger(n) && n > 0 ? `pr-${n}` : 'pr-target'
47
97
  }
48
98
  const run = event?.workflow_run
49
99
  if (
@@ -51,7 +101,10 @@ function prOf(name: string | undefined, event: ActionsEvent | undefined): string
51
101
  typeof run?.event === 'string' &&
52
102
  run.event.startsWith('pull_request')
53
103
  ) {
54
- return scope(run.pull_requests?.[0]?.number)
104
+ const branch = stringOf(run.head_branch)
105
+ return branch === undefined
106
+ ? 'pr-target'
107
+ : headScope(branch, stringOf(run.head_repository?.full_name), repo)
55
108
  }
56
109
  return undefined
57
110
  }
package/src/gitlab.ts ADDED
@@ -0,0 +1,63 @@
1
+ // The `gitlab()` plugin (owner, 2026-10-18): on GitLab CI it names the
2
+ // run's branch through `setBranch`, as `github()` does on Actions. Off
3
+ // GitLab CI (`GITLAB_CI` not 'true') the factory returns undefined, which
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'
13
+
14
+ export interface GitlabPluginOptions {
15
+ /**
16
+ * Name the run's cache scope and base commit from GitLab's predefined
17
+ * variables: the default branch and a merge train read and write the
18
+ * default keys, any other branch `<project path>:<branch>`, and a merge
19
+ * request is compared with its diff base. `VX_CACHE_SCOPE` beats it.
20
+ * Default: on; `false` opts out.
21
+ */
22
+ cacheScope?: boolean
23
+ }
24
+
25
+ const GITLAB_PLUGIN_KEYS: PluginOptionKinds<GitlabPluginOptions> = {
26
+ cacheScope: 'boolean',
27
+ }
28
+
29
+ export function gitlab(options: GitlabPluginOptions = {}): VxPlugin | undefined {
30
+ if (process.env['GITLAB_CI'] !== 'true') return undefined
31
+ refuseUnknownOptions('gitlab()', options, GITLAB_PLUGIN_KEYS)
32
+ return definePlugin(import.meta, {
33
+ config(_workspace, ctx) {
34
+ if (options.cacheScope === false) return
35
+ ctx.setBranch(gitlabBranch(process.env))
36
+ },
37
+ })
38
+ }
39
+
40
+ /**
41
+ * What a GitLab CI job knows of its branch. A merge request pipeline names
42
+ * its source branch in the project it lives in (a fork's own,
43
+ * `CI_MERGE_REQUEST_SOURCE_PROJECT_PATH`) and its diff base; a merge
44
+ * train's pipeline builds what lands on the target, so it is trusted; a
45
+ * branch or tag pipeline names its ref, none on the default branch.
46
+ */
47
+ function gitlabBranch(env: Readonly<Record<string, string | undefined>>): BranchFacts {
48
+ const value = (key: string): string | undefined => env[key] || undefined
49
+ const base = value('CI_MERGE_REQUEST_DIFF_BASE_SHA')
50
+ const withBase = (facts: BranchFacts): BranchFacts =>
51
+ base === undefined ? facts : { ...facts, base }
52
+ if (value('CI_MERGE_REQUEST_EVENT_TYPE') === 'merge_train') return withBase({ trusted: true })
53
+ const project = value('CI_PROJECT_PATH')
54
+ const source = value('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME')
55
+ if (source !== undefined) {
56
+ const from = value('CI_MERGE_REQUEST_SOURCE_PROJECT_PATH') ?? project
57
+ return withBase(from === undefined ? {} : { scope: branchCacheScope(from, source) })
58
+ }
59
+ const ref = value('CI_COMMIT_REF_NAME')
60
+ if (project === undefined || ref === undefined) return {}
61
+ if (value('CI_COMMIT_TAG') === undefined && ref === value('CI_DEFAULT_BRANCH')) return {}
62
+ return { scope: branchCacheScope(project, ref) }
63
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Public API for @vzn/vx-ci — the GitHub Actions integration plugin.
1
+ // Public API for @vzn/vx-ci — the GitHub Actions and GitLab CI plugins.
2
2
  //
3
3
  // Usage in .vx/workspace.ts:
4
4
  // import { defineWorkspace } from '@vzn/vx'
@@ -9,6 +9,7 @@
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
  export { github, type GithubPluginOptions } from './plugin.js'
12
+ export { gitlab, type GitlabPluginOptions } from './gitlab.js'
12
13
  export { renderJobSummary } from './summary.js'
13
14
 
14
15
  // The Checks API helpers are the plugin's own; only the transport type
package/src/plugin.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  type VxPlugin,
14
14
  } from '@vzn/vx'
15
15
  import type { FetchFn } from './checks.js'
16
- import { githubCacheScope } from './cache-scope.js'
16
+ import { githubBranch } from './cache-scope.js'
17
17
 
18
18
  // Every run evaluates `.vx/workspace.ts`; the sink, the summary renderer
19
19
  // and the Checks API client load only where a summary file is set. The
@@ -40,9 +40,11 @@ export interface GithubPluginOptions {
40
40
  /** Check-run name. Default: `'vx'`. */
41
41
  checkName?: string
42
42
  /**
43
- * Set the workspace's `cache.scope` from the run's ref when it names none:
44
- * a push to the default branch keeps the default keys, a pull request writes to
45
- * `pr-<n>`, any other ref to `ref-<name>`. Default: on; `false` opts out.
43
+ * Name the run's cache scope and base commit from the Actions event: a
44
+ * push to the default branch keeps the default keys, a branch or its pull
45
+ * request writes to `<owner>/<repo>:<branch>` (a fork's names the fork),
46
+ * and a pull request is compared with its base commit. `VX_CACHE_SCOPE`
47
+ * beats it. Default: on; `false` opts out.
46
48
  */
47
49
  cacheScope?: boolean
48
50
  /** Test seam — inject the append. Defaults to fs appendFile. */
@@ -94,10 +96,9 @@ export function github(options: GithubPluginOptions = {}): VxPlugin | undefined
94
96
  throw new UserError(`github() option "${key}" must be a non-empty string, got ""`)
95
97
  }
96
98
  return definePlugin(import.meta, {
97
- async config(workspace) {
98
- if (options.cacheScope === false || workspace.cache?.scope !== undefined) return
99
- const scope = await githubCacheScope(process.env)
100
- if (scope !== undefined) workspace.cache = { ...workspace.cache, scope }
99
+ async config(_workspace, ctx) {
100
+ if (options.cacheScope === false) return
101
+ ctx.setBranch(await githubBranch(process.env))
101
102
  },
102
103
  telemetry(ctx) {
103
104
  const file = options.summaryFile ?? process.env['GITHUB_STEP_SUMMARY']