> For the complete documentation index, see llms.txt.
Skip to main content

Check out Port for yourself ➜ 

Sync Port properties to GitHub external custom properties

Implement with AI

Send this guide to your coding agent.

Prerequisite: Install Port MCP

Closed Beta Feature

This feature is currently in closed beta with limited availability.

This feature requires a feature flag to be enabled on both your Port organization and your GitHub organization. Contact Port to request access.

Platform teams manage important repository context in Port, such as business criticality, ownership, or governance signals. GitHub external custom properties let you put that context to work directly inside GitHub:

  • Search repositories in GitHub by business or operational context, such as finding every repository with critical business criticality.
  • Combine external custom properties with GitHub rulesets to enforce policy directly on GitHub, such as requiring 2 approving reviews on repositories where criticality is critical.

In this guide, we will sync a criticality property from Port's service blueprint to a criticality_attr external custom property in GitHub, as an example. You can apply the same pattern to any property, on any blueprint that has a relation to a githubRepository entity. We will start with a simple, single-workflow setup, then show how to move to a centrally managed setup once you want dedicated visibility and control over which properties are synced.

Prerequisites

This guide assumes you have a Port account and completed the onboarding process.

Set up the GitHub Ocean integration for external custom properties

GitHub requires every GitHub App that writes external custom properties to own an exclusive namespace per GitHub organization, so that different integrators can't override each other's properties. Port claims a namespace called Port on your behalf when a GitHub Ocean integration is granted the Admin access to organization external custom properties for repositories permission.

Because a namespace can only be claimed once per GitHub organization, we recommend keeping exactly one GitHub Ocean integration per GitHub organization that's responsible for external custom properties. Once claimed, every synced property is accessible in GitHub under props.Port.<your_attr_name>, for example props.Port.criticality_attr.

  1. Follow the GitHub Ocean integration installation guide to install a new integration.
  2. During installation, the requested permissions template already includes Admin access to organization external custom properties for repositories. Approve it as part of the installation.
  3. Once the installation completes, Port automatically claims the Port namespace for that GitHub organization. No further action is needed.
Limitations
  • GitHub allows exactly one namespace per GitHub organization per installation, and it's always named Port. There's no option to choose a different name at this stage.
  • Two different Port environments can't both control the same namespace on the same GitHub organization. If you run separate production and staging Port environments against the same GitHub organization, only one of them can own the Port namespace there.

Create the sync workflow

With the integration ready, we can sync a property from Port to GitHub. In this example, we sync the criticality property on the service blueprint to a criticality_attr external custom property in GitHub. This is just an example, you can apply the same pattern to any property on any blueprint that relates to a githubRepository entity.

  1. Go to the Workflows page in Port.

  2. Click + Workflow.

  3. Fill out the Create new workflow form, then click Confirm.

  4. Build the workflow using one of the following methods:

    Click {...} and copy the workflow JSON below into the workflow's JSON editor.

    Sync GitHub External Props workflow (click to expand)
    {
    "identifier": "sync_gh_external_props",
    "title": "Sync GitHub External Props",
    "icon": "Sync",
    "description": "Sync the service criticality property to GitHub as an external custom property",
    "category": "GitHub ECPs - Simple",
    "nodes": [
    {
    "identifier": "trigger_svc",
    "config": {
    "type": "EVENT_TRIGGER",
    "event": {
    "type": "ANY_ENTITY_CHANGE",
    "blueprintIdentifier": "service"
    },
    "condition": {
    "type": "JQ",
    "combinator": "and",
    "expressions": [
    "((.diff.after.properties.github_org // .diff.before.properties.github_org) == \"<GITHUB_ORG>\")",
    "(.diff.before == null or .diff.after == null or .diff.before.properties.criticality != .diff.after.properties.criticality)"
    ]
    },
    "published": true
    }
    },
    {
    "identifier": "trigger_github",
    "title": "Update external properties",
    "icon": "Github",
    "config": {
    "type": "INTEGRATION_ACTION",
    "onFailure": "terminate",
    "installationId": "<GITHUB_INTEGRATION_IDENTIFIER>",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "update_repo_external_custom_properties",
    "integrationActionExecutionProperties": {
    "org": "<GITHUB_ORG>",
    "repo": "{{ .outputs.trigger.diff.after.relations.github_repository // .outputs.trigger.diff.before.relations.github_repository }}",
    "externalPropertiesMapping": {
    "criticality_attr": "{{ .outputs.trigger.diff.after.properties.criticality }}"
    }
    }
    }
    }
    ],
    "connections": [
    { "sourceIdentifier": "trigger_svc", "targetIdentifier": "trigger_github" }
    ]
    }
  5. Click Apply changes.

