From 8cfc8f1d14d045c67d2f4e2cee2714b23c178ea1 Mon Sep 17 00:00:00 2001 From: Gaurav Chavan Date: Wed, 22 Jul 2026 15:57:00 +0530 Subject: [PATCH 1/3] [AIOS-398] added 2 new repos to the list --- generate_input_json.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/generate_input_json.py b/generate_input_json.py index 5ed18fa..d69dce3 100755 --- a/generate_input_json.py +++ b/generate_input_json.py @@ -85,6 +85,14 @@ "aiden-ui": { "version_key": "AIDEN_UI_VERSION", "repository": "https://github.com/appcd-dev/aiden-ui-v2" + }, + "stackgen-guild": { + "version_key": "STACKGEN_GUILD_VERSION", + "repository": "https://github.com/appcd-dev/stackgen-guild" + }, + "stackgen-sre-app": { + "version_key": "STACKGEN_SRE_APP_VERSION", + "repository": "https://github.com/appcd-dev/stackgen-sre-app" } } From 06bda62b9643b5cd3dd4ea85cd8a941b5c4f4434 Mon Sep 17 00:00:00 2001 From: Gaurav Chavan Date: Fri, 24 Jul 2026 23:27:15 +0530 Subject: [PATCH 2/3] [HZ-815] Automated release ticket creation --- Makefile | 173 ++++++++- README.md | 318 +++++++++++---- create_monthly_release_ticket.py | 646 +++++++++++++++++++++++-------- docs/create-release-ticket.md | 262 +++++++++++++ generate_input_json.py | 4 +- process_all_repos.py | 83 +++- release_pipeline/pipeline.py | 8 + run_monthly_release.py | 26 +- 8 files changed, 1259 insertions(+), 261 deletions(-) create mode 100644 docs/create-release-ticket.md diff --git a/Makefile b/Makefile index 53511d3..1f118f1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup generate-input generate-input-custom generate-custom-input-file fetch_changes_between_tags_from_input clean test-linear monthly-release monthly-release-no-ticket +.PHONY: help setup generate-input generate-input-custom generate-custom-input-file fetch_changes_between_tags_from_input clean test-linear monthly-release monthly-release-no-ticket create-release-ticket create-release-ticket-only # Configuration PYTHON := python3 @@ -7,10 +7,20 @@ VERSION_URL := https://cloud.stackgen.com/version.json # New versions: raw .env from appcd-dist at STACKGEN_TAG (required for generate-input), e.g. # https://raw.githubusercontent.com/appcd-dev/appcd-dist/v2026.3.12/.env APPCD_DIST_RAW_ENV = https://raw.githubusercontent.com/appcd-dev/appcd-dist/$(STACKGEN_TAG)/.env -INPUT_FILE := generated_files/input_file/input.json -OUTPUT_FILE := generated_files/final_tag_differences.json + +# Artifact root. Default for legacy targets: generated_files/ +# create-release-ticket overrides this to $(FROM_REF)-$(TO_REF) (e.g. v2026.7.3-v2026.7.7) +GENERATED_DIR ?= generated_files +INPUT_FILE := $(GENERATED_DIR)/input_file/input.json +OUTPUT_FILE := $(GENERATED_DIR)/final_tag_differences.json +COMMIT_DIFF_FILE := $(GENERATED_DIR)/commit_differences_with_messages.txt ENV_FILE := .env +# create-release-ticket defaults (override on the command line) +RELEASE_KIND ?= weekly +ASSIGNEE_QUERY ?= gaurav@stackgen.com +STATE_NAME ?= Todo + # Default target help: @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -22,18 +32,41 @@ help: @echo " make setup - Set up environment and test Linear API" @echo " make generate-input STACKGEN_TAG= - input.json (prod version.json + raw appcd-dist .env at tag)" @echo " make generate-input-custom STACKGEN_TAG= - Generate input.json (STACKGEN_TAG required)" - @echo " make generate-custom-input-file - input.json from appcd-dist .env between FROM_REF and TO_REF" + @echo " make generate-custom-input-file FROM_REF= TO_REF=" + @echo " - input.json from appcd-dist .env between two refs" @echo " make fetch_changes_between_tags_from_input - Extract ticket changes between versions" @echo " make monthly-release STACKGEN_TAG= - Full pipeline: clean → prod input + .env → tickets → Linear" @echo " make monthly-release-no-ticket STACKGEN_TAG= - Steps 1–3 only (no Linear issue)" + @echo " make create-release-ticket FROM_REF= TO_REF=" + @echo " - clean → custom input → fetch → Linear ticket" + @echo " - artifacts under -/" + @echo " make create-release-ticket-only FROM_REF= TO_REF=" + @echo " - Create Linear ticket from -/ artifacts" @echo " make full-workflow - Run complete workflow (generate + process)" @echo " make test-linear - Test Linear API connection" - @echo " make clean - Remove generated files" + @echo " make clean - Remove generated_files/ (or GENERATED_DIR=…)" @echo "" @echo "Configuration:" - @echo " VERSION_URL = $(VERSION_URL)" + @echo " VERSION_URL = $(VERSION_URL)" + @echo " GENERATED_DIR = $(GENERATED_DIR) (default: generated_files)" @echo " (generate-input) STACKGEN_TAG required — .env = appcd-dist raw at that tag" @echo "" + @echo "create-release-ticket parameters:" + @echo " FROM_REF required — appcd-dist base ref/tag (current)" + @echo " TO_REF required — appcd-dist candidate ref/tag (new); also used as STACKGEN_TAG" + @echo " STACKGEN_TAG optional — overrides title tag (default: TO_REF)" + @echo " RELEASE_KIND optional — weekly|monthly (default: weekly)" + @echo " MONTH_LABEL optional — e.g. \"July 2026\"" + @echo " ASSIGNEE_QUERY optional — default gaurav@stackgen.com" + @echo " STATE_NAME optional — default Todo" + @echo " OUT_DIR optional — artifact dir (default: -)" + @echo " DRY_RUN=1 optional — preview Linear body only (skip issueCreate)" + @echo "" + @echo "Example:" + @echo " make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 MONTH_LABEL=\"July 2026\"" + @echo " # writes to v2026.7.3-v2026.7.7/" + @echo " make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1" + @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Setup environment and test Linear API @@ -125,6 +158,7 @@ generate-input-custom: --pretty # Generate input.json by comparing appcd-dist .env between two refs/tags/branches +# Required: FROM_REF TO_REF (interactive prompt only if either is missing) generate-custom-input-file: @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "Generating custom input.json from appcd-dist .env refs..." @@ -145,6 +179,7 @@ generate-custom-input-file: fi; \ echo ""; \ echo "Comparing appcd-dist refs: $$from_ref → $$to_ref"; \ + echo "Artifact dir: $(GENERATED_DIR)"; \ echo "Output: $(INPUT_FILE)"; \ echo ""; \ $(PYTHON) generate_custom_input_file.py \ @@ -154,22 +189,26 @@ generate-custom-input-file: --pretty # Process all repos and extract ticket changes +# Override artifact root: make fetch_changes_between_tags_from_input GENERATED_DIR=v2026.7.3-v2026.7.7 fetch_changes_between_tags_from_input: @if [ ! -f "$(INPUT_FILE)" ]; then \ - echo "❌ Error: $(INPUT_FILE) not found. Run 'make generate-input' first."; \ + echo "❌ Error: $(INPUT_FILE) not found. Run 'make generate-input' or 'make generate-custom-input-file' first."; \ exit 1; \ fi @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "Processing all repositories and extracting ticket changes..." + @echo "Artifact dir: $(GENERATED_DIR)" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "" @$(PYTHON) process_all_repos.py \ --input "$(INPUT_FILE)" \ + --output "$(OUTPUT_FILE)" \ + --commit-diff-log "$(COMMIT_DIFF_FILE)" \ --verbose \ --pretty @echo "" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - @echo "✅ Processing complete! Check the output file above." + @echo "✅ Processing complete! Output: $(OUTPUT_FILE)" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Run complete workflow: generate input + process changes @@ -224,12 +263,116 @@ monthly-release-no-ticket: if [ -n "$(VERSION_JSON_URL)" ]; then EXTRA="$$EXTRA --version-json-url $(VERSION_JSON_URL)"; fi; \ $(PYTHON) run_monthly_release.py "$(STACKGEN_TAG)" $$EXTRA -# Clean generated files +# Full weekly/monthly release ticket pipeline: +# clean OUT_DIR → generate-custom-input-file → fetch_changes → Linear ticket +# Artifacts written to OUT_DIR (default: -/), e.g. v2026.7.3-v2026.7.7/ +# +# Required: FROM_REF TO_REF +# Optional: OUT_DIR, STACKGEN_TAG, RELEASE_KIND, MONTH_LABEL, ASSIGNEE_QUERY, STATE_NAME, DRY_RUN=1 +create-release-ticket: + @if [ -z "$(FROM_REF)" ] || [ -z "$(TO_REF)" ]; then \ + echo "❌ FROM_REF and TO_REF are required."; \ + echo ""; \ + echo "Usage:"; \ + echo " make create-release-ticket FROM_REF= TO_REF= [options]"; \ + echo ""; \ + echo "Options:"; \ + echo " OUT_DIR= artifact dir (default: -)"; \ + echo " STACKGEN_TAG= title tag (default: TO_REF)"; \ + echo " RELEASE_KIND=weekly|monthly (default: weekly)"; \ + echo " MONTH_LABEL=\"July 2026\""; \ + echo " ASSIGNEE_QUERY=gaurav@stackgen.com"; \ + echo " STATE_NAME=Todo"; \ + echo " DRY_RUN=1 preview only"; \ + echo ""; \ + echo "Example:"; \ + echo " make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 MONTH_LABEL=\"July 2026\""; \ + exit 1; \ + fi + @from_safe=$$(printf '%s' "$(FROM_REF)" | sed 's|[/ :]|-|g'); \ + to_safe=$$(printf '%s' "$(TO_REF)" | sed 's|[/ :]|-|g'); \ + out_dir="$(OUT_DIR)"; \ + if [ -z "$$out_dir" ]; then out_dir="$${from_safe}-$${to_safe}"; fi; \ + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; \ + echo "create-release-ticket pipeline"; \ + echo " FROM_REF=$(FROM_REF) → TO_REF=$(TO_REF)"; \ + echo " OUT_DIR=$$out_dir"; \ + echo " STACKGEN_TAG=$(if $(STACKGEN_TAG),$(STACKGEN_TAG),$(TO_REF))"; \ + echo " RELEASE_KIND=$(RELEASE_KIND) DRY_RUN=$(if $(DRY_RUN),$(DRY_RUN),0)"; \ + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; \ + echo ""; \ + echo "▶ Step 1/4 — clean $$out_dir"; \ + rm -rf "$$out_dir"; \ + echo "✅ Removed $$out_dir (if it existed)"; \ + echo ""; \ + echo "▶ Step 2/4 — generate-custom-input-file → $$out_dir/"; \ + $(MAKE) generate-custom-input-file \ + FROM_REF="$(FROM_REF)" \ + TO_REF="$(TO_REF)" \ + GENERATED_DIR="$$out_dir"; \ + echo ""; \ + echo "▶ Step 3/4 — fetch_changes_between_tags_from_input → $$out_dir/"; \ + $(MAKE) fetch_changes_between_tags_from_input GENERATED_DIR="$$out_dir"; \ + echo ""; \ + echo "▶ Step 4/4 — create Linear release ticket"; \ + $(MAKE) create-release-ticket-only \ + FROM_REF="$(FROM_REF)" \ + TO_REF="$(TO_REF)" \ + GENERATED_DIR="$$out_dir" \ + STACKGEN_TAG="$(if $(STACKGEN_TAG),$(STACKGEN_TAG),$(TO_REF))" \ + RELEASE_KIND="$(RELEASE_KIND)" \ + MONTH_LABEL="$(MONTH_LABEL)" \ + ASSIGNEE_QUERY="$(ASSIGNEE_QUERY)" \ + STATE_NAME="$(STATE_NAME)" \ + DRY_RUN="$(DRY_RUN)"; \ + echo ""; \ + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; \ + echo "✅ create-release-ticket pipeline finished"; \ + echo " Artifacts: $$out_dir/"; \ + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Create HZ Linear release ticket from existing tag-diff artifacts only. +# +# Prefer: FROM_REF + TO_REF → reads -/final_tag_differences.json +# Or set: GENERATED_DIR=... / OUT_DIR=... +# Required: TO_REF or STACKGEN_TAG (for title) +create-release-ticket-only: + @tag="$(STACKGEN_TAG)"; \ + if [ -z "$$tag" ]; then tag="$(TO_REF)"; fi; \ + if [ -z "$$tag" ]; then \ + echo "❌ STACKGEN_TAG or TO_REF is required."; \ + echo "Usage: make create-release-ticket-only FROM_REF=v2026.7.3 TO_REF=v2026.7.7 [DRY_RUN=1]"; \ + exit 1; \ + fi; \ + out_dir="$(GENERATED_DIR)"; \ + if [ -n "$(OUT_DIR)" ]; then out_dir="$(OUT_DIR)"; fi; \ + if [ "$$out_dir" = "generated_files" ] && [ -n "$(FROM_REF)" ] && [ -n "$(TO_REF)" ]; then \ + from_safe=$$(printf '%s' "$(FROM_REF)" | sed 's|[/ :]|-|g'); \ + to_safe=$$(printf '%s' "$(TO_REF)" | sed 's|[/ :]|-|g'); \ + out_dir="$${from_safe}-$${to_safe}"; \ + fi; \ + output_file="$$out_dir/final_tag_differences.json"; \ + services_input="$$out_dir/input_file/input.json"; \ + if [ ! -f "$$output_file" ]; then \ + echo "❌ Error: $$output_file not found."; \ + echo " Run: make create-release-ticket FROM_REF=… TO_REF=…"; \ + exit 1; \ + fi; \ + EXTRA="--release-kind $(RELEASE_KIND) --stackgen-tag $$tag --assignee-query $(ASSIGNEE_QUERY) --state-name $(STATE_NAME)"; \ + if [ -n "$(MONTH_LABEL)" ]; then EXTRA="$$EXTRA --month-label \"$(MONTH_LABEL)\""; fi; \ + if [ "$(DRY_RUN)" = "1" ]; then EXTRA="$$EXTRA --dry-run"; fi; \ + if [ -f "$$services_input" ]; then EXTRA="$$EXTRA --services-input \"$$services_input\""; fi; \ + echo "Creating Linear ticket from $$output_file (tag=$$tag)…"; \ + eval $(PYTHON) create_monthly_release_ticket.py --input "$$output_file" $$EXTRA + +# Clean generated artifacts +# make clean → removes generated_files/ +# make clean GENERATED_DIR=v2026.7.3-v2026.7.7 → removes that dir only clean: - @echo "🧹 Cleaning generated files..." - @rm -rf generated_files/ + @echo "🧹 Cleaning $(GENERATED_DIR)..." + @rm -rf "$(GENERATED_DIR)" @rm -rf __pycache__ release_pipeline/__pycache__ - @echo "✅ Cleaned!" + @echo "✅ Cleaned $(GENERATED_DIR)/" # Show current configuration config: @@ -237,7 +380,13 @@ config: @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @echo "VERSION_URL = $(VERSION_URL)" @echo "ENV_URL = $(ENV_URL)" + @echo "GENERATED_DIR = $(GENERATED_DIR)" @echo "INPUT_FILE = $(INPUT_FILE)" + @echo "OUTPUT_FILE = $(OUTPUT_FILE)" + @echo "FROM_REF = $(FROM_REF)" + @echo "TO_REF = $(TO_REF)" + @echo "STACKGEN_TAG = $(STACKGEN_TAG)" + @echo "RELEASE_KIND = $(RELEASE_KIND)" @echo "LINEAR_API_KEY = $${LINEAR_API_KEY:+Set (hidden)}$${LINEAR_API_KEY:-Not set}" @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/README.md b/README.md index fa137fc..5ad2cf2 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,299 @@ # StackGen release-utils -Python utilities for StackGen version checks, tag-to-tag diffs, and **monthly release automation** (input generation, Linear ticket extraction, optional Linear issue creation). +Release engineering toolkit for **StackGen** product cuts. -All tag-diff and pipeline scripts live in the **repository root** (no `tags-diff/` subdirectory). +This repository helps release managers: + +1. Compare service versions between two distribution tags (or deployed vs candidate). +2. Extract Linear tickets and projects shipped in that delta. +3. Open a structured **HZ** Linear release issue for QA, Docs, and engineering. +4. Optionally curate changelog / “what’s new” documentation from the same artifacts. + +Scripts and Make targets live at the **repository root**. Detailed operator steps for the primary ticket flow are in [`docs/create-release-ticket.md`](docs/create-release-ticket.md). + +--- + +## Who this is for + +| Role | Typical use | +|------|-------------| +| Release manager | Run `create-release-ticket`, review the Linear issue, hand off to QA/Docs | +| Docs | Use ticket tables / curated changelog for release notes | +| Engineering | Inspect per-service tag diffs and commit logs | +| CI | Run tag-diff artifact generation without creating Linear issues | --- -## Prerequisites +## Concepts + +| Term | Meaning | +|------|---------| +| **appcd-dist** | Distribution repo whose `.env` pins every service image/tag for a StackGen release | +| **FROM_REF** | Base `appcd-dist` tag or branch (versions you are leaving) | +| **TO_REF** | Candidate `appcd-dist` tag or branch (versions you are shipping) | +| **input.json** | Per-service matrix: repo + `current_tag` → `new_tag` | +| **final_tag_differences.json** | Tickets, Linear states, and projects for bumped services | +| **HZ release ticket** | Linear issue on team **HZ** summarizing the cut for the org | + +```text +FROM_REF (.env) ──compare──► TO_REF (.env) + │ │ + └──────── input.json ────────┘ + │ + process_all_repos + │ + final_tag_differences.json + │ + Linear HZ issue +``` + +--- + +## Quick start + +### 1. Install ```bash +cd release-utils pip install -r requirements.txt ``` -For GitHub API calls (rate limits, private repos): +### 2. Credentials + +```bash +export GITHUB_PAT=ghp_... # or GH_TOKEN — private repo / API access +export LINEAR_API_KEY=lin_api_... # ticket enrichment + issue create +``` + +Optional local file (never commit secrets): ```bash -export GITHUB_PAT=ghp_... # or GH_TOKEN / GITHUB_TOKEN +make setup # copies env.template → .env if missing; smoke-tests Linear ``` -For Linear-enriched output (`process_all_repos`, monthly ticket body): +### 3. Primary workflow — create a release ticket + +Compare two `appcd-dist` tags, extract changes, and open (or preview) the Linear issue: ```bash -export LINEAR_API_KEY=lin_api_... +# Preview (recommended first) +make create-release-ticket \ + FROM_REF=v2026.7.3 \ + TO_REF=v2026.7.7 \ + MONTH_LABEL="July 2026" \ + DRY_RUN=1 + +# Create the Linear issue for real +make create-release-ticket \ + FROM_REF=v2026.7.3 \ + TO_REF=v2026.7.7 \ + MONTH_LABEL="July 2026" ``` -Optional `make setup` copies `env.template` → `.env` for local `LINEAR_API_KEY`. +Artifacts are written under **`-/`** (for example `v2026.7.3-v2026.7.7/`), not under a shared `generated_files/` folder. + +Full step-by-step documentation: [`docs/create-release-ticket.md`](docs/create-release-ticket.md). --- -## Tag diff & monthly release (main flow) +## What `create-release-ticket` does -### Outputs (generated, gitignored) +Executed **in order**: -| Path | Purpose | -|------|---------| -| `generated_files/input_file/input.json` | Service → repo → current/new tags | -| `generated_files/final_tag_differences.json` | Consolidated tickets + projects | -| `generated_files/commit_differences_with_messages.txt` | Raw commit compare logs (optional) | +| Step | Action | Result | +|------|--------|--------| +| 1 | Clean the tag-pair directory | Removes `-/` if it exists | +| 2 | `generate-custom-input-file` | Reads both `.env` files → `input.json` | +| 3 | `fetch_changes_between_tags_from_input` | GitHub compares + Linear enrichment | +| 4 | `create-release-ticket-only` | Builds and creates the HZ Linear issue | -### Makefile (from repo root) +### Linear issue shape -| Target | Purpose | -|--------|---------| -| `make help` | List targets | -| `make setup` | `.env` from template + Linear smoke test | -| `make generate-input STACKGEN_TAG=v2026.3.12` | Production `version.json` + **raw** appcd-dist `.env` at that tag | -| `make generate-input-custom STACKGEN_TAG=vX.Y.Z` | Interactive **version.json** only; `.env` is always raw at that tag | -| `make fetch_changes_between_tags_from_input` | Run `process_all_repos.py` on `input.json` | -| `make full-workflow STACKGEN_TAG=` | `generate-input` then `fetch_changes...` | -| `make monthly-release STACKGEN_TAG=vX.Y.Z` | Full pipeline: clean → prod input → tickets → **Linear issue** | -| `make monthly-release-no-ticket STACKGEN_TAG=vX.Y.Z` | Steps 1–3 only (no Linear create) | -| `make clean` | Remove `generated_files/` + local `__pycache__` | -| `make test-linear` | Linear API test | +| Field | Value | +|-------|--------| +| Team | HZ | +| Title | `[Weekly release] ` or `[Monthly release] ` | +| Assignee | `gaurav@stackgen.com` | +| Status | `Todo` | +| Body | Release Candidate Tags (all services) · AIOS / DPP / CORE tables (linked ID, status, summary) · other teams · Projects (linked) | + +### Artifact directory layout + +```text +v2026.7.3-v2026.7.7/ +├── input_file/ +│ └── input.json +├── final_tag_differences.json +├── commit_differences_with_messages.txt +└── projects_list.json +``` + +### Useful variants + +```bash +# Recreate / re-preview the ticket from existing artifacts +make create-release-ticket-only FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1 + +# Custom artifact folder name +make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 OUT_DIR=july-rc +``` -### One-shot pipeline (`run_monthly_release.py`) +Parameters: `FROM_REF`, `TO_REF`, `OUT_DIR`, `STACKGEN_TAG`, `RELEASE_KIND`, `MONTH_LABEL`, `ASSIGNEE_QUERY`, `STATE_NAME`, `DRY_RUN=1`. Run `make help` for the full list. -Non-interactive: **production** `version.json` (`https://cloud.stackgen.com/version.json`), appcd-dist **raw** `.env` at your tag, then tickets, then optional Linear ticket. +--- + +## Alternate workflow — deployed vs candidate (`monthly-release`) + +Use this when **FROM** should be whatever is currently deployed (via `version.json`) and **TO** is a single `appcd-dist` tag. ```bash -# Full flow (needs LINEAR_API_KEY for step 4) -python run_monthly_release.py v2026.2.7 +# Artifacts only (no Linear create) +make monthly-release-no-ticket STACKGEN_TAG=v2026.7.7 + +# Full pipeline including Linear issue (legacy monthly path) +make monthly-release STACKGEN_TAG=v2026.7.7 -# Use stage/demo deployed versions instead (optional) -python run_monthly_release.py v2026.2.7 --version-json-url https://stage.dev.stackgen.com/version.json +# Or via Python +python run_monthly_release.py v2026.7.7 --skip-ticket +python run_monthly_release.py v2026.7.7 --dry-run-ticket +``` + +Defaults: -# CI / artifacts only -python run_monthly_release.py v2026.2.7 --skip-ticket +- Deployed versions: `https://cloud.stackgen.com/version.json` (override with `VERSION_JSON_URL` / `--version-json-url`) +- Candidate versions: raw `.env` at `https://raw.githubusercontent.com/appcd-dev/appcd-dist//.env` +- Artifacts: `generated_files/` + +--- + +## Makefile reference + +Run `make help` from the repo root for the live list. + +| Target | Purpose | +|--------|---------| +| `make setup` | Create `.env` from template; test Linear | +| `make create-release-ticket FROM_REF=… TO_REF=…` | **Primary:** clean → input → fetch → Linear ticket | +| `make create-release-ticket-only FROM_REF=… TO_REF=…` | Linear ticket from existing tag-pair artifacts | +| `make generate-custom-input-file FROM_REF=… TO_REF=…` | Build `input.json` from two `appcd-dist` refs | +| `make fetch_changes_between_tags_from_input` | Process `input.json` → ticket/project JSON | +| `make generate-input STACKGEN_TAG=…` | `version.json` + candidate `.env` → `input.json` | +| `make generate-input-custom STACKGEN_TAG=…` | Interactive `version.json` URL picker | +| `make full-workflow STACKGEN_TAG=…` | `generate-input` + fetch | +| `make monthly-release STACKGEN_TAG=…` | Clean → prod input → fetch → Linear | +| `make monthly-release-no-ticket STACKGEN_TAG=…` | Same without Linear create | +| `make clean` | Remove `generated_files/` (or `GENERATED_DIR=…`) | +| `make test-linear` | Linear API connectivity check | +| `make config` | Print effective Make variables | + +Override the artifact root for shared targets with `GENERATED_DIR=…` (defaults to `generated_files`). + +--- -# Preview Linear body without creating -python run_monthly_release.py v2026.2.7 --dry-run-ticket +## Repository layout + +```text +release-utils/ +├── Makefile # Operator entry points +├── docs/ +│ └── create-release-ticket.md # Full create-release-ticket sequence +├── .cursor/skills/build-changelog/ # Cursor skill for curated changelogs +├── release_pipeline/ # monthly-release orchestration +├── generate_custom_input_file.py # FROM_REF / TO_REF → input.json +├── generate_input_json.py # version.json + .env → input.json +├── process_all_repos.py # Tag compares + Linear enrichment +├── create_monthly_release_ticket.py # HZ Linear issue body + create +├── run_monthly_release.py # One-shot monthly pipeline +├── build_project_changelogs.py # Per-project Markdown from GitHub Releases +├── compare_tags.py # Single-repo GitHub compare helper +├── fetch_version.py / compare_versions.py +├── env.template +└── .github/workflows/tags-diff-release.yml ``` -Use `--project-root` if the script is not run from the repo root. +--- -`make generate-input STACKGEN_TAG=` loads new versions from -`https://raw.githubusercontent.com/appcd-dev/appcd-dist//.env` (always the raw URL). Override deployed `version.json` with `VERSION_URL=...` if needed. -`make monthly-release` / `monthly-release-no-ticket` use the same raw `.env` pattern; optional **`VERSION_JSON_URL=...`** only changes which **version.json** URL is used. +## Script catalog -### Monthly Linear issue (`create_monthly_release_ticket.py`) +### Core release path -Creates an issue from `generated_files/final_tag_differences.json` (after a successful fetch step). Configure template, team (`--team-key HZ`), assignee, etc. See `python create_monthly_release_ticket.py --help`. +| Script | Role | +|--------|------| +| `generate_custom_input_file.py` | Compare two `appcd-dist` `.env` refs into `input.json` | +| `generate_input_json.py` | Build `input.json` from deployed `version.json` + candidate `.env` | +| `process_all_repos.py` | For each bumped service: GitHub compare, ticket extract, Linear enrich | +| `create_monthly_release_ticket.py` | Format and create the HZ Linear release issue | +| `run_monthly_release.py` | Orchestrate clean → input → fetch → optional ticket | -### Other CLI tools +### Supporting utilities | Script | Role | |--------|------| -| `verify_latest_tags_vs_appcd_dist_env.py` | Compare latest GitHub tags vs appcd-dist `main` `.env` | -| `parse_ui_changes_tickets.py` | Parse ticket IDs from text | -| `scan_ticket_formats.py` | Scan files for ticket ID patterns | -| `fetchTicketChangesInBuildsForRepo.py` | Single-repo ticket extraction via `compare_tags.py` | -| `compare_tags.py` | GitHub compare API between two tags | -| `fetch_version_json.py` | Fetch/print a `version.json` URL | -| `test_linear_api.py` | Linear connectivity | +| `build_project_changelogs.py` | Per Linear-prefix changelogs from GitHub Release notes | +| `compare_tags.py` | Low-level GitHub tag compare | +| `fetchTicketChangesInBuildsForRepo.py` | Single-repo ticket extraction | +| `verify_latest_tags_vs_appcd_dist_env.py` | Latest GitHub tags vs `appcd-dist` `main` `.env` | +| `fetch_version.py` / `fetch_version_json.py` | Fetch environment `version.json` | +| `compare_versions.py` | Deployed versions vs a repo `.env` | +| `parse_ui_changes_tickets.py` / `scan_ticket_formats.py` | Ticket ID parsing helpers | +| `test_linear_api.py` | Linear API smoke test | +| `generate_whats_new_pdf.py` | PDF “what’s new” (content curated separately) | --- -## Original version utilities (repo root) - -These predate the tag-diff stack and remain unchanged in behavior: +## Curated changelogs (Cursor) -- **`fetch_version.py`** — fetch StackGen environment `version.json` (interactive or CLI). -- **`compare_versions.py`** — compare deployed versions vs repo `.env` via GitHub. +For documentation-oriented release notes (highlights/fixes, not the full ticket dump), use the project skill: -Examples: +**.cursor/skills/build-changelog/** -```bash -python fetch_version.py -python fetch_version.py cloud +In Cursor chat, say `build-changelog` (optionally with a release name and audience). The skill reads tag-diff JSON, uses `gh` / GitHub Releases where needed, and writes curated Markdown under an output directory. -python compare_versions.py owner/repo -``` +Linear ticket creation remains a separate, explicit step (`create-release-ticket` or the skill’s ticket instructions). --- ## GitHub Actions -Workflow **Monthly release (tag diff)** (`.github/workflows/tags-diff-release.yml`): +Workflow: **Monthly release (tag diff)** — `.github/workflows/tags-diff-release.yml` -- Manual dispatch: `stackgen_candidate_tag` (required); optional `version_json_url` to override production `version.json` -- Runs `python run_monthly_release.py "" --skip-ticket` (plus `--version-json-url` when set) -- Uploads artifact `monthly-release-generated-files` from `generated_files/` +- Trigger: manual (`workflow_dispatch`) +- Inputs: `stackgen_candidate_tag` (required); optional `version_json_url` +- Runs: `python run_monthly_release.py "" --skip-ticket` +- Uploads artifact: `monthly-release-generated-files` from `generated_files/` -Set repository secret **GITHUB_PAT** for GitHub API; optional **LINEAR_API_KEY** for richer JSON. +Repository secrets: + +- **GITHUB_PAT** — required for GitHub API access +- **LINEAR_API_KEY** — optional; richer ticket/project fields in the JSON --- -## Package layout +## Security -``` -release_pipeline/ # Orchestration: steps, pipeline, config, constants -run_monthly_release.py -create_monthly_release_ticket.py -generate_input_json.py -process_all_repos.py -compare_tags.py -… -Makefile -env.template -``` +- Store tokens in environment variables or CI secrets only. Never commit `.env` or API keys. +- GitHub credentials need **read** access to the service repos you compare and to `appcd-dist`. +- Linear keys need permission to read issues/projects and to create issues on team **HZ**. +- Prefer `DRY_RUN=1` before the first live `issueCreate` for a new cut. --- -## Security +## Further reading + +| Document | Contents | +|----------|----------| +| [`docs/create-release-ticket.md`](docs/create-release-ticket.md) | Operator guide: full descending sequence for `create-release-ticket` | +| `make help` | Live Make target and parameter list | +| `.cursor/skills/build-changelog/SKILL.md` | Curated changelog skill instructions | + +--- + +## Troubleshooting -- Tokens only via environment variables or CI secrets—never commit `.env`. -- GitHub PAT should have read access to repos you compare. +| Symptom | What to check | +|---------|----------------| +| `FROM_REF and TO_REF are required` | Pass both on the Make command line | +| Empty or missing tags in `input.json` | Confirm refs exist on `appcd-dist` and `version_key` names match `.env` | +| GitHub 404 / rate limit | Set `GITHUB_PAT` / `GH_TOKEN`; confirm repo access | +| No Linear titles / cannot create issue | Export `LINEAR_API_KEY`; run `make test-linear` | +| Ticket-only cannot find JSON | Ensure `-/final_tag_differences.json` exists, or pass `GENERATED_DIR` / `OUT_DIR` | diff --git a/create_monthly_release_ticket.py b/create_monthly_release_ticket.py index b8d8bcc..ae9be8b 100644 --- a/create_monthly_release_ticket.py +++ b/create_monthly_release_ticket.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Create a Monthly Release issue in Linear from template + grouped tickets.""" +"""Create a weekly/monthly release issue in Linear from tag-diff artifacts.""" import argparse import json @@ -9,83 +9,191 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import requests LINEAR_API_URL = "https://api.linear.app/graphql" +# Ticket prefixes always rendered as their own description sections (in this order). +PRIMARY_TICKET_SECTIONS = ("AIOS", "DPP", "CORE") + @dataclass class MonthlyTicketConfig: - """Inputs for creating the monthly release Linear issue (Step 4 of the release pipeline).""" + """Inputs for creating the release Linear issue (Step 4 of the release pipeline).""" input_path: Path api_key: Optional[str] = None - template_name: str = "Monthly Release" + template_name: str = "" template_id: str = "" - assignee_query: str = "gaurav" + assignee_query: str = "gaurav@stackgen.com" team_key: str = "HZ" team_id: str = "" title: str = "" month_label: str = "" + # "weekly" → "[Weekly release] "; "monthly" → "[Monthly release] " + release_kind: str = "weekly" + stackgen_tag: str = "" + state_name: str = "Todo" + # Optional path to input.json (all services). Auto-detected next to final_tag_differences.json. + services_input_path: Optional[Path] = None dry_run: bool = False +def default_release_title(release_kind: str, month_label: str, stackgen_tag: str = "") -> str: + """Title: [Weekly release] vX.Y.Z or [Monthly release] vX.Y.Z.""" + kind = (release_kind or "weekly").strip().lower() + kind_label = "Weekly" if kind == "weekly" else "Monthly" + tag = (stackgen_tag or "").strip() or (month_label or "").strip() or "unknown" + return f"[{kind_label} release] {tag}" + + +LINEAR_WORKSPACE = "stackgen" +LINEAR_ISSUE_BASE = f"https://linear.app/{LINEAR_WORKSPACE}/issue" + +# Noise / non-issue identifiers sometimes extracted from commit text. +NOISE_TICKET_IDS = frozenset({"UTF-8", "UTF-16", "DEP-02", "INT-03"}) + # process_all_repos.py formats all_tickets as: "TICKET-ID : status : title" _TICKET_WITH_META = re.compile(r"^(\S+)\s*:\s*(.+?)\s*:\s*(.+)$", re.DOTALL) _TICKET_ID_ONLY = re.compile(r"^([A-Za-z]+-\d+)$") -def parse_ticket_line(line: str) -> tuple[str, str]: - """Return (ticket_id, summary). Summary is title from Linear when line includes status/title.""" +def parse_ticket_line(line: str) -> Tuple[str, str, str]: + """Return (ticket_id, state, title) from an all_tickets line.""" line_stripped = (line or "").strip() if not line_stripped: - return "", "" + return "", "", "" meta_match = _TICKET_WITH_META.match(line_stripped) if meta_match: - return meta_match.group(1).strip(), meta_match.group(3).strip() + return ( + meta_match.group(1).strip(), + meta_match.group(2).strip(), + meta_match.group(3).strip(), + ) id_only_match = _TICKET_ID_ONLY.match(line_stripped) if id_only_match: - return id_only_match.group(1), "" - return line_stripped, "" + return id_only_match.group(1), "", "" + return line_stripped, "", "" -def ticket_summaries_from_all_tickets(data: Dict[str, Any]) -> Dict[str, str]: - """Map ticket id -> title/summary from `all_tickets` strings (see process_all_repos).""" - summaries: Dict[str, str] = {} +def ticket_meta_from_all_tickets(data: Dict[str, Any]) -> Dict[str, Dict[str, str]]: + """Map ticket id -> {title, state} from `all_tickets` strings.""" + meta: Dict[str, Dict[str, str]] = {} raw_entries = data.get("all_tickets") if not isinstance(raw_entries, list): - return summaries + return meta for raw_line in raw_entries: if not isinstance(raw_line, str): continue - ticket_id, summary_text = parse_ticket_line(raw_line) + ticket_id, state, title = parse_ticket_line(raw_line) if ticket_id: - summaries[ticket_id] = summary_text - return summaries + meta[ticket_id] = {"title": title, "state": state} + return meta + + +def ticket_summaries_from_all_tickets(data: Dict[str, Any]) -> Dict[str, str]: + """Map ticket id -> title/summary from `all_tickets` strings (see process_all_repos).""" + return { + ticket_id: details.get("title", "") + for ticket_id, details in ticket_meta_from_all_tickets(data).items() + } + + +def linear_issue_url(ticket_id: str) -> str: + return f"{LINEAR_ISSUE_BASE}/{ticket_id}" -# Linear API returns project.state; active work is typically "started" (UI: In Progress). -IN_PROGRESS_PROJECT_STATES = frozenset({"started"}) +def default_services_input_path(tag_diff_path: Path) -> Optional[Path]: + """Resolve input.json next to final_tag_differences.json when present.""" + candidate = tag_diff_path.parent / "input_file" / "input.json" + return candidate if candidate.is_file() else None -def linear_projects_in_progress(data: Dict[str, Any]) -> List[Dict[str, Any]]: - """Subset of `projects` from final_tag_differences.json where state is in progress.""" +def _service_tag_row(svc: Dict[str, Any]) -> Optional[Dict[str, Any]]: + name = str(svc.get("service") or "").strip() + if not name: + return None + from_tag = str(svc.get("current_tag") or "").strip() + to_tag = str(svc.get("new_tag") or "").strip() + changed = bool(from_tag and to_tag and from_tag != to_tag) + return { + "service": name, + "from": from_tag or "—", + "to": to_tag or "—", + "changed": changed, + } + + +def release_candidate_rows( + data: Dict[str, Any], + *, + include_unchanged: bool = True, +) -> List[Dict[str, Any]]: + """ + Service tag rows from a services list (input.json or final_tag_differences.json). + + When include_unchanged is True (default), every service is listed; unchanged + tags show the same value in From/To with changed=False. + """ + rows: List[Dict[str, Any]] = [] + services = data.get("services") + if not isinstance(services, list): + # input.json is a bare list + if isinstance(data, list): + services = data + else: + return rows + for svc in services: + if not isinstance(svc, dict): + continue + row = _service_tag_row(svc) + if not row: + continue + if not include_unchanged and not row["changed"]: + continue + rows.append(row) + # Changed services first, then alphabetical within each group. + return sorted(rows, key=lambda r: (not r["changed"], str(r["service"]).lower())) + + +def release_candidate_tag_lines(data: Dict[str, Any]) -> List[str]: + """Bumped service tags as plain lines (legacy helper).""" + return [ + f"{r['service']}: {r['from']} → {r['to']}" + for r in release_candidate_rows(data, include_unchanged=False) + ] + + +def load_release_candidate_rows( + tag_diff_path: Path, + services_input_path: Optional[Path] = None, +) -> List[Dict[str, Any]]: + """ + Prefer full service list from input.json (all services); fall back to + final_tag_differences.json services[]. + """ + path = services_input_path or default_services_input_path(tag_diff_path) + if path and path.is_file(): + with path.open("r", encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, list): + return release_candidate_rows({"services": raw}, include_unchanged=True) + if isinstance(raw, dict): + return release_candidate_rows(raw, include_unchanged=True) + + with tag_diff_path.open("r", encoding="utf-8") as f: + data = json.load(f) + return release_candidate_rows(data, include_unchanged=True) + +def all_linear_projects(data: Dict[str, Any]) -> List[Dict[str, Any]]: + """All projects from final_tag_differences.json, sorted by name.""" raw_projects = data.get("projects") if not isinstance(raw_projects, list): return [] - in_progress: List[Dict[str, Any]] = [] - for project_entry in raw_projects: - if not isinstance(project_entry, dict): - continue - state_normalized = str(project_entry.get("state", "")).strip().lower() - if state_normalized in IN_PROGRESS_PROJECT_STATES: - in_progress.append(project_entry) - return sorted( - in_progress, - key=lambda row: str(row.get("name", "") or "").lower(), - ) + projects = [p for p in raw_projects if isinstance(p, dict)] + return sorted(projects, key=lambda row: str(row.get("name", "") or "").lower()) def linear_request(api_key: str, query: str, variables: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: @@ -112,31 +220,40 @@ def linear_request(api_key: str, query: str, variables: Optional[Dict[str, Any]] return data.get("data", {}) if isinstance(data, dict) else {} -def read_grouped_tickets(input_path: Path) -> Dict[str, List[str]]: - grouped, _, _ = load_release_data(input_path) - return grouped - - def load_release_data( input_path: Path, -) -> tuple[Dict[str, List[str]], Dict[str, str], List[Dict[str, Any]]]: - """Load tickets_by_project, all_tickets summaries, and in-progress Linear projects.""" + services_input_path: Optional[Path] = None, +) -> Tuple[ + Dict[str, List[str]], + Dict[str, Dict[str, str]], + List[Dict[str, Any]], + List[Dict[str, Any]], +]: + """Load tickets_by_project, ticket meta, RC tag rows (all services), and projects.""" with input_path.open("r", encoding="utf-8") as f: data = json.load(f) - ticket_summaries = ticket_summaries_from_all_tickets(data) - projects_in_progress_list = linear_projects_in_progress(data) + ticket_meta = ticket_meta_from_all_tickets(data) + rc_rows = load_release_candidate_rows(input_path, services_input_path) + projects = all_linear_projects(data) grouped = data.get("tickets_by_project") if isinstance(grouped, dict) and grouped: by_prefix: Dict[str, List[str]] = {} for prefix, ticket_list in grouped.items(): if isinstance(ticket_list, list): - by_prefix[prefix] = sorted(set(str(x) for x in ticket_list if x)) + by_prefix[prefix] = sorted( + { + str(x) + for x in ticket_list + if x and str(x) not in NOISE_TICKET_IDS + } + ) return ( dict(sorted(by_prefix.items(), key=lambda kv: kv[0])), - ticket_summaries, - projects_in_progress_list, + ticket_meta, + rc_rows, + projects, ) derived: Dict[str, List[str]] = {} @@ -144,6 +261,8 @@ def load_release_data( if isinstance(all_tickets, list): for item in all_tickets: ticket = str(item).split(":", 1)[0].strip() + if ticket in NOISE_TICKET_IDS: + continue if "-" in ticket: proj = ticket.split("-", 1)[0] derived.setdefault(proj, []).append(ticket) @@ -152,6 +271,8 @@ def load_release_data( for service in data["services"]: for t in service.get("tickets", []): ticket = str(t) + if ticket in NOISE_TICKET_IDS: + continue if "-" in ticket: proj = ticket.split("-", 1)[0] derived.setdefault(proj, []).append(ticket) @@ -160,121 +281,213 @@ def load_release_data( derived[proj] = sorted(set(derived[proj])) return ( dict(sorted(derived.items(), key=lambda kv: kv[0])), - ticket_summaries, - projects_in_progress_list, + ticket_meta, + rc_rows, + projects, ) -# Project prefixes grouped for Linear mentions in the monthly summary body -SECTION_ABHISHES_PROJECTS = ("AE", "PLAT") -SECTION_GAURAV_PROJECTS = ("CLOUD", "DPP") +def read_grouped_tickets(input_path: Path) -> Dict[str, List[str]]: + grouped, _, _, _ = load_release_data(input_path) + return grouped + + +def _is_bug_like(title: str) -> bool: + t = (title or "").lower() + return any(tok in t for tok in ("[bug]", "bug fix", "fails", "error", "broken", "incorrect")) + + +def _md_cell(text: str) -> str: + """Escape pipe characters so markdown tables stay intact.""" + return (text or "").replace("|", "\\|").replace("\n", " ").strip() + +def _ticket_table_row(ticket_id: str, meta: Dict[str, Dict[str, str]]) -> str: + details = meta.get(ticket_id) or {} + title = _md_cell(details.get("title") or "") + state = _md_cell(details.get("state") or "—") or "—" + link = f"[{ticket_id}]({linear_issue_url(ticket_id)})" + return f"| {link} | {state} | {title or '—'} |" -def _append_project_tickets( + +def _append_ticket_table( lines: List[str], - project_prefix: str, tickets: List[str], - ticket_id_to_summary: Dict[str, str], + ticket_meta: Dict[str, Dict[str, str]], ) -> None: - if not tickets: - return - lines.append(f"### {project_prefix} ({len(tickets)})") + lines.append("| ID | Status | Summary |") + lines.append("| --- | --- | --- |") for ticket_id in tickets: - summary_text = ticket_id_to_summary.get(ticket_id, "").strip() - if summary_text: - lines.append(f"- `{ticket_id}` — {summary_text}") - else: - lines.append(f"- `{ticket_id}`") + lines.append(_ticket_table_row(ticket_id, ticket_meta)) lines.append("") -def _append_linear_projects_in_progress( +def _append_ticket_section( lines: List[str], - linear_projects: List[Dict[str, Any]], + heading: str, + tickets: List[str], + ticket_meta: Dict[str, Dict[str, str]], ) -> None: - if not linear_projects: - return - lines.append("## Projects — In Progress") - lines.append("") - for project in linear_projects: - display_name = str(project.get("name") or "Untitled").strip() or "Untitled" - project_url = str(project.get("url") or "").strip() - description = str(project.get("description") or "").strip() - progress_value = project.get("progress") - percent_label = "" - if isinstance(progress_value, (int, float)): - percent_label = f"{progress_value * 100:.0f}%" - bullet = f"- [{display_name}]({project_url})" if project_url else f"- **{display_name}**" - if percent_label: - bullet = f"{bullet} — {percent_label}" - lines.append(bullet) - if description: - lines.append(f" {description}") + lines.append(f"## {heading}") lines.append("") + if not tickets: + lines.append("_None_") + lines.append("") + return + features = [] + bugs = [] + for ticket_id in tickets: + title = (ticket_meta.get(ticket_id) or {}).get("title", "") + if _is_bug_like(title): + bugs.append(ticket_id) + else: + features.append(ticket_id) + + if features and bugs: + lines.append(f"### Features / changes ({len(features)})") + lines.append("") + _append_ticket_table(lines, features, ticket_meta) + lines.append(f"### Bug fixes ({len(bugs)})") + lines.append("") + _append_ticket_table(lines, bugs, ticket_meta) + else: + _append_ticket_table(lines, tickets, ticket_meta) def build_summary( grouped: Dict[str, List[str]], month_label: str, ticket_id_to_summary: Optional[Dict[str, str]] = None, in_progress_projects: Optional[List[Dict[str, Any]]] = None, + release_kind: str = "weekly", + stackgen_tag: str = "", + rc_tag_lines: Optional[List[str]] = None, + projects: Optional[List[Dict[str, Any]]] = None, + ticket_meta: Optional[Dict[str, Dict[str, str]]] = None, + rc_rows: Optional[List[Dict[str, str]]] = None, ) -> str: - ticket_id_to_summary = ticket_id_to_summary or {} - in_progress_projects = in_progress_projects or [] - total = sum(len(ticket_ids) for ticket_ids in grouped.values()) - lines = [ - f"Monthly release summary for {month_label}.", - "", - f"Total unique tickets: {total}", - "", - ] - - _append_linear_projects_in_progress(lines, in_progress_projects) + """ + Readable Linear markdown for release managers + docs: - abhishes_prefixes = set(SECTION_ABHISHES_PROJECTS) - gaurav_prefixes = set(SECTION_GAURAV_PROJECTS) + - Partitioned sections with --- + - Component tag table (from → to) + - Ticket tables: ID (link) | Status | Summary + - Project lists with Linear hyperlinks + state/progress + """ + _ = (ticket_id_to_summary, in_progress_projects, rc_tag_lines) + + kind = (release_kind or "weekly").strip().lower() + kind_label = "Weekly" if kind == "weekly" else "Monthly" + tag = (stackgen_tag or "").strip() + ticket_meta = ticket_meta or {} + projects = projects if projects is not None else [] + rc_rows = list(rc_rows or []) + + lines: List[str] = [] + header = f"**{kind_label} release**" + if tag: + header += f" candidate `{tag}`" + if month_label: + header += f" — {month_label}" + lines.append(header) + lines.append("") + lines.append( + "_Auto-generated from release-utils tag diff. " + "Click ticket / project links to open in Linear._" + ) + lines.append("") + lines.append("---") + lines.append("") - abhishes_section_keys = [ - prefix for prefix in SECTION_ABHISHES_PROJECTS if grouped.get(prefix) - ] - gaurav_section_keys = [ - prefix for prefix in SECTION_GAURAV_PROJECTS if grouped.get(prefix) - ] - other_prefix_keys = sorted( - prefix - for prefix in grouped - if prefix not in abhishes_prefixes - and prefix not in gaurav_prefixes - and grouped.get(prefix) + # --- Release Candidate Tags (all services; changed rows highlighted) --- + lines.append("## Release Candidate Tags") + lines.append("") + lines.append( + "_Changed components are listed first and marked **Updated** " + "(Linear markdown has no text color; bold = changed)._" ) + lines.append("") + lines.append("| Component | From | To | Change |") + lines.append("| --- | --- | --- | --- |") + if tag: + lines.append(f"| **stackgen** | — | **`{tag}`** | **Updated** |") + if rc_rows: + for row in rc_rows: + service = _md_cell(str(row["service"])) + from_tag = _md_cell(str(row["from"])) + to_tag = _md_cell(str(row["to"])) + if row.get("changed"): + lines.append( + f"| **`{service}`** | `{from_tag}` | **`{to_tag}`** | **Updated** |" + ) + else: + # Same tag (or empty) — show both columns for clarity + display_to = to_tag if to_tag != "—" else from_tag + lines.append( + f"| `{service}` | `{from_tag}` | `{display_to}` | Unchanged |" + ) + elif not tag: + lines.append("| — | — | — | _(no services)_ |") + lines.append("") + changed_count = sum(1 for r in rc_rows if r.get("changed")) + lines.append( + f"_Services: {len(rc_rows)} total · " + f"**{changed_count} updated** · " + f"{len(rc_rows) - changed_count} unchanged_" + ) + lines.append("") + lines.append("---") + lines.append("") - if abhishes_section_keys: - lines.append("## AE & PLAT — @Abhishes") + # --- Primary team ticket sections --- + for prefix in PRIMARY_TICKET_SECTIONS: + tickets = grouped.get(prefix, []) + _append_ticket_section( + lines, f"{prefix} ({len(tickets)})", tickets, ticket_meta + ) + lines.append("---") lines.append("") - for prefix in abhishes_section_keys: - _append_project_tickets( - lines, prefix, grouped[prefix], ticket_id_to_summary - ) - if gaurav_section_keys: - lines.append("## CLOUD & DPP — @Gaurav") + # --- Other prefixes --- + other_prefixes = [ + p for p in sorted(grouped.keys()) + if p not in PRIMARY_TICKET_SECTIONS and grouped.get(p) + ] + if other_prefixes: + lines.append("## Other teams") + lines.append("") + for prefix in other_prefixes: + tickets = grouped[prefix] + lines.append(f"### {prefix} ({len(tickets)})") + lines.append("") + _append_ticket_table(lines, tickets, ticket_meta) + lines.append("---") lines.append("") - for prefix in gaurav_section_keys: - _append_project_tickets( - lines, prefix, grouped[prefix], ticket_id_to_summary - ) - if other_prefix_keys: - lines.append("## Other projects") + # --- Projects --- + lines.append(f"## Projects ({len(projects)})") + lines.append("") + if not projects: + lines.append("_None_") lines.append("") - for prefix in other_prefix_keys: - _append_project_tickets( - lines, prefix, grouped[prefix], ticket_id_to_summary + else: + lines.append("| Project | State | Progress |") + lines.append("| --- | --- | --- |") + for project in projects: + display_name = str(project.get("name") or "Untitled").strip() or "Untitled" + project_url = str(project.get("url") or "").strip() + state = str(project.get("state") or "—").strip() or "—" + progress_value = project.get("progress") + percent_label = "—" + if isinstance(progress_value, (int, float)): + percent_label = f"{progress_value * 100:.0f}%" + name_cell = ( + f"[{display_name}]({project_url})" if project_url else display_name ) + lines.append(f"| {name_cell} | `{state}` | {percent_label} |") + lines.append("") - lines.append("---") - lines.append("Generated by release automation from release-utils output.") - return "\n".join(lines) + return "\n".join(lines).rstrip() + "\n" def resolve_assignee_id(api_key: str, assignee_query: str) -> str: @@ -287,6 +500,13 @@ def resolve_assignee_id(api_key: str, assignee_query: str) -> str: """ user_nodes = linear_request(api_key, graphql_query).get("users", {}).get("nodes", []) query_normalized = assignee_query.lower().strip() + + # Prefer exact email match when the query looks like an email. + if "@" in query_normalized: + for user in user_nodes: + if str(user.get("email", "")).lower().strip() == query_normalized: + return user["id"] + selected_user = None for user in user_nodes: searchable = " ".join( @@ -294,6 +514,9 @@ def resolve_assignee_id(api_key: str, assignee_query: str) -> str: ).lower() if query_normalized in searchable: selected_user = user + email = str(user.get("email", "")).lower().strip() + if email == query_normalized: + return user["id"] if ( user.get("name", "").lower() == query_normalized or user.get("displayName", "").lower() == query_normalized @@ -307,8 +530,9 @@ def resolve_assignee_id(api_key: str, assignee_query: str) -> str: def resolve_template_id(api_key: str, template_name: str) -> str: """Resolve template id using root Query.templates (a list, not a connection).""" template_name_normalized = template_name.lower().strip() + if not template_name_normalized: + raise RuntimeError("template_name is empty") - # Linear GraphQL: Query.templates is [Template!]! with no pagination args. query_templates = """ query { templates { @@ -372,34 +596,66 @@ def resolve_team_id(api_key: str, team_key: str) -> str: raise RuntimeError(f"No Linear team found for key '{team_key}'") +def resolve_state_id(api_key: str, team_id: str, state_name: str = "Todo") -> str: + """Resolve a workflow state id on the team by name (default: Todo).""" + wanted = (state_name or "Todo").strip().lower() + query = """ + query TeamStates($id: String!) { + team(id: $id) { + states { + nodes { id name type } + } + } + } + """ + team = linear_request(api_key, query, {"id": team_id}).get("team") or {} + nodes = (team.get("states") or {}).get("nodes") or [] + for state in nodes: + if str(state.get("name", "")).strip().lower() == wanted: + return state["id"] + # Fallback: first unstarted state (Linear's usual Todo type). + for state in nodes: + if str(state.get("type", "")).strip().lower() == "unstarted": + return state["id"] + available = ", ".join(str(s.get("name", "")) for s in nodes if s.get("name")) + raise RuntimeError( + f"Could not find workflow state '{state_name}' on team. " + f"Available: {available or 'none'}" + ) + + def create_issue( api_key: str, title: str, summary: str, assignee_id: str, - template_id: str, team_id: str, + state_id: Optional[str] = None, + template_id: str = "", ) -> Dict[str, Any]: mutation = """ mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success - issue { id identifier title url } + issue { id identifier title url state { name } assignee { email } } } } """ - # teamId places the issue on the target team (e.g. HZ). Template fields may still apply. - base = { + base: Dict[str, Any] = { "title": title, "description": summary, "assigneeId": assignee_id, "teamId": team_id, } - candidate_inputs = [ - {**base, "templateId": template_id}, - {**base, "issueTemplateId": template_id}, - ] + if state_id: + base["stateId"] = state_id + + candidate_inputs: List[Dict[str, Any]] = [] + if template_id: + candidate_inputs.append({**base, "templateId": template_id}) + candidate_inputs.append({**base, "issueTemplateId": template_id}) + candidate_inputs.append(base) last_error: Optional[Exception] = None for issue_input in candidate_inputs: @@ -416,32 +672,50 @@ def create_issue( except Exception as exc: last_error = exc - raise RuntimeError( - f"Failed creating issue with template fields templateId/issueTemplateId: {last_error}" - ) + raise RuntimeError(f"Failed creating Linear issue: {last_error}") def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: - """Create the Linear monthly release issue from structured config. Used by CLI and release pipeline.""" + """Create the Linear release issue from structured config. Used by CLI and release pipeline.""" input_path = cfg.input_path if not input_path.exists(): print(f"Error: input file not found: {input_path}", file=sys.stderr) return 1 month_label = cfg.month_label.strip() or datetime.now().strftime("%B %Y") - grouped, ticket_id_to_summary, in_progress_projects = load_release_data(input_path) + release_kind = (cfg.release_kind or "weekly").strip().lower() + stackgen_tag = cfg.stackgen_tag.strip() + grouped, ticket_meta, rc_rows, projects = load_release_data( + input_path, + services_input_path=cfg.services_input_path, + ) + ticket_id_to_summary = { + tid: (meta.get("title") or "") for tid, meta in ticket_meta.items() + } summary = build_summary( - grouped, month_label, ticket_id_to_summary, in_progress_projects + grouped, + month_label, + ticket_id_to_summary, + release_kind=release_kind, + stackgen_tag=stackgen_tag, + projects=projects, + ticket_meta=ticket_meta, + rc_rows=rc_rows, + ) + title = cfg.title.strip() or default_release_title( + release_kind, month_label, stackgen_tag ) - title = cfg.title.strip() or f"Monthly Release - {month_label}" api_key = cfg.api_key if cfg.api_key is not None else os.getenv("LINEAR_API_KEY") - print("Preparing Linear Monthly Release ticket") + print("Preparing Linear release ticket") print("=" * 60) print(f"Input: {input_path}") - print(f"Template name: {cfg.template_name}") - print(f"Assignee query: {cfg.assignee_query}") + print(f"Release kind: {release_kind}") + print(f"StackGen tag: {stackgen_tag or '(none)'}") + print(f"Assignee: {cfg.assignee_query}") + print(f"Status: {cfg.state_name}") + print(f"Template name: {cfg.template_name or '(none)'}") print(f"Team: {cfg.team_id.strip() or f'key={cfg.team_key!r}'}") print(f"Title: {title}") print() @@ -458,20 +732,25 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: try: assignee_id = resolve_assignee_id(api_key, cfg.assignee_query) - template_id = cfg.template_id.strip() or resolve_template_id(api_key, cfg.template_name) team_id = cfg.team_id.strip() or resolve_team_id(api_key, cfg.team_key) + state_id = resolve_state_id(api_key, team_id, cfg.state_name) + template_id = cfg.template_id.strip() + if not template_id and cfg.template_name.strip(): + template_id = resolve_template_id(api_key, cfg.template_name) + print(f"Resolved assignee id: {assignee_id}") - print(f"Resolved template id: {template_id}") print(f"Resolved team id: {team_id}") + print(f"Resolved state id: {state_id} ({cfg.state_name})") + if template_id: + print(f"Resolved template id: {template_id}") print() print("=" * 60) print("Ticket content") print("=" * 60) print(f"Title:\n{title}") print() - print(f"Assignee ID: {assignee_id}") - print(f"Template ID: {template_id}") - print(f"Team ID: {team_id}") + print(f"Assignee: {cfg.assignee_query}") + print(f"Status: {cfg.state_name}") print() print("Description (issue body):") print("-" * 60) @@ -479,12 +758,26 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: print("-" * 60) print() - issue = create_issue(api_key, title, summary, assignee_id, template_id, team_id) + issue = create_issue( + api_key, + title, + summary, + assignee_id, + team_id, + state_id=state_id, + template_id=template_id, + ) print() - print("✅ Monthly Release ticket created") + print("✅ Release ticket created") print(f"Identifier: {issue.get('identifier')}") print(f"Title: {issue.get('title')}") print(f"URL: {issue.get('url')}") + state = (issue.get("state") or {}).get("name") + if state: + print(f"Status: {state}") + assignee_email = (issue.get("assignee") or {}).get("email") + if assignee_email: + print(f"Assignee: {assignee_email}") return 0 except Exception as exc: print(f"❌ Failed to create ticket: {exc}", file=sys.stderr) @@ -492,12 +785,34 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: def build_argument_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Create Monthly Release ticket in Linear") + parser = argparse.ArgumentParser( + description=( + "Create a release ticket in Linear (HZ team). " + "Title: '[Weekly release] ' / '[Monthly release] '. " + "Assignee: gaurav@stackgen.com. Status: Todo." + ) + ) parser.add_argument("--input", "-i", default="generated_files/final_tag_differences.json") + parser.add_argument( + "--services-input", + default="", + help=( + "Path to input.json with all services (default: " + "/input_file/input.json when present)." + ), + ) parser.add_argument("--api-key", default=os.getenv("LINEAR_API_KEY")) - parser.add_argument("--template-name", default="Monthly Release") + parser.add_argument( + "--template-name", + default="", + help="Optional Linear issue template name (empty = no template).", + ) parser.add_argument("--template-id", default="") - parser.add_argument("--assignee-query", default="gaurav") + parser.add_argument( + "--assignee-query", + default="gaurav@stackgen.com", + help="Assignee email or name (default: gaurav@stackgen.com).", + ) parser.add_argument( "--team-key", default="HZ", @@ -510,12 +825,29 @@ def build_argument_parser() -> argparse.ArgumentParser: ) parser.add_argument("--title", default="") parser.add_argument("--month-label", default="") + parser.add_argument( + "--release-kind", + choices=("weekly", "monthly"), + default="weekly", + help="Title flavor: [Weekly release] or [Monthly release].", + ) + parser.add_argument( + "--stackgen-tag", + default="", + help="Candidate StackGen/appcd-dist tag included in the title (e.g. v2026.7.7).", + ) + parser.add_argument( + "--state-name", + default="Todo", + help="Linear workflow state name (default: Todo).", + ) parser.add_argument("--dry-run", action="store_true") return parser def main() -> int: args = build_argument_parser().parse_args() + services_input = Path(args.services_input) if args.services_input.strip() else None cfg = MonthlyTicketConfig( input_path=Path(args.input), api_key=args.api_key, @@ -526,6 +858,10 @@ def main() -> int: team_id=args.team_id, title=args.title, month_label=args.month_label, + release_kind=args.release_kind, + stackgen_tag=args.stackgen_tag, + state_name=args.state_name, + services_input_path=services_input, dry_run=args.dry_run, ) return run_create_monthly_release(cfg) diff --git a/docs/create-release-ticket.md b/docs/create-release-ticket.md new file mode 100644 index 0000000..a54b1b1 --- /dev/null +++ b/docs/create-release-ticket.md @@ -0,0 +1,262 @@ +# StackGen release ticket workflow + +This document describes the **create-release-ticket** pipeline used by release managers to compare two `appcd-dist` tags, extract Linear tickets and projects, and open an HZ release issue in Linear. + +Actions are listed in **execution order**, from the top-level Make target down through each nested step. + +--- + +## 0. Prerequisites + +Before you run the pipeline: + +1. Work from the `release-utils` repository root. +2. Install Python dependencies: `pip install -r requirements.txt`. +3. Authenticate to GitHub for private repos (`gh auth login` and/or `export GITHUB_PAT=…`). +4. Export a Linear API key: `export LINEAR_API_KEY=lin_api_…`. +5. Identify the two `appcd-dist` refs to compare: + - **FROM_REF** — currently shipping / previous candidate tag + - **TO_REF** — new release candidate tag + +--- + +## 1. `create-release-ticket` (entry point) + +**Purpose:** End-to-end weekly or monthly release ticket creation. + +**Command:** + +```bash +make create-release-ticket \ + FROM_REF=v2026.7.3 \ + TO_REF=v2026.7.7 \ + MONTH_LABEL="July 2026" +``` + +**Preview only (no Linear issueCreate):** + +```bash +make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1 +``` + +### 1.1 Inputs accepted by this target + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `FROM_REF` | Yes | — | Base `appcd-dist` tag/branch (current versions) | +| `TO_REF` | Yes | — | Candidate `appcd-dist` tag/branch (new versions) | +| `OUT_DIR` | No | `-` | Artifact directory (e.g. `v2026.7.3-v2026.7.7`) | +| `STACKGEN_TAG` | No | `TO_REF` | Tag shown in the Linear issue title | +| `RELEASE_KIND` | No | `weekly` | `weekly` or `monthly` | +| `MONTH_LABEL` | No | _(empty)_ | Human label, e.g. `July 2026` | +| `ASSIGNEE_QUERY` | No | `gaurav@stackgen.com` | Linear assignee | +| `STATE_NAME` | No | `Todo` | Linear workflow state | +| `DRY_RUN=1` | No | off | Print issue body; do not create the issue | + +### 1.2 Sequence performed by `create-release-ticket` + +The target runs these four steps **in order**: + +| Step | Action | Section | +|------|--------|---------| +| 1 | Clean the artifact directory | [2. Clean](#2-clean-artifact-directory) | +| 2 | Generate the service tag matrix | [3. Generate custom input](#3-generate-custom-input-file) | +| 3 | Fetch commits, tickets, and projects | [4. Fetch changes](#4-fetch_changes_between_tags_from_input) | +| 4 | Create the Linear release ticket | [5. Create Linear ticket](#5-create-release-ticket-only--linear-issue) | + +### 1.3 Artifact layout produced by this run + +All outputs for a given from→to pair live under one directory: + +```text +v2026.7.3-v2026.7.7/ +├── input_file/ +│ └── input.json +├── final_tag_differences.json +├── commit_differences_with_messages.txt +└── projects_list.json +``` + +--- + +## 2. Clean artifact directory + +**Purpose:** Remove any previous artifacts for this tag pair so the run starts fresh. + +**What runs:** + +- Deletes `OUT_DIR` (default `-/`). +- Does **not** wipe unrelated directories (for example other tag-pair folders or legacy `generated_files/`). + +**Operator note:** Re-running `create-release-ticket` with the same refs regenerates everything under that folder. + +--- + +## 3. `generate-custom-input-file` + +**Purpose:** Build the service version matrix by comparing `appcd-dist` `.env` at two refs. + +**Underlying script:** `generate_custom_input_file.py` + +### 3.1 Actions in order + +1. Fetch `.env` from `appcd-dev/appcd-dist` at **FROM_REF**. +2. Fetch `.env` from `appcd-dev/appcd-dist` at **TO_REF**. +3. Map known services (`SERVICE_VERSION_MAP`) to version keys. +4. For each service, record: + - `current_tag` ← value at FROM_REF + - `new_tag` ← value at TO_REF +5. Write `OUT_DIR/input_file/input.json`. + +### 3.2 What `input.json` represents + +One row per service, including unchanged services. Example fields: + +- `service`, `repository`, `version_key` +- `current_tag`, `new_tag` + +This file is the **source of truth** for “what versions are in this candidate.” + +--- + +## 4. `fetch_changes_between_tags_from_input` + +**Purpose:** For every service whose tag changed, compare Git history and extract Linear ticket IDs; enrich with Linear metadata and related projects. + +**Underlying script:** `process_all_repos.py` + +### 4.1 Actions in order + +1. Read `OUT_DIR/input_file/input.json`. +2. Skip services where `current_tag == new_tag` (or tags are empty / non-comparable). +3. For each bumped service: + 1. Call GitHub compare between `current_tag` and `new_tag`. + 2. Collect commit messages / PR text. + 3. Extract Linear ticket identifiers (e.g. `AIOS-273`, `CORE-1003`). + 4. Optionally resolve each ticket via Linear API (title, state, project). +4. Aggregate: + - Per-service ticket lists + - Unique tickets across the release (`all_tickets`) + - Tickets grouped by project prefix (`tickets_by_project`) + - Related Linear projects (`projects`) +5. Write: + - `OUT_DIR/final_tag_differences.json` — primary inventory for the ticket body + - `OUT_DIR/commit_differences_with_messages.txt` — raw commit audit log + - `OUT_DIR/projects_list.json` — projects snapshot (when present) + +### 4.2 What release managers use from this step + +| Artifact | Use | +|----------|-----| +| `final_tag_differences.json` | Ticket inventory, states, projects | +| `input.json` | Full component version table (changed + unchanged) | +| Commit diff log | Deep dive / format debugging | + +--- + +## 5. `create-release-ticket-only` → Linear issue + +**Purpose:** Create (or preview) the HZ Linear release issue from artifacts already on disk. + +**Underlying script:** `create_monthly_release_ticket.py` + +This step is invoked automatically as step 4 of `create-release-ticket`. It can also be run alone after a successful pipeline: + +```bash +make create-release-ticket-only FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1 +``` + +### 5.1 Actions in order + +1. Resolve artifact directory (`OUT_DIR` / `-` / `GENERATED_DIR`). +2. Load `final_tag_differences.json` and, when present, `input_file/input.json`. +3. Build the issue **title**: + - `[Weekly release] ` or + - `[Monthly release] ` +4. Build the issue **description** (see [5.2](#52-issue-description-structure)). +5. If `DRY_RUN=1`: print title + body and stop. +6. Otherwise (requires `LINEAR_API_KEY`): + 1. Resolve assignee (`gaurav@stackgen.com` by default). + 2. Resolve team **HZ**. + 3. Resolve workflow state **Todo**. + 4. Call Linear `issueCreate`. + 5. Print identifier + URL. + +### 5.2 Issue description structure + +Sections appear in this order, separated by horizontal rules: + +1. **Header** — weekly/monthly label, candidate tag, optional month label +2. **Release Candidate Tags** — table of **all** services + - Columns: Component | From | To | Change + - Changed rows listed first and marked **Updated** (bold; Linear has no text color) + - Unchanged rows show the same tag in From and To +3. **AIOS** — table: ID (hyperlink) | Status | Summary +4. **DPP** — same table format +5. **CORE** — same table format +6. **Other teams** — ENG, PLAT, PRO, … when present (same table format) +7. **Projects** — table: Project (hyperlink) | State | Progress + +Where Features and Bug fixes can be distinguished, AIOS/DPP/CORE may split into those subsections before the tables. + +### 5.3 Linear issue fields written + +| Field | Value | +|-------|--------| +| Team | HZ | +| Title | `[Weekly release] ` or `[Monthly release] ` | +| Assignee | `gaurav@stackgen.com` | +| Status | `Todo` | +| Description | Partitioned markdown with linked tickets and projects | + +--- + +## 6. Related actions (optional / alternate paths) + +These are **not** part of the default `create-release-ticket` chain, but support related release work. + +### 6.1 `create-release-ticket-only` + +Reuses an existing `-/` folder. Use when you already ran the pipeline (or a dry-run) and only need to recreate or re-preview the Linear issue. + +### 6.2 `build-changelog` (Cursor skill) + +Curates documentation-oriented release notes (`RELEASE_NOTES.md`, etc.) from the same tag-diff artifacts. Prefer this when Docs needs a customer-facing or themed changelog rather than the full Linear inventory. + +### 6.3 `monthly-release` / `monthly-release-no-ticket` + +Alternate pipeline that builds input from production `version.json` + a single `STACKGEN_TAG` on `appcd-dist`, instead of an explicit FROM_REF→TO_REF `.env` compare. + +### 6.4 Legacy `generated_files/` targets + +`make generate-input`, `make full-workflow`, and `make clean` (without `GENERATED_DIR`) still use `generated_files/` for older monthly flows. The tag-pair workflow above prefers `-/`. + +--- + +## 7. Recommended operator checklist + +1. Confirm **FROM_REF** and **TO_REF** with the release owner. +2. Dry-run first: + `make create-release-ticket FROM_REF=… TO_REF=… DRY_RUN=1` +3. Review printed title + description (tags, ticket tables, projects). +4. Create for real (same command without `DRY_RUN=1`). +5. Open the returned Linear URL; confirm assignee **Todo** and HZ team. +6. Hand the issue (and optional curated changelog) to Docs / QA. +7. Keep the `-/` folder as the audit trail for that cut. + +--- + +## 8. Quick reference + +```bash +# Full pipeline → artifacts in v2026.7.3-v2026.7.7/ + Linear issue +make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 MONTH_LABEL="July 2026" + +# Preview only +make create-release-ticket FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1 + +# Recreate ticket from existing artifacts +make create-release-ticket-only FROM_REF=v2026.7.3 TO_REF=v2026.7.7 +``` + +For Make help: `make help`. diff --git a/generate_input_json.py b/generate_input_json.py index d69dce3..2a11c58 100755 --- a/generate_input_json.py +++ b/generate_input_json.py @@ -87,11 +87,11 @@ "repository": "https://github.com/appcd-dev/aiden-ui-v2" }, "stackgen-guild": { - "version_key": "STACKGEN_GUILD_VERSION", + "version_key": "STACKGEN_GUILD", "repository": "https://github.com/appcd-dev/stackgen-guild" }, "stackgen-sre-app": { - "version_key": "STACKGEN_SRE_APP_VERSION", + "version_key": "STACKGEN_SRE_APP", "repository": "https://github.com/appcd-dev/stackgen-sre-app" } } diff --git a/process_all_repos.py b/process_all_repos.py index 0b0593e..7df1bcd 100755 --- a/process_all_repos.py +++ b/process_all_repos.py @@ -51,6 +51,61 @@ def __init__(self, skip_unchanged: bool = True, verbose: bool = False, ) self.linear_api_key = os.getenv('LINEAR_API_KEY') self.linear_api_url = "https://api.linear.app/graphql" + + @staticmethod + def _linear_issue_browser_url(ticket_id: str) -> str: + """Public web URL for an issue id (used when GraphQL returns HTTP 400).""" + ws = (os.getenv('LINEAR_WEB_WORKSPACE') or 'stackgen').strip().strip('/') + if not ws: + ws = 'stackgen' + return f'https://linear.app/{ws}/issue/{ticket_id}' + + def _unresolved_issue_placeholder(self, ticket_id: str) -> Dict[str, object]: + """Placeholder when the API cannot resolve the issue id (HTTP 400 or GraphQL issue not found).""" + return { + 'id': ticket_id, + 'title': '', + 'state': 'Unknown', + 'priority': 0, + 'assignee': 'Unassigned', + 'projectId': 'No Project', + 'projectName': 'No Project', + 'issueUrl': self._linear_issue_browser_url(ticket_id), + 'fetchHttpStatus': '400', + } + + @staticmethod + def _graphql_errors_indicate_issue_not_found(errors: object) -> bool: + """ + Linear often returns HTTP 200 with errors[].extensions.statusCode 400 for + missing/legacy identifiers (e.g. AE-1952) while the browser URL still resolves. + """ + if not isinstance(errors, list): + return False + for err in errors: + if not isinstance(err, dict): + continue + path = err.get('path') + if not isinstance(path, list) or not path or path[-1] != 'issue': + continue + ext = err.get('extensions') + ext = ext if isinstance(ext, dict) else {} + if ext.get('statusCode') == 400: + return True + if ext.get('code') == 'INPUT_ERROR': + return True + msg = str(err.get('message', '')).lower() + if 'not found' in msg and 'issue' in msg: + return True + return False + + @staticmethod + def _is_canceled_ticket(details: Optional[Dict[str, str]]) -> bool: + """True when Linear workflow state is Canceled (case-insensitive).""" + if not details: + return False + state = str(details.get('state') or '').strip().lower() + return state in ('canceled', 'cancelled') def extract_repo_path(self, repo_url: str) -> Optional[str]: """ @@ -191,11 +246,17 @@ def fetch_ticket_details(self, ticket_id: str) -> Optional[Dict[str, str]]: json=payload, timeout=10 ) + + if response.status_code == 400: + return self._unresolved_issue_placeholder(ticket_id) if response.status_code == 200: data = response.json() if 'errors' in data: + errs = data.get('errors') + if self._graphql_errors_indicate_issue_not_found(errs): + return self._unresolved_issue_placeholder(ticket_id) return None if 'data' in data and data['data'].get('issue'): @@ -570,10 +631,16 @@ def process_all_services(self, services: List[Dict]) -> Dict: # Fetch Linear details for all unique tickets ticket_details_map = self.fetch_all_ticket_details(all_tickets_set) + + # Omit tickets whose Linear state is Canceled from aggregated outputs + included_tickets_set = { + tid for tid in all_tickets_set + if not self._is_canceled_ticket(ticket_details_map.get(tid)) + } # Collect unique project IDs from ticket details project_ids = set() - for ticket_id in all_tickets_set: + for ticket_id in included_tickets_set: details = ticket_details_map.get(ticket_id) if details and details.get('projectId') and details['projectId'] != 'No Project': project_ids.add(details['projectId']) @@ -582,9 +649,9 @@ def process_all_services(self, services: List[Dict]) -> Dict: project_details_map = self.fetch_all_project_details(project_ids) # Calculate max widths for uniform formatting - max_ticket_id_len = max(len(tid) for tid in all_tickets_set) if all_tickets_set else 0 + max_ticket_id_len = max(len(tid) for tid in included_tickets_set) if included_tickets_set else 0 max_status_len = 0 - for ticket_id in all_tickets_set: + for ticket_id in included_tickets_set: details = ticket_details_map.get(ticket_id) if details and details.get('state'): status_len = len(details['state']) @@ -594,9 +661,11 @@ def process_all_services(self, services: List[Dict]) -> Dict: # Build all_tickets array as uniformly formatted strings: "TICKET-ID: status: Summary" all_tickets = [] - for ticket_id in sorted(all_tickets_set): + for ticket_id in sorted(included_tickets_set): details = ticket_details_map.get(ticket_id) - if details and details.get('title'): + if details and str(details.get('fetchHttpStatus')) == '400' and details.get('issueUrl'): + all_tickets.append(f"{ticket_id}: {details['issueUrl']}") + elif details and details.get('title'): # Format with uniform spacing: "AE-1234 : Done : Ticket Summary" status = details.get('state', 'Unknown') formatted_ticket = f"{ticket_id:<{max_ticket_id_len}}: {status:<{max_status_len}}: {details['title']}" @@ -607,7 +676,7 @@ def process_all_services(self, services: List[Dict]) -> Dict: # Group tickets by project (still using IDs for backward compatibility) tickets_by_project = {} - for ticket_id in sorted(all_tickets_set): + for ticket_id in sorted(included_tickets_set): prefix = ticket_id.split('-')[0] if prefix not in tickets_by_project: tickets_by_project[prefix] = [] @@ -638,7 +707,7 @@ def process_all_services(self, services: List[Dict]) -> Dict: 'processed': processed, 'skipped': skipped, 'failed': failed, - 'total_unique_tickets': len(all_tickets_set), + 'total_unique_tickets': len(included_tickets_set), 'total_unique_projects': len(projects_list) }, 'services': results, diff --git a/release_pipeline/pipeline.py b/release_pipeline/pipeline.py index d95bcd8..8a6497b 100644 --- a/release_pipeline/pipeline.py +++ b/release_pipeline/pipeline.py @@ -91,7 +91,15 @@ def run_monthly_release_pipeline( if not skip_ticket and resolved_ticket_config is None: resolved_ticket_config = MonthlyTicketConfig( input_path=config.final_tag_differences_path(root_path), + release_kind="weekly", + stackgen_tag=stackgen_tag.strip(), ) + elif ( + not skip_ticket + and resolved_ticket_config is not None + and not (resolved_ticket_config.stackgen_tag or "").strip() + ): + resolved_ticket_config.stackgen_tag = stackgen_tag.strip() step_results: List[StepResult] = [] diff --git a/run_monthly_release.py b/run_monthly_release.py index 97015e2..f66271d 100644 --- a/run_monthly_release.py +++ b/run_monthly_release.py @@ -78,13 +78,32 @@ def build_argument_parser() -> argparse.ArgumentParser: default=os.getenv("LINEAR_API_KEY"), help="Linear API key for step 4", ) - parser.add_argument("--template-name", default="Monthly Release") + parser.add_argument( + "--template-name", + default="", + help="Optional Linear issue template (empty = none)", + ) parser.add_argument("--template-id", default="") - parser.add_argument("--assignee-query", default="gaurav") + parser.add_argument( + "--assignee-query", + default="gaurav@stackgen.com", + help="Assignee email or name (default: gaurav@stackgen.com)", + ) parser.add_argument("--team-key", default="HZ") parser.add_argument("--team-id", default="") parser.add_argument("--title", default="") parser.add_argument("--month-label", default="") + parser.add_argument( + "--release-kind", + choices=("weekly", "monthly"), + default="weekly", + help="Title: [Weekly release] or [Monthly release] ", + ) + parser.add_argument( + "--state-name", + default="Todo", + help="Linear workflow state (default: Todo)", + ) return parser @@ -107,6 +126,9 @@ def main() -> int: team_id=parsed.team_id, title=parsed.title, month_label=parsed.month_label, + release_kind=parsed.release_kind, + stackgen_tag=parsed.stackgen_tag, + state_name=parsed.state_name, dry_run=parsed.dry_run_ticket, ) From 7c38d3bb520a3ffb21c3a2e729acbefeb2538d8d Mon Sep 17 00:00:00 2001 From: Gaurav Chavan Date: Tue, 28 Jul 2026 17:52:19 +0530 Subject: [PATCH 3/3] [HZ-815] Added comment addition to tag docs team for notification --- create_monthly_release_ticket.py | 60 ++++++++++++++++++++++++++++++++ docs/create-release-ticket.md | 7 ++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/create_monthly_release_ticket.py b/create_monthly_release_ticket.py index ae9be8b..9841d4c 100644 --- a/create_monthly_release_ticket.py +++ b/create_monthly_release_ticket.py @@ -675,6 +675,40 @@ def create_issue( raise RuntimeError(f"Failed creating Linear issue: {last_error}") +# Handles mentioned on the candidate-build comment after ticket create. +CANDIDATE_BUILD_CC_HANDLES = ("saumya-ctr", "harshit", "gaurav") + + +def candidate_build_comment_body(release_id: str) -> str: + """Comment body posted on the new release ticket; release_id comes from TO_REF / stackgen_tag.""" + release = (release_id or "").strip() or "unknown" + cc = " ".join(f"@{handle}" for handle in CANDIDATE_BUILD_CC_HANDLES) + return f"Candidate build for the coming release {release}. Cc: {cc}" + + +def create_issue_comment(api_key: str, issue_id: str, body: str) -> Dict[str, Any]: + """Post a comment on an existing Linear issue (by UUID).""" + mutation = """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id body url } + } + } + """ + payload = linear_request( + api_key, + mutation, + {"input": {"issueId": issue_id, "body": body}}, + ).get("commentCreate", {}) + if not payload.get("success"): + raise RuntimeError("commentCreate returned success=false") + comment = payload.get("comment") + if not comment: + raise RuntimeError("commentCreate returned no comment") + return comment + + def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: """Create the Linear release issue from structured config. Used by CLI and release pipeline.""" input_path = cfg.input_path @@ -720,10 +754,17 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: print(f"Title: {title}") print() + comment_body = candidate_build_comment_body(stackgen_tag) + if cfg.dry_run: print("[DRY RUN] Summary that will be sent:") print() print(summary) + print() + print("[DRY RUN] Comment that will be posted after create:") + print("-" * 60) + print(comment_body) + print("-" * 60) return 0 if not api_key: @@ -757,6 +798,11 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: print(summary) print("-" * 60) print() + print("Post-create comment:") + print("-" * 60) + print(comment_body) + print("-" * 60) + print() issue = create_issue( api_key, @@ -778,6 +824,20 @@ def run_create_monthly_release(cfg: MonthlyTicketConfig) -> int: assignee_email = (issue.get("assignee") or {}).get("email") if assignee_email: print(f"Assignee: {assignee_email}") + + issue_uuid = str(issue.get("id") or "").strip() + if not issue_uuid: + print( + "⚠️ Ticket created but missing id; skipped candidate-build comment.", + file=sys.stderr, + ) + return 0 + + comment = create_issue_comment(api_key, issue_uuid, comment_body) + print() + print("✅ Candidate-build comment added") + if comment.get("url"): + print(f"Comment URL: {comment.get('url')}") return 0 except Exception as exc: print(f"❌ Failed to create ticket: {exc}", file=sys.stderr) diff --git a/docs/create-release-ticket.md b/docs/create-release-ticket.md index a54b1b1..4b8831d 100644 --- a/docs/create-release-ticket.md +++ b/docs/create-release-ticket.md @@ -174,13 +174,16 @@ make create-release-ticket-only FROM_REF=v2026.7.3 TO_REF=v2026.7.7 DRY_RUN=1 - `[Weekly release] ` or - `[Monthly release] ` 4. Build the issue **description** (see [5.2](#52-issue-description-structure)). -5. If `DRY_RUN=1`: print title + body and stop. +5. If `DRY_RUN=1`: print title + body + post-create comment and stop. 6. Otherwise (requires `LINEAR_API_KEY`): 1. Resolve assignee (`gaurav@stackgen.com` by default). 2. Resolve team **HZ**. 3. Resolve workflow state **Todo**. 4. Call Linear `issueCreate`. - 5. Print identifier + URL. + 5. Post a comment on the new issue: + `Candidate build for the coming release . Cc: @saumya-ctr @harshit @gaurav` + (`` is the StackGen/candidate tag passed as `--stackgen-tag`, defaulting from `TO_REF`). + 6. Print identifier + URL (+ comment confirmation). ### 5.2 Issue description structure