OCDevel
Walk
The logo for OCDevel Claude Code features clean, modern typography paired with minimalist developer-centric iconography representing the Claude command-line interface.
OCDevel Claude Code Podcast
The podcast for developers who live in Claude Code. A fast news segment on the latest Claude Code releases with a hands-on tutorial that levels up your agentic coding. The news covers what actually shipped across Claude Code and the wider Anthropic stack - new versions, models, pricing, plus the MCP servers, skills, and hooks worth your time. Then the tutorial climbs a single ladder across the series: from driving one Claude session by hand in your terminal, to power-user tooling (custom slash commands, subagents, MCP), to multi-agent fleets, to autonomous review-and-fix loops, to a full pipeline where you file a GitHub issue from your phone and Claude implements the feature, opens the PR, runs the tests, and ships to production while you're on the beach. Claude as the senior engineer on your one-person team. One copyable workflow and one real pitfall per episode - every command, flag, and setting named exactly as it appears in the tool. For working developers who want to stop typing every keystroke and start directing. AI-generated podcast by OCDevel.
CTA
Generated with OCDevel PodcasterMade with OCDevel Podcaster
This show was made with OCDevel Podcaster: turn any topic or text into an AI-narrated podcast episode that drops right into your feed.Turn any topic into an AI-narrated episode in your feed.Create your own →Create your own →

GitHub Actions Foundations for Unattended Claude Code Runs: Triggers, Permissions, OIDC, Timeouts, and Concurrency

1d ago

Every GitHub Actions primitive is the automated substitute for a human's presence, so when nobody's at the keyboard you must declare a tight timeout-minutes (the default is a six-hour burn) and an explicit permissions block. We walk the full workflow anatomy, triggers, secrets, GITHUB_TOKEN scopes, and end-to-end AWS OIDC auth for a Claude Code run.

Show Notes

Act III opens with the CI plumbing under every headless Claude Code run. The frame: each GitHub Actions primitive covers a failure mode a human would otherwise handle live. Claude Code GitHub Actions is built on the Claude Agent SDK and runs on GitHub's own runners.

