Using GitHub Actions With Your C++ Project

Amir Kirsh
Amir Kirsh reading time: 7 minutes
August 30, 2021

GitHub Actions are a way for developers and administrators to implement workflows based on code changes and events in a repository. Events can be a push (such as writing new C++ code), opening or changing a pull request (to merge updated C++ code), creating a new branch, opening or changing an issue, cron schedule, and much more.

Workflow steps are defined in YAML code and stored in the directory .github/workflows 

Actions execute on runners, which listen for available jobs and execute one job at a time to completion. By design, runners are installed in a container hosted in the GitHub virtual environment or optionally self-hosted by administrators. 

Why Use GitHub Actions with your C++ project?

GitHub Actions offer a very effective vehicle for standardizing and automating all manner of work related to code – from compiling C++ code to performing dependency checks to executing testing and more. By implementing repeatable and portable processes for activities, businesses can significantly raise confidence in the code being deployed. Actions can also increase developer velocity by improving the time to isolate code issues through testing, reporting, and notification as well as eliminating manual Continuous Integration and Continuous Deployment (CI/CD) activities such as performing security analysis, initiating tests, etc. 

For those already using other DevOps tools such as Jenkins or Codeship – Actions may appear to have several overlaps with processes already in place. Such teams may most likely benefit from Actions in either of two ways:

1. Migration – Many CI/CD frameworks already implement processes similar to Actions – such as Codeship Steps and Services – and teams already invested in such frameworks may find that the benefits of consolidating within GitHub outweigh the effort of translating existing processes into Actions. Benefits could include:

  • consolidating vendors by eliminating disparate CI/CD tools
  • cost savings
  • better integration into GitOps processes
  • greater reliability by shifting testing and validation of C++ code left

