-
Notifications
You must be signed in to change notification settings - Fork 0
267 lines (251 loc) · 14.4 KB
/
Copy pathcheck-commits.yml
File metadata and controls
267 lines (251 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
#Check commits against Flowing Code Conventional Commits guidelines
#and verify that the Semantic Versioning change level is consistent with the version in POM
name: Check Commits
on:
workflow_call:
jobs:
check-commits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# The action only analyses the commit messages. Every conclusion, message and
# annotation is produced by the step below, so that the wording and the policy live
# here and not in a compiled bundle.
- id: commits
uses: FlowingCode/action-conventional-commits@master
with:
enforce: false
# Reading the version does not depend on the commit check, and the semver
# alignment must be reported even when the commit check failed.
- name: Get version
id: version
if: always()
run: echo "version=$(grep -oPm1 "(?<=<version>)[^<]+" "pom.xml" || true)" >> $GITHUB_OUTPUT
# The check run that GitHub creates for this job cannot carry a message: its
# conclusion is derived from the job outcome and its output is empty, so the pull
# request page would show a failing check without saying why. These check runs are
# created here instead, and they carry both the verdict and the detail.
- name: Report commit message checks
if: always()
uses: actions/github-script@v7
env:
# Passed through the environment rather than interpolated into the script:
# commit messages are untrusted input.
RESULTS: ${{ steps.commits.outputs.results }}
# SEMVER_LEVEL is exported by the action into the environment of the steps that
# follow it, so it needs no mapping here. The action only fails when the commit
# messages could not be retrieved, which is the one case where nothing was
# analysed and an empty result says nothing about the commits.
COMMITS_OUTCOME: ${{ steps.commits.outcome }}
VERSION: ${{ steps.version.outputs.version }}
with:
script: |
const guidelines =
'https://github.com/FlowingCode/DevelopmentConventions/blob/main/conventional-commits.md';
// Check runs attach to any commit, so nothing here is specific to a pull
// request: on other events the checks are reported for the pushed commit.
const sha = context.payload.pull_request?.head?.sha ?? context.sha;
// A pull request from a fork runs with a read-only token, whatever the
// permissions block asks for, so checks.create answers 403 there.
const pr = context.payload.pull_request;
// head.repo is null once the head repository has been deleted, which leaves the
// pull request as much a fork as it ever was, so reading it optionally reports a
// fork rather than throwing.
const fromFork = !!pr && pr.head.repo?.full_name !== pr.base.repo.full_name;
const results = JSON.parse(process.env.RESULTS || '[]');
// Commit headers are untrusted text, and the tables below put them in a Markdown
// cell: a pipe would split the row and a backtick would close the code span, which
// would corrupt the one place that tells the author which commits to consolidate.
// Pipes are escaped — a table resolves its escapes before the inline parsing, so
// the escape holds inside a code span — and the span is fenced with a run of
// backticks longer than any in the header, padded when the header itself opens or
// closes with one.
const codeCell = (text) => {
const escaped = String(text ?? '').replace(/\|/g, '\\|');
const longest = (escaped.match(/`+/g) ?? [])
.reduce((n, run) => Math.max(n, run.length), 0);
const fence = '`'.repeat(longest + 1);
const pad = escaped.startsWith('`') || escaped.endsWith('`') ? ' ' : '';
return `${fence}${pad}${escaped}${pad}${fence}`;
};
const invalid = results.filter((r) => r.level === 'invalid');
const wip = results.filter((r) => r.level === 'wip');
const nonWip = results.filter((r) => r.level !== 'wip');
// Squash and Merge stands in for a manual consolidation by the author if, and
// only if, the history is exactly one non-WIP commit followed by WIP commits:
// collapsing the branch then yields the commit the author would have produced.
// Any other shape — several non-WIP commits, a branch that opens with a WIP
// commit, or nothing but WIP commits — has to be consolidated by the author,
// because there is no single commit for the WIP ones to be squashed into.
const squashable = nonWip.length === 1 && results[0]?.level !== 'wip';
const wipCount = wip.length === 1
? '1 Work-in-Progress (WIP) commit'
: `${wip.length} Work-in-Progress (WIP) commits`;
const wipNoun = wip.length === 1 ? 'WIP commit' : 'WIP commits';
// File-less annotations are only possible through workflow commands, so they
// are emitted here. They attach to the check run of this job.
for (const r of invalid) core.error(`${r.sha ? r.sha.substring(0, 7) + ' ' : ''}${r.header} : ${r.reason}`);
if (wip.length) {
const found = wip.length === 1
? 'A Work-in-Progress (WIP) commit was found'
: `${wip.length} Work-in-Progress (WIP) commits were found`;
core.warning(
squashable
? `\u{1F6A7} ${found}. The branch can be squashed on merge, so the author does not need to consolidate it.`
: `\u{1F6A7} ${found}. ${wip.length === 1 ? 'It' : 'They'} must be consolidated by the author before merging.`,
);
}
// Skipped rather than attempted on a fork: the annotations above are workflow
// commands and still work, and the job still fails, so the outcome is reported
// either way — only the checks that carry it separately are missing.
// A check run that cannot be created must not take the others down with it: the
// calls are independent, and the failure message below is the only place left that
// says what was found once a check run is missing. So the error is recorded and
// reported there rather than thrown, and only when there was a finding to carry.
const uncreated = [];
const create = async (name, conclusion, title, summary) => {
if (fromFork) return;
try {
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name,
head_sha: sha,
status: 'completed',
conclusion,
details_url: guidelines,
output: { title, summary },
});
} catch (error) {
uncreated.push(name);
core.warning(
`The ${name} check run could not be created: ${error instanceof Error ? error.message : error}`,
);
}
};
const LEVELS = ['NONE', 'PATCH', 'MINOR', 'MAJOR'];
// Anything other than success means the commits were not read: the step fails when
// they cannot be retrieved, and is skipped or cancelled when an earlier step failed or
// the run was cancelled. Those leave an empty results, which must not be reported as a
// pull request with nothing to find.
const analysed = process.env.COMMITS_OUTCOME === 'success';
// Only a commit that settles a level raises it, so a commit message that could not
// be parsed leaves the level a lower bound rather than a determination: that commit
// might have described a breaking change. A lower bound can only be too low, so it is
// still enough to fail a version that the commits already outgrew; it is not enough to
// call a version consistent. Hence this is tested after the version checks and not
// before them.
//
// A WIP commit is deliberately not part of this. It must not affect the level at all —
// neither raise it, nor render it unknown — because it is going to be squashed into a
// commit whose own type settles the level.
const levelKnown = !!process.env.SEMVER_LEVEL && invalid.length === 0;
const level = Number(process.env.SEMVER_LEVEL);
const version = process.env.VERSION || '';
const zero = version.startsWith('0.');
let semver;
if (!analysed) {
semver = ['neutral', 'The commit messages were not analysed',
'The semantic versioning level is unknown, so the version was not verified.'];
} else if (!version) {
semver = ['neutral', 'No project version found in pom.xml',
'The version could not be read from pom.xml, so it was not verified.'];
} else if (!version.endsWith('-SNAPSHOT')) {
semver = ['failure', `🚫 Version ${version} is not a SNAPSHOT`,
'The version in pom.xml must be a SNAPSHOT.'];
} else if (level === 3 && !zero && !version.endsWith('.0.0-SNAPSHOT')) {
semver = ['failure', '🚫 MAJOR version required',
`Version ${version} contains breaking changes. Breaking changes must target a `
+ 'new MAJOR version (x.0.0).'];
} else if (level === 2 && !zero && !version.endsWith('.0-SNAPSHOT')) {
semver = ['failure', '🚫 MINOR version required',
`Version ${version} contains new features. New features and deprecations must `
+ 'target a new MINOR version (x.y.0).'];
} else if (!levelKnown) {
semver = ['neutral', 'The semantic versioning level is unknown',
'The level of change described by this pull request is undetermined, because a '
+ 'commit message could not be parsed. The version was not verified.'];
} else {
semver = ['success', `Consistent: ${LEVELS[level]} change`,
`Version ${version} is consistent with a ${LEVELS[level]} change, which is what `
+ 'the commit messages in this pull request describe.'];
}
const [semverConclusion, semverTitle, semverSummary] = semver;
await create('semver-alignment', semverConclusion, semverTitle, semverSummary);
// An empty result is not evidence of consolidated commits when nothing was
// analysed, so this check is reported as unknown rather than as a success.
let wipCheck;
if (!analysed) {
wipCheck = ['neutral', 'The commit messages were not analysed',
'The step that analyses the commit messages did not complete, so the commits '
+ 'were not verified.'];
} else if (wip.length && squashable) {
// Kept short: a check run title is truncated in the list on the pull
// request, so the detail belongs in the summary rather than the title.
wipCheck = ['action_required', `\u{1F6A7} Must squash ${wipNoun}`,
[
`This branch is one non-WIP commit followed by ${wipCount.replace(/^1 /, 'a ')},`
+ ' so it can be collapsed at merge time instead of being consolidated by'
+ ' the author.',
'',
'Merge it with **Squash and Merge**, and edit the final commit message to be'
+ ' descriptive before finalizing. If the author communicated a final commit'
+ ' message, use theirs.',
'',
'| Commit |',
'|---|',
wip.map((r) => `| ${codeCell(r.header)} |`).join('\n'),
].join('\n')];
} else if (wip.length) {
wipCheck = ['failure', `\u{1F6A7} Must consolidate ${wipNoun}`,
[
nonWip.length === 0
? 'Every commit in this branch is a WIP commit, so there is no commit for'
+ ' them to be squashed into.'
: nonWip.length === 1
? 'This branch opens with a WIP commit, so it is not one non-WIP commit'
+ ' followed by WIP commits.'
: `This branch has ${nonWip.length} non-WIP commits, so collapsing it at`
+ ' merge time would lose the distinction between them.',
'',
'The author must consolidate it with an interactive rebase, squashing each WIP'
+ ' commit into the commit it belongs to, and force-push the branch.',
'',
'| Commit |',
'|---|',
wip.map((r) => `| ${codeCell(r.header)} |`).join('\n'),
].join('\n')];
} else {
wipCheck = ['success', 'No Work-in-Progress (WIP) commits',
'All the commits in this pull request are consolidated.'];
}
const [wipConclusion, wipTitle, wipSummary] = wipCheck;
await create('wip-commits', wipConclusion, wipTitle, wipSummary);
// The job fails if any of the checks above failed, so that the pull request
// is marked as failing; the checks themselves say which one it was.
const failed = [];
if (invalid.length) {
failed.push(
invalid.length === 1
? '1 commit message does not follow the guidelines'
: `${invalid.length} commit messages do not follow the guidelines`,
);
}
if (wip.length) {
failed.push(
`${wipCount} must be ${squashable ? 'squashed on merge' : 'consolidated by the author'}`,
);
}
if (semverConclusion === 'failure') {
failed.push(semverTitle);
}
// Only worth failing over when a check run was carrying a verdict. With nothing
// found, its absence withholds nothing, and failing here would mark a pull request
// that has no problem as failing — which is also why a fork, where the check runs
// are skipped outright, does not fail on their account either.
if (uncreated.length && failed.length) {
failed.push(`${uncreated.join(' and ')} could not be reported`);
}
if (failed.length) {
core.setFailed(failed.join(' — '));
}