Dieser Post ist Teil 3 meiner Serie „Source Management in SFDX“. In den ersten beiden Teilen habe ich die Anforderungen analysiert, ein Konzept vorgestellt und ein Template entwickelt. In diesem Teil zeige ich, wie ich mit SFDX eine Continuous Integration (CI) Pipeline mit CircleCI aufsetze und das Deployment mit 2nd Generation Packages bis Produktiv voll automatisiere.

Die Pipeline in CircleCI besteht aus drei Jobs, die jeweils um zusätzliche Steps (z.B. Deployments von unpackaged Source, Jest Tests, Linting, Statische Code Analyse) erweitert werden können:

  1. Scratch Org erstellen, Abhängigkeiten des Projekts installieren, Source Pushen, Package Tests durchführen
  2. Beta Package Version erstellen, auf einer Staging Umgebung installieren und vollen Regressions Testlauf auf Staging durchführen (dieser Job wird sinnvoller Weise nur nach manuellem Approval durchgeführt)
  3. Production Package Version erstellen, Promoten, auf Staging & Production installieren und vollen Regressions Testlauf auf Production durchführen (dieser Job wird sinnvoller Weise nur nach manuellem Approval durchgeführt)
Kompletter CircleCI Workflow mit Approvals von Scratch Org Tests bis Production Install.
Kompletter CircleCI Workflow von Scratch Org bis Produktion.

CircleCI für SFDX Projekte

Um eine vollwertige CI Pipeline für SFDX aufzubauen, muss man zuerst die elementaren Phasen des Entwicklungsprozesses einer 2nd Generation Package Version verstehen. Egal wie komplex die Anwendung ist, im Wesentlichen durchläuft jede Package Version die folgenden drei Phasen:

  1. Lokale Entwicklung mit fixen Abhängigkeiten auf einer Scratch Org.
  2. Integrations Test der Package Version mit den tatsächlichen Abhängigkeiten auf einer Integrations-Test Umgebung
  3. Finales Packaging und Release auf eine Produktiv Umgebung

Jede dieser Phasen stellt unterschiedliche Anforderungen an eine CI Pipeline und wird deshalb in einem eigenen Job implementiert. Alle Jobs teilen sich den selben Setup Code:

  1. Installation der CLI über den offiziellen Orb von CircleCI: circleci/[email protected].
  2. Autorisierung der CLI mit dem DevHub (Production) und allen Staging oder Integration Umgebungen (Sandboxes).
  3. Installation von NPM (falls es auf dem verwendeten Image nicht schon vorinstalliert ist) und installation der node.js Abhängigkeiten.

Jobs

Ein Job in CircleCI ist eine wiederverwendbare Serie von Einzelschritten (Steps). Sie werden immer in ihrem eigenen Container ausgeführt, das bedeutet, dass jeder Job die CLI einrichten und autorisieren muss. Ich implementiere für jede Phase einen eigenen Job und orchestriere diese über einen Workflow.

Eine komplette Implementierung, die mit wenigen Anpassungen lauffähig ist, habe ich in meinem Template Repository für den 1-1-1 Setup bereitgestellt: https://github.com/j-schreiber/1-1-1-setup-template

Scratch Org Test