The workflow triggers whenever a service entity in the matching GitHub organization changes, checks whether criticality actually changed, and pushes the new value to the criticality_attr external custom property on the related GitHub repository.

Only future changes sync

This workflow only reacts to changes that occur after its creation. It doesn't backfill the current criticality value of existing repositories. If you need every repository's current value synced immediately, use the managed approach below instead.

Growing into the managed approach

The workflow above is a great starting point, and works well as you add more properties to the same workflow. Set up the managed approach below when you want:

  • A single, centralized place to see every property you sync, its GitHub attribute, and whether the last sync succeeded.
  • New properties to sync their current value to GitHub immediately when you add them, instead of only picking up future changes.
  • To manage which properties sync by creating and editing Port entities, instead of editing workflow JSON.
AspectSimple approachManaged approach
Data modelNone.A githubExternalCustomProperty blueprint.
WorkflowsOne sync workflow per blueprint, and it can carry multiple properties.One management workflow, plus one generated sync workflow per property.
Where you manage propertiesInside the workflow definition.By creating and editing githubExternalCustomProperty entities.
Visibility into sync statusNone dedicated.An entity page per property, with a widget showing affected entities and their synced values.
Bulk syncOnly future changes, no backfill.Automatic backfill whenever a property is added or edited, plus an on-demand re-sync trigger.

Managed approach

The managed approach adds a small data model and one general-purpose workflow that creates, updates, and deletes the per-property sync workflows for you. Instead of writing a workflow per property, you manage a Port entity per property.

Create the data model

Add a githubExternalCustomProperty blueprint to hold the sync configuration for each property:

  1. Go to the data model page in Port.

  2. Click + Blueprint, then {...} and Edit JSON.

  3. Paste the blueprint below.

    GitHub External Property blueprint (click to expand)
    {
    "identifier": "githubExternalCustomProperty",
    "title": "GitHub External Property",
    "icon": "Github",
    "schema": {
    "properties": {
    "blueprint_name": {
    "type": "string",
    "title": "Port Blueprint"
    },
    "github_attr_name": {
    "type": "string",
    "title": "GitHub Attribute Name"
    },
    "github_integration": {
    "type": "string",
    "title": "GitHub Integration",
    "description": "The Port GitHub Ocean integration installation ID used to push the property."
    },
    "github_org": {
    "type": "string",
    "title": "GitHub Organization"
    },
    "property_name": {
    "type": "string",
    "title": "Port Property Key"
    }
    },
    "required": [
    "blueprint_name",
    "github_attr_name",
    "github_integration",
    "github_org",
    "property_name"
    ]
    },
    "relations": {
    "entity_update_sync_workflow": {
    "title": "Entity Update Sync Workflow",
    "target": "_workflow",
    "required": false,
    "many": false
    }
    }
    }
  4. Click Save.

Create the management workflow

This workflow does all the work from here on:

  • When you create a githubExternalCustomProperty entity, it generates a dedicated sync_port_<attr> workflow for that property, links it back to the entity, and immediately bulk-syncs the current value to every matching repository. Unlike the simple approach, you get an instant backfill, not just future changes.
  • When you update the entity (for example, renaming github_attr_name), it recreates or updates the generated workflow to match, and re-syncs.
  • When you delete the entity, it removes the generated workflow and bulk-clears that property's values from GitHub.

