From e82d2e0feed35f225267d27bdd17b905a73abd18 Mon Sep 17 00:00:00 2001 From: Leonhard Breuer Date: Sat, 22 Aug 2026 10:31:33 +0200 Subject: [PATCH] feat(update): reuse artifacts across releases --- .../features/update/delta_integration_test.go | 8 +- backend/internal/features/update/dto.go | 7 + backend/internal/features/update/handler.go | 40 ++ .../internal/features/update/repository.go | 103 ++++- .../reuse_artifacts_integration_test.go | 88 ++++ backend/internal/features/update/routes.go | 1 + backend/internal/features/update/service.go | 78 ++++ website/src/features/admin/api.ts | 13 + website/src/routes/_dash/updates/releases.tsx | 430 +++++++++++++----- 9 files changed, 626 insertions(+), 142 deletions(-) create mode 100644 backend/internal/features/update/reuse_artifacts_integration_test.go diff --git a/backend/internal/features/update/delta_integration_test.go b/backend/internal/features/update/delta_integration_test.go index e638421..4edc0ef 100644 --- a/backend/internal/features/update/delta_integration_test.go +++ b/backend/internal/features/update/delta_integration_test.go @@ -43,7 +43,7 @@ func TestDeltaJobGenerationAndStateTransitionsIntegration(t *testing.T) { storageRoot := t.TempDir() noPredecessor := seedSingleRelease(t, tx) - svc := newDeltaIntegrationService(q, storageRoot) + svc := newDeltaIntegrationService(q, tx, storageRoot) jobs, err := svc.GenerateDeltaJobs(t.Context(), noPredecessor) if err != nil { t.Fatalf("generate without predecessor: %v", err) @@ -135,7 +135,7 @@ func TestDeltaJobGenerationAndStateTransitionsIntegration(t *testing.T) { func TestDeltaWorkerAndAdminRoutesIntegration(t *testing.T) { tx, q := openDeltaIntegrationTx(t) storageRoot := t.TempDir() - svc := newDeltaIntegrationService(q, storageRoot) + svc := newDeltaIntegrationService(q, tx, storageRoot) fixture := seedDeltaFixture(t, tx, svc, storageRoot) handler := NewHandler(svc, nil) @@ -233,8 +233,8 @@ func openDeltaIntegrationTx(t *testing.T) (pgx.Tx, *db.Queries) { return tx, db.New(tx) } -func newDeltaIntegrationService(q *db.Queries, storageRoot string) *Service { - return NewService(nil, nil, NewRepository(q, nil), nil, "http://clave.test", storageRoot) +func newDeltaIntegrationService(q *db.Queries, tx pgx.Tx, storageRoot string) *Service { + return NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", storageRoot) } func seedSingleRelease(t *testing.T, tx pgx.Tx) uuid.UUID { diff --git a/backend/internal/features/update/dto.go b/backend/internal/features/update/dto.go index 5329c3f..0ff1cb2 100644 --- a/backend/internal/features/update/dto.go +++ b/backend/internal/features/update/dto.go @@ -87,6 +87,13 @@ type CreateReleaseRequest struct { ChangelogID string `json:"changelogId,omitempty"` } +// ReuseArtifactsRequest links the target draft to the payloads of another +// release. It creates new artifact records, but they share the existing stored +// object rather than uploading or copying the binary again. +type ReuseArtifactsRequest struct { + SourceReleaseID string `json:"sourceReleaseId" validate:"required"` +} + type ChangelogDTO struct { ID string `json:"id"` ProductID string `json:"productId"` diff --git a/backend/internal/features/update/handler.go b/backend/internal/features/update/handler.go index 4a23f70..dfd586d 100644 --- a/backend/internal/features/update/handler.go +++ b/backend/internal/features/update/handler.go @@ -516,6 +516,46 @@ func (h *Handler) AdminUploadArtifact(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusCreated, artifact) } +func (h *Handler) AdminReuseArtifacts(w http.ResponseWriter, r *http.Request) { + orgID, ok := middleware.AdminOrganizationIDFromContext(r.Context()) + if !ok { + helpers.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + + targetReleaseID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid release id"}) + return + } + if err := h.verifyReleaseOwnership(r.Context(), orgID, targetReleaseID); err != nil { + helpers.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "release not found"}) + return + } + + var req ReuseArtifactsRequest + if !helpers.DecodeValidated(w, r, &req) { + return + } + sourceReleaseID, err := uuid.Parse(req.SourceReleaseID) + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid source release id"}) + return + } + if err := h.verifyReleaseOwnership(r.Context(), orgID, sourceReleaseID); err != nil { + helpers.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "source release not found"}) + return + } + + artifacts, err := h.svc.ReuseArtifacts(r.Context(), targetReleaseID, sourceReleaseID) + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + h.audit(r, "release.artifacts_reused", "release", &targetReleaseID) + helpers.WriteJSON(w, http.StatusCreated, artifacts) +} + func (h *Handler) AdminPublishRelease(w http.ResponseWriter, r *http.Request) { orgID, ok := middleware.AdminOrganizationIDFromContext(r.Context()) if !ok { diff --git a/backend/internal/features/update/repository.go b/backend/internal/features/update/repository.go index 3d6d8e3..b0f6f68 100644 --- a/backend/internal/features/update/repository.go +++ b/backend/internal/features/update/repository.go @@ -3,21 +3,25 @@ package update import ( "context" "encoding/json" - "time" + "errors" "github.com/cheetahbyte/clave/internal/db" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" ) +type transactionBeginner interface { + Begin(context.Context) (pgx.Tx, error) +} + type Repository struct { - q *db.Queries - pool *pgxpool.Pool + q *db.Queries + db transactionBeginner } -func NewRepository(q *db.Queries, pool *pgxpool.Pool) *Repository { - return &Repository{q: q, pool: pool} +func NewRepository(q *db.Queries, database transactionBeginner) *Repository { + return &Repository{q: q, db: database} } func (r *Repository) GetProductByIDAndOrganization(ctx context.Context, orgID, id uuid.UUID) (db.Product, error) { @@ -202,8 +206,80 @@ func (r *Repository) FindPreviousPublishedRelease(ctx context.Context, release d }) } +var ( + ErrReleaseAlreadyHasArtifacts = errors.New("target release already has artifacts") + ErrArtifactsOnlyAddedToDraft = errors.New("artifacts can only be added to a draft release") +) + func (r *Repository) InsertUpdateArtifact(ctx context.Context, params db.InsertUpdateArtifactParams) (db.UpdateArtifact, error) { - return r.q.InsertUpdateArtifact(ctx, params) + tx, err := r.db.Begin(ctx) + if err != nil { + return db.UpdateArtifact{}, err + } + defer tx.Rollback(ctx) + + var status string + if err := tx.QueryRow(ctx, "SELECT status FROM update_releases WHERE id = $1 FOR UPDATE", params.ReleaseID).Scan(&status); err != nil { + return db.UpdateArtifact{}, err + } + if status != "draft" { + return db.UpdateArtifact{}, ErrArtifactsOnlyAddedToDraft + } + artifact, err := r.q.WithTx(tx).InsertUpdateArtifact(ctx, params) + if err != nil { + return db.UpdateArtifact{}, err + } + if err := tx.Commit(ctx); err != nil { + return db.UpdateArtifact{}, err + } + return artifact, nil +} + +// InsertUpdateArtifactsIfReleaseEmpty atomically verifies that releaseID has no +// artifacts and inserts the supplied artifacts. Locking the parent release row +// serializes competing reuse requests before the empty-release check. +func (r *Repository) InsertUpdateArtifactsIfReleaseEmpty(ctx context.Context, releaseID uuid.UUID, artifacts []db.InsertUpdateArtifactParams) ([]db.UpdateArtifact, error) { + tx, err := r.db.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + // Acquiring a FOR UPDATE lock on the parent release serializes this guard + // with other reuse attempts for the same release. + if err := tx.QueryRow(ctx, "SELECT id FROM update_releases WHERE id = $1 FOR UPDATE", releaseID).Scan(new(uuid.UUID)); err != nil { + return nil, err + } + + qtx := r.q.WithTx(tx) + release, err := qtx.GetUpdateRelease(ctx, releaseID) + if err != nil { + return nil, err + } + if release.Status != "draft" { + return nil, ErrArtifactsOnlyAddedToDraft + } + + existing, err := qtx.ListArtifactsForRelease(ctx, releaseID) + if err != nil { + return nil, err + } + if len(existing) != 0 { + return nil, ErrReleaseAlreadyHasArtifacts + } + + inserted := make([]db.UpdateArtifact, 0, len(artifacts)) + for _, artifact := range artifacts { + insertedArtifact, err := qtx.InsertUpdateArtifact(ctx, artifact) + if err != nil { + return nil, err + } + inserted = append(inserted, insertedArtifact) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return inserted, nil } func (r *Repository) GetArtifact(ctx context.Context, id uuid.UUID) (db.UpdateArtifact, error) { @@ -247,7 +323,7 @@ func (r *Repository) ListCompletedDeltaArtifactsForRelease(ctx context.Context, } func (r *Repository) CompleteDeltaJobWithArtifact(ctx context.Context, artifact db.InsertUpdateArtifactParams, complete db.CompleteDeltaJobParams) (db.UpdateDeltaJob, error) { - tx, err := r.pool.Begin(ctx) + tx, err := r.db.Begin(ctx) if err != nil { return db.UpdateDeltaJob{}, err } @@ -354,14 +430,3 @@ func MustParseProviderConfig(raw []byte) map[string]any { } return m } - -func timePtr(t pgtype.Timestamptz) *time.Time { - if t.Valid { - return &t.Time - } - return nil -} - -func uuidToPG(id uuid.UUID) pgtype.UUID { - return pgtype.UUID{Bytes: [16]byte(id), Valid: true} -} diff --git a/backend/internal/features/update/reuse_artifacts_integration_test.go b/backend/internal/features/update/reuse_artifacts_integration_test.go new file mode 100644 index 0000000..b86d78c --- /dev/null +++ b/backend/internal/features/update/reuse_artifacts_integration_test.go @@ -0,0 +1,88 @@ +package update + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestReuseArtifactsIntegration(t *testing.T) { + tx, q := openDeltaIntegrationTx(t) + orgID, productID, channelID := seedDeltaParents(t, tx) + sourceID, targetID := uuid.New(), uuid.New() + if _, err := tx.Exec(t.Context(), ` + INSERT INTO update_releases (id, organization_id, product_id, channel_id, platform, version, status, published_at) + VALUES ($1, $2, $3, $4, 'macos', '1.0.0', 'published', now()), + ($5, $2, $3, $4, 'macos', '1.0.0', 'draft', NULL)`, + sourceID, orgID, productID, channelID, targetID); err != nil { + t.Fatalf("insert releases: %v", err) + } + + fullID, deltaID := uuid.New(), uuid.New() + filename := "app.zip" + checksum := strings.Repeat("a", 64) + size := int64(42) + if _, err := tx.Exec(t.Context(), ` + INSERT INTO update_artifacts (id, release_id, artifact_type, os, arch, url, size_bytes, checksum_sha256, filename) + VALUES ($1, $2, 'zip', 'macos', 'universal', 'full', $3, $4, $5), + ($6, $2, 'delta', 'macos', 'universal', 'delta', 1, $4, 'patch.delta')`, + fullID, sourceID, size, checksum, filename, deltaID); err != nil { + t.Fatalf("insert source artifacts: %v", err) + } + + svc := NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", t.TempDir()) + artifacts, err := svc.ReuseArtifacts(t.Context(), targetID, sourceID) + if err != nil { + t.Fatalf("reuse artifacts: %v", err) + } + if len(artifacts) != 1 || artifacts[0].ArtifactType != "zip" { + t.Fatalf("reused artifacts = %#v, want one full artifact", artifacts) + } + + stored, err := q.ListArtifactsForRelease(t.Context(), targetID) + if err != nil { + t.Fatalf("list target artifacts: %v", err) + } + wantKey := fullID.String() + "/" + filename + if len(stored) != 1 || stored[0].StorageKey == nil || *stored[0].StorageKey != wantKey { + t.Fatalf("stored artifacts = %#v, want shared key %q", stored, wantKey) + } + + _, err = svc.ReuseArtifacts(t.Context(), targetID, sourceID) + if !errors.Is(err, ErrReleaseAlreadyHasArtifacts) { + t.Fatalf("second reuse error = %v, want %v", err, ErrReleaseAlreadyHasArtifacts) + } +} + +func TestReuseArtifactsRejectsInvalidReleasesIntegration(t *testing.T) { + tx, q := openDeltaIntegrationTx(t) + orgID, productID, channelID := seedDeltaParents(t, tx) + sourceID, publishedTargetID, otherPlatformID := uuid.New(), uuid.New(), uuid.New() + if _, err := tx.Exec(t.Context(), ` + INSERT INTO update_releases (id, organization_id, product_id, channel_id, platform, version, status, published_at) + VALUES ($1, $2, $3, $4, 'macos', '1.0.0', 'published', now()), + ($5, $2, $3, $4, 'macos', '1.1.0', 'published', now()), + ($6, $2, $3, $4, 'windows', '1.1.0', 'draft', NULL)`, + sourceID, orgID, productID, channelID, publishedTargetID, otherPlatformID); err != nil { + t.Fatalf("insert releases: %v", err) + } + if _, err := tx.Exec(t.Context(), ` + INSERT INTO update_artifacts (id, release_id, artifact_type, os, arch, url) + VALUES ($1, $2, 'zip', 'macos', 'universal', 'full')`, uuid.New(), sourceID); err != nil { + t.Fatalf("insert artifact: %v", err) + } + + svc := NewService(nil, nil, NewRepository(q, tx), nil, "http://clave.test", t.TempDir()) + if _, err := svc.ReuseArtifacts(t.Context(), publishedTargetID, sourceID); err == nil || !strings.Contains(err.Error(), "draft") { + t.Fatalf("published target error = %v, want draft error", err) + } + if _, err := svc.ReuseArtifacts(t.Context(), otherPlatformID, sourceID); err == nil || !strings.Contains(err.Error(), "same product and platform") { + t.Fatalf("platform mismatch error = %v", err) + } + if _, err := svc.ReuseArtifacts(t.Context(), sourceID, sourceID); err == nil { + t.Fatal("reusing a release onto itself succeeded") + } + +} diff --git a/backend/internal/features/update/routes.go b/backend/internal/features/update/routes.go index ae13355..e9fe606 100644 --- a/backend/internal/features/update/routes.go +++ b/backend/internal/features/update/routes.go @@ -22,6 +22,7 @@ func (h *Handler) RegisterAdminRoutes(r chi.Router) { r.Get("/update-releases", h.AdminListReleases) r.Post("/update-releases", h.AdminCreateRelease) r.Post("/update-releases/{id}/artifacts", h.AdminUploadArtifact) + r.Post("/update-releases/{id}/artifacts/reuse", h.AdminReuseArtifacts) r.Post("/update-releases/{id}/publish", h.AdminPublishRelease) r.Get("/update-releases/{id}/delta-jobs", h.AdminListDeltaJobs) r.Post("/update-releases/{id}/delta-jobs/retry", h.AdminRetryDeltaJobs) diff --git a/backend/internal/features/update/service.go b/backend/internal/features/update/service.go index c3a69d3..6006a04 100644 --- a/backend/internal/features/update/service.go +++ b/backend/internal/features/update/service.go @@ -904,6 +904,84 @@ func (svc *Service) UploadArtifact(ctx context.Context, releaseID uuid.UUID, rea }, nil } +// ReuseArtifacts creates artifact records for targetReleaseID that reference the +// source release's existing storage objects. The binary is never read, copied, +// or uploaded again, so a build can be promoted to another channel cheaply. +func (svc *Service) ReuseArtifacts(ctx context.Context, targetReleaseID, sourceReleaseID uuid.UUID) ([]ArtifactDTOFull, error) { + if targetReleaseID == sourceReleaseID { + return nil, fmt.Errorf("choose a different source release") + } + + target, err := svc.repo.GetUpdateRelease(ctx, targetReleaseID) + if err != nil { + return nil, fmt.Errorf("target release not found: %w", err) + } + if target.Status != "draft" { + return nil, fmt.Errorf("artifacts can only be added to a draft release") + } + source, err := svc.repo.GetUpdateRelease(ctx, sourceReleaseID) + if err != nil { + return nil, fmt.Errorf("source release not found: %w", err) + } + if target.ProductID != source.ProductID || target.Platform != source.Platform { + return nil, fmt.Errorf("source release must be for the same product and platform") + } + sourceArtifacts, err := svc.repo.ListArtifactsForRelease(ctx, sourceReleaseID) + if err != nil { + return nil, fmt.Errorf("list source artifacts: %w", err) + } + artifactsToInsert := make([]db.InsertUpdateArtifactParams, 0, len(sourceArtifacts)) + for _, sourceArtifact := range sourceArtifacts { + // Delta contracts are tied to source/target versions. Publishing the new + // release generates fresh deltas for its channel instead. + if sourceArtifact.ArtifactType == "delta" { + continue + } + artifactID := uuid.New() + storageKey := svc.artifactStorageKey(sourceArtifact) + artifactsToInsert = append(artifactsToInsert, db.InsertUpdateArtifactParams{ + ID: artifactID, + ReleaseID: targetReleaseID, + ArtifactType: sourceArtifact.ArtifactType, + Os: sourceArtifact.Os, + Arch: sourceArtifact.Arch, + Url: svc.artifactDownloadURL(artifactID), + SizeBytes: sourceArtifact.SizeBytes, + ChecksumSha256: sourceArtifact.ChecksumSha256, + Signature: sourceArtifact.Signature, + Metadata: sourceArtifact.Metadata, + Filename: sourceArtifact.Filename, + MimeType: sourceArtifact.MimeType, + MinimumSystemVersion: sourceArtifact.MinimumSystemVersion, + StorageBackend: sourceArtifact.StorageBackend, + StorageKey: &storageKey, + }) + } + if len(artifactsToInsert) == 0 { + return nil, fmt.Errorf("source release has no reusable artifacts") + } + + inserted, err := svc.repo.InsertUpdateArtifactsIfReleaseEmpty(ctx, targetReleaseID, artifactsToInsert) + if err != nil { + if errors.Is(err, ErrReleaseAlreadyHasArtifacts) || errors.Is(err, ErrArtifactsOnlyAddedToDraft) { + return nil, err + } + return nil, fmt.Errorf("link artifacts: %w", err) + } + + result := make([]ArtifactDTOFull, 0, len(inserted)) + for _, artifact := range inserted { + result = append(result, ArtifactDTOFull{ + ID: artifact.ID.String(), ReleaseID: artifact.ReleaseID.String(), ArtifactType: artifact.ArtifactType, + OS: artifact.Os, Arch: artifact.Arch, URL: artifact.Url, SizeBytes: artifact.SizeBytes, + ChecksumSHA256: artifact.ChecksumSha256, Signature: artifact.Signature, Filename: artifact.Filename, + MimeType: artifact.MimeType, MinimumSystemVersion: artifact.MinimumSystemVersion, + }) + } + svc.feedCache.invalidateProduct(target.ProductID) + return result, nil +} + func (svc *Service) PublishRelease(ctx context.Context, releaseID uuid.UUID) (*ReleaseDTO, error) { artifacts, err := svc.repo.ListArtifactsForRelease(ctx, releaseID) if err != nil { diff --git a/website/src/features/admin/api.ts b/website/src/features/admin/api.ts index 9ca2c91..08e35db 100644 --- a/website/src/features/admin/api.ts +++ b/website/src/features/admin/api.ts @@ -761,6 +761,19 @@ export function uploadArtifact( ); } +export function reuseArtifacts( + releaseId: string, + sourceReleaseId: string, +): Promise { + return adminFetch( + `/api/v1/admin/update-releases/${encodeURIComponent(releaseId)}/artifacts/reuse`, + { + method: "POST", + body: JSON.stringify({ sourceReleaseId }), + }, + ); +} + export function publishRelease(releaseId: string): Promise { return adminFetch( `/api/v1/admin/update-releases/${encodeURIComponent(releaseId)}/publish`, diff --git a/website/src/routes/_dash/updates/releases.tsx b/website/src/routes/_dash/updates/releases.tsx index 29f13b0..8608ce7 100644 --- a/website/src/routes/_dash/updates/releases.tsx +++ b/website/src/routes/_dash/updates/releases.tsx @@ -12,6 +12,7 @@ import { yankRelease, deleteRelease, uploadArtifact, + reuseArtifacts, attachReleaseChangelog, listDeltaJobs, retryDeltaJobs, @@ -47,7 +48,16 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Plus, Upload, Rocket, Ban, Trash2, Pencil, Layers, RefreshCw } from "lucide-react"; +import { + Plus, + Upload, + Rocket, + Ban, + Trash2, + Pencil, + Layers, + RefreshCw, +} from "lucide-react"; export const Route = createFileRoute("/_dash/updates/releases")({ component: ReleasesPage, @@ -56,16 +66,23 @@ export const Route = createFileRoute("/_dash/updates/releases")({ function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB"]; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + units.length - 1, + ); + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function formatPlatform(platform: string): string { switch (platform) { - case "macos": return "macOS"; - case "windows": return "Windows"; - case "linux": return "Linux"; - default: return platform; + case "macos": + return "macOS"; + case "windows": + return "Windows"; + case "linux": + return "Linux"; + default: + return platform; } } @@ -94,7 +111,8 @@ function ReleasesPage() { toast.success("Release published"); queryClient.invalidateQueries({ queryKey: ["updateReleases"] }); }, - onError: (e) => toast.error(e instanceof Error ? e.message : "Publish failed"), + onError: (e) => + toast.error(e instanceof Error ? e.message : "Publish failed"), }); const yankMut = useMutation({ @@ -112,7 +130,8 @@ function ReleasesPage() { toast.success("Release deleted"); queryClient.invalidateQueries({ queryKey: ["updateReleases"] }); }, - onError: (e) => toast.error(e instanceof Error ? e.message : "Delete failed"), + onError: (e) => + toast.error(e instanceof Error ? e.message : "Delete failed"), }); function invalidate() { @@ -125,7 +144,8 @@ function ReleasesPage() {

