|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | +"context" |
| 5 | +"errors" |
| 6 | +"os" |
| 7 | +"slices" |
| 8 | +"strings" |
| 9 | + |
| 10 | +"github.com/google/go-cmp/cmp" |
| 11 | +"github.com/google/go-github/v43/github" |
| 12 | +"golang.org/x/mod/semver" |
| 13 | +"golang.org/x/xerrors" |
| 14 | + |
| 15 | +"cdr.dev/slog" |
| 16 | +"cdr.dev/slog/sloggers/sloghuman" |
| 17 | +"github.com/coder/coder/v2/cli/cliui" |
| 18 | +"github.com/coder/serpent" |
| 19 | +) |
| 20 | + |
| 21 | +const ( |
| 22 | +owner="coder" |
| 23 | +repo="coder" |
| 24 | +) |
| 25 | + |
| 26 | +funcmain() { |
| 27 | +logger:=slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug) |
| 28 | + |
| 29 | +vardryRunbool |
| 30 | + |
| 31 | +cmd:= serpent.Command{ |
| 32 | +Use:"release <subcommand>", |
| 33 | +Short:"Prepare, create and publish releases.", |
| 34 | +Options: serpent.OptionSet{ |
| 35 | +{ |
| 36 | +Flag:"dry-run", |
| 37 | +FlagShorthand:"n", |
| 38 | +Description:"Do not make any changes, only print what would be done.", |
| 39 | +Value:serpent.BoolOf(&dryRun), |
| 40 | +}, |
| 41 | +}, |
| 42 | +Children: []*serpent.Command{ |
| 43 | +{ |
| 44 | +Use:"promote <version>", |
| 45 | +Short:"Promote version to stable.", |
| 46 | +Handler:func(inv*serpent.Invocation)error { |
| 47 | +ctx:=inv.Context() |
| 48 | +iflen(inv.Args)==0 { |
| 49 | +returnxerrors.New("version argument missing") |
| 50 | +} |
| 51 | + |
| 52 | +err:=promoteVersionToStable(ctx,inv,logger,dryRun,inv.Args[0]) |
| 53 | +iferr!=nil { |
| 54 | +returnerr |
| 55 | +} |
| 56 | + |
| 57 | +returnnil |
| 58 | +}, |
| 59 | +}, |
| 60 | +}, |
| 61 | +} |
| 62 | + |
| 63 | +err:=cmd.Invoke().WithOS().Run() |
| 64 | +iferr!=nil { |
| 65 | +iferrors.Is(err,cliui.Canceled) { |
| 66 | +os.Exit(1) |
| 67 | +} |
| 68 | +logger.Error(context.Background(),"release command failed","err",err) |
| 69 | +os.Exit(1) |
| 70 | +} |
| 71 | +} |
| 72 | + |
| 73 | +//nolint:revive // Allow dryRun control flag. |
| 74 | +funcpromoteVersionToStable(ctx context.Context,inv*serpent.Invocation,logger slog.Logger,dryRunbool,versionstring)error { |
| 75 | +client:=github.NewClient(nil) |
| 76 | + |
| 77 | +logger=logger.With(slog.F("dry_run",dryRun),slog.F("version",version)) |
| 78 | + |
| 79 | +logger.Info(ctx,"checking current stable release") |
| 80 | + |
| 81 | +// Check if the version is already the latest stable release. |
| 82 | +currentStable,_,err:=client.Repositories.GetLatestRelease(ctx,"coder","coder") |
| 83 | +iferr!=nil { |
| 84 | +returnerr |
| 85 | +} |
| 86 | + |
| 87 | +logger=logger.With(slog.F("stable_version",currentStable.GetTagName())) |
| 88 | +logger.Info(ctx,"found current stable release") |
| 89 | + |
| 90 | +ifcurrentStable.GetTagName()==version { |
| 91 | +returnxerrors.Errorf("version %q is already the latest stable release",version) |
| 92 | +} |
| 93 | + |
| 94 | +// Ensure the version is a valid release. |
| 95 | +perPage:=20 |
| 96 | +latestReleases,_,err:=client.Repositories.ListReleases(ctx,owner,repo,&github.ListOptions{ |
| 97 | +Page:0, |
| 98 | +PerPage:perPage, |
| 99 | +}) |
| 100 | +iferr!=nil { |
| 101 | +returnerr |
| 102 | +} |
| 103 | + |
| 104 | +varreleaseVersions []string |
| 105 | +varnewStable*github.RepositoryRelease |
| 106 | +for_,r:=rangelatestReleases { |
| 107 | +releaseVersions=append(releaseVersions,r.GetTagName()) |
| 108 | +ifr.GetTagName()==version { |
| 109 | +newStable=r |
| 110 | +} |
| 111 | +} |
| 112 | +semver.Sort(releaseVersions) |
| 113 | +slices.Reverse(releaseVersions) |
| 114 | + |
| 115 | +switch { |
| 116 | +caselen(releaseVersions)==0: |
| 117 | +returnxerrors.Errorf("no releases found") |
| 118 | +casenewStable==nil: |
| 119 | +returnxerrors.Errorf("version %q is not found in the last %d releases",version,perPage) |
| 120 | +} |
| 121 | + |
| 122 | +logger=logger.With(slog.F("mainline_version",releaseVersions[0])) |
| 123 | + |
| 124 | +ifversion!=releaseVersions[0] { |
| 125 | +logger.Warn(ctx,"selected version is not the latest mainline release") |
| 126 | +} |
| 127 | + |
| 128 | +ifreply,err:=cliui.Prompt(inv, cliui.PromptOptions{ |
| 129 | +Text:"Are you sure you want to promote this version to stable?", |
| 130 | +Default:"no", |
| 131 | +IsConfirm:true, |
| 132 | +});err!=nil { |
| 133 | +ifreply==cliui.ConfirmNo { |
| 134 | +returnnil |
| 135 | +} |
| 136 | +returnerr |
| 137 | +} |
| 138 | + |
| 139 | +logger.Info(ctx,"promoting version to stable") |
| 140 | + |
| 141 | +// Update the release to latest. |
| 142 | +updatedNewStable:=cloneRelease(newStable) |
| 143 | + |
| 144 | +updatedNewStable.Name=github.String(newStable.GetName()+" (Stable)") |
| 145 | +updatedNewStable.Body=github.String(removeMainlineBlurb(newStable.GetBody())) |
| 146 | +updatedNewStable.Prerelease=github.Bool(false) |
| 147 | +updatedNewStable.Draft=github.Bool(false) |
| 148 | +if!dryRun { |
| 149 | +_,_,err=client.Repositories.EditRelease(ctx,owner,repo,newStable.GetID(),newStable) |
| 150 | +iferr!=nil { |
| 151 | +returnerr |
| 152 | +} |
| 153 | +logger.Info(ctx,"version promoted to stable") |
| 154 | +}else { |
| 155 | +logger.Info(ctx,"dry-run: release not updated","uncommitted_changes",cmp.Diff(newStable,updatedNewStable)) |
| 156 | +} |
| 157 | + |
| 158 | +logger.Info(ctx,"updating title of the previous stable release") |
| 159 | + |
| 160 | +// Update the previous stable release to a regular release. |
| 161 | +updatedOldStable:=cloneRelease(currentStable) |
| 162 | +currentStable.Name=github.String(strings.TrimSuffix(currentStable.GetName()," (Stable)")) |
| 163 | + |
| 164 | +if!dryRun { |
| 165 | +_,_,err=client.Repositories.EditRelease(ctx,owner,repo,currentStable.GetID(),currentStable) |
| 166 | +iferr!=nil { |
| 167 | +returnerr |
| 168 | +} |
| 169 | +logger.Info(ctx,"title of the previous stable release updated") |
| 170 | +}else { |
| 171 | +logger.Info(ctx,"dry-run: release not updated","uncommitted_changes",cmp.Diff(currentStable,updatedOldStable)) |
| 172 | +} |
| 173 | + |
| 174 | +returnnil |
| 175 | +} |
| 176 | + |
| 177 | +funccloneRelease(r*github.RepositoryRelease)*github.RepositoryRelease { |
| 178 | +rr:=*r |
| 179 | +return&rr |
| 180 | +} |
| 181 | + |
| 182 | +// removeMainlineBlurb removes the mainline blurb from the release body. |
| 183 | +// |
| 184 | +// Example: |
| 185 | +// |
| 186 | +//> [!NOTE] |
| 187 | +//> This is a mainline Coder release. We advise enterprise customers without a staging environment to install our [latest stable release](https://github.com/coder/coder/releases/latest) while we refine this version. Learn more about our [Release Schedule](https://coder.com/docs/v2/latest/install/releases). |
| 188 | +funcremoveMainlineBlurb(bodystring)string { |
| 189 | +lines:=strings.Split(body,"\n") |
| 190 | + |
| 191 | +varnewBody,clip []string |
| 192 | +varfoundbool |
| 193 | +for_,line:=rangelines { |
| 194 | +ifstrings.HasPrefix(strings.TrimSpace(line),"> [!NOTE]") { |
| 195 | +clip=append(clip,line) |
| 196 | +found=true |
| 197 | +continue |
| 198 | +} |
| 199 | +iffound { |
| 200 | +clip=append(clip,line) |
| 201 | +found=strings.HasPrefix(strings.TrimSpace(line),">") |
| 202 | +continue |
| 203 | +} |
| 204 | +if!found&&len(clip)>0 { |
| 205 | +if!strings.Contains(strings.ToLower(strings.Join(clip,"\n")),"this is a mainline coder release") { |
| 206 | +newBody=append(newBody,clip...)// This is some other note, restore it. |
| 207 | +} |
| 208 | +clip=nil |
| 209 | +} |
| 210 | +newBody=append(newBody,line) |
| 211 | +} |
| 212 | + |
| 213 | +returnstrings.Join(newBody,"\n") |
| 214 | +} |