Skip to content

Commit 8496b84

Browse files
lex00claude
andauthored
feat(cycle): secrets & variables reconcile cycle (#11) (#29)
Reconciles Actions secrets (PRESENCE only — never reads/writes values; a missing declared secret is reported so the value is provisioned out-of-band, extras removed only when ownership-gated) and variables (full create/update/delete, values not being secret), at org + repo scope. New SecretConfig/VariableConfig + Live types + four diff resource types (org/repo × secret/variable). Environment scope noted as follow-up. Registered, exported, action bundle rebuilt. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9d7a44f commit 8496b84

7 files changed

Lines changed: 1018 additions & 2 deletions

File tree

action/index.mjs

Lines changed: 226 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ function diff(org, desired, live, opts = {}) {
275275
const entries = [];
276276
diffSettings(desired.settings, live.settings, entries);
277277
diffRulesets("", "org-ruleset", desired.rulesets, live.rulesets ?? [], opts, entries);
278+
diffSecrets("", "org-secret", desired.secrets, live.secrets ?? [], opts, entries);
279+
diffVariables("", "org-variable", desired.variables, live.variables ?? [], opts, entries);
278280
diffTeams(desired.teams, live.teams ?? {}, opts, entries);
279281
diffMembers(desired.members, live.members ?? [], opts, entries);
280282
diffRepos(desired.repos, live.repos ?? {}, opts, entries);
@@ -489,6 +491,8 @@ function diffRepos(desired, live, opts, out) {
489491
diffRulesets(`${name}/`, "repo-ruleset", dr.rulesets, lr.rulesets ?? [], opts, out);
490492
diffRepoSecurity(name, dr.security, lr.security, out);
491493
diffEnvironments(name, dr.environments, lr.environments ?? [], opts, out);
494+
diffSecrets(`${name}/`, "repo-secret", dr.secrets, lr.secrets ?? [], opts, out);
495+
diffVariables(`${name}/`, "repo-variable", dr.variables, lr.variables ?? [], opts, out);
492496
}
493497
for (const name of Object.keys(live)) {
494498
if (!Object.prototype.hasOwnProperty.call(desired, name)) {
@@ -633,6 +637,55 @@ function diffEnvironments(repoName, desired, live, opts, out) {
633637
}
634638
}
635639
}
640+
function diffSecrets(keyPrefix, resourceType, desired, live, opts, out) {
641+
if (desired === void 0) return;
642+
const desiredByName = new Map(desired.map((s) => [s.name, s]));
643+
const liveByName = new Map(live.map((s) => [s.name, s]));
644+
for (const [name, ds] of desiredByName) {
645+
if (!liveByName.has(name)) {
646+
out.push({ kind: "create", resourceType, key: `${keyPrefix}${name}`, after: ds });
647+
}
648+
}
649+
for (const [name, ls] of liveByName) {
650+
if (!desiredByName.has(name)) {
651+
const key = `${keyPrefix}${name}`;
652+
if (opts.isOwned?.(resourceType, key)) {
653+
out.push({ kind: "delete", resourceType, key, before: ls });
654+
}
655+
}
656+
}
657+
}
658+
function diffVariables(keyPrefix, resourceType, desired, live, opts, out) {
659+
if (desired === void 0) return;
660+
const desiredByName = new Map(desired.map((v) => [v.name, v]));
661+
const liveByName = new Map(live.map((v) => [v.name, v]));
662+
for (const [name, dv] of desiredByName) {
663+
const lv = liveByName.get(name);
664+
const key = `${keyPrefix}${name}`;
665+
if (!lv) {
666+
out.push({ kind: "create", resourceType, key, after: dv });
667+
continue;
668+
}
669+
if (dv.value !== void 0 && dv.value !== lv.value) {
670+
out.push({
671+
kind: "update",
672+
resourceType,
673+
key,
674+
before: lv,
675+
after: dv,
676+
fields: [{ field: "value", before: lv.value, after: dv.value }]
677+
});
678+
}
679+
}
680+
for (const [name, lv] of liveByName) {
681+
if (!desiredByName.has(name)) {
682+
const key = `${keyPrefix}${name}`;
683+
if (opts.isOwned?.(resourceType, key)) {
684+
out.push({ kind: "delete", resourceType, key, before: lv });
685+
}
686+
}
687+
}
688+
}
636689
function diffObject(desired, live) {
637690
const fields = [];
638691
for (const key of Object.keys(desired)) {
@@ -703,6 +756,8 @@ var init_diff = __esm({
703756
RESOURCE_TYPE_ORDER = [
704757
"org-settings",
705758
"org-ruleset",
759+
"org-secret",
760+
"org-variable",
706761
"team",
707762
"team-member",
708763
"team-repo",
@@ -711,7 +766,9 @@ var init_diff = __esm({
711766
"repo-security",
712767
"environment",
713768
"branch-protection",
714-
"repo-ruleset"
769+
"repo-ruleset",
770+
"repo-secret",
771+
"repo-variable"
715772
];
716773
RULESET_FIELDS = ["target", "enforcement", "bypassActors", "conditions", "rules"];
717774
ENVIRONMENT_FIELDS = [
@@ -230240,6 +230297,172 @@ var environmentsCycle = {
230240230297
}
230241230298
};
230242230299

230300+
// src/cycles/secrets-variables.ts
230301+
var PER_PAGE4 = 100;
230302+
async function listWrapped(client, makePath, field, budget) {
230303+
const out = [];
230304+
let page = 1;
230305+
for (; ; ) {
230306+
if (budget.exhausted) break;
230307+
budget.use(1);
230308+
const data = await client.request("GET", makePath(page));
230309+
const batch = data?.[field] ?? [];
230310+
if (!Array.isArray(batch) || batch.length === 0) break;
230311+
out.push(...batch);
230312+
if (batch.length < PER_PAGE4) break;
230313+
page++;
230314+
}
230315+
return out;
230316+
}
230317+
async function fetchSecrets(client, basePath, budget) {
230318+
try {
230319+
const raw = await listWrapped(
230320+
client,
230321+
(page) => `${basePath}?per_page=${PER_PAGE4}&page=${page}`,
230322+
"secrets",
230323+
budget
230324+
);
230325+
return raw.filter((s) => typeof s.name === "string").map((s) => ({ name: s.name }));
230326+
} catch (err) {
230327+
if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) return [];
230328+
throw err;
230329+
}
230330+
}
230331+
async function fetchVariables(client, basePath, budget) {
230332+
try {
230333+
const raw = await listWrapped(
230334+
client,
230335+
(page) => `${basePath}?per_page=${PER_PAGE4}&page=${page}`,
230336+
"variables",
230337+
budget
230338+
);
230339+
return raw.filter((v) => typeof v.name === "string").map((v) => ({ name: v.name, value: v.value }));
230340+
} catch (err) {
230341+
if (err instanceof Error && (err.message.includes("404") || err.message.includes("403"))) return [];
230342+
throw err;
230343+
}
230344+
}
230345+
function splitRepoKey(key, resourceType) {
230346+
const idx = key.indexOf("/");
230347+
if (idx === -1) {
230348+
throw new Error(`secrets-variables: malformed ${resourceType} key "${key}" \u2014 expected "<repo>/<name>"`);
230349+
}
230350+
return [key.slice(0, idx), key.slice(idx + 1)];
230351+
}
230352+
async function applySecret(client, entry, org, budget) {
230353+
const isRepo = entry.resourceType === "repo-secret";
230354+
let basePath;
230355+
let name;
230356+
if (isRepo) {
230357+
const [repo, secretName] = splitRepoKey(entry.key, "repo-secret");
230358+
basePath = `/repos/${org}/${repo}/actions/secrets`;
230359+
name = secretName;
230360+
} else {
230361+
basePath = `/orgs/${org}/actions/secrets`;
230362+
name = entry.key;
230363+
}
230364+
if (entry.kind === "delete") {
230365+
budget.use(1);
230366+
await client.request("DELETE", `${basePath}/${encodeURIComponent(name)}`);
230367+
return;
230368+
}
230369+
throw new Error(
230370+
`secret "${name}" is declared but absent \u2014 provision its value out-of-band; warden never reads or writes secret values`
230371+
);
230372+
}
230373+
async function applyVariable(client, entry, org, budget) {
230374+
const isRepo = entry.resourceType === "repo-variable";
230375+
let basePath;
230376+
let name;
230377+
if (isRepo) {
230378+
const [repo, varName] = splitRepoKey(entry.key, "repo-variable");
230379+
basePath = `/repos/${org}/${repo}/actions/variables`;
230380+
name = varName;
230381+
} else {
230382+
basePath = `/orgs/${org}/actions/variables`;
230383+
name = entry.key;
230384+
}
230385+
if (entry.kind === "delete") {
230386+
budget.use(1);
230387+
await client.request("DELETE", `${basePath}/${encodeURIComponent(name)}`);
230388+
return;
230389+
}
230390+
const desired = entry.after;
230391+
if (entry.kind === "create") {
230392+
if (desired.value === void 0) {
230393+
throw new Error(`variable "${name}" is declared without a value \u2014 set a value to create it`);
230394+
}
230395+
const body = { name, value: desired.value };
230396+
if (!isRepo) body.visibility = desired.visibility ?? "all";
230397+
budget.use(1);
230398+
await client.request("POST", basePath, body);
230399+
return;
230400+
}
230401+
if (desired.value === void 0) return;
230402+
budget.use(1);
230403+
await client.request("PATCH", `${basePath}/${encodeURIComponent(name)}`, { value: desired.value });
230404+
}
230405+
var secretsVariablesCycle = {
230406+
name: "secrets-variables",
230407+
// ── Part 2: fetchLive ──────────────────────────────────────────────────────
230408+
async fetchLive(client, orgLogin, scope, budget) {
230409+
if (budget.exhausted) {
230410+
const { BudgetExhaustedError: BudgetExhaustedError2 } = await Promise.resolve().then(() => (init_runner(), runner_exports));
230411+
throw new BudgetExhaustedError2();
230412+
}
230413+
const secrets = await fetchSecrets(client, `/orgs/${orgLogin}/actions/secrets`, budget);
230414+
const variables = budget.exhausted ? [] : await fetchVariables(client, `/orgs/${orgLogin}/actions/variables`, budget);
230415+
const repos = {};
230416+
for (const [name, repoConfig] of Object.entries(scope?.repos ?? {})) {
230417+
const wantsSecrets = repoConfig.secrets !== void 0;
230418+
const wantsVariables = repoConfig.variables !== void 0;
230419+
if (!wantsSecrets && !wantsVariables) continue;
230420+
if (budget.exhausted) break;
230421+
const repoLive = {};
230422+
if (wantsSecrets) {
230423+
repoLive.secrets = await fetchSecrets(client, `/repos/${orgLogin}/${name}/actions/secrets`, budget);
230424+
}
230425+
if (wantsVariables && !budget.exhausted) {
230426+
repoLive.variables = await fetchVariables(client, `/repos/${orgLogin}/${name}/actions/variables`, budget);
230427+
}
230428+
repos[name] = repoLive;
230429+
}
230430+
return { secrets, variables, repos };
230431+
},
230432+
// ── Part 3: buildDesired ───────────────────────────────────────────────────
230433+
buildDesired(orgConfig, _orgLogin, _scope) {
230434+
const out = {};
230435+
if (orgConfig.secrets) out.secrets = orgConfig.secrets;
230436+
if (orgConfig.variables) out.variables = orgConfig.variables;
230437+
if (orgConfig.repos) {
230438+
const repos = {};
230439+
for (const [name, repoConfig] of Object.entries(orgConfig.repos)) {
230440+
const stripped = {};
230441+
if (repoConfig.secrets !== void 0) stripped.secrets = repoConfig.secrets;
230442+
if (repoConfig.variables !== void 0) stripped.variables = repoConfig.variables;
230443+
if (stripped.secrets !== void 0 || stripped.variables !== void 0) {
230444+
repos[name] = stripped;
230445+
}
230446+
}
230447+
out.repos = repos;
230448+
}
230449+
return out;
230450+
},
230451+
// ── Part 4: apply ──────────────────────────────────────────────────────────
230452+
async apply(client, entry, orgLogin, _scope, budget) {
230453+
switch (entry.resourceType) {
230454+
case "org-secret":
230455+
case "repo-secret":
230456+
return applySecret(client, entry, orgLogin, budget);
230457+
case "org-variable":
230458+
case "repo-variable":
230459+
return applyVariable(client, entry, orgLogin, budget);
230460+
default:
230461+
return;
230462+
}
230463+
}
230464+
};
230465+
230243230466
// src/cli/registry.ts
230244230467
var CYCLE_REGISTRY = {
230245230468
[branchProtectionCycle.name]: branchProtectionCycle,
@@ -230249,7 +230472,8 @@ var CYCLE_REGISTRY = {
230249230472
[teamsCycle.name]: teamsCycle,
230250230473
[rulesetsCycle.name]: rulesetsCycle,
230251230474
[securityFeaturesCycle.name]: securityFeaturesCycle,
230252-
[environmentsCycle.name]: environmentsCycle
230475+
[environmentsCycle.name]: environmentsCycle,
230476+
[secretsVariablesCycle.name]: secretsVariablesCycle
230253230477
};
230254230478

230255230479
// node_modules/@intentius/chant/src/audit/fetch.ts

src/cli/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { teamsCycle } from "../cycles/teams.js";
1717
import { rulesetsCycle } from "../cycles/rulesets.js";
1818
import { securityFeaturesCycle } from "../cycles/security-features.js";
1919
import { environmentsCycle } from "../cycles/environments.js";
20+
import { secretsVariablesCycle } from "../cycles/secrets-variables.js";
2021

2122
/**
2223
* Registry of all available governance cycles, keyed by the name accepted by
@@ -34,4 +35,5 @@ export const CYCLE_REGISTRY: Record<string, Cycle> = {
3435
[rulesetsCycle.name]: rulesetsCycle,
3536
[securityFeaturesCycle.name]: securityFeaturesCycle,
3637
[environmentsCycle.name]: environmentsCycle,
38+
[secretsVariablesCycle.name]: secretsVariablesCycle,
3739
};

src/config/types.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,42 @@ export interface MemberConfig {
100100
role?: OrgMemberRole;
101101
}
102102

103+
// ---------------------------------------------------------------------------
104+
// Actions secrets & variables
105+
// ---------------------------------------------------------------------------
106+
107+
/**
108+
* An Actions secret declaration. warden manages a secret's PRESENCE only — it
109+
* NEVER reads or writes secret values (those are provisioned out-of-band). A
110+
* declared-but-missing secret is reported; its value must be supplied
111+
* separately. An undeclared live secret is only removed when ownership-gated.
112+
*/
113+
export interface SecretConfig {
114+
/** Secret name (the identity key within its scope). */
115+
name: string;
116+
/**
117+
* Optional informational rotation pointer (e.g. a ticket or KMS key ref).
118+
* Recorded for humans/automation; never sent to GitHub and never diffed.
119+
*/
120+
rotationRef?: string;
121+
}
122+
123+
/**
124+
* An Actions variable declaration. Unlike secrets, variable values are NOT
125+
* secret, so warden can reconcile them fully (create/update/delete).
126+
*/
127+
export interface VariableConfig {
128+
/** Variable name (the identity key within its scope). */
129+
name: string;
130+
/** Variable value. Required to create or update; absent → presence-only. */
131+
value?: string;
132+
/**
133+
* Visibility for ORG-level variables on create: "all" | "private" |
134+
* "selected". Ignored for repo-level variables. Defaults to "all".
135+
*/
136+
visibility?: "all" | "private" | "selected";
137+
}
138+
103139
// ---------------------------------------------------------------------------
104140
// Deployment environments
105141
// ---------------------------------------------------------------------------
@@ -286,6 +322,16 @@ export interface RepoConfig {
286322
* Absent means environments are not managed by chant.
287323
*/
288324
environments?: EnvironmentConfig[];
325+
/**
326+
* Repo-level Actions secrets (presence only — never values).
327+
* Absent means secrets are not managed by chant.
328+
*/
329+
secrets?: SecretConfig[];
330+
/**
331+
* Repo-level Actions variables.
332+
* Absent means variables are not managed by chant.
333+
*/
334+
variables?: VariableConfig[];
289335
}
290336

291337
// ---------------------------------------------------------------------------
@@ -325,6 +371,16 @@ export interface OrgConfig {
325371
* Absent means org rulesets are not managed by chant.
326372
*/
327373
rulesets?: RulesetConfig[];
374+
/**
375+
* Org-level Actions secrets (presence only — never values).
376+
* Absent means secrets are not managed by chant.
377+
*/
378+
secrets?: SecretConfig[];
379+
/**
380+
* Org-level Actions variables.
381+
* Absent means variables are not managed by chant.
382+
*/
383+
variables?: VariableConfig[];
328384
}
329385

330386
/**

0 commit comments

Comments
 (0)