heroku / heroku/languages-github-actions
Release workflow cache write fails on pull_request trigger after GitHub's read-only cache change
- Dominant language
- Rust
- Stars
- 4
- Forks
- 2
- Avg merge
- 12h 44m
- Merged PRs (30d)
- 6
Description
## Summary
The `_buildpacks-release.yml` reusable workflow fails when triggered by its `pull_request: [closed]` event, because the cross-job buildpack cache can no longer be written. The `compile` job logs:
```
Warning: Failed to save: Unable to reserve cache with key 28885892322-compiled-buildpacks. More details: cache write denied: token has no writable scopes
```
The `Cache buildpacks` step (`actions/cache/save`) reports success despite the warning, so no cache is reserved. The `publish-docker` and `publish-github` jobs then fail when restoring that cache with `fail-on-cache-miss: true`.
Example failure (buildpacks-procfile v4.2.3):
https://github.com/heroku/buildpacks-procfile/actions/runs/28885892322/job/85737625538
## Cause
On 2026-06-26 GitHub shipped [Read-only Actions cache for untrusted triggers](https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers/), which issues read-only cache tokens for the default-branch scope on `pull_request`-triggered runs. Our release workflow is triggered by `pull_request` (activity type `closed`), so its cache token no longer has writable scopes.
Confirmed by comparing two runs of the identical workflow (same `permissions: {}`, both printing `GITHUB_TOKEN Permissions: Metadata: read`):
| | Event | Ref | `Cache buildpacks` |
|---|---|---|---|
| Last success (2026-06-25) | `pull_request` | `prepare-release` | ✅ saved |
| Failure (2026-07-07) | `pull_request` | PR merge ref | ❌ `token has no writable scopes` |
| Manual recovery (2026-07-07) | `workflow_dispatch` | `main` | ✅ saved |
`push`, `workflow_dispatch`, `schedule`, etc. remain trusted (read-write cache), which is why a manual `workflow_dispatch` on `main` succeeds. Re-running the failed `pull_request` run does not help — it replays the same untrusted event context.
This affects every repo consuming this reusable workflow (buildpacks-procfile, -go, -php, -deb-packages, -dotnet, -ruby, -nodejs, …), so all releases via the auto-merge path are currently broken.
## Why the failure surfaced late (and was hard to debug)
`actions/cache/save` never fails its own step on a write/reservation error — by design it downgrades every failure to a warning ([`saveImpl.ts`](https://github.com/actions/cache/blob/main/save/src/saveImpl.ts) catches all errors and calls `logWarning`, never `setFailed`; the [README](https://github.com/actions/cache/tree/main/save) explains this was to stop flaky saves failing long builds). There is no save-side equivalent of restore's `fail-on-cache-miss`.
So the `compile` job went fully green even though nothing was cached, and the real failure only appeared later in `publish-docker` / `publish-github` as a cache-*miss* on restore — pointing at the wrong job and obscuring the actual cause (the denied write). Worth considering a guard that fails fast on the write side, e.g. asserting the cache exists at the end of `compile`, or checking the save step's `cache-hit`/outputs, so a denied write surfaces immediately rather than one job downstream.
## Possible fixes
1. **Switch the release trigger from `pull_request: [closed]` to `push` to `main`** (a trusted trigger). This restores writable cache scopes. The reason we currently use `pull_request` is to know which "prepare release" PR to post release-progress/failure comments on; on `push` the PR number can be recovered from the merge commit via `GET /repos/{owner}/{repo}/commits/{sha}/pulls` (returns the PR number, `head.ref`, and author, so the existing `prepare-release` / `heroku-linguist[bot]` guard can be reconstructed). Note `push` fires for every commit to `main`, so the job guard must confirm the associated PR really is the prepare-release PR before commenting.
2. **Pass buildpacks between jobs via `actions/upload-artifact` + `download-artifact`** instead of `actions/cache`. Artifacts have no ref-scoped write restriction and are the intended mechanism for within-run job handoffs. This keeps the `pull_request` trigger but replaces the `${{ github.run_id }}-compiled-buildpacks` cache in `compile` / `publish-docker` / `publish-github`.
### Cache vs artifacts for the buildpack handoff
Passing `./packaged` from `compile` to the publish jobs within a single run is the textbook use case for **artifacts**, not cache — the cache is keyed on `${{ github.run_id }}`, so there's no cross-run reuse; it's being used as an intra-run pipe. Comparison:
- **Trust model:** artifacts are unaffected by the 2026-06-26 change and upload fine on `pull_request` runs; cache writes are now denied on that trigger. (This is the bug.)
- **Fail-fast:** `upload-artifact` fails the producing step on error (and with `if-no-files-found: error`), and `download-artifact` fails on a missing artifact — so problems surface in `compile` rather than a job downstream. Cache save only warns.
- **`permissions: {}`:** within-run upload/download work under `permissions: {}`; only cross-run artifact download would need `actions: read` (not needed here).
- **Performance:** roughly a wash for this payload. Both use the same Azure backend (v4+). Cache compresses with multithreaded zstd (`zstdmt`) vs artifacts' single-threaded zip, but `./packaged` is mostly already-compressed binaries/OCI content, so the ratio/speed gap is small; cache's cross-run dedup and `restore-keys` fallback give no benefit with a per-run key. Difference is a few seconds against multi-minute publish steps.
- **Lifetime/cost:** cache is free but repo-capped/evictable; artifacts incur (small) storage billing and persist by default — mitigate with `retention-days: 1` since they're only needed within the run.
Note the `Swatinem/rust-cache` step in `compile` is a genuine *cross-run* build cache (highly compressible, reused every release, best-effort) — it should stay on `actions/cache` and is exactly where zstd + dedup pay off. Its write is also currently denied on `pull_request`, but a miss there is harmless (just a slower compile).
## Recommendation
We should probably do **both** fixes, since they address different problems:
- **Artifacts (2)** for the `compile` → publish handoff — the correct design for an intra-run transfer regardless of the trust change, and it fails fast.
- **`push` trigger (1)** so the workflow runs on a trusted event — this restores writable cache scopes for `Swatinem/rust-cache` (which we want to keep as a cache), which switching to artifacts alone would not.
Either fix alone unblocks releases; together they both fix the handoff correctly and keep the rust build cache working.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with _buildpacks-release.yml and trace the compile, publish-docker, and publish-github jobs, including the Cache buildpacks and Swatinem/rust-cache steps. Compare the pull_request and workflow_dispatch behavior, then verify the chosen trigger and artifact or cache handoff with a release workflow run; done means automatic releases succeed and failures surface at the producing step.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions
- Domain
- ci-cd, devops, release
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100