|
| 1 | +$ErrorActionPreference = "Stop" |
| 2 | +Set-StrictMode -Version Latest |
| 3 | +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 |
| 4 | +$OutputEncoding = [System.Text.Encoding]::UTF8 |
| 5 | + |
| 6 | +function Require-GhAuth { |
| 7 | + & gh auth status | Out-Null |
| 8 | + if ($LASTEXITCODE -ne 0) { |
| 9 | + Write-Error "GitHub CLI is not authenticated. Run: gh auth login" |
| 10 | + exit 1 |
| 11 | + } |
| 12 | +} |
| 13 | + |
| 14 | +function Get-RepoRoot { |
| 15 | + $root = (& git rev-parse --show-toplevel).Trim() |
| 16 | + if (-not $root) { |
| 17 | + throw "Unable to determine repo root." |
| 18 | + } |
| 19 | + return $root |
| 20 | +} |
| 21 | + |
| 22 | +function Parse-OwnerRepo { |
| 23 | + param([string]$RemoteUrl) |
| 24 | + $httpsMatch = [regex]::Match($RemoteUrl, '^https://github\.com/([^/]+)/([^/]+?)(\.git)?$') |
| 25 | + if ($httpsMatch.Success) { |
| 26 | + return @($httpsMatch.Groups[1].Value, $httpsMatch.Groups[2].Value) |
| 27 | + } |
| 28 | + $sshMatch = [regex]::Match($RemoteUrl, '^git@github\.com:([^/]+)/([^/]+?)(\.git)?$') |
| 29 | + if ($sshMatch.Success) { |
| 30 | + return @($sshMatch.Groups[1].Value, $sshMatch.Groups[2].Value) |
| 31 | + } |
| 32 | + throw "Unsupported origin remote URL: $RemoteUrl" |
| 33 | +} |
| 34 | + |
| 35 | +function Get-IssueByTitle { |
| 36 | + param([string]$Title) |
| 37 | + $search = "in:title $Title" |
| 38 | + $raw = & gh issue list --state all --search $search --limit 200 --json title,number,url |
| 39 | + $issues = $raw | ConvertFrom-Json |
| 40 | + return $issues | Where-Object { $_.title -eq $Title } | Select-Object -First 1 |
| 41 | +} |
| 42 | + |
| 43 | +function Ensure-Issue { |
| 44 | + param( |
| 45 | + [string]$Title, |
| 46 | + [string]$Body, |
| 47 | + [string[]]$Labels, |
| 48 | + [string]$Milestone |
| 49 | + ) |
| 50 | + $issue = Get-IssueByTitle -Title $Title |
| 51 | + if (-not $issue) { |
| 52 | + $createArgs = @("issue", "create", "--title", $Title, "--body", $Body) |
| 53 | + if ($Labels -and $Labels.Count -gt 0) { |
| 54 | + $createArgs += @("--label", ($Labels -join ",")) |
| 55 | + } |
| 56 | + if ($Milestone) { |
| 57 | + $createArgs += @("--milestone", $Milestone) |
| 58 | + } |
| 59 | + & gh @createArgs | Out-Null |
| 60 | + $issue = Get-IssueByTitle -Title $Title |
| 61 | + } |
| 62 | + if (-not $issue) { |
| 63 | + throw "Failed to create or locate issue: $Title" |
| 64 | + } |
| 65 | + $editArgs = @("issue", "edit", $issue.number) |
| 66 | + $hasEdits = $false |
| 67 | + if ($Labels -and $Labels.Count -gt 0) { |
| 68 | + $editArgs += @("--add-label", ($Labels -join ",")) |
| 69 | + $hasEdits = $true |
| 70 | + } |
| 71 | + if ($Milestone) { |
| 72 | + $editArgs += @("--milestone", $Milestone) |
| 73 | + $hasEdits = $true |
| 74 | + } |
| 75 | + if ($hasEdits) { |
| 76 | + & gh @editArgs | Out-Null |
| 77 | + } |
| 78 | + return $issue |
| 79 | +} |
| 80 | + |
| 81 | +Require-GhAuth |
| 82 | +$repoRoot = Get-RepoRoot |
| 83 | +Set-Location $repoRoot |
| 84 | + |
| 85 | +$originUrl = (& git remote get-url origin).Trim() |
| 86 | +$parsed = Parse-OwnerRepo -RemoteUrl $originUrl |
| 87 | +$owner = $parsed[0] |
| 88 | +$repo = $parsed[1] |
| 89 | +$env:OWNER = $owner |
| 90 | +$env:REPO = $repo |
| 91 | +Write-Host "OWNER=$owner" |
| 92 | +Write-Host "REPO=$repo" |
| 93 | + |
| 94 | +$enDash = [char]0x2013 |
| 95 | +$leftQuote = [char]0x201c |
| 96 | +$rightQuote = [char]0x201d |
| 97 | +$apostrophe = [char]0x2019 |
| 98 | +$rightArrow = [char]0x2192 |
| 99 | + |
| 100 | +$DISCUSSION_TITLE = "CloudSQLCTL $enDash Production Hardening & Roadmap" |
| 101 | +$ROADMAP_ISSUE_TITLE = "Roadmap: Production Finalization (P0/P1/P2) $enDash CloudSQLCTL" |
| 102 | + |
| 103 | +$DISCUSSION_BODY = (@' |
| 104 | +--- |
| 105 | +# CloudSQLCTL {0} Production Hardening & Roadmap |
| 106 | +
|
| 107 | +## Goal |
| 108 | +Elevate CloudSQLCTL to a company-grade {1}production final{2} standard: |
| 109 | +- Deterministic GitHub Releases (installer + exe + zip + checksums) |
| 110 | +- Fully automated self-upgrade (`cloudsqlctl upgrade` default: check {3} download {3} verify {3} install) |
| 111 | +- NPM publish (node CLI distribution) integrated with release versioning |
| 112 | +- Secure machine-scope installation (no user-writable service binaries) |
| 113 | +- Supportability (support bundle, structured logs, docs automation) |
| 114 | +
|
| 115 | +## Current state (high-level) |
| 116 | +- Release pipeline builds SEA exe, builds Inno Setup installer, stages artifacts, uploads to GitHub Release. |
| 117 | +- Upgrade checks GitHub Releases and verifies SHA256SUMS before applying installer/portable update. |
| 118 | +- Setup supports gcloud login + ADC + instance selection. |
| 119 | +
|
| 120 | +## Risks / What{4}s wrong today |
| 121 | +1) **Machine-scope security**: ProgramData bin permissions must not allow Users write (service privilege escalation risk). |
| 122 | +2) **Release republish**: Re-running the same tag should update assets reliably (delete/replace clashing asset names). |
| 123 | +3) **Workflow rerun**: Manual `workflow_dispatch` is required for deterministic republish without tag gymnastics. |
| 124 | +4) **System-scope update guardrails**: system-scope writes require admin/elevation and must be enforced. |
| 125 | +5) **Supportability gap**: need one-command support bundle zip (logs/config/doctor/paths/status/gcloud info). |
| 126 | +6) **Upgrade rollout controls**: channel (stable/beta), pinned version, target version install, rollback. |
| 127 | +7) **Supply chain**: code signing + better release verification is needed for enterprise environments. |
| 128 | +8) **NPM publish**: define what we publish (node CLI) and keep contents minimal + version-gated. |
| 129 | +
|
| 130 | +## Milestones |
| 131 | +- P0 {0} Release & Security Blockers |
| 132 | +- P1 {0} Operations & Supportability |
| 133 | +- P2 {0} Enterprise Distribution & Signing |
| 134 | +
|
| 135 | +## How we will track |
| 136 | +- A single Roadmap issue will link all work items and milestones. |
| 137 | +- Each item is an issue with labels: Priority (P0/P1/P2), Area, Type. |
| 138 | +--- |
| 139 | +'@ -f $enDash, $leftQuote, $rightQuote, $rightArrow, $apostrophe) |
| 140 | + |
| 141 | +$ROADMAP_BODY_TEMPLATE = (@' |
| 142 | +--- |
| 143 | +# Roadmap: Production Finalization (P0/P1/P2) {0} CloudSQLCTL |
| 144 | +
|
| 145 | +This issue tracks all work required to reach {1}production final{2}. |
| 146 | +Checklist items below link to the corresponding issues. |
| 147 | +
|
| 148 | +## P0 {0} Release & Security Blockers |
| 149 | +- [ ] P0: Fix Machine-scope security for ProgramData bin (prevent Users write) |
| 150 | +- [ ] P0: Release republish same tag (clobber existing assets safely) |
| 151 | +- [ ] P0: Add workflow_dispatch to release workflow + manual rerun support |
| 152 | +- [ ] P0: Update/Upgrade guardrails for system scope (admin/elevation required) |
| 153 | +- [ ] P0: Repo hygiene (.gitignore) to prevent committing bin/dist/artifacts |
| 154 | +
|
| 155 | +## P1 {0} Operations & Supportability |
| 156 | +- [ ] P1: Support bundle command (zip logs+config+doctor+paths+status) |
| 157 | +- [ ] P1: Upgrade channels (stable/beta) + pinned version + target version install |
| 158 | +- [ ] P1: GitHub API hardening (token support, retries, rate-limit messaging) |
| 159 | +- [ ] P1: Proxy checksum verification robustness (deterministic source) |
| 160 | +- [ ] P1: Improve portable upgrade swap (temp + atomic replace + rollback) |
| 161 | +- [ ] P1: NPM publish pipeline + package contents control |
| 162 | +
|
| 163 | +## P2 {0} Enterprise Distribution & Signing |
| 164 | +- [ ] P2: Code signing for exe + installer (CI integration) |
| 165 | +- [ ] P2: winget/choco/scoop distribution |
| 166 | +- [ ] P2: Enterprise policy.json (updates/auth constraints, rollout control) |
| 167 | +- [ ] P2: Service-aware upgrade coordination (stop/start service safely) |
| 168 | +
|
| 169 | +## Notes |
| 170 | +- Use labels: Priority:P0/P1/P2, Area:*, Type:*. |
| 171 | +- Each milestone should have a clear scope and DoD. |
| 172 | +--- |
| 173 | +'@ -f $enDash, $leftQuote, $rightQuote) |
| 174 | + |
| 175 | +$labelDefinitions = @( |
| 176 | + @{ Name = "P0"; Color = "ff0000" }, |
| 177 | + @{ Name = "P1"; Color = "ff8c00" }, |
| 178 | + @{ Name = "P2"; Color = "0066ff" }, |
| 179 | + @{ Name = "bug"; Color = "b60205" }, |
| 180 | + @{ Name = "enhancement"; Color = "0e8a16" }, |
| 181 | + @{ Name = "chore"; Color = "c5def5" }, |
| 182 | + @{ Name = "docs"; Color = "1d76db" }, |
| 183 | + @{ Name = "release"; Color = "5319e7" }, |
| 184 | + @{ Name = "security"; Color = "8b0000" }, |
| 185 | + @{ Name = "upgrade"; Color = "0052cc" }, |
| 186 | + @{ Name = "installer"; Color = "fbca04" }, |
| 187 | + @{ Name = "service"; Color = "006b75" }, |
| 188 | + @{ Name = "auth"; Color = "7f8c8d" }, |
| 189 | + @{ Name = "npm"; Color = "e99695" }, |
| 190 | + @{ Name = "blocked"; Color = "000000" }, |
| 191 | + @{ Name = "ready"; Color = "2ecc71" } |
| 192 | +) |
| 193 | + |
| 194 | +foreach ($label in $labelDefinitions) { |
| 195 | + & gh label create $label.Name --color $label.Color --force | Out-Null |
| 196 | +} |
| 197 | + |
| 198 | +$milestoneP0 = "P0 $enDash Release & Security Blockers" |
| 199 | +$milestoneP1 = "P1 $enDash Operations & Supportability" |
| 200 | +$milestoneP2 = "P2 $enDash Enterprise Distribution & Signing" |
| 201 | +$milestoneTitles = @($milestoneP0, $milestoneP1, $milestoneP2) |
| 202 | + |
| 203 | +$milestoneMap = @{} |
| 204 | +$existingMilestones = & gh api "repos/$owner/$repo/milestones?state=all&per_page=100" | ConvertFrom-Json |
| 205 | +foreach ($milestone in $existingMilestones) { |
| 206 | + $milestoneMap[$milestone.title] = [int]$milestone.number |
| 207 | +} |
| 208 | + |
| 209 | +foreach ($title in $milestoneTitles) { |
| 210 | + if (-not $milestoneMap.ContainsKey($title)) { |
| 211 | + $created = & gh api -X POST "repos/$owner/$repo/milestones" -f title="$title" | ConvertFrom-Json |
| 212 | + $milestoneMap[$title] = [int]$created.number |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +$discussionUrl = $null |
| 217 | +try { |
| 218 | + $repoQuery = @' |
| 219 | +query($owner: String!, $name: String!) { |
| 220 | + repository(owner: $owner, name: $name) { |
| 221 | + id |
| 222 | + discussionCategories(first: 100) { |
| 223 | + nodes { id name } |
| 224 | + } |
| 225 | + discussions(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) { |
| 226 | + nodes { id title url } |
| 227 | + } |
| 228 | + } |
| 229 | +} |
| 230 | +'@ |
| 231 | + $repoData = & gh api graphql -f query="$repoQuery" -f owner="$owner" -f name="$repo" | ConvertFrom-Json |
| 232 | + $repoInfo = $repoData.data.repository |
| 233 | + if (-not $repoInfo) { |
| 234 | + throw "Repository not found." |
| 235 | + } |
| 236 | + $categories = $repoInfo.discussionCategories.nodes |
| 237 | + if (-not $categories -or $categories.Count -eq 0) { |
| 238 | + throw "No discussion categories found." |
| 239 | + } |
| 240 | + $category = $categories | Where-Object { $_.name -eq "General" } | Select-Object -First 1 |
| 241 | + if (-not $category) { |
| 242 | + $category = $categories | Select-Object -First 1 |
| 243 | + } |
| 244 | + $existingDiscussion = $repoInfo.discussions.nodes | Where-Object { $_.title -eq $DISCUSSION_TITLE } | Select-Object -First 1 |
| 245 | + if ($existingDiscussion) { |
| 246 | + $updateDiscussion = @' |
| 247 | +mutation($discussionId: ID!, $body: String!) { |
| 248 | + updateDiscussion(input: {discussionId: $discussionId, body: $body}) { |
| 249 | + discussion { url } |
| 250 | + } |
| 251 | +} |
| 252 | +'@ |
| 253 | + & gh api graphql -f query="$updateDiscussion" -f discussionId="$($existingDiscussion.id)" -f body="$DISCUSSION_BODY" | Out-Null |
| 254 | + $discussionUrl = $existingDiscussion.url |
| 255 | + } else { |
| 256 | + $createDiscussion = @' |
| 257 | +mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { |
| 258 | + createDiscussion(input: {repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body}) { |
| 259 | + discussion { url } |
| 260 | + } |
| 261 | +} |
| 262 | +'@ |
| 263 | + $createdDiscussion = & gh api graphql -f query="$createDiscussion" -f repositoryId="$($repoInfo.id)" -f categoryId="$($category.id)" -f title="$DISCUSSION_TITLE" -f body="$DISCUSSION_BODY" | ConvertFrom-Json |
| 264 | + $discussionUrl = $createdDiscussion.data.createDiscussion.discussion.url |
| 265 | + } |
| 266 | +} catch { |
| 267 | + Write-Warning "Discussion creation/update failed; falling back to issue. $($_.Exception.Message)" |
| 268 | + $discussionIssue = Ensure-Issue -Title $DISCUSSION_TITLE -Body $DISCUSSION_BODY -Labels @("docs") |
| 269 | + $discussionUrl = $discussionIssue.url |
| 270 | +} |
| 271 | + |
| 272 | +$roadmapIssue = Ensure-Issue -Title $ROADMAP_ISSUE_TITLE -Body $ROADMAP_BODY_TEMPLATE -Labels @("chore", "release", "P0") |
| 273 | +$roadmapUrl = $roadmapIssue.url |
| 274 | +$roadmapNumber = $roadmapIssue.number |
| 275 | + |
| 276 | +$taskDefinitions = @( |
| 277 | + @{ Title = "P0: Fix Machine-scope security for ProgramData bin (prevent Users write)"; Milestone = $milestoneP0; Labels = @("P0", "security", "installer", "bug") }, |
| 278 | + @{ Title = "P0: Release republish same tag (clobber existing assets safely)"; Milestone = $milestoneP0; Labels = @("P0", "release", "enhancement") }, |
| 279 | + @{ Title = "P0: Add workflow_dispatch to release workflow + manual rerun support"; Milestone = $milestoneP0; Labels = @("P0", "release", "enhancement") }, |
| 280 | + @{ Title = "P0: Update/Upgrade guardrails for system scope (admin/elevation required)"; Milestone = $milestoneP0; Labels = @("P0", "upgrade", "security", "bug") }, |
| 281 | + @{ Title = "P0: Repo hygiene (.gitignore) to prevent committing bin/dist/artifacts"; Milestone = $milestoneP0; Labels = @("P0", "chore") }, |
| 282 | + @{ Title = "P1: Support bundle command (zip logs+config+doctor+paths+status)"; Milestone = $milestoneP1; Labels = @("P1", "enhancement", "docs") }, |
| 283 | + @{ Title = "P1: Upgrade channels (stable/beta) + pinned version + target version install"; Milestone = $milestoneP1; Labels = @("P1", "upgrade", "enhancement") }, |
| 284 | + @{ Title = "P1: GitHub API hardening (token support, retries, rate-limit messaging)"; Milestone = $milestoneP1; Labels = @("P1", "upgrade", "chore") }, |
| 285 | + @{ Title = "P1: Proxy checksum verification robustness (deterministic source)"; Milestone = $milestoneP1; Labels = @("P1", "release", "bug") }, |
| 286 | + @{ Title = "P1: Improve portable upgrade swap (temp + atomic replace + rollback)"; Milestone = $milestoneP1; Labels = @("P1", "upgrade", "enhancement") }, |
| 287 | + @{ Title = "P1: NPM publish pipeline + package contents control"; Milestone = $milestoneP1; Labels = @("P1", "npm", "release", "enhancement") }, |
| 288 | + @{ Title = "P2: Code signing for exe + installer (CI integration)"; Milestone = $milestoneP2; Labels = @("P2", "security", "release", "enhancement") }, |
| 289 | + @{ Title = "P2: winget/choco/scoop distribution"; Milestone = $milestoneP2; Labels = @("P2", "release", "enhancement") }, |
| 290 | + @{ Title = "P2: Enterprise policy.json (updates/auth constraints, rollout control)"; Milestone = $milestoneP2; Labels = @("P2", "security", "enhancement") }, |
| 291 | + @{ Title = "P2: Service-aware upgrade coordination (stop/start service safely)"; Milestone = $milestoneP2; Labels = @("P2", "service", "enhancement") } |
| 292 | +) |
| 293 | + |
| 294 | +$issueUrlMap = @{} |
| 295 | +foreach ($task in $taskDefinitions) { |
| 296 | + $issue = Ensure-Issue -Title $task.Title -Body "" -Labels $task.Labels -Milestone $task.Milestone |
| 297 | + $issueUrlMap[$task.Title] = $issue.url |
| 298 | +} |
| 299 | + |
| 300 | +$milestoneUrls = $milestoneTitles | ForEach-Object { "https://github.com/$owner/$repo/milestone/$($milestoneMap[$_])" } |
| 301 | + |
| 302 | +$p0Titles = $taskDefinitions | Where-Object { $_.Milestone -eq $milestoneP0 } | ForEach-Object { $_.Title } |
| 303 | +$p1Titles = $taskDefinitions | Where-Object { $_.Milestone -eq $milestoneP1 } | ForEach-Object { $_.Title } |
| 304 | +$p2Titles = $taskDefinitions | Where-Object { $_.Milestone -eq $milestoneP2 } | ForEach-Object { $_.Title } |
| 305 | + |
| 306 | +$roadmapLines = @() |
| 307 | +$roadmapLines += "---" |
| 308 | +$roadmapLines += "# Roadmap: Production Finalization (P0/P1/P2) $enDash CloudSQLCTL" |
| 309 | +$roadmapLines += "" |
| 310 | +$roadmapLines += "## Links" |
| 311 | +$roadmapLines += "- Discussion: $discussionUrl" |
| 312 | +$roadmapLines += "- Milestones: $($milestoneUrls -join ' ')" |
| 313 | +$roadmapLines += "- Repo: https://github.com/$owner/$repo" |
| 314 | +$roadmapLines += "" |
| 315 | +$roadmapLines += ("This issue tracks all work required to reach {0}production final{1}." -f $leftQuote, $rightQuote) |
| 316 | +$roadmapLines += "Checklist items below link to the corresponding issues." |
| 317 | +$roadmapLines += "" |
| 318 | +$roadmapLines += "## $milestoneP0" |
| 319 | +foreach ($title in $p0Titles) { |
| 320 | + $roadmapLines += "- [ ] [$title]($($issueUrlMap[$title]))" |
| 321 | +} |
| 322 | +$roadmapLines += "" |
| 323 | +$roadmapLines += "## $milestoneP1" |
| 324 | +foreach ($title in $p1Titles) { |
| 325 | + $roadmapLines += "- [ ] [$title]($($issueUrlMap[$title]))" |
| 326 | +} |
| 327 | +$roadmapLines += "" |
| 328 | +$roadmapLines += "## $milestoneP2" |
| 329 | +foreach ($title in $p2Titles) { |
| 330 | + $roadmapLines += "- [ ] [$title]($($issueUrlMap[$title]))" |
| 331 | +} |
| 332 | +$roadmapLines += "" |
| 333 | +$roadmapLines += "## Notes" |
| 334 | +$roadmapLines += "- Use labels: Priority:P0/P1/P2, Area:*, Type:*." |
| 335 | +$roadmapLines += "- Each milestone should have a clear scope and DoD." |
| 336 | +$roadmapLines += "---" |
| 337 | + |
| 338 | +$roadmapBody = $roadmapLines -join "`n" |
| 339 | +& gh issue edit $roadmapNumber --body $roadmapBody | Out-Null |
| 340 | + |
| 341 | +Write-Host "" |
| 342 | +Write-Host "Discussion/Issue URL: $discussionUrl" |
| 343 | +Write-Host "Roadmap issue URL: $roadmapUrl" |
| 344 | +Write-Host "Milestones:" |
| 345 | +foreach ($title in $milestoneTitles) { |
| 346 | + Write-Host " $title (#$($milestoneMap[$title]))" |
| 347 | +} |
| 348 | +Write-Host "Issues:" |
| 349 | +foreach ($task in $taskDefinitions) { |
| 350 | + Write-Host " $($task.Title): $($issueUrlMap[$task.Title])" |
| 351 | +} |
0 commit comments