SDD: Version Control Service (Release Manifest) — ft-internal-tools

  • Status: DRAFT

  • Task grade: architecture-led

  • Ticket: DEV-2755 — CI/CD manager > Finalize sprint > Implement version control service

  • Deadline: 12 August 2026

  • Long-form companion: Release Manifest — design reference

Baseline: main, as it stands today. The feature stores its data in JSON files through the existing storage service, the same way release history, QA history and release-build history already do. It takes no dependency on the Postgres work that is under way elsewhere, because that work is not close to release and this feature has a date.

Section 5 defines the storage so the later move to Postgres is additive: the stored form is already a single self-contained document per manifest, which is exactly the shape a jsonb column takes.

The one thing this feature needs that main lacks is a REST transport in ft-github-client for reading repository files at a given ref — roughly 40 lines, taken as its own small change rather than as a reason to wait for anything larger.

0. Requirements and assumptions

Elicited answers

  • Where the service lives → a module inside CI/CD Manager, not a fourth application.

  • How the deployment checklist is filled → generated draft from the deployment contracts and a manual editor, because the generated part cannot be trusted for every case.

  • How support receives the page → a link in the finalize-sprint e-mail; the page itself published on the documentation portal, with the authenticated UI reserved for the release manager.

  • Scope against the deadline → keep all phases in, reduce depth per phase, and state which items may not make the date.

Assumptions taken

  • The feature is built on main and stores manifests as JSON, deliberately not waiting for the Postgres work — see the baseline note above and the migration path in section 5.

  • Manifest volume stays in the order of tens per year. At roughly 50 KB per snapshot that is about 1 MB after a year in one file; if it grows past that, section 5 names the split.

  • The manifest reproduces the prototype release-manifest-v11.html and the reference guide v1.0 plus its Docker Pull addendum. Two documented deviations are listed in section 10.

  • Rendering is a port of the working prototype, not new development. This is the largest schedule assumption.

  • The documentation portal is an acceptable reading surface for support engineers, i.e. no per-customer access control is required.

1. Problem and goal

The Release Manifest replaces the legacy VersionReleaseControl spreadsheet and the deployment guides on the shared disk: one page that tells a support engineer which version and build of every component is current and compatible, what changed this sprint, and what a customer on an older version must apply. Today it exists only as a prototype whose data arrays are hand-edited. Goal: the manifest is produced automatically when a sprint is finalized, curated by the release manager, and published where support can read it.

2. Scope

In

  • A manifest snapshot per sprint release and per hot-fix build, generated from the release record, the Jira items and the deployment contracts.

  • An admin-only Version Control tab in CI/CD Manager for reviewing the draft and editing the deployment checklist.

  • A self-contained HTML artifact published to the documentation portal, linked from the finalize e-mail.

  • Both views from the guide: Releases and Upgrade Path.

Out

  • Editing releases themselves. The manifest is a read model; corrections happen upstream.

  • Per-customer access control or customer-facing distribution.

  • Kubernetes deployments. The checklist is Docker Compose only.

3. Affected components

CI/CD Manager backend — new routes/manifest/ (_crud.py, _publish.py) and services/manifest/ (storage.py, catalog.py, solution.py, builder.py, contract_diff.py, contracts.py, renderer.py). No schema change: storage.py is a storage service in the shape of services/release_storage.py.

Touchedroutes/workflow/release/_changelog.py (persist the resolved Jira items), routes/workflow/release_build.py (hot-fix snapshot), routes/workflow/email.py (CTA button), frontend/js/sprint-release.js (one call after the docs-portal phase).

CI/CD Manager frontend — new manifest-render.js, manifest-upgrade.js, manifest-admin.js, manifest-editor.js, css/manifest.css.

Shared librariesft-jira-client gains an additive customers field alongside the existing hasCustomer flag; ft-github-client gains a REST transport for reading a repository file at a given ref, which the contract reader needs.

Documentation portalrelease-process.adoc and sprint-end-checklist.adoc cover the release and sprint-end procedure this feature changes, so they are updated in the same change.

4. Design and approach

A manifest is an immutable snapshot written when a release is finalized. It embeds the component catalogue, the dependency edges, the versions, the Jira items and the checklist as they were at that moment, so re-reading an old release is unaffected by later renames. The catalogue is composed rather than re-entered: FT components come from data/projects.json (adding a layer attribute), third-party components and their pinned tags from the deployment.yaml contracts. The Solution version is derived from the FTACS major.minor version shipped in the release.

