Technology Blog Posts by Members
cancel
Showing results for 
Search instead for 
Did you mean: 

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.

harishkumar_jc1aebb_0-1788765386419.png

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. 

harishkumar_jc1aebb_1-1788765556055.png

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 

harishkumar_jc1aebb_2-1788765614162.png

Configuring GitHub for This Pipeline 

  1. 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.

    harishkumar_jc1aebb_3-1788765814875.png
  2. 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
  3. harishkumar_jc1aebb_4-1788765962445.pngRepository 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. 

harishkumar_jc1aebb_5-1788766091377.png     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.

harishkumar_jc1aebb_6-1788766529872.png      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. 

harishkumar_jc1aebb_7-1788766636682.png

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. 

harishkumar_jc1aebb_8-1788766818710.png

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.json

Step 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.

harishkumar_jc1aebb_9-1788766910119.png

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.json
datasphere objects local-tables read \
  --host https://yourtenant.hcs.cloud.sap \
  --space QA_SPACE \
  --technical-name SALARY

Step 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 SALARY

harishkumar_jc1aebb_10-1788766983163.png

A 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.json

Handling 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.

harishkumar_jc1aebb_11-1788767102509.png

Tags to consider for SAP Community 

SAP Datasphere, CI/CD, GitHub Actions, DevOps, Application Lifecycle Management, SAP BTP 

1 Comment
Labels in this area