├── .all-contributorsrc
├── .github
├── dependabot.yml
├── release.yml
└── workflows
│ ├── auto-merge-dependabot.yml
│ ├── ci.yml
│ ├── code_coverage_comment.yml
│ ├── release_auto.yml
│ ├── release_pr_snapshot.yml
│ └── remove_snapshot_branch.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── action.yml
├── package-lock.json
├── package.json
├── src
├── action
│ ├── main.ts
│ ├── post.ts
│ └── pre.ts
├── core
│ ├── cache.ts
│ ├── files.ts
│ ├── http.ts
│ ├── logger.ts
│ ├── os.ts
│ └── types.ts
├── modules
│ ├── coursier.ts
│ ├── github.test.ts
│ ├── github.ts
│ ├── healthcheck.test.ts
│ ├── healthcheck.ts
│ ├── input.test.ts
│ ├── input.ts
│ ├── mill.ts
│ ├── workspace.test.ts
│ └── workspace.ts
└── utils
│ └── docs.ts
└── tsconfig.json
/.all-contributorsrc:
--------------------------------------------------------------------------------
1 | {
2 | "projectName": "scala-steward-action",
3 | "projectOwner": "scala-steward-org",
4 | "repoType": "github",
5 | "repoHost": "https://github.com",
6 | "files": [
7 | "README.md"
8 | ],
9 | "imageSize": 100,
10 | "commit": true,
11 | "commitConvention": "none",
12 | "contributorsSortAlphabetically": "true",
13 | "contributors": [
14 | {
15 | "login": "alejandrohdezma",
16 | "name": "Alejandro Hernández",
17 | "avatar_url": "https://avatars.githubusercontent.com/u/9027541?v=4",
18 | "profile": "https://alejandrohdezma.com/",
19 | "contributions": [
20 | "code"
21 | ]
22 | },
23 | {
24 | "login": "laughedelic",
25 | "name": "Alexey Alekhin",
26 | "avatar_url": "https://avatars.githubusercontent.com/u/766656?v=4",
27 | "profile": "https://github.com/laughedelic",
28 | "contributions": [
29 | "code"
30 | ]
31 | },
32 | {
33 | "login": "fthomas",
34 | "name": "Frank Thomas",
35 | "avatar_url": "https://avatars.githubusercontent.com/u/141252?v=4",
36 | "profile": "https://github.com/fthomas",
37 | "contributions": [
38 | "code"
39 | ]
40 | },
41 | {
42 | "login": "ryota0624",
43 | "name": "ryota0624",
44 | "avatar_url": "https://avatars.githubusercontent.com/u/11390724?v=4",
45 | "profile": "https://github.com/ryota0624",
46 | "contributions": [
47 | "code"
48 | ]
49 | },
50 | {
51 | "login": "arashi01",
52 | "name": "Ali Salim Rashid",
53 | "avatar_url": "https://avatars.githubusercontent.com/u/1921493?v=4",
54 | "profile": "https://github.com/arashi01",
55 | "contributions": [
56 | "code"
57 | ]
58 | },
59 | {
60 | "login": "xuwei-k",
61 | "name": "kenji yoshida",
62 | "avatar_url": "https://avatars.githubusercontent.com/u/389787?v=4",
63 | "profile": "https://twitter.com/xuwei_k",
64 | "contributions": [
65 | "code",
66 | "question"
67 | ]
68 | },
69 | {
70 | "login": "EwoutH",
71 | "name": "Ewout ter Hoeven",
72 | "avatar_url": "https://avatars.githubusercontent.com/u/15776622?v=4",
73 | "profile": "https://github.com/EwoutH",
74 | "contributions": [
75 | "code"
76 | ]
77 | },
78 | {
79 | "login": "MPV",
80 | "name": "Victor Sollerhed",
81 | "avatar_url": "https://avatars.githubusercontent.com/u/62675?v=4",
82 | "profile": "http://victor.sollerhed.se/",
83 | "contributions": [
84 | "code"
85 | ]
86 | },
87 | {
88 | "login": "NomadBlacky",
89 | "name": "Takumi Kadowaki",
90 | "avatar_url": "https://avatars.githubusercontent.com/u/3215961?v=4",
91 | "profile": "https://www.nomadblacky.dev/",
92 | "contributions": [
93 | "code"
94 | ]
95 | },
96 | {
97 | "login": "TonioGela",
98 | "name": "Antonio Gelameris",
99 | "avatar_url": "https://avatars.githubusercontent.com/u/41690956?v=4",
100 | "profile": "https://toniogela.dev/",
101 | "contributions": [
102 | "code"
103 | ]
104 | },
105 | {
106 | "login": "bpg",
107 | "name": "Pavel Boldyrev",
108 | "avatar_url": "https://avatars.githubusercontent.com/u/627562?v=4",
109 | "profile": "http://ca.linkedin.com/in/pboldyrev/",
110 | "contributions": [
111 | "code"
112 | ]
113 | },
114 | {
115 | "login": "tovbinm",
116 | "name": "Matthew Tovbin",
117 | "avatar_url": "https://avatars.githubusercontent.com/u/629845?v=4",
118 | "profile": "https://www.tovbin.com/",
119 | "contributions": [
120 | "code"
121 | ]
122 | },
123 | {
124 | "login": "michele-pinto-kensu",
125 | "name": "Michele Pinto",
126 | "avatar_url": "https://avatars.githubusercontent.com/u/69146696?v=4",
127 | "profile": "https://github.com/michele-pinto-kensu",
128 | "contributions": [
129 | "ideas"
130 | ]
131 | },
132 | {
133 | "login": "spliakos",
134 | "name": "Stefanos Pliakos",
135 | "avatar_url": "https://avatars.githubusercontent.com/u/15560159?v=4",
136 | "profile": "https://github.com/spliakos",
137 | "contributions": [
138 | "ideas"
139 | ]
140 | },
141 | {
142 | "login": "jshiell",
143 | "name": "Jamie Shiell",
144 | "avatar_url": "https://avatars.githubusercontent.com/u/1030482?v=4",
145 | "profile": "https://infernus.org/",
146 | "contributions": [
147 | "bug"
148 | ]
149 | },
150 | {
151 | "login": "marcelocarlos",
152 | "name": "Marcelo Carlos",
153 | "avatar_url": "https://avatars.githubusercontent.com/u/16080771?v=4",
154 | "profile": "https://github.com/marcelocarlos",
155 | "contributions": [
156 | "bug"
157 | ]
158 | },
159 | {
160 | "login": "jeffboutotte",
161 | "name": "Jeff Boutotte",
162 | "avatar_url": "https://avatars.githubusercontent.com/u/6991403?v=4",
163 | "profile": "https://github.com/jeffboutotte",
164 | "contributions": [
165 | "code"
166 | ]
167 | },
168 | {
169 | "login": "milanvdm",
170 | "name": "Milan van der Meer",
171 | "avatar_url": "https://avatars.githubusercontent.com/u/5628925?v=4",
172 | "profile": "https://github.com/milanvdm",
173 | "contributions": [
174 | "bug"
175 | ]
176 | },
177 | {
178 | "login": "exoego",
179 | "name": "TATSUNO Yasuhiro",
180 | "avatar_url": "https://avatars.githubusercontent.com/u/127635?v=4",
181 | "profile": "https://www.exoego.net/",
182 | "contributions": [
183 | "code"
184 | ]
185 | },
186 | {
187 | "login": "francisdb",
188 | "name": "Francis De Brabandere",
189 | "avatar_url": "https://avatars.githubusercontent.com/u/161305?v=4",
190 | "profile": "https://github.com/francisdb",
191 | "contributions": [
192 | "bug"
193 | ]
194 | },
195 | {
196 | "login": "armanbilge",
197 | "name": "Arman Bilge",
198 | "avatar_url": "https://avatars.githubusercontent.com/u/3119428?v=4",
199 | "profile": "https://github.com/armanbilge",
200 | "contributions": [
201 | "bug",
202 | "code"
203 | ]
204 | },
205 | {
206 | "login": "ybasket",
207 | "name": "Yannick Heiber",
208 | "avatar_url": "https://avatars.githubusercontent.com/u/2632023?v=4",
209 | "profile": "https://github.com/ybasket",
210 | "contributions": [
211 | "code",
212 | "bug"
213 | ]
214 | },
215 | {
216 | "login": "wunderk1nd-e",
217 | "name": "Elias Court",
218 | "avatar_url": "https://avatars.githubusercontent.com/u/36158087?v=4",
219 | "profile": "https://k1nd.ltd/",
220 | "contributions": [
221 | "code"
222 | ]
223 | },
224 | {
225 | "login": "leobenkel",
226 | "name": "Leo Benkel",
227 | "avatar_url": "https://avatars.githubusercontent.com/u/4960573?v=4",
228 | "profile": "https://leobenkel.com/",
229 | "contributions": [
230 | "bug"
231 | ]
232 | },
233 | {
234 | "login": "anatoliykmetyuk",
235 | "name": "Anatolii Kmetiuk",
236 | "avatar_url": "https://avatars.githubusercontent.com/u/2614813?v=4",
237 | "profile": "https://akmetiuk.com/",
238 | "contributions": [
239 | "doc"
240 | ]
241 | },
242 | {
243 | "login": "jyrkih",
244 | "name": "Jyrki Hokkanen",
245 | "avatar_url": "https://avatars.githubusercontent.com/u/2580851?v=4",
246 | "profile": "https://github.com/jyrkih",
247 | "contributions": [
248 | "bug"
249 | ]
250 | },
251 | {
252 | "login": "yokra9",
253 | "name": "yokra",
254 | "avatar_url": "https://avatars.githubusercontent.com/u/53964890?v=4",
255 | "profile": "https://qiita.com/yokra9",
256 | "contributions": [
257 | "doc"
258 | ]
259 | },
260 | {
261 | "login": "ckipp01",
262 | "name": "Chris Kipp",
263 | "avatar_url": "https://avatars.githubusercontent.com/u/13974112?v=4",
264 | "profile": "https://chris-kipp.io/",
265 | "contributions": [
266 | "bug",
267 | "code"
268 | ]
269 | },
270 | {
271 | "login": "regadas",
272 | "name": "Filipe Regadas",
273 | "avatar_url": "https://avatars.githubusercontent.com/u/163899?v=4",
274 | "profile": "https://regadas.dev/",
275 | "contributions": [
276 | "doc"
277 | ]
278 | },
279 | {
280 | "login": "fmeriaux",
281 | "name": "Florian Meriaux",
282 | "avatar_url": "https://avatars.githubusercontent.com/u/16759768?v=4",
283 | "profile": "https://github.com/fmeriaux",
284 | "contributions": [
285 | "bug"
286 | ]
287 | },
288 | {
289 | "login": "adamw",
290 | "name": "Adam Warski",
291 | "avatar_url": "https://avatars.githubusercontent.com/u/60503?v=4",
292 | "profile": "http://www.warski.org/",
293 | "contributions": [
294 | "question"
295 | ]
296 | },
297 | {
298 | "login": "SpecialThing44",
299 | "name": "Spencer Perkins",
300 | "avatar_url": "https://avatars.githubusercontent.com/u/95900100?v=4",
301 | "profile": "https://github.com/SpecialThing44",
302 | "contributions": [
303 | "doc"
304 | ]
305 | }
306 | ],
307 | "contributorsPerLine": 6
308 | }
309 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "monthly"
7 | - package-ecosystem: npm
8 | directory: "/"
9 | schedule:
10 | interval: monthly
11 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | labels:
4 | - ":chart_with_upwards_trend: dependency-update"
5 | authors:
6 | - dependabot
7 | categories:
8 | - title: "⚠️ Breaking changes"
9 | labels:
10 | - ":warning: breaking"
11 | - title: "🚀 New features"
12 | labels:
13 | - ":rocket: feature"
14 | - title: "📘 Documentation updates"
15 | labels:
16 | - ":blue_book: documentation"
17 | - title: "🐛 Bug fixes"
18 | labels:
19 | - ":beetle: bug"
20 | - title: Other Changes
21 | labels:
22 | - "*"
23 |
--------------------------------------------------------------------------------
/.github/workflows/auto-merge-dependabot.yml:
--------------------------------------------------------------------------------
1 | name: Auto-merge Dependabot PR
2 |
3 | on: [pull_request_target]
4 |
5 | permissions:
6 | pull-requests: write
7 | contents: write
8 |
9 | jobs:
10 | auto-merge:
11 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'scala-steward-org/scala-steward-action' }}
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Auto-merge Dependabot PRs
15 | env:
16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
17 | PR_URL: ${{ github.event.pull_request.html_url }}
18 | run: gh pr merge --auto --squash "$PR_URL"
19 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | ci:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - name: Checkout project
10 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
11 |
12 | - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
13 | with:
14 | node-version: '20'
15 |
16 | - name: Run `npm install`
17 | run: npm install
18 |
19 | - name: Run `npm run all`
20 | run: npm run all
21 |
22 | - name: Save PR number
23 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
24 | run: echo ${{ github.event.number }} > PR_NUMBER
25 |
26 | - name: Upload artifact with `PR_NUMBER`
27 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
28 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
29 | with:
30 | name: PR_NUMBER
31 | path: PR_NUMBER
32 |
33 | - name: Upload artifact with `dist` folder
34 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
35 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
36 | with:
37 | name: dist
38 | path: dist/
39 |
40 | - name: Upload artifact with `covertura.xml`
41 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
42 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
43 | with:
44 | name: cobertura
45 | path: coverage/cobertura-coverage.xml
46 |
--------------------------------------------------------------------------------
/.github/workflows/code_coverage_comment.yml:
--------------------------------------------------------------------------------
1 | name: Add comment with code-coverage to PR
2 |
3 | on:
4 | workflow_run:
5 | workflows: ['CI']
6 | branches-ignore: ['dependabot/**']
7 | types: [completed]
8 |
9 | jobs:
10 | add-comment-with-code-coverage:
11 | runs-on: ubuntu-latest
12 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.workflow_run.conclusion == 'success' }}
13 | steps:
14 | - name: Checkout project
15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
16 |
17 | - name: Download cobertura.xml file
18 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
19 | with:
20 | run_id: ${{github.event.workflow_run.id }}
21 | name: cobertura
22 | path: .
23 |
24 | - name: Download PR_NUMBER
25 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
26 | with:
27 | run_id: ${{github.event.workflow_run.id }}
28 | name: PR_NUMBER
29 | path: .
30 |
31 | - name: Extract PR number
32 | id: extract-pr-number
33 | run: |
34 | pr_number=$(cat ./PR_NUMBER)
35 | echo "PR_NUMBER=$pr_number" >> $GITHUB_OUTPUT
36 |
37 | - name: Code Coverage Report
38 | uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0
39 | with:
40 | filename: cobertura-coverage.xml
41 | badge: true
42 | format: markdown
43 | output: both
44 | thresholds: '60 80'
45 |
46 | - name: Add Coverage PR Comment
47 | uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
48 | with:
49 | recreate: true
50 | header: coverage
51 | number: ${{ steps.extract-pr-number.outputs.PR_NUMBER }}
52 | path: code-coverage-results.md
53 |
--------------------------------------------------------------------------------
/.github/workflows/release_auto.yml:
--------------------------------------------------------------------------------
1 | # Releases a new minor version every time a PR is merged into `master`.
2 | #
3 | # It also generates the `dist` folder inside the tag's commit, keeping
4 | # the `master` branch clean.
5 | #
6 | # It will also update the major tag v2 to track the latest tag.
7 |
8 | name: Release new version
9 |
10 | on:
11 | workflow_dispatch:
12 | push:
13 | branches:
14 | - master
15 |
16 | jobs:
17 | new-release:
18 | name: Create new release
19 | runs-on: ubuntu-latest
20 | steps:
21 | - name: Checkout project
22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
23 | with:
24 | fetch-depth: 0
25 | ref: master
26 |
27 | - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0
28 | with:
29 | node-version: '20'
30 |
31 | - name: Run `npm install`
32 | run: npm install
33 |
34 | - name: Run `npm run build`
35 | run: npm run build
36 |
37 | - name: Release new version
38 | uses: int128/release-typescript-action@f30250a9bb537e3a541f581122deb035080e8f68 # v1.35.0
39 | with:
40 | major-version: 2
41 |
42 | update-docs:
43 | name: Update documentation
44 | if: ${{ github.actor != 'dependabot[bot]' }}
45 | runs-on: ubuntu-latest
46 | steps:
47 | - name: Checkout project
48 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
49 | with:
50 | fetch-depth: 0
51 | ref: master
52 |
53 | - name: Run `npm install`
54 | run: npm install
55 |
56 | - name: Run `npm run docs`
57 | run: npm run docs
58 |
59 | - name: Commit changes by `npm run docs`
60 | uses: alejandrohdezma/actions/commit-and-push@v1
61 | with:
62 | message: Run `npm run docs` [skip ci]
63 | branch: master
64 |
--------------------------------------------------------------------------------
/.github/workflows/release_pr_snapshot.yml:
--------------------------------------------------------------------------------
1 | name: Release a snapshot artifact for a PR
2 |
3 | on:
4 | workflow_run:
5 | workflows: ['CI']
6 | branches-ignore: ['dependabot/**']
7 | types: [completed]
8 |
9 | jobs:
10 | release-snapshot-artifact:
11 | runs-on: ubuntu-latest
12 | if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' && github.event.workflow_run.conclusion == 'success' }}
13 | steps:
14 | - name: Download PR_NUMBER
15 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
16 | with:
17 | run_id: ${{github.event.workflow_run.id }}
18 | name: PR_NUMBER
19 | path: .
20 |
21 | - name: Extract PR number
22 | id: extract-pr-number
23 | run: |
24 | pr_number=$(cat ./PR_NUMBER)
25 | echo "PR_NUMBER=$pr_number" >> $GITHUB_OUTPUT
26 |
27 | - name: Checkout project
28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
29 |
30 | - name: Checkout PR
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 | run: gh pr checkout ${{ steps.extract-pr-number.outputs.PR_NUMBER }}
34 |
35 | - name: Download dist folder
36 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9
37 | with:
38 | run_id: ${{github.event.workflow_run.id }}
39 | name: dist
40 | path: dist
41 |
42 | - name: Remove `dist` from `.gitignore`
43 | run: sed -i -E 's|^/?dist/?||g' .gitignore
44 |
45 | - name: Create snapshot branch
46 | uses: alejandrohdezma/actions/commit-and-push@v1
47 | with:
48 | message: 'Release snapshot for #${{ steps.extract-pr-number.outputs.PR_NUMBER }}'
49 | force-push: 'true'
50 | branch: snapshots/${{ steps.extract-pr-number.outputs.PR_NUMBER }}
51 |
52 | - name: Create Comment
53 | uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
54 | with:
55 | recreate: true
56 | header: snapshot
57 | number: ${{ steps.extract-pr-number.outputs.PR_NUMBER }}
58 | message: |
59 | A snapshot release has been created as `snapshots/${{ steps.extract-pr-number.outputs.PR_NUMBER }}`.
60 |
61 | You can test it out with:
62 |
63 | ```yaml
64 | uses: scala-steward-org/scala-steward-action@snapshots/${{ steps.extract-pr-number.outputs.PR_NUMBER }}
65 | ```
66 |
67 | It will be automatically recreated on any change to this PR.
68 |
--------------------------------------------------------------------------------
/.github/workflows/remove_snapshot_branch.yml:
--------------------------------------------------------------------------------
1 | name: Remove snapshot branch
2 |
3 | on:
4 | pull_request_target:
5 | types:
6 | - closed
7 |
8 | jobs:
9 | delete-pr-branch:
10 | runs-on: ubuntu-latest
11 | if: github.event.sender.login != 'dependabot[bot]'
12 | steps:
13 | - name: Checkout project
14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
15 |
16 | - name: Remove snapshot branch
17 | run: git push origin -d snapshots/${{ github.event.number }} || true
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | node_modules
3 |
4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
15 |
16 | # Runtime data
17 | pids
18 | *.pid
19 | *.seed
20 | *.pid.lock
21 |
22 | # Directory for instrumented libs generated by jscoverage/JSCover
23 | lib-cov
24 |
25 | # Coverage directory used by tools like istanbul
26 | coverage
27 | *.lcov
28 |
29 | # nyc test coverage
30 | .nyc_output
31 |
32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
33 | .grunt
34 |
35 | # Bower dependency directory (https://bower.io/)
36 | bower_components
37 |
38 | # node-waf configuration
39 | .lock-wscript
40 |
41 | # Compiled binary addons (https://nodejs.org/api/addons.html)
42 | build/Release
43 |
44 | # Dependency directories
45 | jspm_packages/
46 |
47 | # TypeScript v1 declaration files
48 | typings/
49 |
50 | # TypeScript cache
51 | *.tsbuildinfo
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional REPL history
60 | .node_repl_history
61 |
62 | # Output of 'npm pack'
63 | *.tgz
64 |
65 | # Yarn Integrity file
66 | .yarn-integrity
67 |
68 | # dotenv environment variables file
69 | .env
70 | .env.test
71 |
72 | # parcel-bundler cache (https://parceljs.org/)
73 | .cache
74 |
75 | # next.js build output
76 | .next
77 |
78 | # nuxt.js build output
79 | .nuxt
80 |
81 | # vuepress build output
82 | .vuepress/dist
83 |
84 | # Serverless directories
85 | .serverless/
86 |
87 | # FuseBox cache
88 | .fusebox/
89 |
90 | # DynamoDB Local files
91 | .dynamodb/
92 |
93 | # OS metadata
94 | .DS_Store
95 | Thumbs.db
96 |
97 | # Ignore built ts files
98 | __tests__/runner/*
99 | lib/**/*
100 |
101 | # Ignore IntelliJ IDEA files
102 | .idea
103 |
104 | # Ignore Visual Studio Code files
105 | .vscode
106 |
107 | # Ignore `dist` folder, it is generated only inside tag's commits
108 | dist
109 |
110 | # Ignore coverage folder generated by `c8`
111 | coverage
112 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct
2 |
3 | We are committed to providing a friendly, safe and welcoming environment for
4 | all, regardless of level of experience, gender, gender identity and expression,
5 | sexual orientation, disability, personal appearance, body size, race, ethnicity,
6 | age, religion, nationality, or other such characteristics.
7 |
8 | Everyone is expected to follow the [Scala Code of Conduct](https://www.scala-lang.org/conduct/)
9 | when discussing the project on the available communication channels. If you are being harassed, please
10 | contact us immediately so that we can support you.
11 |
12 | ## Moderation
13 |
14 | For any questions, concerns, or moderation requests please contact a member of the project.
15 |
16 | - [Alejandro Hernández](mailto:info@alejandrohdezma.com)
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2020 Alejandro Hernández
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Scala Steward GitHub Action
2 |
3 | A GitHub Action to launch [Scala Steward](https://github.com/scala-steward-org/scala-steward) in your repository.
4 |
5 |
6 |
7 | - [Installation](#installation)
8 | - [Simple workflow using default GitHub Actions App](#simple-workflow-using-default-github-actions-app)
9 | - [Workflow with a custom GitHub App to manage multiple repositories](#workflow-with-a-custom-github-app-to-manage-multiple-repositories)
10 | - [Usage](#usage)
11 | - [Guides](#guides)
12 | - [Contributors](#contributors)
13 |
14 |
15 |
16 | ---
17 |
18 | ## Installation
19 |
20 | You can use this action for [individual repositories](#simple-workflow-using-default-github-actions-app) or you can configure a repository to [manage many repositories](#workflow-with-a-custom-github-app-to-manage-multiple-repositories).
21 |
22 | ### Simple workflow using default GitHub Actions App
23 |
24 | ```yaml
25 | on:
26 | schedule:
27 | - cron: '0 0 * * 0'
28 |
29 | name: Scala Steward
30 |
31 | permissions:
32 | contents: write
33 | pull-requests: write
34 |
35 | jobs:
36 | scala-steward:
37 | runs-on: ubuntu-latest
38 | name: Scala Steward
39 | steps:
40 | - name: Scala Steward
41 | uses: scala-steward-org/scala-steward-action@v2
42 | ```
43 |
44 | See [Usage](#usage)
45 |
46 | Note that you won't be able to use any of `github-app-*`, `repos-file`, or `github-repository` inputs as the GitHub Actions App can only possibly write to the current repository.
47 |
48 | You will also need to ensure that GitHub Actions has permissions to create and approve pull requests for the [organization](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#preventing-github-actions-from-creating-or-approving-pull-requests) or [repository](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#preventing-github-actions-from-creating-or-approving-pull-requests).
49 |
50 |
51 | ### Workflow with a custom GitHub App to manage multiple repositories
52 |
53 | To use the Action in your repo, you need to create a GitHub App. Then you need to create a new GitHub Actions workflow file to run this Action. Here is a step-by-step tutorial on how to do it:
54 |
55 | 1. Create a new GitHub App
56 |
57 | If you are creating a GitHub App for your personal account, just click [here][github-app-personal] and it will create one with the base settings already pre-configured.
58 |
59 | On the other hand, if you are creating the App for an organization account, copy [this url][github-app-organization] and replace `my_org` with the name of your organization.
60 |
61 | > You will need to rename the App's name to a handler that is not already taken. You can use `scala-steward-{my-github-login}` if you are creating the app for your personal account; otherwise, you can use `scala-steward-{my-org}`.
62 |
63 | Alternatively, you can follow the official guide for creating a GitHub App :point_down:
64 |
65 | Official guide
66 |
67 | Follow the GitHub's [Creating a GitHub App](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app) Guide.
68 |
69 | 1. If you're setting up this Action for an organisation-owned repo, note that the step (1) of the "Creating a GitHub App" Guide tells you how to create an organization-level App.
70 | 2. Step (7) of the Guide tells you to specify the homepage – you can write a random URL there.
71 | 3. Step (13) of the Guide tells you to specify the Webhook URL - you don't need it. Uncheck the box.
72 | 4. Step (15) of the Guide asks you which permissions you want your app to have. Specify the following:
73 | - Metadata: Read-only
74 | - Pull requests: Read and write
75 | - Contents: Read and write
76 |
77 |
78 |
79 | Optional: Upload a profile picture for the newly created App.
80 |
81 | 1. Locate the newly created App's Settings. To do so, go to the settings of either your personal profile or of that of your organisation (depending on where you created the App), select "Developer Settings" from the side bar, then click "GitHub Apps". Find your app, and click "Edit" next to it.
82 | - To access your personal settings, click on your profile icon at the top-right corner of the GitHub interface, click "Settings".
83 | - To access the settings of an organisation, click on your profile icon at the top-right, select "Your organizations", find the organisation for which you created an App and click "Settings" for that organisation.
84 | 2. In the settings, locate the "Display information" section and press the "Upload a logo" button.
85 | 3. If you want to use [Scala Steward's official logo](https://github.com/scala-steward-org/scala-steward/raw/main/data/images/scala-steward-logo-circle-0.png) just download it to a folder in your computer and upload it back using the input. Then set "Badge background color" to `#3d5a80`
86 |
87 |
88 |
89 | 2. Install the App
90 |
91 | 1. At the App Settings, at the sidebar, click the "Public page" button, there, click the green "Install" button.
92 | 2. Select whether you'd like to install it account-wide or only for selected repos. If you install it for your entire account (personal or organisation), Scala Steward will try to update every repository in your organization, even if they're not Scala repositories.
93 | 3. Click "Install".
94 | 4. When the new page opens, find its URL and copy the number behind `https://github.com/settings/installations/`. It is the installation ID, you will need it in the following step.
95 |
96 |
97 |
98 | 3. Copy the App ID, the App private key and the installation ID
99 |
100 | Locate the App ID, the installation ID and the App private key for usage in the next step of this tutorial. All of them can be accessed from your App's Settings.
101 |
102 | 1. App ID is available in the "About" section of the Settings.
103 | 2. You should have the installation ID from step (2.4). If you didn't copy it, go to the App's settings and click on "Install App" on the left. On the new page you should see the account where you install the app. Click the "gear" icon on the right. When the next page loads, find its URL and copy the number behind `https://github.com/settings/installations/`. That's the installation ID.
104 | 3. The private key needs to be generated from the "Private keys" section. Clicking the "Generate private key" button will download a `*.pem` file on your computer. Save that file for the following step.
105 |
106 |
107 |
108 | 4. Create repo secrets
109 |
110 | Create repo secrets for the private key, the app id and the installation ID in the repository from where you want to run this action.
111 |
112 | 1. To do so, from the repo's page, click the "Settings" tab. There, select "Secrets" at the sidebar, and click "Actions" at the dropdown menu. Click "New repository secret".
113 | 2. At the "Name" field, enter `APP_PRIVATE_KEY`. Then, open the ".pem" file you downloaded at step (3.3) with a text editor, and copy the contents. Make sure to copy everything, including the first line `-----BEGIN RSA PRIVATE KEY-----` and the last line `-----END RSA PRIVATE KEY-----`. Paste it at the "Value" text area. Click "Add Secret".
114 | 3. Repeat the previous steps (4.1) to add a secret for the app id you recover on step (3.1). Specify `APP_ID` as the name.
115 | 4. Repeat the previous steps (4.1) to add a secret for the installation id you recover on step (3.2). Specify `APP_INSTALLATION_ID` as the name.
116 |
117 |
118 |
119 | 5. Create a new GitHub Actions Workflow
120 |
121 | Create a new GitHub Actions Workflow file, e.g. `.github/workflows/scala-steward.yml`, in the repo where you're installing this Action. Paste the following content into that file:
122 |
123 | ```yaml
124 | on:
125 | schedule:
126 | - cron: '0 0 * * 0'
127 |
128 | name: Launch Scala Steward
129 |
130 | jobs:
131 | scala-steward:
132 | runs-on: ubuntu-latest
133 | name: Launch Scala Steward
134 | steps:
135 | - name: Launch Scala Steward
136 | uses: scala-steward-org/scala-steward-action@v2
137 | with:
138 | github-app-id: ${{ secrets.APP_ID }}
139 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
140 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
141 | ```
142 |
143 |
144 |
145 | 6. Scala Steward does its magic :tada:
146 |
147 | If you have used the default cron expression the workflow will launch at 00:00 every Sunday. If you want to change it to a different schedule, you can check [this page](https://crontab.guru).
148 |
149 | When it launches it will send PR to update all the repos selected in step (2.2).
150 |
151 |
152 |
153 | ## Usage
154 |
155 |
156 | ```yaml
157 | - uses: scala-steward-org/scala-steward-action@v2
158 | with:
159 | # Artifact migrations for newer versions of artifacts with
160 | # different group Ids, artifact ids, or both different.
161 | #
162 | # Expects the path to HOCON file with migration/s.
163 | #
164 | # See https://github.com/scala-steward-org/scala-steward/blob/main/docs/artifact-migrations.md
165 | artifact-migrations: ''
166 |
167 | # Author email address to use in commits. If set it will
168 | # override any email retrieved from GitHub.
169 | author-email: ''
170 |
171 | # Author name to use in commits. If set it will override
172 | # any name retrieved from GitHub.
173 | author-name: ''
174 |
175 | # A comma-separated list of branches to update (if not
176 | # provided, the repository's default branch will be
177 | # updated instead).
178 | #
179 | # This option only has effect if updating the current
180 | # repository or using the `github-repository` input.
181 | branches: ''
182 |
183 | # TTL of cache for fetching dependency versions and
184 | # metadata, set it to 0s to disable it.
185 | #
186 | # Default: 2hours
187 | cache-ttl: ''
188 |
189 | # Size of the buffer for the output of an external process
190 | # in lines.
191 | #
192 | # Default: 16384
193 | max-buffer-size: ''
194 |
195 | # Url to download the coursier linux CLI from.
196 | #
197 | # Default: https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz
198 | coursier-cli-url: ''
199 |
200 | # The URL of the GitHub API, only use this input if
201 | # you are using GitHub Enterprise.
202 | #
203 | # Default: https://api.github.com
204 | github-api-url: ''
205 |
206 | # If set to `true` the GitHub App information will
207 | # only be used for authentication.
208 | #
209 | # Repositories to update will be read from either
210 | # the `repos-file` or the `github-repository` inputs.
211 | #
212 | # Default: false
213 | github-app-auth-only: ''
214 |
215 | # GitHub App ID. See the "Installation" section of the
216 | # README to learn how to set up the app and how to fill this input.
217 | github-app-id: ''
218 |
219 | # GitHub App Installation ID. See the "Installation"
220 | # section of the README to learn how to set up the app
221 | # and how to fill this input.
222 | github-app-installation-id: ''
223 |
224 | # GitHub App Private Key. See the "Installation" section
225 | # of the README to learn how to set up the app and how to
226 | # fill this input.
227 | github-app-key: ''
228 |
229 | # Repository to update. Will be ignored if either
230 | # `repos-file` is provided or the `github-app-*`
231 | # inputs are and `github-app-auth-only` is not `true`.
232 | #
233 | # Default: ${{ github.repository }}
234 | github-repository: ''
235 |
236 | # GitHub Personal Access Token with permission to create
237 | # branches on repo.
238 | #
239 | # If `github-app-*` inputs are provided an App's
240 | # installation token will be used instead of this one.
241 | #
242 | # Default: ${{ github.token }}
243 | github-token: ''
244 |
245 | # Whether to ignore "opts" files (such as `.jvmopts`
246 | # or `.sbtopts`) when found on repositories or not.
247 | #
248 | # Default: true
249 | ignore-opts-files: ''
250 |
251 | # Mill version to install. Take into account this will
252 | # just affect the global `mill` executable. Scala
253 | # Steward will still respect the version specified in
254 | # your repository while updating it.
255 | #
256 | # Default: 0.12.5
257 | mill-version: ''
258 |
259 | # Other Scala Steward arguments not yet supported by
260 | # this action as a separate argument.
261 | other-args: ''
262 |
263 | # Location of a `.scala-steward.conf` file with default
264 | # values.
265 | #
266 | # If you specify a file and it does not exist in the
267 | # selected branch then this action will fail when it runs.
268 | #
269 | # See https://github.com/scala-steward-org/scala-steward/blob/main/docs/repo-specific-configuration.md
270 | # Default: .github/.scala-steward.conf
271 | repo-config: ''
272 |
273 | # Path to a file containing the list of repositories
274 | # to update in markdown format:
275 | #
276 | # - owner/repo1
277 | # - owner/repo2
278 | #
279 | # This input will be ignored if the `github-app-*`
280 | # inputs are provided and `github-app-auth-only` is
281 | # not `true`.
282 | repos-file: ''
283 |
284 | # Scala Steward version to use. If not provided it
285 | # will use the last one published.
286 | scala-steward-version: ''
287 |
288 | # Scalafix rules for version updates to run after
289 | # certain updates.
290 | #
291 | # Expects the path to HOCON file with migration/s.
292 | #
293 | # See https://github.com/scala-steward-org/scala-steward/blob/main/docs/scalafix-migrations.md
294 | scalafix-migrations: ''
295 |
296 | # Whether to sign commits or not.
297 | #
298 | # Default: false
299 | sign-commits: ''
300 |
301 | # Key ID of GPG key to use for signing commits. See the
302 | # "Signing commits with GPG" section to learn how to
303 | # prepare the environment and fill this input.
304 | signing-key: ''
305 |
306 | # Timeout for external process invocations.
307 | #
308 | # Default: 20min
309 | timeout: ''
310 | ```
311 |
312 |
313 | ## Guides
314 |
315 | Manually triggering a run
316 |
317 | You can manually trigger workflow runs using the [workflow_dispatch](https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#workflow_dispatch) event:
318 |
319 | ```diff
320 | on:
321 | + workflow_dispatch:
322 | schedule:
323 | - cron: '0 0 * * 0'
324 |
325 | name: Launch Scala Steward
326 |
327 | jobs:
328 | scala-steward:
329 | runs-on: ubuntu-latest
330 | name: Launch Scala Steward
331 | steps:
332 | - name: Launch Scala Steward
333 | uses: scala-steward-org/scala-steward-action@v2
334 | with:
335 | github-app-id: ${{ secrets.APP_ID }}
336 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
337 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
338 | ```
339 | Once you've added this trigger GitHub will show a "Run workflow" button at the workflow page.
340 |
341 |
342 |
343 |
344 | Specify JVM version
345 |
346 | If you would like to specify a specific Java version (e.g Java 11) please add the following step before `Launch Scala Steward` step:
347 |
348 | ```yaml
349 | - name: Set up JDK 11
350 | uses: actions/setup-java@v3
351 | with:
352 | java-version: 11
353 | distribution: temurin
354 | ```
355 |
356 |
357 |
358 |
359 | Add JVM options to the running JVM
360 |
361 | If you would like to add JVM options (such as `-Xmx`) to the Scala Steward JVM process please add the following to the main `Launch Scala Steward` step:
362 |
363 | ```yaml
364 | - name: Launch Scala Steward
365 | uses: scala-steward-org/scala-steward-action@v2
366 | env:
367 | JAVA_OPTS: "-XX:+UseG1GC -Xms4G -Xmx4G -Xss2M -XX:MetaspaceSize=512M"
368 | ```
369 |
370 |
371 |
372 |
373 | Updating a specific repository
374 |
375 | When using the `github-app-*` inputs, Scala Steward will always retrieve the list of repositories to update from the App's installation. You can override this by setting `github-app-auth-only` to `'true'`. This way the action will only use the app credentials to authenticate and will update the repository set on the `github-repository` input (defaults to the current repository).
376 |
377 | ```yaml
378 | on:
379 | schedule:
380 | - cron: '0 0 * * 0'
381 |
382 | name: Launch Scala Steward
383 |
384 | jobs:
385 | scala-steward:
386 | runs-on: ubuntu-latest
387 | name: Launch Scala Steward
388 | steps:
389 | - name: Launch Scala Steward
390 | uses: scala-steward-org/scala-steward-action@v2
391 | with:
392 | github-app-id: ${{ secrets.APP_ID }}
393 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
394 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
395 | github-app-auth-only: 'true'
396 | ```
397 |
398 | To update a repository other than the one where the Action runs, we can override the `github-repository` input. Just set it to the name (owner/repo) of the repository you would like to update.
399 |
400 | ```yaml
401 | - name: Launch Scala Steward
402 | uses: scala-steward-org/scala-steward-action@v2
403 | with:
404 | github-app-id: ${{ secrets.APP_ID }}
405 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
406 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
407 | github-app-auth-only: 'true'
408 | github-repository: owner/repo
409 | ```
410 |
411 |
412 |
413 |
414 | Update specific repositories (listed on a file)
415 |
416 | When using the `github-app-*` inputs, Scala Steward will always retrieve the list of repositories to update from the App's installation. You can override this by setting `github-app-auth-only` to `'true'`. This way the action will only use the app credentials to authenticate and will allow other mechanisms for selecting which repository should be updated. For example, you can specify a list of repositories in a markdown file.
417 |
418 | 1. Create a file containing the list of repositories in markdown format:
419 | ```markdown
420 | # repos.md
421 | - owner/repo_1
422 | - owner/repo_2
423 | ```
424 | 2. Put that file inside the repository directory (so it is accessible to Scala Steward's action).
425 | 3. Provide it to the action using `repos-file`:
426 | ```yaml
427 | - name: Checkout repository so `repos.md` is available
428 | uses: actions/checkout@v2
429 |
430 | - name: Launch Scala Steward
431 | uses: scala-steward-org/scala-steward-action@v2
432 | with:
433 | github-app-id: ${{ secrets.APP_ID }}
434 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
435 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
436 | github-app-auth-only: 'true'
437 | repos-file: 'repos.md'
438 | ```
439 |
440 | >
441 |
442 | > This input (if present) will always take precedence over `github-repository`.
443 |
444 |
445 |
446 |
447 | Update one or more specific branches
448 |
449 | > **Important!** This input is only used when using the `github-repository` input (see the "Updating a specific repository" guide). For cases where the `repos-file` input is used (see the "Update specific repositories (listed on a file)" guide), you should follow the [instructions to update multiple branches in a repository](https://github.com/scala-steward-org/scala-steward/blob/main/docs/faq.md#can-scala-steward-update-multiple-branches-in-a-repository).
450 |
451 | > **This input won't have any effect when using a GitHub App for listing the repositories to update.**
452 |
453 | By default, Scala Steward uses the repository's default branch to make the updates. If you want to customize that behavior, you can use the `branches` input:
454 |
455 | ```yml
456 | - name: Launch Scala Steward
457 | uses: scala-steward-org/scala-steward-action@v2
458 | with:
459 | github-app-id: ${{ secrets.APP_ID }}
460 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
461 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
462 | github-app-auth-only: 'true'
463 | github-repository: owner/repo
464 | branches: main,0.1.x,0.2.x
465 | ```
466 |
467 |
468 |
469 |
470 | Disable ignoring OPTS files
471 |
472 | By default, Scala Steward will ignore "opts" files (such as `.jvmopts` or `.sbtopts`) when found on repositories, if you want to disable this feature, use the `ignore-opts-files` input:
473 |
474 | ```yaml
475 | - name: Launch Scala Steward
476 | uses: scala-steward-org/scala-steward-action@v2
477 | with:
478 | github-app-id: ${{ secrets.APP_ID }}
479 | github-app-installation-id: ${{ secrets.APP_INSTALLATION_ID }}
480 | github-app-key: ${{ secrets.APP_PRIVATE_KEY }}
481 | ignore-opts-files: false
482 | ```
483 |
484 |
485 |
486 |
487 | Run Scala Steward with step debug logging
488 |
489 | You just need to enable [GitHub Actions' "step debug logging"](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging#enabling-step-debug-logging) and Scala Steward will start automatically in debug mode too.
490 |
491 | For this you must set the following secret in the repository that contains the workflow: `ACTIONS_STEP_DEBUG` to `true` (as stated in GitHub's documentation).
492 |
493 | > Alternatively, if you are re-running a failed job and want to re-run it in debug
494 | > mode, follow this tutorial and check `Enable debug logging` before clicking on
495 | > `Re-run jobs`.
496 | >
497 | > 
498 |
499 |
500 |
501 |
502 | Running locally to attach a JVM debugger
503 |
504 | When debugging the behaviour of Scala Steward, it can be helpful to run Scala Steward
505 | locally, while mimicking the settings used by the Scala Steward GitHub Action, so that
506 | a debugger can be attached - [the Guardian have notes on how they do that](https://github.com/guardian/scala-steward-public-repos/blob/main/running-locally.md),
507 | which may provide a helpful example if you need to do that in your own organisation.
508 |
509 |
510 |
511 |
512 | Using a Personal Access Token (instead of the GitHub App)
513 |
514 | If for any reason you want to use the default GitHub Token available in GitHub Actions, you won't be able to use the `github-app-*` inputs. If you still want to use it you just need to remove all the `github-app-*` inputs and follow these steps:
515 |
516 | 1. Generate a [GitHub Personal Access Token](https://github.com/settings/tokens) with `repo` permissions for reading/writing in the repository/repositories you wish to update.
517 | 2. Add it as a repository secret.
518 | 3. Follow either the `Updating a specific repository` or the `Update specific repositories (listed on a file)` guides to provide a repository to update.
519 | 3. Provide the token to the action using the `github-token` input.
520 |
521 | **Example updating the current repository with the default GitHub Token**
522 |
523 | ```yaml
524 | - name: Launch Scala Steward
525 | uses: scala-steward-org/scala-steward-action@v2
526 | with:
527 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
528 | ```
529 |
530 | **Example updating a specific repository with the default GitHub Token**
531 |
532 | ```yaml
533 | - name: Launch Scala Steward
534 | uses: scala-steward-org/scala-steward-action@v2
535 | with:
536 | github-repository: owner/repo
537 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
538 | ```
539 |
540 | **Example updating a list of repositories (from a file) with the default GitHub Token**
541 |
542 | ```yaml
543 | - name: Launch Scala Steward
544 | uses: scala-steward-org/scala-steward-action@v2
545 | with:
546 | github-repository: owner/repo
547 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
548 | repos-file: 'repos.md'
549 | ```
550 |
551 | Beware that using the Personal Access Token will make it look like it's you who submitted all the PRs. The workaround for this is to create a separate GitHub account for the Action and give it the [Collaborator](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/inviting-collaborators-to-a-personal-repository) permission in the repository or repositories you wish to update.
552 |
553 | Make sure the account you choose has *Name* and *Public email* fields defined in its [Public Profile](https://github.com/settings/profile), as they will be used by Scala Steward to make commits.
554 |
555 | If the account has [personal email address protection](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/blocking-command-line-pushes-that-expose-your-personal-email-address) enabled, then you will need to explicitly specify an email to use in commits:
556 |
557 | ```yaml
558 | - name: Launch Scala Steward
559 | uses: scala-steward-org/scala-steward-action@v2
560 | with:
561 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
562 | author-email: 12345+octocat@users.noreply.github.com
563 | ```
564 |
565 |
566 |
567 |
568 | Sign commits created by Scala Steward
569 |
570 | > Signing commits only take place when using a GitHub Personal Access Token (see the "Using a Personal Access Token (instead of the GitHub App)" guide).
571 |
572 | If you want commits created by Scala Steward to be automatically signed with a GPG key, follow these steps:
573 |
574 | 1. Generate a new GPG key following [GitHub's own tutorial](https://help.github.com/en/github/authenticating-to-github/generating-a-new-gpg-key).
575 | 2. Add your new GPG key to your [user's GitHub account](https://github.com/settings/keys) following [GitHub's own tutorial](https://help.github.com/en/github/authenticating-to-github/adding-a-new-gpg-key-to-your-github-account).
576 | 3. Export the GPG private key as an ASCII armored version to your clipboard (change `joe@foo.bar` with your key email address):
577 |
578 | ```bash
579 | # macOS
580 | gpg --armor --export-secret-key joe@foo.bar | pbcopy
581 |
582 | # Ubuntu (assuming GNU base64)
583 | gpg --armor --export-secret-key joe@foo.bar -w0 | xclip
584 |
585 | # Arch
586 | gpg --armor --export-secret-key joe@foo.bar | sed -z 's;\n;;g' | xclip -selection clipboard -i
587 |
588 | # FreeBSD (assuming BSD base64)
589 | gpg --armor --export-secret-key joe@foo.bar | xclip
590 | ```
591 |
592 | 4. Paste your clipboard as a new `GPG_PRIVATE_KEY` repository secret.
593 | 5. If the key is passphrase protected, add the passphrase as another repository secret called `GPG_PASSPHRASE`.
594 | 6. Import it to the workflow using an action such us [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg):
595 |
596 | ```yaml
597 | - name: Import GPG key
598 | uses: crazy-max/ghaction-import-gpg@v2
599 | with:
600 | git_user_signingkey: true
601 | env:
602 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
603 | PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
604 | ```
605 |
606 | 7. Obtain the key ID for the key that should be used. For instance, in the following example, the GPG key ID is `3AA5C34371567BD2`:
607 |
608 | ```
609 | $ gpg --list-secret-keys --keyid-format=long
610 |
611 | ~/.gnupg/secring.gpg
612 | ------------------------------------
613 | sec 4096R/3AA5C34371567BD2 2022-01-01
614 | uid My Name
615 | ssb 4096R/42B317FD4BA89E7A 2022-01-01
616 | ```
617 |
618 | 8. Copy the key ID and paste it as the content of a new repository secret, named `GPG_SIGNING_KEY_ID`.
619 |
620 | 9. Tell Scala Steward to sign commits using the `sign-commits` input. Use as well the `signing-key` parameter to allow Scala Steward to use the correct key:
621 |
622 | ```yaml
623 | - name: Launch Scala Steward
624 | uses: scala-steward-org/scala-steward-action@v2
625 | with:
626 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
627 | signing-key: ${{ secrets.GPG_SIGNING_KEY_ID }}
628 | sign-commits: true
629 | ```
630 |
631 | 10. **Optional**. By default, Scala Steward will use the email/name of the user that created the token added in `github-token`, if you want to override that behavior, you can use `author-email`/`author-name` inputs, for example with the values extracted from the imported private key:
632 |
633 | ```yaml
634 | - name: Import GPG key
635 | id: import_gpg
636 | uses: crazy-max/ghaction-import-gpg@v2
637 | with:
638 | git_user_signingkey: true
639 | env:
640 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
641 | PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
642 |
643 | - name: Launch Scala Steward
644 | uses: scala-steward-org/scala-steward-action@v2
645 | with:
646 | github-token: ${{ secrets.REPO_GITHUB_TOKEN }}
647 | signing-key: ${{ secrets.GPG_SIGNING_KEY_ID }}
648 | sign-commits: true
649 | author-email: ${{ steps.import_gpg.outputs.email }}
650 | author-name: ${{ steps.import_gpg.outputs.name }}
651 | ```
652 |
653 |
654 |
655 |
656 | ## Contributors
657 |
658 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
659 |
660 |
661 |
662 |
663 |
711 |
712 |
713 |
714 |
715 |
716 |
717 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
718 |
719 | [github-app-personal]: https://github.com/settings/apps/new?name=scala-steward&description=GitHub%20App%20to%20facilitate%20running%20Scala%20Steward%20against%20my%20repositories&url=https://github.com/scala-steward-org/scala-steward&public=false&webhook_active=false&pull_requests=write&contents=write
720 | [github-app-organization]: https://github.com/organizations/my_org/settings/apps/new?name=scala-steward&description=GitHub%20App%20to%20facilitate%20running%20Scala%20Steward%20against%20my%20repositories&url=https://github.com/scala-steward-org/scala-steward&public=false&webhook_active=false&pull_requests=write&contents=write
721 |
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | name: Scala Steward GitHub Action
2 | author: The Scala Steward contributors
3 | description: 🤖 A bot that helps you keeping your Scala projects up-to-date
4 | branding:
5 | icon: arrow-up-circle
6 | color: red
7 |
8 | inputs:
9 | artifact-migrations:
10 | description: |
11 | Artifact migrations for newer versions of artifacts with
12 | different group Ids, artifact ids, or both different.
13 |
14 | Expects the path to HOCON file with migration/s.
15 |
16 | See https://github.com/scala-steward-org/scala-steward/blob/main/docs/artifact-migrations.md
17 | required: false
18 | author-email:
19 | description: |
20 | Author email address to use in commits. If set it will
21 | override any email retrieved from GitHub.
22 | required: false
23 | author-name:
24 | description: |
25 | Author name to use in commits. If set it will override
26 | any name retrieved from GitHub.
27 | required: false
28 | branches:
29 | description: |
30 | A comma-separated list of branches to update (if not
31 | provided, the repository's default branch will be
32 | updated instead).
33 |
34 | This option only has effect if updating the current
35 | repository or using the `github-repository` input.
36 | required: false
37 | cache-ttl:
38 | description: |
39 | TTL of cache for fetching dependency versions and
40 | metadata, set it to 0s to disable it.
41 | default: 2hours
42 | required: false
43 | max-buffer-size:
44 | description: |
45 | Size of the buffer for the output of an external process
46 | in lines. The default is 16384.
47 | default: "16384"
48 | required: false
49 | coursier-cli-url:
50 | description: |
51 | Url to download the coursier linux CLI from.
52 | default: https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz
53 | required: false
54 | extra-jars:
55 | description: |
56 | Extra JARs to be added to the classpath of the
57 | launched application. Directories accepted too.
58 | required: false
59 | github-api-url:
60 | description: |
61 | The URL of the GitHub API, only use this input if
62 | you are using GitHub Enterprise.
63 | default: ${{ github.api_url }}
64 | required: false
65 | github-app-auth-only:
66 | description: |
67 | If set to `true` the GitHub App information will
68 | only be used for authentication.
69 |
70 | Repositories to update will be read from either
71 | the `repos-file` or the `github-repository` inputs.
72 | default: "false"
73 | required: false
74 | github-app-id:
75 | description: |
76 | GitHub App ID. See the "Installation" section of the
77 | README to learn how to set up the app and how to fill this input.
78 | required: false
79 | github-app-installation-id:
80 | description: |
81 | GitHub App Installation ID. See the "Installation"
82 | section of the README to learn how to set up the app
83 | and how to fill this input.
84 | required: false
85 | github-app-key:
86 | description: |
87 | GitHub App Private Key. See the "Installation" section
88 | of the README to learn how to set up the app and how to
89 | fill this input.
90 | required: false
91 | github-repository:
92 | description: |
93 | Repository to update. Will be ignored if either
94 | `repos-file` is provided or the `github-app-*`
95 | inputs are and `github-app-auth-only` is not `true`.
96 | required: false
97 | default: ${{ github.repository }}
98 | github-token:
99 | description: |
100 | GitHub Personal Access Token with permission to create
101 | branches on repo.
102 |
103 | If `github-app-*` inputs are provided an App's
104 | installation token will be used instead of this one.
105 | required: false
106 | default: ${{ github.token }}
107 | ignore-opts-files:
108 | description: |
109 | Whether to ignore "opts" files (such as `.jvmopts`
110 | or `.sbtopts`) when found on repositories or not.
111 | default: "true"
112 | required: false
113 | mill-version:
114 | description: |
115 | Mill version to install. Take into account this will
116 | just affect the global `mill` executable. Scala
117 | Steward will still respect the version specified in
118 | your repository while updating it.
119 | default: "0.12.5"
120 | required: false
121 | other-args:
122 | description: |
123 | Other Scala Steward arguments not yet supported by
124 | this action as a separate argument.
125 | required: false
126 | repo-config:
127 | description: |
128 | Location of a `.scala-steward.conf` file with default
129 | values.
130 |
131 | If you specify a file and it does not exist in the
132 | selected branch then this action will fail when it runs.
133 |
134 | See https://github.com/scala-steward-org/scala-steward/blob/main/docs/repo-specific-configuration.md
135 | default: ".github/.scala-steward.conf"
136 | required: false
137 | repos-file:
138 | description: |
139 | Path to a file containing the list of repositories
140 | to update in markdown format:
141 |
142 | - owner/repo1
143 | - owner/repo2
144 |
145 | This input will be ignored if the `github-app-*`
146 | inputs are provided and `github-app-auth-only` is
147 | not `true`.
148 | required: false
149 | scala-steward-version:
150 | description: |
151 | Scala Steward version to use. If not provided it
152 | will use the last one published.
153 | required: false
154 | scalafix-migrations:
155 | description: |
156 | Scalafix rules for version updates to run after
157 | certain updates.
158 |
159 | Expects the path to HOCON file with migration/s.
160 |
161 | See https://github.com/scala-steward-org/scala-steward/blob/main/docs/scalafix-migrations.md
162 | required: false
163 | sign-commits:
164 | description: |
165 | Whether to sign commits or not.
166 | default: "false"
167 | required: false
168 | signing-key:
169 | description: |
170 | Key ID of GPG key to use for signing commits. See the
171 | "Signing commits with GPG" section to learn how to
172 | prepare the environment and fill this input.
173 | default: ""
174 | required: false
175 | timeout:
176 | description: |
177 | Timeout for external process invocations.
178 | default: 20min
179 | required: false
180 |
181 | runs:
182 | using: node20
183 | pre: dist/pre.js
184 | main: dist/main.js
185 | post: dist/post.js
186 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scala-steward-action",
3 | "description": "A GitHub Action to launch Scala Steward in your repository",
4 | "type": "module",
5 | "scripts": {
6 | "build": "npm run build-pre && npm run build-main && npm run build-post",
7 | "build-pre": "ncc build --target es2022 src/action/pre.ts && mv dist/index.js dist/pre.js",
8 | "build-main": "ncc build --target es2022 src/action/main.ts && mv dist/index.js dist/main.js",
9 | "build-post": "ncc build --target es2022 src/action/post.ts && mv dist/index.js dist/post.js",
10 | "docs": "node --loader ts-node/esm src/utils/docs.ts && markdown-toc-gen update README.md",
11 | "contributors:add": "all-contributors add",
12 | "contributors:generate": "all-contributors generate",
13 | "contributors:check": "all-contributors check",
14 | "test": "xo && NODE_OPTIONS='--loader=ts-node/esm --experimental-specifier-resolution=node' c8 ava",
15 | "report": "c8 report",
16 | "all": "npm run build && npm test && npm run report"
17 | },
18 | "engines": {
19 | "node": ">=20"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "git+https://github.com/scala-steward-org/scala-steward-action.git"
24 | },
25 | "keywords": [
26 | "actions",
27 | "scala",
28 | "dependency-update",
29 | "scala-steward"
30 | ],
31 | "author": "alejandrohdezma",
32 | "license": "Apache-2.0",
33 | "dependencies": {
34 | "@actions/cache": "^4.0.0",
35 | "@actions/core": "^1.11.1",
36 | "@actions/exec": "^1.1.1",
37 | "@actions/github": "^6.0.0",
38 | "@actions/io": "^1.1.3",
39 | "@actions/tool-cache": "^2.0.2",
40 | "@octokit/auth-app": "^7.1.4",
41 | "@octokit/request": "^9.1.3",
42 | "@types/node-fetch": "^2.6.11",
43 | "@types/sinon": "^17.0.3",
44 | "jssha": "^3.3.1",
45 | "node-fetch": "^2.7.0"
46 | },
47 | "devDependencies": {
48 | "@ava/typescript": "^5.0.0",
49 | "@tsconfig/node20": "^20.1.5",
50 | "@types/js-yaml": "^4.0.9",
51 | "@types/node": "^22.13.14",
52 | "@vercel/ncc": "^0.38.3",
53 | "all-contributors-cli": "^6.26.1",
54 | "ava": "^6.2.0",
55 | "c8": "^10.1.3",
56 | "js-yaml": "^4.1.0",
57 | "markdown-toc-gen": "^1.2.0",
58 | "sinon": "^20.0.0",
59 | "ts-node": "^10.9.2",
60 | "ts-pattern": "^5.7.0",
61 | "typescript": "^5.8.2",
62 | "xo": "^0.60.0"
63 | },
64 | "xo": {
65 | "space": true,
66 | "semicolon": false,
67 | "rules": {
68 | "new-cap": 0,
69 | "ava/no-ignored-test-files": 0,
70 | "n/file-extension-in-import": 0,
71 | "node/prefer-global/process": 0,
72 | "import/extensions": 0,
73 | "@typescript-eslint/naming-convention": 0,
74 | "@typescript-eslint/no-empty-function": 0,
75 | "unicorn/prefer-node-protocol": 0
76 | }
77 | },
78 | "ava": {
79 | "files": [
80 | "src/**/*.test.ts"
81 | ],
82 | "extensions": {
83 | "ts": "module"
84 | }
85 | },
86 | "c8": {
87 | "all": true,
88 | "src": "src",
89 | "exclude": [
90 | "src/core/!(types.ts)",
91 | "src/action",
92 | "src/**/*.test.ts",
93 | "src/utils"
94 | ],
95 | "reporter": [
96 | "text",
97 | "cobertura"
98 | ]
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/action/main.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import os from 'os'
3 | import process from 'process'
4 | import * as cache from '@actions/cache'
5 | import * as core from '@actions/core'
6 | import {getOctokit} from '@actions/github'
7 | import * as io from '@actions/io'
8 | import {createAppAuth} from '@octokit/auth-app'
9 | import {request} from '@octokit/request'
10 | import {type Files} from '../core/files'
11 | import {type Logger} from '../core/logger'
12 | import {nonEmpty, NonEmptyString} from '../core/types'
13 | import * as coursier from '../modules/coursier'
14 | import {GitHub} from '../modules/github'
15 | import {Input, type GitHubAppInfo} from '../modules/input'
16 | import {Workspace} from '../modules/workspace'
17 |
18 | /**
19 | * Runs the action main code. In order it will do the following:
20 | * - Recover user inputs
21 | * - Get authenticated user data from provided GitHub Token
22 | * - Prepare Scala Steward's workspace
23 | * - Run Scala Steward using Coursier.
24 | */
25 | async function run(): Promise {
26 | try {
27 | const logger: Logger = core
28 | const files: Files = {...fs, ...io}
29 | const inputs = Input.from(core, files, logger).all()
30 | const gitHubApiUrl = inputs.github.apiUrl.value
31 | const gitHubToken = async () => await gitHubAppToken(inputs.github.app, gitHubApiUrl, 'installation') ?? inputs.github.token.value
32 | const octokit = getOctokit(await gitHubToken(), {baseUrl: gitHubApiUrl})
33 | const github = GitHub.from(logger, octokit)
34 | const workspace = Workspace.from(logger, files, os, cache)
35 |
36 | const user = await gitHubAppToken(inputs.github.app, gitHubApiUrl, 'app')
37 | .then(appToken => appToken ? getOctokit(appToken, {baseUrl: gitHubApiUrl}) : undefined)
38 | .then(async octokit => octokit ? octokit.rest.apps.getAuthenticated() : undefined)
39 | .then(async response => response ? github.getAppUser(response.data.slug) : github.getAuthUser())
40 |
41 | await workspace.prepare(inputs.steward.repos, gitHubToken, inputs.github.app)
42 | await workspace.restoreWorkspaceCache()
43 |
44 | if (process.env.RUNNER_DEBUG) {
45 | core.debug('🐛 Debug mode activated for Scala Steward')
46 | core.exportVariable('LOG_LEVEL', 'TRACE')
47 | core.exportVariable('ROOT_LOG_LEVEL', 'TRACE')
48 | }
49 |
50 | const app = inputs.steward.version
51 | ? `org.scala-steward:scala-steward-core_2.13:${inputs.steward.version.value}`
52 | : 'scala-steward'
53 |
54 | try {
55 | await coursier.launch(app, [
56 | argument('--workspace', workspace.workspace),
57 | argument('--repos-file', workspace.repos_md),
58 | argument('--git-ask-pass', workspace.askpass_sh),
59 | argument('--git-author-email', inputs.commits.author.email ?? user.email()),
60 | argument('--git-author-name', inputs.commits.author.name ?? user.name()),
61 | argument('--forge-login', user.login()),
62 | argument('--env-var', nonEmpty('"SBT_OPTS=-Xmx2048m -Xss8m -XX:MaxMetaspaceSize=512m"')),
63 | argument('--process-timeout', inputs.steward.timeout),
64 | argument('--forge-api-host', inputs.github.apiUrl),
65 | argument('--ignore-opts-files', inputs.steward.ignoreOptsFiles),
66 | argument('--sign-commits', inputs.commits.sign.enabled),
67 | argument('--git-author-signing-key', inputs.commits.sign.key),
68 | argument('--cache-ttl', inputs.steward.cacheTtl),
69 | argument('--max-buffer-size', inputs.steward.maxBufferSize),
70 | argument('--scalafix-migrations', inputs.migrations.scalafix),
71 | argument('--artifact-migrations', inputs.migrations.artifacts),
72 | argument('--repo-config', inputs.steward.defaultConfiguration),
73 | argument('--github-app-id', inputs.github.app && !inputs.github.app.authOnly ? inputs.github.app.id : undefined),
74 | argument('--github-app-key-file', inputs.github.app && !inputs.github.app.authOnly ? workspace.app_pem : undefined),
75 | '--do-not-fork',
76 | '--disable-sandbox',
77 | inputs.steward.extraArgs?.value.split(' ') ?? [],
78 | ], inputs.steward.extraJars)
79 |
80 | if (files.existsSync(workspace.runSummary_md)) {
81 | logger.info(`✓ Run Summary file: ${workspace.runSummary_md}`)
82 |
83 | const summaryMarkdown = files.readFileSync(workspace.runSummary_md, 'utf8')
84 | await core.summary.addRaw(summaryMarkdown).write()
85 | }
86 | } finally {
87 | await workspace.purgeTempFilesAndSaveCache()
88 | await workspace.cancelTokenRefresh()
89 | }
90 | } catch (error: unknown) {
91 | core.setFailed(` ✕ ${(error as Error).message}`)
92 | }
93 | }
94 |
95 | /**
96 | * Returns a GitHub App Token.
97 | *
98 | * @param app The GitHub App information.
99 | * @param gitHubApiUrl The GitHub API URL.
100 | * @param type The type of token to retrieve, either `app` or `installation`.
101 | * @returns the GitHub App Token for the provided installation.
102 | */
103 | async function gitHubAppToken(app: GitHubAppInfo | undefined, gitHubApiUrl: string, type: 'app' | 'installation') {
104 | if (!app) {
105 | return undefined
106 | }
107 |
108 | const auth = createAppAuth({
109 | appId: app.id.value,
110 | privateKey: app.key.value,
111 | request: request.defaults({
112 | baseUrl: gitHubApiUrl,
113 | }),
114 | })
115 |
116 | const response = type === 'app'
117 | ? await auth({type: 'app'})
118 | : (app.installation ? await auth({type: 'installation', installationId: app.installation.value, refresh: true}) : undefined)
119 |
120 | return response?.token
121 | }
122 |
123 | /**
124 | * Creates an optional argument depending on an input's value.
125 | *
126 | * @param name Name of the arg being added.
127 | * @param value The argument's value, empty string, false booleans or undefined will be skipped.
128 | * @returns the argument to add if it should be added; otherwise returns `[]`.
129 | */
130 | function argument(name: string, value: NonEmptyString | boolean | undefined) {
131 | if (value instanceof NonEmptyString) {
132 | return [name, value.value]
133 | }
134 |
135 | if (value === undefined) {
136 | return []
137 | }
138 |
139 | return value ? [name] : []
140 | }
141 |
142 | // eslint-disable-next-line unicorn/prefer-top-level-await
143 | void run()
144 |
--------------------------------------------------------------------------------
/src/action/post.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import os from 'os'
3 | import * as core from '@actions/core'
4 | import * as io from '@actions/io'
5 | import * as cache from '@actions/cache'
6 | import * as coursier from '../modules/coursier'
7 | import * as mill from '../modules/mill'
8 | import {type Logger} from '../core/logger'
9 | import {Workspace} from '../modules/workspace'
10 | import {type Files} from '../core/files'
11 |
12 | /**
13 | * Performs a cleanup of all the artifacts/folders created by this action.
14 | */
15 | async function run(): Promise {
16 | try {
17 | const logger: Logger = core
18 | const files: Files = {...fs, ...io}
19 | const workspace = Workspace.from(logger, files, os, cache)
20 |
21 | await workspace.remove()
22 | core.info('🗑 Scala Steward\'s workspace removed')
23 |
24 | await coursier.remove()
25 | core.info('🗑 Coursier binary removed')
26 |
27 | await mill.remove()
28 | core.info('🗑 Mill binary removed')
29 | } catch (error: unknown) {
30 | core.warning((error as Error).message)
31 | }
32 | }
33 |
34 | // eslint-disable-next-line unicorn/prefer-top-level-await
35 | void run()
36 |
--------------------------------------------------------------------------------
/src/action/pre.ts:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import fetch from 'node-fetch'
3 | import * as coursier from '../modules/coursier'
4 | import {HealthCheck} from '../modules/healthcheck'
5 | import * as mill from '../modules/mill'
6 |
7 | /**
8 | * Runs the action prerequisites code. In order it will do the following:
9 | *
10 | * - Check connection with Maven Central
11 | * - Install Coursier
12 | * - Install Scalafmt
13 | * - Install Scalafix
14 | * - Install Mill
15 | */
16 | async function run(): Promise {
17 | try {
18 | const healthCheck: HealthCheck = HealthCheck.from(core, {run: async url => fetch(url)})
19 | await healthCheck.mavenCentral()
20 |
21 | await coursier.install()
22 | await mill.install()
23 | } catch (error: unknown) {
24 | core.setFailed(` ✕ ${(error as Error).message}`)
25 | }
26 | }
27 |
28 | // eslint-disable-next-line unicorn/prefer-top-level-await
29 | void run()
30 |
--------------------------------------------------------------------------------
/src/core/cache.ts:
--------------------------------------------------------------------------------
1 | export type ActionCache = {
2 | /**
3 | * Restores cache from keys
4 | */
5 | restoreCache(paths: string[], primaryKey: string, restoreKeys?: string[]): Promise;
6 |
7 | /**
8 | * Saves a list of files with the specified key
9 | */
10 | saveCache(paths: string[], key: string): Promise;
11 | }
12 |
--------------------------------------------------------------------------------
/src/core/files.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represent file operations performed by the action
3 | */
4 | export type Files = {
5 | /**
6 | * Changes the permissions of a file.
7 | */
8 | chmodSync: (path: string, mode: number) => void;
9 |
10 | /**
11 | * Write file contents to the filesystem.
12 | */
13 | writeFileSync: (path: string, content: string) => void;
14 |
15 | /**
16 | * Make a directory. Creates the full path with folders in between.
17 | */
18 | mkdirP: (path: string) => Promise;
19 |
20 | /**
21 | * Read file contents from the filesystem.
22 | */
23 | rmRF: (path: string) => Promise;
24 |
25 | /**
26 | * Read file contents from the filesystem.
27 | */
28 | readFileSync: (path: string, encoding: 'utf8') => string;
29 |
30 | /**
31 | * Returns `true` if the provided path exists.
32 | */
33 | existsSync: (path: string) => boolean;
34 | }
35 |
--------------------------------------------------------------------------------
/src/core/http.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents an HTTP client
3 | */
4 | export type HttpClient = {
5 | run: (url: string) => Promise;
6 | }
7 |
8 | export type Response = {
9 | ok: boolean;
10 | status: number;
11 | }
12 |
--------------------------------------------------------------------------------
/src/core/logger.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents the logger used across the action.
3 | */
4 | export type Logger = {
5 | startGroup(group: string): void;
6 |
7 | endGroup(): void;
8 |
9 | info(message: string): void;
10 |
11 | debug(message: string): void;
12 |
13 | error(message: string): void;
14 |
15 | warning(message: string): void;
16 | }
17 |
18 | // eslint-disable-next-line @typescript-eslint/no-redeclare
19 | export const Logger = {
20 | noOp: {
21 | info() {}, debug() {}, error() {}, warning() {}, startGroup() {}, endGroup() {},
22 | },
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/core/os.ts:
--------------------------------------------------------------------------------
1 | export type OSInfo = {
2 | homedir(): string;
3 | }
4 |
--------------------------------------------------------------------------------
/src/core/types.ts:
--------------------------------------------------------------------------------
1 | export class NonEmptyString {
2 | static from(string: string): NonEmptyString | undefined {
3 | return (string === '') ? undefined : new NonEmptyString(string)
4 | }
5 |
6 | private constructor(readonly value: string) {}
7 | }
8 |
9 | /**
10 | * Creates a `NonEmptyString` from a `string`. Returns `undefined` if the string is empty.
11 | */
12 | export function nonEmpty(string: string): NonEmptyString | undefined {
13 | return NonEmptyString.from(string)
14 | }
15 |
16 | /**
17 | * Creates a `NonEmptyString` from a `string`. Throws an `Error` if the string is empty.
18 | */
19 | export function mandatory(string: string, message = `Input ${string} cannot be empty`): NonEmptyString {
20 | const value = nonEmpty(string)
21 |
22 | if (value === undefined) {
23 | throw new Error(message)
24 | }
25 |
26 | return value
27 | }
28 |
--------------------------------------------------------------------------------
/src/modules/coursier.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path'
2 | import * as os from 'os'
3 | import * as core from '@actions/core'
4 | import * as tc from '@actions/tool-cache'
5 | import * as io from '@actions/io'
6 | import * as exec from '@actions/exec'
7 | import {type NonEmptyString} from '../core/types'
8 |
9 | /**
10 | * Installs `coursier` and add its executable to the `PATH`.
11 | *
12 | * Once coursier is installed, installs `scalafmt`
13 | * `scalafix`, `sbt` and `scala-cli` tools.
14 | *
15 | * Throws error if the installation fails.
16 | */
17 | export async function install(): Promise {
18 | try {
19 | const coursierUrl = core.getInput('coursier-cli-url')
20 |
21 | core.debug(`Installing coursier from ${coursierUrl}`)
22 |
23 | const binary = path.join(os.homedir(), 'bin')
24 | await io.mkdirP(binary)
25 |
26 | const zip = await tc.downloadTool(coursierUrl, path.join(binary, 'cs.gz'))
27 |
28 | await exec.exec('gzip', ['-d', zip], {silent: true})
29 | await exec.exec('chmod', ['+x', path.join(binary, 'cs')], {silent: true})
30 |
31 | core.addPath(binary)
32 |
33 | await exec.exec(
34 | 'cs',
35 | ['install', 'scalafmt', 'scalafix', 'scala-cli', 'sbt', '--install-dir', binary],
36 | {
37 | silent: true,
38 | listeners: {stdline: core.debug, errline: core.debug},
39 | },
40 | )
41 |
42 | const coursierVersion = await execute('cs', 'version')
43 |
44 | core.info(`✓ Coursier installed, version: ${coursierVersion.trim()}`)
45 |
46 | const scalafmtVersion = await execute('cs', 'launch', 'scalafmt', '--', '--version')
47 |
48 | core.info(`✓ Scalafmt installed, version: ${scalafmtVersion.replace(/^scalafmt /, '').trim()}`)
49 |
50 | const scalafixVersion = await execute('cs', 'launch', 'scalafix', '--', '--version')
51 |
52 | core.info(`✓ Scalafix installed, version: ${scalafixVersion.trim()}`)
53 |
54 | core.info('✓ SBT installed')
55 |
56 | core.info('✓ scala-cli installed')
57 | } catch (error: unknown) {
58 | core.debug((error as Error).message)
59 | throw new Error('Unable to install coursier or managed tools')
60 | }
61 | }
62 |
63 | /**
64 | * Launches an app using `coursier`.
65 | *
66 | * Refer to [coursier](https://get-coursier.io/docs/cli-launch) for more information.
67 | *
68 | * @param app - The application to launch
69 | * @param arguments_ - The args to pass to the application launcher.
70 | * @param extraJars - Extra JARs to be added to the classpath of the launched application. Directories accepted too.
71 | */
72 | export async function launch(
73 | app: string,
74 | arguments_: Array = [],
75 | extraJars: NonEmptyString | undefined = undefined,
76 | ): Promise {
77 | core.startGroup(`Launching ${app}`)
78 |
79 | const launchArguments = [
80 | 'launch',
81 | '--contrib',
82 | '-r',
83 | 'sonatype:snapshots',
84 | app,
85 | ...(extraJars ? ['--extra-jars', extraJars.value] : []),
86 | '--',
87 | ...arguments_.flatMap((argument: string | string[]) => (typeof argument === 'string' ? [argument] : argument)),
88 | ]
89 |
90 | const code = await exec.exec('cs', launchArguments, {
91 | silent: true,
92 | ignoreReturnCode: true,
93 | listeners: {stdline: core.info, errline: core.error},
94 | })
95 |
96 | core.endGroup()
97 |
98 | if (code !== 0) {
99 | throw new Error(`Launching ${app} failed`)
100 | }
101 | }
102 |
103 | /**
104 | * Removes coursier binary
105 | */
106 | export async function remove(): Promise {
107 | await io.rmRF(path.join(os.homedir(), '.cache', 'coursier', 'v1'))
108 | await exec.exec('cs', ['uninstall', '--all'], {
109 | silent: true,
110 | ignoreReturnCode: true,
111 | listeners: {stdline: core.info, errline: core.debug},
112 | })
113 | }
114 |
115 | /**
116 | * Executes a tool and returns its output.
117 | */
118 | async function execute(tool: string, ...arguments_: string[]): Promise {
119 | let output = ''
120 |
121 | const code = await exec.exec(tool, arguments_, {
122 | silent: true,
123 | ignoreReturnCode: true,
124 | listeners: {
125 | stdout(data) {
126 | (output += data.toString())
127 | }, errline: core.debug,
128 | },
129 | })
130 |
131 | if (code !== 0) {
132 | throw new Error(`There was an error while executing '${tool} ${arguments_.join(' ')}'`)
133 | }
134 |
135 | return output
136 | }
137 |
--------------------------------------------------------------------------------
/src/modules/github.test.ts:
--------------------------------------------------------------------------------
1 | import {fail} from 'assert'
2 | import test from 'ava'
3 | import {Logger} from '../core/logger'
4 | import {GitHub, type GitHubClient} from './github'
5 |
6 | test('`GitHub.getAuthUser()` → returns every auth user component', async t => {
7 | const client: GitHubClient = {
8 | rest: {
9 | users: {
10 | getByUsername: async () => fail('This should not be called'),
11 | getAuthenticated: async () => ({data: {login: 'alejandrohdezma', email: 'alex@example.com', name: 'Alex'}}),
12 | },
13 | },
14 | }
15 |
16 | const input = GitHub.from(Logger.noOp, client)
17 |
18 | const user = await input.getAuthUser()
19 |
20 | t.is(user.login().value, 'alejandrohdezma')
21 | t.is(user.email().value, 'alex@example.com')
22 | t.is(user.name().value, 'Alex')
23 | })
24 |
25 | test('`GitHub.getAuthUser()` → throws error on any empty component', async t => {
26 | const client: GitHubClient = {
27 | rest: {
28 | users: {
29 | getByUsername: async () => fail('This should not be called'),
30 | getAuthenticated: async () => ({data: {login: '', email: '', name: ''}}),
31 | },
32 | },
33 | }
34 |
35 | const input = GitHub.from(Logger.noOp, client)
36 |
37 | const user = await input.getAuthUser()
38 |
39 | {
40 | const expected = 'Unable to retrieve user information from GitHub'
41 | t.throws(() => user.login().value, {instanceOf: Error, message: expected})
42 | }
43 |
44 | {
45 | const expected = 'Unable to find author\'s email. Either ensure that the token\'s GitHub Account '
46 | + 'has the email privacy feature disabled for at least one email or use the `author-email` input to provide one.'
47 | t.throws(() => user.email().value, {instanceOf: Error, message: expected})
48 | }
49 |
50 | {
51 | const expected = 'Unable to find author\'s name. Either ensure that the token\'s GitHub Account '
52 | + 'has a valid name set in its profile or use the `author-name` input to provide one.'
53 | t.throws(() => user.name().value, {instanceOf: Error, message: expected})
54 | }
55 | })
56 |
57 | test('`GitHub.getAuthUser()` → throws error on any null component', async t => {
58 | const client: GitHubClient = {
59 | rest: {
60 | users: {
61 | getByUsername: async () => fail('This should not be called'),
62 | getAuthenticated: async () => ({data: {login: 'alex', email: null, name: null}}),
63 | },
64 | },
65 | }
66 |
67 | const input = GitHub.from(Logger.noOp, client)
68 |
69 | const user = await input.getAuthUser()
70 |
71 | {
72 | const expected = 'Unable to find author\'s email. Either ensure that the token\'s GitHub Account '
73 | + 'has the email privacy feature disabled for at least one email or use the `author-email` input to provide one.'
74 | t.throws(() => user.email().value, {instanceOf: Error, message: expected})
75 | }
76 |
77 | {
78 | const expected = 'Unable to find author\'s name. Either ensure that the token\'s GitHub Account '
79 | + 'has a valid name set in its profile or use the `author-name` input to provide one.'
80 | t.throws(() => user.name().value, {instanceOf: Error, message: expected})
81 | }
82 | })
83 |
84 | test('`GitHub.getAppUser()` → returns every auth user component', async t => {
85 | const client: GitHubClient = {
86 | rest: {
87 | users: {
88 | getByUsername: async () => ({data: {login: 'my-app[bot]', id: 123}}),
89 | getAuthenticated: async () => fail('This should not be called'),
90 | },
91 | },
92 | }
93 |
94 | const input = GitHub.from(Logger.noOp, client)
95 |
96 | const user = await input.getAppUser('the-slug')
97 |
98 | t.is(user.login().value, 'my-app[bot]')
99 | t.is(user.email().value, '123+my-app[bot]@users.noreply.github.com')
100 | t.is(user.name().value, 'my-app[bot]')
101 | })
102 |
103 | test('`GitHub.getAppUser()` → returns default user if slug is empty', async t => {
104 | const client: GitHubClient = {
105 | rest: {
106 | users: {
107 | getByUsername: async () => ({data: {login: 'my-app[bot]', id: 123}}),
108 | getAuthenticated: async () => fail('This should not be called'),
109 | },
110 | },
111 | }
112 |
113 | const input = GitHub.from(Logger.noOp, client)
114 |
115 | const user = await input.getAppUser(undefined)
116 |
117 | t.is(user.login().value, 'github-actions[bot]')
118 | t.is(user.email().value, '41898282+github-actions[bot]@users.noreply.github.com')
119 | t.is(user.name().value, 'github-actions[bot]')
120 | })
121 |
122 | test('`GitHub.getAppUser()` → returns default user if failed to obtain bot user', async t => {
123 | const client: GitHubClient = {
124 | rest: {
125 | users: {
126 | getByUsername: async () => fail('BOOM!'),
127 | getAuthenticated: async () => fail('This should not be called'),
128 | },
129 | },
130 | }
131 |
132 | const input = GitHub.from(Logger.noOp, client)
133 |
134 | const user = await input.getAppUser(undefined)
135 |
136 | t.is(user.login().value, 'github-actions[bot]')
137 | t.is(user.email().value, '41898282+github-actions[bot]@users.noreply.github.com')
138 | t.is(user.name().value, 'github-actions[bot]')
139 | })
140 |
--------------------------------------------------------------------------------
/src/modules/github.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-types */
2 | import {type Logger} from '../core/logger'
3 | import {mandatory, type NonEmptyString} from '../core/types'
4 |
5 | const emailErrorMessage
6 | = 'Unable to find author\'s email. Either ensure that the token\'s GitHub Account has the email '
7 | + 'privacy feature disabled for at least one email or use the `author-email` input to provide one.'
8 |
9 | const nameErrorMessage
10 | = 'Unable to find author\'s name. Either ensure that the token\'s GitHub Account has a valid name '
11 | + 'set in its profile or use the `author-name` input to provide one.'
12 |
13 | export class GitHub {
14 | static from(
15 | logger: Logger,
16 | github: GitHubClient,
17 | ) {
18 | return new GitHub(logger, github)
19 | }
20 |
21 | // https://github.community/t/github-actions-bot-email-address/17204/6
22 | // https://api.github.com/users/github-actions%5Bbot%5D
23 | private readonly defaultUser = {
24 | login: () => mandatory('github-actions[bot]'),
25 | email: () => mandatory('41898282+github-actions[bot]@users.noreply.github.com'),
26 | name: () => mandatory('github-actions[bot]'),
27 | }
28 |
29 | constructor(
30 | private readonly logger: Logger,
31 | private readonly github: GitHubClient,
32 | ) {}
33 |
34 | /**
35 | * Returns the login, email and name of the authenticated user.
36 | */
37 | async getAuthUser(): Promise {
38 | try {
39 | const auth = await this.github.rest.users.getAuthenticated()
40 | const {login, email, name} = auth.data
41 |
42 | this.logger.info('✓ User information retrieved from GitHub')
43 |
44 | this.logger.debug(`- Login: ${login}`)
45 | this.logger.debug(`- Email: ${email ?? 'no email found'}`)
46 | this.logger.debug(`- Name: ${name ?? 'no name found'}`)
47 |
48 | return {
49 | login: () => mandatory(login, 'Unable to retrieve user information from GitHub'),
50 | email: () => mandatory(email ?? '', emailErrorMessage),
51 | name: () => mandatory(name ?? '', nameErrorMessage),
52 | }
53 | } catch (error: unknown) {
54 | this.logger.debug(`- User information retrieve failed. Error: ${(error as Error).message}`)
55 | return this.defaultUser
56 | }
57 | }
58 |
59 | /**
60 | * Returns the login, email and name of the authenticated user.
61 | */
62 | async getAppUser(slug: string | undefined): Promise {
63 | try {
64 | if (slug === undefined) {
65 | throw new Error('Unable to find GitHub App Slug')
66 | }
67 |
68 | const response = await this.github.rest.users.getByUsername({username: slug + '[bot]'})
69 |
70 | // Workaround until https://github.com/github/rest-api-description/issues/288 is fixed
71 | const {login, id} = (response as {data: {login: string; id: string}}).data
72 |
73 | this.logger.info('✓ GitHub App information retrieved from GitHub')
74 |
75 | return {
76 | login: () => mandatory(login, 'Unable to retrieve user information from GitHub'),
77 | email: () => mandatory(`${id}+${login}@users.noreply.github.com`),
78 | name: () => mandatory(login),
79 | }
80 | } catch (error: unknown) {
81 | this.logger.debug(`- GitHub App User information retrieve failed. Error: ${(error as Error).message}`)
82 | return this.defaultUser
83 | }
84 | }
85 | }
86 |
87 | type AuthUser = {
88 | email: () => NonEmptyString;
89 | login: () => NonEmptyString;
90 | name: () => NonEmptyString;
91 | }
92 |
93 | export type GitHubClient = {
94 | rest: {
95 | users: {
96 | getAuthenticated: () => Promise<{data: {login: string; email: string | null; name: string | null}}>;
97 | getByUsername: (parameters?: {username: string}) => Promise;
98 | };
99 | };
100 | }
101 |
--------------------------------------------------------------------------------
/src/modules/healthcheck.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava'
2 | import {type HttpClient} from '../core/http'
3 | import {Logger} from '../core/logger'
4 | import {HealthCheck} from './healthcheck'
5 |
6 | test('`HealthCheck.mavenCentral()` → does not fail if connected to Maven Central', async t => {
7 | const client: HttpClient = {
8 | run: async (url: string) => url === 'https://repo1.maven.org/maven2/'
9 | ? {ok: true, status: 200} : {ok: false, status: 404},
10 | }
11 |
12 | const healthCheck = HealthCheck.from(Logger.noOp, client)
13 |
14 | await t.notThrowsAsync(async () => healthCheck.mavenCentral())
15 | })
16 |
17 | test('`HealthCheck.mavenCentral()` → fails if not connected to Maven Central', async t => {
18 | const client: HttpClient = {
19 | run: async () => ({ok: false, status: 404}),
20 | }
21 |
22 | const healthCheck = HealthCheck.from(Logger.noOp, client)
23 |
24 | const expected = 'Unable to connect to Maven Central'
25 |
26 | await t.throwsAsync(async () => healthCheck.mavenCentral(), {instanceOf: Error, message: expected})
27 | })
28 |
--------------------------------------------------------------------------------
/src/modules/healthcheck.ts:
--------------------------------------------------------------------------------
1 | import {type Logger} from '../core/logger'
2 | import {type HttpClient} from '../core/http'
3 |
4 | export class HealthCheck {
5 | static from(logger: Logger, httpClient: HttpClient) {
6 | return new HealthCheck(logger, httpClient)
7 | }
8 |
9 | constructor(private readonly logger: Logger, private readonly httpClient: HttpClient) {}
10 |
11 | /**
12 | * Checks connection with Maven Central, throws error if unable to connect.
13 | */
14 | async mavenCentral(): Promise {
15 | const success = await this.httpClient.run('https://repo1.maven.org/maven2/').then(response => response.ok)
16 |
17 | if (!success) {
18 | throw new Error('Unable to connect to Maven Central')
19 | }
20 |
21 | this.logger.info('✓ Connected to Maven Central')
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/modules/input.test.ts:
--------------------------------------------------------------------------------
1 | import {fail} from 'assert'
2 | import test from 'ava'
3 | import {match} from 'ts-pattern'
4 | import {type Files} from '../core/files'
5 | import {Logger} from '../core/logger'
6 | import {nonEmpty} from '../core/types'
7 | import {Input} from './input'
8 |
9 | test('`Input.all` → returns all inputs', t => {
10 | const inputs = (name: string) => match(name)
11 | .with('github-token', () => '123')
12 | .with('repo-config', () => '.github/defaults/.scala-steward.conf')
13 | .with('github-repository', () => 'owner/repo')
14 | .with('branches', () => '1.0x,2.0x')
15 | .with('author-email', () => 'alex@example.com')
16 | .with('author-name', () => 'Alex')
17 | .with('github-api-url', () => 'github.my-org.com')
18 | .with('cache-ttl', () => '20m')
19 | .with('max-buffer-size', () => '16384')
20 | .with('timeout', () => '60s')
21 | .with('scala-steward-version', () => '1.0')
22 | .with('artifact-migrations', () => '.github/artifact-migrations.conf')
23 | .with('scalafix-migrations', () => '.github/scalafix-migrations.conf')
24 | .with('other-args', () => '--help')
25 | .with('signing-key', () => '42')
26 | .with('extra-jars', () => 'path/to/my/jars')
27 | .otherwise(() => '')
28 |
29 | const booleanInputs = (name: string) => match(name)
30 | .with('ignore-opts-files', () => true)
31 | .with('sign-commits', () => true)
32 | .otherwise(() => false)
33 |
34 | const files: Files = {
35 | chmodSync: () => fail('Should not be called'),
36 | rmRF: () => fail('Should not be called'),
37 | mkdirP: () => fail('Should not be called'),
38 | existsSync: name => name === '.github/defaults/.scala-steward.conf',
39 | writeFileSync: () => fail('Should not be called'),
40 | readFileSync: () => fail('Should not be called'),
41 | }
42 |
43 | const input = Input.from({getInput: inputs, getBooleanInput: booleanInputs}, files, Logger.noOp)
44 |
45 | const expected = {
46 | github: {
47 | token: nonEmpty('123'),
48 | app: undefined,
49 | apiUrl: nonEmpty('github.my-org.com'),
50 | },
51 | steward: {
52 | defaultConfiguration: nonEmpty('.github/defaults/.scala-steward.conf'),
53 | repos: '- owner/repo:1.0x\n- owner/repo:2.0x',
54 | cacheTtl: nonEmpty('20m'),
55 | maxBufferSize: nonEmpty('16384'),
56 | version: nonEmpty('1.0'),
57 | timeout: nonEmpty('60s'),
58 | ignoreOptsFiles: true,
59 | extraArgs: nonEmpty('--help'),
60 | extraJars: nonEmpty('path/to/my/jars'),
61 | },
62 | migrations: {
63 | scalafix: nonEmpty('.github/scalafix-migrations.conf'),
64 | artifacts: nonEmpty('.github/artifact-migrations.conf'),
65 | },
66 | commits: {
67 | sign: {
68 | enabled: true,
69 | key: nonEmpty('42'),
70 | },
71 | author: {
72 | email: nonEmpty('alex@example.com'),
73 | name: nonEmpty('Alex'),
74 | },
75 | },
76 | }
77 |
78 | t.deepEqual(input.all(), expected)
79 | })
80 |
81 | test('`Input.githubAppInfo()` → returns GitHub App info', t => {
82 | const inputs = (name: string) => match(name)
83 | .with('github-app-auth-only', () => 'true')
84 | .with('github-app-id', () => '123')
85 | .with('github-app-key', () => '42')
86 | .with('github-app-installation-id', () => '456')
87 | .otherwise(() => '')
88 |
89 | const booleanInputs = (name: string) => match(name)
90 | .with('github-app-auth-only', () => true)
91 | .otherwise(() => false)
92 |
93 | const files: Files = {
94 | chmodSync: () => fail('Should not be called'),
95 | rmRF: () => fail('Should not be called'),
96 | mkdirP: () => fail('Should not be called'),
97 | existsSync: () => false,
98 | writeFileSync: () => fail('Should not be called'),
99 | readFileSync: () => fail('Should not be called'),
100 | }
101 |
102 | const input = Input.from({getInput: inputs, getBooleanInput: booleanInputs}, files, Logger.noOp)
103 |
104 | const file = input.githubAppInfo()
105 |
106 | t.deepEqual(file, {
107 | authOnly: true, id: nonEmpty('123'), key: nonEmpty('42'), installation: nonEmpty('456'),
108 | })
109 | })
110 |
111 | test('`Input.githubAppInfo()` → returns undefined on missing inputs', t => {
112 | const files: Files = {
113 | chmodSync: () => fail('Should not be called'),
114 | rmRF: () => fail('Should not be called'),
115 | mkdirP: () => fail('Should not be called'),
116 | existsSync: () => false,
117 | writeFileSync: () => fail('Should not be called'),
118 | readFileSync: () => fail('Should not be called'),
119 | }
120 |
121 | const input = Input.from({getInput: () => '', getBooleanInput: () => false}, files, Logger.noOp)
122 |
123 | const file = input.githubAppInfo()
124 |
125 | t.is(file, undefined)
126 | })
127 |
128 | test('`Input.githubAppInfo()` → throws error if only id input present', t => {
129 | const inputs = (name: string) => match(name)
130 | .with('github-app-id', () => '123')
131 | .otherwise(() => '')
132 |
133 | const files: Files = {
134 | chmodSync: () => fail('Should not be called'),
135 | rmRF: () => fail('Should not be called'),
136 | mkdirP: () => fail('Should not be called'),
137 | existsSync: () => false,
138 | writeFileSync: () => fail('Should not be called'),
139 | readFileSync: () => fail('Should not be called'),
140 | }
141 |
142 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
143 |
144 | const expected = '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing'
145 |
146 | const error = t.throws(() => input.githubAppInfo(), {instanceOf: Error})
147 |
148 | t.is(error?.message, expected)
149 | })
150 |
151 | test('`Input.githubAppInfo()` → throws error if only key input present', t => {
152 | const inputs = (name: string) => match(name)
153 | .with('github-app-key', () => '42')
154 | .otherwise(() => '')
155 |
156 | const files: Files = {
157 | chmodSync: () => fail('Should not be called'),
158 | rmRF: () => fail('Should not be called'),
159 | mkdirP: () => fail('Should not be called'),
160 | existsSync: () => false,
161 | writeFileSync: () => fail('Should not be called'),
162 | readFileSync: () => fail('Should not be called'),
163 | }
164 |
165 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
166 |
167 | const expected = '`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing'
168 |
169 | const error = t.throws(() => input.githubAppInfo(), {instanceOf: Error})
170 |
171 | t.is(error?.message, expected)
172 | })
173 |
174 | test('`Input.reposFile()` → returns undefined on missing input', t => {
175 | const files: Files = {
176 | chmodSync: () => fail('Should not be called'),
177 | rmRF: () => fail('Should not be called'),
178 | mkdirP: () => fail('Should not be called'),
179 | existsSync: () => false,
180 | writeFileSync: () => fail('Should not be called'),
181 | readFileSync: () => fail('Should not be called'),
182 | }
183 |
184 | const input = Input.from({getInput: () => '', getBooleanInput: () => false}, files, Logger.noOp)
185 |
186 | const file = input.reposFile()
187 | t.is(file, undefined)
188 | })
189 |
190 | test('`Input.reposFile()` → returns contents if file exists', t => {
191 | const inputs = (name: string) => match(name)
192 | .with('repos-file', () => 'repos.md')
193 | .otherwise(() => '')
194 |
195 | const contents = '- owner1/repo1\n- owner1/repo2\n- owner2/repo'
196 |
197 | const files: Files = {
198 | chmodSync: () => fail('Should not be called'),
199 | rmRF: () => fail('Should not be called'),
200 | mkdirP: () => fail('Should not be called'),
201 | existsSync: name => match(name).with('repos.md', () => true).run(),
202 | writeFileSync: () => fail('Should not be called'),
203 | readFileSync: name => match(name).with('repos.md', () => contents).run(),
204 | }
205 |
206 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
207 |
208 | const file = input.reposFile() ?? ''
209 |
210 | t.is(file.toString(), contents)
211 | })
212 |
213 | test('`Input.reposFile()` → throws error if file does not exist', t => {
214 | const inputs = (name: string) => match(name)
215 | .with('repos-file', () => 'this/does/not/exist.md')
216 | .otherwise(() => '')
217 |
218 | const files: Files = {
219 | chmodSync: () => fail('Should not be called'),
220 | rmRF: () => fail('Should not be called'),
221 | mkdirP: () => fail('Should not be called'),
222 | existsSync: () => false,
223 | writeFileSync: () => fail('Should not be called'),
224 | readFileSync: () => fail('Should not be called'),
225 | }
226 |
227 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
228 |
229 | const expected = 'The path indicated in `repos-file` (this/does/not/exist.md) does not exist'
230 |
231 | const error = t.throws(() => input.reposFile(), {instanceOf: Error})
232 |
233 | t.is(error?.message, expected)
234 | })
235 |
236 | test('`Input.githubRepository()` → returns repository from input', t => {
237 | const inputs = (name: string) => match(name)
238 | .with('github-repository', () => 'owner/repo')
239 | .otherwise(() => '')
240 |
241 | const files: Files = {
242 | chmodSync: () => fail('Should not be called'),
243 | rmRF: () => fail('Should not be called'),
244 | mkdirP: () => fail('Should not be called'),
245 | existsSync: () => false,
246 | writeFileSync: () => fail('Should not be called'),
247 | readFileSync: () => fail('Should not be called'),
248 | }
249 |
250 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
251 |
252 | const content = input.githubRepository()
253 |
254 | const expected = '- owner/repo'
255 |
256 | t.is(content, expected)
257 | })
258 |
259 | test('`Input.githubRepository()` → returns repository from input with custom branch', t => {
260 | const inputs = (name: string) => match(name)
261 | .with('github-repository', () => 'owner/repo')
262 | .with('branches', () => '0.1.x')
263 | .otherwise(() => '')
264 |
265 | const files: Files = {
266 | chmodSync: () => fail('Should not be called'),
267 | rmRF: () => fail('Should not be called'),
268 | mkdirP: () => fail('Should not be called'),
269 | existsSync: () => false,
270 | writeFileSync: () => fail('Should not be called'),
271 | readFileSync: () => fail('Should not be called'),
272 | }
273 |
274 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
275 |
276 | const content = input.githubRepository()
277 |
278 | const expected = '- owner/repo:0.1.x'
279 |
280 | t.is(content, expected)
281 | })
282 |
283 | test('`Input.githubRepository()` → returns repository from input with multiple custom branches', t => {
284 | const inputs = (name: string) => match(name)
285 | .with('github-repository', () => 'owner/repo')
286 | .with('branches', () => 'main,0.1.x,0.2.x')
287 | .otherwise(() => '')
288 |
289 | const files: Files = {
290 | chmodSync: () => fail('Should not be called'),
291 | rmRF: () => fail('Should not be called'),
292 | mkdirP: () => fail('Should not be called'),
293 | existsSync: () => false,
294 | writeFileSync: () => fail('Should not be called'),
295 | readFileSync: () => fail('Should not be called'),
296 | }
297 |
298 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
299 |
300 | const content = input.githubRepository()
301 |
302 | const expected = '- owner/repo:main\n- owner/repo:0.1.x\n- owner/repo:0.2.x'
303 |
304 | t.is(content, expected)
305 | })
306 |
307 | test('`Input.defaultRepoConf()` → returns the path if it exists', t => {
308 | const inputs = (name: string) => match(name)
309 | .with('repo-config', () => '.scala-steward.conf')
310 | .otherwise(() => '')
311 |
312 | const files: Files = {
313 | chmodSync: () => fail('Should not be called'),
314 | rmRF: () => fail('Should not be called'),
315 | mkdirP: () => fail('Should not be called'),
316 | existsSync: name => match(name).with('.scala-steward.conf', () => true).run(),
317 | writeFileSync: () => fail('Should not be called'),
318 | readFileSync: () => fail('This should not be called'),
319 | }
320 |
321 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
322 |
323 | const path = input.defaultRepoConf()
324 |
325 | const expected = '.scala-steward.conf'
326 |
327 | t.is(path?.value, expected)
328 | })
329 |
330 | test('`Input.defaultRepoConf()` → returns the default path if it exists', t => {
331 | const inputs = (name: string) => match(name)
332 | .with('repo-config', () => '.github/.scala-steward.conf')
333 | .otherwise(() => '')
334 |
335 | const files: Files = {
336 | chmodSync: () => fail('Should not be called'),
337 | rmRF: () => fail('Should not be called'),
338 | mkdirP: () => fail('Should not be called'),
339 | existsSync: name => match(name).with('.github/.scala-steward.conf', () => true).run(),
340 | writeFileSync: () => fail('Should not be called'),
341 | readFileSync: () => fail('This should not be called'),
342 | }
343 |
344 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
345 |
346 | const path = input.defaultRepoConf()
347 |
348 | const expected = '.github/.scala-steward.conf'
349 |
350 | t.is(path?.value, expected)
351 | })
352 |
353 | test('`Input.defaultRepoConf()` → returns undefined if the default path does not exist', t => {
354 | const inputs = (name: string) => match(name)
355 | .with('repo-config', () => '.github/.scala-steward.conf')
356 | .otherwise(() => '')
357 |
358 | const files: Files = {
359 | chmodSync: () => fail('Should not be called'),
360 | rmRF: () => fail('Should not be called'),
361 | mkdirP: () => fail('Should not be called'),
362 | existsSync: () => false,
363 | writeFileSync: () => fail('Should not be called'),
364 | readFileSync: () => fail('Should not be called'),
365 | }
366 |
367 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
368 |
369 | const path = input.defaultRepoConf()
370 |
371 | t.is(path, undefined)
372 | })
373 |
374 | test('`Input.defaultRepoConf()` → throws error if provided non-default file does not exist', t => {
375 | const inputs = (name: string) => match(name)
376 | .with('repo-config', () => 'tests/resources/.scala-steward-new.conf')
377 | .otherwise(() => '')
378 |
379 | const files: Files = {
380 | chmodSync: () => fail('Should not be called'),
381 | rmRF: () => fail('Should not be called'),
382 | mkdirP: () => fail('Should not be called'),
383 | existsSync: () => false,
384 | writeFileSync: () => fail('Should not be called'),
385 | readFileSync: () => fail('Should not be called'),
386 | }
387 |
388 | const input = Input.from({getInput: inputs, getBooleanInput: () => false}, files, Logger.noOp)
389 |
390 | const expected = 'Provided default repo conf file (tests/resources/.scala-steward-new.conf) does not exist'
391 |
392 | const error = t.throws(() => input.defaultRepoConf(), {instanceOf: Error})
393 |
394 | t.is(error?.message, expected)
395 | })
396 |
--------------------------------------------------------------------------------
/src/modules/input.ts:
--------------------------------------------------------------------------------
1 |
2 | import {type Files} from '../core/files'
3 | import {type Logger} from '../core/logger'
4 | import {mandatory, nonEmpty, type NonEmptyString} from '../core/types'
5 |
6 | export type GitHubAppInfo = {
7 | authOnly: boolean;
8 | id: NonEmptyString;
9 | installation: NonEmptyString | undefined;
10 | key: NonEmptyString;
11 | }
12 |
13 | /**
14 | * Retrieves (and sanitize) inputs.
15 | */
16 | export class Input {
17 | static from(
18 | inputs: {getInput: (name: string) => string; getBooleanInput: (name: string) => boolean},
19 | files: Files,
20 | logger: Logger,
21 | ) {
22 | return new Input(inputs, files, logger)
23 | }
24 |
25 | constructor(
26 | private readonly inputs: {getInput: (name: string) => string; getBooleanInput: (name: string) => boolean},
27 | private readonly files: Files,
28 | private readonly logger: Logger,
29 | ) {}
30 |
31 | /**
32 | * Returns every input for this action.
33 | */
34 | all() {
35 | return {
36 | github: {
37 | token: mandatory(this.inputs.getInput('github-token')),
38 | app: this.githubAppInfo(),
39 | apiUrl: mandatory(this.inputs.getInput('github-api-url')),
40 | },
41 | steward: {
42 | defaultConfiguration: this.defaultRepoConf(),
43 | repos: this.reposFile() ?? this.githubRepository(),
44 | cacheTtl: nonEmpty(this.inputs.getInput('cache-ttl')),
45 | maxBufferSize: nonEmpty(this.inputs.getInput('max-buffer-size')),
46 | version: nonEmpty(this.inputs.getInput('scala-steward-version')),
47 | timeout: nonEmpty(this.inputs.getInput('timeout')),
48 | ignoreOptsFiles: this.inputs.getBooleanInput('ignore-opts-files'),
49 | extraArgs: nonEmpty(this.inputs.getInput('other-args')),
50 | extraJars: nonEmpty(this.inputs.getInput('extra-jars')),
51 | },
52 | migrations: {
53 | scalafix: nonEmpty(this.inputs.getInput('scalafix-migrations')),
54 | artifacts: nonEmpty(this.inputs.getInput('artifact-migrations')),
55 | },
56 | commits: {
57 | sign: {
58 | enabled: this.inputs.getBooleanInput('sign-commits'),
59 | key: nonEmpty(this.inputs.getInput('signing-key')),
60 | },
61 | author: {
62 | email: nonEmpty(this.inputs.getInput('author-email')),
63 | name: nonEmpty(this.inputs.getInput('author-name')),
64 | },
65 | },
66 | }
67 | }
68 |
69 | /**
70 | * Reads the path of the file containing the default Scala Steward configuration.
71 | *
72 | * If the provided file does not exist and is not the default one it will throw an error.
73 | * On the other hand, if it exists it will be returned, otherwise; it will return `undefined`.
74 | *
75 | * @returns {string | undefined} The path indicated in the `repo-config` input, if it
76 | * exists; otherwise, `undefined`.
77 | */
78 | defaultRepoConf(): NonEmptyString | undefined {
79 | const path = nonEmpty(this.inputs.getInput('repo-config'))
80 |
81 | if (!path) {
82 | return undefined
83 | }
84 |
85 | const fileExists = this.files.existsSync(path.value)
86 |
87 | if (!fileExists && path.value !== '.github/.scala-steward.conf') {
88 | throw new Error(`Provided default repo conf file (${path.value}) does not exist`)
89 | }
90 |
91 | if (fileExists) {
92 | this.logger.info(`✓ Default Scala Steward configuration set to: ${path.value}`)
93 |
94 | return path
95 | }
96 |
97 | return undefined
98 | }
99 |
100 | /**
101 | * Returns the GitHub repository set to update.
102 | *
103 | * It reads it from the `github-repository` input.
104 | *
105 | * Throws error if input is empty or missing.
106 | *
107 | * If the `branches` input is set, the selected branches will be added.
108 | *
109 | * @returns {string} The GitHub repository read from the `github-repository` input.
110 | */
111 | githubRepository(): string {
112 | const repo = nonEmpty(this.inputs.getInput('github-repository'))
113 |
114 | if (!repo) {
115 | throw new Error('Unable to read GitHub repository from `github-repository` input')
116 | }
117 |
118 | const branches = this.inputs.getInput('branches').split(',').filter(Boolean)
119 |
120 | if (branches.length === 1) {
121 | const branch = branches[0]
122 |
123 | this.logger.info(`✓ GitHub Repository set to: ${repo.value}. Will update ${branch} branch.`)
124 |
125 | return `- ${repo.value}:${branch}`
126 | }
127 |
128 | if (branches.length > 1) {
129 | this.logger.info(`✓ GitHub Repository set to: ${repo.value}. Will update ${branches.join(', ')} branches.`)
130 |
131 | return branches.map((branch: string) => `- ${repo.value}:${branch}`).join('\n')
132 | }
133 |
134 | return `- ${repo.value}`
135 | }
136 |
137 | /**
138 | * Reads the path of the file containing the list of repositories to update from the `repos-file`
139 | * input.
140 | *
141 | * If the input isn't provided this function will return `undefined`.
142 | * On the other hand, if it is provided, it will check if the path exists:
143 | * - If the file exists, its contents will be returned.
144 | * - If it doesn't exists, an error will be thrown.
145 | *
146 | * @returns {string | undefined} The contents of the file indicated in `repos-file` input, if is
147 | * defined; otherwise, `undefined`.
148 | */
149 | reposFile(): string | undefined {
150 | const file = nonEmpty(this.inputs.getInput('repos-file'))
151 |
152 | if (!file) {
153 | return undefined
154 | }
155 |
156 | if (this.files.existsSync(file.value)) {
157 | this.logger.info(`✓ Using multiple repos file: ${file.value}`)
158 |
159 | return this.files.readFileSync(file.value, 'utf8')
160 | }
161 |
162 | throw new Error(`The path indicated in \`repos-file\` (${file.value}) does not exist`)
163 | }
164 |
165 | /**
166 | * Checks that GitHub App ID and private key are set together.
167 | *
168 | * Throws error if only one of the two inputs is set.
169 | *
170 | * @returns {{id: string, key: string} | undefined} App ID and key or undefined if both inputs are empty.
171 | */
172 | githubAppInfo(): GitHubAppInfo | undefined {
173 | const authOnly = this.inputs.getBooleanInput('github-app-auth-only')
174 | const id = nonEmpty(this.inputs.getInput('github-app-id'))
175 | const installation = nonEmpty(this.inputs.getInput('github-app-installation-id'))
176 | const key = nonEmpty(this.inputs.getInput('github-app-key')?.replace(/\\n/g, '\n'))
177 |
178 | if (!id && !key) {
179 | return undefined
180 | }
181 |
182 | if (id && key) {
183 | return {
184 | authOnly, id, installation, key,
185 | }
186 | }
187 |
188 | throw new Error('`github-app-id` and `github-app-key` inputs have to be set together. One of them is missing')
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/src/modules/mill.ts:
--------------------------------------------------------------------------------
1 | import * as os from 'os'
2 | import * as path from 'path'
3 | import * as core from '@actions/core'
4 | import * as io from '@actions/io'
5 | import * as tc from '@actions/tool-cache'
6 | import * as exec from '@actions/exec'
7 |
8 | /**
9 | * Installs `Mill` and add its executable to the `PATH`.
10 | *
11 | * Throws error if the installation fails.
12 | */
13 | export async function install(): Promise {
14 | try {
15 | const millVersion = core.getInput('mill-version')
16 |
17 | const cachedPath = tc.find('mill', millVersion)
18 |
19 | if (cachedPath) {
20 | core.addPath(cachedPath)
21 | } else {
22 | const millUrl = `https://github.com/lihaoyi/mill/releases/download/${millVersion}/${millVersion}`
23 |
24 | core.debug(`Attempting to install Mill from ${millUrl}`)
25 |
26 | const binary = path.join(os.homedir(), 'bin')
27 | await io.mkdirP(binary)
28 |
29 | const mill = await tc.downloadTool(millUrl, path.join(binary, 'mill'))
30 |
31 | await exec.exec('chmod', ['+x', mill], {silent: true, ignoreReturnCode: true})
32 |
33 | await tc.cacheFile(mill, 'mill', 'mill', millVersion)
34 | }
35 |
36 | core.info(`✓ Mill installed, version: ${millVersion}`)
37 | } catch (error: unknown) {
38 | core.error((error as Error).message)
39 | throw new Error('Unable to install Mill')
40 | }
41 | }
42 |
43 | /**
44 | * Removes Mill binary
45 | */
46 | export async function remove(): Promise {
47 | await io.rmRF(path.join(path.join(os.homedir(), 'bin'), 'mill'))
48 | }
49 |
--------------------------------------------------------------------------------
/src/modules/workspace.test.ts:
--------------------------------------------------------------------------------
1 | import {fail} from 'assert'
2 | import test from 'ava'
3 | import * as sinon from 'sinon'
4 | import {type ActionCache} from '../core/cache'
5 | import {type Files} from '../core/files'
6 | import {Logger} from '../core/logger'
7 | import {mandatory} from '../core/types'
8 | import {Workspace} from './workspace'
9 |
10 | function fixture(repos_md = '') {
11 | const calls: string[] = []
12 |
13 | const files: Files = {
14 | async chmodSync(path, mode) {
15 | calls.push(`chmodSync("${path}", ${mode})`)
16 | },
17 | async mkdirP(path) {
18 | calls.push(`mkdirP("${path}")`)
19 | },
20 | async writeFileSync(path, content) {
21 | calls.push(`writeFileSync("${path}", "${content}")`)
22 | },
23 | existsSync: path => fail(`existsSync(${path}) should not be called`),
24 | async rmRF(path) {
25 | calls.push(`rmRF("${path}")`)
26 | },
27 | readFileSync(path) {
28 | calls.push(`readFileSync("${path}")`)
29 | return repos_md
30 | },
31 | }
32 |
33 | const os = {homedir: () => '/home/'}
34 |
35 | const cache: ActionCache = {
36 | async restoreCache(paths, primaryKey, restoreKeys) {
37 | calls.push(`restoreCache([${paths.toString()}], "${primaryKey}", [${restoreKeys?.toString() ?? ''}])`)
38 | return 'hit'
39 | },
40 | async saveCache(paths, key) {
41 | return calls.push(`saveCache([${paths.toString()}], "${key}")`)
42 | },
43 | }
44 |
45 | const workspace = Workspace.from(Logger.noOp, files, os, cache)
46 |
47 | return {workspace, calls}
48 | }
49 |
50 | test.before(() => {
51 | sinon.useFakeTimers()
52 | })
53 |
54 | test.after(() => {
55 | sinon.restore()
56 | })
57 |
58 | test('`Workspace.prepare()` → prepares the workspace', async t => {
59 | const {workspace, calls} = fixture()
60 |
61 | await workspace.prepare('- owner/repo1\n- owner/repo2', async () => '123', undefined)
62 |
63 | const expected: string[] = [
64 | 'mkdirP("/home/scala-steward")',
65 | 'writeFileSync("/home/scala-steward/repos.md", "- owner/repo1\n- owner/repo2")',
66 | 'writeFileSync("/home/scala-steward/askpass.sh", "#!/bin/sh\n\necho \'123\'")',
67 | 'chmodSync("/home/scala-steward/askpass.sh", 493)',
68 | ]
69 |
70 | t.deepEqual(calls, expected)
71 | })
72 |
73 | test('`Workspace.prepare()` → prepares the workspace when using a GitHub App', async t => {
74 | const {workspace, calls} = fixture()
75 |
76 | const gitHubAppInfo = {
77 | authOnly: false,
78 | id: mandatory('this-is-the-id'),
79 | installation: mandatory('this-is-the-installation-id'),
80 | key: mandatory('this-is-the-key'),
81 | }
82 |
83 | await workspace.prepare('this will not be used', async () => '123', gitHubAppInfo)
84 |
85 | const expected: string[] = [
86 | 'mkdirP("/home/scala-steward")',
87 | 'writeFileSync("/home/scala-steward/repos.md", "")',
88 | 'writeFileSync("/home/scala-steward/app.pem", "this-is-the-key")',
89 | 'writeFileSync("/home/scala-steward/askpass.sh", "#!/bin/sh\n\necho \'123\'")',
90 | 'chmodSync("/home/scala-steward/askpass.sh", 493)',
91 | ]
92 |
93 | t.deepEqual(calls, expected)
94 | })
95 |
96 | test('`Workspace.prepare()` → uses the repos input when GitHub App is "auth only"', async t => {
97 | const {workspace, calls} = fixture()
98 |
99 | const gitHubAppInfo = {
100 | authOnly: true,
101 | id: mandatory('this-is-the-id'),
102 | installation: mandatory('this-is-the-installation-id'),
103 | key: mandatory('this-is-the-key'),
104 | }
105 |
106 | await workspace.prepare('- owner/repo', async () => '123', gitHubAppInfo)
107 |
108 | const expected: string[] = [
109 | 'mkdirP("/home/scala-steward")',
110 | 'writeFileSync("/home/scala-steward/repos.md", "- owner/repo")',
111 | 'writeFileSync("/home/scala-steward/askpass.sh", "#!/bin/sh\n\necho \'123\'")',
112 | 'chmodSync("/home/scala-steward/askpass.sh", 493)',
113 | ]
114 |
115 | t.deepEqual(calls, expected)
116 | })
117 |
118 | test('`Workspace.writeAskPass()` → writes a token to the askpass.sh', async t => {
119 | const {workspace, calls} = fixture()
120 |
121 | await workspace.writeAskPass(async () => '123')
122 |
123 | const expected: string[] = [
124 | 'writeFileSync("/home/scala-steward/askpass.sh", "#!/bin/sh\n\necho \'123\'")',
125 | ]
126 |
127 | t.deepEqual(calls, expected)
128 | })
129 |
130 | test('`Workspace.remove()` → removes the workspace', async t => {
131 | const {workspace, calls} = fixture()
132 |
133 | await workspace.remove()
134 |
135 | const expected: string[] = [
136 | 'rmRF("/home/scala-steward")',
137 | ]
138 |
139 | t.deepEqual(calls, expected)
140 | })
141 |
142 | test('`Workspace.restoreWorkspaceCache()` → tries to restore the workspace cache', async t => {
143 | const {workspace, calls} = fixture('- owner/repo')
144 |
145 | await workspace.restoreWorkspaceCache()
146 |
147 | const now = Date.now()
148 |
149 | const expected: string[] = [
150 | 'readFileSync("/home/scala-steward/repos.md")',
151 | `restoreCache([/home/scala-steward/workspace], "scala-steward-acc000fd-${now}", [scala-steward-acc000fd,scala-steward-])`,
152 | ]
153 |
154 | t.deepEqual(calls, expected)
155 | })
156 |
157 | test('`Workspace.restoreWorkspaceCache()` → generates same hash for same contents', async t => {
158 | const {workspace, calls} = fixture('- owner/repo')
159 |
160 | await workspace.restoreWorkspaceCache()
161 |
162 | const now = Date.now()
163 |
164 | const expected: string[] = [
165 | 'readFileSync("/home/scala-steward/repos.md")',
166 | `restoreCache([/home/scala-steward/workspace], "scala-steward-acc000fd-${now}", [scala-steward-acc000fd,scala-steward-])`,
167 | ]
168 |
169 | t.deepEqual(calls, expected)
170 | })
171 |
172 | test('`Workspace.restoreWorkspaceCache()` → generates different hash for different contents', async t => {
173 | const {workspace, calls} = fixture('- owner/repo1')
174 |
175 | await workspace.restoreWorkspaceCache()
176 |
177 | const now = Date.now()
178 |
179 | const expected: string[] = [
180 | 'readFileSync("/home/scala-steward/repos.md")',
181 | `restoreCache([/home/scala-steward/workspace], "scala-steward-fe470d28-${now}", [scala-steward-fe470d28,scala-steward-])`,
182 | ]
183 |
184 | t.deepEqual(calls, expected)
185 | })
186 |
187 | test('`Workspace.saveWorkspaceCache()` → saves cache', async t => {
188 | const {workspace, calls} = fixture('- owner/repo')
189 |
190 | await workspace.purgeTempFilesAndSaveCache()
191 |
192 | const now = Date.now()
193 |
194 | const expected: string[] = [
195 | 'rmRF("/home/scala-steward/workspace/store/refresh_error")',
196 | 'rmRF("/home/scala-steward/workspace/repos")',
197 | 'rmRF("/home/scala-steward/workspace/run-summary.md")',
198 | 'readFileSync("/home/scala-steward/repos.md")',
199 | `saveCache([/home/scala-steward/workspace], "scala-steward-acc000fd-${now}")`,
200 | ]
201 |
202 | t.deepEqual(calls, expected)
203 | })
204 |
--------------------------------------------------------------------------------
/src/modules/workspace.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import jsSHA from 'jssha/dist/sha256'
3 | import {type ActionCache} from '../core/cache'
4 | import {type Files} from '../core/files'
5 | import {type Logger} from '../core/logger'
6 | import {type OSInfo} from '../core/os'
7 | import {mandatory, type NonEmptyString} from '../core/types'
8 | import {type GitHubAppInfo} from './input'
9 |
10 | export class Workspace {
11 | static from(
12 | logger: Logger,
13 | files: Files,
14 | os: OSInfo,
15 | cache: ActionCache,
16 | ) {
17 | return new Workspace(logger, files, os, cache)
18 | }
19 |
20 | readonly directory: string
21 | readonly workspace: NonEmptyString
22 | readonly repos_md: NonEmptyString
23 | readonly app_pem: NonEmptyString
24 | readonly askpass_sh: NonEmptyString
25 | readonly runSummary_md: string
26 |
27 | private intervalId: NodeJS.Timeout | undefined
28 |
29 | constructor(
30 | private readonly logger: Logger,
31 | private readonly files: Files,
32 | private readonly os: OSInfo,
33 | private readonly cache: ActionCache,
34 | ) {
35 | this.directory = path.join(this.os.homedir(), 'scala-steward')
36 | this.workspace = mandatory(path.join(this.directory, 'workspace'))
37 | this.repos_md = mandatory(path.join(this.directory, 'repos.md'))
38 | this.app_pem = mandatory(path.join(this.directory, 'app.pem'))
39 | this.askpass_sh = mandatory(path.join(this.directory, 'askpass.sh'))
40 | this.runSummary_md = path.join(this.workspace.value, 'run-summary.md')
41 | }
42 |
43 | /**
44 | * Tries to restore the Scala Steward workspace build from the cache, if any.
45 | */
46 | async restoreWorkspaceCache(): Promise {
47 | try {
48 | this.logger.startGroup('Trying to restore workspace contents from cache...')
49 |
50 | const hash = this.hashFile(this.repos_md.value)
51 |
52 | const cacheHit = await this.cache.restoreCache(
53 | [this.workspace.value],
54 | `scala-steward-${hash}-${Date.now().toString()}`,
55 | [`scala-steward-${hash}`, 'scala-steward-'],
56 | )
57 |
58 | if (cacheHit) {
59 | this.logger.info('Scala Steward workspace contents restored from cache')
60 | } else {
61 | this.logger.info('Scala Steward workspace contents weren\'t found on cache')
62 | }
63 |
64 | this.logger.endGroup()
65 | } catch (error: unknown) {
66 | this.logger.debug((error as Error).message)
67 | this.logger.warning('Unable to restore workspace from cache')
68 | this.logger.endGroup()
69 | }
70 | }
71 |
72 | /**
73 | * Tries to save the Scala Steward workspace build to the cache.
74 | *
75 | * @param {string} workspace - the Scala Steward workspace directory
76 | */
77 | async purgeTempFilesAndSaveCache(): Promise {
78 | try {
79 | this.logger.startGroup('Saving workspace to cache...')
80 |
81 | // We don't want to keep `workspace/store/refresh_error` nor `workspace/repos` in the cache.
82 | await this.files.rmRF(path.join(this.workspace.value, 'store', 'refresh_error'))
83 | await this.files.rmRF(path.join(this.workspace.value, 'repos'))
84 |
85 | // Don't persist a summary that's specific to this run
86 | await this.files.rmRF(this.runSummary_md)
87 |
88 | const hash = this.hashFile(this.repos_md.value)
89 |
90 | await this.cache.saveCache([this.workspace.value], `scala-steward-${hash}-${Date.now().toString()}`)
91 |
92 | this.logger.info('Scala Steward workspace contents saved to cache')
93 | this.logger.endGroup()
94 | } catch (error: unknown) {
95 | this.logger.debug((error as Error).message)
96 | this.logger.warning('Unable to save workspace to cache')
97 | this.logger.endGroup()
98 | }
99 | }
100 |
101 | /**
102 | * Prepares the Scala Steward workspace that will be used when launching the app.
103 | *
104 | * This will involve:
105 | * - Creating a folder `scala-steward` in the "HOME" directory.
106 | * - Creating a `repos.md` file inside workspace containing the repository/repositories to update.
107 | * - Creating a `app.pem` with the GitHub App key (if applicable).
108 | * - Creating a `askpass.sh` file inside workspace containing the GitHub token.
109 | * - Making the previous file executable.
110 | *
111 | * @param reposList The Markdown list of repositories to write to the `repos.md` file. It is only used if no
112 | * GitHub App key is provided on `gitHubAppKey` parameter.
113 | * @param token The GitHub Token used to authenticate into GitHub.
114 | * @param gitHubAppInfo The GitHub App information as provided by the user.
115 | */
116 | async prepare(reposList: string, token: () => Promise, gitHubAppInfo: GitHubAppInfo | undefined): Promise {
117 | try {
118 | await this.files.mkdirP(this.directory)
119 |
120 | if (gitHubAppInfo && !gitHubAppInfo.authOnly) {
121 | this.files.writeFileSync(this.repos_md.value, '')
122 | this.files.writeFileSync(this.app_pem.value, gitHubAppInfo.key.value)
123 | } else {
124 | this.files.writeFileSync(this.repos_md.value, reposList)
125 | }
126 |
127 | await this.writeAskPass(token)
128 | this.intervalId = setInterval(async () => {
129 | await this.writeAskPass(token)
130 | this.logger.info('✓ GitHub Token refreshed')
131 | }, 1000 * 60 * 50)
132 |
133 | this.files.chmodSync(this.askpass_sh.value, 0o755)
134 |
135 | this.logger.info('✓ Scala Steward workspace created')
136 | } catch (error: unknown) {
137 | this.logger.debug((error as Error).message)
138 | throw new Error('Unable to create Scala Steward workspace')
139 | }
140 | }
141 |
142 | /**
143 | * Removes the Scala Steward's workspace.
144 | */
145 | async remove(): Promise {
146 | await this.files.rmRF(this.directory)
147 | }
148 |
149 | /**
150 | * Writes a GitHub Token to the git-ask-pass file
151 | *
152 | * @param fetchToken - A function that returns a Promise resolving to a new token string.
153 | */
154 | async writeAskPass(fetchToken: () => Promise): Promise {
155 | const token = await fetchToken()
156 | this.files.writeFileSync(this.askpass_sh.value, `#!/bin/sh\n\necho '${token}'`)
157 | }
158 |
159 | /**
160 | * Cancels the token refresh if it is currently scheduled.
161 | */
162 | async cancelTokenRefresh(): Promise {
163 | if (this.intervalId) {
164 | clearInterval(this.intervalId)
165 | }
166 | }
167 |
168 | /**
169 | * Gets the first eight characters of the SHA-256 hash value for the
170 | * provided file's contents.
171 | *
172 | * @param {string} file - the file for which to calculate the hash
173 | * @returns {string} the file content's hash
174 | */
175 | private hashFile(file: string): string {
176 | // eslint-disable-next-line unicorn/text-encoding-identifier-case
177 | const sha = new jsSHA('SHA-256', 'TEXT', {encoding: 'UTF8'})
178 | sha.update(this.files.readFileSync(file, 'utf8'))
179 | return sha.getHash('HEX').slice(0, 8)
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/src/utils/docs.ts:
--------------------------------------------------------------------------------
1 | // Copy inputs from `action.yml` into `README.md`
2 |
3 | import * as fs from 'fs'
4 | import * as yaml from 'js-yaml'
5 |
6 | /**
7 | * Copy inputs from `action.yml`
8 | */
9 | type ActionYaml = {inputs: Record}
10 |
11 | const actionYaml = yaml.load(fs.readFileSync('action.yml', {encoding: 'utf8'})) as ActionYaml
12 |
13 | const inputs = Object.entries(actionYaml.inputs).flatMap(input =>
14 | [
15 | '',
16 | ...input[1].description.trimEnd().split('\n').map(line => ` # ${line}`),
17 | ...(input[1].default ? [' #', ` # Default: ${input[1].default}`] : []),
18 | ` ${input[0]}: ''`,
19 | ],
20 | )
21 |
22 | /**
23 | * Update `README.md`
24 | */
25 | const readme = fs.readFileSync('README.md', {encoding: 'utf8'})
26 |
27 | const start = readme.indexOf('') + ''.length
28 | const end = readme.indexOf('')
29 |
30 | const content = [
31 | readme.slice(0, start),
32 | '```yaml',
33 | '- uses: scala-steward-org/scala-steward-action@v2',
34 | ' with:',
35 | ...inputs.slice(1),
36 | '```',
37 | readme.slice(end),
38 | ].join('\n')
39 |
40 | fs.writeFileSync('README.md', content)
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@tsconfig/node20/tsconfig.json",
4 | "compilerOptions": {
5 | "module": "es2022",
6 | "moduleResolution": "bundler"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------