Skip to content

Commit 46c3f4b

Browse files
committed
feat(ci): add dual-mode secrets encryption (file/vars)
Support both whole-file encryption (--file, default) and individual variable encryption (--vars) for CI secrets. - encrypt-secrets.ts: add --file/--vars CLI flags - decrypt-secrets.ts: auto-detect format from encrypted file structure - Backwards compatible: existing file-mode encryption continues to work
1 parent 1d1ac59 commit 46c3f4b

File tree

3 files changed

+76
-18
lines changed

3 files changed

+76
-18
lines changed

.github/secrets.env.encrypted

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"k": "ct",
3-
"c": "mBbKAoNc(qCyj1dv~cffmKx^812qC-mMd8i2N)OP5M3E)uk=Yn{4kUIe-v{dQ+cJ=D+$HeAONGotT3y<*<#>k_o$T{KS;Nz!;7fwmbuVYJ_hzcdybtS2&z(C1CSv%;uS__hclT8wUKkQW3GqR70E!W>MB+5zq@1F>rqBLmw(G!fK0Lhc7>=eiUA9BSOa>5{3AIvBpWg4&hw&OlC(uk&H%$h%>tZ`QG)sMah4!fB+ry_YC0lHb>b^T*n!!%YI&4~rZTc%6~TRETmlc<^FH!HhiK0>=bI6HV(V!&$m5-T=E$ziHSfEPSP5a1=eskcjrWx5@HRq@zmMkBB7dq*Wy+Ts-<oBJO|g^I{H)-$RQcM_RoE!=qRGbx$|IC#6}e_>4kCWHu&gN>rJ!Piwt=;u%;IAawIKXxxX1Z)nSV+|T<uJm<oPB9_+<rfjNUeJP#moM_=ilEn&xI#C!mb_#oGY15%r9O__UU1cguTi)rbwJXu2o<w`oX*Kms?R*ZY9l+{V6Xq`CD?I`1a)i_<`RdxgK)!(#(n2eOuq^<rUiGPCQ;4O`lLBlz1de7utIR0*Wo*_$MeVOy>_KB1}u>Y|Dkp}i32WPAW3$R|vRky(Vl;<$Vb4&CLI&JdW{MD=R$sT)#1g<G{}WL{OOOQ8hJNw>}Z5>+7*A;V3dZ}9Qy+Ghg{70uIo@$=pweSy;(DJHMlYjvP_T70n`BCq_hP9`n;<r4Al;gO<&&ihjdPw})wDpvqHwRR`@EfT4gr@Ya4R)yW!L1o&7ef=44;I-P2(^_{@gm8b4)^$1FFH(Z3o@aAJ-#>pph=Z4dD4%kfn)~z&M}}cbmJy8Sk!Tz)W8$Ju9T0v<B{}){MVblB0Oq-pT{&d{&{;dZ$G>44m$14hXjTM$<gO3;+chFH9`yS)s_5pI<Yn>B6O^ohdxKeUV(nvnmeni;#)m<6Veou9#DPak%mzbqA;tZ5Y2S2AOS@Lq97|$J$thFlY~`vY=6s=>>`pDg71Q_XSX{x~#5Ep;;-;)iId*3@SDNf|u)g&bf(P*Yjol+9l*gG%6bHj}T2f1IX0DXPAkYy0y^tZcM*Wcw_PHZQNC|ec!FFR-8TOPB@`E*k>ZNvJY;|SC5Hwu<&vtJ>^t3onC{{VkhX",
3+
"c": "mBbKV<xz1b;lL$qXaZ1eEazgy12v{5BvnND87}b_mbG58wOs>0G4)-8UdG=fVK^Mgf0{+={6c)WxWPnZ(Cp1Iq=%3}nK4a)CGBx1bjZ=qv8EHV9gp30vQ`13OJxFK&vBuwpyEt$JE{*iks{9CMl@IGpcvQ8gA7RNsgU>j6~vXw+Ir0WKXxrDu|PrlDk@Z@tYC<k#21dU5I!B7JhCdj#Oah$5;B@;`J!gjCaT!(>11HTq*7Ufq{V_GxAc=rh+gkkDsiM%LjPyK-fvut?5Fwf*i8>YcD<c1kk(FP_6PM@GEq&lP0STP$!tjzkO6$$`4{%3v{sgVk+M~*j#Iic)1CkbkbIYoay}^IRAz!t<tG2uEdpeU*9F(1a9p3RcFU*5pxM?C&6}LH0u<=<>-HELZe^)cc$8aXF;l#4*srYmmdO$ydq-KxbwECzD%{l$#IQh90%*!8$=`lK`!aea=QOICK(5JGRkL1<_eHdY9z1_J8<51L=N29rCD=uup}QKsFs2>$S)|D9?w4r;l>ovY*rGAxmlR14{QFjmRFQP!4d#Q6k6p=#uO(tNpb(^yl7XC+h4xCXFq(gy*@DmrEK>}gF7fQx{^)m{aV4gA&KH|wO)rikb|jF_K@5|f+NpJ*%)`>xuKl=uSaMF4p!PA;EzE4?hdtPnd+5Y4Dk|UC7@wAs%G|M~f`LP3K*__-N~wag5|>u|aHGqB{mnyWcVLsKU&RUA*PLl#znp61^`!uKxq8~0fpsZi%tn;yFzj>-vHH*Zndd0d(|vjSN7XFO6f$aN3~2jD)GOi}6EHaE^o(dM0T6=5c!rBY7_9zJzpt74(wmd&;*o}70+0^HUc9)3GP7-s0il063@(-@;mcA;%pp%{`q+J}@ZQ>Y7i|Que0zv2o~nuk#%2pe^E0UN;J&qv)igfU!(!JHBVN`&s8VknlfK+YI`d6Ex(qIR)))}{j{8ZssNpWrUUpL%CGhk)6bjn`YMP@j&!4b)o5EHb&x(EwT5Re@jO4IK&ZSSQ50Gn`=uvGmBQ_N|c=l+SXHc{Mwe^hT_DK)zCcCW;$(YGqnzo9>AZz%W3Dp9wW9mjKd*NrGw;>G4_yU_>0^X1Ol6KHcL#1|MY;|SC5Hwu<&vtJ>^t3onC{{VkhX",
44
"ob": null,
55
"bf": null,
66
"hm": null,

