Files
PaperClipAI/.github/workflows/refresh-lockfile.yml
T
Nicky LeachandClaude Opus 5.5 7d194e901f feat(ci): merge lockfile refresh PRs through a dedicated bot (#18238)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - CI owns `pnpm-lock.yaml`. Contributors do not commit it, so a
poisoned lockfile cannot come in from a community PR.
> - `refresh-lockfile.yml` regenerates the lockfile from the manifests
on master and opens a PR.
> - That PR waits on required checks and code owner review. A
`GITHUB_TOKEN` push does not start the checks, so a maintainer must
merge each PR by hand.
> - The PR content comes only from manifests that maintainers already
reviewed on master. A human review adds little.
> - This pull request lets a dedicated GitHub App merge the refresh PR
at once, without CI or review.
> - A release-age gate holds the merge when the refresh adds a package
version that is less than 3 days old on npm.
> - The benefit is a lockfile loop that closes itself, without a bypass
for any identity that other workflows can use.

## Linked Issues or Issue Description

Refs #12115 (the same stuck-PR problem, fixed there by starting the
checks). Refs #18219 (an example refresh PR that needed a manual
approval and merge).

**What happened?**

Each lockfile refresh PR stays open until a code owner approves it and a
maintainer merges it. The required checks `ci / verify` and `ci / e2e`
also do not start, because the workflow pushes with `GITHUB_TOKEN`.

**Expected behavior**

The refresh PR merges with no human action and no CI run, unless it adds
a package version that is too new to trust without review.

**Steps to reproduce**

Merge a manifest change to master. Look at the `chore/refresh-lockfile`
PR that the workflow opens. It stays blocked.

## What Changed

- `refresh-lockfile.yml` runs in a new `lockfile-refresh` environment.
Only `master` can use this environment.
- The workflow creates a token for a dedicated lockfile bot app after
dependency resolution is complete. The token has only contents and pull
request write access.
- The workflow merges with `gh pr merge --squash --admin
--match-head-commit <sha>`, not `--auto`. It merges only the commit that
it pushed.
- New `.github/scripts/check-lockfile-release-age.mjs` compares the base
and refreshed lockfiles. It looks up the npm publish time of each added
package version. The bot merges only when every version is at least 3
days old. A lookup error, a missing publish time, a non-registry
version, or a resolution that is not a plain registry tarball also holds
the merge.
- When the gate holds the PR, the workflow posts or updates one report
comment on the PR and does not merge.
- A daily schedule runs the refresh again, so a held PR merges after its
versions are old enough.
- A held run uploads its lockfile as a `held-pnpm-lock` artifact. The
artifact records the hash of the master lockfile it started from. The
next master run resolves from it only while master still has that
lockfile, so held versions stay while their manifests do not change, and
a maintainer's lockfile correction replaces them. The workflow does not
read the PR branch for this, because users with write access can push to
it.
- The generated PR body now describes the gate.
- `tests/runner-e2e/OPERATIONS.md` now says that the bot can merge the
report-lock correction without review, under the same gate.
- New `lockfile-refresh-merge.test.mjs` pins the merge safeguards: the
environment, the step order, the gate conditions, and
`--match-head-commit`.
- `doc/RELEASE-AUTOMATION-SETUP.md` §8.1 tells an admin how to create
the app, the environment, and the ruleset bypass. It also tells why we
do not use `github-actions` or `commitperclip` for the bypass.

## Verification

- `node --test .github/scripts/tests/check-lockfile-release-age.test.mjs
.github/scripts/tests/lockfile-refresh-*.test.mjs` passes (17/17).
- I ran the gate against the real lockfile diff of #18219 and the live
npm registry. With 3 days, all 7 added versions passed
(`eligible=true`). With 60 days, it held all 7 `turbo` 2.11.10 versions
(9.1 days old) and wrote the report.
- The workflow YAML parses. actionlint did not run, because it is not
installed locally and Docker was not available.
- No live run yet. After the admin setup in §8.1, run the workflow with
`workflow_dispatch` on master, or merge a manifest change. Then make
sure that the bot opens and merges the refresh PR.

## Risks

