-
Notifications
You must be signed in to change notification settings - Fork 62
Add GitHub secrets provider #718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` |
| 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" | ||
| } | ||
| } | ||
| } | ||
| } |
| 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)); | ||
| }); | ||
| }); |
| 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 []; | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The log line |
||
| 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', | ||
| ], | ||
| }), | ||
| }); | ||
| 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/**/*"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--no-repos-selectedincorrectly allowed for user-scoped secretsorgVisibilityArgsgates only on!org && !user, so whenconfig.user = trueandconfig.noReposSelected = truethe function returns['--no-repos-selected']. The GitHub CLI only accepts--no-repos-selectedon org secrets; passing it on a user-scoped call will causeghto error. A guard likeif (!text(config.org)) return []before thenoReposSelectedbranch would prevent this invalid combination from reaching the CLI.