Dentolize · Composable Sandbox QA Walkthrough
On this pageBusiness viewTechnical view

Artifacts & outputs

The evidence trail — and the bug this PR fixes in it.


Business view

What gets uploaded

Every QA run attaches a downloadable archive to the workflow run: the agent's full log, the review and plan in their untruncated form, the raw stories.json, and whatever else the agent left in its state directory. This is where you go when a comment says "see agent.log in the artifacts" — which is exactly what the workflow tells you when the agent produced nothing.

Archives are kept for 14 days.

The bug that was fixed

GitHub does not allow / in artifact names. Dentolize branch names routinely contain one — ci/qa-composable, the very branch of this PR, does.

Before this change, the artifact was named after the raw branch name. On any branch with a slash, the upload step failed, and the evidence for that run was lost. The workflow run still went green, because the upload is the last step and failures there are easy to miss.

The fix: the agent script now emits a sanitized slug, and the artifact is named from that instead.

This is a small change with a disproportionate effect, because the branches most likely to contain a slash — feature branches — are exactly the branches you run QA on.


Technical view

The upload step, before and after

Before:

- name: Upload run artifacts
  if: always() && steps.qa.outputs.QA_RUN_DIR
  uses: actions/upload-artifact@v4
  with:
    name: sandbox-qa-${{ github.ref_name }}-${{ github.run_id }}
    path: ${{ steps.qa.outputs.QA_RUN_DIR }}

After:

- name: Upload run artifacts
  if: always() && steps.qa.outputs.QA_STATE_DIR
  uses: actions/upload-artifact@v4
  with:
    # Branch names can contain '/', which is illegal in artifact names —
    # use the sanitized slug the QA script emits.
    name: sandbox-qa-${{ steps.qa.outputs.QA_SLUG }}-${{ github.run_id }}
    path: ${{ steps.qa.outputs.QA_STATE_DIR }}
    if-no-files-found: warn
    retention-days: 14

.github/workflows/sandbox-qa.yml:241-250

Three changes in four lines:

  1. github.ref_namesteps.qa.outputs.QA_SLUG. The name now comes from

the agent script rather than from the git ref. The sanitization happens host-side; this repository does not contain the sanitizing code, so the shape of the slug (lowercased? /-? truncated?) is not verifiable from the diff. The sandbox for this branch is served at ci-qa-composable.sandbox.anastawfik.com, so /- is the evident rule.

  1. QA_RUN_DIRQA_STATE_DIR. Per-run directory becomes per-branch state

directory. This is the storage half of the composability feature: plan can only build on a review from an earlier job because that review persisted in the branch's state directory.

  1. Both the guard and the path moved to the new output, together — so

there's no window where the condition and the path disagree.

if-no-files-found: warn and retention-days: 14 are unchanged.

The guard is also the failure mode

if: always() && steps.qa.outputs.QA_STATE_DIR

always() means the upload runs even if the agent step failed — which is the point, since a failed run is when you most want the log.

But the second half of the condition undercuts it. QA_STATE_DIR is a workflow output, set by the agent script writing to $GITHUB_OUTPUT at .github/workflows/sandbox-qa.yml:93. If the script dies before printing that line — a crash on startup, a missing container, a bad argument, an OOM — the output is empty, the condition is false, and no artifact is uploaded at all.

That is the precise scenario in which the publish step's advice —

core.setFailed(`agent produced no ${action} output — see agent.log in the artifacts`);

.github/workflows/sandbox-qa.yml:128

— points at an artifact that does not exist. The same message appears for the summary at :227. This behavior is inherited, not introduced by this PR (the old code guarded on QA_RUN_DIR identically), but the PR does not fix it.

A more robust shape would emit QA_STATE_DIR early, or fall back to a known path. Worth raising if the team hits a run they cannot diagnose.

The full output contract

The workflow consumes seven outputs from the qa step:

OutputLineTypeConsumed for
QA_REVIEW:126file pathBody of the 🔍 review comment
QA_PLAN:126file pathBody of the 🧪 plan comment
QA_STORIES:153file pathstories.json → issues + tally
QA_ISSUES:210file pathExtra [QA] findings
QA_SUMMARY:226file pathProse block in the summary comment
QA_STATE_DIR:242, :248dir pathArtifact upload guard + path
QA_SLUG:247stringArtifact name

Every file-path output is checked with fs.existsSync before use, so a stale or absent path degrades rather than throws — except in the review/plan branch, where it is a deliberate setFailed (:128).

Note that these paths are read by the publish step, which runs in the runner's own filesystem context. The paths the agent emits must therefore be visible to the runner, not only inside the agent's container.

The $GITHUB_OUTPUT channel and stdout discipline

/opt/homelab/sandbox/bin/sandbox-qa dentolize "$GITHUB_REF_NAME" "${{ inputs.action }}" >> "$GITHUB_OUTPUT"

Redirecting the command's stdout straight into $GITHUB_OUTPUT puts a hard constraint on the script: stdout is a machine channel. Every line must be a valid KEY=VALUE (or a heredoc-delimited multiline block). Anything else is either discarded or, if it contains =, silently becomes a bogus output.

The PR advertises "live agent activity streaming in the job log". Given this redirect, that streaming necessarily goes to stderr, which the runner captures into the job log while leaving stdout clean. Nothing in the diff implements it — it is entirely host-side — but the redirect at :93 is the reason the design has to work that way.

set -e on :92 means a non-zero exit from the script fails the step, at which point always() carries the upload step forward (if the outputs made it out).

Interaction with RUNNER_TEMP

The fetch step writes to $RUNNER_TEMP/qa-review.md and $RUNNER_TEMP/qa-plan.md (.github/workflows/sandbox-qa.yml:76-82), and the agent step always exports both paths as QA_REVIEW_FILE / QA_PLAN_FILE (:89-90) — whether or not the fetch step created them, and whether or not the fetch step even ran (it is skipped for review, fix, and full-auto, per :61).

So the agent always receives two path variables, and must handle the case where the file at that path does not exist. On a self-hosted runner this is worth attention: RUNNER_TEMP is cleaned between jobs by the runner agent, but the assumption is load-bearing here — a leftover qa-plan.md from a previous branch's job would be silently adopted as the plan.