1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import { isResolved } from "../Diff.ts";
4import * as Provider from "../Provider.ts";
5import { Resource } from "../Resource.ts";
6import { type Environment, resolveEnvironmentName } from "./Environment.ts";
7import { gitHubBaseUrlChanged, octokitFor } from "./Octokit.ts";
8import type * as GitHub from "./Providers.ts";
9
10export interface SecretProps {
11 /**
12 * Repository owner (user or organization).
13 */
14 owner: string;
15
16 /**
17 * Repository name.
18 */
19 repository: string;
20
21 /**
22 * Secret name (e.g. `AWS_ROLE_ARN`).
23 */
24 name: string;
25
26 /**
27 * Secret value. Wrap with `Redacted.make` to prevent the value from
28 * appearing in logs or state.
29 */
30 value: Redacted.Redacted;
31
32 /**
33 * Optional environment. When set the secret is scoped to that GitHub
34 * Actions environment instead of the whole repository. Accepts an
35 * environment name or a `GitHub.Environment` resource.
36 */
37 environment?: string | Environment;
38
39 /**
40 * Override the GitHub host or API base URL for this resource only (e.g.
41 * `github.example.com` for GitHub Enterprise). Falls back to
42 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
43 * provider. Changing it replaces the resource — the same name on a
44 * different GitHub instance is a different physical resource.
45 */
46 baseUrl?: string;
47}
48
49export interface Secret extends Resource<
50 "GitHub.Secret",
51 SecretProps,
52 {
53 /**
54 * ISO-8601 timestamp of the last update.
55 */
56 updatedAt: string;
57 },
58 never,
59 GitHub.Providers
60> {}
61
62/**
63 * A GitHub Actions repository or environment secret.
64 *
65 * `Secret` manages the lifecycle of an encrypted secret in GitHub Actions.
66 * Secrets are encrypted using the repository's (or environment's) public
67 * key via `libsodium` before being stored. The resource is idempotent —
68 * calling it with the same name will update the secret value in place.
69 *
70 * Authentication is resolved via the `GitHubCredentials` service supplied
71 * by `GitHub.providers()` (which uses the Alchemy AuthProvider — env,
72 * stored PAT, `gh` CLI, or OAuth). The token needs `repo` scope for
73 * private repositories or `public_repo` for public ones.
74 * ### Repository Secrets
75 * Store secrets accessible to all GitHub Actions workflows in the
76 * repository.
77 *
78 * **Example:** Create a Repository Secret
79 * ```typescript
80 * yield* GitHub.Secret("aws-role", {
81 * owner: "my-org",
82 * repository: "my-repo",
83 * name: "AWS_ROLE_ARN",
84 * value: Redacted.make(role.roleArn),
85 * });
86 * ```
87 *
88 * ### Environment Secrets
89 * Scope a secret to a specific GitHub Actions environment (e.g.
90 * `production`, `staging`). Environment secrets require environment
91 * protection rules to be satisfied before workflows can access them.
92 *
93 * **Example:** Create an Environment Secret
94 * ```typescript
95 * yield* GitHub.Secret("deploy-key", {
96 * owner: "my-org",
97 * repository: "my-repo",
98 * environment: "production",
99 * name: "DEPLOY_KEY",
100 * value: Redacted.make("my-secret-value"),
101 * });
102 * ```
103 *
104 * ### Wiring with Other Resources
105 * A common pattern is wiring the output of another resource — like an
106 * IAM role ARN or a database URL — directly into a GitHub secret so
107 * that CI workflows can use it.
108 *
109 * **Example:** Store an IAM Role ARN for CI
110 * ```typescript
111 * const role = yield* AWS.IAM.Role("ci-role", { ... });
112 *
113 * yield* GitHub.Secret("ci-role-arn", {
114 * owner: "my-org",
115 * repository: "my-repo",
116 * name: "AWS_ROLE_ARN",
117 * value: Redacted.make(role.roleArn),
118 * });
119 * ```
120 *
121 * **Example:** Store Multiple Secrets
122 * ```typescript
123 * yield* GitHub.Secret("db-url", {
124 * owner: "my-org",
125 * repository: "my-repo",
126 * environment: "production",
127 * name: "DATABASE_URL",
128 * value: Redacted.make(database.connectionString),
129 * });
130 *
131 * yield* GitHub.Secret("api-key", {
132 * owner: "my-org",
133 * repository: "my-repo",
134 * environment: "production",
135 * name: "API_KEY",
136 * value: Redacted.make(apiKey),
137 * });
138 * ```
139 *
140 * @resource
141 * @product Actions
142 */
143export const Secret = Resource<Secret>("GitHub.Secret");
144
145async function encryptValue(
146 plaintext: string,
147 publicKey: string,
148): Promise<string> {
149 const mod = await import("libsodium-wrappers");
150 // Bun/ESM interop: the actual sodium API lives on `.default` when the
151 // CJS module is wrapped, but is the module itself under other loaders.
152 const sodium: typeof import("libsodium-wrappers") =
153 (mod as any).default ?? mod;
154 await sodium.ready;
155 const binKey = sodium.from_base64(publicKey, sodium.base64_variants.ORIGINAL);
156 const binMessage = sodium.from_string(plaintext);
157 const encrypted = sodium.crypto_box_seal(binMessage, binKey);
158 return sodium.to_base64(encrypted, sodium.base64_variants.ORIGINAL);
159}
160
161export const SecretProvider = () =>
162 Provider.succeed(Secret, {
163 // A secret's entire identity is (host, owner, repository, environment,
164 // name) — GitHub has no rename/move API for secrets, so changing any of
165 // them replaces the resource: the engine upserts the new secret first,
166 // then `delete` removes the old one from its old location. Replacement
167 // is safe here — a secret is declarative config that is fully
168 // re-creatable from props.
169 diff: Effect.fn(function* ({ news, olds }) {
170 if (!isResolved(news)) return;
171 if (olds === undefined) return;
172 if (
173 news.owner !== olds.owner ||
174 news.repository !== olds.repository ||
175 news.name !== olds.name ||
176 resolveEnvironmentName(news.environment) !==
177 resolveEnvironmentName(olds.environment) ||
178 (yield* gitHubBaseUrlChanged(olds, news))
179 ) {
180 return { action: "replace" };
181 }
182 }),
183
184 // Non-listable: a GitHub Actions secret is keyed entirely by its parent
185 // (owner, repository[, environment], name) which arrive as props — there is
186 // no ambient owner/repo scope to enumerate from, `list()` takes no input,
187 // and the `Attributes` shape carries no identifying keys (only `updatedAt`).
188 // GitHub only exposes list-secrets *within* a specific repo/environment, so
189 // there is no account-wide enumeration API. Return an empty array.
190 list: () => Effect.succeed([]),
191
192 reconcile: Effect.fn(function* ({ news, olds }) {
193 // Observe — there's no API to read a secret's value back, so we can
194 // only observe its location (repo vs. environment, environment name).
195 // A location change normally arrives as a replacement (see `diff`);
196 // this guard is the safety net for the plan-time path where `news`
197 // contained unresolved outputs and diff could not compare — delete the
198 // orphaned secret from its old location before upserting the new one.
199 if (
200 olds !== undefined &&
201 resolveEnvironmentName(olds.environment) !==
202 resolveEnvironmentName(news.environment)
203 ) {
204 yield* deleteSecret(olds);
205 }
206
207 // Ensure & Sync — `createOrUpdate*Secret` is upsert-style: it
208 // creates the secret if absent and overwrites it if present. The
209 // value is encrypted client-side with the repo/environment public
210 // key, so we re-encrypt and re-upload on every reconcile (Redacted
211 // values can't be diffed across runs anyway).
212 yield* upsertSecret(news);
213 return { updatedAt: new Date().toISOString() };
214 }),
215
216 delete: Effect.fn(function* ({ olds }) {
217 yield* deleteSecret(olds);
218 }),
219 });
220
221const upsertSecret = Effect.fn(function* (props: SecretProps) {
222 const octokit = yield* octokitFor(props.baseUrl);
223 const plaintext = Redacted.value(props.value);
224 const environment = resolveEnvironmentName(props.environment);
225
226 const publicKey = yield* Effect.tryPromise(async () => {
227 if (environment !== undefined) {
228 const { data } = await octokit.rest.actions.getEnvironmentPublicKey({
229 owner: props.owner,
230 repo: props.repository,
231 environment_name: environment,
232 });
233 return data;
234 }
235 const { data } = await octokit.rest.actions.getRepoPublicKey({
236 owner: props.owner,
237 repo: props.repository,
238 });
239 return data;
240 });
241
242 const encrypted = yield* Effect.tryPromise(() =>
243 encryptValue(plaintext, publicKey.key),
244 );
245
246 yield* Effect.tryPromise(async () => {
247 if (environment !== undefined) {
248 await octokit.rest.actions.createOrUpdateEnvironmentSecret({
249 owner: props.owner,
250 repo: props.repository,
251 environment_name: environment,
252 secret_name: props.name,
253 encrypted_value: encrypted,
254 key_id: publicKey.key_id,
255 });
256 } else {
257 await octokit.rest.actions.createOrUpdateRepoSecret({
258 owner: props.owner,
259 repo: props.repository,
260 secret_name: props.name,
261 encrypted_value: encrypted,
262 key_id: publicKey.key_id,
263 });
264 }
265 });
266});
267
268const deleteSecret = Effect.fn(function* (props: SecretProps) {
269 const octokit = yield* octokitFor(props.baseUrl);
270 const environment = resolveEnvironmentName(props.environment);
271 yield* Effect.tryPromise(async () => {
272 try {
273 if (environment !== undefined) {
274 await octokit.rest.actions.deleteEnvironmentSecret({
275 owner: props.owner,
276 repo: props.repository,
277 environment_name: environment,
278 secret_name: props.name,
279 });
280 } else {
281 await octokit.rest.actions.deleteRepoSecret({
282 owner: props.owner,
283 repo: props.repository,
284 secret_name: props.name,
285 });
286 }
287 } catch (error: any) {
288 if (error.status !== 404) {
289 throw error;
290 }
291 }
292 });
293});