Skip to content

Release Architecture

Branching model, versioning strategy, and repository split — from feature development to customer production.


Why GitFlow

GitFlow was built for versioned software with explicit release cycles and a clear line between ongoing development and production. That's exactly what ACEX needs: customers pin to specific package versions, and a broken release can't be recalled once it's on PyPI.

We use a simplified GitFlow: main and develop as long-lived branches, short-lived feature/* and fix/* branches, time-bounded release/x.y branches, and hotfix/* for emergencies.


What we dropped from classic GitFlow

No staging branch

The hierarchy feature → develop → release → staging → main has a redundant layer. Staging is a Kubernetes cluster, not a code state. The release/x.y branch is the staging artifact — publish it as a pre-release to PyPI, have GitLab install that version, deploy, and test. Fixes go on the same branch; you publish rc2. The branch merges to main only after staging passes.

DAST and load tests belong in GitLab, not GitHub

These tests exercise a running deployment, not source code. GitHub Actions has no access to the staging cluster, and internal testing infrastructure doesn't belong in a public repository. They run in GitLab CI, triggered after GitLab deploys the release candidate.

The merge-back from main to develop should be automated

GitFlow requires merging main back into develop after every release so fixes applied on the release branch aren't lost. This step is automated in the GitHub Actions release workflow.


Branching strategy

gitGraph
   commit id: "init"
   branch develop
   checkout develop
   commit id: "ongoing work"
   branch feature/50001
   checkout feature/50001
   commit id: "OSPF redistribution"
   checkout develop
   merge feature/50001 id: "merge feature"
   branch release/1.4
   checkout release/1.4
   commit id: "fix route-leak" tag: "v1.4.0rc1"
   commit id: "fix route-leak #2" tag: "v1.4.0rc2"
   commit id: "stable" tag: "v1.4.0"
   checkout main
   merge release/1.4 id: "release to PyPI"
   checkout develop
   merge main id: "merge-back"

After a release merges to main, an automated step merges main back into develop to carry any release-branch fixes forward.


Branch definitions

Branch Long-lived Based on Merges into Purpose
main Yes Production source. Every commit here is a released version. No direct pushes — merge only from release/* or hotfix/*.
develop Yes Integration branch. All feature work lands here. Source for the next release branch.
feature/* No develop develop One branch per ticket or logical change. Deleted after merge.
fix/* No develop develop Non-urgent bugfixes that go through the normal release cycle.
release/x.y No develop main + develop Release candidate. Accepts only bug fixes — no new features. Source of RC builds deployed to staging. Deleted after merging to main.
hotfix/* No main main + develop Emergency fixes for a production defect that can't wait for the next release.

Feature development

Every new task starts from develop:

git checkout develop
git pull
git checkout -b feature/50001-ospf-redistribution

Work happens on the feature branch. A pull request targets develop. GitHub Actions runs lint and unit tests. Peer review, approval, merge. Branch deleted.

No version bumps on feature branches. Feature branches are invisible to customers.

Keep PRs small

One logical change per branch. A PR touching eight unrelated packages is a signal the branch scope drifted — split it.


Release workflow

When the team decides to cut a release, a release branch is created from develop:

git checkout develop
git checkout -b release/1.4

Tag the first release candidate and push:

git tag v1.4.0rc1
git push origin release/1.4 --tags

GitHub Actions sees the tag and publishes acex==1.4.0rc1 to PyPI as a pre-release. From this point the branch accepts only bug fixes. Fixes are applied here and cherry-picked back to develop:

git cherry-pick <commit-hash>

Each fix gets a new RC tag (rc2, rc3) and CI publishes it automatically.


Staging: environment, not branch

Staging is a Kubernetes cluster running the full ACEX stack. It is not a Git branch.

When a new RC is published to PyPI, the GitLab CI pipeline for the staging environment is triggered (via a GitHub webhook). It installs the RC version and deploys it:

ARG ACEX_VERSION=1.4.0rc1
RUN pip install acex==${ACEX_VERSION}

The staging environment mirrors production as closely as possible: same Kubernetes version, same database schema, same Vault configuration, synthetic (non-production) customer ConfigMaps and NEDs.

Pre-release versions are safe on PyPI

pip install acex does not install pre-release versions by default. A customer who runs pip install acex will never pick up 1.4.0rc1 unless they explicitly pass --pre. The RC is published early and safely.


Testing strategy

Tests are divided by what they exercise: source code (GitHub) vs. running system (GitLab).

  • Ruff lint and format check
  • Unit tests per package
  • Cross-package integration tests
  • Branch name policy validation
  • DAST (OWASP ZAP / GitLab DAST)
  • Load testing (k6 or Locust)
  • Stress testing
  • End-to-end tests (Playwright / pytest)
  • Integration tests against a live database and real device responses

GitLab system tests run after every RC deployment to staging. If any suite fails, the release branch stays open and the team cuts a new RC with a fix.


Promoting a release candidate to production

When all GitLab staging tests pass:

  1. Open PR: release/1.4 → main
  2. Code review confirms the diff contains only fixes from the RC cycle — no unreviewed changes
  3. Merge to main
  4. GitHub Actions detects the tag and publishes acex==1.4.0 to PyPI as stable
  5. Tag v1.4.0 is created on main
  6. Automated merge-back: main → develop
  7. release/1.4 deleted

The promotion PR is lightweight by design

The content was already validated on staging. If the diff is large, something went wrong earlier in the process.


PyPI releases

ACEX uses semantic versioning following PEP 440. All packages in the monorepo share a single version — released as a coordinated set via a Git tag.

Stage Version format Tag PyPI behavior
Release candidate 1.4.0rc1 v1.4.0rc1 Published; not installed by pip install acex without --pre
Stable release 1.4.0 v1.4.0 Published; installed by default
Patch release 1.4.1 v1.4.1 Hotfix path: from hotfix/* directly to main

Convention: tags are only pushed from release/* and hotfix/* branches. Never from feature branches, never directly from develop. Enforce in code review.


Repository split

Repository: acex-labs/acex

  • All ACEX framework source (backend/, devkit/, client/, mcp/, cli/, worker/)
  • Generic device drivers (drivers/cisco_ios_cli/, drivers/juniper_junos_cli/)
  • Local development environment (docker-compose.yml, mock-device/)
  • Public documentation
  • GitHub Actions CI: lint, unit tests, PyPI publish, GHCR agent image builds
  • .env.example with generic placeholders

Repositories: internal infrastructure

  • Customer ConfigMaps and NEDs
  • Customer Dockerfiles
  • Kubernetes manifests (staging and production clusters)
  • Helm charts with customer-specific values
  • DAST configurations and scan history
  • Load and stress test scenarios and results
  • GitLab CI pipelines for customer image builds and deployments
  • Vault policies and secret references
  • Internal infrastructure as code (Terraform, etc.)

Never cross-commit

No customer identifier, hostname, NED path, or internal IP should ever appear in the GitHub repository — not in comments, not in example files, not in test fixtures. Mock devices use generic hostnames. .env.example uses generic placeholders.


Customer images

Customer images are built in GitLab CI using released PyPI packages as their ACEX source. Customer-specific configuration is injected at build time from private GitLab repositories.

# Private GitLab repo: acex-customer-{name}/Dockerfile
ARG ACEX_VERSION=1.4.0

FROM python:3.13-slim

RUN pip install \
    acex==${ACEX_VERSION} \
    acex-driver-cisco-ioscli==${ACEX_VERSION} \
    acex-driver-juniper-junoscli==${ACEX_VERSION}

COPY configmaps/ /app/configmaps/
COPY neds/       /app/neds/

CMD ["python", "-m", "acex"]

Built and tagged in GitLab CI:

docker build \
  --build-arg ACEX_VERSION=1.4.0 \
  -t registry.gitlab.com/acex-labs/acex-customer-abc:1.4.0 .

The image is pushed to the private GitLab Container Registry and deployed to the customer's Kubernetes cluster. It never touches a public registry. For staging, substitute ACEX_VERSION=1.4.0rc1 and target the staging cluster.


CI/CD pipeline division

Event Runs in Actions
PR opened against develop GitHub Lint, format check, unit tests, integration tests, branch name policy
Tag v*rc* pushed GitHub Publish pre-release to PyPI; send webhook to GitLab staging pipeline
New RC detected on PyPI GitLab Build staging image, deploy to staging cluster, run DAST + load + E2E
Tag v* (stable) pushed GitHub Publish stable version to PyPI, build & push agent images to GHCR, merge-back to develop
Stable version detected on PyPI GitLab Build customer images, push to private registry, deploy to customer clusters

The connection between GitHub and GitLab is a webhook: GitHub Actions posts to a GitLab pipeline trigger URL when an RC or stable version is published. GitLab handles the rest privately. No GitLab credentials or internal pipeline details exist in the public repository.


Naming conventions

Entity Format Example
Feature branch feature/{ticket-id}-{slug} feature/50001-ospf-redistribution
Fix branch fix/{ticket-id}-{slug} fix/51234-vlan-reference-null
Release branch release/{major}.{minor} release/1.4
Hotfix branch hotfix/{ticket-id}-{slug} hotfix/51500-critical-auth-bypass
Git tag (RC) v{major}.{minor}.{patch}rc{n} v1.4.0rc1, v1.4.0rc2
Git tag (stable) v{major}.{minor}.{patch} v1.4.0
PyPI version PEP 440 1.4.0rc1, 1.4.0
Public agent image ghcr.io/acex-labs/{image}:{version} ghcr.io/acex-labs/acex-collection-agent:1.4.0
Private customer image registry.gitlab.com/{org}/acex-{customer}:{version} registry.gitlab.com/acex-labs/acex-customer-abc:1.4.0

Complete lifecycle example

Feature: OSPF static route redistribution (ticket #50001) — from first commit to customer production.

  1. Feature branch — Developer creates feature/50001-ospf-redistribution from develop. Makes commits, writes unit tests.

  2. GitHub CI — PR opened to develop. Lint and unit tests pass. Peer review approves. Merged. Branch deleted.

  3. Release branch cut — Team cuts release/1.4 from develop. Tags v1.4.0rc1 and pushes.

  4. GitHub Actions — Detects tag v1.4.0rc1. Publishes acex==1.4.0rc1 as pre-release to PyPI. Sends webhook to GitLab staging pipeline.

  5. GitLab — Staging — Builds staging image with acex==1.4.0rc1 and synthetic ConfigMaps. Deploys to staging cluster. Runs DAST, load tests, E2E suite.

  6. Bug found — E2E test reveals a route-leak. Developer creates fix/50002-route-leak from release/1.4. Fix applied. Cherry-picked to develop. Tags v1.4.0rc2. Staging tests re-run — all pass.

  7. Promotion — Team approves. Tags v1.4.0 on release/1.4. PR opened: release/1.4 → main.

  8. Merged to main — Review confirms only RC fixes in the diff. Merged. GitHub Actions publishes acex==1.4.0 to PyPI (stable). Automated merge-back to develop. release/1.4 deleted.

  9. GitLab — Production — Detects stable 1.4.0 on PyPI. Builds customer image with real ConfigMaps and NEDs. Pushes registry.gitlab.com/acex-labs/acex-customer-abc:1.4.0.

  10. Customer production — Image deployed to customer Kubernetes cluster. ACEX 1.4.0 is live. The customer never touched the public GitHub repository.

Two quality gates: GitHub CI (source) and GitLab staging (system). Every change passes both before any customer sees it.