From IDE to Pipeline: CI/CD for CODESYS 4 with GitHub Actions

CI/CD
CODESYS
PLC
DevOps
IEC 61131-3

Context: CODESYS 4 shipped in September 2026. Everyone talks about the browser UI, but the part that caught my eye is that a PLC project is now a folder of text files with a CLI called c4-cli. That’s the piece we were always missing to put PLC code in a real pipeline, without fragile export scripts or bolt-on tooling. Below is how I’d wire it up with GitHub Actions.


1. Why this is a big deal for PLC people

For most of my career a PLC project was one binary blob. Sure, you could drop it in Git, but you couldn’t diff it, review it, or build it on a server. Be honest: “version control” usually meant machine_final_v3_REALLY_final.project sitting in a shared folder.

CODESYS 4 changes three things that actually matter here (I’m leaning on Simon Adamek’s CODESYS 4 breakdown for the specifics):

  1. The project is a folder of text files. .fbslib (libraries), .fbsdev (device projects), .fbsws (workspaces); JSON/XML/plain text inside. Git diffs them like source code.
  2. Deterministic dependencies. Libraries.json (version ranges like 3.5.*) plus Libraries.lock.json (exact resolved versions + SHA384). The build server uses exactly the libraries your laptop used.
  3. A real CLI, c4-cli. Build a compiled library, pack a source archive, or compile a boot application with no IDE and no license on the build machine (free until end of 2028).

None of this is new to a software team; it’s table stakes there. The news is that PLC code finally gets it too.


2. The mental model: PLC code as a software artifact

 Engineer (CODESYS 4)
   edits ST → folder of text files (.fbslib)
        │
        │ git push → Pull Request
        ▼
 GitHub  ──triggers──▶  CI runner (self-hosted)
   PR                     1. c4-cli restore
   ▲                      2. c4-cli build
   │ status check         3. library save-compiled
   │ (pass / fail)        4. upload artifact
   └───────────────────────────┘
 PASS → merge allowed
 FAIL → red check, merge blocked

The point: a broken build now fails the way broken code does — on the pull request, at your desk, instead of at the machine during commissioning.


3. Repository layout

my-st-library/
├─ src/
│  └─ MyLib.fbslib/          # CODESYS 4 library (text files)
│     ├─ Libraries.json      # declared deps (version ranges)
│     ├─ Libraries.lock.json # resolved versions + SHA384
│     └─ ... POUs as text ...
├─ .github/
│  └─ workflows/
│     └─ build.yml           # the pipeline below
├─ CHANGELOG.md
└─ README.md

Because everything under src/ is text, a reviewer sees a real diff on a PR:

  FUNCTION_BLOCK FB_Motor
  VAR_INPUT
-     rSpeedSetpoint : REAL;
+     rSpeedSetpoint : REAL;   // 0..100 %
+     bEnable        : BOOL;
  END_VAR

4. A concrete GitHub Actions workflow

c4-cli is a native tool, so the pipeline is really just shell steps. The one catch: c4-cli has to be installed on the runner, so you run a self-hosted runner — a small Windows or Linux box / VM with CODESYS 4 on it. You don’t need a CODESYS license for the build until 2028.

# .github/workflows/build.yml
name: Build CODESYS 4 Library

on:
  pull_request:
    paths: ["src/**"]
  push:
    branches: [main]
    tags: ["v*"]

jobs:
  build:
    runs-on: [self-hosted, codesys4]      # your runner with c4-cli installed
    defaults:
      run:
        working-directory: src/MyLib.fbslib
    steps:
      - uses: actions/checkout@v4

      # 1. Resolve deps to the EXACT versions in Libraries.lock.json
      - name: Restore dependencies
        run: c4-cli restore

      # 2. Fail early if the lock file is out of date vs Libraries.json
      - name: Verify dependencies are locked
        run: c4-cli check --dependencies

      # 3. Build the compiled library (this is the actual "compile" gate)
      - name: Build compiled library
        run: c4-cli library save-compiled --output ../../out/MyLib.compiled-library

      # 4. Pack a source archive for release
      - name: Pack source library
        run: c4-cli library pack --output ../../out/MyLib.library

      # 5. Publish artifacts so reviewers can download the build
      - uses: actions/upload-artifact@v4
        with:
          name: MyLib-build
          path: out/

What each step buys you:

StepGuarantees
restore + check --dependenciesthe build uses the locked library versions; a drifted lock file fails the PR
library save-compiledthe code actually compiles on a clean machine, not just on the author’s laptop
library packa reproducible source archive ready to attach to a release
upload-artifactreviewers and the release job get the exact binaries built by CI

