@ebalzan/ci 0.0.0-stage → 1.0.0
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 +21 -0
- package/bin/cli.mjs +101 -0
- package/commitlint.config.mjs +39 -0
- package/config/actions.env +28 -0
- package/config/repo-settings.json +12 -0
- package/config/rulesets/protect-dev.json +10 -0
- package/config/rulesets/protect-main.json +10 -0
- package/config/rulesets/require-pull-request.json +33 -0
- package/config/rulesets/require-release-pr.json +33 -0
- package/config/secrets.env.example +33 -0
- package/config/types.json +14 -0
- package/lib/config.mjs +182 -0
- package/lib/github.mjs +132 -0
- package/lib/log.mjs +34 -0
- package/lib/onboard.mjs +526 -0
- package/lib/sync.mjs +312 -0
- package/package.json +72 -4
- package/renovate/tooling.json +45 -0
- package/templates/.github/actions.env +21 -0
- package/templates/.github/workflows/auto-pr.yml +56 -0
- package/templates/.github/workflows/deploy.yml +88 -0
- package/templates/.github/workflows/pr-checks.yml +110 -0
- package/templates/.github/workflows/release.yml +76 -0
- package/templates/.husky/commit-msg +11 -0
- package/templates/eslint.config.mjs +6 -0
- package/templates/renovate.json +4 -0
- package/templates/secrets.gitignore +4 -0
- package/README.md +0 -3
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)
|