News

  • Claude Code CLI v2.1.204 (Jul 8): single-change release fixing hook events not streaming during SessionStart hooks in headless sessions, which could idle-reap remote workers mid-hook. Safe to keep heavier SessionStart hooks again. (changelog, Releasebot)
  • claude-code-action v1.0.166 (Jul 6): drop buffered inline comment when posted live (#1412), modern noreply co-author email (#1369), symlinked CLAUDE.md config snapshot (#1441), preserve repeated --add-dir (#1256), propagate curl install failures (#1241). (release notes)
  • CLI v2.1.202 (Jul 6): Dynamic workflow size in /config; workflow.run_id / workflow.name OpenTelemetry attributes; /review back to single-pass, multi-agent moved to /code-review.
  • CLI v2.1.200 (Jul 3): default permission mode now labeled "Manual" — set an explicit --permission-mode in CI.

Tutorial: GitHub Actions foundations Workflow anatomy (on, permissions, jobs, runs-on, steps). Triggers: workflow_dispatch, schedule/cron (UTC, 5-min min, default-branch only, 60-day public auto-disable), repository_dispatch, issues/pull_request (synchronize), workflow_call. Jobs run in isolated VMs — matrix, needs, if, and outputs pass state. Secrets vs variables vs environment secrets. The Feb 2023 read-only GITHUB_TOKEN shift and the permissions: allowlist (unlisted scopes become none; id-token: write required for OIDC). Full AWS OIDC auth with the sub-claim security lever. Concurrency groups, timeout-minutes (360-min default), and caching. Pitfalls: GITHUB_TOKEN can't trigger downstream CI, silent 403s, pull_request_target injection, the 6-hour burn, fork PRs lack secrets, OIDC sub mismatch, and silently disabled crons. Pin actions to full SHAs.

Transcript

Welcome to Act Three. The pipeline runs itself now, and this show is proof of it. Let's open with the news, then spend the rest of the hour on the GitHub Actions foundations that make unattended Claude Code runs possible.

Top of the list is Claude Code CLI version two point one point two-oh-four, shipped July eighth. It's a single-change release, and it's squarely on-topic for us. The changelog says it fixed hook events not streaming during SessionStart hooks in headless sessions, which could cause remote workers to be idle-reaped mid-hook. Releasebot corroborates the July eighth date. Here's why you care. If your SessionStart hook does real work, installing dependencies or warming a cache, that pause used to look like idleness to a remote worker, and the worker could get reaped right in the middle of the hook. Now it streams, so the run stays alive. Upgrade with claude update or the global npm install, and go back to keeping heavier SessionStart hooks in your headless and remote workers.

Next, the GitHub Actions foundation itself, claude-code-action version one point oh point one six six, from July sixth. The cadence in our window was busy, versions one-six-four and one-six-five on the third, one-six-six on the sixth, one-six-seven on the seventh, one-six-eight on the eighth, but only one-six-six carries itemized notes. What landed: it drops a buffered inline comment when it's already posted live, which stops duplicate pull request comments in unattended runs. It uses the modern noreply email for co-author trailers, so commit authorship is correct. It handles symlinked CLAUDE.md paths during the config snapshot. It preserves repeated add-dir flags in your Claude arguments, so multi-directory context survives. And it propagates curl failures in the install pipeline, so a broken install fails loudly instead of silently continuing. Pin the action to one point oh point one six six or later, or just float at v1, and if you depend on multi-directory context, confirm your repeated add-dir flags now survive.

Secondary but useful, CLI version two point one point two-oh-two, July sixth. It added a dynamic workflow size setting in config, small, medium, or large agent counts, plus two OpenTelemetry attributes, workflow run id and workflow name, for workflow-spawned agents. Those two attributes let you attribute and trace unattended multi-agent runs in your observability stack, which ties straight back to our observability episode. It also split the review commands: slash review is a fast single pass again, and multi-agent review moved to slash code-review. So audit any CI that expected slash review to be multi-pass.

One boundary note from version two point one point two hundred on July third: the default permission mode is now labeled Manual across the CLI and the editors. For unattended runs, set an explicit permission mode rather than leaning on a default that now waits on you.

Here's the premise for the whole tutorial. A headless Claude Code run rides on GitHub Actions plumbing, and when there's no human at the keyboard, every one of these continuous integration primitives, every CI primitive, is covering a failure mode a person would otherwise handle in real time. A human approves a step, retries a flake, notices a hung job, kills a runaway that's burning minutes and tokens, or spots a silent permission error. Take the human away and each of those becomes something you have to declare in advance. So the mental model I want you to hold for the next half hour is simple: every primitive is the automated substitute for a human's presence.

Worth grounding where this sits officially. Claude Code GitHub Actions is built on top of the Claude Agent SDK, and it runs on GitHub's own runners. The docs put it plainly, your code stays on GitHub's runners. That's the substrate. Everything we talk about today, triggers, permissions, timeouts, is standard GitHub Actions machinery that the Claude action plugs into. Nothing here is exotic. It's the same plumbing every CI pipeline uses, just viewed through the lens of nobody being present when it runs.

Let's start with the workflow file itself, because the file is the operator's runbook. Workflow files live in the dot github slash workflows directory, and they're YAML, with a dot yml or dot yaml extension. There's no human to type the next command, so every action, every ordering decision, every guard has to be written down up front. Read the file top to bottom and you're reading exactly what the machine will do, in the order it'll do it.

At the top level, a workflow has a handful of keys. There's the name key, which is just the display name. There's the on key, which declares the triggers. There's a permissions block, which sets the scopes on the automatically generated GitHub token. There's an env block for variables shared across the workflow. There's a concurrency key for serialization. And there's the jobs key, which is a map of the actual jobs the workflow will run.

Inside a single job you get another layer of keys. The runs-on key picks the runner label. The needs key declares dependencies on other jobs. The if key makes the whole job conditional. The strategy and matrix keys fan the job out into variations. The timeout-minutes key caps wall-clock time. A job can also carry its own permissions block that overrides the workflow-level one, plus an outputs map, and of course a list of steps.

Inside a step, you've got a name, an id that becomes the handle for reading that step's outputs later, a uses key for a prebuilt action like actions slash checkout at version four, or a run key for a shell command. You pass inputs to an action with the with block, you set step-level env, and you gate the step with its own if. The rule is that a step is either a uses step or a run step, never both. Steps run sequentially in the same runner and they share a filesystem, which matters a lot when we get to why jobs don't. Why does an unattended agent care about all this? Because the file is the runbook. No human is going to type the next command, so every action, every order, and every guard has to be declared up front.

Now the on block, the triggers, because this is how the agent gets woken up in the first place. The first trigger is workflow dispatch, which is manual or API kickoff with typed inputs. The minimal form is just on workflow dispatch with nothing else. But you can declare inputs, and each input has a description, a required flag, a default, and a type. The type can be choice, boolean, string, number, or environment, and when it's a choice you supply an options list. Inside the workflow you read those values through the inputs context or the github event inputs context. You get up to twenty-five top-level inputs and a payload up to sixty-five thousand five hundred thirty-five characters. For an unattended agent, this is the on-demand launch: an operator, or another piece of automation, kicks off a Claude run with parameters, and it's your manual re-run escape hatch when something needs a do-over.

The second trigger is schedule, driven by cron. You write on schedule with a cron entry, five fields like thirty, then five, then one through five, which is the POSIX five-field format: minute zero to fifty-nine, hour zero to twenty-three, day of month one to thirty-one, month one to twelve, and day of week zero to six. A few things will bite you here. Cron runs in UTC, always, so translate from your local time or you'll be an hour or several off. The minimum interval is once every five minutes. Scheduled workflows only run on the default branch, so a cron on a feature branch does nothing. In public repositories the schedule auto-disables after sixty days of repository inactivity. And it's best-effort, not real-time: the top of the hour is the most congested slot on GitHub's schedulers, so stagger your crons a few minutes off the hour. For an unattended agent this is the nightly run with nobody present, triage the issues, update the dependencies, generate a report. The sixty-day auto-disable and the top-of-hour delay are exactly the silent failure modes you have to design around.

Third is repository dispatch, which is an external API event. Some outside system POSTs to the repo's dispatches endpoint with an event type and a client payload object, and inside the workflow you read github event client payload. You get up to ten top-level payload properties. This is how an outside system, a monitoring tool, a deploy hook, another repository entirely, hands a task to a Claude run programmatically, with no human in the loop at all.

Fourth, the issues and pull request triggers, which together are the at-claude event surface. The issues trigger has types like opened, edited, labeled, and assigned, among others. The pull request trigger, if you omit the types filter, defaults to three types: opened, synchronize, and reopened. That synchronize type is worth calling out on its own: it means new commits were pushed to a pull request branch, so it fires on every single push to an open pull request. You can filter these triggers by types, by branches, and by paths. The canonical Claude example gates on issue comment created, pull request review comment created, and issues opened or assigned. Those are the events that carry an at-claude mention or an assignment the agent should react to, and nothing else.

Fifth is workflow call, which marks a workflow as reusable. When you write on workflow call, the workflow becomes callable from another workflow, and it declares typed inputs, secrets, and outputs of its own. The gotcha here is secrets: a called workflow does not automatically see the caller's secrets. You either pass secrets inherit, or you name them one by one. The payoff for unattended work is real. You define one hardened build-plus-test-plus-claude workflow once, and call it from many repositories or triggers, so your guardrails don't drift copy by copy across the org. One source of truth for the safety rails, instead of a dozen slightly different forks of the same YAML.

Now jobs, runners, and how data moves between them. The runs-on key with ubuntu-latest is the canonical GitHub-hosted Linux runner. You've also got ubuntu twenty-four oh four, windows-latest, macos-latest, and self-hosted labels for your own machines. Here's the critical fact: each job runs in a fresh virtual machine, and jobs do not share a filesystem. That's the whole reason artifacts, caching, and outputs exist, to pass data between jobs that otherwise cannot see each other's disk at all. If you write a file in one job and expect the next job to read it off the same disk, you'll be surprised, because that next job is a brand new machine.

The matrix is your fan-out mechanism. Under the strategy key you write a matrix with, say, a node-version list of eighteen, twenty, and twenty-two, and GitHub generates one job per combination. By default fail-fast is true, which cancels the remaining combinations the moment one of them fails, and max-parallel caps how many run at once. For an unattended agent, the matrix tests the agent's changes across Node versions or operating systems in parallel, with nobody kicking off each variation by hand. You get broad coverage for free while you're asleep.

The needs key sequences jobs. If you write needs build, this job waits for the build job to succeed before it starts; without needs, jobs run in parallel. And if you want a cleanup job to run even when an upstream job failed, you give it if always, so it fires regardless of what came before it.

The if conditional gates jobs and steps. You'll see things like if github event name equals schedule, or if contains of the comment body and the at-claude string. There are status functions you lean on here: success, which is the implicit default, along with always, failure, and cancelled. This if is how the agent decides, is this event actually for me, with no human to eyeball it. A loose condition means Claude runs on every comment, which costs you money, or the condition is too tight and it never runs, which fails silently and you don't find out for a week.

Outputs move state between those isolated jobs. A step writes an output by echoing a key-equals-value line into the GitHub output file, and then you read it as steps, the step id, outputs, the key name. A job declares outputs under its own outputs map, and downstream jobs read them as needs, the job name, outputs, the key. That's how you pass a computed value from one job to the next without a human copy-pasting it across, which is the only other way isolated machines could share it.

Let's talk secrets, variables, and environment secrets, because an unattended agent authenticates entirely from these; nobody's there to type a password. A secret is read through the secrets context, it's encrypted at rest, and it's masked in the logs so it won't leak into your output. The Claude action reads its key as anthropic api key from the secrets context. Never hardcode a secret; the docs are explicit about that. A variable, read through the vars context, is for non-sensitive config, and importantly it is not masked in the logs, so don't put anything sensitive in a variable thinking it's protected, because it isn't.

Environment secrets are a step up from ordinary secrets. They're attached to a named GitHub Environment, and that environment can require reviewers, a wait timer, or branch restrictions before a job is allowed to run at all. That's how you gate a deploy behind human approval inside an otherwise fully automated pipeline, a single checkpoint where a person signs off. There's also the GitHub token secret, which is auto-generated fresh per run with no setup on your part. You add all of these under Settings, then Secrets and variables, then Actions. If you're running on Bedrock you'll need an AWS role to assume; if you use a custom GitHub App you'll need an app id and an app private key. The through-line: the agent authenticates entirely from secrets and OIDC, because nobody types a password when it runs.

Here's a piece of history that still trips people up, the default permissions on the GitHub token. On February second, twenty twenty-three, GitHub changed the default GitHub token permissions from read-write to read-only, but only for newly created enterprises, organizations, and repositories. Existing orgs and repos were not automatically changed, so what you actually get depends on when and where your repo was created, which is maddening. There's also a repo and org level Workflow permissions setting, permissive meaning read plus write, versus restricted or read-only, and you find it under Settings, Actions, General. And the permissions key itself, the one you write in the workflow file, shipped back on April twentieth, twenty twenty-one. Why does an unattended run care about any of this history? Because if the default is read-only and your workflow needs to push a branch or open a pull request, the run gets a silent four-oh-three, and there's no human watching to notice the failure. The fix is to declare the permissions block explicitly rather than trusting whatever the default happens to be.

So let's nail the permissions block, because it has one rule that surprises almost everybody. There are around fourteen scopes, and each one can be set to read, write, or none: actions, attestations, checks, contents, deployments, discussions, id-token, issues, packages, pages, pull-requests, repository-projects, security-events, and statuses. Here's the rule: the moment you specify a permissions block, any scope you do not list is set to none. It's an allowlist, not a patch. So writing a permissions block with just contents read doesn't only set contents to read; it strips write access from everything else at the same time. People add a block to grant one thing and accidentally revoke five others.

You can set the permissions block at the workflow level, where it applies to every job, or per job, where it overrides the workflow level for that one job. A least-privilege build-and-test job wants just contents read and nothing more. The Claude action's own example uses a broader set because it does more work: contents write so it can push commits, pull-requests write so it can open and update pull requests, issues write so it can comment, id-token write so it can mint an OpenID Connect token, and actions read so it can read your CI results. That id-token write permission is required to mint an OIDC JWT, a JSON Web Token; without it, the token request just fails outright. This is where least privilege caps your blast radius, which ties straight back to our blast-radius episode. Under-scope and you get silent four-oh-threes; over-scope and a prompt-injected agent has all the room it needs to do real damage.

Now the big one, cloud authentication with no long-lived keys, using OpenID Connect, which everyone abbreviates OIDC. The concept: GitHub's OIDC provider issues a short-lived JSON Web Token, a JWT, for each individual run. Amazon's Security Token Service, STS, exchanges that token for temporary credentials through an action called assume role with web identity. There is no stored AWS access key id or secret access key anywhere in your setup; the credentials are minted just in time and they auto-rotate. Let me walk the three parts end to end so it's concrete.

Part one lives on the AWS side. You register GitHub as an OIDC identity provider in AWS IAM, that's Identity and Access Management. The provider URL is token dot actions dot githubusercontent dot com, and the audience is sts dot amazonaws dot com. That's a one-time setup that tells AWS to trust tokens minted by GitHub.

Part two is the IAM role's trust policy, and this is where the actual security lives, so slow down here. The principal is the federated OIDC provider ARN for token dot actions dot githubusercontent dot com. The action allowed is assume role with web identity. And there's a condition, a StringEquals check, on two claims. The audience claim must equal sts dot amazonaws dot com. And the subject claim, the sub, must match your run's context. That sub claim is the security lever, the thing that decides which runs can assume this role. It takes forms like repo, then org slash repo, then ref, then refs heads main, which pins it to one branch. Or repo, org slash repo, environment, production, which pins it to a named environment. Or repo, org slash repo, pull_request. Or the far-too-broad repo, org slash repo, star, which you should avoid. If you genuinely need wildcards, switch the condition from StringEquals to StringLike.

Part three is the workflow side, and it's the easy part once the AWS side is right. You set a permissions block with id-token write and contents read. Then you add a step that uses aws-actions slash configure-aws-credentials at version four, with a role to assume ARN, an AWS region like us-east-1, and a role session name like gha-claude-run so the session is identifiable in logs. Then you verify it actually worked by running aws sts get-caller-identity, which prints back the identity you assumed.

Why does an unattended run need all of this ceremony? An autonomous run can't hold a rotating password, and it really shouldn't hold a static one that lives forever. OIDC gives it just-in-time, auto-expiring credentials scoped to exactly this repo, this branch, this environment, and nothing wider. Now the pitfalls, because there are two classic ones. First, the sub claim has to match the run's actual context. A trust policy pinned to ref, refs heads main will deny a run that happens on a pull request branch or inside an environment, and you'll get a not-authorized-to-perform assume role with web identity error that sends people down a rabbit hole. Second, and sneakier, a stray restrictive permissions block that forgets id-token write will silently break OIDC, because the token can't be minted in the first place, and the error you see is about credentials rather than permissions, which points you in the wrong direction.

Concurrency groups keep two runs from stepping on each other. You write a concurrency key with a group and a cancel-in-progress flag. The group is the bucket, and you often key it by the workflow name plus the git ref, which makes it per-branch, or by the head ref, which makes it per-pull-request. Runs that share a group serialize instead of running at the same time. If cancel-in-progress is true, starting a new run cancels the one already running; if it's false, which is the default, the new run queues up behind the old one. For an unattended agent this prevents two runs from the same pull request or branch from double-pushing or racing each other. Without it, a pull request that gets a lot of synchronize events, a lot of rapid pushes, spawns a whole pile of parallel Claude runs, all of them burning tokens and potentially creating conflicting commits on top of each other.

Timeouts are, honestly, the single most important pitfall in this entire episode, so if you remember one thing, remember this one. The timeout-minutes key at the job level caps wall-clock time, and a step can carry its own timeout too. Here's the number that matters: if you don't set it, the default job timeout is three hundred sixty minutes, which is six hours. Now think about what that means with no human present. A person notices a hung job and cancels it inside a minute or two because they can see it spinning. An unattended agent that loops or stalls just runs to that six-hour default, spending billable Actions minutes and API tokens the entire time, and you find out when the bill arrives. Always set a tight timeout-minutes, and pair it with a bounded max-turns flag, so you're capping both the wall-clock time and the number of agent iterations at once. Two different runaway modes, two different caps.

Caching keeps your builds fast and cheap, and cheap matters when you're paying per billable minute. The general-purpose tool is actions slash cache at version four. It takes a required path, a key that has to match exactly, something like the runner operating system plus node plus a hash of your package-lock file, and a set of restore-keys, which are ordered prefix fallbacks for partial hits when the exact key misses. The hashFiles function produces a SHA-256 hash, so the key automatically busts itself whenever your dependencies change, which is exactly what you want. There's a cache-hit output that's true only on an exact match, which you can use to decide whether to bother running a clean install. For a TypeScript or Next.js project there's a simpler path: actions slash setup-node at version four with a node-version set and cache set to npm handles the whole thing for you. Two caveats worth holding onto. A poisoned cache is a supply-chain attack vector, so treat cache contents with a little suspicion. And pull requests from forks only get read access to the default branch's cache, by design, so a fork can't poison your cache but also can't populate it.

Let me stitch all of this into the canonical end-to-end example from the docs, the AWS and OIDC workflow. It's named Claude PR Action. At the workflow level it sets a permissions block with contents write, pull-requests write, issues write, and id-token write. Its on block listens to issue comment, pull request review comment, and issues. There's a job, call it claude-pr, gated by an if that checks whether the at-claude string appears across those event types. The steps run in order. First, actions slash checkout at version four to get the code onto the runner. Then actions slash create-github-app-token at version two, with an app id and private key pulled from secrets, producing a token output. Then aws-actions slash configure-aws-credentials at version four, with the role to assume pulled from an AWS role to assume secret. And finally anthropics slash claude-code-action at version one, given a github token that points at that app token output, use-bedrock set to true, and a claude args string carrying the model, us dot anthropic dot claude-sonnet-4-6, and max-turns ten.

There are two lighter variants worth knowing alongside that big one. The direct-API minimal version just uses anthropics slash claude-code-action at version one with an anthropic api key pulled from secrets, no Bedrock, no OIDC, nothing else. And the scheduled, autonomous version drops the at-claude gating entirely: it triggers on a schedule, cron zero nine every day, and hands the action a prompt like generate a summary of yesterday's commits and open issues, with a claude args of model opus. That last one is the exact shape of a fully unattended nightly agent, a run that nobody starts and nobody watches.

A few specifics on the Claude Code Action itself, version one, since it has moved past beta. You pin anthropics slash claude-code-action at v1, which is generally available now. If you're coming from the beta, there are breaking changes you need to know about. You move from at-beta to at-v1. You remove the mode input entirely, because it's auto-detected now. The direct prompt input became just prompt. And several things that used to be top-level inputs, max-turns, model, custom-instructions, allowed-tools, and disallowed-tools, all move into the claude args string as flags: max-turns, model, append-system-prompt, allowedTools, and disallowedTools. The old claude env input became a settings JSON blob instead.

The auto-detection is the nice part of version one. It figures out whether it's in interactive mode, responding to an at-claude mention, or in automation mode, running immediately because you set a prompt. The key inputs are prompt, claude args, anthropic api key which is direct-API only and not for Bedrock or Vertex, github token, trigger phrase which defaults to at-claude, and the use-bedrock and use-vertex flags. Inside claude args the flags you'll reach for most are max-turns, which defaults to ten, model, mcp-config, allowedTools, and debug. One Bedrock wrinkle to remember: Bedrock model ids carry a region prefix, like us dot anthropic dot claude-sonnet-4-6, so don't drop the us prefix. To set all this up initially, you run install-github-app in your terminal, which installs the Claude GitHub App, needs repo admin rights to do it, and adds the anthropic api key secret for you.

Let me close with the pitfalls listeners are going to hit, because these are the ones that quietly cost real time and money. The first is a subtle one: the GitHub token can't trigger downstream workflows. A pull request created or updated by a workflow using the default GitHub token does not create other workflow runs that are themselves triggered by the GitHub token. So if Claude opens a pull request with the default token, your on pull request CI simply won't run on it. The symptom people report is CI not running on Claude's commits, and they burn an afternoon on it. The fix is to authenticate with a GitHub App token, via actions slash create-github-app-token at version two, or with a personal access token. That is exactly why the Bedrock example feeds the action a github token from the app token output instead of the default one. This was the same pitfall we hit in the auto-PR and label-driven episodes, so it should feel familiar.

The second pitfall, the silent four-oh-three: a read-only default token plus an agent that tries to push or open a pull request equals a four-oh-three that nobody notices. The fix, again, is to declare the permissions block explicitly, remembering that it's an allowlist and every scope you don't list becomes none.

The third pitfall is the genuinely dangerous one, pull request target injection and secret exfiltration. Here's the distinction that matters. The pull request trigger runs fork code with no secrets and a read-only token, which is safe. The pull request target trigger runs the base repository's workflow with your secrets, a write token, and your org secrets, and it runs automatically even for a first-time contributor. So if you check out and then execute the untrusted pull request head under pull request target, an attacker can steal your secrets or gain write access to your repo. Never run fork pull request code in a privileged pull request target job. This ties directly to our prompt-injection and blast-radius episodes.

The fourth pitfall we already covered, but it bears repeating because it's the expensive one: forgetting timeout-minutes means the six-hour burn. Default is three hundred sixty minutes, and a stuck agent runs the full window. Tight timeout-minutes plus a bounded max-turns.

The fifth pitfall: secrets are simply not available to fork pull requests. A pull request from a fork runs without your repo or org secrets and with a read-only token, entirely by design. So an agent that relies on the anthropic api key secret just won't have it on a fork pull request, and you have to plan for that rather than be surprised by it.

The sixth pitfall is the OIDC sub mismatch we walked through earlier: trust policy pinned to one context, the run actually happening in another, and assume role with web identity gets denied. Match the sub to the actual run context, and reach for StringLike when you genuinely need wildcards.

And the seventh pitfall is the scheduled workflow that silently disables itself. Public-repo cron auto-disables after sixty days of repository inactivity, and it only ever runs on the default branch, so your autonomous nightly Claude job can just quietly stop firing one day with no error to catch and no alert to page you.

On cost, keep two separate meters in your head at once. There's GitHub Actions minutes, what you pay for GitHub-hosted runners, and there's Claude API and token cost. They are two different bills. The levers the docs give you are the same ones we've been discussing all episode: use specific at-claude commands instead of broad open-ended ones, cap iterations with max-turns, cap wall-clock time with timeout-minutes, and use concurrency controls to kill redundant runs before they pile up.

And one last practical note on versions before we wrap. Pin anthropics slash claude-code-action at v1, actions slash checkout at v4, actions slash create-github-app-token at v2, aws-actions slash configure-aws-credentials at v4, actions slash setup-node at v4, and actions slash cache at v4. For third-party actions especially, the hardened practice is to pin to a full commit SHA rather than a moving tag, so a compromised tag can't silently ship you new code inside your own pipeline. That's your GitHub Actions foundation. Every primitive we covered today, the triggers, the permissions block, the OIDC handshake, the concurrency group, the timeout, is standing in for a decision a human would otherwise make live, in the moment. Declare them well, and the pipeline really does run itself.