Skip to content

    Apigee

    Apigee CI/CD: Lint, Test and Promote Proxies

    Treat Apigee proxies as code: lint with apigeelint, test, build one bundle, deploy with apigeecli, Maven or Terraform, and promote with approval.

    By Published 20 min read

    Share

    This lesson is part of Apigee API Management: Beginner to Advanced. Enroll for free, then sign in, to read it in full.

    What you'll learn: How to keep Apigee proxies and shared flows in git and ship them through a pipeline that lints, tests, builds one bundle, deploys it to dev, runs integration tests, waits for approval and promotes the same revision to production. You will pick between apigeecli, the Maven plugin, gcloud and Terraform, authenticate from CI without a long-lived key, and roll back by redeploying the previous revision.

    Clicking "deploy" in the console is fast, and it is also a production change that nobody reviewed, nobody can reproduce and nobody can undo with confidence. An API gateway sits in front of every client you have, so a bad policy change is an outage for all of them at once. The fix is the one software teams already use for applications: the source is in git, a pipeline is the only path to production, and every release is an artifact you can name.

    The core idea

    Think of a proxy bundle the way you think of a container image or a JAR file. It is built once from a commit, it is immutable after that, and the same artifact travels from one environment to the next. Only the configuration around it changes.

    Apigee already pushes you in this direction. A revision is a numbered snapshot of a proxy, and the deployment overview states that once a revision is deployed it becomes read-only: any change means creating a new revision. Deployments are zero-downtime: the new revision is deployed first and the old one is undeployed only after that. And rolling back is documented in the same terms: if a new revision fails, redeploy the previous revision.

    So the pipeline has one job: turn a commit into a revision, prove the revision works in a low environment, and then deploy that same revision upwards, with a human decision before production.

    Pipeline architecture: git to GitHub Actions to Apigee environments, authenticated through Google CloudGitHub repo to GitHub Actions (push, pull request); GitHub Actions to Google Cloud (OIDC token); GitHub Actions to Bundle zip; GitHub Actions to Apigee dev (revision N); GitHub Actions to Apigee prod (revision N); Terraform to Apigee dev (env config); Terraform to Apigee prodGitHub repo: code and testsGitHub repocode and testsGitHub Actions: lint, test, deployGitHub Actionslint, test,deployBundle zip: built once, checksummedBundle zipbuilt once,checksummedGoogle Cloud: OIDC to access tokenGoogle CloudOIDC to accesstokenApigee dev: auto deploy, integration testsApigee devauto deploy,integration testsApigee prod: after approval, same revisionApigee prodafter approval,same revisionTerraform: environments, target serversTerraformenvironments,target serverspush, pull requestOIDC tokenrevision Nrevision Nenv config

    Two kinds of change flow through this picture. Code changes (proxies, shared flows) go through GitHub Actions on every commit. Environment changes (target servers, key value maps, the environments themselves) are managed separately, here with Terraform, because they change on a different rhythm and must differ between environments.

    Proxies as code: the repository layout

    Export or write the bundle as files and commit it. A layout that keeps code, configuration and tests apart:

    orders-platform/
    ├── proxies/
    │   └── orders-v1/
    │       └── apiproxy/
    │           ├── orders-v1.xml            # proxy descriptor
    │           ├── proxies/default.xml      # ProxyEndpoint
    │           ├── targets/default.xml      # TargetEndpoint (refers to a target server by name)
    │           ├── policies/                # one XML file per policy
    │           └── resources/jsc/           # JavaScript callouts
    ├── sharedflows/
    │   └── sf-verify-token/
    │       └── sharedflowbundle/
    ├── terraform/                           # environments, target servers, KVM maps
    │   ├── dev.tfvars
    │   └── prod.tfvars
    ├── tests/
    │   ├── unit/                            # Mocha tests for JavaScript callouts
    │   ├── integration/                     # Postman collection or apickli features
    │   └── smoke/                           # read-only checks that are safe in production
    ├── .apigeelintrc
    ├── package.json
    └── .github/workflows/apigee.yml

    The rule that makes this layout work: the bundle contains no environment-specific values. The target endpoint names a target server (<Server name="orders-backend"/>), and each environment defines what orders-backend points to. The bundle reads secrets from a key value map by name, and each environment holds different contents. If a hostname, a key or a quota number is typed into policy XML, you will have to rebuild the bundle per environment, and then you are no longer testing what you ship.

    Shared flows get the same treatment as proxies: their own folder, their own lint and tests, their own revisions. They are covered in the third use case below because they deserve extra care.

    Stage 1: static analysis with apigeelint

    apigeelint is the Apigee project's linter for bundles. Install it with npm install -g apigeelint (the README asks for Node 20 or later) or run it through npx in the pipeline.

    npx apigeelint -s proxies/orders-v1/apiproxy --profile apigeex -f stylish.js --maxWarnings 0

    What the flags do, from the project README:

    • -s points at a proxy or shared flow directory (or a zipped bundle).
    • --profile apigeex selects the rules for Apigee X and hybrid. The default profile targets Edge, and some rules differ: for example, a VirtualHost is not expected on X.
    • -f chooses the output format. The README lists json.js (default), stylish.js, table.js, junit.js, html.js and others. Use junit.js with -w results/lint.xml if your CI renders JUnit reports.
    • --maxWarnings is "the number of warnings to trigger a non-zero exit code"; the default of -1 means warnings alone never fail the run. In the tool's source, any finding with error severity exits with code 1, and warnings fail the run only when their total exceeds --maxWarnings, so --maxWarnings 0 fails on any warning.
    • -e PO007,ST003 skips rules by ID, and a .apigeelintrc file in the repo can hold the same options.

    Rules are grouped by prefix: BN for bundle structure (unused or unattached policies, bundle size, malformed XML), PD for proxy endpoints and route rules, TD for target endpoints, FL and ST for flows and steps, PO for individual policies, FR for fault rules, CC for conditions. It will flag things such as unreachable steps, unattached policies and risky target configuration before they reach an environment.

    Two habits keep the lint stage honest. First, prove the gate fails: add a deliberately broken policy on a branch and confirm the job goes red, because your configuration (profile, --maxWarnings, excluded rules) decides the outcome. Second, suppress rules deliberately and visibly: the README supports an inline apigeelint disable=RULEID directive in an XML comment on the line before the element, which leaves a reviewable trace in the pull request.

    Stage 2: unit tests for JavaScript callouts

    Most of a proxy is declarative XML that you cannot unit test in the usual sense. The code you can test is the JavaScript in resources/jsc/. Keep callouts small and push logic into them only when no native policy fits, as earlier lessons argue.

    Apigee runs JavaScript policies on the Rhino engine (the reference names version 1.7.13), and scripts talk to the flow through the context object, for example context.getVariable("request.header.x-customer-id"). Write conservative, older-style JavaScript (var, plain functions) and check the engine's limits in the JavaScript policy reference before using newer syntax.

    A callout that normalises a header:

    // resources/jsc/normalize-customer.js (runs inside Apigee's JavaScript policy)
    var raw = context.getVariable("request.header.x-customer-id");
    var id = raw ? String(raw).replace(/^\s+|\s+$/g, "").toUpperCase() : "";
    context.setVariable("order.customerId", id);

    A test that runs the real file in Node with a fake context. This is a testing technique, not an Apigee feature:

    // tests/unit/normalize-customer.test.js (Mocha plus Node's built-in assert and vm)
    const fs = require("node:fs");
    const vm = require("node:vm");
    const assert = require("node:assert");
    
    function runCallout(file, inputs) {
      const outputs = {};
      const context = {
        getVariable: (name) => inputs[name],
        setVariable: (name, value) => { outputs[name] = value; },
      };
      vm.runInNewContext(fs.readFileSync(file, "utf8"), { context });
      return outputs;
    }
    
    describe("normalize-customer.js", () => {
      const file = "proxies/orders-v1/apiproxy/resources/jsc/normalize-customer.js";
    
      it("trims and upper-cases the id", () => {
        const out = runCallout(file, { "request.header.x-customer-id": "  ab12 " });
        assert.strictEqual(out["order.customerId"], "AB12");
      });
    
      it("sets an empty id when the header is missing", () => {
        const out = runCallout(file, {});
        assert.strictEqual(out["order.customerId"], "");
      });
    });

    Run it with npx mocha tests/unit. Google's CI/CD guidance names Mocha and Chai for JavaScript unit tests; run them on every build. Be clear about what this proves: your logic is right. It does not prove the policy is attached in the right flow or that Rhino accepts the syntax. Integration tests cover that.

    Stage 3: integration and contract tests against a deployed revision

    The integration stage calls the real gateway after a deploy. It is the only stage that exercises policy order, conditions, quotas and fault rules together. Two tools are widely used (Google's CI/CD guidance lists apickli and Postman as integration-testing options):

    Newman runs Postman collections from the command line. Install it with npm install newman. The flags used below are documented in its README: -e loads an environment file, --env-var "key=value" sets a variable, -r cli,junit picks reporters (list cli explicitly if you want console output as well), --reporter-junit-export <path> chooses the JUnit file, and --bail stops at the first failing test script.

    apickli is a Gherkin-style framework on top of cucumber.js, made for REST APIs. Features read like this (these step forms are from its README):

    Feature: Orders API through the gateway
    
      Scenario: A request without an API key is rejected
        When I GET /orders/v1/orders/1001
        Then response code should be 401
    
      Scenario: A valid key returns the order
        Given I set x-api-key header to `apikey`
        When I GET /orders/v1/orders/1001
        Then response code should be 200
        And response body path $.id should be 1001

    The base URL and the backtick variables are set in a Before hook in features/support/init.js, which is where you read the environment (dev URL, dev key) so the same features run against any environment. apickli can also check a response body against an OpenAPI description, which gives you a contract test: the gateway's actual output must match the published spec.

    What to assert: the unhappy paths matter more than the happy one. A missing key returns 401, an over-quota key returns 429, a malformed body returns 400, and an upstream failure returns your error format rather than the backend's stack trace.

    flowchart BT U["Lint and unit tests: many, seconds, no deployment"] I["Integration and contract tests: dozens, against the dev revision"] S["Smoke tests: a handful, read-only, run in production after promotion"] U --> I --> S

    The shape is the usual test pyramid applied to a gateway: the wide base runs on every commit without any environment, the middle layer needs a deployed revision, and the narrow top is the minimum you dare run against production. Keep the top thin: a smoke test must be safe to run against real traffic, so no writes, no destructive calls.

    Stage 4: build the bundle once

    The artifact is the zip of the bundle directory, produced once per commit:

    mkdir -p dist
    (cd proxies/orders-v1 && zip -r -X ../../dist/orders-v1.zip apiproxy)
    sha256sum dist/orders-v1.zip > dist/orders-v1.zip.sha256

    Upload it as a pipeline artifact and use that file in every later job. Never run zip again in a later stage: a second build is a second artifact, even if the source is identical.

    Importing the zip creates a revision. The Apigee REST API does this with action=import: if the proxy does not exist it is created, and if it exists a new revision is created. The response describes the new revision, and a new revision is not reachable at runtime until it is deployed.

    curl -sS --fail-with-body -X POST \
      "https://apigee.googleapis.com/v1/organizations/${ORG}/apis?name=orders-v1&action=import" \
      -H "Authorization: Bearer ${TOKEN}" \
      -F "file=@dist/orders-v1.zip" -o import.json
    jq -r .revision import.json     # the new revision number, for example 12

    Deployment tooling: four options and when to choose each

    All four call the same Apigee API. They differ in what else they manage and what they need installed.

    apigeecli is Google's open-source CLI (a single binary, also published as a container image). Authenticate with a token (-t), a metadata token, application default credentials (--default-token), or a service account key file. Verified commands:

    # import a revision and deploy it in one step, waiting for completion
    apigeecli apis create bundle -n orders-v1 -p dist/orders-v1.zip -e dev -o "$ORG" -t "$TOKEN" --wait
    
    # deploy an existing revision (omit -v to deploy the highest revision)
    apigeecli apis deploy -n orders-v1 -v 12 -e prod -o "$ORG" -t "$TOKEN" --ovr --wait
    
    # list what is deployed where
    apigeecli apis listdeploy -n orders-v1 -o "$ORG" -t "$TOKEN"

    --ovr forces the new revision to replace the one currently deployed, --wait blocks until the deployment succeeds or fails, --safedeploy (on by default for apis deploy) runs a change report and proceeds only if there are no conflicts, and -s sets the service account the proxy runs as. apigeecli sharedflows deploy and sharedflows create bundle have --ovr, --wait and -s, but no --safedeploy. The README notes that version 2.x renamed many flags to kebab-case, so pin the version in CI.

    Apigee Deploy Maven plugin suits Java and Maven shops. Plugin version 2.x is for Apigee X and hybrid (1.x is for Edge), and it needs Java 8 or later and Maven 3. Profiles hold the organisation and environment, authentication is a bearer token (-Dbearer=$(gcloud auth print-access-token)), and goals include apigee-enterprise:deploy and apigee-enterprise:promote. Promotion can take a specific revision (apigee.promote.revision) or the revision deployed in a source environment (apigee.promote.sourceEnv). A config.json can override values per environment at build time, which is convenient but works against build-once: prefer environment-side configuration.

    gcloud has gcloud apigee apis deploy [REVISION] --api=NAME --environment=ENV --organization=ORG [--override]. It deploys a revision you already imported (or the latest if you omit the revision) and needs nothing beyond the Google Cloud CLI. Good for a manual, audited rollback.

    Terraform manages Apigee as infrastructure. From the Google provider documentation: google_apigee_api takes name, org_id and config_bundle (a zip path) and exposes latest_revision_id; google_apigee_api_deployment takes org_id, environment, proxy_id, revision and an optional service_account; google_apigee_target_server takes name, host, port, env_id and an optional protocol; google_apigee_environment_keyvaluemaps creates a key value map in an environment. A sketch of per-environment configuration:

    variable "org_id" { type = string }
    variable "env_id" { type = string }   # organizations/<org>/environments/<env>
    variable "backend_host" { type = string }
    
    resource "google_apigee_target_server" "orders_backend" {
      name     = "orders-backend"        # the name the bundle refers to
      env_id   = var.env_id
      host     = var.backend_host        # differs per environment
      port     = 443
      protocol = "HTTP"                  # TLS settings go in the s_sl_info block
    }
    
    resource "google_apigee_environment_keyvaluemaps" "orders_config" {
      name   = "orders-config"
      env_id = var.env_id
    }

    Run it once per environment with a dev.tfvars and a prod.tfvars. Secret values should not be committed in .tfvars; populate them from a secret store outside the repo.

    flowchart TD A["What are you deploying?"] --> B{"Apigee itself: environments, target servers, KVM maps?"} B -->|Yes| T["Terraform google_apigee_* resources"] B -->|"No: proxy or shared flow bundles"| C{"Does the team already build with Maven and Java?"} C -->|Yes| M["Apigee Deploy Maven plugin 2.x"] C -->|No| D{"Scripted import, deploy and promote in any CI runner?"} D -->|Yes| CLI["apigeecli"] D -->|"No: redeploy one known revision by hand"| G["gcloud apigee apis deploy"]

    The tools combine. A common split is Terraform for the platform and environment configuration, and apigeecli or the Maven plugin for the high-frequency proxy revisions. Terraform can also deploy revisions (google_apigee_api plus google_apigee_api_deployment), which suits small estates that want everything in one state file, but each proxy change then becomes a Terraform apply, and promotion across environments is a matter of wiring the same revision into several deployment resources.

    Option Best for Strength Watch out for
    apigeecli Scripted proxy and shared flow delivery One binary, --wait, --safedeploy, many resource types Pin the version; flags changed in 2.x
    Maven plugin Java and Maven organisations promote from a source environment JVM and Maven in CI; per-env config.json tempts rebuilds
    gcloud Manual deploy or rollback of a known revision Nothing extra to install No bundle import in the commands covered here
    Terraform Environments, target servers, KVM maps, small estates State, review of every change Slower feedback loop for proxy-only changes

    Authenticating CI to Google Cloud without long-lived keys

    A service account JSON key in a CI secret is a password that never expires. Workload identity federation removes it: the CI job presents a short-lived identity token that Google Cloud exchanges for a short-lived access token.

    With GitHub Actions the google-github-actions/auth action does the exchange. Its README recommends federation over service account keys, states that the job needs id-token: write permission, and notes the GitHub OIDC token lives about five minutes, so credentials derived from it are equally short-lived. Setup has three parts:

    1. Create a workload identity pool and a provider for GitHub, and add an attribute condition that restricts who can use it, for example assertion.repository_owner == 'your-org'. The README says to always add one.
    2. Create a deploy service account per environment and grant it the Apigee roles it needs. The Apigee roles documentation says the apigee.deployments.create permission comes from the Environment Admin role at environment level, that deploying a proxy also needs apigee.proxyrevisions.deploy (which comes from API Reader, granted at project level), and that assigning a role at environment level limits access to that one environment; check the current role list when you design the grants. Keep the dev and production service accounts separate so a compromised dev job cannot touch production.
    3. Allow the GitHub identity to impersonate the service account, then reference the provider and account in the workflow.

    If the proxy itself authenticates to Google services (for example Vertex AI) through the Authentication element of its target endpoint, the Apigee documentation adds another identity: a proxy service account given at deployment time (the -s flag in apigeecli, the serviceAccount parameter in the API), which the deployer needs permission to act as.

    The complete pipeline

    This workflow lints and tests on every pull request, and on a merge to main it builds once, deploys to dev, runs integration tests, then pauses for approval before promoting the same revision to production. The approval is a GitHub environment named production with required reviewers: the job does not start until a reviewer approves. Environment-level variables (vars.*) and secrets let dev and production use different providers, accounts and organisations.

    name: orders-apigee
    
    on:
      pull_request:
      push:
        branches: [main]
    
    permissions:
      contents: read
    
    env:
      PROXY: orders-v1
    
    jobs:
      verify:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - name: Lint proxy and shared flow bundles
            run: |
              npx apigeelint -s proxies/orders-v1/apiproxy --profile apigeex -f stylish.js --maxWarnings 0
              npx apigeelint -s sharedflows/sf-verify-token/sharedflowbundle --profile apigeex -f stylish.js --maxWarnings 0
          - name: Unit tests for JavaScript callouts
            run: npx mocha tests/unit
    
      build:
        needs: verify
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v7
          - name: Zip the bundle once
            run: |
              mkdir -p dist
              (cd proxies/orders-v1 && zip -r -X ../../dist/orders-v1.zip apiproxy)
              sha256sum dist/orders-v1.zip > dist/orders-v1.zip.sha256
          - uses: actions/upload-artifact@v7
            with:
              name: orders-v1-bundle
              path: dist/
    
      deploy-dev:
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        needs: build
        runs-on: ubuntu-latest
        environment: dev
        permissions:
          contents: read
          id-token: write            # lets the job request a GitHub OIDC token
        outputs:
          revision: ${{ steps.import.outputs.revision }}
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - uses: actions/download-artifact@v8
            with:
              name: orders-v1-bundle
              path: dist
          - id: auth
            uses: google-github-actions/auth@v3
            with:
              workload_identity_provider: ${{ vars.WIF_PROVIDER }}   # projects/NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER
              service_account: ${{ vars.DEPLOY_SA }}                 # the dev deploy account
              token_format: access_token
          - name: Install apigeecli
            run: |
              curl -L https://raw.githubusercontent.com/apigee/apigeecli/main/downloadLatest.sh | sh -
              echo "$HOME/.apigeecli/bin" >> "$GITHUB_PATH"
          - name: Import the bundle as a new revision
            id: import
            env:
              TOKEN: ${{ steps.auth.outputs.access_token }}
              ORG: ${{ vars.APIGEE_ORG }}
            run: |
              curl -sS --fail-with-body -X POST \
                "https://apigee.googleapis.com/v1/organizations/${ORG}/apis?name=${PROXY}&action=import" \
                -H "Authorization: Bearer ${TOKEN}" \
                -F "file=@dist/orders-v1.zip" -o import.json
              REV=$(jq -r .revision import.json)
              echo "revision=${REV}" >> "$GITHUB_OUTPUT"
              echo "${PROXY} revision ${REV} from commit ${GITHUB_SHA}" >> "$GITHUB_STEP_SUMMARY"
          - name: Deploy the revision to dev and wait
            env:
              TOKEN: ${{ steps.auth.outputs.access_token }}
              ORG: ${{ vars.APIGEE_ORG }}
            run: >
              apigeecli apis deploy -n "${PROXY}" -v "${{ steps.import.outputs.revision }}"
              -e "${{ vars.APIGEE_ENV }}" -o "${ORG}" -t "${TOKEN}" --ovr --wait
          - name: Integration tests against dev
            run: >
              npx newman run tests/integration/orders.postman_collection.json
              --env-var "baseUrl=${{ vars.API_BASE_URL }}"
              --env-var "apiKey=${{ secrets.API_KEY }}"
              -r cli,junit --reporter-junit-export results/newman-dev.xml --bail
          - uses: actions/upload-artifact@v7
            if: always()
            with:
              name: integration-results
              path: results/
    
      promote-prod:
        needs: deploy-dev
        runs-on: ubuntu-latest
        environment: production      # protection rule: required reviewers = the approval gate
        permissions:
          contents: read
          id-token: write
        steps:
          - uses: actions/checkout@v7
          - uses: actions/setup-node@v7
            with:
              node-version: 20
              cache: npm
          - run: npm ci
          - id: auth
            uses: google-github-actions/auth@v3
            with:
              workload_identity_provider: ${{ vars.WIF_PROVIDER }}
              service_account: ${{ vars.DEPLOY_SA }}                 # the production deploy account
              token_format: access_token
          - name: Install apigeecli
            run: |
              curl -L https://raw.githubusercontent.com/apigee/apigeecli/main/downloadLatest.sh | sh -
              echo "$HOME/.apigeecli/bin" >> "$GITHUB_PATH"
          - name: Record the revision serving production now
            id: before
            env:
              TOKEN: ${{ steps.auth.outputs.access_token }}
              ORG: ${{ vars.APIGEE_ORG }}
            run: |
              curl -sS --fail-with-body \
                "https://apigee.googleapis.com/v1/organizations/${ORG}/environments/${{ vars.APIGEE_ENV }}/apis/${PROXY}/deployments" \
                -H "Authorization: Bearer ${TOKEN}" -o current.json
              echo "previous=$(jq -r '.deployments[0].revision // empty' current.json)" >> "$GITHUB_OUTPUT"
          - name: Deploy the revision that passed in dev
            env:
              TOKEN: ${{ steps.auth.outputs.access_token }}
              ORG: ${{ vars.APIGEE_ORG }}
            run: >
              apigeecli apis deploy -n "${PROXY}" -v "${{ needs.deploy-dev.outputs.revision }}"
              -e "${{ vars.APIGEE_ENV }}" -o "${ORG}" -t "${TOKEN}" --ovr --wait
          - name: Smoke test production
            run: >
              npx newman run tests/smoke/orders.smoke.postman_collection.json
              --env-var "baseUrl=${{ vars.API_BASE_URL }}"
              --env-var "apiKey=${{ secrets.API_KEY }}"
              --bail
          - name: Roll back to the previous revision
            if: failure() && steps.before.outputs.previous != ''
            env:
              TOKEN: ${{ steps.auth.outputs.access_token }}
              ORG: ${{ vars.APIGEE_ORG }}
            run: >
              apigeecli apis deploy -n "${PROXY}" -v "${{ steps.before.outputs.previous }}"
              -e "${{ vars.APIGEE_ENV }}" -o "${ORG}" -t "${TOKEN}" --ovr --wait

    How to read it. verify and build need no cloud access, so they run for pull requests too and cost nothing in risk. deploy-dev and promote-prod are the only jobs with id-token: write, and each runs in a GitHub environment, so each reads its own variables (WIF_PROVIDER, DEPLOY_SA, APIGEE_ORG, APIGEE_ENV, API_BASE_URL) and secret (API_KEY). The production job pauses at the production environment until a reviewer approves. --wait matters: without it the integration tests can run against the previous revision while the new one is still rolling out. The rollback step runs only when something before it failed and a previous revision was found, and it redeploys that revision, which the Apigee documentation names as the recovery path.

    Two assumptions to check against your setup. This example promotes by revision number, which only works when dev and production environments are in the same Apigee organisation. If production is a separate organisation, the revision number is not portable: download the same zip artifact in the production job and import it there, which produces a new number but identical content, and the checksum file lets you prove it. Also pin tool versions (the action majors and the apigeecli release) in your own repository and check each project's README for the current major version before you copy this workflow.

    Use cases

    1. A team promoting through dev, test and prod

    Every pull request runs verify and build. A merge deploys to dev automatically and runs the integration suite. Add a test job between dev and production for slower suites (performance checks, consumer-driven contract tests) using the same pattern and its own GitHub environment. Production waits for a named reviewer. The result is that "what is in prod" always maps to a commit and a build, and the revision that reaches prod is the one that passed every earlier gate.

    2. A hotfix path

    A production defect cannot wait behind unrelated work on main. Branch from the commit that is live, apply the minimal fix, and run the same pipeline with the same gates, since lint, unit tests and the dev integration run take minutes, not hours. Keep the approval gate but let the reviewer decide quickly. Merge the fix back to main straight away so the next normal release does not reintroduce the bug. If the fix itself misbehaves, you do not debug under pressure: redeploy the previous revision (the pipeline's rollback step, or gcloud apigee apis deploy 11 --api=orders-v1 --environment=prod --organization=$ORG --override by hand) and continue the investigation calmly.

    3. Rolling out a shared-flow update safely

    A shared flow is called by many proxies, so one change can affect all of them. Apigee documents that a shared flow must be deployed to the same environment as the proxies that call it, and that the shared flow should be deployed before the consuming proxy. A FlowCallout to a shared flow that is missing or not deployed fails with flow.SharedFlowNotFound at runtime. So deploy order matters: the shared flow first, then proxies that need the new behaviour. Beyond that, a safe routine is:

    1. Make the change backward compatible: add behaviour behind a flow variable the old callers do not set, and do not rename variables or change error formats that proxies rely on.
    2. Lint and unit test the shared flow, then import and deploy it to dev (apigeecli sharedflows create bundle and sharedflows deploy have --ovr and --wait like their proxy equivalents).
    3. Run the integration suites of every proxy that calls it, not only one. A matrix job over the proxy list does this.
    4. Promote to test, then prod, recording the previous shared flow revision for rollback.

    The Apigee documentation sources reviewed for this lesson do not describe how an already deployed proxy picks up a newly deployed shared flow revision, so confirm that behaviour in a lower environment before depending on it.

    The promotion sequence

    The pipeline reads as a short conversation between a developer, GitHub, Google Cloud and the two Apigee environments. The point to notice is where the human decision sits and where the previous revision is kept.

    sequenceDiagram participant Dev as Developer participant CI as GitHub Actions participant IAM as Google Cloud IAM participant AD as Apigee dev participant Rev as Reviewer participant AP as Apigee prod Dev->>CI: Merge pull request to main CI->>CI: Lint, unit tests, zip the bundle once CI->>IAM: Present the OIDC token IAM-->>CI: Short-lived access token CI->>AD: Import zip as revision N, deploy N, wait CI->>AD: Run integration tests CI->>Rev: Ask for approval of the production environment Rev-->>CI: Approve CI->>AP: Record the current revision P CI->>AP: Deploy revision N, wait CI->>AP: Run smoke tests alt Smoke tests fail CI->>AP: Redeploy revision P end

    Because the same revision N is deployed in both places, a green dev run is real evidence about production. The recorded revision P is what makes the rollback a single command rather than an investigation.

    Per-environment configuration

    What differs Where it lives How it is delivered
    Backend hostnames and ports Target servers, one per environment Terraform (google_apigee_target_server)
    Secrets and feature switches Key value maps in the environment Terraform creates the map; values come from a secret store, never git
    Quotas and limits per consumer API products (and the quota settings the policies read from them) Product configuration, reviewed like code
    Identity the proxy runs as Service account at deployment time -s in apigeecli, service_account in Terraform
    Base URL, test keys Pipeline variables and secrets GitHub environment variables and secrets

    The bundle holds only names; the environment holds values. That one rule is what makes a promoted revision the same artifact.

    Versioning and release notes

    Three different numbers exist, and mixing them up causes confusion.

    • API version is the contract callers see, usually the major version in the base path (/orders/v1). Change it only for breaking changes, and ship a second proxy (orders-v2) next to the first rather than editing v1 incompatibly.
    • Revision is Apigee's counter for a proxy's saved snapshots. It belongs to the organisation, is immutable after deployment, and says nothing about meaning.
    • Release version is your own git tag (for example orders-1.8.0), which names a commit and a pipeline run.

    Record the mapping in the pipeline: the step summary above writes "revision 12 from commit abc123", and a tagged release can carry the notes: gh release create orders-1.8.0 --generate-notes produces release notes from the merged pull requests. Write pull request titles so they read as notes ("Return 429 with Retry-After for token quota"), and the release notes become a by-product.

    In practice: moving the Orders API from console edits to a pipeline

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation. A team of six owns an Orders API on Apigee X. Proxies are edited in the console in dev, exported by hand, and imported into prod on release days. Dev and prod have drifted: a spike arrest value was changed in prod during an incident and never copied back. A release takes about half a day (assumed), and nobody can say with certainty which revision of the shared token-verification flow production is using.

    Starting architecture: console edits and manual export, with no gate between dev and prodDeveloper to Apigee dev (console); Apigee dev to Exported zip (export); Exported zip to Apigee prod (manual import)Developer: edits in the consoleDeveloperedits in theconsoleApigee dev: edited in placeApigee devedited in placeExported zip: on a laptopExported zipon a laptopApigee prod: imported by hand, driftedApigee prodimported by hand,driftedconsoleexportmanual import

    What changes.

    1. Export the current bundles and shared flows from production (the apigeecli apis export and sharedflows export commands exist, as does apigeelint -d to download a bundle) and commit them. Production is the baseline because it is what customers run.
    2. Add the .apigeelintrc and fix what lint reports. Exclude noisy rules explicitly, in review, instead of turning the linter off.
    3. Move target hostnames out of policy XML into target servers, and create them per environment with Terraform. The bundle now carries names only.
    4. Add unit tests for the two JavaScript callouts and an integration collection that covers the unhappy paths: missing key, over quota, bad payload.
    5. Create the GitHub environments dev and production, the workload identity pool with an attribute condition, and one deploy service account per environment. Delete the exported-zip step and any stored key.
    6. Turn off console deploys for the production environment by removing direct write roles from people and leaving them to the deploy service account.

    Result. A change reaches production only through a pull request, a green pipeline and a named approval. The revision in production can be traced to a commit. The incident-time spike arrest change now happens as a pull request, is reviewed, and cannot silently diverge from dev. Release effort drops from hours of manual steps to waiting for an approval click: the team spends its time on the change itself rather than on moving it (qualitative, and the exact saving depends on the team).

    Architect's notes

    • Do not overbuild the first version. Lint, a unit test or two, one integration suite and an approval gate cover most of the risk. Add a performance stage and Terraform for proxies only when a real need appears.
    • Environment-specific bundles are the main way pipelines rot. The moment a per-environment build exists, tests in dev stop predicting production. Push every difference into environment configuration, even when that takes more up-front work.
    • Treat shared flows as libraries. A shared flow is shared code with a blast radius. Version it, test it with all its callers, and be slow to change its interface.
    • Integration tests need a stable dev backend. If the dev backend is flaky, the pipeline becomes flaky and people learn to re-run it. Stub the backend with a target server pointing at a mock when you can.
    • Cost and access. Every pipeline identity is a way into production. Prefer federation to keys, scope service accounts per environment, and keep the production approval a human decision, not a formality.
    • Lint is advice until you gate on it. If --maxWarnings is never set, warnings pile up unseen. Start strict on new code and ratchet older code.

    Common mistakes

    • Rebuilding the bundle in each environment. The artifact in prod then differs from the one tested. Build once and promote the artifact or the revision.
    • A lint step that cannot fail. Check the exit code with a deliberately broken bundle, and set the profile (--profile apigeex) and --maxWarnings on purpose.
    • Long-lived service account keys in CI secrets. Use workload identity federation, add the attribute condition, and keep one service account per environment.
    • Not waiting for the deployment to finish. Without --wait, tests may hit the old revision and pass. Always wait before testing.
    • Forgetting --ovr and hitting a conflict. A new revision does not replace the deployed one unless you override; the pipeline then fails or, worse, nothing changes.
    • Secrets or hostnames in the repository. Put names in the bundle, values in the environment.
    • Promoting by revision number across organisations. The number is not portable. Promote the artifact and verify its checksum.
    • Changing a shared flow and testing only one proxy. Run the suites of every caller before promoting.
    • No rollback rehearsal. Practise redeploying the previous revision in dev, so the first time is not during an incident.

    Key takeaways

    • A proxy bundle is a build artifact: built once, immutable once deployed, promoted unchanged through environments.
    • Keep proxies, shared flows, tests and pipeline in git; keep environment values (target servers, key value maps) in the environment, delivered separately.
    • Lint with apigeelint (--profile apigeex), unit test JavaScript callouts, then run integration and contract tests against the deployed dev revision; keep production smoke tests read-only.
    • Choose the tool by job: Terraform for the platform, apigeecli or the Maven plugin for proxy revisions, gcloud for a manual redeploy.
    • Authenticate with workload identity federation and an attribute condition; give each environment its own service account.
    • Use --wait and --ovr deliberately, and record the previous revision before every production deploy so rollback is one command.
    • Treat shared flow changes as library releases: backward compatible, tested against every caller.

    Practice exercises

    Starter (about 30 minutes). Put one proxy bundle in a git repository and run npx apigeelint -s <path> --profile apigeex -f stylish.js on it. Fix two findings and exclude one rule with a .apigeelintrc, writing down why. Then add a deliberately broken policy on a branch and confirm the command exits non-zero with your chosen --maxWarnings.

    Intermediate (about 2 hours). Write a JavaScript callout that normalises one header, and the Mocha test from this lesson with at least three cases. Add an apickli feature or a Newman collection with one happy path and two failure paths, run it against a dev environment, and add --bail and a JUnit report. Wire lint, unit tests and zip creation into a GitHub Actions workflow that runs on pull requests.

    Advanced (half a day). Build the full pipeline: workload identity federation with an attribute condition, a deploy service account per environment, deploy-dev with integration tests, and a production environment with required reviewers. Add the rollback step and rehearse it by making the smoke test fail on purpose. Then add a shared-flow job that runs the integration suites of two proxies that call it, and Terraform for a target server and a key value map in two environments.

    Official documentation

    Enroll to keep reading

    Lesson 9 of 11 · Apigee API Management: Beginner to Advanced

    Treat Apigee proxies as code: lint with apigeelint, test, build one bundle, deploy with apigeecli, Maven or Terraform, and promote with approval.

    In the rest of this lesson

    • Stage 4: build the bundle once
    • Deployment tooling: four options and when to choose each
    • Authenticating CI to Google Cloud without long-lived keys
    • The complete pipeline
    • Use cases
    • The promotion sequence

    Enrolling is free. It unlocks every lesson in Apigee API Management: Beginner to Advanced, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments