Skip to main content
Version: v5.7.0

13.1 GitHub

integrations:github

After this you can: connect a GitHub App, exchange an Actions OIDC token for an OpsChain identity, run actions from a pipeline with no repo-held secret, and take GitHub events as triggers.

Before you start — tick each one:

  • Lesson 5.2 — Triggers (opschain learn 5.2)
  • Lesson 7.3 — Tokens, automation principals and the review export (opschain learn 7.3)

In the GUI: a GitHub-initiated change opens on its run page and in Chains like a native run, and every exchange, mint and attestation is a row on the Operations area's Activity panel — the connection and its policies are administered from the CLI.

GitHub's unit of management is the repository, so multi-repo estates end up duplicating secrets, environment definitions and inventories per repo — and they drift. With the GitHub integration the repository holds no OpsChain configuration and no standing credential: each workflow run authenticates with its own short-lived Actions OIDC token, exchanges it for a short-lived OpsChain token under a trust policy you declared, acts as a service account you control, and reports its outcome back onto the change it ran. This lesson walks that loop once, end to end, against an example my-org/payments repository. The guide opschain docs getting-started/integrations-github is the day-to-day reference; the full component reference is opschain docs integrations-github.

Licence entitlement. The server side is gated on integrations:github (on the registered Basic tier, with limits: 3 trust policies, 1,000 exchanges and 1,000 just-in-time mints per UTC day; the unregistered evaluation footprint does not carry it — opschain admin licence register lights it up). Registering a GitHub Enterprise Server host is Enterprise-gated. On lapse everything is retained; only new exchanges and registrations refuse, by name.

You need a GitHub App on your instance (github.com or your GHES host) with its PEM private key downloaded, admin rights on the OpsChain server, and a repository whose Actions workflows you can edit.

1. Register the App connection​