The deployment checklist is a generated draft with a human override: a semantic diff of the deployment contracts produces the mechanical part, the release manager corrects it, and regeneration never overwrites an edited field. Diffs are computed between manifest snapshots — each manifest stores a contract fingerprint — rather than between git refs, so nothing depends on the contracts repository being tagged per release.

One renderer serves both consumers: it is a pure function from the snapshot bundle to DOM, fed from the API inside the application and inlined into the published artifact.

Alternatives considered

  • A standalone version-control-manager application — rejected: every input already lives in CI/CD Manager, so it would fetch all of its data back over HTTP and pay for its own auth, database, chart and CI first.

  • A module in Deployment Manager — rejected: it owns the contracts but none of the release, version or Jira data, and it deliberately dropped its platform_version concept in migration 20260520_0003.

5. Data model and DB changes

No DB changes. Manifests live in data/release_manifests.json, written through the same storage service as the rest of the application, with services/manifest/storage.py in the shape of services/release_storage.py — a read-modify-write under a module-level lock so concurrent draft edits cannot clobber each other.

{ "manifests": [
  { "id": "sprint-7",              // stable, also the DOM anchor and deep-link fragment
    "type": "sprint",              // 'sprint' | 'hotfix'
    "releaseId": "4390807f-...",   // sprint manifests: the release record
    "buildRunId": null,            // hot-fix manifests: the release-build run
    "solutionId": "sol-7",
    "solutionVersion": "7.0",
    "targetBranch": null,          // hot-fix only, e.g. '7.0.x'
    "name": "Sprint 7",
    "date": "2026-06-18",
    "status": "draft",             // 'draft' | 'published'
    "publishedAt": null,
    "publishedBy": null,
    "createdAt": "...", "updatedAt": "...",
    "snapshot": { }                // the self-contained document, see below
  }
] }

The snapshot document carries components, layers, solutions, deps, items, versions, checklist, contracts and source; the full shape is in the design reference. Everything a published manifest renders from is inside it — the record above is only the index the sidebar and the publish flow need.

JsonStorage.write truncates and rewrites in place, and JsonStorage.read swallows a parse error and returns the default. Together that turns an interrupted write into a silently empty history, and the next write makes the loss permanent. Manifest storage writes to a temporary file in the same directory and os.replace-s it into position, and treats an unreadable file as an error rather than as an empty list. This is release history — losing it quietly is the one failure mode that is not recoverable.

Two additive changes elsewhere: an optional layer attribute on each project, and the resolved Jira items persisted on the release record (they are currently computed during changelog generation and discarded).

Migration path to Postgres

The storage choice is deliberately the only thing that has to change when the database work lands. The record above maps one-to-one onto a table whose snapshot is a jsonb column:

CREATE TABLE cicd.release_manifests (
    id               text        PRIMARY KEY,
    release_id       uuid        NULL,   -- no FK, per the DB contract
    build_run_id     uuid        NULL,
    type             text        NOT NULL,
    solution_id      text        NOT NULL,
    solution_version text        NOT NULL,
    target_branch    text        NULL,
    name             text        NOT NULL,
    release_date     date        NOT NULL,
    status           text        NOT NULL,
    snapshot         jsonb       NOT NULL,
    published_at     timestamptz NULL,
    published_by     text        NULL,
    created_at       timestamptz NOT NULL,
    updated_at       timestamptz NOT NULL
);
CREATE UNIQUE INDEX release_manifests_release_id_idx
    ON cicd.release_manifests (release_id) WHERE release_id IS NOT NULL;
CREATE UNIQUE INDEX release_manifests_build_run_id_idx
    ON cicd.release_manifests (build_run_id) WHERE build_run_id IS NOT NULL;

Three rules keep that migration additive, and they constrain the code written now:

  • Callers never touch the file. Everything goes through services/manifest/storage.py; no route, builder or renderer reads data/ directly. The migration then replaces one module’s internals, following the dual-read/write facade already used for release history.

  • Field names are the column names. The JSON record uses the names above so the mapping is mechanical, and snapshot stays one opaque document rather than being spread across columns.

  • No JSON-only affordances. Nothing relies on ordering within the file, on rewriting unrelated records in the same write, or on reading the whole list to answer a single-id lookup.

When the move happens, snapshot becomes a JSONB column, and SQLAlchemy does not track in-place mutation of JSON column contents — a nested list appended to a shallow copy compares equal and the UPDATE is skipped. The repo layer must deep-copy before mutating, the same trap already handled in the release and cleanup-schedule repos.

6. FE-BE contract changes

New admin-only routes under /api/manifest, all returning the app’s existing JSON envelope.

