From 72f2fa99fa7fb864ff515956fc37acf33d26d611 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:48:13 -0300 Subject: [PATCH 1/3] feat(check-commits): report the commit checks from the action outputs 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 a pull request showed a failing check without saying why, and a WIP commit was reported exactly like an invalid commit message. The action is now asked not to report anything, and this workflow renders the outcome instead. The offending commits become annotations, the presence of WIP commits is reported as a check of its own, and the job fails if either check failed, so that the pull request is marked as failing while the checks themselves say which one it was. Nothing here is specific to pull requests: check runs attach to any commit, so the checks are also reported for the commits of other events. A pull request from a fork is the exception: it runs with a read-only token whatever the permissions block asks for, so no check run can be created for it. Rather than answering 403, the checks are skipped there. The annotations 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, which is why they must not be required on a repository that takes contributions from forks. --- .github/workflows/check-commits.yml | 95 ++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 6f7cd97..7141aa6 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -9,12 +9,21 @@ on: jobs: check-commits: runs-on: ubuntu-latest + permissions: + contents: read + checks: write steps: - uses: actions/checkout@v3 - - uses: FlowingCode/action-conventional-commits@master - + # 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: + report: none + - name: Get version run: echo "VERSION=$(grep -oPm1 "(?<=)[^<]+" "pom.xml")" >> $GITHUB_ENV && cat $GITHUB_ENV | grep VERSION= @@ -35,3 +44,85 @@ jobs: uses: actions/github-script@v6 with: script: core.setFailed("Version ${{ env.VERSION }} cannot contain new features") + + # 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 }} + 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 }, + }); + + await create( + 'wip-commits', + wip.length ? 'failure' : 'success', + wip.length + ? '\u{1F6A7} Work-in-Progress (WIP) commits found' + : 'No Work-in-Progress (WIP) commits', + wip.length + ? [ + 'These commits must be squashed before rebasing or merging.', + '', + '| Commit |', + '|---|', + wip.map((r) => `| \`${r.header}\` |`).join('\n'), + ].join('\n') + : 'All the commits in this pull request are consolidated.', + ); + + // 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 (failed.length) { + core.setFailed(failed.join(' — ')); + } From 95229138d8e7988fb6ef674e9767bea7d19d140b Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:48:13 -0300 Subject: [PATCH 2/3] feat(check-commits): report semver alignment as its own check The consistency between the version in the POM and the level of semantic versioning change described by the commit messages was enforced by failing this job, which made it indistinguishable from an invalid commit message. It is now reported as a check of its own, with the version and the level in its title, and it joins the other checks in failing the job. Both of its inputs are reported as unknown rather than assumed. The version is read with if: always(), because reading it does not depend on the commit check. The level is read from the SEMVER_LEVEL environment variable that the action exports, and it counts as unknown in two cases: when the action could not analyse the commit messages at all, which it signals by failing, and when no commit message could be parsed, because only a valid commit raises the level and NONE is also what an unparseable pull request leaves behind. The WIP check is reported as unknown on the first of those paths too, because an empty result is not evidence of consolidated commits. --- .github/workflows/check-commits.yml | 108 +++++++++++++++++++--------- 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 7141aa6..07a99ad 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -24,26 +24,12 @@ jobs: 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 "(?<=)[^<]+" "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 "(?<=)[^<]+" "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 @@ -56,6 +42,12 @@ jobs: # 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 = @@ -97,22 +89,65 @@ jobs: output: { title, summary }, }); - await create( - 'wip-commits', - wip.length ? 'failure' : 'success', - wip.length - ? '\u{1F6A7} Work-in-Progress (WIP) commits found' - : 'No Work-in-Progress (WIP) commits', - wip.length - ? [ - 'These commits must be squashed before rebasing or merging.', - '', - '| Commit |', - '|---|', - wip.map((r) => `| \`${r.header}\` |`).join('\n'), - ].join('\n') - : 'All the commits in this pull request are consolidated.', - ); + 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. @@ -123,6 +158,9 @@ jobs: 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(' — ')); } From 446876c4ea437463077007068852ccdc9c574895 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:48:13 -0300 Subject: [PATCH 3/3] WIP: ci: point to the action feature branch for testing The report input is not on action-conventional-commits master yet, so the workflow has to reference the feature branch for the new behaviour to be testable from a consumer repository. This commit must be dropped before merging. --- .github/workflows/check-commits.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-commits.yml b/.github/workflows/check-commits.yml index 07a99ad..40d7d21 100644 --- a/.github/workflows/check-commits.yml +++ b/.github/workflows/check-commits.yml @@ -16,11 +16,12 @@ jobs: - uses: actions/checkout@v3 + # 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@master + uses: FlowingCode/action-conventional-commits@feature/wip-action-required with: report: none