2. Capabilities – Actions aren’t necessarily mutually exclusive with other established processes; some teams find that when adding a capability – such as testing for a new library or automatically updating dependencies – Actions can be a quick and effective path to success.

    Actions may be best understood with a practical example. 

    GitHub Actions C++ – Let’s Combine Them

    In this example we will:

    • create a simple C++ program to print “Hello, World!”
    • write supporting code for compiling the program such as a configure script and a Makefile
    • add a test to validate the code
    • implement a GitHub Action, that will compile and test the C++ code on any push or pull request to the main branch.

    The code presented in this example is quite simple, nevertheless, if you wish to follow the example without creating it on your own by typing or copying the code from this post, you can find the example code here in GitHub.

    Enable Actions

    Make sure that the option “Allow all actions” is checked:

    Enable Actions_GitHub Actions C++

    Write The Code

    A simple Hello World program will provide the basis for the work (hello.cpp):

    #include <iostream>
    
    int main()
    {
        std::cout << "Hello, World!" << std::endl;
    }

    Write a configuration script named configure: 

    CXX=g++          # The C++ compiler
    CXXFLAGS=-g       # C++ complilation flags
    NATIVE=on        # compiles code targeted to current hardware
    TUNE=generic     # performance-tuning switch

    And a Makefile: 

    all:
          g++ -std=c++17 hello.cpp -o hello
    
    clean:
          $(RM) hello

    Manually test the code: 

    $ ./configure && make && ./hello
    g++ -std=c++17 hello.cpp -o hello
    g++ -std=c++17 helloTest.cpp -lcppunit -o helloTest
    Hello, World!

    Write A Test

    With the code working, write a CppUnit test (helloTest.cpp):

    #include <iostream>
    #include <cppunit/TestRunner.h>
    #include <cppunit/TestResult.h>
    #include <cppunit/TestResultCollector.h>
    #include <cppunit/extensions/HelperMacros.h>
    #include <cppunit/BriefTestProgressListener.h>
    #include <cppunit/extensions/TestFactoryRegistry.h>
    
    class Test : public CPPUNIT_NS::TestCase
    {
      CPPUNIT_TEST_SUITE(Test);
      CPPUNIT_TEST(testHelloWorld);
      CPPUNIT_TEST_SUITE_END();
    
    public:
      void setUp(void) {}
      void tearDown(void) {}
    
    protected:
      void testHelloWorld(void) {
        system("./hello >nul 2>nul");
      }
    };
    
    
    CPPUNIT_TEST_SUITE_REGISTRATION(Test);
    
    int main()
    
    {
      CPPUNIT_NS::TestResult controller;
    
      CPPUNIT_NS::TestResultCollector result;
      controller.addListener(&result);
    
      CPPUNIT_NS::BriefTestProgressListener progress;
      controller.addListener(&progress);
    
      CPPUNIT_NS::TestRunner runner;
      runner.addTest(CPPUNIT_NS::TestFactoryRegistry::getRegistry().makeTest());
      runner.run(controller);
    
      return result.wasSuccessful() ? 0 : 1;
    }

    Update the Makefile to include the test code and add a test rule: 

    all:
          g++ -std=c++17 hello.cpp -o hello
          g++ -std=c++17 helloTest.cpp -lcppunit -o helloTest
    
    test:
          chmod +x hello
          ./helloTest
    
    clean:
          $(RM) hello helloTest

    Again, manually test the code: 

    $ ./configure && make && make test
    g++ -std=c++17 hello.cpp -o hello
    g++ -std=c++17 helloTest.cpp -lcppunit -o helloTest
    chmod +x hello
    ./helloTest
    Test::testHelloWorld : OK

    Write The Action

    With code and tests written, it’s time to add a GitHub Action. Actions are stored by default in the .github/workflows/ directory as YAML files, such as .github/workflows/helloAction.yml 

    The name directive configures a string that will be displayed in the Actions dialog in GitHub. 

    The on directive tells the Action when to run, which can be as general or as granular as needed corresponding to events – such as a push – and restricted to specific branches – such as main. 

    Action workflows are made up of one or more jobs, which run in parallel by default. Jobs define where steps should be run – steps below – and what actions to take in the step – such as commands, setup tasks, or other actions in the repository. This Action will use primarily commands – such as make and configure – but also import the action checkout from upstream. 

    name: C/C++ CI
    
    on:
      push:
          branches: [ main ]
      pull_request:
          branches: [ main ]
    
    jobs:
      build-and-test:
          runs-on: ubuntu-latest
          steps:
          - uses: actions/checkout@v2
          - name: install cppunit
          run: sudo apt install -y libcppunit-dev
          - name: configure
          run: ./configure
          - name: make
          run: make
          - name: make test
          run: make test

     Commit The Code

    Add the code to the repository:

    $ git add hello.cpp helloTest.cpp Makefile configure .github/workflows/helloAction.yml
    $ git commit -m "initial commit" -a
    $ git push origin main

    Check GitHub

    Observe the results of the Action:Observe the results of the Action_GitHub Actions C++

    The Action can also be expanded to view step details: step details_GitHub Actions C++

    Test A Failure

    Certainly, successful tests are the goal, but it’s always prudent to test with a known failure when implementing something for the first time. To that end, modify the helloTest.cpp code to force a failure:

    #include <iostream>
    #include <cppunit/TestRunner.h>
    #include <cppunit/TestResult.h>
    #include <cppunit/TestResultCollector.h>
    #include <cppunit/extensions/HelperMacros.h>
    #include <cppunit/BriefTestProgressListener.h>
    #include <cppunit/extensions/TestFactoryRegistry.h>
    
    class Test : public CPPUNIT_NS::TestCase
    {
    CPPUNIT_TEST_SUITE(Test);
    CPPUNIT_TEST(testHelloWorld);
    CPPUNIT_TEST(failHelloWorld);
    CPPUNIT_TEST_SUITE_END();
    
    public:
      void setUp(void) {}
      void tearDown(void) {}
    
    protected:
      void testHelloWorld(void)
      {
        system("./hello >nul 2>nul");
      }
    
      void failHelloWorld(void)
      {
        exit(1);
      }
    
    };
    
    
    CPPUNIT_TEST_SUITE_REGISTRATION(Test);
    
    int main(int ac, char **av)
    {
      CPPUNIT_NS::TestResult controller;
    
      CPPUNIT_NS::TestResultCollector result;
      controller.addListener(&result);
    
      CPPUNIT_NS::BriefTestProgressListener progress;
      controller.addListener(&progress);
    
      CPPUNIT_NS::TestRunner runner;
      runner.addTest(CPPUNIT_NS::TestFactoryRegistry::getRegistry().makeTest());
      runner.run(controller);
    
      return result.wasSuccessful() ? 0 : 1;
    }

    Confirm with a manual test: 

    $ ./configure && make && make test
    g++ -std=c++17 hello.cpp -o hello
    g++ -std=c++17 helloTest.cpp -lcppunit -o helloTest
    chmod +x hello
    ./helloTest
    Test::testHelloWorld : OK
    Test::failHelloWorldMakefile:6: recipe for target 'test' failed
    make: *** [test] Error 1

    Commit and push the code changes: 

    $ git commit -m "test failure" helloTest.cpp && git push origin main
    [main 8b39841] test failure
     1 file changed, 3 insertions(+), 3 deletions(-)
    Counting objects: 3, done.
    Delta compression using up to 8 threads.
    Compressing objects: 100% (3/3), done.
    Writing objects: 100% (3/3), 311 bytes | 155.00 KiB/s, done.
    Total 3 (delta 2), reused 0 (delta 0)
    remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
    To github.com:jonathanmhurley/demo.git
       3828a97..8b39841  main -> main

    Observe the results in GitHub: 

    Observe the results in GitHub_1_GitHub Actions C++

    Observe the results in GitHub_2_GitHub Actions C++

    Of course, we could have tested a failure, and in many cases, this is what actually being done, by mutating the unit under test (i.e. the tested code), so for example we could have instead of imitating a failure in the test code, perform a temporary change to our hello.cpp code, make it print something else and see that the test catches it and fails.

    GitHub Actions C++ – Conclusion

    Although this example is using GitHub actions C++ and Make, GitHub Actions are a good fit for any other language and project type. Examples for using GitHub actions for other languages can be found in GitHub guides, here for python, and here for Java. A guide for deploying code to AWS cloud using GitHub actions can be found in GitHub guides here.

    GitHub Actions are for everyone, but not necessarily everything, there are still processes that require some manual handling or are too costly for setting as an automatic trigger. Usually writing an action would be most effective after you establish and stabilize the process that you wish to trigger. Before rushing to write all your imagined triggers as a GitHub action, be sure to balance the effort with your other tasks and timelines, to use it in a productive fashion. But when you see that a certain operation repeats itself, based on operations done in your repo, do consider using GitHub actions to automate it. 

    speed up c++

    Amir Kirsh
    Amir Kirsh reading time: 7 minutes minutes August 30, 2021
    August 30, 2021

    Table of Contents

    Related Posts

    7 minutes 8 Reasons Why You Need Build Observability

    Read More  

    7 minutes These 4 advantages of caching are a game-changer for development projects

    Read More  

    7 minutes What Level of Build Observability Is Right for You?

    Read More