Workflow Internals
This page walks the actual file — .github/workflows/sandbox-qa.yml, the only file this PR changes — top to bottom. If you're reviewing the diff, read this alongside What Changed.
Business view
The workflow file is small and does three jobs:
- Run one QA phase. It calls one command on the homelab and hands it the branch name and the phase you picked.
- Update the PR comment. It writes (or rewrites) a single comment on the pull request: a header, a timestamp, links to the Jira epic and Confluence page, a one-line "what to do next," and whatever summary the agent produced.
- Save the evidence. It uploads the agent's working directory as a downloadable artifact so nothing is lost if you want to inspect a run later.
Everything clever — the reviewing, the testing, the Jira and Confluence writing — happens outside this file, on the homelab. The file's own job is plumbing: dispatch, comment, archive.
Technical view
1. Trigger and inputs
on:
workflow_dispatch:
inputs:
action:
description: QA phase to run
type: choice
required: true
default: prepare
options: [prepare, test, retest, fix]
.github/workflows/sandbox-qa.yml:30-42. Manual only — workflow_dispatch. You go to the Actions tab, pick "Sandbox QA," pick the branch you want QA'd, and pick a phase. There is no push/pull_request trigger, so nothing here runs automatically.
2. Concurrency, permissions, runner
concurrency:
group: sandbox-qa-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: write # fix action pushes to the PR branch
pull-requests: write
issues: write
.github/workflows/sandbox-qa.yml:44-51.
- One phase per branch, never cancelled mid-run (
:44-46). contents: writeexists only forfix.prepare/test/retestnever need it — a point worth remembering when reasoning about least privilege (see For Quality).issues: writelooks surprising now that "GitHub story-issues are retired" (PR body). It's still needed because a PR comment is an issue comment in GitHub's API — the summary-comment step below callsgithub.rest.issues.listComments/createComment/updateComment(:104-114).- Runner:
runs-on: [self-hosted, homelab, dentolize](:55),timeout-minutes: 300(:56).
3. The agent step
- name: Run QA agent (${{ inputs.action }})
id: qa
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -e
/opt/homelab/sandbox/bin/sandbox-qa dentolize "$GITHUB_REF_NAME" "${{ inputs.action }}" >> "$GITHUB_OUTPUT"
.github/workflows/sandbox-qa.yml:58-64.
- One command.
set -emeans a non-zero exit fails the job (and the header,:22-24, calls out that "job success means results are published" — the run is foreground, no detached agent). - The binary appends
KEY=VALUElines to$GITHUB_OUTPUT. Those becomesteps.qa.outputs.*for the later steps. This is the entire contract between the homelab agent and this workflow. - Only
GITHUB_TOKENis passed in. The old workflow also passedQA_SCOPE,QA_REVIEW_FILE,QA_PLAN_FILE(all removed).
4. The PR summary comment
- name: Update PR summary comment
if: always() && steps.qa.outputs.QA_STATE_DIR
uses: actions/github-script@v7
.github/workflows/sandbox-qa.yml:66-114.
Guard: always() && steps.qa.outputs.QA_STATE_DIR (:67) — run even if the agent step "failed," but only if the agent got far enough to emit a state dir.
The step:
- Reads
JIRA_EPIC_URL,CONFLUENCE_URL, and theQA_SUMMARYfile (:76-80). - Finds the open PR for this branch by head ref (
:82-85); if there's none, it logs "no open PR — nothing to comment" and returns (:86). So dispatching QA on a branch with no open PR does the QA work but posts nothing. - Builds the comment body (
:88-102) — a hidden marker<!-- sandbox-qa-report -->, an### 🧪 Quality Reviewheading, a timestamp + phase + run link, the two Atlassian links (each only if present,:95-96), the phase-specific call-to-action (:98-100), and the agent's summary text. - Upserts by marker: find an existing comment containing the marker, update it if present, otherwise create it (
:104-114). So the PR accumulates exactly one QA comment that gets rewritten each phase.
Full comment builder:
const marker = "<!-- sandbox-qa-report -->";
const lines = [
marker,
"### 🧪 Quality Review",
"",
`_${now} · phase **${action}** · [run](${runUrl})_`,
"",
...(epicUrl ? [`**Jira epic:** ${epicUrl} _(the QA workspace — edit stories there)_`] : []),
...(pageUrl ? [`**Confluence:** ${pageUrl} _(review + living test report)_`] : []),
"",
action === "prepare"
? "Stories are ready for the QA team's review in Jira. Delete, refine or add stories on the epic, then dispatch test."
: "Results are on the Jira tickets (comments, screenshots, statuses). Open items = tickets not in Done.",
"", summary,
];
.github/workflows/sandbox-qa.yml:88-102.
Honesty note — this is the whole Atlassian surface in the diff. The only things this workflow does with Jira and Confluence are: printJIRA_EPIC_URLandCONFLUENCE_URLas links. It never calls a Jira or Confluence API. Every Story, transition, screenshot attachment, results table, and Bug ticket described in the PR is produced by the off-repoqa-agent/atlassian-qa.py. When reviewing this diff, you are reviewing a dispatch-and-comment wrapper, not the Atlassian integration itself.
5. Artifact upload
- name: Upload run artifacts
if: always() && steps.qa.outputs.QA_STATE_DIR
uses: actions/upload-artifact@v4
with:
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:116-123. The agent's state directory is archived for 14 days. QA_SLUG is used because raw branch names can contain /, which is illegal in an artifact name.
The output contract, collected
The sandbox-qa binary is expected to emit some or all of:
| Key | Purpose |
|---|---|
QA_STATE_DIR | Gates both post-steps; also the artifact path. |
QA_SLUG | Filesystem-safe branch slug for the artifact name. |
JIRA_EPIC_URL | Optional link in the comment. |
CONFLUENCE_URL | Optional link in the comment. |
QA_SUMMARY | Path to a file whose contents go in the comment. |
Every consumer is null-guarded, so a phase that emits only QA_STATE_DIR still produces a valid (if link-less) comment.
See also
- The Four Phases — what each phase means.
- What Changed (Migration) — the ~189 deleted lines this replaced.
- Glossary & Data Model — the JSON/output contracts.