Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 161 additions & 2 deletions action/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ function diffRepos(desired, live, opts, out) {
diffBranchProtection(name, dr.branchProtection, lr.branchProtection ?? [], opts, out);
diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out);
diffRepoSecurity(name, dr.security, lr.security, out);
diffEnvironments(name, dr.environments, lr.environments ?? [], opts, out);
}
for (const name of Object.keys(live)) {
if (!Object.prototype.hasOwnProperty.call(desired, name)) {
Expand Down Expand Up @@ -603,6 +604,35 @@ function diffRepoSecurity(repoName, desired, live, out) {
});
}
}
function diffEnvironments(repoName, desired, live, opts, out) {
if (desired === void 0) return;
const desiredByName = new Map(desired.map((e) => [e.name, e]));
const liveByName = new Map(live.map((e) => [e.name, e]));
for (const [name, de] of desiredByName) {
const le = liveByName.get(name);
const key = `${repoName}/${name}`;
if (!le) {
out.push({ kind: "create", resourceType: "environment", key, after: de });
continue;
}
const fields = diffObjectKeys(
de,
le,
ENVIRONMENT_FIELDS
);
if (fields.length > 0) {
out.push({ kind: "update", resourceType: "environment", key, before: le, after: de, fields });
}
}
for (const [name, le] of liveByName) {
if (!desiredByName.has(name)) {
const key = `${repoName}/${name}`;
if (opts.isOwned?.("environment", key)) {
out.push({ kind: "delete", resourceType: "environment", key, before: le });
}
}
}
}
function diffObject(desired, live) {
const fields = [];
for (const key of Object.keys(desired)) {
Expand Down Expand Up @@ -666,7 +696,7 @@ function fmt(v) {
const json2 = JSON.stringify(v);
return json2.length > 60 ? `${json2.slice(0, 57)}...` : json2;
}
var RESOURCE_TYPE_ORDER, RULESET_FIELDS;
var RESOURCE_TYPE_ORDER, RULESET_FIELDS, ENVIRONMENT_FIELDS;
var init_diff = __esm({
"src/reconcile/diff.ts"() {
"use strict";
Expand All @@ -679,10 +709,17 @@ var init_diff = __esm({
"member",
"repo",
"repo-security",
"environment",
"branch-protection",
"repo-ruleset"
];
RULESET_FIELDS = ["target", "enforcement", "bypassActors", "conditions", "rules"];
ENVIRONMENT_FIELDS = [
"waitTimer",
"preventSelfReview",
"reviewers",
"deploymentBranchPolicy"
];
}
});

Expand Down Expand Up @@ -230082,6 +230119,127 @@ var securityFeaturesCycle = {
}
};

// src/cycles/environments.ts
function mapEnvironmentToLive(raw) {
const live = { name: raw.name };
for (const rule of raw.protection_rules ?? []) {
if (rule.type === "wait_timer" && typeof rule.wait_timer === "number") {
live.waitTimer = rule.wait_timer;
} else if (rule.type === "required_reviewers") {
if (typeof rule.prevent_self_review === "boolean") {
live.preventSelfReview = rule.prevent_self_review;
}
live.reviewers = (rule.reviewers ?? []).filter((r) => typeof r.reviewer?.id === "number").map((r) => ({ type: r.type, id: r.reviewer.id }));
}
}
if (raw.deployment_branch_policy === null) {
live.deploymentBranchPolicy = null;
} else if (raw.deployment_branch_policy) {
live.deploymentBranchPolicy = {
protectedBranches: raw.deployment_branch_policy.protected_branches ?? false,
customBranchPolicies: raw.deployment_branch_policy.custom_branch_policies ?? false
};
}
return live;
}
function dbpToApi(dbp) {
if (dbp === null) return null;
return {
protected_branches: dbp.protectedBranches ?? false,
custom_branch_policies: dbp.customBranchPolicies ?? false
};
}
function reviewersToApi(reviewers) {
return reviewers.map((r) => ({ type: r.type, id: r.id }));
}
function buildEnvironmentBody(desired, live) {
const body = {};
if (live) {
if (live.waitTimer !== void 0) body.wait_timer = live.waitTimer;
if (live.preventSelfReview !== void 0) body.prevent_self_review = live.preventSelfReview;
if (live.reviewers !== void 0) body.reviewers = reviewersToApi(live.reviewers);
if (live.deploymentBranchPolicy !== void 0) {
body.deployment_branch_policy = dbpToApi(live.deploymentBranchPolicy);
}
}
if (desired.waitTimer !== void 0) body.wait_timer = desired.waitTimer;
if (desired.preventSelfReview !== void 0) body.prevent_self_review = desired.preventSelfReview;
if (desired.reviewers !== void 0) body.reviewers = reviewersToApi(desired.reviewers);
if (desired.deploymentBranchPolicy !== void 0) {
body.deployment_branch_policy = dbpToApi(desired.deploymentBranchPolicy);
}
return body;
}
async function fetchRepoEnvironments(client, org, repo, budget) {
budget.use(1);
let data;
try {
data = await client.request("GET", `/repos/${org}/${repo}/environments`);
} catch (err) {
if (err instanceof Error && err.message.includes("404")) return [];
throw err;
}
return (data.environments ?? []).map(mapEnvironmentToLive);
}
var environmentsCycle = {
name: "environments",
// ── Part 2: fetchLive ──────────────────────────────────────────────────────
async fetchLive(client, orgLogin, scope, budget) {
if (budget.exhausted) {
const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
throw new BudgetExhaustedError2();
}
const repos = {};
for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) {
if (repoConfig.environments === void 0) continue;
if (budget.exhausted) break;
repos[name] = { environments: await fetchRepoEnvironments(client, orgLogin, name, budget) };
}
return { repos };
},
// ── Part 3: buildDesired ───────────────────────────────────────────────────
buildDesired(orgConfig, _orgLogin, _scope) {
if (!orgConfig.repos) return {};
const repos = {};
for (const [name, repoConfig] of Object.entries(orgConfig.repos)) {
if (repoConfig.environments && repoConfig.environments.length > 0) {
repos[name] = { environments: repoConfig.environments };
}
}
return { repos };
},
// ── Part 4: apply ──────────────────────────────────────────────────────────
async apply(client, entry, orgLogin, _scope, budget) {
if (entry.resourceType !== "environment") return;
const slashIdx = entry.key.indexOf("/");
if (slashIdx === -1) {
throw new Error(
`environments: malformed entry key "${entry.key}" \u2014 expected "<repo>/<env>"`
);
}
const repo = entry.key.slice(0, slashIdx);
const env2 = entry.key.slice(slashIdx + 1);
const path = `/repos/${orgLogin}/${repo}/environments/${encodeURIComponent(env2)}`;
if (entry.kind === "delete") {
budget.use(1);
await client.request("DELETE", path);
return;
}
const desired = entry.after;
let live = null;
if (entry.kind === "update") {
live = entry.before ?? null;
if (!live) {
const fetched = await fetchRepoEnvironments(client, orgLogin, repo, budget);
live = fetched.find((e) => e.name === env2) ?? null;
}
}
const body = buildEnvironmentBody(desired, live);
budget.use(1);
await client.request("PUT", path, body);
}
};

// src/cli/registry.ts
var CYCLE_REGISTRY = {
[branchProtectionCycle.name]: branchProtectionCycle,
Expand All @@ -230090,7 +230248,8 @@ var CYCLE_REGISTRY = {
[membershipCycle.name]: membershipCycle,
[teamsCycle.name]: teamsCycle,
[rulesetsCycle.name]: rulesetsCycle,
[securityFeaturesCycle.name]: securityFeaturesCycle
[securityFeaturesCycle.name]: securityFeaturesCycle,
[environmentsCycle.name]: environmentsCycle
};

// node_modules/@intentius/chant/src/audit/fetch.ts
Expand Down
2 changes: 2 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { membershipCycle } from "../cycles/membership.js";
import { teamsCycle } from "../cycles/teams.js";
import { rulesetsCycle } from "../cycles/rulesets.js";
import { securityFeaturesCycle } from "../cycles/security-features.js";
import { environmentsCycle } from "../cycles/environments.js";

/**
* Registry of all available governance cycles, keyed by the name accepted by
Expand All @@ -32,4 +33,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
[teamsCycle.name]: teamsCycle,
[rulesetsCycle.name]: rulesetsCycle,
[securityFeaturesCycle.name]: securityFeaturesCycle,
[environmentsCycle.name]: environmentsCycle,
};
45 changes: 45 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,46 @@ export interface MemberConfig {
role?: OrgMemberRole;
}

// ---------------------------------------------------------------------------
// Deployment environments
// ---------------------------------------------------------------------------

/** A required reviewer for an environment (a user or a team, by numeric id). */
export interface EnvironmentReviewer {
/** "User" or "Team". */
type: "User" | "Team";
/** GitHub numeric id of the user or team. */
id: number;
}

/**
* Deployment-branch policy for an environment. At most one of the two flags is
* true. `null` (as a declared value) disables the policy entirely.
*/
export interface DeploymentBranchPolicy {
/** Restrict deployments to branches matching the repo's protection rules. */
protectedBranches?: boolean;
/** Restrict deployments to branches matching custom name patterns. */
customBranchPolicies?: boolean;
}

/** Desired state for a single deployment environment. Absent fields are not managed. */
export interface EnvironmentConfig {
/** Environment name (the identity key within a repo). */
name: string;
/** Wait timer in minutes before a deployment can proceed (0–43200). */
waitTimer?: number;
/** Prevent a deployment's actor from approving their own run. */
preventSelfReview?: boolean;
/** Required reviewers for deployments to this environment. */
reviewers?: EnvironmentReviewer[];
/**
* Deployment branch policy. An object configures it; `null` disables it.
* Absent means the branch policy is not managed.
*/
deploymentBranchPolicy?: DeploymentBranchPolicy | null;
}

// ---------------------------------------------------------------------------
// Repository security features
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -241,6 +281,11 @@ export interface RepoConfig {
* Absent means security features are not managed by chant.
*/
security?: RepoSecurityConfig;
/**
* Deployment environments and their protection rules.
* Absent means environments are not managed by chant.
*/
environments?: EnvironmentConfig[];
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading