diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index b7b14b67..eb821a1a 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -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', diff --git a/packages/secrets/github/README.md b/packages/secrets/github/README.md new file mode 100644 index 00000000..1a468362 --- /dev/null +++ b/packages/secrets/github/README.md @@ -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 +``` diff --git a/packages/secrets/github/package.json b/packages/secrets/github/package.json new file mode 100644 index 00000000..3b03b7cb --- /dev/null +++ b/packages/secrets/github/package.json @@ -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" + } + } + } +} diff --git a/packages/secrets/github/src/index.test.ts b/packages/secrets/github/src/index.test.ts new file mode 100644 index 00000000..b8dbc5b9 --- /dev/null +++ b/packages/secrets/github/src/index.test.ts @@ -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('@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(); + }); +}); diff --git a/packages/secrets/github/src/index.ts b/packages/secrets/github/src/index.ts new file mode 100644 index 00000000..857d3740 --- /dev/null +++ b/packages/secrets/github/src/index.ts @@ -0,0 +1,167 @@ +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 { + if (config.user) { + if (config.app && config.app !== 'codespaces') { + throw new Error('GitHub user secrets only support the Codespaces app'); + } + return 'codespaces'; + } + 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 (config.user && (repo || environment || org)) { + throw new Error('GitHub user secrets cannot be combined with repository, environment, or organization scope'); + } + if (org && (repo || environment)) { + throw new Error('GitHub organization secrets cannot be combined with repository or environment scope'); + } + + 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 scopeVisibilityArgs(config: Config): string[] { + if (!text(config.org) && !config.user) return []; + + if (config.user) { + if (config.noReposSelected) { + throw new Error('GitHub user secrets do not support noReposSelected; omit it or use repos to restrict access'); + } + if (config.visibility) { + throw new Error('GitHub user secrets do not support visibility; use repos to restrict Codespaces access'); + } + if (config.repos?.length) return ['--repos', config.repos.join(',')]; + return []; + } + + if (config.visibility && (config.noReposSelected || config.repos?.length)) { + throw new Error('GitHub organization secrets cannot combine visibility with explicit repository selection'); + } + if (config.noReposSelected) return ['--no-repos-selected']; + if (config.repos?.length) return ['--repos', config.repos.join(',')]; + if (config.visibility) return ['--visibility', config.visibility]; + return []; +} + +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 []; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + throw new Error('Unable to parse `gh secret list --json` output as JSON. Run `gh auth status` and retry.', { + cause: error, + }); + } + if (!Array.isArray(parsed)) { + throw new Error('Expected `gh secret list --json` to return a JSON array.'); + } + const entries = parsed 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, + })); +} + +export default defineSecretProvider({ + 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}`); + await exec('gh', ['auth', 'status'], { log: (message) => ctx.log(message), throwOnNonZero: true }); + return { accountId: scope }; + }, + async pull(ctx, config): Promise { + 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), ...scopeVisibilityArgs(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 `); + 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', + ], + }), +}); diff --git a/packages/secrets/github/tsconfig.json b/packages/secrets/github/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/secrets/github/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51c7c525..0b91aecd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1379,6 +1379,12 @@ importers: specifier: workspace:* version: link:../../core + packages/secrets/github: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/secrets/onepassword: dependencies: '@profullstack/sh1pt-core':