gh-secrets-sync 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/dist/cli.mjs +139 -83
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# GitHub Secrets Sync
|
|
2
2
|
|
|
3
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
4
|
+
[![bundle][bundle-src]][bundle-href]
|
|
5
|
+
[![JSDocs][jsdocs-src]][jsdocs-href]
|
|
6
|
+
[![License][license-src]][license-href]
|
|
7
|
+
|
|
3
8
|
A CLI tool to batch sync GitHub Actions secrets across multiple repositories. Sync secrets from a central repository to target repositories using GitHub CI.
|
|
4
9
|
|
|
5
|
-
## Why
|
|
10
|
+
## Why?
|
|
6
11
|
|
|
7
12
|
Managing GitHub Actions secrets across multiple repositories can be tedious:
|
|
8
13
|
|
|
@@ -18,14 +23,15 @@ This tool automates the process, allowing you to sync secrets across multiple re
|
|
|
18
23
|
|
|
19
24
|
```yaml
|
|
20
25
|
repos:
|
|
21
|
-
- owner/vscode
|
|
22
|
-
- owner/vscode-extension2
|
|
26
|
+
- owner/vscode-*
|
|
23
27
|
|
|
24
28
|
envs:
|
|
25
29
|
- VSCE_PAT
|
|
26
30
|
- OVSX_PAT
|
|
27
31
|
```
|
|
28
32
|
|
|
33
|
+
> **Note**: The `repos` configuration supports regex patterns with `*` wildcards. The tool will fetch all repositories accessible by your GitHub token and filter them based on the regex patterns. For example, `owner/vscode-*` will match all repositories under the specified owner that start with "vscode-".
|
|
34
|
+
|
|
29
35
|
2. **Set up GitHub CI** in your central repository:
|
|
30
36
|
|
|
31
37
|
```yaml
|
|
@@ -37,8 +43,10 @@ permissions:
|
|
|
37
43
|
|
|
38
44
|
on:
|
|
39
45
|
push:
|
|
40
|
-
branches:
|
|
41
|
-
|
|
46
|
+
branches: [main]
|
|
47
|
+
schedule:
|
|
48
|
+
- cron: '0 0 * * *'
|
|
49
|
+
workflow_dispatch:
|
|
42
50
|
|
|
43
51
|
jobs:
|
|
44
52
|
sync:
|
|
@@ -81,3 +89,16 @@ jobs:
|
|
|
81
89
|
## License
|
|
82
90
|
|
|
83
91
|
[MIT](./LICENSE) License © [jinghaihan](https://github.com/jinghaihan)
|
|
92
|
+
|
|
93
|
+
<!-- Badges -->
|
|
94
|
+
|
|
95
|
+
[npm-version-src]: https://img.shields.io/npm/v/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669
|
|
96
|
+
[npm-version-href]: https://npmjs.com/package/gh-secrets-sync
|
|
97
|
+
[npm-downloads-src]: https://img.shields.io/npm/dm/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669
|
|
98
|
+
[npm-downloads-href]: https://npmjs.com/package/gh-secrets-sync
|
|
99
|
+
[bundle-src]: https://img.shields.io/bundlephobia/minzip/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669&label=minzip
|
|
100
|
+
[bundle-href]: https://bundlephobia.com/result?p=gh-secrets-sync
|
|
101
|
+
[license-src]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat&colorA=080f12&colorB=1fa669
|
|
102
|
+
[license-href]: https://github.com/jinghaihan/gh-secrets-sync/LICENSE
|
|
103
|
+
[jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
|
|
104
|
+
[jsdocs-href]: https://www.jsdocs.io/package/gh-secrets-sync
|
package/dist/cli.mjs
CHANGED
|
@@ -9,44 +9,18 @@ import Spinner from 'yocto-spinner';
|
|
|
9
9
|
import sodium from 'libsodium-wrappers';
|
|
10
10
|
|
|
11
11
|
const name = "gh-secrets-sync";
|
|
12
|
-
const version = "0.0
|
|
12
|
+
const version = "0.1.0";
|
|
13
13
|
|
|
14
14
|
const DEFAULT_SYNC_OPTIONS = {
|
|
15
15
|
config: "./secrets.config.yaml",
|
|
16
16
|
repos: [],
|
|
17
17
|
secrets: [],
|
|
18
18
|
envPrefix: "",
|
|
19
|
+
apiVersion: "2022-11-28",
|
|
20
|
+
private: false,
|
|
19
21
|
dry: false,
|
|
20
22
|
strict: true
|
|
21
23
|
};
|
|
22
|
-
const GITHUB_API_VERSION = "2022-11-28";
|
|
23
|
-
|
|
24
|
-
function resolveConfig(options) {
|
|
25
|
-
const getConfig = () => {
|
|
26
|
-
const cwd = options.cwd || process.cwd();
|
|
27
|
-
if (typeof options.repos === "string")
|
|
28
|
-
options.repos = [options.repos];
|
|
29
|
-
if (typeof options.secrets === "string")
|
|
30
|
-
options.secrets = [options.secrets];
|
|
31
|
-
const config2 = { ...DEFAULT_SYNC_OPTIONS, ...options };
|
|
32
|
-
config2.token = config2.token || process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || "";
|
|
33
|
-
if (config2.repos.length && config2.secrets.length) {
|
|
34
|
-
return config2;
|
|
35
|
-
}
|
|
36
|
-
const configContent = parse(readFileSync(join(cwd, config2.config), "utf-8"));
|
|
37
|
-
config2.repos = Array.isArray(configContent.repos) ? configContent.repos : [];
|
|
38
|
-
config2.secrets = Array.isArray(configContent.envs) ? configContent.envs : [];
|
|
39
|
-
return config2;
|
|
40
|
-
};
|
|
41
|
-
const config = getConfig();
|
|
42
|
-
if (!config.token)
|
|
43
|
-
throw new Error(c.red("Please provide a GitHub token"));
|
|
44
|
-
if (!config.repos)
|
|
45
|
-
throw new Error(c.red("Please provide repos to sync"));
|
|
46
|
-
if (!config.secrets)
|
|
47
|
-
throw new Error(c.red("Please provide secrets to sync"));
|
|
48
|
-
return config;
|
|
49
|
-
}
|
|
50
24
|
|
|
51
25
|
async function encrypt(value, publicKey) {
|
|
52
26
|
await sodium.ready;
|
|
@@ -59,85 +33,167 @@ async function encrypt(value, publicKey) {
|
|
|
59
33
|
function formatToken(token) {
|
|
60
34
|
return `Bearer ${token}`;
|
|
61
35
|
}
|
|
36
|
+
function parseRepo(repoPath) {
|
|
37
|
+
const [owner, repo] = repoPath.split("/");
|
|
38
|
+
return { owner, repo };
|
|
39
|
+
}
|
|
62
40
|
|
|
63
41
|
async function getRepoPublicKey(repoPath, config) {
|
|
64
|
-
const octokit =
|
|
65
|
-
auth: formatToken(config.token)
|
|
66
|
-
});
|
|
67
|
-
const spinner = Spinner({ text: c.blue(`Fetching public key for ${repoPath}`) }).start();
|
|
42
|
+
const octokit = createOctokit(config);
|
|
68
43
|
const { owner, repo } = parseRepo(repoPath);
|
|
69
44
|
const url = `GET /repos/${owner}/${repo}/actions/secrets/public-key`;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
45
|
+
return withSpinner(
|
|
46
|
+
`Fetching public key for ${repoPath}`,
|
|
47
|
+
`Public key fetched successfully for ${repoPath}`,
|
|
48
|
+
`Failed to fetch public key for ${repoPath}`,
|
|
49
|
+
`Fetch Public Key: ${c.yellow(url)}`,
|
|
50
|
+
{ key: "", key_id: "" },
|
|
51
|
+
config,
|
|
52
|
+
async () => {
|
|
53
|
+
const { status, data } = await octokit.request(
|
|
54
|
+
url,
|
|
55
|
+
{
|
|
56
|
+
owner,
|
|
57
|
+
repo,
|
|
58
|
+
headers: {
|
|
59
|
+
"X-GitHub-Api-Version": config.apiVersion
|
|
60
|
+
}
|
|
79
61
|
}
|
|
62
|
+
);
|
|
63
|
+
if (status !== 200) {
|
|
64
|
+
throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
|
|
80
65
|
}
|
|
81
|
-
|
|
82
|
-
if (status !== 200) {
|
|
83
|
-
spinner.error(c.red(`Failed to fetch public key for ${repoPath}: ${status}`));
|
|
84
|
-
console.error(c.red(JSON.stringify(data, null, 2)));
|
|
85
|
-
process.exit(1);
|
|
66
|
+
return data;
|
|
86
67
|
}
|
|
87
|
-
|
|
88
|
-
} else {
|
|
89
|
-
console.log();
|
|
90
|
-
console.log(c.yellow(`Get a repository public key: ${repoPath}`));
|
|
91
|
-
}
|
|
92
|
-
spinner.success(c.green(`Public key fetched successfully for ${repoPath}`));
|
|
93
|
-
return res;
|
|
68
|
+
);
|
|
94
69
|
}
|
|
95
70
|
async function createOrUpdateRepoSecret(secretName, secretValue, publicKey, repoPath, config) {
|
|
96
|
-
const octokit =
|
|
97
|
-
auth: formatToken(config.token)
|
|
98
|
-
});
|
|
71
|
+
const octokit = createOctokit(config);
|
|
99
72
|
secretName = `${config.envPrefix}${secretName}`;
|
|
100
|
-
const spinner = Spinner({ text: c.blue(`Create or update ${secretName} for ${repoPath}`) }).start();
|
|
101
73
|
const { owner, repo } = parseRepo(repoPath);
|
|
102
74
|
const url = `PUT /repos/${owner}/${repo}/actions/secrets/${secretName}`;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
75
|
+
return withSpinner(
|
|
76
|
+
`Create or update ${secretName} for ${repoPath}`,
|
|
77
|
+
`${secretName} created/updated successfully for ${repoPath}`,
|
|
78
|
+
`Failed to create or update ${secretName} for ${repoPath}`,
|
|
79
|
+
`Create or Update Secret: ${c.yellow(url)}`,
|
|
80
|
+
void 0,
|
|
81
|
+
config,
|
|
82
|
+
async () => {
|
|
83
|
+
const { status, data } = await octokit.request(
|
|
84
|
+
url,
|
|
85
|
+
{
|
|
86
|
+
owner,
|
|
87
|
+
repo,
|
|
88
|
+
secret_name: secretName,
|
|
89
|
+
encrypted_value: await encrypt(secretValue, publicKey),
|
|
90
|
+
key_id: publicKey.key_id,
|
|
91
|
+
headers: {
|
|
92
|
+
"X-GitHub-Api-Version": config.apiVersion
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
if (status !== 201 && status !== 204) {
|
|
97
|
+
throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
|
|
98
|
+
}
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
async function getRepos(config) {
|
|
104
|
+
const octokit = createOctokit(config);
|
|
105
|
+
const url = "GET /user/repos";
|
|
106
|
+
return withSpinner(
|
|
107
|
+
"Fetching repositories",
|
|
108
|
+
"Repositories fetched successfully",
|
|
109
|
+
"Failed to fetch repositories",
|
|
110
|
+
`Fetch Repositories: ${c.yellow(url)}`,
|
|
111
|
+
[],
|
|
112
|
+
config,
|
|
113
|
+
async () => {
|
|
114
|
+
const { status, data } = await octokit.request(url, {
|
|
114
115
|
headers: {
|
|
115
|
-
"X-GitHub-Api-Version":
|
|
116
|
+
"X-GitHub-Api-Version": config.apiVersion
|
|
116
117
|
}
|
|
118
|
+
});
|
|
119
|
+
if (status !== 200) {
|
|
120
|
+
throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
|
|
117
121
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
function createOctokit(config) {
|
|
127
|
+
return new Octokit({ auth: formatToken(config.token) });
|
|
128
|
+
}
|
|
129
|
+
async function withSpinner(loadingText, successText, failedText, dryText, defaultValue, config, request) {
|
|
130
|
+
const spinner = Spinner({ text: c.blue(loadingText) }).start();
|
|
131
|
+
let result;
|
|
132
|
+
if (!config.dry) {
|
|
133
|
+
try {
|
|
134
|
+
result = await request();
|
|
135
|
+
spinner.success(c.green(successText));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
spinner.error(c.red(failedText));
|
|
138
|
+
console.error(c.red(JSON.stringify(error, null, 2)));
|
|
122
139
|
process.exit(1);
|
|
123
140
|
}
|
|
124
|
-
operation = status === 201 ? "created" : "updated";
|
|
125
|
-
res = data;
|
|
126
141
|
} else {
|
|
127
142
|
console.log();
|
|
128
|
-
console.log(c.yellow(
|
|
143
|
+
console.log(c.yellow(dryText));
|
|
144
|
+
result = defaultValue;
|
|
129
145
|
}
|
|
130
|
-
|
|
131
|
-
return res;
|
|
146
|
+
return result;
|
|
132
147
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
|
|
149
|
+
function normalizeConfig(options) {
|
|
150
|
+
const cwd = options.cwd || process.cwd();
|
|
151
|
+
if (typeof options.repos === "string")
|
|
152
|
+
options.repos = [options.repos];
|
|
153
|
+
if (typeof options.secrets === "string")
|
|
154
|
+
options.secrets = [options.secrets];
|
|
155
|
+
const config = { ...DEFAULT_SYNC_OPTIONS, ...options };
|
|
156
|
+
config.token = config.token || process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || "";
|
|
157
|
+
if (config.repos.length && config.secrets.length) {
|
|
158
|
+
return config;
|
|
159
|
+
}
|
|
160
|
+
const configContent = parse(readFileSync(join(cwd, config.config), "utf-8"));
|
|
161
|
+
config.repos = Array.isArray(configContent.repos) ? configContent.repos : [];
|
|
162
|
+
config.secrets = Array.isArray(configContent.envs) ? configContent.envs : [];
|
|
163
|
+
return config;
|
|
164
|
+
}
|
|
165
|
+
async function resolveConfig(options) {
|
|
166
|
+
const config = normalizeConfig(options);
|
|
167
|
+
if (config.repos.some((r) => r.includes("*"))) {
|
|
168
|
+
const repos = await getReposByRegex(config);
|
|
169
|
+
if (repos.length) {
|
|
170
|
+
config.repos = [.../* @__PURE__ */ new Set([...config.repos, ...repos])];
|
|
171
|
+
}
|
|
172
|
+
config.repos = config.repos.filter((r) => !r.includes("*"));
|
|
173
|
+
}
|
|
174
|
+
if (!config.token)
|
|
175
|
+
throw new Error(c.red("Please provide a GitHub token"));
|
|
176
|
+
if (!config.repos)
|
|
177
|
+
throw new Error(c.red("Please provide repos to sync"));
|
|
178
|
+
if (!config.secrets)
|
|
179
|
+
throw new Error(c.red("Please provide secrets to sync"));
|
|
180
|
+
return config;
|
|
181
|
+
}
|
|
182
|
+
async function getReposByRegex(options) {
|
|
183
|
+
const _repos = options.repos.filter((r) => r.includes("*"));
|
|
184
|
+
const regexes = _repos.find((r) => r === "*") ? ["*"] : _repos.map((r) => new RegExp(r));
|
|
185
|
+
if (regexes.length === 0)
|
|
186
|
+
return [];
|
|
187
|
+
const list = await getRepos(options);
|
|
188
|
+
const repos = options.private ? list : list.filter((r) => !r.private);
|
|
189
|
+
if (regexes.find((r) => r === "*"))
|
|
190
|
+
return repos.map((r) => r.full_name);
|
|
191
|
+
return repos.filter((r) => regexes.some((regex) => regex.test(r.full_name))).map((r) => r.full_name);
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
try {
|
|
139
195
|
const cli = cac("gh-secrets-sync");
|
|
140
|
-
cli.command("").option("--config <config>", "secrets config file", { default: "./secrets.config.yaml" }).option("--secrets <secrets...>", "secrets name to sync", { default: [] }).option("--token <token>", "GitHub token").option("--env-prefix <prefix>", "environment variable prefix", { default: "" }).option("--strict", "Throw error if secret is not found in the environment variables", { default: true }).option("--dry", "Dry run", { default: false }).allowUnknownOptions().action(async (options) => {
|
|
196
|
+
cli.command("").option("--config <config>", "secrets config file", { default: "./secrets.config.yaml" }).option("--secrets <secrets...>", "secrets name to sync", { default: [] }).option("--token <token>", "GitHub token").option("--api-version <version>", "GitHub API version", { default: "2022-11-28" }).option("--private", "Detect private repositories", { default: false }).option("--env-prefix <prefix>", "environment variable prefix", { default: "" }).option("--strict", "Throw error if secret is not found in the environment variables", { default: true }).option("--dry", "Dry run", { default: false }).allowUnknownOptions().action(async (options) => {
|
|
141
197
|
console.log(`${c.yellow(name)} ${c.dim(`v${version}`)}`);
|
|
142
198
|
console.log();
|
|
143
199
|
const config = await resolveConfig(options);
|