Skip to content

ci: improve release contributor detection#161

Open
Mirrowel wants to merge 3 commits into
devfrom
chore/robust-release-contributors
Open

ci: improve release contributor detection#161
Mirrowel wants to merge 3 commits into
devfrom
chore/robust-release-contributors

Conversation

@Mirrowel
Copy link
Copy Markdown
Owner

@Mirrowel Mirrowel commented May 29, 2026

Summary

  • add a definitive-release-workflow TODO note to keep this workflow aligned with opencode-agent-variants
  • preserve existing merge-commit PR detection while also parsing squash-style feat: implement multi-user auth, usage attribution, UI, and MVP hardening #123 subjects
  • add GraphQL associatedPullRequests lookup for robust community contribution detection across merge, squash, and rebase flows
  • skip executable/container release workflows for workflow-only changes; manual dispatch remains available for testing
  • add release-job idempotency so duplicate runs for the same branch/SHA do not create duplicate build releases
  • extend cleanup workflow with branch and stale-branch modes plus keep-latest retention

Validation

  • git diff --check

Notes

  • This keeps the workflow in Bash/YAML style so it can stay aligned with opencode-agent-variants without introducing a separate shared workflow repo.
  • Semver prerelease promotion from opencode-agent-variants is not copied here because this project uses date/SHA build releases instead of npm semver channels.
  • Cleanup can now delete more (all stale unprotected branch build releases) or less (keep the newest N matches).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Chores
    • Enhanced the release workflow: added concurrency, detect-and-skip existing releases, and gate downstream steps to run only when a release is needed.
    • Improved changelog/PR collection by combining commit parsing with API queries and updated “No associated PRs found in range” messaging.
    • Refined cleanup workflow: added manual modes for branch or stale-release cleanup, retention and dry-run controls, expanded protected-branch handling, and safer deletion reporting.
    • Adjusted Docker build triggers to run on Dockerfile and dependency changes.

Walkthrough

Adds release gating and enhanced PR discovery to the build workflow, tweaks Docker push triggers to target Dockerfile/requirements, and refactors the cleanup workflow to support mode-based deletion, retention, and metricized summaries.

Changes

Release Workflow PR Detection Enhancement

Layer / File(s) Summary
Maintenance note and concurrency
.github/workflows/build.yml
Adds a top-of-file TODO and a workflow-level concurrency keyed by build-release-${{ github.ref }}.
Generate build version and early exit
.github/workflows/build.yml
Version step detects existing release tags for the current commit and sets should_release=false to exit early when a release exists.
Gate downstream release steps on should_release
.github/workflows/build.yml
Adds if: steps.version.outputs.should_release == 'true' guards to artifact download, archiving, git-cliff install/config, changelog generation, username resolution, artifact debug, metadata generation, create release, and prune old releases.
PR discovery and changelog generation
.github/workflows/build.yml
Parses PR numbers from commit subjects and supplements via per-commit GraphQL associatedPullRequests; fetches PR title/author/url via REST and updates the no-PR fallback message to “No associated PRs found in range”.
Docker build trigger paths
.github/workflows/docker-build.yml
Changes on.push.paths to remove .github/workflows/docker-build.yml and add Dockerfile and requirements.txt.

Cleanup Workflow Refactor

Layer / File(s) Summary
Dispatch inputs and mode
.github/workflows/cleanup.yml
Replaces single branch_name dispatch input with cleanup_mode, optional branch_name, and keep_latest inputs.
Job condition, env, and checkout
.github/workflows/cleanup.yml
Runs cleanup conditionally for dispatch or branch-deletion events, updates PROTECTED_BRANCHES and checkout behavior.
Configuration and validation
.github/workflows/cleanup.yml
Computes/validates cleanup_mode, branch_name, keep_latest, dry_run and emits outputs.
Release discovery and deletion algorithm
.github/workflows/cleanup.yml
Adds helper checks, supports branch and stale_branches modes, applies retention, and standardizes deletion metrics and failure handling.
Action step summary
.github/workflows/cleanup.yml
Outputs a metric table summarizing mode, search pattern, retention, dry-run, and deleted/failed counts.

Sequence Diagram(s)

sequenceDiagram
  participant Workflow as Build Workflow
  participant Version as Version Step
  participant GraphQL as GitHub GraphQL
  participant REST as GitHub REST
  participant Changelog as Changelog Step

  Workflow->>Version: generate version (check tags for commit)
  Version-->>Workflow: outputs should_release
  alt should_release == 'true'
    Workflow->>GraphQL: query associatedPullRequests for commit SHAs
    GraphQL-->>REST: return PR numbers for SHAs
    REST-->>Changelog: return PR title / author / url
    Workflow->>Changelog: run changelog generation
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~35 minutes

Poem

🐰 A workflow hops through commit logs and tags,
It checks and skips where old releases lag.
GraphQL fetches and REST replies sing,
Changelogs gather each contributory thing.
Docker wakes only when Dockerfiles spring.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main focus of the changeset: improving release contributor detection through GraphQL lookups and PR parsing across different commit styles.
Description check ✅ Passed The PR description includes a comprehensive summary of changes, validation steps, and implementation notes, though it deviates from the template by using a custom 'Summary' section instead of the template's 'Description' section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/robust-release-contributors

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mirrobot-agent
Copy link
Copy Markdown
Contributor

Starting my review of the release contributor detection improvements in build.yml. The two-layer PR discovery approach looks interesting — I'll dig into the details and report back shortly.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR updates release workflows to improve contributor detection and avoid unnecessary release jobs. It changes:

  • Adds release workflow notes and idempotency for duplicate branch/SHA releases.
  • Expands release-note PR detection with squash-style subjects and associated PR lookups.
  • Skips executable and container release workflows for workflow-only changes.
  • Adds cleanup modes for branch-specific and stale branch build releases.

Confidence Score: 4/5

This is close, but the stale cleanup path should be fixed before merging.

  • The new stale branch cleanup can delete active branch releases when branch lookup fails.

  • The rest of the changed workflow logic is mostly contained to CI release and cleanup paths.

  • .github/workflows/cleanup.yml stale branch detection

Important Files Changed

Filename Overview
.github/workflows/cleanup.yml Adds manual cleanup modes and stale branch release pruning, with a destructive stale-detection failure path.

Reviews (3): Last reviewed commit: "ci: extend release cleanup modes" | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@mirrobot-agent mirrobot-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall Assessment

