mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
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>
This commit is contained in:
1 parent
ebed7f5237
commit
7d194e901f
6 files changed
+518
-8
No files matched your search
@@ -4,6 +4,9 @@ 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:
|
||||
@@ -14,7 +17,11 @@ 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
|
||||
|
||||
@@ -36,6 +43,34 @@ jobs:
|
||||
# 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
|
||||
|
||||
@@ -65,6 +100,7 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -117,11 +153,11 @@ jobs:
|
||||
|
||||
- Pinned pnpm resolution completed with lifecycle scripts disabled.
|
||||
- The report proof refresh accepted the reviewed committed baseline.
|
||||
- Required PR checks and reviews must pass before merge.
|
||||
- The workflow rejected changes outside the lock and report proof files.
|
||||
|
||||
## Risks
|
||||
|
||||
Dependency changes require review. Unknown report lock baselines remain blocked. This update changes no permissions or paid enable gates.
|
||||
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
|
||||
|
||||
@@ -131,7 +167,7 @@ jobs:
|
||||
|
||||
- [x] Thinking path and problem description included
|
||||
- [x] Generated lock and matching report proof included
|
||||
- [ ] Required CI checks and reviews pass
|
||||
- [x] Release-age gate decides between bot merge and maintainer review
|
||||
PR_BODY
|
||||
pr_url="$(gh pr create \
|
||||
--head "$BRANCH" \
|
||||
@@ -142,10 +178,81 @@ jobs:
|
||||
echo "PR already exists: $pr_url"
|
||||
fi
|
||||
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge for lockfile PR
|
||||
# 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: |
|
||||
gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}"
|
||||
# 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"
|
||||
Reference in new issue
Block a user