Table of Contents
This is part 5 of the GitHub Actions for Platform Engineers series. In part 4, we covered self-hosted runners on Azure, why GitHub-hosted runners hit a wall in secured environments, and how to run your own compute inside your network boundary. In this post, we’re going one level higher: building custom actions that your platform team owns and application teams consume without needing to understand what’s happening underneath.
The Problem With Copy-Paste Pipelines
If you’ve been following this series, you’ve seen how reusable workflows let you centralise your pipeline logic and have teams call it rather than duplicate it. Custom actions take that idea further.
The difference is subtle but important. Reusable workflows are about standardising the structure of a pipeline run: the jobs, the steps, the environment, the approval gates. Custom actions are about standardising individual units of work, things like authenticating to Azure, setting up Terraform, running a compliance check, or tagging a deployment. They’re the building blocks your workflows are made of.
Without them, you end up with the same 15 lines of YAML repeated across every workflow in every repo in your organisation. Someone changes the login step on one pipeline, and three other pipelines are now doing something different. It doesn’t take long before you have no idea what’s actually running in production.
Custom actions fix this. You write the logic once, version it, and teams reference it. When you need to update something, you update it in one place.
Three Types of Custom Action
GitHub supports three action types. It’s worth understanding the difference before you build one.
Composite actions are the simplest. They’re just a sequence of steps defined in YAML, the same steps you’d write inline in a workflow. No JavaScript, no Docker, no external dependencies. You can run shell commands, call other actions, and pass inputs and outputs between steps. For platform engineering use cases, this is almost always what you want.
JavaScript actions run Node.js directly on the runner. They’re faster than composite actions because there’s no shell overhead, and they give you access to the full GitHub Actions toolkit — reading context, setting outputs, interacting with the API. Worth reaching for if you’re doing something complex that YAML alone can’t handle cleanly.
Docker actions package their dependencies in a container image. They’re the most portable and the most consistent across environments, but they’re slower to start and need Docker available on the runner. Useful when your action has system-level dependencies or when you need a very specific environment.
For this post, we’re focusing on composite actions. They’re the right starting point for most platform teams, and they’ll cover the majority of what you need to standardise.
What We’re Building
The example we’ll walk through is a composite action that handles Azure login using OIDC. This is a good real-world example because it’s a step that needs to happen in almost every pipeline that touches Azure, the logic is fiddly to get right, and it’s something application teams shouldn’t have to think about.
If you haven’t set up OIDC federation between GitHub and Azure yet, read part 3 of this series first. The action we’re building here assumes that’s already in place.
The action will:
- Accept the tenant ID, subscription ID, and client ID as inputs
- Log in to Azure using the OIDC token from the workflow
- Set the correct subscription context
- Optionally verify the login succeeded before continuing
Repository Structure
Custom actions live in their own repository. The platform team owns this repo, versions it, and controls who can make changes to it. Application teams reference it from their workflows.
platform-actions/
├── azure-login/
│ ├── action.yml
│ └── README.md
├── terraform-setup/
│ ├── action.yml
│ └── README.md
└── README.md
This structure lets you ship multiple actions from a single repo, each in its own subdirectory. Teams reference them using the repo/subdirectory@version pattern, which we’ll see shortly.
Building the Azure Login Action
Here’s the action.yml for our Azure login composite action:
name: 'Azure Login (OIDC)'
description: 'Authenticates to Azure using OIDC federation. Requires the calling workflow to have id-token: write permission.'
inputs:
tenant-id:
description: 'Azure tenant ID'
required: true
subscription-id:
description: 'Azure subscription ID to set as the active context'
required: true
client-id:
description: 'Client ID of the federated identity credential'
required: true
outputs:
subscription-name:
description: 'The display name of the active subscription'
value: ${{ steps.get-context.outputs.subscription-name }}
runs:
using: 'composite'
steps:
- name: Login to Azure
uses: azure/login@v2
with:
tenant-id: ${{ inputs.tenant-id }}
subscription-id: ${{ inputs.subscription-id }}
client-id: ${{ inputs.client-id }}
- name: Set subscription context
shell: bash
run: az account set --subscription ${{ inputs.subscription-id }}
- name: Verify login and get context
id: get-context
shell: bash
run: |
SUBSCRIPTION_NAME=$(az account show --query name -o tsv)
echo "subscription-name=${SUBSCRIPTION_NAME}" >> $GITHUB_OUTPUT
echo "Logged in to subscription: ${SUBSCRIPTION_NAME}"
A few things worth pointing out here.
The runs.using: 'composite' declaration is what makes this a composite action rather than a JavaScript or Docker one. Everything inside runs.steps is standard workflow YAML.
The outputs block lets the action surface information back to the calling workflow. In this case, we’re passing back the subscription display name so a downstream step can log or verify it. Outputs from composite actions require the value field to point to a specific step output using the ${{ steps.<id>.outputs.<name> }} pattern.
Note that the action itself doesn’t set the OIDC permissions. That’s intentional. The calling workflow needs to declare id-token: write in its permissions block. The action can’t grant itself permissions it doesn’t have, and it shouldn’t try to. This is a deliberate design decision: the workflow owns its permission surface, the action owns the implementation.
Versioning the Action
Before application teams can reference this action, you need to tag a release. This is non-negotiable. If teams reference a branch instead of a tag, any change you push immediately affects every running pipeline. That’s not a platform, that’s a liability.
Semantic versioning works well here:
git tag -a v1.0.0 -m "Initial release: Azure OIDC login action"
git push origin v1.0.0
GitHub also supports major version tags, a moving tag like v1 that always points to the latest v1.x.x release. This gives teams a stable reference that still picks up non-breaking updates automatically:
git tag -fa v1 -m "Update v1 to v1.0.0"
git push origin v1 --force
My preference is to use the full version tag in production pipelines. The explicit reference means you know exactly what’s running, and updates are deliberate rather than automatic. You can always bump the version reference in your reusable workflow when you’re ready to roll a new version out.
Calling the Action
Here’s how an application team, or your own reusable workflow, would call the action:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Azure
uses: your-org/platform-actions/azure-login@v1
with:
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
client-id: ${{ vars.AZURE_CLIENT_ID }}
- name: Run deployment
run: |
echo "Deploying to $(az account show --query name -o tsv)"
# Your deployment steps here
The reference format is organisation/repo/subdirectory@version. The Azure tenant ID, subscription ID, and client ID are pulled from GitHub environment variables rather than hardcoded, which keeps the workflow portable across environments.
Application teams don’t need to know anything about how OIDC authentication works. They pass in three values and get back an authenticated Azure session. That’s the abstraction you’re building.
Access Control
By default, any repository in your organisation can reference actions from a public repo. If your platform-actions repo is internal or private, you need to make sure the GitHub Actions settings at the organisation level allow internal repo access, or explicitly grant access to the repos that need it.
In GitHub Enterprise, you can also use runner groups and environment policies to control which repos can consume which actions. That’s worth thinking about if you’re operating at scale or in a regulated environment, but it’s a separate topic from building the action itself.
What to Build Next
The Azure login action is a practical starting point, but it’s just one building block. Once you have the pattern down, there are a handful of actions that pay back the investment very quickly for platform teams:
Terraform setup: installs the correct Terraform version, configures the backend, and initialises the working directory. Pin the version in the action so teams don’t accidentally upgrade mid-sprint.
Compliance check: wraps Checkov or a policy-as-code tool, runs it against the Terraform plan output, and fails the pipeline if something is out of policy. Teams get a consistent gate without having to wire it up themselves.
Deployment notification: posts a structured message to a Teams or Slack channel when a deployment starts and finishes. Useful for audit trails and for making deployments visible across the team.
Environment validation: checks that the required variable groups and service connections exist before a deployment starts, so failures are caught early with a clear error rather than mid-deployment with a confusing one.
Build the actions your pipelines already need and pull them out into the actions repo. Don’t try to build a library from scratch.
Wrapping Up
Custom actions are how you stop rewriting the same pipeline logic across every repository and start building something that functions like a platform. The platform team owns the building blocks, application teams consume them, and changes propagate cleanly through versioned releases rather than copy-paste.
The composite action pattern is the right starting point for most platform engineering use cases. It’s pure YAML, it’s readable, and it doesn’t introduce JavaScript or container dependencies you don’t need.
In the next post, we’re bringing the whole series together: GitHub Actions and Terraform running as a production-grade platform pipeline, with OIDC authentication, self-hosted runners, reusable workflows, and custom actions all working together.
Part of the GitHub Actions for Platform Engineers series.