Workflow Reference: leftsize.yml, Inputs and Outputs

LeftSize runs as a step in a GitHub Actions workflow in your repository, usually .github/workflows/leftsize.yml. The onboarding page at leftsize.com generates this file for you; this page shows it in full, explains each part, and lists every input and output of the leftsize/leftsize-action step in scan mode. For the stats mode inputs and outputs, see Reports.

Terms used on this page

  • Workflow: a YAML file in .github/workflows/ that GitHub Actions runs on a schedule or on demand.
  • Matrix: a list of values that runs the same job once per value. LeftSize uses it to scan several subscriptions or accounts from one workflow, one job each.
  • Job summary: the report a workflow run shows on its summary page in the Actions tab. LeftSize writes its findings per rule, estimated savings and any problems there.
  • Scope: how LeftSize groups findings into issues: an Azure resource group, an AWS account and region, or a Cloudflare account. Each rule gets one issue per scope.

Azure workflow

name: LeftSize Cost Optimization Scan

on:
  schedule:
    - cron: '17 6 * * *'
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  leftsize-scan:
    runs-on: ubuntu-latest
    name: Scan ${{ matrix.environment }}
    strategy:
      fail-fast: false
      matrix:
        environment: [MAIN]
    steps:
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets[format('AZURE_CLIENT_ID_{0}', matrix.environment)] }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets[format('AZURE_SUBSCRIPTION_ID_{0}', matrix.environment)] }}

      - name: Run LeftSize Scan
        id: leftsize
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: azure
          environment-name: ${{ matrix.environment }}
          verbose: false

      - name: View Results Summary
        if: always()
        run: |
          echo "LeftSize scan complete for ${{ matrix.environment }}!"
          echo "Found ${{ steps.leftsize.outputs.findings-count }} optimization opportunities"
          echo "Results submitted: ${{ steps.leftsize.outputs.findings-submitted }}"

Secrets: AZURE_CLIENT_ID_MAIN, AZURE_SUBSCRIPTION_ID_MAIN, AZURE_TENANT_ID, LEFTSIZE_INSTALLATION_ID and LEFTSIZE_REPOSITORY_TOKEN. See Azure Setup.

AWS workflow

name: LeftSize Cost Optimization Scan

on:
  schedule:
    - cron: '17 6 * * *'
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  leftsize-scan:
    runs-on: ubuntu-latest
    name: Scan ${{ matrix.environment }}
    strategy:
      fail-fast: false
      matrix:
        environment: [MAIN]
    steps:
      - name: AWS Login (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets[format('AWS_ROLE_ARN_{0}', matrix.environment)] }}
          aws-region: us-east-1

      - name: Run LeftSize Scan
        id: leftsize
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: aws
          aws-regions: ${{ vars.AWS_REGIONS }}
          environment-name: ${{ matrix.environment }}
          verbose: false

      - name: View Results Summary
        if: always()
        run: |
          echo "LeftSize scan complete for ${{ matrix.environment }}!"
          echo "Found ${{ steps.leftsize.outputs.findings-count }} optimization opportunities"
          echo "Results submitted: ${{ steps.leftsize.outputs.findings-submitted }}"

Secrets: AWS_ROLE_ARN_MAIN, LEFTSIZE_INSTALLATION_ID and LEFTSIZE_REPOSITORY_TOKEN. Variable: AWS_REGIONS. See AWS Setup.

Cloudflare workflow

name: LeftSize Cloudflare Scan

on:
  schedule:
    - cron: '17 6 * * *'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  leftsize-cloudflare:
    runs-on: ubuntu-latest
    steps:
      - name: Run LeftSize Scan
        id: leftsize
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: cloudflare
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          cloudflare-account-ids: ${{ vars.CLOUDFLARE_ACCOUNT_IDS }}
          verbose: false

      - name: View Results Summary
        if: always()
        run: |
          echo "LeftSize Cloudflare scan complete!"
          echo "Found ${{ steps.leftsize.outputs.findings-count }} findings"
          echo "Results submitted: ${{ steps.leftsize.outputs.findings-submitted }}"

Secrets: CLOUDFLARE_API_TOKEN, LEFTSIZE_INSTALLATION_ID and LEFTSIZE_REPOSITORY_TOKEN. Variable (optional): CLOUDFLARE_ACCOUNT_IDS. Cloudflare has no OIDC for API tokens, so there is no login step and no id-token: write; the action signs in with the read-only token. See Cloudflare Setup.

Scanning several clouds from one workflow

Each run of the action scans one cloud. To scan AWS, Azure and Cloudflare from one workflow, give each cloud its own job:

name: LeftSize

