import { request } from '../core.ts'
import type { GitHubAccount, GitHubRequestOptions } from '../types.ts'
import { sealActionsSecret } from './sealed-box.ts'
const SECRET_NAME_PATTERN = /^[A-Z0-9_]{1,100}$/
const DEFAULT_GENERATE_BYTES = 32
export type PutActionsSecretParams = {
owner: string
repo: string
name: string
value?: string
generateBytes?: number
environment?: string
account?: GitHubAccount
}
export type PutActionsSecretResult = {
owner: string
repo: string
name: string
environment: string | null
created: boolean
updated: boolean
status: number
keyId: string
generated: boolean
}
type GithubRequest = typeof request
type ActionsPublicKey = {
key_id: string
key: string
}
/**
* Create or update a GitHub Actions secret without returning the plaintext.
*
* Fetches the repo (or environment) public key, sealed-box encrypts the value,
* and PUTs it. Pass `value` for a known secret, or `generateBytes` to mint a
* random hex value that never leaves this call.
*
* @example
* import putActionsSecret from 'kody:@kentcdodds/github/actions/put-secret'
* const result = await putActionsSecret({
* owner: 'kentcdodds',
* repo: 'kody',
* name: 'NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN',
* generateBytes: 32,
* })
* // => { created: true, status: 201, generated: true, name: 'NX_...' }
*/
export default async function putActionsSecret(
params: Record<string, unknown> = {},
requestImpl: GithubRequest = request,
): Promise<PutActionsSecretResult> {
const input = normalizePutActionsSecretInput(params)
const value =
input.value ?? generateHexSecret(input.generateBytes ?? DEFAULT_GENERATE_BYTES)
const generated = input.value === undefined
const publicKeyPath = actionsPublicKeyPath(input)
const secretPath = actionsSecretPath(input)
const publicKeyResponse = await requestImpl<ActionsPublicKey>({
account: input.account,
path: publicKeyPath,
throwOnError: true,
} satisfies GitHubRequestOptions)
const publicKey = publicKeyResponse.data
if (!publicKey?.key || !publicKey.key_id) {
throw new Error(
`GitHub Actions public key response for ${input.owner}/${input.repo} was missing key or key_id`,
)
}
const encryptedValue = sealActionsSecret(value, publicKey.key)
const putResponse = await requestImpl({
account: input.account,
path: secretPath,
method: 'PUT',
body: {
encrypted_value: encryptedValue,
key_id: publicKey.key_id,
},
throwOnError: true,
} satisfies GitHubRequestOptions)
if (putResponse.status !== 201 && putResponse.status !== 204) {
throw new Error(
`GitHub Actions secret PUT for ${input.name} returned unexpected status ${putResponse.status}`,
)
}
return {
owner: input.owner,
repo: input.repo,
name: input.name,
environment: input.environment,
created: putResponse.status === 201,
updated: putResponse.status === 204,
status: putResponse.status,
keyId: publicKey.key_id,
generated,
}
}
function normalizePutActionsSecretInput(params: Record<string, unknown>) {
const errors: Array<string> = []
const owner = readNonEmptyString(params.owner, 'owner', errors)
const repo = readNonEmptyString(params.repo, 'repo', errors)
const name = readSecretName(params.name, errors)
const environment = readOptionalName(params.environment, 'environment', errors)
const account = readAccount(params.account, errors)
const hasValue = params.value !== undefined
const hasGenerate = params.generateBytes !== undefined
let value: string | undefined
let generateBytes: number | undefined
if (hasValue && hasGenerate) {
errors.push('Provide either value or generateBytes, not both')
}
if (!hasValue && !hasGenerate) {
errors.push('Provide value or generateBytes')
}
if (hasValue) {
if (typeof params.value !== 'string' || params.value.length === 0) {
errors.push('value must be a non-empty string')
} else {
value = params.value
}
}
if (hasGenerate) {
const bytes = Number(params.generateBytes)
if (!Number.isInteger(bytes) || bytes <= 0 || bytes > 1024) {
errors.push('generateBytes must be an integer from 1 to 1024')
} else {
generateBytes = bytes
}
}
if (errors.length > 0 || !owner || !repo || !name) {
throw new Error(errors.join('; '))
}
return {
owner,
repo,
name,
value,
generateBytes,
environment,
account,
}
}
function actionsPublicKeyPath(input: {
owner: string
repo: string
environment: string | null
}) {
if (input.environment) {
return `/repos/${input.owner}/${input.repo}/environments/${encodeURIComponent(input.environment)}/secrets/public-key`
}
return `/repos/${input.owner}/${input.repo}/actions/secrets/public-key`
}
function actionsSecretPath(input: {
owner: string
repo: string
name: string
environment: string | null
}) {
if (input.environment) {
return `/repos/${input.owner}/${input.repo}/environments/${encodeURIComponent(input.environment)}/secrets/${encodeURIComponent(input.name)}`
}
return `/repos/${input.owner}/${input.repo}/actions/secrets/${encodeURIComponent(input.name)}`
}
function generateHexSecret(bytes: number) {
const buffer = new Uint8Array(bytes)
crypto.getRandomValues(buffer)
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join(
'',
)
}
function readNonEmptyString(
value: unknown,
fieldName: string,
errors: Array<string>,
) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${fieldName} must be a non-empty string`)
return null
}
return value.trim()
}
function readOptionalName(
value: unknown,
fieldName: string,
errors: Array<string>,
) {
if (value === undefined) return null
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push(`${fieldName} must be a non-empty string when provided`)
return null
}
return value.trim()
}
function readSecretName(value: unknown, errors: Array<string>) {
if (typeof value !== 'string' || value.trim().length === 0) {
errors.push('name must be a non-empty GitHub Actions secret name')
return null
}
const name = value.trim().toUpperCase()
if (!SECRET_NAME_PATTERN.test(name)) {
errors.push('name must match [A-Z0-9_]{1,100}')
return null
}
if (name.startsWith('GITHUB_')) {
errors.push('name cannot start with GITHUB_')
return null
}
return name
}
function readAccount(value: unknown, errors: Array<string>): GitHubAccount {
if (value === undefined) return 'bot'
if (value === 'bot' || value === 'kent') return value
errors.push('account must be "bot" or "kent"')
return 'bot'
}