├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── enhancement.md ├── renovate.json └── workflows │ ├── auto_build_action_step1.yml │ ├── auto_build_action_step2.yml │ ├── dummy.yml │ ├── dummyWithId.yml │ ├── extId_dummy_with_fail.yml │ ├── extId_dummy_with_timeout.yml │ ├── housekeeping.yml │ ├── test_NoMarkerWithoutPayload.yml │ ├── test_WithExtId.yml │ ├── test_WithFail.yml │ ├── test_WithWaitTimeout.yml │ └── test_noMarkerWithPayload.yml ├── .gitignore ├── CODE_OF_CONDUCT.adoc ├── CONTRIBUTING.adoc ├── GOVERNANCE.adoc ├── LICENSE ├── README.adoc ├── SECURITY.adoc ├── action.yml ├── build.gradle.kts ├── dist ├── index.js └── index.js.LICENSE.txt ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kotlin-js-store └── yarn.lock ├── runTests.ps1 ├── settings.gradle.kts ├── src └── jsMain │ └── kotlin │ ├── data │ ├── GhGraphClient.kt │ ├── GhRestClient.kt │ └── WsClient.kt │ ├── main.kt │ ├── model │ ├── Inputs.kt │ ├── Jobs.kt │ └── WorkflowRun.kt │ ├── usecases │ ├── WorkflowRuns.kt │ └── Workflows.kt │ └── utils │ ├── Utils.kt │ └── actions │ ├── ActionEnvironment.kt │ └── ActionFailedException.kt └── webpack.config.d └── github.action.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # These are explicitly windows files and should use crlf 5 | *.bat text eol=crlf 6 | 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Data/Screenshots** 24 | If applicable, add logs, screenshots or other content to help explain your problem and our investigation. 25 | 26 | **System (please complete the following information):** 27 | - Runner-OS: [e.g. ubuntu-latest] 28 | - Action-Version: [e.g. v1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Getting help 4 | url: https://github.com/mathze/workflow-dispatch-action/discussions/categories/q-a 5 | about: Still not what you need? Have a look in our discussions Q&A 6 | - name: Search existing issues 7 | url: https://github.com/mathze/workflow-dispatch-action/issues 8 | about: If the wiki wasn't helpfull, please check for existing issues. 9 | - name: Talk with us 10 | url: https://github.com/mathze/workflow-dispatch-action/discussions 11 | about: Maybe also this may be interesting -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | ***Pro's and Con's*** 19 | What are benefits of this alternative and what might its drawbacks. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/auto_build_action_step1.yml: -------------------------------------------------------------------------------- 1 | name: Auto-update dist 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths-ignore: 7 | - dist/** 8 | 9 | jobs: 10 | build: 11 | name: Update dist 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up JDK 18 | uses: actions/setup-java@v4 19 | with: 20 | distribution: 'temurin' 21 | java-version: 21 22 | cache: 'gradle' 23 | 24 | - name: Build 25 | run: ./gradlew build 26 | 27 | - name: Commit dist 28 | uses: EndBug/add-and-commit@v9.1.4 29 | with: 30 | add: 'dist' 31 | message: 'Commit new/updated dist' 32 | -------------------------------------------------------------------------------- /.github/workflows/auto_build_action_step2.yml: -------------------------------------------------------------------------------- 1 | name: Auto-update dist 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | paths: 7 | - dist/** 8 | 9 | jobs: 10 | build: 11 | name: Update dist 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - run: 'echo "No build required"' 16 | -------------------------------------------------------------------------------- /.github/workflows/dummy.yml: -------------------------------------------------------------------------------- 1 | name: Dummy 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | to-greet: 6 | required: false 7 | description: Whom to greet 8 | default: "World" 9 | type: string 10 | 11 | jobs: 12 | greetJob: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Greet 16 | run: | 17 | echo "::notice title=Greet::Hello ${{ github.event.inputs.to-greet }}" -------------------------------------------------------------------------------- /.github/workflows/dummyWithId.yml: -------------------------------------------------------------------------------- 1 | name: Dummy with external id 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | to-greet: 6 | required: false 7 | description: Whom to greet 8 | default: "World" 9 | type: string 10 | external_ref_id: 11 | description: Id to use for unique run detection 12 | required: false 13 | type: string 14 | default: "" 15 | 16 | jobs: 17 | beforeAll: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: ${{ github.event.inputs.external_ref_id }} 21 | run: echo 22 | 23 | greetJob: 24 | runs-on: ubuntu-latest 25 | needs: beforeAll 26 | steps: 27 | - name: Greet 28 | run: | 29 | echo "::notice title=Greet::Hello ${{ github.event.inputs.to-greet }}" 30 | -------------------------------------------------------------------------------- /.github/workflows/extId_dummy_with_fail.yml: -------------------------------------------------------------------------------- 1 | name: Dummy with external id and failure 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | external_ref_id: 6 | description: Id to use for unique run detection 7 | required: false 8 | type: string 9 | default: "" 10 | 11 | jobs: 12 | beforeAll: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: ${{ github.event.inputs.external_ref_id }} 16 | run: echo "a run cmd must be present to avoid syntax error ;)" 17 | 18 | - name: "Fail workflow" 19 | run: exit 1 20 | shell: bash -------------------------------------------------------------------------------- /.github/workflows/extId_dummy_with_timeout.yml: -------------------------------------------------------------------------------- 1 | name: Dummy with external id and timeout 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | external_ref_id: 6 | description: Id to use for unique run detection 7 | required: false 8 | type: string 9 | default: "" 10 | 11 | jobs: 12 | beforeAll: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: ${{ github.event.inputs.external_ref_id }} 16 | run: echo "a run cmd must be present to avoid syntax error ;)" 17 | 18 | - name: "Wait 30 seconds" 19 | run: sleep 30s 20 | shell: bash 21 | -------------------------------------------------------------------------------- /.github/workflows/housekeeping.yml: -------------------------------------------------------------------------------- 1 | name: Housekeeping 2 | on: 3 | schedule: 4 | - cron: 0 0 * * * 5 | workflow_dispatch: 6 | inputs: 7 | retain_days: 8 | type: string 9 | default: "0" 10 | description: "Minimum age of runs to keep" 11 | required: false 12 | min_num_runs: 13 | type: string 14 | default: "0" 15 | description: "Min number of runs to keep" 16 | required: false 17 | 18 | jobs: 19 | clean-workflow-runs: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: setup 23 | id: setup 24 | run: | 25 | if [[ ! -z "${{ github.event.inputs.retain_days }}" ]]; then 26 | echo "retain_days=${{ github.event.inputs.retain_days }}" >> $GITHUB_OUTPUT 27 | else 28 | echo "retain_days=1" >> $GITHUB_OUTPUT 29 | fi 30 | if [[ ! -z "${{ github.event.inputs.min_num_runs }}" ]]; then 31 | echo "min_runs=${{ github.event.inputs.min_num_runs }}" >> $GITHUB_OUTPUT 32 | else 33 | echo "min_runs=1" >> $GITHUB_OUTPUT 34 | fi 35 | 36 | - name: clean runs 37 | uses: Mattraks/delete-workflow-runs@main 38 | with: 39 | retain_days: ${{ steps.setup.outputs.retain_days }} 40 | keep_minimum_runs: ${{ steps.setup.outputs.min_runs }} 41 | -------------------------------------------------------------------------------- /.github/workflows/test_NoMarkerWithoutPayload.yml: -------------------------------------------------------------------------------- 1 | name: noMarkerNoPayload 2 | on: 3 | workflow_dispatch 4 | 5 | env: 6 | ANNO_TITLE: "Greet" 7 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 8 | 9 | jobs: 10 | runAction: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Checkout" 14 | uses: actions/checkout@v4 15 | 16 | - name: "Extract branch name" 17 | id: get_ref 18 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 19 | 20 | - name: "Run action with no payload" 21 | id: dispatchNoPayload 22 | uses: ./ 23 | with: 24 | workflow-name: dummy.yml 25 | run-id: wait 26 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 27 | ref: ${{ steps.get_ref.outputs.branch }} 28 | 29 | ## receiving and checking the annotation for noPayload test 30 | - name: "get jobs" 31 | uses: octokit/request-action@v2.x 32 | id: get_jobs 33 | with: 34 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 35 | repo: ${{ github.repository }} 36 | run_id: ${{ steps.dispatchNoPayload.outputs.run-id }} 37 | 38 | - name: "Extract job ids" 39 | id: extract_job_id 40 | run: | 41 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 42 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 43 | 44 | - name: "get checkrun" 45 | uses: octokit/request-action@v2.x 46 | id: get_checkrun 47 | with: 48 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 49 | repo: ${{ github.repository }} 50 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 51 | 52 | - name: "extract annotation data" 53 | id: extract_annotations_data 54 | run: | 55 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 56 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 57 | 58 | - name: "Assert noPayload result" 59 | run: | 60 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 61 | [[ "Hello World" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 62 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 63 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 64 | exit 1 65 | else 66 | echo "Check passed" 67 | fi 68 | -------------------------------------------------------------------------------- /.github/workflows/test_WithExtId.yml: -------------------------------------------------------------------------------- 1 | name: Test with external id 2 | on: 3 | workflow_dispatch 4 | 5 | env: 6 | ANNO_TITLE: "Greet" 7 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 8 | 9 | jobs: 10 | runJob1: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Checkout" 14 | uses: actions/checkout@v4 15 | 16 | - name: "Extract branch name" 17 | id: get_ref 18 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 19 | 20 | - name: "Run first action" 21 | id: action1 22 | uses: ./ 23 | with: 24 | workflow-name: dummyWithId.yml 25 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 26 | use-marker-step: true 27 | payload: | 28 | { 29 | "to-greet": "first job, first action" 30 | } 31 | ref: ${{ steps.get_ref.outputs.branch }} 32 | 33 | - name: "Run second action" 34 | id: action2 35 | uses: ./ 36 | with: 37 | workflow-name: dummyWithId.yml 38 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 39 | use-marker-step: true 40 | payload: | 41 | { 42 | "to-greet": "first job, second action" 43 | } 44 | ref: ${{ steps.get_ref.outputs.branch }} 45 | 46 | outputs: 47 | action1_run_id: ${{ steps.action1.outputs.run-id }} 48 | action2_run_id: ${{ steps.action2.outputs.run-id }} 49 | 50 | runJob2: 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: "Checkout" 54 | uses: actions/checkout@v4 55 | 56 | - name: "Extract branch name" 57 | id: get_ref 58 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 59 | 60 | - name: "Run separate dispatch" 61 | id: action3 62 | uses: ./ 63 | with: 64 | workflow-name: dummyWithId.yml 65 | use-marker-step: true 66 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 67 | payload: | 68 | { 69 | "to-greet": "second job, first action" 70 | } 71 | ref: ${{ steps.get_ref.outputs.branch }} 72 | 73 | - name: "Wait to complete" 74 | uses: ./ 75 | with: 76 | use-marker-step: true 77 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 78 | run-id: ${{ steps.action3.outputs.run-id }} 79 | 80 | outputs: 81 | run_id: ${{ steps.action3.outputs.run-id }} 82 | 83 | runJob3: 84 | runs-on: ubuntu-latest 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v4 88 | 89 | - name: "Extract branch name" 90 | id: get_ref 91 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 92 | 93 | - name: Run and wait 94 | id: action4 95 | uses: ./ 96 | with: 97 | workflow-name: dummyWithId.yml 98 | use-marker-step: true 99 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 100 | payload: | 101 | { 102 | "to-greet": "third job, first action" 103 | } 104 | run-id: dummy 105 | ref: ${{ steps.get_ref.outputs.branch }} 106 | outputs: 107 | run_id: ${{ steps.action4.outputs.run-id }} 108 | 109 | checkJob1Action1: 110 | ## receiving and checking the annotation for job1 action1 test 111 | runs-on: ubuntu-latest 112 | needs: 113 | - runJob1 114 | steps: 115 | - name: "J1_A1: get jobs" 116 | uses: octokit/request-action@v2.x 117 | id: get_jobs 118 | with: 119 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 120 | repo: ${{ github.repository }} 121 | run_id: ${{ needs.runJob1.outputs.action1_run_id }} 122 | 123 | - name: "J1_A1: extract job id" 124 | id: extract_job_id 125 | run: | 126 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 127 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 128 | 129 | - name: "J1_A1: get checkrun" 130 | uses: octokit/request-action@v2.x 131 | id: get_checkrun 132 | with: 133 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 134 | repo: ${{ github.repository }} 135 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 136 | 137 | - name: "J1_A1: extract annotation data" 138 | id: extract_annotations_data 139 | run: | 140 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 141 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 142 | 143 | - name: "Assert Job1 Action1 result" 144 | run: | 145 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 146 | [[ "Hello first job, first action" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 147 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 148 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 149 | exit 1 150 | else 151 | echo "Check passed" 152 | fi 153 | 154 | checkJob1Action2: 155 | ## receiving and checking the annotation for job1 action2 test 156 | runs-on: ubuntu-latest 157 | needs: 158 | - runJob1 159 | steps: 160 | - name: "J1_A2: get jobs" 161 | uses: octokit/request-action@v2.x 162 | id: get_jobs 163 | with: 164 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 165 | repo: ${{ github.repository }} 166 | run_id: ${{ needs.runJob1.outputs.action2_run_id }} 167 | 168 | - name: "J1_A2: extract job ids" 169 | id: extract_job_id 170 | run: | 171 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 172 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 173 | 174 | - name: "J1_A2: get checkruns" 175 | uses: octokit/request-action@v2.x 176 | id: get_checkrun 177 | with: 178 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 179 | repo: ${{ github.repository }} 180 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 181 | 182 | - name: "J1_A2: extract annotation data" 183 | id: extract_annotations_data 184 | run: | 185 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 186 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 187 | 188 | - name: "Assert Job1 Action2 result" 189 | run: | 190 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 191 | [[ "Hello first job, second action" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 192 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 193 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 194 | exit 1 195 | else 196 | echo "Check passed" 197 | fi 198 | 199 | checkJob2: 200 | ## receiving and checking the annotation for job2 test 201 | runs-on: ubuntu-latest 202 | needs: 203 | - runJob2 204 | steps: 205 | - name: "J2: get jobs" 206 | uses: octokit/request-action@v2.x 207 | id: get_jobs 208 | with: 209 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 210 | repo: ${{ github.repository }} 211 | run_id: ${{ needs.runJob2.outputs.run_id }} 212 | 213 | - name: "J2: extract job ids" 214 | id: extract_job_id 215 | run: | 216 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 217 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 218 | 219 | - name: "J2: get checkruns" 220 | uses: octokit/request-action@v2.x 221 | id: get_checkrun 222 | with: 223 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 224 | repo: ${{ github.repository }} 225 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 226 | 227 | - name: "J2: extract annotations data" 228 | id: extract_annotations_data 229 | run: | 230 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 231 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 232 | 233 | - name: "Assert Job2 result" 234 | run: | 235 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 236 | [[ "Hello second job, first action" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 237 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 238 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 239 | exit 1 240 | else 241 | echo "Check passed" 242 | fi 243 | 244 | checkJob3: 245 | ## receiving and checking the annotation for job2 test 246 | runs-on: ubuntu-latest 247 | needs: 248 | - runJob3 249 | steps: 250 | - name: "J3: get jobs" 251 | uses: octokit/request-action@v2.x 252 | id: get_jobs 253 | with: 254 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 255 | repo: ${{ github.repository }} 256 | run_id: ${{ needs.runJob3.outputs.run_id }} 257 | 258 | - name: "J3: extract job ids" 259 | id: extract_job_id 260 | run: | 261 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 262 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 263 | 264 | - name: "J3: get checkrun" 265 | uses: octokit/request-action@v2.x 266 | id: get_checkrun 267 | with: 268 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 269 | repo: ${{ github.repository }} 270 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 271 | 272 | - name: "J3: extract annotations data" 273 | id: extract_annotations_data 274 | run: | 275 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 276 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 277 | 278 | - name: "Assert Job3 result" 279 | run: | 280 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 281 | [[ "Hello third job, first action" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 282 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 283 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 284 | exit 1 285 | else 286 | echo "Check passed" 287 | fi 288 | -------------------------------------------------------------------------------- /.github/workflows/test_WithFail.yml: -------------------------------------------------------------------------------- 1 | name: Test with external id and failure 2 | on: 3 | workflow_dispatch 4 | 5 | jobs: 6 | runJob: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: "Checkout" 10 | uses: actions/checkout@v4 11 | 12 | - name: "Extract branch name" 13 | id: get_ref 14 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 15 | 16 | - name: "Trigger workflow" 17 | id: dispatch 18 | uses: ./ 19 | with: 20 | workflow-name: extId_dummy_with_fail.yml 21 | use-marker-step: true 22 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 23 | ref: ${{ steps.get_ref.outputs.branch }} 24 | 25 | - name: "Wait to complete" 26 | id: wait 27 | uses: ./ 28 | with: 29 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 30 | run-id: ${{ steps.dispatch.outputs.run-id }} 31 | 32 | - name: Check result 33 | run: | 34 | if [[ "completed" != "${{ steps.wait.outputs.run-status }}" ]] || 35 | [[ "failure" != "${{ steps.wait.outputs.run-conclusion }}" ]] || 36 | [[ false -ne ${{ steps.wait.outputs.failed }} ]]; then 37 | echo "Run-Status should be but was <${{ steps.wait.outputs.run-status }}>" 38 | echo "Run-Conclusion should be but was <${{ steps.wait.outputs.run-conclusion }}>" 39 | echo "Failed should be but was <${{ steps.wait.outputs.failed }}>" 40 | exit 1 41 | else 42 | echo "Checks passed" 43 | fi 44 | -------------------------------------------------------------------------------- /.github/workflows/test_WithWaitTimeout.yml: -------------------------------------------------------------------------------- 1 | name: Test with external id and wait timeout 2 | on: 3 | workflow_dispatch 4 | 5 | jobs: 6 | runJob: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: "Checkout" 10 | uses: actions/checkout@v4 11 | 12 | - name: "Extract branch name" 13 | id: get_ref 14 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 15 | 16 | - name: "Trigger workflow" 17 | id: dispatch 18 | uses: ./ 19 | with: 20 | workflow-name: extId_dummy_with_timeout.yml 21 | use-marker-step: true 22 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 23 | ref: ${{ steps.get_ref.outputs.branch }} 24 | 25 | - name: "Wait to complete" 26 | id: wait 27 | uses: ./ 28 | with: 29 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 30 | run-id: ${{ steps.dispatch.outputs.run-id }} 31 | wait-timeout: 10s 32 | 33 | - name: Check result 34 | run: | 35 | if [[ "in_progress" != "${{ steps.wait.outputs.run-status }}" ]] || 36 | [[ "" != "${{ steps.wait.outputs.run-conclusion }}" ]] || 37 | [[ true -ne ${{ steps.wait.outputs.failed }} ]]; then 38 | echo "Run-Status should be but was <${{ steps.wait.outputs.run-status }}>" 39 | echo "Run-Conclusion should be but was <${{ steps.wait.outputs.run-conclusion }}>" 40 | echo "Failed should be but was <${{ steps.wait.outputs.failed }}>" 41 | exit 1 42 | else 43 | echo "Checks passed" 44 | fi 45 | -------------------------------------------------------------------------------- /.github/workflows/test_noMarkerWithPayload.yml: -------------------------------------------------------------------------------- 1 | name: noMarkerWithPayload 2 | on: 3 | workflow_dispatch 4 | 5 | env: 6 | ANNO_TITLE: "Greet" 7 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 8 | 9 | jobs: 10 | runAction: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: "Checkout" 14 | uses: actions/checkout@v4 15 | 16 | - name: "Extract branch name" 17 | id: get_ref 18 | run: echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 19 | 20 | - name: "Run action with payload" 21 | id: dispatchPayload 22 | uses: ./ 23 | with: 24 | workflow-name: dummy.yml 25 | run-id: wait 26 | payload: | 27 | { 28 | "to-greet": "${{ github.actor }}" 29 | } 30 | token: ${{ secrets.WF_TRIGGER_TOKEN }} 31 | ref: ${{ steps.get_ref.outputs.branch }} 32 | 33 | ## receiving and checking the annotation for payload test 34 | - name: "get jobs" 35 | uses: octokit/request-action@v2.x 36 | id: get_jobs 37 | with: 38 | route: GET /repos/{repo}/actions/runs/{run_id}/jobs 39 | repo: ${{ github.repository }} 40 | run_id: ${{ steps.dispatchPayload.outputs.run-id }} 41 | 42 | - name: "extract job ids" 43 | id: extract_job_id 44 | run: | 45 | job_id=$(echo '${{steps.get_jobs.outputs.data}}' | jq '.jobs[] | select(.name == "greetJob") | .id') 46 | echo "job_id=$job_id" >> "$GITHUB_OUTPUT" 47 | 48 | - name: "get checkrun" 49 | uses: octokit/request-action@v2.x 50 | id: get_checkrun 51 | with: 52 | route: GET /repos/{repo}/check-runs/{check_id}/annotations 53 | repo: ${{ github.repository }} 54 | check_id: ${{ steps.extract_job_id.outputs.job_id }} 55 | 56 | - name: "extract annotations data" 57 | id: extract_annotations_data 58 | run: | 59 | echo "title=${{ fromJson(steps.get_checkrun.outputs.data)[0].title }}" >> "$GITHUB_OUTPUT" 60 | echo "message=${{ fromJson(steps.get_checkrun.outputs.data)[0].message }}" >> "$GITHUB_OUTPUT" 61 | 62 | - name: "Assert withPayload result" 63 | run: | 64 | if [[ "${{ env.ANNO_TITLE }}" != "${{ steps.extract_annotations_data.outputs.title }}" ]] || 65 | [[ "Hello ${{ github.actor }}" != "${{ steps.extract_annotations_data.outputs.message }}" ]]; then 66 | echo "Expected Title <${{ env.ANNO_TITLE }}> but got <${{ steps.extract_annotations_data.outputs.title }}>" 67 | echo "Expected Message but got <${{ steps.extract_annotations_data.outputs.message }}>" 68 | exit 1 69 | else 70 | echo "Check passed" 71 | fi 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | build/ 3 | /.idea/ 4 | .kotlin/ 5 | 6 | # Ignore Gradle GUI config 7 | gradle-app.setting 8 | 9 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 10 | !gradle-wrapper.jar 11 | 12 | # Avoid checkin of .secrets file 13 | .secrets 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.adoc: -------------------------------------------------------------------------------- 1 | = Contributor Covenant Code of Conduct 2 | 3 | == Our Pledge 4 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 5 | 6 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 7 | 8 | == Our Standards 9 | Examples of behavior that contributes to a positive environment for our 10 | community include: 11 | 12 | * Demonstrating empathy and kindness toward other people 13 | * Being respectful of differing opinions, viewpoints, and experiences 14 | * Giving and gracefully accepting constructive feedback 15 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 16 | * Focusing on what is best not just for us as individuals, but for the overall community 17 | 18 | Examples of unacceptable behavior include: 19 | 20 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 21 | * Trolling, insulting or derogatory comments, and personal or political attacks 22 | * Public or private harassment 23 | * Publishing others' private information, such as a physical or email address, without their explicit permission 24 | * Other conduct which could reasonably be considered inappropriate in a professional setting 25 | 26 | == Enforcement Responsibilities 27 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 28 | 29 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 30 | 31 | == Scope 32 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 33 | 34 | == Enforcement 35 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at mailto:abuse%25gh_mathze@gmx.net[abuse%gh_mathze@gmx.net,CoC-violation-report]. All complaints will be reviewed and investigated promptly and fairly. 36 | 37 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 38 | 39 | == Enforcement Guidelines 40 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 41 | 42 | === 1. Correction 43 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 44 | 45 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 46 | 47 | === 2. Warning 48 | **Community Impact**: A violation through a single incident or series of actions. 49 | 50 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 51 | 52 | === 3. Temporary Ban 53 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 54 | 55 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 56 | 57 | === 4. Permanent Ban 58 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 59 | 60 | **Consequence**: A permanent ban from any sort of public interaction within the community. 61 | 62 | == Attribution 63 | This Code of Conduct is adapted from the https://www.contributor-covenant.org[Contributor Covenant], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 64 | 65 | Community Impact Guidelines were inspired by https://github.com/mozilla/diversity[Mozilla's code of conduct enforcement ladder]. 66 | 67 | For answers to common questions about this code of conduct, see the FAQ at 68 | https://www.contributor-covenant.org/faq. Translations are available at 69 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /CONTRIBUTING.adoc: -------------------------------------------------------------------------------- 1 | = Contributing to workflow-dispatch-action 2 | :toc: 3 | :toc-placement!: 4 | :gh_repo: https://github.com/mathze/workflow-dispatch-action 5 | 6 | 👍🎉 First off, thanks for taking the time to contributing 🎉👍 7 | Any kind of contribution is welcome😊 8 | 9 | toc::[] 10 | 11 | == Code of conduct 12 | This project and everyone participating in it is governed by the link:CODE_OF_CONDUCT.adoc[Code of Conduct] 13 | 14 | == Found a bug? 15 | * Ensure the bug was not already reported by searching our {gh_repo}/issues[issues] 16 | * Check for an existing {gh_repo}/discussions[discussion] on your issue 17 | * If you're unable to find an open issue addressing the problem by creating a {gh_repo}/issues/new?assignees=&labels=&template=bug_report.md&title=[new bug ticket] 18 | 19 | == Found a security vulnerability? 20 | Please refer to our link:SECURITY.adoc[SECURITY] advisory 21 | 22 | == Have an improvement idea? 23 | Very cool! Let's get it into the project! 24 | 25 | * First make sure your idea isn't already addressed by a {gh_repo}/issues[feature request] or already in {gh_repo}/discussions[discussion] 26 | * If a feature request already exists and no one is assigned to it, you can leaf a comment on it that you are interested in work on it or vote for it (see <>) 27 | * If there is a discussion about your idea, participate in it. 28 | * If neither a discussion nor a feature request exists, feel free to create a {gh_repo}/issues/new?assignees=&labels=&template=enhancement.md&title=[new feature request] 29 | 30 | == Working with issues 31 | To keep the information of any issue on its essentials, please respect the following guidelines: 32 | 33 | * any discussion shall take place in {gh_repo}/discussions/categories/issues[discuss an issue]. There is no "`golden rule`" from when a discussion in an issue get out of hand, and a discussion should be opened. But a rule of thumb is: "If there are any conflictive opinions, open a discussion" 34 | * If a discussion about an issue exists it shall be linked. 35 | * Discussions about an issue must have the issue's id within its title 36 | * Us 👍 or 👎 on the issue (not a single comment, see <>) to up/down vote it. + 37 | Note:: Do not add comments like "+1", "-1", "Want that", "Disagree"; Such comments will be deleted. 38 | 39 | == Discussions 40 | {gh_repo}/discussions[Here] is the place where you can share your ideas, ask or answer questions. 41 | 42 | === Discussions on issues 43 | To up or down vote an idea/comment, use 👍 or 👎 respectively. Avoid cluttering the discussion with "+1", "-1", "like", "dislike" etc. comments. + 44 | If you write a comment, it should provide value to the discussion. 45 | 46 | == Code review 47 | This project encourages actively reviewing the code, as it will store your precious data, so it's common practice to receive comments on provided patches. 48 | 49 | If you are reviewing other contributor's code please consider the following when reviewing: 50 | 51 | * Be nice. Please make the review comment as constructive as possible so all participants will learn something from your review. 52 | 53 | As a contributor, you might be asked to rewrite portions of your code to make it fit better into the upstream sources. 54 | 55 | == How to test 56 | . Create a PAT with sufficient rights within the repo (refer to link:README.adoc#_inputs[README -> Inputs -> Token]) 57 | . Install https://github.com/nektos/act[act] e.g. with scoop 58 | + 59 | [source,powershell] 60 | ---- 61 | scoop install act 62 | ---- 63 | . Create a `.secrets` file under `src/jsTest/act` 64 | + 65 | [source,powershell] 66 | ---- 67 | mkdir src/jsTest/act 68 | cd src/jsTest/act 69 | touch .secrets 70 | ---- 71 | 72 | . Add PAT to `.secrets` file 73 | + 74 | [title=.secrets file] 75 | ---- 76 | GITHUB_TOKEN= 77 | WF_TRIGGER_TOKEN= 78 | ---- 79 | . Run `runTests.ps1` 80 | -------------------------------------------------------------------------------- /GOVERNANCE.adoc: -------------------------------------------------------------------------------- 1 | = workflow-dispatch-action project governance 2 | 3 | == Overview 4 | The *workflow-dispatch-action* project uses a governance model commonly described as Benevolent 5 | Dictator For Life (BDFL). This document outlines our understanding of what this 6 | means. 7 | 8 | == Roles 9 | * user: anyone who interacts with the *workflow-dispatch-action* project 10 | * core contributor: a handful of people who have contributed significantly to 11 | the project by any means (issue triage, support, documentation, code, etc.). 12 | Core contributors are recognizable via GitHub's "Member" badge. 13 | * BDFL: a single individual who makes decisions when consensus cannot be 14 | reached. _workflow-dispatch-action_’s current BDFL is https://github.com/mathze[@mathze]. 15 | 16 | == Decision-making process 17 | In general, we try to reach consensus in discussions. In case consensus cannot 18 | be reached, the BDFL makes a decision. 19 | 20 | == Contribution process 21 | The contribution process is described in a separate document called link:CONTRIBUTING.adoc[CONTRIBUTING]. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 mathze 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = Workflow dispatch action 2 | ifdef::env-github[] 3 | :note-caption: :information_source: 4 | :warning-caption: :warning: 5 | :important-caption: :bangbang: 6 | :tip-caption: :bulb: 7 | endif::[] 8 | :toc: preamble 9 | :current_version: v1.1.0 10 | 11 | An action that creates a workflow dispatch event and returns the run-id of started workflow. 12 | This action can also be used to wait on completion of the triggered workflow. 13 | 14 | == About 15 | 16 | In contrast to other existing dispatch-actions, this action provides a way to reliably identify the workflow run (see <>). + 17 | To provide most flexibility, the action supports three different modes. + 18 | Thereby the mode is controlled by the two input parameters `workflow-name` and `run-id`. 19 | 20 | 1. [[mode_trigger]] 'Trigger' mode: + 21 | In this mode the action triggers the workflow and just tries to receive the workflow run id. 22 | This can be used if you want to get the run id and wait for it in another step/job later on. 23 | This mode will be enabled if the `workflow-name` input is present. 24 | 25 | 2. [[mode_wait]] 'Wait' mode: + 26 | In this mode the action waits until a workflow run finishes. 27 | This can be used in combination with <> or if you get the run id from somewhere else. 28 | The mode will be enabled if `run-id` is given. 29 | In this case the run-id is expected to be valid otherwise it results in error. 30 | 31 | 3. 'Trigger and wait' mode: + 32 | This is a combination of 1. and 2. mode. 33 | To enable it, specify the `workflow-name` and `run-id` inputs. 34 | In this case, the `run-id` isn't required to be valid because it gets replaced by the one detected in the trigger step. 35 | 36 | == Usage 37 | 38 | In addition to the examples shown here, you can also take a look at the link:.github/workflows/[] folder. There we have a few workflows for test purposes that might inspire you. 39 | 40 | === Simple (flaky) usage 41 | 42 | The following examples show the minimum setup without using a marker step. 43 | This mode is handy if you know that there will be only on workflow run at a time. 44 | 45 | [source,yaml,title="With direct wait", subs="+attributes"] 46 | ---- 47 | #... 48 | - name: "Start and wait for a workflow" 49 | id: startAndWaitWorkflow 50 | uses: mathze/workflow-dispatch-action@{current_version} 51 | with: 52 | workflow-name: my-workflow.yml 53 | token: ${{ secrets.MY_PAT }} 54 | run-id: dummy 55 | #... 56 | - name: "Reuse workflow run id" 57 | run: "echo ${{ steps.startAndWaitWorkflow.outputs.run-id }} 58 | ---- 59 | 60 | [source,yaml,title="Trigger and independent wait", subs="+attributes"] 61 | ---- 62 | #... 63 | - name: "Start a workflow" 64 | id: startWorkflow 65 | uses: mathze/workflow-dispatch-action@{current_version} 66 | with: 67 | workflow-name: my-workflow.yml 68 | token: ${{ secrets.MY_PAT }} 69 | #... 70 | - name: "wait to complete" 71 | uses: mathze/workflow-dispatch-action@{current_version} 72 | with: 73 | token: ${{ secrets.MY_PAT }} 74 | run-id: ${{ steps.startWorkflow.outputs.run-id }} 75 | ---- 76 | [TIP] 77 | You also can create a 'fire & forget' workflow by simply omitting the 'wait-to-complete' step. 78 | 79 | === With marker step 80 | 81 | [source,yaml,title="Caller workflow ('Trigger and independent wait' case)",subs="+attributes"] 82 | ---- 83 | #... 84 | - name: "Start workflow" 85 | id: startWorkflow 86 | uses: mathze/workflow-dispatch-action@{current_version} 87 | with: 88 | workflow-name: workflow-with-marker.yml 89 | token: ${{ secrets.MY_PAT }} 90 | use-marker-step: true 91 | #... 92 | - name: "wait to complete" 93 | uses: mathze/workflow-dispatch-action@{current_version} 94 | with: 95 | token: ${{ secrets.MY_PAT }} 96 | run-id: ${{ steps.startWorkflow.outputs.run-id }} 97 | ---- 98 | 99 | [source,yaml,title="workflow-with-marker.yml"] 100 | ---- 101 | on: 102 | workflow-dispatch: 103 | inputs: 104 | external_ref_id: #<.> 105 | description: Id to use for unique run detection 106 | required: false 107 | type: string 108 | default: "" 109 | jobs: 110 | beforeAll: 111 | runs-on: ubuntu-latest 112 | steps: 113 | - name: ${{ github.event.inputs.external_ref_id }} #<.> 114 | run: echo 115 | ---- 116 | <1> The target workflow has to define the `external_ref_id` input 117 | <2> Here we define a step with the name of the passed input. 118 | 119 | === With additional payload 120 | 121 | In this section we show how to configure the `payload` input of this action in two scenarios, with and without marker-step. 122 | 123 | WARNING: Be careful when using secrets within payload! 124 | They might get exposed in the target-workflow! 125 | 126 | ==== No marker step 127 | 128 | First, lets assume we have the following (simple) workflow we want to trigger through our action 129 | 130 | [source,yaml,title="say-hello.yml"] 131 | ---- 132 | on: 133 | workflow_dispatch: 134 | inputs: 135 | whom-to-greet: 136 | required: false 137 | description: Whom to greet 138 | default: "World" 139 | type: string 140 | 141 | jobs: 142 | greetJob: 143 | runs-on: ubuntu-latest 144 | steps: 145 | - name: Greet 146 | run: | 147 | echo "::notice title=Greet::Hello ${{ github.event.inputs.whom-to-greet }}" 148 | ---- 149 | 150 | Now, the step in our calling workflow could look like this 151 | 152 | [source,yaml,subs="+verbatim,+attributes"] 153 | ---- 154 | #... 155 | - name: "Start say hello" 156 | id: startSayHello 157 | uses: mathze/workflow-dispatch-action@{current_version} 158 | with: 159 | workflow-name: say-hello.yml 160 | token: ${{ secrets.MY_PAT }} 161 | payload: | #<.> 162 | { 163 | "whom-to-greet": "${{ github.actor }}" #<.> 164 | } 165 | #... 166 | ---- 167 | <.> We use multiline string (indicated by '|'). This allows us to write the json in a more natural way. 168 | <.> We only need the 'inputs' argument names -- in this case "whom-to-greet" -- and the value that shall be submitted. We also can use github expressions (even for/within the argument's name). 169 | 170 | ==== With marker step 171 | 172 | [source,yaml,title="say-hello-with-marker.yml"] 173 | ---- 174 | on: 175 | workflow-dispatch: 176 | inputs: 177 | external_ref_id: 178 | description: Id to use for unique run detection 179 | required: false 180 | type: string 181 | default: "" 182 | whom-to-greet: 183 | required: false 184 | description: Whom to greet 185 | default: "World" 186 | type: string 187 | jobs: 188 | greetJob: 189 | runs-on: ubuntu-latest 190 | steps: 191 | - name: ${{ github.event.inputs.external_ref_id }} 192 | run: echo 193 | 194 | - name: Greet 195 | run: | 196 | echo "::notice title=Greet::Hello ${{ github.event.inputs.whom-to-greet }}" 197 | ---- 198 | 199 | The respective step in our calling workflow could look like this 200 | 201 | [source,yaml,subs="+verbatim,+attributes"] 202 | ---- 203 | #... 204 | - name: "Start say hello" 205 | id: startSayHello 206 | uses: mathze/workflow-dispatch-action@{current_version} 207 | with: 208 | workflow-name: say-hello-with-marker.yml 209 | token: ${{ secrets.MY_PAT }} 210 | use-marker-step: true 211 | payload: | #<.> 212 | { 213 | "whom-to-greet": "${{ github.actor }}" #<.><.> 214 | } 215 | #... 216 | ---- 217 | <.> We use multiline string (indicated by '|'). This allows us to write the json in a more natural way. 218 | <.> We only need the 'inputs' argument names -- in this case "whom-to-greet" -- and the value that shall be submitted. We also can use github expressions (even for/within the argument's name). 219 | <.> Note that you do not need to specify the `external_ref_id` input, as it will be added automatically when `use-marker-step` is enabled. 220 | 221 | == Inputs 222 | 223 | [cols="20%a,30%a,20%a,30%a",options="header"] 224 | |=== 225 | |Input|Description|``R``equired/ + 226 | ``O``ptional|Default 227 | 228 | |`owner` 229 | |Organization or user under which the repository of the workflow resist. 230 | |*O* 231 | |Current owner 232 | 233 | |`repo` 234 | |Name of the repository the workflow resist in. 235 | |*O* 236 | |Current repository 237 | 238 | |`token` 239 | |The token used to work with the API. + 240 | The token must have `repo` scope. 241 | [IMPORTANT] 242 | Because token is used to also trigger dispatch-event, + 243 | you can not use the GITHUB_TOKEN as explained https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow[here] 244 | |*R* 245 | |- 246 | 247 | |`workflow-name` 248 | |Name of the workflow to trigger. E.g. 'my-workflow.yml'. + 249 | (Enables trigger-mode) 250 | |`conditional`<> 251 | |- 252 | 253 | |`ref` 254 | |The git reference for the workflow. 255 | The reference can be a branch or tag name. 256 | [NOTE] 257 | If you want to use `GITHUB_REF`, make sure you + 258 | shorten it to the name only through + 259 | `${{ GITHUB_REF#refs/heads/ }}` 260 | |*O* 261 | |Default branch of the target repository. 262 | 263 | |`payload` 264 | |Json-String representing any payload/input that shall be sent with the dispatch event. 265 | [WARNING] 266 | Be careful when using secrets within payload! + 267 | They might get exposed in the target-workflow! 268 | |*O* 269 | | {} 270 | 271 | |`trigger-timeout` 272 | |Maximum duration<> of workflow run id retrieval. 273 | |*O* 274 | |1 minute 275 | 276 | |`trigger-interval` 277 | |Duration<> to wait between consecutive tries to retrieve a workflow run id. 278 | |*O* 279 | |1 second 280 | 281 | |`use-marker-step` 282 | |Indicates if the action shall look for a marker-step to find the appropriate run. 283 | |*O* 284 | |`false` 285 | 286 | |`run-id` 287 | |A workflow run id for which to wait. + 288 | (Enables wait-mode) 289 | |`conditional`<> 290 | |- 291 | 292 | |`wait-timeout` 293 | |Maximum duration<> to wait until a workflow run completes. 294 | |*O* 295 | |10 minutes 296 | 297 | |`wait-interval` 298 | |Duration<> to wait between consecutive queries on the workflow run status. 299 | |*O* 300 | |1 second 301 | 302 | |`fail-on-error` 303 | |Defines if the action should result in a workflow failure if an error was discovered. + 304 | [NOTE] 305 | Errors in the `inputs` of this action are not + 306 | covered by the flag and always let the action + 307 | and the workflow fail. 308 | |*O* 309 | |`false` 310 | 311 | |=== 312 | [#duration] 313 | (D): Duration can be specified in either ISO-8601 Duration format or in specific format e.g. `1m 10s` (details see https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.time/-duration/parse.html) 314 | 315 | == Outputs 316 | 317 | |=== 318 | |Output|Type|Description 319 | 320 | |`failed` 321 | |Boolean 322 | |Indicates if there was an issue within the action run, and the workflow may not have been triggered correctly or didn't reach the completed status. To drill down the cause you can check the `run-id` and `run-status` outputs. 323 | 324 | |`run-id` 325 | |String 326 | |The run id of the started workflow. 327 | May be empty if no run was found or in case of an error. 328 | 329 | |`run-status` 330 | |String 331 | |The status of the triggered workflow. (Normally always 'completed') + 332 | Only set through the <>. 333 | May be empty if no run was found or in case of an error. 334 | 335 | |`run-conclusion` 336 | |String 337 | |The conclusion of the triggered workflow. + 338 | Only set through the <>. 339 | May be empty if no run was found or in case of an error. 340 | 341 | |=== 342 | 343 | == How it works 344 | 345 | Trigger-mode:: 346 | 1. Determine workflow id for given workflow-name 347 | 2. If `use-marker-step` is enabled, generate a unique `external_ref_id` (--) 348 | 3. Trigger dispatch event to target workflow and store the `dispatch-date` (also pass `external_ref_id` in input if enabled) 349 | 4. Query workflow runs for the given workflow (-id) that are younger than `dispatch-date` and targeting the given `ref` + 350 | The query use the _etag_ to reduce rate-limit impact 351 | 5. Filter found runs 352 | + 353 | .. *If `use-marker-step` is enabled* 354 | ... Filter runs that are not 'queued' 355 | ... Get step details for each run 356 | ... Find the step with the name of generated `external_ref_id` 357 | ... Take first (if any) 358 | .. *Else* 359 | ... Order runs by date created 360 | ... Take first (if any) 361 | + 362 | [NOTE] 363 | All subsequent requests use _etag_'s 364 | 365 | 6. Repeat 4 and 5 until a matching workflow run was found or `trigger-timeout` exceeds. Between each round trip we pause for `trigger-interval` units. 366 | 7. Return the found workflow run id or raise/log error (depending on `failOnError`) 367 | 368 | Wait-mode:: 369 | This is quite simple, with the former retrieved workflow-run-id we query the state of the workflow-run until it becomes _complete_ (or `wait-timeout` exceeds). All queries uses _etag_'s 370 | -------------------------------------------------------------------------------- /SECURITY.adoc: -------------------------------------------------------------------------------- 1 | = Security Policy 2 | 3 | == Supported Versions 4 | Only latest version will receive security updates. 5 | 6 | == Reporting a Vulnerability 7 | You can file a security vulnerability https://github.com/mathze/workflow-dispatch-action/security/advisories/new[here] -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Workflow Dispatch Action 2 | description: > 3 | Triggers a workflow and retrieves the run-id and also supports waiting on it to complete. 4 | 5 | author: mathze 6 | 7 | inputs: 8 | # general inputs 9 | owner: 10 | required: false 11 | description: Organization or user under which the repository of the workflow resist. Defaults to current owner. 12 | 13 | repo: 14 | required: false 15 | description: Name of the repository the workflow resist in. Defaults to current repository 16 | 17 | token: 18 | required: true 19 | description: > 20 | The token used to work with the API. The token must have repo scope. 21 | Because this token is also used to trigger dispatch event, you can not use the GITHUB_TOKEN as explained here 22 | https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#triggering-new-workflows-using-a-personal-access-token. 23 | 24 | fail-on-error: 25 | required: false 26 | default: "false" 27 | description: > 28 | Defines if the action should result in a build failure, if an error was discovered. Defaults to `false`. 29 | You can use the `failed` output to check for errors. 30 | 31 | # trigger only inputs 32 | workflow-name: 33 | required: false 34 | description: Name of the workflow to trigger. Can be the name of the workflow or its ID. 35 | 36 | ref: 37 | description: > 38 | The git reference for the workflow. The reference can be a branch or tag name. 39 | Defaults to default branch of the repository. 40 | required: false 41 | 42 | payload: 43 | description: Json-String representing any payload/input that shall be sent with the dispatch event. 44 | required: false 45 | 46 | trigger-timeout: 47 | description: Maximum time to use to getting workflow run id. Defaults to 1 minute. 48 | required: false 49 | 50 | trigger-interval: 51 | description: Time to wait between consecutive tries to retrieve a workflow run id. Defaults to 1 second. 52 | required: false 53 | 54 | use-marker-step: 55 | required: false 56 | default: "false" 57 | description: > 58 | Indicates that the action shall send a unique id (external_ref_id) within the `inputs` payload. 59 | To detect the correct workflow run, your target workflow has to have at first step, in the earliest job, 60 | the name of this id. 61 | 62 | Example: 63 | # ... 64 | inputs: 65 | external_ref_id: 66 | required: false 67 | # ... 68 | jobs: 69 | beforeAll: 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: $ { { github.event.inputs.external_ref_id } } 73 | # ... 74 | 75 | # wait specific stuff 76 | run-id: 77 | required: false 78 | description: > 79 | Workflow run id for which to wait. 80 | If set, enables the wait mode. 81 | 82 | wait-timeout: 83 | required: false 84 | description: Maximum time to use to wait until a workflow run completes. Defaults to 10 minutes. 85 | 86 | wait-interval: 87 | required: false 88 | description: Time to wait between consecutive queries on the workflow run status. Defaults to 1 second. 89 | 90 | outputs: 91 | failed: 92 | description: > 93 | Indicates if there was an issue with the action run, and the workflow may not have 94 | been triggered correctly. [true, false] 95 | run-id: 96 | description: The id of the started workflow run. May be empty if error or timeout occurred 97 | run-status: 98 | description: > 99 | The status of the triggered workflow. (Normally always 'completed') 100 | Only set through the wait mode. May be empty if no run was found or on error. 101 | run-conclusion: 102 | description: > 103 | The conclusion of the triggered workflow. 104 | Only set through the wait mode. May be empty if no run was found or on error. 105 | 106 | runs: 107 | using: node20 108 | main: dist/index.js 109 | 110 | branding: 111 | icon: repeat 112 | color: green 113 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.rnett.action.addWebpackGenTask 2 | import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler 3 | import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalDistributionDsl 4 | import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension 5 | import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin 6 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig 7 | 8 | plugins { 9 | kotlin("multiplatform") 10 | id("com.github.rnett.ktjs-github-action") version "1.6.0" 11 | } 12 | 13 | repositories { 14 | mavenCentral() 15 | } 16 | 17 | kotlin { 18 | js(IR) { 19 | val outputDir = layout.projectDirectory.dir("dist") 20 | val outFileName = "index.js" 21 | val webpackTask = addWebpackGenTask() 22 | binaries.executable() 23 | browser { 24 | @OptIn(ExperimentalDistributionDsl::class) 25 | distribution { 26 | outputDirectory = outputDir 27 | distributionName = outFileName 28 | } 29 | webpackTask { 30 | if (mode == KotlinWebpackConfig.Mode.PRODUCTION) { 31 | output.globalObject = "this" 32 | sourceMaps = false 33 | mainOutputFileName = outFileName 34 | 35 | dependsOn(webpackTask) 36 | } 37 | } 38 | } 39 | 40 | tasks.clean.configure { 41 | delete(outputDir) 42 | } 43 | 44 | rootProject.plugins.withType { 45 | rootProject.the().version = "20.9.0" 46 | } 47 | } 48 | 49 | sourceSets { 50 | val jsMain by getting { 51 | dependencies { 52 | listOf("kotlin-js-action", "serialization").forEach { 53 | implementation(group = "com.github.rnett.ktjs-github-action", name = it, version = "1.6.0") 54 | } 55 | implementation(group = "app.softwork", name = "kotlinx-uuid-core-js", version = "0.1.2") 56 | implementation(group = "io.ktor", name = "ktor-client-js", version = "2.3.12") 57 | } 58 | } 59 | } 60 | } 61 | 62 | fun KotlinDependencyHandler.implementation( 63 | group: String, 64 | name: String, 65 | version: String? = null, 66 | configure: ExternalModuleDependency.() -> Unit = {} 67 | ): ExternalModuleDependency { 68 | val depNot = listOfNotNull(group, name, version).joinToString(":") 69 | return implementation(depNot, configure) 70 | } 71 | -------------------------------------------------------------------------------- /dist/index.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! formdata-polyfill. MIT License. Jimmy Wärting */ 2 | 3 | /*! ws. MIT License. Einar Otto Stangvik */ 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathze/workflow-dispatch-action/5893a1d3ed6f0f54dc15889b47c7c2b73ab38026/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /runTests.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | See CONTRIBUTING.adoc for details 3 | #> 4 | $testNames = @( 5 | "test_noMarkerWithoutPayload" 6 | "test_noMarkerWithPayload" 7 | "test_WithExtId" 8 | "test_WithWaitTimeout" 9 | "test_WithFail" 10 | ) 11 | $rootDir = "./src/jstest/act" 12 | $secrets = "$rootDir/.secrets" 13 | $failedTest = $testNames.Length 14 | $testNames | ForEach-Object { 15 | $workflow = "$_.yml" 16 | Write-Host "🛫 Running test workflow $workflow" 17 | act workflow_dispatch -W ".github/workflows/$workflow" --secret-file "$secrets" --pull=false 18 | $resState = '' 19 | if (0 -eq $LASTEXITCODE) { 20 | $resState = '✅' 21 | $failedTest = $failedTest - 1 22 | } else { 23 | $resState = '❌' 24 | } 25 | $msg = "$resState Workflow test $workflow finished with exitcode $LASTEXITCODE $resState" 26 | $reps = [math]::ceiling(($msg.Length - 2 ) / 2) - 1 27 | $border = ($resState * $reps) 28 | $border = $border.SubString(0, [math]::min($msg.Length, $border.Length)) 29 | Write-Host "" 30 | Write-Host $border 31 | Write-Host $msg 32 | Write-Host $border 33 | Write-Host "" 34 | } 35 | Write-Host "Summary:" 36 | Write-Host "$failedTest tests failed" 37 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val kotlinVersion = "2.0.20" 3 | repositories { 4 | gradlePluginPortal() 5 | } 6 | 7 | plugins { 8 | // realization 9 | kotlin("multiplatform") version kotlinVersion 10 | kotlin("plugin.serialization") version kotlinVersion 11 | } 12 | resolutionStrategy { 13 | eachPlugin { 14 | if (requested.id.id == "kotlin2js") { 15 | useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version}") 16 | } 17 | } 18 | } 19 | } 20 | 21 | rootProject.name = "workflow-dispatch-action" 22 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/data/GhGraphClient.kt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import com.rnett.action.core.logger.debug 4 | import com.rnett.action.httpclient.MutableHeaders 5 | import io.ktor.http.HttpHeaders 6 | import kotlinx.serialization.json.JsonElement 7 | import kotlinx.serialization.json.JsonObject 8 | import kotlinx.serialization.json.JsonPrimitive 9 | import kotlinx.serialization.json.buildJsonObject 10 | import utils.actions.ActionEnvironment 11 | 12 | /** 13 | * Convenient class to handle requests to GitHub's GraphQL endpoint. 14 | * 15 | * @property[token] The PAT to use for authentication. 16 | * @constructor Create an instance. 17 | */ 18 | class GhGraphClient(token: String) : WsClient(token) { 19 | 20 | /** 21 | * Sends the given query to GitHub's GraphQL endpoint. 22 | * 23 | * @param[query] The query to send. 24 | * @param[variables] Optional additional variables to use in the query. 25 | * 26 | * @return The received response as json. 27 | */ 28 | suspend fun sendQuery(query: String, variables: JsonObject? = null): JsonElement { 29 | debug("Sending request >>$query<< to $graphApiUrl") 30 | val req = buildJsonObject { 31 | put("query", JsonPrimitive(query)) 32 | variables?.let { 33 | this.put("variables", variables) 34 | } 35 | } 36 | 37 | val response = client.post(graphApiUrl, req.toString()) 38 | val result = response.toJson() 39 | debug("Response $result") 40 | return result 41 | } 42 | 43 | /** 44 | * The url of the GraphQL endpoint. 45 | */ 46 | private val graphApiUrl by lazy { 47 | ActionEnvironment.GITHUB_GRAPHQL_URL 48 | } 49 | 50 | /** 51 | * Extend headers by `Accept` header. 52 | */ 53 | override fun applyHeaders(headers: MutableHeaders) { 54 | super.applyHeaders(headers) 55 | headers.add(HttpHeaders.Accept, "application/json") 56 | } 57 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/data/GhRestClient.kt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import com.rnett.action.httpclient.HttpResponse 4 | import com.rnett.action.httpclient.MutableHeaders 5 | import io.ktor.http.HttpHeaders 6 | import io.ktor.http.URLBuilder 7 | import io.ktor.http.takeFrom 8 | import utils.actions.ActionEnvironment 9 | 10 | /** 11 | * Convenient class to handle requests to GitHub's rest-api. 12 | * 13 | * @constructor Create an instance. 14 | * 15 | * General Remarks: If using any request method with a path, this path is prepended by the owner and repo. 16 | * 17 | * @param[token] See [WsClient.token] 18 | * @param[owner] Used to build the main part of GitHub resource paths. 19 | * @param[repo] Used to build the main part of GitHub resource paths. 20 | */ 21 | class GhRestClient(token: String, private val owner: String, private val repo: String) : WsClient(token) { 22 | 23 | /** 24 | * Send a POST request to GitHub's rest-api. 25 | * 26 | * @param[pathOrUrl] The path ('actions/workflows') to the resource or a full qualified url. 27 | * Attention: If using a URL this should point to a GitHub-resource, 28 | * otherwise you may have to override some default headers set by [WsClient]. 29 | * @param[body] The request body to send. 30 | * 31 | * @return The response object of the request. 32 | */ 33 | suspend fun sendPost(pathOrUrl: String, body: String): HttpResponse { 34 | return client.post(createUrl(pathOrUrl), body) 35 | } 36 | 37 | /** 38 | * Sends a GET request to GitHub's rest-api. 39 | * 40 | * @param[pathOrUrl] The path to the resource or a full qualified url. 41 | * Attention: If using a URL this should point to a GitHub-resource, 42 | * otherwise you may have to override some default headers set by [WsClient]. 43 | * @param[query] Query parameters to add to the path. Defaults to empty map. 44 | * @param[headerProvider] Function to manipulate the headers of the request. 45 | * 46 | * @return The response object of the request. 47 | */ 48 | suspend fun sendGet( 49 | pathOrUrl: String, 50 | query: Map = mapOf(), 51 | headerProvider: (MutableHeaders.() -> Unit)? = null 52 | ): HttpResponse { 53 | return client.get(createUrl(pathOrUrl, query)) { 54 | if (null != headerProvider) { 55 | headerProvider() 56 | } 57 | } 58 | } 59 | 60 | /** 61 | * Create the target url and adds additional query parameter to it. 62 | * 63 | * The target URL will be build either based on [ActionEnvironment.GITHUB_API_URL] if just a path is passed. 64 | * This function distinguishes a path from a URL by whether it starts with 'http'. 65 | * 66 | * @param[pathOrUrl] The path to a GitHub resource or a URL. 67 | * @param[query] The query arguments to append to the URL. Defaults to empty map. 68 | * 69 | * @return The configured URLBuilder to use in [HttpClient][com.rnett.action.httpclient.HttpClient]s request methods. 70 | */ 71 | private fun createUrl(pathOrUrl: String, query: Map = mapOf()) = URLBuilder() 72 | .takeFrom(pathOrUrl.let { 73 | if (it.startsWith("http")) { 74 | it 75 | } else { 76 | "$restApiUrl/repos/$owner/$repo/$pathOrUrl" 77 | } 78 | }) 79 | .queryParams(query) 80 | .buildString() 81 | 82 | private val restApiUrl by lazy { 83 | ActionEnvironment.GITHUB_API_URL 84 | } 85 | 86 | /** 87 | * Extends default headers with the appropriate 'Accept' header. 88 | */ 89 | override fun applyHeaders(headers: MutableHeaders) { 90 | super.applyHeaders(headers) 91 | headers.add(HttpHeaders.Accept, "application/vnd.github.v3+json") 92 | } 93 | } 94 | 95 | /** 96 | * Extension function to add query parameters to this URLBuilder. 97 | */ 98 | fun URLBuilder.queryParams(params: Map): URLBuilder = this.also { 99 | params.forEach { (k, v) -> 100 | parameters[k] = v 101 | } 102 | } 103 | 104 | /** 105 | * Extension function to retrieve the `etag` header value. 106 | */ 107 | fun HttpResponse.etag() = headers["etag"] -------------------------------------------------------------------------------- /src/jsMain/kotlin/data/WsClient.kt: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import com.rnett.action.httpclient.HttpClient 4 | import com.rnett.action.httpclient.HttpResponse 5 | import com.rnett.action.httpclient.MutableHeaders 6 | import io.ktor.http.HttpHeaders 7 | import io.ktor.http.HttpStatusCode 8 | import kotlinx.serialization.json.Json 9 | import kotlinx.serialization.json.JsonObject 10 | import kotlinx.serialization.json.JsonPrimitive 11 | import kotlinx.serialization.json.buildJsonObject 12 | import kotlinx.serialization.json.put 13 | 14 | /** 15 | * Base implementation to easily interact with GitHub's webservice apis. 16 | * 17 | * @param[token] The PAT used to access GitHub's apis. 18 | */ 19 | abstract class WsClient(private val token: String) { 20 | protected val client by lazy { 21 | HttpClient { 22 | bearerAuth(token) 23 | headers { 24 | applyHeaders(this) 25 | } 26 | } 27 | } 28 | 29 | /** 30 | * Apply some default headers. 31 | * 32 | * Additional headers can be added by overriding. Make sure to call super. 33 | * 34 | * @param[headers] The headers instance to extend. 35 | */ 36 | protected open fun applyHeaders(headers: MutableHeaders) { 37 | headers.add(HttpHeaders.CacheControl, "no-cache") 38 | headers.add(HttpHeaders.UserAgent, "mathze/workflow-dispatch-action") 39 | } 40 | 41 | companion object { 42 | /** 43 | * Header key for use with etags. 44 | */ 45 | const val HEADER_IF_NONE_MATCH = "If-None-Match" 46 | } 47 | } 48 | 49 | /** 50 | * Extension to easily get the response body as json object. 51 | */ 52 | suspend inline fun HttpResponse.toJson() = Json.parseToJsonElement(readBody()) 53 | 54 | /** 55 | * Extension to convert status code integer representation to [HttpStatusCode]. 56 | */ 57 | fun HttpResponse.httpStatus() = HttpStatusCode.fromValue(this.statusCode) 58 | 59 | /** 60 | * Creates a json-object of following structure: 61 | * ``` 62 | * { 63 | * "headers": , 64 | * "body": , // optional 65 | * "status-code": , 66 | * "status-message": 67 | * "" 68 | * } 69 | * ``` 70 | * 71 | * @param withBody Also put the body in the result. This consumes the body, so subsequent calls 72 | * to readBody will fail! 73 | * Default: `false` 74 | * 75 | * @return The above described json-structure. 76 | */ 77 | suspend fun HttpResponse.toResponseJson(withBody: Boolean = false): JsonObject { 78 | val resp = this 79 | return buildJsonObject { 80 | put("headers", JsonObject(resp.headers.toMap().mapValues { (_, v) -> 81 | JsonPrimitive(v) 82 | })) 83 | if (withBody) { 84 | put("body", resp.readBody()) 85 | } 86 | put("status-code", resp.statusCode) 87 | put("status-message", resp.statusMessage) 88 | } 89 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import app.softwork.uuid.nextUuid 2 | import com.rnett.action.core.logger 3 | import com.rnett.action.core.outputs 4 | import data.GhGraphClient 5 | import data.GhRestClient 6 | import kotlinx.serialization.json.JsonObject 7 | import kotlinx.serialization.json.JsonPrimitive 8 | import kotlinx.serialization.json.jsonObject 9 | import kotlinx.serialization.json.jsonPrimitive 10 | import model.Inputs 11 | import model.Inputs.Companion.resolveInputs 12 | import usecases.WorkflowRuns 13 | import usecases.Workflows 14 | import utils.actions.ActionEnvironment 15 | import utils.actions.ActionFailedException 16 | import utils.failOrError 17 | import kotlin.random.Random 18 | import kotlin.uuid.ExperimentalUuidApi 19 | 20 | suspend fun main() { 21 | // By design, errors in processing the inputs always make the action failing! 22 | val inputs: Inputs = resolveInputs() 23 | processAndValidateInputs(inputs) 24 | 25 | // Main action lifecycle 26 | try { 27 | // prepare the external reference id if requested 28 | val externalRefId = if (inputs.useIdentifierStep) { 29 | val extRunId = generateExternalRefId() 30 | logger.info("Using external_ref_id: $extRunId") 31 | inputs.payload = JsonObject(inputs.payload.toMutableMap().also { 32 | it["external_ref_id"] = JsonPrimitive(extRunId) 33 | }) 34 | extRunId 35 | } else null 36 | 37 | val client = GhRestClient(inputs.token, inputs.owner, inputs.repo) 38 | if (null != inputs.workflowName) { 39 | val runId = processTriggerMode(client, inputs, externalRefId) 40 | // we are in 'trigger and wait' mode 41 | if (null != inputs.runId) { 42 | inputs.runId = runId 43 | } 44 | } 45 | 46 | inputs.runId?.let { runId -> 47 | logger.info("Going to wait until run $runId completes") 48 | val wfRun = WorkflowRuns(client) 49 | val result = wfRun.waitWorkflowRunCompleted(runId, inputs.waitTimeout, inputs.waitInterval) 50 | val run = result.second 51 | outputs["run-status"] = run.status.value 52 | outputs["run-conclusion"] = run.conclusion?.value ?: "" 53 | if (!result.first) { 54 | throw ActionFailedException("Triggered workflow does not complete within ${inputs.waitTimeout}!") 55 | } 56 | } 57 | 58 | outputs["failed"] = "false" 59 | } catch (ex: Throwable) { 60 | failOrError(ex.message ?: "Error while trigger workflow", inputs.failOnError) 61 | } 62 | } 63 | 64 | private suspend fun processAndValidateInputs(inputs: Inputs) { 65 | if (inputs.token.isBlank()) { 66 | throw ActionFailedException("Token must not be empty or blank!") 67 | } 68 | 69 | if ((null == inputs.workflowName) && (null == inputs.runId)) { 70 | throw ActionFailedException("Either workflow-name or run-id must be set!") 71 | } 72 | 73 | if (inputs.ref.isNullOrBlank()) { 74 | logger.info("No branch given, detecting default branch") 75 | val defaultBranch = detectDefaultBranch(inputs) 76 | inputs.ref = defaultBranch 77 | } 78 | } 79 | 80 | suspend fun detectDefaultBranch(inputs: Inputs): String { 81 | val ghClient = GhGraphClient(inputs.token) 82 | val owner = inputs.owner 83 | val repo = inputs.repo 84 | val request = """{ 85 | repository(owner: "$owner", name: "$repo") { 86 | defaultBranchRef { 87 | name 88 | } 89 | } 90 | }""".trimIndent() 91 | 92 | return logger.withGroup("Retrieve default branch") { 93 | val response = ghClient.sendQuery(request).jsonObject 94 | val data = response["data"]!!.jsonObject 95 | val result = data["repository"]!!.jsonObject["defaultBranchRef"]!!.jsonObject["name"]!!.jsonPrimitive.content 96 | logger.info("Detected branch '$result' as default branch of '$owner/$repo'") 97 | result 98 | } 99 | } 100 | 101 | @OptIn(ExperimentalUuidApi::class) 102 | private fun generateExternalRefId(): String = Random.Default.nextUuid().let { 103 | "${ActionEnvironment.GITHUB_RUN_ID}-${ActionEnvironment.GITHUB_JOB}-$it" 104 | } 105 | 106 | /** 107 | * Triggers the workflow dispatch event and tries to receive its workflow run id. 108 | * 109 | * @param[client] Client to send the requests. 110 | * @param[inputs] The inputs passed to the action. 111 | * @param[externalReferenceId] The id used to pass to the target workflow (in case it isn't `null`). 112 | * 113 | * @return The id of the workflow run. 114 | * @throws ActionFailedException in case we couldn't receive an id within the [Inputs.triggerTimeout]. 115 | */ 116 | private suspend fun processTriggerMode(client: GhRestClient, inputs: Inputs, externalReferenceId: String?): String { 117 | logger.info("Going to trigger workflow run.") 118 | val workflows = Workflows(client) 119 | val wfId = workflows.getWorkflowIdFromName(inputs.workflowName!!) 120 | logger.info("Got workflow-id $wfId for workflow ${inputs.workflowName}") 121 | val dispatchTime = workflows.triggerWorkflow(wfId, inputs.ref!!, inputs.payload) 122 | 123 | val wfRuns = WorkflowRuns(client) 124 | val workflowRun = wfRuns.waitForWorkflowRunCreated( 125 | wfId, dispatchTime, inputs.ref!!, 126 | inputs.triggerTimeout, inputs.triggerInterval, 127 | externalReferenceId 128 | ) 129 | 130 | return workflowRun?.let { 131 | logger.notice("Found workflow run with ${workflowRun.id}") 132 | outputs["run-id"] = workflowRun.id 133 | workflowRun.id 134 | } ?: throw ActionFailedException("Unable to receive workflow run within ${inputs.triggerTimeout}!") 135 | } 136 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/model/Inputs.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import com.rnett.action.core.inputs 4 | import com.rnett.action.core.logger 5 | import com.rnett.action.core.maskSecret 6 | import kotlinx.serialization.json.Json 7 | import kotlinx.serialization.json.JsonObject 8 | import kotlinx.serialization.json.jsonObject 9 | import utils.actions.ActionEnvironment 10 | import kotlin.time.Duration 11 | import kotlin.time.Duration.Companion.minutes 12 | import kotlin.time.Duration.Companion.seconds 13 | 14 | data class Inputs( 15 | val owner: String, 16 | val repo: String, 17 | var ref: String?, 18 | val workflowName: String?, 19 | var payload: JsonObject, 20 | val token: String, 21 | val failOnError: Boolean = false, 22 | val useIdentifierStep: Boolean, 23 | var runId: String? = null, 24 | val triggerTimeout: Duration, 25 | val triggerInterval: Duration, 26 | val waitTimeout: Duration, 27 | val waitInterval: Duration 28 | ) { 29 | companion object { 30 | fun resolveInputs() = logger.withGroup("Reading inputs") { 31 | // retrieve token first and mask it in case it will be reused somewhere else where an 32 | // exception could be thrown which may expose the token (e.g. in payload-parsing below) 33 | val token = inputs.getRequired("token").apply { maskSecret() } 34 | val (currOwner, currRepo) = ActionEnvironment.GITHUB_REPOSITORY.split('/') 35 | Inputs( 36 | inputs.getOrElse("owner") { currOwner }, 37 | inputs.getOrElse("repo") { currRepo }, 38 | inputs.getOptional("ref"), 39 | inputs.getOptional("workflow-name"), 40 | Json.parseToJsonElement(inputs.getOrElse("payload") { "{}" }).jsonObject, 41 | token, 42 | inputs.getOptional("fail-on-error")?.toBooleanStrictOrNull() ?: false, 43 | inputs.getOptional("use-marker-step")?.toBooleanStrictOrNull() ?: false, 44 | inputs.getOptional("run-id"), 45 | getDuration("trigger-timeout", 1.minutes), 46 | getDuration("trigger-interval", 1.seconds), 47 | getDuration("wait-timeout", 10.minutes), 48 | getDuration("wait-interval", 1.seconds) 49 | ).also { 50 | logger.info("Got inputs: $it") 51 | } 52 | } 53 | 54 | private fun getDuration(key: String, default: Duration) = inputs.getOptional(key)?.let { 55 | Duration.parse(it) 56 | } ?: default 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/model/Jobs.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import com.rnett.action.core.logger 4 | import data.GhRestClient 5 | import data.WsClient.Companion.HEADER_IF_NONE_MATCH 6 | import data.httpStatus 7 | import data.toJson 8 | import data.toResponseJson 9 | import io.ktor.http.HttpStatusCode 10 | import kotlinx.serialization.json.JsonObject 11 | import kotlinx.serialization.json.contentOrNull 12 | import kotlinx.serialization.json.jsonArray 13 | import kotlinx.serialization.json.jsonObject 14 | import kotlinx.serialization.json.jsonPrimitive 15 | import utils.actions.ActionFailedException 16 | 17 | data class Jobs( 18 | val url: String 19 | ) { 20 | private var etag: String? = null 21 | private var jobs = listOf() 22 | 23 | suspend fun fetchJobs(client: GhRestClient) { 24 | val response = client.sendGet(url) { 25 | etag?.let { 26 | this.add(HEADER_IF_NONE_MATCH, it) 27 | } 28 | } 29 | 30 | when { 31 | HttpStatusCode.NotModified == response.httpStatus() -> { 32 | logger.debug("Jobs: Not modified") 33 | } 34 | response.isSuccess() -> { 35 | jobs = response.toJson().jsonObject.getValue("jobs").jsonArray.map { 36 | it.jsonObject 37 | }.toList() 38 | } 39 | else -> { 40 | throw ActionFailedException("Cannot retrieve jobs from $url! Details:${response.toResponseJson(true)}") 41 | } 42 | } 43 | } 44 | 45 | fun hasJobWithName(name: String): Boolean = jobs.any { job -> 46 | job["steps"]?.let { steps -> 47 | steps.jsonArray.any { step -> 48 | name == step.jsonObject.getValue("name").jsonPrimitive.contentOrNull 49 | } 50 | } ?: false 51 | } 52 | } -------------------------------------------------------------------------------- /src/jsMain/kotlin/model/WorkflowRun.kt: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | data class WorkflowRun( 4 | val id: String, 5 | var etag: String? = null, 6 | var branch: String? = null, 7 | var status: RunStatus = RunStatus.QUEUED, 8 | var conclusion: RunConclusion? = null, 9 | var jobs: Jobs? = null, 10 | var dateCreated: String? = null 11 | ) 12 | 13 | enum class RunStatus(val value: String) { 14 | QUEUED("queued"), 15 | PENDING("pending"), 16 | IN_PROGRESS("in_progress"), 17 | COMPLETED("completed"), 18 | REQUESTED("requested"), 19 | WAITING("waiting"); 20 | 21 | companion object { 22 | fun from(value: String?) = value?.let { v -> 23 | entries.firstOrNull { 24 | it.value == v 25 | } ?: throw IllegalArgumentException("Cannot map unknown value '$v' to RunStatus!") 26 | } 27 | } 28 | } 29 | 30 | enum class RunConclusion(val value: String) { 31 | ACTION_REQUIRED("action_required"), 32 | CANCELLED("cancelled"), 33 | FAILURE("failure"), 34 | NEUTRAL("neutral"), 35 | SUCCESS("success"), 36 | SKIPPED("skipped"), 37 | STALE("stale"), 38 | TIMED_OUT("timed_out"); 39 | 40 | companion object { 41 | fun from(value: String?) = value?.let { v -> 42 | entries.firstOrNull { 43 | it.value == v 44 | } ?: throw IllegalArgumentException("Cannot map unknown value '$v' to RunConclusion!") 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/usecases/WorkflowRuns.kt: -------------------------------------------------------------------------------- 1 | package usecases 2 | 3 | import com.rnett.action.core.logger 4 | import com.rnett.action.httpclient.HttpResponse 5 | import data.GhRestClient 6 | import data.WsClient.Companion.HEADER_IF_NONE_MATCH 7 | import data.etag 8 | import data.httpStatus 9 | import data.toJson 10 | import data.toResponseJson 11 | import io.ktor.http.HttpStatusCode 12 | import io.ktor.util.date.getTimeMillis 13 | import kotlinx.serialization.json.JsonObject 14 | import kotlinx.serialization.json.contentOrNull 15 | import kotlinx.serialization.json.jsonArray 16 | import kotlinx.serialization.json.jsonObject 17 | import kotlinx.serialization.json.jsonPrimitive 18 | import model.Jobs 19 | import model.RunConclusion 20 | import model.RunStatus 21 | import model.WorkflowRun 22 | import utils.actions.ActionFailedException 23 | import utils.delay 24 | import kotlin.math.abs 25 | import kotlin.time.Duration 26 | import kotlin.time.DurationUnit 27 | import kotlin.time.toDuration 28 | 29 | /** 30 | * 31 | * Class to deal with the main use cases trough GitHub's `runs` resource api. 32 | * 33 | * @constructor Create an instance to interact with GitHub's runs api. 34 | * @property[client] The client used to send requests to GitHub's rest-api. 35 | */ 36 | class WorkflowRuns( 37 | private val client: GhRestClient, 38 | ) { 39 | 40 | /** 41 | * Caches the already known workflow runs. 42 | */ 43 | private val runs = mutableListOf() 44 | 45 | /** 46 | * Used in [updateRunList] to reduce food print and spare the rate limit. 47 | */ 48 | private var runListEtag: String? = null 49 | 50 | /** 51 | * Waits until a workflow run with given criteria exists. 52 | * If more than one run was found the first will be returned. 53 | * If the [externalRefId] is given, the found runs will be checked, no matter if only one was found or more! 54 | * 55 | * @param workflowId The id or name of the workflow the run belongs to. 56 | * @param dispatchTime The time the run was created. 57 | * @param ref The branch or tag the run belongs to. 58 | * @param maxTimeout The maximum duration to wait for the run to appear. 59 | * @param frequency The duration to wait between consecutive calls to the api. 60 | * @param externalRefId The id used to check for in step-names. May be `null` if not marker step is used. 61 | * 62 | * @return The found workflow run or `null` if none was found within the timeout. 63 | */ 64 | suspend fun waitForWorkflowRunCreated( 65 | workflowId: String, 66 | dispatchTime: String, 67 | ref: String, 68 | maxTimeout: Duration, 69 | frequency: Duration, 70 | externalRefId: String? = null 71 | ): WorkflowRun? = logger.withGroup("Trying to detect workflow run id") { 72 | val result: Pair = executePolling(maxTimeout, frequency) { 73 | findWorkflowRun(workflowId, ref, dispatchTime, externalRefId) 74 | } 75 | 76 | return result.second 77 | } 78 | 79 | /** 80 | * Finds a workflow run matching the given criteria. 81 | * 82 | * @param[workflowId] The id of the workflow the run have to belong to 83 | * @param[dispatchTime] The time when the workflow dispatch event was triggered. 84 | * We only consider runs after this time. 85 | * @param[externalRefId] Optional, if present we only consider the workflow that 86 | * contains a step with the given value as its name. 87 | * 88 | * @return Pair where the first member defines if we found a matching workflow and the second member 89 | * may contain the found Workflow. Details see [executePolling]. 90 | */ 91 | private suspend fun findWorkflowRun( 92 | workflowId: String, ref: String, dispatchTime: String, externalRefId: String? 93 | ): Pair { 94 | // 1. update run list 95 | updateRunList(workflowId, ref, dispatchTime) 96 | // 2. update run-details 97 | runs.forEach { run -> 98 | updateRunDetails(run) 99 | } 100 | 101 | logger.info("Current runs in scope: $runs") 102 | 103 | // 3. if external ref id is present we check jobs of the runs 104 | val candidate = if (null != externalRefId) { 105 | runs.filter { 106 | // if we have an external ref id we only can consider runs that have jobs (in_progress or completed) 107 | when (it.status) { 108 | RunStatus.IN_PROGRESS, RunStatus.COMPLETED -> true 109 | RunStatus.QUEUED, RunStatus.PENDING, RunStatus.REQUESTED, RunStatus.WAITING -> false 110 | } 111 | }.firstOrNull { run -> 112 | // normally here the job should never be null (ensured by updateRunDetails) 113 | run.jobs?.let { 114 | // before we check, we update the jobs entry 115 | it.fetchJobs(client) 116 | it.hasJobWithName(externalRefId) 117 | } ?: false // but in case, it is equal to `false` (has no job with name) 118 | } 119 | } else { // Otherwise, we take the first closest to dispatch date 120 | runs.sortedWith(compareBy { it.dateCreated }).firstOrNull() 121 | } 122 | 123 | return Pair(null != candidate, candidate) 124 | } 125 | 126 | /** 127 | * Queries the workflow runs list for runs triggered by dispatch events which were created after a 128 | * given time for a specified branch. 129 | * 130 | * Query will be performed with internal etag to spare users rate limit. 131 | * 132 | * The retrieved runs will be filtered by the actual workflowId this action runs for. 133 | * 134 | * Finally, the internal list of runs gets updated by [updateRunListFromResponse]. 135 | * 136 | * @param workflowId Used to filter the resulting runs. 137 | * @param ref branch to which the run applies. 138 | * @param dispatchTime Gotten from [Workflows.triggerWorkflow], used as query parameter with '>=' operator. 139 | * @throws ActionFailedException In case receiving run details fails. 140 | */ 141 | private suspend fun updateRunList(workflowId: String, ref: String, dispatchTime: String) { 142 | val queryArgs = mapOf( 143 | queryEvent(), queryCreatedAt(dispatchTime), queryRef(ref) 144 | ) 145 | val runsResp = client.sendGet("actions/runs", queryArgs) { 146 | runListEtag?.also { 147 | this.add(HEADER_IF_NONE_MATCH, it) 148 | } 149 | } 150 | 151 | when (runsResp.httpStatus()) { 152 | HttpStatusCode.OK -> { 153 | logger.info("Got workflow runs") 154 | runListEtag = runsResp.etag() 155 | // new (no run with id) or updates 156 | val jsonRuns = runsResp.toJson().jsonObject.getValue("workflow_runs").jsonArray.map { 157 | it.jsonObject 158 | }.filter { // we are only interested in those runs that belong to our workflowId 159 | getWorkflowId(it) == workflowId 160 | } 161 | 162 | logger.info("${jsonRuns.size} runs left that matches actual workflow.") 163 | updateRunListFromResponse(jsonRuns) 164 | } 165 | HttpStatusCode.NotModified -> { 166 | logger.info("Run list up-to-date") 167 | } 168 | else -> { 169 | throw ActionFailedException("Unable to retrieve list of workflow runs! Details: ${runsResp.toResponseJson()}") 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Updates the current known run list with the prefiltered fresh runs of [updateRunList]. 176 | * 177 | * We do not simply replace known runs with the fresh ones, because we already have requested the run details 178 | * and therefor also have an etag which spare the users rate limit. 179 | * 180 | * Flow: 181 | * - First, transforms the raw json object to an internal representation. 182 | * - Second, determines runs that were created since last query (in first run all are new). 183 | * - Third and last, we remove those runs that no longer exists (edge-case if runs where deleted in meantime). 184 | * 185 | * @param[jsonRuns] The list of prefiltered 'run' JsonObjects used to update internal run cache. 186 | * 187 | * @see [runs] 188 | */ 189 | private fun updateRunListFromResponse(jsonRuns: List) { 190 | val freshRuns = jsonRuns.map { jRun -> 191 | WorkflowRun(getId(jRun)) 192 | } 193 | val newRuns = freshRuns.filter { frshRun -> 194 | runs.none { it.id == frshRun.id } 195 | } 196 | logger.info("Found ${newRuns.size} new runs") 197 | val removedRuns = runs.filter { oldRun -> 198 | freshRuns.none { it.id == oldRun.id } 199 | } 200 | logger.info("Found ${removedRuns.size} removed runs") 201 | runs.removeAll(removedRuns) 202 | runs.addAll(newRuns) 203 | } 204 | 205 | /** 206 | * Waits until a workflow run reaches the [complete][RunStatus.COMPLETED] status or the timeout exceeds. 207 | * 208 | * Waiting will be performed in a loop using etag to spare users rate limit. 209 | * 210 | * @param[workflowRunId] The id of the workflow run for which to wait. 211 | * @param[maxTimeout] The maximum duration this function shall wait on the status before 'giving up'. 212 | * @param[frequency] The duration to wait between two consecutive requests. 213 | * 214 | * @return Pair where the first member indicates the success and the second member contains the WorkflowRun. 215 | * @throws ActionFailedException In case updating run details fails. 216 | * @see [executePolling] 217 | */ 218 | suspend fun waitWorkflowRunCompleted( 219 | workflowRunId: String, 220 | maxTimeout: Duration, 221 | frequency: Duration 222 | ): Pair { 223 | val runDetails = WorkflowRun(id = workflowRunId) 224 | return executePolling(maxTimeout, frequency) { 225 | updateRunDetails(runDetails) 226 | Pair(runDetails.status == RunStatus.COMPLETED, runDetails) 227 | } 228 | } 229 | 230 | /** 231 | * Support function to update the workflow run details from json response. 232 | * 233 | * Uses etag, if present, to spare users rate limit. 234 | * The function mutates the passed argument. 235 | * 236 | * @param[run] The run to update. 237 | * @throws ActionFailedException In case receiving run details fails. 238 | */ 239 | private suspend fun updateRunDetails(run: WorkflowRun) { 240 | val runResponse = sendRunRequest(run) 241 | if (HttpStatusCode.NotModified == runResponse.httpStatus()) { 242 | return 243 | } 244 | if (!runResponse.isSuccess()) { 245 | val rawResp = runResponse.toResponseJson(true) 246 | throw ActionFailedException("Received error response while retrieving workflow run details! Response: $rawResp") 247 | } 248 | 249 | // else either new or changed -> update 250 | val runJson = runResponse.toJson().jsonObject 251 | run.etag = runResponse.etag() 252 | run.branch = getHeadBranch(runJson) 253 | run.status = getRunStatus(runJson)!! 254 | run.conclusion = getConclusion(runJson) 255 | run.jobs = getJobs(runJson) 256 | run.dateCreated = getCreationDate(runJson) 257 | } 258 | 259 | /** 260 | * Retrieve the details of a workflow run from the GitHub's rest-api. 261 | * 262 | * The request uses the [WorkflowRun.id] and if present the respective [WorkflowRun.etag]. 263 | * 264 | * @param run The [WorkflowRun] for which to get the details. 265 | * @return The raw HttpResponse for further processing by the caller. 266 | */ 267 | private suspend fun sendRunRequest(run: WorkflowRun): HttpResponse = 268 | client.sendGet("actions/runs/${run.id}") { 269 | run.etag?.also { 270 | this.add(HEADER_IF_NONE_MATCH, it) 271 | } 272 | } 273 | 274 | /** 275 | * Support function to execute [block] in a loop. 276 | * 277 | * The times block will be invoked depends on the [maxTimeout], the [frequency] 278 | * and the execution time of [block] itself. 279 | * 280 | * To identify if polling can be stopped, [block] has to return a pair which first member 281 | * is `true`, otherwise function waits [frequency] and executes [block]. 282 | * 283 | * @param[maxTimeout] The maximum duration of total execute time before returning to caller. 284 | * @param[frequency] The duration to wait between two consecutive [block] invocations. 285 | * @param[block] The function to execute in loop. 286 | * 287 | * @return The result of [block] invocation. 288 | */ 289 | private suspend inline fun executePolling( 290 | maxTimeout: Duration, frequency: Duration, block: () -> Pair 291 | ): Pair { 292 | val start = getTimeMillis() 293 | var delta: Duration 294 | var result: Pair 295 | do { 296 | result = block().also { 297 | if (!it.first) { 298 | logger.info("No result, retry in $frequency") 299 | delay(frequency) 300 | } 301 | } 302 | delta = getTimeMillis().deltaMs(start) 303 | logger.debug("Time passed since start: $delta") 304 | } while (!result.first && (delta < maxTimeout)) 305 | 306 | return result 307 | } 308 | 309 | /** 310 | * Extension to retrieve the duration between two integer timestamps. 311 | * 312 | * Example: 313 | * ``` 314 | * val start = getTimeMillis() 315 | * // Do some work ... 316 | * val timePassed = getTimeMillis().deltaMs(start) 317 | * println("Processing took $timePassed") 318 | * ``` 319 | * @return The time passed between start and this as [Duration]. 320 | */ 321 | private fun Long.deltaMs(other: Long): Duration { 322 | val delta = abs(this - other) 323 | return delta.toDuration(DurationUnit.MILLISECONDS) 324 | } 325 | 326 | /** 327 | * Helper object to deal with some common GitHub rest-api stuff. 328 | */ 329 | companion object { 330 | private const val QUERY_EVENT = "event" 331 | private const val EVENT_DISPATCH = "workflow_dispatch" 332 | private const val QUERY_CREATED_AT = "created" 333 | private const val QUERY_REF = "branch" 334 | 335 | // 336 | fun queryEvent(type: String = EVENT_DISPATCH) = QUERY_EVENT to type 337 | fun queryCreatedAt(at: String) = QUERY_CREATED_AT to ">=$at" 338 | fun queryRef(of: String) = QUERY_REF to of 339 | // 340 | 341 | // 342 | fun getWorkflowId(json: JsonObject) = json.getValue("workflow_id").jsonPrimitive.content 343 | fun getId(json: JsonObject) = json.getValue("id").jsonPrimitive.content 344 | fun getHeadBranch(json: JsonObject) = json.getValue("head_branch").jsonPrimitive.content 345 | fun getRunStatus(json: JsonObject) = RunStatus.from(json.getValue("status").jsonPrimitive.content) 346 | fun getConclusion(json: JsonObject) = RunConclusion.from(json.getValue("conclusion").jsonPrimitive.contentOrNull) 347 | fun getCreationDate(json: JsonObject) = json.getValue("created_at").jsonPrimitive.content 348 | fun getJobs(json: JsonObject) = Jobs(json.getValue("jobs_url").jsonPrimitive.content) 349 | // 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/usecases/Workflows.kt: -------------------------------------------------------------------------------- 1 | package usecases 2 | 3 | import com.rnett.action.core.logger 4 | import data.GhRestClient 5 | import data.toJson 6 | import data.toResponseJson 7 | import io.ktor.http.HttpStatusCode 8 | import kotlinx.serialization.json.JsonElement 9 | import kotlinx.serialization.json.JsonObject 10 | import kotlinx.serialization.json.JsonPrimitive 11 | import kotlinx.serialization.json.contentOrNull 12 | import kotlinx.serialization.json.jsonObject 13 | import kotlinx.serialization.json.jsonPrimitive 14 | import utils.actions.ActionFailedException 15 | import kotlin.js.Date 16 | 17 | /** 18 | * Class to deal with the main use cases trough GitHub's `workflows` resource api. 19 | * 20 | * @constructor Create an instance to interact with GitHub's workflow api. 21 | * @property[client] The client used to send requests to GitHub rest-api. 22 | */ 23 | class Workflows(private val client: GhRestClient) { 24 | 25 | /** 26 | * Retrieves the technical id of a workflow. 27 | * 28 | * Internally the action, as well as GitHub's apis, rely on ids rather than on "human-readable" names. 29 | * 30 | * @param wfName Human-readable name of a workflow. 31 | * 32 | * @return GitHub's technical id for the workflow name. 33 | * 34 | * @throws ActionFailedException If the request does not succeed, or if no 'id' could be retrieved from response. 35 | */ 36 | suspend fun getWorkflowIdFromName(wfName: String): String { 37 | val response = client.sendGet("actions/workflows/$wfName") 38 | if (!response.isSuccess()) { 39 | logger.error("Receiving workflow id results in error! Details: ${response.toResponseJson(true)}") 40 | throw ActionFailedException("Unable to receive workflow id! Details see log") 41 | } 42 | 43 | val jsonResponse = response.toJson() 44 | return jsonResponse.jsonObject["id"]?.jsonPrimitive?.contentOrNull 45 | ?: throw ActionFailedException("Unable to get workflow id from response! Got: $jsonResponse") 46 | } 47 | 48 | /** 49 | * Fires the workflow dispatch event for a workflow. 50 | * 51 | * @param workflowId The `id` of the workflow. 52 | * @param ref The name of the `branch` the workflow shall run on. 53 | * @param inputs Additional data that will be sent as `inputs` to the workflow. 54 | * 55 | * @return The datetime string (ISO) of creation time (or current date if not received by the endpoint) 56 | */ 57 | suspend fun triggerWorkflow(workflowId: String, ref: String, inputs: JsonObject? = null): String = 58 | logger.withGroup("Triggering workflow") { 59 | val body = JsonObject( 60 | mutableMapOf( 61 | "ref" to JsonPrimitive(ref) 62 | ).also { 63 | if (null != inputs) { 64 | it["inputs"] = inputs 65 | } 66 | } 67 | ).toString() 68 | 69 | logger.info("Sending workflow dispatch event with body $body") 70 | val fallBackDate = Date().toISOString() 71 | val response = client.sendPost("actions/workflows/$workflowId/dispatches", body) 72 | if (HttpStatusCode.MultipleChoices.value <= response.statusCode) { 73 | logger.error("Response: ${response.readBody()}") 74 | throw ActionFailedException("Error starting workflow! For response details see log.") 75 | } 76 | 77 | val rawDate = response.headers["date"] 78 | val date = if (null != rawDate) { 79 | Date(rawDate).toISOString() 80 | } else { 81 | logger.warning( 82 | """No start date received from response, using fallback date $fallBackDate. 83 | |If you see this message everytime please inform the action developers.""".trimMargin() 84 | ) 85 | fallBackDate 86 | } 87 | logger.info("Dispatched event at '$date'. (Header: ${response.headers.toMap()}\nBody: ${response.readBody()})") 88 | return date 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import com.rnett.action.core.fail 4 | import com.rnett.action.core.logger.error 5 | import com.rnett.action.core.outputs 6 | import com.rnett.action.currentProcess 7 | import web.timers.setTimeout 8 | import kotlin.coroutines.resume 9 | import kotlin.coroutines.suspendCoroutine 10 | import kotlin.time.Duration 11 | 12 | /** 13 | * Controls how an error in the action is carried out to the workflow. 14 | * 15 | * Always sets the outputs 'failed' variable to "true"! 16 | * 17 | * @param[message] The message to display in the log. 18 | * @param[failOnError] If `true` action terminates with exit code `1` which will also marks the workflow run as failed. 19 | * If `false` action terminates with exit code `0` which marks the step "passed" and workflow continues normally. 20 | */ 21 | fun failOrError(message: String, failOnError: Boolean) { 22 | // if we report any failure, we consider the action as failed, but maybe don't want the workflow to fail 23 | outputs["failed"] = "true" 24 | if (failOnError) { 25 | fail(message) 26 | } else { 27 | error(message) 28 | currentProcess.exit(0) 29 | } 30 | } 31 | 32 | suspend fun delay(duration: Duration): Unit = suspendCoroutine { continuation -> 33 | setTimeout(duration) { continuation.resume(Unit) } 34 | } 35 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/actions/ActionEnvironment.kt: -------------------------------------------------------------------------------- 1 | package utils.actions 2 | 3 | import kotlinx.js.get 4 | import node.process.process 5 | import kotlin.reflect.KProperty 6 | 7 | /** 8 | * Subset of Environment variables used by this action. 9 | */ 10 | object ActionEnvironment { 11 | /** 12 | * The owner and repository of the workflow the action is executed in. 13 | */ 14 | val GITHUB_REPOSITORY by Environment 15 | 16 | /** 17 | * The id of the current workflow run. 18 | * Same over multiple re-runs. 19 | */ 20 | val GITHUB_RUN_ID by Environment 21 | 22 | /** 23 | * The job_id of the current job. 24 | */ 25 | val GITHUB_JOB by Environment 26 | 27 | /** 28 | * URL to GitHub's rest api. 29 | */ 30 | val GITHUB_API_URL by Environment 31 | 32 | /** 33 | * URL to GitHub's GraphQL api. 34 | */ 35 | val GITHUB_GRAPHQL_URL by Environment 36 | 37 | private object Environment { 38 | operator fun getValue(env: Any, property: KProperty<*>): String = 39 | process.env[property.name] ?: throw ActionFailedException("Could not find ${property.name} in process.env!") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/jsMain/kotlin/utils/actions/ActionFailedException.kt: -------------------------------------------------------------------------------- 1 | package utils.actions 2 | 3 | /** 4 | * Exception to indicate a controlled action exit. 5 | */ 6 | class ActionFailedException(message: String?, cause: Throwable? = null): Throwable(message, cause) 7 | -------------------------------------------------------------------------------- /webpack.config.d/github.action.config.js: -------------------------------------------------------------------------------- 1 | config.target = 'node'; --------------------------------------------------------------------------------