Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [
{
id: 'secrets',
pkgPrefix: '@profullstack/sh1pt-secrets',
description: 'Secrets CLIs — Doppler, dotenvx, 1Password',
adapters: ['doppler', 'dotenvx', 'onepassword'],
description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password',
adapters: ['doppler', 'dotenvx', 'github', 'onepassword'],
},
{
id: 'security',
Expand Down
35 changes: 35 additions & 0 deletions packages/secrets/github/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# GitHub Secrets

Provides the GitHub Actions secrets module for sh1pt.

## What it does

- Lists repository, environment, organization, or user secret names with `gh secret list`.
- Pushes secret values with `gh secret set` without logging secret values.
- Supports GitHub Actions, Agents, Codespaces, and Dependabot secret scopes exposed by GitHub CLI.

## Package

- Name: `@profullstack/sh1pt-secrets-github`
- Path: `packages/secrets/github`
- Adapter ID: `secrets-github`
- Homepage: https://sh1pt.com

## Scripts

- `build`: `tsc -p tsconfig.json`
- `prepublishOnly`: `pnpm build`
- `typecheck`: `tsc -p tsconfig.json --noEmit`

## Usage

```bash
pnpm add @profullstack/sh1pt-secrets-github
```

## Development

```bash
pnpm --filter @profullstack/sh1pt-secrets-github typecheck
pnpm vitest run packages/secrets/github/src/index.test.ts
```
37 changes: 37 additions & 0 deletions packages/secrets/github/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@profullstack/sh1pt-secrets-github",
"version": "0.1.15",
"type": "module",
"main": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"@profullstack/sh1pt-core": "workspace:*"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/profullstack/sh1pt.git",
"directory": "packages/secrets/github"
},
"homepage": "https://sh1pt.com",
"bugs": "https://github.com/profullstack/sh1pt/issues",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}
}
214 changes: 214 additions & 0 deletions packages/secrets/github/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const { execMock } = vi.hoisted(() => ({
execMock: vi.fn(),
}));

vi.mock('@profullstack/sh1pt-core', async () => ({
...await vi.importActual<typeof import('@profullstack/sh1pt-core')>('@profullstack/sh1pt-core'),
exec: execMock,
}));

import adapter from './index.js';

smokeTest(adapter, { idPrefix: 'secrets' });

beforeEach(() => {
vi.clearAllMocks();
});

describe('GitHub secrets provider', () => {
it('checks GitHub CLI authentication before reporting a connection', async () => {
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });

await expect(adapter.connect({ secret: () => undefined, log: () => {} }, {
repo: 'owner/repo',
})).resolves.toEqual({ accountId: 'owner/repo' });

expect(execMock).toHaveBeenCalledWith('gh', [
'auth',
'status',
], expect.objectContaining({ throwOnNonZero: true }));
});

it('lists GitHub secret metadata without attempting to read values', async () => {
execMock.mockResolvedValue({
exitCode: 0,
stderr: '',
stdout: JSON.stringify([
{ name: 'API_TOKEN', updatedAt: '2026-06-10T00:00:00Z', visibility: 'private' },
{ name: 'DEPLOY_KEY', numSelectedRepos: 2 },
]),
});

await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
repo: 'owner/repo',
app: 'actions',
})).resolves.toEqual([
{ key: 'API_TOKEN', path: 'private · 2026-06-10T00:00:00Z' },
{ key: 'DEPLOY_KEY', path: '2 selected repos' },
]);

expect(execMock).toHaveBeenCalledWith('gh', [
'secret',
'list',
'--app',
'actions',
'--json',
'name,updatedAt,visibility,selectedReposURL,numSelectedRepos',
'--repo',
'owner/repo',
], expect.objectContaining({ throwOnNonZero: true }));
});

