-
Notifications
You must be signed in to change notification settings - Fork 3
Rebuild comment sync as a post-merge repair caller #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
301 changes: 22 additions & 279 deletions
301
.github/workflows/dependabot-sync-actions-comments.yml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,289 +1,32 @@ | ||
| name: Sync Dependabot action pin comments | ||
|
|
||
| # Dependabot's github-actions updater bumps SHA pins but cannot rewrite the | ||
| # `# vX.Y.Z` comment on lines that carry a second trailing comment (e.g. a | ||
| # zizmor suppression), so those comments go stale on every actions-group PR. | ||
| # | ||
| # This workflow runs trusted default-branch code after CI completes on a | ||
| # Dependabot actions branch and rewrites the stale comments in place: | ||
| # head-branch files are edited as *data* only — no head-branch code is ever | ||
| # executed, no `uses:` steps run, and the updater script is loaded from this | ||
| # run's immutable trusted commit. | ||
| # Post-merge backstop. Dependabot maintains the bare `# vX.Y.Z` comments on | ||
| # its own PRs natively (pins restructured to one-line-pin + standalone zizmor | ||
| # ignore). After workflow-file pushes land on main, the SHA-pinned reusable | ||
| # workflow recomputes the comments and, when drifted, opens a comment-only | ||
| # repair PR that auto-merges once checks pass. Only reviewed default-branch | ||
| # code executes; nothing is ever pushed to a Dependabot branch, so the | ||
| # Dependabot sandbox on its PRs is never disturbed. | ||
|
|
||
| on: | ||
| workflow_run: # zizmor: ignore[dangerous-triggers] -- runs only trusted default-branch code; the untrusted head branch is fetched as data, never executed (no checkout into an executing context, no `uses:` steps) | ||
| workflows: [CI] | ||
| types: [completed] | ||
| workflow_dispatch: | ||
| inputs: | ||
| branch: | ||
| description: Dependabot branch to sync (dependabot/github_actions/...) | ||
| required: true | ||
| type: string | ||
| e2e: | ||
| description: "E2E proof mode: expect a draft PR authored by the dispatcher on a dependabot/github_actions/e2e-proof-* branch" | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| push: | ||
| branches: [main] | ||
| # GitHub path filters don't do {yml,yaml} brace expansion — list both. | ||
| paths: | ||
| - ".github/workflows/*.yml" | ||
| - ".github/workflows/*.yaml" | ||
| workflow_dispatch: {} | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| sync: | ||
| name: Sync action pin comments | ||
| runs-on: ubuntu-latest | ||
| # Two explicit event paths: | ||
| # - workflow_dispatch: must run from main so GITHUB_SHA points at | ||
| # reviewed code (a non-main dispatch would execute unreviewed script | ||
| # content with contents:write). | ||
| # - workflow_run: only same-repo Dependabot-actored pull_request CI runs | ||
| # on dependabot/github_actions/* branches. The pull_request gate also | ||
| # breaks the re-trigger loop: the CI run this workflow dispatches after | ||
| # pushing is a workflow_dispatch run and is filtered out here. | ||
| if: >- | ||
| ( | ||
| github.event_name == 'workflow_dispatch' && | ||
| github.ref == 'refs/heads/main' | ||
| ) || ( | ||
| github.event_name == 'workflow_run' && | ||
| github.event.workflow_run.event == 'pull_request' && | ||
| github.event.workflow_run.actor.login == 'dependabot[bot]' && | ||
| github.event.workflow_run.head_repository.full_name == github.repository && | ||
| startsWith(github.event.workflow_run.head_branch, 'dependabot/github_actions/') | ||
| ) | ||
| uses: basecamp/.github/.github/workflows/dependabot-sync-actions-comments.yml@45d3fc9588f15d50fbccde7476418007dd19e16e | ||
| permissions: | ||
| contents: read # fetch the repo over https; the push itself uses the deploy key | ||
| actions: write # dispatch ci.yml / approve held runs so required checks attach | ||
| pull-requests: read # `gh pr list` in the pull request gate | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| steps: | ||
| - name: Set up git authentication | ||
| run: | | ||
| gh auth setup-git | ||
| git ls-remote "https://github.com/${GITHUB_REPOSITORY}" HEAD > /dev/null | ||
|
|
||
| - name: Resolve and validate the target branch and head SHA | ||
| id: target | ||
| env: | ||
| EVENT_NAME: ${{ github.event_name }} | ||
| RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} | ||
| RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | ||
| INPUT_BRANCH: ${{ inputs.branch }} | ||
| run: | | ||
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | ||
| BRANCH="$INPUT_BRANCH" | ||
| else | ||
| BRANCH="$RUN_HEAD_BRANCH" | ||
| fi | ||
| if ! printf '%s' "$BRANCH" | grep -qE '^dependabot/github_actions/[A-Za-z0-9._/-]+$'; then | ||
| echo "::error::branch is not an allowed dependabot/github_actions/* branch" | ||
| exit 1 | ||
| fi | ||
|
|
||
| # The captured SHA is both the detached checkout and the push-lease | ||
| # expectation. For workflow_run it is the audited head_sha; for | ||
| # dispatch it is resolved from the remote exactly once, here. | ||
| if [ "$EVENT_NAME" = "workflow_dispatch" ]; then | ||
| HEAD_SHA="$(git ls-remote "https://github.com/${GITHUB_REPOSITORY}" "refs/heads/${BRANCH}" | cut -f1)" | ||
| if [ -z "$HEAD_SHA" ]; then | ||
| echo "::error::branch not found on the remote" | ||
| exit 1 | ||
| fi | ||
| else | ||
| HEAD_SHA="$RUN_HEAD_SHA" | ||
| fi | ||
| if ! printf '%s' "$HEAD_SHA" | grep -qE '^[0-9a-f]{40}$'; then | ||
| echo "::error::resolved head SHA is not a 40-hex commit id" | ||
| exit 1 | ||
| fi | ||
|
|
||
| { echo "branch=$BRANCH"; echo "sha=$HEAD_SHA"; } >> "$GITHUB_OUTPUT" | ||
|
|
||
| # Exactly one open same-repo PR for the branch, base main, head still at | ||
| # the captured SHA. Normal paths additionally require the Dependabot App | ||
| # as PR author (gh reports it as app/dependabot — distinct from the | ||
| # dependabot[bot] *actor* checked in the job `if:`). E2E dispatch instead | ||
| # requires a draft PR authored by the dispatching human on a dedicated | ||
| # e2e-proof branch. Gate mismatches exit cleanly without writing. | ||
| - name: Verify the pull request gate | ||
| id: gate | ||
| env: | ||
| BRANCH: ${{ steps.target.outputs.branch }} | ||
| HEAD_SHA: ${{ steps.target.outputs.sha }} | ||
| INPUT_E2E: ${{ inputs.e2e }} | ||
| DISPATCHER: ${{ github.actor }} | ||
| run: | | ||
| skip() { echo "::notice::$1"; echo "proceed=false" >> "$GITHUB_OUTPUT"; exit 0; } | ||
|
|
||
| prs="$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$BRANCH" \ | ||
| --json number,headRefOid,isCrossRepository,isDraft,author)" | ||
| count="$(jq 'length' <<<"$prs")" | ||
| [ "$count" = "1" ] || skip "expected exactly one open PR for the branch, found $count" | ||
|
|
||
| cross="$(jq -r '.[0].isCrossRepository' <<<"$prs")" | ||
| oid="$(jq -r '.[0].headRefOid' <<<"$prs")" | ||
| author="$(jq -r '.[0].author.login' <<<"$prs")" | ||
| draft="$(jq -r '.[0].isDraft' <<<"$prs")" | ||
|
|
||
| [ "$cross" = "false" ] || skip "PR head is not in this repository" | ||
| [ "$oid" = "$HEAD_SHA" ] || skip "PR head has moved past the captured SHA; a later run will pick it up" | ||
|
|
||
| if [ "$INPUT_E2E" = "true" ]; then | ||
| case "$BRANCH" in | ||
| dependabot/github_actions/e2e-proof-*) ;; | ||
| *) echo "::error::e2e mode requires a dependabot/github_actions/e2e-proof-* branch"; exit 1 ;; | ||
| esac | ||
| if [ "$draft" != "true" ] || [ "$author" != "$DISPATCHER" ]; then | ||
| echo "::error::e2e mode requires a draft PR authored by the dispatcher" | ||
| exit 1 | ||
| fi | ||
| else | ||
| [ "$author" = "app/dependabot" ] || skip "PR is not authored by the Dependabot app" | ||
| fi | ||
|
|
||
| echo "proceed=true" >> "$GITHUB_OUTPUT" | ||
|
|
||
| # Fresh clone pinned to the captured head SHA (not the mutable branch). | ||
| # The updater script comes from GITHUB_SHA — the immutable commit of the | ||
| # reviewed default-branch code this run was triggered from — never from | ||
| # the head branch. | ||
| - name: Fetch the head commit and the trusted updater script | ||
| if: steps.gate.outputs.proceed == 'true' | ||
| env: | ||
| HEAD_SHA: ${{ steps.target.outputs.sha }} | ||
| run: | | ||
| git init -q repo | ||
| cd repo | ||
| git remote add origin "https://github.com/${GITHUB_REPOSITORY}" | ||
| git fetch -q --no-tags --depth 1 origin "$HEAD_SHA" "$GITHUB_SHA" | ||
| git show "$GITHUB_SHA:scripts/sync-action-pin-comments.mjs" > "$RUNNER_TEMP/sync.mjs" | ||
| git checkout -q --detach "$HEAD_SHA" | ||
|
|
||
| - name: Sync action pin comments | ||
| if: steps.gate.outputs.proceed == 'true' | ||
| working-directory: repo | ||
| run: node "$RUNNER_TEMP/sync.mjs" | ||
|
|
||
| - name: Commit and push behind a lease | ||
| if: steps.gate.outputs.proceed == 'true' | ||
| id: push | ||
| working-directory: repo | ||
| env: | ||
| BRANCH: ${{ steps.target.outputs.branch }} | ||
| HEAD_SHA: ${{ steps.target.outputs.sha }} | ||
| DEPLOY_KEY: ${{ secrets.SYNC_ACTIONS_DEPLOY_KEY }} | ||
| run: | | ||
| finish() { echo "::notice::$1"; echo "pushed=false" >> "$GITHUB_OUTPUT"; exit 0; } | ||
|
|
||
| git diff --quiet && finish "action pin comments already in sync; nothing to push" | ||
|
|
||
| # The ID-based noreply address associates the commit with | ||
| # github-actions[bot]; [dependabot skip] lets Dependabot keep | ||
| # force-rebasing the branch above our commit. | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
|
|
||
| git add -- .github/workflows | ||
|
|
||
| # Belt and braces: every staged diff line must be a SHA-pinned | ||
| # `uses:` line — the only thing the updater may touch. | ||
| bad="$(git diff --cached -U0 \ | ||
| | grep -E '^[+-]' \ | ||
| | grep -vE '^(\+\+\+|---)' \ | ||
| | grep -vE '^[+-][[:space:]]*(-[[:space:]]+)?uses:[[:space:]]+[A-Za-z0-9._-]+/[A-Za-z0-9./_-]+@[0-9a-f]{40}[[:space:]]+#' \ | ||
| || true)" | ||
| if [ -n "$bad" ]; then | ||
| echo "::error::staged diff touches lines other than pinned uses lines" | ||
| printf '%s\n' "$bad" | ||
| exit 1 | ||
| fi | ||
| git diff --cached --quiet && finish "no workflow changes staged; nothing to push" | ||
|
|
||
| git commit -q -m "chore(actions): sync action pin comments [dependabot skip]" | ||
|
|
||
| # The push must use the dedicated write deploy key, not GITHUB_TOKEN: | ||
| # GitHub refuses to let App tokens introduce new workflow-file | ||
| # content ("refusing to allow a GitHub App to create or update | ||
| # workflow ... without `workflows` permission"), and the fixed | ||
| # workflow files are exactly that. SSH deploy keys are exempt. The | ||
| # key exists solely for this workflow's pushes; GitHub's SSH host | ||
| # keys are pinned from the authenticated API rather than trusted on | ||
| # first use. | ||
| if [ -z "$DEPLOY_KEY" ]; then | ||
| echo "::error::SYNC_ACTIONS_DEPLOY_KEY secret is not configured" | ||
| exit 1 | ||
| fi | ||
| umask 077 | ||
| printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" | ||
| gh api meta --jq '.ssh_keys[]' | sed 's/^/github.com /' > "$RUNNER_TEMP/known_hosts" | ||
| export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/deploy_key -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts -o IdentitiesOnly=yes" | ||
|
|
||
| # Compare-and-swap: succeeds only if the remote branch still equals | ||
| # the SHA we fixed. If Dependabot rebased mid-run this fails cleanly | ||
| # and the next CI completion re-triggers us. | ||
| git push "git@github.com:${GITHUB_REPOSITORY}.git" \ | ||
| "HEAD:refs/heads/${BRANCH}" --force-with-lease="refs/heads/${BRANCH}:${HEAD_SHA}" | ||
| { echo "pushed=true"; echo "new_head=$(git rev-parse HEAD)"; } >> "$GITHUB_OUTPUT" | ||
|
|
||
| # A deploy-key push fires normal pull_request synchronize events, so CI | ||
| # should start on its own. Dispatch ci.yml — and security.yml, whose | ||
| # checks also gate Dependabot PRs — only if no pull_request CI run has | ||
| # appeared for the new head after a grace period: a fallback, not the | ||
| # norm. | ||
| - name: Dispatch CI on the new head if none started | ||
| if: steps.push.outputs.pushed == 'true' | ||
| env: | ||
| BRANCH: ${{ steps.target.outputs.branch }} | ||
| NEW_HEAD: ${{ steps.push.outputs.new_head }} | ||
| run: | | ||
| for _ in 1 2 3 4 5 6; do | ||
| count="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs" \ | ||
| -f event=pull_request -f branch="$BRANCH" -f per_page=100 \ | ||
| --jq '[.workflow_runs[] | select(.head_sha == env.NEW_HEAD and .name == "CI")] | length')" | ||
| if [ "${count:-0}" -gt 0 ]; then | ||
| echo "::notice::CI already running for ${NEW_HEAD}; no dispatch needed" | ||
| exit 0 | ||
| fi | ||
| sleep 10 | ||
| done | ||
| echo "::notice::no pull_request CI run appeared for ${NEW_HEAD}; dispatching" | ||
| gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH" | ||
| gh workflow run security.yml --repo "$GITHUB_REPOSITORY" --ref "$BRANCH" | ||
|
|
||
| # If the pushed head's pull_request runs get held for approval (the repo | ||
| # requires Actions approval for first-time contributors), a held run | ||
| # blocks the PR's merge state. Approve every held run for the exact | ||
| # commit this workflow just pushed, and only those. If approval is | ||
| # denied for GITHUB_TOKEN, surface a warning so a maintainer can approve | ||
| # from the PR page. | ||
| - name: Approve held pull_request runs for the pushed head | ||
| if: steps.push.outputs.pushed == 'true' | ||
| env: | ||
| BRANCH: ${{ steps.target.outputs.branch }} | ||
| NEW_HEAD: ${{ steps.push.outputs.new_head }} | ||
| run: | | ||
| # Poll the full window: held runs for the different pull_request | ||
| # workflows can materialize in staggered batches, so an empty | ||
| # snapshot is not proof there is nothing left to approve. Track | ||
| # already-approved run ids — the API can keep reporting them as | ||
| # action_required for a while, and re-approving would double-count | ||
| # or emit spurious warnings. | ||
| approved="" | ||
| for _ in 1 2 3 4 5 6; do | ||
| run_ids="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs" \ | ||
| -f event=pull_request -f branch="$BRANCH" -f per_page=100 \ | ||
| --jq '.workflow_runs[] | select(.head_sha == env.NEW_HEAD and .conclusion == "action_required") | .id')" | ||
| for run_id in $run_ids; do | ||
| case " $approved " in *" $run_id "*) continue ;; esac | ||
| if gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/approve" > /dev/null; then | ||
| echo "::notice::approved held run ${run_id} for ${NEW_HEAD}" | ||
| approved="$approved $run_id" | ||
| else | ||
| echo "::warning::could not approve held run ${run_id}; approve it manually from the PR page to unblock merging" | ||
| fi | ||
| done | ||
| sleep 10 | ||
| done | ||
| count="$(echo "$approved" | wc -w | tr -d ' ')" | ||
| echo "::notice::approved ${count} held run(s) for ${NEW_HEAD}" | ||
| contents: read | ||
| actions: write | ||
| pull-requests: write | ||
| # The deploy key is scoped to this repo's dependabot-sync environment | ||
| # (deployment branches: default branch only); environment secrets cannot | ||
| # be forwarded explicitly to a reusable workflow, so inherit is required. | ||
| secrets: inherit # zizmor: ignore[secrets-inherit] -- see above; the called workflow is SHA-pinned and reads only SYNC_ACTIONS_DEPLOY_KEY, gated by the dependabot-sync environment | ||
|
jeremy marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.