gh-secrets-sync 0.1.0 → 0.1.2

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 +25 -9
  2. package/dist/cli.mjs +125 -23
  3. package/package.json +12 -12
package/README.md CHANGED
@@ -12,14 +12,13 @@ A CLI tool to batch sync GitHub Actions secrets across multiple repositories. Sy
12
12
  Managing GitHub Actions secrets across multiple repositories can be tedious:
13
13
 
14
14
  - **Manual repetition**: You need to manually add the same secret to each repository
15
- - **Time-consuming**: Updating a secret across many repos requires visiting each one individually
16
15
  - **Error-prone**: Easy to forget to update a secret in one of the repositories
17
16
 
18
17
  This tool automates the process, allowing you to sync secrets across multiple repositories with a single command.
19
18
 
20
19
  ## Usage
21
20
 
22
- 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:
23
22
 
24
23
  ```yaml
25
24
  repos:
@@ -30,9 +29,25 @@ envs:
30
29
  - OVSX_PAT
31
30
  ```
32
31
 
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-".
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
34
 
35
- 2. **Set up GitHub CI** in your central repository:
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 GH_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:
36
51
 
37
52
  ```yaml
38
53
  # .github/workflows/sync-secrets.yml
@@ -62,16 +77,17 @@ jobs:
62
77
  node-version: lts/*
63
78
 
64
79
  - name: Sync Secrets
65
- 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
66
82
  env:
67
- GITHUB_PAT: ${{secrets.GITHUB_PAT}}
83
+ GH_PAT: ${{secrets.GH_PAT}}
68
84
  VSCE_PAT: ${{secrets.VSCE_PAT}}
69
85
  OVSX_PAT: ${{secrets.OVSX_PAT}}
70
86
  ```
71
87
 
72
- 3. **Configure secrets in your central repository**:
88
+ **Configure secrets in your central repository**:
73
89
  - Go to your central repository Settings > Secrets and variables > Actions
74
- - Add `GITHUB_PAT` as a repository secret (this is your GitHub Personal Access Token)
90
+ - Add `GH_PAT` as a repository secret (this is your GitHub Personal Access Token)
75
91
  - Add `VSCE_PAT` and `OVSX_PAT` as repository secrets
76
92
 
77
93
  ### How to Get Your GitHub Token
@@ -84,7 +100,7 @@ jobs:
84
100
  - Repository permissions > Actions: Read and write
85
101
  - Metadata
86
102
  5. Click "Generate token"
87
- 6. Add the token as a repository secret named `GITHUB_PAT` in your central repository
103
+ 6. Add the token as a repository secret named `GH_PAT` in your central repository
88
104
 
89
105
  ## License
90
106
 
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,7 +10,7 @@ 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.1.0";
13
+ const version = "0.1.2";
13
14
 
14
15
  const DEFAULT_SYNC_OPTIONS = {
15
16
  config: "./secrets.config.yaml",
@@ -18,10 +19,21 @@ const DEFAULT_SYNC_OPTIONS = {
18
19
  envPrefix: "",
19
20
  apiVersion: "2022-11-28",
20
21
  private: false,
22
+ baseUrl: "github.com",
23
+ repo: "",
21
24
  dry: false,
22
- strict: true
25
+ strict: true,
26
+ yes: false
23
27
  };
24
28
 
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]));
35
+ }
36
+
25
37
  async function encrypt(value, publicKey) {
26
38
  await sodium.ready;
27
39
  const binkey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
@@ -38,6 +50,27 @@ function parseRepo(repoPath) {
38
50
  return { owner, repo };
39
51
  }
40
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
+ }
41
74
  async function getRepoPublicKey(repoPath, config) {
42
75
  const octokit = createOctokit(config);
43
76
  const { owner, repo } = parseRepo(repoPath);
@@ -123,6 +156,35 @@ async function getRepos(config) {
123
156
  }
124
157
  );
125
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, {
175
+ owner,
176
+ repo,
177
+ headers: {
178
+ "X-GitHub-Api-Version": config.apiVersion
179
+ }
180
+ });
181
+ if (status !== 200) {
182
+ throw new Error(`HTTP ${status}: ${JSON.stringify(data)}`);
183
+ }
184
+ return data.secrets;
185
+ }
186
+ );
187
+ }
126
188
  function createOctokit(config) {
127
189
  return new Octokit({ auth: formatToken(config.token) });
128
190
  }
