Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/github

src/actions/put-secret.ts

235 lines · 6.4 KB · TypeScript
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'
}