gh-secrets-sync 0.0.2 → 0.1.1

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 +47 -10
  2. package/dist/cli.mjs +239 -81
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,32 +1,53 @@
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
 
9
14
  - **Manual repetition**: You need to manually add the same secret to each repository
10
- - **Time-consuming**: Updating a secret across many repos requires visiting each one individually
11
15
  - **Error-prone**: Easy to forget to update a secret in one of the repositories
12
16
 
13
17
  This tool automates the process, allowing you to sync secrets across multiple repositories with a single command.
14
18
 
15
19
  ## Usage
16
20
 
17
- 1. **Create a configuration file** (`secrets.config.yaml`) in your central repository:
21
+ **Create a configuration file** (`secrets.config.yaml`) in your central repository or local directory:
18
22
 
19
23
  ```yaml
20
24
  repos:
21
- - owner/vscode-extension1
22
- - owner/vscode-extension2
25
+ - owner/vscode-*
23
26
 
24
27
  envs:
25
28
  - VSCE_PAT
26
29
  - OVSX_PAT
27
30
  ```
28
31
 
29
- 2. **Set up GitHub CI** in your central repository:
32
+ > [!NOTE]
33
+ > Both `repos` and `envs` support `*` wildcards. For `repos`, the tool lists all repositories accessible by your token and filters by the pattern (e.g., `owner/vscode-*`). For `envs`, wildcards are expanded by listing secrets from the central repository and matching by name. The central repository is auto-detected in GitHub Actions (from the checked-out repo); for local runs, pass `--repo <owner/repo>`.
34
+
35
+ ### Local usage
36
+
37
+ If GitHub CI feels too complex, you can simply run it locally:
38
+
39
+ ```bash
40
+ # Set your token and secret values in env
41
+ export GITHUB_PAT=...
42
+ export VSCE_PAT=...
43
+ export OVSX_PAT=...
44
+
45
+ npx gh-secrets-sync
46
+ ```
47
+
48
+ ### GitHub CI usage
49
+
50
+ **Set up GitHub CI** in your central repository:
30
51
 
31
52
  ```yaml
32
53
  # .github/workflows/sync-secrets.yml
@@ -37,8 +58,10 @@ permissions:
37
58
 
38
59
  on:
39
60
  push:
40
- branches:
41
- - main
61
+ branches: [main]
62
+ schedule:
63
+ - cron: '0 0 * * *'
64
+ workflow_dispatch:
42
65
 
43
66
  jobs:
44
67
  sync:
@@ -54,14 +77,15 @@ jobs:
54
77
  node-version: lts/*
55
78
 
56
79
  - name: Sync Secrets
57
- run: npx gh-secrets-sync
80
+ # if regex patterns are used in `repos` or `secrets` must set `--yes` in GitHub Actions
81
+ run: npx gh-secrets-sync --yes
58
82
  env:
59
83
  GITHUB_PAT: ${{secrets.GITHUB_PAT}}
60
84
  VSCE_PAT: ${{secrets.VSCE_PAT}}
61
85
  OVSX_PAT: ${{secrets.OVSX_PAT}}
62
86
  ```
63
87
 
64
- 3. **Configure secrets in your central repository**:
88
+ **Configure secrets in your central repository**:
65
89
  - Go to your central repository Settings > Secrets and variables > Actions
66
90
  - Add `GITHUB_PAT` as a repository secret (this is your GitHub Personal Access Token)
67
91
  - Add `VSCE_PAT` and `OVSX_PAT` as repository secrets
@@ -81,3 +105,16 @@ jobs:
81
105
  ## License
82
106
 
