diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml
new file mode 100644
index 0000000..c6363cb
--- /dev/null
+++ b/.github/workflows/integration-tests.yaml
@@ -0,0 +1,233 @@
+---
+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 }}
+ debug: true
+
+ 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 }}
+ debug: true
+
+ test-complex:
+ name: Test Complex
+ 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: ""
+ debug: true
+ - name: Action failed
+ if: ${{ steps.matrix-output.outcome != 'failure' }}
+ run: exit 1
+
+ test-duplicate:
+ name: Test Duplicate
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ - uses: ./
+ id: matrix-output1
+ continue-on-error: true
+ with:
+ yaml: |
+ demo: 1
+ debug: true
+ - uses: ./
+ id: matrix-output2
+ continue-on-error: true
+ with:
+ yaml: |
+ demo: 2
+ debug: true
+ - name: Action failed
+ if: ${{ steps.matrix-output1.outcome != 'success' || steps.matrix-output2.outcome != 'failure' }}
+ run: exit 1
+
+ setup-race-condition:
+ name: Setup Race Condition
+ 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
+ - if: ${{ strategy.job-index == 0 }}
+ run: sleep 5
+ - uses: ./
+ id: matrix-output
+ with:
+ yaml: |
+ index: ${{ matrix.index }}
+ debug: true
+ - if: ${{ strategy.job-index == 1 }}
+ run: sleep 30
+
+ test-race-condition:
+ name: Test Race Condition
+ needs: setup-race-condition
+ 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-race-condition.outputs.results-json }}
+ expected_json: |-
+ [
+ {
+ "index": 1
+ },
+ {
+ "index": 2
+ }
+ ]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..868a0f7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Beacon Biosignals
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index fe889be..0bc955f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,88 @@
-# 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.
+
+## Requirements
+
+The `matrix-output` action requires that each job in the job matrix uses a distinct job name which is unique within the workflow file. By default job matrix job names are distinct (e.g. `name: Build` -> `Build (App One, user/app1)`) so this only a problem if you accidentally specify the same job name for multiple matrix jobs. If the job name is not unique within the workflow this action will fail and report the ambiguous job name.
+
+Additionally, the `matrix-output` action is intended to only be used once within a job. Attempting to utilize this action multiple times within the same job will cause the second use of this action to fail.
+
+Finally, it is highly recommended that the `matrix-output` is either the last step in the job or the near the end of the job. If you choose to have this action run before other slow running actions you may see some extended runtimes for the last few running jobs in the matrix. In order to guarantee correct output we require that the last running job has a complete set of outputs from all other jobs in the matrix. To ensure the last running job has a complete set of outputs we have those jobs wait for other jobs with an incomplete set of outputs.
+
+## 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
+ # !!! Important: In order to reduce delays we highly recommend that the
+ # `matrix-output` action is either the last step in a job or close to the end.
+ - 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:
+ actions: read
+ contents: read
+```
+
+The permission `actions: read` is needed to determine which matrix jobs are still running while `contents: read` is need for running the action [`beacon-biosignals/job-context`](https://github.com/beacon-biosignals/job-context#permissions).
diff --git a/action.yaml b/action.yaml
new file mode 100644
index 0000000..bf852b3
--- /dev/null
+++ b/action.yaml
@@ -0,0 +1,220 @@
+---
+name: Matrix Output
+description: Collect outputs from each matrix job.
+branding:
+ icon: layers
+ color: orange
+inputs:
+ yaml:
+ description: >-
+ A string representing YAML data. Typically, a simple dictionary of key/value pairs.
+ type: string
+ required: true
+ debug:
+ description: >-
+ Enable debug logging for this specific action.
+ type: string
+ default: "false"
+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:
+ - uses: beacon-biosignals/job-context@abc4b535d749dacbcd688250e36cbec0da6f7d6a # v1.0.0
+ id: job
+ with:
+ path: ${{ github.action_path }}/repo
+ - name: Generate job output
+ shell: bash
+ run: |
+ input_json="$(yq -o=json <<<"${input_yaml:?}")"
+ jq -ne \
+ --argjson metadata "{\"job-id\": ${self_job_id:?}}" \
+ --argjson outputs "${input_json:?}" \
+ '$ARGS.named' | tee -a job-output.json
+ touch job-sync
+ env:
+ input_yaml: ${{ inputs.yaml }}
+ self_job_id: ${{ steps.job.outputs.id }}
+ - name: Upload job output
+ uses: actions/upload-artifact@v4
+ with:
+ name: matrix-output-${{ github.job }}-${{ strategy.job-index }}
+ path: job-output.json
+ if-no-files-found: error
+ - name: Download job matrix outputs
+ uses: actions/download-artifact@v4
+ with:
+ pattern: matrix-output-${{ github.job }}-*
+ path: matrix-output
+ merge-multiple: false
+ - name: Determine artifact jobs
+ id: artifact-jobs
+ shell: bash
+ run: |
+ # Determine artifact jobs
+ jobs="$(jq -rs '[.[].metadata."job-id"] | to_entries | map({id: .value, index: .key})' matrix-output/*/*.json)"
+
+ if [[ "$RUNNER_DEBUG" -eq 1 ]] || [[ "${{ inputs.debug }}" == "true" ]]; then
+ echo "Artifact jobs:" >&2
+ jq '.' <<<"${jobs}" >&2
+ fi
+
+ # 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_OUTPUT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # An alternate version of `/repos/{owner}/{repo}/actions/runs/${run_id}/attempts/${run_attempt}/jobs`
+ # which reports only jobs which were actually executed. Mainly, this results in
+ # different values for `id`, `run_attempt`, and `created_at` being returned.
+ #
+ # Specifically, this alternate version is needed such that the `job-id` written to the
+ # `matrix-output` artifact matches the job `id` from the GitHub API for jobs which were
+ # not re-run.
+ - name: Determine executed jobs
+ id: executed-jobs
+ shell: bash
+ run: |
+ # Determine executed jobs
+
+ # Fetch the jobs for the current and all previous run attempts. We are not using
+ # the GitHub API endpoint `/repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=all`
+ # here as it has proven to be unreliable and does not include running jobs.
+ jobs='[]'
+ for attempt in $(seq 1 ${run_attempt}); do
+ attempt_jobs="$(gh api -X GET --paginate "/repos/{owner}/{repo}/actions/runs/${run_id:?}/attempts/${attempt:?}/jobs" --jq '.jobs')"
+
+ # Remove job entries which weren't actually executed during this attempt.
+ # The GitHub API includes new `job_id`s for each `run_attempt` and we want to
+ # exclude these entries as we only want jobs which have actually executed.
+ attempt_jobs="$(jq 'map(select(.completed_at == null or .completed_at >= .created_at))' <<<"${attempt_jobs}")"
+
+ jobs="$(jq -e --argjson attempt_jobs "${attempt_jobs}" '. |= . + $attempt_jobs' <<<"$jobs")"
+ done
+
+ # Remove older jobs which were re-run
+ jobs="$(jq 'sort_by(.created_at) | reverse | unique_by(.name)' <<<"${jobs}")"
+
+ if [[ "$RUNNER_DEBUG" -eq 1 ]] || [[ "${{ inputs.debug }}" == "true" ]]; then
+ echo "Executed jobs:" >&2
+ jq '[.[] | {id, name, run_id, run_attempt, created_at, completed_at, conclusion}]' <<<"${jobs}" >&2
+ fi
+
+ {
+ echo "json<>"$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run_id: ${{ github.run_id }}
+ run_attempt: ${{ github.run_attempt }}
+ - name: Determine matrix jobs
+ id: matrix-jobs
+ shell: bash
+ run: |
+ # Determine matrix jobs
+ executed_jobs="$(jq '[.[] | {id, name, conclusion}]' <<<"${executed_jobs:?}")"
+
+ # Combine the `executed_jobs` into the `artifact_jobs` data (left join) to produce list of jobs in the matrix.
+ # For more info on `jq`'s SQL style JOIN see: https://qmacro.org/blog/posts/2022/06/23/understanding-jq%27s-sql-style-operators-join-and-index/
+ matrix_jobs="$(jq -n --argjson aj "${artifact_jobs}" --argjson ej "${executed_jobs}" '[JOIN(INDEX($ej[]; .id); $aj[]; (.id | tostring); add)]')"
+
+ num_jobs="$(jq length <<<"${matrix_jobs}")"
+ num_running_jobs="$(jq 'map(select(.conclusion == null)) | length' <<<"${matrix_jobs}")"
+
+ echo "Matrix outputs produced: ${num_jobs}/${{ strategy.job-total }}" >&2
+ echo "Running jobs remaining: ${num_running_jobs}" >&2
+
+ if [[ "$RUNNER_DEBUG" -eq 1 ]] || [[ "${{ inputs.debug }}" == "true" ]]; then
+ echo "Matrix jobs:" >&2
+ jq '.' <<<"${matrix_jobs}" >&2
+ fi
+
+ {
+ echo "num=${num_jobs}"
+ echo "num-running=${num_running_jobs}"
+
+ echo "json<>"$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ artifact_jobs: ${{ steps.artifact-jobs.outputs.json }}
+ executed_jobs: ${{ steps.executed-jobs.outputs.json }}
+ - name: Upload sync artifact
+ uses: actions/upload-artifact@v4
+ if: ${{ steps.matrix-jobs.outputs.num == strategy.job-total && steps.matrix-jobs.outputs.num-running > 1 }}
+ with:
+ name: matrix-output-sync-${{ github.job }}-${{ strategy.job-index }}
+ path: job-sync
+ - id: wait
+ if: ${{ steps.matrix-jobs.outputs.num == strategy.job-total && steps.matrix-jobs.outputs.num-running > 1 }}
+ shell: bash
+ run: |
+ # Wait for running matrix jobs
+ start=$(date +%s)
+
+ # Wait for remaining running jobs (excluding ourself)
+ readarray -t remaining_job_ids <<<"$(jq -r --argjson self "${self_job_id}" '.[] | select(.conclusion == null and .id != $self) | .id' <<<"${matrix_jobs}")"
+
+ while [[ ${#remaining_job_ids[@]} -gt 0 ]]; do
+ job_id="${remaining_job_ids[0]}"
+ echo "Waiting for $job_id" >&2
+
+ # Check if the job is also waiting
+ job_index="$(jq --argjson id "${job_id}" '.[] | select(.id == $id).index' <<<"${matrix_jobs}")"
+ sync_artifact_name="${sync_artifact_prefix:?}-${job_index:?}"
+
+ # 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")"
+
+ sync_artifact_exists="$(jq --arg name "${sync_artifact_name:?}" '.artifacts | map(select(.name == $name)) | any' <<<"${artifacts}")"
+ if [[ "${sync_artifact_exists}" == "true" ]]; then
+ remaining_job_ids=("${remaining_job_ids[@]:1}") # Drop the first element
+ continue
+ fi
+
+ # Check if the job is still running
+ is_running="$(gh api -X GET "/repos/{owner}/{repo}/actions/jobs/${job_id:?}" | jq '.conclusion == null')"
+ if [[ "${is_running}" == "false" ]]; then
+ remaining_job_ids=("${remaining_job_ids[@]:1}") # Drop the first element
+ continue
+ fi
+
+ sleep 5
+ done
+
+ end=$(date +%s)
+ echo "Waited for other jobs for $((end-start)) seconds" >&2
+ env:
+ GH_TOKEN: ${{ github.token }}
+ matrix_jobs: ${{ steps.matrix-jobs.outputs.json }}
+ run_id: ${{ github.run_id }}
+ self_job_id: ${{ steps.job.outputs.id }}
+ sync_artifact_prefix: matrix-output-sync-${{ github.job }}
+ - name: Merge job matrix output
+ id: merge
+ if: ${{ steps.matrix-jobs.outputs.num == strategy.job-total }}
+ shell: bash
+ run: |
+ # Merge job matrix output
+
+ # 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<.outputs` in dependent jobs.
+
+In order to accomplish this goal we need do two things:
+
+1. Preserve output from each job in the matrix
+2. Ensure the last running job in the matrix has combined all job matrix outputs
+
+## Preserving job matrix output
+
+In order to preserve the output from each job in the matrix we utilize GitHub artifact to store each job's output. The artifact naming convention used here is `matrix-output-${{ github.job }}-${{ strategy.job-index}}`.
+
+We include both the `github.job` (YAML job key) and the `strategy.job-index` (matrix job index) to avoid artifact name collisions within a run attempt. The job key allows this action to be used by multiple jobs within the same workflow and the job index ensures matrix jobs use distinct artifacts. Additionally, the use of the job index allows us to ensure consistent ordering of the output.
+
+At this time we do not support using this action multiple times within the same workflow job. We could support this in the future by possibly including `github.action` (step ID).
+
+> Note: The [GitHub Actions documentation states that the `github.job`](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#github-context) is the "`job_id`" of the current job. The `github.job` specifically refers to the YAML key used for the job which is different from the numeric `job_id` used by the GitHub API. In order to disambiguate this term here I'll be referring to the `github.job` as the job key or `job_key` and the GitHub API job ID as `job_id`.
+
+## Ensure the last matrix job has combined all job matrix outputs
+
+When using the `outputs` field for a matrix job only results from the last completed job in the matrix are accessible. We need the action to ensure that the last completed job in the matrix returns the outputs from all jobs in the matrix. Guaranteeing this turns out to be quite complex.
+
+Consider a scenario in which there are two jobs in a matrix job: A and B. Job A completes the `matrix-output` action first but encounters a long running post step. Job B executes `matrix-output` after A but completes before A. In this scenario the final job A output would not contain the outputs for B. To address this problem we need to have B wait for A to complete to ensure the output is complete. We now need to answer these questions:
+
+- How do we know when a job in the matrix has complete set of outputs?
+- How can we determine we are the last running job in the matrix?
+
+### How do we know when a job in the matrix has complete set of outputs?
+
+Initially, determining if a job in the matrix has a complete set of output seems trivial. We just need to download all the artifacts for run with the pattern `matrix-output-${{ github.job }}-*` and check that the number of artifacts equals the `strategy.job-total`. Unfortunately, re-runs complicate things as we cannot determine by the artifacts alone if an artifact was created on the current attempt or from a previous attempt.
+
+Consider a scenario in which there are two jobs in a job matrix (X and Y) which create distinct output on each execution. On the first run attempt only X completed successfully. The user then triggers a re-run of all jobs. In this second run attempt Y reaches the `matrix-output` action before X. When Y checks for which output artifacts are available it sees the artifact for X from the first run attempt and the artifact for Y for the second run attempt. If Y were to finish last on the second attempt we would produce an incorrect set of outputs.
+
+To solve this new problem we need to be able to identify which artifacts are associated with the current run attempt. The GitHub API for listing artifacts doesn't provide this information. However, the GitHub API for listing jobs does contain the information we need. All we need to do is to be able to associate the output artifacts we create with the specific `job_id` which created the artifact
+
+> Aside: As GitHub enforces that each run attempt cannot occur concurrently we can determine the artifact run attempt by using the list artifacts and list jobs API. This however is not enough to determine the specific job which produced the artifact though.
+
+If we include the `job_id` of the current job as part of the output artifact we can then utilize the GitHub API job list endpoint to determine if that artifact is part of the current run attempt. As the [GitHub provided contexts](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs) do not provide the numeric `job_id` used by the GitHub API we'll utilize the custom GHA `job-context` to determine the `job_id` for the current job.
+
+So now we can use the GitHub API job list endpoint to determine the set of jobs executed for the latest attempt and the GHA `action/download` to determine the `job_id`s of the latest (possibly outdated) outputs for each job in the matrix. By performing an intersection of these job IDs we can identify the jobs for the latest attempt which have produced output. We refer to this job set as the "known matrix jobs". If the number of known matrix jobs is equal to `strategy.job-total` we know that the current running job has a complete set of outputs.
+
+### How can we determine we are the last running job in the matrix?
+
+We can partially determine if we have a complete set of outputs. If the number of outputs is less than the `strategy.job-total` we know there is another job in the matrix which has yet to upload its output as an artifact.
+
+For jobs with a complete set of output we can utilize the GitHub API jobs endpoint to determine which jobs in the matrix are still running. The set of "known matrix jobs" (see previous section) for a job with a complete set of output will provide us with the job IDs for all jobs in the matrix. By polling the GitHub API job endpoint and waiting when we find a running job (besides ourself) we can ensure that only a job with a complete set of outputs is the last to complete.
+
+However, consider a scenario where there are two jobs in a job matrix (E and F) where both have complete sets of outputs. If these jobs wait until all other siblings jobs have completed they will end up waiting indefinitely as E waits on F and F waits on E.
+
+What we need here is some kind of synchronization primitive to determine which jobs are waiting. So for jobs that have complete sets of outputs we create another artifact with the convention `matrix-output-sync-${{ github.job }}-${{ strategy.job-index }}` which indicates that a job is waiting. If we update our waiting loop to treat waiting sibling jobs as complete we can can avoid the waiting deadlock. Note we don't actually know which of these waiting jobs will complete last but it doesn't matter as all waiting jobs will have a complete set of outputs.
+
+One concern about using waiting within the `matrix-output` GHA is that we could end up artificially extending the duration of a waiting job. To minimize the waiting duration we recommend that the `matrix-output` GHA is used as the last step in the matrix job. By following this recommendation the waiting duration should be near zero since the waiting jobs each have complete set of outputs and therefore all jobs in the matrix must have reached this step.