From a862a08d6bbb6199a1480a3617393db81f7be6a8 Mon Sep 17 00:00:00 2001 From: Alex Stanfield <13949480+chaptersix@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:40:39 -0500 Subject: [PATCH 1/3] ci/docs: escape MDX-incompatible patterns and add -subdir to gen-docs (#1112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This consolidates the excellent groundwork in #980, #1076, and #981 into a single change, and verifies the result against a real Docusaurus 3.10.1 build. Huge thanks to @lennessyy — the approach here is entirely built on those PRs; this just stitches them together and irons out a few interactions between them. gen-docs now escapes the patterns that break Docusaurus MDX (JSX) compilation, on **every** path that writes a command description (including the new split paths): - bare angle-bracket placeholders in prose (e.g. ``, ``) → `\<...\>` - single-quoted JSON examples (e.g. `'{"a":"b"}'`, `'Key={"a":"b"}'`) → braces escaped in body text, backticked in option tables - custom heading IDs (e.g. `## Heading {#id}`) → `{/* #id */}`, the form that compiles under Docusaurus 3.10 and still produces the custom anchor Fenced code blocks and inline code spans are left untouched. It also adds the `-subdir` flag (subcommands of the named command are written to a subdirectory, e.g. `-subdir cloud` → `cloud/*.mdx`, with deeper subcommands nested as headings), and a generic auto-generated notice. ## What changed relative to the existing PRs All three PRs were on the right track. The differences here are about how they interact: - **#1076 (MDX escaping):** kept as the core of this change, including the `{#id}` → `{/* #id */}` heading conversion — which I confirmed is exactly right for the 3.10 upgrade (see verification below). Escaping is now also applied to the `-subdir` split output, so cloud docs get the same treatment. Re-added unit tests for the escaping logic. - **#980 (`-subdir`):** kept the split mechanism. Unified the `encodeJSONExample` regex so it matches both `'{...}'` and `'Key={...}'` (the standalone-vs-key-value cases the two PRs handled separately). Intentionally did **not** carry over #980's index-page generation: `command-reference/index.mdx` and `cloud/index.mdx` are hand-maintained on the docs site (custom ordering, the `ReleaseNoteHeader` component), so gen-docs deliberately does not emit them — generating them would overwrite that curated content. The companion docs PR restores those files after each regeneration. - **#981 (generic notice):** folded in — the previous notice pointed at paths that no longer exist and don't hold for cloud-cli inputs. ## Companion PR https://github.com/temporalio/documentation/pull/4836 (cherry picked from commit a38333dbb7952173a9d30668c45b92031dccb0eb) --- cmd/gen-docs/main.go | 12 +- internal/commandsgen/docs.go | 273 ++++++++++++++++++++++++++---- internal/commandsgen/docs_test.go | 184 ++++++++++++++++++++ 3 files changed, 431 insertions(+), 38 deletions(-) create mode 100644 internal/commandsgen/docs_test.go diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 11ea430e0..f6825f328 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -10,7 +10,8 @@ import ( "github.com/temporalio/cli/internal/commandsgen" ) -// stringSlice implements flag.Value to support multiple -input flags +// stringSlice implements flag.Value to support flags that may be specified +// multiple times (e.g. -input, -subdir). type stringSlice []string func (s *stringSlice) String() string { @@ -32,10 +33,12 @@ func run() error { var ( outputDir string inputFiles stringSlice + subdirs stringSlice ) flag.Var(&inputFiles, "input", "Input YAML file (can be specified multiple times)") flag.StringVar(&outputDir, "output", ".", "Output directory for docs") + flag.Var(&subdirs, "subdir", "Write the subcommands of this command into a subdirectory of separate files instead of a single file (can be specified multiple times)") flag.Parse() if len(inputFiles) == 0 { @@ -60,13 +63,18 @@ func run() error { return fmt.Errorf("failed parsing YAML: %w", err) } - docs, err := commandsgen.GenerateDocsFiles(cmds) + docs, err := commandsgen.GenerateDocsFiles(cmds, subdirs) if err != nil { return fmt.Errorf("failed generating docs: %w", err) } for filename, content := range docs { filePath := filepath.Join(outputDir, filename+".mdx") + // Filenames may contain a path separator (e.g. "cloud/namespace") when + // -subdir is used, so ensure the parent directory exists. + if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + return fmt.Errorf("failed creating directory for %s: %w", filePath, err) + } if err := os.WriteFile(filePath, content, 0644); err != nil { return fmt.Errorf("failed writing %s: %w", filePath, err) } diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index b3857503e..d699c23f6 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -8,17 +8,33 @@ import ( "strings" ) -func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { +// GenerateDocsFiles generates the MDX documentation files from parsed commands. +// +// subdirNames lists command names whose subcommands should each be written to +// their own file within a subdirectory instead of being combined into a single +// file. For example, passing "cloud" produces cloud/namespace.mdx, cloud/user.mdx, +// etc. rather than a single, unmanageably large cloud.mdx. +// +// Index/landing pages are intentionally not generated: on the docs site they are +// hand-maintained (custom ordering, front matter, embedded components), so +// generating them would overwrite that curated content. +func GenerateDocsFiles(commands Commands, subdirNames []string) (map[string][]byte, error) { optionSetMap := make(map[string]OptionSets) for i, optionSet := range commands.OptionSets { optionSetMap[optionSet.Name] = commands.OptionSets[i] } + splitParents := make(map[string]bool) + for _, name := range subdirNames { + splitParents[name] = true + } + w := &docWriter{ fileMap: make(map[string]*bytes.Buffer), optionSetMap: optionSetMap, allCommands: commands.CommandList, globalFlagsMap: make(map[string]map[string]Option), + splitParents: splitParents, } // sorted ascending by full name of command (activity complete, batch list, etc) @@ -45,13 +61,33 @@ type docWriter struct { optionSetMap map[string]OptionSets optionsStack [][]Option globalFlagsMap map[string]map[string]Option // fileName -> optionName -> Option + splitParents map[string]bool // command names whose subcommands are split into a subdirectory } func (c *Command) writeDoc(w *docWriter) error { w.processOptions(c) - // If this is a root command, write a new file depth := c.depth() + + // A split parent (e.g. "cloud") has no standalone file; its children are + // each written into a subdirectory instead. + if w.splitParents[c.FullName] { + return nil + } + // If any ancestor is a split parent, route this command into that subdirectory. + // FullName segments are separated by single spaces (never multiple), so + // splitting on " " yields the exact command path. + if depth >= 1 { + parts := strings.Split(c.FullName, " ") + for i := len(parts) - 1; i >= 1; i-- { + ancestor := strings.Join(parts[:i], " ") + if w.splitParents[ancestor] { + c.writeSplitDoc(w, ancestor) + return nil + } + } + } + if depth == 1 { w.writeCommand(c) } else if depth > 1 { @@ -80,8 +116,7 @@ func (w *docWriter) writeCommand(c *Command) { } w.fileMap[fileName].WriteString("---") w.fileMap[fileName].WriteString("\n\n") - w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n") - w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli/blob/main/internal/commandsgen/commands.yml via internal/cmd/gen-docs */}\n\n") + w.fileMap[fileName].WriteString(autoGeneratedNotice) // Add introductory paragraph w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal` CLI `%s` command. ", fileName)) w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") @@ -92,45 +127,146 @@ func (w *docWriter) writeSubcommand(c *Command) { fileName := c.fileName() prefix := strings.Repeat("#", c.depth()) w.fileMap[fileName].WriteString(prefix + " " + c.leafName() + "\n\n") - w.fileMap[fileName].WriteString(c.Description + "\n\n") + w.fileMap[fileName].WriteString(escapeMDXDescription(c.Description) + "\n\n") if w.isLeafCommand(c) { - // gather options from command and all options available from parent commands - var options = make([]Option, 0) - var globalOptions = make([]Option, 0) - for i, o := range w.optionsStack { - if i == len(w.optionsStack)-1 { - options = append(options, o...) - } else { - globalOptions = append(globalOptions, o...) - } - } - - // alphabetize options - sort.Slice(options, func(i, j int) bool { - return options[i].Name < options[j].Name - }) + w.writeLeafOptions(fileName) + } +} - // Write command-specific flags or global flags message - if len(options) > 0 { - w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command. ") - w.fileMap[fileName].WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n") - w.writeOptionsTable(options, c) +// writeLeafOptions writes the option table (or the global-flags fallback message) +// for a leaf command, and records its inherited options for the file's Global +// Flags section. The command's own options are the top frame of the options +// stack; everything below it is inherited from parent commands. +// +// The reference to the "#global-flags" anchor is only emitted when there are +// inherited options, since that is what causes a Global Flags section (and thus +// the anchor) to be generated for the file. A command with no inherited options +// (e.g. a top-level command in a split subdirectory) would otherwise link to an +// anchor that never exists. +func (w *docWriter) writeLeafOptions(fileName string) { + var options, globalOptions []Option + for i, o := range w.optionsStack { + if i == len(w.optionsStack)-1 { + options = append(options, o...) } else { - w.fileMap[fileName].WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n") + globalOptions = append(globalOptions, o...) } + } + + sort.Slice(options, func(i, j int) bool { + return options[i].Name < options[j].Name + }) + + buf := w.fileMap[fileName] + hasGlobal := len(globalOptions) > 0 + switch { + case len(options) > 0 && hasGlobal: + buf.WriteString("Use the following options to change the behavior of this command. ") + buf.WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n") + w.writeOptionsTable(options, fileName) + case len(options) > 0: + buf.WriteString("Use the following options to change the behavior of this command.\n\n") + w.writeOptionsTable(options, fileName) + case hasGlobal: + buf.WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n") + } + + w.collectGlobalFlags(fileName, globalOptions) +} + +// writeSplitDoc routes a command that lives under a split parent to the right +// writer: direct children get their own file, deeper subcommands are appended +// as headings within their parent's file. +func (c *Command) writeSplitDoc(w *docWriter, splitRoot string) { + splitDepth := len(strings.Split(splitRoot, " ")) + relativeDepth := len(strings.Split(c.FullName, " ")) - splitDepth + + switch { + case relativeDepth == 1: + w.writeSplitCommand(c, splitRoot) + case relativeDepth > 1: + w.writeSplitSubcommand(c, splitRoot) + } +} + +// splitFileName returns the file path for a command within a split parent. +// For example, with splitRoot "cloud" and command "cloud namespace", it returns +// "cloud/namespace". A multi-word split root is hyphenated into a single +// directory: splitRoot "one two" with command "one two three" returns +// "one-two/three" (not "one/two/three"). +func splitFileName(c *Command, splitRoot string) string { + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + if len(cmdParts) <= len(splitParts) { + return "" + } + return strings.Join(splitParts, "-") + "/" + cmdParts[len(splitParts)] +} + +func (w *docWriter) writeSplitCommand(c *Command, splitRoot string) { + fileName := splitFileName(c, splitRoot) + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + leafName := cmdParts[len(splitParts)] + + buf := &bytes.Buffer{} + w.fileMap[fileName] = buf + buf.WriteString("---\n") + buf.WriteString("id: " + leafName + "\n") + buf.WriteString("title: Temporal CLI " + c.FullName + " command reference\n") + buf.WriteString("sidebar_label: " + leafName + "\n") + buf.WriteString("description: " + c.Docs.DescriptionHeader + "\n") + buf.WriteString("toc_max_heading_level: 4\n") + buf.WriteString("keywords:\n") + for _, keyword := range c.Docs.Keywords { + buf.WriteString(" - " + keyword + "\n") + } + buf.WriteString("tags:\n") + for _, tag := range c.Docs.Tags { + buf.WriteString(" - " + tag + "\n") + } + buf.WriteString("---") + buf.WriteString("\n\n") + buf.WriteString(autoGeneratedNotice) + + buf.WriteString(escapeMDXDescription(c.Description) + "\n\n") + + if w.isLeafCommand(c) { + w.writeLeafOptions(fileName) + } else { + buf.WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", c.FullName)) + buf.WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") + buf.WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") + } +} + +func (w *docWriter) writeSplitSubcommand(c *Command, splitRoot string) { + fileName := splitFileName(c, splitRoot) + splitParts := strings.Split(splitRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + relativeDepth := len(cmdParts) - len(splitParts) + prefix := strings.Repeat("#", relativeDepth) + // Use the path relative to the split root (e.g. "ha update", "cert-ca create") + // rather than just the final segment. Within one aggregated file this keeps + // each heading's generated anchor unique and stable, which other docs link to + // (e.g. /cloud/high-availability/enable -> cloud/namespace#ha-update). + leafName := strings.Join(cmdParts[len(splitParts)+1:], " ") + + buf := w.fileMap[fileName] + buf.WriteString(prefix + " " + leafName + "\n\n") + buf.WriteString(escapeMDXDescription(c.Description) + "\n\n") - // Collect global flags for later (deduplicated) - w.collectGlobalFlags(fileName, globalOptions) + if w.isLeafCommand(c) { + w.writeLeafOptions(fileName) } } -func (w *docWriter) writeOptionsTable(options []Option, c *Command) { +func (w *docWriter) writeOptionsTable(options []Option, fileName string) { if len(options) == 0 { return } - fileName := c.fileName() buf := w.fileMap[fileName] // Command-specific flags: 3 columns (no Default) @@ -254,11 +390,76 @@ func (w *docWriter) isLeafCommand(c *Command) bool { return true } +const autoGeneratedNotice = "{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n" + + "This file is generated from the CLI command definitions via cmd/gen-docs. */}\n\n" + +// jsonInSingleQuotes matches a single-quoted string containing curly braces, +// e.g. 'YourKey={"a":"b"}' or '{"a":"b"}'. MDX interprets the braces as a JSX +// expression, so such examples must be wrapped in backticks (option table cells) +// or backslash-escaped (description body text). +var jsonInSingleQuotes = regexp.MustCompile(`'[^']*\{[^']*\}[^']*'`) + +// angleBracketPlaceholder matches lowercase angle-bracket placeholders such as +// , , or . Restricting the +// match to lowercase, multi-character names avoids escaping legitimate inline +// HTML that authors may intentionally include. +var angleBracketPlaceholder = regexp.MustCompile(`<([a-z][a-z0-9_:.-]+)>`) + +// headingID matches a Markdown heading line ending with a custom ID, e.g. +// "### Some heading {#custom-id}". The Docusaurus MDX parser (3.10+) treats the +// bare {#custom-id} as a JSX expression and fails to compile it. +var headingID = regexp.MustCompile(`^(#{1,6}\s+.+?)\s+\{#([\w-]+)\}\s*$`) + +// encodeJSONExample wraps single-quoted JSON examples in backticks so MDX renders +// them as inline code rather than parsing the curly braces as a JSX expression. +// This handles option description table cells; escapeMDXDescription handles the +// same class of issue for command description body text. func encodeJSONExample(v string) string { - // example: 'YourKey={"your": "value"}' - // results in an mdx acorn rendering error - // and wrapping in backticks lets it render - re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) - v = re.ReplaceAllString(v, "`$1`") - return v + return jsonInSingleQuotes.ReplaceAllString(v, "`$0`") +} + +// escapeMDXDescription escapes patterns in a command description that are valid +// CommonMark but break MDX (JSX) compilation under Docusaurus 3.10+: +// +// - bare angle-bracket placeholders like , which MDX +// parses as an unclosed JSX/HTML tag; +// - curly braces in single-quoted JSON examples like '{"a":"b"}', which MDX +// parses as a JSX expression; and +// - custom heading IDs like "## Heading {#id}", where MDX parses the {#id} as +// a JSX expression and fails on the '#'. These are converted to Docusaurus's +// MDX-compatible {/* #id */} comment form, which preserves the custom anchor. +// +// Content inside fenced code blocks (```) and inline code spans (backticks) is +// left untouched, since MDX does not interpret those as JSX. Fence markers are +// assumed to be on their own line (the standard CommonMark form the command +// descriptions use): a ``` opening or closing a block must start the line. +func escapeMDXDescription(desc string) string { + lines := strings.Split(desc, "\n") + inCodeBlock := false + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "```") { + inCodeBlock = !inCodeBlock + continue + } + if inCodeBlock { + continue + } + + line = headingID.ReplaceAllString(line, "$1 {/* #$2 */}") + + // Escape only outside inline code spans. Splitting on backticks yields + // alternating outside/inside segments (assuming balanced backticks). + segments := strings.Split(line, "`") + for j := range segments { + if j%2 != 0 { + continue // inside an inline code span + } + segments[j] = angleBracketPlaceholder.ReplaceAllString(segments[j], `\<$1\>`) + segments[j] = jsonInSingleQuotes.ReplaceAllStringFunc(segments[j], func(m string) string { + return strings.NewReplacer("{", `\{`, "}", `\}`).Replace(m) + }) + } + lines[i] = strings.Join(segments, "`") + } + return strings.Join(lines, "\n") } diff --git a/internal/commandsgen/docs_test.go b/internal/commandsgen/docs_test.go new file mode 100644 index 000000000..8e9391b79 --- /dev/null +++ b/internal/commandsgen/docs_test.go @@ -0,0 +1,184 @@ +package commandsgen + +import ( + "strings" + "testing" +) + +func TestEscapeMDXDescription(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "bare angle-bracket placeholder is escaped", + in: "Pass the cert via --ca-certificate to authenticate.", + want: `Pass the cert via --ca-certificate \ to authenticate.`, + }, + { + name: "placeholder with separators and colon is escaped", + in: "Set --limit and --reason .", + want: `Set --limit \ and --reason \.`, + }, + { + name: "angle brackets inside a fenced code block are left untouched", + in: "Example:\n\n```\ntemporal x --cert \n```\n", + want: "Example:\n\n```\ntemporal x --cert \n```\n", + }, + { + name: "angle brackets inside an inline code span are left untouched", + in: "Use `--cert ` here but escape there.", + want: "Use `--cert ` here but escape \\ there.", + }, + { + name: "custom heading id is converted to MDX comment form", + in: "### Resetting activities that heartbeat {#reset-heartbeats}\n\nSee [details](#reset-heartbeats).", + want: "### Resetting activities that heartbeat {/* #reset-heartbeats */}\n\nSee [details](#reset-heartbeats).", + }, + { + name: "non-heading line with {#id}-like text is not treated as a heading", + in: "See [details](#reset-heartbeats).", + want: "See [details](#reset-heartbeats).", + }, + { + name: "bare single-quoted JSON has its braces escaped", + in: `Provide default '{"some-key": "some-value"}' inline.`, + want: `Provide default '\{"some-key": "some-value"\}' inline.`, + }, + { + name: "single-quoted JSON inside a fence is left untouched", + in: "```\ntemporal x --input '{\"some-key\": \"some-value\"}'\n```", + want: "```\ntemporal x --input '{\"some-key\": \"some-value\"}'\n```", + }, + { + name: "plain prose is unchanged", + in: "This command does something useful with no special characters.", + want: "This command does something useful with no special characters.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := escapeMDXDescription(tc.in); got != tc.want { + t.Errorf("escapeMDXDescription()\n got: %q\nwant: %q", got, tc.want) + } + }) + } +} + +func TestEncodeJSONExample(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "key=json example is wrapped", + in: `Example: 'YourKey={"your": "value"}'.`, + want: "Example: `'YourKey={\"your\": \"value\"}'`.", + }, + { + name: "standalone json example is wrapped", + in: `Example: '{"some-key": "some-value"}'.`, + want: "Example: `'{\"some-key\": \"some-value\"}'`.", + }, + { + name: "nested json example is wrapped", + in: `Example: '{"a": {"b": "c"}}'.`, + want: "Example: `'{\"a\": {\"b\": \"c\"}}'`.", + }, + { + name: "no json is unchanged", + in: "A plain description without JSON.", + want: "A plain description without JSON.", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := encodeJSONExample(tc.in); got != tc.want { + t.Errorf("encodeJSONExample()\n got: %q\nwant: %q", got, tc.want) + } + }) + } +} + +// splitFixture is a minimal command tree with a "cloud" root, mirroring how the +// cloud CLI extension is structured, used to exercise -subdir splitting. +const splitFixture = ` +commands: + - name: cloud + summary: Cloud + description: Manage Temporal Cloud. + - name: cloud thing + summary: Thing + description: | + Manage things. + docs: + keywords: + - thing + description-header: Manage things + tags: + - Cloud + - name: cloud thing sub + summary: Sub + description: | + Do a thing with a bare outside code. + options: + - name: flag + type: string + description: A flag. +` + +func generateSplitFixture(t *testing.T, subdirs []string) map[string][]byte { + t.Helper() + cmds, err := ParseCommands([]byte(splitFixture)) + if err != nil { + t.Fatalf("ParseCommands: %v", err) + } + docs, err := GenerateDocsFiles(cmds, subdirs) + if err != nil { + t.Fatalf("GenerateDocsFiles: %v", err) + } + return docs +} + +func TestGenerateDocsFilesSubdir(t *testing.T) { + docs := generateSplitFixture(t, []string{"cloud"}) + + if _, ok := docs["cloud/thing"]; !ok { + t.Errorf("expected split file cloud/thing, got keys: %v", keys(docs)) + } + if _, ok := docs["thing"]; ok { + t.Errorf("did not expect a flat 'thing' file when splitting cloud") + } + if _, ok := docs["cloud"]; ok { + t.Errorf("split parent 'cloud' should not produce a standalone file") + } + // Index pages are hand-maintained on the docs site, so gen-docs never emits them. + if _, ok := docs["cloud/index"]; ok { + t.Errorf("gen-docs should not emit a cloud/index page") + } + if _, ok := docs["index"]; ok { + t.Errorf("gen-docs should not emit a top-level index page") + } + + // The deeper subcommand's description is appended to its parent file and + // must be MDX-escaped there. + thing := string(docs["cloud/thing"]) + if !strings.Contains(thing, `\`) { + t.Errorf("expected escaped placeholder in cloud/thing, got:\n%s", thing) + } + if !strings.Contains(thing, "## sub") { + t.Errorf("expected 'sub' heading in cloud/thing, got:\n%s", thing) + } +} + +func keys(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} From 6fa3564ecd87b7185fe50591dd931d500954dc76 Mon Sep 17 00:00:00 2001 From: Lina Jodoin Date: Wed, 8 Jul 2026 12:13:58 -0700 Subject: [PATCH 2/3] Bump go.temporal.io/server to v1.31.2 (#1115) - Bumps server version for an OSS security release - Dockerfile's Alpine is already up-to-date (cherry picked from commit 1d8f321f3fccdca740d359a8ae5704b5bb35ff01) --- go.mod | 12 ++++++------ go.sum | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index b7e909abc..403b1a09f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/temporalio/cli -go 1.26.3 +go 1.26.4 require ( github.com/BurntSushi/toml v1.4.0 @@ -20,7 +20,7 @@ require ( go.temporal.io/api v1.62.8 go.temporal.io/sdk v1.41.1 go.temporal.io/sdk/contrib/envconfig v1.0.0 - go.temporal.io/server v1.31.0 + go.temporal.io/server v1.31.2 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 golang.org/x/mod v0.35.0 golang.org/x/term v0.43.0 @@ -52,7 +52,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/apache/thrift v0.21.0 // indirect + github.com/apache/thrift v0.23.0 // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/config v1.32.13 // indirect @@ -190,11 +190,11 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.256.0 // indirect diff --git a/go.sum b/go.sum index 646682280..1d71eeae3 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3 github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs= github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI= github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU= -github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= -github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s= +github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -460,8 +460,8 @@ go.temporal.io/sdk v1.41.1 h1:yOpvsHyDD1lNuwlGBv/SUodCPhjv9nDeC9lLHW/fJUA= go.temporal.io/sdk v1.41.1/go.mod h1:/InXQT5guZ6AizYzpmzr5avQ/GMgq1ZObcKlKE2AhTc= go.temporal.io/sdk/contrib/envconfig v1.0.0 h1:1Q/swVgB4EW/p3k7rI9/4hpU4/DC57FSRbU90+UisXw= go.temporal.io/sdk/contrib/envconfig v1.0.0/go.mod h1:Pj4N1lwUEvxap6quBm8GrVMSUMJhSZkVtxjt3AYnPPg= -go.temporal.io/server v1.31.0 h1:FKLodreaMXUxYc3zr6xxwxtpGz1WH/t7O0IWxV1d1x0= -go.temporal.io/server v1.31.0/go.mod h1:MTQAw8uMU3ooSHyg/62JsNu/j8lK34SfKMTXkexYcw8= +go.temporal.io/server v1.31.2 h1:0O4zQ0NGRit2Z32s1XUHfs9k8zSKqff2n1QvFttYN0M= +go.temporal.io/server v1.31.2/go.mod h1:kOOpZs6WMcLuVmlu0uH+dVD2Ul1d4hGjmGpHjxfz2EI= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -492,8 +492,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -526,8 +526,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -556,8 +556,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From 0bdb9fc482863d06a272838d80b00b3e2d2ebce6 Mon Sep 17 00:00:00 2001 From: Jeri Lane Date: Thu, 9 Jul 2026 07:48:30 -0700 Subject: [PATCH 3/3] When delegating to an extension, exit with the same code the extension used (#1116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Related issues CLDDX-141 ## What changed? When delegating to an extension (e.g. `temporal-cloud`, non-zero exit codes don't make it back to the user, so there's no programmatic way to know if the command succeeded. This change uses the exit code of the extension as the exit command for the `temporal` command that wrapped it. ## Checklist **Stability** - [ ] Breaking changes are marked with 💥 in the PR title and release notes - [ ] Changes to JSON output (`-o json` / `-o jsonl`) are treated as breaking changes **Design** - [ ] This feature does not depend on Cloud-only APIs or behavior (it works against an OSS server) - [ ] New commands follow `temporal ` structure (e.g. `temporal workflow start`) - [ ] New flags are named after the API concept, not the implementation mechanism (good: `--search-attribute`, bad: `--index-field`) - [ ] New flags don't duplicate an existing flag that serves the same purpose - [ ] New flags do not have short aliases without strong justification - [ ] Experimental features are marked with `(Experimental)` in `commands.yaml` **Help text** (see style guide at the top of `commands.yaml`) - [ ] All flags shown in help text and examples are implemented and functional - [ ] Summaries use sentence case and have no trailing period - [ ] Long descriptions end with a period and include at least one example invocation - [ ] Examples use long flags (`--namespace`, not `-n`), one flag per line - [ ] Placeholder values use `YourXxx` form (`YourWorkflowId`, `YourNamespace`) **Behavior** - [ ] Results go to stdout; errors and warnings go to stderr - [ ] Error messages are lowercase with no trailing punctuation **Tests** - [ ] Added functional test(s) (`SharedServerSuite`) - [ ] Added unit test(s) (`func TestXxx`) where applicable ## Manual tests **Setup** Create an executable file named `temporal-exit` somewhere on your PATH ``` #!/bin/bash exit $1 ``` **Happy path** ``` $ temporal exit 0; echo $? 0 ``` **Error case** ``` $ temporal exit 1; echo $? 1 ``` Co-authored-by: Alex Stanfield <13949480+chaptersix@users.noreply.github.com> (cherry picked from commit 99eb0500f6bbab923db1944c9baa7f3934b12fda) --- internal/temporalcli/commands.extension.go | 12 ++++++++++-- internal/temporalcli/commands.extension_test.go | 10 +++++++--- internal/temporalcli/commands.go | 7 ++++++- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/temporalcli/commands.extension.go b/internal/temporalcli/commands.extension.go index 794242263..b00900810 100644 --- a/internal/temporalcli/commands.extension.go +++ b/internal/temporalcli/commands.extension.go @@ -25,6 +25,14 @@ var cliArgsToParseForExtension = map[string]bool{ "command-timeout": true, } +type ExtensionNonZeroExit struct { + *exec.ExitError +} + +func (err ExtensionNonZeroExit) Unwrap() error { + return err.ExitError +} + // tryExecuteExtension tries to execute an extension command if the command is not a built-in command. // It returns an error if the extension command fails, and a boolean indicating whether an extension was executed. func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bool) { @@ -77,8 +85,8 @@ func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bo if ctx.Err() != nil { return fmt.Errorf("program interrupted"), true } - if _, ok := err.(*exec.ExitError); ok { - return nil, true + if exitError, ok := err.(*exec.ExitError); ok { + return ExtensionNonZeroExit{exitError}, true } return fmt.Errorf("extension %s failed: %w", extPath, err), true } diff --git a/internal/temporalcli/commands.extension_test.go b/internal/temporalcli/commands.extension_test.go index 266a8fcd1..2590be0cc 100644 --- a/internal/temporalcli/commands.extension_test.go +++ b/internal/temporalcli/commands.extension_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/temporalcli" "golang.org/x/tools/imports" ) @@ -232,7 +233,7 @@ func TestExtension_FailsOnNonExecutableCommand(t *testing.T) { h := newExtensionHarness(t) // Create file without execute permission. path := filepath.Join(h.binDir, "temporal-foo") - err := os.WriteFile(path, []byte("a text file"), 0644) + err := os.WriteFile(path, []byte("a text file"), 0o644) require.NoError(t, err) res := h.Execute("foo") @@ -248,7 +249,10 @@ func TestExtension_PassesThroughNonZeroExit(t *testing.T) { res := h.Execute("foo") assert.Equal(t, "Args: temporal-foo \n", res.Stdout.String()) - assert.NoError(t, res.Err) + var exitError temporalcli.ExtensionNonZeroExit + if assert.ErrorAs(t, res.Err, &exitError) { + assert.Equal(t, 42, exitError.ExitCode()) + } } func TestExtension_FailsOnCommandTimeout(t *testing.T) { @@ -306,7 +310,7 @@ func (h *extensionHarness) createExtension(name string, code ...string) string { // Write source file. srcPath := filepath.Join(h.binDir, name+".go") - require.NoError(h.t, os.WriteFile(srcPath, formatted, 0644)) + require.NoError(h.t, os.WriteFile(srcPath, formatted, 0o644)) // Build executable. binPath := filepath.Join(h.binDir, name) diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index ecb52515d..eaf08dbdf 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -145,6 +145,11 @@ func (c *CommandContext) preprocessOptions() error { if c.Err() != nil { err = fmt.Errorf("program interrupted") } + if exitError, ok := errors.AsType[ExtensionNonZeroExit](err); ok { + // An extension failed after being found and successfully started. Here we defer + // to its own error handling logic, and just copy the exit code through. + os.Exit(exitError.ExitCode()) + } fmt.Fprintf(c.Options.Stderr, "Error: %v\n", err) os.Exit(1) } @@ -524,7 +529,7 @@ var buildInfo string func VersionString() string { // To add build-time information to the version string, use // go build -ldflags "-X github.com/temporalio/cli/internal.buildInfo=" - var bi = buildInfo + bi := buildInfo if bi != "" { bi = fmt.Sprintf(", %s", bi) }