Skip to content

Commit e1b21f0

Browse files
authored
feat: adds circleci to trust command (#8909)
This pull request adds support for CircleCI as a trusted provider in the trust command system. The changes introduce a new `circleci` subcommand, implement its logic for validating and processing CircleCI-specific trust relationships, and update documentation and tests to reflect the new functionality.
1 parent 9a33ad0 commit e1b21f0

File tree

8 files changed

+706
-2
lines changed

8 files changed

+706
-2
lines changed

lib/commands/trust/circleci.js

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
const Definition = require('@npmcli/config/lib/definitions/definition.js')
2+
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
3+
const TrustCommand = require('../../trust-cmd.js')
4+
5+
// UUID validation regex
6+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
7+
8+
class TrustCircleCI extends TrustCommand {
9+
static description = 'Create a trusted relationship between a package and CircleCI'
10+
static name = 'circleci'
11+
static positionals = 1 // expects at most 1 positional (package name)
12+
static providerName = 'CircleCI'
13+
static providerEntity = 'CircleCI pipeline'
14+
15+
static usage = [
16+
'[package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [-y|--yes]',
17+
]
18+
19+
static definitions = [
20+
new Definition('org-id', {
21+
default: null,
22+
type: String,
23+
required: true,
24+
description: 'CircleCI organization UUID',
25+
}),
26+
new Definition('project-id', {
27+
default: null,
28+
type: String,
29+
required: true,
30+
description: 'CircleCI project UUID',
31+
}),
32+
new Definition('pipeline-definition-id', {
33+
default: null,
34+
type: String,
35+
required: true,
36+
description: 'CircleCI pipeline definition UUID',
37+
}),
38+
new Definition('vcs-origin', {
39+
default: null,
40+
type: String,
41+
required: true,
42+
description: "CircleCI repository origin in format 'provider/owner/repo'",
43+
}),
44+
new Definition('context-id', {
45+
default: null,
46+
type: [null, String, Array],
47+
description: 'CircleCI context UUID to match',
48+
}),
49+
// globals are alphabetical
50+
globalDefinitions['dry-run'],
51+
globalDefinitions.json,
52+
globalDefinitions.registry,
53+
globalDefinitions.yes,
54+
]
55+
56+
validateUuid (value, fieldName) {
57+
if (!UUID_REGEX.test(value)) {
58+
throw new Error(`${fieldName} must be a valid UUID`)
59+
}
60+
}
61+
62+
validateVcsOrigin (value) {
63+
// Expected format: provider/owner/repo (e.g., github.com/owner/repo, bitbucket.org/owner/repo)
64+
if (value.includes('://')) {
65+
throw new Error("vcs-origin must not include a scheme (e.g., use 'github.com/owner/repo' not 'https://github.com/owner/repo')")
66+
}
67+
const parts = value.split('/')
68+
if (parts.length < 3) {
69+
throw new Error("vcs-origin must be in format 'provider/owner/repo'")
70+
}
71+
}
72+
73+
// Generate a URL from vcs-origin (e.g., github.com/npm/repo -> https://github.com/npm/repo)
74+
getVcsOriginUrl (vcsOrigin) {
75+
if (!vcsOrigin) {
76+
return null
77+
}
78+
// vcs-origin format: github.com/owner/repo or bitbucket.org/owner/repo
79+
return `https://${vcsOrigin}`
80+
}
81+
82+
static optionsToBody (options) {
83+
const { orgId, projectId, pipelineDefinitionId, vcsOrigin, contextIds } = options
84+
const trustConfig = {
85+
type: 'circleci',
86+
claims: {
87+
'oidc.circleci.com/org-id': orgId,
88+
'oidc.circleci.com/project-id': projectId,
89+
'oidc.circleci.com/pipeline-definition-id': pipelineDefinitionId,
90+
'oidc.circleci.com/vcs-origin': vcsOrigin,
91+
},
92+
}
93+
if (contextIds && contextIds.length > 0) {
94+
trustConfig.claims['oidc.circleci.com/context-ids'] = contextIds
95+
}
96+
return trustConfig
97+
}
98+
99+
static bodyToOptions (body) {
100+
return {
101+
...(body.id) && { id: body.id },
102+
...(body.type) && { type: body.type },
103+
...(body.claims?.['oidc.circleci.com/org-id']) && { orgId: body.claims['oidc.circleci.com/org-id'] },
104+
...(body.claims?.['oidc.circleci.com/project-id']) && { projectId: body.claims['oidc.circleci.com/project-id'] },
105+
...(body.claims?.['oidc.circleci.com/pipeline-definition-id']) && {
106+
pipelineDefinitionId: body.claims['oidc.circleci.com/pipeline-definition-id'],
107+
},
108+
...(body.claims?.['oidc.circleci.com/vcs-origin']) && { vcsOrigin: body.claims['oidc.circleci.com/vcs-origin'] },
109+
...(body.claims?.['oidc.circleci.com/context-ids']) && { contextIds: body.claims['oidc.circleci.com/context-ids'] },
110+
}
111+
}
112+
113+
// Override flagsToOptions since CircleCI doesn't use file/entity pattern
114+
async flagsToOptions ({ positionalArgs, flags }) {
115+
const content = await this.optionalPkgJson()
116+
const pkgName = positionalArgs[0] || content.name
117+
118+
if (!pkgName) {
119+
throw new Error('Package name must be specified either as an argument or in package.json file')
120+
}
121+
122+
const orgId = flags['org-id']
123+
const projectId = flags['project-id']
124+
const pipelineDefinitionId = flags['pipeline-definition-id']
125+
const vcsOrigin = flags['vcs-origin']
126+
const contextIds = flags['context-id']
127+
128+
// Validate required flags
129+
if (!orgId) {
130+
throw new Error('org-id is required')
131+
}
132+
if (!projectId) {
133+
throw new Error('project-id is required')
134+
}
135+
if (!pipelineDefinitionId) {
136+
throw new Error('pipeline-definition-id is required')
137+
}
138+
if (!vcsOrigin) {
139+
throw new Error('vcs-origin is required')
140+
}
141+
142+
// Validate formats
143+
this.validateUuid(orgId, 'org-id')
144+
this.validateUuid(projectId, 'project-id')
145+
this.validateUuid(pipelineDefinitionId, 'pipeline-definition-id')
146+
this.validateVcsOrigin(vcsOrigin)
147+
if (contextIds?.length > 0) {
148+
for (const contextId of contextIds) {
149+
this.validateUuid(contextId, 'context-id')
150+
}
151+
}
152+
153+
return {
154+
values: {
155+
package: pkgName,
156+
orgId,
157+
projectId,
158+
pipelineDefinitionId,
159+
vcsOrigin,
160+
...(contextIds?.length > 0 && { contextIds }),
161+
},
162+
fromPackageJson: {},
163+
warnings: [],
164+
urls: {
165+
package: this.getFrontendUrl({ pkgName }),
166+
vcsOrigin: this.getVcsOriginUrl(vcsOrigin),
167+
},
168+
}
169+
}
170+
171+
async exec (positionalArgs, flags) {
172+
await this.createConfigCommand({
173+
positionalArgs,
174+
flags,
175+
})
176+
}
177+
}
178+
179+
module.exports = TrustCircleCI

lib/commands/trust/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Trust extends BaseCommand {
77
static subcommands = {
88
github: require('./github.js'),
99
gitlab: require('./gitlab.js'),
10+
circleci: require('./circleci.js'),
1011
list: require('./list.js'),
1112
revoke: require('./revoke.js'),
1213
}

lib/commands/trust/list.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { otplease } = require('../../utils/auth.js')
22
const npmFetch = require('npm-registry-fetch')
33
const npa = require('npm-package-arg')
4+
const TrustCircleCI = require('./circleci.js')
45
const TrustGithub = require('./github.js')
56
const TrustGitlab = require('./gitlab.js')
67
const TrustCommand = require('../../trust-cmd.js')
@@ -21,7 +22,9 @@ class TrustList extends TrustCommand {
2122
]
2223

2324
static bodyToOptions (body) {
24-
if (body.type === 'github') {
25+
if (body.type === 'circleci') {
26+
return TrustCircleCI.bodyToOptions(body)
27+
} else if (body.type === 'github') {
2528
return TrustGithub.bodyToOptions(body)
2629
} else if (body.type === 'gitlab') {
2730
return TrustGitlab.bodyToOptions(body)

lib/trust-cmd.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class TrustCommand extends BaseCommand {
5555

5656
const json = this.config.get('json')
5757
if (json) {
58+
// Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
5859
output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false })
5960
return
6061
}
@@ -95,7 +96,7 @@ class TrustCommand extends BaseCommand {
9596
}
9697
if (urlLines.length > 0) {
9798
output.standard()
98-
output.standard(urlLines.join('\n'))
99+
output.standard(urlLines.join('\n'), { [META]: true, redact: false })
99100
}
100101
}
101102
if (pad) {

tap-snapshots/test/lib/commands/completion.js.test.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ Array [
139139
String(
140140
github
141141
gitlab
142+
circleci
142143
list
143144
revoke
144145
),

tap-snapshots/test/lib/docs.js.test.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5791,6 +5791,9 @@ Subcommands:
57915791
gitlab
57925792
Create a trusted relationship between a package and GitLab CI/CD
57935793
5794+
circleci
5795+
Create a trusted relationship between a package and CircleCI
5796+
57945797
list
57955798
List trusted relationships for a package
57965799
@@ -5815,6 +5818,8 @@ Note: This command is unaware of workspaces.
58155818
#### Flags
58165819
#### Synopsis
58175820
#### Flags
5821+
#### Synopsis
5822+
#### Flags
58185823
`
58195824

58205825
exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = `

0 commit comments

Comments
 (0)