Die erste und simpelste Phase dieser Pipeline. Dieser Job kann fast problemlos parallelisiert werden, da wir ausschließlich mit Scratch Orgs arbeiten (das einzige relevante Limit sind 40 gleichzeitig aktive Scratch Orgs, auf die die meisten DevHubs limitiert sind).

  1. Run Org Setup: Erstellt eine Scratch Org mit dem Setup Script dieses Repos. Warum? Damit gewährleiste ich, dass das SFDX Projekt als Ganzes immer funktioniert. Da der Setup in jedem Projekt individuell ist, können wir das config.yml ohne große Anpassungen für jedes Projekt wiederverwenden. Das Setup Script führt in der Regel folgende Schritte aus:
    1. Erstellen der Scratch Org mit der scratch-org-def.json des Projekts.
    2. Installation der Dependencies des Projekts – Die Installation Keys werden als Environment Variable (dazu später mehr) gespeichert, während die Subscriber Package Version Ids in der sfdx-project.json gepflegt werden.
    3. Push des Sources dieses Packages auf die Scratch Org (sfdx force:source:push)
    4. Zuweisen von Berechtigungssätzen an den Admin User, die für die Entwicklung benötigt werden
    5. Import der Testdaten über sfdx force:data:tree:import
    6. Individuelle weitere Schritte, welche für die Entwicklung sinnvoll sind wie z.B. Apex-Skripte für erweiterten Test Daten Setup (Preisbucheinträge, Community User aktivieren, usw) oder Deployment von Unpackaged Source.
  2. Run Tests (Scratch Org): Führt einen Apex Test Run auf der Scratch Org durch. Dieser Run testet nur das Package mit seinen Abhängigkeiten. Das sind nicht zwangsläufig auch die tatsächlich installierten Abhängigkeiten auf Integration oder Production.
  3. Optional weitere Tests wie Linting (z.B. mit dem sfdx-scanner plugin) oder Jest Tests durchführen, falls das Projekt LWC’s hat.
  4. Clean Scratch Org und und Clean Duplicate Test Results (ein Fehler in sfdx force:apex:test:run erzeugt immer zwei Dateien, was bei CircleCI dazu führt, dass alle Tests doppelt hochgeladen und gezählt werden)

Beispiel config.yml für den scratch_org_test Job:

jobs:
scratch_org_test:
executor:
name: sfdx-default
steps:
- checkout
- sfdx/install
- run:
name: Setup SFDX CLI
command: |
echo $SFDX_JWT_KEY | base64 --decode --ignore-garbage > ./server.key
sfdx auth:jwt:grant --clientid $SFDX_CONSUMER_KEY --jwtkeyfile server.key --username $USERNAME_PRODUCTION
mkdir -p force-app
- run:
name: Run Org Setup
command: |
bash scripts/setup/macOS.sh -a CircleCi -v $USERNAME_PRODUCTION
- run:
name: Run Tests (Scratch Org)
command: |
mkdir -p ~/test-results/apex
export SFDX_IMPROVED_CODE_COVERAGE="true"
sfdx force:apex:test:run -w 10 -r junit -d ~/test-results/apex -c
- run:
name: Clean Scratch Org
command: |
sfdx force:org:delete -u CircleCi --noprompt
when: always
- run:
name: Clean Duplicate Test Results
command: |
rm -f ~/test-results/apex/test-result.xml
when: always
- store_test_results:
path: ~/test-results

Und hier ein Beispiel dieser Config in einem produktiven Lauf:

Beispielhafter Durchlauf des Scratch Org Test Jobs in CircleCI

Build And Install Staging (Integrations Test)

Die zweite Phase ist etwas komplizierter, da sich ab hier einzelne Entwickler unter Umständen in die Quere kommen könnten: Auf einer Sandbox kann immer nur ein Test-Lauf gleichzeitig durchgeführt werden. Läuft also gerade eine Pipeline, sind alle anderen Pipelines blockiert und scheitern. Deshalb sollte der zweite Job nicht automatisch durchgeführt werden, sondern nur nach manueller Freigabe von z.B. einem Release Manager bzw. in Absprache mit anderen Entwicklern.

  1. Build Beta Package: Erstellen einer Beta-Package Version ohne Validierung. Der Installation Key ist als Projekt Environment Variable in CircleCI gespeichert.
  2. Install Package (Staging): Installation des Packages auf einer Staging oder Integrations Umgebung.
  3. Deploy Source (Staging): Deploy von zusätzlichem Source der nicht Teil des Packages aber Teil des Projekts ist (z.B. Workflow Email Alerts mit organisationsweiten Adressen oder Übersetzungen)
  4. Run Tests (Staging): Testlauf mit den tatsächlich installierten Abhängigkeiten und weiterem Source (Unpackaged Metadata, weitere Packages, etc) auf der Staging Umgebung.
  5. Clean Duplicate Test Results: Aufräumen der doppelt erzeugten Testergebnisse.

Beispiel config.yml für den build_and_install_staging job:

jobs:
build_and_install_staging:
executor:
name: sfdx-default
steps:
- checkout
- sfdx/install
- run:
name: Setup SFDX CLI
command: |
echo $SFDX_JWT_KEY | base64 --decode --ignore-garbage > ./server.key
sfdx auth:jwt:grant --clientid $SFDX_CONSUMER_KEY --jwtkeyfile server.key --username $USERNAME_PRODUCTION
sfdx auth:jwt:grant --clientid $SFDX_CONSUMER_KEY_STAGING --jwtkeyfile server.key --username $USERNAME_STAGING --instanceurl https://test.salesforce.com
mkdir -p force-app
- run:
name: Build Beta Package
command: |
sfdx force:package:version:create -p $PACKAGE_ID -v $USERNAME_PRODUCTION -w 60 -k $INSTALLATION_KEY --skipvalidation
- run:
name: Install Package (Staging)
command: |
query=$(sfdx force:data:soql:query -t -q "SELECT SubscriberpackageVersionId FROM Package2Version WHERE Package2Id = '$PACKAGE_ID' ORDER BY CreatedDate DESC LIMIT 1" -u $USERNAME_PRODUCTION)
SUBSCRIBER_PACKAGE_VERSION_ID=$(echo $query | grep -o '04t[a-zA-Z0-9]*')
echo "Installing latest package version: $SUBSCRIBER_PACKAGE_VERSION_ID ..."
sfdx force:package:install -w 10 -b 10 -u $USERNAME_STAGING -p $SUBSCRIBER_PACKAGE_VERSION_ID -k $INSTALLATION_KEY -r
- run:
name: Run Tests (Staging)
command: |
mkdir -p ~/test-results/apex
export SFDX_IMPROVED_CODE_COVERAGE="true"
sfdx force:apex:test:run -w 10 -u $USERNAME_STAGING -r junit -d ~/test-results/apex -c
- run:
name: Clean Duplicate Test Results
command: |
rm -f ~/test-results/apex/test-result.xml
when: always
- store_test_results:
path: ~/test-results
Beispielhafter Durchlauf eines Integrations Tests Jobs in CircleCI

Build And Install Production

Der Production Job ist definitiv der komplizierteste. Zusätzlich zu den Besonderheiten auf Staging ist der Job nicht mehr idempotent: Sobald eine Package Version einmal promoted wurde, kann kein weiterer Build dieser Package Version mehr promoted werden. Sie sollte deshalb ebenfalls nur nach manuellem Approval und Absprache aller Entwickler ausgeführt werden. Mein Workflow ist so konfiguriert, dass nur Pull Requests von einem version/ Branch den Production Job starten.

  1. Build Production Package: Erzeugen einer Package Version mit Validierung und Test Coverage. Dieser Schritt dauert je nach Größe des Packages 5 bis 15 Minuten. Die Wahrscheinlichkeit, dass der Package Build scheitert, ist vergleichsweise gering aufgrund der Validierungen in den Jobs davor.
  2. Install Package (Staging): Installation des (validierten) Builds auf Staging. Der Build sollte sich nicht vom Build aus dem Staging Job unterscheiden.
  3. Deploy Source (Staging): Deployment von unpackaged Source des Projekts auf Staging. Bei diesem Deployment aktiviere ich zusätzlich den Flag -l RunLocalTests. Das simuliert ein Deployment auf Produktiv und stellt sicher, dass die Pipeline frühzeitig failed, falls es Probleme gibt.
  4. Promote Package: Ab diesem Schritt gibt es kein Zurück mehr: Ist der Package Build bereit für Production, wird er promoted.
  5. Install Package (Production): Installation des (validierten und promoteten) Builds auf Production.
  6. Deploy Source (Production): Deployment von unpackaged Source des Projekts auf Production. Hier muss der Flag -l RunLocalTests aktiviert sein.
  7. Run Tests (Production): Vollständiger Testlauf mit allen dort installierten Abhängigkeiten auf Produktiv.
  8. Clean Duplicate Test Results: Wie immer müssen doppelt erstellte Test Results gelöscht werden.

Beispiel config.yml für den build_and_install_production job