it('reports invalid GitHub CLI list output with an actionable error', async () => {
execMock.mockResolvedValue({
exitCode: 0,
stderr: '',
stdout: 'warning: authentication needs attention\n[]',
});

await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
repo: 'owner/repo',
})).rejects.toThrow('Unable to parse `gh secret list --json` output as JSON');
});

it('sets repository environment secrets from provided values or the sh1pt vault', async () => {
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
const logs: string[] = [];

await expect(adapter.push({
secret: (key) => key === 'FROM_VAULT' ? 'vault-value' : undefined,
log: (message) => logs.push(message),
}, [
{ key: 'DIRECT_VALUE', value: 'direct-value' },
{ key: 'FROM_VAULT' },
], {
repo: 'owner/repo',
environment: 'production',
})).resolves.toEqual({ count: 2 });

expect(execMock).toHaveBeenNthCalledWith(1, 'gh', [
'secret',
'set',
'--app',
'actions',
'--repo',
'owner/repo',
'--env',
'production',
'DIRECT_VALUE',
'--body',
'direct-value',
], expect.objectContaining({ throwOnNonZero: true }));
expect(execMock).toHaveBeenNthCalledWith(2, 'gh', [
'secret',
'set',
'--app',
'actions',
'--repo',
'owner/repo',
'--env',
'production',
'FROM_VAULT',
'--body',
'vault-value',
], expect.objectContaining({ throwOnNonZero: true }));
expect(logs.join('\n')).not.toContain('direct-value');
expect(logs.join('\n')).not.toContain('vault-value');
});

it('supports organization visibility arguments', async () => {
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });

await adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'ORG_TOKEN', value: 'token' },
], {
org: 'my-org',
repos: ['repo-a', 'repo-b'],
});

expect(execMock).toHaveBeenCalledWith('gh', expect.arrayContaining([
'--org',
'my-org',
'--repos',
'repo-a,repo-b',
]), expect.any(Object));
});

it('rejects conflicting organization visibility and repository selection options', async () => {
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'ORG_TOKEN', value: 'token' },
], {
org: 'my-org',
visibility: 'all',
repos: ['repo-a'],
})).rejects.toThrow('GitHub organization secrets cannot combine visibility with explicit repository selection');

await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'ORG_TOKEN', value: 'token' },
], {
org: 'my-org',
visibility: 'private',
noReposSelected: true,
})).rejects.toThrow('GitHub organization secrets cannot combine visibility with explicit repository selection');

expect(execMock).not.toHaveBeenCalled();
});

it('supports repository restrictions for user secrets', async () => {
execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });

await adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'USER_TOKEN', value: 'token' },
], {
user: true,
repos: ['owner/repo'],
});

expect(execMock).toHaveBeenCalledWith('gh', expect.arrayContaining([
'--app',
'codespaces',
'--user',
'--repos',
'owner/repo',
]), expect.any(Object));
});

it('rejects organization-only visibility options for user secrets', async () => {
await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'USER_TOKEN', value: 'token' },
], {
user: true,
noReposSelected: true,
})).rejects.toThrow('GitHub user secrets do not support noReposSelected');

expect(execMock).not.toHaveBeenCalled();
});

it('rejects non-Codespaces apps for user secrets', async () => {
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
user: true,
app: 'actions',
})).rejects.toThrow('GitHub user secrets only support the Codespaces app');

expect(execMock).not.toHaveBeenCalled();
});

it('rejects mutually exclusive target scopes before calling gh', async () => {
await expect(adapter.pull({ secret: () => undefined, log: () => {} }, {
user: true,
repo: 'owner/repo',
})).rejects.toThrow('GitHub user secrets cannot be combined with repository, environment, or organization scope');

await expect(adapter.push({ secret: () => undefined, log: () => {} }, [
{ key: 'ORG_TOKEN', value: 'token' },
], {
org: 'my-org',
environment: 'production',
})).rejects.toThrow('GitHub organization secrets cannot be combined with repository or environment scope');

expect(execMock).not.toHaveBeenCalled();
});
});
Loading
Loading