Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/branch-protection-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
name: Branch Protection Check

on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9 AM UTC
workflow_dispatch:

permissions:
contents: read

jobs:
verify-config:
name: Verify Dependabot and auto-merge setup
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
Comment on lines +16 to +17
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 full commit SHA should actions/checkoutv4 be pinned to in GitHub Actions workflows, and what do GitHub security best practices say about settingpersist-credentials: false when the job only needs read-only checkout?

💡 Result:

To pin actions/checkout v4 to a specific commit SHA, you should select a full-length SHA from the actions/checkout repository's commit history that corresponds to the version you intend to use [1][2]. Because tags are mutable pointers, pinning to a full commit SHA is the only way to ensure your workflow uses an immutable version of the action, protecting it against potential supply chain attacks where a tag might be updated to point to malicious code [1][2]. You can find the full SHA for any release by navigating to the actions/checkout repository on GitHub, viewing the tags or commit history, and copying the complete hash [1][2]. Regarding persist-credentials: false, GitHub security best practices strongly recommend setting this to false whenever your job does not require the ability to perform authenticated Git operations (such as git push) [3]. By default, actions/checkout sets persist-credentials: true, which configures the local Git environment with the GITHUB_TOKEN (or provided PAT) [4][5]. While the token is removed during post-job cleanup, leaving it enabled means that any subsequent step in the same job—including potentially compromised third-party actions or scripts—can access the token from the local Git configuration [6][3]. Setting persist-credentials: false prevents this configuration, thereby reducing the attack surface and adhering to the principle of least privilege [7][3].

Citations:


🏁 Script executed:

cat -n .github/workflows/branch-protection-check.yml

Repository: openshift/cloud-ingress-operator

Length of output: 4238


Pin actions/checkout to a commit SHA and disable credential persistence.

This scheduled workflow requires only read-only access to repository files. Pinning actions/checkout@v4 to a full commit SHA prevents supply-chain attacks via mutable tag reuse, and setting persist-credentials: false prevents the GITHUB_TOKEN from remaining available to subsequent steps, reducing token-exposure risk per GitHub security best practices.

Update the checkout step:

      - name: Checkout code
        uses: actions/checkout@v4
        with:
          persist-credentials: false

Replace @v4 with the full commit SHA from https://github.com/actions/checkout/releases/tag/v4 (e.g., @a5ac7e51b41094c153f46a9261a0be5ab68572ebb6ae6244bbf1136490427062).

🧰 Tools
🪛 zizmor (1.25.2)

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

(artipacked)


[error] 17-17: 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/branch-protection-check.yml around lines 16 - 17, Update
the Checkout code step that currently uses the mutable tag "uses:
actions/checkout@v4" by replacing the tag with the full v4 commit SHA from the
actions/checkout releases and add the checkout input to disable credential
persistence; specifically change the actions/checkout usage referenced in the
workflow step (the "Checkout code" step) to the full commit SHA (e.g.,
`@a5ac7e51b41094c153f46a9261a0be5ab68572ebb6ae6244bbf1136490427062`) and add with:
persist-credentials: false so the GITHUB_TOKEN is not left available to later
steps.


- name: Validate Dependabot and workflow configuration
run: |
set -euo pipefail
pip install --quiet pyyaml
python3 <<'PY'
import sys
from pathlib import Path

import yaml

def fail(msg: str) -> None:
print(f"❌ {msg}")
sys.exit(1)

dependabot_path = Path(".github/dependabot.yml")
if not dependabot_path.is_file():
fail("Dependabot configuration missing (.github/dependabot.yml)")

with dependabot_path.open() as f:
cfg = yaml.safe_load(f)
if not isinstance(cfg, dict):
fail("dependabot.yml must be a YAML mapping")
if cfg.get("version") != 2:
fail("dependabot.yml: version must be 2")
updates = cfg.get("updates")
if not isinstance(updates, list) or not updates:
fail("dependabot.yml: updates must be a non-empty list")
for i, entry in enumerate(updates):
if not isinstance(entry, dict):
fail(f"dependabot.yml: updates[{i}] must be a mapping")
if not entry.get("package-ecosystem"):
fail(f"dependabot.yml: updates[{i}] missing package-ecosystem")
if "directory" not in entry:
fail(f"dependabot.yml: updates[{i}] missing directory")

