1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { type Environment, resolveEnvironmentName } from "./Environment.ts";
6import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
7import type * as GitHub from "./Providers.ts";
8
9export interface VariableProps {
10 /**
11 * Repository owner (user or organization).
12 */
13 owner: string;
14
15 /**
16 * Repository name.
17 */
18 repository: string;
19
20 /**
21 * Variable name (e.g. `AWS_ROLE_ARN`).
22 */
23 name: string;
24
25 /**
26 * Variable value.
27 */
28 value: string;
29
30 /**
31 * Optional environment. When set the variable is scoped to that GitHub
32 * Actions environment instead of the whole repository. Accepts an
33 * environment name or a `GitHub.Environment` resource.
34 */
35 environment?: string | Environment;
36
37 /**
38 * Override the GitHub host or API base URL for this resource only (e.g.
39 * `github.example.com` for GitHub Enterprise). Falls back to
40 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
41 * provider. Changing it replaces the resource — the same name on a
42 * different GitHub instance is a different physical resource.
43 */
44 baseUrl?: string;
45}
46
47export interface Variable extends Resource<
48 "GitHub.Variable",
49 VariableProps,
50 {
51 /**
52 * ISO-8601 timestamp of the last update.
53 */
54 updatedAt: string;
55 },
56 never,
57 GitHub.Providers
58> {}
59
60/**
61 * A GitHub Actions repository variable.
62 *
63 * `Variable` manages the lifecycle of a plain-text configuration variable
64 * in GitHub Actions. Variables are visible in workflow logs and are
65 * suitable for non-sensitive configuration like region names, environment
66 * labels, or feature flags. For sensitive values, use `GitHub.Secret`
67 * instead.
68 *
69 * Authentication is resolved via the `GitHubCredentials` service supplied
70 * by `GitHub.providers()` (which uses the Alchemy AuthProvider — env,
71 * stored PAT, `gh` CLI, or OAuth). The token needs `repo` scope for
72 * private repositories or `public_repo` for public ones.
73 * ### Repository Variables
74 * Store variables accessible to all GitHub Actions workflows in the
75 * repository.
76 *
77 * **Example:** Create a Repository Variable
78 * ```typescript
79 * yield* GitHub.Variable("aws-region", {
80 * owner: "my-org",
81 * repository: "my-repo",
82 * name: "AWS_REGION",
83 * value: "us-east-1",
84 * });
85 * ```
86 *
87 * ### Environment Variables
88 * Scope a variable to a specific GitHub Actions environment (e.g.
89 * `production`, `staging`). Use `GitHub.Environment` to manage the
90 * environment itself.
91 *
92 * **Example:** Create an Environment Variable
93 * ```typescript
94 * yield* GitHub.Variable("region", {
95 * owner: "my-org",
96 * repository: "my-repo",
97 * environment: "production",
98 * name: "AWS_REGION",
99 * value: "us-east-1",
100 * });
101 * ```
102 *
103 * ### Wiring with Other Resources
104 * Pass output attributes from other resources into GitHub variables so
105 * that CI workflows can reference them.
106 *
107 * **Example:** Store a Worker URL for CI
108 * ```typescript
109 * const worker = yield* Cloudflare.Worker("Api", { ... });
110 *
111 * yield* GitHub.Variable("api-url", {
112 * owner: "my-org",
113 * repository: "my-repo",
114 * name: "API_URL",
115 * value: worker.url!,
116 * });
117 * ```
118 *
119 * **Example:** Multiple Variables
120 * ```typescript
121 * yield* GitHub.Variable("region", {
122 * owner: "my-org",
123 * repository: "my-repo",
124 * name: "AWS_REGION",
125 * value: "us-east-1",
126 * });
127 *
128 * yield* GitHub.Variable("stage", {
129 * owner: "my-org",
130 * repository: "my-repo",
131 * name: "DEPLOY_STAGE",
132 * value: "production",
133 * });
134 * ```
135 *
136 * @resource
137 * @product Actions
138 */
139export const Variable = Resource<Variable>("GitHub.Variable");
140
141export const VariableProvider = () =>
142 Provider.succeed(Variable, {
143 // A variable's entire identity is (host, owner, repository, environment,
144 // name) — GitHub has no rename/move API for variables, so changing any
145 // of them replaces the resource: the engine creates the new variable
146 // first, then `delete` removes the old one from its old location.
147 // Replacement is safe here — a variable is declarative config that is
148 // fully re-creatable from props.
149 diff: Effect.fn(function* ({ news, olds }) {
150 if (!isResolved(news)) return;
151 if (olds === undefined) return;
152 if (
153 news.owner !== olds.owner ||
154 news.repository !== olds.repository ||
155 news.name !== olds.name ||
156 resolveEnvironmentName(news.environment) !==
157 resolveEnvironmentName(olds.environment) ||
158 (yield* gitHubBaseUrlChanged(olds, news))
159 ) {
160 return { action: "replace" };
161 }
162 }),
163
164 reconcile: Effect.fn(function* ({ news, olds }) {
165 // A variable is keyed by its location (repo vs. environment). A
166 // location change normally arrives as a replacement (see `diff`); this
167 // guard is the safety net for the plan-time path where `news` contained
168 // unresolved outputs and diff could not compare — delete the orphaned
169 // variable from its old location before converging the new one.
170 if (
171 olds !== undefined &&
172 resolveEnvironmentName(olds.environment) !==
173 resolveEnvironmentName(news.environment)
174 ) {
175 yield* deleteVariable(olds);
176 }
177
178 // Observe — `name` is the path identifier; ask GitHub directly for the
179 // live row. A 404 means it doesn't exist (deleted out-of-band, or never
180 // created), so we converge by creating it; otherwise we PATCH the value.
181 const observed = yield* getVariable(news);
182
183 // Ensure — POST creates the variable.
184 if (observed === undefined) {
185 yield* createVariable(news);
186 return { updatedAt: new Date().toISOString() };
187 }
188
189 // Sync — PATCH the value if it drifted; skip the call when the
190 // observed value already matches to keep the API quiet.
191 if (observed.value !== news.value) {
192 yield* updateVariable(news);
193 }
194 return { updatedAt: new Date().toISOString() };
195 }),
196
197 // Enumerate every Actions variable visible to the authenticated token.
198 // GitHub variables are keyed by {owner, repository, name} and there is no
199 // account-wide "list all variables" endpoint, so the ambient scope is the
200 // authenticated account: list every repository the token can see, then
201 // exhaustively paginate each repo's variables and hydrate into the same
202 // `Attributes` shape `reconcile` returns. Variable values are readable
203 // (unlike secrets), but the resource's `Attributes` only exposes
204 // `updatedAt`, so that's all we surface here.
205 list: Effect.fn(function* () {
206 const octokit = yield* Octokit;
207
208 // `octokit.paginate` walks every page and flattens to a single array.
209 const repos = yield* Effect.tryPromise({
210 try: () =>
211 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
212 per_page: 100,
213 }),
214 catch: (e) => e as Error,
215 });
216
217 const perRepo = yield* Effect.forEach(
218 repos,
219 (repo) =>
220 Effect.tryPromise({
221 try: async () => {
222 try {
223 const variables = await octokit.paginate(
224 octokit.rest.actions.listRepoVariables,
225 {
226 owner: repo.owner.login,
227 repo: repo.name,
228 per_page: 100,
229 },
230 );
231 return variables.map((v) => ({ updatedAt: v.updated_at }));
232 } catch (error: any) {
233 // Repos with Actions disabled, or where the token lacks the
234 // `repo`/`actions` scope, reject the variables endpoint with
235 // 403/404 — skip them per the per-item not-found rule rather
236 // than failing the whole enumeration.
237 if (error.status === 403 || error.status === 404) {
238 return [];
239 }
240 throw error;
241 }
242 },
243 catch: (e) => e as Error,
244 }),
245 { concurrency: 10 },
246 );
247
248 return perRepo.flat();
249 }),
250
251 delete: Effect.fn(function* ({ olds }) {
252 yield* deleteVariable(olds);
253 }),
254 });
255
256const getVariable = Effect.fn(function* (props: VariableProps) {
257 const octokit = yield* octokitFor(props.baseUrl);
258 const environment = resolveEnvironmentName(props.environment);
259 return yield* Effect.tryPromise({
260 try: async () => {
261 try {
262 if (environment !== undefined) {
263 const { data } = await octokit.rest.actions.getEnvironmentVariable({
264 owner: props.owner,
265 repo: props.repository,
266 environment_name: environment,
267 name: props.name,
268 });
269 return data;
270 }
271 const { data } = await octokit.rest.actions.getRepoVariable({
272 owner: props.owner,
273 repo: props.repository,
274 name: props.name,
275 });
276 return data;
277 } catch (error: any) {
278 if (error.status === 404) return undefined;
279 throw error;
280 }
281 },
282 catch: (e) => e as Error,
283 });
284});
285
286const createVariable = Effect.fn(function* (props: VariableProps) {
287 const octokit = yield* octokitFor(props.baseUrl);
288 const environment = resolveEnvironmentName(props.environment);
289 yield* Effect.tryPromise(async () => {
290 if (environment !== undefined) {
291 await octokit.rest.actions.createEnvironmentVariable({
292 owner: props.owner,
293 repo: props.repository,
294 environment_name: environment,
295 name: props.name,
296 value: props.value,
297 });
298 } else {
299 await octokit.rest.actions.createRepoVariable({
300 owner: props.owner,
301 repo: props.repository,
302 name: props.name,
303 value: props.value,
304 });
305 }
306 });
307});
308
309const updateVariable = Effect.fn(function* (props: VariableProps) {
310 const octokit = yield* octokitFor(props.baseUrl);
311 const environment = resolveEnvironmentName(props.environment);
312 yield* Effect.tryPromise(async () => {
313 if (environment !== undefined) {
314 await octokit.rest.actions.updateEnvironmentVariable({
315 owner: props.owner,
316 repo: props.repository,
317 environment_name: environment,
318 name: props.name,
319 value: props.value,
320 });
321 } else {
322 await octokit.rest.actions.updateRepoVariable({
323 owner: props.owner,
324 repo: props.repository,
325 name: props.name,
326 value: props.value,
327 });
328 }
329 });
330});
331
332const deleteVariable = Effect.fn(function* (props: VariableProps) {
333 const octokit = yield* octokitFor(props.baseUrl);
334 const environment = resolveEnvironmentName(props.environment);
335 yield* Effect.tryPromise(async () => {
336 try {
337 if (environment !== undefined) {
338 await octokit.rest.actions.deleteEnvironmentVariable({
339 owner: props.owner,
340 repo: props.repository,
341 environment_name: environment,
342 name: props.name,
343 });
344 } else {
345 await octokit.rest.actions.deleteRepoVariable({
346 owner: props.owner,
347 repo: props.repository,
348 name: props.name,
349 });
350 }
351 } catch (error: any) {
352 if (error.status !== 404) {
353 throw error;
354 }
355 }
356 });
357});