This is a well-structured improvement to the release contributor detection logic. The two-layer approach (subject-line parsing + GraphQL associatedPullRequests) correctly addresses the gap where squash and rebase merges were being missed by the old merge-commit-only detection. The dedup/sort step and the cleaner PR URL fetching (pulling html_url from the API rather than constructing it) are nice improvements.

Architectural Feedback

The layered strategy (Layer PR-1 for commit subjects, Layer PR-2 for GraphQL) is sound and maintainable. The TODO note at the top of the file is a good touch for keeping this aligned with opencode-agent-variants.

Key Suggestions

  • Regex false positives (line 668): The #number pattern in Layer PR-1 will match issue references and other incidental #123 mentions in commit subjects, not just PR numbers. See the inline suggestion for a tighter pattern.
  • Per-commit GraphQL calls (lines 673-685): The loop issues one API call per commit. For larger releases this could be slow — consider batching or adding an early-exit optimization.

Questions for the Author

  • Have you tested this with a release range that includes both squash-merged and merge-committed PRs to verify both layers contribute results without duplicates?

This review was generated by an AI assistant.


# Layer PR-1: parse PR numbers from commit subjects. This keeps the old
# merge-commit behavior and also catches squash commits that include #123.
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE 'Merge pull request #[0-9]+|#[0-9]+' | grep -oE '[0-9]+' || true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex will match any #123 occurrence in commit subjects — including false positives like issue references (fixes #123, related to #456). Consider tightening to only match explicit PR patterns:

Suggested change
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE 'Merge pull request #[0-9]+|#[0-9]+' | grep -oE '[0-9]+' || true)
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE 'Merge pull request #[0-9]+|\([^)]*#[0-9]+[^)]*\)' | grep -oE '[0-9]+' || true)

This limits matching to Merge pull request #N subjects and (#N) parenthesized references, which are the conventional squash/merge PR formats.

Comment on lines +673 to +685
COMMIT_SHAS=$(git rev-list "$PREV_TAG".."$CURRENT_SHA" 2>/dev/null || true)
while read -r SHA; do
if [ -z "$SHA" ]; then
continue
fi
ASSOCIATED_PRS=$(gh api graphql \
-f owner="$OWNER" \
-f name="$REPO_NAME" \
-f oid="$SHA" \
-f query='query($owner:String!, $name:String!, $oid:GitObjectID!) { repository(owner:$owner, name:$name) { object(oid:$oid) { ... on Commit { associatedPullRequests(first: 10) { nodes { number } } } } } }' \
--jq '.data.repository.object.associatedPullRequests.nodes[].number' 2>/dev/null || true)
PR_NUMBERS=$(printf '%s\n%s\n' "$PR_NUMBERS" "$ASSOCIATED_PRS")
done <<< "$COMMIT_SHAS"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This issues a GraphQL API call per commit in the range. For releases with many commits (20-50+), this adds significant runtime. Two options to consider:

  1. Batch via GraphQL aliases — query multiple OIDs in a single request using c0: ... on Commit { ... }, c1: ... aliases.
  2. Early exit — break out of the loop once no new PR numbers are being discovered (most PRs are found in the first few commits of a range).

Neither is blocking, but for a CI workflow that may run on every release, the N API calls could become noticeable.


# Layer PR-1: parse PR numbers from commit subjects. This keeps the old
# merge-commit behavior and also catches squash commits that include #123.
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE 'Merge pull request #[0-9]+|#[0-9]+' | grep -oE '[0-9]+' || true)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Issue numbers become PRs

This scans every commit subject for any #123 token, not just merge commits or squash-merge PR suffixes. When a release range contains a normal commit like fix: handle timeout #42, this code treats 42 as a pull request number. If PR 42 exists, the release notes can add an unrelated Community Contributions entry.

Suggested change
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE 'Merge pull request #[0-9]+|#[0-9]+' | grep -oE '[0-9]+' || true)
SUBJECT_PRS=$(git log "$PREV_TAG".."$CURRENT_SHA" --format='%s' 2>/dev/null | grep -oE '^Merge pull request #[0-9]+|\(#[0-9]+\)$' | grep -oE '[0-9]+' || true)

Comment on lines +673 to +684
COMMIT_SHAS=$(git rev-list "$PREV_TAG".."$CURRENT_SHA" 2>/dev/null || true)
while read -r SHA; do
if [ -z "$SHA" ]; then
continue
fi
ASSOCIATED_PRS=$(gh api graphql \
-f owner="$OWNER" \
-f name="$REPO_NAME" \
-f oid="$SHA" \
-f query='query($owner:String!, $name:String!, $oid:GitObjectID!) { repository(owner:$owner, name:$name) { object(oid:$oid) { ... on Commit { associatedPullRequests(first: 10) { nodes { number } } } } } }' \
--jq '.data.repository.object.associatedPullRequests.nodes[].number' 2>/dev/null || true)
PR_NUMBERS=$(printf '%s\n%s\n' "$PR_NUMBERS" "$ASSOCIATED_PRS")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Per-commit API loop

This makes one GraphQL request for every commit between the previous tag and the current SHA. On a large release range, such as a parent-tag fallback or a long-lived branch, the release job can spend a long time in this loop or hit GitHub API limits. Since errors are redirected and swallowed, failed requests silently drop associated PRs from the Community Contributions section.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build.yml:
- Around line 671-685: The current loop issues a gh api graphql call per commit
(COMMIT_SHAS -> while read -> ASSOCIATED_PRS), which can exhaust rate limits;
replace it by batching commits into chunks and issuing a single gh api graphql
call per chunk that builds a GraphQL query with aliased object(oid: "<sha>")
fields (e.g., alias1: object(oid: "...") { ... on Commit {
associatedPullRequests { nodes { number } } } } ) for all SHAs in the chunk,
then extract all returned numbers and append them into PR_NUMBERS; implement
chunking (e.g., 50–100 SHAs per request) and fall back gracefully on partial
failures (continue on errors) to preserve robustness while drastically reducing
the number of API calls.
- Around line 662-663: Move direct GitHub context expansions out of the script
body and into the step-level env block: define environment variables (e.g.,
OWNER, REPO_NAME, REPO_FULL) in the step's env: using the GitHub contexts (${ {
github.repository_owner } }, ${ { github.event.repository.name } }, ${ {
github.repository } }) and then update any shell script references that
currently interpolate those contexts (OWNER and REPO_NAME usages in the script)
to read from the environment variables instead; this removes template-injection
instances flagged by static analysis while keeping the same values available to
the step.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 449f1297-ab38-4115-a4ec-123d5b6575d8

📥 Commits

