field note

GitHub Actions Turned CI Into the Package Manager Nobody Audits

The new GitHub Actions panic is not just another mutable-tag warning. CI has become the package manager that publishes the package, and its lockfile story is still mostly folklore.

A dark CI control room with tangled YAML strips feeding into a glowing package registry vault, green terminal warnings reflected on black metal

GitHub Actions is no longer just CI glue. It is a package manager, a credential broker, and a release authority, but it still behaves like a YAML convenience layer that assumes the happy path is the common path.

This is a revisit of the GitHub control-plane theme from the merge queue post. That piece was about repository correctness: the reviewed diff, the tested state, and the landed branch diverging. This one is about a different invariant that is now breaking in public: the thing that builds your artifact is also a dependency graph, and most projects do not audit it like one.

The immediate artifact is Andrew Nesbitt’s April 28 essay, “GitHub Actions is the weakest link”, which hit Hacker News again on April 30. The post is useful because it refuses to treat Actions incidents as isolated footguns. It lays out a chain of failures from Spotbugs in 2024 through Ultralytics, tj-actions, nx, Trivy, and Elementary-data, all exploiting slightly different parts of the same machine: mutable action refs, pull_request_target, inline shell interpolation, shared caches, default write tokens, and trusted publishing sitting on top of workflows that were never designed like a security boundary.

That last part is the real thesis. Trusted publishing moved package ecosystems in the right direction by killing long-lived registry tokens. PyPI’s Trusted Publishers announcement describes the win clearly: GitHub Actions can exchange short-lived OIDC identity tokens with PyPI, which means maintainers no longer need to store a PyPI API token in CI. This is better than static secrets. It is also a transfer of trust. The registry stops trusting a maintainer’s token and starts trusting the workflow that can mint id-token: write.

So the workflow becomes the secret.

That is a strange place for the industry to be, because GitHub Actions does not feel like a package manager when you write it. It feels like snippets. uses: actions/checkout@v4. uses: pypa/gh-action-pypi-publish@release/v1. uses: docker/login-action@v3. A little declarative wiring, a few shell blocks, a cache stanza, maybe a publishing step. But every uses: line is dependency resolution. Every tag is a mutable pointer unless pinned to a commit SHA. Every composite action can contain more uses: lines. Every Docker action can pull another image. Every JavaScript action carries a bundled npm universe. The workflow file is not just orchestration. It is a build-time dependency manifest with ambient credentials.

The difference is that package managers spent years being bullied into lockfiles, integrity hashes, provenance, advisory databases, and reproducible-ish installs. Actions got copy-paste culture.

This is why the Hacker News thread around Nesbitt’s piece is more substantive than the usual CI grumbling. The strongest comments are not saying “use another vendor and relax.” They are saying the dependency model itself is under-specified. Pinning actions to commit hashes is necessary, but not sufficient. A SHA-pinned action can call composite actions by tag. It can run npm code. It can download tools at runtime. It can use a Docker tag. Renovate can help replace tags with SHAs, but that is only the first layer of the onion.

GitHub’s own security guidance has been warning about one of these layers for years. The GitHub Security Lab article on untrusted input in workflows explains why ${{ github.event.pull_request.title }} inside a run: block is not a string in the normal sense. GitHub expands expressions before the shell executes the script. A pull request title, issue body, comment body, branch name, commit message, or author email can become script structure if you splice it directly into the shell. The recommended pattern is boring and correct: map untrusted context into environment variables, then quote shell variables.

The boring pattern loses to the snippet pattern every day.

The newer pressure is that GitHub is now forcing maintenance churn through the same ecosystem. GitHub’s changelog says Node 20 reaches end-of-life in April 2026, and Actions runners begin using Node 24 by default on June 2, 2026. Node 20 stays temporarily behind ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true until removal later in the fall. Node 24 also drops some old platform assumptions, including incompatibility with macOS 13.4 and lower and no official ARM32 support.

That sounds like routine platform hygiene, and in one sense it is. Dead runtimes should die. But watch what happens downstream. Issues like HashiCorp’s vault-action request to upgrade from node20 to node24 show how quickly an Actions runtime change becomes a transitive maintenance event for thousands of workflows. Users see deprecation warnings. Maintainers race to release action updates. Projects bump action versions. Some will pin better. Many will bump from one mutable tag to another mutable tag because the warning is loud and the supply-chain model is quiet.

This is the failure mode that deserves more attention: platform-driven churn creates exactly the moment when people relax pinning discipline. The dashboard says update. The job log says deprecated. The fix looks like changing @v3.4.0 to @v3.5.0 or @v4. If the organization does not already have policy, the migration becomes a refactor of trust boundaries performed by whoever got annoyed by the yellow warning first.

Nesbitt’s chronology is ugly because it shows there is no single magic bad feature. pull_request_target can be correct for labeling and deadly when it checks out untrusted fork code. Caches are performance tools until a poisoned cache crosses from an attacker-controlled PR into a trusted release workflow. OIDC is safer than static tokens until the workflow minting the OIDC token is writable by an injection bug. Tags are convenient until the upstream maintainer account is compromised. The platform’s strength is composability. The attack surface is composability with secrets.

GitHub has shipped and announced useful pieces around the edges. The April Actions updates include service container entrypoint customization and OIDC token support for repository custom properties, now generally available, which can make cloud trust policies less brittle. Those are real improvements. They also reinforce the point: the CI layer is becoming more like an identity fabric. More claims. More policy. More release authority. More reason the workflow language should be treated as infrastructure code with dependency governance, not as .github confetti.

The pragmatic answer today is not glamorous. Put permissions: {} at the top of workflows and add back only what each job needs. Stop using ${{ }} directly inside run: for attacker-controlled fields. Treat pull_request_target and issue_comment like loaded weapons. Pin actions to SHAs, then inspect composite actions and runtime downloads instead of pretending the first SHA ends the problem. Prefer isolated release workflows that run only on protected tags or environments. Use GitHub environments and manual approvals for publishing. Run a scanner like zizmor, which exists specifically to find common GitHub Actions security issues, and then make its findings blocking instead of advisory wallpaper.

The cultural answer is harsher. GitHub Actions became successful because it made automation look cheap. A YAML file in the repo, a marketplace action, a green badge, a release pushed to PyPI or npm or GHCR before anyone had to think too hard about identity. That convenience was the product. Now the same convenience is the liability.

There is an old internet pattern here. The thing that starts as glue becomes substrate. CGI scripts become web apps. Bash deploy scripts become platforms. Browser extensions become identity brokers. CI snippets become the root of trust for public software distribution. The danger is not that people made a bad trade in 2019. The danger is that they are still mentally pricing it as glue in 2026.

A workflow that can publish a package is production infrastructure. A uses: line in that workflow is a production dependency. A GitHub token with write scope is a deploy key. An OIDC permission is a mint. A cache restore step is a trust decision. A pull request title in a shell script is untrusted input wearing a friendly hat.

Call it CI if you want. Operationally, GitHub Actions is the package manager nobody audits, and it is already holding the signing pen.