Releases

- Manage releases for {product ? product.name : "the selected product"}. + Manage releases for{" "} + {product ? product.name : "the selected product"}.

{product ? ( @@ -152,39 +172,54 @@ function ReleasesPage() { {releasesLoading ? ( [{ id: "sk1" }, { id: "sk2" }].map((s) => ( - - - - - - + + + + + + + + + + + + + + + + + + )) - ) : !releases?.length ? ( - - - No releases yet. Create one to start delivering updates. - - - ) : ( + ) : releases?.length ? ( releases.map((r) => ( - {r.channel} + + {r.channel} + + + + {formatPlatform(r.platform)} - {formatPlatform(r.platform)} {r.version} {r.buildNumber && ( - ({r.buildNumber}) + + ({r.buildNumber}) + )} @@ -193,7 +228,9 @@ function ReleasesPage() { {r.changelogId ? ( - Attached + + Attached + ) : ( )} @@ -202,10 +239,17 @@ function ReleasesPage() { {r.artifacts?.length ? (
{r.artifacts.map((a, i) => ( -
- {a.filename || a.type} +
+ + {a.filename || a.type} + {a.sizeBytes != null && ( - ({formatBytes(a.sizeBytes)}) + + ({formatBytes(a.sizeBytes)}) + )}
))} @@ -215,58 +259,58 @@ function ReleasesPage() { )} -
- - {(r.status === "draft" || !r.status) && ( - <> - - - - )} +
+ + {(r.status === "draft" || !r.status) && ( + <> + + + + )} {r.status === "published" && ( - <> - - - - )} + <> + + + + )} +
+

+ Links the existing binary; it does not upload a copy. +

+
+ ) : null}