print("✅ dependabot.yml structure is valid")
for entry in updates:
print(f" - {entry.get('package-ecosystem')} ({entry.get('directory')})")

workflow_path = Path(".github/workflows/dependabot-auto-merge.yml")
if not workflow_path.is_file():
fail("dependabot-auto-merge workflow missing")

with workflow_path.open() as f:
wf = yaml.safe_load(f)
if not isinstance(wf, dict):
fail("dependabot-auto-merge.yml must be a YAML mapping")
on = wf.get("on")
if not isinstance(on, dict) or "pull_request_target" not in on:
fail("dependabot-auto-merge.yml must use pull_request_target trigger")
Comment on lines +62 to +68
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
python3 - <<'PY'
from pathlib import Path
import yaml

wf = yaml.safe_load(Path(".github/workflows/dependabot-auto-merge.yml").read_text())
print("top-level keys:", list(wf.keys()))
print('wf.get("on") ->', wf.get("on"))
print("wf.get(True) ->", wf.get(True))
PY

Repository: openshift/cloud-ingress-operator

Length of output: 261


Fix PyYAML's on key coercion in workflow validation.

yaml.safe_load() parses the bare top-level on: key as boolean True instead of the string "on", causing wf.get("on") to return None and the validator to incorrectly reject valid workflows.

This has been verified—the file at .github/workflows/dependabot-auto-merge.yml parses with top-level keys ['name', True, 'permissions', 'jobs'], where wf.get("on") returns None but wf.get(True) correctly returns the trigger configuration.

Suggested fix
           with workflow_path.open() as f:
               wf = yaml.safe_load(f)
           if not isinstance(wf, dict):
               fail("dependabot-auto-merge.yml must be a YAML mapping")
-          on = wf.get("on")
-          if not isinstance(on, dict) or "pull_request_target" not in on:
+          on_section = wf.get("on")
+          if on_section is None and True in wf:
+              on_section = wf[True]
+          if not isinstance(on_section, dict) or "pull_request_target" not in on_section:
               fail("dependabot-auto-merge.yml must use pull_request_target trigger")
📝 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
with workflow_path.open() as f:
wf = yaml.safe_load(f)
if not isinstance(wf, dict):
fail("dependabot-auto-merge.yml must be a YAML mapping")
on = wf.get("on")
if not isinstance(on, dict) or "pull_request_target" not in on:
fail("dependabot-auto-merge.yml must use pull_request_target trigger")
with workflow_path.open() as f:
wf = yaml.safe_load(f)
if not isinstance(wf, dict):
fail("dependabot-auto-merge.yml must be a YAML mapping")
on_section = wf.get("on")
if on_section is None and True in wf:
on_section = wf[True]
if not isinstance(on_section, dict) or "pull_request_target" not in on_section:
fail("dependabot-auto-merge.yml must use pull_request_target trigger")
🤖 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/branch-protection-check.yml around lines 62 - 68, PyYAML
can coerce the bare top-level "on" key to the boolean True, so change the
retrieval of the trigger to handle that case: replace the current on =
wf.get("on") with a fallback that checks for the True key (e.g. on =
wf.get("on", wf.get(True))) and keep the subsequent validation using on and fail
as-is so valid workflows using a bare on: entry are accepted.

jobs = wf.get("jobs")
if not isinstance(jobs, dict) or "auto-merge" not in jobs:
fail("dependabot-auto-merge.yml must define jobs.auto-merge")
job = jobs["auto-merge"]
if not isinstance(job, dict):
fail("jobs.auto-merge must be a mapping")
steps = job.get("steps")
if not isinstance(steps, list) or not steps:
fail("jobs.auto-merge must define steps")
uses = [
s.get("uses", "")
for s in steps
if isinstance(s, dict)
]
if not any("dependabot/fetch-metadata" in u for u in uses):
fail("jobs.auto-merge must include dependabot/fetch-metadata")

