Copy Pipeline Template to Target Repo
Send this guide to your coding agent.
Prerequisite: Install Port MCP
Open plan mode. Implement this Port guide in my org via MCP: https://docs.port.io/guides/all/copy-pipeline-template-to-target-repo Goal: get the guide's core flow working end-to-end in my org; adapting it to fit my existing setup takes priority over matching the guide 1:1. Plan: 1. Confirm MCP is connected, in the right org, with sufficient permissions. 2. Diff the guide's data model (blueprints, properties, relations, actions, agents, automations, integrations, secrets) against mine. 3. Propose adaptations for gaps, reusing existing blueprints/relations over guide-named duplicates. 4. Flag what needs a UI click, credential, or secret from me, testing MCP capability empirically before ruling anything out. 5. Stop on any blocker and give me options. Approving this plan authorizes the writes it lists; pause only for writes beyond what's listed. Build: - Extend blueprint schema additively when upserting; don't remove or overwrite existing properties, and treat type conflicts as a blocker, not an auto-fix. - List any mock data in the plan, minimal and labeled mock; once approved, seed it without re-asking, and tell me what you seeded. - For anything the guide writes downstream (e.g. a webhook target), use a real entity, not a mock. - For pages/widgets, use the real page identifier from the app URL, not a guessed slug. - When you hit a UI step confirmed (not assumed) unsupported via MCP, pause, give exact clicks, then resume via MCP. - Validate and give links after each meaningful step (only a tool-returned URL, no guessed paths); don't proceed if the last run wasn't a success. Done: - Confirm the guide's expected output exists and runs in Port. - Summarize adaptations, seeded data, what was mocked or skipped, remaining UI steps, and how to verify.
This guide demonstrates how to copy pipeline templates between Azure DevOps repositories using a self-service action in Port.
Once implemented:
- Platform teams can define standard pipeline templates in a base repository.
- Developers can easily copy these templates to their repositories using a self-service action in Port.
- Teams can maintain consistent CI/CD configurations across projects.
Prerequisitesβ
- Ensure you have a Port account and have completed the onboarding process.
- You can either:
- Install the Azure DevOps integration to create the blueprint and mappings automatically, or
- Alternatively, create only the
Azure DevOps Repositoryblueprint and ingest repositories directly using Portβs APIs
Set up infrastructureβ
First, let's set up the necessary Azure DevOps components to handle the pipeline copying process.
Create a pipeline copier repositoryβ
-
Create an Azure DevOps repository called
pipeline_copierin your Azure DevOps Organization/Project and configure a service connection.Use an existing repositoryYou may use an existing repository instead of creating a new one. Just ensure that you add the
azure-pipelines.yamlfile in Step 4 to the repository's root. -
Configure your Service Connection by setting the
WebHook NameandService connection nametoport_trigger -
Update the
azureDevopsRepositoryblueprint and mapping configuration with thedefaultBranchproperty, depending on how your setup was created:-
If you installed the Azure DevOps integration, update the
defaultBranchproperty in the mapping config file. -
If you created the blueprint manually (without the integration), add the JSON blueprint below and use Port's API to ingest the repository data.
Using Port's APIIf youβre not using the Azure DevOps integration, you will need to use Port's API to ingest repository data based on the blueprint you created.
To create the necessary data model manually, use the following blueprint JSON and mapping configuration:
Azure DevOps repository blueprint
{"identifier": "service","title": "Service","icon": "AzureDevops","schema": {"properties": {"url": {"title": "URL","format": "url","type": "string","icon": "Link"},"readme": {"title": "README","type": "string","format": "markdown","icon": "Book"},"defaultBranch": {"title": "Default Branch","type": "string"}},"required": []},"mirrorProperties": {},"calculationProperties": {},"aggregationProperties": {},"relations": {"project": {"title": "Project","target": "project","required": true,"many": false}}}Azure DevOps repository mapping config
- kind: repositoryselector:query: 'true'port:entity:mappings:identifier: >-"\(.project.name | ascii_downcase | gsub("[ ();]"; ""))/\(.name | ascii_downcase | gsub("[ ();]"; ""))"title: .nameblueprint: '"service"'properties:url: .remoteUrlreadme: file://README.mddefaultBranch: .defaultBranch # Add this linerelations:project: .project.id | gsub(" "; "") -
-
Create a self-service action in Port using the following JSON definition:
Organization and repository placeholdersReplace
<AZURE_DEVOPS_ORGANIZATION_NAME>with your Azure DevOps organization name in thepipeline-copierrepository and ensureinvocationMethod.webhookis set toport_trigger.Port Action
{"identifier": "copy_pipeline_template","title": "Copy Pipeline Template to Target Repo","icon": "Azure","trigger": {"type": "self-service","operation": "DAY-2","userInputs": {"properties": {"base_repo": {"type": "string","title": "Base Repository","icon": "Azure","blueprint": "azureDevopsRepository","format": "entity"},"target_repo": {"type": "string","title": "Target Repository","icon": "Azure","blueprint": "azureDevopsRepository","format": "entity"}},"required": ["base_repo","target_repo"],"order": ["base_repo","target_repo"]}},"invocationMethod": {"type": "AZURE_DEVOPS","webhook": "port_trigger","org": "<AZURE_DEVOPS_ORGANIZATION_NAME>","payload": {"base_repo_url": "{{ .inputs.base_repo.properties.url }}","target_repo_url": "{{ .inputs.target_repo.properties.url }}","base_repo_branch": "{{ .inputs.base_repo.properties.defaultBranch }}","target_repo_branch": "{{ .inputs.target_repo.properties.defaultBranch }}","azure_organization": "<AZURE_DEVOPS_ORGANIZATION_NAME>","pipeline_file_name": "pipeline.yaml", # Update this if your pipeline file name is different"port_context": {"runId": "{{ .run.id }}"}}},"requiredApproval": false} -
In your
pipeline_copierAzure DevOps Repository, create an Azure Pipeline file underazure-pipelines.ymlin the root of the repo's main branch with the following content:Azure DevOps Pipeline Script
trigger: nonepool:vmImage: "ubuntu-latest"variables:RUN_ID: "${{ parameters.port_trigger.port_context.runId }}"BASE_REPO_URL: "${{ parameters.port_trigger.base_repo_url }}"TARGET_REPO_URL: "${{ parameters.port_trigger.target_repo_url }}"BASE_REPO_BRANCH_REF: "${{ parameters.port_trigger.base_repo_branch }}"TARGET_REPO_BRANCH_REF: "${{ parameters.port_trigger.target_repo_branch }}"AZURE_ORGANIZATION: "${{ parameters.port_trigger.azure_organization }}"PIPELINE_FILE_NAME: "${{ parameters.port_trigger.pipeline_file_name }}"# Ensure that PERSONAL_ACCESS_TOKEN is set as a secret variable in your pipeline settingsresources:webhooks:- webhook: port_triggerconnection: port_triggerstages:# Stage 1: Fetch Port Access Token- stage: fetch_port_access_tokenjobs:- job: fetch_port_access_tokensteps:- script: |sudo apt-get updatesudo apt-get install -y jqdisplayName: "Install jq"- script: |accessToken=$(curl -X POST \-H 'Content-Type: application/json' \-d '{"clientId": "$(PORT_CLIENT_ID)", "clientSecret": "$(PORT_CLIENT_SECRET)"}' \-s 'https://api.port.io/v1/auth/access_token' | jq -r '.accessToken')echo "##vso[task.setvariable variable=accessToken;isOutput=true]$accessToken"displayName: "Fetch Port Access Token"name: getToken# Stage 2: Copy and Create Pipeline- stage: copy_and_create_pipelinedisplayName: "Copy and Create Pipeline"dependsOn:- fetch_port_access_tokenjobs:- job: copy_and_create_pipelinedisplayName: "Copy Pipeline and Create ADO Pipeline"variables:accessToken: $[ stageDependencies.fetch_port_access_token.fetch_port_access_token.outputs['getToken.accessToken'] ]steps:- script: |sudo apt-get updatesudo apt-get install -y jq gitdisplayName: "Install jq and git"- script: |# Set default branch ref if TARGET_REPO_BRANCH_REF is emptyif [ -z "$TARGET_REPO_BRANCH_REF" ]; thenecho "TARGET_REPO_BRANCH_REF is empty. Setting default to 'refs/heads/main'."TARGET_REPO_BRANCH_REF="refs/heads/main"fi# Extract project names from URLsBASE_PROJECT_NAME=$(echo "$BASE_REPO_URL" | awk -F'/' '{print $5}')TARGET_PROJECT_NAME=$(echo "$TARGET_REPO_URL" | awk -F'/' '{print $5}')# Extract repository names from URLsBASE_REPO_NAME=$(basename "$BASE_REPO_URL")TARGET_REPO_NAME=$(basename "$TARGET_REPO_URL")# Extract branch names from refs (e.g., "refs/heads/main" -> "main")BASE_REPO_BRANCH=${BASE_REPO_BRANCH_REF##*/}TARGET_REPO_BRANCH=${TARGET_REPO_BRANCH_REF##*/}# Validate extracted valuesif [ -z "$BASE_PROJECT_NAME" ] || [ -z "$TARGET_PROJECT_NAME" ] || [ -z "$BASE_REPO_NAME" ] || [ -z "$TARGET_REPO_NAME" ] || [ -z "$BASE_REPO_BRANCH" ] || [ -z "$TARGET_REPO_BRANCH" ] || [ -z "$PIPELINE_FILE_NAME" ]; thenecho "Error: One or more required variables are empty."exit 1fi# Construct API URLsBASE_REPO_API_URL="https://dev.azure.com/${AZURE_ORGANIZATION}/${BASE_PROJECT_NAME}/_apis/git/repositories/${BASE_REPO_NAME}"TARGET_REPO_API_URL="https://dev.azure.com/${AZURE_ORGANIZATION}/${TARGET_PROJECT_NAME}/_apis/git/repositories/${TARGET_REPO_NAME}"# Fetch pipeline file content from base_repo at specified branchHTTP_RESPONSE=$(curl -s -w "HTTPSTATUS:%{http_code}" -u :$PERSONAL_ACCESS_TOKEN \"${BASE_REPO_API_URL}/items?path=/${PIPELINE_FILE_NAME}&versionDescriptor.versionType=branch&versionDescriptor.version=${BASE_REPO_BRANCH}&api-version=6.0&format=text")# Extract the body and statusPIPELINE_CONTENT=$(echo "$HTTP_RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')# Check if the status is 200 OKif [ "$HTTP_STATUS" -ne 200 ]; thenecho "Failed to retrieve ${PIPELINE_FILE_NAME} from base repository."echo "HTTP Status: $HTTP_STATUS"echo "Response: $PIPELINE_CONTENT"exit 1fi# Base64 encode the pipeline contentPIPELINE_CONTENT_BASE64=$(echo "$PIPELINE_CONTENT" | base64 -w 0)# Check if the pipeline file exists in target_reporesponse_target_code=$(curl -s -o /dev/null -w "%{http_code}" -u :$PERSONAL_ACCESS_TOKEN \"${TARGET_REPO_API_URL}/items?path=/${PIPELINE_FILE_NAME}&versionDescriptor.versionType=branch&versionDescriptor.version=${TARGET_REPO_BRANCH}&api-version=6.0")if [ "$response_target_code" == "200" ]; thenecho "${PIPELINE_FILE_NAME} already exists in target repository. Skipping copy."else# Initialize LAST_COMMIT_ID to zeros by defaultLAST_COMMIT_ID="0000000000000000000000000000000000000000"# Get repository info to check if it's emptyREPO_INFO=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \"${TARGET_REPO_API_URL}?api-version=6.0")DEFAULT_BRANCH=$(echo "$REPO_INFO" | jq -r '.defaultBranch')if [ -z "$DEFAULT_BRANCH" ] || [ "$DEFAULT_BRANCH" == "null" ]; thenecho "Target repository is empty."REPO_IS_EMPTY=trueelseecho "Target repository is not empty."REPO_IS_EMPTY=falsefiif [ "$REPO_IS_EMPTY" = true ]; thenecho "Repository is empty. Using LAST_COMMIT_ID as zeros for initial commit."else# Repository is not empty, check if branch existsBRANCH_INFO=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \"${TARGET_REPO_API_URL}/refs/heads/${TARGET_REPO_BRANCH}?api-version=6.0")BRANCH_EXISTS=$(echo "$BRANCH_INFO" | jq -r '.value[0].objectId')if [ -n "$BRANCH_EXISTS" ] && [ "$BRANCH_EXISTS" != "null" ]; thenLAST_COMMIT_ID="$BRANCH_EXISTS"echo "Branch exists. LAST_COMMIT_ID: $LAST_COMMIT_ID"elseecho "Branch does not exist. Need to create branch."# Get the commit ID of the default branch to base the new branch onDEFAULT_BRANCH_NAME=${DEFAULT_BRANCH##*/}DEFAULT_BRANCH_INFO=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \"${TARGET_REPO_API_URL}/refs/heads/${DEFAULT_BRANCH_NAME}?api-version=6.0")DEFAULT_BRANCH_COMMIT_ID=$(echo "$DEFAULT_BRANCH_INFO" | jq -r '.value[0].objectId')if [ -n "$DEFAULT_BRANCH_COMMIT_ID" ] && [ "$DEFAULT_BRANCH_COMMIT_ID" != "null" ]; then# Use the default branch's commit ID as LAST_COMMIT_IDLAST_COMMIT_ID="$DEFAULT_BRANCH_COMMIT_ID"echo "Using default branch ${DEFAULT_BRANCH_NAME} commit ID: $LAST_COMMIT_ID as base for new branch."elseecho "Failed to get default branch commit ID."exit 1fififi# Create a push to add the pipeline file using base64 encoded contentADD_FILE_RESPONSE=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \-X POST \-H "Content-Type: application/json" \-d "{\"refUpdates\": [{\"name\": \"refs/heads/${TARGET_REPO_BRANCH}\",\"oldObjectId\": \"${LAST_COMMIT_ID}\"}],\"commits\": [{\"comment\": \"Adding ${PIPELINE_FILE_NAME}\",\"changes\": [{\"changeType\": \"add\",\"item\": { \"path\": \"/${PIPELINE_FILE_NAME}\" },\"newContent\": {\"content\": \"${PIPELINE_CONTENT_BASE64}\",\"contentType\": \"base64encoded\"}}]}]}" \"${TARGET_REPO_API_URL}/pushes?api-version=6.0")if ! echo "$ADD_FILE_RESPONSE" | jq -e '.commits' > /dev/null; thenecho "Failed to add ${PIPELINE_FILE_NAME} to target repository."echo "API Response: $ADD_FILE_RESPONSE"exit 1fifi# Check if the pipeline already existsEXISTING_PIPELINE_RESPONSE=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \"https://dev.azure.com/${AZURE_ORGANIZATION}/${TARGET_PROJECT_NAME}/_apis/pipelines?api-version=6.0-preview.1")PIPELINE_NAME="Pipeline for ${TARGET_REPO_NAME}"EXISTING_PIPELINE_ID=$(echo "$EXISTING_PIPELINE_RESPONSE" | jq -r --arg PIPELINE_NAME "$PIPELINE_NAME" '.value[] | select(.name==$PIPELINE_NAME) | .id')if [ -n "$EXISTING_PIPELINE_ID" ]; then# Optionally update the existing pipeline or skip creationecho "Pipeline already exists with ID: $EXISTING_PIPELINE_ID. Skipping creation."else# Create the pipeline in Azure DevOpsCREATE_PIPELINE_RESPONSE=$(curl -s -u :$PERSONAL_ACCESS_TOKEN \-X POST \-H "Content-Type: application/json" \-d "{\"name\": \"${PIPELINE_NAME}\",\"configuration\": {\"type\": \"yaml\",\"path\": \"/${PIPELINE_FILE_NAME}\",\"repository\": {\"id\": \"${TARGET_REPO_NAME}\",\"type\": \"azureReposGit\"}}}" \"https://dev.azure.com/${AZURE_ORGANIZATION}/${TARGET_PROJECT_NAME}/_apis/pipelines?api-version=7.1-preview.1")PIPELINE_ID=$(echo "$CREATE_PIPELINE_RESPONSE" | jq -r '.id')if [ -z "$PIPELINE_ID" ] || [ "$PIPELINE_ID" == "null" ]; thenecho "Failed to create pipeline."echo "API Response: $CREATE_PIPELINE_RESPONSE"exit 1fifidisplayName: "Copy ${PIPELINE_FILE_NAME} and Create ADO Pipeline"env:PERSONAL_ACCESS_TOKEN: $(PERSONAL_ACCESS_TOKEN)- stage: update_run_statusdependsOn:- fetch_port_access_token- copy_and_create_pipelinecondition: succeeded()jobs:- job: update_run_statusvariables:accessToken: $[ stageDependencies.fetch_port_access_token.fetch_port_access_token.outputs['getToken.accessToken'] ]steps:- script: |curl -X PATCH \-H 'Content-Type: application/json' \-H 'Authorization: Bearer $(accessToken)' \-d '{"status":"SUCCESS","statusLabel":"Successfully copied file","message": {"run_status": "Copying finished successfully!" }}' \"https://api.port.io/v1/actions/runs/${{ variables.RUN_ID }}"displayName: "Update Port with Success Status"- stage: update_run_status_faileddependsOn:- fetch_port_access_token- copy_and_create_pipelinecondition: failed()jobs:- job: update_run_status_failedvariables:accessToken: $[ stageDependencies.fetch_port_access_token.fetch_port_access_token.outputs['getToken.accessToken'] ]steps:- script: |curl -X PATCH \-H 'Content-Type: application/json' \-H 'Authorization: Bearer $(accessToken)' \-d '{"status":"FAILURE","statusLabel":"Failed to copy file","message": {"run_status": "Copying pipeline failed" }}' \"https://api.port.io/v1/actions/runs/${{ variables.RUN_ID }}"displayName: "Update Port with Failure Status" -
To configure the Pipeline in your project go to Pipelines -> Create Pipeline -> Azure Repos Git and choose
pipeline_copierand click Save (in "Run" dropdown menu). -
Create the following variables as Secret Variables:
-
PERSONAL_ACCESS_TOKEN- a Personal Access Token with the following scopes:- Code: Full.
- Build: Read, Read & execute.
- Project and Team: Read, Write.
-
PORT_CLIENT_ID- Port Client ID learn more. -
PORT_CLIENT_SECRET- Port Client Secret learn more.
-
Execute the actionβ
-
Head over to the Self-service page of your Port application.
-
Click on the
Copy Pipeline Template to Target Repoaction. -
Select the
Base Repositorywhere the template resides. -
Select the
Target Repositorywhere the repository will be copied to. -
Click the
Executebutton to trigger the action.
Having issues with Azure DevOps integration or pipelines? See the Azure DevOps Troubleshooting Guide for step-by-step help.