@ebalzan/ci 0.0.0-stage → 1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ebalzan
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/bin/cli.mjs ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // ebalzan-ci — the command-line entry point of @ebalzan/ci.
4
+ //
5
+ // sync is lib/sync.mjs, onboard lib/onboard.mjs. `ebalzan-ci <cmd> --help`
6
+ // prints the command's own help.
7
+
8
+ import { spawnSync } from 'node:child_process'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { createRequire } from 'node:module'
11
+ import { dirname, join } from 'node:path'
12
+ import process from 'node:process'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ import { onboard } from '../lib/onboard.mjs'
16
+ import { sync } from '../lib/sync.mjs'
17
+
18
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url))
19
+ const ROOT = dirname(BIN_DIR)
20
+ const COMMITLINT_CONFIG = join(ROOT, 'commitlint.config.mjs')
21
+
22
+ const HELP = `Usage: ebalzan-ci <command> [options]
23
+
24
+ Commands:
25
+ sync Push variables, secrets, settings and rulesets to this repo
26
+ onboard Add the shared workflows, hook and rules to a repo, then sync it
27
+ commit-msg Validate a commit message (the husky commit-msg hook calls this)
28
+ version Print the package version
29
+ help Show this help
30
+
31
+ Run 'ebalzan-ci <command> --help' for a command's options.`
32
+
33
+ function version() {
34
+ return JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
35
+ }
36
+
37
+ // commit-msg FILE|SUBJECT — husky passes the path of the message file; a
38
+ // non-path argument is checked as the subject itself (handy for scripts). The
39
+ // subject goes through commitlint with commitlint.config.mjs — the rule the
40
+ // work-PR check applies to the PR title and commits. Subjects git writes itself
41
+ // (Merge …, Revert "…", fixup! …) are let through by commitlint's defaults.
42
+ function commitMsg(args) {
43
+ const [arg] = args
44
+ if (!arg || arg === '--help' || arg === '-h') {
45
+ console.log('Usage: ebalzan-ci commit-msg <message-file | subject>')
46
+ return arg ? 0 : 1
47
+ }
48
+ const text = existsSync(arg) ? readFileSync(arg, 'utf8') : arg
49
+ const subject =
50
+ text
51
+ .split(/\r?\n/)
52
+ .find(line => line.trim() && !line.startsWith('#'))
53
+ ?.trim() ?? ''
54
+ const result = spawnSync(process.execPath, [commitlintCli(), '--config', COMMITLINT_CONFIG], {
55
+ input: `${subject}\n`,
56
+ stdio: ['pipe', 'inherit', 'inherit'],
57
+ })
58
+ if (result.error) {
59
+ console.error(`ebalzan-ci: could not run commitlint: ${result.error.message}`)
60
+ return 127
61
+ }
62
+ if (result.status === 0) return 0
63
+ console.log(`Invalid commit message: "${subject}"`)
64
+ console.log('Use Conventional Commits, for example:')
65
+ console.log(' feat(viewer): add section filters')
66
+ console.log(' fix: prevent null model crash')
67
+ return 1
68
+ }
69
+
70
+ function commitlintCli() {
71
+ const require = createRequire(import.meta.url)
72
+ return join(dirname(require.resolve('@commitlint/cli/package.json')), 'cli.js')
73
+ }
74
+
75
+ async function main(argv) {
76
+ const [command, ...args] = argv
77
+ switch (command) {
78
+ case 'sync':
79
+ return sync(args)
80
+ case 'onboard':
81
+ return onboard(args)
82
+ case 'commit-msg':
83
+ return commitMsg(args)
84
+ case 'version':
85
+ case '--version':
86
+ case '-v':
87
+ console.log(version())
88
+ return 0
89
+ case undefined:
90
+ case 'help':
91
+ case '--help':
92
+ case '-h':
93
+ console.log(HELP)
94
+ return command === undefined ? 1 : 0
95
+ default:
96
+ console.error(`ebalzan-ci: unknown command '${command}'.\n\n${HELP}`)
97
+ return 1
98
+ }
99
+ }
100
+
101
+ process.exit(await main(process.argv.slice(2)))
@@ -0,0 +1,39 @@
1
+ // The one Conventional Commits rule (commitlint), used by the husky commit-msg
2
+ // hook (`ebalzan-ci commit-msg`) and by the PR check on the PR title and
3
+ // every commit of the PR (actions/pr-checks). The types come from
4
+ // config/types.json.
5
+ //
6
+ // Deliberately no @commitlint/config-conventional: only the header shape is
7
+ // enforced — `<type>(<scope>)!: <subject>`, scope and `!` optional — as the
8
+ // release PR's version and title read it (config/cliff.toml, scripts/release-title.mjs). Git's own subjects
9
+ // (Merge …, Revert "…", fixup! …) are ignored by commitlint's defaults.
10
+
11
+ import { readFileSync } from 'node:fs'
12
+
13
+ import { stripDocKeys } from './lib/config.mjs'
14
+
15
+ // { <type>: { release, color } }, in config/types.json's order (precedence).
16
+ export const TYPES = stripDocKeys(
17
+ JSON.parse(readFileSync(new URL('./config/types.json', import.meta.url), 'utf8')),
18
+ )
19
+ export const types = Object.keys(TYPES)
20
+
21
+ // THE Conventional Commit header: commitlint parses with it, and the PR rules
22
+ // (actions/pr-checks) and the release title (scripts/release-title.mjs) read the
23
+ // type and the `!` with it. Not anchored at the end: it also runs on whole
24
+ // multi-line commit messages.
25
+ export const HEADER_PATTERN = /^(?<type>\w*)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?: (?<subject>.+)/
26
+
27
+ export default {
28
+ parserPreset: {
29
+ parserOpts: {
30
+ headerPattern: HEADER_PATTERN,
31
+ headerCorrespondence: ['type', 'scope', 'breaking', 'subject'],
32
+ },
33
+ },
34
+ rules: {
35
+ 'type-empty': [2, 'never'],
36
+ 'type-enum': [2, 'always', types],
37
+ 'subject-empty': [2, 'never'],
38
+ },
39
+ }
@@ -0,0 +1,28 @@
1
+ # SHARED GitHub Actions VARIABLES — the house rules every repo running the
2
+ # CI actions gets. Non-sensitive; committed; published with the package.
3
+ #
4
+ # A repo adds or overrides any key in its own .github/actions.env; the repo's
5
+ # value wins. Keep here only what is genuinely the same everywhere — anything
6
+ # tied to one project (AUTO_PROJECT_NUMBER, AUTO_MILESTONE_TITLE, deploy
7
+ # settings) belongs to that repo.
8
+ #
9
+ # Read at run time, never copied onto repos: the actions load this file from the
10
+ # copy of ebalzan/ci the runner downloads for `uses: …@v1`
11
+ # (scripts/shared-vars.mjs) and fill every key the repo leaves unset. A change
12
+ # here reaches the repos when it is released and the major tag moves to it — no
13
+ # bootstrap run.
14
+ #
15
+ # A key with a default in the code is not repeated here: the board owner is the
16
+ # repo owner, a PR over 500 changed lines gets a warning
17
+ # (AUTO_MAX_CHANGED_LINES), the branches are dev and main
18
+ # (scripts/shared-vars.mjs), and there is no board or milestone unless the repo
19
+ # sets one. The workflows pass the repo's variables whole (REPO_VARS_JSON), so
20
+ # setting a key here or in a repo is all it takes — no workflow edit.
21
+
22
+ # Claude model that writes the PR descriptions (actions/auto-pr/describe.sh,
23
+ # through Claude Code). Defined only here: without it there is no AI
24
+ # description. AUTO_DESCRIBE_EFFORT: low | medium | high | xhigh | max —
25
+ # ignored for Haiku, which does not accept it; unset leaves Claude Code's
26
+ # default.
27
+ AUTO_DESCRIBE_MODEL=claude-sonnet-5-5
28
+ AUTO_DESCRIBE_EFFORT=low
@@ -0,0 +1,12 @@
1
+ {
2
+ "$comment": "Repository settings every managed repo gets (PATCH /repos/{owner}/{repo}). A repo overrides any key in its own .github/repo-settings.json. Keys starting with $ are documentation and are never sent. Work PRs are squash-merged into dev (the PR title becomes the commit); the dev → main release PR is merged with a merge commit (GitHub's default title, which commitlint and git-cliff skip). Rebase merges are off: they rewrite commits.",
3
+ "allow_squash_merge": true,
4
+ "allow_merge_commit": true,
5
+ "allow_rebase_merge": false,
6
+ "squash_merge_commit_title": "PR_TITLE",
7
+ "squash_merge_commit_message": "COMMIT_MESSAGES",
8
+ "merge_commit_title": "MERGE_MESSAGE",
9
+ "merge_commit_message": "PR_TITLE",
10
+ "delete_branch_on_merge": true,
11
+ "allow_update_branch": true
12
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "dev is the integration branch: it cannot be deleted or force-pushed. The Release workflow only fast-forwards it (or merges main into it), so nothing in the pipeline needs a bypass.",
3
+ "name": "ci: protect dev",
4
+ "target": "branch",
5
+ "enforcement": "active",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/dev"], "exclude": [] }
8
+ },
9
+ "rules": [{ "type": "deletion" }, { "type": "non_fast_forward" }]
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$comment": "main is the release branch: it cannot be deleted or force-pushed. The Release workflow only fast-forwards it with the `chore(release): X.Y.Z` commit, so nothing in the pipeline needs a bypass.",
3
+ "name": "ci: protect main",
4
+ "target": "branch",
5
+ "enforcement": "active",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/main"], "exclude": [] }
8
+ },
9
+ "rules": [{ "type": "deletion" }, { "type": "non_fast_forward" }]
10
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$comment": "Changes reach dev only through a squash-merged work PR whose 'pr-check / Work PR' check passed — its title becomes the one commit on dev. main is require-release-pr.json's. DISABLED by default: the Release workflow brings dev up to main with GITHUB_TOKEN (a fast-forward, or a merge of main when work landed meanwhile), so the GitHub Actions app (Integration 15368) must be able to bypass it. Enable it per repo with .github/rulesets/require-pull-request.json containing {\"enforcement\": \"active\"}, run one release, and confirm it went through.",
3
+ "name": "ci: require pull request",
4
+ "target": "branch",
5
+ "enforcement": "disabled",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/dev"], "exclude": [] }
8
+ },
9
+ "bypass_actors": [
10
+ { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" },
11
+ { "actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always" }
12
+ ],
13
+ "rules": [
14
+ {
15
+ "type": "pull_request",
16
+ "parameters": {
17
+ "required_approving_review_count": 0,
18
+ "dismiss_stale_reviews_on_push": false,
19
+ "require_code_owner_review": false,
20
+ "require_last_push_approval": false,
21
+ "required_review_thread_resolution": false,
22
+ "allowed_merge_methods": ["squash"]
23
+ }
24
+ },
25
+ {
26
+ "type": "required_status_checks",
27
+ "parameters": {
28
+ "strict_required_status_checks_policy": false,
29
+ "required_status_checks": [{ "context": "pr-check / Work PR" }]
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "$comment": "Changes reach main only through the dev → main release PR, merged with a merge commit after 'pr-check / Release PR' passed: dev's commits reach main unchanged, so dev catches up without a force-push. The Release workflow fails on a squash or rebase merge; this ruleset hides those buttons. DISABLED by default: the Release workflow pushes the `chore(release): X.Y.Z` commit to main with GITHUB_TOKEN, so the GitHub Actions app (Integration 15368) must be able to bypass it. Enable it per repo with .github/rulesets/require-release-pr.json containing {\"enforcement\": \"active\"}, run one release, and confirm it went through.",
3
+ "name": "ci: require release pull request",
4
+ "target": "branch",
5
+ "enforcement": "disabled",
6
+ "conditions": {
7
+ "ref_name": { "include": ["refs/heads/main"], "exclude": [] }
8
+ },
9
+ "bypass_actors": [
10
+ { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" },
11
+ { "actor_id": 15368, "actor_type": "Integration", "bypass_mode": "always" }
12
+ ],
13
+ "rules": [
14
+ {
15
+ "type": "pull_request",
16
+ "parameters": {
17
+ "required_approving_review_count": 0,
18
+ "dismiss_stale_reviews_on_push": false,
19
+ "require_code_owner_review": false,
20
+ "require_last_push_approval": false,
21
+ "required_review_thread_resolution": false,
22
+ "allowed_merge_methods": ["merge"]
23
+ }
24
+ },
25
+ {
26
+ "type": "required_status_checks",
27
+ "parameters": {
28
+ "strict_required_status_checks_policy": false,
29
+ "required_status_checks": [{ "context": "pr-check / Release PR" }]
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,33 @@
1
+ # SHARED repository SECRETS — every repo you run `ebalzan-ci sync` in gets these.
2
+ #
3
+ # 1. Copy: cp config/secrets.env.example config/secrets.env
4
+ # 2. Fill in the real values (the copy is gitignored).
5
+ # 3. Sync: run `npx ebalzan-ci sync` in each repo (here: npm run gh:sync)
6
+ #
7
+ # A repo adds or overrides any key in its own .github/secrets.env (the repo's
8
+ # value wins). Shared secrets for a GitHub ENVIRONMENT would go in
9
+ # config/secrets.<environment>.env, overridable by the repo's
10
+ # .github/secrets.<environment>.env — none exist today: every deploy secret is
11
+ # specific to one repo's deploy target.
12
+ #
13
+ # GITHUB_TOKEN is provided automatically by Actions — never set it here.
14
+
15
+ # Anthropic API key used by auto-pr.yml (Claude Code) to describe work PRs and
16
+ # summarize the release PR.
17
+ # https://console.anthropic.com/settings/keys
18
+ ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxx
19
+
20
+ # CLASSIC PAT used by auto-pr.yml to write to your user Projects (v2) boards,
21
+ # and so the PRs it opens and edits (work PRs, the release PR) trigger PR
22
+ # Checks; pr-check.yml reads the board with it to check the PR is on it.
23
+ # Scopes: `project` (read/write on Projects v2) and `repo` (create issues/PRs on
24
+ # private repos).
25
+ #
26
+ # It must be classic, not fine-grained: the boards are owned by your personal
27
+ # account, and a fine-grained PAT only offers a Projects permission under
28
+ # ORGANIZATION permissions — there is no account-level equivalent, so it cannot
29
+ # reach a user-owned board at all. The cost is that `repo` cannot be narrowed to
30
+ # selected repositories the way a fine-grained token can, so keep the expiry
31
+ # short. One token serves every repo.
32
+ # https://github.com/settings/tokens
33
+ GH_PROJECT_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxx
@@ -0,0 +1,14 @@
1
+ {
2
+ "$comment": "THE list of work types — the only place a type is defined. A type is at once a branch prefix (<type>/<description>), a Conventional Commit and PR-title type, and a PR label. release: the bump a commit of that type triggers (minor | patch; `!` or a BREAKING CHANGE footer makes any type major). color: its label's color (description \"Changes for '<type>' branches\"). Key order is precedence: the release PR's title takes the first type among the released commits (scripts/release-title.mjs), so keep the minor types first. Read by commitlint.config.mjs (hook + PR check), scripts/release-title.mjs, actions/pr-checks and actions/auto-pr. config/cliff.toml applies the same bumps (feat minor, the rest patch). This repo uses `refact`, never `refactor`.",
3
+ "feat": { "release": "minor", "color": "1D76DB" },
4
+ "fix": { "release": "patch", "color": "D73A4A" },
5
+ "refact": { "release": "patch", "color": "FBCA04" },
6
+ "perf": { "release": "patch", "color": "F9D0C4" },
7
+ "revert": { "release": "patch", "color": "E99695" },
8
+ "build": { "release": "patch", "color": "0052CC" },
9
+ "ci": { "release": "patch", "color": "0052CC" },
10
+ "docs": { "release": "patch", "color": "5319E7" },
11
+ "test": { "release": "patch", "color": "0E8A16" },
12
+ "style": { "release": "patch", "color": "C2E0C6" },
13
+ "chore": { "release": "patch", "color": "BFD4F2" }
14
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // config.mjs — what a repo should look like on GitHub, read from files only.
2
+ //
3
+ // Every section has two layers: the SHARED one (this package's config/) and
4
+ // the repo's OWN one (<repo>/.github/). For any key the repo's value wins.
5
+ // Nothing here talks to GitHub; lib/sync.mjs compares the result with it.
6
+
7
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { dirname, isAbsolute, join } from 'node:path'
10
+
11
+ // A value that was copied from an .example and never filled in.
12
+ const PLACEHOLDER = /xxxx/i
13
+
14
+ // KEY=VALUE lines; blank lines and `#` comments skipped, one pair of
15
+ // surrounding quotes stripped.
16
+ export function parseEnvFile(text) {
17
+ const vars = {}
18
+ for (const line of text.split(/\r?\n/)) {
19
+ if (/^\s*(#|$)/.test(line)) continue
20
+ const i = line.indexOf('=')
21
+ if (i < 1) continue
22
+ let value = line.slice(i + 1)
23
+ if (value.length >= 2 && /^(["']).*\1$/.test(value)) value = value.slice(1, -1)
24
+ vars[line.slice(0, i).trim()] = value
25
+ }
26
+ return vars
27
+ }
28
+
29
+ function isPlainObject(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
31
+ }
32
+
33
+ // Objects merge key by key; arrays and scalars are replaced whole.
34
+ export function deepMerge(base, override) {
35
+ if (!isPlainObject(base) || !isPlainObject(override)) return override
36
+ const merged = { ...base }
37
+ for (const [key, value] of Object.entries(override)) {
38
+ merged[key] = key in base ? deepMerge(base[key], value) : value
39
+ }
40
+ return merged
41
+ }
42
+
43
+ // Top-level keys starting with `$` (e.g. "$comment") are documentation.
44
+ export function stripDocKeys(value) {
45
+ if (!isPlainObject(value)) return value
46
+ return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith('$')))
47
+ }
48
+
49
+ // [key, current, desired] for every top-level key of DESIRED that differs in CURRENT.
50
+ export function diffTopLevel(desired, current) {
51
+ const rows = []
52
+ for (const [key, value] of Object.entries(desired)) {
53
+ const now = current?.[key]
54
+ if (JSON.stringify(now) !== JSON.stringify(value)) {
55
+ rows.push([key, now === undefined ? '(unset)' : JSON.stringify(now), JSON.stringify(value)])
56
+ }
57
+ }
58
+ return rows
59
+ }
60
+
61
+ function readEnv(file) {
62
+ return existsSync(file) ? parseEnvFile(readFileSync(file, 'utf8')) : {}
63
+ }
64
+
65
+ function readJson(file) {
66
+ try {
67
+ return JSON.parse(readFileSync(file, 'utf8'))
68
+ } catch (error) {
69
+ throw new Error(`${file}: ${error.message}`, { cause: error })
70
+ }
71
+ }
72
+
73
+ // mergeJson FILE... — missing files skipped, later wins; null when none exist.
74
+ function mergeJson(...files) {
75
+ const docs = files.filter(file => existsSync(file)).map(readJson)
76
+ return docs.length ? stripDocKeys(docs.reduce(deepMerge)) : null
77
+ }
78
+
79
+ // mergeEnv SHARED REPO — one entry per key: { name, key, value, file, layer }.
80
+ // `layer` says where the value comes from, and flags a repo line that only
81
+ // repeats the shared value (it can be deleted). For secrets, FOO_FILE and FOO
82
+ // are the same key, so either layer can override the other.
83
+ export function mergeEnv(sharedFile, repoFile, { secrets = false } = {}) {
84
+ const merged = new Map()
85
+ for (const [file, layer] of [
86
+ [sharedFile, 'shared'],
87
+ [repoFile, 'repo'],
88
+ ]) {
89
+ for (const [key, value] of Object.entries(readEnv(file))) {
90
+ const name = secrets ? key.replace(/_FILE$/, '') : key
91
+ const shared = merged.get(name)
92
+ let label = layer
93
+ if (shared?.layer === 'shared' && layer === 'repo') {
94
+ label =
95
+ shared.key === key && shared.value === value
96
+ ? 'repo (same as shared: redundant)'
97
+ : 'repo (overrides shared)'
98
+ }
99
+ merged.set(name, { name, key, value, file, layer: label })
100
+ }
101
+ }
102
+ return [...merged.values()]
103
+ }
104
+
105
+ // The value to push for a secret entry, refusing empty and placeholder values:
106
+ // a secret cannot be read back to notice a bad write. KEY_FILE=path yields that
107
+ // file's contents (multiline values cannot live in a dotenv file); a relative
108
+ // path is relative to the env file.
109
+ export function secretValue({ key, value, file }) {
110
+ if (key.endsWith('_FILE')) {
111
+ let path = value.replace(/^~(?=\/|$)/, homedir())
112
+ if (!isAbsolute(path)) path = join(dirname(file), path)
113
+ if (!existsSync(path))
114
+ throw new Error(`${key} in ${file} points to '${value}', which does not exist.`)
115
+ return readFileSync(path, 'utf8')
116
+ }
117
+ if (!value)
118
+ throw new Error(`${key} has an empty value in ${file}. Fill it in, or comment the line out.`)
119
+ if (PLACEHOLDER.test(value))
120
+ throw new Error(`${key} in ${file} is still a placeholder. Fill it in.`)
121
+ return value
122
+ }
123
+
124
+ // Where the shared secrets files live: they are gitignored, so never inside an
125
+ // installed npm package.
126
+ export function sharedSecretsDir(ciRoot, env = process.env) {
127
+ if (env.CI_SECRETS_DIR) return env.CI_SECRETS_DIR
128
+ if (existsSync(join(ciRoot, '.git'))) return join(ciRoot, 'config')
129
+ return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'ebalzan-ci')
130
+ }
131
+
132
+ function filesIn(dir, pattern) {
133
+ return existsSync(dir) ? readdirSync(dir).filter(name => pattern.test(name)) : []
134
+ }
135
+
136
+ // desiredState — the merged config for the repo checked out at REPO_DIR:
137
+ // variables [entry] shared ones are read by the actions at run time
138
+ // secrets { repository: [entry], environments: { name: [entry] } }
139
+ // settings object | null
140
+ // rulesets [{ file, body }]
141
+ export function desiredState({ sharedDir, secretsDir, repoDir }) {
142
+ const own = join(repoDir, '.github')
143
+
144
+ const environments = {}
145
+ const envNames = [
146
+ ...filesIn(secretsDir, /^secrets\..+\.env$/),
147
+ ...filesIn(own, /^secrets\..+\.env$/),
148
+ ].map(name => name.slice('secrets.'.length, -'.env'.length))
149
+ for (const env of [...new Set(envNames)].sort()) {
150
+ environments[env] = mergeEnv(
151
+ join(secretsDir, `secrets.${env}.env`),
152
+ join(own, `secrets.${env}.env`),
153
+ {
154
+ secrets: true,
155
+ },
156
+ )
157
+ }
158
+
159
+ const rulesetFiles = [
160
+ ...new Set([
161
+ ...filesIn(join(sharedDir, 'rulesets'), /\.json$/),
162
+ ...filesIn(join(own, 'rulesets'), /\.json$/),
163
+ ]),
164
+ ].sort()
165
+ const rulesets = rulesetFiles.map(file => {
166
+ const body = mergeJson(join(sharedDir, 'rulesets', file), join(own, 'rulesets', file))
167
+ if (!body.name) throw new Error(`Ruleset ${file} has no "name".`)
168
+ return { file, body }
169
+ })
170
+
171
+ return {
172
+ variables: mergeEnv(join(sharedDir, 'actions.env'), join(own, 'actions.env')),
173
+ secrets: {
174
+ repository: mergeEnv(join(secretsDir, 'secrets.env'), join(own, 'secrets.env'), {
175
+ secrets: true,
176
+ }),
177
+ environments,
178
+ },
179
+ settings: mergeJson(join(sharedDir, 'repo-settings.json'), join(own, 'repo-settings.json')),
180
+ rulesets,
181
+ }
182
+ }
package/lib/github.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // github.mjs — the GitHub side, through the gh CLI (it owns authentication).
2
+ // Every call throws with gh's own message when it fails.
3
+
4
+ import { spawnSync } from 'node:child_process'
5
+ import { existsSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ export function gh(args, { input } = {}) {
9
+ const result = spawnSync('gh', args, { input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
10
+ if (result.error) {
11
+ throw new Error(
12
+ result.error.code === 'ENOENT'
13
+ ? 'GitHub CLI (gh) is not installed: https://cli.github.com'
14
+ : result.error.message,
15
+ )
16
+ }
17
+ if (result.status !== 0) throw new Error(result.stderr.trim() || `gh ${args.join(' ')} failed`)
18
+ return result.stdout
19
+ }
20
+
21
+ const ghJson = args => JSON.parse(gh(args) || 'null')
22
+
23
+ export function isAuthenticated() {
24
+ try {
25
+ gh(['auth', 'status'])
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ export function requireAuth() {
33
+ gh(['--version'])
34
+ if (!isAuthenticated()) throw new Error('gh is not authenticated. Run: gh auth login')
35
+ }
36
+
37
+ // ---- repos -------------------------------------------------------------------
38
+ export const isCheckout = dir => existsSync(join(dir, '.git'))
39
+
40
+ // OWNER/REPO of the checkout at DIR, or ''. Starts from the git remote, then
41
+ // asks GitHub for the canonical name, so a transferred repo resolves to where
42
+ // it lives now.
43
+ export function detectRepo(dir) {
44
+ const remote = spawnSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], { encoding: 'utf8' })
45
+ if (remote.status !== 0) return ''
46
+ const slug = remote.stdout
47
+ .trim()
48
+ .replace(/^(git@|ssh:\/\/git@|https:\/\/)[^/:]+[:/]/, '')
49
+ .replace(/\.git$/, '')
50
+ try {
51
+ return (
52
+ gh(['repo', 'view', slug, '--json', 'nameWithOwner', '-q', '.nameWithOwner']).trim() || slug
53
+ )
54
+ } catch {
55
+ return slug
56
+ }
57
+ }
58
+
59
+ // Creates branch NAME on GitHub at the head of the default branch:
60
+ // { from: the default branch, sha }.
61
+ export function createBranchFromDefault(repo, name) {
62
+ const from = gh([
63
+ 'repo',
64
+ 'view',
65
+ repo,
66
+ '--json',
67
+ 'defaultBranchRef',
68
+ '-q',
69
+ '.defaultBranchRef.name',
70
+ ]).trim()
71
+ const sha = gh(['api', `repos/${repo}/commits/${from}`, '-q', '.sha']).trim()
72
+ gh([
73
+ 'api',
74
+ '-X',
75
+ 'POST',
76
+ `repos/${repo}/git/refs`,
77
+ '-f',
78
+ `ref=refs/heads/${name}`,
79
+ '-f',
80
+ `sha=${sha}`,
81
+ ])
82
+ return { from, sha }
83
+ }
84
+
85
+ // ---- variables & secrets -----------------------------------------------------
86
+ // KIND is "variable" or "secret"; ENV, when given, is an environment's scope.
87
+ const scope = (repo, env) => (env ? ['--repo', repo, '--env', env] : ['--repo', repo])
88
+
89
+ export const listNames = (kind, repo, env) =>
90
+ ghJson([kind, 'list', ...scope(repo, env), '--json', 'name']).map(entry => entry.name)
91
+
92
+ export const setVariable = (repo, name, value) =>
93
+ gh(['variable', 'set', name, '--body', value, '--repo', repo])
94
+
95
+ export const setSecret = (repo, name, value, env) =>
96
+ gh(['secret', 'set', name, ...scope(repo, env)], { input: value })
97
+
98
+ export const deleteEntry = (kind, repo, name, env) =>
99
+ gh([kind, 'delete', name, ...scope(repo, env)])
100
+
101
+ // Idempotent: PUT creates the environment or leaves it as it is.
102
+ export const createEnvironment = (repo, env) =>
103
+ gh(['api', '-X', 'PUT', `repos/${repo}/environments/${env}`])
104
+
105
+ // ---- settings & rulesets -----------------------------------------------------
106
+ export const getRepo = repo => ghJson(['api', `repos/${repo}`])
107
+
108
+ export const patchRepo = (repo, settings) =>
109
+ gh(['api', '-X', 'PATCH', `repos/${repo}`, '--input', '-'], { input: JSON.stringify(settings) })
110
+
111
+ // The repo's own rulesets (not those inherited from an organization).
112
+ export const listRulesets = repo =>
113
+ ghJson(['api', `repos/${repo}/rulesets?includes_parents=false`]).filter(
114
+ ruleset => ruleset.source_type === 'Repository',
115
+ )
116
+
117
+ export const createRuleset = (repo, body) =>
118
+ gh(['api', '-X', 'POST', `repos/${repo}/rulesets`, '--input', '-'], {
119
+ input: JSON.stringify(body),
120
+ })
121
+
122
+ export const updateRuleset = (repo, id, body) =>
123
+ gh(['api', '-X', 'PUT', `repos/${repo}/rulesets/${id}`, '--input', '-'], {
124
+ input: JSON.stringify(body),
125
+ })
126
+
127
+ export const deleteRuleset = (repo, id) =>
128
+ gh(['api', '-X', 'DELETE', `repos/${repo}/rulesets/${id}`])
129
+
130
+ // The API's answer when rulesets need a paid plan (a private repo on GitHub Free).
131
+ export const rulesetsUnavailable = error =>
132
+ /upgrade to github pro|make this repository public/i.test(error.message)