blob: 5fc03cd20870f1fb488df4d867f259519ff30186 [file] [log] [blame] [edit]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.23
// This script is used to build and publish VS Code Go extension.
// The script should be run from the root of the repository where package.json is located.
//
// The script requires the following environment variables to be set:
//
// TAG_NAME: the name of the tag to be released.
// COMMIT_SHA: the commit SHA to be released (optional. if not set, it will be retrieved from git)
// VSCE_PAT: the Personal Access Token for the VS Code Marketplace.
// GITHUB_TOKEN: the GitHub token for the Go repository.
//
// This script requires the following tools to be installed:
//
// jq, npx, gh, git
//
// Usage:
//
// // build vscgo@TAG_NAME (based on TAG_NAME).
// go run ./tools/release build-vscgo -out=/tmp/artifacts
// // package the extension (based on TAG_NAME).
// go run ./tools/release package
// // publish the extension.
// go run ./tools/release publish
package main
import (
"archive/zip"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var (
flagN = false
)
var (
cmdBuildVSCGO = &command{
usage: "build-vscgo",
short: "build github.com/golang/vscode-go/vscgo@TAG_NAME",
long: "build-vscgo command cross-compiles the vscgo binaries and produces vscgo.zip in -out dir",
run: runBuildVSCGO,
}
cmdPackage = &command{
usage: "package",
short: "package the extension to vsix file",
long: `package command builds the extension and produces .vsix file in -out`,
run: runPackage,
}
cmdPublish = &command{
usage: "publish",
short: "publish the packaged extension (vsix) to the Visual Studio Code marketplace",
long: `publish command publishes all the extension files in -in to the Visual Studio Code marketplace`,
run: runPublish,
}
allCommands = []*command{cmdBuildVSCGO, cmdPackage, cmdPublish}
)
func init() {
cmdBuildVSCGO.flags.String("out", ".", "directory where the vscgo.zip file is written")
cmdPackage.flags.String("out", ".", "directory where the artifacts are written")
cmdPublish.flags.String("in", ".", "directory where the artifacts to be published are")
addCommonFlags := func(cmd *command) {
cmd.flags.BoolVar(&flagN, "n", flagN, "print the underlying commands but do not run them")
}
for _, cmd := range allCommands {
addCommonFlags(cmd)
name := cmd.name()
cmd.flags.Usage = func() {
help(name)
}
}
}
func main() {
flag.Usage = usage
flag.Parse()
args := flag.Args()
if flag.NArg() == 0 {
flag.Usage()
os.Exit(2)
}
// len(args) > 0
if args[0] == "help" {
flag.CommandLine.SetOutput(os.Stdout)
switch len(args) {
case 1:
flag.Usage()
case 2:
help(args[1])
default:
flag.Usage()
fatalf(`too many arguments to "help"`)
}
os.Exit(0)
}
cmd := findCommand(args[0])
if cmd == nil {
flag.Usage()
os.Exit(2)
}
cmd.run(cmd, args[1:])
}
func usage() {
printCommand := func(cmd *command) {
output(fmt.Sprintf("\t%s\t%s", cmd.name(), cmd.short))
}
output("go run release.go [command]")
output("The commands are:")
output()
for _, cmd := range allCommands {
printCommand(cmd)
}
output()
}
func output(msgs ...any) {
fmt.Fprintln(flag.CommandLine.Output(), msgs...)
}
func findCommand(name string) *command {
for _, cmd := range allCommands {
if cmd.name() == name {
return cmd
}
}
return nil
}
func help(name string) {
cmd := findCommand(name)
if cmd == nil {
fatalf("unknown command %q", name)
}
output(fmt.Sprintf("Usage: release %s", cmd.usage))
output()
if cmd.long != "" {
output(cmd.long)
} else {
output(fmt.Sprintf("release %s is used to %s.", cmd.name(), cmd.short))
}
anyflags := false
cmd.flags.VisitAll(func(*flag.Flag) {
anyflags = true
})
if anyflags {
output()
output("Flags:")
output()
cmd.flags.PrintDefaults()
}
}
type command struct {
usage string
short string
long string
flags flag.FlagSet
run func(cmd *command, args []string)
}
func (c command) name() string {
name, _, _ := strings.Cut(c.usage, " ")
return name
}
func (c command) lookupFlag(name string) flag.Value {
f := c.flags.Lookup(name)
if f == nil {
fatalf("flag %q not found", name)
}
return f.Value
}
// runBuildVSCGO implements the "build-vscgo" subcommand.
func runBuildVSCGO(cmd *command, args []string) {
cmd.flags.Parse(args) // will exit on error
checkWD()
requireTools("go")
tagName := requireEnv("TAG_NAME")
outDir := prepareOutputDir(cmd.lookupFlag("out").String())
buildVSCGO(tagName, outDir)
}
// runPackage implements the "package" subcommand.
func runPackage(cmd *command, args []string) {
cmd.flags.Parse(args) // will exit on error
checkWD()
requireTools("npx")
tagName := requireEnv("TAG_NAME")
version, isPrerelease := releaseVersionInfo(tagName)
outDir := prepareOutputDir(cmd.lookupFlag("out").String())
vsix := filepath.Join(outDir, fmt.Sprintf("go-%s.vsix", version))
buildPackage(version, tagName, isPrerelease, vsix)
}
// runPublish implements the "publish" subcommand.
func runPublish(cmd *command, args []string) {
cmd.flags.Parse(args) // will exit on error
checkWD()
requireTools("npx")
// npx vsce directly reads VSCE_PAT, so no parsing is needed.
// See https://github.com/microsoft/vscode-vsce/blob/ba6681809080ee8685fb86d4b4fca765f1d82708/src/main.ts#L186
requireEnv("VSCE_PAT")
tagName := requireEnv("TAG_NAME")
version, isPrerelease := releaseVersionInfo(tagName)
inDir := prepareInputDir(cmd.lookupFlag("in").String())
publish(tagName, filepath.Join(inDir, fmt.Sprintf("go-%s.vsix", version)), isPrerelease)
}
func fatalf(format string, args ...any) {
if len(args) == 0 {
fmt.Fprint(os.Stderr, format)
} else {
fmt.Fprintf(os.Stderr, format, args...)
}
fmt.Fprintf(os.Stderr, "\n")
os.Exit(1)
}
// prepareOutputDir normalizes --output-dir. If the directory doesn't exist,
// prepareOutputDir creates it.
func prepareOutputDir(outDir string) string {
if outDir == "" {
outDir = "."
}
if flagN {
// -n used for testing. don't create the directory nor try to resolve.
return outDir
}
// resolve to absolute path so output dir can be consitent
// even when child processes accessing it need to run in a different directory.
dir, err := filepath.Abs(outDir)
if err != nil {
fatalf("failed to get absolute path of output directory: %v", err)
}
if err := os.MkdirAll(dir, 0755); err != nil {
fatalf("failed to create output directory: %v", err)
}
return dir
}
// prepareInputDir normalizes --input-dir.
func prepareInputDir(inDir string) string {
if inDir == "" {
inDir = "."
}
if flagN {
// -n used for testing. don't create the directory nor try to resolve.
return inDir
}
// resolve to absolute path so input dir can be consitent
// even when child processes accessing it need to run in a different directory.
dir, err := filepath.Abs(inDir)
if err != nil {
fatalf("failed to get absolute path of output directory: %v", err)
}
return dir
}
func requireTools(tools ...string) {
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
fatalf("required tool %q not found", tool)
}
}
}
func requireEnv(name string) string {
v := os.Getenv(name)
if v == "" {
fatalf("required environment variable %q not set", name)
}
return v
}
// checkWD checks if the working directory is the extension directory where package.json is located.
func checkWD() {
wd, err := os.Getwd()
if err != nil {
fatalf("failed to get working directory")
}
// check if package.json is in the working directory
if _, err := os.Stat("package.json"); os.IsNotExist(err) {
fatalf("package.json not found in working directory %q", wd)
}
}
// releaseVersionInfo computes the version and label information for this release.
// It requires the TAG_NAME environment variable to be set and the tag matches the version info embedded in package.json.
func releaseVersionInfo(tagName string) (version string, isPrerelease bool) {
// Odd numbered minor version -> prerelease, after v0.42.
major, minor, patch, label := parseVersionTagName(tagName)
if (major != 0 || minor > 42) && minor%2 == 1 {
isPrerelease = true
}
if label != "" {
if !strings.HasPrefix(label, "-rc.") {
fatalf("TAG_NAME environment variable %q is not a valid release candidate version", tagName)
}
isPrerelease = true
}
return fmt.Sprintf("%d.%d.%d", major, minor, patch) + label, isPrerelease
}
func parseVersionTagName(tagName string) (major, minor, patch int, label string) {
versionTagRE := regexp.MustCompile(`^v(?P<Major>\d+)\.(?P<Minor>\d+)\.(?P<Patch>\d+)(?P<Label>\S*)$`)
m := versionTagRE.FindStringSubmatch(tagName)
if m == nil {
fatalf("TAG_NAME environment variable %q is not a valid version", tagName)
}
atoi := func(key string) int {
val, err := strconv.Atoi(m[versionTagRE.SubexpIndex(key)])
if err != nil {
fatalf("%v in %v (%q) is not valid", key, tagName, m[versionTagRE.SubexpIndex(key)])
}
return val
}
return atoi("Major"), atoi("Minor"), atoi("Patch"), m[versionTagRE.SubexpIndex("Label")]
}
func commandRun(cmd *exec.Cmd) error {
if flagN {
if cmd.Dir != "" {
tracef("cd %v", cmd.Dir)
}
fmt.Fprintf(os.Stderr, "%v\n", strings.Join(cmd.Args, " "))
return nil
}
return cmd.Run()
}
func copy(dst, src string) error {
if flagN {
tracef("cp %s %s", src, dst)
return nil
}
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
func rm(file string) error {
if flagN {
tracef("rm %s", file)
return nil
}
return os.Remove(file)
}
// buildPackage builds the extension of the given version, using npx vsce package.
func buildPackage(version, tagName string, isPrerelease bool, output string) {
// We want to embed the README.md file of the repo root to the extension,
// but vsce does not allow to include a file outside the node.js module directory.
// So, let's copy the file temporarily.
if err := copy("README.md", filepath.Join("..", "README.md")); err != nil {
fatalf("failed to copy README.md: %v", err)
}
defer func() {
if err := rm("README.md"); err != nil {
fatalf("failed to delete the temporarily created README.md file: %v", err)
}
}()
// build the package.
args := []string{"vsce", "package",
"-o", output,
"--baseContentUrl", "https://github.com/golang/vscode-go/raw/" + tagName,
"--baseImagesUrl", "https://github.com/golang/vscode-go/raw/" + tagName,
}
if isPrerelease || strings.Contains(tagName, "-rc.") {
// Do not update of the version field in packages.json for prerelease or rc.
// relui will create a cl to update package.json during stable release only.
args = append(args, "--no-update-package-json", "--no-git-tag-version")
}
if isPrerelease {
args = append(args, "--pre-release")
}
args = append(args, version)
// build the package.
cmd := exec.Command("npx", args...)
cmd.Stderr = os.Stderr
if err := commandRun(cmd); err != nil {
fatalf("failed to build package: %v", err)
}
}
// publish publishes the extension to the VS Code Marketplace using npx vsce.
func publish(tagName, packageFile string, isPrerelease bool) {
// Skip prerelease versions, as they are not published to the marketplace.
if strings.Contains(tagName, "-rc.") {
return
}
// check if the package file exists.
if flagN {
tracef("stat %s", packageFile)
} else {
if _, err := os.Stat(packageFile); os.IsNotExist(err) {
fatalf("package file %q does not exist. Did you run 'go run tools/release/release.go package'?", packageFile)
}
}
npxVsceArgs := []string{"vsce", "publish", "-i", packageFile}
if isPrerelease {
npxVsceArgs = append(npxVsceArgs, "--pre-release")
}
cmd2 := exec.Command("npx", npxVsceArgs...)
cmd2.Stderr = os.Stderr
if err := commandRun(cmd2); err != nil {
fatalf("failed to publish release")
}
}
// commitSHA returns COMMIT_SHA environment variable, or the commit SHA of the current branch.
func commitSHA() string {
if commitSHA := os.Getenv("COMMIT_SHA"); commitSHA != "" {
return commitSHA
}
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Stderr = os.Stderr
commitSHA, err := cmd.Output()
if err != nil {
fatalf("failed to get commit SHA")
}
return strings.TrimSpace(string(commitSHA))
}
// The followings are the platforms we ship precompiled vscgo binaries for.
// On other platforms not in this list, the extension will fall back to
// install vscgo using `go install`.
//
// The full list of platforms officially supported by VS Code is:
// https://code.visualstudio.com/api/working-with-extensions/publishing-extension#platformspecific-extensions
var targetPlatforms = []struct {
name string
goos, goarch string
}{
{name: "linux-x64", goos: "linux", goarch: "amd64"},
{name: "linux-arm64", goos: "linux", goarch: "arm64"},
{name: "darwin-x64", goos: "darwin", goarch: "amd64"},
{name: "darwin-arm64", goos: "darwin", goarch: "arm64"},
{name: "win32-x64", goos: "windows", goarch: "amd64"},
{name: "win32-arm64", goos: "windows", goarch: "arm64"},
}
func buildVSCGO(tagName, outdir string) {
tmpDir, err := mkdirTemp("", "buildVSCGO")
if err != nil {
fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
for _, target := range targetPlatforms {
base := "vscgo"
if target.goos == "windows" {
base += ".exe"
}
outfile := filepath.Join(tmpDir, target.goos+"_"+target.goarch, base)
if err := goinstall(outfile, target.goos, target.goarch, "github.com/golang/vscode-go/vscgo@"+tagName); err != nil {
fatalf("failed to build %s/%s: %v", target.goos, target.goarch, err)
}
}
artifact := filepath.Join(outdir, "vscgo.zip")
if err := zipAll(tmpDir, artifact); err != nil {
fatalf("failed to zip: %v", err)
}
}
func zipAll(srcDir, dstFile string) (err error) {
if flagN {
tracef("cd %s; zip -r %s *", srcDir, dstFile)
return nil
}
// zip all files in srcDir
w, err := os.Create(dstFile)
if err != nil {
return err
}
defer func() {
cerr := w.Close()
if err == nil {
err = cerr
}
}()
zw := zip.NewWriter(w)
defer func() {
cerr := zw.Close()
if err == nil {
err = cerr
}
}()
return zw.AddFS(os.DirFS(srcDir))
}
// goinstall runs 'go install' with the given GOOS/GOARCH,
// and renames the produced binary as outfile. Since the binary
// is built with 'go install', the binary contains complete
// build info.
// This is a workaround for the limitation of `go install`
// (go.dev/issue/57485) and `go build` (go.dev/issue/50603)
func goinstall(outfile, goOS, goArch string, args ...string) error {
if flagN {
// -o is proposed in go.dev/issue/57485, but not yet
// a supported go install flag. So, it's a lie.
tracef("go install -o %s %s", outfile, strings.Join(args, " "))
return nil
}
env := goenv("GOHOSTOS", "GOHOSTARCH", "GOMODCACHE")
hostOS, hostArch := env["GOHOSTOS"], env["GOHOSTARCH"]
gomodcache := env["GOMODCACHE"]
outGOPATH, err := mkdirTemp("", "goinstall")
if err != nil {
fatalf("failed to create temp for go install: %v", err)
}
defer os.RemoveAll(outGOPATH)
args = append([]string{"install"}, args...)
cmd := exec.Command("go", args...)
cmd.Env = append(os.Environ(),
"GOOS="+goOS,
"GOARCH="+goArch,
"GOPATH="+outGOPATH,
"GOMODCACHE="+gomodcache, // reuse the process's module cache
"CGO_ENABLED=0",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
from, err := searchGoinstallOutput(outGOPATH, goOS, goArch, hostOS, hostArch)
if err != nil {
return err
}
if outfile == "" {
outfile = filepath.Join(".", filepath.Base(outfile))
}
if err := mv(from, outfile); err != nil {
return err
}
return nil
}
// searchGoinstallOutput returns the path to the binary produced by go install command.
func searchGoinstallOutput(outGOPATH, goOS, goArch, hostOS, hostArch string) (string, error) {
// binary will be written in GOPATH/bin/ or GOPATH/bin/<goos>_<goarch>.
installDir := filepath.Join(outGOPATH, "bin")
if goOS != hostOS || goArch != hostArch {
installDir = filepath.Join(outGOPATH, "bin", goOS+"_"+goArch)
}
// since we do not know the target binary name, we assume any executable file found in installDir
// is the target binary we just built. It's ok since we are working with temporary GOPATH.
files, err := os.ReadDir(installDir)
if err != nil {
return "", err
}
for _, f := range files {
if f.IsDir() {
continue
}
if i, err := f.Info(); err != nil {
return "", err
} else if i.Mode().Perm()&0111 == 0 { // not executable, skip.
continue
}
return filepath.Join(installDir, f.Name()), nil
}
return "", fmt.Errorf("failed to find the installed binary in %q", installDir)
}
func mv(from, to string) error {
if err := os.MkdirAll(filepath.Dir(to), 0777); err != nil {
return err
}
if err := os.Rename(from, to); err != nil {
return err
}
return nil
}
func goenv(keys ...string) map[string]string {
args := append([]string{"env", "-json"}, keys...)
res, err := exec.Command("go", args...).Output()
if err != nil {
fatalf("failed go env: %v", err)
}
var ret map[string]string
if err := json.Unmarshal(res, &ret); err != nil {
fatalf("failed to unmarshal go env output: %v", err)
}
return ret
}
// mkdirTemp wraps os.MkdirTemp, and records the created directory
// to tempDirs. tracef uses tempDirs to replace any references to temporarily
// created directories with short names.
func mkdirTemp(dir, pattern string) (string, error) {
dir, err := os.MkdirTemp(dir, pattern)
if err != nil {
return "", err
}
tempDirs = append(tempDirs, replaceRule{from: dir, to: strings.ToUpper(pattern)})
return dir, nil
}
var tempDirs []replaceRule
type replaceRule struct{ from, to string }
func tracef(format string, args ...any) {
str := format
if len(args) > 0 {
str = fmt.Sprintf(format, args...)
}
for _, tmpdir := range tempDirs {
str = strings.ReplaceAll(str, tmpdir.from, tmpdir.to)
}
fmt.Fprintln(os.Stderr, str)
}