You are currently viewing Better Practices in CI, .NET, and GitHub Actions: CI Test and Publish Stages

Better Practices in CI, .NET, and GitHub Actions: CI Test and Publish Stages

Part 1 of 3 of the Better CI In Stages series

Portfolio Project

This series covers an example for .NET CI. This post will cover the CI test and publish stages.

Pipeline Parallels

The script has the test and publish jobs dependent on the build job. This allows the test and publish to run in parallel. The deploy job then requires both test and publish jobs to run.

We get the speed of running test and publish jobs concurrently, yet we still block the deploy if either fails.

CI Test Stage

The example’s test stage run tests that are essentially black-box tests of the API, written with XUnit, versus unit tests testing classes or class methods. Systems testing is considered out of scope for this example.

Test Stage Yaml

 test:
    name: API Black-Box Testing
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash

    needs: [build]       
    steps:     

    - name: Checkout
      uses: actions/checkout@v3

    - name: Validate directory state
      run: |
        du -sh ./*

    - name: Nuget from cache
      id: cache-nuget
      uses: actions/cache@v3
      with:
        path: nuget
        key: nuget-${{ hashFiles('LabDemo.MinimalApi/LabDemo.MinimalApi.csproj')}}

    - name: Validate directory state
      run: |
        du -sh ./*

    - name: Download artifact from build job
      uses: actions/download-artifact@v3

    - name: Validate directory state
      run: |
        du -sh ./*

    # artifacts are overkill for this small of project, but done as a 
    # poc and best practice for larger systems

    - name: Test the api
      run: |
        pwd
        du -sh .
        dotnet test $TEST_PROJECT/$TEST_PROJECT.csproj -c Release -o $BUILD_PATH

    - name: Validate directory state
      run: |
        du -sh ./*

Caching and Dependencies

On line 23, did you notice that the cache is only the app’s external dependencies in the cache? Not the test project dependencies?

We cache app dependencies for use in the build, test, and publish jobs. The test dependencies are only needed in the test job.

The script on line 43 pulls the test dependencies from the nuget source specified in the nuget.config.

Test Output

The -o parameter in the dotnet test command is misleading. That’s where the build output lives — a combination of the build stage output from the cache and the build of the test project.

This is NOT where any test result lives, per the documentation. dotnet test does have a --results-directory if we wanted to preserve and artifact test results.

So, in this example, we rely on the the workflow job passing or failing. The job will fail if any of the tests ran by dotnet test fail.

CI Test Validation

Through the validation output we can see

  1. The external dependencies pop in to the directory via the cache
  2. The build artifact from the app build
  3. Both the build and nuget subdirectories growing in size after dotnet test built the test project

Test Stage Summary

So, the test stage uses the app dependencies from the cache and the app build output as an artifact to save time, to save from repeating steps done in the build stage.

But, we’re not done yet — the publish stage is next. It will need the cache and artifact as well.

CI Publish Stage

This stage requires the build artifact (generated in the build stage and used by the test stage), then generates the final product to be delivered and deployed.

Publish Stage Yaml

  publish:
    name: Publish the API
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash

    needs: [build]          
    steps:

    - name: Checkout
      uses: actions/checkout@v3

    - name: Validate directory state
      run: |
        du -sh ./*

    - name: App's nuget from cache
      id: cache-nuget
      uses: actions/cache@v3
      with:
        path: nuget
        key: nuget-app-${{ hashFiles('LabDemo.MinimalApi/LabDemo.MinimalApi.csproj')}}

    - name: Validate directory state
      run: |
        du -sh ./*

    - name: Download artifact from build job
      uses: actions/download-artifact@v3

    - name: Validate directory state
      run: |
        du -sh ./*

    # artifacts are overkill for this small of project, but done as a 
    # poc and best practice for larger systems

    - name: Publish the api
      run: dotnet publish $PROJECT/$PROJECT.csproj -c Release -p:OutDir=$BUILD_PATH -o $PUBLISH_PATH

    - name: Validate directory state
      run: |
        du -sh ./*

    - name: Upload artifact for deployment
      uses: actions/upload-artifact@v3
      with:
        name: ${{env.PUBLISH_ARTIFACT_NAME}}
        path: ${{env.PUBLISH_PATH}}
        retention-days: 1

Caching and Dependencies

On line 18 of the publish job the cache is accessed, and on line 29 the download of build artifact happens.

The dotnet publish on line 40 will regenerate the nuget dependencies if the cache step fails to provide a cache. The artifact used in this job is the artifact generated by the build job.

Line 8 ensures the publish stage will not happen if the test stage fails.

Pointing to the Build Directory

The dotnet publish command on line 40 includes a -o directory where we’re directing the final product to deploy.

Less obviously, the -p:OutDir=$BUILD_PATH points to the build artifact.

CI Publish Validation

We can validate the behavior of the publish job with manual visual inspection, checking to be sure dotnet behaved in the desired way.

In the screenshot, we can see the nuget directory exists after the cache step. This directory remains unchanged, as expected.

We can also see the build artifact download and remain unchanged in size.

Finally, we can see the publish directory and the size of its contents.

CI Summary

In this series, we’ve seen an efficient but basic way to structure CI steps for a .NET app.

We’ve also seen a way to track that each dotnet step is using the inputs as given, versus regenerating products already created in previous CI steps.

As a bonus, we saw how a bash script can be used locally to imitate each of the dotnet steps in these jobs, so that we can explore the behavior covered in the app’s GitHub Actions yaml.

CR Johnson

As a software engineer with over a decade of experience working for Fortune 50 companies developing software for Windows, the web, and a few interplanetary spacecraft, she's programmed in a plethora of languages including the C#/ASP.NET stack and, recently, Rails. She has tweaked more CSS files than she can count and geeks out a little on data and SQL databases. In her spare time she works on her first novel and enjoys bicycling and dark chocolate.