jobs:
build_and_install_production:
executor:
name: sfdx-default
steps:
- checkout
- sfdx/install
- run:
name: Setup SFDX CLI
command: |
echo $SFDX_JWT_KEY | base64 --decode --ignore-garbage > ./server.key
sfdx auth:jwt:grant --clientid $SFDX_CONSUMER_KEY --jwtkeyfile server.key --username $USERNAME_PRODUCTION
sfdx auth:jwt:grant --clientid $SFDX_CONSUMER_KEY_STAGING --jwtkeyfile server.key --username $USERNAME_STAGING --instanceurl https://test.salesforce.com
mkdir -p force-app
- run:
name: Build Production Package
command: |
sfdx force:package:version:create -p $PACKAGE_ID -v $USERNAME_PRODUCTION -w 60 -k $INSTALLATION_KEY -c
- run:
name: Promote Latest Build
command: |
query=$(sfdx force:data:soql:query -t -q "SELECT SubscriberpackageVersionId FROM Package2Version WHERE Package2Id = '$PACKAGE_ID' ORDER BY CreatedDate DESC LIMIT 1" -u $USERNAME_PRODUCTION)
SUBSCRIBER_PACKAGE_VERSION_ID=$(echo $query | grep -o '04t[a-zA-Z0-9]*')
echo "Promoting latest package version: $SUBSCRIBER_PACKAGE_VERSION_ID ..."
sfdx force:package:version:promote -v $USERNAME_PRODUCTION -p $SUBSCRIBER_PACKAGE_VERSION_ID -n
- run:
name: Install Package (Staging)
command: |
query=$(sfdx force:data:soql:query -t -q "SELECT SubscriberpackageVersionId FROM Package2Version WHERE Package2Id = '$PACKAGE_ID' ORDER BY CreatedDate DESC LIMIT 1" -u $USERNAME_PRODUCTION)
SUBSCRIBER_PACKAGE_VERSION_ID=$(echo $query | grep -o '04t[a-zA-Z0-9]*')
echo "Installing latest package version: $SUBSCRIBER_PACKAGE_VERSION_ID ..."
sfdx force:package:install -w 10 -b 10 -u $USERNAME_STAGING -p $SUBSCRIBER_PACKAGE_VERSION_ID -k $INSTALLATION_KEY -r
- run:
name: Install Package (Production)
command: |
query=$(sfdx force:data:soql:query -t -q "SELECT SubscriberpackageVersionId FROM Package2Version WHERE Package2Id = '$PACKAGE_ID' ORDER BY CreatedDate DESC LIMIT 1" -u $USERNAME_PRODUCTION)
SUBSCRIBER_PACKAGE_VERSION_ID=$(echo $query | grep -o '04t[a-zA-Z0-9]*')
echo "Installing latest package version: $SUBSCRIBER_PACKAGE_VERSION_ID ..."
sfdx force:package:install -w 10 -b 10 -u $USERNAME_PRODUCTION -p $SUBSCRIBER_PACKAGE_VERSION_ID -k $INSTALLATION_KEY -r
- run:
name: Run Tests (Production)
command: |
mkdir -p ~/test-results/apex
export SFDX_IMPROVED_CODE_COVERAGE="true"
sfdx force:apex:test:run -w 10 -u $USERNAME_PRODUCTION -r junit -d ~/test-results/apex -c
- run:
name: Clean Duplicate Test Results
command: |
rm -f ~/test-results/apex/test-result.xml
when: always
- store_test_results:
path: ~/test-results

Beispiel-Screenshot eines vollständigen Laufs. Der Job deployed zusätzlich noch den Source einer Community und veröffentlicht alle Änderungen der Community. Im konkreten Beispiel ist ein Test fehlgeschlagen. Da der Step jedoch mit set +e ausgeführt wurde, läuft die Pipeline weiter.

Putting it all together … der Workflow

Da nicht alle Jobs unter allen Bedingungen durchgeführt werden sollen, orchestriere ich sie mit dem Workflow. Hier haben wir die Möglichkeit, einzelne Schritte nur bei Bedarf auszuführen (falls man sich z.B. vorbehalten möchte, nur nach manueller Prüfung auf Produktiv zu deployen …).

