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.
- package/README.md +25 -9
- package/dist/cli.mjs +125 -23
- 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
|
-
|
|
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
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
**Configure secrets in your central repository**:
|
|
73
89
|
- Go to your central repository Settings > Secrets and variables > Actions
|
|
74
|
-
- Add `
|
|
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 `
|
|
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 {
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"@antfu/eslint-config": "^5.2.1",
|
|
50
50
|
"@types/libsodium-wrappers": "^0.7.14",
|
|
51
|
-
"@types/node": "^24.
|
|
52
|
-
"bumpp": "^10.2.
|
|
53
|
-
"eslint": "^9.
|
|
54
|
-
"lint-staged": "^16.1.
|
|
55
|
-
"pncat": "^0.4.
|
|
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.
|
|
58
|
-
"tsx": "^4.20.
|
|
57
|
+
"taze": "^19.3.0",
|
|
58
|
+
"tsx": "^4.20.4",
|
|
59
59
|
"typescript": "^5.9.2",
|
|
60
|
-
"unbuild": "^3.6.
|
|
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",
|