Reviewing files that changed from the base of the PR and between 4d71780 and f2238d4.

📒 Files selected for processing (1)
  • .github/workflows/build.yml
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Greptile Review
  • GitHub Check: review-pr
🧰 Additional context used
🪛 zizmor (1.25.2)
.github/workflows/build.yml

[warning] 662-662: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 663-663: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 695-695: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🔇 Additional comments (3)
.github/workflows/build.yml (3)

3-9: LGTM!


662-669: ⚡ Quick win

Verify Layer PR runs when the changelog has no author placeholders.

This block lives inside the "Resolve GitHub Usernames" step, which exits early at Line 514 (exit 0) when TOTAL_PLACEHOLDERS -eq 0. In that case the entire new Layer PR detection is skipped. Placeholders normally exist whenever commits appear in the changelog, but if a community PR's commits fall into cliff-hidden categories (e.g., chore/ci), the changelog body can have zero placeholders while a PR still exists in range — so the "robust" detection wouldn't run. Please confirm this is intended, or relocate the Layer PR block ahead of the early-exit (or into its own step) so PR detection is independent of placeholder presence.


689-708: LGTM!

Comment on lines +662 to +663
OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Move GitHub context expansions into env: to clear the template-injection findings.

zizmor flags lines 662, 663 (error), and 695 as template-injection because the ${{ … }} values are interpolated directly into the script body. Real exploitability here is low (github.repository_owner, github.event.repository.name, and github.repository cannot contain shell metacharacters), but routing them through the step env: block is the standard hardening and resolves the gating static-analysis error on Line 663.

🔒 Proposed hardening via step env

Add to the step's env: block (near Line 486):

      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        OWNER: ${{ github.repository_owner }}
        REPO_NAME: ${{ github.event.repository.name }}
        REPO_FULL: ${{ github.repository }}

Then reference the env vars instead of interpolating:

-          OWNER="${{ github.repository_owner }}"
-          REPO_NAME="${{ github.event.repository.name }}"
           PR_NUMBERS=""
-              PR_INFO=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM" \
+              PR_INFO=$(gh api "repos/$REPO_FULL/pulls/$PR_NUM" \
                 --jq '{title: .title, author: .user.login, url: .html_url}' 2>/dev/null || echo "{}")
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 662-662: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 663-663: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 662 - 663, Move direct GitHub
context expansions out of the script body and into the step-level env block:
define environment variables (e.g., OWNER, REPO_NAME, REPO_FULL) in the step's
env: using the GitHub contexts (${ { github.repository_owner } }, ${ {
github.event.repository.name } }, ${ { github.repository } }) and then update
any shell script references that currently interpolate those contexts (OWNER and
REPO_NAME usages in the script) to read from the environment variables instead;
this removes template-injection instances flagged by static analysis while
keeping the same values available to the step.

Comment on lines +671 to +685
# Layer PR-2: GraphQL associatedPullRequests catches squash/rebase flows
# even when the commit subject does not include a PR number.
COMMIT_SHAS=$(git rev-list "$PREV_TAG".."$CURRENT_SHA" 2>/dev/null || true)
while read -r SHA; do
if [ -z "$SHA" ]; then
continue
fi
ASSOCIATED_PRS=$(gh api graphql \
-f owner="$OWNER" \
-f name="$REPO_NAME" \
-f oid="$SHA" \
-f query='query($owner:String!, $name:String!, $oid:GitObjectID!) { repository(owner:$owner, name:$name) { object(oid:$oid) { ... on Commit { associatedPullRequests(first: 10) { nodes { number } } } } } }' \
--jq '.data.repository.object.associatedPullRequests.nodes[].number' 2>/dev/null || true)
PR_NUMBERS=$(printf '%s\n%s\n' "$PR_NUMBERS" "$ASSOCIATED_PRS")
done <<< "$COMMIT_SHAS"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Per-commit GraphQL lookup is O(commits) API calls — watch rate limits on large ranges.

This loop issues one gh api graphql call per commit. Combined with the existing per-commit username resolution (Layer B-2) and the per-email search/users calls in "Generate Build Metadata", a large range — especially the parent-branch fallback path where the range can span many commits — can generate hundreds of API calls and risk secondary rate limiting plus long runtimes. Consider batching the associatedPullRequests lookups into a single GraphQL request using aliased object(oid:) fields (chunked), which keeps the robustness benefit while collapsing N calls into a few.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 671 - 685, The current loop issues
a gh api graphql call per commit (COMMIT_SHAS -> while read -> ASSOCIATED_PRS),
which can exhaust rate limits; replace it by batching commits into chunks and
issuing a single gh api graphql call per chunk that builds a GraphQL query with
aliased object(oid: "<sha>") fields (e.g., alias1: object(oid: "...") { ... on
Commit { associatedPullRequests { nodes { number } } } } ) for all SHAs in the
chunk, then extract all returned numbers and append them into PR_NUMBERS;
implement chunking (e.g., 50–100 SHAs per request) and fall back gracefully on
partial failures (continue on errors) to preserve robustness while drastically
reducing the number of API calls.

@Mirrowel Mirrowel self-assigned this May 29, 2026
@Mirrowel Mirrowel added Agent Monitored Monitored for AI Agent to review PR's and commits under consideration Under consideration for future to work on/implement. labels May 29, 2026
Comment on lines 114 to 116
paths:
- 'src/proxy_app/**' # Main application source code
- 'src/rotator_library/**' # Key rotation library
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Restore workflow triggers

The push.paths filter now only matches source directories, so a commit that changes only this release workflow or .github/cliff.toml on dev/main will not run the build-and-release workflow. That means release workflow fixes and changelog-template changes can merge without exercising the release path they changed, and the updated release notes logic will not run until an unrelated source change is pushed.

Suggested change
paths:
- 'src/proxy_app/**' # Main application source code
- 'src/rotator_library/**' # Key rotation library
paths:
- 'src/proxy_app/**' # Main application source code
- 'src/rotator_library/**' # Key rotation library
- '.github/workflows/build.yml' # This workflow file
- '.github/cliff.toml' # Changelog configuration

@mirrobot-agent
Copy link
Copy Markdown
Contributor

Starting my review of the idempotency and concurrency additions. The build-skip logic for duplicate runs and the path-trigger cleanup look like smart operational improvements — let me dig into the details.

Copy link
Copy Markdown
Contributor

@mirrobot-agent mirrobot-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall Assessment