workflows:
package_build:
jobs:
- scratch_org_test:
context:
- salesforce
- approve_staging:
type: approval
requires:
- scratch_org_test
filters:
branches:
only:
- /^version/.*/
- /^feature/.*/
- build_and_install_staging:
context:
- salesforce
requires:
- approve_staging
filters:
branches:
only:
- /^version/.*/
- /^feature/.*/
- approve_production:
type: approval
requires:
- build_and_install_staging
filters:
branches:
only:
- /^version/.*/
- build_and_install_production:
context:
- salesforce
requires:
- approve_production
filters:
branches:
only:
- /^version/.*/
view raw workflow.yml hosted with ❤ by GitHub

Wir verwenden bei TMH ein feature/ und version/ Branching Model. Feature branches werden in Version Branches gemerged (mit Pull Requests) und Version Branches in Master.

  • Feature Branches durchlaufen nur die Jobs scratch_org_test und build_and_install_staging aus.
  • Version Branches durchlaufen zusätzlich noch den Job build_and_install_production.

CircleCI für SFDX konfigurieren

Damit CircleCI bei jedem Build die SFDX CLI sicher mit dem DevHub und allen Sandboxes autorisieren kann, sind einige spezifischen Konfigurationen notwendig.

Context Variables

In Context Variablen organisieren wir Variablen, die alle Projekte verwenden. Die Projekte müssen im Workflow mit context: - salesforce den jeweiligen Context angeben, damit sie mit $VARIABLE_NAME verfügbar sind:

  • SFDX_CONSUMER_KEY: Der Consumer Key der Connected App für den DevHub
  • SFDX_CONSUMER_KEY_STAGING: Der Consumer Key der Connected App für Staging Sandbox
  • SFDX_JWT_KEY: Base64 encoded Zertifikat für die Connected App.
  • USERNAME_PRODUCTION: Username für die Autorisierung auf Production. Der User muss pre-approved sein.
  • USERNAME_STAGING: Username für die Autorisierung auf Staging. Der User muss pre-approved sein.

Project Environment Variables

In Project Environment Variables organisieren wir die lokalen Variablen für ein Projekt. Diese sind in jedem CircleCI Job mit $VARIABLE_NAME immer verfügbar.

  • INSTALLATION_KEY und PACKAGE_ID (0Ho) des Projekts (für Erstellung einer Package Version)
  • Installation Keys der Abhängigkeiten. Theoretisch kann man alle Installation Keys auch im Context speichern, muss dann aber für jedes Projekt die config.yml individualisieren.

Advanced Settings

Unter Project Settings > Advanced gibt es für SFDX zwei relevante Einstellungen:

  • Only Build Pull Requests: Macht Sinn, da der komplexe Workflow in der Regel 20-30 Minuten läuft. Dieses Setting hilft vor allem, die parallel laufenden Pipelines einzuschränken.
  • Auto-cancel Redundant Builds: Macht nur unter bestimmten Voraussetzungen Sinn, zum Beispiel wenn nur wenige Entwickler gleichzeitig auf einem Package arbeiten und nicht zu viele Pull-Requests für das selbe Projekt gleichzeitig offen sind.

Zusammenfassung

Wir verwenden bei TMH für jedes SFDX Projekt das 1-1-1 Template und haben den kompletten Build mit CircleCI automatisiert. Durch diesen Setup ist gewährleistet, dass wir die Salesforce Entwicklung in alle Richtungen skalieren können: Externe Entwickler brauchen nur Zugriff auf die Github Repositories mit denen sie arbeiten, aber keine Zugänge für Produktiv oder Staging. Onboarding neuer Entwickler und Einrichtung der Arbeitsumgebung dauert bei erfahrenen Entwicklern weniger als eine Stunde. Ein Entwickler arbeitet lokal auf seinem Feature Branch und kann mit einem Pull Request praktisch vollautomatisch eine Package Version für den UAT erzeugen und auf Staging deployen. Kein Entwickler kann mehr „vergessen“, einen vollständigen Regressionstest auszuführen.

Ich bin bei TMH der einzige Technical Architect und da her natürlicherweise der Flaschenhals. Um so besser, dass ich meine Zeit nicht mit dem Lösen von Merge Konflikten und repetitive Deployment Prozessen vergeuden muss, sondern mich inhaltlich auf den Code Review konzentrieren kann.

Weiterführende Links