- **Setup order.** An org admin must do the steps in §8.1 before this PR
merges. If not, the next refresh fails at the token step, and the PR
stays open for a manual merge, as it does now.
- **No CI on lockfile changes.** A bad resolution can land on master
without a check. The next manifest PR or the canary release shows the
problem.
- **Release-age gate limits.** The gate stops only fast-detected attacks
on new versions. A malicious version that stays undetected for more than
3 days still merges without review. Lockfile changes that add very new
versions now wait for a maintainer or up to 3 days.
- **Bypass scope.** The app bypasses the master ruleset only in pull
request mode. It cannot push to master directly. Only the master-only
`lockfile-refresh` environment holds its key. If the key leaks, the
holder can merge any open PR past review. Rotate the key if you think it
leaked.
- **More master runs.** A merge by an app token starts push workflows on
master. A merge by `GITHUB_TOKEN` does not. After each lockfile merge,
the canary release runs, and the refresh workflow runs again with no
change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edit, GitHub CLI).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:55:02 -07:00

259 lines
11 KiB
YAML

name: Refresh Lockfile
on:
push:
branches:
- master
schedule:
# Retry refreshes held by the release-age gate once their versions are old enough.
- cron: "23 6 * * *"
workflow_dispatch:
concurrency:
group: refresh-lockfile-master
cancel-in-progress: false
jobs:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 10
# Holds the lockfile bot key. Restrict this environment to master so no
# other branch or workflow can mint a token that bypasses master rules.
environment: lockfile-refresh
permissions:
actions: read
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.4
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
# Resolution-only installs do not populate the package store. Do not
# claim the shared cache key with an empty archive before full installs.
package-manager-cache: false
# Start from the lockfile the previous run held, so resolution keeps those
# versions while their manifests are unchanged and they can age past the
# gate. Read it from that run's artifact, not the PR branch: anyone with
# write access can push to chore/refresh-lockfile.
- name: Reuse versions from a held refresh
env:
GH_TOKEN: ${{ github.token }}
run: |
run_id="$(
gh run list --workflow refresh-lockfile.yml --branch master --status success --limit 20 \
--json databaseId,event \
--jq '[.[] | select(.event == "push" or .event == "schedule" or .event == "workflow_dispatch")][0].databaseId // empty'
)"
held="$RUNNER_TEMP/held-lock"
if [ -z "$run_id" ] || ! gh run download "$run_id" --name held-pnpm-lock --dir "$held" 2>/dev/null; then
echo "The previous refresh held nothing. Resolving from master."
exit 0
fi
# Reuse the held lock only while master still has the lock it was
# resolved from. A lock change on master, such as a maintainer's
# correction, supersedes it.
if [ "$(sha256sum < pnpm-lock.yaml)" != "$(cat "$held/source-lock.sha256")" ]; then
echo "The lockfile on master changed after run $run_id held its refresh. Resolving from master."
exit 0
fi
cp "$held/pnpm-lock.yaml" pnpm-lock.yaml
echo "Resolving from the lockfile held by run $run_id."
- name: Refresh pnpm lockfile
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
- name: Refresh reviewed eval report dependency proof
run: node tests/runner-e2e/prepare-report-lock.mjs --refresh
- name: Fail on unexpected file changes
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
unexpected="$(printf '%s\n' "$changed" | awk '$2 != "pnpm-lock.yaml" && $2 != "tests/runner-e2e/report-lock.json" && $2 != "tests/runner-e2e/report-lock.patch" { print }')"
if [ -n "$unexpected" ]; then
echo "Unexpected files changed during lockfile refresh:"
echo "$changed"
exit 1
fi
- name: Create or update pull request
id: upsert-pr
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
echo "pr_url=" >> "$GITHUB_OUTPUT"
echo "head_sha=" >> "$GITHUB_OUTPUT"
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push --force origin "$BRANCH"
# Only reuse an open PR from this repository owner, not a fork with the same branch name.
pr_url="$(
gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \
--jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" |
head -n 1
)"
if [ -z "$pr_url" ]; then
pr_body_file="$RUNNER_TEMP/lockfile-pr-body.md"
cat > "$pr_body_file" <<'PR_BODY'
## Thinking Path
> - Paperclip uses a dependency lock for reproducible builds.
> - Dependency changes on master need matching lock entries.
> - Paid eval reports also pin a reviewed offline correction.
> - Reports must install before paid model work starts.
> - This automated PR keeps the lock and report proof together.
## Linked Issues or Issue Description
**What happened?**
Dependency manifests on master changed after the last committed lock refresh.
**Expected behavior**
Frozen installs must match those manifests. Eval reports must retain matching offline lock proof.
**Steps to reproduce**
Run a frozen install before and after this lock refresh.
## What Changed
- Refresh the dependency lock from the manifests on master.
- Include the matching offline report correction and hashes.
## Verification
- Pinned pnpm resolution completed with lifecycle scripts disabled.
- The report proof refresh accepted the reviewed committed baseline.
- The workflow rejected changes outside the lock and report proof files.
## Risks
The lockfile bot merges this PR without CI or review when every added package version is at least 3 days old on npm. Otherwise the PR waits for a maintainer, and the daily refresh tries again. The bot merges only the exact commit this workflow generated from manifests already reviewed on master. Unknown report lock baselines remain blocked. This update changes no permissions or paid enable gates.
## Model Used
None — generated by pnpm and the deterministic report-lock updater.
## Checklist
- [x] Thinking path and problem description included
- [x] Generated lock and matching report proof included
- [x] Release-age gate decides between bot merge and maintainer review
PR_BODY
pr_url="$(gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body-file "$pr_body_file")"
echo "Created new PR: $pr_url"
else
echo "PR already exists: $pr_url"
fi
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
# Resolution picks the newest matching version for any added or widened
# dependency. Merge without review only when every added version has been
# on npm long enough for a hijacked release to be caught and pulled.
- name: Check release age of added package versions
id: release-age
if: steps.upsert-pr.outputs.pr_url != ''
run: |
git show "$GITHUB_SHA:pnpm-lock.yaml" > "$RUNNER_TEMP/base-pnpm-lock.yaml"
node .github/scripts/check-lockfile-release-age.mjs \
--base "$RUNNER_TEMP/base-pnpm-lock.yaml" \
--head pnpm-lock.yaml \
--min-age-days 3 \
--report "$RUNNER_TEMP/release-age-report.md"
cat "$RUNNER_TEMP/release-age-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Hold lockfile PR for review
if: steps.release-age.outputs.eligible == 'false'
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ steps.upsert-pr.outputs.pr_url }}
run: |
# Keep one report comment per PR instead of adding one on every run.
pr_number="${PR_URL##*/}"
comment_id="$(
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$pr_number/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("<!-- lockfile-release-age -->"))) | .id' |
head -n 1
)"
if [ -n "$comment_id" ]; then
gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$comment_id" \
-F "body=@$RUNNER_TEMP/release-age-report.md" > /dev/null
else
gh pr comment "$PR_URL" --body-file "$RUNNER_TEMP/release-age-report.md"
fi
echo "::notice title=Lockfile PR held::Added package versions are too new to merge without review. See $PR_URL"
- name: Stage held lockfile
if: steps.release-age.outputs.eligible == 'false'
run: |
mkdir -p "$RUNNER_TEMP/held-upload"
cp pnpm-lock.yaml "$RUNNER_TEMP/held-upload/pnpm-lock.yaml"
sha256sum < "$RUNNER_TEMP/base-pnpm-lock.yaml" > "$RUNNER_TEMP/held-upload/source-lock.sha256"
- name: Save held lockfile for the next refresh
if: steps.release-age.outputs.eligible == 'false'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: held-pnpm-lock
path: ${{ runner.temp }}/held-upload/
if-no-files-found: error
retention-days: 7
# Mint the bypass token only after resolution has finished, so no earlier
# step can read it. The lockfile bot is a master ruleset bypass actor in
# pull_request mode: it can merge PRs past checks and review, but cannot
# push to master directly. See doc/RELEASE-AUTOMATION-SETUP.md.
- name: Create lockfile bot token
id: bot-token
if: steps.release-age.outputs.eligible == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ vars.LOCKFILE_BOT_CLIENT_ID }}
private-key: ${{ secrets.LOCKFILE_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- name: Merge lockfile PR
if: steps.release-age.outputs.eligible == 'true'
env:
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
PR_URL: ${{ steps.upsert-pr.outputs.pr_url }}
HEAD_SHA: ${{ steps.upsert-pr.outputs.head_sha }}
run: |
# Merge only the commit this job pushed. Anything else pushed to the
# branch since then makes GitHub refuse the merge.
gh pr merge "$PR_URL" --squash --admin --delete-branch --match-head-commit "$HEAD_SHA"