83
107
  [MIT](./LICENSE) License © [jinghaihan](https://github.com/jinghaihan)
108
+
109
+ <!-- Badges -->
110
+
111
+ [npm-version-src]: https://img.shields.io/npm/v/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669
112
+ [npm-version-href]: https://npmjs.com/package/gh-secrets-sync
113
+ [npm-downloads-src]: https://img.shields.io/npm/dm/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669
114
+ [npm-downloads-href]: https://npmjs.com/package/gh-secrets-sync
115
+ [bundle-src]: https://img.shields.io/bundlephobia/minzip/gh-secrets-sync?style=flat&colorA=080f12&colorB=1fa669&label=minzip
116
+ [bundle-href]: https://bundlephobia.com/result?p=gh-secrets-sync
117
+ [license-src]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat&colorA=080f12&colorB=1fa669
118
+ [license-href]: https://github.com/jinghaihan/gh-secrets-sync/LICENSE
119
+ [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669
120
+ [jsdocs-href]: https://www.jsdocs.io/package/gh-secrets-sync
package/dist/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import process from 'node:process';
2
2
  import c from 'ansis';
3
3
  import { cac } from 'cac';
4
- import { readFileSync } from 'node:fs';
4
+ import { readFile } from 'node:fs/promises';
5
+ import * as p from '@clack/prompts';
5
6
  import { join } from 'pathe';
6
7
  import { parse } from 'yaml';
7
8
  import { Octokit } from '@octokit/core';
@@ -9,43 +10,28 @@ import Spinner from 'yocto-spinner';
9
10
  import sodium from 'libsodium-wrappers';
10
11
 
11
12
  const name = "gh-secrets-sync";
12
- const version = "0.0.2";
13
+ const version = "0.1.1";
13
14
 
14
15
  const DEFAULT_SYNC_OPTIONS = {
15
16
  config: "./secrets.config.yaml",
16
17
  repos: [],
17
18
  secrets: [],
18
19
  envPrefix: "",
20
+ apiVersion: "2022-11-28",
21
+ private: false,
22
+ baseUrl: "github.com",
23
+ repo: "",
19
24
  dry: false,
20
- strict: true
25
+ strict: true,
26
+ yes: false
21
27
  };
22
- const GITHUB_API_VERSION = "2022-11-28";
23
28
 
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;
29
+ function createRegexFilter(options, field) {
30
+ const list = options.filter((i) => i.includes("*"));
31
+ if (list.length === 0 || list.includes("*"))
32
+ return () => true;
33
+ const regexes = list.map((r) => new RegExp(r));
34
+ return (i) => regexes.some((regex) => regex.test(i[field]));
49
35
  }
50
36
 
51
37
  async function encrypt(value, publicKey) {
@@ -59,85 +45,257 @@ async function encrypt(value, publicKey) {
59
45
  function formatToken(token) {
60
46
  return `Bearer ${token}`;
61
47
  }
48
+ function parseRepo(repoPath) {
49
+ const [owner, repo] = repoPath.split("/");
50
+ return { owner, repo };
51
+ }
62
52
 
53
+ async function readTokenFromGitHubCli() {
54
+ try {
55
+ return await execCommand("gh", ["auth", "token"]);
56
+ } catch {
57
+ return "";
58
+ }
59
+ }
60
+ async function getGitHubRepo(baseUrl) {
61
+ const url = await execCommand("git", ["config", "--get", "remote.origin.url"]);
62
+ const escapedBaseUrl = baseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ const regex = new RegExp(`${escapedBaseUrl}[/:]([\\w\\d._-]+?)\\/([\\w\\d._-]+?)(\\.git)?$`, "i");
64
+ const match = regex.exec(url);
65
+ if (!match)
66
+ throw new Error(`Can not parse GitHub repo from url ${url}`);
67
+ return `${match[1]}/${match[2]}`;
68
+ }
69
+ async function execCommand(cmd, args) {
70
+ const { execa } = await import('execa');
71
+ const res = await execa(cmd, args);
72
+ return res.stdout.trim();
73
+ }
63
74
  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();
75
+ const octokit = createOctokit(config);
68
76
  const { owner, repo } = parseRepo(repoPath);
69
77
  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
78
+ return withSpinner(
79
+ `Fetching public key for ${repoPath}`,
80
+ `Public key fetched successfully for ${repoPath}`,
81
+ `Failed to fetch public key for ${repoPath}`,
82
+ `Fetch Public Key: ${c.yellow(url)}`,
83
+ { key: "", key_id: "" },
84
+ config,
85
+ async () => {
86
+ const { status, data } = await octokit.request(
87
+ url,
88
+ {
89
+ owner,
90
+ repo,
91
+ headers: {
92
+ "X-GitHub-Api-Version": config.apiVersion
93
+ }
79
94
  }
95
+ );
96
+ if (status !== 200) {
97
+ throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
80
98
  }
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);
99
+ return data;
86
100
  }
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;
101
+ );
94
102
  }
95
103
  async function createOrUpdateRepoSecret(secretName, secretValue, publicKey, repoPath, config) {
96
- const octokit = new Octokit({
97
- auth: formatToken(config.token)
98
- });
104
+ const octokit = createOctokit(config);
99
105
  secretName = `${config.envPrefix}${secretName}`;
100
- const spinner = Spinner({ text: c.blue(`Create or update ${secretName} for ${repoPath}`) }).start();
101
106
  const { owner, repo } = parseRepo(repoPath);
102
107
  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
- {
108
+ return withSpinner(
109
+ `Create or update ${secretName} for ${repoPath}`,
110
+ `${secretName} created/updated successfully for ${repoPath}`,
111
+ `Failed to create or update ${secretName} for ${repoPath}`,
112
+ `Create or Update Secret: ${c.yellow(url)}`,
113
+ void 0,
114
+ config,
115
+ async () => {
116
+ const { status, data } = await octokit.request(
117
+ url,
118
+ {
119
+ owner,
120
+ repo,
121
+ secret_name: secretName,
122
+ encrypted_value: await encrypt(secretValue, publicKey),
123
+ key_id: publicKey.key_id,
124
+ headers: {
125
+ "X-GitHub-Api-Version": config.apiVersion
126
+ }
127
+ }
128
+ );
129
+ if (status !== 201 && status !== 204) {
130
+ throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
131
+ }
132
+ return data;
133
+ }
134
+ );
135
+ }
136
+ async function getRepos(config) {
137
+ const octokit = createOctokit(config);
138
+ const url = "GET /user/repos";
139
+ return withSpinner(
140
+ "Fetching repositories",
141
+ "Repositories fetched successfully",
142
+ "Failed to fetch repositories",
143
+ `Fetch Repositories: ${c.yellow(url)}`,
144
+ [],
145
+ config,
146
+ async () => {
147
+ const { status, data } = await octokit.request(url, {
148
+ headers: {
149
+ "X-GitHub-Api-Version": config.apiVersion
150
+ }
151
+ });
152
+ if (status !== 200) {
153
+ throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
154
+ }
155
+ return data;
156
+ }
157
+ );
158
+ }
159
+ async function getRepoSecrets(config) {
160
+ const repoPath = config.repo || await getGitHubRepo(config.baseUrl);
161
+ if (!repoPath)
162
+ return [];
163
+ const octokit = createOctokit(config);
164
+ const { owner, repo } = parseRepo(repoPath);
165
+ const url = `GET /repos/${owner}/${repo}/actions/secrets`;
166
+ return withSpinner(
167
+ `Fetching secrets for ${repoPath}`,
168
+ `Secrets fetched successfully for ${repoPath}`,
169
+ `Failed to fetch secrets for ${repoPath}`,
170
+ `Fetch Secrets: ${c.yellow(url)}`,
171
+ [],
172
+ config,
173
+ async () => {
174
+ const { status, data } = await octokit.request(url, {
109
175
  owner,
110
176
  repo,
111
- secret_name: secretName,
112
- encrypted_value: await encrypt(secretValue, publicKey),
113
- key_id: publicKey.key_id,
114
177
  headers: {
115
- "X-GitHub-Api-Version": GITHUB_API_VERSION
178
+ "X-GitHub-Api-Version": config.apiVersion
116
179
  }
180
+ });
181
+ if (status !== 200) {
182
+ throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
117
183
  }
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)));
184
+ return data.secrets;
185
+ }
186
+ );
187
+ }
188
+ function createOctokit(config) {
189
+ return new Octokit({ auth: formatToken(config.token) });
190
+ }
191
+ async function withSpinner(loadingText, successText, failedText, dryText, defaultValue, config, request) {
192
+ const spinner = Spinner({ text: c.blue(loadingText) }).start();
193
+ let result;
194
+ if (!config.dry) {
195
+ try {
196
+ result = await request();
197
+ spinner.success(c.green(successText));
198
+ } catch (error) {
199
+ spinner.error(c.red(failedText));
200
+ console.error(c.red(JSON.stringify(error, null, 2)));
122
201
  process.exit(1);
123
202
  }
124
- operation = status === 201 ? "created" : "updated";
125
- res = data;
126
203
  } else {
127
204
  console.log();
128
- console.log(c.yellow(`Create or update ${secretName} for ${repoPath}`));
205
+ console.log(c.yellow(dryText));
206
+ result = defaultValue;
129
207
  }
130
- spinner.success(c.green(`${secretName} ${operation} successfully for ${repoPath}`));
131
- return res;
208
+ return result;
132
209
  }
