GitHub Actions CI/CD Architecture
1. Overview
This guide documents the CI/CD pipeline architecture used across Event and Membership Management projects. The system uses a combination of reusable workflows (defined centrally) and project-specific workflows that consume them.
CI/CD is one element of the broader EMS service baseline — the parent context (Maven module shape, Helm chart, ArgoCD manifests, OTel, profiles) lives in Microservice Pattern. Read that page first when bootstrapping a new service; this page focuses on the GitHub Actions layer specifically.
1.1. Key Principles
-
DRY (Don’t Repeat Yourself) - Common build logic is centralized in reusable workflows
-
Consistency - All projects follow the same patterns and conventions
-
Conditional Execution - Only run tests for changed components (frontend/backend)
-
GitOps - Deployments are managed through ArgoCD and Git-based configuration
2. Workflow Architecture
The CI/CD system consists of two layers:
- Reusable Workflows (in
christhonie/event/.github/workflows/) -
Centrally managed, versioned workflow definitions that encapsulate common operations like testing, building, and deploying.
- Project Workflows (in each repository’s
.github/workflows/) -
Project-specific workflows that orchestrate the reusable workflows and define triggers.
The five wrapper workflows above are the canonical inventory for a backend or MCP-adapter service. Portal services (those serving an Angular SPA) add a frontend filter to pr-non-main.yml and pass require-npm: true to maven-package.yml.
3. Path Filtering Strategy
3.1. Excluding Non-Build Directories
Use paths-ignore at the workflow trigger level to prevent unnecessary builds when only documentation or configuration files change:
on:
push:
branches:
- develop
# NOTE: Keep paths-ignore in sync with push-main.yml
paths-ignore:
- '*.md'
- '*.adoc'
- '.devcontainer/**'
- '.husky/**'
- '.jhipster/**'
- 'docs/**'
When modifying the paths-ignore list, update it in all workflow files that share this pattern. The sync note comment serves as a reminder.
|
The two push workflows must not ignore src/main/helm/** — they own the ArgoCD bump for their environment and would otherwise leave the manifest stale. See Helm changes (no separate pipeline).
|
Do not use trigger-level Filtering still happens for PRs — one level down, in the This applies only to workflows whose checks are required. |
|
|
3.2. Conditional Job Execution
Use the dorny/paths-filter action to conditionally run frontend or backend tests based on what files changed:
jobs:
paths-filter:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
frontend:
- '.github/workflows/**'
- 'package*.json'
- 'angular.json'
- 'tsconfig*.json'
- 'webpack/**'
- 'src/main/webapp/**'
backend:
- '.github/workflows/**'
- 'pom.xml'
- 'src/main/java/**'
- 'src/main/resources/**'
- 'src/test/java/**'
- 'src/test/resources/**'
test-fe:
needs: paths-filter
if: needs.paths-filter.outputs.frontend == 'true'
uses: christhonie/event/.github/workflows/test-fe.yml@main
# ...
test-be:
needs: paths-filter
if: needs.paths-filter.outputs.backend == 'true'
uses: christhonie/event/.github/workflows/test-be.yml@main
# ...
3.3. The pr-checks aggregator
Conditional job execution creates a problem for branch protection: required status checks are matched by name, and Back-end / ✅ Test is legitimately skipped on a front-end-only PR. Requiring it directly would block those PRs.
The fix is one job that always runs, inspects its dependencies itself, and reports a single verdict. Require only that job.
pr-checks:
name: 'PR checks'
needs: [paths-filter, test-fe, test-be]
if: always() (1)
runs-on: ubuntu-latest
steps:
- name: Verify every gate passed or was legitimately skipped
run: |
set -euo pipefail
failed=0
for entry in "paths-filter=${{ needs.paths-filter.result }}" \
"test-fe=${{ needs.test-fe.result }}" \
"test-be=${{ needs.test-be.result }}"; do
job="${entry%%=*}"; result="${entry#*=}"
case "$result" in
success|skipped) ;; (2)
*) echo "::error::$job reported '$result'"; failed=1 ;;
esac
done
[ "$failed" -eq 0 ] || { echo "::error::This PR must not be merged."; exit 1; }
| 1 | Load-bearing. Without always(), a failed dependency skips this job too, the required check reports skipped, and branch protection can treat that as satisfied — a gate that opens precisely when a job fails, which is worse than no gate because it looks like protection. |
| 2 | skipped is paths-filter working as designed. failure and cancelled are fatal. |
In the Angular repositories needs also lists check-format, which has no if: and so always reports. See Formatting gate.
Behaviour:
| Scenario | test-fe |
test-be |
PR checks |
|---|---|---|---|
Back-end PR, a gate fails |
skipped |
failure |
failure |
Back-end PR, all good |
skipped |
success |
success |
Front-end-only PR |
success |
skipped |
success |
Run cancelled |
– |
cancelled |
failure |
|
This job reports; it does not block. Only branch protection blocks — see Branch protection: require the PR checks context. A PR with a red The pattern exists because admin-portal PR #85 merged with a red |
3.4. Formatting gate
The Angular repositories' pre-commit hook formats staged files but deliberately skips wherever it cannot run — see Formatting and the Pre-commit Hook. check-format.yml is what enforces formatting:
check-format:
name: 'Format'
uses: christhonie/event/.github/workflows/check-format.yml@main (1)
secrets:
NPM_PASSWORD: ${{ secrets.EVENT_PACKAGE_REPO_TOKEN }}
| 1 | No needs: paths-filter and no if:. The hook formats Java and Markdown too, so the check must run on every pull request. |
It checks out the pull request’s merge commit, soft-resets it to the merge base so every change is staged as one commit, and runs npx lint-staged --no-stash. The job fails if that changes the index or working tree, or if lint-staged itself fails.
Running lint-staged, rather than Prettier directly, is the point: the check applies the repository’s own glob, .prettierignore and commands, whatever lint-staged and Prettier versions the repository pins. Only changed files are checked, so a repository with existing drift adopts it without a mass reformat — at the cost that touching a drifted file requires formatting all of it.
pull_request events only. event-admin-ui references it at @main while its other jobs are pinned to a tag that predates it.
4. Reusable Workflows Reference
The following reusable workflows are available in christhonie/event/.github/workflows/:
| Workflow | Purpose | Inputs | Secrets | Outputs |
|---|---|---|---|---|
|
Run backend tests using Maven |
|
|
- |
|
Run frontend tests using npm |
|
|
- |
|
Fail a pull request whose changes the pre-commit formatting would rewrite — see Formatting gate |
|
|
- |
|
Build and cache Maven artifacts |
|
|
cached target |
|
Deploy JAR to GitHub Packages |
|
|
- |
|
Build and push Docker image via JIB |
|
|
|
|
Package and push Helm charts |
|
|
- |
|
Update ArgoCD deployment manifests in |
|
|
- |
|
Submit dependency graph to GitHub |
|
- |
- |
|
Upload build artifacts |
|
- |
- |
|
Complete GitFlow release process |
|
- |
- |
|
Request a Copilot code review on a new pull request |
- |
|
- |
|
Pass
All wrapper workflows across |
4.1. Workflow Naming Convention
Workflow names use emoji prefixes for quick visual identification:
| 🚀 |
Deployment and push workflows |
| ✅ |
Testing workflows |
| 📦 |
Packaging workflows |
| 🐋 |
Docker workflows |
| ⎈ |
Helm/Kubernetes workflows |
| ⏩ |
GitFlow release workflows |
| 📰 |
Publishing workflows |
| 🔄 |
Synchronization workflows |
| 🤖 |
Code review automation |
4.2. Post-deploy SPA cache verification
argocd-update.yml takes an optional spa-url. When set, the deploy is followed by a verify-spa-cache job that waits for the new build.version to appear at that URL, then asserts index.html does not revalidate to a stale copy:
update-argocd-stage:
uses: christhonie/event/.github/workflows/argocd-update.yml@main
with:
environment: stage
service-name: admin-portal
chart-version: ${{ needs.helm-build.outputs.chart-version }}
spa-url: https://admin-portal-stage.idealogic.co.za
The check is one conditional request:
curl -s -o /dev/null -w '%{http_code}' \
-H 'If-Modified-Since: Thu, 01 Jan 1970 00:00:01 GMT' https://HOST/index.html
# 200 = correct
# 304 = the deployed image serves stale bundles to every returning browser
That timestamp is Jib’s fixed reproducible-build time, so it is exactly the validator a browser cached from a broken build holds. A 304 means returning users keep running the previous release until they hard-reload.
Why this is a deployment check and not a unit test. Bugs #975 and #976 were omissions, not regressions — two services never received the index.html validator fix that a third had, and one carried the defect for about five months. A repo-local test can only protect code that already exists in that repo, so no test anywhere would have flagged either. This tests the deployed artefact, so it also covers chart edits, ingress rewrites and a CDN appearing in front of a host.
Requires the service to expose build.version, i.e. the build-info goal on spring-boot-maven-plugin. The job says so when the wait times out.
|
Two limits worth knowing. On dev the version is a |
4.3. Copilot code review requests
copilot-review.yml requests a Copilot code review on every new pull request. Each repository calls it from a thin wrapper at .github/workflows/copilot-review.yml:
on:
pull_request:
types:
- opened
- ready_for_review
jobs:
copilot-review:
name: Request Copilot Review
uses: christhonie/event/.github/workflows/copilot-review.yml@main
secrets:
RELEASE_PAT: ${{ secrets.RELEASE_PAT }}
4.3.1. Why the request is made under a PAT
A Copilot review is billed to the entitlement of whoever requests it, never the author’s. A contributor who holds no Copilot subscription cannot obtain a review by asking for one, and no review appears on their pull requests at all.
Requesting under RELEASE_PAT — a token belonging to an account that does hold Copilot — is what closes that gap. The token owner’s entitlement is spent and the review lands on the contributor’s pull request as normal.
The GITHUB_TOKEN fallback exists only so that a repository without the secret still parses. It runs as github-actions[bot], which holds no Copilot entitlement: the request is accepted and no review is ever produced. It is a diagnostic path, not a working one.
Requesting the review needs Pull requests: write on the token, which is a different permission from the Contents: read+write the release back-merge needs. A classic PAT carrying the repo scope already covers both.
4.3.2. Why not the GitHub ruleset
GitHub offers a branch ruleset rule, Automatically request Copilot code review, which appears to do the same job in one setting with no YAML at all. It does not solve this problem, and it fails silently.
The rule requests the review on the author’s behalf. The GitHub UI states that it applies "if the author has access to Copilot code review and their premium requests quota has not reached the limit". Where the author holds no subscription that condition is never met, so nothing happens: no review, no error, no failed check, and no entry in the pull request timeline.
The ruleset is therefore useful only where every contributor already holds a Copilot seat — which is precisely the case in which the problem does not arise.
4.3.3. Pull requests that are skipped
The shared workflow declines to spend a premium request on three classes of pull request:
dependabot[bot]
|
Version bumps carry no diff that a review can improve. |
github-actions[bot]
|
The GitFlow release and back-merge pull requests, whose content was built, tested and merged before they existed. |
| Drafts |
Collected later by the |
A ruleset cannot express the first two. It has no author condition, and its bypass_actors escape hatch accepts an app only on a repository where that app is installed — adding Dependabot as a bypass actor fails with 422 Validation Failed everywhere Dependabot is not configured.
5. Pipeline Flows
5.1. Pull Request Pipeline
When a PR is created or updated against non-main branches:
PR created/updated
│
├──► paths-filter
│ │
│ ├──► test-fe (if frontend changed)
│ │
│ └──► test-be (if backend changed)
│
└──► check-format (Angular repositories; always)
│
▼
pr-checks — the single required status check
5.2. Develop Branch Pipeline
When code is pushed to the develop branch:
Push to develop
│
▼
paths-filter
│
├──► test-fe ──┐
│ │
└──► test-be ──┤
│
▼
package
│
┌──────────────┼──────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
publish dependencygraph docker-build helm-build
│
▼
argocd-update
(env=dev,
chart-version + build-sha)
build-sha is passed only on develop. It writes podAnnotations."app.kubernetes.io/build-sha", forcing a pod rollover when the SNAPSHOT chart version has not changed but the image behind it has.
5.3. Main Branch Pipeline (Release)
When code is pushed to the main branch (typically after a release merge):
Push to main
│
▼
package (prod profile)
│
├──────────────┬──────────────┐
│ │ │
▼ ▼ ▼
publish docker-build helm-build
│
┌──────────┼──────────────┐
▼ ▼
argocd-update gitflow-release-finish
(env=stage) (merges main → develop;
bumps SNAPSHOT version)
5.4. Release Branch Pipeline
When code is pushed to a release/** branch (created by manual-release-start.yml):
Push to release/**
│
▼
test-be
(verifies the RC before main is updated)
This is intentionally minimal — packaging, image build and chart push are deferred until the release branch is merged into main. The release branch is a candidate, not a deliverable.
Often nothing runs at all, and a release pull request reporting no checks is normal:
-
The push that creates the branch is made by
manual-release-start.ymlwithGITHUB_TOKEN, and events fromGITHUB_TOKENtrigger no workflows. -
push-release.ymlignores*.md, so a release-notes commit runs nothing. See CI and Release Pipeline Traps. -
The SPA and plugin repositories have no
push-release.yml. -
The release pull request targets
main, whichpr-non-main.ymlignores.
5.5. Manual Release Start
A workflow_dispatch trigger on manual-release-start.yml lets a human cut a release branch:
workflow_dispatch
inputs: { version: "X.Y.Z" }
│
▼
mvn gitflow:release-start
(creates release/X.Y.Z branch off develop,
rewrites <revision> from X.Y.Z-SNAPSHOT to X.Y.Z)
│
▼
gh pr create -B main -H release/X.Y.Z
(opens a PR back to main for review;
merging the PR triggers push-main.yml)
This is the only way new versions enter main. There is no direct push from develop to main.
5.6. Helm changes (no separate pipeline)
Chart changes under src/main/helm/** travel with the branch’s normal pipeline: push-dev.yml on develop, push-main.yml on main. Both already call helm-build.yml and then argocd-update.yml, so the chart is packaged, pushed and deployed by the workflow that owns that environment.
|
Do not add
It was also the sole source of two defects: the Retired in The legacy |
pr-non-main.yml is the one place src/main/helm/* *should stay in paths-ignore — a chart edit needs no Java or Angular tests, and that workflow performs no deployment.
6. Consuming Workflows
6.1. Referencing Reusable Workflows
Project workflows reference reusable workflows using the uses keyword:
jobs:
test-be:
uses: christhonie/event/.github/workflows/test-be.yml@main
with:
maven-profiles: dev
jdk-version: '17'
secrets:
MAVEN_PASSWORD: ${{ secrets.EVENT_PACKAGE_REPO_TOKEN }}
6.2. Versioning Strategy
Recommended: Use @main to stay current with the latest reusable workflow improvements.
uses: christhonie/event/.github/workflows/test-be.yml@main
Alternative: Pin to a specific tag for stability when needed:
uses: christhonie/event/.github/workflows/[email protected]
7. Job Dependencies and Caching
7.1. Sequential Execution with needs
Use needs to define job dependencies:
jobs:
package:
uses: christhonie/event/.github/workflows/maven-package.yml@main
# ...
docker-build:
needs: [package] # Wait for package to complete
uses: christhonie/event/.github/workflows/docker-build.yml@main
# ...
7.2. Caching Strategy
The maven-package workflow caches the target/ directory, which is then reused by:
-
docker-build- For building Docker images -
helm-build- For packaging Helm charts -
publish- For uploading artifacts -
dependencygraph- For dependency analysis
7.3. Running Jobs After Skipped Dependencies
Use if: always() to run jobs even when dependencies were skipped:
docker-build:
needs: [package]
if: always() && needs.package.result == 'success' (1)
uses: christhonie/event/.github/workflows/docker-build.yml@main
| 1 | always() alone is rarely what you want. It runs the job when a dependency failed as well as when it skipped, so pair it with an explicit result check unless you intend to proceed after a failure. |
|
For a build job, it lets the pipeline continue past a skipped test — pair it with a For an aggregator whose check is required (see The pr-checks aggregator), |
8. Environment Variables Pattern
Centralize fallback logic at the job level to avoid repetition:
jobs:
helm-build:
runs-on: ubuntu-latest
env:
JDK_VERSION: ${{ inputs.jdk-version || '17' }}
MAVEN_PROFILES: ${{ inputs.maven-profiles || 'dev' }}
steps:
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: ${{ env.JDK_VERSION }}
# ...
- name: Package Helm
run: mvn helm:package -P${{ env.MAVEN_PROFILES }}
9. Required Repository Settings
When bootstrapping a new service repo, two GitHub settings must be configured before the first push to develop, and a third once the repo has its first PR workflow:
9.1. Default workflow permissions: write
Settings → Actions → General → Workflow permissions → "Read and write permissions".
The reusable workflows declare permissions: contents: write (e.g. dependencygraph.yml, gitflow-release-finish.yml). GitHub blocks reusable workflows from elevating beyond the calling workflow’s default GITHUB_TOKEN permissions. New repos default to read, which causes the calling workflow to fail with startup_failure and no jobs created — an opaque error with no log output.
This is the single most common bring-up mistake. Mirror what event-admin-service already has set.
CLI shortcut to flip it:
gh api -X PUT repos/<owner>/<repo>/actions/permissions/workflow \
-f default_workflow_permissions=write \
-F can_approve_pull_request_reviews=true
9.2. Reusable workflow access on christhonie/event
On the christhonie/event repo: Settings → Actions → General → Access → "Accessible from repositories owned by the user 'christhonie'".
Without this, calls to christhonie/event/.github/workflows/@main from a service repo fail with "workflow not found" or a permissions error. The default for new repos is "Not accessible". Once set to "user-owned", every existing and future repo under christhonie/ can consume the reusable workflows.
This setting is configured once on christhonie/event, not per service repo.
9.3. Branch protection: require the PR checks context
Settings → Branches → Add rule → develop → "Require status checks to pass before merging" → select PR checks.
Without this, the pipeline is advisory. admin-portal PR #85 merged with a red Back-end / ✅ Test, broke develop, and stayed broken through the next merge — the check was correct, nothing enforced it.
Require exactly one context: PR checks. Never require Back-end / ✅ Test or Front-end / ✅ Test directly; paths-filter skips them by design and a skipped required check is not a pass. See The pr-checks aggregator.
CLI equivalent:
gh api -X PUT repos/<owner>/<repo>/branches/develop/protection \
--input - <<'JSON'
{
"required_status_checks": { "strict": false, "contexts": ["PR checks"] },
"enforce_admins": false,
"required_pull_request_reviews": null,
"restrictions": null
}
JSON
|
Enabling this requires that |
strict: false is deliberate — strict: true forces every branch to be up to date with the base before merging, which serialises merges and adds little on a small team.
|
Do not apply this rule to |
9.3.1. Sweep open PRs before you enable it
For pull_request events GitHub runs the workflow definition from the head branch, not from the base. Every branch created before the aggregator merged therefore produces no PR checks context, and its PR sits at mergeStateStatus=BLOCKED indefinitely — note that mergeable still reads MERGEABLE, so mergeable alone will not reveal this.
Before enabling protection, list what is open and account for it:
gh pr list --repo <owner>/<repo> --base develop --state open \
--json number,headRefName -q '.[] | "#\(.number) \(.headRefName)"'
Each affected PR is unblocked by merging (or rebasing onto) the base branch so the head picks up the workflow. For Dependabot PRs, comment @dependabot rebase on each.
This is a real adoption cost, not a formality: when the aggregator landed in membership-ui there were 29 open Dependabot PRs, every one of which would have been blocked. Protection was deliberately deferred there for that reason, while registration-portal — with no open PRs — was enabled immediately.
9.3.2. Auditing existing protection
gh api repos/<owner>/<repo>/branches/<branch>/protection returns HTTP 404 with a JSON error body on an unprotected branch, and gh writes that body to stdout. A script that only tests whether output is non-empty will therefore report an unprotected branch as "protected with no required contexts" — the opposite of the truth. Test the status code:
code=$(gh api "repos/$repo/branches/$br/protection" --silent -i 2>/dev/null | head -1 | awk '{print $2}')
[ "$code" = "200" ] || echo "NOT PROTECTED"
9.4. service-name ↔ chart name ↔ ArgoCD manifest filename
The service-name input passed to argocd-update.yml must match three things:
-
The Helm chart’s
name:inChart.yaml -
The Docker image name on Docker Hub (
christhonie/<service-name>) -
The ArgoCD manifest filename in
idl-xnl-jhb-rc01:argocd/<service-name>-<env>.yml
It does not need to match the GitHub repo name — for example, christhonie/ems-mcp-server ships chart name ems-mcp-server, image christhonie/ems-mcp-server, and manifest ems-mcp-server-prod.yml. The repo name happens to match in this case, but the convention links chart/image/manifest, not repo.
10. Secrets Configuration
10.1. Required Secrets
| Secret | Purpose | Used By |
|---|---|---|
|
Built-in token for repository operations |
Git operations, GitHub API |
|
Standard secret name for GitHub Packages access (Maven/NPM) |
|
|
Docker Hub Personal Access Token |
|
|
Access to ArgoCD configuration repository |
|
|
Administrator PAT used for the release back-merge push and for Copilot review requests — see Repository Rules |
|
10.2. Secret Variable Mapping
The EVENT_PACKAGE_REPO_TOKEN secret is internally mapped to workflow-specific variables:
# In workflow steps
secrets:
MAVEN_PASSWORD: ${{ secrets.EVENT_PACKAGE_REPO_TOKEN }}
NPM_PASSWORD: ${{ secrets.EVENT_PACKAGE_REPO_TOKEN }}
10.3. Maven Repository Configuration
The setup-java action creates a settings.xml file with credentials:
- name: Set up JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: maven
server-id: github-christhonie
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Build with Maven
run: mvn --batch-mode package
env:
MAVEN_USERNAME: christhonie
MAVEN_PASSWORD: ${{ secrets.EVENT_PACKAGE_REPO_TOKEN }}
The generated settings.xml will contain:
<servers>
<server>
<id>github-christhonie</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
</servers>
11. Artifact Management
12. Docker Compose Version Synchronization
|
Retired. The It was removed because it broke releases in two distinct ways — a Removed in |
13. Version Strategy for Docker/Helm Artifacts
|
TODO: Implement build number conversion for SNAPSHOT versions:
|
14. Related Documentation
-
Development Workflow - GitFlow branching and release process
-
Helm Chart Patterns - Helm chart structure and configuration
-
ArgoCD Deployment - GitOps deployment patterns
-
Manual Helm Chart Release - Manual Helm release workflow