repokeeper 0.4.7 → 0.6.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/.github/workflows/commitlint.yml +49 -0
- package/.github/workflows/release-please.yml +59 -0
- package/.github/workflows/repokeeper-check.yml +30 -0
- package/.github/workflows/stack-dart.yml +72 -0
- package/.github/workflows/stack-dotnet.yml +68 -0
- package/.github/workflows/stack-go.yml +59 -0
- package/.github/workflows/stack-java.yml +70 -0
- package/.github/workflows/stack-node.yml +85 -0
- package/.github/workflows/stack-php.yml +66 -0
- package/.github/workflows/stack-python.yml +70 -0
- package/.github/workflows/stack-ruby.yml +65 -0
- package/.github/workflows/stack-rust.yml +60 -0
- package/.github/workflows/stack-script.yml +87 -0
- package/README.md +60 -63
- package/dist/cli.js +56 -8
- package/dist/commands/context.js +26 -3
- package/dist/commands/eject.js +74 -0
- package/dist/commands/github.js +2 -0
- package/dist/commands/gitlab.js +46 -0
- package/dist/commands/init.js +110 -13
- package/dist/commands/presets.js +114 -0
- package/dist/commands/report.js +23 -3
- package/dist/commands/update.js +3 -2
- package/dist/config/load.js +8 -1
- package/dist/config/schema.js +45 -1
- package/dist/config/types.js +3 -3
- package/dist/duplicates.js +2 -2
- package/dist/errors.js +1 -1
- package/dist/git.js +33 -4
- package/dist/gitlab/api.js +50 -0
- package/dist/gitlab/settings.js +181 -0
- package/dist/model.js +8 -0
- package/dist/modules/commits.js +3 -2
- package/dist/modules/deps.js +1 -1
- package/dist/modules/health.js +5 -8
- package/dist/modules/hooks.js +7 -5
- package/dist/modules/release.js +18 -5
- package/dist/platforms/github.js +77 -13
- package/dist/platforms/gitlab.js +233 -0
- package/dist/platforms/index.js +5 -0
- package/dist/stacks/index.js +56 -0
- package/dist/stacks/support.js +19 -0
- package/dist/templates.js +13 -1
- package/dist/version.js +9 -0
- package/package.json +6 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
name: stack-ruby
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
ruby-versions:
|
|
7
|
+
description: JSON list of Ruby versions
|
|
8
|
+
type: string
|
|
9
|
+
default: '["3.3","3.4"]'
|
|
10
|
+
os:
|
|
11
|
+
description: JSON list of runner labels
|
|
12
|
+
type: string
|
|
13
|
+
default: '["ubuntu-latest"]'
|
|
14
|
+
install-command:
|
|
15
|
+
description: Command that installs dependencies
|
|
16
|
+
type: string
|
|
17
|
+
default: bundle install
|
|
18
|
+
commands:
|
|
19
|
+
description: JSON list of shell commands run in order
|
|
20
|
+
type: string
|
|
21
|
+
default: "[]"
|
|
22
|
+
working-directory:
|
|
23
|
+
description: Directory holding the Gemfile
|
|
24
|
+
type: string
|
|
25
|
+
default: .
|
|
26
|
+
|
|
27
|
+
permissions:
|
|
28
|
+
contents: read
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
ruby:
|
|
32
|
+
name: ruby ${{ matrix.ruby }} (${{ matrix.os }})
|
|
33
|
+
strategy:
|
|
34
|
+
fail-fast: false
|
|
35
|
+
matrix:
|
|
36
|
+
os: ${{ fromJSON(inputs.os) }}
|
|
37
|
+
ruby: ${{ fromJSON(inputs.ruby-versions) }}
|
|
38
|
+
runs-on: ${{ matrix.os }}
|
|
39
|
+
permissions:
|
|
40
|
+
contents: read
|
|
41
|
+
defaults:
|
|
42
|
+
run:
|
|
43
|
+
shell: bash
|
|
44
|
+
working-directory: ${{ inputs.working-directory }}
|
|
45
|
+
steps:
|
|
46
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
47
|
+
with:
|
|
48
|
+
persist-credentials: false
|
|
49
|
+
- uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
|
50
|
+
with:
|
|
51
|
+
ruby-version: ${{ matrix.ruby }}
|
|
52
|
+
working-directory: ${{ inputs.working-directory }}
|
|
53
|
+
- name: Install dependencies
|
|
54
|
+
env:
|
|
55
|
+
INSTALL: ${{ inputs.install-command }}
|
|
56
|
+
run: bash -c "$INSTALL"
|
|
57
|
+
- name: Run checks
|
|
58
|
+
env:
|
|
59
|
+
COMMANDS: ${{ inputs.commands }}
|
|
60
|
+
run: |
|
|
61
|
+
node -e 'for (const c of JSON.parse(process.env.COMMANDS)) console.log(c)' | while IFS= read -r command; do
|
|
62
|
+
echo "::group::$command"
|
|
63
|
+
bash -c "$command" </dev/null
|
|
64
|
+
echo "::endgroup::"
|
|
65
|
+
done
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
name: stack-rust
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
rust-versions:
|
|
7
|
+
description: JSON list of rustup toolchains ("stable", "1.85.0")
|
|
8
|
+
type: string
|
|
9
|
+
default: '["stable"]'
|
|
10
|
+
os:
|
|
11
|
+
description: JSON list of runner labels
|
|
12
|
+
type: string
|
|
13
|
+
default: '["ubuntu-latest"]'
|
|
14
|
+
commands:
|
|
15
|
+
description: JSON list of shell commands run in order
|
|
16
|
+
type: string
|
|
17
|
+
default: '["cargo test --all-targets"]'
|
|
18
|
+
working-directory:
|
|
19
|
+
description: Directory holding Cargo.toml
|
|
20
|
+
type: string
|
|
21
|
+
default: .
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
rust:
|
|
28
|
+
name: rust ${{ matrix.rust }} (${{ matrix.os }})
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
os: ${{ fromJSON(inputs.os) }}
|
|
33
|
+
rust: ${{ fromJSON(inputs.rust-versions) }}
|
|
34
|
+
runs-on: ${{ matrix.os }}
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
defaults:
|
|
38
|
+
run:
|
|
39
|
+
shell: bash
|
|
40
|
+
working-directory: ${{ inputs.working-directory }}
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
43
|
+
with:
|
|
44
|
+
persist-credentials: false
|
|
45
|
+
- name: Install the toolchain
|
|
46
|
+
env:
|
|
47
|
+
TOOLCHAIN: ${{ matrix.rust }}
|
|
48
|
+
# rustup ships on the hosted runners
|
|
49
|
+
run: |
|
|
50
|
+
rustup toolchain install "$TOOLCHAIN" --profile minimal --component rustfmt,clippy
|
|
51
|
+
rustup default "$TOOLCHAIN"
|
|
52
|
+
- name: Run checks
|
|
53
|
+
env:
|
|
54
|
+
COMMANDS: ${{ inputs.commands }}
|
|
55
|
+
run: |
|
|
56
|
+
node -e 'for (const c of JSON.parse(process.env.COMMANDS)) console.log(c)' | while IFS= read -r command; do
|
|
57
|
+
echo "::group::$command"
|
|
58
|
+
bash -c "$command" </dev/null
|
|
59
|
+
echo "::endgroup::"
|
|
60
|
+
done
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
name: stack-script
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_call:
|
|
5
|
+
inputs:
|
|
6
|
+
shell-scripts:
|
|
7
|
+
description: '"true" to run ShellCheck and shfmt on *.sh'
|
|
8
|
+
type: string
|
|
9
|
+
default: "true"
|
|
10
|
+
powershell-scripts:
|
|
11
|
+
description: '"true" to run PSScriptAnalyzer on *.ps1'
|
|
12
|
+
type: string
|
|
13
|
+
default: "false"
|
|
14
|
+
test-command:
|
|
15
|
+
description: Test command run on Linux and Windows; empty for none
|
|
16
|
+
type: string
|
|
17
|
+
default: ""
|
|
18
|
+
working-directory:
|
|
19
|
+
description: Directory the checks start from
|
|
20
|
+
type: string
|
|
21
|
+
default: .
|
|
22
|
+
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
shell:
|
|
28
|
+
if: inputs.shell-scripts == 'true'
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
permissions:
|
|
31
|
+
contents: read
|
|
32
|
+
defaults:
|
|
33
|
+
run:
|
|
34
|
+
working-directory: ${{ inputs.working-directory }}
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
37
|
+
with:
|
|
38
|
+
persist-credentials: false
|
|
39
|
+
- name: ShellCheck
|
|
40
|
+
run: git ls-files -z '*.sh' | xargs -0 --no-run-if-empty shellcheck
|
|
41
|
+
- name: shfmt
|
|
42
|
+
run: |
|
|
43
|
+
curl -fsSL -o "$RUNNER_TEMP/shfmt" https://github.com/mvdan/sh/releases/download/v3.14.1/shfmt_v3.14.1_linux_amd64
|
|
44
|
+
chmod +x "$RUNNER_TEMP/shfmt"
|
|
45
|
+
git ls-files -z '*.sh' | xargs -0 --no-run-if-empty "$RUNNER_TEMP/shfmt" -d
|
|
46
|
+
powershell:
|
|
47
|
+
if: inputs.powershell-scripts == 'true'
|
|
48
|
+
runs-on: windows-latest
|
|
49
|
+
permissions:
|
|
50
|
+
contents: read
|
|
51
|
+
defaults:
|
|
52
|
+
run:
|
|
53
|
+
working-directory: ${{ inputs.working-directory }}
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
56
|
+
with:
|
|
57
|
+
persist-credentials: false
|
|
58
|
+
- name: PSScriptAnalyzer
|
|
59
|
+
shell: pwsh
|
|
60
|
+
run: |
|
|
61
|
+
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force -Scope CurrentUser
|
|
62
|
+
# a repository settings file decides the rules; otherwise only errors and warnings fail the job
|
|
63
|
+
$issues = if (Test-Path PSScriptAnalyzerSettings.psd1) {
|
|
64
|
+
Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSScriptAnalyzerSettings.psd1
|
|
65
|
+
} else {
|
|
66
|
+
Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error, Warning
|
|
67
|
+
}
|
|
68
|
+
$issues | Format-Table -AutoSize
|
|
69
|
+
if ($issues) { exit 1 }
|
|
70
|
+
test:
|
|
71
|
+
if: inputs.test-command != ''
|
|
72
|
+
strategy:
|
|
73
|
+
fail-fast: false
|
|
74
|
+
matrix:
|
|
75
|
+
os: [ubuntu-latest, windows-latest]
|
|
76
|
+
runs-on: ${{ matrix.os }}
|
|
77
|
+
permissions:
|
|
78
|
+
contents: read
|
|
79
|
+
defaults:
|
|
80
|
+
run:
|
|
81
|
+
shell: bash
|
|
82
|
+
working-directory: ${{ inputs.working-directory }}
|
|
83
|
+
steps:
|
|
84
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
85
|
+
with:
|
|
86
|
+
persist-credentials: false
|
|
87
|
+
- run: ${{ inputs.test-command }}
|
package/README.md
CHANGED
|
@@ -6,12 +6,40 @@ the standard evolves.
|
|
|
6
6
|
|
|
7
7
|
> Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
|
|
8
8
|
> Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP and
|
|
9
|
-
> Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`.
|
|
9
|
+
> Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. GitLab is
|
|
10
|
+
> supported for Node.js and Go projects, with project settings through `repokeeper gitlab apply`. See the
|
|
10
11
|
> [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
|
|
11
12
|
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```mermaid
|
|
16
|
+
flowchart LR
|
|
17
|
+
config[".repokeeper.yml<br/>stacks, modules, owned"]
|
|
18
|
+
tool(["repokeeper<br/>init, update, check"])
|
|
19
|
+
files["Managed files<br/>.editorconfig, .gitignore, lefthook.yml,<br/>commitlint, CONTRIBUTING, LICENSE,<br/>dependabot.yml, ci.yml, release.yml"]
|
|
20
|
+
lock[".repokeeper/lock.json<br/>what repokeeper wrote"]
|
|
21
|
+
workflows["Reusable workflows<br/>vannt-dev/repokeeper at v0,<br/>or pinned, mirrored, local"]
|
|
22
|
+
config --> tool
|
|
23
|
+
tool -->|"init, update: write,<br/>keep your edits"| files
|
|
24
|
+
tool -.->|"check: compare,<br/>exit 1 on drift"| files
|
|
25
|
+
tool --> lock
|
|
26
|
+
files -->|"ci.yml, release.yml call"| workflows
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
One file describes the repository (`.repokeeper.yml`); repokeeper turns it into the managed files and
|
|
30
|
+
remembers what it wrote, so it can tell your edits from its own. `check` only compares; `update`
|
|
31
|
+
moves the files to a newer standard and leaves alone what you changed or listed under `owned`.
|
|
32
|
+
|
|
12
33
|
## Usage
|
|
13
34
|
|
|
14
|
-
repokeeper is
|
|
35
|
+
repokeeper is [on npm](https://www.npmjs.com/package/repokeeper). Install it once, or run it without
|
|
36
|
+
installing by putting `npx` in front of each command below:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install --global repokeeper
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
To work on repokeeper itself, build it from source and link the command instead:
|
|
15
43
|
|
|
16
44
|
```bash
|
|
17
45
|
git clone https://github.com/vannt-dev/repokeeper.git
|
|
@@ -33,6 +61,29 @@ yours. `owned` also takes a single key of a shared file, such as `.github/workfl
|
|
|
33
61
|
`package.json#devDependencies.lefthook`; repokeeper then leaves that key, and everything under it,
|
|
34
62
|
alone. Every write command accepts `--dry-run`.
|
|
35
63
|
|
|
64
|
+
`init` applies the whole standard unless you say otherwise:
|
|
65
|
+
|
|
66
|
+
- `repokeeper init --preset essential` writes only the editor settings, `.gitignore` and the CI
|
|
67
|
+
workflow. `standard` is the default. `strict` adds the drift check to CI and, on GitHub, writes
|
|
68
|
+
branch protection (pull requests with one approval, no force push, the commit check required) and
|
|
69
|
+
Dependabot security updates into `.repokeeper.yml`; `repokeeper github apply` then puts them in
|
|
70
|
+
place. There is no preset that promises code or secret scanning: repokeeper does not set those up.
|
|
71
|
+
- `repokeeper init --interactive` (or `-i`) asks about each part in turn, naming the files it would
|
|
72
|
+
write, shows the result, and asks once more before writing anything. Enter keeps the preset's
|
|
73
|
+
answer. Add `--dry-run` to go through the questions without the possibility of writing.
|
|
74
|
+
|
|
75
|
+
Either way the choice ends up as plain `modules:` switches in `.repokeeper.yml`, which you can change
|
|
76
|
+
later and apply with `repokeeper update`.
|
|
77
|
+
|
|
78
|
+
You can also start from the file instead of from detection: put a `.repokeeper.yml` you wrote
|
|
79
|
+
yourself in the repository and run `repokeeper init`. When the file is there and repokeeper has not
|
|
80
|
+
applied anything yet, `init` applies it as it is written. Nothing is detected, `--stack`,
|
|
81
|
+
`--platform` and `--preset` are refused, and the only line it changes is `standard:`, which it sets
|
|
82
|
+
to the standard it applied.
|
|
83
|
+
|
|
84
|
+
The [playground](https://vannt-dev.github.io/repokeeper/) writes that file for you: tick the stacks
|
|
85
|
+
and the parts you want, and it shows the `.repokeeper.yml` and the command to run.
|
|
86
|
+
|
|
36
87
|
`init` reads the default branch from `origin/HEAD` and records it as `github.default_branch` when it
|
|
37
88
|
isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
|
|
38
89
|
of its own.
|
|
@@ -48,68 +99,14 @@ projects, which the dotnet CLI can't build.
|
|
|
48
99
|
|
|
49
100
|
Requires Node.js 22.12 or newer.
|
|
50
101
|
|
|
51
|
-
##
|
|
52
|
-
|
|
53
|
-
`ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
|
|
54
|
-
`commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
|
|
55
|
-
without a pull request. repokeeper owns the `name`, `on`, `permissions` and `concurrency` keys and
|
|
56
|
-
the jobs it adds; jobs you add yourself are left alone, and so is the formatting of the rest of the
|
|
57
|
-
file. A new push to a pull request cancels that pull request's earlier `ci` run; runs on the default
|
|
58
|
-
branch always finish.
|
|
59
|
-
|
|
60
|
-
The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
|
|
61
|
-
and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
|
|
62
|
-
choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then
|
|
63
|
-
uses it instead of its own filter, so keep `Severity` in it unless you want information-level rules too:
|
|
64
|
-
|
|
65
|
-
```powershell
|
|
66
|
-
@{
|
|
67
|
-
Severity = @('Error', 'Warning')
|
|
68
|
-
# installers print for the person running them
|
|
69
|
-
ExcludeRules = @('PSAvoidUsingWriteHost')
|
|
70
|
-
}
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
`release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
|
|
74
|
-
pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
|
|
75
|
-
work:
|
|
76
|
-
|
|
77
|
-
- In the repository settings, under Actions → General, allow GitHub Actions to create and approve
|
|
78
|
-
pull requests.
|
|
79
|
-
- Optionally add a `RELEASE_PLEASE_TOKEN` secret (a fine-grained token with contents, pull requests
|
|
80
|
-
and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
|
|
81
|
-
GitHub holds its `pull_request` runs until someone approves them; the `release-pr-ci` job approves
|
|
82
|
-
them, so the pull request gets its checks and required checks in a ruleset can pass.
|
|
83
|
-
|
|
84
|
-
A repository with no release yet (manifest at `0.0.0`) gets `initial-version: 0.1.0`, so its first
|
|
85
|
-
release is 0.1.0 rather than release-please's default 1.0.0.
|
|
86
|
-
|
|
87
|
-
Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
|
|
88
|
-
the version that wrote the standard, so a pull request that edits a managed file fails until the edit
|
|
89
|
-
is resolved.
|
|
90
|
-
|
|
91
|
-
## GitHub settings
|
|
92
|
-
|
|
93
|
-
`repokeeper github apply` brings the repository's settings in line with the `github:` section of
|
|
94
|
-
`.repokeeper.yml`. Only the keys you write are managed; anything left out stays as it is.
|
|
95
|
-
|
|
96
|
-
```yaml
|
|
97
|
-
github:
|
|
98
|
-
description: Keeps repositories on one standard
|
|
99
|
-
topics: [cli, conventional-commits]
|
|
100
|
-
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
|
|
101
|
-
security: { dependabot_alerts: true, dependabot_security_updates: true }
|
|
102
|
-
protect: # a ruleset named "repokeeper" on the default branch; false removes it
|
|
103
|
-
require_pull_request: true
|
|
104
|
-
required_approvals: 0
|
|
105
|
-
required_checks: ["commits / commitlint"] # check names exactly as pull requests show them
|
|
106
|
-
allow_force_push: false
|
|
107
|
-
```
|
|
102
|
+
## Guides
|
|
108
103
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
| Guide | What it covers |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script linting, release-please, the drift check |
|
|
107
|
+
| [Monorepos](docs/monorepos.md) | Stacks in folders: `stack_options.<stack>.directory`, what runs where, the limits |
|
|
108
|
+
| [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings, security, branch protection |
|
|
109
|
+
| [GitLab](docs/gitlab.md) | What differs on GitLab, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
|
|
113
110
|
|
|
114
111
|
## Pilots
|
|
115
112
|
|
package/dist/cli.js
CHANGED
|
@@ -4,8 +4,11 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
import { checkCommand } from "./commands/check.js";
|
|
7
|
+
import { ejectCommand } from "./commands/eject.js";
|
|
7
8
|
import { githubApplyCommand } from "./commands/github.js";
|
|
9
|
+
import { gitlabApplyCommand } from "./commands/gitlab.js";
|
|
8
10
|
import { initCommand } from "./commands/init.js";
|
|
11
|
+
import { PRESET_SUMMARY, PRESETS } from "./commands/presets.js";
|
|
9
12
|
import { updateCommand } from "./commands/update.js";
|
|
10
13
|
import { STACK_IDS } from "./config/types.js";
|
|
11
14
|
import { RepokeeperError, UsageError } from "./errors.js";
|
|
@@ -14,20 +17,26 @@ export const USAGE = [
|
|
|
14
17
|
"usage: repokeeper <command> [options]",
|
|
15
18
|
"",
|
|
16
19
|
"commands:",
|
|
17
|
-
" init detect stacks, write .repokeeper.yml and apply the standard",
|
|
20
|
+
" init detect stacks, write .repokeeper.yml and apply the standard (or apply a .repokeeper.yml you wrote)",
|
|
18
21
|
" check report drift from the standard without writing (exit 1 on drift)",
|
|
19
22
|
" update move to the standard of this repokeeper version and resync",
|
|
20
23
|
" github apply diff the GitHub settings against .repokeeper.yml and apply them",
|
|
24
|
+
" gitlab apply the same for a GitLab project: settings, branch protection, the Renovate schedule",
|
|
25
|
+
" eject copy the reusable CI workflows into this repository and call them from there",
|
|
21
26
|
"",
|
|
22
27
|
"options:",
|
|
23
28
|
" --dry-run show what would change without writing",
|
|
24
29
|
" --force write even when target files have uncommitted changes",
|
|
25
30
|
" --adopt <path> let repokeeper manage an existing file (repeatable); --adopt-all for every file",
|
|
26
31
|
" --accept <path> take repokeeper's version of a locally edited file (update, repeatable)",
|
|
27
|
-
" --stack <id> stack to use instead of detection (init, repeatable)",
|
|
32
|
+
" --stack <id> stack to use instead of detection (init, repeatable); id:folder for a stack in a folder",
|
|
33
|
+
" --platform <id> github or gitlab, instead of detection from the origin remote (init)",
|
|
34
|
+
` --preset <name> ${PRESETS.map((name) => `${name}: ${PRESET_SUMMARY[name]}`).join("; ")} (init)`,
|
|
35
|
+
" -i, --interactive ask which parts to apply, naming the files of each, and confirm before writing (init)",
|
|
28
36
|
" --relock rebuild .repokeeper/lock.json from the current files (init)",
|
|
37
|
+
" --to <folder> write every reusable workflow into another repository's folder instead (eject)",
|
|
29
38
|
" --json machine-readable output (check)",
|
|
30
|
-
" -y, --yes apply without asking (github apply)",
|
|
39
|
+
" -y, --yes apply without asking (github apply, gitlab apply)",
|
|
31
40
|
" -v, --version print the version",
|
|
32
41
|
].join("\n");
|
|
33
42
|
const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -44,7 +53,11 @@ export async function run(argv, io) {
|
|
|
44
53
|
"adopt-all": { type: "boolean", default: false },
|
|
45
54
|
accept: { type: "string", multiple: true, default: [] },
|
|
46
55
|
stack: { type: "string", multiple: true, default: [] },
|
|
56
|
+
platform: { type: "string" },
|
|
57
|
+
preset: { type: "string" },
|
|
58
|
+
interactive: { type: "boolean", short: "i", default: false },
|
|
47
59
|
relock: { type: "boolean", default: false },
|
|
60
|
+
to: { type: "string" },
|
|
48
61
|
json: { type: "boolean", default: false },
|
|
49
62
|
yes: { type: "boolean", short: "y", default: false },
|
|
50
63
|
version: { type: "boolean", short: "v", default: false },
|
|
@@ -60,12 +73,34 @@ export async function run(argv, io) {
|
|
|
60
73
|
io.out(USAGE);
|
|
61
74
|
return command === undefined && !values.help ? 2 : 0;
|
|
62
75
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
// `go:backend` names the folder of a monorepo the stack lives in
|
|
77
|
+
const stackDirectories = {};
|
|
78
|
+
const stacks = values.stack.map((value) => {
|
|
79
|
+
const [stack, ...rest] = value.split(":");
|
|
80
|
+
if (stack === undefined || !STACK_IDS.includes(stack)) {
|
|
66
81
|
throw new UsageError(`unknown stack ${stack}; expected one of ${STACK_IDS.join(", ")}`);
|
|
67
82
|
}
|
|
83
|
+
const directory = toPosix(rest.join(":")).replace(/\/+$/, "");
|
|
84
|
+
if (directory !== "" && directory !== ".")
|
|
85
|
+
stackDirectories[stack] = directory;
|
|
86
|
+
return stack;
|
|
87
|
+
});
|
|
88
|
+
const platform = values.platform;
|
|
89
|
+
if (platform !== undefined && platform !== "github" && platform !== "gitlab") {
|
|
90
|
+
throw new UsageError(`unknown platform ${platform}; expected github or gitlab`);
|
|
68
91
|
}
|
|
92
|
+
if (platform !== undefined && command !== "init")
|
|
93
|
+
throw new UsageError("--platform is only for init");
|
|
94
|
+
const preset = values.preset;
|
|
95
|
+
if (preset !== undefined && !PRESETS.includes(preset)) {
|
|
96
|
+
throw new UsageError(`unknown preset ${preset}; expected one of ${PRESETS.join(", ")}`);
|
|
97
|
+
}
|
|
98
|
+
if ((preset !== undefined || values.interactive) && command !== "init") {
|
|
99
|
+
throw new UsageError("--preset and --interactive are only for init");
|
|
100
|
+
}
|
|
101
|
+
const to = values.to;
|
|
102
|
+
if (to !== undefined && command !== "eject")
|
|
103
|
+
throw new UsageError("--to is only for eject");
|
|
69
104
|
const options = {
|
|
70
105
|
dryRun: values["dry-run"],
|
|
71
106
|
force: values.force,
|
|
@@ -73,9 +108,14 @@ export async function run(argv, io) {
|
|
|
73
108
|
adoptAll: values["adopt-all"],
|
|
74
109
|
accept: values.accept.map(toPosix),
|
|
75
110
|
stacks: stacks,
|
|
111
|
+
stackDirectories,
|
|
76
112
|
relock: values.relock,
|
|
77
113
|
json: values.json,
|
|
78
114
|
yes: values.yes,
|
|
115
|
+
...(platform !== undefined ? { platform } : {}),
|
|
116
|
+
...(to !== undefined ? { to } : {}),
|
|
117
|
+
...(preset !== undefined ? { preset: preset } : {}),
|
|
118
|
+
interactive: values.interactive,
|
|
79
119
|
};
|
|
80
120
|
if (command === "init")
|
|
81
121
|
return await initCommand(io.cwd, options, io);
|
|
@@ -83,11 +123,18 @@ export async function run(argv, io) {
|
|
|
83
123
|
return await checkCommand(io.cwd, options, io);
|
|
84
124
|
if (command === "update")
|
|
85
125
|
return await updateCommand(io.cwd, options, io);
|
|
126
|
+
if (command === "eject")
|
|
127
|
+
return await ejectCommand(io.cwd, options, io);
|
|
86
128
|
if (command === "github") {
|
|
87
129
|
if (positionals[1] !== "apply")
|
|
88
130
|
throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
|
|
89
131
|
return await githubApplyCommand(io.cwd, options, io);
|
|
90
132
|
}
|
|
133
|
+
if (command === "gitlab") {
|
|
134
|
+
if (positionals[1] !== "apply")
|
|
135
|
+
throw new UsageError("usage: repokeeper gitlab apply [--dry-run] [--yes]");
|
|
136
|
+
return await gitlabApplyCommand(io.cwd, options, io);
|
|
137
|
+
}
|
|
91
138
|
throw new UsageError(`unknown command: ${command}`);
|
|
92
139
|
}
|
|
93
140
|
catch (error) {
|
|
@@ -105,10 +152,11 @@ export async function run(argv, io) {
|
|
|
105
152
|
throw error;
|
|
106
153
|
}
|
|
107
154
|
}
|
|
108
|
-
async function askYesNo(question) {
|
|
155
|
+
async function askYesNo(question, fallback = false) {
|
|
109
156
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
110
157
|
try {
|
|
111
|
-
|
|
158
|
+
const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim();
|
|
159
|
+
return answer === "" ? fallback : /^y(es)?$/i.test(answer);
|
|
112
160
|
}
|
|
113
161
|
finally {
|
|
114
162
|
rl.close();
|
package/dist/commands/context.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_FILE } from "../config/load.js";
|
|
4
|
+
import { ConfigError, UsageError } from "../errors.js";
|
|
1
5
|
import { repoInfo } from "../git.js";
|
|
2
|
-
import {
|
|
6
|
+
import { platformFor } from "../platforms/index.js";
|
|
3
7
|
import { getStackPack } from "../stacks/index.js";
|
|
8
|
+
import { stackDirectory } from "../stacks/support.js";
|
|
4
9
|
export async function buildContext(root, config, repo) {
|
|
5
|
-
const
|
|
6
|
-
|
|
10
|
+
const platform = platformFor(config.platform);
|
|
11
|
+
const unsupported = config.stacks.find((id) => !platform.stacks.includes(id));
|
|
12
|
+
if (unsupported) {
|
|
13
|
+
throw new UsageError(`stack "${unsupported}" is not supported on ${platform.id} yet (supported: ${platform.stacks.join(", ")})`);
|
|
14
|
+
}
|
|
15
|
+
const stacks = await Promise.all(config.stacks.map(async (id) => {
|
|
16
|
+
// `directory` is read here, for every stack alike; the rest of the options are the stack's own
|
|
17
|
+
const { directory: _directory, ...options } = config.stack_options[id] ?? {};
|
|
18
|
+
const directory = stackDirectory(id, config.stack_options[id] ?? {});
|
|
19
|
+
if (directory === undefined)
|
|
20
|
+
return getStackPack(id).resolve(root, options);
|
|
21
|
+
if (platform.id !== "github") {
|
|
22
|
+
throw new UsageError(`a stack in a folder of its own (stack_options.${id}.directory) is not supported on ${platform.id} yet`);
|
|
23
|
+
}
|
|
24
|
+
if (!existsSync(join(root, directory))) {
|
|
25
|
+
throw new ConfigError(`${CONFIG_FILE}: stack_options.${id}.directory: the folder ${directory} does not exist`);
|
|
26
|
+
}
|
|
27
|
+
return { ...(await getStackPack(id).resolve(join(root, directory), options)), directory };
|
|
28
|
+
}));
|
|
29
|
+
return { config, stacks, platform, repo: repo ?? (await repoInfo(root, config.platform)) };
|
|
7
30
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { parseDocument } from "yaml";
|
|
5
|
+
import { CONFIG_FILE, loadConfig } from "../config/load.js";
|
|
6
|
+
import { UsageError } from "../errors.js";
|
|
7
|
+
import { listWorkflows, readWorkflow } from "../templates.js";
|
|
8
|
+
import { PACKAGE_VERSION, REUSABLE_REPO } from "../version.js";
|
|
9
|
+
import { updateCommand } from "./update.js";
|
|
10
|
+
const WORKFLOWS = ".github/workflows";
|
|
11
|
+
/** Sets `github.workflows.source: local` in place, keeping every comment and the rest of the layout. */
|
|
12
|
+
export function setLocalWorkflows(text) {
|
|
13
|
+
const doc = parseDocument(text);
|
|
14
|
+
doc.setIn(["github", "workflows", "source"], "local");
|
|
15
|
+
// a ref belongs to a repository; the files of this one are called as they are on each commit
|
|
16
|
+
doc.deleteIn(["github", "workflows", "ref"]);
|
|
17
|
+
return doc.toString();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Stops depending on repokeeper's repository for CI.
|
|
21
|
+
*
|
|
22
|
+
* Without `--to`: this repository gets its own copies of the reusable workflows its CI and release
|
|
23
|
+
* call, and the callers are pointed at them. With `--to <folder>`: every reusable workflow is
|
|
24
|
+
* written into that folder, for a repository of the organisation that the others then call.
|
|
25
|
+
*/
|
|
26
|
+
export async function ejectCommand(root, options, io) {
|
|
27
|
+
if (options.to !== undefined)
|
|
28
|
+
return ejectTo(resolve(root, options.to), options, io);
|
|
29
|
+
const config = await loadConfig(root);
|
|
30
|
+
if (config.platform !== "github") {
|
|
31
|
+
throw new UsageError("eject is for GitHub: on GitLab every job is already generated into .gitlab-ci.yml");
|
|
32
|
+
}
|
|
33
|
+
const local = {
|
|
34
|
+
...config,
|
|
35
|
+
github: { ...config.github, workflows: { source: "local" } },
|
|
36
|
+
};
|
|
37
|
+
const code = await updateCommand(root, options, io, {
|
|
38
|
+
config: local,
|
|
39
|
+
async beforeWrite() {
|
|
40
|
+
const path = join(root, CONFIG_FILE);
|
|
41
|
+
await writeFile(path, setLocalWorkflows(await readFile(path, "utf8")));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (code === 0 && !options.dryRun) {
|
|
45
|
+
io.out(`the reusable workflows are now files of this repository, under ${WORKFLOWS}/, and yours to maintain`);
|
|
46
|
+
}
|
|
47
|
+
return code;
|
|
48
|
+
}
|
|
49
|
+
async function ejectTo(target, options, io) {
|
|
50
|
+
const directory = join(target, WORKFLOWS);
|
|
51
|
+
const files = listWorkflows();
|
|
52
|
+
const existing = files.filter((file) => existsSync(join(directory, file)));
|
|
53
|
+
if (existing.length > 0 && !options.force) {
|
|
54
|
+
throw new UsageError(`${existing.map((file) => `${WORKFLOWS}/${file}`).join(", ")} already exist in ${target}; pass --force to replace them`);
|
|
55
|
+
}
|
|
56
|
+
for (const file of files)
|
|
57
|
+
io.out(`${existing.includes(file) ? "replace" : "create "} ${WORKFLOWS}/${file}`);
|
|
58
|
+
if (options.dryRun) {
|
|
59
|
+
io.out("dry run: nothing written");
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
await mkdir(directory, { recursive: true });
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
await writeFile(join(directory, file), `# Copied from repokeeper ${PACKAGE_VERSION} (https://github.com/${REUSABLE_REPO}).\n${readWorkflow(file)}`);
|
|
65
|
+
}
|
|
66
|
+
io.out(`wrote ${files.length} reusable workflows to ${directory}`);
|
|
67
|
+
io.out("next: commit and push them in that repository, and allow other repositories to call its workflows");
|
|
68
|
+
io.out("next: in each repository that should call them, add to .repokeeper.yml and run `repokeeper update`:");
|
|
69
|
+
io.out(" github:");
|
|
70
|
+
io.out(" workflows:");
|
|
71
|
+
io.out(" source: your-org/that-repository");
|
|
72
|
+
io.out(" ref: main # or a tag or commit of that repository");
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
package/dist/commands/github.js
CHANGED
|
@@ -6,6 +6,8 @@ import { planGithub } from "../github/settings.js";
|
|
|
6
6
|
/** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
|
|
7
7
|
export async function githubApplyCommand(root, options, io) {
|
|
8
8
|
const config = await loadConfig(root);
|
|
9
|
+
if (config.platform !== "github")
|
|
10
|
+
throw new UsageError(`this repository uses the ${config.platform} platform`);
|
|
9
11
|
const repo = await repoInfo(root);
|
|
10
12
|
if (!repo.owner)
|
|
11
13
|
throw new UsageError("the origin remote is not a GitHub repository");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.js";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { repoInfo } from "../git.js";
|
|
4
|
+
import { gitlabRestApi, resolveGitlabToken } from "../gitlab/api.js";
|
|
5
|
+
import { planGitlab } from "../gitlab/settings.js";
|
|
6
|
+
/** `repokeeper gitlab apply`: diff the project's GitLab settings against `gitlab:` and apply on confirmation. */
|
|
7
|
+
export async function gitlabApplyCommand(root, options, io) {
|
|
8
|
+
const config = await loadConfig(root);
|
|
9
|
+
if (config.platform !== "gitlab")
|
|
10
|
+
throw new UsageError(`this repository uses the ${config.platform} platform`);
|
|
11
|
+
const repo = await repoInfo(root, "gitlab");
|
|
12
|
+
if (!repo.owner)
|
|
13
|
+
throw new UsageError("the origin remote is not a GitLab project");
|
|
14
|
+
const path = `${repo.owner}/${repo.name}`;
|
|
15
|
+
const host = repo.host ?? "gitlab.com";
|
|
16
|
+
const api = io.gitlabApi ?? gitlabRestApi(await resolveGitlabToken(host), host);
|
|
17
|
+
// the jobs that wait for a secret: reported when it is missing, never created
|
|
18
|
+
const secrets = [
|
|
19
|
+
...(config.modules.release ? [{ key: "GITLAB_TOKEN", job: "release" }] : []),
|
|
20
|
+
...(config.modules.deps ? [{ key: "RENOVATE_TOKEN", job: "renovate" }] : []),
|
|
21
|
+
];
|
|
22
|
+
const { changes, notes } = await planGitlab(api, path, config.gitlab ?? {}, secrets);
|
|
23
|
+
if (changes.length === 0)
|
|
24
|
+
io.out(`GitLab settings of ${path} match .repokeeper.yml`);
|
|
25
|
+
for (const change of changes)
|
|
26
|
+
io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
|
|
27
|
+
for (const note of notes)
|
|
28
|
+
io.out(`note: ${note}`);
|
|
29
|
+
if (changes.length === 0)
|
|
30
|
+
return 0;
|
|
31
|
+
if (options.dryRun) {
|
|
32
|
+
io.out("dry run: nothing applied");
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const question = `apply ${changes.length} change(s) to ${path}?`;
|
|
36
|
+
const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
|
|
37
|
+
if (!confirmed) {
|
|
38
|
+
io.out("nothing applied; pass --yes to apply");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
for (const change of changes) {
|
|
42
|
+
await change.apply(api);
|
|
43
|
+
io.out(`applied ${change.setting}`);
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|