DevOps · Devops

Acumatica CI/CD Pipeline Design

A working CI/CD pipeline for Acumatica customizations: version the project package, build a validated ZIP, publish it through the API, and gate promotion on a real smoke test.

John Kihiu12 min read

Acumatica customizations do not build like a normal .NET app. The unit of deployment is a customization project — a package of graph extensions, DACs, screen edits, generic inquiries, and site-map entries that Acumatica compiles at publish time inside the instance itself. A CI/CD pipeline that actually helps has to work with that reality: export the project to source, validate it, produce a versioned package, and publish it into a target instance through the API rather than by clicking Publish in the Customization Project Editor. This is the pipeline I keep rebuilding, and the decisions that matter in it.

What the artifact actually is

The thing you version and promote is the customization project ZIP. It is not compiled DLLs — Acumatica compiles the C# in the extension library on the target instance when the project is published. That single fact drives the whole design: your build stage cannot produce a runnable binary and be done, because the same package can compile cleanly against one instance version and fail against another. The pipeline's job is to keep the project in source control as unpacked XML/C#, repackage it deterministically, and then let a real target instance be the compiler and the integration test.

Keep the project unpacked in Git. Export it once from the Customization Project Editor (Publish → Export gives you the ZIP; unzip it into the repo), and from then on treat the individual files — the _project/ metadata, the code files, the customized screen deltas — as the source of truth. Diffing a screen change in a pull request is only possible if the files are unpacked; a committed ZIP is a black box in review.

Build: repackage deterministically

The build stage takes the unpacked project and produces a versioned ZIP whose layout matches what Acumatica's import expects. Stamp the version into the project name so a published instance shows exactly which build it is running — this is the single most useful thing you can do for support later.

YAML · GITHUB ACTIONS
name: build-customization
on:
  push:
    tags: ['v*']
jobs:
  package:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Stamp version into project metadata
        run: |
          VERSION="${GITHUB_REF_NAME}"
          sed -i "s/<Level>.*<\/Level>/<Level>${VERSION}<\/Level>/" \
            src/_project/ProjectMetadata.xml
      - name: Package project ZIP
        run: |
          cd src
          zip -r "../MyCompanyCustomizations-${GITHUB_REF_NAME}.zip" . -x '*.git*'
      - uses: actions/upload-artifact@v4
        with:
          name: customization-package
          path: MyCompanyCustomizations-*.zip

There is no MSBuild step here, and that is the point. If you want the compiler to check the C# before you ship it, add a throwaway project that references PX.Data.dll and PX.Objects.dll from the matching Acumatica build and compiles the extension code — but treat that as a fast lint, not proof of a good publish. The authoritative compile still happens on the target.

Deploy: publish through the API

Clicking Publish by hand does not belong in a pipeline. Acumatica exposes the customization operations over the contract-based/REST endpoints so you can import a package and publish it from a script. The flow is: authenticate, upload the ZIP via the UploadFileBase64/customization import call, then invoke publish and poll until the compile finishes. Publishing recompiles the extension library and recycles the application domain, so the instance drops connections for a few seconds — schedule it into a maintenance window, never mid-day.

BASH · DEPLOY SCRIPT
#!/usr/bin/env bash
set -euo pipefail

BASE="https://erp-uat.example.com"
COOKIE_JAR=$(mktemp)

# 1. Log in (credentials come from the CI secret store, never the repo)
curl -sf -c "$COOKIE_JAR" "$BASE/entity/auth/login" \
  -H 'Content-Type: application/json' \
  -d "{\"name\":\"$ACU_USER\",\"password\":\"$ACU_PASS\",
       \"company\":\"$ACU_TENANT\"}"

# 2. Import + publish the package (sysContract endpoint)
PKG_B64=$(base64 -w0 MyCompanyCustomizations-*.zip)
curl -sf -b "$COOKIE_JAR" "$BASE/CustomizationApi/Import" \
  -H 'Content-Type: application/json' \
  -d "{\"projectLevel\":1,\"isReplaceIfExists\":true,
       \"projectContentBase64\":\"$PKG_B64\"}"

curl -sf -b "$COOKIE_JAR" "$BASE/CustomizationApi/Publish" \
  -H 'Content-Type: application/json' \
  -d '{"projectNames":["MyCompanyCustomizations"],
       "isMergeWithExistingPackages":false}'

# 3. Always log out — sessions are a licensed, limited resource
curl -sf -b "$COOKIE_JAR" "$BASE/entity/auth/logout" -X POST
rm -f "$COOKIE_JAR"
Log out, or you will run out of sessions

Acumatica API sessions count against your licensed concurrent-user limit and are not released immediately when a script exits. A pipeline that logs in on every run and never logs out will, within a day or two, exhaust the session pool and lock real users out. Wrap the logout in a trap so it fires even when publish fails.

Environments and promotion

Keep at least three instances: a developer instance where the project is authored and exported, a UAT/staging instance that mirrors production's Acumatica build number, and production. The build number match matters — a package authored against 2023 R2 can fail to compile against 2024 R1 because a base method signature moved. Promote the same ZIP artifact through each stage; never rebuild between UAT and production, or you have not tested what you ship.

StageTriggerGate before promotion
Dev instanceManual export → commitPull request review of unpacked diff
UATGit tag v*Publish succeeds + smoke test passes
ProductionManual approval on the same artifactUAT sign-off, maintenance window

The smoke test that earns its keep

A green publish only means the code compiled. It does not mean the screen still opens or the endpoint still returns data. The gate that actually catches regressions is a tiny post-publish check that hits the customized surface: open one modified screen through the API, pull one record through a customized generic inquiry or endpoint, and assert the shape you expect. If that fails, fail the pipeline and hold promotion.

BASH · POST-PUBLISH SMOKE TEST
set -euo pipefail
# Retrieve one record through the customized endpoint and check a field
RESP=$(curl -sf -b "$COOKIE_JAR" \
  "$BASE/entity/MyCustomEndpoint/22.200.001/SalesOrder?\$top=1")

echo "$RESP" | jq -e '.[0].CustomFieldValue.value != null' > /dev/null \
  || { echo "Smoke test failed: custom field missing after publish"; exit 1; }
echo "Smoke test passed"

Rollback

Rollback in Acumatica is republishing the previous package version, not a database revert — published customizations do not migrate data, they change compiled behavior, so reverting the code usually restores the prior behavior cleanly. Keep the last known-good ZIP as a retained artifact and make "publish previous tag" a one-click job. The exception is any customization that also shipped a schema change through a PXDatabase upgrade script or a custom SQL step; those need their own down-path, and that is exactly why database changes belong in a separate, explicitly reviewed part of the project rather than bundled invisibly into a feature.

Separate schema changes from code changes

Column additions via custom fields are handled by Acumatica automatically and roll back safely. Hand-written SQL upgrade scripts do not. Keep them isolated and idempotent so a re-publish or a rollback does not double-apply them.

Wrapping up

A good Acumatica pipeline is mostly about respecting how the platform actually deploys: version the project ZIP, keep it unpacked in Git for reviewable diffs, publish through the API into a build-matched target, and gate promotion on a smoke test that touches the real customized surface. None of it is exotic — but each of those steps is one I have watched a team skip and then spend a Friday evening recovering from.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.