This is a solid set of operational improvements to the release workflow. The idempotency check (detecting existing releases for the same SHA) combined with the concurrency group and the should_release gate on downstream steps is a clean approach to preventing duplicate builds. The path trigger cleanup (removing workflow files from paths:) is also sensible — manual dispatch remains available for testing workflow changes.

Key Suggestions

  • Build job still runs on duplicates (see inline): The should_release check lives in the release job, so the build job (3 matrix runners) compiles all binaries before discovering the release already exists. A preliminary check job would save significant runner time.
  • --limit 1000 edge case (see inline): The existing-release lookup could miss a match if the repo ever exceeds 1000 releases. Low risk with pruning enabled, but worth documenting or hardening.

Path Trigger Changes

Removing .github/workflows/build.yml, cliff.toml, and .github/workflows/docker-build.yml from the paths: triggers is consistent and reasonable. Just be aware that edits to these files won't auto-trigger a build — manual dispatch is the escape hatch.

Prune Behavior Change

The Prune Old Releases step changed from if: always() to if: steps.version.outputs.should_release == 'true'. The removed comments already discussed this — pruning now only runs when a release is actually created. This is the right call.

Previous Review Feedback

My earlier review on commit f2238d4 raised two points on the contributor detection code (lines 692 and 697–709): the regex matching false positives on #number patterns, and the per-commit GraphQL API calls. These haven't been addressed in this commit — they still apply.

Questions for the Author

  • Have you observed duplicate workflow runs in practice that motivated the idempotency check, or is this preemptive?

This review was generated by an AI assistant.

Comment on lines +247 to +260
EXISTING_TAG=$(gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,targetCommitish \
--jq ".[] | select(.tagName | startswith(\"$BRANCH_NAME/build-\")) | select(.targetCommitish == \"${{ github.sha }}\") | .tagName" \
| head -n 1)

