Skip to content

Commit a19b724

Browse files
authored
More secure version of renovate-changelog.yml workflow (#4453)
1 parent 691053e commit a19b724

3 files changed

Lines changed: 240 additions & 67 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Generate Renovate Changelog (Stage 1 - Prepare)
2+
3+
# Stage 1: runs with pull_request (no secrets, no write access).
4+
# Checks out BASE repo code only, runs the trusted Python script,
5+
# and saves the generated changelog file + PR metadata as an artifact
6+
# for Stage 2 to pick up and push to the fork branch.
7+
on:
8+
pull_request:
9+
types:
10+
- opened
11+
- synchronize
12+
branches:
13+
- main
14+
- 'branch_*'
15+
16+
concurrency:
17+
group: renovate-changelog-prepare-${{ github.event.pull_request.number }}
18+
cancel-in-progress: true
19+
20+
permissions:
21+
contents: read
22+
23+
jobs:
24+
generate:
25+
# Only run for Renovate bot PRs from the expected fork
26+
if: |
27+
github.event.pull_request.user.login == 'solrbot' &&
28+
github.event.pull_request.head.repo.full_name == 'solrbot/apache-_-solr'
29+
runs-on: ubuntu-latest
30+
31+
steps:
32+
- name: Checkout BASE repository at base branch (NOT fork code)
33+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
34+
with:
35+
ref: ${{ github.event.pull_request.base.ref }}
36+
repository: ${{ github.repository }}
37+
38+
- name: Set up Python
39+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
40+
with:
41+
python-version: '3.x'
42+
43+
- name: Install dependencies
44+
run: python3 -m pip install --quiet pyyaml
45+
46+
- name: Generate changelog entry
47+
env:
48+
PR_NUMBER: ${{ github.event.pull_request.number }}
49+
PR_TITLE: ${{ github.event.pull_request.title }}
50+
run: |
51+
python3 .github/scripts/generate-renovate-changelog.py \
52+
--pr-number "$PR_NUMBER" \
53+
--pr-title "$PR_TITLE"
54+
55+
- name: Assemble artifact
56+
env:
57+
PR_NUMBER: ${{ github.event.pull_request.number }}
58+
HEAD_REF: ${{ github.event.pull_request.head.ref }}
59+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
60+
run: |
61+
set -euo pipefail
62+
mkdir -p artifact
63+
if [ -d changelog/unreleased ]; then
64+
cp -r changelog/unreleased artifact/changelog-unreleased
65+
fi
66+
# Use printf + env vars to avoid shell injection from PR metadata values
67+
printf 'PR_NUMBER=%s\nHEAD_REF=%s\nHEAD_REPO=%s\n' \
68+
"$PR_NUMBER" "$HEAD_REF" "$HEAD_REPO" \
69+
> artifact/pr-metadata.env
70+
echo "Artifact contents:"; find artifact/ -type f
71+
72+
- name: Upload artifact
73+
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
74+
with:
75+
name: renovate-changelog-artifact
76+
path: artifact/
77+
retention-days: 1
78+
if-no-files-found: error
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
name: Generate Renovate Changelog (Stage 2 - Push)
2+
3+
# Stage 2: runs after Stage 1 completes, in the base-repo context with access to
4+
# SOLRBOT_GITHUB_TOKEN. Downloads the pre-generated artifact (no fork code executed here),
5+
# validates metadata, and pushes the changelog file to the fork branch.
6+
on:
7+
workflow_run:
8+
workflows:
9+
- "Generate Renovate Changelog (Stage 1 - Prepare)"
10+
types:
11+
- completed
12+
13+
# Each Stage 2 run corresponds to a unique Stage 1 run (unique workflow_run.id).
14+
# No cancel-in-progress: Stage 1's concurrency already serializes per-PR.
15+
concurrency:
16+
group: renovate-changelog-push-${{ github.event.workflow_run.id }}
17+
18+
permissions:
19+
actions: read # Required to download artifacts from another workflow run
20+
contents: read # Minimal; actual write access to fork uses SOLRBOT_GITHUB_TOKEN PAT
21+
22+
jobs:
23+
push-changelog:
24+
# Only proceed if Stage 1 succeeded for the expected fork.
25+
# Checking head_repository here avoids a spurious artifact-not-found failure
26+
# when Stage 1 ran but skipped its generate job (e.g. non-solrbot PR).
27+
if: |
28+
github.event.workflow_run.conclusion == 'success' &&
29+
github.event.workflow_run.head_repository.full_name == 'solrbot/apache-_-solr'
30+
runs-on: ubuntu-latest
31+
32+
steps:
33+
- name: Download artifact from Stage 1
34+
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
35+
with:
36+
name: renovate-changelog-artifact
37+
run-id: ${{ github.event.workflow_run.id }}
38+
github-token: ${{ secrets.GITHUB_TOKEN }}
39+
path: downloaded-artifact/
40+
41+
- name: Read and validate PR metadata
42+
id: meta
43+
run: |
44+
set -euo pipefail
45+
META_FILE="downloaded-artifact/pr-metadata.env"
46+
if [ ! -f "$META_FILE" ]; then
47+
echo "::error::Metadata file missing from artifact"; exit 1
48+
fi
49+
50+
# Parse with grep/cut rather than source to avoid executing file content as shell.
51+
# Use || true so a missing key doesn't abort under set -euo pipefail before the
52+
# explicit emptiness check below can emit a meaningful error message.
53+
PR_NUMBER=$(grep '^PR_NUMBER=' "$META_FILE" | cut -d= -f2- || true)
54+
HEAD_REF=$(grep '^HEAD_REF=' "$META_FILE" | cut -d= -f2- || true)
55+
HEAD_REPO=$(grep '^HEAD_REPO=' "$META_FILE" | cut -d= -f2- || true)
56+
57+
if [ -z "$PR_NUMBER" ] || [ -z "$HEAD_REF" ] || [ -z "$HEAD_REPO" ]; then
58+
echo "::error::Missing required metadata fields (PR_NUMBER, HEAD_REF, or HEAD_REPO)"; exit 1
59+
fi
60+
61+
# Security: verify this is the expected fork before using SOLRBOT_GITHUB_TOKEN
62+
if [ "$HEAD_REPO" != "solrbot/apache-_-solr" ]; then
63+
echo "::error::Unexpected HEAD_REPO: '$HEAD_REPO'. Expected 'solrbot/apache-_-solr'. Aborting."; exit 1
64+
fi
65+
66+
# Validate PR_NUMBER is a plain positive integer (prevents injection)
67+
if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
68+
echo "::error::PR_NUMBER is not a valid positive integer: '$PR_NUMBER'"; exit 1
69+
fi
70+
71+
# Validate HEAD_REF using Git's own branch-name rules. This rejects edge cases
72+
# such as '..', '@{', trailing '.lock', and ':' (dangerous in push refspecs)
73+
# while still accepting valid Renovate branch names like renovate/node@lts.
74+
if ! git check-ref-format --branch "$HEAD_REF" > /dev/null 2>&1; then
75+
echo "::error::HEAD_REF is not a valid Git branch name: '$HEAD_REF'"; exit 1
76+
fi
77+
78+
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
79+
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
80+
echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
81+
echo "Validated: PR#${PR_NUMBER} on ${HEAD_REPO}@${HEAD_REF}"
82+
83+
- name: Clone fork branch
84+
env:
85+
SOLRBOT_TOKEN: ${{ secrets.SOLRBOT_GITHUB_TOKEN }}
86+
HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
87+
HEAD_REF: ${{ steps.meta.outputs.head_ref }}
88+
run: |
89+
set -euo pipefail
90+
# Store credentials so the token never appears in the command line or process list
91+
git config --global credential.helper store
92+
printf 'https://x-access-token:%s@github.com\n' "$SOLRBOT_TOKEN" > ~/.git-credentials
93+
chmod 600 ~/.git-credentials
94+
95+
git clone --depth=1 --branch "$HEAD_REF" \
96+
"https://github.com/${HEAD_REPO}.git" \
97+
fork-checkout
98+
99+
- name: Apply changelog to fork checkout
100+
id: apply
101+
env:
102+
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
103+
run: |
104+
set -euo pipefail
105+
CHANGELOG_DIR="fork-checkout/changelog/unreleased"
106+
ARTIFACT_DIR="downloaded-artifact/changelog-unreleased"
107+
108+
if [ ! -d "$CHANGELOG_DIR" ]; then
109+
echo "::error::changelog/unreleased not found in fork checkout"; exit 1
110+
fi
111+
112+
if [ ! -d "$ARTIFACT_DIR" ]; then
113+
echo "::warning::No changelog-unreleased directory in artifact for PR#${PR_NUMBER}"
114+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
115+
exit 0
116+
fi
117+
118+
# Remove stale PR#NNN-*.yml files (handles slug changes between synchronize events)
119+
find "$CHANGELOG_DIR" -maxdepth 1 -name "PR#${PR_NUMBER}-*.yml" -delete -print
120+
121+
# Copy the new PR#NNN-*.yml file(s) from the artifact
122+
COPIED=0
123+
for f in "${ARTIFACT_DIR}/PR#${PR_NUMBER}-"*.yml; do
124+
if [ -f "$f" ]; then
125+
cp -v "$f" "$CHANGELOG_DIR/"
126+
COPIED=$((COPIED + 1))
127+
fi
128+
done
129+
130+
if [ "$COPIED" -eq 0 ]; then
131+
echo "::warning::No PR#${PR_NUMBER}-*.yml file found in artifact"
132+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
133+
else
134+
echo "Copied $COPIED changelog file(s)"
135+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
136+
fi
137+
138+
- name: Commit and push to fork branch
139+
if: steps.apply.outputs.has_changes == 'true'
140+
env:
141+
HEAD_REF: ${{ steps.meta.outputs.head_ref }}
142+
PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
143+
run: |
144+
set -euo pipefail
145+
cd fork-checkout
146+
147+
if [ -z "$(git status --porcelain changelog/unreleased/)" ]; then
148+
echo "No changelog changes (already up to date)"
149+
exit 0
150+
fi
151+
152+
git config user.name "SolrBot"
153+
git config user.email "solrbot@cominvent.com"
154+
155+
git add changelog/unreleased/
156+
git commit -m "Add changelog entry for PR#${PR_NUMBER}"
157+
158+
# Credential store (configured in Clone step) provides authentication
159+
git push origin "HEAD:refs/heads/${HEAD_REF}"
160+
161+
# Remove credentials from disk now that the push is complete
162+
rm -f ~/.git-credentials

.github/workflows/renovate-changelog.yml

Lines changed: 0 additions & 67 deletions
This file was deleted.

0 commit comments

Comments
 (0)