SDD — QA Build from a Jira Issue
For how to use the feature, see QA Build from a Jira Issue. This page describes the final design. Where early drafts differ, the code and this page win.
Components
Jira issue
UI module (issueAction "QA build", issueContext "QA build")
│ invoke('<key>', payload)
▼
Forge endpoint cicd-invoke (auth.appUserToken.enabled: true)
│ POST /api/forge/invoke
│ Authorization: Forge Invocation Token (FIT)
│ x-forge-oauth-user: the caller's own Jira token
▼
CI/CD Manager routes/forge.py (before_request gate, then a dispatch table)
├── services/forge_auth.py verify the FIT
├── services/forge_jira.py mypermissions with the caller's token
├── services/qa_target_resolution.py plan and warnings
├── services/qa_target_confirmations.py developer confirmations
├── services/qa_job_store.py jobs, groups, holds
├── services/qa_job_runner.py ticker: run status, triage, abandonment
└── routes/workflow/qa.py, qa_group.py start, submit, retry
| File | Role |
|---|---|
|
Two UI modules, one endpoint, one remote. Scopes: |
|
Every state the backend reports and its wording. The UI branches on enums and never on message text. |
|
The only door. Seven keys: |
|
FIT check (RS256, audience, issuer, expiry, pinned |
|
The caller’s own permissions on the issue. There is no service account fallback. |
|
Stream narrowing, discovery, the plan record and its warnings. |
|
|
|
|
|
Group Submit, the one e-mail, the Jira comments, and the retry door. |
Decisions that shape the behavior
The endpoint carries the auth
remotes[].auth applies only to calls from a Forge function. These modules call from the frontend.
So auth.appUserToken is on the endpoint entry. Moving it to remotes sends no user token,
and the server then refuses every call. tests/manifest.test.js pins this.
Two proofs, in this order
-
The FIT proves the call came from Atlassian, for this app, from the configured site.
-
mypermissionswith the caller’s own token proves the caller may act on the named issue.
Each payload names exactly one issueKey. A list of keys would let one proved key carry the others.
Fields that the server derives are removed from the payload (PINNED_FIELDS). Eligibility and the build
number are always derived on the server.
Permissions: BROWSE_PROJECTS for read and confirm. BROWSE_PROJECTS and ADD_COMMENTS for build,
submit and retry. EDIT_ISSUES is in no set, because nothing writes a Jira field.
Authorization is allow-all
Any authenticated user who passes both proofs may build. The switch jira.qaTriggerAllowAnyUser and the list
jira.qaTriggerAccountIds narrow this without a deploy. They gate build, submit and retry only. Reads stay open.
The decision and its accepted consequence are in docs/decisions/LOG.md. The verified accountId is
stored on the group and on each QA history row.
Errors have words
Every refusal has errorKind, a sentence and nextAction. The server uses 401 only when the Forge token failed,
because Forge replaces the body of a 401. Other refusals use other statuses.
The plan always resolves
eligible is a field of the plan, not a gate. The panel is readable in any status. startBuild derives
eligibility again. The plan is cached for about 60 seconds per issue, after the gate, and is evicted on
start, submit, retry and confirm.
Branch source order
-
developer-confirmed targets
-
GitHub: head of an open pull request, base of a merged pull request, branch name
-
Fixed Build, only as a hint
The branch-name scan runs only when the stream narrowed the candidates, and for at most 6 repositories.
The Forge remote call times out after 25 seconds with no retry, and each gh call costs about 0.6 seconds.
Then the plan has the state branch-not-found instead of a timeout.
Groups, jobs and holds
-
A job is one attempt for one repository and branch. A job belongs to exactly one group.
-
A group is one Submit, one e-mail and one set of comments.
-
The group phase is a pure function of its members and markers. It is derived on read and never stored.
-
A hold is the lease on a repository and branch. Its kind is
qaorrelease-build. -
The hold check and create is one critical section. Two issues cannot both start the same branch.
-
A hold outlives the build. The QA baseline tag moves only at Submit.
-
The release build takes its hold at the top of
submit_release_build, before any commit, and releases it on every failure path. The server ticker releases it when the run ends, so a closed browser tab cannot strand it.
Retry
A retry appends a new job to the member. Nothing is rewritten, so built is never undone. The retry goes
through the same hold and critical section as a start. The retry is allowed when:
-
the group has no
submittedAt -
the current job is
build-failed, and the failure is notunknown-* -
the number of retries is below
qa.maxRetries -
no retry of this job is already claimed
Abandonment
The ticker marks a group abandoned when qa.jobHoldTtlHours pass after the last terminal transition.
A group with a non-terminal member is never abandoned.
Submit order
-
submittedAtfirst, before any effect. -
The ticket permission filter, with the caller’s token. The result is stored, because a resumed Submit has no user token.
-
Per member behind a compare-and-set: Harbor tag, workflow dispatch, audit row.
-
The outcome latch, written once. The e-mail and the comments render the same latch.
-
The e-mail, with
emailSentAtwritten before the send. -
The comments, one per allowed ticket, best effort.
Every step has its own marker, so a second Submit resumes and does not announce twice. A stale claim is never claimed again, because the tag is idempotent and the dispatch is not.
Failure triage
The class is infra-transient, code, config or unrecognised. It comes from the name of the failing step,
with a default map and a per-project override. build and push can both match a step name such as
Build and push image, so the map has an explicit precedence. The log tail is captured with gh run view
--log-failed, bounded in lines and bytes, and redacted for known secret shapes. The tail goes only to the
Jira panel and the web modal. The Jira comment carries the run link and not the tail.
Data
| File | Content |
|---|---|
|
|
|
|
|
Each project has |
Limits
| Bucket | Limit |
|---|---|
Unverified calls, global |
120 per 60 seconds |
Reads, per account |
120 per 60 seconds |
Writes, per account |
20 per 60 seconds |
Live QA builds |
|
The request body is limited to 64 KiB.
Tests
-
ft-tools/jira-app:npx vitest run. -
ft-tools/cicd-manager/backend:./.venv/bin/pytest -q. -
kuber/tests/cicd-manager-tls.shchecks the ingress TLS.
Open items
-
The Forge request envelope of a remote resolver is not published.
_unpack_invocationaccepts three shapes and is the one place to tighten when a real call is captured. -
No real FIT has been captured. The fixture
fixtures/fit-doc-example.jsonis a documentation example. Whetherjti,issandexpare mandatory stays open. -
The Jira app has never run in a browser, because no deployment exists.
-
data/settings.jsonis tracked in git with live credentials, andGET /api/settingsreturns the GitHub, Jira and ArgoCD tokens in clear to any signed-in user. This is a recorded and deferred risk. Seedocs/decisions/LOG.md. The ingress is now public, so the risk is higher than before.