| // 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) | |
| } |