print("✅ dependabot-auto-merge.yml structure is valid")
print("")
print("ℹ️ dependabot-auto-merge.yml enables merge via GraphQL")
print(" enablePullRequestAutoMerge; the PR merges only after existing")
print(" required status checks pass (e.g. ci/prow/*). No extra CI job.")
PY
234 changes: 234 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
name: Dependabot Auto-Merge

on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: write
pull-requests: write
checks: read
actions: read

jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository_owner == 'openshift'
steps:
- name: Fetch Dependabot Metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"

- name: Enable Auto-Merge for Safe Updates
id: enable-auto-merge
if: |
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
env:
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
PREVIOUS_VERSION: ${{ steps.metadata.outputs.previous-version }}
NEW_VERSION: ${{ steps.metadata.outputs.new-version }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
export GH_TOKEN

comment_count() {
local marker="$1"
local http_code
http_code=$(curl -sS -w "%{http_code}" -o /tmp/comments-list.json \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments")
if [[ "$http_code" != "200" ]]; then
echo "::warning::Could not list PR comments (HTTP ${http_code})" >&2
echo "1"
return
fi
jq --arg m "$marker" '[.[] | select(.body | contains($m))] | length' /tmp/comments-list.json
}

post_issue_comment() {
local body="$1"
local http_code
http_code=$(curl -sS -w "%{http_code}" -o /tmp/comment-response.json \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \
-d "$(jq -n --arg body "$body" '{body: $body}')")
if [[ ! "$http_code" =~ ^2 ]]; then
echo "❌ Failed to post PR comment. HTTP status: ${http_code}"
cat /tmp/comment-response.json
echo "::warning::PR comment could not be posted"
return 1
fi
}

graphql_auto_merge_ok() {
local http_code="$1"
[[ "$http_code" == "200" ]] || return 1
jq -e '(.errors // []) | length == 0' /tmp/response.json >/dev/null 2>&1 || return 1
jq -e '.data.enablePullRequestAutoMerge.pullRequest != null' /tmp/response.json >/dev/null 2>&1
}

graphql_error_summary() {
jq -c '(.errors // []) | if length > 0 then . else .data end' /tmp/response.json 2>/dev/null || cat /tmp/response.json
}

echo "Enabling auto-merge for ${UPDATE_TYPE} update"
echo "Dependency: ${DEPENDENCY_NAMES}"

pr_http_code=$(curl -sS -w "%{http_code}" -o /tmp/pr-response.json \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${REPOSITORY}/pulls/${PR_NUMBER}")

if [[ "$pr_http_code" != "200" ]]; then
echo "❌ Failed to fetch PR metadata. HTTP status: ${pr_http_code}"
cat /tmp/pr-response.json
echo "auto_merge_enabled=false" >> "$GITHUB_OUTPUT"
exit 1
fi

PR_NODE_ID=$(jq -r '.node_id' /tmp/pr-response.json)
if [[ -z "$PR_NODE_ID" || "$PR_NODE_ID" == "null" ]]; then
echo "❌ Failed to parse PR node ID from response"
cat /tmp/pr-response.json
echo "auto_merge_enabled=false" >> "$GITHUB_OUTPUT"
exit 1
fi

http_code=$(curl -sS -w "%{http_code}" -o /tmp/response.json \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/graphql" \
-d "{\"query\":\"mutation { enablePullRequestAutoMerge(input: { pullRequestId: \\\"$PR_NODE_ID\\\", mergeMethod: SQUASH }) { pullRequest { autoMergeRequest { enabledAt } } } }\"}")

if graphql_auto_merge_ok "$http_code"; then
echo "✅ Auto-merge enabled successfully via GraphQL"
cat /tmp/response.json
echo "auto_merge_enabled=true" >> "$GITHUB_OUTPUT"
else
api_detail=$(graphql_error_summary)
echo "❌ Failed to enable auto-merge. HTTP status: ${http_code}"
echo "Response body:"
cat /tmp/response.json
echo "auto_merge_enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::Could not enable auto-merge. PR may need manual review."
if [[ "$(comment_count 'Dependabot Auto-Merge Status')" -eq 0 ]]; then
failure_body=$(jq -rn \
--arg ut "$UPDATE_TYPE" \
--arg deps "$DEPENDENCY_NAMES" \
--arg prev "$PREVIOUS_VERSION" \
--arg new "$NEW_VERSION" \
--arg api "$api_detail" \
'@text "🤖 **Dependabot Auto-Merge Status**

This PR meets the criteria for auto-merge but could not be automatically merged.

**Details:**
- Update type: \($ut)
- Dependencies: \($deps)
- Previous version: \($prev)
- New version: \($new)
- API response: `\($api)`

Please review and merge manually if appropriate."')
post_issue_comment "$failure_body" || true
else
echo "Auto-merge status comment already posted; skipping duplicate"
fi
fi

- name: Comment on Major Version Updates
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
env:
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
PREVIOUS_VERSION: ${{ steps.metadata.outputs.previous-version }}
NEW_VERSION: ${{ steps.metadata.outputs.new-version }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}"
export GH_TOKEN

comments_http=$(curl -sS -w "%{http_code}" -o /tmp/comments-list.json \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments")
if [[ "$comments_http" != "200" ]]; then
echo "::warning::Could not list PR comments (HTTP ${comments_http})" >&2
exit 0
fi
existing=$(jq '[.[] | select(.body | contains("Major Version Update Detected"))] | length' /tmp/comments-list.json)

if [[ "$existing" -gt 0 ]]; then
echo "Major-version notice already posted; skipping duplicate comment"
exit 0
fi

major_body=$(jq -rn \
--arg deps "$DEPENDENCY_NAMES" \
--arg prev "$PREVIOUS_VERSION" \
--arg new "$NEW_VERSION" \
'@text "🚨 **Major Version Update Detected** 🚨

This PR contains a major version update that requires manual review:
- **Dependency:** \($deps)
- **Previous version:** \($prev)
- **New version:** \($new)

Please review the changelog and breaking changes before merging.

Auto-merge has been **disabled** for this PR."')

http_code=$(curl -sS -w "%{http_code}" -o /tmp/comment-response.json \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" \
-d "$(jq -n --arg body "$major_body" '{body: $body}')")

if [[ ! "$http_code" =~ ^2 ]]; then
echo "❌ Failed to post major-version comment. HTTP status: ${http_code}"
cat /tmp/comment-response.json
echo "::warning::Major-version comment could not be posted"
fi

- name: Log Auto-Merge Decision
if: always() && steps.metadata.outcome == 'success'
env:
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
PR_NUMBER: ${{ github.event.pull_request.number }}
AUTO_MERGE_ENABLED: ${{ steps.enable-auto-merge.outputs.auto_merge_enabled }}
run: |
echo "Auto-merge decision for PR #${PR_NUMBER}:"
echo "- Update type: ${UPDATE_TYPE}"
echo "- Dependency: ${DEPENDENCY_NAMES}"

case "${UPDATE_TYPE}" in
version-update:semver-patch|version-update:semver-minor)
if [[ "${AUTO_MERGE_ENABLED}" == "true" ]]; then
echo "✅ Auto-merge ENABLED (GraphQL mutation succeeded)"
elif [[ "${AUTO_MERGE_ENABLED}" == "false" ]]; then
echo "❌ Auto-merge NOT enabled (GraphQL mutation failed — see enable step logs)"
else
echo "⚠️ Auto-merge enable step did not complete (check workflow logs)"
fi
;;
version-update:semver-major)
echo "❌ Auto-merge DISABLED: Major version update"
;;
*)
echo "❌ Auto-merge DISABLED: Update type not eligible for auto-merge (${UPDATE_TYPE})"
;;
esac