133
- function parseRepo(repoPath) {
134
- const [owner, repo] = repoPath.split("/");
135
- return { owner, repo };
210
+
211
+ async function normalizeConfig(options) {
212
+ const cwd = options.cwd || process.cwd();
213
+ if (typeof options.repos === "string")
214
+ options.repos = [options.repos];
215
+ if (typeof options.secrets === "string")
216
+ options.secrets = [options.secrets];
217
+ const config = { ...DEFAULT_SYNC_OPTIONS, ...options };
218
+ config.token = config.token || process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || await readTokenFromGitHubCli();
219
+ if (config.repos.length && config.secrets.length) {
220
+ return config;
221
+ }
222
+ const configContent = parse(await readFile(join(cwd, config.config), "utf-8"));
223
+ config.repos = Array.isArray(configContent.repos) ? configContent.repos : [];
224
+ config.secrets = Array.isArray(configContent.envs) ? configContent.envs : [];
225
+ return config;
226
+ }
227
+ async function resolveConfig(options) {
228
+ const config = await normalizeConfig(options);
229
+ if (config.repos.some((r) => r.includes("*"))) {
230
+ await resolveRepoPatterns(config);
231
+ }
232
+ if (config.secrets.some((s) => s.includes("*"))) {
233
+ await resolveSecretPatterns(config);
234
+ }
235
+ if (!config.token)
236
+ throw new Error(c.red("Please provide a GitHub token"));
237
+ if (!config.repos)
238
+ throw new Error(c.red("Please provide repos to sync"));
239
+ if (!config.secrets)
240
+ throw new Error(c.red("Please provide secrets to sync"));
241
+ return config;
242
+ }
243
+ async function resolveRepoPatterns(options) {
244
+ const filter = createRegexFilter(options.repos, "full_name");
245
+ let repos = (await getRepos(options)).filter((i) => filter(i) && (options.private || !i.private)).map((i) => i.full_name);
246
+ if (repos.length) {
247
+ repos = [.../* @__PURE__ */ new Set([...options.repos, ...repos])];
248
+ }
249
+ repos = repos.filter((i) => !i.includes("*"));
250
+ if (options.yes || !repos.length) {
251
+ options.repos = repos;
252
+ return;
253
+ }
254
+ const result = await p.multiselect({
255
+ message: "Select repos to sync",
256
+ options: repos.map((i) => ({ label: i, value: i })),
257
+ initialValues: repos
258
+ });
259
+ if (p.isCancel(result)) {
260
+ console.error(c.red("aborting"));
261
+ process.exit(1);
262
+ }
263
+ if (typeof result === "symbol") {
264
+ console.error(c.red("invalid repo selection"));
265
+ process.exit(1);
266
+ }
267
+ options.repos = result;
268
+ }
269
+ async function resolveSecretPatterns(options) {
270
+ const filter = createRegexFilter(options.secrets, "name");
271
+ let secrets = (await getRepoSecrets(options)).filter((i) => filter(i)).map((i) => i.name);
272
+ if (secrets.length) {
273
+ secrets = [.../* @__PURE__ */ new Set([...options.secrets, ...secrets])];
274
+ }
275
+ secrets = secrets.filter((i) => !i.includes("*"));
276
+ if (options.yes || !secrets.length) {
277
+ options.secrets = secrets;
278
+ return;
279
+ }
280
+ const result = await p.multiselect({
281
+ message: "Select secrets to sync",
282
+ options: secrets.map((i) => ({ label: i, value: i })),
283
+ initialValues: secrets
284
+ });
285
+ if (p.isCancel(result)) {
286
+ console.error(c.red("aborting"));
287
+ process.exit(1);
288
+ }
289
+ if (typeof result === "symbol") {
290
+ console.error(c.red("invalid secret selection"));
291
+ process.exit(1);
292
+ }
293
+ options.secrets = result;
136
294
  }
137
295
 
138
296
  try {
139
297
  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) => {
298
+ 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("--repo <repo>", "central GitHub repository").option("--strict", "Throw error if secret is not found in the environment variables", { default: true }).option("--yes", "Prompt to confirm resolved matches when regex patterns are used in repos or secrets", { default: false }).option("--dry", "Dry run", { default: false }).allowUnknownOptions().action(async (options) => {
141
299
  console.log(`${c.yellow(name)} ${c.dim(`v${version}`)}`);
142
300
  console.log();
143
301
  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.1",
5
5
  "description": "CLI tool to batch sync GitHub Actions secrets across multiple repositories.",
6
6
  "author": "jinghaihan",
7
7
  "license": "MIT",
@@ -35,13 +35,13 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
+ "@clack/prompts": "^0.11.0",
38
39
  "@octokit/core": "^7.0.3",
39
40
  "ansis": "^4.1.0",
40
41
  "cac": "^6.7.14",
41
42
  "execa": "^9.6.0",
42
43
  "libsodium-wrappers": "^0.7.15",
43
44
  "pathe": "^2.0.3",
44
- "unconfig": "^7.3.2",
45
45
  "yaml": "^2.8.1",
46
46
  "yocto-spinner": "^1.0.0"
47
47
  },