Skip to content

Commit f822815

Browse files
Add GitHub secrets provider (#718)
* Add GitHub secrets provider * Tighten GitHub secrets provider edge cases * Validate GitHub org secret scope options * Handle user-scoped GitHub secrets app * Validate GitHub secret target scopes
1 parent 2eee97e commit f822815

7 files changed

Lines changed: 466 additions & 2 deletions

File tree

packages/cli/src/adapter-registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [
9595
{
9696
id: 'secrets',
9797
pkgPrefix: '@profullstack/sh1pt-secrets',
98-
description: 'Secrets CLIs — Doppler, dotenvx, 1Password',
99-
adapters: ['doppler', 'dotenvx', 'onepassword'],
98+
description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password',
99+
adapters: ['doppler', 'dotenvx', 'github', 'onepassword'],
100100
},
101101
{
102102
id: 'security',

packages/secrets/github/README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# GitHub Secrets
2+
3+
Provides the GitHub Actions secrets module for sh1pt.
4+
5+
## What it does
6+
7+
- Lists repository, environment, organization, or user secret names with `gh secret list`.
8+
- Pushes secret values with `gh secret set` without logging secret values.
9+
- Supports GitHub Actions, Agents, Codespaces, and Dependabot secret scopes exposed by GitHub CLI.
10+
11+
## Package
12+
13+
- Name: `@profullstack/sh1pt-secrets-github`
14+
- Path: `packages/secrets/github`
15+
- Adapter ID: `secrets-github`
16+
- Homepage: https://sh1pt.com
17+
18+
## Scripts
19+
20+
- `build`: `tsc -p tsconfig.json`
21+
- `prepublishOnly`: `pnpm build`
22+
- `typecheck`: `tsc -p tsconfig.json --noEmit`
23+
24+
## Usage
25+
26+
```bash
27+
pnpm add @profullstack/sh1pt-secrets-github
28+
```
29+
30+
## Development
31+
32+
```bash
33+
pnpm --filter @profullstack/sh1pt-secrets-github typecheck
34+
pnpm vitest run packages/secrets/github/src/index.test.ts
35+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@profullstack/sh1pt-secrets-github",
3+
"version": "0.1.15",
4+
"type": "module",
5+
"main": "./src/index.ts",
6+
"scripts": {
7+
"build": "tsc -p tsconfig.json",
8+
"typecheck": "tsc -p tsconfig.json --noEmit",
9+
"prepublishOnly": "pnpm build"
10+
},
11+
"dependencies": {
12+
"@profullstack/sh1pt-core": "workspace:*"
13+
},
14+
"license": "MIT",
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/profullstack/sh1pt.git",
18+
"directory": "packages/secrets/github"
19+
},
20+
"homepage": "https://sh1pt.com",
21+
"bugs": "https://github.com/profullstack/sh1pt/issues",
22+
"files": [
23+
"dist"
24+
],
25+
"publishConfig": {
26+
"access": "public",
27+
"main": "./dist/index.js",
28+
"types": "./dist/index.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./dist/index.d.ts",
32+
"import": "./dist/index.js",
33+
"default": "./dist/index.js"
34+
}
35+
}
36+
}
37+
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { execMock } = vi.hoisted(() => ({
5+
execMock: vi.fn(),
6+
}));
7+
8+
vi.mock('@profullstack/sh1pt-core', async () => ({
9+
...await vi.importActual<typeof import('@profullstack/sh1pt-core')>('@profullstack/sh1pt-core'),
10+
exec: execMock,
11+
}));
12+
13+
import adapter from './index.js';
14+
15+
smokeTest(adapter, { idPrefix: 'secrets' });
16+
17+
beforeEach(() => {
18+
vi.clearAllMocks();
19+
});
20+
21+
describe('GitHub secrets provider', () => {
22+
it('checks GitHub CLI authentication before reporting a connection', async () => {
23+
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
24+
25+
await expect(adapter.connect({ secret: () => undefined, log: () => {} }, {
26+
repo: 'owner/repo',
27+
})).resolves.toEqual({ accountId: 'owner/repo' });
28+
29+
expect(execMock).toHaveBeenCalledWith('gh', [
30+
'auth',
31+
'status',
32+
], expect.objectContaining({ throwOnNonZero: true }));
33+
});
34+
35+
it('lists GitHub secret metadata without attempting to read values', async () => {
36+
execMock.mockResolvedValue({
37+
exitCode: 0,
38+
stderr: '',
39+
stdout: JSON.stringify([
40+
{ name: 'API_TOKEN', updatedAt: '2026-06-10T00:00:00Z', visibility: 'private' },
41+
{ name: 'DEPLOY_KEY', numSelectedRepos: 2 },
42+
]),
43+
});
44+
45+
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
46+
repo: 'owner/repo',
47+
app: 'actions',
48+
})).resolves.toEqual([
49+
{ key: 'API_TOKEN', path: 'private · 2026-06-10T00:00:00Z' },
50+
{ key: 'DEPLOY_KEY', path: '2 selected repos' },
51+
]);
52+
53+
expect(execMock).toHaveBeenCalledWith('gh', [
54+
'secret',
55+
'list',
56+
'--app',
57+
'actions',
58+
'--json',
59+
'name,updatedAt,visibility,selectedReposURL,numSelectedRepos',
60+
'--repo',
61+
'owner/repo',
62+
], expect.objectContaining({ throwOnNonZero: true }));
63+
});
64+
65+
it('reports invalid GitHub CLI list output with an actionable error', async () => {
66+
execMock.mockResolvedValue({
67+
exitCode: 0,
68+
stderr: '',
69+
stdout: 'warning: authentication needs attention\n[]',
70+
});
71+
72+
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
73+
repo: 'owner/repo',
74+
})).rejects.toThrow('Unable to parse `gh secret list --json` output as JSON');
75+
});
76+
77+
it('sets repository environment secrets from provided values or the sh1pt vault', async () => {
78+
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
79+
const logs: string[] = [];
80+
81+
await expect(adapter.push({
82+
secret: (key) => key === 'FROM_VAULT' ? 'vault-value' : undefined,
83+
log: (message) => logs.push(message),
84+
}, [
85+
{ key: 'DIRECT_VALUE', value: 'direct-value' },
86+
{ key: 'FROM_VAULT' },
87+
], {
88+
repo: 'owner/repo',
89+
environment: 'production',
90+
})).resolves.toEqual({ count: 2 });
91+
92+
expect(execMock).toHaveBeenNthCalledWith(1, 'gh', [
93+
'secret',
94+
'set',
95+
'--app',
96+
'actions',
97+
'--repo',
98+
'owner/repo',
99+
'--env',
100+
'production',
101+
'DIRECT_VALUE',
102+
'--body',
103+
'direct-value',
104+
], expect.objectContaining({ throwOnNonZero: true }));
105+
expect(execMock).toHaveBeenNthCalledWith(2, 'gh', [
106+
'secret',
107+
'set',
108+
'--app',
109+
'actions',
110+
'--repo',
111+
'owner/repo',
112+
'--env',
113+
'production',
114+
'FROM_VAULT',
115+
'--body',
116+
'vault-value',
117+
], expect.objectContaining({ throwOnNonZero: true }));
118+
expect(logs.join('\n')).not.toContain('direct-value');
119+
expect(logs.join('\n')).not.toContain('vault-value');
120+
});
121+
122+
it('supports organization visibility arguments', async () => {
123+
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
124+
125+
await adapter.push({ secret: () => undefined, log: () => {} }, [
126+
{ key: 'ORG_TOKEN', value: 'token' },
127+
], {
128+
org: 'my-org',
129+
repos: ['repo-a', 'repo-b'],
130+
});
131+
132+
expect(execMock).toHaveBeenCalledWith('gh', expect.arrayContaining([
133+
'--org',
134+
'my-org',
135+
'--repos',
136+
'repo-a,repo-b',
137+
]), expect.any(Object));
138+
});
139+
140+
it('rejects conflicting organization visibility and repository selection options', async () => {
141+
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
142+
{ key: 'ORG_TOKEN', value: 'token' },
143+
], {
144+
org: 'my-org',
145+
visibility: 'all',
146+
repos: ['repo-a'],
147+
})).rejects.toThrow('GitHub organization secrets cannot combine visibility with explicit repository selection');
148+
149+
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
150+
{ key: 'ORG_TOKEN', value: 'token' },
151+
], {
152+
org: 'my-org',
153+
visibility: 'private',
154+
noReposSelected: true,
155+
})).rejects.toThrow('GitHub organization secrets cannot combine visibility with explicit repository selection');
156+
157+
expect(execMock).not.toHaveBeenCalled();
158+
});
159+
160+
it('supports repository restrictions for user secrets', async () => {
161+
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
162+
163+
await adapter.push({ secret: () => undefined, log: () => {} }, [
164+
{ key: 'USER_TOKEN', value: 'token' },
165+
], {
166+
user: true,
167+
repos: ['owner/repo'],
168+
});
169+
170+
expect(execMock).toHaveBeenCalledWith('gh', expect.arrayContaining([
171+
'--app',
172+
'codespaces',
173+
'--user',
174+
'--repos',
175+
'owner/repo',
176+
]), expect.any(Object));
177+
});
178+
179+
it('rejects organization-only visibility options for user secrets', async () => {
180+
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
181+
{ key: 'USER_TOKEN', value: 'token' },
182+
], {
183+
user: true,
184+
noReposSelected: true,
185+
})).rejects.toThrow('GitHub user secrets do not support noReposSelected');
186+
187+
expect(execMock).not.toHaveBeenCalled();
188+
});
189+
190+
it('rejects non-Codespaces apps for user secrets', async () => {
191+
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
192+
user: true,
193+
app: 'actions',
194+
})).rejects.toThrow('GitHub user secrets only support the Codespaces app');
195+
196+
expect(execMock).not.toHaveBeenCalled();
197+
});
198+
199+
it('rejects mutually exclusive target scopes before calling gh', async () => {
200+
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
201+
user: true,
202+
repo: 'owner/repo',
203+
})).rejects.toThrow('GitHub user secrets cannot be combined with repository, environment, or organization scope');
204+
205+
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
206+
{ key: 'ORG_TOKEN', value: 'token' },
207+
], {
208+
org: 'my-org',
209+
environment: 'production',
210+
})).rejects.toThrow('GitHub organization secrets cannot be combined with repository or environment scope');
211+
212+
expect(execMock).not.toHaveBeenCalled();
213+
});
214+
});

0 commit comments

Comments
 (0)