Skip to content

Commit 5d3df78

Browse files
authored
Add org-membership check for changelog commits (#119)
* Get head repo and maintainer_can_modify from fetch script * Check for org-membership to determine if we can commit changelogs * Resolve PR author during preflight * Remove gh token for generation and conditionally pass it for evaluation * Split artifact generation and usage steps * Use docs-builder to handle artifacts * Improve detection of org membership result * Add fallback for post-push changes to maintainer
1 parent de75c94 commit 5d3df78

10 files changed

Lines changed: 415 additions & 107 deletions

File tree

.github/workflows/changelog-submit.yml

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,112 @@ concurrency:
1919
cancel-in-progress: true
2020

2121
jobs:
22-
submit:
22+
preflight:
2323
if: >
2424
github.event.workflow_run.event == 'pull_request'
2525
&& github.event.workflow_run.conclusion != 'cancelled'
2626
&& github.event.repository.fork == false
2727
runs-on: ubuntu-latest
28+
permissions:
29+
contents: none
30+
pull-requests: read
31+
id-token: write
32+
outputs:
33+
should-submit: ${{ steps.evaluate.outputs.should-submit }}
34+
is-org-member: ${{ steps.check-org-membership.outputs.is-member }}
35+
steps:
36+
- name: Resolve PR author
37+
id: pr-author
38+
if: github.event.workflow_run.head_repository.full_name != github.repository
39+
uses: actions/github-script@v9
40+
with:
41+
# language=js
42+
script: |
43+
const run = context.payload.workflow_run;
44+
const { owner, repo } = context.repo;
45+
46+
let prNumber;
47+
if (run.pull_requests?.length > 0) {
48+
prNumber = run.pull_requests[0].number;
49+
} else {
50+
const headLabel = `${run.head_repository.owner.login}:${run.head_branch}`;
51+
const { data: prs } = await github.rest.pulls.list({
52+
owner, repo, state: 'open', head: headLabel
53+
});
54+
const match = prs.find(pr => pr.head.sha === run.head_sha);
55+
if (match) prNumber = match.number;
56+
}
57+
58+
if (!prNumber) {
59+
core.setFailed('Could not resolve PR number for fork — cannot verify org membership. Failing closed.');
60+
return;
61+
}
62+
63+
const { data: pr } = await github.rest.pulls.get({
64+
owner, repo, pull_number: prNumber
65+
});
66+
core.setOutput('login', pr.user.login);
67+
68+
- name: Fetch ephemeral GitHub token
69+
if: github.event.workflow_run.head_repository.full_name != github.repository
70+
id: fetch-ephemeral-token
71+
uses: elastic/ci-gh-actions/fetch-github-token@v1.5.0
72+
with:
73+
vault-instance: "ci-prod"
74+
vault-role: "token-policy-a71b1f2b88ec"
75+
76+
- name: Check org membership
77+
id: check-org-membership
78+
if: github.event.workflow_run.head_repository.full_name != github.repository
79+
uses: elastic/docs-actions/github/is-elastic-org-member@v1
80+
with:
81+
username: ${{ steps.pr-author.outputs.login }}
82+
token: ${{ steps.fetch-ephemeral-token.outputs.token }}
83+
84+
- name: Evaluate
85+
id: evaluate
86+
env:
87+
IS_FORK: ${{ github.event.workflow_run.head_repository.full_name != github.repository }}
88+
IS_ORG_MEMBER: ${{ steps.check-org-membership.outputs.is-member }}
89+
# language=bash
90+
run: |
91+
if [[ "$IS_FORK" == "true" && "$IS_ORG_MEMBER" != "true" ]]; then
92+
echo "should-submit=false" >> "$GITHUB_OUTPUT"
93+
echo "::notice::Changelog submit skipped — fork PRs from non-members are not processed"
94+
else
95+
echo "should-submit=true" >> "$GITHUB_OUTPUT"
96+
fi
97+
98+
generate:
99+
needs: preflight
100+
if: needs.preflight.outputs.should-submit == 'true'
101+
runs-on: ubuntu-latest
102+
permissions:
103+
contents: read
104+
packages: read
105+
outputs:
106+
proceed: ${{ steps.evaluate.outputs.proceed }}
107+
steps:
108+
- name: Evaluate and generate changelog
109+
id: evaluate
110+
uses: elastic/docs-actions/changelog/submit/evaluate@v1
111+
with:
112+
config: ${{ inputs.config }}
113+
# For same-repo PRs the fork-only org-membership steps are skipped,
114+
# so the output is empty. Default to 'false' to ensure it is never
115+
# interpreted as 'true'.
116+
is-org-member: ${{ needs.preflight.outputs.is-org-member || 'false' }}
117+
comment-only: ${{ inputs.comment-only }}
118+
119+
apply:
120+
needs: [preflight, generate]
121+
if: needs.generate.outputs.proceed == 'true'
122+
runs-on: ubuntu-latest
28123
permissions:
29124
contents: write
30125
pull-requests: write
31126
steps:
32-
- name: Submit changelog
33-
uses: elastic/docs-actions/changelog/submit@v1
127+
- name: Apply changelog
128+
uses: elastic/docs-actions/changelog/submit/apply@v1
34129
with:
35130
config: ${{ inputs.config }}
36-
comment-only: ${{ inputs.comment-only }}

changelog/submit/apply/action.yml

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
name: Changelog submit / apply
2+
description: >
3+
Write action that downloads the changelog artifact produced by the evaluate
4+
action and either commits it to the PR branch or posts it as a PR comment.
5+
Requires contents:write and pull-requests:write permissions.
6+
7+
inputs:
8+
github-token:
9+
description: 'GitHub token with contents:write and pull-requests:write'
10+
default: '${{ github.token }}'
11+
config:
12+
description: 'Path to changelog.yml configuration file (used in failure comment)'
13+
default: 'docs/changelog.yml'
14+
15+
outputs:
16+
committed:
17+
description: 'Whether a changelog was committed (true/false)'
18+
value: ${{ steps.commit.outputs.committed || 'false' }}
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Download staging artifact
24+
uses: actions/download-artifact@v8
25+
with:
26+
name: changelog-staging
27+
path: /tmp/changelog-staging
28+
29+
- name: Setup docs-builder
30+
uses: elastic/docs-actions/docs-builder/setup@v1
31+
with:
32+
version: edge
33+
github-token: ${{ inputs.github-token }}
34+
35+
- name: Evaluate artifact
36+
id: meta
37+
shell: bash
38+
env:
39+
GITHUB_TOKEN: ${{ inputs.github-token }}
40+
REPO_OWNER: ${{ github.repository_owner }}
41+
REPO_NAME: ${{ github.event.repository.name }}
42+
run: |
43+
docs-builder changelog evaluate-artifact \
44+
--metadata /tmp/changelog-staging/metadata.json \
45+
--owner "$REPO_OWNER" \
46+
--repo "$REPO_NAME"
47+
48+
- name: Checkout PR branch
49+
if: steps.meta.outputs.should-commit == 'true'
50+
uses: actions/checkout@v6
51+
with:
52+
repository: ${{ steps.meta.outputs.head-repo }}
53+
ref: ${{ steps.meta.outputs.head-sha }}
54+
token: ${{ inputs.github-token }}
55+
persist-credentials: false
56+
57+
- name: Validate ref names
58+
if: steps.meta.outputs.should-commit == 'true'
59+
shell: bash
60+
env:
61+
HEAD_REF: ${{ steps.meta.outputs.head-ref }}
62+
run: |
63+
if [[ ! "$HEAD_REF" =~ ^[a-zA-Z0-9._/+-]+$ ]]; then
64+
echo "::error::Ref name contains disallowed characters: ${HEAD_REF}"
65+
exit 1
66+
fi
67+
68+
- name: Commit changelog
69+
if: steps.meta.outputs.should-commit == 'true'
70+
id: commit
71+
shell: bash
72+
env:
73+
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
74+
CHANGELOG_DIR: ${{ steps.meta.outputs.changelog-dir }}
75+
CHANGELOG_FILENAME: ${{ steps.meta.outputs.changelog-filename }}
76+
GH_TOKEN: ${{ inputs.github-token }}
77+
GIT_REPOSITORY: ${{ github.repository }}
78+
IS_FORK: ${{ steps.meta.outputs.is-fork }}
79+
HEAD_REPO: ${{ steps.meta.outputs.head-repo }}
80+
HEAD_REF: ${{ steps.meta.outputs.head-ref }}
81+
run: |
82+
GENERATED=$(ls /tmp/changelog-staging/*.yaml 2>/dev/null | head -1)
83+
if [ -z "$GENERATED" ]; then
84+
echo "::error::No changelog YAML found in staging directory"
85+
exit 1
86+
fi
87+
88+
if [ -n "$CHANGELOG_FILENAME" ]; then
89+
TARGET_FILENAME="$CHANGELOG_FILENAME"
90+
else
91+
TARGET_FILENAME=$(basename "$GENERATED")
92+
fi
93+
CHANGELOG_FILE="$CHANGELOG_DIR/$TARGET_FILENAME"
94+
95+
if [ -f "$CHANGELOG_FILE" ]; then
96+
VERB="Update"
97+
else
98+
VERB="Add"
99+
fi
100+
101+
mkdir -p "$CHANGELOG_DIR"
102+
cp "$GENERATED" "$CHANGELOG_FILE"
103+
104+
git config user.name "github-actions[bot]"
105+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
106+
git add "$CHANGELOG_FILE"
107+
108+
if git diff --cached --quiet; then
109+
echo "No changes to commit"
110+
echo "committed=false" >> "$GITHUB_OUTPUT"
111+
else
112+
git commit -m "${VERB} changelog for PR #${PR_NUMBER}"
113+
push_failed=false
114+
if [[ "$IS_FORK" == "true" ]]; then
115+
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${HEAD_REPO}.git"
116+
if ! git push origin "HEAD:refs/heads/${HEAD_REF}" 2>&1; then
117+
echo "::warning::Push to fork failed — maintainer_can_modify may have been revoked after evaluation"
118+
push_failed=true
119+
fi
120+
else
121+
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GIT_REPOSITORY}.git"
122+
git push
123+
fi
124+
git remote set-url origin "https://github.com/${GIT_REPOSITORY}.git"
125+
126+
if [[ "$push_failed" == "true" ]]; then
127+
echo "committed=false" >> "$GITHUB_OUTPUT"
128+
echo "push-failed=true" >> "$GITHUB_OUTPUT"
129+
else
130+
echo "committed=true" >> "$GITHUB_OUTPUT"
131+
fi
132+
fi
133+
134+
echo "changelog-file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
135+
136+
- name: Post success comment
137+
if: steps.commit.outputs.committed == 'true'
138+
uses: actions/github-script@v9
139+
env:
140+
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
141+
HEAD_REF: ${{ steps.meta.outputs.head-ref }}
142+
CHANGELOG_FILE: ${{ steps.commit.outputs.changelog-file }}
143+
with:
144+
github-token: ${{ inputs.github-token }}
145+
script: |
146+
const script = require('${{ github.action_path }}/scripts/post-success-comment.js');
147+
await script({ github, context, core });
148+
149+
- name: Post push-fallback comment
150+
if: steps.commit.outputs.push-failed == 'true'
151+
uses: actions/github-script@v9
152+
env:
153+
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
154+
CHANGELOG_DIR: ${{ steps.meta.outputs.changelog-dir }}
155+
STAGING_DIR: /tmp/changelog-staging
156+
with:
157+
github-token: ${{ inputs.github-token }}
158+
script: |
159+
const script = require('${{ github.action_path }}/scripts/post-push-fallback.js');
160+
await script({ github, context, core });
161+
162+
- name: Post comment-only changelog
163+
if: steps.meta.outputs.should-comment-success == 'true'
164+
uses: actions/github-script@v9
165+
env:
166+
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
167+
CHANGELOG_DIR: ${{ steps.meta.outputs.changelog-dir }}
168+
STAGING_DIR: /tmp/changelog-staging
169+
with:
170+
github-token: ${{ inputs.github-token }}
171+
script: |
172+
const script = require('${{ github.action_path }}/scripts/post-comment-only.js');
173+
await script({ github, context, core });
174+
175+
- name: Post failure comment
176+
if: steps.meta.outputs.should-comment-failure == 'true'
177+
uses: actions/github-script@v9
178+
env:
179+
PR_NUMBER: ${{ steps.meta.outputs.pr-number }}
180+
LABEL_TABLE: ${{ steps.meta.outputs.label-table }}
181+
PRODUCT_LABEL_TABLE: ${{ steps.meta.outputs.product-label-table }}
182+
SKIP_LABELS: ${{ steps.meta.outputs.skip-labels }}
183+
CONFIG_FILE: ${{ inputs.config }}
184+
with:
185+
github-token: ${{ inputs.github-token }}
186+
script: |
187+
const script = require('${{ github.action_path }}/scripts/post-failure-comment.js');
188+
await script({ github, context, core });
File renamed without changes.
File renamed without changes.
File renamed without changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const fs = require('fs');
2+
const { TITLE, upsertComment, escapeMarkdown } = require('./comment-helper');
3+
4+
module.exports = async ({ github, context, core }) => {
5+
const prNumber = parseInt(process.env.PR_NUMBER, 10);
6+
const changelogDir = process.env.CHANGELOG_DIR;
7+
const stagingDir = process.env.STAGING_DIR || '/tmp/changelog-staging';
8+
9+
const files = fs.readdirSync(stagingDir).filter(f => f.endsWith('.yaml'));
10+
const content = files.length > 0
11+
? fs.readFileSync(`${stagingDir}/${files[0]}`, 'utf8').trim()
12+
: '';
13+
14+
const bodyParts = [
15+
TITLE,
16+
'',
17+
'⚠️ **Could not push changelog to your fork branch.**',
18+
'This usually happens when "Allow edits from maintainers" was disabled after the changelog was evaluated.',
19+
'',
20+
'Please re-enable it in your PR settings, or apply the changelog manually:',
21+
];
22+
23+
if (content) {
24+
const targetPath = `${changelogDir}/${files[0]}`;
25+
bodyParts.push(
26+
'',
27+
`Save the following to \`${escapeMarkdown(targetPath)}\`:`,
28+
'',
29+
'```yaml',
30+
content,
31+
'```',
32+
);
33+
} else {
34+
bodyParts.push('', '⚠️ Changelog entry was generated but the file content could not be read.');
35+
}
36+
37+
await upsertComment({ github, context, prNumber, body: bodyParts.join('\n') });
38+
};
File renamed without changes.

0 commit comments

Comments
 (0)