Sync Port properties to GitHub external custom properties
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
criticalbusiness 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.
- Setting up a new GitHub integration
- Existing integration, add a second one
- Existing integration, reuse it
- Follow the GitHub Ocean integration installation guide to install a new integration.
- During installation, the requested permissions template already includes Admin access to organization external custom properties for repositories. Approve it as part of the installation.
- Once the installation completes, Port automatically claims the
Portnamespace for that GitHub organization. No further action is needed.
If you already have a GitHub Ocean integration for this organization and prefer not to change its permissions, install a second, dedicated integration:
- Follow the GitHub Ocean integration installation guide to install a new integration on the same GitHub organization.
- Give it a clear, descriptive name (for example,
github-ocean-ecp), so it's easy to identify as the integration responsible for external custom properties. - Disable Create default resources during setup. This integration only writes properties to GitHub, it doesn't need to map and ingest GitHub resources into Port.
- Approve the Admin access to organization external custom properties for repositories permission and let Port claim the
Portnamespace.
If you'd rather not install another integration, extend the permissions of your existing one:
-
In GitHub, open your organization's Settings, then GitHub Apps.
-
Select your Port app installation, click Configure, then App settings.
-
Go to Permissions and events → Organization permissions → External custom properties for repositories, and set Access to Admin.
-
Have an organization administrator approve the updated permissions.
-
Claim the
Portnamespace for this installation by calling Port's API:curl -X POST https://api.port.io/v1/integration/github-ocean/<GITHUB_INTEGRATION_IDENTIFIER>/external-properties-namespaces \-H "Authorization: Bearer <PORT_API_TOKEN>"The response includes a
claimsobject listing every GitHub organization where the namespace was claimed successfully, and anerrorsobject explaining any failures.
- 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
Portnamespace 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.
-
Go to the Workflows page in Port.
-
Click + Workflow.
-
Fill out the Create new workflow form, then click Confirm.
-
Build the workflow using one of the following methods:
- JSON
- Manual creation
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" }]}-
Add a trigger node: choose An entity change occurs, select the
serviceblueprint, and add a JQ condition that checks both the GitHub organization and thatcriticalityactually changed (see the JSON in the other tab for the exact expressions). -
Click the + button (add node) and, under Core actions, select Update external properties. This is a dedicated node for writing to GitHub external custom properties, and it can be configured either through the JSON in the other tab or through its form, fill in:
- Installation ID: your GitHub Ocean integration's installation identifier, the one you set up in the previous step (for example,
github-ocean). - Organization: your GitHub organization name.
- Repository:
{{ .outputs.trigger.diff.after.relations.github_repository // .outputs.trigger.diff.before.relations.github_repository }}. This is the relation path from the triggering blueprint (service, in this example) to thegithubRepositoryblueprint, so it depends on your own data model. The//fallback makes sure the repository still resolves when the trigger fires on an entity deletion. - External properties mapping: one row per property you want to sync from this trigger. The Key is the GitHub external custom property name (for example,
criticality_attr), and the Value is a JQ expression that reads the Port property (for example,{{ .outputs.trigger.diff.after.properties.criticality }}). Click Add property to map more properties from the same blueprint, this single workflow can carry as many properties as you need, as long as they all come from the same triggering blueprint.
- Installation ID: your GitHub Ocean integration's installation identifier, the one you set up in the previous step (for example,
-
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.
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.
| Aspect | Simple approach | Managed approach |
|---|---|---|
| Data model | None. | A githubExternalCustomProperty blueprint. |
| Workflows | One sync workflow per blueprint, and it can carry multiple properties. | One management workflow, plus one generated sync workflow per property. |
| Where you manage properties | Inside the workflow definition. | By creating and editing githubExternalCustomProperty entities. |
| Visibility into sync status | None dedicated. | An entity page per property, with a widget showing affected entities and their synced values. |
| Bulk sync | Only 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:
-
Go to the data model page in Port.
-
Click + Blueprint, then
{...}and Edit JSON. -
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}}} -
Click Save.
Create the management workflow
This workflow does all the work from here on:
- When you create a
githubExternalCustomPropertyentity, it generates a dedicatedsync_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.
-
Go to the Workflows page in Port.
-
Click + Workflow.
-
Fill out the Create new workflow form, then click Confirm.
-
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" }]} -
Click Apply changes.
Add properties to sync
From here, adding a property to sync is just creating an entity, never a workflow:
- Go to the
githubExternalCustomPropertycatalog page in Port. - Create a new entity, filling in
blueprint_name,property_name,github_attr_name,github_org, andgithub_integration. - 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:
- Go to the Plugins Library page in Port.
- Find and install the GitHub external property values plugin.
- Open the
githubExternalCustomPropertyentity page's Overview tab and add the plugin as a widget, alongside the existing Details widget.
Verify the sync
- Simple approach
- Managed approach
- Change the
criticalityproperty on aserviceentity that matches the workflow's GitHub organization. - Open the
sync_gh_external_propsworkflow's run log and confirm the run completed successfully. - In GitHub, search the organization's repositories with
props.Port.criticality_attr:<value>and confirm the right repository shows up. - Reset the property back to its original value once you're done testing.
- Create a
githubExternalCustomPropertyentity for the property you want to test, if you haven't already. Creating it triggers an immediate bulk sync of the current value to every matching repository. - In GitHub, search the organization's repositories with
has:props.Port.criticality_attrto confirm the bulk sync populated the property. - Change the underlying property (for example,
criticality) on a matching entity. - From the
githubExternalCustomPropertyentity, follow theentity_update_sync_workflowrelation to open the generatedsync_port_<attr>workflow, and confirm the run completed successfully. - In GitHub, search the organization's repositories with
props.Port.criticality_attr:<value>and confirm the right repository shows up. - Open the
githubExternalCustomPropertyentity's page to see the list of affected entities, their synced values, and their last sync status. - Reset the property back to its original value once you're done testing.
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):
- Build the new approach first, recreating every property you already sync under the old approach.
- Verify the new setup works end to end, following Verify the sync above.
- 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).