5. Release automation on a tag

Semantic versioning is built into CODESYS 4’s dependency model, so wire releases to Git tags:

  release:
    needs: build
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: [self-hosted, codesys4]
    steps:
      - uses: actions/download-artifact@v4
        with: { name: MyLib-build, path: out }
      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            out/MyLib.compiled-library
            out/MyLib.library

Flow:

 git tag v1.2.0
   └▶ push tag
        └▶ build job (compile + pack)
             └▶ release job
                 attaches .compiled-library
                 + .library to the GitHub Release

Now “cutting a release” of a PLC library is one git tag — the same muscle memory as shipping any npm or NuGet package.


6. A boot-app pipeline for small machines

Adamek’s article names a second production-ready use case: small device projects on Modbus TCP, like test benches and simple machines. c4-cli bootapp compile builds the boot application headless:

  bootapp:
    runs-on: [self-hosted, codesys4]
    steps:
      - uses: actions/checkout@v4
      - run: c4-cli restore
      - run: c4-cli bootapp compile --output out/app.bootapp
      - uses: actions/upload-artifact@v4
        with: { name: bootapp, path: out/app.bootapp }

You get a versioned, CI-built boot application artifact ready to load onto the controller, with a full audit trail of which commit produced it.


7. Behavioral testing on CODESYS: compile ≠ correct

c4-cli library save-compiled only proves the code compiles. It tells you nothing about whether FB_Motor actually scales its setpoint the way you think it does. For that you have to run the code on a runtime and check what it does — the same idea behind TcUnit. Trouble is, TcUnit is married to TwinCAT (Tc2_* libraries, ADS logging, a Beckhoff XAE runner). On CODESYS you rebuild that pattern from CODESYS-native pieces — and the nice part is you can do it with no hardware, because CODESYS 4 runs a virtual runtime (Virtual Control for Linux SL).

The CODESYS-native test stack

  1. An assertion FB in plain ST. A tiny xUnit-style block with no vendor libraries, so it’s portable across CODESYS and Automation Builder.
  2. A test-runner POU that exercises your code and records pass/fail.
  3. A runtime to execute on. Virtual Control for Linux SL in a container on the CI runner, no PLC hardware.
  4. Result export. The runtime writes a JUnit XML to disk via the CAA File library; the CI reads it and turns it into a pass/fail gate.

Minimal ST assertion block

FUNCTION_BLOCK FB_Assert
VAR
    nTotal  : UDINT;
    nPassed : UDINT;
    nFailed : UDINT;
    sReport : STRING(2000);   // accumulated JUnit <testcase> lines
END_VAR

METHOD AreEqualInt : BOOL
VAR_INPUT
    sName    : STRING;
    expected : DINT;
    actual   : DINT;
END_VAR
nTotal := nTotal + 1;
IF expected = actual THEN
    nPassed := nPassed + 1;
    sReport := CONCAT(sReport,
        CONCAT('  <testcase name="', CONCAT(sName, '"/>$N')));
ELSE
    nFailed := nFailed + 1;
    sReport := CONCAT(sReport, CONCAT('  <testcase name="', CONCAT(sName,
        CONCAT('"><failure>expected=', CONCAT(DINT_TO_STRING(expected),
        CONCAT(' actual=', CONCAT(DINT_TO_STRING(actual),
        '</failure></testcase>$N')))))));
END_IF

A test-runner POU (runs once on cold start)

PROGRAM PRG_TestRunner
VAR
    Assert     : FB_Assert;
    Writer     : FB_JUnitWriter;
    fbMotor    : FB_Motor;
    xTestsDone : BOOL;
END_VAR

IF NOT xTestsDone THEN
    // test 1: 50 % setpoint, enabled → raw output 500
    fbMotor(rSpeedSetpoint := 50, bEnable := TRUE);
    Assert.AreEqualInt('Motor_scales_50pct', 500, fbMotor.nRawOutput);

    // test 2: disabled → output forced to 0
    fbMotor(rSpeedSetpoint := 50, bEnable := FALSE);
    Assert.AreEqualInt('Motor_disabled_zero', 0, fbMotor.nRawOutput);

    xTestsDone := TRUE;
END_IF

// write the results file once the tests have run (async, see below)
Writer(xExecute := xTestsDone, sPath := '/data/results.xml',
       sBody := Assert.sReport, nTotal := Assert.nTotal, nFailed := Assert.nFailed);

Writing the results file: FB_JUnitWriter

