mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The repository has a supply-chain rule: contributors do not commit lockfiles. A bot and verified Dependabot commits write them. > - The rule checks only the root `pnpm-lock.yaml`. > - The sandbox providers are outside the root workspace, so they have two more pnpm lockfiles. Pull requests edited them by hand, for example #12791, #18839 and #19489. > - This pull request makes the same rule and the same bot cover those two lockfiles. > - The benefit is one lockfile rule for every managed pnpm lockfile, and no lockfile edits in contributor pull requests. ## Linked Issues or Issue Description Refs #19489 **What existing behavior does this improve?** The lockfile policy and the lockfile refresh bot. **Subsystem affected** CI: `.github/scripts/lockfile-trust.mjs`, `.github/workflows/refresh-lockfile.yml`, and the sandbox provider installs. **Current behavior** `lockfile-trust.mjs` blocks contributor edits to `pnpm-lock.yaml` only. A contributor can edit `scripts/typecheck-sandbox-providers/pnpm-lock.yaml` or `packages/plugins/sandbox-providers/daytona/pnpm-lock.yaml`, and CI passes. The refresh bot does not update these files. `typecheck:test-programs` fails on a stale provider lockfile and tells the contributor to commit a new one. **Proposed behavior** The policy blocks contributor edits to all three lockfiles. The refresh bot regenerates all three after merge. CI resolves a stale provider lockfile in place for the run, as it does for the root lockfile. **Reason and benefit** Lockfile content comes only from the bot or from verified Dependabot commits. Contributors do not need to run pnpm in special folders. **Breaking changes** None. A pull request that edits a provider lockfile now fails the policy check. Remove the lockfile change from the pull request. The bot commits it after merge. ## What Changed - `lockfile-trust.mjs` now exports `LOCKFILES`: the root lockfile, the shared sandbox provider lockfile, and the Daytona lockfile. The policy blocks contributor edits to each of them. A Dependabot pull request must match a verified Dependabot commit for each lockfile that it changes. The `merged` command and the reporting-lock proof still use only the root lockfile. - `check-pr-lockfile.mjs` (advisory check) uses the same list. - `refresh-lockfile.yml`: - A job-level `LOCKFILES` env lists the same three files. A test checks that it equals the policy list. - A new step regenerates the two provider lockfiles with `--resolution-only --ignore-scripts --no-frozen-lockfile`. - The unexpected-changes check, the change check, and `git add` cover all three lockfiles. - The release-age gate checks the added versions in every lockfile. - A held refresh saves all three lockfiles at their repository paths, with a `source-locks.sha256` file. The next run reuses them only if every master lockfile is unchanged. An artifact in the old format is not reused. - The PR title is now `chore(lockfile): refresh pnpm lockfiles`. - `check-lockfile-release-age.mjs` accepts more than one `--base`/`--head` pair. - `scripts/typecheck-test-programs.mjs`: if the frozen install of the provider workspace fails, the script resolves the lockfile in place and installs frozen again. - `scripts/build-standalone-public-packages.mjs`: the same fallback applies to a package with a committed lockfile (Daytona). `release.sh` already restores tracked files when it exits. - `doc/DEVELOPING.md` and `doc/RELEASE-AUTOMATION-SETUP.md` describe the managed lockfiles. They also say that the Pi and Kimi distribution lockfiles stay hand-managed, because they hold attested provider pins. ## Verification - `node --test .github/scripts/tests/*.test.mjs scripts/__tests__/build-standalone-concurrency.test.mjs`: 731 tests pass. New tests cover: - the policy for provider lockfiles, including a real git history with a Dependabot commit and a later hand edit - the workflow and policy lists match - the unexpected-changes check, run with bash against a fixture - a held refresh round trip, and its invalidation when a provider lockfile changes on master - the release-age gate across lockfile pairs - the inline fallback in both install scripts - `node --test scripts/typecheck-test-programs.test.mjs`: the install tests pass. Two tests fail locally only because this worktree has no root `node_modules` (no `tsc`). They also fail without this change. - I ran the new bot commands on master with pnpm 9.15.4. No package version changed. pnpm 9.15.4 removes `libc:` fields and adds `deprecated:` notes, so the first refresh is a metadata-only change. The release-age gate passes it because it adds no versions. - I ran the release-age and held-lock steps locally with bash against this commit. The gate reported `eligible=true`, and `sha256sum --check` accepted the saved hashes. - The workflow YAML parses, and `bash -n` accepts every `run` block. - Not testable before merge: the policy job loads `lockfile-trust.mjs` from the base branch, so the new rule applies only to pull requests opened after this merges. The refresh workflow runs only on master. ## Risks - The release build now resolves a stale Daytona lockfile in place, from the registry. The root release install already resolves without a frozen lockfile, so this is the same level of trust. - The paid runner e2e job still installs Daytona with `--frozen-lockfile`. After a Daytona manifest change merges, its Daytona cases fail until the bot's refresh merges. This matches the rule that paid jobs refuse a new root lockfile until its refresh merges. - Dependabot pull requests for the provider entries in #19489 change a provider lockfile. They pass only if each lockfile matches a verified Dependabot commit, the same as for the root lockfile. - Low risk otherwise: the bot keeps its existing token, environment, and merge rules. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, and git). ## 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>
313 lines
14 KiB
YAML
313 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"
|
|
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.
|
|
|
|
## 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"
|