diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml new file mode 100644 index 0000000..f6a363e --- /dev/null +++ b/.github/workflows/integration-tests.yaml @@ -0,0 +1,271 @@ +--- +name: Integration Tests +on: + pull_request: + paths: + - "action.yaml" + - ".github/workflows/integration-tests.yaml" + +jobs: + setup-simple: + name: Setup Simple + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + index: + - 1 + - 2 + outputs: + results-json: ${{ steps.matrix-output.outputs.json }} + steps: + - uses: actions/checkout@v4 + # Slow down on job to ensure that this is the last run + - if: ${{ strategy.job-index == 0 }} + run: sleep 5 + # Keep `id` the same between `setup-simple` and `setup-complex` + # to ensure we can separate output per job. + - uses: ./ + id: matrix-output + with: + yaml: | + index: ${{ matrix.index }} + + test-simple: + name: Test Simple + needs: setup-simple + runs-on: ubuntu-latest + steps: + - name: Output JSON + run: | + if [[ "${output_json}" != "${expected_json}" ]]; then + cat <<<"${output_json}" >"output" + cat <<<"${expected_json}" >"expected" + diff output expected | cat -te + exit 1 + fi + env: + output_json: ${{ needs.setup-simple.outputs.results-json }} + expected_json: |- + [ + { + "index": 1 + }, + { + "index": 2 + } + ] + + setup-complex: + name: Setup Complex + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + build: + - name: App One + repo: user/app1 + - name: App Two + repo: user/app2 + version: + - "1.0" + - "2.0" + outputs: + results-json: ${{ steps.matrix-output.outputs.json }} + steps: + - uses: actions/checkout@v4 + # Keep `id` the same between `setup-simple` and `setup-complex` + # to ensure we can separate output per job. + - uses: ./ + id: matrix-output + with: + yaml: | + name: ${{ matrix.build.name }} + repo: ${{ matrix.build.repo }} + version_string: "${{ matrix.version }}" + version_number: ${{ matrix.version }} + + test-complex: + name: Test Complext + needs: setup-complex + runs-on: ubuntu-latest + steps: + - name: Output JSON + run: | + if [[ "${output_json}" != "${expected_json}" ]]; then + cat <<<"${output_json}" >"output" + cat <<<"${expected_json}" >"expected" + diff output expected | cat -te + exit 1 + fi + env: + output_json: ${{ needs.setup-complex.outputs.results-json }} + expected_json: |- + [ + { + "name": "App One", + "repo": "user/app1", + "version_string": "1.0", + "version_number": 1 + }, + { + "name": "App One", + "repo": "user/app1", + "version_string": "2.0", + "version_number": 2 + }, + { + "name": "App Two", + "repo": "user/app2", + "version_string": "1.0", + "version_number": 1 + }, + { + "name": "App Two", + "repo": "user/app2", + "version_string": "2.0", + "version_number": 2 + } + ] + + test-empty: + name: Test Empty + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + - uses: ./ + id: matrix-output + continue-on-error: true + with: + yaml: "" + - name: Action failed + if: ${{ steps.matrix-output.outcome == 'success' }} + run: exit 1 + + test-job-name: + name: Job Name + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v4 + - uses: ./job-context/ + id: job-context + - name: Matches expected name + run: '[[ "${output}" == "${expected}" ]] || exit 1' + env: + output: ${{ steps.job-context.outputs.job-name }} + expected: "Job Name" + + test-job-name-matrix: + name: Job Name + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + build: + - name: App One + repo: user/app1 + - name: App Two + repo: user/app2 + version: + - "1.0" + - "2.0" + steps: + - uses: actions/checkout@v4 + - uses: ./job-context/ + id: job-context + - name: Matches expected name + run: '[[ "${output}" == "${expected}" ]] || exit 1' + env: + output: ${{ steps.job-context.outputs.job-name }} + expected: "Job Name (${{ matrix.build.name }}, ${{ matrix.build.repo }}, ${{ matrix.version }})" + - name: Matches GitHub API name + run: | + jobs="$(gh api -X GET "/repos/{owner}/{repo}/actions/runs/${run_id:?}/attempts/${run_attempt:?}/jobs")" + if [[ $(jq --arg name "$job_name" '.jobs | map(select(.name == $name)) | length' <<<"${jobs}") -ne 1 ]]; then + jq '.jobs[].name' <<<"${jobs}" + exit 1 + fi + env: + GH_TOKEN: ${{ github.token }} + run_id: ${{ github.run_id }} + run_attempt: ${{ github.run_attempt }} + job_name: ${{ steps.job-context.outputs.job-name }} + + test-job-name-matrix-expr: + # TODO: Using `github.job` with any expressions results in it being empty + name: ${{ github.event_name }} - ${{ matrix.dne }} - ${{ matrix.index }} - ${{ strategy.job-index }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + index: + - 1 + - 2 + steps: + - uses: actions/checkout@v4 + - uses: ./job-context/ + id: job-context + - name: Matches expected name + run: '[[ "${output}" == "${expected}" ]] || exit 1' + env: + output: ${{ steps.job-context.outputs.job-name }} + expected: ${{ github.event_name }} - - ${{ matrix.index }} - ${{ strategy.job-index }} + - name: Matches GitHub API name + run: | + jobs="$(gh api -X GET "/repos/{owner}/{repo}/actions/runs/${run_id:?}/attempts/${run_attempt:?}/jobs")" + if [[ $(jq --arg name "$job_name" '.jobs | map(select(.name == $name)) | length' <<<"${jobs}") -ne 1 ]]; then + jq '.jobs[].name' <<<"${jobs}" + exit 1 + fi + env: + GH_TOKEN: ${{ github.token }} + run_id: ${{ github.run_id }} + run_attempt: ${{ github.run_attempt }} + job_name: ${{ steps.job-context.outputs.job-name }} + + test-job-name-ambiguous: + name: ${{ github.job }} + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + strategy: + fail-fast: false + matrix: + index: + - 1 + - 2 + steps: + - uses: actions/checkout@v4 + - uses: ./job-context/ + id: job-context + - name: Matches expected name + run: '[[ "${output}" == "${expected}" ]] || exit 1' + env: + output: ${{ steps.job-context.outputs.job-name }} + expected: ${{ github.job }} + - name: Matches multiple jobs + run: | + [[ $(jq length <<<"${job_ids}") -eq 2 ]] || exit 1 + [[ -z "${job_id}" ]] || exit 1 + env: + job_ids: ${{ steps.job-context.outputs.job-ids }} + job_id: ${{ steps.job-context.outputs.job-id }} diff --git a/README.md b/README.md index fe889be..c3aca59 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# matrix-output -Collect outputs from each matrix job +# Matrix Output + +Collect outputs from each matrix job. Currently, setting output for matrix jobs will cause outputs of earlier completed jobs to be overwritten by jobs completed later (see [discussion](https://github.com/orgs/community/discussions/26639)). This action allows the output from each matrix job to be collected into a JSON list to be utilized by dependent jobs. + +## Examples + +```yaml +# CI.yaml +jobs: + build: + name: Build ${{ matrix.build.name }} + # These permissions are needed to: + # - Use `matrix-output`: https://github.com/beacon-biosignals/matrix-output#permissions + permissions: + actions: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + build: + - name: App One + repo: user/app1 + - name: App Two + repo: user/app2 + outputs: + json: ${{ steps.matrix-output.outputs.json }} + steps: + - uses: docker/build-push-action@v6 + id: build-push + with: + tags: ${{ matrix.build.repo }}:latest + push: true + - uses: beacon-biosignals/matrix-output@v1 + id: matrix-output + with: + yaml: | + name: ${{ matrix.build.name }} + image: ${{ matrix.build.repo }}@${{ steps.build-push.outputs.digest }} + + test: + name: Test ${{ matrix.build.name }} + needs: build + runs-on: ubuntu-latest + container: + image: ${{ matrix.build.image }} + strategy: + fail-fast: false + matrix: + build: ${{ fromJSON(needs.build.outputs.json) }} + steps: + ... +``` + +## Inputs + +The `matrix-output` action supports the following inputs: + +| Name | Description | Required | Example | +|:-----------------|:------------|:---------|:--------| +| `yaml` | A string representing a YAML data. Typically, a simple dictionary of key/value pairs. | Yes |
name: ${{ matrix.name }}
...
|
+
+## Outputs
+
+| Name | Description | Example |
+|:-------|:------------|:--------|
+| `json` | A string representing a JSON list of dictionaries. Each dictionary in the list contains the output for a single job from the job matrix. The order of this list corresponds to the job index (i.e. `strategy.job-index`). | [
{
"name": "Server.jl",
...
},
{
"name": "Client.jl",
...
}
]
|
+
+## Permissions
+
+The follow [job permissions](https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs) are required to run this action:
+
+```yaml
+permissions:
+ action: read
+```
diff --git a/action.yaml b/action.yaml
new file mode 100644
index 0000000..806f87d
--- /dev/null
+++ b/action.yaml
@@ -0,0 +1,63 @@
+---
+name: Matrix Output
+description: >-
+ Collect outputs from each matrix job.
+inputs:
+ yaml:
+ description: >-
+ A string representing YAML data. Typically, a simple dictionary of key/value pairs.
+ type: string
+ required: true
+outputs:
+ json:
+ description: >-
+ A string representing a JSON list of dictionaries. Each dictionary in the list
+ contains the output for a single job from the job matrix. The order of this list
+ corresponds to the job index (i.e. `strategy.job-index`).
+ value: ${{ steps.merge.outputs.json }}
+runs:
+ using: composite
+ steps:
+ - name: Generate job output
+ shell: bash
+ run: yq -o=json <<<"${input_yaml:?}" >job-output.json
+ env:
+ input_yaml: ${{ inputs.yaml }}
+ - name: Upload job output
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ github.action }}-${{ github.job }}-${{ strategy.job-index }}
+ path: job-output.json
+ if-no-files-found: error
+ - name: Determine available matrix job outputs
+ id: job-output
+ shell: bash
+ run: |
+ # https://docs.github.com/en/rest/actions/artifacts?apiVersion=2022-11-28#list-workflow-run-artifacts
+ artifacts="$(gh api -X GET "/repos/{owner}/{repo}/actions/runs/${run_id:?}/artifacts" --jq '.artifacts')"
+ num_job_uploads="$(jq --arg prefix "${prefix:?}" 'map(select(.name | startswith($prefix))) | length' <<<"$artifacts")"
+ echo "Uploaded ${num_job_uploads}/${{ strategy.job-total }}" >&2
+ echo "num=${num_job_uploads:?}" | tee -a "$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ prefix: ${{ github.action }}-${{ github.job }}-
+ run_id: ${{ github.run_id }}
+ - name: Download job matrix outputs
+ uses: actions/download-artifact@v4
+ if: ${{ steps.job-output.outputs.num == strategy.job-total }}
+ with:
+ pattern: ${{ github.action }}-${{ github.job }}-*
+ path: matrix-output
+ merge-multiple: false
+ - name: Merge job matrix output
+ id: merge
+ if: ${{ steps.job-output.outputs.num == strategy.job-total }}
+ shell: bash
+ run: |
+ # Specify our multiline output using GH action flavored heredocs
+ # https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
+ {
+ echo "json<