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.
Files changed (3) hide show
  1. package/README.md +26 -5
  2. package/dist/cli.mjs +139 -83
  3. 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 Use This Tool?
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-extension1
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
- - main
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.2";
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 = new 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
- let res = { key: "", key_id: "" };
71
- if (!config.dry) {
72
- const { status, data } = await octokit.request(
73
- url,
74
- {
75
- owner,
76
- repo,
77
- headers: {
78
- "X-GitHub-Api-Version": GITHUB_API_VERSION
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
- res = data;
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 = new 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
- let res;
104
- let operation = "create or update";
105
- if (!config.dry) {
106
- const { status, data } = await octokit.request(
107
- url,
108
- {
109
- owner,
110
- repo,
111
- secret_name: secretName,
112
- encrypted_value: await encrypt(secretValue, publicKey),
113
- key_id: publicKey.key_id,
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": 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
- if (status !== 201 && status !== 204) {
120
- spinner.error(c.red(`Failed to create or update ${secretName} for ${repoPath}`));
121
- console.error(c.red(JSON.stringify(data, null, 2)));
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(`Create or update ${secretName} for ${repoPath}`));
143
+ console.log(c.yellow(dryText));
144
+ result = defaultValue;
129
145
  }
130
- spinner.success(c.green(`${secretName} ${operation} successfully for ${repoPath}`));
131
- return res;
146
+ return result;
132
147
  }
133
- function parseRepo(repoPath) {
134
- const [owner, repo] = repoPath.split("/");
135
- return { owner, repo };
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);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gh-secrets-sync",
3
3
  "type": "module",
4
- "version": "0.0.2",
4
+ "version": "0.1.0",
5
5
  "description": "CLI tool to batch sync GitHub Actions secrets across multiple repositories.",
6
6
  "author": "jinghaihan",
7
7
  "license": "MIT",