Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 151 additions & 21 deletions .github/workflows/check-commits.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,159 @@ on:
jobs:
check-commits:
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
steps:

- uses: actions/checkout@v3

- uses: FlowingCode/action-conventional-commits@master

# TODO revert to @master before merging (fail-on-wip is not on master yet)
# 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@feature/wip-action-required
with:
report: none

# 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
run: echo "VERSION=$(grep -oPm1 "(?<=<version>)[^<]+" "pom.xml")" >> $GITHUB_ENV && cat $GITHUB_ENV | grep VERSION=

- name: Check snapshot version
if: ${{ !endsWith( env.VERSION , '-SNAPSHOT' ) }}
uses: actions/github-script@v3
with:
script: core.setFailed('Version is not SNAPSHOT')

- name: Fail on required major version
if: ${{ fromJSON(env.SEMVER_LEVEL)==3 && !startsWith( env.VERSION, '0.' ) && !endsWith( env.VERSION, '.0.0-SNAPSHOT' ) }}
uses: actions/github-script@v6
with:
script: core.setFailed("Version ${{ env.VERSION }} cannot contain breaking changes.")

- name: Fail on required minor version
if: ${{ fromJSON(env.SEMVER_LEVEL)==2 && !startsWith( env.VERSION, '0.' ) && !endsWith( env.VERSION, '.0-SNAPSHOT' ) }}
uses: actions/github-script@v6
with:
script: core.setFailed("Version ${{ env.VERSION }} cannot contain new features")
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;
const fromFork = !!pr && pr.head.repo.full_name !== pr.base.repo.full_name;
const results = JSON.parse(process.env.RESULTS || '[]');
const invalid = results.filter((r) => r.level === 'invalid');
const wip = results.filter((r) => r.level === 'wip');

// 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.header} : ${r.reason}`);
if (wip.length) {
core.warning(
'\u{1F6A7} Work-in-Progress (WIP) commits found. They must be squashed before rebasing or 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.
const create = (name, conclusion, title, summary) =>
fromFork
? Promise.resolve()
: 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 },
});

const LEVELS = ['NONE', 'PATCH', 'MINOR', 'MAJOR'];
const analysed = process.env.COMMITS_OUTCOME !== 'failure';
// Only a valid commit raises the level, so NONE is also what an analysis that
// could not parse a single commit message leaves behind. That is an unknown
// level rather than a NONE one, and the version cannot be verified against it.
const levelKnown =
!!process.env.SEMVER_LEVEL && results.some((r) => r.level !== 'invalid');
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 (!levelKnown) {
semver = ['neutral', 'The semantic versioning level is unknown',
'No commit message could be parsed, so the level of change described by this '
+ 'pull request is undetermined and 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', `🚫 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', `🚫 Version ${version} contains new features`,
'New features and deprecations must target a new MINOR version (x.y.0).'];
} else {
semver = ['success', `Version ${version} is consistent with a ${LEVELS[level]} change`,
`The commit messages in this pull request describe a ${LEVELS[level]} change.`];
}

await create('semver-alignment', semver[0], semver[1], semver[2]);

// 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 commit messages could not be retrieved, so the commits were not verified.'];
} else if (wip.length) {
wipCheck = ['failure', '\u{1F6A7} Work-in-Progress (WIP) commits found',
[
'These commits must be squashed before rebasing or merging.',
'',
'| Commit |',
'|---|',
wip.map((r) => `| \`${r.header}\` |`).join('\n'),
].join('\n')];
} else {
wipCheck = ['success', 'No Work-in-Progress (WIP) commits',
'All the commits in this pull request are consolidated.'];
}

await create('wip-commits', wipCheck[0], wipCheck[1], wipCheck[2]);

// 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} commit message(s) do not follow the guidelines`);
}
if (wip.length) {
failed.push(`${wip.length} Work-in-Progress (WIP) commit(s) must be squashed`);
}
if (semver[0] === 'failure') {
failed.push(semver[1]);
}
if (failed.length) {
core.setFailed(failed.join(' — '));
}