Mert Özen Further With Every Line
DevOps GitHub Actions — #07

GitHub Actions in a .NET + Angular Monorepo: Path Filters, Passing Artifacts and a Single Release Package

Mert Özen Nov 8, 2026 10 min 4 views
GitHub Actions in a .NET + Angular Monorepo: Path Filters, Passing Artifacts and a Single Release Package

Path-filtered separate workflows leave required checks pending; in a monorepo, a single workflow with a changes job that decides via git diff and an always-running summary job (ci-ok) fixes that. We carry Angular's dist into the .NET publish's wwwroot with an artifact, produce a single release zip on a tag push, and explain artifact versus cache and a GitHub Release with gh release create, using act output.

Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403), Node 24, Angular 22, actions/upload-artifact@v7 and actions/download-artifact@v8. The workflows were run locally with act; they were not run on GitHub, and the job that creates the GitHub Release was never executed.

Guess First

The Bookstore repository has two CI (Continuous Integration) workflows: api-ci.yml runs only when api/** changes, web-ci.yml only when web/** changes. To protect main, the team makes the important jobs of both workflows required checks: build-test and lint-test-build. Ada makes a CSS (Cascading Style Sheets) fix on the Angular side only and opens a pull request. web-ci runs and finishes green. Can the pull request be merged?

It cannot. api-ci was never triggered because of the path filter, and as the documentation states plainly, the check of a workflow skipped by a filter stays "Pending"; as long as a required check is pending, the merge button is locked. Nearly every team that uses path filters in a monorepo runs into this at some point. In this post we look at the fix and at the monorepo's two other questions: how to carry Angular's build output into the .NET publish, and how to produce a single release package on a tag push.

Two Ways: Separate Workflows or One Workflow?

TopicSeparate workflows + pathsOne workflow + job conditions
Change detectionGitHub does it, the workflow never startsYou do it (changes job)
Skipped workThe workflow "does not exist", the check stays pendingThe job is "skipped", the run completes
Required checksIncompatible with path filtersA single summary job is made required
ReadabilityEach app's CI lives in its own fileOne file, longer
Links between jobsNo needs across workflowsNatural with needs and artifacts
CostA skipped workflow starts no runnerchanges and the summary job start a runner for a few seconds every time

If you don't use required checks, separate workflows are simple and sufficient. If you do, there are two options: either remove the jobs of path-filtered workflows from the required check list (and accept that a red check will not block a merge), or switch to a single workflow and make an "always-running summary job" required. For Bookstore we built the second one as ci.yml. In this series we also keep api-ci.yml and web-ci.yml in the repository as detailed examples; in a real project you pick one, not both.

The changes Job: Which Folder Changed?

The single workflow triggers on every push and pull request; the first job decides which jobs will run:

  changes:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      api: ${{ steps.filter.outputs.api }}
      web: ${{ steps.filter.outputs.web }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - id: filter
        env:
          BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
        run: |
          if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
            echo "no base commit, building everything"
            echo "api=true" >> "$GITHUB_OUTPUT"
            echo "web=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          changed=$(git diff --name-only "$BASE" "$GITHUB_SHA")
          echo "$changed"
          api=false
          web=false
          if grep -qE '^(api/|global\.json$|\.github/workflows/ci\.yml$)' <<< "$changed"; then api=true; fi
          if grep -qE '^(web/|\.github/workflows/ci\.yml$)' <<< "$changed"; then web=true; fi
          echo "api=$api" | tee -a "$GITHUB_OUTPUT"
          echo "web=$web" | tee -a "$GITHUB_OUTPUT"

The comparison base depends on the event: github.event.pull_request.base.sha for a pull request, github.event.before (the commit the branch pointed to before the push) for a push. On the first push of a new branch, before is all zeros; in that case we build everything so as not to miss anything. fetch-depth: 0 is required because checkout fetches only the last commit by default, and git diff cannot find the base commit. We pass BASE through env: because of the script injection rule from the second post.

There are ready-made actions that do this (the best known is dorny/paths-filter); they let you write filters with globs in YAML (YAML Ain't Markup Language) and fetch the list of changed files from the API (Application Programming Interface) on pull requests. We preferred a few lines of shell script: it adds no third-party dependency and what it does is visible at a glance. Whichever way you choose, apply the Marketplace evaluation from the first post here too.

We gave act the push in which Ada changed only web/src/styles.css, with an event payload that has the before field filled in:

$ act push -W .github/workflows/ci.yml -e push-web-only.json
[ci/changes]   | web/src/styles.css
[ci/changes]   | api=false
[ci/changes]   | web=true
[ci/changes] 🏁  Job succeeded
[ci/web    ] 🏁  Job succeeded
[ci/ci-ok  ] ⭐ Run Main Check job results
[ci/ci-ok  ]   | results: success skipped success
[ci/ci-ok  ]   | all required jobs passed or were skipped
[ci/ci-ok  ] 🏁  Job succeeded

git diff found a single file, the api job did not run, web passed lint, test and build, and ci-ok read the three results (changes, api, web, in that order) and finished green. Unlike the situation in the second post, where act did not evaluate path filters, this mechanism can be verified entirely in act: the decision is made inside the workflow, in a shell script.

An Always-Running Summary Job: ci-ok

The job you pick as the required check must have two properties: it must run on every run, and it must be red only when something really broke. "Skipped" is not red:

  ci-ok:
    needs: [changes, api, web]
    if: ${{ always() }}
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Check job results
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          echo "results: $RESULTS"
          for r in $RESULTS; do
            if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
              echo "::error::a required job finished with '$r'"
              exit 1
            fi
          done
          echo "all required jobs passed or were skipped"

Without if: ${{ always() }}, this job would be skipped whenever one of the jobs it depends on was skipped, and the chain rule from the fourth post would apply here too. With the object filter syntax, needs.*.result gives the results of all dependency jobs as an array; join turns that into a space-separated string. We used always() on purpose here: we want the summary job to report a "cancelled" result and turn red on a cancelled run too. In the branch ruleset only ci-ok is selected as the required check; even if the names of api and web change later, the rule does not break.

The results: success skipped success line in the act output above is what this job saw. Had api broken, the line would contain failure, and the loop would print an ::error:: annotation and exit with 1.

Moving Between Jobs with Artifacts: From Angular's dist to wwwroot

We want to ship Bookstore as a single package: the API should serve the Angular app from its own wwwroot folder. For that we added three lines to Program.cs:

app.UseDefaultFiles();
app.UseStaticFiles();

app.MapHealthChecks("/health");
// ...
app.MapFallbackToFile("index.html");

MapFallbackToFile sends requests that match no endpoint to index.html, so that Angular routing does not return 404 on a page refresh. This is the .NET side of the SPA (Single Page Application) routing topic in the Angular series post "Angular Build and Deploy". The release workflow builds Angular in one job and uploads it as an artifact; the .NET job downloads it and puts it into wwwroot before publishing:

  web:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    defaults:
      run:
        working-directory: web
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version-file: web/package.json
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v7
        with:
          name: web-dist
          path: web/dist/bookstore-web/browser/
          retention-days: 1

  package:
    needs: web
    runs-on: ubuntu-latest
    timeout-minutes: 15
    outputs:
      version: ${{ steps.version.outputs.version }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
          cache: true
          cache-dependency-path: api/**/packages.lock.json
      - id: version
        run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
      - uses: actions/download-artifact@v8
        with:
          name: web-dist
          path: api/src/Bookstore.Api/wwwroot
      - run: dotnet restore api/src/Bookstore.Api --locked-mode
      - name: Publish API with the Angular app in wwwroot
        run: >
          dotnet publish api/src/Bookstore.Api -c Release --no-restore -o out/bookstore
          -p:Version=${{ steps.version.outputs.version }}
      - name: Zip
        run: |
          cd out
          zip -qr "bookstore-${{ steps.version.outputs.version }}.zip" bookstore
          ls -l "bookstore-${{ steps.version.outputs.version }}.zip"
          unzip -l "bookstore-${{ steps.version.outputs.version }}.zip" | grep -E 'wwwroot/(index\.html|main-)|Bookstore\.Api\.dll'
      - uses: actions/upload-artifact@v7
        with:
          name: release-package
          path: out/bookstore-${{ steps.version.outputs.version }}.zip
          retention-days: 7

