Skip to content

Commit 0b26986

Browse files
authored
Split commands.yml into running and saving (#18688)
1 parent f974b11 commit 0b26986

File tree

2 files changed

+190
-119
lines changed

2 files changed

+190
-119
lines changed

.github/workflows/commands.yml

Lines changed: 170 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,131 +1,182 @@
1-
name: Commands on PR
1+
name: Run CLI Commands via PR Comment
2+
23
on:
34
issue_comment:
45
types: [created]
5-
schedule:
6-
# once a day at 13:00 UTC
7-
- cron: '0 13 * * *'
8-
9-
permissions:
10-
contents: write
11-
issues: write
12-
pull-requests: read
136

147
jobs:
15-
cleanup_old_runs:
16-
if: github.event.schedule == '0 13 * * *'
8+
# This first job by definiton runs user-supplied code - you must NOT elevate its permissions to `write`
9+
# Malicious code could change nuget source URL, build targets or even compiler itself to pass a GH token
10+
# And use it to create branches, spam issues etc. Any write-actions happen in the second job, which does not allow
11+
# user extension points (i.e. plain scripts, must NOT run scripts from within checked-out code)
12+
detect-and-run:
1713
runs-on: ubuntu-latest
18-
permissions:
19-
actions: write
20-
env:
21-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
14+
outputs:
15+
command: ${{ steps.parse.outputs.command }}
16+
arg: ${{ steps.parse.outputs.arguments }}
17+
if: github.event.issue.pull_request
2218
steps:
23-
- name: Delete old workflow runs
24-
run: |
25-
_UrlPath="/repos/$GITHUB_REPOSITORY/actions/workflows"
26-
_CurrentWorkflowID="$(gh api -X GET "$_UrlPath" | jq '.workflows[] | select(.name == '\""$GITHUB_WORKFLOW"\"') | .id')"
19+
- name: Parse comment
20+
id: parse
21+
uses: dotnet/comment-pipeline@1
22+
with:
23+
comment: ${{ toJSON(github.event.comment) }}
24+
commands: |
25+
/run fantomas
26+
/run ilverify
27+
/run xlf
28+
/run test-baseline
29+
github-token: ${{ secrets.GITHUB_TOKEN }}
2730

28-
# delete workitems which are 'completed'. (other candidate values of status field are: 'queued' and 'in_progress')
31+
- name: Checkout the repository
32+
uses: actions/checkout@v4
33+
34+
- name: Checkout PR branch
35+
if: ${{ steps.parse.outputs.command }}
36+
run: gh auth setup-git && gh pr checkout ${{ github.event.issue.number }}
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2939

30-
gh api -X GET "$_UrlPath/$_CurrentWorkflowID/runs" --paginate \
31-
| jq '.workflow_runs[] | select(.status == "completed") | .id' \
32-
| xargs -I{} gh api -X DELETE "/repos/$GITHUB_REPOSITORY/actions/runs"/{}
40+
- name: Install dotnet
41+
uses: actions/setup-dotnet@v3
42+
with:
43+
global-json-file: global.json
44+
45+
- name: Install dotnet tools
46+
run: dotnet tool restore
47+
48+
- name: Setup .NET 9.0.0 Runtime for test execution
49+
if: ${{ steps.parse.outputs.command == '/run test-baseline' }}
50+
uses: actions/setup-dotnet@v4
51+
with:
52+
dotnet-version: '9.0.x'
3353

34-
run_command:
35-
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/run')
54+
- name: Run command
55+
id: run-cmd
56+
env:
57+
TEST_UPDATE_BSL: 1
58+
continue-on-error: true
59+
run: |
60+
case "${{ steps.parse.outputs.command }}" in
61+
"/run fantomas") dotnet fantomas . ;;
62+
"/run xlf") dotnet build src/Compiler /t:UpdateXlf ;;
63+
"/run ilverify") pwsh tests/ILVerify/ilverify.ps1 ;;
64+
"/run test-baseline") dotnet test ./FSharp.Compiler.Service.sln --filter "${{ steps.parse.outputs.arguments }}" -c Release || true ;;
65+
*) echo "Unknown command" && exit 1 ;;
66+
esac
67+
68+
- name: Create patch & metadata
69+
id: meta
70+
if: steps.parse.outputs.command
71+
run: |
72+
echo "run_step_outcome=${{ steps.run-cmd.outcome }}" > result
73+
if [[ "${{ steps.run-cmd.outcome }}" == "success" ]]; then
74+
git diff > repo.patch || true
75+
if [ -s repo.patch ]; then echo "hasPatch=true" >> result; else echo "hasPatch=false" >> result; fi
76+
else
77+
echo "hasPatch=false" >> result
78+
fi
79+
cat result
80+
81+
- name: Upload artifacts
82+
uses: actions/upload-artifact@v4
83+
with:
84+
name: cli-results
85+
path: |
86+
repo.patch
87+
result
88+
89+
apply-and-report:
90+
needs: detect-and-run
3691
runs-on: ubuntu-latest
92+
permissions:
93+
contents: write
94+
pull-requests: write
95+
if: needs.detect-and-run.outputs.command != ''
3796
steps:
38-
- name: Extract command to run
39-
uses: actions/github-script@v3
40-
id: command-extractor
41-
with:
42-
result-encoding: string
43-
script: |
44-
if (context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
45-
46-
// extract the command to run, allowed characters: a-z, A-Z, digits, hyphen, underscore
47-
const regex = /^\/run ([a-zA-Z\d\-\_]+)/;
48-
command = regex.exec(context.payload.comment.body);
49-
if (command == null) throw "Error: No command found in the trigger phrase.";
50-
51-
return command[1];
52-
- name: Get github ref
53-
uses: actions/github-script@v3
54-
id: get-pr
55-
with:
56-
script: |
57-
const result = await github.pulls.get({
58-
pull_number: context.issue.number,
59-
owner: context.repo.owner,
60-
repo: context.repo.repo,
61-
});
62-
return { "ref": result.data.head.ref, "repository": result.data.head.repo.full_name};
63-
- name: Checkout repo
64-
uses: actions/checkout@v2
65-
with:
66-
repository: ${{ fromJson(steps.get-pr.outputs.result).repository }}
67-
ref: ${{ fromJson(steps.get-pr.outputs.result).ref }}
68-
fetch-depth: 0
69-
- name: Install dotnet
70-
uses: actions/setup-dotnet@v3
71-
with:
72-
global-json-file: global.json
73-
- name: Install dotnet tools
74-
run: dotnet tool restore
75-
- name: Process fantomas command
76-
if: steps.command-extractor.outputs.result == 'fantomas'
77-
id: fantomas
78-
run: dotnet fantomas .
79-
- name: Process xlf command
80-
if: steps.command-extractor.outputs.result == 'xlf'
81-
id: xlf
82-
run: dotnet build src/Compiler /t:UpdateXlf
83-
84-
- name: Commit and push changes
85-
if: steps.fantomas.outcome == 'success' || steps.xlf.outcome == 'success' || steps.ilverify.outcome == 'success'
86-
run: |
87-
# Only commit if there are actual changes
88-
if git diff --quiet; then
89-
echo "No changes to commit, skipping."
90-
exit 0
91-
fi
92-
93-
git config --local user.name "github-actions[bot]"
94-
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
95-
git commit -a -m 'Automated command ran: ${{ steps.command-extractor.outputs.result }}
96-
97-
Co-authored-by: ${{ github.event.comment.user.login }} <${{ github.event.comment.user.id }}+${{ github.event.comment.user.login }}@users.noreply.github.com>'
98-
git push origin HEAD:"refs/heads/$PR_HEAD_REF"\
99-
- name: Post command comment
100-
if: steps.fantomas.outcome == 'success' || steps.xlf.outcome == 'success' || steps.ilverify.outcome == 'success'
101-
uses: actions/github-script@v3
102-
with:
103-
script: |
104-
// Probably, there's more universal way of getting outputs, but my gh-actions-fu is not that good.
105-
var output = ""
106-
if ("${{steps.command-extractor.outputs.result}}" == 'fantomas') {
107-
output = "${{steps.fantomas.outputs.result}}"
108-
} else if ("${{steps.command-extractor.outputs.result}}" == 'xlf') {
109-
output = "${{steps.xlf.outputs.result}}"
110-
} else if ("${{steps.command-extractor.outputs.result}}" == 'ilverify') {
111-
output = "${{steps.ilverify.outputs.result}}"
112-
}
113-
const body = `Ran ${{ steps.command-extractor.outputs.result }}: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}\n${output}`;
114-
await github.issues.createComment({
115-
issue_number: context.issue.number,
116-
owner: context.repo.owner,
117-
repo: context.repo.repo,
118-
body: body
119-
});
120-
- name: Post command failed comment
121-
if: failure()
122-
uses: actions/github-script@v3
123-
with:
124-
script: |
125-
const body = `Failed to run ${{ steps.command-extractor.outputs.result }}: https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
126-
await github.issues.createComment({
127-
issue_number: context.issue.number,
128-
owner: context.repo.owner,
129-
repo: context.repo.repo,
130-
body: body
131-
});
97+
- name: Checkout the repository
98+
uses: actions/checkout@v4
99+
100+
- name: Checkout PR branch
101+
run: gh auth setup-git && gh pr checkout ${{ github.event.issue.number }}
102+
env:
103+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
104+
105+
- name: Download artifacts
106+
uses: actions/download-artifact@v4
107+
with:
108+
name: cli-results
109+
110+
- name: Read metadata
111+
id: read-meta
112+
run: |
113+
source result
114+
echo "run_step_outcome=$run_step_outcome" >> $GITHUB_OUTPUT
115+
echo "hasPatch=$hasPatch" >> $GITHUB_OUTPUT
116+
117+
- name: Apply and push patch
118+
if: ${{ steps.read-meta.outputs.run_step_outcome == 'success' && steps.read-meta.outputs.hasPatch == 'true' }}
119+
run: |
120+
patch -p1 -s --force < repo.patch || true
121+
git config user.name "GH Actions"
122+
git config user.email "actions@github.com"
123+
git add -u
124+
git commit -m "Apply patch from ${{ needs.detect-and-run.outputs.command }}"
125+
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name @{u})
126+
remote=${upstream%%/*}
127+
branch=${upstream#*/}
128+
129+
echo "Pushing to $remote $branch"
130+
git push "$remote" HEAD:"$branch"
131+
132+
- name: Count stats
133+
id: stats
134+
if: ${{ steps.read-meta.outputs.run_step_outcome == 'success' && steps.read-meta.outputs.hasPatch == 'true' }}
135+
run: |
136+
files=$(git diff --name-only HEAD~1 HEAD | wc -l)
137+
lines=$(git diff HEAD~1 HEAD | wc -l)
138+
echo "files=$files" >> $GITHUB_OUTPUT
139+
echo "lines=$lines" >> $GITHUB_OUTPUT
140+
- name: Generate and publish report
141+
if: always()
142+
env:
143+
COMMAND: ${{ needs.detect-and-run.outputs.command }}
144+
OUTCOME: ${{ steps.read-meta.outputs.run_step_outcome }}
145+
PATCH: ${{ steps.read-meta.outputs.hasPatch }}
146+
run: |
147+
# Build the markdown report
148+
report="
149+
# 🔧 CLI Command Report
150+
151+
- **Command:** \`${COMMAND}\`
152+
- **Outcome:** ${OUTCOME}
153+
154+
"
155+
156+
if [[ "$OUTCOME" == "success" ]]; then
157+
if [[ "$PATCH" == "true" ]]; then
158+
report+="✅ Patch applied:
159+
- Files changed: ${{ steps.stats.outputs.files }}
160+
- Lines changed: ${{ steps.stats.outputs.lines }}"
161+
else
162+
report+="✅ Command succeeded, no changes needed."
163+
fi
164+
else
165+
report+="❌ Command **failed** — no patch applied."
166+
fi
167+
168+
# Output to GitHub Actions UI
169+
echo "$report" >> "$GITHUB_STEP_SUMMARY"
170+
171+
# Store for use in next step
172+
echo "$report" > pr_report.md
173+
174+
- name: Comment on PR
175+
if: always()
176+
env:
177+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
178+
PR_NUMBER: ${{ env.PR_NUMBER }}
179+
run: |
180+
# Use gh CLI to comment with multi-line markdown
181+
gh pr comment ${{ github.event.issue.number }} \
182+
--body-file pr_report.md

CONTRIBUTING.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ If you don't know what a pull request is read this article: <https://help.github
8989
- **DO** submit issues for other features. This facilitates discussion of a feature separately from its implementation, and increases the acceptance rates for pull requests.
9090
- **DO NOT** submit large code formatting changes without discussing with the team first.
9191

92+
#### Repository automation via commands
93+
94+
The following comments in a PR can be used as commands to execute scripts which automate repository maintenance and make it part of the visible diff.
95+
- `/run fantomas` runs `dotnet fantomas .`
96+
- `/run ilverify` updates IL verification baseline
97+
- `/run xlf` refreshes localisation files for translatable strings
98+
- `/run test-baseline ...` runs tests with the `TEST_UPDATE_BSL: 1` environment variable and an argument supplied filter (passed to `dotnet test --filter ..`). Its goal is to refresh baselines.
99+
100+
This code repository uses a lot of baselines - captures for important output - to spot regressions and willingfully accept changes via PR review.
101+
For example, the following errors can appear during CI runs:
102+
- Changes in `Syntax tree tests`
103+
- Differences in generated `IL output`
104+
- Diffrences in produced baseline diagnostics
105+
106+
After identifying a failing test which relies on a baseline, the command can then for example be:
107+
- `/run test-baseline ParseFile` to update parsing tests related to syntactical tree
108+
- `/run test-baseline SurfaceAreaTest` to update the API surface area of FSharp.Compiler.Service
109+
- `/run test-baseline FullyQualifiedName~EmittedIL&FullyQualifiedName~Nullness` to update IL baseline (namespace `EmittedIL`) for tests that touch the `Nullness` feature
110+
111+
92112
### Reviewing pull requests
93113

94114
Our repository gets a high volume of pull requests and reviewing each of them is a significant time commitment. Our team priorities often force us to focus on reviewing a subset of the active pull requests at a given time.

0 commit comments

Comments
 (0)