gh-secrets-sync 0.0.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.
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jing Haihan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # GitHub Secrets Sync
2
+
3
+ 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
+
5
+ ## Why Use This Tool?
6
+
7
+ Managing GitHub Actions secrets across multiple repositories can be tedious:
8
+
9
+ - **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
+ - **Error-prone**: Easy to forget to update a secret in one of the repositories
12
+
13
+ This tool automates the process, allowing you to sync secrets across multiple repositories with a single command.
14
+
15
+ ## Usage
16
+
17
+ 1. **Create a configuration file** (`secrets.config.yaml`) in your central repository:
18
+
19
+ ```yaml
20
+ repos:
21
+ - owner/vscode-extension1
22
+ - owner/vscode-extension2
23
+
24
+ envs:
25
+ - VSCE_PAT
26
+ - OVSX_PAT
27
+ ```
28
+
29
+ 2. **Set up GitHub CI** in your central repository:
30
+
31
+ ```yaml
32
+ # .github/workflows/sync-secrets.yml
33
+ name: Sync Secrets
34
+
35
+ permissions:
36
+ contents: write
37
+
38
+ on:
39
+ push:
40
+ branches:
41
+ - main
42
+
43
+ jobs:
44
+ sync:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ fetch-depth: 0
50
+
51
+ - name: Set node
52
+ uses: actions/setup-node@v4
53
+ with:
54
+ node-version: lts/*
55
+
56
+ - name: Sync Secrets
57
+ run: npx gh-secrets-sync
58
+ env:
59
+ GITHUB_PAT: ${{secrets.GITHUB_PAT}}
60
+ VSCE_PAT: ${{secrets.VSCE_PAT}}
61
+ OVSX_PAT: ${{secrets.OVSX_PAT}}
62
+ ```
63
+
64
+ 3. **Configure secrets in your central repository**:
65
+ - Go to your central repository Settings > Secrets and variables > Actions
66
+ - Add `GITHUB_PAT` as a repository secret (this is your GitHub Personal Access Token)
67
+ - Add `VSCE_PAT` and `OVSX_PAT` as repository secrets
68
+
69
+ ### How to Get Your GitHub Token
70
+
71
+ 1. Go to [GitHub Personal Access Tokens](https://github.com/settings/personal-access-tokens)
72
+ 2. Click "Generate new token"
73
+ 3. Give it a descriptive name like "Secrets Sync Tool"
74
+ 4. Select the required scopes:
75
+ - Repository permissions > Secrets: Read and write
76
+ - Repository permissions > Actions: Read and write
77
+ - Metadata
78
+ 5. Click "Generate token"
79
+ 6. Add the token as a repository secret named `GITHUB_PAT` in your central repository
80
+
81
+ ## License
82
+
83
+ [MIT](./LICENSE) License © [jinghaihan](https://github.com/jinghaihan)
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+ import '../dist/cli.mjs'
package/dist/cli.d.mts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,171 @@
1
+ import process from 'node:process';
2
+ import c from 'ansis';
3
+ import { cac } from 'cac';
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'pathe';
6
+ import { parse } from 'yaml';
7
+ import { Octokit } from '@octokit/core';
8
+ import Spinner from 'yocto-spinner';
9
+ import sodium from 'libsodium-wrappers';
10
+
11
+ const name = "gh-secrets-sync";
12
+ const version = "0.0.1";
13
+
14
+ const DEFAULT_SYNC_OPTIONS = {
15
+ config: "./secrets.config.yaml",
16
+ repos: [],
17
+ secrets: [],
18
+ envPrefix: "",
19
+ dry: false,
20
+ strict: true
21
+ };
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
+
51
+ async function encrypt(value, publicKey) {
52
+ await sodium.ready;
53
+ const binkey = sodium.from_base64(publicKey.key, sodium.base64_variants.ORIGINAL);
54
+ const binsec = sodium.from_string(value);
55
+ const encrypted = sodium.crypto_box_seal(binsec, binkey);
56
+ return sodium.to_base64(encrypted, sodium.base64_variants.ORIGINAL);
57
+ }
58
+
59
+ function formatToken(token) {
60
+ return `Bearer ${token}`;
61
+ }
62
+
63
+ 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();
68
+ const { owner, repo } = parseRepo(repoPath);
69
+ 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
79
+ }
80
+ }
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);
86
+ }
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;
94
+ }
95
+ async function createOrUpdateRepoSecret(secretName, secretValue, publicKey, repoPath, config) {
96
+ const octokit = new Octokit({
97
+ auth: formatToken(config.token)
98
+ });
99
+ const spinner = Spinner({ text: c.blue(`Create or update ${secretName} for ${repoPath}`) }).start();
100
+ const { owner, repo } = parseRepo(repoPath);
101
+ const url = `PUT /repos/${owner}/${repo}/actions/secrets/${secretName}`;
102
+ let res;
103
+ let operation = "create or update";
104
+ if (!config.dry) {
105
+ const { status, data } = await octokit.request(
106
+ url,
107
+ {
108
+ owner,
109
+ repo,
110
+ secret_name: secretName,
111
+ encrypted_value: await encrypt(secretValue, publicKey),
112
+ key_id: publicKey.key_id,
113
+ headers: {
114
+ "X-GitHub-Api-Version": GITHUB_API_VERSION
115
+ }
116
+ }
117
+ );
118
+ if (status !== 201 && status !== 204) {
119
+ spinner.error(c.red(`Failed to create or update ${secretName} for ${repoPath}`));
120
+ console.error(c.red(JSON.stringify(data, null, 2)));
121
+ process.exit(1);
122
+ }
123
+ operation = status === 201 ? "created" : "updated";
124
+ res = data;
125
+ } else {
126
+ console.log();
127
+ console.log(c.yellow(`Create or update ${secretName} for ${repoPath}`));
128
+ }
129
+ spinner.success(c.green(`${secretName} ${operation} successfully for ${repoPath}`));
130
+ return res;
131
+ }
132
+ function parseRepo(repoPath) {
133
+ const [owner, repo] = repoPath.split("/");
134
+ return { owner, repo };
135
+ }
136
+
137
+ try {
138
+ const cli = cac("gh-secrets-sync");
139
+ 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) => {
140
+ console.log(`${c.yellow(name)} ${c.dim(`v${version}`)}`);
141
+ console.log();
142
+ const config = await resolveConfig(options);
143
+ for (const repo of config.repos) {
144
+ const publicKey = await getRepoPublicKey(repo, config);
145
+ for (const secretKey of config.secrets) {
146
+ const secretValue = getEnv(secretKey, config.strict);
147
+ if (!secretValue)
148
+ continue;
149
+ await createOrUpdateRepoSecret(secretKey, secretValue, publicKey, repo, config);
150
+ }
151
+ }
152
+ console.log(c.green("Done"));
153
+ });
154
+ cli.help();
155
+ cli.version(version);
156
+ cli.parse();
157
+ } catch (error) {
158
+ console.error(error);
159
+ process.exit(1);
160
+ }
161
+ function getEnv(name2, strict) {
162
+ const value = process.env[name2];
163
+ if (!value) {
164
+ const error = c.red(`Secret ${name2} not found in environment variables`);
165
+ if (strict)
166
+ throw new Error(error);
167
+ else
168
+ console.warn(c.yellow(error));
169
+ }
170
+ return value;
171
+ }
@@ -0,0 +1,2 @@
1
+
2
+ export { };
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "gh-secrets-sync",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "CLI tool to batch sync GitHub Actions secrets across multiple repositories.",
6
+ "author": "jinghaihan",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/jinghaihan/gh-secrets-sync#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/jinghaihan/gh-secrets-sync.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jinghaihan/gh-secrets-sync/issues"
15
+ },
16
+ "keywords": [
17
+ "github",
18
+ "actions",
19
+ "secrets",
20
+ "sync"
21
+ ],
22
+ "exports": {
23
+ ".": "./dist/index.mjs",
24
+ "./cli": "./dist/cli.mjs",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "main": "./dist/index.mjs",
28
+ "module": "./dist/index.mjs",
29
+ "types": "./dist/index.d.mts",
30
+ "bin": {
31
+ "gh-secrets-sync": "./bin/gh-secrets-sync.mjs"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "dist"
36
+ ],
37
+ "dependencies": {
38
+ "@octokit/core": "^7.0.3",
39
+ "ansis": "^4.1.0",
40
+ "cac": "^6.7.14",
41
+ "execa": "^9.6.0",
42
+ "libsodium-wrappers": "^0.7.15",
43
+ "pathe": "^2.0.3",
44
+ "unconfig": "^7.3.2",
45
+ "yaml": "^2.8.1",
46
+ "yocto-spinner": "^1.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@antfu/eslint-config": "^5.2.0",
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",
56
+ "simple-git-hooks": "^2.13.1",
57
+ "taze": "^19.1.0",
58
+ "tsx": "^4.20.3",
59
+ "typescript": "^5.9.2",
60
+ "unbuild": "^3.6.0",
61
+ "vitest": "^3.2.4"
62
+ },
63
+ "simple-git-hooks": {
64
+ "pre-commit": "pnpm lint-staged"
65
+ },
66
+ "lint-staged": {
67
+ "*": "eslint --fix"
68
+ },
69
+ "scripts": {
70
+ "start": "tsx ./src/cli.ts",
71
+ "build": "unbuild",
72
+ "typecheck": "tsc",
73
+ "test": "vitest",
74
+ "lint": "eslint",
75
+ "deps": "taze major -I",
76
+ "release": "bumpp && pnpm publish --no-git-checks",
77
+ "catalog": "pncat",
78
+ "bootstrap": "pnpm install",
79
+ "preinstall": "npx only-allow pnpm"
80
+ }
81
+ }