Skip to content
Open
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"
}
}
}
}
115 changes: 115 additions & 0 deletions packages/secrets/github/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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('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('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',
visibility: 'selected',
repos: ['repo-a', 'repo-b'],
});

expect(execMock).toHaveBeenCalledWith('gh', expect.arrayContaining([
'--org',
'my-org',
'--repos',
'repo-a,repo-b',
]), expect.any(Object));
});
});
127 changes: 127 additions & 0 deletions packages/secrets/github/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { defineSecretProvider, exec, manualSetup, type SecretRef } from '@profullstack/sh1pt-core';

type GitHubSecretApp = 'actions' | 'agents' | 'codespaces' | 'dependabot';
type GitHubSecretVisibility = 'all' | 'private' | 'selected';

interface Config {
app?: GitHubSecretApp;
repo?: string;
environment?: string;
org?: string;
user?: boolean;
visibility?: GitHubSecretVisibility;
repos?: string[];
noReposSelected?: boolean;
}

interface GitHubSecretListEntry {
name: string;
updatedAt?: string;
visibility?: string;
selectedReposURL?: string;
numSelectedRepos?: number;
}

function text(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

function app(config: Config): GitHubSecretApp {
return config.app ?? 'actions';
}

function targetArgs(config: Config): string[] {
const args: string[] = [];
const repo = text(config.repo);
const environment = text(config.environment);
const org = text(config.org);

if (repo) args.push('--repo', repo);
if (environment) args.push('--env', environment);
if (org) args.push('--org', org);
if (config.user) args.push('--user');

return args;
}

function orgVisibilityArgs(config: Config): string[] {
if (!text(config.org) && !config.user) return [];
if (config.noReposSelected) return ['--no-repos-selected'];
if (config.repos?.length) return ['--repos', config.repos.join(',')];
if (config.visibility) return ['--visibility', config.visibility];
return [];
}
Comment on lines +48 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --no-repos-selected incorrectly allowed for user-scoped secrets

orgVisibilityArgs gates only on !org && !user, so when config.user = true and config.noReposSelected = true the function returns ['--no-repos-selected']. The GitHub CLI only accepts --no-repos-selected on org secrets; passing it on a user-scoped call will cause gh to error. A guard like if (!text(config.org)) return [] before the noReposSelected branch would prevent this invalid combination from reaching the CLI.


function assertSecretKey(key: string): string {
const normalized = key.trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(normalized)) {
throw new Error(`GitHub secret key must be an environment-style name: ${key}`);
}
return normalized;
}

function parseSecretList(stdout: string): SecretRef[] {
const body = stdout.trim();
if (!body) return [];
const entries = JSON.parse(body) as GitHubSecretListEntry[];
return entries.map((entry) => ({
key: entry.name,
path: [
entry.visibility,
entry.numSelectedRepos !== undefined ? `${entry.numSelectedRepos} selected repos` : undefined,
entry.updatedAt,
].filter(Boolean).join(' · ') || undefined,
}));
}
Comment on lines +64 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unguarded JSON.parse will surface as an opaque SyntaxError

parseSecretList calls JSON.parse(body) with no try/catch. If gh secret list --json returns a non-zero exit code that somehow slips through, emits a warning/notice on stdout before the JSON array, or produces truncated output, the caller receives a raw SyntaxError with no indication of what the gh output actually contained. Wrapping the parse in a try/catch and rethrowing with the raw stdout snippet in the message would make diagnosis far easier in production.


export default defineSecretProvider<Config>({
id: 'secrets-github',
label: 'GitHub Secrets',
cli: 'gh',
async connect(ctx, config) {
const scope = text(config.repo) ?? text(config.org) ?? (config.user ? 'user' : 'current repository');
ctx.log(`gh auth status · scope=${scope}`);
return { accountId: scope };
},
Comment on lines +82 to +86

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 connect logs but never runs gh auth status

The log line gh auth status · scope=… implies the command is executed, but the method only constructs a string and returns without running any subprocess. Authentication failures therefore aren't surfaced at connect time — the first real error appears at pull or push, typically with a less helpful message. Running exec('gh', ['auth', 'status'], { throwOnNonZero: true }) here would give callers an early, actionable failure.

async pull(ctx, config): Promise<SecretRef[]> {
const args = [
'secret',
'list',
'--app',
app(config),
'--json',
'name,updatedAt,visibility,selectedReposURL,numSelectedRepos',
...targetArgs(config),
];
ctx.log(`gh ${args.join(' ')}`);
const result = await exec('gh', args, { log: (message) => ctx.log(message), throwOnNonZero: true });
return parseSecretList(result.stdout);
},
async push(ctx, secrets, config) {
const commonArgs = ['secret', 'set', '--app', app(config), ...targetArgs(config), ...orgVisibilityArgs(config)];
for (const secret of secrets) {
const key = assertSecretKey(secret.key);
const value = secret.value ?? ctx.secret(key);
if (value === undefined) {
throw new Error(`No value provided for GitHub secret ${key}`);
}
ctx.log(`gh ${commonArgs.join(' ')} ${key} --body <redacted>`);
await exec('gh', [...commonArgs, key, '--body', value], {
log: (message) => ctx.log(message),
throwOnNonZero: true,
});
}
return { count: secrets.length };
},
setup: manualSetup({
label: 'GitHub CLI',
vendorDocUrl: 'https://cli.github.com/manual/gh_secret',
steps: [
'Install GitHub CLI from the official docs',
'Authenticate with a token that can manage the target secret scope: gh auth login',
'For repository secrets, configure repo: owner/name',
'For environment secrets, configure repo plus environment',
],
}),
});
5 changes: 5 additions & 0 deletions packages/secrets/github/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src/**/*"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading