All writing

A CI gate that is silently off: MegaLinter's FAIL_IF_UPDATED_SOURCES

MegaLinter's FAIL_IF_UPDATED_SOURCES fires only when its value ends up as the exact string "true". How it stays off without a word, a config that holds, and a pull request that proves it.

Note5 min readFacts checked

Contents

A common setup on GitHub Actions: MegaLinter runs with APPLY_FIXES, fixers rewrite files inside the container, and the results come back in the updated_sources folder of the report artifact. Nothing commits them: on Actions MegaLinter itself never does, and the template's commit steps are switched off. For that to be a gate, the job has to fail whenever a fixer changed something, so that a human commits the result.

FAIL_IF_UPDATED_SOURCES exists for exactly that, since v5.12.0 in 2022. In one pipeline I looked at, runs stayed green while the summary's Fixed column was above zero and updated_sources was full. The option was off, and nothing said so. Everything below is checked against v10.1.0, the latest release today.

One exact string turns it on

The switch is one comparison, MegaLinter.py lines 207-209:

self.fail_if_updated_sources = (
    config.get(self.request_id, "FAIL_IF_UPDATED_SOURCES", "false") == "true"
)

config.get turns YAML booleans into "true" or "false" and passes everything else through untouched. Replaying that logic with PyYAML 6.0.3, the version MegaLinter locks:

Set inValueGate
.mega-linter.ymltrue, True, yes, "true"on
.mega-linter.yml"True", 1off
.mega-linter.ymlFAIL_IF_UPDATED_SOURCE: true (typo)off
environmenttrueon
environmentTrue, 1, emptyoff

The environment rows matter most. MegaLinter lays the environment over the config file (config.py lines 132-140), so any environment value wins. A step env: entry FAIL_IF_UPDATED_SOURCES: ${{ vars.SOME_VAR }} with the variable unset still reaches the container as an empty string and overrides a correct file. That is the case I hit on GitHub Actions.

The file also has to be found. MegaLinter takes the first of .mega-linter.yml, .megalinter.yml, .mega-linter.yaml and .megalinter.yaml in the workspace root and does not merge them, so a stray .mega-linter.yml shadows the file you edit. A .mega-linter.yml in .github/linters/, the default folder for linter rule files, is not read unless MEGALINTER_CONFIG points to it. Meanwhile the official template sets APPLY_FIXES: all in its workflow env:, so fixes still appear and the run looks configured.

MegaLinter warns in none of these cases. The signature is Fixed above zero with no "failed because of FAIL_IF_UPDATED_SOURCES" line in the summary. For the file, MegaLinter's JSON schema helps. SchemaStore maps the schema to .mega-linter.yml, and the schema types the key as a boolean and rejects unknown keys. An editor that validates against SchemaStore flags the "True", 1 and misspelled rows, and so does MegaLinter's own YAML_V8R linter whenever it lints the file; on "True" it fails the run with .mega-linter.yml#/FAIL_IF_UPDATED_SOURCES must be boolean. The schema also rejects the working "true". Nothing catches the environment rows.

with: is the wrong channel

Setting FAIL_IF_UPDATED_SOURCES: true under with: on the MegaLinter step does nothing. MegaLinter's action.yml declares no inputs. The Actions runner exports a with: entry as INPUT_FAIL_IF_UPDATED_SOURCES, and nothing in MegaLinter reads an INPUT_ variable. This is the one variant that warns at all: the runner adds an "Unexpected input(s)" annotation to an otherwise green run.

DISABLE_ERRORS turns the failure into exit 0

Before v8.2.0 the gate ran after a successful lint and called sys.exit(1) itself. PR #4217, merged on 2024-11-03, added an error status to the main run so the PR comment could explain it; the old exit path is still there but can no longer trigger. Under a global DISABLE_ERRORS: true, error status exits 0 (MegaLinter.py lines 1339-1342). The job is green while the summary reads:

MegaLinter failed because of FAIL_IF_UPDATED_SOURCES=true

On v8.1.0 the same configuration exited 1, so a repository that set both options lost its gate on upgrade (traced in code, not run).

Two smaller traps: when the gate fires, the console never names it ("Error(s) have been found during linting"); only the markdown summary and the PR comment do. And when another blocking linter fails too, the gate does not run at all: the run is red for that lint error alone, and fixing only that error leads to a second red run, this time for the fixes.

Private repositories fail before the gate runs