Create it once, exactly as shown below, you don't need to customize it per property.

  1. Go to the Workflows page in Port.

  2. Click + Workflow.

  3. Fill out the Create new workflow form, then click Confirm.

  4. Click {...}, then paste the workflow below.

    Manage sync workflows workflow (click to expand)
    {
    "identifier": "manage_sync_workflows",
    "title": "Manage sync workflows",
    "icon": "JsonEditor",
    "category": "GitHub ECPs - Managed",
    "allowAnyoneToViewRuns": false,
    "nodes": [
    {
    "identifier": "trigger",
    "config": {
    "type": "EVENT_TRIGGER",
    "event": { "type": "ANY_ENTITY_CHANGE", "blueprintIdentifier": "githubExternalCustomProperty" },
    "condition": {
    "type": "JQ",
    "expressions": ["(.diff.before == null or .diff.after == null) or (.diff.before.properties != .diff.after.properties) or ((.diff.before.relations | del(.entity_update_sync_workflow)) != (.diff.after.relations | del(.entity_update_sync_workflow)))"],
    "combinator": "and"
    },
    "published": true
    }
    },
    {
    "identifier": "branch",
    "title": "Branch by event type",
    "config": {
    "type": "CONDITION",
    "outlets": [
    { "identifier": "on_create", "title": "Created", "expression": ".outputs.trigger.diff.before == null" },
    { "identifier": "on_delete", "title": "Deleted", "expression": ".outputs.trigger.diff.after == null" }
    ]
    }
    },
    {
    "identifier": "branch_rename",
    "title": "Attr name changed?",
    "icon": "GitBranch",
    "config": {
    "type": "CONDITION",
    "outlets": [
    { "identifier": "attr_renamed", "title": "Yes - attr name changed", "expression": ".outputs.trigger.diff.before.properties.github_attr_name != .outputs.trigger.diff.after.properties.github_attr_name" }
    ]
    }
    },
    {
    "identifier": "delete_old_workflow",
    "title": "Delete old sync workflow",
    "icon": "Trash",
    "config": {
    "type": "WEBHOOK",
    "url": "{{ \"https://api.port.io/v1/workflows/sync_port_\" + (.outputs.trigger.diff.before.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}",
    "agent": false,
    "synchronized": true,
    "method": "DELETE",
    "onTimeout": "fail",
    "onFailure": "continue"
    }
    },
    {
    "identifier": "create_workflow",
    "title": "Create sync workflow",
    "icon": "Sync",
    "config": {
    "type": "WEBHOOK",
    "url": "https://api.port.io/v1/workflows",
    "agent": false,
    "synchronized": true,
    "method": "POST",
    "body": {
    "icon": "Sync",
    "nodes": [
    {
    "config": {
    "type": "EVENT_TRIGGER",
    "event": { "type": "ANY_ENTITY_CHANGE", "blueprintIdentifier": "{{ .outputs.trigger.diff.after.properties.blueprint_name }}" },
    "condition": {
    "type": "JQ",
    "combinator": "and",
    "expressions": [
    "{{ \"((.diff.after.properties.github_org // .diff.before.properties.github_org) == \\\"\" + .outputs.trigger.diff.after.properties.github_org + \"\\\")\" }}",
    "{{ \"(.diff.before == null or .diff.after == null or .diff.before.properties.\" + .outputs.trigger.diff.after.properties.property_name + \" != .diff.after.properties.\" + .outputs.trigger.diff.after.properties.property_name + \")\" }}"
    ]
    },
    "published": true
    },
    "verbose": true,
    "identifier": "trigger_svc"
    },
    {
    "icon": "Github",
    "title": "Trigger GitHub Update",
    "config": {
    "type": "INTEGRATION_ACTION",
    "onFailure": "terminate",
    "installationId": "{{ .outputs.trigger.diff.after.properties.github_integration }}",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "update_repo_external_custom_properties",
    "integrationActionExecutionProperties": {
    "org": "{{ .outputs.trigger.diff.after.properties.github_org }}",
    "repo": "{{ \"{{ .outputs.trigger.diff.after.relations.github_repository // .outputs.trigger.diff.before.relations.github_repository }}\" }}",
    "externalPropertiesMapping": {
    "{{ .outputs.trigger.diff.after.properties.github_attr_name }}": "{{ \"{{ .outputs.trigger.diff.after.properties.\" + .outputs.trigger.diff.after.properties.property_name + \" }}\" }}"
    }
    }
    },
    "verbose": true,
    "identifier": "trigger_github"
    }
    ],
    "title": "{{ \"Sync Port.\" + .outputs.trigger.diff.after.properties.github_attr_name }}",
    "category": "GitHub ECPs - Managed",
    "identifier": "{{ \"sync_port_\" + (.outputs.trigger.diff.after.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}",
    "connections": [
    { "sourceIdentifier": "trigger_svc", "targetIdentifier": "trigger_github" }
    ],
    "allowAnyoneToViewRuns": false
    },
    "onTimeout": "fail",
    "onFailure": "terminate"
    }
    },
    {
    "identifier": "attach_workflow",
    "title": "Attach workflow to entity",
    "icon": "Link",
    "config": {
    "type": "UPSERT_ENTITY",
    "blueprintIdentifier": "githubExternalCustomProperty",
    "mapping": {
    "identifier": "{{ .outputs.trigger.diff.after.identifier }}",
    "relations": {
    "entity_update_sync_workflow": "{{ \"sync_port_\" + (.outputs.trigger.diff.after.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}"
    }
    },
    "onFailure": "terminate"
    }
    },
    {
    "identifier": "delete_workflow",
    "title": "Delete sync workflow",
    "icon": "Trash",
    "config": {
    "type": "WEBHOOK",
    "url": "{{ \"https://api.port.io/v1/workflows/sync_port_\" + (.outputs.trigger.diff.before.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}",
    "agent": false,
    "synchronized": true,
    "method": "DELETE",
    "onTimeout": "fail",
    "onFailure": "continue"
    }
    },
    {
    "identifier": "bulk_delete",
    "title": "Bulk Delete GitHub External Properties",
    "icon": "Github",
    "config": {
    "type": "INTEGRATION_ACTION",
    "installationId": "{{ .outputs.trigger.diff.before.properties.github_integration }}",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "bulk_delete_external_custom_property_values",
    "integrationActionExecutionProperties": {
    "orgs": ["{{ .outputs.trigger.diff.before.properties.github_org }}"],
    "propertyName": "{{ .outputs.trigger.diff.before.properties.github_attr_name }}"
    },
    "onFailure": "terminate"
    }
    },
    {
    "identifier": "trigger_manual_sync",
    "title": "Bulk Sync GitHub External Property",
    "icon": "Github",
    "config": {
    "type": "SELF_SERVE_TRIGGER",
    "userInputs": {
    "properties": {
    "github_external_custom_property": {
    "title": "Property",
    "type": "string",
    "format": "entity",
    "blueprint": "githubExternalCustomProperty"
    }
    },
    "required": ["github_external_custom_property"],
    "order": ["github_external_custom_property"]
    },
    "published": true,
    "contexts": [
    { "on": "ENTITY", "userInput": "github_external_custom_property" }
    ]
    }
    },
    {
    "identifier": "fetch_property",
    "title": "Fetch Property Config",
    "config": {
    "type": "WEBHOOK",
    "url": "https://api.port.io/v1/blueprints/githubExternalCustomProperty/entities/{{ .outputs.trigger_manual_sync.github_external_custom_property }}",
    "agent": false,
    "synchronized": true,
    "method": "GET",
    "onTimeout": "fail",
    "onFailure": "terminate"
    },
    "variables": { "entity": "{{ .result.response.data.entity }}" }
    },
    {
    "identifier": "update_workflow",
    "title": "Update sync workflow",
    "icon": "Sync",
    "config": {
    "type": "WEBHOOK",
    "url": "{{ \"https://api.port.io/v1/workflows/sync_port_\" + (.outputs.trigger.diff.after.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}",
    "agent": false,
    "synchronized": true,
    "method": "PUT",
    "body": {
    "icon": "Sync",
    "nodes": [
    {
    "config": {
    "type": "EVENT_TRIGGER",
    "event": { "type": "ANY_ENTITY_CHANGE", "blueprintIdentifier": "{{ .outputs.trigger.diff.after.properties.blueprint_name }}" },
    "condition": {
    "type": "JQ",
    "combinator": "and",
    "expressions": [
    "{{ \"((.diff.after.properties.github_org // .diff.before.properties.github_org) == \\\"\" + .outputs.trigger.diff.after.properties.github_org + \"\\\")\" }}",
    "{{ \"(.diff.before == null or .diff.after == null or .diff.before.properties.\" + .outputs.trigger.diff.after.properties.property_name + \" != .diff.after.properties.\" + .outputs.trigger.diff.after.properties.property_name + \")\" }}"
    ]
    },
    "published": true
    },
    "verbose": true,
    "identifier": "trigger_svc"
    },
    {
    "icon": "Github",
    "title": "Trigger GitHub Update",
    "config": {
    "type": "INTEGRATION_ACTION",
    "onFailure": "terminate",
    "installationId": "{{ .outputs.trigger.diff.after.properties.github_integration }}",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "update_repo_external_custom_properties",
    "integrationActionExecutionProperties": {
    "org": "{{ .outputs.trigger.diff.after.properties.github_org }}",
    "repo": "{{ \"{{ .outputs.trigger.diff.after.relations.github_repository // .outputs.trigger.diff.before.relations.github_repository }}\" }}",
    "externalPropertiesMapping": {
    "{{ .outputs.trigger.diff.after.properties.github_attr_name }}": "{{ \"{{ .outputs.trigger.diff.after.properties.\" + .outputs.trigger.diff.after.properties.property_name + \" }}\" }}"
    }
    }
    },
    "verbose": true,
    "identifier": "trigger_github"
    }
    ],
    "title": "{{ \"Sync Port.\" + .outputs.trigger.diff.after.properties.github_attr_name }}",
    "category": "GitHub ECPs - Managed",
    "identifier": "{{ \"sync_port_\" + (.outputs.trigger.diff.after.properties.github_attr_name | gsub(\"[^a-zA-Z0-9_]\"; \"_\")) }}",
    "connections": [
    { "sourceIdentifier": "trigger_svc", "targetIdentifier": "trigger_github" }
    ],
    "allowAnyoneToViewRuns": false
    },
    "onTimeout": "fail",
    "onFailure": "terminate"
    }
    },
    {
    "identifier": "fetch_target_entities",
    "title": "Fetch Target Entities",
    "config": {
    "type": "WEBHOOK",
    "url": "{{ \"https://api.port.io/v1/blueprints/\" + (.outputs.fetch_property.entity.properties.blueprint_name // .outputs.trigger.diff.after.properties.blueprint_name) + \"/entities/search\" }}",
    "agent": false,
    "synchronized": true,
    "method": "POST",
    "headers": { "Content-Type": "application/json" },
    "body": {
    "query": {
    "rules": [
    { "value": "{{ .outputs.fetch_property.entity.properties.github_org // .outputs.trigger.diff.after.properties.github_org }}", "operator": "=", "property": "github_org" },
    { "operator": "isNotEmpty", "relation": "github_repository" }
    ],
    "combinator": "and"
    }
    },
    "onTimeout": "fail",
    "onFailure": "terminate"
    },
    "variables": { "entities": "{{ .result.response.data.entities }}" }
    },
    {
    "identifier": "bulk_update",
    "title": "Bulk Update GitHub External Properties",
    "icon": "Github",
    "config": {
    "type": "INTEGRATION_ACTION",
    "installationId": "{{ .outputs.fetch_property.entity.properties.github_integration // .outputs.trigger.diff.after.properties.github_integration }}",
    "integrationProvider": "github-ocean",
    "integrationInvocationType": "bulk_update_external_custom_property_values",
    "integrationActionExecutionProperties": {
    "propertyName": "{{ .outputs.fetch_property.entity.properties.github_attr_name // .outputs.trigger.diff.after.properties.github_attr_name }}",
    "repositoryValues": "{{ (.outputs.fetch_property.entity.properties.property_name // .outputs.trigger.diff.after.properties.property_name) as $prop | [.outputs.fetch_target_entities.entities[] | {org: .properties.github_org, repository_name: .relations.github_repository, value: .properties[$prop]}] }}"
    },
    "onFailure": "terminate"
    }
    }
    ],
    "connections": [
    { "sourceIdentifier": "trigger", "targetIdentifier": "branch" },
    { "sourceIdentifier": "branch", "targetIdentifier": "create_workflow", "sourceOutletIdentifier": "on_create" },
    { "sourceIdentifier": "create_workflow", "targetIdentifier": "attach_workflow" },
    { "sourceIdentifier": "branch", "targetIdentifier": "branch_rename", "fallback": true },
    { "sourceIdentifier": "branch_rename", "targetIdentifier": "delete_old_workflow", "sourceOutletIdentifier": "attr_renamed" },
    { "sourceIdentifier": "delete_old_workflow", "targetIdentifier": "create_workflow" },
    { "sourceIdentifier": "branch_rename", "targetIdentifier": "update_workflow", "fallback": true },
    { "sourceIdentifier": "branch", "targetIdentifier": "delete_workflow", "sourceOutletIdentifier": "on_delete" },
    { "sourceIdentifier": "attach_workflow", "targetIdentifier": "fetch_target_entities" },
    { "sourceIdentifier": "update_workflow", "targetIdentifier": "fetch_target_entities" },
    { "sourceIdentifier": "delete_workflow", "targetIdentifier": "bulk_delete" },
    { "sourceIdentifier": "trigger_manual_sync", "targetIdentifier": "fetch_property" },
    { "sourceIdentifier": "fetch_property", "targetIdentifier": "fetch_target_entities" },
    { "sourceIdentifier": "fetch_target_entities", "targetIdentifier": "bulk_update" }
    ]
    }
  5. Click Apply changes.