opschain admin github-connections create corp-github \
--app-id 4242 \
--oidc-audience https://opschain.example.com \
--private-key-file ~/Downloads/opschain-app.pem
Created GitHub connection corp-github for github.com (private_key_ref secret-vault://github-connections/corp-github/credential)

The key is vaulted and the record stores only the reference — the same custody story every provider family shares (lesson 3.5). Set --oidc-audience to your server's URL so a token requested for this instance can never exchange anywhere else. Validate the record without calling GitHub:

opschain admin github-connections test corp-github
GitHub connection corp-github: record validation succeeded

2. The service identity and the trust policy​

The exchanged token acts as a service account, never as whoever triggered the workflow — the same workflow always sees the same target set. Create one and grant it exactly what the pipeline needs (read on the paths it deploys from, create on the targets it runs; grants as in lesson 7.2), then declare the policy that maps the repository's claims onto it:

opschain admin service-accounts create gha-payments
Created service account gha-payments (id 01a2fa00-…)
Grant it authority via policy assignments or group membership, then point automations at it with run_as.
opschain admin github-trust-policies create payments-main \
--connection corp-github \
--repository 'my-org/payments' --ref 'refs/heads/main' \
--service-account gha-payments
Created trust policy payments-main (repository my-org/payments -> service account gha-payments)
opschain admin github-trust-policies list
NAME CONNECTION REPOSITORY REF ENABLED ID
payments-main corp-github my-org/payments refs/heads/main yes 01a2fa01-…

Patterns use * (any run of characters) and ? (one character); policies match in name order, first match wins; --environment adds a GitHub environment predicate. The minted token's TTL defaults to 900 seconds (--token-ttl tunes it, 60–3600). --scope-prefix /projects/payments clamps every session the policy mints to that subtree: inside it the service account's grants decide, outside it everything denies — one service account, several pipelines, none of them reaching another project.

3. Exchange from the workflow​

Give the job id-token: write and exchange at the top. The starter workflow and composite actions under examples/github-actions/ in the distribution encode the whole shape, ::add-mask:: included:

permissions:
id-token: write
steps:
- name: Authenticate to OpsChain
run: |
OIDC_TOKEN=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://opschain.example.com" | jq -r .value)
RESPONSE=$(curl -sS -X POST \
"$OPSCHAIN_URL/api/v2/opschain.api.v2.GitHubIntegrationService/ExchangeGitHubActionsToken" \
-H 'Content-Type: application/json' \
-d "{\"oidc_token\":\"$OIDC_TOKEN\",\"connection\":\"corp-github\"}")
TOKEN=$(echo "$RESPONSE" | jq -r .accessToken)
echo "::add-mask::$TOKEN"
echo "OPSCHAIN_TOKEN=$TOKEN" >> "$GITHUB_ENV"

Every later step calls the API (or the CLI) with that bearer. The token is short-lived and never renewable: a long job exchanges again. Push the workflow, let it run, and watch the exchange land on the server:

opschain events --type-search github --tail 3
WHEN ID TYPE NODE USER CORRELATION DETAIL
2026-10-27T12:01:14.102331Z 01a2fa10-… info:github:token_exchange - gha-payments 01a2fa10-… policy payments-main · my-org/payments refs/heads/main · run 18234567890

A workflow from another repository or branch is refused by name — a warn:github:exchange_refused row carrying the reason — and the pipeline's own step fails on the exchange, never mid-deploy.

4. Run an action from GitHub, and attest the outcome​

The shortest path is the published reusable workflow — one uses: line and the job's OIDC token, no credential, no binary:

jobs:
deploy:
permissions:
id-token: write
uses: LimePoint/opschain-action/.github/workflows/opschain-change.yml@v1
with:
server-url: https://opschain.example.com
connection: corp-github
audience: https://opschain.example.com
project: payments
environment: prod
asset: app
action: deploy

The job exchanges, raises the change, waits for it and ends with the change's result; opschain-workflow.yml does the same for a workflow run (opschain docs integrations-github, "The published workflows"). Composed by hand, the run-action seed action invokes an OpsChain action with the exchanged token (the v2 CreateChange) and awaits its conclusion; attest reports the workflow's outcome back onto the change:

- name: Run the OpsChain deploy action
id: opschain
uses: ./.github/actions/run-action
with:
node-path: /projects/payments/environments/prod/assets/app
action: deploy
- name: Attest the outcome to OpsChain
if: always()
uses: ./.github/actions/attest
with:
change-id: ${{ steps.opschain.outputs.change-id }}

On the server the change is an ordinary change — created by gha-payments, with the GitHub actor in its audit metadata — and the attestation is one more event on its trail:

opschain runs list --filter action=deploy
ID KIND ACTION TARGET STATUS CREATED DURATION
01a2fa20-5b6c-7d8e-9f0a-1b2c3d4e5f60 change deploy payments/environments/prod/assets/app success 2026-10-27T12:01:20Z 48.2s
opschain events --type-search github:attestation --tail 1
WHEN ID TYPE NODE USER CORRELATION DETAIL
2026-10-27T12:02:11.440812Z 01a2fa21-… info:github:attestation /projects/payments/environments/prod/assets/app gha-payments 01a2fa20-5b6c-7d8e-9f0a-1b2c3d4e5f60 success · my-org/payments run 18234567890

opschain chains show <correlation-id> renders the GitHub-initiated work beside everything the change cascaded (lesson 5.1). A change the funnel holds (lesson 10.1) holds for a GitHub-initiated run exactly as for a person's.

5. Retire the stored PAT: just-in-time GH_TOKEN​

Dispatching GitHub work from OpsChain (the packs:common github:workflow_run / github:repo_dispatch actions) used a hand-provisioned PAT in the opschain.env properties. Record the App's installation id and point the property at the connection instead:

opschain admin github-connections update corp-github --installation-id 12345678
Updated GitHub connection corp-github
opschain properties edit payments # opschain.env.GH_TOKEN: github-app://corp-github

At step dispatch the server mints a short-lived App installation token (about an hour, GitHub's cap), injects it as GH_TOKEN, masks it in every log and never persists it — the pack actions are unchanged. Every mint is an info:github:token_mint event; a mint that cannot happen fails the step by name (warn:github:mint_refused carries the reason). The secret-vault:// PAT remains the documented fallback for vaultless or unconnected instances.

A reference can narrow the token it mints — github-app://corp-github?repos=payments&permissions=contents:write — and the connection's --allowed-repositories / --allowed-permissions bound what any reference may ask; a reference beyond the bound fails its step by name (warn:github:token_scope_refused), and the scope every token was minted under is on the step's setup lines.

6. Webhooks: GitHub events as trigger sources​

Provision ingestion once and point the GitHub App's webhook at the printed path:

opschain admin github-connections provision-webhook corp-github --service-account gha-payments
Provisioned GitHub webhook ingestion for corp-github as inbound connection github-corp-github
Point the GitHub App's webhook at <server URL>/api/hooks/github-corp-github
Signing secret (shown ONCE - paste it into the App's webhook configuration): 4f1e…

The signing secret is generated and shown exactly once (supply your own with --secret-env/--secret-prompt). Verified deliveries land as external:webhook:received events with the raw payload under event.data.payload and, for push / pull_request / workflow_run / release, clean typed fields under event.data.github — so a trigger condition reads event.data.github.conclusion == "success" instead of spelunking GitHub's document. Bind a trigger exactly as lesson 5.3 taught for the generic lane:

# promote_on_green.yaml
code: promote_on_green
subject: external
events: [external:webhook:received]
conditions:
- event.data.connection eq github-corp-github
- event.data.github.event eq workflow_run
- event.data.github.conclusion eq success
action_type: workflow_run
action_params:
workflow: promote
inputs:
git_ref: ${{ event.data.github.ref }}
opschain triggers create payments -f promote_on_green.yaml

Every firing executes as the connection's service account — never with ambient authority. The connection lives on the generic inbound-webhook lane (opschain admin inbound-webhooks list shows it beside the rest) for secret rotation, rate and enablement.

7. Day to day​

  • Audit: every exchange is info:github:token_exchange (policy, repository, ref, run id); every refusal warn:github:exchange_refused with its reason; the Activity panel and opschain events show both.
  • Rotation: opschain admin github-connections update corp-github --private-key-file new.pem rotates the App key; revoking the service account's sessions kills live exchanged tokens.
  • Limits: over-cap calls refuse at their step with a named licence message, never a mid-pipeline mystery.

2026-11-11 — a complete two-direction demonstration with a presenter runbook: github.com/LimePoint/opschain-github-demo.

Check yourself​

  • What does the trust policy bind the exchanged token to — a person or a service account, and which one in this walk? Then: which event does a verified GitHub delivery raise, and under which key does a trigger read its typed fields?

What you learned​

  • A GitHub App connection holds the key encrypted at rest (or pins a vault reference); a trust policy maps a repository and ref onto a service account; the workflow exchanges its own OIDC token for a short-lived OpsChain token and holds no secret.
  • GitHub-initiated changes are ordinary changes, attested back with info:github:attestation, visible in Chains like native runs.
  • github-app://<connection> replaces the stored PAT with a just-in-time installation token.
  • GitHub deliveries land as external:webhook:received with typed event.data.github fields; firings run as the connection's service account.

Next: lesson 13.2 — Jira Service Management (opschain learn 13.2), or back to your track (opschain learn).