Files
PaperClipAI/.github/workflows/refresh-lockfile.yml
T
DottaandClaude Opus 5.5 e2cf9a670f fix(runner-e2e): resync SDK public lock pins and make the lockfile bot keep them current (#20623)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The Runner support suite proves the plugin SDK public types from
pinned sources in `tests/runner-e2e/source-sdk-public-inputs.json`
> - That registry pins the committed `pnpm-lock.yaml` blob and a
`lockSha256` that must equal the `outputSha256` in
`tests/runner-e2e/report-lock.json`
> - The lockfile bot refresh #20615 changed the lock and
`report-lock.json`, but the bot does not write the SDK registry
> - The bot merges without CI, so the drift guard from #20579 could not
block it, and every PR now fails `ci / Runner support suite`
> - The guard also checked only the source blobs, so it did not name the
`lockSha256` drift, and the sync script could not update `lockSha256`
> - This pull request regenerates the registry, derives `lockSha256`
from the reviewed policy, and makes the bot resync and check the
registry in its own commit
> - The benefit is that master CI is green again, and the next lockfile
refresh cannot leave the SDK proof stale

## Linked Issues or Issue Description

Refs #20615. Refs #20579. Refs #20550. Refs #20613. Overlaps #20618,
which refreshes only the registry data. This PR has the same data change
and also closes the gap that let the drift land.

**What happened?**

After #20615 merged, `ci / Runner support suite` failed on master and on
every PR (for example runs 42877750315 and 42877750957, and PR #20613).
`source-sdk-public-type.test.ts` and `source-sdk-evidence.test.ts`
failed with "Reviewed SDK reference policy does not match public proof".
`source-sdk-public-inputs.test.mjs` failed with "pnpm-lock.yaml
(changed)".

**Expected behavior**

A lockfile refresh keeps the SDK public proof registry in step with the
lock and the reviewed report-lock policy. If the registry drifts, the
failure names the stale pin and the fix command.

**Steps to reproduce**

1. Check out master at `3bf7af6c77`.
2. Run `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
source-sdk`.
3. See the "Reviewed SDK reference policy does not match public proof"
failure.

## What Changed

- Root cause: #20615 changed `report-lock.json` from output `2360d5e9…`
to `b3208306…` and changed the `pnpm-lock.yaml` blob from `851e65af…` to
`6d825f97…`. The registry kept the old values. `refresh-lockfile.yml`
commits only the lockfiles and the report-lock proof, and it merges
without CI.
- `source-sdk-public-inputs.json`: regenerated with `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --write`. Only
`inputs["pnpm-lock.yaml"]` and `lockSha256` change. The compiler, parser
and Zod pins do not change.
- `source-sdk-public-inputs.mjs`: the sync now copies `lockSha256` from
the committed `report-lock.json` `outputSha256`. It refuses when the
committed lock is not that policy's reviewed input or output. This is
the same rule that `prepareReviewedSdkReference` applies. Only the
lockfile bot can write that policy, so a contributor cannot choose
`lockSha256`.
- `staleSdkPublicInputs()`: also reports a `lockSha256` that differs
from the committed report-lock output. The guard test then names both
stale pins and the `--write` command.
- `source-sdk-reviewed-fixture.ts`: the "Reviewed SDK reference policy
does not match public proof" error now names the two values and the fix
command. The check itself is unchanged.
- `refresh-lockfile.yml`: after the lock commit, the bot runs the SDK
sync with `--write`, amends the commit, and runs `--check` before it
pushes. `head_sha` is still read after the amend, so the bot merges the
exact commit it checked.
- Tests: new sync tests for a lockfile refresh and for an unreviewed
lock, and a workflow-order test in `lockfile-refresh-merge.test.mjs`.
- Docs: `doc/RELEASE-AUTOMATION-SETUP.md` §8.1 and
`tests/runner-e2e/OPERATIONS.md` describe the resync.

## Verification

- Reproduced on master: `source-sdk-public-type.test.ts` and
`source-sdk-evidence.test.ts` failed with the policy mismatch.
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
source-sdk prepare-report-lock sdk-canary workflow-security`: 9 files,
135 tests pass.
- Before the registry change, the extended guard reported
`pnpm-lock.yaml (changed), tests/runner-e2e/report-lock.json
outputSha256 (lockSha256 changed)` with the fix command.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs $(git rev-parse
HEAD) --check` reports `changed: false`.
- `node --test .github/scripts/tests/lockfile-refresh-merge.test.mjs
.github/scripts/tests/lockfile-refresh-workflows.test.mjs
.github/scripts/tests/lockfile-trust.test.mjs`: 39 tests pass.
- `pnpm test:e2e:runner:unit` on a host with load near 28: 6 files timed
out, and none of them are the SDK proof files. All 15 of those files
pass when run alone. CI runs the full suite.

## Risks

- The bot step now runs one more repository script. If that script
refuses (for example, an unreviewed committed lock), the refresh PR is
not pushed. Before this change, the bot merged a refresh that made
master red.
- `lockSha256` now comes from the reviewed policy, not from the old
registry value. The proof check is unchanged. It still needs the lock
bytes to hash to `lockSha256`.
- If #20618 merges first, this branch rebases cleanly, because the
registry change is identical.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edit) and code execution.

## 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-11-11 06:57:51 -08:00

321 lines
14 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
env:
# Every lockfile this workflow owns: the root one, then the sandbox
# providers' own lockfiles. Keep it equal to LOCKFILES in
# .github/scripts/lockfile-trust.mjs, which blocks contributor edits to
# the same files. A test checks that the two lists match.
LOCKFILES: pnpm-lock.yaml scripts/typecheck-sandbox-providers/pnpm-lock.yaml packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml
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 lockfiles the previous run held, so resolution keeps those
# versions while their manifests are unchanged and they can age past the
# gate. Read them 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
# An artifact saved before the provider lockfiles has no
# source-locks.sha256. Resolve from master instead.
if [ ! -f "$held/source-locks.sha256" ]; then
echo "Run $run_id held its refresh in an older format. Resolving from master."
exit 0
fi
# Reuse the held locks only while master still has every lock they
# were resolved from. A lock change on master, such as a maintainer's
# correction, supersedes them.
if ! sha256sum --check --status "$held/source-locks.sha256"; then
echo "A lockfile on master changed after run $run_id held its refresh. Resolving from master."
exit 0
fi
for lock in $LOCKFILES; do
cp "$held/$lock" "$lock"
done
echo "Resolving from the lockfiles held by run $run_id."
- name: Refresh pnpm lockfile
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
# Sandbox providers stay out of the root workspace. The shared typecheck
# workspace has its own pnpm-workspace.yaml, and Daytona installs alone.
- name: Refresh sandbox provider lockfiles
run: |
pnpm --dir scripts/typecheck-sandbox-providers install --resolution-only --ignore-scripts --no-frozen-lockfile
pnpm --dir packages/plugins/sandbox-providers/daytona install --ignore-workspace --resolution-only --ignore-scripts --no-frozen-lockfile
# A verified Dependabot lockfile merged to master is trusted lock content.
# Report its hash so the proof refresh can admit it.
- name: Identify a verified Dependabot lockfile on master
id: trusted_lock
env:
GH_TOKEN: ${{ github.token }}
run: node .github/scripts/lockfile-trust.mjs merged
- name: Refresh reviewed eval report dependency proof
env:
TRUSTED_LOCK_SHA256: ${{ steps.trusted_lock.outputs.trusted_lock_sha256 }}
run: |
if [ -n "$TRUSTED_LOCK_SHA256" ]; then
node tests/runner-e2e/prepare-report-lock.mjs --refresh --trusted-lock-sha256 "$TRUSTED_LOCK_SHA256"
else
node tests/runner-e2e/prepare-report-lock.mjs --refresh
fi
- 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 -v allowed="$LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch" '
BEGIN { n = split(allowed, files, " "); for (i = 1; i <= n; i++) ok[files[i]] = 1 }
!($2 in ok) { 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: |
# A verified Dependabot lockfile that already resolves changes only
# the reporting-lock proof. Commit that proof too.
if git diff --quiet -- $LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch; then
echo "Lockfiles and reporting-lock proof 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 $LOCKFILES tests/runner-e2e/report-lock.json tests/runner-e2e/report-lock.patch
git commit -m "chore(lockfile): refresh pnpm lockfiles"
# The SDK public source proof pins the committed lock blob and the
# report-lock output hash. This PR merges without CI, so resync those
# pins from the commit above and check them before pushing (#20615).
node tests/runner-e2e/source-sdk-public-inputs.mjs "$(git rev-parse HEAD)" --write
git add tests/runner-e2e/source-sdk-public-inputs.json
git commit --amend --no-edit
node tests/runner-e2e/source-sdk-public-inputs.mjs "$(git rev-parse HEAD)" --check
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.
- Refresh the sandbox provider lockfiles from their manifests on master.
- Include the matching offline report correction and hashes.
- Resync the SDK public source proof's lock pins from the refreshed lock.
## 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 lockfiles" \
--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: |
# Keep each base lockfile at its repository path. "Stage held
# lockfile" hashes them from there.
base="$RUNNER_TEMP/base-locks"
pairs=()
for lock in $LOCKFILES; do
mkdir -p "$base/$(dirname "$lock")"
# A lockfile that master does not have yet has no base, so all of its entries count as added.
git show "$GITHUB_SHA:$lock" > "$base/$lock" 2>/dev/null || : > "$base/$lock"
pairs+=(--base "$base/$lock" --head "$lock")
done
node .github/scripts/check-lockfile-release-age.mjs \
"${pairs[@]}" \
--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: |
held="$RUNNER_TEMP/held-upload"
for lock in $LOCKFILES; do
mkdir -p "$held/$(dirname "$lock")"
cp "$lock" "$held/$lock"
done
# Hash the master lockfiles this refresh started from, by repository
# path, so the next run can check them with sha256sum --check.
(cd "$RUNNER_TEMP/base-locks" && sha256sum $LOCKFILES) > "$held/source-locks.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"