audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: add release script with nebula timestamp versioning (#80)

- removes old migration scripts (migrate.sh, migrate_artists.py)
- adds scripts/release.py using YYYY.MMDD.HHMMSS format
- adds 'just release' command to justfile
- script checks branch, working directory, generates version, and creates github release

authored by

nate nowack and committed by
GitHub
529e3096 361af0f1

+129 -162
+4
justfile
··· 25 25 # deploy frontend to cloudflare pages 26 26 deploy-frontend: 27 27 cd frontend && bun run build && bun x wrangler pages deploy .svelte-kit/cloudflare 28 + 29 + # create a github release (triggers production deployment) 30 + release: 31 + uv run scripts/release.py
-6
scripts/migrate.sh
··· 1 - #!/bin/bash 2 - set -e 3 - 4 - echo "running database migrations..." 5 - uv run alembic upgrade head 6 - echo "migrations complete"
-156
scripts/migrate_artists.py
··· 1 - """migrate existing tracks to use Artist model. 2 - 3 - this script: 4 - 1. creates artists table 5 - 2. extracts unique artist data from tracks 6 - 3. fetches avatars from bluesky 7 - 4. creates artist records 8 - 5. adds foreign key constraint 9 - 6. drops old artist fields from tracks 10 - """ 11 - 12 - import asyncio 13 - import sys 14 - from pathlib import Path 15 - 16 - # add src to path 17 - sys.path.insert(0, str(Path(__file__).parent.parent / "src")) 18 - 19 - import httpx 20 - from sqlalchemy import text 21 - 22 - from backend.models import get_db 23 - 24 - 25 - async def fetch_avatar(did: str) -> str | None: 26 - """fetch avatar from bluesky.""" 27 - url = f"https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={did}" 28 - async with httpx.AsyncClient() as client: 29 - try: 30 - response = await client.get(url, timeout=10.0) 31 - if response.status_code == 200: 32 - data = response.json() 33 - return data.get("avatar") 34 - except Exception as e: 35 - print(f"error fetching avatar for {did}: {e}") 36 - return None 37 - 38 - 39 - async def main(): 40 - """run migration.""" 41 - db = next(get_db()) 42 - 43 - try: 44 - # step 1: create artists table 45 - print("creating artists table...") 46 - db.execute( 47 - text( 48 - """ 49 - CREATE TABLE IF NOT EXISTS artists ( 50 - did VARCHAR PRIMARY KEY, 51 - handle VARCHAR NOT NULL, 52 - display_name VARCHAR NOT NULL, 53 - bio VARCHAR, 54 - avatar_url VARCHAR, 55 - created_at TIMESTAMP NOT NULL, 56 - updated_at TIMESTAMP NOT NULL 57 - ) 58 - """ 59 - ) 60 - ) 61 - db.commit() 62 - print("✓ artists table created") 63 - 64 - # step 2: extract unique artists from tracks 65 - print("\nextracting unique artists from tracks...") 66 - result = db.execute( 67 - text( 68 - """ 69 - SELECT DISTINCT 70 - artist_did, 71 - artist_handle, 72 - (ARRAY_AGG(artist ORDER BY id))[1] as display_name 73 - FROM tracks 74 - WHERE artist_did IS NOT NULL 75 - GROUP BY artist_did, artist_handle 76 - """ 77 - ) 78 - ) 79 - unique_artists = result.fetchall() 80 - print(f"✓ found {len(unique_artists)} unique artist(s)") 81 - 82 - # step 3 & 4: fetch avatars and create artist records 83 - print("\ncreating artist records...") 84 - for row in unique_artists: 85 - did, handle, display_name = row 86 - print("\nprocessing artist:") 87 - print(f" did: {did}") 88 - print(f" handle: {handle}") 89 - print(f" display_name: {display_name}") 90 - 91 - # fetch avatar 92 - print(" fetching avatar from bluesky...") 93 - avatar_url = await fetch_avatar(did) 94 - if avatar_url: 95 - print(f" ✓ avatar found: {avatar_url}") 96 - else: 97 - print(" ✗ no avatar found") 98 - 99 - # create artist record 100 - from datetime import datetime 101 - 102 - db.execute( 103 - text( 104 - """ 105 - INSERT INTO artists (did, handle, display_name, bio, avatar_url, created_at, updated_at) 106 - VALUES (:did, :handle, :display_name, NULL, :avatar_url, :created_at, :updated_at) 107 - ON CONFLICT (did) DO NOTHING 108 - """ 109 - ), 110 - { 111 - "did": did, 112 - "handle": handle, 113 - "display_name": display_name, 114 - "avatar_url": avatar_url, 115 - "created_at": datetime.utcnow(), 116 - "updated_at": datetime.utcnow(), 117 - }, 118 - ) 119 - db.commit() 120 - print(" ✓ artist record created") 121 - 122 - # step 5: add foreign key constraint 123 - print("\nadding foreign key constraint...") 124 - db.execute( 125 - text( 126 - """ 127 - ALTER TABLE tracks 128 - ADD CONSTRAINT fk_tracks_artist_did 129 - FOREIGN KEY (artist_did) REFERENCES artists(did) 130 - """ 131 - ) 132 - ) 133 - db.commit() 134 - print("✓ foreign key constraint added") 135 - 136 - # step 6: drop old columns 137 - print("\ndropping old artist columns from tracks...") 138 - db.execute(text("ALTER TABLE tracks DROP COLUMN artist")) 139 - db.execute(text("ALTER TABLE tracks DROP COLUMN artist_handle")) 140 - db.commit() 141 - print("✓ old columns dropped") 142 - 143 - print("\n" + "=" * 60) 144 - print("migration completed successfully!") 145 - print("=" * 60) 146 - 147 - except Exception as e: 148 - print(f"\nerror during migration: {e}") 149 - db.rollback() 150 - raise 151 - finally: 152 - db.close() 153 - 154 - 155 - if __name__ == "__main__": 156 - asyncio.run(main())
+125
scripts/release.py
··· 1 + #!/usr/bin/env python3 2 + """create a github release using timestamp-based versioning (nebula strategy). 3 + 4 + version format: YYYY.MMDD.HHMMSS (e.g., 2025.1106.134523) 5 + """ 6 + 7 + import subprocess 8 + import sys 9 + from datetime import UTC, datetime 10 + 11 + 12 + def get_timestamp_version() -> str: 13 + """generate version string using nebula's timestamp strategy.""" 14 + now = datetime.now(UTC) 15 + # format: YYYY.MMDD.HHMMSS 16 + return now.strftime("%Y.%m%d.%H%M%S") 17 + 18 + 19 + def get_recent_commits(since_tag: str | None = None) -> list[str]: 20 + """get commit messages since last tag or all recent commits.""" 21 + if since_tag: 22 + cmd = ["git", "log", f"{since_tag}..HEAD", "--oneline"] 23 + else: 24 + cmd = ["git", "log", "--oneline", "-20"] 25 + 26 + result = subprocess.run(cmd, capture_output=True, text=True, check=True) 27 + return [line for line in result.stdout.strip().split("\n") if line] 28 + 29 + 30 + def get_latest_tag() -> str | None: 31 + """get the most recent git tag.""" 32 + result = subprocess.run( 33 + ["git", "describe", "--tags", "--abbrev=0"], 34 + capture_output=True, 35 + text=True, 36 + ) 37 + return result.stdout.strip() if result.returncode == 0 else None 38 + 39 + 40 + def create_release(version: str, notes: str) -> None: 41 + """create github release using gh cli.""" 42 + cmd = [ 43 + "gh", 44 + "release", 45 + "create", 46 + version, 47 + "--title", 48 + version, 49 + "--notes", 50 + notes, 51 + ] 52 + 53 + subprocess.run(cmd, check=True) 54 + print(f"✓ created release {version}") 55 + print("✓ deployment to production will start automatically") 56 + 57 + 58 + def main() -> int: 59 + """main entry point.""" 60 + # ensure we're on main branch 61 + result = subprocess.run( 62 + ["git", "branch", "--show-current"], 63 + capture_output=True, 64 + text=True, 65 + check=True, 66 + ) 67 + current_branch = result.stdout.strip() 68 + 69 + if current_branch != "main": 70 + print(f"error: must be on main branch (currently on {current_branch})") 71 + return 1 72 + 73 + # ensure working directory is clean 74 + result = subprocess.run( 75 + ["git", "status", "--porcelain"], 76 + capture_output=True, 77 + text=True, 78 + check=True, 79 + ) 80 + if result.stdout.strip(): 81 + print("error: working directory has uncommitted changes") 82 + return 1 83 + 84 + # generate version 85 + version = get_timestamp_version() 86 + print(f"version: {version}") 87 + 88 + # get commits since last tag 89 + latest_tag = get_latest_tag() 90 + commits = get_recent_commits(latest_tag) 91 + 92 + if not commits: 93 + print("warning: no commits since last release") 94 + 95 + # generate release notes 96 + notes_lines = [] 97 + if latest_tag: 98 + notes_lines.append(f"changes since {latest_tag}:\n") 99 + else: 100 + notes_lines.append("initial production release\n") 101 + 102 + for commit in commits: 103 + notes_lines.append(f"- {commit}") 104 + 105 + notes = "\n".join(notes_lines) 106 + 107 + # show preview 108 + print("\nrelease notes:") 109 + print(notes) 110 + print() 111 + 112 + # confirm 113 + response = input(f"create release {version}? [y/N] ") 114 + if response.lower() != "y": 115 + print("cancelled") 116 + return 0 117 + 118 + # create release 119 + create_release(version, notes) 120 + 121 + return 0 122 + 123 + 124 + if __name__ == "__main__": 125 + sys.exit(main())