repokeeper 0.5.0 → 0.7.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 -106
- package/dist/cli.js +52 -9
- package/dist/commands/bump.js +30 -0
- package/dist/commands/context.js +19 -2
- package/dist/commands/eject.js +74 -0
- package/dist/commands/gitlab.js +46 -0
- package/dist/commands/init.js +104 -10
- package/dist/commands/presets.js +114 -0
- package/dist/commands/report.js +7 -5
- package/dist/commands/update.js +3 -2
- package/dist/config/schema.js +40 -1
- package/dist/duplicates.js +2 -2
- package/dist/errors.js +1 -1
- 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 +3 -3
- package/dist/modules/hooks.js +7 -5
- package/dist/modules/release.js +18 -5
- package/dist/platforms/github.js +70 -12
- package/dist/platforms/gitlab-jobs.js +191 -0
- package/dist/platforms/gitlab.js +53 -13
- package/dist/release/bump.js +223 -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 +3 -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,13 +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,
|
|
10
|
-
>
|
|
9
|
+
> Ruby, each with CI and releases, on GitHub and on GitLab, plus repository settings through
|
|
10
|
+
> `repokeeper github apply` and `repokeeper gitlab apply`. See the
|
|
11
11
|
> [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
|
|
12
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
|
+
|
|
13
33
|
## Usage
|
|
14
34
|
|
|
15
|
-
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:
|
|
16
43
|
|
|
17
44
|
```bash
|
|
18
45
|
git clone https://github.com/vannt-dev/repokeeper.git
|
|
@@ -34,6 +61,29 @@ yours. `owned` also takes a single key of a shared file, such as `.github/workfl
|
|
|
34
61
|
`package.json#devDependencies.lefthook`; repokeeper then leaves that key, and everything under it,
|
|
35
62
|
alone. Every write command accepts `--dry-run`.
|
|
36
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
|
+
|
|
37
87
|
`init` reads the default branch from `origin/HEAD` and records it as `github.default_branch` when it
|
|
38
88
|
isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
|
|
39
89
|
of its own.
|
|
@@ -49,110 +99,14 @@ projects, which the dotnet CLI can't build.
|
|
|
49
99
|
|
|
50
100
|
Requires Node.js 22.12 or newer.
|
|
51
101
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
`ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
|
|
55
|
-
`commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
|
|
56
|
-
without a pull request. repokeeper owns the `name`, `on`, `permissions` and `concurrency` keys and
|
|
57
|
-
the jobs it adds; jobs you add yourself are left alone, and so is the formatting of the rest of the
|
|
58
|
-
file. A new push to a pull request cancels that pull request's earlier `ci` run; runs on the default
|
|
59
|
-
branch always finish.
|
|
60
|
-
|
|
61
|
-
The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
|
|
62
|
-
and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
|
|
63
|
-
choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then
|
|
64
|
-
uses it instead of its own filter, so keep `Severity` in it unless you want information-level rules too:
|
|
65
|
-
|
|
66
|
-
```powershell
|
|
67
|
-
@{
|
|
68
|
-
Severity = @('Error', 'Warning')
|
|
69
|
-
# installers print for the person running them
|
|
70
|
-
ExcludeRules = @('PSAvoidUsingWriteHost')
|
|
71
|
-
}
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
`release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
|
|
75
|
-
pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
|
|
76
|
-
work:
|
|
77
|
-
|
|
78
|
-
- In the repository settings, under Actions → General, allow GitHub Actions to create and approve
|
|
79
|
-
pull requests.
|
|
80
|
-
- Optionally add a `RELEASE_PLEASE_TOKEN` secret (a fine-grained token with contents, pull requests
|
|
81
|
-
and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
|
|
82
|
-
GitHub holds its `pull_request` runs until someone approves them; the `release-pr-ci` job approves
|
|
83
|
-
them, so the pull request gets its checks and required checks in a ruleset can pass.
|
|
84
|
-
|
|
85
|
-
A repository with no release yet (manifest at `0.0.0`) gets `initial-version: 0.1.0`, so its first
|
|
86
|
-
release is 0.1.0 rather than release-please's default 1.0.0.
|
|
87
|
-
|
|
88
|
-
Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
|
|
89
|
-
the version that wrote the standard, so a pull request that edits a managed file fails until the edit
|
|
90
|
-
is resolved.
|
|
91
|
-
|
|
92
|
-
## GitHub settings
|
|
93
|
-
|
|
94
|
-
`repokeeper github apply` brings the repository's settings in line with the `github:` section of
|
|
95
|
-
`.repokeeper.yml`. Only the keys you write are managed; anything left out stays as it is.
|
|
96
|
-
|
|
97
|
-
```yaml
|
|
98
|
-
github:
|
|
99
|
-
description: Keeps repositories on one standard
|
|
100
|
-
topics: [cli, conventional-commits]
|
|
101
|
-
merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
|
|
102
|
-
security: { dependabot_alerts: true, dependabot_security_updates: true }
|
|
103
|
-
protect: # a ruleset named "repokeeper" on the default branch; false removes it
|
|
104
|
-
require_pull_request: true
|
|
105
|
-
required_approvals: 0
|
|
106
|
-
required_checks: ["commits / commitlint"] # check names exactly as pull requests show them
|
|
107
|
-
allow_force_push: false
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
It prints every change first and applies them with `--yes`, or after you confirm in a terminal;
|
|
111
|
-
`--dry-run` only prints. The token comes from `GITHUB_TOKEN` or `gh auth token` and needs admin
|
|
112
|
-
access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
|
|
113
|
-
touched.
|
|
114
|
-
|
|
115
|
-
## GitLab
|
|
116
|
-
|
|
117
|
-
`repokeeper init` selects GitLab when the `origin` remote's host contains `gitlab`; pass
|
|
118
|
-
`--platform gitlab` otherwise (a self-hosted instance under another name, or no remote yet). Only
|
|
119
|
-
the node stack is supported on GitLab for now.
|
|
120
|
-
|
|
121
|
-
What differs from GitHub:
|
|
122
|
-
|
|
123
|
-
| | GitHub | GitLab |
|
|
124
|
-
| --- | --- | --- |
|
|
125
|
-
| Templates, CODEOWNERS | `.github/` | `.gitlab/` |
|
|
126
|
-
| CI | caller workflows of reusable workflows | every job generated into `.gitlab-ci.yml` |
|
|
127
|
-
| Dependency updates | Dependabot | Renovate (`renovate.json`) |
|
|
128
|
-
| Releases | release-please, through a release pull request | semantic-release, on every push to the default branch |
|
|
129
|
-
|
|
130
|
-
Jobs you add to `.gitlab-ci.yml` are kept; repokeeper manages only its own top-level keys
|
|
131
|
-
(`workflow`, `node`, `commits`, `repokeeper`, `renovate`, `release`). `stack_options.node.os` has
|
|
132
|
-
no effect: GitLab jobs run on Linux.
|
|
133
|
-
|
|
134
|
-
Two things to know when you add jobs of your own:
|
|
135
|
-
|
|
136
|
-
- The managed `workflow` runs pipelines for merge requests, the default branch, schedules and
|
|
137
|
-
tags. repokeeper's own jobs skip tags, so a tag pipeline holds only your jobs (publishing, for
|
|
138
|
-
example). A job meant for other branches never starts. To write the `workflow` rules yourself,
|
|
139
|
-
list the key under `owned` in `.repokeeper.yml`: `owned: [".gitlab-ci.yml#workflow"]`.
|
|
140
|
-
- repokeeper's jobs use GitLab's default stages `test` and `deploy`. If you declare `stages`,
|
|
141
|
-
include both; repokeeper warns when one is missing.
|
|
142
|
-
|
|
143
|
-
Two jobs stay inactive until you set them up in the project's CI/CD settings:
|
|
144
|
-
|
|
145
|
-
- **`release`** needs a CI/CD variable `GITLAB_TOKEN`: a project access token with the `api` and
|
|
146
|
-
`write_repository` scopes and a role that may push to the default branch. Every push to the
|
|
147
|
-
default branch with a `feat`, `fix` or breaking change then releases at once: version bump,
|
|
148
|
-
`CHANGELOG.md`, tag and GitLab release. There is no release merge request. A repository without
|
|
149
|
-
a `vX.Y.Z` tag starts at `1.0.0`; tag the current version first to continue from it. A variable
|
|
150
|
-
marked Protected is only visible on protected branches, so protect the default branch or leave
|
|
151
|
-
the variable unprotected; otherwise the job silently stays away.
|
|
152
|
-
- **`renovate`** needs a CI/CD variable `RENOVATE_TOKEN` (same scopes) and a pipeline schedule,
|
|
153
|
-
for example weekly. The schedule alone decides how often Renovate runs.
|
|
102
|
+
## Guides
|
|
154
103
|
|
|
155
|
-
|
|
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 image each stack's job runs in, how releases write the version, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
|
|
156
110
|
|
|
157
111
|
## Pilots
|
|
158
112
|
|
package/dist/cli.js
CHANGED
|
@@ -3,9 +3,13 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { bumpCommand } from "./commands/bump.js";
|
|
6
7
|
import { checkCommand } from "./commands/check.js";
|
|
8
|
+
import { ejectCommand } from "./commands/eject.js";
|
|
7
9
|
import { githubApplyCommand } from "./commands/github.js";
|
|
10
|
+
import { gitlabApplyCommand } from "./commands/gitlab.js";
|
|
8
11
|
import { initCommand } from "./commands/init.js";
|
|
12
|
+
import { PRESET_SUMMARY, PRESETS } from "./commands/presets.js";
|
|
9
13
|
import { updateCommand } from "./commands/update.js";
|
|
10
14
|
import { STACK_IDS } from "./config/types.js";
|
|
11
15
|
import { RepokeeperError, UsageError } from "./errors.js";
|
|
@@ -14,21 +18,27 @@ export const USAGE = [
|
|
|
14
18
|
"usage: repokeeper <command> [options]",
|
|
15
19
|
"",
|
|
16
20
|
"commands:",
|
|
17
|
-
" init detect stacks, write .repokeeper.yml and apply the standard",
|
|
21
|
+
" init detect stacks, write .repokeeper.yml and apply the standard (or apply a .repokeeper.yml you wrote)",
|
|
18
22
|
" check report drift from the standard without writing (exit 1 on drift)",
|
|
19
23
|
" update move to the standard of this repokeeper version and resync",
|
|
20
24
|
" github apply diff the GitHub settings against .repokeeper.yml and apply them",
|
|
25
|
+
" gitlab apply the same for a GitLab project: settings, branch protection, the Renovate schedule",
|
|
26
|
+
" eject copy the reusable CI workflows into this repository and call them from there",
|
|
27
|
+
" bump <version> write a release's version into the stack's version files (the GitLab release job runs it)",
|
|
21
28
|
"",
|
|
22
29
|
"options:",
|
|
23
30
|
" --dry-run show what would change without writing",
|
|
24
31
|
" --force write even when target files have uncommitted changes",
|
|
25
32
|
" --adopt <path> let repokeeper manage an existing file (repeatable); --adopt-all for every file",
|
|
26
33
|
" --accept <path> take repokeeper's version of a locally edited file (update, repeatable)",
|
|
27
|
-
" --stack <id> stack to use instead of detection (init, repeatable)",
|
|
34
|
+
" --stack <id> stack to use instead of detection (init, repeatable); id:folder for a stack in a folder",
|
|
28
35
|
" --platform <id> github or gitlab, instead of detection from the origin remote (init)",
|
|
36
|
+
` --preset <name> ${PRESETS.map((name) => `${name}: ${PRESET_SUMMARY[name]}`).join("; ")} (init)`,
|
|
37
|
+
" -i, --interactive ask which parts to apply, naming the files of each, and confirm before writing (init)",
|
|
29
38
|
" --relock rebuild .repokeeper/lock.json from the current files (init)",
|
|
39
|
+
" --to <folder> write every reusable workflow into another repository's folder instead (eject)",
|
|
30
40
|
" --json machine-readable output (check)",
|
|
31
|
-
" -y, --yes apply without asking (github apply)",
|
|
41
|
+
" -y, --yes apply without asking (github apply, gitlab apply)",
|
|
32
42
|
" -v, --version print the version",
|
|
33
43
|
].join("\n");
|
|
34
44
|
const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -46,7 +56,10 @@ export async function run(argv, io) {
|
|
|
46
56
|
accept: { type: "string", multiple: true, default: [] },
|
|
47
57
|
stack: { type: "string", multiple: true, default: [] },
|
|
48
58
|
platform: { type: "string" },
|
|
59
|
+
preset: { type: "string" },
|
|
60
|
+
interactive: { type: "boolean", short: "i", default: false },
|
|
49
61
|
relock: { type: "boolean", default: false },
|
|
62
|
+
to: { type: "string" },
|
|
50
63
|
json: { type: "boolean", default: false },
|
|
51
64
|
yes: { type: "boolean", short: "y", default: false },
|
|
52
65
|
version: { type: "boolean", short: "v", default: false },
|
|
@@ -62,18 +75,34 @@ export async function run(argv, io) {
|
|
|
62
75
|
io.out(USAGE);
|
|
63
76
|
return command === undefined && !values.help ? 2 : 0;
|
|
64
77
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
// `go:backend` names the folder of a monorepo the stack lives in
|
|
79
|
+
const stackDirectories = {};
|
|
80
|
+
const stacks = values.stack.map((value) => {
|
|
81
|
+
const [stack, ...rest] = value.split(":");
|
|
82
|
+
if (stack === undefined || !STACK_IDS.includes(stack)) {
|
|
68
83
|
throw new UsageError(`unknown stack ${stack}; expected one of ${STACK_IDS.join(", ")}`);
|
|
69
84
|
}
|
|
70
|
-
|
|
85
|
+
const directory = toPosix(rest.join(":")).replace(/\/+$/, "");
|
|
86
|
+
if (directory !== "" && directory !== ".")
|
|
87
|
+
stackDirectories[stack] = directory;
|
|
88
|
+
return stack;
|
|
89
|
+
});
|
|
71
90
|
const platform = values.platform;
|
|
72
91
|
if (platform !== undefined && platform !== "github" && platform !== "gitlab") {
|
|
73
92
|
throw new UsageError(`unknown platform ${platform}; expected github or gitlab`);
|
|
74
93
|
}
|
|
75
94
|
if (platform !== undefined && command !== "init")
|
|
76
95
|
throw new UsageError("--platform is only for init");
|
|
96
|
+
const preset = values.preset;
|
|
97
|
+
if (preset !== undefined && !PRESETS.includes(preset)) {
|
|
98
|
+
throw new UsageError(`unknown preset ${preset}; expected one of ${PRESETS.join(", ")}`);
|
|
99
|
+
}
|
|
100
|
+
if ((preset !== undefined || values.interactive) && command !== "init") {
|
|
101
|
+
throw new UsageError("--preset and --interactive are only for init");
|
|
102
|
+
}
|
|
103
|
+
const to = values.to;
|
|
104
|
+
if (to !== undefined && command !== "eject")
|
|
105
|
+
throw new UsageError("--to is only for eject");
|
|
77
106
|
const options = {
|
|
78
107
|
dryRun: values["dry-run"],
|
|
79
108
|
force: values.force,
|
|
@@ -81,10 +110,14 @@ export async function run(argv, io) {
|
|
|
81
110
|
adoptAll: values["adopt-all"],
|
|
82
111
|
accept: values.accept.map(toPosix),
|
|
83
112
|
stacks: stacks,
|
|
113
|
+
stackDirectories,
|
|
84
114
|
relock: values.relock,
|
|
85
115
|
json: values.json,
|
|
86
116
|
yes: values.yes,
|
|
87
117
|
...(platform !== undefined ? { platform } : {}),
|
|
118
|
+
...(to !== undefined ? { to } : {}),
|
|
119
|
+
...(preset !== undefined ? { preset: preset } : {}),
|
|
120
|
+
interactive: values.interactive,
|
|
88
121
|
};
|
|
89
122
|
if (command === "init")
|
|
90
123
|
return await initCommand(io.cwd, options, io);
|
|
@@ -92,11 +125,20 @@ export async function run(argv, io) {
|
|
|
92
125
|
return await checkCommand(io.cwd, options, io);
|
|
93
126
|
if (command === "update")
|
|
94
127
|
return await updateCommand(io.cwd, options, io);
|
|
128
|
+
if (command === "eject")
|
|
129
|
+
return await ejectCommand(io.cwd, options, io);
|
|
130
|
+
if (command === "bump")
|
|
131
|
+
return await bumpCommand(io.cwd, positionals[1], options, io);
|
|
95
132
|
if (command === "github") {
|
|
96
133
|
if (positionals[1] !== "apply")
|
|
97
134
|
throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
|
|
98
135
|
return await githubApplyCommand(io.cwd, options, io);
|
|
99
136
|
}
|
|
137
|
+
if (command === "gitlab") {
|
|
138
|
+
if (positionals[1] !== "apply")
|
|
139
|
+
throw new UsageError("usage: repokeeper gitlab apply [--dry-run] [--yes]");
|
|
140
|
+
return await gitlabApplyCommand(io.cwd, options, io);
|
|
141
|
+
}
|
|
100
142
|
throw new UsageError(`unknown command: ${command}`);
|
|
101
143
|
}
|
|
102
144
|
catch (error) {
|
|
@@ -114,10 +156,11 @@ export async function run(argv, io) {
|
|
|
114
156
|
throw error;
|
|
115
157
|
}
|
|
116
158
|
}
|
|
117
|
-
async function askYesNo(question) {
|
|
159
|
+
async function askYesNo(question, fallback = false) {
|
|
118
160
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
119
161
|
try {
|
|
120
|
-
|
|
162
|
+
const answer = (await rl.question(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `)).trim();
|
|
163
|
+
return answer === "" ? fallback : /^y(es)?$/i.test(answer);
|
|
121
164
|
}
|
|
122
165
|
finally {
|
|
123
166
|
rl.close();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadConfig } from "../config/load.js";
|
|
3
|
+
import { UsageError } from "../errors.js";
|
|
4
|
+
import { pickReleaseStack } from "../modules/release.js";
|
|
5
|
+
import { bumpVersion } from "../release/bump.js";
|
|
6
|
+
import { buildContext } from "./context.js";
|
|
7
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
8
|
+
/**
|
|
9
|
+
* `repokeeper bump <version>`: writes a release's version into the version files of the stack the
|
|
10
|
+
* repository releases. The GitLab release job runs it, where no release tool knows the stack's files.
|
|
11
|
+
*/
|
|
12
|
+
export async function bumpCommand(root, version, options, io) {
|
|
13
|
+
if (version === undefined || !VERSION.test(version)) {
|
|
14
|
+
throw new UsageError("usage: repokeeper bump <version> [--dry-run], with a version such as 1.4.0");
|
|
15
|
+
}
|
|
16
|
+
const ctx = await buildContext(root, await loadConfig(root));
|
|
17
|
+
const stack = pickReleaseStack(ctx.stacks);
|
|
18
|
+
if (stack === undefined)
|
|
19
|
+
throw new UsageError("no stack to release");
|
|
20
|
+
const folder = stack.directory ? join(root, stack.directory) : root;
|
|
21
|
+
const changed = await bumpVersion(folder, stack.release, version, !options.dryRun);
|
|
22
|
+
for (const path of changed)
|
|
23
|
+
io.out(`bumped ${stack.directory ? `${stack.directory}/${path}` : path}`);
|
|
24
|
+
if (changed.length === 0) {
|
|
25
|
+
io.out(`no file of the ${stack.id} stack holds a version to change; the release is its tag`);
|
|
26
|
+
}
|
|
27
|
+
if (options.dryRun)
|
|
28
|
+
io.out("dry run: nothing written");
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
package/dist/commands/context.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
2
5
|
import { repoInfo } from "../git.js";
|
|
3
6
|
import { platformFor } from "../platforms/index.js";
|
|
4
7
|
import { getStackPack } from "../stacks/index.js";
|
|
8
|
+
import { stackDirectory } from "../stacks/support.js";
|
|
5
9
|
export async function buildContext(root, config, repo) {
|
|
6
10
|
const platform = platformFor(config.platform);
|
|
7
11
|
const unsupported = config.stacks.find((id) => !platform.stacks.includes(id));
|
|
8
12
|
if (unsupported) {
|
|
9
13
|
throw new UsageError(`stack "${unsupported}" is not supported on ${platform.id} yet (supported: ${platform.stacks.join(", ")})`);
|
|
10
14
|
}
|
|
11
|
-
const stacks = await Promise.all(config.stacks.map((id) =>
|
|
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
|
+
}));
|
|
12
29
|
return { config, stacks, platform, repo: repo ?? (await repoInfo(root, config.platform)) };
|
|
13
30
|
}
|