@@ -146,30 +208,29 @@ async function withSpinner(loadingText, successText, failedText, dryText, defaul
146
208
  return result;
147
209
  }
148
210
 
149
- function normalizeConfig(options) {
211
+ async function normalizeConfig(options) {
150
212
  const cwd = options.cwd || process.cwd();
151
213
  if (typeof options.repos === "string")
152
214
  options.repos = [options.repos];
153
215
  if (typeof options.secrets === "string")
154
216
  options.secrets = [options.secrets];
155
217
  const config = { ...DEFAULT_SYNC_OPTIONS, ...options };
156
- config.token = config.token || process.env.GITHUB_PAT || process.env.GITHUB_TOKEN || "";
218
+ config.token = config.token || process.env.GH_PAT || process.env.GITHUB_TOKEN || await readTokenFromGitHubCli();
157
219
  if (config.repos.length && config.secrets.length) {
158
220
  return config;
159
221
  }
160
- const configContent = parse(readFileSync(join(cwd, config.config), "utf-8"));
222
+ const configContent = parse(await readFile(join(cwd, config.config), "utf-8"));
161
223
  config.repos = Array.isArray(configContent.repos) ? configContent.repos : [];
162
224
  config.secrets = Array.isArray(configContent.envs) ? configContent.envs : [];
163
225
  return config;
164
226
  }
165
227
  async function resolveConfig(options) {
166
- const config = normalizeConfig(options);
228
+ const config = await normalizeConfig(options);
167
229
  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("*"));
230
+ await resolveRepoPatterns(config);
231
+ }
232
+ if (config.secrets.some((s) => s.includes("*"))) {
233
+ await resolveSecretPatterns(config);
173
234
  }
174
235
  if (!config.token)
175
236
  throw new Error(c.red("Please provide a GitHub token"));
@@ -179,21 +240,62 @@ async function resolveConfig(options) {
179
240
  throw new Error(c.red("Please provide secrets to sync"));
180
241
  return config;
181
242
  }
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);
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;
192
294
  }
193
295
 
194
296
  try {
195
297
  const cli = cac("gh-secrets-sync");
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) => {
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) => {
197
299
  console.log(`${c.yellow(name)} ${c.dim(`v${version}`)}`);
198
300
  console.log();
199
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.1.0",
4
+ "version": "0.1.2",
5
5
  "description": "CLI tool to batch sync GitHub Actions secrets across multiple repositories.",
6
6
  "author": "jinghaihan",
7
7
  "license": "MIT",
@@ -35,29 +35,29 @@
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
  },
48
48
  "devDependencies": {
49
- "@antfu/eslint-config": "^5.2.0",
49
+ "@antfu/eslint-config": "^5.2.1",
50
50
  "@types/libsodium-wrappers": "^0.7.14",
51
- "@types/node": "^24.2.0",
52
- "bumpp": "^10.2.2",
53
- "eslint": "^9.32.0",
54
- "lint-staged": "^16.1.4",
55
- "pncat": "^0.4.1",
51
+ "@types/node": "^24.3.0",
52
+ "bumpp": "^10.2.3",
53
+ "eslint": "^9.33.0",
54
+ "lint-staged": "^16.1.5",
55
+ "pncat": "^0.4.2",
56
56
  "simple-git-hooks": "^2.13.1",
57
- "taze": "^19.1.0",
58
- "tsx": "^4.20.3",
57
+ "taze": "^19.3.0",
58
+ "tsx": "^4.20.4",
59
59
  "typescript": "^5.9.2",
60
- "unbuild": "^3.6.0",
60
+ "unbuild": "^3.6.1",
61
61
  "vitest": "^3.2.4"
62
62
  },
63
63
  "simple-git-hooks": {
@@ -69,7 +69,7 @@
69
69
  "scripts": {
70
70
  "start": "tsx ./src/cli.ts",
71
71
  "build": "unbuild",
72
- "typecheck": "tsc",
72
+ "typecheck": "tsc --noEmit",
73
73
  "test": "vitest",
74
74
  "lint": "eslint",
75
75
  "deps": "taze major -I",