Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/auth/src/access.rs +1 −0
| @@ -150,6 +150,7 @@ mod tests { | |||
| 150 | 150 | object_format: "sha1".to_string(), | |
| 151 | 151 | issues_enabled: true, | |
| 152 | 152 | pulls_enabled: true, | |
| 153 | + | releases_enabled: true, | |
| 153 | 154 | is_mirror: false, | |
| 154 | 155 | fork_parent_id: None, | |
| 155 | 156 | next_iid: 1, | |
crates/model/src/entity.rs +5 −0
| @@ -400,6 +400,9 @@ pub struct PullRequest { | |||
| 400 | 400 | ||
| 401 | 401 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the | |
| 402 | 402 | /// database is the sole authority mapping `(owner, path)` to this id. | |
| 403 | + | // Several independent feature toggles (issues/pulls/releases/mirror) are flags, | |
| 404 | + | // not a state machine — a struct of bools is the clearest representation. | |
| 405 | + | #[allow(clippy::struct_excessive_bools)] | |
| 403 | 406 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 404 | 407 | pub struct Repo { | |
| 405 | 408 | /// ULID primary key, and the on-disk directory stem. | |
| @@ -425,6 +428,8 @@ pub struct Repo { | |||
| 425 | 428 | pub issues_enabled: bool, | |
| 426 | 429 | /// Whether pull requests are enabled for this repository. | |
| 427 | 430 | pub pulls_enabled: bool, | |
| 431 | + | /// Whether releases are enabled for this repository. | |
| 432 | + | pub releases_enabled: bool, | |
| 428 | 433 | /// Whether this repository is a mirror (kept in sync from a remote). | |
| 429 | 434 | pub is_mirror: bool, | |
| 430 | 435 | /// The repository this one was forked from, if any. | |
crates/store/src/lib.rs +2 −0
| @@ -33,6 +33,7 @@ mod keys; | |||
| 33 | 33 | mod labels; | |
| 34 | 34 | mod lfs; | |
| 35 | 35 | mod mirrors; | |
| 36 | + | mod releases; | |
| 36 | 37 | mod repos; | |
| 37 | 38 | mod sessions; | |
| 38 | 39 | mod tokens; | |
| @@ -51,6 +52,7 @@ pub use crate::invites::NewInvite; | |||
| 51 | 52 | pub use crate::issues::StateCounts; | |
| 52 | 53 | pub use crate::keys::NewKey; | |
| 53 | 54 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; | |
| 55 | + | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; | |
| 54 | 56 | pub use crate::repos::{Collaborator, NewRepo}; | |
| 55 | 57 | pub use crate::sessions::NewSession; | |
| 56 | 58 | pub use crate::tokens::NewToken; | |
crates/store/src/releases.rs +345 −0
| @@ -0,0 +1,345 @@ | |||
| 1 | + | // This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | // License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | ||
| 5 | + | //! Releases: tag-based release notes and their uploaded assets. The asset bytes | |
| 6 | + | //! live on disk (under the data directory); this layer tracks the metadata. | |
| 7 | + | ||
| 8 | + | use sqlx::any::AnyRow; | |
| 9 | + | use sqlx::{AssertSqlSafe, Row}; | |
| 10 | + | ||
| 11 | + | use crate::{Store, StoreError, new_id, now_ms}; | |
| 12 | + | ||
| 13 | + | /// A release: notes attached to a git tag, optionally a draft or pre-release. | |
| 14 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 15 | + | pub struct Release { | |
| 16 | + | /// ULID primary key. | |
| 17 | + | pub id: String, | |
| 18 | + | /// The repository this release belongs to. | |
| 19 | + | pub repo_id: String, | |
| 20 | + | /// The git tag the release is cut from. | |
| 21 | + | pub tag: String, | |
| 22 | + | /// The release title. | |
| 23 | + | pub name: String, | |
| 24 | + | /// The markdown release notes. | |
| 25 | + | pub body: Option<String>, | |
| 26 | + | /// Whether this is a pre-release (not the "latest"). | |
| 27 | + | pub is_prerelease: bool, | |
| 28 | + | /// Whether this is an unpublished draft. | |
| 29 | + | pub is_draft: bool, | |
| 30 | + | /// The author's user id, if still present. | |
| 31 | + | pub author_id: Option<String>, | |
| 32 | + | /// Creation time. | |
| 33 | + | pub created_at: i64, | |
| 34 | + | /// Last edit time. | |
| 35 | + | pub updated_at: i64, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Fields to create a release. | |
| 39 | + | #[derive(Debug, Clone)] | |
| 40 | + | pub struct NewRelease { | |
| 41 | + | /// The repository. | |
| 42 | + | pub repo_id: String, | |
| 43 | + | /// The git tag. | |
| 44 | + | pub tag: String, | |
| 45 | + | /// The release title. | |
| 46 | + | pub name: String, | |
| 47 | + | /// The markdown body. | |
| 48 | + | pub body: Option<String>, | |
| 49 | + | /// Whether it is a pre-release. | |
| 50 | + | pub is_prerelease: bool, | |
| 51 | + | /// Whether it is a draft. | |
| 52 | + | pub is_draft: bool, | |
| 53 | + | /// The author's user id. | |
| 54 | + | pub author_id: String, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// An uploaded release asset. The bytes are stored on disk keyed by `id`. | |
| 58 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 59 | + | pub struct ReleaseAsset { | |
| 60 | + | /// ULID primary key (and on-disk file stem). | |
| 61 | + | pub id: String, | |
| 62 | + | /// The owning release. | |
| 63 | + | pub release_id: String, | |
| 64 | + | /// The original file name. | |
| 65 | + | pub name: String, | |
| 66 | + | /// Size in bytes. | |
| 67 | + | pub size: i64, | |
| 68 | + | /// The MIME content type. | |
| 69 | + | pub content_type: String, | |
| 70 | + | /// How many times it has been downloaded. | |
| 71 | + | pub download_count: i64, | |
| 72 | + | /// Upload time. | |
| 73 | + | pub created_at: i64, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | const RELEASE_COLUMNS: &str = "id, repo_id, tag, name, body, is_prerelease, is_draft, \ | |
| 77 | + | author_id, created_at, updated_at"; | |
| 78 | + | const ASSET_COLUMNS: &str = "id, release_id, name, size, content_type, download_count, created_at"; | |
| 79 | + | ||
| 80 | + | impl Store { | |
| 81 | + | fn map_release(&self, row: &AnyRow) -> Result<Release, sqlx::Error> { | |
| 82 | + | Ok(Release { | |
| 83 | + | id: row.try_get("id")?, | |
| 84 | + | repo_id: row.try_get("repo_id")?, | |
| 85 | + | tag: row.try_get("tag")?, | |
| 86 | + | name: row.try_get("name")?, | |
| 87 | + | body: row.try_get("body")?, | |
| 88 | + | is_prerelease: self.get_bool(row, "is_prerelease")?, | |
| 89 | + | is_draft: self.get_bool(row, "is_draft")?, | |
| 90 | + | author_id: row.try_get("author_id")?, | |
| 91 | + | created_at: row.try_get("created_at")?, | |
| 92 | + | updated_at: row.try_get("updated_at")?, | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// Create a release. | |
| 97 | + | /// | |
| 98 | + | /// # Errors | |
| 99 | + | /// | |
| 100 | + | /// [`StoreError::Conflict`] if a release already exists for the tag, else | |
| 101 | + | /// [`StoreError::Query`]. | |
| 102 | + | pub async fn create_release(&self, new: NewRelease) -> Result<Release, StoreError> { | |
| 103 | + | let now = now_ms(); | |
| 104 | + | let release = Release { | |
| 105 | + | id: new_id(), | |
| 106 | + | repo_id: new.repo_id, | |
| 107 | + | tag: new.tag, | |
| 108 | + | name: new.name, | |
| 109 | + | body: new.body, | |
| 110 | + | is_prerelease: new.is_prerelease, | |
| 111 | + | is_draft: new.is_draft, | |
| 112 | + | author_id: Some(new.author_id), | |
| 113 | + | created_at: now, | |
| 114 | + | updated_at: now, | |
| 115 | + | }; | |
| 116 | + | sqlx::query( | |
| 117 | + | "INSERT INTO releases \ | |
| 118 | + | (id, repo_id, tag, name, body, is_prerelease, is_draft, author_id, created_at, updated_at) \ | |
| 119 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", | |
| 120 | + | ) | |
| 121 | + | .bind(&release.id) | |
| 122 | + | .bind(&release.repo_id) | |
| 123 | + | .bind(&release.tag) | |
| 124 | + | .bind(&release.name) | |
| 125 | + | .bind(&release.body) | |
| 126 | + | .bind(release.is_prerelease) | |
| 127 | + | .bind(release.is_draft) | |
| 128 | + | .bind(&release.author_id) | |
| 129 | + | .bind(release.created_at) | |
| 130 | + | .bind(release.updated_at) | |
| 131 | + | .execute(&self.pool) | |
| 132 | + | .await | |
| 133 | + | .map_err(|e| crate::map_conflict(e, "release", &[("tag", "tag")]))?; | |
| 134 | + | Ok(release) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// List a repository's releases, newest first (drafts included). | |
| 138 | + | /// | |
| 139 | + | /// # Errors | |
| 140 | + | /// | |
| 141 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 142 | + | pub async fn list_releases(&self, repo_id: &str) -> Result<Vec<Release>, StoreError> { | |
| 143 | + | let sql = format!( | |
| 144 | + | "SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 ORDER BY created_at DESC" | |
| 145 | + | ); | |
| 146 | + | let rows = sqlx::query(AssertSqlSafe(sql)) | |
| 147 | + | .bind(repo_id) | |
| 148 | + | .fetch_all(&self.pool) | |
| 149 | + | .await?; | |
| 150 | + | rows.iter() | |
| 151 | + | .map(|r| self.map_release(r)) | |
| 152 | + | .collect::<Result<_, _>>() | |
| 153 | + | .map_err(Into::into) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// Fetch a release by id. | |
| 157 | + | /// | |
| 158 | + | /// # Errors | |
| 159 | + | /// | |
| 160 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 161 | + | pub async fn release_by_id(&self, id: &str) -> Result<Option<Release>, StoreError> { | |
| 162 | + | let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE id = $1"); | |
| 163 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 164 | + | .bind(id) | |
| 165 | + | .fetch_optional(&self.pool) | |
| 166 | + | .await?; | |
| 167 | + | Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// Fetch a repository's release for a given tag. | |
| 171 | + | /// | |
| 172 | + | /// # Errors | |
| 173 | + | /// | |
| 174 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 175 | + | pub async fn release_by_tag( | |
| 176 | + | &self, | |
| 177 | + | repo_id: &str, | |
| 178 | + | tag: &str, | |
| 179 | + | ) -> Result<Option<Release>, StoreError> { | |
| 180 | + | let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 AND tag = $2"); | |
| 181 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 182 | + | .bind(repo_id) | |
| 183 | + | .bind(tag) | |
| 184 | + | .fetch_optional(&self.pool) | |
| 185 | + | .await?; | |
| 186 | + | Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Update a release's editable fields. | |
| 190 | + | /// | |
| 191 | + | /// # Errors | |
| 192 | + | /// | |
| 193 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 194 | + | pub async fn update_release( | |
| 195 | + | &self, | |
| 196 | + | id: &str, | |
| 197 | + | name: &str, | |
| 198 | + | body: Option<&str>, | |
| 199 | + | is_prerelease: bool, | |
| 200 | + | is_draft: bool, | |
| 201 | + | ) -> Result<(), StoreError> { | |
| 202 | + | sqlx::query( | |
| 203 | + | "UPDATE releases SET name = $1, body = $2, is_prerelease = $3, is_draft = $4, \ | |
| 204 | + | updated_at = $5 WHERE id = $6", | |
| 205 | + | ) | |
| 206 | + | .bind(name) | |
| 207 | + | .bind(body) | |
| 208 | + | .bind(is_prerelease) | |
| 209 | + | .bind(is_draft) | |
| 210 | + | .bind(now_ms()) | |
| 211 | + | .bind(id) | |
| 212 | + | .execute(&self.pool) | |
| 213 | + | .await?; | |
| 214 | + | Ok(()) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | /// Delete a release (its assets cascade). | |
| 218 | + | /// | |
| 219 | + | /// # Errors | |
| 220 | + | /// | |
| 221 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 222 | + | pub async fn delete_release(&self, id: &str) -> Result<bool, StoreError> { | |
| 223 | + | let n = sqlx::query("DELETE FROM releases WHERE id = $1") | |
| 224 | + | .bind(id) | |
| 225 | + | .execute(&self.pool) | |
| 226 | + | .await? | |
| 227 | + | .rows_affected(); | |
| 228 | + | Ok(n > 0) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // ---- Assets ---- | |
| 232 | + | ||
| 233 | + | fn map_asset(row: &AnyRow) -> Result<ReleaseAsset, sqlx::Error> { | |
| 234 | + | Ok(ReleaseAsset { | |
| 235 | + | id: row.try_get("id")?, | |
| 236 | + | release_id: row.try_get("release_id")?, | |
| 237 | + | name: row.try_get("name")?, | |
| 238 | + | size: row.try_get("size")?, | |
| 239 | + | content_type: row.try_get("content_type")?, | |
| 240 | + | download_count: row.try_get("download_count")?, | |
| 241 | + | created_at: row.try_get("created_at")?, | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// Record an uploaded asset, returning its id (the on-disk file stem). | |
| 246 | + | /// | |
| 247 | + | /// # Errors | |
| 248 | + | /// | |
| 249 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 250 | + | pub async fn add_release_asset( | |
| 251 | + | &self, | |
| 252 | + | release_id: &str, | |
| 253 | + | name: &str, | |
| 254 | + | size: i64, | |
| 255 | + | content_type: &str, | |
| 256 | + | ) -> Result<ReleaseAsset, StoreError> { | |
| 257 | + | let asset = ReleaseAsset { | |
| 258 | + | id: new_id(), | |
| 259 | + | release_id: release_id.to_string(), | |
| 260 | + | name: name.to_string(), | |
| 261 | + | size, | |
| 262 | + | content_type: content_type.to_string(), | |
| 263 | + | download_count: 0, | |
| 264 | + | created_at: now_ms(), | |
| 265 | + | }; | |
| 266 | + | sqlx::query( | |
| 267 | + | "INSERT INTO release_assets \ | |
| 268 | + | (id, release_id, name, size, content_type, download_count, created_at) \ | |
| 269 | + | VALUES ($1, $2, $3, $4, $5, $6, $7)", | |
| 270 | + | ) | |
| 271 | + | .bind(&asset.id) | |
| 272 | + | .bind(&asset.release_id) | |
| 273 | + | .bind(&asset.name) | |
| 274 | + | .bind(asset.size) | |
| 275 | + | .bind(&asset.content_type) | |
| 276 | + | .bind(asset.download_count) | |
| 277 | + | .bind(asset.created_at) | |
| 278 | + | .execute(&self.pool) | |
| 279 | + | .await?; | |
| 280 | + | Ok(asset) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// List a release's assets, oldest first. | |
| 284 | + | /// | |
| 285 | + | /// # Errors | |
| 286 | + | /// | |
| 287 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 288 | + | pub async fn list_release_assets( | |
| 289 | + | &self, | |
| 290 | + | release_id: &str, | |
| 291 | + | ) -> Result<Vec<ReleaseAsset>, StoreError> { | |
| 292 | + | let sql = format!( | |
| 293 | + | "SELECT {ASSET_COLUMNS} FROM release_assets WHERE release_id = $1 ORDER BY created_at ASC" | |
| 294 | + | ); | |
| 295 | + | let rows = sqlx::query(AssertSqlSafe(sql)) | |
| 296 | + | .bind(release_id) | |
| 297 | + | .fetch_all(&self.pool) | |
| 298 | + | .await?; | |
| 299 | + | rows.iter() | |
| 300 | + | .map(Self::map_asset) | |
| 301 | + | .collect::<Result<_, _>>() | |
| 302 | + | .map_err(Into::into) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Fetch an asset by id. | |
| 306 | + | /// | |
| 307 | + | /// # Errors | |
| 308 | + | /// | |
| 309 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 310 | + | pub async fn release_asset_by_id(&self, id: &str) -> Result<Option<ReleaseAsset>, StoreError> { | |
| 311 | + | let sql = format!("SELECT {ASSET_COLUMNS} FROM release_assets WHERE id = $1"); | |
| 312 | + | let row = sqlx::query(AssertSqlSafe(sql)) | |
| 313 | + | .bind(id) | |
| 314 | + | .fetch_optional(&self.pool) | |
| 315 | + | .await?; | |
| 316 | + | Ok(row.as_ref().map(Self::map_asset).transpose()?) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// Delete an asset record (the caller removes the on-disk file). | |
| 320 | + | /// | |
| 321 | + | /// # Errors | |
| 322 | + | /// | |
| 323 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 324 | + | pub async fn delete_release_asset(&self, id: &str) -> Result<bool, StoreError> { | |
| 325 | + | let n = sqlx::query("DELETE FROM release_assets WHERE id = $1") | |
| 326 | + | .bind(id) | |
| 327 | + | .execute(&self.pool) | |
| 328 | + | .await? | |
| 329 | + | .rows_affected(); | |
| 330 | + | Ok(n > 0) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// Increment an asset's download counter (best-effort). | |
| 334 | + | /// | |
| 335 | + | /// # Errors | |
| 336 | + | /// | |
| 337 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 338 | + | pub async fn bump_asset_download(&self, id: &str) -> Result<(), StoreError> { | |
| 339 | + | sqlx::query("UPDATE release_assets SET download_count = download_count + 1 WHERE id = $1") | |
| 340 | + | .bind(id) | |
| 341 | + | .execute(&self.pool) | |
| 342 | + | .await?; | |
| 343 | + | Ok(()) | |
| 344 | + | } | |
| 345 | + | } | |
crates/store/src/repos.rs +5 −2
| @@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | |
| 14 | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | |
| 15 | - | default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, fork_parent_id, \ | |
| 16 | - | next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 15 | + | default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \ | |
| 16 | + | fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 17 | 17 | ||
| 18 | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| 19 | 19 | /// and the derived size/push bookkeeping. | |
| @@ -69,6 +69,7 @@ impl Store { | |||
| 69 | 69 | object_format: "sha1".to_string(), | |
| 70 | 70 | issues_enabled: true, | |
| 71 | 71 | pulls_enabled: true, | |
| 72 | + | releases_enabled: true, | |
| 72 | 73 | is_mirror: false, | |
| 73 | 74 | fork_parent_id: None, | |
| 74 | 75 | next_iid: 1, | |
| @@ -277,6 +278,7 @@ impl Store { | |||
| 277 | 278 | let column = match feature { | |
| 278 | 279 | "issues" => "issues_enabled", | |
| 279 | 280 | "pulls" => "pulls_enabled", | |
| 281 | + | "releases" => "releases_enabled", | |
| 280 | 282 | _ => return Ok(false), | |
| 281 | 283 | }; | |
| 282 | 284 | let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3"); | |
| @@ -495,6 +497,7 @@ impl Store { | |||
| 495 | 497 | object_format: row.try_get("object_format")?, | |
| 496 | 498 | issues_enabled: self.get_bool(row, "issues_enabled")?, | |
| 497 | 499 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | |
| 500 | + | releases_enabled: self.get_bool(row, "releases_enabled")?, | |
| 498 | 501 | is_mirror: self.get_bool(row, "is_mirror")?, | |
| 499 | 502 | fork_parent_id: row.try_get("fork_parent_id")?, | |
| 500 | 503 | next_iid: row.try_get("next_iid")?, | |
migrations/postgres/0017_releases.sql +28 −0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | -- Releases: tag-based release notes with optional uploaded assets. Toggleable | |
| 2 | + | -- per repository (releases_enabled), like issues and pull requests. | |
| 3 | + | ALTER TABLE repos ADD COLUMN releases_enabled BOOLEAN NOT NULL DEFAULT TRUE; | |
| 4 | + | ||
| 5 | + | CREATE TABLE releases ( | |
| 6 | + | id TEXT PRIMARY KEY, | |
| 7 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 8 | + | tag TEXT NOT NULL, | |
| 9 | + | name TEXT NOT NULL, | |
| 10 | + | body TEXT, | |
| 11 | + | is_prerelease BOOLEAN NOT NULL DEFAULT FALSE, | |
| 12 | + | is_draft BOOLEAN NOT NULL DEFAULT FALSE, | |
| 13 | + | author_id TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 14 | + | created_at BIGINT NOT NULL, | |
| 15 | + | updated_at BIGINT NOT NULL | |
| 16 | + | ); | |
| 17 | + | CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag); | |
| 18 | + | ||
| 19 | + | CREATE TABLE release_assets ( | |
| 20 | + | id TEXT PRIMARY KEY, | |
| 21 | + | release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | |
| 22 | + | name TEXT NOT NULL, | |
| 23 | + | size BIGINT NOT NULL, | |
| 24 | + | content_type TEXT NOT NULL, | |
| 25 | + | download_count BIGINT NOT NULL DEFAULT 0, | |
| 26 | + | created_at BIGINT NOT NULL | |
| 27 | + | ); | |
| 28 | + | CREATE INDEX idx_release_assets_release ON release_assets (release_id); | |
migrations/sqlite/0017_releases.sql +28 −0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | -- Releases: tag-based release notes with optional uploaded assets. Toggleable | |
| 2 | + | -- per repository (releases_enabled), like issues and pull requests. | |
| 3 | + | ALTER TABLE repos ADD COLUMN releases_enabled INTEGER NOT NULL DEFAULT 1; | |
| 4 | + | ||
| 5 | + | CREATE TABLE releases ( | |
| 6 | + | id TEXT PRIMARY KEY, | |
| 7 | + | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 8 | + | tag TEXT NOT NULL, | |
| 9 | + | name TEXT NOT NULL, | |
| 10 | + | body TEXT, | |
| 11 | + | is_prerelease INTEGER NOT NULL DEFAULT 0, | |
| 12 | + | is_draft INTEGER NOT NULL DEFAULT 0, | |
| 13 | + | author_id TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 14 | + | created_at BIGINT NOT NULL, | |
| 15 | + | updated_at BIGINT NOT NULL | |
| 16 | + | ); | |
| 17 | + | CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag); | |
| 18 | + | ||
| 19 | + | CREATE TABLE release_assets ( | |
| 20 | + | id TEXT PRIMARY KEY, | |
| 21 | + | release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | |
| 22 | + | name TEXT NOT NULL, | |
| 23 | + | size BIGINT NOT NULL, | |
| 24 | + | content_type TEXT NOT NULL, | |
| 25 | + | download_count BIGINT NOT NULL DEFAULT 0, | |
| 26 | + | created_at BIGINT NOT NULL | |
| 27 | + | ); | |
| 28 | + | CREATE INDEX idx_release_assets_release ON release_assets (release_id); | |