Automation

GitHub Actions: validate a reusable workflow before trusting it with an Azure deployment

A production runbook for qualifying a reusable GitHub Actions workflow through permissions, secrets, OIDC, environments, traces, canary validation and rollback before an Azure deployment.

09 Sept 2026 github-actionsreusable-workflowazuredevopsoidcworkload-identitysecurityautomationobservabilityguardrailsrunbookrollbackproduction

A platform team centralizes Azure deployments in a reusable GitHub Actions workflow. Application repositories only provide an environment, an artifact and a small set of parameters. This removes duplication, but it also moves the trust boundary: one change to the shared workflow can affect several services, and a poorly constrained caller can pass more secrets or permissions than the deployment needs.

The use case is a deploy-azure.yml workflow called by several repositories to promote an already-built image into Azure. It uses OIDC, a dedicated deployment identity and a protected GitHub environment. This runbook decides whether a new workflow version is ready for production rights, should remain in canary, or must be rolled back without restoring a long-lived secret.

Freeze the caller-to-called workflow contract

A reusable workflow is not just a YAML library. It runs with caller context, receives inputs and secrets, and requests permissions for the GitHub token. Describe that contract before reviewing its internal steps.

yaml reusable-deployment-contract.yml
contract_version: deploy-azure-v4
caller:
repository: company/orders-api
workflow: .github/workflows/release.yml
ref: refs/heads/main
environment: production

called_workflow:
repository: company/platform-workflows
path: .github/workflows/deploy-azure.yml
ref: <reviewed-commit-sha>

inputs:
environment: production
image_digest: sha256:<digest>
resource_group: rg-orders-prod

expected_permissions:
contents: read
id-token: write

forbidden:
- arbitrary_subscription
- mutable_image_tag
- inherited_secrets
- unreviewed_workflow_ref
- production_without_environment_gate

The record should name the allowed caller, called version, expected Azure identity, RBAC scope and final effect. An input such as subscription_id or resource_group should not let every repository choose an arbitrary target. Prefer a value derived from an approved environment or an allowlist controlled by the shared workflow.

Type inputs and close dangerous escape hatches

workflow_call makes the interface visible, but a string input can still carry an Azure scope, a command or free-form options. Keep the interface at use-case level: promote a digest to a known environment, not execute a generic Azure command.

yaml deploy-azure.yml
name: deploy-azure

on:
workflow_call:
  inputs:
    environment:
      type: string
      required: true
    image_digest:
      type: string
      required: true
  outputs:
    deployment_id:
      description: Stable deployment identifier
      value: ${{ jobs.deploy.outputs.deployment_id }}

permissions:
contents: read
id-token: write

jobs:
deploy:
  environment: ${{ inputs.environment }}
  runs-on: ubuntu-latest
  steps:
    - name: Validate bounded inputs
      run: ./scripts/validate-deployment-inputs.sh
      env:
        TARGET_ENVIRONMENT: ${{ inputs.environment }}
        IMAGE_DIGEST: ${{ inputs.image_digest }}

Deterministic validation should reject unknown environments, mutable tags and malformed digests before Azure authentication. The workflow should return a deployment identifier and a verifiable state, not only a green GitHub conclusion.

Calculate permissions across the complete chain

Effective permissions are not defined only in the called workflow. They start in the caller and cannot be elevated through a chain of reusable workflows. A missing permission can break OIDC; broad top-level permissions can expose the repository unnecessarily.

Declare the minimum in the calling job:

yaml release.yml
jobs:
deploy-production:
  permissions:
    contents: read
    id-token: write
  uses: company/platform-workflows/.github/workflows/deploy-azure.yml@<reviewed-commit-sha>
  with:
    environment: production
    image_digest: sha256:<digest>
  secrets:
    deployment_observability_key: ${{ secrets.DEPLOYMENT_OBSERVABILITY_KEY }}

Avoid write-all, and review workflows called by the shared workflow as well. A step that only downloads an artifact does not need issues: write, pull-requests: write or actions: write. Separate the validation job with no Azure identity from the deployment job that requests the OIDC token.

Pin code that crosses the trust boundary

A call to @main or a movable tag lets the shared workflow change without a new review in the application repository. Production callers should reference a reviewed commit SHA. Apply the same discipline to third-party actions used transitively by the reusable workflow.

Keep a compact promotion record: currently authorized SHA, candidate SHA, permission diff, new inputs, new actions, owners and validation date. Automation can open SHA update changes, but promotion should remain tied to an explicit diff.

