Approval Workflows for Service Deployment
This guide demonstrates the power and flexibility of chaining self-service actions and automations in Port to deploy a service. The deployment process is integrated with GitLab pipelines and ServiceNow for handling approval workflows when threasholds are violated.
Use-case
- A developer initiates a deployment via Port's SSA, triggering a GitLab pipeline.
- Pipeline stages: build, test, and deploy.
- If code coverage in test stage meets the threshold, deployment proceeds.
- If coverage fails, a ServiceNow Change Request (CR) is created.
- A Platform Engineer can approve or decline the CR in Port.
- CR decisions in ServiceNow UI trigger Port automation to update the pipeline.
Prerequisites
- Port account: If you don't have a Port account, you will need to create one.
- GitLab environment: This guide includes the creation of actions and automations that use a GitLab pipeline as their backend.
- ServiceNow instance access: You need admin or developer-level access.
- Create the following GitLab secrets:
- PORT_CLIENT_ID - Your port client id.
- PORT_CLIENT_SECRET - Your port client secret.
- SERVICENOW_INSTANCE_URL - The ServiceNow instance URL. For example https://example-id.service-now.com.
- SERVICENOW_API_TOKEN - A base64 encoded string of your servicenow credentials generated as
<username>:<password>
. - Create a secret in Port named
SERVICENOW_API_TOKEN
with the base64 token content
Data Model
Define a new blueprint in Port for the change request entity using the following JSON definition:
ServiceNow change request (click to expand)
{
"identifier": "servicenowChangeRequest",
"title": "Servicenow Change Request",
"icon": "Servicenow",
"schema": {
"properties": {
"number": {
"title": "Change Number",
"type": "string"
},
"description": {
"title": "Description",
"type": "string"
},
"isActive": {
"title": "Is active",
"type": "boolean"
},
"priority": {
"title": "Priority",
"type": "string"
},
"state": {
"icon": "DefaultProperty",
"title": "State",
"type": "string"
},
"createdOn": {
"title": "Created On",
"type": "string",
"format": "date-time"
},
"createdBy": {
"title": "Created By",
"type": "string"
},
"service": {
"type": "string",
"title": "Service"
},
"category": {
"type": "string",
"title": "Category"
},
"approval": {
"title": "Approval",
"icon": "DefaultProperty",
"type": "string",
"enum": [
"approved",
"not requested",
"requested",
"rejected"
],
"enumColors": {
"approved": "green",
"not requested": "turquoise",
"requested": "yellow",
"rejected": "pink"
}
},
"externalTags": {
"type": "string",
"title": "External Tags"
}
},
"required": []
},
"mirrorProperties": {},
"calculationProperties": {},
"aggregationProperties": {},
"relations": {}
}
Add a new column named external_tags
to your change_request
table in ServiceNow to associate deployment requests with their corresponding Port context.
Follow the ServiceNow documentation to complete this step.
Actions & automations
This workflow uses three self-service actions and two automations to manage the deployment of service and approval of change request.
Self-service actions
Go to the self-service page of your portal and create the following actions using the JSON definitions below:
This action can be executed by a developer to request the deployment of a service.
Request deployment of service (click to expand)
This action uses the WEBHOOK
backend type, which directly invokes the pre_deployment_check
action to trigger the GitLab pipeline. It passes a required deploy_run_id
param to the request object for the purpose of streaming logs between the two actions.
{
"identifier": "deploy_service_to_cluster",
"title": "Deploy Service to Cluster",
"icon": "Deployment",
"description": "Deploys a service to a cluster",
"trigger": {
"type": "self-service",
"operation": "DAY-2",
"userInputs": {
"properties": {
"environment": {
"type": "string",
"title": "Environment",
"icon": "Environment",
"default": "Staging",
"enum": [
"Development",
"Staging",
"Production"
],
"enumColors": {
"Development": "lightGray",
"Staging": "lightGray",
"Production": "lightGray"
}
}
},
"required": [],
"order": []
},
"blueprintIdentifier": "service"
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "${PORT_API_URL}/actions/pre_deployment_check/runs",
"agent": false,
"synchronized": false,
"method": "POST",
"headers": {
"RUN_ID": "{{ .run.id }}"
},
"body": {
"properties": {
"deploy_run_id": "{{ .run.id }}",
"{{ spreadValue() }}": "{{ .inputs }}",
"runId": "{{ .run.id }}",
"blueprint": "{{ .action.blueprint }}",
"entity": "{{ .entity.identifier }}"
}
}
},
"requiredApproval": false
}
This action triggers the GitLab pipeline which contains the deployment logic and streams log back to the deploy_service_to_cluster
action in Port.
Pre deployment checks (click to expand)
Remember to replace the GITLAB_PROJECT_ID
and GITLAB_TRIGGER_TOKEN
placeholders with your values.
To learn how to obtain these values, see the GitLab backend documentation.
{
"identifier": "pre_deployment_check",
"title": "Pre Deployment Check",
"icon": "Deployment",
"description": "Pre Deployment checks and change request creator",
"trigger": {
"type": "self-service",
"operation": "CREATE",
"userInputs": {
"properties": {
"deploy_run_id": {
"type": "string",
"title": "Deploy Run Id"
}
},
"required": [],
"order": []
}
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/ref/main/trigger/pipeline?token={GITLAB_TRIGGER_TOKEN}",
"agent": false,
"synchronized": false,
"method": "POST",
"headers": {
"RUN_ID": "{{ .run.id }}"
},
"body": {
"{{ spreadValue() }}": "{{ .inputs }}",
"approval_status": "pending",
"port_context": {
"runId": "{{ .run.id }}"
}
}
},
"requiredApproval": false
}
This action can be executed by an admin to approve the change request. It triggers the GitLab pipeline and updates the status of the servicenowChangeRequest
entity in ServiceNow to "approved/rejected".
Approve change request (click to expand)
Remember to replace the GITLAB_PROJECT_ID
and GITLAB_TRIGGER_TOKEN
placeholders with your values.
To learn how to obtain these values, see the GitLab backend documentation.
{
"identifier": "approve_and_deploy_service",
"title": "Approve and Deploy Service",
"icon": "Deployment",
"description": "Approves a ServiceNow change request and triggers a GitLab pipeline",
"trigger": {
"type": "self-service",
"operation": "DAY-2",
"userInputs": {
"properties": {
"reason": {
"type": "string",
"title": "Reason"
},
"approval_status": {
"icon": "DefaultProperty",
"title": "Action",
"type": "string",
"default": "approved",
"enum": [
"approved",
"declined"
],
"enumColors": {
"approved": "lightGray",
"declined": "lightGray"
}
}
},
"required": [],
"order": [
"approval_status",
"reason"
]
},
"blueprintIdentifier": "servicenowChangeRequest"
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/ref/main/trigger/pipeline?token={GITLAB_TRIGGER_TOKEN}",
"agent": false,
"synchronized": false,
"method": "POST",
"headers": {
"RUN_ID": "{{ .run.id }}"
},
"body": {
"{{ spreadValue() }}": "{{ .inputs }}",
"port_context": {
"runId": "{{ .run.id }}"
},
"deploy_run_id": "{{ .entity.properties.tags }}",
"system_id": "{{ .entity.properties.system_id }}"
}
},
"requiredApproval": false
}
Automations
Go to the automations page of your portal, then create the following automations using the JSON definitions below:
This automation is triggered when a run of type approve_and_deploy_service
action is created. It's purpose is to patch the approval field in ServiceNow to approved
or rejected
.
Patch change request in ServiceNow (click to expand)
Remember to replace the SERVICENOW_INSTANCE
placeholders with your values.
{
"identifier": "updateChangeRequestInSnow",
"title": "Patch Change Request in ServiceNow",
"description": "Sends a PATCH request to serviceNow when the admin approves or declines the change request",
"trigger": {
"type": "automation",
"event": {
"type": "RUN_CREATED",
"actionIdentifier": "approve_and_deploy_service"
}
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://{SERVICENOW_INSTANCE}/api/now/table/change_request/{{ .event.diff.after.entity.identifier }}",
"agent": false,
"synchronized": true,
"method": "PATCH",
"headers": {
"Authorization": "Basic {{ .secrets.SERVICENOW_API_TOKEN }}"
},
"body": {
"approval": "{{ .event.diff.after.properties.approval_status }}"
}
},
"publish": true
}
This automation is triggered when a servicenowChangeRequest
is updated to "approved" or "rejected"
Trigger Gitlab pipeline from webhook listener (click to expand)
Remember to replace the GITLAB_PROJECT_ID and GITLAB_TRIGGER_TOKEN placeholders with your values
{
"identifier": "approveSnowPipeline",
"title": "Approve or Deny GitLab Pipeline from SNOW",
"trigger": {
"type": "automation",
"event": {
"type": "ENTITY_UPDATED",
"blueprintIdentifier": "servicenowChangeRequest"
},
"condition": {
"type": "JQ",
"expressions": [
".diff.after.properties.approval == \"approved\"",
".diff.after.properties.approval == \"rejected\""
],
"combinator": "or"
}
},
"invocationMethod": {
"type": "WEBHOOK",
"url": "https://gitlab.com/api/v4/projects/{GITLAB_PROJECT_ID}/ref/main/trigger/pipeline?token={GITLAB_TRIGGER_TOKEN}",
"agent": false,
"synchronized": false,
"method": "POST",
"headers": {
"RUN_ID": "{{ .run.id }}"
},
"body": {
"port_context": {
"runId": "{{ .run.id }}"
},
"approval_status": "{{ .event.diff.after.properties.approval }}",
"system_id": "{{ .event.diff.after.identifier }}",
"deploy_run_id": "{{ .event.diff.after.properties.tags }}"
}
},
"publish": true
}
Follow our documentation to learn how to configure webhooks in ServiceNow
GitLab pipeline script
This pipeline contains the logic for service deployment. The pipeline consists of three stages: build, test and deploy
GitLab pipeline
stages:
- build
- test
- deploy
image: alpine:latest
variables:
PORT_CLIENT_ID: ${PORT_CLIENT_ID}
PORT_CLIENT_SECRET: ${PORT_CLIENT_SECRET}
PORT_API_URL: "${PORT_API_URL}"
SERVICENOW_INSTANCE_URL: ${SERVICENOW_INSTANCE_URL}
SERVICENOW_API_TOKEN: ${SERVICENOW_API_TOKEN} # Base64 encoded version of SNOW username:password
APPROVAL_STATUS: "pending" # Default status for all pipelines
initialize-build:
stage: build
except:
- pushes
before_script:
- apk update
- apk add --upgrade curl jq -q
script:
- |
echo "Initiating build checks"
echo "Getting access token from Port API"
accessToken=$(curl -X POST \
-H 'Content-Type: application/json' \
-d '{"clientId": "'"$PORT_CLIENT_ID"'", "clientSecret": "'"$PORT_CLIENT_SECRET"'"}' \
-s '${PORT_API_URL}/auth/access_token' | jq -r '.accessToken')
echo "ACCESS_TOKEN=$accessToken" >> data.env
runId=$(cat $TRIGGER_PAYLOAD | jq -r '.port_context.runId')
deployActionRunId=$(cat $TRIGGER_PAYLOAD | jq -r '.deploy_run_id')
APPROVAL_STATUS=$(cat "$TRIGGER_PAYLOAD" | jq -r '.approval_status // "pending"')
echo "DEPLOY_ACTION_RUN_ID=$deployActionRunId" >> data.env
echo "TRIGGER_ACTION_RUN_ID=$runId" >> data.env
echo "APPROVAL_STATUS=$APPROVAL_STATUS" >> data.env
# Check APPROVAL STATUS
if [ "$APPROVAL_STATUS" = "approved" ]; then
echo "Deployment approved by platform engineer. Skipping initial logs and proceeding directly."
# Log the approval message to Port action run logs
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"message":"✅ Deployment approved by platform engineer, proceeding with deployment"}' \
"${PORT_API_URL}/actions/runs/$deployActionRunId/logs"
elif [ "$APPROVAL_STATUS" = "declined" ]; then
echo "Deployment declined by platform engineer. Notifying developer."
# Send the decline notification to Port logs for developer
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"terminationStatus":"FAILURE", "message":"❌ Deployment declined by platform engineer. Please review the change request and take necessary actions."}' \
"${PORT_API_URL}/actions/runs/$deployActionRunId/logs"
# Send the decline notification to Port logs
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"terminationStatus":"SUCCESS", "message":"❌ Declined deployment"}' \
"${PORT_API_URL}/actions/runs/$runId/logs"
exit 1 # Exit pipeline due to declined approval
else
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"message":"🚀 Initiating deployment sequence, preparing environment and resources"}' \
"${PORT_API_URL}/actions/runs/$runId/logs"
curl -X PATCH \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"link":"'"$CI_PIPELINE_URL"'"}' \
"${PORT_API_URL}/actions/runs/$runId"
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $accessToken" \
-d '{"message":"🔄 Deployment preparation underway, validating resources and configurations"}' \
"${PORT_API_URL}/actions/runs/$deployActionRunId/logs"
# HERE IS WHERE YOU CAN ADD YOU BUILD SCRIPTS
fi
artifacts:
reports:
dotenv: data.env
run-tests:
stage: test
dependencies:
- initialize-build # Ensure the data.env artifact is downloaded
except:
- pushes
before_script:
- apk update
- apk add --upgrade curl jq -q
script:
- |
# Check if TRIGGER_PAYLOAD is set and the file exists
if [ -z "$TRIGGER_PAYLOAD" ] || [ ! -f "$TRIGGER_PAYLOAD" ]; then
echo "Error: TRIGGER_PAYLOAD is not set or the file does not exist."
exit 1
fi
# Check if we need to skip tests and threshold checks
APPROVAL_STATUS=$(cat "$TRIGGER_PAYLOAD" | jq -r '.approval_status // "pending"')
if [ "$APPROVAL_STATUS" = "approved" ]; then
echo "Skipping tests and threshold checks...";
echo "COVERAGE_MET=true" >> data.env
exit 0;
fi
echo "🧪 Running tests and checking thresholds..."
# Simulate threshold check (e.g., code coverage)
COVERAGE=65
if [ "$COVERAGE" -lt 70 ]; then
echo "⚠️ Coverage is below the 70% threshold, initiating ServiceNow change request..."
# Extract the service entity from the trigger payload
serviceEntity=$(cat "$TRIGGER_PAYLOAD" | jq -r '.entity')
echo "COVERAGE_MET=false" >> data.env
echo "Creating a change request in ServiceNow"
changeRequestResponse=$(curl -X POST \
-H "Authorization: Basic $SERVICENOW_API_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"short_description\": \"Automated change request from GitLab CI/CD\", \"business_service\": \"$serviceEntity\", \"priority\": \"1\", \"u_external_tag\": \"$DEPLOY_ACTION_RUN_ID\"}" \
-s "$SERVICENOW_INSTANCE_URL/api/now/table/change_request")
# Check if the request was successful and extract important details
if [[ $(echo "$changeRequestResponse" | jq -r '.result.number') != "null" ]]; then
changeNumber=$(echo "$changeRequestResponse" | jq -r '.result.number')
changeSysId=$(echo "$changeRequestResponse" | jq -r '.result.sys_id')
changeState=$(echo "$changeRequestResponse" | jq -r '.result.state')
changeCreatedOn=$(echo "$changeRequestResponse" | jq -r '.result.sys_created_on')
changeCreatedBy=$(echo "$changeRequestResponse" | jq -r '.result.sys_created_by')
changeCategory=$(echo "$changeRequestResponse" | jq -r '.result.category')
changePriority=$(echo "$changeRequestResponse" | jq -r '.result.priority')
changeDescription=$(echo "$changeRequestResponse" | jq -r '.result.short_description')
changeApproval=$(echo "$changeRequestResponse" | jq -r '.result.approval')
changeTags=$(echo "$changeRequestResponse" | jq -r '.result.u_external_tag')
changeService=$(echo "$changeRequestResponse" | jq -r '.result.business_service.value')
echo "Change Request Created Successfully: Number: $changeNumber, Sys ID: $changeSysId, State: $changeState"
logMessage="⚠️ Pipeline did not meet the deployment checks, so a ServiceNow Change Request with Number $changeNumber and system ID $changeSysId has been created. An admin will need to review and approve this request before the deployment can proceed."
runId=$(cat $TRIGGER_PAYLOAD | jq -r '.port_context.runId')
# Send the details to Port action run logs
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "{\"message\":\"$logMessage\"}" \
"${PORT_API_URL}/actions/runs/$DEPLOY_ACTION_RUN_ID/logs"
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "{\"terminationStatus\":\"SUCCESS\", \"message\":\"$logMessage\"}" \
"${PORT_API_URL}/actions/runs/$runId/logs"
# Create the change request in Port
portPayload="{
\"identifier\": \"$changeSysId\",
\"title\": \"$changeDescription\",
\"icon\": \"Servicenow\",
\"properties\": {
\"number\": \"$changeNumber\",
\"createdBy\": \"$changeCreatedBy\",
\"createdOn\": \"$changeCreatedOn\",
\"state\": \"$changeState\",
\"category\": \"$changeCategory\",
\"priority\": \"$changePriority\",
\"description\": \"$changeDescription\",
\"approval\": \"$changeApproval\",
\"tags\": \"$changeTags\",
\"service\": \"$changeService\"
},
\"relations\": {}
}"
# Send the change request data to Port API
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "$portPayload" \
"${PORT_API_URL}/blueprints/servicenowChangeRequest/entities"
else
echo "Failed to create ServiceNow Change Request: $changeRequestResponse"
# Send failure message to Port action run logs
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"message":"❌ Failed to create ServiceNow Change Request with response: '"$changeRequestResponse"'"}' \
"${PORT_API_URL}/actions/runs/$DEPLOY_ACTION_RUN_ID/logs"
exit 1 # Exit with failure
fi
else
echo "✅ Coverage is sufficient (≥ 70%), no need to create a ServiceNow change request."
echo "COVERAGE_MET=true" >> data.env
fi
artifacts:
reports:
dotenv: data.env
# Deployment job
deploy-to-cloud:
stage: deploy
dependencies:
- run-tests
- initialize-build
except:
- pushes
before_script:
- apk update
- apk add --upgrade curl jq -q
script:
- |
# Check if APPROVAL_STATUS or COVERAGE_MET is true
if [ "$APPROVAL_STATUS" = "approved" ] || [ "$COVERAGE_MET" = "true" ]; then
echo "Conditions met, deploying service to the cluster..."
# HERE IS WHERE YOU CAN ADD YOUR DEPLOYMENT SCRIPT
# Update port run status and logs
runId=$(cat $TRIGGER_PAYLOAD | jq -r '.port_context.runId')
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"terminationStatus":"SUCCESS", "message":"✅ Service has been successfully deployed to the cluster"}' \
"${PORT_API_URL}/actions/runs/$runId/logs"
# Update developer that deployment was successful
curl -X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d '{"terminationStatus":"SUCCESS", "message":"✅ Service has been successfully deployed to the cluster"}' \
"${PORT_API_URL}/actions/runs/$DEPLOY_ACTION_RUN_ID/logs"
else
echo "Conditions not met, skipping deployment"
exit 0
fi
Conclusion
Once all of the above components are created, you will have a the necessary setup to run the workflow described in the scenario overview.
You can use this chaining mechanism to create complex workflows for many use-cases, that involve multiple actions and automations, enabling you to streamline your DevOps processes.
Let's test it!
-
Head to the Self Service hub
-
Click on the
Deploy Service to Cluster
action -
Choose the service you want to deploy and select your environment
-
A GitLab pipeline will be triggered but will fail on the test stage due to not meeting coverage threshold
-
The developer can see the logs on the runs page
-
The platform engineer can see the
servicenowChangeRequest
entity displayed in Port -
Once the change request is either approved or declined, the GitLab pipeline will be re-triggered and the log stream will be shared with the developer
-
The GitLab pipeline will be retriggered and Port context variable will be passed to instruct the pipeline script to deploy the service
Congrats 🎉 You've managed your GitLab service deployment in Port 🔥