Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ You can read more about the technical specifications for a collection, such as t

The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles.

The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, a progress bar with a status message appears under the page header while a long operation runs (creating a draft, preparing or committing a release, deleting a release or the track, saving the configuration), and deleting an entire track lives in the danger zone at the bottom of the Config tab.
The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. A standard release preserves its exact pre-release draft, which remains hidden while the release exists. Administrators can roll back the most recent standard release from the Releases tab by confirming its version; the preserved draft then reappears. Rollback is blocked when any virtual snapshot resolved that release. Virtual releases retain their irreversible delete action because virtual materializations are tagged in place. Administrators can also correct a tagged snapshot's version from its card when the replacement remains valid between adjacent releases. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers rollback (standard) or delete (virtual), a progress bar with a status message appears under the page header while a long operation runs, and deleting an entire track lives in the danger zone at the bottom of the Config tab.

Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes.

Expand Down
2 changes: 2 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { MultipleChoiceDialogComponent } from './components/multiple-choice-dial
import { NavigationComponent } from './components/navigation/navigation.component';
import { ReferenceEditDialogComponent } from './components/reference-edit-dialog/reference-edit-dialog.component';
import { ReleasePreviewDialogComponent } from './components/release-preview-dialog/release-preview-dialog.component';
import { ReleaseVersionDialogComponent } from './components/release-version-dialog/release-version-dialog.component';
import { SnapshotDescriptionDialogComponent } from './components/snapshot-description-dialog/snapshot-description-dialog.component';
import { HistoryTimelineComponent } from './components/stix/stix-page-tabs/history-timeline/history-timeline.component';
import { MembershipSectionComponent } from './components/stix/stix-page-tabs/membership-section/membership-section.component';
Expand Down Expand Up @@ -274,6 +275,7 @@ export function initConfig(appConfigService: AppConfigService) {
ReferenceSidebarComponent,
ReferenceEditDialogComponent,
ReleasePreviewDialogComponent,
ReleaseVersionDialogComponent,
SnapshotDescriptionDialogComponent,
MultipleChoiceDialogComponent,
ValidationResultsComponent,
Expand Down
7 changes: 7 additions & 0 deletions src/app/classes/release-tracks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export type ReleasePayload = (
| { increment?: undefined; version?: undefined }
) & { description?: string };

export interface RetagReleasePayload {
version: string;
}

export interface ClonePayload {
name?: string;
}
Expand Down Expand Up @@ -115,6 +119,7 @@ export interface ReleasePreviewSummaryBase {
track_id: string;
type: ReleaseTrackType;
source_snapshot_modified: string;
release_snapshot_modified?: string;
version: string;
version_bounds: {
lower: { version: string; modified: string } | null;
Expand Down Expand Up @@ -177,6 +182,8 @@ export interface ReleaseTrackSnapshotHistoryItem {
id?: string;
modified?: string | Date;
version?: string | null;
/** Exact draft snapshot retained when a standard release was created. */
release_source_modified?: string | Date;
content_manifest_id?: string;
publication?: SnapshotPublication;
bundle_id?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<h2 mat-dialog-title>Change release version</h2>

<mat-dialog-content>
<p>
Change release {{ data.currentVersion }} without changing the snapshot it
identifies. The new version must remain between the adjacent releases.
</p>
<mat-form-field appearance="outline">
<mat-label>Release version</mat-label>
<input matInput [(ngModel)]="version" placeholder="1.2" />
<mat-hint>Use MAJOR.MINOR format.</mat-hint>
<mat-error *ngIf="isInvalid">Enter a version such as 1.2.</mat-error>
</mat-form-field>
</mat-dialog-content>

<mat-dialog-actions align="end">
<button mat-button type="button" [mat-dialog-close]="undefined">
Cancel
</button>
<button
mat-raised-button
type="button"
color="primary"
[disabled]="isInvalid || normalizedVersion === data.currentVersion"
(click)="save()">
Change version
</button>
</mat-dialog-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:host {
display: block;
}

mat-form-field {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { vi } from 'vitest';

import { ReleaseVersionDialogComponent } from './release-version-dialog.component';

describe('ReleaseVersionDialogComponent', () => {
let component: ReleaseVersionDialogComponent;
let fixture: ComponentFixture<ReleaseVersionDialogComponent>;
const dialogRef = { close: vi.fn() };

beforeEach(async () => {
dialogRef.close.mockReset();
await TestBed.configureTestingModule({
declarations: [ReleaseVersionDialogComponent],
imports: [FormsModule],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: { currentVersion: '1.1' } },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

fixture = TestBed.createComponent(ReleaseVersionDialogComponent);
component = fixture.componentInstance;
});

it('accepts a normalized MAJOR.MINOR version', () => {
component.version = ' 1.2 ';
component.save();
expect(dialogRef.close).toHaveBeenCalledWith('1.2');
});

it('rejects malformed versions', () => {
component.version = '1.2.3';
component.save();
expect(component.isInvalid).toBe(true);
expect(dialogRef.close).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';

export interface ReleaseVersionDialogData {
currentVersion: string;
}

@Component({
selector: 'app-release-version-dialog',
templateUrl: './release-version-dialog.component.html',
styleUrls: ['./release-version-dialog.component.scss'],
standalone: false,
})
export class ReleaseVersionDialogComponent {
public version: string;

constructor(
public dialogRef: MatDialogRef<ReleaseVersionDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ReleaseVersionDialogData
) {
this.version = data.currentVersion;
}

public get normalizedVersion(): string {
return this.version.trim();
}

public get isInvalid(): boolean {
return !/^\d+\.\d+$/.test(this.normalizedVersion);
}

public save(): void {
if (this.isInvalid) return;
this.dialogRef.close(this.normalizedVersion);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ describe('ReleaseTracksConnectorService', () => {
expect(options.params.get('confirm_version')).toBe('1.1');
});

it('should change a release version by snapshot identity', () => {
http.put.mockReturnValue(of({ version: '1.2' }));

service
.retagRelease('release-track--standard', '2026-07-23T13:37:28.000Z', {
version: '1.2',
})
.subscribe();

expect(http.put).toHaveBeenCalledWith(
`${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/release`,
{ version: '1.2' }
);
});

it('should create virtual snapshots through the virtual namespace', () => {
service
.createVirtualSnapshot('release-track--virtual', {
Expand Down
16 changes: 16 additions & 0 deletions src/app/services/connectors/rest-api/release-tracks.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ExportFormatType,
PromoteQuarantinePayload,
ReleasePayload,
RetagReleasePayload,
ReleasePreviewOptions,
ReleaseTrackConfig,
ReleaseTrackSnapshotHistoryItem,
Expand All @@ -35,6 +36,7 @@ export type {
CreateReleaseTrackPayload,
PromoteQuarantinePayload,
ReleasePayload,
RetagReleasePayload,
ReleasePreviewOptions,
ReleaseTrackSnapshotHistoryItem,
ReleaseTrackSnapshotOptions,
Expand Down Expand Up @@ -517,6 +519,20 @@ export class ReleaseTracksConnectorService extends ApiConnector {
);
}

/** Change a tagged snapshot's semantic version without changing its identity. */
public retagRelease(
id: string,
modified: string,
body: RetagReleasePayload
): Observable<ReleaseTrackSnapshotHistoryItem> {
const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/release`;
return this.http.put<ReleaseTrackSnapshotHistoryItem>(url, body).pipe(
tap(result => logger.log(`retagged snapshot ${modified}`, result)),
catchError(this.handleError_raise()),
share()
);
}

/**
* POST /api/release-tracks/:id/virtual/snapshots/create
* Resolve component tracks and create a new draft snapshot for a virtual track.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -765,13 +765,39 @@ <h3 [class.promote-color]="item.isTagged">
<mat-icon>cloud_download</mat-icon>
Export&hellip;
</button>
<button
*ngIf="item.isTagged && canDeleteRelease"
mat-stroked-button
[disabled]="isRetaggingRelease(item) || !item.modified"
[attr.aria-label]="'Change version for ' + item.title"
matTooltip="Change this release version while preserving its snapshot identity and semantic-version order."
(click)="onRetagRelease(item)">
<mat-spinner
*ngIf="isRetaggingRelease(item)"
class="snapshot-action-spinner"
diameter="18"></mat-spinner>
<mat-icon *ngIf="!isRetaggingRelease(item)">edit</mat-icon>
{{
isRetaggingRelease(item)
? 'Changing version'
: 'Change version'
}}
</button>
<button
*ngIf="item.isLatestRelease && canDeleteRelease"
color="warn"
mat-stroked-button
[disabled]="isDeletingRelease(item) || !item.modified"
[attr.aria-label]="'Delete release ' + item.title"
matTooltip="Only the most recent release can be deleted. Its version becomes available again."
[attr.aria-label]="
(isVirtualReleaseTrack
? 'Delete release '
: 'Roll back release ') + item.title
"
[matTooltip]="
isVirtualReleaseTrack
? 'Permanently delete the most recent virtual release.'
: 'Remove the most recent release and restore its exact pre-release draft. Virtual snapshot dependencies block rollback.'
"
(click)="onDeleteRelease(item)">
<mat-spinner
*ngIf="isDeletingRelease(item)"
Expand All @@ -781,8 +807,12 @@ <h3 [class.promote-color]="item.isTagged">
<mat-icon *ngIf="!isDeletingRelease(item)">delete</mat-icon>
{{
isDeletingRelease(item)
? 'Deleting release'
: 'Delete release'
? isVirtualReleaseTrack
? 'Deleting release'
: 'Rolling back release'
: isVirtualReleaseTrack
? 'Delete release'
: 'Roll back release'
}}
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.com
import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component';
import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component';
import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component';
import { ReleaseVersionDialogComponent } from 'src/app/components/release-version-dialog/release-version-dialog.component';
import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component';
import { ReleaseReviewDialogComponent } from 'src/app/components/release-review-dialog/release-review-dialog.component';
import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service';
Expand Down Expand Up @@ -63,6 +64,7 @@ describe('ReleaseTrackPageComponent', () => {
previewRelease: vi.fn(() => createAsyncObservable({})),
releaseLatest: vi.fn(() => createAsyncObservable({})),
releaseSnapshot: vi.fn(() => createAsyncObservable({})),
retagRelease: vi.fn(() => createAsyncObservable({})),
getConfig: vi.fn(() => createAsyncObservable(null)),
updateConfig: vi.fn(() => createAsyncObservable({})),
updateComposition: vi.fn(() => createAsyncObservable({})),
Expand Down Expand Up @@ -974,7 +976,7 @@ describe('ReleaseTrackPageComponent', () => {
DeleteDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
title: 'Delete release 1.1?',
title: 'Roll back release 1.1?',
stixId: '1.1',
}),
})
Expand All @@ -987,12 +989,75 @@ describe('ReleaseTrackPageComponent', () => {
expect(trackSpy).toHaveBeenCalled();
expect(historySpy).toHaveBeenCalled();
expect(mockSnackbar.open).toHaveBeenCalledWith(
'Release 1.1 deleted.',
'Release 1.1 rolled back to draft.',
null,
{ duration: 5000 }
);
});

it('should change a tagged release version as an administrator', () => {
const item = {
snapshot: { version: '1.1' },
title: 'v1.1',
modified: '2026-07-23T13:37:28.000Z',
isTagged: true,
} as any;
mockDialog.open.mockReturnValue({ afterClosed: () => of('1.2') });
mockReleaseTrackApiConnector.retagRelease.mockReturnValue(
of({ version: '1.2' })
);
const refreshSpy = vi
.spyOn(component as any, 'refreshReleaseTrackState')
.mockImplementation(() => undefined);
component.id = 'release-track--123';

component.onRetagRelease(item);

expect(mockDialog.open).toHaveBeenCalledWith(
ReleaseVersionDialogComponent,
expect.objectContaining({ data: { currentVersion: '1.1' } })
);
expect(mockReleaseTrackApiConnector.retagRelease).toHaveBeenCalledWith(
'release-track--123',
item.modified,
{ version: '1.2' }
);
expect(refreshSpy).toHaveBeenCalled();
expect(mockSnackbar.open).toHaveBeenCalledWith(
'Release 1.1 changed to 1.2.',
null,
{ duration: 5000 }
);
});

it('should hide a preserved release-source draft until rollback', () => {
const sourceModified = '2026-07-23T13:37:27.000Z';
mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
of({
data: [
{
id: 'release-track--123',
modified: '2026-07-23T13:37:28.000Z',
version: '1.0',
release_source_modified: sourceModified,
},
{
id: 'release-track--123',
modified: sourceModified,
version: null,
},
],
})
);
component.id = 'release-track--123';

component.getSnapshotHistory();

expect(component.snapshotHistory).toHaveLength(1);
expect(component.snapshotHistory[0].title).toBe('v1.0');
expect(component.hasCurrentDraftSnapshot).toBe(false);
});

it('should not offer release deletion to non-administrators or for drafts', () => {
mockAuthenticationService.canDelete.mockReturnValue(false);
component.id = 'release-track--123';
Expand Down
Loading
Loading