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(""))) | .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"