Because CODESYS 4 1.0.0.0 ships with no Test Manager, the runtime has to write the result file itself. There’s no c4-cli command that reads PLC variables for you. CAA File handles the writing, but its FBs are asynchronous (xExecute/xDone spread over several cycles), so the writer ends up being a small state machine. You write it once and reuse it on every project:

FUNCTION_BLOCK FB_JUnitWriter
VAR_INPUT
    xExecute : BOOL;
    sPath    : STRING;        // '/data/results.xml'
    sBody    : STRING(2000);  // Assert.sReport
    nTotal   : UDINT;
    nFailed  : UDINT;
END_VAR
VAR_OUTPUT
    xDone  : BOOL;
    xError : BOOL;
END_VAR
VAR
    fbOpen  : FILE.Open;      // CAA File (CmpFileSys)
    fbPuts  : FILE.Puts;
    fbClose : FILE.Close;
    hFile   : CAA.HANDLE;
    eStep   : (IDLE, OPEN, HEADER, BODY, FOOTER, CLOSE, DONE);
    sLine   : STRING(2000);
END_VAR

CASE eStep OF
  IDLE:
    IF xExecute THEN eStep := OPEN; END_IF

  OPEN:                                   // create / overwrite the file
    fbOpen(xExecute := TRUE, sFileName := sPath, eFileMode := FILE.MODE.MWRITE);
    IF fbOpen.xDone THEN hFile := fbOpen.hFile; eStep := HEADER;
    ELSIF fbOpen.xError THEN xError := TRUE; eStep := DONE; END_IF

  HEADER:
    sLine := CONCAT('<testsuite tests="', CONCAT(UDINT_TO_STRING(nTotal),
             CONCAT('" failures="', CONCAT(UDINT_TO_STRING(nFailed), '">$N'))));
    fbPuts(xExecute := TRUE, hFile := hFile, sLine := sLine);
    IF fbPuts.xDone THEN fbPuts(xExecute := FALSE); eStep := BODY; END_IF

  BODY:
    fbPuts(xExecute := TRUE, hFile := hFile, sLine := sBody);
    IF fbPuts.xDone THEN fbPuts(xExecute := FALSE); eStep := FOOTER; END_IF

  FOOTER:
    fbPuts(xExecute := TRUE, hFile := hFile, sLine := '</testsuite>$N');
    IF fbPuts.xDone THEN fbPuts(xExecute := FALSE); eStep := CLOSE; END_IF

  CLOSE:
    fbClose(xExecute := TRUE, hFile := hFile);
    IF fbClose.xDone THEN xDone := TRUE; eStep := DONE; END_IF
END_CASE

Three caveats. The FILE.* blocks are CAA File (CmpFileSys); on some Automation Builder and CODESYS targets the equivalent is SysFile, and on Virtual Control for Linux SL CAA File is available. /data/... has to be a path the runtime can see, meaning the mapped container volume that matches the -v .../data:/data mount in the Actions job. And STRING is bounded (2000 here), so for large suites write incrementally after each test instead of buffering everything.

Runtime-in-CI flow

 c4-cli bootapp compile
   └▶ start Virtual Control (container)
        └▶ load + COLD reset → test task runs once
             └▶ ST assertions write JUnit
                /data/results.xml  (CAA File)
                  └▶ CI reads results.xml
                       └▶ pass/fail → gate the PR

The GitHub Actions job

  behavioral-test:
    runs-on: [self-hosted, codesys4]
    steps:
      - uses: actions/checkout@v4
      - run: c4-cli restore

      # build the boot app whose active application is PRG_TestRunner
      - name: Compile test boot app
        run: c4-cli bootapp compile --output out/tests.bootapp

      # spin up a virtual CODESYS controller — no hardware
      - name: Start Virtual Control for Linux SL
        run: docker run -d --name vctl -v ${{ github.workspace }}/data:/data
             codesys/virtual-control-linux-sl:latest

      # deploy and cold-start so the test task executes once
      - name: Deploy & run tests
        run: |
          c4-cli controller login  --target vctl
          # cold start → tests run, results.xml written
          c4-cli controller reset  --target vctl
          c4-cli controller logout --target vctl

      - name: Wait for results file
        run: ./scripts/wait-for-file.sh data/results.xml 60

      - name: Publish test results (gates the PR)
        uses: EnricoMi/publish-unit-test-result-action@v2
        with:
          files: data/results.xml

      - name: Stop controller
        if: always()
        run: docker rm -f vctl

The container image name is illustrative. CODESYS ships Virtual Control for Linux SL, which you package as the runner’s test runtime. c4-cli already provides controller login/logout and reset, so the deploy-and-run step is native.