on:
  schedule:
    - cron: '17 6 * * *'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  aws:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: AWS Login (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN_MAIN }}
          aws-region: us-east-1

      - name: Run LeftSize Scan
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: aws
          aws-regions: ${{ vars.AWS_REGIONS }}

  azure:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Azure Login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID_MAIN }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID_MAIN }}

      - name: Run LeftSize Scan
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: azure

  cloudflare:
    runs-on: ubuntu-latest
    steps:
      - name: Run LeftSize Scan
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: cloudflare
          cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          cloudflare-account-ids: ${{ vars.CLOUDFLARE_ACCOUNT_IDS }}

Leave out the jobs for clouds you don’t use. To scan several AWS accounts or Azure subscriptions, add the matrix from the workflows above to that job.

  • The jobs run in parallel and each submits its own findings. All of them count as one repository for your plan.
  • Permissions per job. Only the AWS and Azure jobs need id-token: write for OIDC. The Cloudflare job inherits contents: read from the workflow and gets no OIDC token.
  • One cloud never closes another’s findings. A scan only adds and updates findings. A Cloudflare scan doesn’t resolve AWS or Azure findings because they’re missing from it, and the other way around.
  • Daily submission limit. An installation can submit findings 20 times per day (UTC), across all its repositories. Each job, and each matrix entry, is one submission, so a three-cloud workflow that runs once a day uses 3. See Findings weren’t submitted.

The same, as a matrix

If you’d rather keep one list of steps, use a matrix over the clouds. Each entry runs as its own job, in parallel, like the separate jobs above:

name: LeftSize

on:
  schedule:
    - cron: '17 6 * * *'
  workflow_dispatch:

permissions:
  contents: read

jobs:
  leftsize:
    name: Scan ${{ matrix.cloud }} ${{ matrix.environment }}
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # for the AWS and Azure logins; a matrix shares one permissions block
      contents: read
    strategy:
      fail-fast: false  # a cloud that fails doesn't cancel the others
      matrix:
        include:
          - cloud: aws
            environment: MAIN
          - cloud: azure
            environment: MAIN
          - cloud: cloudflare
            environment: MAIN
    steps:
      - name: AWS Login (OIDC)
        if: matrix.cloud == 'aws'
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets[format('AWS_ROLE_ARN_{0}', matrix.environment)] }}
          aws-region: us-east-1

      - name: Azure Login (OIDC)
        if: matrix.cloud == 'azure'
        uses: azure/login@v2
        with:
          client-id: ${{ secrets[format('AZURE_CLIENT_ID_{0}', matrix.environment)] }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets[format('AZURE_SUBSCRIPTION_ID_{0}', matrix.environment)] }}

      - name: Run LeftSize Scan
        uses: leftsize/leftsize-action@v1
        with:
          installation-id: ${{ secrets.LEFTSIZE_INSTALLATION_ID }}
          repository-token: ${{ secrets.LEFTSIZE_REPOSITORY_TOKEN }}
          cloud-provider: ${{ matrix.cloud }}
          environment-name: ${{ matrix.environment }}
          aws-regions: ${{ vars.AWS_REGIONS }}
          cloudflare-api-token: ${{ matrix.cloud == 'cloudflare' && secrets.CLOUDFLARE_API_TOKEN || '' }}
          cloudflare-account-ids: ${{ vars.CLOUDFLARE_ACCOUNT_IDS }}

Remove the entries of clouds you don’t use, and their login step. To scan another AWS account or Azure subscription, add an entry such as cloud: aws with environment: PROD and the secrets ending in _PROD, as in the single-cloud workflows above. The action ignores the inputs of the other clouds (aws-regions on an Azure entry, for example), and the Cloudflare token is passed only to the Cloudflare entry.

How the matrix differs from separate jobs:

  • One permissions block. A matrix can’t set permissions per entry, so the Cloudflare entry also gets id-token: write. It doesn’t use it. With separate jobs, only the AWS and Azure jobs have it.
  • fail-fast: false matters. A scan that can’t sign in fails its job. Without fail-fast: false, GitHub cancels the other entries when one fails.
  • Same limits. Each entry is one submission against the daily limit, and all entries count as one repository for your plan.

The workflow explained

Schedule

schedule runs the scan once a day. The onboarding page picks a fixed time that differs per repository (here 17 6 * * *, 06:17 UTC) to spread the load. workflow_dispatch adds a Run workflow button in the Actions tab for manual scans. GitHub runs scheduled workflows on the default branch.

Permissions

id-token: write lets the job request the OIDC token that the login step exchanges for cloud credentials. Only the AWS and Azure workflows need it; the Cloudflare workflow signs in with an API token and has only contents: read. contents: read is the minimum GitHub needs to run the workflow. The scan doesn’t need any other GitHub permission: LeftSize opens issues through its GitHub App, not through the workflow’s token.

