Build GitHub Actions infrastructure for cross-repo Hats reviews and agentic task execution#3
Conversation
Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d30553c-4fdf-40c8-ac6c-392b98ed83ef Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
…nd README update Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d30553c-4fdf-40c8-ac6c-392b98ed83ef Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
…and documentation Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d30553c-4fdf-40c8-ac6c-392b98ed83ef Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
…tion in CLI, fix empty list edge case Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d30553c-4fdf-40c8-ac6c-392b98ed83ef Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
🎩 Hats Team Review Report✅ Verdict: ALLOW (Risk Score: 0/100)Severity Summary
Hat Execution Summary
Pipeline Stats
|
There was a problem hiding this comment.
Pull request overview
This PR makes Hat Stack operational by adding GitHub Actions workflows, runner scripts, configuration, and documentation so other repositories (and local agents via hat CLI) can dispatch reviews and agentic tasks executed against the configured Ollama model pool.
Changes:
- Adds Python orchestrators for review (
scripts/hats_runner.py) and task execution (scripts/hats_task_runner.py) plus the hat/model config (scripts/hat_configs.yml). - Adds GitHub Actions workflows for reusable reviews, dispatch-triggered reviews, task execution, and self-review.
- Adds a
hatCLI wrapper and fork/setup documentation/templates for secret management.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/requirements.txt | Adds Python dependencies for runners (PyYAML, requests). |
| scripts/hats_task_runner.py | Implements task-mode orchestration (hat selection, parallel supporting hats, output writing). |
| scripts/hats_runner.py | Implements review-mode orchestration (hat selection, gates, execution, consolidation, reporting). |
| scripts/hat_configs.yml | Defines hats, models, gates, execution settings, and risk scoring weights. |
| scripts/hat | Adds a gh api-based CLI for dispatching reviews/tasks to Hat Stack. |
| README.md | Documents GitHub Actions integration, task mode usage, and repo layout. |
| FORK_SETUP.md | Provides fork-friendly setup/integration guide and examples. |
| .gitignore | Adds secret/runtime-output ignore patterns. |
| .github/workflows/hats-task.yml | Adds repository/workflow dispatch handler for task execution + callback posting. |
| .github/workflows/hats-self-review.yml | Adds self-review workflow to run Hats on PRs to this repo. |
| .github/workflows/hats-review.yml | Adds reusable workflow for other repos to call for reviews. |
| .github/workflows/hats-dispatch.yml | Adds repository_dispatch handler for review requests + callback posting. |
| .github/actions/run-hats/action.yml | Adds composite action to run Hats review in any workflow. |
| .env.example | Adds environment template for local usage and optional settings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
.github/workflows/hats-task.yml
Outdated
| ARGS="--task ${{ steps.params.outputs.task }}" | ||
| ARGS="$ARGS --prompt '${{ steps.params.outputs.prompt }}'" | ||
| ARGS="$ARGS --config scripts/hat_configs.yml" | ||
| ARGS="$ARGS --output /tmp/hats-task-output" | ||
| ARGS="$ARGS --json-file /tmp/hats-task-result.json" | ||
|
|
||
| if [ -n "${{ steps.params.outputs.hats }}" ]; then | ||
| ARGS="$ARGS --hats ${{ steps.params.outputs.hats }}" | ||
| fi | ||
|
|
||
| if [ -d "/tmp/hats-context" ] && [ "$(ls -A /tmp/hats-context 2>/dev/null)" ]; then | ||
| ARGS="$ARGS --context-dir /tmp/hats-context" | ||
| fi | ||
|
|
||
| set +e | ||
| python scripts/hats_task_runner.py $ARGS |
There was a problem hiding this comment.
Building CLI args in a single string and then invoking python ... $ARGS is vulnerable to shell word-splitting and quote-breaking. steps.params.outputs.prompt comes from user-controlled dispatch payload and is only newline-sanitized, so a prompt containing a single quote or shell metacharacters can break the command or inject extra flags. Construct the command with a bash array (or pass values via env / a temp file) and quote each argument when invoking Python.
| ARGS="--task ${{ steps.params.outputs.task }}" | |
| ARGS="$ARGS --prompt '${{ steps.params.outputs.prompt }}'" | |
| ARGS="$ARGS --config scripts/hat_configs.yml" | |
| ARGS="$ARGS --output /tmp/hats-task-output" | |
| ARGS="$ARGS --json-file /tmp/hats-task-result.json" | |
| if [ -n "${{ steps.params.outputs.hats }}" ]; then | |
| ARGS="$ARGS --hats ${{ steps.params.outputs.hats }}" | |
| fi | |
| if [ -d "/tmp/hats-context" ] && [ "$(ls -A /tmp/hats-context 2>/dev/null)" ]; then | |
| ARGS="$ARGS --context-dir /tmp/hats-context" | |
| fi | |
| set +e | |
| python scripts/hats_task_runner.py $ARGS | |
| ARGS=( | |
| --task "${{ steps.params.outputs.task }}" | |
| --prompt "${{ steps.params.outputs.prompt }}" | |
| --config scripts/hat_configs.yml | |
| --output /tmp/hats-task-output | |
| --json-file /tmp/hats-task-result.json | |
| ) | |
| if [ -n "${{ steps.params.outputs.hats }}" ]; then | |
| ARGS+=(--hats "${{ steps.params.outputs.hats }}") | |
| fi | |
| if [ -d "/tmp/hats-context" ] && [ "$(ls -A /tmp/hats-context 2>/dev/null)" ]; then | |
| ARGS+=(--context-dir /tmp/hats-context) | |
| fi | |
| set +e | |
| python scripts/hats_task_runner.py "${ARGS[@]}" |
.github/workflows/hats-dispatch.yml
Outdated
| ARGS="--diff ${{ steps.diff.outputs.diff_file }}" | ||
| ARGS="$ARGS --config scripts/hat_configs.yml" | ||
| ARGS="$ARGS --output both" | ||
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | ||
| ARGS="$ARGS --json-file /tmp/hats-report.json" | ||
|
|
||
| if [ -n "${{ steps.payload.outputs.hats }}" ]; then | ||
| ARGS="$ARGS --hats ${{ steps.payload.outputs.hats }}" | ||
| fi | ||
|
|
||
| if [ -n "${{ steps.payload.outputs.context }}" ]; then | ||
| ARGS="$ARGS --context '${{ steps.payload.outputs.context }}'" | ||
| fi | ||
|
|
||
| set +e | ||
| python scripts/hats_runner.py $ARGS |
There was a problem hiding this comment.
context is interpolated into a single-quoted string inside an ARGS accumulator and then executed via python ... $ARGS. Since context originates from repository_dispatch payload and is only newline-sanitized, a single quote (or other shell metacharacters) can break quoting and potentially inject additional CLI flags. Prefer invoking Python with properly quoted args (bash array) instead of building a string.
| ARGS="--diff ${{ steps.diff.outputs.diff_file }}" | |
| ARGS="$ARGS --config scripts/hat_configs.yml" | |
| ARGS="$ARGS --output both" | |
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | |
| ARGS="$ARGS --json-file /tmp/hats-report.json" | |
| if [ -n "${{ steps.payload.outputs.hats }}" ]; then | |
| ARGS="$ARGS --hats ${{ steps.payload.outputs.hats }}" | |
| fi | |
| if [ -n "${{ steps.payload.outputs.context }}" ]; then | |
| ARGS="$ARGS --context '${{ steps.payload.outputs.context }}'" | |
| fi | |
| set +e | |
| python scripts/hats_runner.py $ARGS | |
| ARGS=( | |
| --diff "${{ steps.diff.outputs.diff_file }}" | |
| --config scripts/hat_configs.yml | |
| --output both | |
| --markdown-file /tmp/hats-report.md | |
| --json-file /tmp/hats-report.json | |
| ) | |
| if [ -n "${{ steps.payload.outputs.hats }}" ]; then | |
| ARGS+=( --hats "${{ steps.payload.outputs.hats }}" ) | |
| fi | |
| if [ -n "${{ steps.payload.outputs.context }}" ]; then | |
| ARGS+=( --context "${{ steps.payload.outputs.context }}" ) | |
| fi | |
| set +e | |
| python scripts/hats_runner.py "${ARGS[@]}" |
.github/workflows/hats-review.yml
Outdated
| ARGS="--diff ${{ steps.diff.outputs.diff_file }}" | ||
| ARGS="$ARGS --config ${{ steps.config.outputs.config_path }}" | ||
| ARGS="$ARGS --output ${{ inputs.output_format }}" | ||
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | ||
| ARGS="$ARGS --json-file /tmp/hats-report.json" | ||
|
|
||
| if [ -n "${{ inputs.hats }}" ]; then | ||
| ARGS="$ARGS --hats ${{ inputs.hats }}" | ||
| fi | ||
|
|
||
| if [ -n "${{ inputs.context }}" ]; then | ||
| ARGS="$ARGS --context '${{ inputs.context }}'" | ||
| fi | ||
|
|
||
| # Run the pipeline — capture exit code but don't fail yet | ||
| set +e | ||
| python hat_stack/scripts/hats_runner.py $ARGS |
There was a problem hiding this comment.
inputs.context is embedded in a single-quoted segment of an ARGS string and then executed as python ... $ARGS. If the context contains a single quote, it will break the shell quoting and can change the invoked arguments. Use a bash array and pass --context as a separate, properly quoted argument (or write context to a file and have the runner read it).
| ARGS="--diff ${{ steps.diff.outputs.diff_file }}" | |
| ARGS="$ARGS --config ${{ steps.config.outputs.config_path }}" | |
| ARGS="$ARGS --output ${{ inputs.output_format }}" | |
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | |
| ARGS="$ARGS --json-file /tmp/hats-report.json" | |
| if [ -n "${{ inputs.hats }}" ]; then | |
| ARGS="$ARGS --hats ${{ inputs.hats }}" | |
| fi | |
| if [ -n "${{ inputs.context }}" ]; then | |
| ARGS="$ARGS --context '${{ inputs.context }}'" | |
| fi | |
| # Run the pipeline — capture exit code but don't fail yet | |
| set +e | |
| python hat_stack/scripts/hats_runner.py $ARGS | |
| ARGS=( | |
| --diff "${{ steps.diff.outputs.diff_file }}" | |
| --config "${{ steps.config.outputs.config_path }}" | |
| --output "${{ inputs.output_format }}" | |
| --markdown-file /tmp/hats-report.md | |
| --json-file /tmp/hats-report.json | |
| ) | |
| if [ -n "${{ inputs.hats }}" ]; then | |
| ARGS+=(--hats "${{ inputs.hats }}") | |
| fi | |
| if [ -n "${{ inputs.context }}" ]; then | |
| ARGS+=(--context "${{ inputs.context }}") | |
| fi | |
| # Run the pipeline — capture exit code but don't fail yet | |
| set +e | |
| python hat_stack/scripts/hats_runner.py "${ARGS[@]}" |
.github/actions/run-hats/action.yml
Outdated
| ARGS="--diff ${{ steps.prep.outputs.diff_file }}" | ||
| ARGS="$ARGS --config $CONFIG" | ||
| ARGS="$ARGS --output ${{ inputs.output_format }}" | ||
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | ||
| ARGS="$ARGS --json-file /tmp/hats-report.json" | ||
|
|
||
| if [ -n "${{ inputs.hats }}" ]; then | ||
| ARGS="$ARGS --hats ${{ inputs.hats }}" | ||
| fi | ||
|
|
||
| if [ -n "${{ inputs.context }}" ]; then | ||
| ARGS="$ARGS --context '${{ inputs.context }}'" | ||
| fi | ||
|
|
||
| set +e | ||
| python _hat_stack/scripts/hats_runner.py $ARGS |
There was a problem hiding this comment.
The composite action builds a command line by concatenating to ARGS and then running python ... $ARGS. inputs.context can contain single quotes and other characters that break shell quoting, allowing argument injection. Use a bash array (e.g., args=(--diff ... --context "$INPUT")) and call Python with "${args[@]}".
| ARGS="--diff ${{ steps.prep.outputs.diff_file }}" | |
| ARGS="$ARGS --config $CONFIG" | |
| ARGS="$ARGS --output ${{ inputs.output_format }}" | |
| ARGS="$ARGS --markdown-file /tmp/hats-report.md" | |
| ARGS="$ARGS --json-file /tmp/hats-report.json" | |
| if [ -n "${{ inputs.hats }}" ]; then | |
| ARGS="$ARGS --hats ${{ inputs.hats }}" | |
| fi | |
| if [ -n "${{ inputs.context }}" ]; then | |
| ARGS="$ARGS --context '${{ inputs.context }}'" | |
| fi | |
| set +e | |
| python _hat_stack/scripts/hats_runner.py $ARGS | |
| args=( | |
| --diff "${{ steps.prep.outputs.diff_file }}" | |
| --config "$CONFIG" | |
| --output "${{ inputs.output_format }}" | |
| --markdown-file /tmp/hats-report.md | |
| --json-file /tmp/hats-report.json | |
| ) | |
| if [ -n "${{ inputs.hats }}" ]; then | |
| args+=(--hats "${{ inputs.hats }}") | |
| fi | |
| if [ -n "${{ inputs.context }}" ]; then | |
| args+=(--context "${{ inputs.context }}") | |
| fi | |
| set +e | |
| python _hat_stack/scripts/hats_runner.py "${args[@]}" |
| def select_model_for_task(config: dict, hat_id: str, task_type: str) -> str: | ||
| """Select the best model for a task, using task tier as a guide.""" | ||
| hats_cfg = config["hats"] | ||
| hat_def = hats_cfg.get(hat_id, {}) | ||
| profile = TASK_PROFILES.get(task_type, {}) | ||
|
|
||
| # For Tier 1 tasks, always use the primary (best) model | ||
| if profile.get("model_tier", 2) == 1: | ||
| return hat_def.get("primary_model", "glm-5.1") | ||
|
|
||
| return hat_def.get("primary_model", "glm-5.1") | ||
|
|
There was a problem hiding this comment.
select_model_for_task() does not actually vary the selected model by model_tier—both Tier 1 and non–Tier 1 paths return primary_model. This contradicts the intent implied by the docstring and the task profiles (and the PR description’s claim of tier-based model selection). Either implement tier-aware selection (e.g., map tier→model in config) or remove the unused tier logic to avoid misleading behavior.
.github/workflows/hats-review.yml
Outdated
| - name: Determine config | ||
| id: config | ||
| shell: bash | ||
| run: | | ||
| if [ -n "${{ inputs.config_override }}" ] && [ -f "${{ inputs.config_override }}" ]; then | ||
| echo "config_path=${{ inputs.config_override }}" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "config_path=hat_stack/scripts/hat_configs.yml" >> "$GITHUB_OUTPUT" | ||
| fi |
There was a problem hiding this comment.
inputs.config_override is described as a path in the calling repo, but this workflow only checks out hat_stack and never checks out the caller repo (nor downloads artifacts). As a result the -f check will be false and the override can’t be used. If override support is needed, add a mechanism to supply the config (inline string, artifact download, or a second checkout of the caller repo).
| hats-review: | ||
| needs: get-diff | ||
| # Point this to YOUR fork: | ||
| uses: YOUR_USERNAME/hat_stack/.github/workflows/hats-review.yml@main | ||
| secrets: | ||
| ollama_api_key: ${{ secrets.OLLAMA_API_KEY }} | ||
| ``` |
There was a problem hiding this comment.
The reusable-workflow example never passes the generated diff into the called workflow (there’s no with: diff or with: diff_file). Also, even if diff_file were passed, a workflow_call job won’t see the caller job’s filesystem path; you’d need to pass the diff content directly or download the pr-diff artifact inside the called workflow. As written, this Quick Start snippet won’t actually run a review.
| **Option A — Reusable Workflow** (recommended): | ||
| ```yaml | ||
| # In your other repo: .github/workflows/hats.yml | ||
| jobs: | ||
| hats-review: | ||
| uses: YOUR_USERNAME/hat_stack/.github/workflows/hats-review.yml@main | ||
| secrets: | ||
| ollama_api_key: ${{ secrets.OLLAMA_API_KEY }} | ||
| ``` |
There was a problem hiding this comment.
The reusable-workflow usage snippet is missing the required with: inputs (diff or diff_file). As shown, the called workflow will exit early with “No diff provided”. Update the example to demonstrate passing a diff (and, if using diff_file, also explain how the called workflow can access it—typically via artifacts rather than a raw filesystem path).
.github/workflows/hats-task.yml
Outdated
| # Prompt may contain special chars — sanitize newlines | ||
| python3 -c " | ||
| import os, re | ||
| val = '''${{ inputs.prompt }}''' | ||
| sanitized = re.sub(r'[\r\n]', ' ', val).strip() | ||
| with open(os.environ['GITHUB_OUTPUT'], 'a') as f: | ||
| f.write(f'prompt={sanitized}\n') | ||
| " |
There was a problem hiding this comment.
In the workflow_dispatch path, inputs.prompt is interpolated directly into a python3 -c snippet inside a triple-quoted string (val = '''${{ inputs.prompt }}'''). A prompt containing ''' (or other crafted content) can break out of the string and inject arbitrary Python code. Avoid embedding untrusted inputs into inline code; pass the prompt via stdin/env and have Python read it, or write it to $GITHUB_OUTPUT using a heredoc delimiter (prompt<<EOF).
| shell: bash | ||
| run: | | ||
| set +e | ||
| python scripts/hats_runner.py \ | ||
| --diff /tmp/pr-diff.patch \ | ||
| --config scripts/hat_configs.yml \ | ||
| --output both \ | ||
| --markdown-file /tmp/hats-report.md \ | ||
| --json-file /tmp/hats-report.json \ | ||
| --context "Self-review of hat_stack PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" |
There was a problem hiding this comment.
github.event.pull_request.title is interpolated into a double-quoted --context argument without sanitization. A PR title containing ", backticks, $(), newlines, etc. can break shell quoting and potentially inject additional shell tokens/commands. Sanitize the title (e.g., strip newlines/quotes) and pass it as a separate, properly quoted argument (bash array), or write the context to a file and have the runner read it.
| shell: bash | |
| run: | | |
| set +e | |
| python scripts/hats_runner.py \ | |
| --diff /tmp/pr-diff.patch \ | |
| --config scripts/hat_configs.yml \ | |
| --output both \ | |
| --markdown-file /tmp/hats-report.md \ | |
| --json-file /tmp/hats-report.json \ | |
| --context "Self-review of hat_stack PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}" | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| PR_TITLE: ${{ github.event.pull_request.title }} | |
| shell: bash | |
| run: | | |
| set +e | |
| SANITIZED_PR_TITLE=$(printf '%s' "$PR_TITLE" | tr '\r\n' ' ' | tr '"' "'") | |
| CONTEXT="Self-review of hat_stack PR #$PR_NUMBER: $SANITIZED_PR_TITLE" | |
| cmd=( | |
| python scripts/hats_runner.py | |
| --diff /tmp/pr-diff.patch | |
| --config scripts/hat_configs.yml | |
| --output both | |
| --markdown-file /tmp/hats-report.md | |
| --json-file /tmp/hats-report.json | |
| --context "$CONTEXT" | |
| ) | |
| "${cmd[@]}" |
|
@copilot apply changes based on the comments in this thread if you can invoke and use the hats in your work, do so. |
…ll word-splitting, fork-friendliness, tier-aware models, docs
- Use bash arrays for all CLI arg construction in workflows
- Pass prompt via env var instead of triple-quoted Python literal
- Sanitize PR title in self-review via env var and tr
- Make hats-review.yml fork-friendly with ${{ github.repository }}
- Make composite action use $GITHUB_ACTION_PATH instead of hardcoded repo
- Implement auto-detect in hat CLI from git remote
- Implement tier-aware model selection in task runner
- Fix consolidate_reports docstring
- Switch hats-review.yml to artifact-based diff flow
- Remove broken config_override input
- Remove undocumented HATS_TIER*_MODEL env vars from docs
- Fix README and FORK_SETUP examples to pass diff correctly
Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d46a63d-1e08-41a4-8cd7-c5fb2d479564
Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
…t HAT_STACK_ROOT var, null-safe find Agent-Logs-Url: https://github.com/Grumpified-OGGVCT/hat_stack/sessions/0d46a63d-1e08-41a4-8cd7-c5fb2d479564 Co-authored-by: AccidentalJedi <92951150+AccidentalJedi@users.noreply.github.com>
All 16 review comments addressed in commits Security (7 fixes): All workflow CLI args now use bash arrays instead of string concatenation. Prompt passed via Fork-friendliness (4 fixes): Correctness (3 fixes): Docs (2 fixes): Removed undocumented |
|
@copilot apply changes based on the comments in this thread use the hat stack if it's helpful |
All 16 review comments from that thread were already applied in commits |
Hat Stack was spec-only. This makes it operational — runnable in GitHub Actions as a service other repos can hook into, with a CLI for local agents (e.g., Copilot in VS Code) to dispatch both reviews and generative tasks against the Ollama Cloud model pool.
Review mode — 18-hat PR analysis pipeline
hats-review.yml— reusableworkflow_callother repos reference directly; accepts diffs via inline content or artifact-based flow (diff_artifactinput) for cross-workflow compatibilityhats-dispatch.yml—repository_dispatchhandler for API-triggered reviews with callback to source PR/issuehats-self-review.yml— self-review on PRs to this repo.github/actions/run-hats/action.yml— composite action for inline use in any workflow, uses$GITHUB_ACTION_PATHso forks run their own codescripts/hats_runner.py— Conductor implementation: hat selection by diff triggers, tiered-parallel execution, cost budget gate (G1), security fast-path gate (G2), consolidation, Gold Hat/CoVE adjudication, risk score computation per CATALOG.md formulaTask mode — agentic work execution
hats-task.yml—repository_dispatch+workflow_dispatchfor generative tasksscripts/hats_task_runner.py— 6 task types (generate_code,generate_docs,refactor,analyze,plan,test), each mapped to optimal hat with tier-aware model selection (Tier 1 tasks get Tier 1 models like glm-5.1, others use hat-assigned primaries). Primary hat generates, supporting hats review/enhance, Gold Hat does final QA.scripts/hat— Bash CLI wrappinggh apidispatches with auto-detection of hat_stack repo from git remote. Local agent runshat task generate_code "Build auth module" --repo org/app --pr 42, results post back as PR comment.Fork-friendly design
${{ github.repository }}for checkout, composite action uses$GITHUB_ACTION_PATH, CLI auto-detects from git remote${{ secrets.* }}/os.environ.env.exampletemplate,FORK_SETUP.md(fork → add one secret → done).gitignorehardened for credential files, runtime outputsConfiguration
scripts/hat_configs.yml— all 18 hats with model assignments per Implementation Guide §E2.2, personas, triggers, gate config, risk score weights. 8 models across 4 tiers (glm-5.1, kimi-k2.5, deepseek-v3.1, minimax-m2.7, nemotron-3-super, qwen3-coder, nemotron-3-nano, ministral-3).Security hardening
"${ARGS[@]}") instead of string concatenation for CLI arg constructionhats-self-review.ymlsanitizes PR title via env var andtrbefore use in shell commandsrepository_dispatchpayload values written toGITHUB_OUTPUTfind -print0pattern for safe filename handling in artifact downloadshatCLI — variables passed viaos.environinstead of string interpolation