Getting the JUnit XML out as a pipeline drop

The runtime writes results.xml inside the container. To use it, you need it on the runner filesystem and then published. The data path:

 ST (FB_JUnitWriter)
   └▶ /data/results.xml   (inside the container)
        via volume mount  -v $WORKSPACE/data:/data
   └▶ $WORKSPACE/data/results.xml   (on the runner)
        └▶ artifact "drop" + test report

With the -v .../data:/data mount above, the file appears on the runner by itself, no copy needed. Without a mount, pull it out with docker cp:

      # fallback when not using a volume mount
      - name: Copy results out of the container
        if: always()
        run: |
          mkdir -p data
          docker cp vctl:/data/results.xml data/results.xml

      # the DROP: a downloadable artifact on the run page
      - name: Upload JUnit as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: data/results.xml

Two things matter here:

  • if: always() on both the copy and the uploads. You want the results especially when tests fail, otherwise the drop never gets published.
  • Timing. The runtime runs asynchronously, so don’t read the file immediately. wait-for-file.sh polls until it exists and is non-empty (with a timeout); alternatively have FB_JUnitWriter also write a done.flag sentinel and wait on that.

On Azure DevOps the same two outputs are PublishTestResults@2 (the gate/report) and PublishPipelineArtifact@1 (the actual “drop”):

  - script: docker cp vctl:/data/results.xml $(Build.ArtifactStagingDirectory)/results.xml
    displayName: Collect JUnit
    condition: always()
  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: '$(Build.ArtifactStagingDirectory)/results.xml'
  - task: PublishPipelineArtifact@1
    condition: always()
    inputs:
      targetPath: '$(Build.ArtifactStagingDirectory)'
      artifact: 'drop'

So you end up with both surfaces: a human-readable pass/fail report that gates the PR, and a downloadable results.xml drop attached to the run for archiving or audit.

Compile gate vs behavioral gate

GateToolProves
Compile (§4)c4-cli library save-compiledthe code builds on a clean machine
Behavioral (§7)ST runner on Virtual Control + JUnitthe logic actually does the right thing

Run both on every PR and you have the two gates software teams take for granted, adapted to CODESYS, with no hardware in the loop.


8. Where the honesty is: what CI can and can’t do today

CI only proves what the tooling supports, and CODESYS 4 1.0.0.0 is deliberately limited:

Want in your pipelineStatus in CODESYS 4 1.0.0.0What to do
Compile libraries (ST/LD)✅ native via c4-clithe workflow in §4
Deterministic deps✅ Libraries.lock.jsonrestore + check
Boot app for Modbus TCP machines✅ bootapp compile§6
Automated unit tests❌ no Test Manager yetroll an ST runner on a virtual controller — §7
Static analysis❌ not in 4 yetCODESYS 3, or lint the text files yourself
Online change / forcing / breakpoints❌keep CODESYS 3 for commissioning

So today CI/CD for CODESYS 4 is strongest for library development and small Modbus machines, the two cases CODESYS Group ships as production-ready.


9. The hybrid pattern (what I’d actually run)

You don’t have to choose. Write and test in CODESYS 3, build in the CODESYS 4 pipeline:

 CODESYS 3 (desktop)
   author + tests + static analysis (rich tooling)
        │ commit (text files)
        ▼
 CODESYS 4 pipeline (CI)
   c4-cli restore / build / save-compiled / pack
        └▶ artifact + release
  • Static analysis and commissioning stay in CODESYS 3, where the tooling is rich.
  • Reproducible compile, locked deps, release artifacts, and behavioral tests on a virtual controller (§7) move to the CODESYS 4 CLI.
  • The text format is the contract between the two. Both environments read the same library source.

You don’t have to borrow CODESYS 3 just for tests. §7 runs a hardware-free behavioral gate entirely on the CODESYS 4 side. Use CODESYS 3 for what 4 still lacks, static analysis and online commissioning, not for testing.


Your IEC 61131-3 and CODESYS 3 skills carry over unchanged; what you add is the software-engineering layer around them.

The same discipline, config as a versioned artifact behind an automated gate, is what substation engineering needs for IEC 61850 SCD files. I’ll write that one up separately.


Credit: CODESYS 4 feature breakdown based on Simon Adamek’s “CODESYS 4: The Web-Based IEC 61131-3 IDE.”

© 2026 Jan Ksenak. All rights reserved.

Design inspired by CleverDeveloper.

Made with 💓 By Jan. Powered by Astro.