1import * as Effect from "effect/Effect";
2import { isResolved } from "../Diff.ts";
3import * as Provider from "../Provider.ts";
4import { Resource } from "../Resource.ts";
5import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
6import type * as GitHub from "./Providers.ts";
7
8export interface EnvironmentProps {
9 /**
10 * Repository owner (user or organization).
11 */
12 owner: string;
13
14 /**
15 * Repository name.
16 */
17 repository: string;
18
19 /**
20 * Environment name (e.g. `production`, `staging`). The name is the
21 * environment's identity — changing it replaces the environment.
22 */
23 name: string;
24
25 /**
26 * Minutes to wait before allowing deployments to proceed (0–43200,
27 * i.e. up to 30 days). Requires a plan that supports environment
28 * protection rules on the repository.
29 * @default 0
30 */
31 waitTimer?: number;
32
33 /**
34 * Whether users who created or pushed the deployment are prevented
35 * from approving their own deployment.
36 * @default false
37 */
38 preventSelfReview?: boolean;
39
40 /**
41 * The people or teams that must review deployments to this environment.
42 * Up to six users or teams in total; reviewers must have at least read
43 * access to the repository. Users are referenced by login, teams by
44 * slug (teams are only valid for organization-owned repositories).
45 */
46 reviewers?: {
47 /**
48 * User logins allowed to review deployments.
49 */
50 users?: string[];
51
52 /**
53 * Team slugs (within the owning organization) allowed to review
54 * deployments.
55 */
56 teams?: string[];
57 };
58
59 /**
60 * Which branches can deploy to this environment. Omit to allow all
61 * branches. Set `protectedBranches: true` to restrict deployments to
62 * branches with branch protection rules, or `customBranchPolicies` to a
63 * list of branch name patterns (e.g. `["main", "release/*"]`).
64 */
65 deploymentBranchPolicy?:
66 | { protectedBranches: true }
67 | { customBranchPolicies: string[] };
68
69 /**
70 * Override the GitHub host or API base URL for this resource only (e.g.
71 * `github.example.com` for GitHub Enterprise). Falls back to
72 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
73 * provider. Changing it replaces the resource — the same name on a
74 * different GitHub instance is a different physical resource.
75 */
76 baseUrl?: string;
77}
78
79export interface Environment extends Resource<
80 "GitHub.Environment",
81 EnvironmentProps,
82 {
83 /**
84 * Numeric GitHub environment ID.
85 */
86 environmentId: number;
87
88 /**
89 * GraphQL node ID of the environment.
90 */
91 nodeId: string;
92
93 /**
94 * The environment name.
95 */
96 name: string;
97
98 /**
99 * URL to view the environment in a browser.
100 */
101 htmlUrl: string;
102
103 /**
104 * ISO-8601 timestamp of when the environment was created.
105 */
106 createdAt: string;
107
108 /**
109 * ISO-8601 timestamp of the last update.
110 */
111 updatedAt: string;
112 },
113 never,
114 GitHub.Providers
115> {}
116
117/**
118 * A GitHub Actions deployment environment.
119 *
120 * `Environment` manages a repository's deployment environment (e.g.
121 * `production`, `staging`) along with its protection rules: required
122 * reviewers, wait timers, self-review prevention, and deployment branch
123 * policies. Pair it with `GitHub.Secret` and `GitHub.Variable` (both accept
124 * an `environment` prop) to scope configuration to the environment.
125 *
126 * Environments are available on public repositories on every plan; private
127 * repositories require GitHub Pro, Team, or Enterprise, and protection
128 * rules on private repositories require Team or Enterprise.
129 *
130 * Authentication is resolved via the `GitHubCredentials` service supplied
131 * by `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token
132 * needs `repo` scope.
133 * ### Creating an Environment
134 * **Example:** Basic Environment
135 * ```typescript
136 * const production = yield* GitHub.Environment("production", {
137 * owner: "my-org",
138 * repository: "my-repo",
139 * name: "production",
140 * });
141 * ```
142 *
143 * **Example:** Environment with Protection Rules
144 * ```typescript
145 * yield* GitHub.Environment("production", {
146 * owner: "my-org",
147 * repository: "my-repo",
148 * name: "production",
149 * waitTimer: 30,
150 * preventSelfReview: true,
151 * reviewers: {
152 * users: ["release-manager"],
153 * teams: ["platform"],
154 * },
155 * });
156 * ```
157 *
158 * ### Deployment Branch Policies
159 * **Example:** Restrict to Protected Branches
160 * ```typescript
161 * yield* GitHub.Environment("production", {
162 * owner: "my-org",
163 * repository: "my-repo",
164 * name: "production",
165 * deploymentBranchPolicy: { protectedBranches: true },
166 * });
167 * ```
168 *
169 * **Example:** Restrict to Branch Name Patterns
170 * ```typescript
171 * yield* GitHub.Environment("production", {
172 * owner: "my-org",
173 * repository: "my-repo",
174 * name: "production",
175 * deploymentBranchPolicy: {
176 * customBranchPolicies: ["main", "release/*"],
177 * },
178 * });
179 * ```
180 *
181 * ### Environment Secrets and Variables
182 * **Example:** Scope Secrets and Variables to the Environment
183 * ```typescript
184 * const env = yield* GitHub.Environment("production", {
185 * owner: "my-org",
186 * repository: "my-repo",
187 * name: "production",
188 * });
189 *
190 * yield* GitHub.Secret("deploy-key", {
191 * owner: "my-org",
192 * repository: "my-repo",
193 * environment: env,
194 * name: "DEPLOY_KEY",
195 * value: Redacted.make("my-secret-value"),
196 * });
197 *
198 * yield* GitHub.Variable("region", {
199 * owner: "my-org",
200 * repository: "my-repo",
201 * environment: env,
202 * name: "AWS_REGION",
203 * value: "us-east-1",
204 * });
205 * ```
206 *
207 * @resource
208 * @product Actions
209 */
210export const Environment = Resource<Environment>("GitHub.Environment");
211
212// At runtime, Resource references in props are resolved to their full
213// attributes — so an `Environment` passed to another resource's
214// `environment` prop arrives as `{ name: string; ... }` rather than the
215// statically-typed Resource (whose attributes are `Output<...>`). We cast
216// through the structural shape here.
217export const resolveEnvironmentName = (
218 environment: string | Environment | undefined,
219): string | undefined => {
220 const ref = environment as unknown as string | { name: string } | undefined;
221 return ref === undefined
222 ? undefined
223 : typeof ref === "string"
224 ? ref
225 : ref.name;
226};
227
228export const EnvironmentProvider = () =>
229 Provider.succeed(Environment, {
230 stables: ["environmentId", "nodeId"],
231
232 // The environment name is its path identity — GitHub has no rename, so
233 // changing owner, repository, name, or the host replaces the resource.
234 diff: Effect.fn(function* ({ news, olds }) {
235 if (!isResolved(news)) return;
236 if (olds === undefined) return;
237 if (
238 news.owner !== olds.owner ||
239 news.repository !== olds.repository ||
240 news.name !== olds.name ||
241 (yield* gitHubBaseUrlChanged(olds, news))
242 ) {
243 return { action: "replace" };
244 }
245 }),
246
247 reconcile: Effect.fn(function* ({ news }) {
248 const octokit = yield* octokitFor(news.baseUrl);
249
250 // Resolve reviewer logins/slugs to the numeric IDs the API expects.
251 const reviewers = yield* Effect.tryPromise({
252 try: async () => {
253 if (news.reviewers === undefined) return null;
254 const users = await Promise.all(
255 (news.reviewers.users ?? []).map(async (username) => {
256 const { data } = await octokit.rest.users.getByUsername({
257 username,
258 });
259 return { type: "User" as const, id: data.id };
260 }),
261 );
262 const teams = await Promise.all(
263 (news.reviewers.teams ?? []).map(async (team_slug) => {
264 const { data } = await octokit.rest.teams.getByName({
265 org: news.owner,
266 team_slug,
267 });
268 return { type: "Team" as const, id: data.id };
269 }),
270 );
271 return [...users, ...teams];
272 },
273 catch: (e) => e as Error,
274 });
275
276 // Ensure & Sync — the PUT is a full upsert of the environment's
277 // protection configuration; send explicit values (not omissions) so
278 // removed props converge back to their defaults.
279 const environment = yield* Effect.tryPromise({
280 try: async () => {
281 const { data } = await octokit.rest.repos.createOrUpdateEnvironment({
282 owner: news.owner,
283 repo: news.repository,
284 environment_name: news.name,
285 wait_timer: news.waitTimer ?? 0,
286 prevent_self_review: news.preventSelfReview ?? false,
287 reviewers:
288 reviewers === null || reviewers.length === 0 ? null : reviewers,
289 deployment_branch_policy:
290 news.deploymentBranchPolicy === undefined
291 ? null
292 : "protectedBranches" in news.deploymentBranchPolicy
293 ? { protected_branches: true, custom_branch_policies: false }
294 : { protected_branches: false, custom_branch_policies: true },
295 });
296 return data;
297 },
298 catch: (e) => e as Error,
299 });
300
301 // Sync — custom branch policies live behind dedicated endpoints. Diff
302 // the observed patterns against the desired list; create the missing
303 // ones and delete the extras. Skipped entirely unless the policy mode
304 // is custom (GitHub drops the policies itself when the mode changes).
305 if (
306 news.deploymentBranchPolicy !== undefined &&
307 "customBranchPolicies" in news.deploymentBranchPolicy
308 ) {
309 const desired = news.deploymentBranchPolicy.customBranchPolicies;
310 yield* Effect.tryPromise({
311 try: async () => {
312 const observed = await octokit.paginate(
313 octokit.rest.repos.listDeploymentBranchPolicies,
314 {
315 owner: news.owner,
316 repo: news.repository,
317 environment_name: news.name,
318 per_page: 100,
319 },
320 );
321 const observedNames = new Set(
322 observed.map((policy) => policy.name),
323 );
324 for (const name of desired) {
325 if (!observedNames.has(name)) {
326 await octokit.rest.repos.createDeploymentBranchPolicy({
327 owner: news.owner,
328 repo: news.repository,
329 environment_name: news.name,
330 name,
331 type: "branch",
332 });
333 }
334 }
335 for (const policy of observed) {
336 if (
337 policy.id !== undefined &&
338 policy.name !== undefined &&
339 !desired.includes(policy.name)
340 ) {
341 await octokit.rest.repos.deleteDeploymentBranchPolicy({
342 owner: news.owner,
343 repo: news.repository,
344 environment_name: news.name,
345 branch_policy_id: policy.id,
346 });
347 }
348 }
349 },
350 catch: (e) => e as Error,
351 });
352 }
353
354 return {
355 environmentId: environment.id,
356 nodeId: environment.node_id,
357 name: environment.name,
358 htmlUrl: environment.html_url,
359 createdAt: environment.created_at,
360 updatedAt: environment.updated_at,
361 };
362 }),
363
364 // Enumerate every environment across the repositories the token can see —
365 // environments are keyed by {owner, repository, name} with no account-wide
366 // list endpoint, so walk the repos like the Variable provider does.
367 list: Effect.fn(function* () {
368 const octokit = yield* Octokit;
369
370 const repos = yield* Effect.tryPromise({
371 try: () =>
372 octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, {
373 per_page: 100,
374 }),
375 catch: (e) => e as Error,
376 });
377
378 const perRepo = yield* Effect.forEach(
379 repos,
380 (repo) =>
381 Effect.tryPromise({
382 try: async () => {
383 try {
384 // `octokit.paginate` can't flatten this endpoint's
385 // `{ total_count, environments }` envelope, so page manually
386 // until a short page signals the end.
387 const environments = [];
388 for (let page = 1; ; page++) {
389 const { data } = await octokit.rest.repos.getAllEnvironments({
390 owner: repo.owner.login,
391 repo: repo.name,
392 per_page: 100,
393 page,
394 });
395 const batch = data.environments ?? [];
396 environments.push(
397 ...batch.map((environment) => ({
398 environmentId: environment.id,
399 nodeId: environment.node_id,
400 name: environment.name,
401 htmlUrl: environment.html_url,
402 createdAt: environment.created_at,
403 updatedAt: environment.updated_at,
404 })),
405 );
406 if (batch.length < 100) break;
407 }
408 return environments;
409 } catch (error: any) {
410 // Repos without environments support (plan limits) or where
411 // the token lacks access reject with 403/404 — skip them
412 // rather than failing the whole enumeration.
413 if (error.status === 403 || error.status === 404) {
414 return [];
415 }
416 throw error;
417 }
418 },
419 catch: (e) => e as Error,
420 }),
421 { concurrency: 10 },
422 );
423
424 return perRepo.flat();
425 }),
426
427 delete: Effect.fn(function* ({ olds }) {
428 const octokit = yield* octokitFor(olds.baseUrl);
429
430 yield* Effect.tryPromise({
431 try: async () => {
432 try {
433 await octokit.rest.repos.deleteAnEnvironment({
434 owner: olds.owner,
435 repo: olds.repository,
436 environment_name: olds.name,
437 });
438 } catch (error: any) {
439 if (error.status !== 404) {
440 throw error;
441 }
442 }
443 },
444 catch: (e) => e as Error,
445 });
446 }),
447 });