Introduction
SAP Datasphere makes it easy to build tables, views, analytic models, and task chains directly in the browser — but that same ease creates a gap once a team grows past one or two people. Changes made directly in a space have no review trail, no approval gate, and the existing process for promoting a change from Development to QA to Production is not in line with the modern architecture Datasphere is otherwise built on.
This blog walks through a working CI/CD pipeline that treats Datasphere object definitions as code: exported into Git, reviewed through a Pull Request, deployed to QA behind an approval gate, automatically verified, and finally promoted to Production behind a second approval — with a rollback snapshot taken automatically before every Production change.
Figure 1: High-level architecture — Datasphere → GitHub → QA Space → Production Space
Business Scenario
Consider a team where two developers, User 1 and User 2, both work against the same Datasphere Development space. Without a formal process:
- There's no record of why a column was added or a view was changed.
- A broken object can reach Production with no warning and no easy way back.
- Two developers editing related objects can silently overwrite each other's work.
The pipeline in this post solves this by making Git the source of truth for object definitions, and GitHub's Pull Request and Environment features the enforcement mechanism for review and approval.
Architecture Overview
The pipeline is a single GitHub Actions workflow file with two independent paths:
- Manual dispatch — exports a chosen object from a chosen Datasphere space, commits it as JSON, and opens a Pull Request.
- Push to main (i.e., a PR gets merged) — resolves what changed, deploys to QA behind an approval gate, runs an automated verification, plans the Production change, and deploys to Production behind a second approval gate.
name: Datasphere CI/CD - Export -> PR -> QA -> Test -> PROD
# ---------------------------------------------------------------------------
# ONE file, TWO paths, gated by which event fired:
#
# workflow_dispatch : export object(s) from a source space, commit to a
# branch, open a PR against main. Run ends here -
# review/edit the JSON in the PR, then merge it.
#
# push to main : fires when that PR (or any objects/** change) is
# merged. Resolves the changed objects and promotes:
# QA (approval) -> test -> plan -> PROD (approval).
#
# Nothing reaches QA or PROD without a human merging a PR first, and nothing
# reaches PROD without two separate environment approvals.
# ---------------------------------------------------------------------------
on:
workflow_dispatch:
inputs:
source_space:
description: "Source space ID to export from (usually DEV)"
required: true
object_type:
description: "Object type"
required: true
type: choice
default: "local-tables"
options:
- local-tables
- views
- analytic-models
- data-flows
- transformation-flows
- replication-flows
- data-access-controls
- task-chains
- intelligent-lookups
technical_name:
description: "Technical name, or comma-separated list"
required: true
push:
branches: [main]
paths:
- "objects/**"
permissions:
contents: write
pull-requests: write
concurrency:
group: datasphere-cicd-${{ github.ref }}
cancel-in-progress: false
env:
DSP_HOST: ${{ vars.DSP_HOST }}
jobs:
# =========================================================================
# PATH A - manual dispatch only: export and raise a PR. Run ends here.
# =========================================================================
export_and_pr:
name: A1. Export -> open PR
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SPACE: ${{ inputs.source_space }}
TYPE: ${{ inputs.object_type }}
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g @SAP/datasphere-cli
- name: Log in to Datasphere
env:
DSP_CLIENT_ID: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_ID }}
DSP_CLIENT_SECRET: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_SECRET }}
DSP_AUTH_URL: ${{ secrets.DSP_AUTH_URL }}
DSP_TOKEN_URL: ${{ secrets.DSP_TOKEN_URL }}
run: |
set +e
datasphere login --host "$DSP_HOST" \
--client-id "$DSP_CLIENT_ID" --client-secret "$DSP_CLIENT_SECRET" \
--authorization-url "$DSP_AUTH_URL" --token-url "$DSP_TOKEN_URL" \
--authorization-flow client_credentials
LOGIN_EXIT=$?
set -e
if [ $LOGIN_EXIT -ne 0 ]; then
echo "::error::datasphere login exited $LOGIN_EXIT - check DSP_HOST, DSP_AUTH_URL, DSP_TOKEN_URL, and that the OAuth client uses the client_credentials flow."
exit 1
fi
echo "Login succeeded."
- name: List objects of this type in the space (pre-flight check)
run: |
echo "Objects of type '$TYPE' visible in space '$SPACE':"
set +e
datasphere objects "$TYPE" list --host "$DSP_HOST" --space "$SPACE" > /tmp/list.json 2> /tmp/list_err.log
LIST_EXIT=$?
set -e
if [ $LIST_EXIT -ne 0 ]; then
echo "::error::Could not list $TYPE objects in space '$SPACE'. This usually means the space ID is wrong or the technical user lacks access to it. stderr:"
cat /tmp/list_err.log
exit 1
fi
cat /tmp/list.json
echo "--- If your target name isn't spelled exactly the same (case-sensitive) above, that's the cause of the next step failing. ---"
- name: Export each requested object
run: |
mkdir -p "objects/$TYPE" objects/.provenance
IFS=',' read -ra NAMES <<< "${{ inputs.technical_name }}"
for RAW in "${NAMES[@]}"; do
NAME=$(echo "$RAW" | xargs)
[ -z "$NAME" ] && continue
FILE="objects/$TYPE/$NAME.json"
if ! grep -qi "\"$NAME\"" /tmp/list.json; then
echo "::warning::'$NAME' was not found (case-insensitive match) in the object list printed above. Proceeding anyway in case the list output format differs, but this will likely fail."
fi
set +e
datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > /tmp/cli_out.log 2> /tmp/cli_err.log
CLI_EXIT=$?
set -e
cp /tmp/cli_out.log "$FILE"
if [ $CLI_EXIT -ne 0 ]; then
echo "::error::CLI exited $CLI_EXIT reading $TYPE/$NAME."
echo "--- stdout ---"
cat /tmp/cli_out.log
echo "--- stderr ---"
cat /tmp/cli_err.log
exit 1
fi
if [ ! -s "$FILE" ]; then
echo "::error::$FILE is empty for $TYPE/$NAME in space $SPACE. Check the technical name is exact (case-sensitive) and that the object is exposed for consumption."
echo "--- stderr, if any ---"
cat /tmp/cli_err.log
exit 1
fi
if ! jq empty "$FILE" 2>/dev/null; then
echo "::error::$FILE is not valid JSON - the CLI likely wrote an error message instead of the object. Contents:"
cat "$FILE"
exit 1
fi
jq --sort-keys . "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
jq -n --arg space "$SPACE" --arg type "$TYPE" --arg name "$NAME" \
--arg run "${{ github.run_id }}" --arg actor "${{ github.actor }}" \
--arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{source_space:$space,object_type:$type,technical_name:$name,run_id:$run,exported_by:$actor,exported_at:$at}' \
> "objects/.provenance/${TYPE}__${NAME}.json"
echo "Exported $TYPE/$NAME ($(wc -c < "$FILE") bytes)"
done
- name: Stop if nothing changed
id: diff
run: |
git add objects
if git diff --cached --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Export matches what is already in Git - no PR needed." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Commit to a branch and open PR
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SLUG=$(echo "${{ inputs.technical_name }}" | tr -c 'a-zA-Z0-9' '-' | tr -s '-' | sed 's/-$//')
BRANCH="export/${TYPE}-${SLUG}-${{ github.run_id }}"
git config user.name "datasphere-ci-bot"
git config user.email "[email protected]"
git checkout -b "$BRANCH"
git commit -m "Export ${{ inputs.technical_name }} ($TYPE) from $SPACE"
git push origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "Export ${{ inputs.technical_name }} ($TYPE) from $SPACE" \
--body "Exported by @${{ github.actor }} from \`$SPACE\`.
Review and edit the JSON as needed. **Merging this PR triggers QA deployment**,
followed by a test pass, a PROD change plan, then PROD deployment - each
promotion step needs its own environment approval."
- name: Summary when nothing to export
if: steps.diff.outputs.changed == 'false'
run: echo "Nothing to do - no PR opened."
# =========================================================================
# PATH B - triggered by push to main (i.e. the PR above got merged, or
# anyone else edited objects/** directly and merged). Resolves + promotes.
# =========================================================================
resolve:
name: B1. Resolve merged objects
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
count: ${{ steps.detect.outputs.count }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build ordered matrix of changed objects
id: detect
env:
RANKS: >-
{"data-access-controls":0,"local-tables":1,"views":2,
"intelligent-lookups":3,"analytic-models":4,"data-flows":5,
"transformation-flows":6,"replication-flows":7,"task-chains":8}
run: |
BEFORE="${{ github.event.before }}"
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
BEFORE="${{ github.sha }}^"
fi
FILES=$(git diff --name-only --diff-filter=ACM "$BEFORE" "${{ github.sha }}" -- objects \
| grep -E '^objects/[^/]+/.+\.json$' \
| grep -v '^objects/\.provenance/' || true)
if [ -z "$FILES" ]; then
echo "matrix={\"include\":[]}" >> "$GITHUB_OUTPUT"
echo "count=0" >> "$GITHUB_OUTPUT"
echo "No deployable object files changed in this merge." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
MATRIX=$(printf '%s\n' "$FILES" | jq -R -s -c --argjson ranks "$RANKS" '
split("\n") | map(select(length > 0)) |
map({object_type: (split("/")[1]),
technical_name: (split("/")[-1] | sub("\\.json$"; ""))}) |
map(. + {rank: ($ranks[.object_type] // 9)}) |
sort_by(.rank) | {include: .}')
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
echo "count=$(echo "$MATRIX" | jq '.include | length')" >> "$GITHUB_OUTPUT"
echo "## Objects merged in this push" >> "$GITHUB_STEP_SUMMARY"
echo "$MATRIX" | jq -r '.include[] | "- `\(.object_type)/\(.technical_name)`"' >> "$GITHUB_STEP_SUMMARY"
deploy_qa:
name: B2. QA - ${{ matrix.object_type }}/${{ matrix.technical_name }}
needs: resolve
if: needs.resolve.outputs.count != '0'
runs-on: ubuntu-latest
timeout-minutes: 20
environment: qa
strategy:
fail-fast: true
max-parallel: 1
matrix: ${{ fromJson(needs.resolve.outputs.matrix) }}
env:
SPACE: ${{ vars.QA_SPACE }}
TYPE: ${{ matrix.object_type }}
NAME: ${{ matrix.technical_name }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g @SAP/datasphere-cli
- name: Log in to Datasphere
env:
DSP_CLIENT_ID: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_ID }}
DSP_CLIENT_SECRET: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_SECRET }}
DSP_AUTH_URL: ${{ secrets.DSP_AUTH_URL }}
DSP_TOKEN_URL: ${{ secrets.DSP_TOKEN_URL }}
run: |
datasphere login --host "$DSP_HOST" \
--client-id "$DSP_CLIENT_ID" --client-secret "$DSP_CLIENT_SECRET" \
--authorization-url "$DSP_AUTH_URL" --token-url "$DSP_TOKEN_URL" \
--authorization-flow client_credentials
- name: Create or update in QA
run: |
FILE="objects/$TYPE/$NAME.json"
UPDATE_FILE="/tmp/update_payload.json"
set +e
if datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > /tmp/current.json 2>/dev/null && [ -s /tmp/current.json ]; then
jq 'del(.id, .spaceId, .spaceName, .createdBy, .createdAt, .changedBy, .changedAt, .lastModifiedAt, .versionId, .objectStatus)' \
"$FILE" > "$UPDATE_FILE" 2>/dev/null || cp "$FILE" "$UPDATE_FILE"
datasphere objects "$TYPE" update --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" --file-path "$UPDATE_FILE" > /tmp/cli_out.log 2> /tmp/cli_err.log
else
datasphere objects "$TYPE" create --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" --file-path "$FILE" > /tmp/cli_out.log 2> /tmp/cli_err.log
fi
CLI_EXIT=$?
set -e
if [ $CLI_EXIT -ne 0 ]; then
echo "::error::CLI exited $CLI_EXIT deploying $TYPE/$NAME to $SPACE."
echo "--- stdout ---"
cat /tmp/cli_out.log
echo "--- stderr ---"
cat /tmp/cli_err.log
exit 1
fi
cat /tmp/cli_out.log
test_qa:
name: B3. Test in QA
needs: [resolve, deploy_qa]
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SPACE: ${{ vars.QA_SPACE }}
steps:
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g @SAP/datasphere-cli
- name: Log in to Datasphere
env:
DSP_CLIENT_ID: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_ID }}
DSP_CLIENT_SECRET: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_SECRET }}
DSP_AUTH_URL: ${{ secrets.DSP_AUTH_URL }}
DSP_TOKEN_URL: ${{ secrets.DSP_TOKEN_URL }}
run: |
datasphere login --host "$DSP_HOST" \
--client-id "$DSP_CLIENT_ID" --client-secret "$DSP_CLIENT_SECRET" \
--authorization-url "$DSP_AUTH_URL" --token-url "$DSP_TOKEN_URL" \
--authorization-flow client_credentials
- name: Verify every object is present and readable
env:
MATRIX: ${{ needs.resolve.outputs.matrix }}
run: |
FAILED=0
echo "## QA test results" >> "$GITHUB_STEP_SUMMARY"
while read -r ITEM; do
TYPE=$(echo "$ITEM" | jq -r .object_type)
NAME=$(echo "$ITEM" | jq -r .technical_name)
if datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > /tmp/after.json 2>/dev/null \
&& [ -s /tmp/after.json ] && jq empty /tmp/after.json 2>/dev/null; then
echo "- PASS \`$TYPE/$NAME\`" >> "$GITHUB_STEP_SUMMARY"
else
echo "- FAIL \`$TYPE/$NAME\`" >> "$GITHUB_STEP_SUMMARY"
echo "::error::$TYPE/$NAME is not readable in QA."
FAILED=1
fi
done < <(echo "$MATRIX" | jq -c '.include[]')
exit $FAILED
plan_prod:
name: B4. Plan PROD change
needs: [resolve, test_qa]
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SPACE: ${{ vars.PROD_SPACE }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g @SAP/datasphere-cli
- name: Log in to Datasphere
env:
DSP_CLIENT_ID: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_ID }}
DSP_CLIENT_SECRET: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_SECRET }}
DSP_AUTH_URL: ${{ secrets.DSP_AUTH_URL }}
DSP_TOKEN_URL: ${{ secrets.DSP_TOKEN_URL }}
run: |
datasphere login --host "$DSP_HOST" \
--client-id "$DSP_CLIENT_ID" --client-secret "$DSP_CLIENT_SECRET" \
--authorization-url "$DSP_AUTH_URL" --token-url "$DSP_TOKEN_URL" \
--authorization-flow client_credentials
- name: Diff live PROD against what is about to deploy
env:
MATRIX: ${{ needs.resolve.outputs.matrix }}
run: |
mkdir -p prod-snapshot
echo "## PROD deployment plan" >> "$GITHUB_STEP_SUMMARY"
echo "$MATRIX" | jq -c '.include[]' | while read -r ITEM; do
TYPE=$(echo "$ITEM" | jq -r .object_type)
NAME=$(echo "$ITEM" | jq -r .technical_name)
TARGET="objects/$TYPE/$NAME.json"
SNAP="prod-snapshot/${TYPE}__${NAME}.json"
if datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > "$SNAP" 2>/dev/null && [ -s "$SNAP" ]; then
jq --sort-keys . "$SNAP" > "$SNAP.n" && mv "$SNAP.n" "$SNAP"
echo "### UPDATE \`$TYPE/$NAME\`" >> "$GITHUB_STEP_SUMMARY"
echo '```diff' >> "$GITHUB_STEP_SUMMARY"
diff -u "$SNAP" <(jq --sort-keys . "$TARGET") | head -n 120 >> "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
else
rm -f "$SNAP"
echo "### CREATE \`$TYPE/$NAME\` - does not exist in PROD" >> "$GITHUB_STEP_SUMMARY"
fi
done
- name: Upload rollback snapshot
uses: actions/upload-artifact@v4
with:
name: prod-snapshot-${{ github.run_id }}
path: prod-snapshot/
retention-days: 30
if-no-files-found: ignore
deploy_prod:
name: B5. PROD - ${{ matrix.object_type }}/${{ matrix.technical_name }}
needs: [resolve, plan_prod]
runs-on: ubuntu-latest
timeout-minutes: 20
environment: production
strategy:
fail-fast: true
max-parallel: 1
matrix: ${{ fromJson(needs.resolve.outputs.matrix) }}
env:
SPACE: ${{ vars.PROD_SPACE }}
TYPE: ${{ matrix.object_type }}
NAME: ${{ matrix.technical_name }}
steps:
- name: Refuse anything that is not on main
if: github.ref != 'refs/heads/main'
run: |
echo "::error::PROD deploys only from main. Current ref: ${{ github.ref }}"
exit 1
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install -g @SAP/datasphere-cli
- name: Log in to Datasphere
env:
DSP_CLIENT_ID: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_ID }}
DSP_CLIENT_SECRET: ${{ secrets.DSP_GIT_INTEGRATE_CLIENT_SECRET }}
DSP_AUTH_URL: ${{ secrets.DSP_AUTH_URL }}
DSP_TOKEN_URL: ${{ secrets.DSP_TOKEN_URL }}
run: |
datasphere login --host "$DSP_HOST" \
--client-id "$DSP_CLIENT_ID" --client-secret "$DSP_CLIENT_SECRET" \
--authorization-url "$DSP_AUTH_URL" --token-url "$DSP_TOKEN_URL" \
--authorization-flow client_credentials
- name: Snapshot current PROD object
id: snap
run: |
if datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > /tmp/backup.json 2>/dev/null && [ -s /tmp/backup.json ]; then
echo "existed=true" >> "$GITHUB_OUTPUT"
else
echo "existed=false" >> "$GITHUB_OUTPUT"
fi
- name: Deploy to PROD
run: |
FILE="objects/$TYPE/$NAME.json"
set +e
if [ "${{ steps.snap.outputs.existed }}" = "true" ]; then
datasphere objects "$TYPE" update --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" --file-path "$FILE" > /tmp/cli_out.log 2> /tmp/cli_err.log
else
datasphere objects "$TYPE" create --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" --file-path "$FILE" > /tmp/cli_out.log 2> /tmp/cli_err.log
fi
CLI_EXIT=$?
set -e
if [ $CLI_EXIT -ne 0 ]; then
echo "::error::CLI exited $CLI_EXIT deploying $TYPE/$NAME to PROD ($SPACE)."
echo "--- stdout ---"
cat /tmp/cli_out.log
echo "--- stderr ---"
cat /tmp/cli_err.log
exit 1
fi
cat /tmp/cli_out.log
- name: Verify in PROD
run: |
datasphere objects "$TYPE" read --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" > /tmp/after.json
if [ ! -s /tmp/after.json ] || ! jq empty /tmp/after.json 2>/dev/null; then
echo "::error::$NAME is not readable in PROD after deployment."
exit 1
fi
echo "- PROD OK \`$TYPE/$NAME\` at commit ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY"
- name: Roll back on failure
if: failure() && steps.snap.outputs.existed == 'true'
run: |
echo "::warning::Restoring the previous PROD definition of $TYPE/$NAME"
datasphere objects "$TYPE" update --host "$DSP_HOST" --space "$SPACE" \
--technical-name "$NAME" --file-path /tmp/backup.json
- name: Warn about a partially created object
if: failure() && steps.snap.outputs.existed == 'false'
run: |
echo "::warning::$TYPE/$NAME was newly created and may be half-deployed. Delete it manually if needed."Prerequisites
- A GitHub repository with Actions enabled
- A Datasphere OAuth client configured with the client_credentials grant type (not Interactive Usage)
- The technical user behind that OAuth client granted access to DEV, QA, and PROD spaces
- Two GitHub Environments created (qa and production), each with at least one required reviewer
- Repository secrets for the Datasphere client ID, client secret, authorization URL, and token URL
- Repository variables for the Datasphere host and the QA/PROD space IDs
Configuring GitHub for This Pipeline
- Creating the QA and Production Environments
GitHub Environments are what make the approval gate possible. Go to:
- Repository → Settings → Environments → New environment
- Create one named exactly qa
- Create a second one named exactly production
These names must match the environment: field used in the workflow's deploy_qa and deploy_prod jobs — a mismatch here means the job runs without ever pausing for approval.
- Setting Required Reviewers (the Approver Tab)
Click into the qa environment. Under Deployment protection rules, tick Required reviewers, then add the specific person or team who should approve QA deployments. Repeat the same inside the production environment — this can be the same reviewer or a different, more senior one.
- Required reviewers is what turns trigger: manual from a formality into an enforced gate — only the named reviewer(s) can click Approve and deploy.
- Optionally, set a Wait timer on the production environment (used here as a short buffer before deployment, giving a last chance to cancel).
- Optionally, restrict Deployment branches to only main, so nothing can deploy from a feature branch by mistake
Repository Secrets (Settings → Secrets and variables → Actions → Secrets tab)
These hold the Datasphere OAuth client credentials used to log in headlessly. The following four secrets were created:
- DSP_GIT_INTEGRATE_CLIENT_ID
- DSP_GIT_INTEGRATE_CLIENT_SECRET
- DSP_AUTH_URL
- DSP_TOKEN_URL
These are repository-level secrets, so every job in the workflow can use them, whether it's exporting from DEV, deploying to QA, or deploying to Production.
4. Repository Variables (same page, Variables tab)
Variables hold non-sensitive configuration values that the workflow reads directly. The following three were created:
- DSP_HOST — the Datasphere tenant URL, e.g. https://<tenant>.<region>.hcs.cloud.sap
- QA_SPACE — the technical ID of the QA space (referenced in the workflow as vars.QA_SPACE)
- PROD_SPACE — the technical ID of the Production space (referenced as vars.PROD_SPACE)
A common setup mistake worth calling out here: these variable names must match exactly what the workflow YAML references. A typo, a different casing, or accidentally adding one of these under Secrets instead of Variables all cause the same silent failure — the value resolves to an empty string with no error message, and every Datasphere CLI call that follows fails.
5. Allowing the Workflow to Open Pull Requests
The export job opens a Pull Request automatically using the built-in GITHUB_TOKEN. For this to work:
- Go to Settings → Actions → General → Workflow permissions
- Select Read and write permissions
- Tick Allow GitHub Actions to create and approve pull requests
Without this, the PR-creation step fails with a permissions error.
6. Branch Protection on main
To ensure every change genuinely goes through a Pull Request rather than being pushed directly:
- Settings → Rules → Rulesets → New branch ruleset, targeting main
- Require a pull request before merging, with at least one approval
- Block force pushes and restrict deletions
Summary of Everything Configured
- Environments: qa, production — each with a required reviewer
- Secrets: DSP_GIT_INTEGRATE_CLIENT_ID, DSP_GIT_INTEGRATE_CLIENT_SECRET, DSP_AUTH_URL, DSP_TOKEN_URL
- Variables: DSP_HOST, QA_SPACE, PROD_SPACE
- Workflow permissions: read/write, with PR creation enabled
- Branch protection: main requires a reviewed Pull Request
Step 1 — Exporting an Object
The pipeline is triggered manually from the Actions tab. The person exporting fills in three fields: the source space, the object type (table, view, task chain, etc.), and the technical name of the object.
Behind the scenes, this step:
- Authenticates to Datasphere using the technical user
- Lists every object of the chosen type in the space, so the exact spelling and case can be visually confirmed
- Reads the object and writes it as a JSON file under objects/<type>/<name>.json
- Commits that file to a new branch and opens a Pull Request against main
datasphere objects local-tables read \
--host https://yourtenant.hcs.cloud.sap \
--space DEV_SPACE \
--technical-name SALARY \
> objects/local-tables/SALARY.jsonStep 2 — Human Review
This is the point where a second developer, or a reviewer, can inspect the exact change before it goes anywhere. The JSON can be edited directly in the Pull Request if needed — this is also where two developers' changes to the same object would need to be reconciled, the same way any code merge conflict is resolved.
Once satisfied, the PR is approved and merged.
Step 3 — Deployment to QA (Approval Gated)
Merging the PR is a push to main, which triggers the second half of the workflow automatically. The first job resolves exactly which objects changed and orders them so that dependencies — tables before views, views before task chains — deploy first.
The next job pauses and waits for a designated reviewer to approve the QA environment before anything touches Datasphere.
Once approved, the object is created or updated in the QA space using the Datasphere CLI, and an automated test step reads it back to confirm it deployed correctly.
datasphere objects local-tables update \
--host https://yourtenant.hcs.cloud.sap \
--space QA_SPACE \
--technical-name SALARY \
--file-path objects/local-tables/SALARY.jsondatasphere objects local-tables read \
--host https://yourtenant.hcs.cloud.sap \
--space QA_SPACE \
--technical-name SALARYStep 4 — Planning and Approving the Production Change
Before anything reaches Production, the pipeline reads the live Production object and produces a diff against what's about to be deployed. This diff is posted directly in the run's summary, giving the Production reviewer full visibility into the exact change before approving.
# Deploy
datasphere objects local-tables update \
--host https://yourtenant.hcs.cloud.sap \
--space PROD_SPACE \
--technical-name SALARY \
--file-path objects/local-tables/SALARY.json
# Verify
datasphere objects local-tables read \
--host https://yourtenant.hcs.cloud.sap \
--space PROD_SPACE \
--technical-name SALARYA rollback snapshot of the current Production object is also captured automatically as a build artifact at this stage.
The final job pauses for a second, separate approval — this time on the production environment — before deploying.
datasphere objects local-tables update \
--host https://yourtenant.hcs.cloud.sap \
--space PROD_SPACE \
--technical-name SALARY \
--file-path /tmp/backup.jsonHandling Dependencies
One important lesson from building this: a view or task chain will only deploy successfully if everything it references already exists in the target space. Deploying a view whose underlying table hasn't yet reached Production will fail — not because of a pipeline defect, but because Datasphere itself enforces referential integrity. The practical rule is to check an object's lineage before exporting it, and deploy its dependencies first.
What This Pipeline Does Not Do
In the interest of setting accurate expectations for readers:
- Deletions are not automated. Removing an object from a space still requires a manual step in Datasphere.
- This moves definitions, not data. Table structures move through this pipeline; the actual rows inside a table do not. Data movement remains the responsibility of Datasphere's own replication flows and task chains.
- Remote tables are out of scope. Objects tied to a live source connection are configured per-space directly rather than through Git.
Conclusion
This pipeline turns Datasphere object changes into a reviewable, approvable, and traceable process — without requiring any tooling beyond GitHub Actions and the Datasphere CLI. Every object that reaches Production has passed through a Pull Request review, a QA deployment with verification, and a Production diff review with a second approval — with a rollback point captured automatically along the way.
Tags to consider for SAP Community
SAP Datasphere, CI/CD, GitHub Actions, DevOps, Application Lifecycle Management, SAP BTP