From 92a11ff2454b6a09788c1325a653ca5c0d1889c8 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:45 -0300 Subject: [PATCH 1/6] refactor: carry the sha of every commit alongside its message The commits of a pull request were mapped to their message and everything else was discarded, and the commits of a push carry their identifier as id rather than sha. Both are now mapped to a message and a sha, so that a caller can identify a commit and not only read it. --- src/extractCommits.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/extractCommits.ts b/src/extractCommits.ts index a3d6fad..4319c1a 100644 --- a/src/extractCommits.ts +++ b/src/extractCommits.ts @@ -5,6 +5,7 @@ import got from "got"; type Commit = { message: string; + sha?: string; }; /** The URL of the next page, taken from the Link header of a response. */ @@ -42,7 +43,8 @@ const extractCommits = async (context, token?: string): Promise => { // For "push" events, commits can be found in the "context.payload.commits". const pushCommits = Array.isArray(get(context, "payload.commits")); if (pushCommits) { - return context.payload.commits; + core.info(`ℹ️ Read ${context.payload.commits.length} commit(s) from the push payload.`); + return context.payload.commits.map((commit) => ({message: commit.message, sha: commit.id})); } // For PRs, we need to get a list of commits via the GH API: @@ -55,10 +57,13 @@ const extractCommits = async (context, token?: string): Promise => { } const items = await readCommits(prCommitsUrl, token); - core.info(`ℹ️ Read ${items.length} commit(s).`); - return items.map((item) => item.commit); + core.info(`ℹ️ Read ${items.length} commit(s) from the pull request.`); + return items.map((item) => ({message: item.commit.message, sha: item.sha})); } + // Neither a push nor a pull request: there is nothing to read, and saying which event + // it was keeps this apart from a push or a pull request that carries no commits. + core.info(`ℹ️ No commits to check: the "${context.eventName}" event has neither a push payload nor a pull request.`); return []; }; From a971a9cd289dc2a838b2e1b5c177b1922fb059cd Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:49 -0300 Subject: [PATCH 2/6] feat: expose the result of the analysis through outputs The action reported its findings only through the log and its own outcome, so a caller could not tell what was found. It now also sets results, which describes every commit as JSON: its sha, its header, whether it is valid, WIP or invalid, and the reason when it is invalid. results is set before the check fails, so that it can be read from a step that runs on failure, and on the path where the commits cannot be retrieved, where it is empty. It is the only output: has-errors and has-wip are derivable from results, and the Semantic Versioning level is already exported as the SEMVER_LEVEL environment variable. --- README.md | 13 +++++++++++++ action.yml | 6 ++++++ src/main.ts | 21 ++++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0409e3f..58e04ac 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,19 @@ Note that, typically, you would make this check on a pre-commit hook (for exampl - Changes of type `deprecate:`, `test:`, `ci:`, `style:` and `docs:` must not be breaking. - Commits of type `remove:` must be breaking changes (i.e. `remove!: something`) +### Outputs + +The result of the analysis is available as outputs: + +|Name|Type|Description| +|---|---|---| +|`results`|output|The result for every commit, as a JSON array of `{sha, header, level, reason}`, where `level` is `valid`, `wip` or `invalid`| + +The `results` output is set before the check fails, so it is available even when the check +failed. It is set on every path, including the one where the commit messages cannot be +retrieved: it is then an empty array, and the action fails, so a caller tells that apart +from a pull request with no findings by the outcome of the step rather than by the output. + ### Semantic Versioning After the action completes, the `SEMVER_LEVEL` environment variable is set according to the highest level of [Semantic Versioning](https://semver.org/spec/v2.0.0.html) change described by the commit messages: diff --git a/action.yml b/action.yml index fd7439e..e600e18 100644 --- a/action.yml +++ b/action.yml @@ -10,6 +10,12 @@ inputs: commits anonymously. required: false default: ${{ github.token }} +outputs: + results: + description: > + The result for every commit, as a JSON array of objects with a "sha", a + "header", a "level" ("valid", "wip" or "invalid") and, for invalid commits, + a "reason". runs: using: node20 main: dist/main/index.js diff --git a/src/main.ts b/src/main.ts index eab29df..5659ff7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,13 @@ const core = require("@actions/core"); import {validateCommitMessage, isWIP, getSemverLevel, SemverLevel} from "./isValidCommitMesage"; import extractCommits from "./extractCommits"; +type Result = {sha?: string, header: string, level: string, reason?: string}; + +function setOutputs(semverLevel: SemverLevel, results: Result[]) { + core.exportVariable('SEMVER_LEVEL', semverLevel.toString()); + core.setOutput('results', JSON.stringify(results)); +} + async function run() { core.info( `ℹ️ Checking if commit messages are following the Flowing Code Commit Message Guidelines...` @@ -14,9 +21,7 @@ async function run() { extractedCommits = await extractCommits(context, core.getInput('token')); } catch (error) { // Not being able to analyse anything is a failure of the action itself. - // SEMVER_LEVEL is exported nonetheless, so that a later step reading it - // does not read an empty value. - core.exportVariable('SEMVER_LEVEL', '0'); + setOutputs(0, []); core.setFailed( `🚫 The commit messages could not be checked: ${error instanceof Error ? error.message : error}` ); @@ -26,9 +31,12 @@ async function run() { let semverLevel : SemverLevel = 0; let hasErrors = false; let hasWIP = false; + const results : Result[] = []; core.startGroup("Commit messages:"); for (let i = 0; i < extractedCommits.length; i++) { let commit = extractedCommits[i]; + const header = commit.message.split('\n')[0]; + const sha = commit.sha; let errmsg = validateCommitMessage(commit.message); if (errmsg === null) { @@ -36,18 +44,21 @@ async function run() { if (commitSemverLevel>semverLevel) semverLevel=commitSemverLevel; if (isWIP(commit.message)) { hasWIP = true; + results.push({sha, header, level: 'wip'}); core.info(`🚧 ${commit.message}`); } else { + results.push({sha, header, level: 'valid'}); core.info(`✅ ${commit.message}`); } } else { - core.info(`🚩 ${commit.message} : ${errmsg}`); + results.push({sha, header, level: 'invalid', reason: errmsg}); hasErrors = true; + core.info(`🚩 ${header} : ${errmsg}`); } } core.endGroup(); - core.exportVariable('SEMVER_LEVEL', semverLevel.toString()); + setOutputs(semverLevel, results); if (hasErrors) { core.setFailed( From 8a7e2efab4d8a9d010b51df48826496654f4d75d Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:52 -0300 Subject: [PATCH 3/6] feat: report invalid commit messages as annotations core.error creates an annotation on the check run, so the offending commit and the reason why it is invalid are visible on the pull request itself, instead of only in the job log. The abbreviated digest precedes the header, which does not identify the commit on its own when the same message appears more than once in a pull request, and leaves nothing to look the commit up by. --- src/main.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 5659ff7..e9e013e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -53,7 +53,10 @@ async function run() { } else { results.push({sha, header, level: 'invalid', reason: errmsg}); hasErrors = true; - core.info(`🚩 ${header} : ${errmsg}`); + // core.error creates an annotation on the check run, so the offending + // commit and the reason are visible on the pull request itself. + const digest = sha ? `${sha.substring(0, 7)} ` : ''; + core.error(`🚩 ${digest}${header} : ${errmsg}`); } } core.endGroup(); From ddb549ac2f41790b2378e87cb685ff4aba21016b Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:56 -0300 Subject: [PATCH 4/6] feat: add enforce input for callers that report the outcome themselves The action reported the outcome the only way it knew, and a caller could not ask for anything else: an invalid commit message and a Work-in-Progress commit both failed the check with the same red X, though only one of them is a defect. A WIP commit is now listed in the results output as a level of its own, so that a caller can report it as a check of its own and the red X is left to mean what it used to mean alone. The action itself still fails on a WIP commit when it reports, because a step cannot both block the merge and avoid the red X: telling the two apart is the caller's to do. An invalid commit message still fails. enforce says whether the action reports at all. With false it becomes a pure analyzer: it produces the result for every commit, exits successfully and writes no annotations, so that the caller can render the conclusions, the wording and the annotations itself, without those being compiled into this action. A value other than true or false fails the action rather than being read as the default, so that a misspelling is not silently a policy. The comparison is exact, so TRUE is an error too. --- README.md | 23 +++++++++++++++++------ action.yml | 12 ++++++++++++ src/main.ts | 39 ++++++++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 58e04ac..b811981 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,29 @@ Note that, typically, you would make this check on a pre-commit hook (for exampl - Changes of type `deprecate:`, `test:`, `ci:`, `style:` and `docs:` must not be breaking. - Commits of type `remove:` must be breaking changes (i.e. `remove!: something`) -### Outputs +### Work in Progress -The result of the analysis is available as outputs: +Commits of type `WIP` are valid, but they must be squashed before rebasing or merging. + +By default a WIP commit fails the check. That blocks the merge, but it marks the pull +request as failing, and an unfinished branch is an expected state rather than an error. A +check run of its own, concluding `action_required`, blocks the merge without the failure; +only the workflow can create one, so `enforce: false` leaves that reporting to the caller. |Name|Type|Description| |---|---|---| +|`enforce`|input|Whether the action reports the outcome and fails on what it found, WIP commits included (default `true`). With `false` it only produces outputs. Any value other than `true` or `false` is an error| |`results`|output|The result for every commit, as a JSON array of `{sha, header, level, reason}`, where `level` is `valid`, `wip` or `invalid`| -The `results` output is set before the check fails, so it is available even when the check -failed. It is set on every path, including the one where the commit messages cannot be -retrieved: it is then an empty array, and the action fails, so a caller tells that apart -from a pull request with no findings by the outcome of the step rather than by the output. +With `enforce: false` the action only analyzes: it does not fail on what it found in the +commits and writes no annotations, and the caller reports the outcome from `results`. That +covers invalid commit messages as much as WIP commits: both are in `results`, and both then +need a conclusion from the caller. + +`results` is set on every path, and before the check fails, so it is available whatever the +outcome. If the commit messages cannot be retrieved it is an empty array, and the action +fails on that path even with `enforce: false`, so a caller tells it apart from a pull request +with no findings by the outcome of the step rather than by the output. ### Semantic Versioning diff --git a/action.yml b/action.yml index e600e18..3150863 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,18 @@ name: "Flowing Code Commit Message Guidelines" description: "Ensures that all commit messages are following the Flowing Code Commit Message Guidelines." inputs: + enforce: + description: > + Whether this action reports the outcome and fails on what it found, which includes + failing on Work-in-Progress (WIP) commits. With false it becomes a pure analyzer: + it then only produces outputs, does not fail on what it found in the commits and + writes no annotations, and the caller is expected to report the outcome + (conclusions, annotations) on its own — which is what a caller that reports WIP + commits as a check of its own asks for. + Any value other than true or false is an error, so that a misspelling is not read + as the default. + required: false + default: "true" token: description: > The token used to read the commits of a pull request. It defaults to the token of diff --git a/src/main.ts b/src/main.ts index e9e013e..6e38b47 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,10 +16,30 @@ async function run() { `ℹ️ Checking if commit messages are following the Flowing Code Commit Message Guidelines...` ); + // action.yml supplies the default whenever the action is called as one, so the input + // is absent only when the bundle runs outside Actions, and the fallback is for that + // alone. A value that is present but empty was written by the caller — an unset + // workflow input interpolated into it, say — and is an error like any other value + // that is neither true nor false, rather than silently the default. + const input = process.env.INPUT_ENFORCE; + const value = input === undefined ? 'true' : input.trim(); + if (value !== 'true' && value !== 'false') { + setOutputs(0, []); + core.setFailed( + `🚫 The enforce input must be true or false, not "${value}".` + ); + return; + } + + /** Whether this action reports the outcome and fails on what it found. When false, + it only produces outputs, and the caller is expected to report the outcome. */ + const enforce = value === 'true'; + let extractedCommits; try { extractedCommits = await extractCommits(context, core.getInput('token')); } catch (error) { + // Reporting is left to the caller only for the outcome of the analysis. // Not being able to analyse anything is a failure of the action itself. setOutputs(0, []); core.setFailed( @@ -27,7 +47,7 @@ async function run() { ); return; } - + let semverLevel : SemverLevel = 0; let hasErrors = false; let hasWIP = false; @@ -53,22 +73,31 @@ async function run() { } else { results.push({sha, header, level: 'invalid', reason: errmsg}); hasErrors = true; - // core.error creates an annotation on the check run, so the offending - // commit and the reason are visible on the pull request itself. + // When this action reports, core.error creates an annotation on the check + // run, so the offending commit is visible on the pull request itself. const digest = sha ? `${sha.substring(0, 7)} ` : ''; - core.error(`🚩 ${digest}${header} : ${errmsg}`); + const line = `🚩 ${digest}${header} : ${errmsg}`; + if (enforce) core.error(line); else core.info(line); } } core.endGroup(); setOutputs(semverLevel, results); + if (!enforce) return; + if (hasErrors) { core.setFailed( `🚫 According to the Flowing Code Commit Message Guidelines, some of the commit messages are not valid.` ); } else if (hasWIP) { - core.setFailed(`🚧 Work-in-Progress (WIP) commits found.`); + // A WIP commit must not be merged, and a step cannot both block the merge and + // avoid the red X: that needs a check run of its own, which only a caller can + // create. So the action keeps failing, and a caller that reports WIP for itself + // asks for enforce: false rather than being handed a green check by default. + core.setFailed( + `🚧 Work-in-Progress (WIP) commits found. They must be squashed before rebasing or merging.` + ); } else if (extractedCommits.length === 0) { core.info(`No commits to check, skipping...`); } else { From dfa2e38ad7bbc3dc221e20bc8820cfd92e3533b5 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:00:59 -0300 Subject: [PATCH 5/6] fix: classify scoped and breaking WIP commits as WIP isWIP matched the literal "WIP:" prefix, but validateCommitMessage accepts WIP(scope): and WIP!: as well, so those commits were reported as valid. The results output published by this branch turns that into a contract callers gate merges on, so the type is parsed off the header rather than prefix-matched. --- src/__tests__/isValidCommitMessage.test.ts | 2 ++ src/isValidCommitMesage.ts | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/__tests__/isValidCommitMessage.test.ts b/src/__tests__/isValidCommitMessage.test.ts index 8d03509..a319134 100644 --- a/src/__tests__/isValidCommitMessage.test.ts +++ b/src/__tests__/isValidCommitMessage.test.ts @@ -66,6 +66,8 @@ test("should be able to correctly parse the semver level", () => { test("should be able to correctly detect WIP commits", () => { expect(isWIP("WIP: foo")).toBe(true); + expect(isWIP("WIP(scope): foo")).toBe(true); + expect(isWIP("WIP!: foo")).toBe(true); expect(isWIP("wip: foo")).toBe(false); expect(isWIP("WIP")).toBe(false); expect(isWIP("fix: foo")).toBe(false); diff --git a/src/isValidCommitMesage.ts b/src/isValidCommitMesage.ts index 11cccb9..587b139 100644 --- a/src/isValidCommitMesage.ts +++ b/src/isValidCommitMesage.ts @@ -41,8 +41,11 @@ const MAJOR_COMMIT_TYPES = [ export type SemverLevel = 0 | 1 | 2 | 3; -export const isWIP = (message): string | null => { - return message.startsWith("WIP:"); +export const isWIP = (message): boolean => { + // The type is parsed rather than matched as a prefix: "WIP(scope):" and "WIP!:" are + // accepted by validateCommitMessage, and were reported as valid commits. + const match = message.match(/^(\w+)(\(\S+?\))?(!?): /); + return match !== null && match[1] === "WIP"; } export const validateCommitMessage = (message): string | null => { From 357dfe7d91bd1be025f01dd004226a51789f6f06 Mon Sep 17 00:00:00 2001 From: Javier Godoy <11554739+javier-godoy@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:01:05 -0300 Subject: [PATCH 6/6] build: rebuild the dist bundle Rebuilt from the current sources. --- dist/main/index.js | 64 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/dist/main/index.js b/dist/main/index.js index 31121ce..bcd4e35 100644 --- a/dist/main/index.js +++ b/dist/main/index.js @@ -62646,7 +62646,10 @@ const MAJOR_COMMIT_TYPES = [ "remove", ]; const isWIP = (message) => { - return message.startsWith("WIP:"); + // The type is parsed rather than matched as a prefix: "WIP(scope):" and "WIP!:" are + // accepted by validateCommitMessage, and were reported as valid commits. + const match = message.match(/^(\w+)(\(\S+?\))?(!?): /); + return match !== null && match[1] === "WIP"; }; const validateCommitMessage = (message) => { let [header] = message.split('\n'); @@ -62781,7 +62784,8 @@ const extractCommits = (context, token) => __awaiter(void 0, void 0, void 0, fun // For "push" events, commits can be found in the "context.payload.commits". const pushCommits = Array.isArray(lodash_get_default()(context, "payload.commits")); if (pushCommits) { - return context.payload.commits; + core.info(`ℹ️ Read ${context.payload.commits.length} commit(s) from the push payload.`); + return context.payload.commits.map((commit) => ({ message: commit.message, sha: commit.id })); } // For PRs, we need to get a list of commits via the GH API: const prCommitsUrl = lodash_get_default()(context, "payload.pull_request.commits_url"); @@ -62790,9 +62794,12 @@ const extractCommits = (context, token) => __awaiter(void 0, void 0, void 0, fun core.warning(`⚠️ The commits of the pull request are being read anonymously, which GitHub rate limits per IP address and which cannot read a private repository. The token input is empty: unless that is deliberate, it is a mistake in the configuration.`); } const items = yield readCommits(prCommitsUrl, token); - core.info(`ℹ️ Read ${items.length} commit(s).`); - return items.map((item) => item.commit); + core.info(`ℹ️ Read ${items.length} commit(s) from the pull request.`); + return items.map((item) => ({ message: item.commit.message, sha: item.sha })); } + // Neither a push nor a pull request: there is nothing to read, and saying which event + // it was keeps this apart from a push or a pull request that carries no commits. + core.info(`ℹ️ No commits to check: the "${context.eventName}" event has neither a push payload nor a pull request.`); return []; }); /* harmony default export */ const src_extractCommits = (extractCommits); @@ -62811,27 +62818,48 @@ const { context } = __nccwpck_require__(5438); const main_core = __nccwpck_require__(2186); +function setOutputs(semverLevel, results) { + main_core.exportVariable('SEMVER_LEVEL', semverLevel.toString()); + main_core.setOutput('results', JSON.stringify(results)); +} function run() { return main_awaiter(this, void 0, void 0, function* () { main_core.info(`ℹ️ Checking if commit messages are following the Flowing Code Commit Message Guidelines...`); + // action.yml supplies the default whenever the action is called as one, so the input + // is absent only when the bundle runs outside Actions, and the fallback is for that + // alone. A value that is present but empty was written by the caller — an unset + // workflow input interpolated into it, say — and is an error like any other value + // that is neither true nor false, rather than silently the default. + const input = process.env.INPUT_ENFORCE; + const value = input === undefined ? 'true' : input.trim(); + if (value !== 'true' && value !== 'false') { + setOutputs(0, []); + main_core.setFailed(`🚫 The enforce input must be true or false, not "${value}".`); + return; + } + /** Whether this action reports the outcome and fails on what it found. When false, + it only produces outputs, and the caller is expected to report the outcome. */ + const enforce = value === 'true'; let extractedCommits; try { extractedCommits = yield src_extractCommits(context, main_core.getInput('token')); } catch (error) { + // Reporting is left to the caller only for the outcome of the analysis. // Not being able to analyse anything is a failure of the action itself. - // SEMVER_LEVEL is exported nonetheless, so that a later step reading it - // does not read an empty value. - main_core.exportVariable('SEMVER_LEVEL', '0'); + setOutputs(0, []); main_core.setFailed(`🚫 The commit messages could not be checked: ${error instanceof Error ? error.message : error}`); return; } let semverLevel = 0; let hasErrors = false; let hasWIP = false; + const results = []; main_core.startGroup("Commit messages:"); for (let i = 0; i < extractedCommits.length; i++) { let commit = extractedCommits[i]; + const header = commit.message.split('\n')[0]; + const sha = commit.sha; let errmsg = validateCommitMessage(commit.message); if (errmsg === null) { const commitSemverLevel = getSemverLevel(commit.message); @@ -62839,24 +62867,40 @@ function run() { semverLevel = commitSemverLevel; if (isWIP(commit.message)) { hasWIP = true; + results.push({ sha, header, level: 'wip' }); main_core.info(`🚧 ${commit.message}`); } else { + results.push({ sha, header, level: 'valid' }); main_core.info(`✅ ${commit.message}`); } } else { - main_core.info(`🚩 ${commit.message} : ${errmsg}`); + results.push({ sha, header, level: 'invalid', reason: errmsg }); hasErrors = true; + // When this action reports, core.error creates an annotation on the check + // run, so the offending commit is visible on the pull request itself. + const digest = sha ? `${sha.substring(0, 7)} ` : ''; + const line = `🚩 ${digest}${header} : ${errmsg}`; + if (enforce) + main_core.error(line); + else + main_core.info(line); } } main_core.endGroup(); - main_core.exportVariable('SEMVER_LEVEL', semverLevel.toString()); + setOutputs(semverLevel, results); + if (!enforce) + return; if (hasErrors) { main_core.setFailed(`🚫 According to the Flowing Code Commit Message Guidelines, some of the commit messages are not valid.`); } else if (hasWIP) { - main_core.setFailed(`🚧 Work-in-Progress (WIP) commits found.`); + // A WIP commit must not be merged, and a step cannot both block the merge and + // avoid the red X: that needs a check run of its own, which only a caller can + // create. So the action keeps failing, and a caller that reports WIP for itself + // asks for enforce: false rather than being handed a green check by default. + main_core.setFailed(`🚧 Work-in-Progress (WIP) commits found. They must be squashed before rebasing or merging.`); } else if (extractedCommits.length === 0) { main_core.info(`No commits to check, skipping...`);