scripts/decrypt-secrets.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ const schema = csTable("ci_secrets", {
77
value: csColumn("value"),
88
});
99

10+
function isFileMode(data: unknown): data is Encrypted {
11+
return data !== null && typeof data === "object" && "k" in data && "c" in data;
12+
}
13+
1014
async function main(): Promise<void> {
1115
const repoRoot = path.resolve(import.meta.dirname, "..");
1216
const encryptedPath = path.join(repoRoot, ".github", "secrets.env.encrypted");
@@ -17,17 +21,35 @@ async function main(): Promise<void> {
1721
}
1822

1923
const client = await protect({ schemas: [schema] });
20-
const encrypted: Encrypted = JSON.parse(
24+
const encrypted: unknown = JSON.parse(
2125
fs.readFileSync(encryptedPath, "utf-8")
2226
);
2327

24-
const result = await client.decrypt(encrypted);
25-
if (result.failure) {
26-
console.error(`Failed to decrypt: ${result.failure.message}`);
27-
process.exit(1);
28-
}
28+
let env: Record<string, string>;
2929

30-
const env = dotenv.parse(String(result.data));
30+
if (isFileMode(encrypted)) {
31+
// File mode: decrypt single blob, parse as .env
32+
const result = await client.decrypt(encrypted);
33+
if (result.failure) {
34+
console.error(`Failed to decrypt: ${result.failure.message}`);
35+
process.exit(1);
36+
}
37+
env = dotenv.parse(String(result.data));
38+
console.error("Detected file mode encryption");
39+
} else {
40+
// Vars mode: decrypt each variable individually
41+
env = {};
42+
const encryptedVars = encrypted as Record<string, Encrypted>;
43+
for (const [key, payload] of Object.entries(encryptedVars)) {
44+
const result = await client.decrypt(payload);
45+
if (result.failure) {
46+
console.error(`Failed to decrypt ${key}: ${result.failure.message}`);
47+
process.exit(1);
48+
}
49+
env[key] = String(result.data);
50+
}
51+
console.error(`Detected vars mode encryption (${Object.keys(env).length} variables)`);
52+
}
3153
const githubEnvPath = process.env.GITHUB_ENV;
3254
const isCI = !!githubEnvPath;
3355

scripts/encrypt-secrets.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { protect, csTable, csColumn } from "@cipherstash/protect";
22
import * as fs from "fs";
33
import * as path from "path";
4+
import * as dotenv from "dotenv";
5+
6+
type Mode = "file" | "vars";
7+
8+
function parseArgs(): Mode {
9+
const args = process.argv.slice(2);
10+
if (args.includes("--vars")) return "vars";
11+
if (args.includes("--file")) return "file";
12+
return "file"; // default
13+
}
414

515
const schema = csTable("ci_secrets", {
616
value: csColumn("value"),
717
});
818

919
async function main(): Promise<void> {
20+
const mode = parseArgs();
1021
const repoRoot = path.resolve(import.meta.dirname, "..");
1122
const plaintextPath = path.join(repoRoot, ".github", "secrets.env.plaintext");
1223
const encryptedPath = path.join(repoRoot, ".github", "secrets.env.encrypted");
@@ -19,18 +30,43 @@ async function main(): Promise<void> {
1930
const fileContent = fs.readFileSync(plaintextPath, "utf-8");
2031
const client = await protect({ schemas: [schema] });
2132

22-
const result = await client.encrypt(fileContent, {
23-
table: schema,
24-
column: schema.value,
25-
});
33+
if (mode === "file") {
34+
// File mode: encrypt entire file content as single blob
35+
const result = await client.encrypt(fileContent, {
36+
table: schema,
37+
column: schema.value,
38+
});
2639

27-
if (result.failure) {
28-
console.error(`Failed to encrypt: ${result.failure.message}`);
29-
process.exit(1);
30-
}
40+
if (result.failure) {
41+
console.error(`Failed to encrypt: ${result.failure.message}`);
42+
process.exit(1);
43+
}
3144

32-
fs.writeFileSync(encryptedPath, JSON.stringify(result.data, null, 2) + "\n");
33-
console.error(`Encrypted secrets file to ${encryptedPath}`);
45+
fs.writeFileSync(encryptedPath, JSON.stringify(result.data, null, 2) + "\n");
46+
console.error(`Encrypted secrets file to ${encryptedPath} (file mode)`);
47+
} else {
48+
// Vars mode: encrypt each variable individually
49+
const env = dotenv.parse(fileContent);
50+
const encrypted: Record<string, unknown> = {};
51+
52+
for (const [key, value] of Object.entries(env)) {
53+
const result = await client.encrypt(value, {
54+
table: schema,
55+
column: schema.value,
56+
});
57+
58+
if (result.failure) {
59+
console.error(`Failed to encrypt ${key}: ${result.failure.message}`);
60+
process.exit(1);
61+
}
62+
63+
encrypted[key] = result.data;
64+
console.error(`Encrypted: ${key}`);
65+
}
66+
67+
fs.writeFileSync(encryptedPath, JSON.stringify(encrypted, null, 2) + "\n");
68+
console.error(`Encrypted ${Object.keys(encrypted).length} secrets to ${encryptedPath} (vars mode)`);
69+
}
3470
}
3571

3672
main().catch((err) => {

0 commit comments

Comments
 (0)