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< .github/workflows/my-workflow.yml + workflow_path="$(echo "${workflow_ref%@*}" | cut -d/ -f3-)" + job_name_template="$(job_key="${job_key}" yq '.jobs[env(job_key)].name' "${repo_path}/${workflow_path:?}")" + echo "job-name-template=${job_name_template:?}" | tee -a "$GITHUB_OUTPUT" + env: + repo_path: ${{ github.action_path }}/repo + workflow_ref: ${{ github.workflow_ref }} + job_key: ${{ github.job }} # User supplied job key cannot be used as we cannot properly render `matrix` expressions + - id: render + if: ${{ inputs.job-name == '' }} + shell: bash + run: | + # Render job name + [[ "$RUNNER_DEBUG" -eq 1 ]] && set -x + set -euo pipefail + + # Determine if job name contains GitHub expressions. As GitHub expressions must be + # closed we can just check for the opening sequence. + if grep -qE '\$\{\{' <<<"${job_name_template}"; then + # Convert GHA expressions into jq Bash expressions. Convert `null` to `""`. + job_name_expr="$(perl -pe 's/\$\{\{\s*([a-z]+)\.([a-z0-9._-]+)\s*}}/\$(jq -r --arg p "\2" '\''getpath(\$p | split(".")) | select(type != "null")'\'' <<<"\${\1_json:?}")/g' <<<"${job_name_template}")" + job_name="$(eval "echo \"${job_name_expr}\"")" + else + # GitHub adds job matrix values to job names which do not contain expressions. + matrix_values="$(jq -r '[.. | select(type != "object")] | join(", ")' <<<"${matrix_json}")" + + if [[ -n "${matrix_values}" ]]; then + job_name="${job_name_template} (${matrix_values})" + else + job_name="${job_name_template}" + fi + fi + echo "job-name=${job_name:?}" | tee -a "$GITHUB_OUTPUT" + env: + job_name_template: ${{ steps.workflow.outputs.job-name-template }} + + # `jobs..name` can use the contexts: [github, needs, strategy, matrix, vars, inputs] + # https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/contexts#context-availability + github_json: ${{ toJSON(github) }} + # needs_json: ${{ toJSON(needs) }} + strategy_json: ${{ toJSON(strategy) }} + matrix_json: ${{ toJSON(matrix) }} + # vars_json: ${{ toJSON(vars) }} + # inputs_json: ${{ toJSON(inputs) }} + - id: job-api + shell: bash + run: | + # Fetch job ID + [[ "$RUNNER_DEBUG" -eq 1 ]] && set -x + set -euo pipefail + + jobs="$(gh api -X GET "/repos/{owner}/{repo}/actions/runs/${run_id:?}/attempts/${run_attempt:?}/jobs")" + job_ids="$(jq -c --arg name "$job_name" '[.jobs[] | select(.name == $name) | .id]' <<<"${jobs}")" + echo "job-ids=${job_ids:?}" | tee -a "$GITHUB_OUTPUT" + + if [[ $(jq length <<<"${job_ids}") -eq 1 ]]; then + echo "job-id=$(jq 'first // empty' <<<"${job_ids}")" | tee -a "$GITHUB_OUTPUT" + fi + env: + GH_TOKEN: ${{ github.token }} + run_id: ${{ github.run_id }} + run_attempt: ${{ github.run_attempt }} + job_name: ${{ inputs.job-name || steps.render.outputs.job-name }}