GET  /api/manifest/releases                     -> sidebar list
GET  /api/manifest/<id>                         -> one snapshot
GET  /api/manifest/bundle                       -> all published snapshots (renderer input)
POST /api/manifest/draft                        -> build or rebuild a draft
PUT  /api/manifest/<id>                         -> release-level edits
PUT  /api/manifest/<id>/checklist/<compId>      -> one checklist entry, marks it edited
POST /api/manifest/<id>/publish                 -> render, commit to the portal, publish
GET  /api/manifest/export.html                  -> the self-contained artifact

No change to any existing endpoint.

7. Protocol

N/A — no TR-069 / TR-181 / TR-369 / LwM2M parameter paths are touched.

8. Logging and observability

Draft generation and publication log against the release id at INFO with releaseId=<id> manifestId=<id> action=<draft|publish> result=<ok|failed>; a generation failure logs at WARNING and is recorded as a release-level log entry, visible in the release history, because generation must never fail the finalize flow. The contract reader logs which resolution source answered, so a stale contract can be traced.

9. Test plan

pytesttest_manifest_catalog.py (projects and contracts compose; the database role expands to mysql and oracle; an unknown alias is reported, not dropped); test_manifest_solution.py (FTACS 7.0.0 gives sol-7 / 7.0; a release without FTACS falls back instead of crashing); test_manifest_builder.py (status new / updated / stable / hotfix / breaking; unreleased components inherit from the previous snapshot; the pull command echoes the recorded imageUrl verbatim — the regression guard for deviation 1); test_manifest_contract_diff.py (variable added, removed, default changed; image tag changed; no change gives a quiet row); test_manifest_editor_precedence.py (regeneration preserves every edited field); test_routes_manifest.py (auth on every route, draft and publish transitions); test_manifest_storage.py (round-trip of a full snapshot; concurrent draft edits serialise instead of clobbering; a write interrupted before os.replace leaves the previous file intact; an unreadable file raises instead of reporting an empty history).

vitestmanifest-upgrade.test.js (chronological order, the from-exclusive to-inclusive window, later supersedes earlier, no-change entries skipped, same-version and downgrade validation); manifest-render.test.js (pull cell, status badge class, diff row builder).

Playwright — draft, edit a checklist entry, publish; sidebar groups hot-fixes above sprints; a breaking entry shows its banner and the diff tabs switch; Upgrade Path builds a combined checklist; the pull command copies to the clipboard; a hot-fix release shows no version table.

A Playwright quality mark on the finalize flow must be green before sprint-release.js is touched.

10. Risks, rollout and follow-ups

Two deviations from the source documents. Implementing them verbatim would ship something wrong.

  1. The addendum specifies a Harbor tag built as <version>_<build>, e.g. 7.0.6_0.0.2. The pipeline actually pushes v<version>-b<build> — a real release record carries hub.friendly-tech.com/configs/ft-configs-service:v1.0.1-b0.0.1. The manifest therefore renders the recorded imageUrl verbatim and never recomputes a tag, so a support engineer cannot be handed a pull command the registry will reject.

  2. The guide shows a customer name on bug items. The Jira integration reduces the "Project (assets)" field to a boolean, discarding the identity. Whether the API response carries a readable label or needs a second Assets call is unverified; a half-day spike decides, and the fallback is a manual field.

Schedule. The window from 31 July to 12 August is 9 working days against roughly 19 days of design. Every phase ships at reduced depth, there is no buffer, and the backfill of previous releases is already out of the window. The three items most likely to follow it, in order: the Upgrade Path view, customer names, and hot-fix manifests. Generation at finalize and publication cannot slip — without either, nothing reaches a support engineer.

Storage. JSON is chosen against the deadline, not on merit: the Postgres work is not close to release, and coupling this feature to it would hand the date to another project. The cost is a file rewritten in full on every save and a history that is one file rather than one row per manifest. Section 5 constrains the code so the later move is a change to one module; the risk is that those three rules erode under time pressure, at which point the migration stops being additive. The test_manifest_storage.py cases are what keep them honest.

Decay risk. Hot-fix manifests are the one deferral that loses data: release_build_history prunes after 10 days, so hot-fixes shipped while it is outstanding cannot be reconstructed and must be entered by hand.

External dependencies, not development time. A layer assigned to each of the 19 projects and the third-party list confirmed by the end of day 1; agreement on who writes the breaking-change prose at finalize.

11. Lens exemptions

  • Protocol — no device-facing parameter paths are touched.

  • Performance — generation runs once per release on an admin path; the published artifact is a static file.

  • Migration and back-compat — a new data file and two additive fields; no schema change and no existing reader changes.