Matrix and the MAIN label

The AWS and Azure workflows use a matrix that runs the job once per environment label. The generated workflow has one label, MAIN, and reads the secrets with that suffix: AZURE_CLIENT_ID_MAIN, AZURE_SUBSCRIPTION_ID_MAIN or AWS_ROLE_ARN_MAIN. To scan more subscriptions or accounts, add labels (environment: [MAIN, PROD, DEV]) and create the matching secrets, such as AWS_ROLE_ARN_PROD. Each label becomes its own job and its own scan; all of them count as one repository for your plan. The Cloudflare workflow has no matrix: one job scans every account the token can read, or the accounts in cloudflare-account-ids.

The label is also passed as environment-name. Labels that aren’t an environment name, such as MAIN, are ignored, so LeftSize detects production and non-production per resource. A label such as PROD or DEV sets the environment for that whole scan. See Production vs non-production.

Login step

On AWS and Azure, the login step signs in to your cloud before LeftSize runs. On AWS, aws-region: us-east-1 is only the region used to sign in; the regions to scan come from aws-regions. The action uses whatever credentials this step sets up, so you can replace it with another sign-in method; see Other ways to authenticate. On Cloudflare, the action signs in itself with cloudflare-api-token.

Results summary

The last step prints the number of findings and whether they were submitted, also when the scan step fails (if: always()). The job summary has the full report.

Inputs

Input Default Description
installation-id (required) Your GitHub App installation ID, from onboarding. Store it as the LEFTSIZE_INSTALLATION_ID secret.
repository-token (required) The repository token from onboarding. Store it as the LEFTSIZE_REPOSITORY_TOKEN secret.
mode scan scan scans your cloud; stats reports statistics (see Reports).
cloud-provider azure azure, aws or cloudflare (any case). One cloud per step; see Scanning several clouds from one workflow.
aws-regions the login region Comma-separated AWS regions to scan, or all for every enabled region.
cloudflare-api-token (required for Cloudflare) The read-only Cloudflare API token. Pass it from a secret: ${{ secrets.CLOUDFLARE_API_TOKEN }}. See Cloudflare Setup for the permissions.
cloudflare-account-ids every account the token can read Comma-separated Cloudflare account IDs to scan (32 hexadecimal characters each).
azure-subscription-ids the login subscription Subscription ID used for scoping and billing-currency detection. The scan covers the subscription the login step selected.
environment-name detected per resource One environment for the whole scan (Production), or name=environment pairs matched against resource group and resource names (Shop-NG-A=acceptance, Shop-NG-P=production, *-dev-*=development). Values that aren’t an environment, such as MAIN, are ignored.
include-policies all Rule categories to run: cost-optimization, deprecation, governance, security (also cost, deprecations).
exclude-policies none Rule IDs to skip, with or without the leftsize- prefix, such as leftsize-idle-vm,unused-public-ip.
currency detected Currency code recorded with the findings. Detected from Azure billing; USD on AWS and Cloudflare. Savings amounts in issues are always in US dollars.
verbose false Debug logging for LeftSize itself.
backend-url https://api.leftsize.com Leave unset. Only https URLs on a LeftSize domain are accepted.

To also see Cloud Custodian’s output and the API response, set the environment variable LEFTSIZE_DEBUG: true on the step. See Troubleshooting.

Outputs

Output Description
findings-count Number of findings in this scan.
findings-submitted true when the findings were sent to LeftSize.
findings-json All findings as a JSON string; an empty array when that would exceed 512 KB (GitHub limits step outputs to 1 MB).
findings-file Path of a JSON file on the runner with all findings, whatever the size.
failed-policies JSON array of checks that didn’t run, for example because of a missing read permission; [] when the scan was complete.
limit-exceeded true when the findings weren’t submitted because the plan’s repository limit was reached.

Use them in later steps as ${{ steps.leftsize.outputs.<name> }}. For example, fail the job when checks are missing:

      - name: Require a complete scan
        if: steps.leftsize.outputs.failed-policies != '[]'
        run: |
          echo "Checks that didn't run: ${{ steps.leftsize.outputs.failed-policies }}"
          exit 1

When the step fails

The step fails (exit code 1) when no policy file could run, for example because the cloud credentials are missing or expired, when AWS credentials don’t work, when the Cloudflare token can’t list zones and isn’t confirmed active, when an input is invalid, or when the findings can’t be submitted after three attempts. Reaching the repository limit or the daily submission limit keeps the step green. See Troubleshooting.

Was this article helpful?

© 2026 LeftSize. Cloud cost optimization for GitHub teams.