The SHA protects the called version; it does not make excessive code safe. Review commands built from inputs, unverified downloads, writes outside the expected target, outputs that may carry tokens and steps executed before the environment gate.

Do not inherit secrets for convenience

secrets: inherit makes calls shorter but silently expands the contract. The shared workflow receives secrets that its interface does not explicitly name. Pass declared secrets individually, and use OIDC for Azure instead of forwarding a persistent client secret.

Environment behavior needs a dedicated check. Environment secrets are not passed like ordinary workflow_call parameters, and a called job that declares an environment can select the secrets associated with that environment. Review the environment name, approval rules, allowed branches, Azure identity and RBAC scope as one control.

Before rollout, prove the OIDC claims emitted for the real caller. The calling repository, ref, environment and executed workflow reference must match the contract accepted by federation. Do not broaden a federated credential merely to make a canary pass; resolve the mismatch between observed and expected context first.

Reconstruct end-to-end evidence

An operable run connects the application commit, shared workflow SHA, artifact digest, approval, Azure identity and deployment operation. Capture those identifiers before relying on a success conclusion.

bash collect-reusable-workflow-evidence.sh
set -euo pipefail

repo="company/orders-api"
run_id="<run-id>"

gh run view "$run_id" --repo "$repo" --json databaseId,headSha,event,workflowName,status,conclusion,url,jobs

gh api -H "Accept: application/vnd.github+json" "/repos/$repo/actions/runs/$run_id/approvals"

On Azure, find the identity that actually acted and correlate the UTC window with the expected operation. The target service may use another table, but the query must retain the target and result rather than merely count calls.

kusto 01-correlate-reusable-workflow-deployment.kql
let StartUtc = datetime(<start-utc>);
let EndUtc = datetime(<end-utc>);
let ExpectedCaller = "<deployment-service-principal-object-id>";
AzureActivity
| where TimeGenerated between (StartUtc .. EndUtc)
| where Caller == ExpectedCaller
| project TimeGenerated,
        CorrelationId,
        OperationNameValue,
        ResourceGroup,
        ResourceId,
        ActivityStatusValue,
        Caller
| order by TimeGenerated asc

No row does not prove no effect: some operations write to resource logs or a separate deployment service. Define the source of truth before the test and retain the backend operation identifier.

Use a canary that exercises the real controls

A syntax check validates neither OIDC, environment protection nor Azure scope. Use a canary repository or service allowed to call the same SHA through a separate identity and a no-impact target. The canary should exercise the real approval path and produce the same trace fields.

yaml reusable-workflow-evaluation.yml
cases:
- id: allow_reviewed_caller_and_sha
  caller: company/orders-api
  called_ref: <candidate-commit-sha>
  environment: staging
  expected: deployment_succeeds

- id: reject_mutable_tag
  called_ref: main
  expected: policy_blocks

- id: reject_unknown_environment
  environment: production-copy
  expected: input_validation_blocks_before_oidc

- id: reject_unapproved_caller
  caller: company/sandbox
  expected: federation_or_policy_blocks

- id: preserve_environment_approval
  environment: production
  approval: missing
  expected: no_azure_token_and_no_deployment

- id: prove_bounded_target
  requested_resource_group: rg-other-prod
  expected: target_allowlist_blocks

Add a case where the called workflow completes technically but application validation fails. The expected result is a deployment that cannot be promoted, with a recovery path to the previous digest.

Decide promotion, hold or rollback

Promote the candidate SHA when inputs are bounded, permissions are minimal, secrets are explicit, claims match, approval is actually exercised and Azure effects can be reconstructed. Hold in canary when deployment works but provenance, identity or traces remain ambiguous.

Roll back the shared workflow when a version widens a target, bypasses the environment, requests unexplained permissions or breaks the link between the GitHub run and Azure operation. Re-pin callers to the last validated SHA, then revoke the candidate identity or federated credential if their scope was expanded. Preserve candidate runs and logs for investigation.

Do not restore a client secret by default. If the last known-good SHA remains usable, it is a narrower and more explainable rollback than reintroducing a persistent credential.

Conclusion

A reusable workflow removes duplication only when its trust boundary remains readable. The caller, called SHA, inputs, permissions, secrets, environment and Azure identity form one production contract.

The decision can then stay practical: immutable SHA, bounded interface, least privilege, proven OIDC and approval, representative canary and complete Azure trace before promotion. At the first unexplained expansion, return to the last validated SHA and block the new identity. Reuse remains valuable because it becomes operable, not because complexity disappears into a central repository.