|
1 |
| -name: Commands on PR |
| 1 | +name: Run CLI Commands via PR Comment |
| 2 | + |
2 | 3 | on:
|
3 | 4 | issue_comment:
|
4 | 5 | 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 |
13 | 6 |
|
14 | 7 | 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: |
17 | 13 | 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 |
22 | 18 | 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@e08a11834acf1e825ac727b732ac9d4cb8120c51 |
| 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 }} |
27 | 30 |
|
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 }} |
29 | 39 |
|
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' |
33 | 53 |
|
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 |
36 | 91 | runs-on: ubuntu-latest
|
| 92 | + permissions: |
| 93 | + contents: write |
| 94 | + pull-requests: write |
| 95 | + if: needs.detect-and-run.outputs.command != '' |
37 | 96 | 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 |
0 commit comments