From 51c7df41238b7048a2c453ef63ad25b328de58a7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 12:56:02 -0700 Subject: [PATCH 1/2] ci: reusable Dependabot action-pin-comment sync workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 bump across our repos. Port openclaw-basecamp's hardened comment-sync automation as a reusable workflow: callers trigger it via workflow_run after CI completes on a Dependabot actions branch; it runs only trusted default-branch code, fetches the head branch as data (never executes it), rewrites stale version comments with an embedded zero-dependency updater script, proves the diff touches nothing but pinned uses lines, and pushes behind a compare-and-swap lease with a dedicated write deploy key (App tokens, GITHUB_TOKEN included, cannot push workflow-file changes; SSH deploy keys are exempt — openclaw-basecamp@54c96d3e). The updater script is embedded in the workflow heredoc so the caller's SHA pin covers it byte for byte; scripts/sync-action-pin-comments.mjs (verbatim from openclaw-basecamp) is the tested source of truth, with the vitest suite ported to node --test and a CI drift gate asserting the embedded copy matches. --- .github/workflows/ci.yml | 17 + .../dependabot-sync-actions-comments.yml | 676 ++++++++++++++++++ scripts/check-embedded-sync.sh | 20 + scripts/sync-action-pin-comments.mjs | 323 +++++++++ scripts/sync-action-pin-comments.test.mjs | 241 +++++++ 5 files changed, 1277 insertions(+) create mode 100644 .github/workflows/dependabot-sync-actions-comments.yml create mode 100755 scripts/check-embedded-sync.sh create mode 100644 scripts/sync-action-pin-comments.mjs create mode 100644 scripts/sync-action-pin-comments.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4bb99e..0833315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,20 @@ jobs: uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 with: advanced-security: false + + script-tests: + name: Script tests + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run node --test + run: node --test "scripts/*.test.mjs" + + - name: Check embedded updater script matches source + run: bash scripts/check-embedded-sync.sh diff --git a/.github/workflows/dependabot-sync-actions-comments.yml b/.github/workflows/dependabot-sync-actions-comments.yml new file mode 100644 index 0000000..5d0a3a2 --- /dev/null +++ b/.github/workflows/dependabot-sync-actions-comments.yml @@ -0,0 +1,676 @@ +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. +# +# Reusable workflow (hardened model from openclaw-basecamp): the caller +# triggers it on workflow_run after CI completes on a Dependabot actions +# branch, and it 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 embedded in this workflow file, so the +# caller's SHA pin covers it byte for byte (scripts/ holds the tested source; +# CI enforces the embedded copy matches). The push uses a dedicated write deploy +# key: GitHub refuses to let App tokens (GITHUB_TOKEN included) create or +# update workflow files, and the comment fix is exactly that; SSH deploy keys +# are exempt. +# +# Caller contract (thin caller in each repo): +# on: +# workflow_run: # needs a dangerous-triggers ignore with rationale +# workflows: [] +# types: [completed] +# workflow_dispatch: +# inputs: {branch: {type: string, required: true}, e2e: {type: boolean, default: false}} +# jobs: +# sync: +# uses: basecamp/.github/.github/workflows/dependabot-sync-actions-comments.yml@ +# with: {ci-workflow-name: , branch: "${{ inputs.branch || '' }}", e2e: "${{ inputs.e2e || false }}"} +# permissions: {contents: read, actions: write, pull-requests: read} +# secrets: {deploy_key: "${{ secrets.SYNC_ACTIONS_DEPLOY_KEY }}"} + +on: + workflow_call: + inputs: + ci-workflow-name: + description: >- + Name of the pull_request CI workflow (the one the caller's + workflow_run trigger names). Used to detect whether CI started on the + pushed head before falling back to a dispatch. + required: true + type: string + default-branch: + description: The caller repository's default branch + required: false + default: main + type: string + dispatch-workflows: + description: >- + Space-separated workflow files to dispatch on the new head if no + pull_request CI run appears (fallback only) + required: false + default: test.yml security.yml + type: string + branch: + description: Dependabot branch to sync (workflow_dispatch path only) + required: false + default: "" + 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 + secrets: + deploy_key: + description: >- + Write deploy key for the caller repository, used solely for the + lease push (SSH keys are exempt from the App-token workflow-file + restriction) + required: true + +permissions: {} + +jobs: + sync: + name: Sync action pin comments + runs-on: ubuntu-latest + # Serialize per branch: repos whose PR CI spans multiple workflow_run + # completions (or two workflows sharing one name) trigger several runs per + # head; the queued run then sees the head moved and skips cleanly instead + # of racing the lease push. + concurrency: + group: dependabot-sync-actions-comments-${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.event.workflow_run.head_branch }} + cancel-in-progress: false + # Two explicit event paths: + # - workflow_dispatch: must run from the default branch so the workflow + # definition and script pin are reviewed code (a non-default-branch + # dispatch would execute unreviewed content with push credentials). + # - 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 == format('refs/heads/{0}', inputs.default-branch) + ) || ( + 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/') + ) + permissions: + contents: read # fetch the repo over https; the push itself uses the deploy key + actions: write # dispatch CI / 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 = default branch, + # 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 }} + BASE_BRANCH: ${{ inputs.default-branch }} + 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 "$BASE_BRANCH" --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 head branch is fetched as data only — nothing from it is executed. + - name: Fetch the head commit + 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" + git checkout -q --detach "$HEAD_SHA" + + # The updater script is embedded here so the caller's SHA pin on this + # reusable workflow covers it byte for byte — no runtime fetch, no + # mutable ref, no extra permissions. scripts/sync-action-pin-comments.mjs + # in basecamp/.github is the tested source of truth; CI fails if this + # embedded copy drifts from it (scripts/check-embedded-sync.sh). + - name: Materialize the trusted updater script + if: steps.gate.outputs.proceed == 'true' + run: | + cat > "$RUNNER_TEMP/sync.mjs" <<'SYNC_MJS' + #!/usr/bin/env node + // Sync the trailing `# vX.Y.Z` comments on SHA-pinned `uses:` lines in + // .github/workflows with the tags each pinned SHA actually points at. + // + // Dependabot's github-actions updater rewrites SHA pins but cannot rewrite the + // version comment on lines that carry a second trailing comment (e.g. a + // `# zizmor: ignore[...]` suppression), leaving the comment stale. This script + // applies zizmor's documented rule instead: the comment token must name a tag + // that points at the pinned SHA. Everything after the token — including zizmor + // annotations — is preserved byte for byte. + // + // Zero dependencies; pure core (exported for tests) + thin I/O shell. + // + // Usage: + // node scripts/sync-action-pin-comments.mjs # rewrite in place + // node scripts/sync-action-pin-comments.mjs --check # report only; exit 1 if stale + // + // Exit codes: 0 = in sync (or successfully rewritten); 1 = --check found stale + // comments; 2 = error (resolution failure, malformed data, guard trip) — the + // tree is left untouched. + + import { execFile } from "node:child_process"; + import { lstatSync, readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { pathToFileURL } from "node:url"; + import { promisify } from "node:util"; + + const execFileAsync = promisify(execFile); + + // --------------------------------------------------------------------------- + // Pure core + // --------------------------------------------------------------------------- + + // Groups partition the whole line: prefix / action path / SHA / comment + // lead-in / version token / rest-of-line (verbatim, carries any annotations). + export const PIN_LINE_RE = /^(\s*(?:-\s+)?uses:\s+)([\w.-]+\/[\w./-]+)@([0-9a-f]{40})(\s+#\s*)(v?\d[^\s#]*)(.*)$/; + + // Tags eligible as version comments: v-optional dotted numerics with an + // optional prerelease suffix. + export const TAG_RE = /^v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/; + + const LS_REMOTE_LINE_RE = /^([0-9a-f]{40})\trefs\/tags\/([^\s^]+)(\^\{\})?$/; + + export class SyncError extends Error {} + + /** Parse one workflow line; null if it is not a SHA-pinned uses line with a version comment. */ + export function parsePinnedLine(line) { + const m = PIN_LINE_RE.exec(line); + if (!m) return null; + const [, prefix, action, sha, leadIn, token, rest] = m; + return { prefix, action, sha, leadIn, token, rest }; + } + + /** owner/repo for an action path, or null for paths that are not resolvable GitHub repos. */ + export function repoKeyFor(action) { + const segments = action.split("/"); + if (segments.length < 2) return null; + const [owner, repo] = segments; + const valid = (s) => /^[\w.-]+$/.test(s) && s !== "." && s !== ".."; + return valid(owner) && valid(repo) ? `${owner}/${repo}` : null; + } + + /** + * Parse `git ls-remote --tags` output into a Map of sha -> [tag, ...]. + * Only version-shaped tags (TAG_RE) are indexed. Peeled `^{}` entries (the + * commit an annotated tag points at) override the tag-object sha. + * Throws SyncError on any line that does not have the ls-remote shape. + */ + export function parseLsRemoteOutput(output) { + const tagToSha = new Map(); + for (const line of output.split("\n")) { + if (line === "") continue; + const m = LS_REMOTE_LINE_RE.exec(line); + if (!m) throw new SyncError(`malformed ls-remote line: ${JSON.stringify(line)}`); + const [, sha, tag, peeled] = m; + if (!TAG_RE.test(tag)) continue; + if (peeled || !tagToSha.has(tag)) tagToSha.set(tag, sha); + } + const shaToTags = new Map(); + for (const [tag, sha] of tagToSha) { + if (!shaToTags.has(sha)) shaToTags.set(sha, []); + shaToTags.get(sha).push(tag); + } + return shaToTags; + } + + function parseTag(tag) { + const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/.exec(tag); + if (!m) return null; + const nums = [m[1], m[2], m[3]].filter((s) => s !== undefined).map(Number); + return { nums, pre: m[4] ?? null }; + } + + // semver §11: dot-split identifiers; numeric-only compare numerically and rank + // below alphanumeric; alphanumeric compare lexically; equal prefix -> more + // identifiers ranks higher. + function comparePrerelease(a, b) { + const as = a.split("."); + const bs = b.split("."); + for (let i = 0; i < Math.min(as.length, bs.length); i++) { + const [x, y] = [as[i], bs[i]]; + if (x === y) continue; + const xNum = /^\d+$/.test(x); + const yNum = /^\d+$/.test(y); + if (xNum && yNum) return Number(x) - Number(y); + if (xNum !== yNum) return xNum ? -1 : 1; + return x < y ? -1 : 1; + } + return as.length - bs.length; + } + + /** + * Order version tags for "best comment token" selection: + * 1. stable > prerelease + * 2. numeric comparison, segment-wise with missing segments as 0 + * 3. numerically equal: more explicit segments win (v7.0.0 > v7) + * 4. both prerelease: semver §11 ordering + * 5. deterministic tie-break: lexicographic tag name + * Returns > 0 when a is the better tag. + */ + export function compareVersionTags(a, b) { + const pa = parseTag(a); + const pb = parseTag(b); + if (!pa || !pb) throw new SyncError(`not a version tag: ${JSON.stringify(!pa ? a : b)}`); + if ((pa.pre === null) !== (pb.pre === null)) return pa.pre === null ? 1 : -1; + for (let i = 0; i < 3; i++) { + const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0); + if (diff !== 0) return diff; + } + if (pa.nums.length !== pb.nums.length) return pa.nums.length - pb.nums.length; + if (pa.pre !== null && pb.pre !== null && pa.pre !== pb.pre) return comparePrerelease(pa.pre, pb.pre); + return a < b ? -1 : a > b ? 1 : 0; + } + + /** The best version tag from a non-empty list. */ + export function chooseBestTag(tags) { + if (!tags || tags.length === 0) throw new SyncError("chooseBestTag: no tags"); + return tags.reduce((best, tag) => (compareVersionTags(tag, best) > 0 ? tag : best)); + } + + /** Every unique owner/repo key referenced by pinned lines across files. */ + export function collectRepoKeys(files) { + const keys = new Set(); + for (const { content } of files) { + for (const line of content.split("\n")) { + const parsed = parsePinnedLine(line); + if (!parsed) continue; + const key = repoKeyFor(parsed.action); + if (key) keys.add(key); + } + } + return [...keys].sort(); + } + + /** + * Compute all comment edits for `files` given `tagIndex` (repoKey -> Map of + * sha -> [tags]). All-or-nothing: any unresolved repo or pinned SHA with no + * matching tag throws SyncError and nothing is returned. + * + * Returns { edits, newContents } where edits is + * [{ path, lineNo, action, sha, oldToken, newToken, oldLine, newLine }] and + * newContents maps path -> rewritten content for files with edits. + */ + export function planEdits(files, tagIndex) { + const edits = []; + const newContents = new Map(); + for (const { path, content } of files) { + const lines = content.split("\n"); + let changed = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parsed = parsePinnedLine(line); + if (!parsed) continue; + const key = repoKeyFor(parsed.action); + if (!key) continue; + const shaToTags = tagIndex.get(key); + if (!shaToTags) throw new SyncError(`${path}:${i + 1}: no tag data for ${key}`); + const tags = shaToTags.get(parsed.sha) ?? []; + if (tags.includes(parsed.token)) continue; + if (tags.length === 0) { + throw new SyncError(`${path}:${i + 1}: no version tag points at ${key}@${parsed.sha}`); + } + const newToken = chooseBestTag(tags); + if (newToken === parsed.token) continue; + const { prefix, action, sha, leadIn, rest } = parsed; + const rebuilt = `${prefix}${action}@${sha}${leadIn}${parsed.token}${rest}`; + if (rebuilt !== line) throw new SyncError(`${path}:${i + 1}: parse did not partition the line exactly`); + const newLine = `${prefix}${action}@${sha}${leadIn}${newToken}${rest}`; + edits.push({ path, lineNo: i + 1, action, sha, oldToken: parsed.token, newToken, oldLine: line, newLine }); + lines[i] = newLine; + changed = true; + } + if (changed) newContents.set(path, lines.join("\n")); + } + assertOnlyTokensChanged(files, newContents); + return { edits, newContents }; + } + + /** + * Guard: every difference between old and new content must be a pinned line + * whose parse groups are identical except for the version token. Throws + * SyncError otherwise. + */ + export function assertOnlyTokensChanged(files, newContents) { + for (const { path, content } of files) { + const rewritten = newContents.get(path); + if (rewritten === undefined) continue; + const oldLines = content.split("\n"); + const newLines = rewritten.split("\n"); + if (oldLines.length !== newLines.length) throw new SyncError(`${path}: line count changed`); + for (let i = 0; i < oldLines.length; i++) { + if (oldLines[i] === newLines[i]) continue; + const before = parsePinnedLine(oldLines[i]); + const after = parsePinnedLine(newLines[i]); + const intact = + before && + after && + before.prefix === after.prefix && + before.action === after.action && + before.sha === after.sha && + before.leadIn === after.leadIn && + before.rest === after.rest; + if (!intact) throw new SyncError(`${path}:${i + 1}: change is not confined to the version token`); + } + } + } + + // --------------------------------------------------------------------------- + // I/O shell + // --------------------------------------------------------------------------- + + async function git(args, opts = {}) { + const { stdout } = await execFileAsync("git", args, { maxBuffer: 16 * 1024 * 1024, ...opts }); + return stdout; + } + + async function listWorkflowFiles(root) { + const out = await git( + ["ls-files", "-z", "--", ":(glob).github/workflows/*.yml", ":(glob).github/workflows/*.yaml"], + { cwd: root }, + ); + return out + .split("\0") + .filter(Boolean) + .sort() + .filter((rel) => { + const stat = lstatSync(join(root, rel), { throwIfNoEntry: false }); + return stat !== undefined && stat.isFile(); + }); + } + + function errorMessage(error) { + return error instanceof Error ? error.message : String(error); + } + + async function resolveRepoTags(repoKey) { + let out; + try { + out = await git(["ls-remote", "--tags", `https://github.com/${repoKey}`]); + } catch (error) { + throw new SyncError(`git ls-remote failed for ${repoKey}: ${errorMessage(error)}`); + } + return parseLsRemoteOutput(out); + } + + async function main() { + const check = process.argv.includes("--check"); + const root = (await git(["rev-parse", "--show-toplevel"])).trim(); + + const paths = await listWorkflowFiles(root); + const files = paths.map((path) => ({ path, content: readFileSync(join(root, path), "utf8") })); + + // Phase 1: resolve every remote and compute + validate every edit. Any + // failure aborts here with the tree untouched. + const tagIndex = new Map(); + for (const key of collectRepoKeys(files)) { + tagIndex.set(key, await resolveRepoTags(key)); + } + const { edits, newContents } = planEdits(files, tagIndex); + + for (const edit of edits) { + console.log(`${edit.path}:${edit.lineNo}: ${edit.oldToken} -> ${edit.newToken} (${edit.action}@${edit.sha})`); + } + if (edits.length === 0) { + console.log("all action pin comments are in sync"); + return 0; + } + if (check) return 1; + + // Phase 2: write, then verify on-disk changes are confined to version tokens. + for (const [path, content] of newContents) { + writeFileSync(join(root, path), content); + } + try { + const reread = paths.map((path) => ({ + path, + content: files.find((f) => f.path === path).content, + onDisk: readFileSync(join(root, path), "utf8"), + })); + assertOnlyTokensChanged( + reread, + new Map(reread.filter((f) => newContents.has(f.path)).map((f) => [f.path, f.onDisk])), + ); + } catch (error) { + for (const { path, content } of files) { + if (newContents.has(path)) writeFileSync(join(root, path), content); + } + throw new SyncError(`post-write verification failed, original files restored: ${errorMessage(error)}`); + } + console.log(`updated ${edits.length} comment${edits.length === 1 ? "" : "s"}`); + return 0; + } + + const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (error) => { + console.error(error instanceof SyncError ? `error: ${error.message}` : error); + process.exit(2); + }, + ); + } + SYNC_MJS + + - 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.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::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 the fallback workflows 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 }} + CI_NAME: ${{ inputs.ci-workflow-name }} + DISPATCH_WORKFLOWS: ${{ inputs.dispatch-workflows }} + 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 == env.CI_NAME)] | 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" + for wf in $DISPATCH_WORKFLOWS; do + gh workflow run "$wf" --repo "$GITHUB_REPOSITORY" --ref "$BRANCH" + done + + # If the pushed head's pull_request runs get held for approval (repos + # that require 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}" diff --git a/scripts/check-embedded-sync.sh b/scripts/check-embedded-sync.sh new file mode 100755 index 0000000..9f72830 --- /dev/null +++ b/scripts/check-embedded-sync.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The reusable dependabot-sync-actions-comments workflow embeds the updater +# script in a heredoc so the caller's SHA pin covers it byte for byte. +# scripts/sync-action-pin-comments.mjs is the tested source of truth; this +# check fails CI if the embedded copy drifts from it. +set -euo pipefail + +wf=.github/workflows/dependabot-sync-actions-comments.yml +src=scripts/sync-action-pin-comments.mjs + +# The heredoc body is the workflow-file text between the SYNC_MJS markers, +# de-indented by the run block's 10 spaces (blank lines carry no indent). +embedded="$(sed -n "/<<'SYNC_MJS'\$/,/^ *SYNC_MJS\$/p" "$wf" | sed -e '1d' -e '$d' -e 's/^ //')" + +if ! diff -u "$src" <(printf '%s\n' "$embedded"); then + echo "error: embedded updater script in $wf drifted from $src" >&2 + echo "regenerate the heredoc from the script file (or vice versa) so they match" >&2 + exit 1 +fi +echo "embedded updater script matches $src" diff --git a/scripts/sync-action-pin-comments.mjs b/scripts/sync-action-pin-comments.mjs new file mode 100644 index 0000000..4511e9d --- /dev/null +++ b/scripts/sync-action-pin-comments.mjs @@ -0,0 +1,323 @@ +#!/usr/bin/env node +// Sync the trailing `# vX.Y.Z` comments on SHA-pinned `uses:` lines in +// .github/workflows with the tags each pinned SHA actually points at. +// +// Dependabot's github-actions updater rewrites SHA pins but cannot rewrite the +// version comment on lines that carry a second trailing comment (e.g. a +// `# zizmor: ignore[...]` suppression), leaving the comment stale. This script +// applies zizmor's documented rule instead: the comment token must name a tag +// that points at the pinned SHA. Everything after the token — including zizmor +// annotations — is preserved byte for byte. +// +// Zero dependencies; pure core (exported for tests) + thin I/O shell. +// +// Usage: +// node scripts/sync-action-pin-comments.mjs # rewrite in place +// node scripts/sync-action-pin-comments.mjs --check # report only; exit 1 if stale +// +// Exit codes: 0 = in sync (or successfully rewritten); 1 = --check found stale +// comments; 2 = error (resolution failure, malformed data, guard trip) — the +// tree is left untouched. + +import { execFile } from "node:child_process"; +import { lstatSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Pure core +// --------------------------------------------------------------------------- + +// Groups partition the whole line: prefix / action path / SHA / comment +// lead-in / version token / rest-of-line (verbatim, carries any annotations). +export const PIN_LINE_RE = /^(\s*(?:-\s+)?uses:\s+)([\w.-]+\/[\w./-]+)@([0-9a-f]{40})(\s+#\s*)(v?\d[^\s#]*)(.*)$/; + +// Tags eligible as version comments: v-optional dotted numerics with an +// optional prerelease suffix. +export const TAG_RE = /^v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/; + +const LS_REMOTE_LINE_RE = /^([0-9a-f]{40})\trefs\/tags\/([^\s^]+)(\^\{\})?$/; + +export class SyncError extends Error {} + +/** Parse one workflow line; null if it is not a SHA-pinned uses line with a version comment. */ +export function parsePinnedLine(line) { + const m = PIN_LINE_RE.exec(line); + if (!m) return null; + const [, prefix, action, sha, leadIn, token, rest] = m; + return { prefix, action, sha, leadIn, token, rest }; +} + +/** owner/repo for an action path, or null for paths that are not resolvable GitHub repos. */ +export function repoKeyFor(action) { + const segments = action.split("/"); + if (segments.length < 2) return null; + const [owner, repo] = segments; + const valid = (s) => /^[\w.-]+$/.test(s) && s !== "." && s !== ".."; + return valid(owner) && valid(repo) ? `${owner}/${repo}` : null; +} + +/** + * Parse `git ls-remote --tags` output into a Map of sha -> [tag, ...]. + * Only version-shaped tags (TAG_RE) are indexed. Peeled `^{}` entries (the + * commit an annotated tag points at) override the tag-object sha. + * Throws SyncError on any line that does not have the ls-remote shape. + */ +export function parseLsRemoteOutput(output) { + const tagToSha = new Map(); + for (const line of output.split("\n")) { + if (line === "") continue; + const m = LS_REMOTE_LINE_RE.exec(line); + if (!m) throw new SyncError(`malformed ls-remote line: ${JSON.stringify(line)}`); + const [, sha, tag, peeled] = m; + if (!TAG_RE.test(tag)) continue; + if (peeled || !tagToSha.has(tag)) tagToSha.set(tag, sha); + } + const shaToTags = new Map(); + for (const [tag, sha] of tagToSha) { + if (!shaToTags.has(sha)) shaToTags.set(sha, []); + shaToTags.get(sha).push(tag); + } + return shaToTags; +} + +function parseTag(tag) { + const m = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/.exec(tag); + if (!m) return null; + const nums = [m[1], m[2], m[3]].filter((s) => s !== undefined).map(Number); + return { nums, pre: m[4] ?? null }; +} + +// semver §11: dot-split identifiers; numeric-only compare numerically and rank +// below alphanumeric; alphanumeric compare lexically; equal prefix -> more +// identifiers ranks higher. +function comparePrerelease(a, b) { + const as = a.split("."); + const bs = b.split("."); + for (let i = 0; i < Math.min(as.length, bs.length); i++) { + const [x, y] = [as[i], bs[i]]; + if (x === y) continue; + const xNum = /^\d+$/.test(x); + const yNum = /^\d+$/.test(y); + if (xNum && yNum) return Number(x) - Number(y); + if (xNum !== yNum) return xNum ? -1 : 1; + return x < y ? -1 : 1; + } + return as.length - bs.length; +} + +/** + * Order version tags for "best comment token" selection: + * 1. stable > prerelease + * 2. numeric comparison, segment-wise with missing segments as 0 + * 3. numerically equal: more explicit segments win (v7.0.0 > v7) + * 4. both prerelease: semver §11 ordering + * 5. deterministic tie-break: lexicographic tag name + * Returns > 0 when a is the better tag. + */ +export function compareVersionTags(a, b) { + const pa = parseTag(a); + const pb = parseTag(b); + if (!pa || !pb) throw new SyncError(`not a version tag: ${JSON.stringify(!pa ? a : b)}`); + if ((pa.pre === null) !== (pb.pre === null)) return pa.pre === null ? 1 : -1; + for (let i = 0; i < 3; i++) { + const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0); + if (diff !== 0) return diff; + } + if (pa.nums.length !== pb.nums.length) return pa.nums.length - pb.nums.length; + if (pa.pre !== null && pb.pre !== null && pa.pre !== pb.pre) return comparePrerelease(pa.pre, pb.pre); + return a < b ? -1 : a > b ? 1 : 0; +} + +/** The best version tag from a non-empty list. */ +export function chooseBestTag(tags) { + if (!tags || tags.length === 0) throw new SyncError("chooseBestTag: no tags"); + return tags.reduce((best, tag) => (compareVersionTags(tag, best) > 0 ? tag : best)); +} + +/** Every unique owner/repo key referenced by pinned lines across files. */ +export function collectRepoKeys(files) { + const keys = new Set(); + for (const { content } of files) { + for (const line of content.split("\n")) { + const parsed = parsePinnedLine(line); + if (!parsed) continue; + const key = repoKeyFor(parsed.action); + if (key) keys.add(key); + } + } + return [...keys].sort(); +} + +/** + * Compute all comment edits for `files` given `tagIndex` (repoKey -> Map of + * sha -> [tags]). All-or-nothing: any unresolved repo or pinned SHA with no + * matching tag throws SyncError and nothing is returned. + * + * Returns { edits, newContents } where edits is + * [{ path, lineNo, action, sha, oldToken, newToken, oldLine, newLine }] and + * newContents maps path -> rewritten content for files with edits. + */ +export function planEdits(files, tagIndex) { + const edits = []; + const newContents = new Map(); + for (const { path, content } of files) { + const lines = content.split("\n"); + let changed = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const parsed = parsePinnedLine(line); + if (!parsed) continue; + const key = repoKeyFor(parsed.action); + if (!key) continue; + const shaToTags = tagIndex.get(key); + if (!shaToTags) throw new SyncError(`${path}:${i + 1}: no tag data for ${key}`); + const tags = shaToTags.get(parsed.sha) ?? []; + if (tags.includes(parsed.token)) continue; + if (tags.length === 0) { + throw new SyncError(`${path}:${i + 1}: no version tag points at ${key}@${parsed.sha}`); + } + const newToken = chooseBestTag(tags); + if (newToken === parsed.token) continue; + const { prefix, action, sha, leadIn, rest } = parsed; + const rebuilt = `${prefix}${action}@${sha}${leadIn}${parsed.token}${rest}`; + if (rebuilt !== line) throw new SyncError(`${path}:${i + 1}: parse did not partition the line exactly`); + const newLine = `${prefix}${action}@${sha}${leadIn}${newToken}${rest}`; + edits.push({ path, lineNo: i + 1, action, sha, oldToken: parsed.token, newToken, oldLine: line, newLine }); + lines[i] = newLine; + changed = true; + } + if (changed) newContents.set(path, lines.join("\n")); + } + assertOnlyTokensChanged(files, newContents); + return { edits, newContents }; +} + +/** + * Guard: every difference between old and new content must be a pinned line + * whose parse groups are identical except for the version token. Throws + * SyncError otherwise. + */ +export function assertOnlyTokensChanged(files, newContents) { + for (const { path, content } of files) { + const rewritten = newContents.get(path); + if (rewritten === undefined) continue; + const oldLines = content.split("\n"); + const newLines = rewritten.split("\n"); + if (oldLines.length !== newLines.length) throw new SyncError(`${path}: line count changed`); + for (let i = 0; i < oldLines.length; i++) { + if (oldLines[i] === newLines[i]) continue; + const before = parsePinnedLine(oldLines[i]); + const after = parsePinnedLine(newLines[i]); + const intact = + before && + after && + before.prefix === after.prefix && + before.action === after.action && + before.sha === after.sha && + before.leadIn === after.leadIn && + before.rest === after.rest; + if (!intact) throw new SyncError(`${path}:${i + 1}: change is not confined to the version token`); + } + } +} + +// --------------------------------------------------------------------------- +// I/O shell +// --------------------------------------------------------------------------- + +async function git(args, opts = {}) { + const { stdout } = await execFileAsync("git", args, { maxBuffer: 16 * 1024 * 1024, ...opts }); + return stdout; +} + +async function listWorkflowFiles(root) { + const out = await git( + ["ls-files", "-z", "--", ":(glob).github/workflows/*.yml", ":(glob).github/workflows/*.yaml"], + { cwd: root }, + ); + return out + .split("\0") + .filter(Boolean) + .sort() + .filter((rel) => { + const stat = lstatSync(join(root, rel), { throwIfNoEntry: false }); + return stat !== undefined && stat.isFile(); + }); +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function resolveRepoTags(repoKey) { + let out; + try { + out = await git(["ls-remote", "--tags", `https://github.com/${repoKey}`]); + } catch (error) { + throw new SyncError(`git ls-remote failed for ${repoKey}: ${errorMessage(error)}`); + } + return parseLsRemoteOutput(out); +} + +async function main() { + const check = process.argv.includes("--check"); + const root = (await git(["rev-parse", "--show-toplevel"])).trim(); + + const paths = await listWorkflowFiles(root); + const files = paths.map((path) => ({ path, content: readFileSync(join(root, path), "utf8") })); + + // Phase 1: resolve every remote and compute + validate every edit. Any + // failure aborts here with the tree untouched. + const tagIndex = new Map(); + for (const key of collectRepoKeys(files)) { + tagIndex.set(key, await resolveRepoTags(key)); + } + const { edits, newContents } = planEdits(files, tagIndex); + + for (const edit of edits) { + console.log(`${edit.path}:${edit.lineNo}: ${edit.oldToken} -> ${edit.newToken} (${edit.action}@${edit.sha})`); + } + if (edits.length === 0) { + console.log("all action pin comments are in sync"); + return 0; + } + if (check) return 1; + + // Phase 2: write, then verify on-disk changes are confined to version tokens. + for (const [path, content] of newContents) { + writeFileSync(join(root, path), content); + } + try { + const reread = paths.map((path) => ({ + path, + content: files.find((f) => f.path === path).content, + onDisk: readFileSync(join(root, path), "utf8"), + })); + assertOnlyTokensChanged( + reread, + new Map(reread.filter((f) => newContents.has(f.path)).map((f) => [f.path, f.onDisk])), + ); + } catch (error) { + for (const { path, content } of files) { + if (newContents.has(path)) writeFileSync(join(root, path), content); + } + throw new SyncError(`post-write verification failed, original files restored: ${errorMessage(error)}`); + } + console.log(`updated ${edits.length} comment${edits.length === 1 ? "" : "s"}`); + return 0; +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) { + main().then( + (code) => process.exit(code), + (error) => { + console.error(error instanceof SyncError ? `error: ${error.message}` : error); + process.exit(2); + }, + ); +} diff --git a/scripts/sync-action-pin-comments.test.mjs b/scripts/sync-action-pin-comments.test.mjs new file mode 100644 index 0000000..f7ff0f1 --- /dev/null +++ b/scripts/sync-action-pin-comments.test.mjs @@ -0,0 +1,241 @@ +// node --test port of openclaw-basecamp's vitest suite for +// sync-action-pin-comments.mjs. Zero dependencies, mirroring the script. + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + assertOnlyTokensChanged, + chooseBestTag, + collectRepoKeys, + compareVersionTags, + parseLsRemoteOutput, + parsePinnedLine, + planEdits, + repoKeyFor, + SyncError, +} from "./sync-action-pin-comments.mjs"; + +const SETUP_NODE_SHA = "820762786026740c76f36085b0efc47a31fe5020"; +const CHECKOUT_SHA = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"; + +// A real combined-comment line that Dependabot cannot rewrite. +const COMBINED_LINE = + " - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6.4.0 # zizmor: ignore[cache-poisoning] -- workflow only triggers on tag push and workflow_dispatch; cache is keyed by lockfile hash and default branch"; +const COMBINED_ANNOTATION = + " # zizmor: ignore[cache-poisoning] -- workflow only triggers on tag push and workflow_dispatch; cache is keyed by lockfile hash and default branch"; + +/** Build a tagIndex entry: repoKey -> Map(sha -> tags). */ +function index(entries) { + return new Map(Object.entries(entries).map(([repo, shas]) => [repo, new Map(Object.entries(shas))])); +} + +/** Assert that every key in `expected` deep-equals the same key in `actual`. */ +function assertMatchObject(actual, expected) { + assert.ok(actual !== null && actual !== undefined, "expected an object, got null/undefined"); + for (const [key, value] of Object.entries(expected)) { + assert.deepEqual(actual[key], value, `mismatch on ${key}`); + } +} + +describe("parsePinnedLine", () => { + it("partitions a combined-comment line, preserving the annotation verbatim", () => { + const parsed = parsePinnedLine(COMBINED_LINE); + assert.deepEqual(parsed, { + prefix: " - uses: ", + action: "actions/setup-node", + sha: SETUP_NODE_SHA, + leadIn: " # ", + token: "v6.4.0", + rest: COMBINED_ANNOTATION, + }); + }); + + it("parses plain pinned lines with and without list dashes", () => { + assertMatchObject(parsePinnedLine(` uses: actions/checkout@${CHECKOUT_SHA} # v7.0.0`), { + action: "actions/checkout", + token: "v7.0.0", + rest: "", + }); + assertMatchObject(parsePinnedLine(` - uses: github/codeql-action/init@${CHECKOUT_SHA} # v3`), { + action: "github/codeql-action/init", + token: "v3", + }); + }); + + it("skips docker refs, local actions, non-SHA refs, and comment-less pins", () => { + assert.equal(parsePinnedLine(" - uses: docker://alpine:3.20"), null); + assert.equal(parsePinnedLine(" - uses: ./local/action"), null); + assert.equal(parsePinnedLine(" - uses: actions/checkout@v7"), null); + assert.equal(parsePinnedLine(` - uses: actions/checkout@${CHECKOUT_SHA}`), null); + assert.equal(parsePinnedLine(" - run: npm ci"), null); + }); +}); + +describe("repoKeyFor", () => { + it("uses the first two path segments", () => { + assert.equal(repoKeyFor("github/codeql-action/init"), "github/codeql-action"); + assert.equal(repoKeyFor("actions/checkout"), "actions/checkout"); + }); + + it("rejects dot segments and single segments", () => { + assert.equal(repoKeyFor("./local"), null); + assert.equal(repoKeyFor("../escape"), null); + assert.equal(repoKeyFor("bare"), null); + }); +}); + +describe("parseLsRemoteOutput", () => { + it("indexes version tags by sha, preferring peeled annotated-tag targets", () => { + const sha = CHECKOUT_SHA; + const tagObject = "1111111111111111111111111111111111111111"; + const out = [ + `${tagObject}\trefs/tags/v7.0.0`, + `${sha}\trefs/tags/v7.0.0^{}`, + `${sha}\trefs/tags/v7`, + `${sha}\trefs/tags/not-a-version`, + ].join("\n"); + const map = parseLsRemoteOutput(out); + assert.deepEqual([...map.get(sha)].sort(), ["v7", "v7.0.0"]); + assert.equal(map.has(tagObject), false); + }); + + it("rejects malformed lines", () => { + assert.throws(() => parseLsRemoteOutput("garbage output"), SyncError); + assert.throws(() => parseLsRemoteOutput(`deadbeef\trefs/tags/v1`), SyncError); + assert.throws(() => parseLsRemoteOutput(`${CHECKOUT_SHA} refs/tags/v1`), SyncError); + }); +}); + +describe("compareVersionTags / chooseBestTag", () => { + it("prefers stable over prerelease", () => { + assert.equal(chooseBestTag(["v7.0.0-rc.1", "v7.0.0"]), "v7.0.0"); + assert.equal(chooseBestTag(["v8.0.0-beta.1", "v7.0.0"]), "v7.0.0"); + }); + + it("prefers more explicit segments among numerically equal tags", () => { + assert.equal(chooseBestTag(["v7", "v7.0.0"]), "v7.0.0"); + assert.equal(chooseBestTag(["v7.0.0", "v7", "v7.0"]), "v7.0.0"); + }); + + it("compares numerically, not lexically", () => { + assert.equal(chooseBestTag(["v9", "v10"]), "v10"); + assert.equal(chooseBestTag(["v1.9.0", "v1.10.0"]), "v1.10.0"); + }); + + it("orders prereleases per semver §11", () => { + assert.ok(compareVersionTags("v1.0.0-alpha", "v1.0.0-alpha.1") < 0); + assert.ok(compareVersionTags("v1.0.0-alpha.1", "v1.0.0-alpha.beta") < 0); + assert.ok(compareVersionTags("v1.0.0-beta.2", "v1.0.0-beta.11") < 0); + assert.ok(compareVersionTags("v1.0.0-rc.1", "v1.0.0-beta.11") > 0); + }); + + it("breaks exact ties deterministically and rejects non-version tags", () => { + assert.equal(chooseBestTag(["v1.0.0", "1.0.0"]), "v1.0.0"); + assert.equal(compareVersionTags("v1.0.0", "v1.0.0"), 0); + assert.throws(() => compareVersionTags("v1.0.0", "nope"), SyncError); + }); +}); + +describe("planEdits", () => { + it("fixes the combined-comment line, preserving the zizmor annotation byte for byte", () => { + const files = [{ path: ".github/workflows/release.yml", content: `${COMBINED_LINE}\n` }]; + const tagIndex = index({ + "actions/setup-node": { [SETUP_NODE_SHA]: ["v7.0.0", "v7"] }, + }); + const { edits, newContents } = planEdits(files, tagIndex); + assert.equal(edits.length, 1); + assertMatchObject(edits[0], { lineNo: 1, oldToken: "v6.4.0", newToken: "v7.0.0" }); + assert.equal( + newContents.get(".github/workflows/release.yml"), + ` - uses: actions/setup-node@${SETUP_NODE_SHA} # v7.0.0${COMBINED_ANNOTATION}\n`, + ); + }); + + it("updates plain stale comments and leaves correct or alias comments untouched", () => { + const content = [ + ` - uses: actions/checkout@${CHECKOUT_SHA} # v6.9.9`, // stale + ` - uses: actions/checkout@${CHECKOUT_SHA} # v7`, // alias tag pointing at the sha + ` - uses: actions/setup-node@${SETUP_NODE_SHA} # v7.0.0`, // already correct + "", + ].join("\n"); + const files = [{ path: "ci.yml", content }]; + const tagIndex = index({ + "actions/checkout": { [CHECKOUT_SHA]: ["v7", "v7.0.0"] }, + "actions/setup-node": { [SETUP_NODE_SHA]: ["v7.0.0"] }, + }); + const { edits, newContents } = planEdits(files, tagIndex); + assert.equal(edits.length, 1); + assertMatchObject(edits[0], { lineNo: 1, oldToken: "v6.9.9", newToken: "v7.0.0" }); + assert.equal( + newContents.get("ci.yml"), + [ + ` - uses: actions/checkout@${CHECKOUT_SHA} # v7.0.0`, + ` - uses: actions/checkout@${CHECKOUT_SHA} # v7`, + ` - uses: actions/setup-node@${SETUP_NODE_SHA} # v7.0.0`, + "", + ].join("\n"), + ); + }); + + it("returns no edits when everything is in sync", () => { + const files = [{ path: "ci.yml", content: ` - uses: actions/checkout@${CHECKOUT_SHA} # v7.0.0\n` }]; + const tagIndex = index({ "actions/checkout": { [CHECKOUT_SHA]: ["v7.0.0"] } }); + const { edits, newContents } = planEdits(files, tagIndex); + assert.equal(edits.length, 0); + assert.equal(newContents.size, 0); + }); + + it("aborts the whole run when a repo is unresolvable — no partial writes", () => { + const content = [ + ` - uses: actions/checkout@${CHECKOUT_SHA} # v6.9.9`, + ` - uses: actions/setup-node@${SETUP_NODE_SHA} # v6.4.0`, + "", + ].join("\n"); + const files = [{ path: "ci.yml", content }]; + const tagIndex = index({ "actions/checkout": { [CHECKOUT_SHA]: ["v7.0.0"] } }); // setup-node missing + assert.throws(() => planEdits(files, tagIndex), /no tag data for actions\/setup-node/); + }); + + it("aborts when no version tag points at a pinned sha", () => { + const files = [{ path: "ci.yml", content: ` - uses: actions/checkout@${CHECKOUT_SHA} # v6.9.9\n` }]; + const tagIndex = index({ "actions/checkout": { "0000000000000000000000000000000000000000": ["v7.0.0"] } }); + assert.throws(() => planEdits(files, tagIndex), /no version tag points at actions\/checkout@/); + }); +}); + +describe("assertOnlyTokensChanged", () => { + const original = ` - uses: actions/checkout@${CHECKOUT_SHA} # v6.9.9\n`; + const files = [{ path: "ci.yml", content: original }]; + + it("passes for a pure token substitution", () => { + const good = new Map([["ci.yml", ` - uses: actions/checkout@${CHECKOUT_SHA} # v7.0.0\n`]]); + assert.doesNotThrow(() => assertOnlyTokensChanged(files, good)); + }); + + it("trips on any change outside the version token", () => { + const shaChanged = new Map([ + ["ci.yml", ` - uses: actions/checkout@0000000000000000000000000000000000000000 # v7.0.0\n`], + ]); + assert.throws(() => assertOnlyTokensChanged(files, shaChanged), /not confined to the version token/); + + const annotationDropped = new Map([["ci.yml", " - run: echo hijacked\n"]]); + assert.throws(() => assertOnlyTokensChanged(files, annotationDropped), /not confined to the version token/); + + const lineCount = new Map([["ci.yml", `${original}extra: line\n`]]); + assert.throws(() => assertOnlyTokensChanged(files, lineCount), /line count changed/); + }); +}); + +describe("collectRepoKeys", () => { + it("collects unique sorted owner/repo keys across files", () => { + const files = [ + { path: "a.yml", content: `uses: actions/setup-node@${SETUP_NODE_SHA} # v6\nuses: docker://alpine:3.20\n` }, + { + path: "b.yml", + content: `uses: actions/checkout@${CHECKOUT_SHA} # v7\nuses: github/codeql-action/init@${CHECKOUT_SHA} # v3\n`, + }, + ]; + assert.deepEqual(collectRepoKeys(files), ["actions/checkout", "actions/setup-node", "github/codeql-action"]); + }); +}); From c84ec8337d47254aee43a87187179a235f8076cf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 27 Jul 2026 13:02:22 -0700 Subject: [PATCH 2/2] ci: scope the deploy key to a dependabot-sync environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zizmor's secrets-outside-env audit (regular-persona in the 1.23.x this repo's pinned zizmor-action resolves) flagged the repo-scoped secret — and the environment model is better anyway, matching our zero repo-scoped-secrets practice: the SYNC_ACTIONS_DEPLOY_KEY lives in each caller's dependabot-sync environment whose deployment branch policy allows only the default branch, so no other workflow (and no non-default ref) can read it. Environment secrets cannot be forwarded explicitly to a reusable workflow, so callers switch to secrets: inherit and the job declares the environment itself. --- .../dependabot-sync-actions-comments.yml | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dependabot-sync-actions-comments.yml b/.github/workflows/dependabot-sync-actions-comments.yml index 5d0a3a2..c574749 100644 --- a/.github/workflows/dependabot-sync-actions-comments.yml +++ b/.github/workflows/dependabot-sync-actions-comments.yml @@ -27,7 +27,9 @@ name: Sync Dependabot action pin comments # uses: basecamp/.github/.github/workflows/dependabot-sync-actions-comments.yml@ # with: {ci-workflow-name: , branch: "${{ inputs.branch || '' }}", e2e: "${{ inputs.e2e || false }}"} # permissions: {contents: read, actions: write, pull-requests: read} -# secrets: {deploy_key: "${{ secrets.SYNC_ACTIONS_DEPLOY_KEY }}"} +# secrets: inherit +# plus a `dependabot-sync` environment (deployment branches: default branch +# only) holding the SYNC_ACTIONS_DEPLOY_KEY write deploy key. on: workflow_call: @@ -63,13 +65,6 @@ on: required: false default: false type: boolean - secrets: - deploy_key: - description: >- - Write deploy key for the caller repository, used solely for the - lease push (SSH keys are exempt from the App-token workflow-file - restriction) - required: true permissions: {} @@ -77,6 +72,13 @@ jobs: sync: name: Sync action pin comments runs-on: ubuntu-latest + # The write deploy key lives in the caller repo's `dependabot-sync` + # environment, whose deployment branch policy is restricted to the default + # branch: only default-branch runs of this job can read it, and no other + # workflow in the repo can. Callers pass `secrets: inherit` — environment + # secrets cannot be forwarded explicitly; they resolve only when the + # running job declares the environment. + environment: dependabot-sync # Serialize per branch: repos whose PR CI spans multiple workflow_run # completions (or two workflows sharing one name) trigger several runs per # head; the queued run then sees the head moved and skips cleanly instead @@ -557,7 +559,7 @@ jobs: env: BRANCH: ${{ steps.target.outputs.branch }} HEAD_SHA: ${{ steps.target.outputs.sha }} - DEPLOY_KEY: ${{ secrets.deploy_key }} + DEPLOY_KEY: ${{ secrets.SYNC_ACTIONS_DEPLOY_KEY }} run: | finish() { echo "::notice::$1"; echo "pushed=false" >> "$GITHUB_OUTPUT"; exit 0; } @@ -596,7 +598,7 @@ jobs: # keys are pinned from the authenticated API rather than trusted on # first use. if [ -z "$DEPLOY_KEY" ]; then - echo "::error::deploy_key secret is not configured" + echo "::error::SYNC_ACTIONS_DEPLOY_KEY is not configured in the dependabot-sync environment" exit 1 fi umask 077