From IDE to Pipeline: CI/CD for CODESYS 4 with GitHub Actions
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):
- 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. - Deterministic dependencies.
Libraries.json(version ranges like3.5.*) plusLibraries.lock.json(exact resolved versions + SHA384). The build server uses exactly the libraries your laptop used. - 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:
| Step | Guarantees |
|---|---|
restore + check --dependencies | the build uses the locked library versions; a drifted lock file fails the PR |
library save-compiled | the code actually compiles on a clean machine, not just on the author’s laptop |
library pack | a reproducible source archive ready to attach to a release |
upload-artifact | reviewers 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
- An assertion FB in plain ST. A tiny xUnit-style block with no vendor libraries, so it’s portable across CODESYS and Automation Builder.
- A test-runner POU that exercises your code and records pass/fail.
- A runtime to execute on.
Virtual Control for Linux SLin a container on the CI runner, no PLC hardware. - 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-clialready providescontroller login/logoutandreset, 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.shpolls until it exists and is non-empty (with a timeout); alternatively haveFB_JUnitWriteralso write adone.flagsentinel 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
| Gate | Tool | Proves |
|---|---|---|
| Compile (§4) | c4-cli library save-compiled | the code builds on a clean machine |
| Behavioral (§7) | ST runner on Virtual Control + JUnit | the 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 pipeline | Status in CODESYS 4 1.0.0.0 | What to do |
|---|---|---|
| Compile libraries (ST/LD) | ✅ native via c4-cli | the workflow in §4 |
| Deterministic deps | ✅ Libraries.lock.json | restore + check |
| Boot app for Modbus TCP machines | ✅ bootapp compile | §6 |
| Automated unit tests | ❌ no Test Manager yet | roll an ST runner on a virtual controller — §7 |
| Static analysis | ❌ not in 4 yet | CODESYS 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.”