diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 7a3de5e..07740ee 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -149,6 +149,112 @@ jobs: [[ "${XFAIL}" == "true" ]] || { >&2 echo "expected step to fail"; exit 1; } + selftest-checks: + name: "TEST: Checks with ${{ matrix.findings }} findings" + if: github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + strategy: + matrix: + include: + - findings: 0 + check-name: zizmor selftest + fixture: checks-clean.yml + - findings: 51 + check-name: zizmor selftest (xfail) + fixture: checks-findings.yml + runs-on: ubuntu-slim + permissions: + contents: read + checks: write + env: + CHECK_NAME: ${{ matrix.check-name }} + CHECK_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: $/ + id: zizmor + continue-on-error: true + with: + advanced-security: false + checks: true + internal-checks-name: ${{ env.CHECK_NAME }} + inputs: test/assets/${{ matrix.fixture }} + online-audits: false + min-severity: high + + - name: Assert check results and PR association + env: + GH_TOKEN: ${{ github.token }} + FINDINGS: ${{ matrix.findings }} + OUTCOME: ${{ steps.zizmor.outcome }} + PR_NUMBER: ${{ github.event.pull_request.number || 0 }} + run: | + expected=success + if [[ "${FINDINGS}" -gt 0 ]]; then expected=failure; fi + [[ "${OUTCOME}" == "${expected}" ]] + # GitHub replaces details_url on checks created with GITHUB_TOKEN. + # The API defaults to the latest check per name; each case has its own name. + gh api "repos/${GITHUB_REPOSITORY}/commits/${CHECK_SHA}/check-runs?per_page=100" \ + | jq -e --arg name "${CHECK_NAME}" \ + --arg conclusion "${expected}" --argjson count "${FINDINGS}" \ + --argjson pr "${PR_NUMBER}" ' + [.check_runs[] | select(.name == $name)] | + length == 1 and (.[0] | .status == "completed" and + .conclusion == $conclusion and .output.annotations_count == $count and + (if $pr == 0 then true else + .pull_requests | any(.number == $pr) end) and + (.output.summary | contains("View workflow run and logs")) and + (if $count == 0 then .output.title == "No findings" + else .output.text | contains("template-injection") end))' + + selftest-checks-invalid-xfail: + name: "TEST: Checks rejects incompatible modes" + strategy: + matrix: + mode: [advanced-security, annotations] + runs-on: ubuntu-slim + steps: + - uses: $/ + id: zizmor + continue-on-error: true + with: + checks: true + internal-checks-name: zizmor selftest (invalid xfail) + advanced-security: ${{ matrix.mode == 'advanced-security' }} + annotations: ${{ matrix.mode == 'annotations' }} + + - name: Assert failure + env: + OUTCOME: ${{ steps.zizmor.outcome }} + run: test "${OUTCOME}" = failure + + selftest-checks-permission-xfail: + name: "TEST: Checks requires write permission" + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: $/ + id: zizmor + continue-on-error: true + with: + advanced-security: false + checks: true + internal-checks-name: zizmor selftest (permission xfail) + inputs: test/assets/checks-clean.yml + online-audits: false + + - name: Assert failure + env: + OUTCOME: ${{ steps.zizmor.outcome }} + run: test "${OUTCOME}" = failure + selftest-fail-on-no-inputs-xfail: name: "TEST: 'fail-on-no-inputs: true' causes failure when no inputs are collected" runs-on: ubuntu-latest @@ -227,6 +333,9 @@ jobs: - selftest-plain-gha-hazmat-offline-audits-xfail - selftest-annotations - selftest-annotations-advanced-security-exclusive-xfail + - selftest-checks + - selftest-checks-invalid-xfail + - selftest-checks-permission-xfail - selftest-fail-on-no-inputs-xfail - selftest-fail-on-no-inputs-disabled - selftest-output-file-output-is-present-when-advanced-security @@ -240,3 +349,4 @@ jobs: uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 with: jobs: ${{ toJSON(needs) }} + allowed-skips: selftest-checks diff --git a/README.md b/README.md index 5d8f031..ad7f163 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Run [`zizmor`] from GitHub Actions! - [`token`](#token) - [`advanced-security`](#advanced-security) - [`annotations`](#annotations) + - [`checks`](#checks) + - [`internal-checks-name`](#internal-checks-name) - [`color`](#color) - [`config`](#config) - [`fail-on-no-inputs`](#fail-on-no-inputs) @@ -235,7 +237,8 @@ which uses the newest `zizmor` release this action knows about. *Default*: `${{ github.token }}` `token` is the GitHub token to use for accessing the GitHub REST API -during online audits. +during online audits and Checks reporting. When `checks: true`, this token +must have `checks: write` permission, even if online audits are disabled. ### `advanced-security` @@ -270,6 +273,50 @@ for GitHub annotations to create annotations for findings. > also set `advanced-security: false`. The action will refuse to run > if you do not do this. +This option is also incompatible with `checks: true`. + +### `checks` + +*Default*: `false` + +`checks` displays findings in a GitHub check, supporting more findings than +[workflow annotations](#annotations). The action fails when zizmor reports +findings or encounters an error, or when results cannot be published. + +Set `advanced-security: false` and leave `annotations: false` when enabling +Checks reporting. Add `checks: write` to the job's permissions: + +```yaml +permissions: + contents: read + checks: write +``` + +Then add these inputs to the zizmor action step: + +```yaml +with: + advanced-security: false + checks: true +``` + +Requires zizmor v1.6.0 or later. + +> [!IMPORTANT] +> Fork and Dependabot pull requests normally receive a read-only token, +> even when the workflow requests `checks: write`. Use plain output or +> [workflow annotations](#annotations) for those runs. + +### `internal-checks-name` + +*Default*: `zizmor` + +> [!WARNING] +> Most users should not override this value. + +When used with `checks: true`, this sets the name of the Check as it appears in +GitHub's Checks API. Typical usage does not require users to set this. + ### `color` *Default*: `true` @@ -316,6 +363,7 @@ The following table summarizes the permissions required and when: | Permission | Description | Required when? | | ---------- | ----------- | --------------- | | `security-events: write` | Required to upload results to [Advanced Security]. | When `advanced-security: true` (the default). | +| `checks: write` | Required to publish a check run and its annotations. | When `checks: true`. | | `contents: read` | Required to read the contents of the repository. | When `advanced-security: true` *and* the parent repository is private. | | `actions: read` | Required to read the actions of the repository. | When `advanced-security: true` *and* the parent repository is private. | @@ -323,7 +371,9 @@ Or, as a decision tree: ```mermaid graph TD - A["Are you using Advanced Security (the default)?"] -->|No| B@{ shape: diamond, label: "permissions: {}"} + A["Are you using Advanced Security (the default)?"] -->|No| F[Are you using Checks?] + F -->|No| B@{ shape: diamond, label: "permissions: {}"} + F -->|Yes| G@{ shape: diamond, label: "checks: write"} A -->|Yes| C[Is your repository public?] C -->|Yes| D@{ shape: diamond, label: "security-events: write"} C -->|No| E@{shape: diamond, label: "actions: read @@ -343,6 +393,12 @@ If you see this error, it _probably_ means that you are running the action from a self-hosted runner without Python installed. Install Python onto the runner, or make sure it is on `PATH` by the time this action runs. +### "Checks reporting requires jq" or "Checks reporting requires curl >= 7.76.0" + +Checks reporting requires `jq` and `curl` v7.76.0 or later. If you see either +error on a self-hosted runner, install the missing tool and ensure it is on +`PATH` before running the action. + ### Changes introduce security alerts but no PR checks are shown > [!NOTE] @@ -368,9 +424,9 @@ If you hit this behavior, you have a few options: but you **must** configure it manually — `zizmor-action` cannot do it for you. 2. Set `advanced-security: false` and use another output format, like - [annotations](#annotations) or the default ("plain") console format + [checks](#checks), [annotations](#annotations), or the default ("plain") console format (which you get by default when you set `advanced-security: false`). - With either of these approaches you lose the stateful tracking and triage + With these approaches you lose the stateful tracking and triage of Advanced Security, but you'll also avoid this issue. If you choose to switch to annotations, please keep in mind diff --git a/action.sh b/action.sh index 2114bf8..5052839 100755 --- a/action.sh +++ b/action.sh @@ -58,11 +58,22 @@ if [[ "${GHA_ZIZMOR_ADVANCED_SECURITY}" == "true" && "${GHA_ZIZMOR_ANNOTATIONS}" die "If you meant to enable 'annotations: true', you must explicitly set 'advanced-security: false'" fi +if [[ "${GHA_ZIZMOR_CHECKS:-false}" == "true" ]]; then + [[ "${GHA_ZIZMOR_ADVANCED_SECURITY}" != "true" && "${GHA_ZIZMOR_ANNOTATIONS}" != "true" ]] \ + || die "'checks: true' requires 'advanced-security: false' and 'annotations: false'" + installed jq || die "Checks reporting requires jq" + installed curl || die "Checks reporting requires curl >= 7.76.0" + [[ -n "${GHA_ZIZMOR_TOKEN}" ]] || die "Checks reporting requires a token with 'checks: write' permission" + [[ -n "${GHA_ZIZMOR_INTERNAL_CHECKS_NAME}" ]] || die "'internal-checks-name' must not be empty" +fi + if [[ "${GHA_ZIZMOR_ADVANCED_SECURITY}" == "true" ]]; then arguments+=("--format=sarif") output "sarif-file" "${output}" elif [[ "${GHA_ZIZMOR_ANNOTATIONS}" == "true" ]]; then arguments+=("--format=github") +elif [[ "${GHA_ZIZMOR_CHECKS:-false}" == "true" ]]; then + arguments+=("--format=json-v1") fi [[ -n "${GHA_ZIZMOR_COLLECT}" ]] && arguments+=("--collect=${GHA_ZIZMOR_COLLECT}") @@ -85,6 +96,12 @@ read -r requirement _ < "${lockfile}" [[ "${requirement}" == zizmor==* ]] || die "Missing zizmor pin in ${lockfile}" zizmor_version="${requirement#zizmor==}" +if [[ "${GHA_ZIZMOR_CHECKS:-false}" == "true" ]]; then + IFS=. read -r major minor _ <<< "${zizmor_version}" + (( major > 1 || (major == 1 && minor >= 6) )) \ + || die "Checks reporting requires zizmor >= 1.6.0" +fi + # The lock pins every wheel for this version by hash, so `--require-hashes` # gives us the same guarantee the pinned container digests used to: pip picks # the wheel matching the runner and verifies it against that set. @@ -125,14 +142,18 @@ chmod +x "${zizmor}" # as one or more flags. cd "${GITHUB_WORKSPACE}" -# shellcheck disable=SC2086 -GH_TOKEN="${GHA_ZIZMOR_TOKEN}" "${zizmor}" \ - "${arguments[@]}" \ - -- \ - ${GHA_ZIZMOR_INPUTS} \ - | tee "${output}" - -exitcode="${PIPESTATUS[0]}" +if [[ "${GHA_ZIZMOR_CHECKS:-false}" == "true" ]]; then + exitcode=0 + # shellcheck disable=SC2086 + GH_TOKEN="${GHA_ZIZMOR_TOKEN}" "${zizmor}" \ + "${arguments[@]}" -- ${GHA_ZIZMOR_INPUTS} > "${output}" || exitcode=$? + bash "${GITHUB_ACTION_PATH}/checks.sh" "${output}" "${exitcode}" +else + # shellcheck disable=SC2086 + GH_TOKEN="${GHA_ZIZMOR_TOKEN}" "${zizmor}" \ + "${arguments[@]}" -- ${GHA_ZIZMOR_INPUTS} | tee "${output}" + exitcode="${PIPESTATUS[0]}" +fi dbg "zizmor exited with code ${exitcode}" if [[ "${exitcode}" -eq 3 ]]; then diff --git a/action.yml b/action.yml index 4833ac2..b48ae69 100644 --- a/action.yml +++ b/action.yml @@ -62,7 +62,8 @@ inputs: default: latest token: description: | - The GitHub API token to use for zizmor online-audits (if enabled). + The GitHub API token to use for zizmor online-audits and Checks reporting + (if enabled). Checks reporting requires checks: write permission. required: false default: ${{ github.token }} advanced-security: @@ -86,8 +87,24 @@ inputs: This option is **mutually exclusive** with `advanced-security: true`. You must explicitly set `advanced-security: false` to enable this option. + It is also mutually exclusive with `checks: true`. required: false default: "false" + checks: + description: | + Whether to display zizmor's findings in a GitHub check. + + Requires checks: write permission and zizmor >= 1.6.0. + Mutually exclusive with advanced-security and annotations: set both to false. + Findings and publishing errors fail the action. + required: false + default: "false" + internal-checks-name: + description: | + Override the check name for this action's internal self-tests. + Leave this at its default for normal use. + required: false + default: zizmor config: description: Path to a custom zizmor configuration file Path (e.g., zizmor.yml). required: false @@ -123,6 +140,10 @@ runs: GHA_ZIZMOR_ADVANCED_SECURITY: ${{ inputs.advanced-security }} GHA_ZIZMOR_COLOR: ${{ inputs.color }} GHA_ZIZMOR_ANNOTATIONS: ${{ inputs.annotations }} + GHA_ZIZMOR_CHECKS: ${{ inputs.checks }} + # Custom checks need the PR head commit to appear on its Checks tab. + GHA_ZIZMOR_CHECKS_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + GHA_ZIZMOR_INTERNAL_CHECKS_NAME: ${{ inputs.internal-checks-name }} GHA_ZIZMOR_CONFIG: ${{ inputs.config }} GHA_ZIZMOR_FAIL_ON_NO_INPUTS: ${{ inputs.fail-on-no-inputs }} shell: bash diff --git a/checks.sh b/checks.sh new file mode 100644 index 0000000..a07398a --- /dev/null +++ b/checks.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash + +# checks.sh: publish zizmor JSON findings to a new GitHub check run. +# Called by action.sh with the results file and the scanner's exit status. +set -euo pipefail + +results="${1}" +exitcode="${2}" +tempdir="$(mktemp -d "${RUNNER_TEMP}/zizmor-checks.XXXXXX")" +endpoint="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/check-runs" +run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" +check_id="" + +api() { + # Annotation updates append, so retrying an ambiguous failure can duplicate + # findings. Leave retries to a new action invocation instead. + if ! curl --silent --show-error --fail-with-body \ + --connect-timeout 10 --max-time 60 \ + --request "${1}" \ + --header "Authorization: Bearer ${GHA_ZIZMOR_TOKEN}" \ + --header 'Accept: application/vnd.github+json' \ + --header 'Content-Type: application/json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + --data-binary @- --output "${tempdir}/response.json" "${2}"; then + echo "::error::Checks reporting failed. Ensure the token has 'checks: write';" \ + "fork and Dependabot PR tokens are normally read-only." + # Keep server messages on one prefixed line, without exposing the token. + jq -r --arg token "${GHA_ZIZMOR_TOKEN}" ' + "GitHub response: " + ( + .message // "No API error message" | + tostring | split($token) | join("***") | .[:1000] | + @json | split("##[") | join("\\u0023#[") + ) + ' "${tempdir}/response.json" 2>/dev/null || true + return 1 + fi +} + +cleanup() { + local status=$? + if [[ "${status}" -ne 0 && -n "${check_id}" ]]; then + jq -n '{status: "completed", conclusion: "failure", output: { + title: "zizmor reporting failed", + summary: "Could not publish all results. See the workflow log for details." + }}' | api PATCH "${endpoint}/${check_id}" || true + fi + rm -rf "${tempdir}" +} +trap cleanup EXIT + +conclusion=failure +[[ "${exitcode}" -eq 0 ]] && conclusion=success + +case "${exitcode}" in + 0|10|11|12|13|14) + # JSON v1 uses zero-based rows. Like zizmor's workflow annotations, use + # only the primary start line: multiline spans can extend past EOF. + if ! jq -e --arg workspace "${GITHUB_WORKSPACE}" ' + def normalize: + split("/") | reduce .[] as $part ([]; + if $part == "" or $part == "." then . + elif $part == ".." then .[:-1] + else . + [$part] end) | "/" + join("/"); + (($workspace | normalize) + "/") as $root | + if type != "array" then error("expected findings array") else . end | + map(select(.ignored != true) | + . as $finding | + ([.locations[] | select(.symbolic.kind == "Primary")][0] + // error("finding has no primary location")) as $primary | + $primary.symbolic.key as $key | + ($key.Local.verbatim_path // $key.Local.given_path) as $local | + (if $local == null then null else + ($local | if startswith("/") then . else $root + . end | normalize) | + if startswith($root) then ltrimstr($root) else null end + end) as $path | + { + path: $path, + display_path: ($local // $key.Remote.path // "stdin"), + severity: $finding.determinations.severity, + description: $finding.desc, + url: $finding.url, + start_line: ($primary.concrete.location.start_point.row + 1), + end_line: ($primary.concrete.location.start_point.row + 1), + annotation_level: ({ + Unknown: "notice", + Informational: "notice", + Low: "warning", + Medium: "warning", + High: "failure" + }[$finding.determinations.severity] // error("unknown severity")), + title: $finding.ident[:255], + # Annotation bodies are plain text; use paragraphs for readability. + message: ("\($finding.desc)\n\n\($primary.symbolic.annotation)\n\n" + + "Documentation: \($finding.url)") + }) + ' "${results}" > "${tempdir}/findings.json"; then + echo "::error::Could not read zizmor JSON results for Checks reporting" + exit 1 + fi + + # JSON quoting keeps each finding on one prefixed line. Also escape the + # legacy command prefix, which the runner recognizes anywhere in a line. + jq -r ' + .[] | "Finding: " + ( + "\(.display_path):\(.start_line): \(.title): \(.message)" | + @json | split("##[") | join("\\u0023#[") + ) + ' "${tempdir}/findings.json" + # 16,000 Unicode code points fit within the API limit of 64 KB. + jq 'map(select(.path != null) | + .title = ("\(.severity): \(.title)" | .[:255]) | + del(.display_path, .severity, .description, .url) | .message |= .[:16000])' \ + "${tempdir}/findings.json" > "${tempdir}/annotations.json" + count="$(jq length "${tempdir}/annotations.json")" + + jq --arg run_url "${run_url}" \ + --arg source_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/" ' + def cell: + @html | gsub("\\|"; "|") | + gsub("\\["; "[") | gsub("\\]"; "]") | + gsub("`"; "`") | gsub("\\\\"; "\") | + gsub("\\*"; "*") | gsub("_"; "_") | + gsub("~"; "~") | gsub("[\r\n]"; " "); + def row: + ("\((.path // .display_path) | cell):\(.start_line)") as $label | + (if .path == null then $label else + (.path | split("/") | map(@uri) | join("/")) as $path | + "[\($label)](\($source_url)\($path)#L\(.start_line))" + end) as $location | + "| \(.severity) | [\(.title | cell)](\(.url)) | " + + "\($location) | \(.description | cell) |\n"; + . as $findings | + length as $total | + (map(select(.path == null)) | length) as $unattached | + (reduce .[:50][] as $finding ({ + text: ("| Severity | Rule | Location | Finding |\n" + + "| --- | --- | --- | --- |\n"), + shown: 0 + }; + ($finding | row) as $row | + # Leave room beneath the API text limit for the truncation notice. + if ((.text + $row) | utf8bytelength) <= 60000 then + .text += $row | .shown += 1 + else . end + )) as $report | + { + title: (if $total == 0 then "No findings" else + "\($total) finding\(if $total == 1 then "" else "s" end)" + end), + summary: ( + if $total == 0 then "🌈 No findings to report." + else + "**\($total) findings**\n\n| Severity | Count |\n| --- | ---: |\n" + + (["High", "Medium", "Low", "Informational", "Unknown"] | + map(. as $severity | + ($findings | map(select(.severity == $severity)) | length) + as $count | + select($count > 0) | "| \($severity) | \($count) |" + ) | join("\n")) + end + + (if $unattached > 0 then + "\n\n\($unattached) findings have no local file annotation; " + + "see the report below and the workflow log." + else "" end) + + "\n\n[View workflow run and logs](\($run_url))" + ), + text: (if $total == 0 then "" else + $report.text + (if $report.shown < $total then + "\nShowing \($report.shown) of \($total) findings. " + + "See the annotations and workflow log for the remaining findings." + else "" end) + end) + } + ' "${tempdir}/findings.json" > "${tempdir}/report.json" + ;; + *) + echo '[]' > "${tempdir}/annotations.json" + count=0 + if [[ "${exitcode}" -eq 3 ]]; then + title="No inputs collected" + summary="No inputs were collected by zizmor." + [[ "${GHA_ZIZMOR_FAIL_ON_NO_INPUTS}" == "false" ]] && conclusion=success + else + title="Analysis failed" + summary="zizmor failed with exit code ${exitcode}. See the workflow log for details." + fi + jq -n --arg title "${title}" --arg summary "${summary}" --arg run_url "${run_url}" \ + '{title: $title, text: "", + summary: ($summary + "\n\n[View workflow run and logs](" + $run_url + ")")}' \ + > "${tempdir}/report.json" + ;; +esac + +jq -n --arg name "${GHA_ZIZMOR_INTERNAL_CHECKS_NAME}" --arg sha "${GHA_ZIZMOR_CHECKS_SHA}" \ + --arg url "${run_url}" \ + '{name: $name, head_sha: $sha, details_url: $url, status: "in_progress"}' \ + | api POST "${endpoint}" +check_id="$(jq -er '.id | select(type == "number")' "${tempdir}/response.json")" + +for ((offset = 0; offset < count; offset += 50)); do + jq --argjson offset "${offset}" --slurpfile report "${tempdir}/report.json" \ + '{output: (($report[0] | del(.text)) + {annotations: .[$offset:$offset + 50]})}' \ + "${tempdir}/annotations.json" \ + | api PATCH "${endpoint}/${check_id}" +done + +jq --arg conclusion "${conclusion}" \ + '{status: "completed", conclusion: $conclusion, output: .}' "${tempdir}/report.json" \ + | api PATCH "${endpoint}/${check_id}" diff --git a/test/assets/checks-clean.yml b/test/assets/checks-clean.yml new file mode 100644 index 0000000..2364b00 --- /dev/null +++ b/test/assets/checks-clean.yml @@ -0,0 +1,8 @@ +# Clean workflow for the Checks self-tests, including the missing-permission case. +on: issues +permissions: {} +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo safe diff --git a/test/assets/checks-findings.yml b/test/assets/checks-findings.yml new file mode 100644 index 0000000..f0912f7 --- /dev/null +++ b/test/assets/checks-findings.yml @@ -0,0 +1,59 @@ +# 51 intentional template-injection findings exercise the Checks API's +# 50-annotation batch limit. This workflow is only scanned, never executed. +on: issues +permissions: {} +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }} + - run: echo ${{ github.event.issue.title }}