if [ -n "$EXISTING_TAG" ]; then
echo "Release already exists for this commit: $EXISTING_TAG"
echo "release_title=Build ($BRANCH_NAME): ${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "release_tag=$EXISTING_TAG" >> $GITHUB_OUTPUT
echo "archive_version_part=${EXISTING_TAG%%/*}-${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "version=${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "timestamp=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idempotency check correctly prevents duplicate releases, but the build job (3 OS matrix runners, lines 127–214) still compiles all binaries before the release job discovers the duplicate and skips. For repos where duplicate triggers are common, this wastes significant runner time.

Consider adding a lightweight check job before build that performs this same existing-tag lookup and gates the build job with if: needs.check.outputs.should_build == true. This would short-circuit the expensive cross-platform compilation when no release is needed.

Comment on lines +247 to +249
EXISTING_TAG=$(gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,targetCommitish \
--jq ".[] | select(.tagName | startswith(\"$BRANCH_NAME/build-\")) | select(.targetCommitish == \"${{ github.sha }}\") | .tagName" \
| head -n 1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --limit 1000 fetches up to 1000 releases to search for an existing tag targeting this SHA. If the repo ever exceeds 1000 releases (unlikely with pruning, but possible if pruning is disabled or during heavy development), this check could silently miss an existing release and create a duplicate.

Consider adding a targeted check via gh release view with the expected tag prefix, or document the 1000-release assumption with a brief comment for future maintainers.

Comment on lines +149 to +150
if branch_exists "$branch"; then
continue
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fail closed on branch checks

In stale_branches mode, branch_exists returns nonzero for both “this branch is gone” and “GitHub could not be queried”. If git ls-remote has a transient network, auth, or service failure, this path treats every unprotected branch as stale and proceeds to delete its matching releases and tags. The stale cleanup should abort or otherwise distinguish lookup failures from confirmed missing branches before deleting releases.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/build.yml:
- Around line 247-260: The gh release list call uses an unsupported field
targetCommitish; update the logic that sets EXISTING_TAG to only request
supported fields (e.g., tagName) and then, for each tagName that starts with
"$BRANCH_NAME/build-", resolve that tag's commit and compare it to "${{
github.sha }}" to decide if a release for this commit already exists;
specifically, keep the EXISTING_TAG variable and BRANCH_NAME usage but replace
the single gh release list --json ... --jq pipeline with a two-step approach:
(1) call gh release list --json tagName (or use gh api to list releases) and
filter tagName values that startwith "$BRANCH_NAME/build-", and (2) for each
candidate tagName use git (git rev-list --max-count=1 <tag> or git rev-parse
refs/tags/<tag>) or gh api (repos/:owner/:repo/git/ref/tags/:tag) to get the
tag's target commit SHA and compare to "${{ github.sha }}" to set EXISTING_TAG
when equal so the rest of the existing early-exit code (release_title,
release_tag, archive_version_part, version, timestamp, should_release) can
remain unchanged.
- Line 282: The workflow uses mutable action tags like
actions/download-artifact@v4 (and many others such as actions/checkout@v4,
actions/checkout@v5, docker/build-push-action@v6, etc. listed by the review),
which must be replaced with immutable 40-character commit SHAs; update each
`uses: owner/repo@v*` entry (including the one referenced as
actions/download-artifact@v4) to `owner/repo@<40-char-commit-sha>` and add the
original tag as a trailing comment (e.g., # v4) so the version is documented
while ensuring the action is pinned to a specific commit SHA.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 250596b7-87c6-4e26-b9cd-a908c4e171d2

📥 Commits

Reviewing files that changed from the base of the PR and between f2238d4 and 027389a.

📒 Files selected for processing (2)
  • .github/workflows/build.yml
  • .github/workflows/docker-build.yml
💤 Files with no reviewable changes (1)
  • .github/workflows/docker-build.yml
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Greptile Review
  • GitHub Check: review-pr
🧰 Additional context used
🪛 zizmor (1.25.2)
.github/workflows/build.yml

[warning] 247-247: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 248-248: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 282-282: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🔇 Additional comments (6)
.github/workflows/build.yml (6)

247-248: 💤 Low value

Route GitHub context expansions through env: to clear the template-injection findings.

zizmor flags lines 247-248 (${{ github.repository }}, ${{ github.sha }}). Real exploitability is low (these can't contain shell metacharacters), but moving them into the step env: block is the standard hardening — same pattern already noted for the Layer PR section. If you adopt the local-tag fix above, only ${{ github.sha }} remains to be routed through env.


692-692: The bare #[0-9]+ alternative matches any #123 in a commit subject (e.g. fixes #45, `reverts `#100, related to #200), not just the source PR. These leak into `PR_NUMBERS`; issue numbers are filtered out later by the 404 from the `pulls/$PR_NUM` fetch, but unrelated *PR* references still get included in Community Contributions. Tightening to `Merge pull request `#N and parenthesized (#N) squash subjects avoids the false positives.


697-709: This issues one gh api graphql call per commit in the range, on top of the existing per-commit username resolution (Layer B-2) and per-email search/users calls. On large ranges — especially the parent-branch fallback path — this can generate hundreds of calls and risk secondary rate limiting. Batching the associatedPullRequests lookups into a single chunked GraphQL request with aliased object(oid:) fields keeps the robustness while collapsing N calls into a few.


118-120: LGTM!


281-281: LGTM!

Also applies to: 288-288, 351-351, 369-369, 378-378, 507-507, 782-782, 799-799, 862-862, 974-974


711-747: LGTM!

Comment on lines +247 to +260
EXISTING_TAG=$(gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,targetCommitish \
--jq ".[] | select(.tagName | startswith(\"$BRANCH_NAME/build-\")) | select(.targetCommitish == \"${{ github.sha }}\") | .tagName" \
| head -n 1)

if [ -n "$EXISTING_TAG" ]; then
echo "Release already exists for this commit: $EXISTING_TAG"
echo "release_title=Build ($BRANCH_NAME): ${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "release_tag=$EXISTING_TAG" >> $GITHUB_OUTPUT
echo "archive_version_part=${EXISTING_TAG%%/*}-${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "version=${EXISTING_TAG#*/build-}" >> $GITHUB_OUTPUT
echo "timestamp=$(date -u +'%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_OUTPUT
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What JSON fields does gh release list --json support, and does it include targetCommitish?

💡 Result:

The gh release list command supports the --json flag, which allows you to output specific fields for each release in the repository [1][2]. According to the official GitHub CLI documentation, the following fields are supported [1][2]: createdAt, isDraft, isImmutable, isLatest, isPrerelease, name, publishedAt, tagName The field targetCommitish is not included in the supported fields for gh release list [1][2]. While targetCommitish is available as a JSON field in other commands like gh release view [3], it is currently not an option for the list subcommand [1][2]. If you need to access targetCommitish for releases, you would typically need to use gh release view for individual releases or query the GitHub API directly [4][3].

Citations:


Fix unsupported targetCommitish in gh release list --json

gh release list --json only supports createdAt, isDraft, isImmutable, isLatest, isPrerelease, name, publishedAt, tagName and does not include targetCommitish, so this command will error and abort the “Generate Build Version” step.

🐛 Proposed fix using local tags
-        EXISTING_TAG=$(gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,targetCommitish \
-          --jq ".[] | select(.tagName | startswith(\"$BRANCH_NAME/build-\")) | select(.targetCommitish == \"${{ github.sha }}\") | .tagName" \
-          | head -n 1)
+        EXISTING_TAG=$(git tag --list "$BRANCH_NAME/build-*" --points-at "${{ github.sha }}" | head -n 1)
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 247-247: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 248-248: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml around lines 247 - 260, The gh release list call
uses an unsupported field targetCommitish; update the logic that sets
EXISTING_TAG to only request supported fields (e.g., tagName) and then, for each
tagName that starts with "$BRANCH_NAME/build-", resolve that tag's commit and
compare it to "${{ github.sha }}" to decide if a release for this commit already
exists; specifically, keep the EXISTING_TAG variable and BRANCH_NAME usage but
replace the single gh release list --json ... --jq pipeline with a two-step
approach: (1) call gh release list --json tagName (or use gh api to list
releases) and filter tagName values that startwith "$BRANCH_NAME/build-", and
(2) for each candidate tagName use git (git rev-list --max-count=1 <tag> or git
rev-parse refs/tags/<tag>) or gh api (repos/:owner/:repo/git/ref/tags/:tag) to
get the tag's target commit SHA and compare to "${{ github.sha }}" to set
EXISTING_TAG when equal so the rest of the existing early-exit code
(release_title, release_tag, archive_version_part, version, timestamp,
should_release) can remain unchanged.


- name: Download build artifacts
if: steps.version.outputs.should_release == 'true'
uses: actions/download-artifact@v4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


🏁 Script executed:

#!/bin/bash
# List action references not pinned to a 40-char commit SHA
rg -nP 'uses:\s*\S+@(?![0-9a-f]{40}\b)\S+' -g '*.yml' -g '*.yaml' .github

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 1990


Pin zizmor-flagged uses: actions to commit SHAs

.github/workflows/build.yml:282 uses actions/download-artifact@v4 (mutable tag), which zizmor reports as an unpinned-uses error. Other unpinned uses: refs found:

  • .github/workflows/issue-comment.yml:29 actions/checkout@v4
  • .github/workflows/issue-comment.yml:56 actions/checkout@v4
  • .github/workflows/pr-review.yml:98 actions/checkout@v4
  • .github/workflows/pr-review.yml:527 actions/checkout@v4
  • .github/workflows/docker-build.yml:38 actions/checkout@v5
  • .github/workflows/docker-build.yml:43 docker/setup-qemu-action@v3
  • .github/workflows/docker-build.yml:46 docker/setup-buildx-action@v3
  • .github/workflows/docker-build.yml:50 docker/login-action@v3
  • .github/workflows/docker-build.yml:90 docker/metadata-action@v5
  • .github/workflows/docker-build.yml:101 docker/build-push-action@v6
  • .github/workflows/docker-build.yml:113 actions/attest-build-provenance@v3
  • .github/workflows/docker-build.yml:127 actions/delete-package-versions@v5
  • .github/workflows/cleanup.yml:34 actions/checkout@v4
  • .github/workflows/compliance-check.yml:217 actions/checkout@v4
  • .github/workflows/compliance-check.yml:376 actions/checkout@v4
  • .github/workflows/bot-reply.yml:79 actions/checkout@v4
  • .github/workflows/bot-reply.yml:536 actions/checkout@v4
  • .github/workflows/bot-reply.yml:599 actions/checkout@v4
  • .github/workflows/build.yml:134 actions/checkout@v5
  • .github/workflows/build.yml:137 astral-sh/setup-uv@v4
  • .github/workflows/build.yml:169 actions/cache@v4
  • .github/workflows/build.yml:211 actions/upload-artifact@v4
  • .github/workflows/build.yml:224 actions/checkout@v5
  • .github/workflows/build.yml:282 actions/download-artifact@v4
  • .github/actions/bot-setup/action.yml:34 actions/create-github-app-token@v1
  • .github/actions/bot-setup/action.yml:159 astral-sh/setup-uv@v4

Replace each owner/repo@v* with owner/repo@<40-char-commit-sha> (keep the version as a trailing comment) to satisfy the policy and avoid gating.

🧰 Tools
🪛 zizmor (1.25.2)

[error] 282-282: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build.yml at line 282, The workflow uses mutable action
tags like actions/download-artifact@v4 (and many others such as
actions/checkout@v4, actions/checkout@v5, docker/build-push-action@v6, etc.
listed by the review), which must be replaced with immutable 40-character commit
SHAs; update each `uses: owner/repo@v*` entry (including the one referenced as
actions/download-artifact@v4) to `owner/repo@<40-char-commit-sha>` and add the
original tag as a trailing comment (e.g., # v4) so the version is documented
while ensuring the action is pinned to a specific commit SHA.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/cleanup.yml:
- Around line 112-116: The releases_for_branch function currently interpolates
$branch into the gh --jq string which prevents using jq CLI args and risks
injection; instead have gh release list emit raw JSON (use --jq '.' or no --jq)
and pipe that output into standalone jq, passing the branch via --arg branch
"$branch" and using that variable in the filter (e.g., sort_by(.createdAt) |
reverse | .[] | select(.tagName | startswith($branch + "/")) | .tagName) so the
branch value is bound safely to jq rather than interpolated into the gh --jq
expression.
- Around line 108-110: branch_exists currently treats any non-zero exit from git
ls-remote as “branch absent”; change the branch_exists() implementation to run
git ls-remote --exit-code --heads "$1" capturing its exit code, and only return
“absent” when the exit code equals 2 (no matching refs); for any other non-zero
exit code (transport/auth/fatal errors) propagate/fail (e.g., return a different
non-zero status or exit) so callers know the lookup failed rather than assuming
the branch was deleted.
- Around line 45-84: The run step "Determine cleanup config" currently injects
`${{ github.event.* }}` and `${{ github.event.ref }}` directly into the script
body (populating CLEANUP_MODE, BRANCH_NAME, KEEP_LATEST, DRY_RUN) which risks
command injection; move the event inputs and ref into the step's env: (e.g.,
EVENT_CLEANUP_MODE, EVENT_BRANCH_NAME, EVENT_KEEP_LATEST, EVENT_DRY_RUN) using
`${{ github.event.inputs.* }}` and `${{ github.event.ref }}` there, then inside
the run script assign CLEANUP_MODE, BRANCH_NAME, KEEP_LATEST, DRY_RUN from those
env vars (e.g., CLEANUP_MODE="$EVENT_CLEANUP_MODE") and proceed with the
existing validation (case, integer check, branch required) and outputs; this
keeps runner-safe data handling while leaving the rest of the logic (echo to
GITHUB_OUTPUT, Mode/Branch/Keep latest/Dry run printing) unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 14d316a0-33d9-496f-ae03-2a1a46bdaec3

📥 Commits

Reviewing files that changed from the base of the PR and between 027389a and 4fc7aa3.

📒 Files selected for processing (1)
  • .github/workflows/cleanup.yml
📜 Review details
🧰 Additional context used
🪛 zizmor (1.25.2)
.github/workflows/cleanup.yml

[warning] 40-43: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 49-49: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 50-50: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 51-51: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 52-52: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 53-53: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 56-56: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 114-114: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 119-119: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[warning] 184-184: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 41-41: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🔇 Additional comments (6)
.github/workflows/cleanup.yml (6)

7-29: LGTM!


31-43: LGTM!


96-106: LGTM!


118-130: LGTM!


132-199: LGTM!


201-226: LGTM!

Comment on lines +45 to +84
- name: Determine cleanup config
id: config
shell: bash
run: |
# Determine branch name based on trigger type
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CLEANUP_MODE="${{ github.event.inputs.cleanup_mode }}"
BRANCH_NAME="${{ github.event.inputs.branch_name }}"
KEEP_LATEST="${{ github.event.inputs.keep_latest }}"
DRY_RUN="${{ github.event.inputs.dry_run }}"
echo "🔧 Manual trigger detected"
else
CLEANUP_MODE="branch"
BRANCH_NAME="${{ github.event.ref }}"
KEEP_LATEST="0"
DRY_RUN="false"
echo "🗑️ Branch deletion detected"
fi

echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "dry_run=$DRY_RUN" >> $GITHUB_OUTPUT

echo "Branch: $BRANCH_NAME"
echo "Dry Run: $DRY_RUN"

- name: Validate branch is not protected
shell: bash
env:
BRANCH_NAME: ${{ steps.config.outputs.branch_name }}
run: |
echo "🔍 Checking if branch '$BRANCH_NAME' is protected..."

# Convert comma-separated list to array
IFS=',' read -ra PROTECTED <<< "$PROTECTED_BRANCHES"

# Check if branch is in protected list
for protected in "${PROTECTED[@]}"; do
# Trim whitespace
protected=$(echo "$protected" | xargs)
if [ "$BRANCH_NAME" == "$protected" ]; then
echo "❌ ERROR: Branch '$BRANCH_NAME' is protected and cannot be cleaned up."
echo ""
echo "Protected branches: $PROTECTED_BRANCHES"
echo ""
echo "If you need to clean up this branch, please remove it from the"
echo "PROTECTED_BRANCHES environment variable in .github/workflows/cleanup.yml"
exit 1
fi
done

echo "✅ Branch '$BRANCH_NAME' is not protected. Proceeding with cleanup."
case "$CLEANUP_MODE" in
branch|stale_branches) ;;
*) echo "Unsupported cleanup mode: $CLEANUP_MODE" >&2; exit 1 ;;
esac

if ! printf '%s\n' "$KEEP_LATEST" | grep -Eq '^[0-9]+$'; then
echo "keep_latest must be a non-negative integer, got: $KEEP_LATEST" >&2
exit 1
fi

if [ "$CLEANUP_MODE" = "branch" ] && [ -z "$BRANCH_NAME" ]; then
echo "branch_name is required for branch mode" >&2
exit 1
fi

echo "cleanup_mode=$CLEANUP_MODE" >> "$GITHUB_OUTPUT"
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
echo "keep_latest=$KEEP_LATEST" >> "$GITHUB_OUTPUT"
echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT"

echo "Mode: $CLEANUP_MODE"
echo "Branch: ${BRANCH_NAME:-'(none)'}"
echo "Keep latest: $KEEP_LATEST"
echo "Dry run: $DRY_RUN"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What characters are allowed in git branch and ref names (git check-ref-format), and can a branch name contain backticks, dollar signs, or semicolons?

💡 Result:

Git branch and reference names are governed by the rules enforced by the git check-ref-format command [1][2]. While Git is generally permissive regarding character usage, it strictly prohibits several characters and patterns to ensure system compatibility and prevent conflicts with shell operations or internal Git mechanisms [3][4]. Specifically, a branch name can contain backticks (`) and dollar signs ($), as these are not explicitly forbidden by the Git ref-format rules [1][4]. However, a branch name cannot contain a semicolon (;) because it is a shell control character, and although not explicitly listed in the core git-check-ref-format rules, it is often grouped with other characters that cause issues in command-line environments [3][5]. More importantly, if you use characters like backticks, dollar signs, or semicolons, you must quote or escape the branch name when interacting with it via the command-line interface (CLI) [3][5]. Without quoting (e.g., using 'branch-name'), your shell may interpret these characters as commands or variables (for instance, treating $ as a variable reference or ; as a command separator), leading to errors or unintended side effects [3][5][6]. The prohibited characters and patterns for Git references include [1][2]: - ASCII control characters (bytes lower than \040, or \177 DEL) [1][7] - Space, tilde (~), caret (^), or colon (:) [1][8] - Question mark (?), asterisk (*), or open bracket ([) [1][9] - A backslash () [1][2] - Consecutive dots (..) [1][7] - The sequence @{ [1][2] - The single character @ [1][8] - Starting or ending with a slash (/) or having multiple consecutive slashes (/) [1][9] - Starting with a dot (.) or ending with a dot (.) [1][2] - Ending with the sequence.lock [1][2] Due to potential shell conflicts and general best practices, it is strongly recommended to restrict branch names to alphanumeric characters, hyphens (-), underscores (_), periods (.), and forward slashes (/) [3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/cleanup.yml"

echo "=== File exists? ==="
ls -la "$FILE"

echo "=== First 220 lines (to include inputs + referenced lines) ==="
nl -ba "$FILE" | sed -n '1,220p'

echo "=== Search for github.event.inputs.* usage ==="
rg -n "github\.event\.inputs\." "$FILE" || true

echo "=== Search for github\.repository usage ==="
rg -n "github\.repository" "$FILE" || true

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 295


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/cleanup.yml"

echo "=== Total lines ==="
wc -l "$FILE"

echo "=== First 240 lines with line numbers ==="
awk '{printf "%5d  %s\n", NR, $0}' "$FILE" | sed -n '1,240p'

echo "=== Search for github.event.inputs.* usage ==="
rg -n "github\.event\.inputs\." "$FILE" || true

echo "=== Search for github.event.ref usage ==="
rg -n "github\.event\.ref" "$FILE" || true

echo "=== Search for github.repository usage ==="
rg -n "github\.repository" "$FILE" || true

echo "=== Search for BRANCH_NAME usage (to see quoting later) ==="
rg -n "\\bBRANCH_NAME\\b" "$FILE" || true

echo "=== Search for git commands involving BRANCH_NAME / CLEANUP_MODE ==="
rg -n "git .*BRANCH_NAME|BRANCH_NAME.*git|git .*CLEANUP_MODE|CLEANUP_MODE.*git" "$FILE" || true

Repository: Mirrowel/LLM-API-Key-Proxy

Length of output: 11861


Fix command-injection risk from ${{ github.event.* }} interpolation in run

In .github/workflows/cleanup.yml, step Determine cleanup config assigns BRANCH_NAME/KEEP_LATEST/DRY_RUN directly from ${{ github.event.inputs.* }} and ${{ github.event.ref }} into the run script body (e.g., lines 49-59). Git ref/branch names can contain backticks (`) and $, which bash will treat as command/parameter expansions when they’re interpolated into the script text—allowing attacker-controlled command execution in a job with permissions: contents: write. Move these event values into env: and reference them as variables inside the script so the runner treats them as data, not shell syntax.

🔒 Proposed fix: pass event data via env
       - name: Determine cleanup config
         id: config
         shell: bash
+        env:
+          EVENT_NAME: ${{ github.event_name }}
+          INPUT_CLEANUP_MODE: ${{ github.event.inputs.cleanup_mode }}
+          INPUT_BRANCH_NAME: ${{ github.event.inputs.branch_name }}
+          INPUT_KEEP_LATEST: ${{ github.event.inputs.keep_latest }}
+          INPUT_DRY_RUN: ${{ github.event.inputs.dry_run }}
+          EVENT_REF: ${{ github.event.ref }}
         run: |
-          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
-            CLEANUP_MODE="${{ github.event.inputs.cleanup_mode }}"
-            BRANCH_NAME="${{ github.event.inputs.branch_name }}"
-            KEEP_LATEST="${{ github.event.inputs.keep_latest }}"
-            DRY_RUN="${{ github.event.inputs.dry_run }}"
+          if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
+            CLEANUP_MODE="$INPUT_CLEANUP_MODE"
+            BRANCH_NAME="$INPUT_BRANCH_NAME"
+            KEEP_LATEST="$INPUT_KEEP_LATEST"
+            DRY_RUN="$INPUT_DRY_RUN"
           else
             CLEANUP_MODE="branch"
-            BRANCH_NAME="${{ github.event.ref }}"
+            BRANCH_NAME="$EVENT_REF"
             KEEP_LATEST="0"
             DRY_RUN="false"
           fi

Zizmor warnings for ${{ github.repository }} usage in the later gh release list/delete calls are false positives (it’s not attacker-controlled).

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 49-49: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 50-50: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 51-51: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 52-52: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 53-53: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 56-56: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cleanup.yml around lines 45 - 84, The run step "Determine
cleanup config" currently injects `${{ github.event.* }}` and `${{
github.event.ref }}` directly into the script body (populating CLEANUP_MODE,
BRANCH_NAME, KEEP_LATEST, DRY_RUN) which risks command injection; move the event
inputs and ref into the step's env: (e.g., EVENT_CLEANUP_MODE,
EVENT_BRANCH_NAME, EVENT_KEEP_LATEST, EVENT_DRY_RUN) using `${{
github.event.inputs.* }}` and `${{ github.event.ref }}` there, then inside the
run script assign CLEANUP_MODE, BRANCH_NAME, KEEP_LATEST, DRY_RUN from those env
vars (e.g., CLEANUP_MODE="$EVENT_CLEANUP_MODE") and proceed with the existing
validation (case, integer check, branch required) and outputs; this keeps
runner-safe data handling while leaving the rest of the logic (echo to
GITHUB_OUTPUT, Mode/Branch/Keep latest/Dry run printing) unchanged.

Comment on lines +108 to +110
branch_exists() {
git ls-remote --exit-code --heads origin "$1" >/dev/null 2>&1
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What exit status codes does "git ls-remote --exit-code" return when no matching refs are found versus when the command fails due to a network or transport error?

💡 Result:

When using git ls-remote --exit-code: 1) No matching refs found: exits with status 2 [1][2]. 2) Network/transport error (e.g., cannot connect / transport failure): git ls-remote does not map those failures to --exit-code’s “no matching refs” status; instead it exits with a different non-zero status used for fatal errors. Git’s git-ls-remote documentation explicitly only defines the --exit-code behavior for the “no matching refs” case (status 2) and says the usual “talked to remote successfully” exit is 0; it does not document an alternate status number for network/transport errors under --exit-code [1][2]. Practical implication: you can reliably treat exit code 2 as “remote reachable, but pattern matched no refs.” For any other non-zero exit, handle it as an error condition such as authentication failure, repository not found, or network/transport failure [2].

Citations:


Fix branch_exists to only treat git ls-remote --exit-code exit code 2 as “branch absent”

git ls-remote --exit-code exits 2 for “no matching refs”; other non-zero exits cover fatal/transport/auth failures. The current implementation treats any non-zero as “branch deleted”, which can cause stale branch cleanup to delete releases during transient lookup failures.

🛡️ Proposed fail-safe lookup
           branch_exists() {
-            git ls-remote --exit-code --heads origin "$1" >/dev/null 2>&1
+            local out status
+            out="$(git ls-remote --exit-code --heads origin "$1" 2>/dev/null)"
+            status=$?
+            if [ "$status" -eq 2 ]; then
+              # No matching refs
+              return 1
+            elif [ "$status" -ne 0 ]; then
+              # Transient/network/auth/etc error: assume the branch exists
+              echo "warning: 'git ls-remote' failed for '$1'; assuming branch exists" >&2
+              return 0
+            fi
+            [ -n "$out" ]
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
branch_exists() {
git ls-remote --exit-code --heads origin "$1" >/dev/null 2>&1
}
branch_exists() {
local out status
out="$(git ls-remote --exit-code --heads origin "$1" 2>/dev/null)"
status=$?
if [ "$status" -eq 2 ]; then
# No matching refs
return 1
elif [ "$status" -ne 0 ]; then
# Transient/network/auth/etc error: assume the branch exists
echo "warning: 'git ls-remote' failed for '$1'; assuming branch exists" >&2
return 0
fi
[ -n "$out" ]
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cleanup.yml around lines 108 - 110, branch_exists
currently treats any non-zero exit from git ls-remote as “branch absent”; change
the branch_exists() implementation to run git ls-remote --exit-code --heads "$1"
capturing its exit code, and only return “absent” when the exit code equals 2
(no matching refs); for any other non-zero exit code (transport/auth/fatal
errors) propagate/fail (e.g., return a different non-zero status or exit) so
callers know the lookup failed rather than assuming the branch was deleted.

Comment on lines +112 to +116
releases_for_branch() {
local branch="$1"
gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,createdAt \
--jq "sort_by(.createdAt) | reverse | .[] | select(.tagName | startswith(\"$branch/\")) | .tagName"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the GitHub CLI gh --jqflag support passing jq named arguments such as--arg, or must filtering with variables be done by piping output to standalone jq?

💡 Result:

No: gh --jq does not support passing through jq named arguments like --arg (e.g., you can’t do gh ... --jq '...' --arg foo bar). The --jq flag only accepts a single jq program/expression string, and gh does not expose a mechanism to pass additional jq CLI arguments/variables to that program; if you need jq variables, you must do it via piping to standalone jq (or by composing values into the jq expression itself). Evidence: - gh documents --jq as “Query to select values from the response using jq syntax” and shows usage as --jq '<jq expression>' (with no mention of forwarding jq CLI arguments such as --arg). [1] - The gh help formatting docs state that formatting uses --json to convert to JSON and then --jq “requires a string argument in jq query syntax” to “only print those JSON values which match the query.” [2] - In the cli/cli source, the --jq flag is stored as a string “expression” (and --jq cannot be used without also specifying --json), indicating gh treats it purely as the jq filter expression rather than something that can accept jq’s own --arg/etc. arguments. [3] - A related GitHub CLI issue explicitly describes a workaround where users shell out to standalone jq specifically “so that I could pass in an arg, rather than being able to use gh's --jq for it,” implying --jq alone can’t take --arg-style bindings. [4] Example workaround (pipe to standalone jq to use --arg): - gh ... --json <fields> | jq --arg name "$NAME" '... use $name ...' So: if you need jq named variables, use standalone jq after gh (or inline everything into the --jq expression); you can’t rely on gh --jq to accept/forward --arg.

Citations:


Bind the branch into standalone jq via --arg (don’t interpolate into gh --jq).

gh --jq only accepts a single jq expression string and can’t take jq CLI named arguments like --arg, so $branch must be passed to standalone jq to avoid jq-filter breakage/injection.

♻️ Proposed refactor
           releases_for_branch() {
             local branch="$1"
             gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,createdAt \
-              --jq "sort_by(.createdAt) | reverse | .[] | select(.tagName | startswith(\"$branch/\")) | .tagName"
+              | jq -r --arg b "$branch" 'sort_by(.createdAt) | reverse | .[] | select(.tagName | startswith($b + "/")) | .tagName'
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
releases_for_branch() {
local branch="$1"
gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,createdAt \
--jq "sort_by(.createdAt) | reverse | .[] | select(.tagName | startswith(\"$branch/\")) | .tagName"
}
releases_for_branch() {
local branch="$1"
gh release list --repo "${{ github.repository }}" --limit 1000 --json tagName,createdAt \
| jq -r --arg b "$branch" 'sort_by(.createdAt) | reverse | .[] | select(.tagName | startswith($b + "/")) | .tagName'
}
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 114-114: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cleanup.yml around lines 112 - 116, The
releases_for_branch function currently interpolates $branch into the gh --jq
string which prevents using jq CLI args and risks injection; instead have gh
release list emit raw JSON (use --jq '.' or no --jq) and pipe that output into
standalone jq, passing the branch via --arg branch "$branch" and using that
variable in the filter (e.g., sort_by(.createdAt) | reverse | .[] |
select(.tagName | startswith($branch + "/")) | .tagName) so the branch value is
bound safely to jq rather than interpolated into the gh --jq expression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Monitored Monitored for AI Agent to review PR's and commits under consideration Under consideration for future to work on/implement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant