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

ft-tools/jira-app/manifest.yml

Two UI modules, one endpoint, one remote. Scopes: read:jira-work and read:app-user-token.

ft-tools/jira-app/src/lib/enums.js

Every state the backend reports and its wording. The UI branches on enums and never on message text.

routes/forge.py

The only door. Seven keys: buildPlan, targetInfo, startBuild, jobsForIssue, confirmTargets, submitGroup, retryJob.

services/forge_auth.py

FIT check (RS256, audience, issuer, expiry, pinned cloudId and installation ID) and the trigger allow list.

services/forge_jira.py

The caller’s own permissions on the issue. There is no service account fallback.

services/qa_target_resolution.py

Stream narrowing, discovery, the plan record and its warnings.

services/qa_job_store.py

data/qa_jobs.json: jobs, groups and the hold registry.

services/qa_target_confirmations.py

data/qa_target_confirmations.json: who confirmed which targets.

routes/workflow/qa_group.py

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

  1. The FIT proves the call came from Atlassian, for this app, from the configured site.

  2. mypermissions with 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

  1. developer-confirmed targets

  2. GitHub: head of an open pull request, base of a merged pull request, branch name

  3. 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 qa or release-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 not unknown-*

  • 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

  1. submittedAt first, before any effect.

  2. The ticket permission filter, with the caller’s token. The result is stored, because a resumed Submit has no user token.

  3. Per member behind a compare-and-set: Harbor tag, workflow dispatch, audit row.

  4. The outcome latch, written once. The e-mail and the comments render the same latch.

  5. The e-mail, with emailSentAt written before the send.

  6. 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.

Fixed Build

Nothing writes the Jira field. The web wizard no longer has Fix Selected. fixed-build-disagrees remains as a low-priority warning.

Data

File Content

data/qa_jobs.json

jobs, groups, holds. Kept 10 days.

data/qa_target_confirmations.json

{issueKey, confirmedBy, confirmedAt, targets[{projectId, branch}], source}, where source is found or specified.

data/projects.json

Each project has jiraStreams. It is the single source of the stream mapping.

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

qa.maxLiveJobs, default 10

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.sh checks the ingress TLS.

Open items

  • The Forge request envelope of a remote resolver is not published. _unpack_invocation accepts 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.json is a documentation example. Whether jti, iss and exp are mandatory stays open.

  • The Jira app has never run in a browser, because no deployment exists.

  • data/settings.json is tracked in git with live credentials, and GET /api/settings returns the GitHub, Jira and ArgoCD tokens in clear to any signed-in user. This is a recorded and deferred risk. See docs/decisions/LOG.md. The ingress is now public, so the risk is higher than before.