When the tag is v1.4.0, ${GITHUB_REF_NAME#v} strips the leading v and leaves 1.4.0; that value goes into both the assembly version and the file name. web-dist has a retention-days of 1 because it only exists for the next job in the same run; the real product is release-package.

$ act push -W .github/workflows/release.yml -e push-tag-v140.json -j package
[release/web]   | Artifact web-dist has been successfully uploaded! Final size is 52975 bytes. Artifact ID is 1094708388
[release/web] 🏁  Job succeeded
[release/package]   ⚙  ::set-output:: version=1.4.0
[release/package] ⭐ Run Main actions/download-artifact@v4
[release/package]   | Starting download of artifact to: …/api/src/Bookstore.Api/wwwroot
[release/package]   | Artifact download completed successfully.
[release/package] ⭐ Run Main Publish API with the Angular app in wwwroot
[release/package]   |   Bookstore.Api -> …/out/bookstore/
[release/package] ⭐ Run Main Zip
[release/package]   | -rw-r--r-- 1 root root 2630285 Nov  8 04:28 bookstore-1.4.0.zip
[release/package]   |    143938  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js
[release/package]   |       161  2026-11-08 04:28   bookstore/wwwroot/index.html.br
[release/package]   |     42898  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js.br
[release/package]   |       292  2026-11-08 04:28   bookstore/wwwroot/index.html.gz
[release/package]   |       426  2026-11-08 04:28   bookstore/wwwroot/index.html
[release/package]   |     48539  2026-11-08 04:28   bookstore/wwwroot/main-6RZJKLNX.js.gz
[release/package]   |     24064  2026-11-08 04:28   bookstore/Bookstore.Api.dll
[release/package] 🏁  Job succeeded

When we passed -j package to act, the web job it depends on ran as well; thanks to refs/tags/v1.4.0 in the event payload, GITHUB_REF_NAME became v1.4.0 and the version came out as 1.4.0. The 2.6 MB zip contains the API's DLL (Dynamic Link Library) and the Angular files under wwwroot. One surprise: we did not produce the .br and .gz files; dotnet publish created Brotli and gzip precompressed copies of the static files in wwwroot on its own. We again ran the artifact steps with the @v4 versions act supports; the workflow in the repository uses upload-artifact@v7 and download-artifact@v8.

Artifact or Cache?

Both get files out of a job and they are often confused. The difference is the purpose. An artifact is the output of a run: it is passed to another job in the same run, can be downloaded from the run page, is kept for the retention period, and says "this is the product of this build". A cache is an accelerator across runs: it is saved under a key, later runs restore it with the same or a similar key, it may be deleted when space runs out or after a long time unused, and it is never guaranteed. When a cache is missing, the build gets slower but must still work correctly; when an artifact is missing, the dependent job has nothing left to do.

A practical rule: NuGet packages, the npm cache and Playwright browsers go into the cache; test reports, publish output, Angular's dist and the release package go into artifacts. If you try to carry Angular's dist through the cache, one day you will get the key wrong, produce a package with the previous commit's dist and not notice.

A GitHub Release on Tag Push

The last job adds the package to a GitHub Release as an asset. In the Git & GitHub series post "Git Tags and Releases: Marking Versions, git describe and GitHub Releases" we created the release by hand; here a tag push triggers the same work:

  github-release:
    needs: package
    runs-on: ubuntu-latest
    timeout-minutes: 5
    permissions:
      contents: write
    steps:
      - uses: actions/download-artifact@v8
        with:
          name: release-package
      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: >
          gh release create "$GITHUB_REF_NAME"
          "bookstore-${{ needs.package.outputs.version }}.zip"
          --title "Bookstore $GITHUB_REF_NAME" --generate-notes --verify-tag

At workflow level there is permissions: contents: read; only this job gets contents: write, because creating a release means writing to the repository. Instead of a ready-made release action we used the GitHub CLI (gh, Command Line Interface) that comes installed on the runners; --generate-notes produces release notes from the pull requests merged since the previous tag, and --verify-tag checks that the tag really exists on the remote. We did not run this job in act: it needs a real repository and token, and in this series we send nothing to GitHub. We validated it with actionlint; the details of GITHUB_TOKEN and permissions are the topic of the next post.

Production Notes

The real gain in a monorepo CI comes from the skipped work. For a week, count how many of Bookstore's pull requests touch only web/ and how many only api/; if the ratio is high, the few seconds the changes job costs pay for themselves many times over. If it is low, that is, nearly every change touches both sides, this complexity is not buying you anything.

Watch artifact sizes too. Our api-publish artifact is 2.5 MB, Angular's dist a few hundred kB. But accidentally putting node_modules or the bin/obj folders into an artifact means hundreds of MB: upload and download time, storage quota, and repeated on every run. Write path as narrowly as possible and use if-no-files-found: error to stop a wrong path from silently producing an empty artifact.

Common Pitfalls

Making a path-filtered workflow a required check. The locked merge button from the guess section. Use a summary job or a single workflow.

Forgetting fetch-depth. With the default shallow checkout, git diff "$BASE" fails with "bad object" or gives a wrong result.

Forgetting always() on the summary job. When a dependency job is skipped, the summary job is skipped too and the required check stays pending again.

Needlessly growing artifacts between jobs. Turning the whole workspace into an artifact and unpacking it in the next job is often slower than checking out and rebuilding in that job.

Skipping tests in the release workflow. A tag push may have been put on an untested commit. Either set up a process that puts tags only on commits with green CI, or run the tests again in the release workflow.

A Small Experiment

★☆☆ Add a docs output to the changes job (for a docs/ folder) and define a markdown lint job that runs only on documentation changes.

★★☆ Break the api job on purpose with act (by breaking a test) and see what ci-ok reports. Then change always() in ci-ok to !cancelled() and think about when the difference shows up.

★★★ Add a SHA256SUMS file to the release package (with sha256sum) and upload both files in the github-release job. Write a shell command into the release notes that downloads and verifies the package.

In this post we used GITHUB_TOKEN with write access in one job, but we did not talk about what it is, how long it lives, and why it should be narrowed with permissions:. The next post covers secrets, variables and GITHUB_TOKEN permissions: at which level they are defined, how they are masked in the log, and where masking falls short.