Add properties to sync

From here, adding a property to sync is just creating an entity, never a workflow:

  1. Go to the githubExternalCustomProperty catalog page in Port.
  2. Create a new entity, filling in blueprint_name, property_name, github_attr_name, github_org, and github_integration.
  3. Save it.

The manage_sync_workflows workflow picks up the new entity, creates its sync workflow, and pushes the current value to every matching repository right away.

Add sync visibility to the entity page

To see, at a glance, which entities are affected by a given synced property and their current values, install the custom widget for this from the Plugins Library:

  1. Go to the Plugins Library page in Port.
  2. Find and install the GitHub external property values plugin.
  3. Open the githubExternalCustomProperty entity page's Overview tab and add the plugin as a widget, alongside the existing Details widget.

Verify the sync

  1. Change the criticality property on a service entity that matches the workflow's GitHub organization.
  2. Open the sync_gh_external_props workflow's run log and confirm the run completed successfully.
  3. In GitHub, search the organization's repositories with props.Port.criticality_attr:<value> and confirm the right repository shows up.
  4. Reset the property back to its original value once you're done testing.
Troubleshooting

If a sync run fails, the most common cause is the GitHub App missing the external custom properties permission at the organization level, or the integration never having successfully claimed the Port namespace. Revisit Set up the GitHub Ocean integration for external custom properties to confirm both.

Switching between approaches

If you already set up the simple approach and want to move to the managed one (or the other way around):

  1. Build the new approach first, recreating every property you already sync under the old approach.
  2. Verify the new setup works end to end, following Verify the sync above.
  3. Only once you've confirmed the new setup is working, remove the old one (the old sync workflow, or the management workflow plus its generated sync workflows).