CI and Release Pipeline Traps

1. Overview

The pipeline architecture is documented in GitHub Actions CI/CD. This page covers the behaviours that surprise people while operating it: things that look like a code problem and are not, tools that fail while reporting success, and the ordering rules that keep develop compiling.

For the question of whether a given repository’s tests ran at all, see Which Test Suites Actually Run.

2. Re-running a job cannot pick up a shared-workflow fix

EMS repositories call the shared workflows as christhonie/event/.github/workflows/<name>.yml@main. A re-run resolves those references to the same commit the original run used, not to the current tip of main.

So the intuitive repair loop — fix the shared workflow, then press "re-run failed jobs" — repeats the identical broken build, and the fix appears not to work.

A fresh run is the only way to pick up a shared-workflow fix. For a push:-only workflow that means a new commit, which brings two follow-on constraints:

  • push-main.yml and push-dev.yml carry paths-ignore for .md and .adoc, so a release-note commit will not fire them. Reaching for a docs commit as the trigger fails silently.

  • .github/* is not in that ignore list, so a workflow-file change *does fire them.

Where a manual re-trigger is wanted, the calling workflow needs workflow_dispatch:. registration-portal’s push-main.yml carries it for exactly this reason; the other services do not. Add it before relying on it.

2.1. The standing rule that came out of this

Never put package in front of jib:build in docker-build.yml.

That job exists to containerise what the package job already built and restored from the target cache. Running the lifecycle again rebuilds everything a second time — measured at roughly twenty seconds for jib:build alone against about four minutes with package in front of it.

On front-end services it does not merely waste time, it fails: the docker job sets no NODE_AUTH_TOKEN, so npm install cannot resolve the generated API client from GitHub Packages and the build returns 401; another service failed the webapp build outright with exit 127.

The problem package was reached for — a missing javaagent on a cache miss — is handled correctly by fail-on-cache-miss: true on the cache restore step instead.

3. Private-repo runner starvation

3.1. Symptom

Jobs sit queued with no runner assigned and are cancelled at exactly the fifteen-minute mark, with empty step lists and no logs. Frequently one early job (for example Package) runs to completion and then every job after it starves — because that first job spent the last of the account’s Actions budget.

3.2. Cause

The EMS service repositories are private on a personal GitHub account. Unlimited free Actions minutes apply to public repositories only; private repositories draw on a metered monthly budget and require a spending limit above zero with a valid payment method. Once the budget or free-usage cap is reached, private-repo Actions stop and queued jobs cancel.

This is not a code problem and not a workflow problem. No amount of re-running fixes it.

3.3. Resolution

Raise the Actions spending limit in GitHub billing settings and confirm the payment method, then re-run:

gh run rerun <run-id> --failed

The billing endpoint that would report the remaining balance needs the user OAuth scope, which the gh token does not carry by default, so the exact remaining budget usually cannot be read from the CLI. Diagnose from the symptom instead.

3.4. Building and pushing a release locally

When CI cannot run and a release must ship, the push-to-main pipeline can be reproduced by hand. Registry and package credentials live as server entries in ~/.m2/settings.xml; never inline a credential into a command or a document.

Build from the release or hotfix commit — after the release merge, main holds the release version:

mvn clean package -U -B -DskipTests -Pprod

The prod profile performs a self-contained front-end build through frontend-maven-plugin, using its own node and npm, so no nvm setup is required.

Build and push the image. Jib’s authentication must be passed explicitly because the settings server id does not match the registry host, so Jib will not match it automatically:

mvn jib:build -Pprod \
  -Djib.to.auth.username=<registry-user> \
  -Djib.to.auth.password="$REGISTRY_TOKEN"

Package and push the chart. The chart version is the project version suffixed -RELEASE, with appVersion set to the project version:

mvn helm:init helm:registry-login helm:package helm:push -Pprod

If the gitflow finish step also failed to run, complete the branch by hand — see Development Workflow.

Finally deploy through GitOps by editing the ArgoCD manifest’s targetRevision to the new <version>-RELEASE and pushing. Stage updates automatically from CI’s deploy step; production is always a manual bump.

4. Docs-only commits on release branches

Every release carries a release-notes commit that changes nothing buildable. Without a filter, that commit triggers the full backend suite on the release branch — on admin-service, eleven to fourteen minutes of runner time, plus queue time, for a Markdown edit.

The fix is to copy push-dev.yml’s `paths-ignore list onto the release-line workflows (push-release.yml and, where present, push-hotfix.yml), together with a per-branch concurrency group:

on:
  push:
    branches:
      - 'release/**'
    # NOTE: keep paths-ignore in sync with push-dev.yml
    paths-ignore:
      - '*.md'
      - '*.adoc'
      - '.devcontainer/**'
      - '.husky/**'
      - '.jhipster/**'
      - 'docs/**'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Three qualifications:

  • Concurrency cancellation is safe here only because these release-line push workflows are test-only — a single test job that publishes and deploys nothing. Confirm that before adding cancel-in-progress to any other workflow.

  • Skipping a push workflow outright is safe only because no push workflow feeds a required status check. The required PR checks context comes from pr-non-main.yml, a pull_request workflow that deliberately carries no trigger-level paths-ignore — a required check that is never created leaves a pull request pending forever. Never add one there. See GitHub Actions CI/CD.

  • .github/workflows/* is *not in the ignore list, so pushing one of these CI edits to develop still costs a full pipeline run and a dev deploy.

Repositories without a push-release.yml — the SPA repositories — trigger nothing on a release-branch push, so there is nothing to add there.

4.1. Why the filter cannot live in parent-pom

Path filters and concurrency live under on:, which belongs to the calling workflow. A workflow_call reusable workflow cannot express either.

This is therefore per-repository by construction. There is nothing to centralise in parent-pom, and attempting to do so produces a filter that is silently inert.

5. Never call a pull request green from a local run

Before reporting a pull request as passing, read what the pipeline says:

gh pr view <n> --json statusCheckRollup \
  -q '.statusCheckRollup[]? | "\(.conclusion // .state)\t\(.name // .context)"'

A local run proves your gates passed, not the pipeline’s. The pipeline runs steps your mvn test never invokes, and it resolves dependencies differently:

  • The shared back-end workflow runs mvn javadoc:javadoc as its own step. It cannot fail the build — see Doclint findings are advisory and cannot fail the build before concluding it did.

  • Verify the application runs mvn verify -U, which re-resolves every SNAPSHOT from GitHub Packages. Your local build reuses whatever already sits in ~/.m2. A SNAPSHOT published by another repository since your last build therefore reaches CI and not you, so develop can go red with nothing committed to it.

That second point is not hypothetical. On 2026-09-17 a malformed Liquibase changelog merged to event-database and republished 2.4.19-SNAPSHOT; admin-service pins that SNAPSHOT, so every admin-service build broke with no admin-service commit, and every integration test failed on ApplicationContext load. When a build breaks and nothing in the repository explains it, check what its SNAPSHOT dependencies published.

Three reading rules:

  • A SKIPPED check is neither a pass nor a failure. dorny/paths-filter skips the back-end job on front-end-only changes and vice versa. Say which checks actually ran rather than summarising the set as green.

  • The aggregator context must never be required on main. pr-non-main.yml triggers with branches-ignore: [main], so a pull request into main emits no such context at all and every release pull request would pend forever. Details in GitHub Actions CI/CD.

  • pull_request runs the workflow definition from the head ref. A branch cut before the aggregator existed never emits the check and sits at mergeStateStatus=BLOCKED while mergeable still reads MERGEABLE. Check mergeStateStatus, not mergeable.

When auditing protection, test the HTTP status rather than the output. gh api repos/<owner>/<repo>/branches/<branch>/protection returns 404 with a JSON error body on an unprotected branch, and gh writes that body to stdout — so an emptiness test reports an unprotected branch as "protected with no required contexts", the opposite of the truth.

5.1. Doclint findings are advisory and cannot fail the build

Every EMS service sets failOnError=false on maven-javadoc-plugin, so the Run JavaDoc tests step reports BUILD SUCCESS however many doclint errors it emits. admin-service currently emits 72, across 49 files, on every single run.

Until 2026-09-17 those lines were rendered as failure-level GitHub annotations by the problem matcher actions/setup-java installs, so a passing step — and a green run — displayed what looked like build failures. That misreading cost a full misdiagnosis of the outage described above: javadoc was blamed, a toolchain pin and a thirty-file rewrite were proposed, and the real cause was the changelog. The step now relabels those lines so the matcher does not claim them.

Two rules follow:

  • The failing step is the one the job reports as failed. Read gh api repos/<owner>/<repo>/actions/runs/<id>/jobs and look at which step has "conclusion": "failure" rather than inferring it from annotations or from the first alarming text in the log.

  • A javadoc error is never why a build failed. If the javadoc step is implicated, check it — it will say BUILD SUCCESS.

The underlying contradiction is real and tracked separately: parent-pom deliberately sets doclint=none with failOnError=true, and the services override that whole block with doclint=all,-missing and failOnError=false — running the checks while discarding their verdict.

6. gh pr edit fails silently

On the EMS repositories, gh pr edit <n> --body-file <f> fails with a Projects (classic) GraphQL deprecation error:

GraphQL: Projects (classic) is being deprecated ... (repository.pullRequest.projectCards)

It exits 1 and changes nothing, but the message reads like a deprecation warning, so it is easy to take for success and move on with an unchanged body.

Use the REST API instead:

gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -F body=@/path/to/body.md
gh api -X PATCH repos/<owner>/<repo>/pulls/<n> -f title='New title'

Always read the field back after editing:

gh pr view <n> --json body

This affects title and body edits only. gh pr create, gh pr merge, gh pr view and the comment and reply calls are unaffected.

7. Requesting a CoPilot review

The REST route silently does nothing on some repositories:

gh api -X POST repos/<owner>/<repo>/pulls/<n>/requested_reviewers \
  -f "reviewers[]=copilot-pull-request-reviewer[bot]"

It returns HTTP 200 with the full pull-request object and adds nobodyrequested_reviewers stays empty, including via the JSON input form. It works on some repositories and not others, with no error to catch, so it cannot be trusted without verification.

The reliable route is the GraphQL mutation with the bot’s node id:

BOT_ID=$(gh api repos/<owner>/<repo>/pulls/<n>/reviews \
  --jq '.[] | select(.user.login=="copilot-pull-request-reviewer[bot]") | .user.node_id' | head -1)
PR_ID=$(gh api repos/<owner>/<repo>/pulls/<n> --jq '.node_id')

gh api graphql -f query="mutation { requestReviews(input:{pullRequestId:\"$PR_ID\",
  botIds:[\"$BOT_ID\"], union:true}) { pullRequest { reviewRequests(first:10) {
  nodes { requestedReviewer { __typename ... on Bot { login } } } } } } }"

union:true adds to the existing reviewer set rather than replacing it, and the mutation echoes the resulting reviewer list, so its own output is the verification. Recover the bot node id, as above, from any repository where CoPilot has already left a review.

Do not use suggestedActors to diagnose this: it returns copilot-swe-agent, which is a different thing, and it looks identical on repositories where the REST call works and where it does not.

7.1. A Snyk error is not automatically the free-tier cap

A failing Snyk check on a private repository is sometimes the free-tier private-test limit and sometimes a real finding. Generalising the first case is a trap — a Critical transitive CVE has been dismissed this way.

The tell is the neighbouring runs. If recent releases on the same repository report "security tests have passed", the tier clearly permits the scan, and a failure means something was found. Open the report before dismissing it.

Two things in such a report look alarming and are not:

  • A version listed against a dependency is the floor declared by a transitive POM, not necessarily what resolves. Reconcile against mvn dependency:tree before acting.

  • One root cause is listed once per dependency path, so a long list of findings is frequently a single version knob.

Reconcile first, then raise one ticket for the root cause rather than one per reported line.

8. parent-pom workflow files go straight to main

In the parent-pom repository (christhonie/event), GitHub Actions workflow files under .github/workflows/ are pushed directly to main, bypassing GitFlow.

Why: every other repository consumes those workflows via @main, so a fix must be visible there immediately. Routing workflow changes through develop, release-start and release-finish would force a parent-pom version bump and a release tag for every CI fix. Workflow files and the parent POM happen to share a repository; they do not share a release cadence.

For pom.xml or any non-workflow change in that repository, standard GitFlow still applies.

8.1. The cost: develop drifts silently

Nothing back-merges those commits. gitflow:release-finish is the only thing that carries main into develop, and it runs only at a release. develop therefore accumulates a growing deficit of workflow fixes and fails its own builds on bugs that were fixed on main months earlier. It self-heals at the next release — which is the worst possible moment to discover it.

Remedy: back-merge after every workflow push. Use a real merge, never a cherry-pick or a squash — ancestry is what stops the next back-merge from conflicting.

base=$(git merge-base origin/main origin/develop)
git log "$base"..origin/develop -- .github/     # competing edits on develop?

If that is empty, the merge is a fast-forward of those files and develop keeps its own <revision> SNAPSHOT. Direct pushes to develop may be blocked, in which case open a pull request and merge it with "Create a merge commit".

8.2. The token-scope trap on workflow files

gh pr merge fails on any pull request that touches .github/workflows/:

refusing to allow an OAuth App to create or update workflow <file> without `workflow` scope

The gh token carries repo but not workflow. The behaviour is inconsistent — a single-file pull request has merged while a two-file one did not — so do not rely on it succeeding. Since the remote is SSH, merge locally and push:

git checkout main && git pull --ff-only
git merge --no-ff origin/<branch> -m "Merge <branch>"
git push origin main
git push origin --delete <branch>

GitHub marks the pull request merged automatically once the commits are reachable from main.

8.3. Verify a shared-workflow change on a real consumer

These changes go live for every repository the moment they land. Re-running a recent consumer build re-resolves @main fresh, which makes it a cheap end-to-end check. Confirm it actually picked up the change by grepping the job log for the Download action repository line, rather than trusting the green tick.

9. The release-finish back-merge into develop

The last step of a release merges the release tag into develop. Two independent things break it, and both leave identical wreckage: the artefact has already published from the jobs running ahead, but main is untagged, develop is neither back-merged nor bumped, and the release branch survives. The release looks finished and is not, and the state has to be repaired by hand.

Re-running does not help, for the reason in Re-running a job cannot pick up a shared-workflow fix and because the failure is deterministic rather than flaky. Worse, once the manual repair is done a re-run fails at a different step against a release that is now complete.

9.1. The version conflict is structural, not incidental

The back-merge is git merge --no-ff <tag> into develop. The tag’s pom.xml carries the release version; develop’s carries a SNAPSHOT. Both sides changed the same `<revision> line since the merge base, so git reports a content conflict.

develop moves because reserving the next SNAPSHOT on a branch is the convention here. Any branch merging to develop while a release is open therefore collides on exactly that line — this is routine, not an accident, and it cannot be designed away by eliminating the diverging file the way an earlier docker-compose divergence was.

The gitflow plugin has no conflict resolution and simply aborts. The shared workflow takes the develop half away from it — -DskipReleaseMergeDevBranch=true on the release path, -DskipMergeDevBranch=true on the hotfix path — and owns the merge, the resolution and the version bump.

Resolution keeps develop’s side. That rule is deterministic: `develop is the leading edge for <revision>, and the release version belongs to the tag and to main.

9.2. Why the auto-resolution is deliberately narrow

Two conditions must both hold, or the job fails with the offending paths named and leaves develop exactly as it was:

  • the root pom.xml is the only conflicted path, and

  • the release or hotfix changed nothing in that pom but <revision>.

The second condition is not redundant. Conflict hunks are line-range based and <revision> sits among the other properties, so a release that also bumped an adjacent property has both changes collapsed into one hunk — and keeping `develop’s side then drops the other edit with no diagnostic at all. The exposure is larger on the hotfix path, where carrying a substantive change is the entire point of the branch.

git checkout --ours pom.xml is not an equivalent shortcut and must not be substituted. It takes develop’s whole file, discarding release edits in hunks that merged cleanly. Resolving hunk by hunk with `git merge-file --ours keeps them.

Silently resolving any conflict would paper over genuine drift between main and develop, which is the failure this narrowness exists to preserve.

9.3. A reusable workflow does not inherit secrets

develop requires the PR checks status check. GITHUB_TOKEN pushes as github-actions[bot], which is not an administrator and so is not covered by enforce_admins: false; GitHub rejects the back-merge push with GH006. A human administrator pushing the same commit succeeds, which is why the failure only ever appears in CI.

The shared workflow accepts an optional RELEASE_PAT for this — an admin PAT (Contents: read+write) configured as a bypass actor — and checks out with:

token: ${{ secrets.RELEASE_PAT || github.token }}

The calling workflow must pass it explicitly. A reusable workflow receives only the secrets its caller maps to it, so a caller with no secrets: block resolves that expression to GITHUB_TOKEN no matter how the secret is configured in the repository. Creating the repository secret is not enough on its own, and nothing warns: the expression simply falls back.

  gitflow-release-finish:
    name: Gitflow
    needs: [package]
    uses: christhonie/event/.github/workflows/gitflow-release-finish.yml@main
    secrets:
      RELEASE_PAT: ${{ secrets.RELEASE_PAT }}

The fallback itself is deliberate. A repository whose develop carries no required status check needs no PAT, and mapping a secret that does not exist resolves to empty rather than failing — so the same block is safe to add everywhere.

9.4. A caller fix arrives only with the next release

push-main.yml runs from the copy on main, so a change to the calling workflow reaches the pipeline differently from a change to the shared workflow it calls:

Change Lands on In effect from

Shared workflow in parent-pom

main of christhonie/event, directly

the next fresh run of any consumer

Calling workflow (push-main.yml)

the consumer’s develop, via GitFlow

the release merge that carries it into main

The release commit that brings a corrected caller onto main is the same commit that triggers the run, so that release is covered — but a release already in flight is not.

10. Never release against a SNAPSHOT

A release built against a SNAPSHOT is not reproducible, and nothing stops it: the enforcer configuration pins the Maven and Java versions and runs a non-failing dependency-convergence rule, but has no requireReleaseDeps.

Check before any release — zero is the gate:

mvn dependency:tree | grep -c 'SNAPSHOT:compile'

This still applies. admin-service consumes wordpress-database, spreadsheet-importer, common-db and parent-pom, each versioned independently of it.

Note also that a pin to <n>-SNAPSHOT does not imply <n> will ever ship. The release version is a free-text input to the GitFlow release-start workflow, so an operator can cut <n+1> while develop sits on <n>-SNAPSHOT — stranding the number that was developed against and forcing consumers to repin mid-review. Release `develop’s current SNAPSHOT number unless something genuinely claims it.

10.1. A derived getter is a landmine in generated assertion helpers

The generated assert…UpdatableFieldsEquals helpers assert every field as though it were persisted. An entity getter that is @Transient and derived rather than persisted is therefore a downstream-breaking change even though no API signature moved: the assertion is a harmless no-op while the getter returns a constant, but once it derives a value it compares two objects that need not share the state it derives from. A partial-update test then fails comparing a derived label against a default.

Adding a derived getter means checking the generated asserts for that entity in the same change.

This used to be worse. The helpers arrived in a separate tests-classifier jar, so a change could break admin-service’s integration suite while the publishing repository’s own build stayed green — that build never ran admin-service’s tests. Since the merge below, the entity, its helper and the test that consumes it are all in one repository and one build, so the breakage is immediate and local.

11. Retired: the admin-service / event-database cross-repo coupling

Until 2026-09-19, admin-service consumed its JPA entities, generated metamodel classes and Liquibase changelog from a separately versioned event-database artifact. That coupling generated a family of traps documented here, all of which are now unreachable:

  • Merge ordering. An admin-service pull request referencing a new entity field had to wait for the paired database pull request to merge and publish a SNAPSHOT, or develop failed to compile with cannot find symbol.

  • The tests-classifier jar, which coupled the repositories a second time — see the surviving lesson above.

  • Claiming the SNAPSHOT slot. Because only develop published, a consumer pinned to develop’s live number got `develop’s content. Pinning a number expecting an unmerged feature’s changesets meant silently building against a schema without them. The remedy was an occupancy test across admin-service `develop, its open pull requests and its unpushed worktrees, then a conditional <revision> bump and a matching <event.database.version> pin — re-run immediately before merging, because a free slot could be claimed while a pull request was open.

event-database was merged into admin-service under ADO #1311. There is no second artifact, no version to pin, no slot to claim, and no ordering to get wrong: a schema change and the Java change that needs it are one commit.

Two things survive the merge and are documented where they belong:

  • The changelog tree must never be relocated. DATABASECHANGELOG.FILENAME stores the literal classpath path from each <include>, so moving an already-applied file makes Liquibase treat it as new and re-run it. Preserving that layout byte-for-byte is what allowed the merge itself to happen without a single changeset re-running. See the Java library pattern and the liquibase-changelog-create skill.

  • A build-time property reference can outlive the property. Removing <event.database.version> left config/application.yml filtering an undefined placeholder into the Hazelcast cluster name, which Maven emits literally rather than failing on — silently turning a versioned cluster name into a constant. Filtered config is not covered by a dependency check.