Since PR #7681 (v9.5.0) the template checks out with persist-credentials: false. On pull requests and pushes to other branches it lints only the diff, and MegaLinter fetches origin/HEAD for that. In a private repository the fetch has no credentials:

cmdline: git fetch origin HEAD:refs/remotes/origin/HEAD
stderr: 'fatal: could not read Username for 'https://github.com': No such device or address'

The run is red before any linter starts. The template's commented alternative, persist-credentials: true, fixes it at the cost of keeping the job token on disk for the rest of the job; that is what its zizmor: ignore[artipacked] comment acknowledges. VALIDATE_ALL_CODEBASE: true also avoids the fetch.

A configuration that holds

One config file, at the root, with an unquoted boolean:

# .mega-linter.yml, the only MegaLinter config file in the repository
APPLY_FIXES: all
FAIL_IF_UPDATED_SOURCES: true
# Do not add DISABLE_ERRORS: true; it turns the gate's failure into exit 0.

And a workflow that does not compete with it. Checkout is as in the template; MegaLinter is pinned to the release checked here:

- uses: actions/checkout@v6
  with:
    persist-credentials: true # needed in private repos: diff mode fetches origin/HEAD
    fetch-depth: 0
- uses: oxsecurity/megalinter@v10.1.0
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    # No FAIL_IF_UPDATED_SOURCES and no DISABLE_ERRORS here.

Keep the template's job permissions: (at least contents: read and pull-requests: write) and its artifact-upload step; the PR comment and the artifact below depend on them.

If you keep the rest of the template, set APPLY_FIXES_EVENT: none. By default its later steps commit fixes on pull requests. Those steps run only when the MegaLinter step passed, which is exactly when the gate did not stop the run, and with persisted credentials their push can succeed.

Prove that it fires

First read the [config] line in the step log. It should name your file, as in [config] /github/workspace/.mega-linter.yml + Environment variables. "Environment variables only (no config file found …)" means it was never read.

Then open a pull request that adds a file one of your fixers will rewrite, such as int main(void){return 0;} with clang-format enabled. Expect a red run, the "failed because of" line in the PR comment, and the fixed file in the artifact. Commit the artifact's version and the run goes green with Fixed 0.

The test pull request must not trip any other blocking linter, or the gate never runs. With the default linter set, cpplint already fails this file, both before and after clang-format, on a missing copyright line: the run is red, but not for the reason you are testing. For the test, list only the fixers you are testing in ENABLE_LINTERS, as the local script below does.

I ran this sequence in a throwaway private repository, with an explicit linter list and cpplint set to non-blocking. A second PR with the same files and FAIL_IF_UPDATED_SOURCES: ${{ vars.SOME_UNSET_VAR }} in the step env went green, same fixes in the artifact.

Locally, the same check looks like this. It creates and works in a fiu-demo directory; the image is amd64-only, so add --platform linux/amd64 on arm64:

mkdir -p fiu-demo && cd fiu-demo
git init -q
printf 'int main(void){return 0;}\n' > main.c
printf 'APPLY_FIXES: all\nENABLE_LINTERS:\n  - C_CLANG_FORMAT\nFAIL_IF_UPDATED_SOURCES: true\n' > .mega-linter.yml
git add main.c .mega-linter.yml && git -c user.name=t -c user.email=t@t commit -qm init
lint() {
  git checkout -q -- main.c
  docker run --rm -v "$PWD:/tmp/lint" -e MARKDOWN_SUMMARY_REPORTER=true \
    -e CLEAR_REPORT_FOLDER=true "$@" ghcr.io/oxsecurity/megalinter-c_cpp:v10.1.0 > /dev/null
  echo "exit=$?"; grep FAIL_IF_UPDATED_SOURCES megalinter-reports/megalinter-report.md
  ls megalinter-reports/updated_sources
}
lint                                  # exit=1, the "failed because of" line, main.c
lint -e FAIL_IF_UPDATED_SOURCES=True  # exit=0, no line, main.c still listed

Upstream

I reported both upstream: issue #9103 for the gate and issue #9104 for the template. Both are open as of 2026-10-06.

One more item from #9103, traced in code but not run: the gate counts fixes per linter, while the artifact is a whole-repository git diff taken after POST_COMMANDS, so project-mode fixers and post-commands can fill updated_sources without failing the run. Until #9103 is resolved, the only evidence I trust is a pull request built to fail, one per kind of fixer I rely on: a file-based one like clang-format, any project-mode one, and anything in POST_COMMANDS. If one of them goes green, the gate does not cover it.