rundoc 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85fbfa9f84b4e98eb72d487721e7f35001f7820404e30a138de4b4505d788f53
4
- data.tar.gz: 1ff08ba1cdd468b190fc54eef124b7ab434d700ffe87ba2b4c8e676d03fb515e
3
+ metadata.gz: 30dcb03fc239fe1ddd02618341f780c8158ec145646049df7b597206dd7692db
4
+ data.tar.gz: c31c17bfa5997dc0c79b218e42493d719f6f19d26c1bc758f7d17f24bbe8d996
5
5
  SHA512:
6
- metadata.gz: 8e87c610bcd8c8589407e68b503eee0578a1dc23b959baa138258185f7d08e6daa3ee17097e4b66b02fea896ee9e5be2bde936e2a601a2b18d1b1a2fc24b36a2
7
- data.tar.gz: 9c9f4d7446beaedc74d61354794327cd30f372f7e5e16500678247f7bc549bb7b04992d0875c06c1fa06ae894a36c35902e07930cf55ab4e9fc01bb8b29c44d3
6
+ metadata.gz: d1c17df9586e3c8d767f8fa79c3d8afebddf5a84c09ae6cbdee14286bb184ee7d51c0ada0827a4dd8a0d480266b63bc1713872200d841b5ae0d5a0af482c2f20
7
+ data.tar.gz: a3e244880fe064cb86360953f8b3ca4e6b3569201f9bc0c96287686dd99a17a4c59cb3122160a4024b37808e98265758dd40290643cdbb84b0f5826fce5e234c
@@ -13,6 +13,7 @@ jobs:
13
13
  ruby:
14
14
  - 3.2
15
15
  - 3.3
16
+ - 3.4
16
17
  - "4.0"
17
18
  - head
18
19
  steps:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## HEAD
2
2
 
3
+ ## 7.0.0
4
+
5
+ - Changed: Shell commands now run under `bash -eo pipefail` instead of `/bin/sh`. This surfaces failures in pipelines and compound commands that were previously swallowed. See the README for details on SIGPIPE interactions.
6
+
7
+ ## 6.0.0
8
+
9
+ - Added: `rundoc.ensure_later(dir: :cwd)` command to register cleanup blocks that run on every build (success and failure). Useful for guaranteed resource teardown (e.g., destroying Heroku apps). Multiple blocks execute in order; failures in one block do not stop the rest.
10
+ - Changed: The ruby context in `rundoc` and `rundoc.configure` commands is now the same binding as the default ERB evaluation. This means that methods can be defined in one and used in both.
11
+
3
12
  ## 5.0.0
4
13
 
5
14
  - Added comment syntax. Use an octothorpe (`#`) after the visibility markers to comment out any commands and make them a no-op.
data/README.md CHANGED
@@ -98,6 +98,8 @@ This will generate a project folder with your project in it, and a markdown `REA
98
98
  - [website.screenshot](#screenshots)
99
99
  - Configure RunDOC
100
100
  - [rundoc.configure](#configure)
101
+ - [rundoc.ensure_later](#ensure_later)
102
+ - [rundoc](#configure) an alias for `rundoc.configure`
101
103
  - Import and compose documents
102
104
  - [rundoc.require](#compose-multiple-rundoc-documents)
103
105
 
@@ -257,6 +259,8 @@ However this command would fall on its face:
257
259
 
258
260
  These custom commands are kept to a minimum, and for the most part behave as you would expect them to. Write your docs as you normally would and check the output frequently.
259
261
 
262
+ Shell commands run under `bash -eo pipefail` to catch silent failures in pipelines and compound commands. See [Bash Error Handling](#bash-error-handling) for details and SIGPIPE caveats.
263
+
260
264
  Running shell commands like this can be very powerful, you'll likely want more control of how you manipulate files in your project. To do this you can use the `file.` namespace:
261
265
 
262
266
  ## Dynamic command templating
@@ -616,7 +620,7 @@ If you need to specify project specific environment variables create a file call
616
620
 
617
621
  ## Configure
618
622
 
619
- You can configure your docs in your docs use the `RunDOC` command
623
+ You can configure your docs in your docs use the `RunDOC` command via `rundoc.configure` (or alias `rundoc`):
620
624
 
621
625
  ```
622
626
  :::-- rundoc.configure
@@ -624,6 +628,30 @@ You can configure your docs in your docs use the `RunDOC` command
624
628
 
625
629
  Note: Make sure you run this as a hidden command (with `-`).
626
630
 
631
+ This will give you a Ruby codeblock that executes and gives you access to `Rundoc.configure do |config|` to configure things about your build (such as modifying your markdown document after successful builds).
632
+
633
+ **Define and Re-use logic**
634
+
635
+ Since it's **just ruby** :tm: you can also use it to define shared logic that can be re-used in ERB templates. For example:
636
+
637
+ ```
638
+ :::-- rundoc
639
+ def run!(command, quiet: false, error_on_fail: true)
640
+ puts "Running `#{command}`" unless quiet
641
+ output = `#{command}`
642
+ puts "Command `#{command}` output:\n#{output}" unless quiet
643
+ if error_on_fail && !$?.success?
644
+ raise "Error running #{command}. Output:\n#{output}"
645
+ end
646
+ output
647
+ end
648
+ ```
649
+
650
+ ```
651
+ :::-> print.erb
652
+ Hello <%= run!("heroku whoami") %>!
653
+ ```
654
+
627
655
  **After Build**
628
656
 
629
657
  This will eval any code you put under that line (in Ruby) when the build was successful but before the contents are finalized on disk. If you want to run some code after you're done building your docs you could use `Rundoc.configure` block and call the `after_build` method like this:
@@ -658,6 +686,128 @@ Sometimes sensitive info like usernames, email addresses, or passwords may be in
658
686
 
659
687
  This command `filter_sensitive` can be called multiple times with different values. Since the config is in Ruby you could iterate over an array of sensitive data
660
688
 
689
+ ## Ensure Later
690
+
691
+ Run a script on EVERY build (success and failure). Used to guarantee resources are cleaned up.
692
+
693
+ - Arguments
694
+ - `dir:` The directory where the script will be run. Must be one of these values:
695
+ - `:cwd` The directory where the command is first invoked. If this directory does not exist when the `rundoc.ensure_later` is invoked, it will raise an error.
696
+ - `:rundoc_root` The tmp directory where the script is being executed. Useful if the directory where the `rundoc.ensure_later` is defined, is deleted by the rundoc script.
697
+
698
+ For example:
699
+
700
+ ```
701
+ :::>> $ heroku create
702
+ :::-- rundoc
703
+ @app_name = run!("heroku info --json | jq -r '.app.name'").strip
704
+
705
+ def app_name
706
+ @app_name
707
+ end
708
+ ```
709
+
710
+ ```ruby
711
+ :::-- rundoc.ensure_later(dir: :cwd)
712
+ if run!("heroku apps").include?(app_name)
713
+ puts "Cleaning up web app #{app_name}"
714
+ run!("heroku apps:destroy #{app_name} --confirm #{app_name}")
715
+ else
716
+ puts "App `#{app_name}` already cleaned, nothing to do"
717
+ end
718
+ ```
719
+
720
+ The output of this will not be included in the document. Multiple ensure blocks can be defined and will execute in the order of their definition. Since they'll be executed EVERY time, logic must handle both success and failure cases. This execution occurs before the temp directory is removed, and before any background task shutdown ensure blocks are triggered.
721
+
722
+ If a build is successful, but an `ensure_later` block fails, the build will be considered a failure. A failure in one block will not stop the rest from executing.
723
+
724
+ ## Bash Error Handling
725
+
726
+ ### Bash `set -eo pipefail` explained
727
+
728
+ Bash error behavior is configurable. Rundoc uses `set -eo pipefail` to catch common problems not caught by the default error mode.
729
+
730
+ If a failing command is piped to a valid command, by default bash will "hide" the failure by returning a zero (success) exit code:
731
+
732
+ ```term
733
+ $ bash -c "cat does-not-exist | head -n1"
734
+ cat: does-not-exist: No such file or directory
735
+ $ echo $?
736
+ 0
737
+ ```
738
+
739
+ With `-o pipefail`, the pipeline reports the exit status of the last command that failed rather than the last command in the pipe. With `-e`, bash exits immediately when a command fails. Together, `-eo pipefail` ensures a failing command's exit status is not hidden. Here, the exit code `1` comes from `cat` failing to open a file that doesn't exist:
740
+
741
+ ```term
742
+ $ bash -eo pipefail -c "cat does-not-exist | head -n1"
743
+ cat: does-not-exist: No such file or directory
744
+ $ echo $?
745
+ 1
746
+ ```
747
+
748
+ A similar scenario is when multiple commands are given on one line separated by a semicolon. Without `-e`, bash continues past the failure:
749
+
750
+ ```term
751
+ $ bash -c "cat does-not-exist; echo 'done'"
752
+ cat: does-not-exist: No such file or directory
753
+ done
754
+ $ echo $?
755
+ 0
756
+ ```
757
+
758
+ With `-e`, bash exits immediately after the failing command:
759
+
760
+ ```term
761
+ $ bash -eo pipefail -c "cat does-not-exist; echo 'done'"
762
+ cat: does-not-exist: No such file or directory
763
+ $ echo $?
764
+ 1
765
+ ```
766
+
767
+ These settings help prevent silent failures from slipping through your Rundoc scripts.
768
+
769
+ ### Bash SIGPIPE
770
+
771
+ Tools like `head -n1` and `grep -m1 "value"` read only part of their input and then exit. When the upstream process next tries to write to the now-closed pipe, the kernel delivers a `SIGPIPE` signal to terminate it. This behavior is useful when the input is a never-ending stream but you only need a subset:
772
+
773
+ ```term
774
+ $ bash -c 'yes "output" | head -n1'
775
+ output
776
+ $ echo $?
777
+ 0
778
+ ```
779
+
780
+ However, this behavior negatively interacts with `set -eo pipefail`. Even though `head -n1` successfully produced its output and the command appears to run cleanly, `pipefail` reports the upstream process's SIGPIPE termination as a non-zero exit:
781
+
782
+ ```term
783
+ $ bash -eo pipefail -c 'yes "output" | head -n1'
784
+ output
785
+ $ echo $?
786
+ 141
787
+ ```
788
+
789
+ If the input is fully buffered (such as reading from a small file), `head` can finish before the writer needs to write again, so SIGPIPE is never triggered. That means `cat small-file.txt | head -n1` is reasonably safe, but this behavior can surface with larger files:
790
+
791
+ ```term
792
+ $ tmpfile=$(mktemp)
793
+ $ seq 1 200000 > "$tmpfile"
794
+ $ bash -eo pipefail -c "cat $tmpfile | head -n1"
795
+ 1
796
+ $ echo $?
797
+ 141
798
+ ```
799
+
800
+ This SIGPIPE behavior can cause your rundoc script to exit early when you aren't expecting it.
801
+
802
+ You can rewrite some commands to take a file directly. For example, `cat Gemfile | head -n 5` can be rewritten as `head -n 5 Gemfile` without SIGPIPE risk.
803
+
804
+ Here are common commands that can trigger a SIGPIPE:
805
+
806
+ - `head` with `-n <N>` or `-c <N>`
807
+ - `grep` with `-m <N>` (max count)
808
+ - `sed <N>q` (quit after N lines)
809
+ - `awk` with `{exit}` — e.g., rewrite `awk '/^Start/ {flag=1} /^End/ {exit} flag'` without exit as `awk '/^Start/ {flag=1} /^End/ {flag=0} flag'`
810
+
661
811
  ## Writing a new command
662
812
 
663
813
  > Note: This is an advanced topic and this interface is unstable.
data/lib/rundoc/cli.rb CHANGED
@@ -134,6 +134,9 @@ module Rundoc
134
134
  end
135
135
 
136
136
  def call
137
+ build_success = false
138
+ ensure_later_errors = []
139
+
137
140
  io.puts "## Running your docs"
138
141
  load_dotenv
139
142
  check_directories_empty!
@@ -165,7 +168,11 @@ module Rundoc
165
168
  io: io
166
169
  )
167
170
  output = begin
168
- parser.to_md
171
+ begin
172
+ parser.to_md
173
+ ensure
174
+ ensure_later_errors = Rundoc.run_ensure_later(io: io)
175
+ end
169
176
  rescue StandardError, SignalException => e
170
177
  io.puts "Received exception: #{e.inspect}, cleaning up before re-raise"
171
178
  on_fail
@@ -174,6 +181,8 @@ module Rundoc
174
181
 
175
182
  on_success(output)
176
183
  end
184
+
185
+ build_success = true
177
186
  ensure
178
187
  # Stop any hanging background tasks
179
188
  Rundoc::CodeCommand::Background::ProcessSpawn.tasks.each do |name, task|
@@ -189,6 +198,10 @@ module Rundoc
189
198
  description: "tmp working directory"
190
199
  )
191
200
  end
201
+
202
+ if ensure_later_errors.any? && build_success
203
+ raise ensure_later_errors.first
204
+ end
192
205
  end
193
206
 
194
207
  private def clean_dir(dir:, description:)
@@ -7,25 +7,19 @@ class Rundoc::CodeCommand::BashRunner
7
7
  @line = line
8
8
  end
9
9
 
10
- # Ignore duplicate chdir warnings "warning: conflicting chdir during another chdir block"
11
- def supress_chdir_warning
12
- old_stderr = $stderr
13
- capture_stderr = StringIO.new
14
- $stderr = capture_stderr
10
+ def suppress_chdir_warning
11
+ old_verbose = $VERBOSE
12
+ $VERBOSE = nil
15
13
  yield
16
14
  ensure
17
- if old_stderr
18
- $stderr = old_stderr
19
- capture_string = capture_stderr.string
20
- warn capture_string if capture_string.each_line.count > 1 || !capture_string.include?("conflicting chdir")
21
- end
15
+ $VERBOSE = old_verbose
22
16
  end
23
17
 
24
18
  def call(env)
25
19
  line = @line.sub("cd", "").strip
26
20
  @io.puts "running $ cd #{line}"
27
21
 
28
- supress_chdir_warning do
22
+ suppress_chdir_warning do
29
23
  Dir.chdir(line)
30
24
  end
31
25
 
@@ -45,13 +45,18 @@ class Rundoc::CodeCommand::BashRunner
45
45
  end
46
46
 
47
47
  def shell(cmd, stdin = nil)
48
- cmd = "(#{cmd}) 2>&1"
49
48
  msg = "Running: $ '#{cmd}'"
50
49
  msg << " with stdin: '#{stdin.inspect}'" if stdin && !stdin.empty?
51
50
  io.puts msg
52
51
 
53
52
  result = +""
54
- IO.popen(cmd, "w+") do |pipe|
53
+ # -e: Abort on first failure in compound commands (e.g. `bundle install; echo done`)
54
+ # so the exit status reflects the real failure, not a trailing success.
55
+ # -o pipefail: Report the first failure in a pipeline (e.g. `git push | tee log`)
56
+ # instead of only the last command's exit status.
57
+ # -u (nounset) is intentionally omitted: tutorial commands routinely
58
+ # reference environment variables that may not be set.
59
+ IO.popen(["bash", "-eo", "pipefail", "-c", cmd, err: [:child, :out]], "w+") do |pipe|
55
60
  pipe << stdin if stdin
56
61
  pipe.close_write
57
62
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ class EmptyBinding
6
+ def self.create
7
+ new.empty_binding
8
+ end
9
+
10
+ def empty_binding
11
+ binding
12
+ end
13
+ end
14
+
15
+ module Rundoc::CodeCommand
16
+ RUNDOC_ERB_BINDINGS = Hash.new { |h, k| h[k] = EmptyBinding.create }
17
+ RUNDOC_DEFAULT_ERB_BINDING = "default"
18
+ end
@@ -1,21 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
-
5
- class EmptyBinding
6
- def self.create
7
- new.empty_binding
8
- end
9
-
10
- def empty_binding
11
- binding
12
- end
13
- end
3
+ require_relative "../empty_binding"
14
4
 
15
5
  module Rundoc::CodeCommand
16
- RUNDOC_ERB_BINDINGS = Hash.new { |h, k| h[k] = EmptyBinding.create }
17
- RUNDOC_DEFAULT_ERB_BINDING = "default"
18
-
19
6
  class PrintERBArgs
20
7
  attr_reader :line, :binding_name
21
8
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ::Rundoc::CodeCommand
4
+ class RundocCommand
5
+ class EnsureLaterArgs
6
+ MAPPING = {
7
+ cwd: ->(context:) {
8
+ Dir.pwd
9
+ },
10
+ rundoc_root: ->(context:) {
11
+ context.output_dir.to_s
12
+ }
13
+ }.freeze
14
+
15
+ def initialize(dir:)
16
+ @dir = dir
17
+ @logic = MAPPING[dir] or raise ArgumentError, "Invalid argument dir: #{dir} must be one of #{MAPPING.keys}"
18
+ end
19
+
20
+ def call(context:)
21
+ @logic.call(context: context)
22
+ end
23
+
24
+ def to_s
25
+ @dir.to_s
26
+ end
27
+ end
28
+
29
+ class EnsureLaterRunner
30
+ attr_reader :io, :contents
31
+
32
+ def initialize(user_args:, render_command:, render_result:, io:, contents: nil)
33
+ @io = io
34
+ @contents = contents.dup if contents && !contents.empty?
35
+ @dir = user_args
36
+ @binding = RUNDOC_ERB_BINDINGS[RUNDOC_DEFAULT_ERB_BINDING]
37
+ end
38
+
39
+ def to_md(env = {})
40
+ ""
41
+ end
42
+
43
+ def call(env = {})
44
+ resolved_dir = @dir.call(context: env[:context])
45
+
46
+ io.puts "Registering ensure_later block (dir: #{@dir} => #{resolved_dir})"
47
+ Rundoc.add_ensure_later(dir: resolved_dir, code: @contents, binding: @binding)
48
+ ""
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ Rundoc.register_code_command(
55
+ keyword: :"rundoc.ensure_later",
56
+ args_klass: Rundoc::CodeCommand::RundocCommand::EnsureLaterArgs,
57
+ runner_klass: Rundoc::CodeCommand::RundocCommand::EnsureLaterRunner,
58
+ always_hidden: true
59
+ )
@@ -17,6 +17,7 @@ module ::Rundoc
17
17
  @io = io
18
18
  @contents = contents.dup if contents && !contents.empty?
19
19
  @contents = user_args.code + (@contents || +"")
20
+ @binding = RUNDOC_ERB_BINDINGS[RUNDOC_DEFAULT_ERB_BINDING]
20
21
  end
21
22
 
22
23
  def to_md(env = {})
@@ -25,7 +26,9 @@ module ::Rundoc
25
26
 
26
27
  def call(env = {})
27
28
  io.puts "Running: #{contents}"
28
- eval(contents) # rubocop:disable Security/Eval
29
+ Rundoc.capture_stdout_stderr(io) do
30
+ eval(contents, @binding) # rubocop:disable Security/Eval
31
+ end
29
32
  ""
30
33
  end
31
34
  end
@@ -36,3 +39,4 @@ Rundoc.register_code_command(keyword: :rundoc, args_klass: Rundoc::CodeCommand::
36
39
  Rundoc.register_code_command(keyword: :"rundoc.configure", args_klass: Rundoc::CodeCommand::RundocCommandArgs, runner_klass: Rundoc::CodeCommand::RundocCommandRunner)
37
40
 
38
41
  require "rundoc/code_command/rundoc/require"
42
+ require "rundoc/code_command/rundoc/ensure_later"
@@ -5,6 +5,7 @@ module Rundoc
5
5
  end
6
6
  end
7
7
 
8
+ require "rundoc/code_command/empty_binding"
8
9
  require "rundoc/code_command/deferred"
9
10
  require "rundoc/code_command/bash"
10
11
  require "rundoc/code_command/pipe"
@@ -39,9 +39,16 @@ module Rundoc
39
39
  ).as(:number)
40
40
  }
41
41
 
42
+ rule(:symbol) {
43
+ str(":") >> (
44
+ match("[a-zA-Z_]") >> match("[a-zA-Z0-9_]").repeat
45
+ ).as(:symbol)
46
+ }
47
+
42
48
  rule(:value) {
43
49
  string |
44
50
  number |
51
+ symbol |
45
52
  str("true").as(true) |
46
53
  str("false").as(false) |
47
54
  str("nil").as(:nil)
@@ -175,6 +182,7 @@ module Rundoc
175
182
  rule(true => simple(:tr)) { true }
176
183
  rule(false => simple(:fa)) { false }
177
184
  rule(string: simple(:st)) { st.to_s }
185
+ rule(symbol: simple(:sy)) { sy.to_s.to_sym }
178
186
 
179
187
  rule(number: simple(:nb)) {
180
188
  /[eE.]/.match?(nb) ? Float(nb) : Integer(nb)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rundoc
4
- VERSION = "5.0.0"
4
+ VERSION = "7.0.0"
5
5
  end
data/lib/rundoc.rb CHANGED
@@ -97,6 +97,43 @@ module Rundoc
97
97
  yield self
98
98
  end
99
99
 
100
+ def ensure_later_blocks
101
+ @ensure_later_blocks ||= []
102
+ end
103
+
104
+ def add_ensure_later(dir:, code:, binding:)
105
+ ensure_later_blocks << {dir: dir, code: code, binding: binding}
106
+ end
107
+
108
+ def run_ensure_later(io:)
109
+ errors = []
110
+ ensure_later_blocks.each do |block|
111
+ io.puts "Running ensure_later block in #{block[:dir]}:\n#{block[:code]}"
112
+ Dir.chdir(block[:dir]) do
113
+ capture_stdout_stderr(io) do
114
+ eval(block[:code], block[:binding]) # rubocop:disable Security/Eval
115
+ end
116
+ end
117
+ rescue => e
118
+ io.puts "ensure_later block failed in #{block[:dir]}: #{e.message}"
119
+ io.puts e.backtrace.join("\n")
120
+ errors << e
121
+ end
122
+ ensure_later_blocks.clear
123
+ errors
124
+ end
125
+
126
+ def capture_stdout_stderr(io)
127
+ old_stdout = $stdout
128
+ old_stderr = $stderr
129
+ $stdout = io
130
+ $stderr = io
131
+ yield
132
+ ensure
133
+ $stdout = old_stdout
134
+ $stderr = old_stderr
135
+ end
136
+
100
137
  def filter_sensitive(sensitive)
101
138
  raise "Expecting #{sensitive} to be a hash" unless sensitive.is_a?(Hash)
102
139
  @sensitive ||= {}
data/rundoc.gemspec CHANGED
@@ -26,6 +26,7 @@ Gem::Specification.new do |gem|
26
26
 
27
27
  gem.add_dependency "aws-sdk-s3", "~> 1"
28
28
  gem.add_dependency "dotenv"
29
+ gem.add_dependency "cgi", ">= 0.3.6"
29
30
 
30
31
  gem.add_development_dependency "rake"
31
32
  gem.add_development_dependency "mocha"
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class IntegrationEnsureLaterTest < Minitest::Test
6
+ def test_runs_on_success
7
+ Dir.mktmpdir do |dir|
8
+ Dir.chdir(dir) do
9
+ dir = Pathname(dir)
10
+ marker = dir.join("ensure_ran.txt")
11
+
12
+ source_path = dir.join("RUNDOC.md")
13
+ source_path.write <<~EOF
14
+ ```
15
+ :::-- rundoc.ensure_later(dir: :cwd)
16
+ File.write("#{marker}", "yes")
17
+ ```
18
+
19
+ ```
20
+ :::>> $ echo "hello"
21
+ ```
22
+ EOF
23
+
24
+ Rundoc::CLI.new(
25
+ io: StringIO.new,
26
+ source_path: source_path,
27
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
28
+ ).call
29
+
30
+ assert marker.exist?, "ensure_later block should have run on success"
31
+ assert_equal "yes", marker.read
32
+ end
33
+ end
34
+ end
35
+
36
+ def test_runs_on_failure
37
+ Dir.mktmpdir do |dir|
38
+ Dir.chdir(dir) do
39
+ dir = Pathname(dir)
40
+ marker = dir.join("ensure_ran.txt")
41
+
42
+ source_path = dir.join("RUNDOC.md")
43
+ source_path.write <<~EOF
44
+ ```
45
+ :::-- rundoc.ensure_later(dir: :cwd)
46
+ File.write("#{marker}", "cleaned")
47
+ ```
48
+
49
+ ```
50
+ :::>> $ exit 1
51
+ ```
52
+ EOF
53
+
54
+ assert_raises do
55
+ Rundoc::CLI.new(
56
+ io: StringIO.new,
57
+ source_path: source_path,
58
+ on_success_dir: dir.join(SUCCESS_DIRNAME),
59
+ on_failure_dir: dir.join(FAILURE_DIRNAME)
60
+ ).call
61
+ end
62
+
63
+ assert marker.exist?, "ensure_later block should have run on failure"
64
+ assert_equal "cleaned", marker.read
65
+ end
66
+ end
67
+ end
68
+
69
+ def test_multiple_blocks_run_in_order
70
+ Dir.mktmpdir do |dir|
71
+ Dir.chdir(dir) do
72
+ dir = Pathname(dir)
73
+ marker = dir.join("order.txt")
74
+
75
+ source_path = dir.join("RUNDOC.md")
76
+ source_path.write <<~EOF
77
+ ```
78
+ :::-- rundoc.ensure_later(dir: :cwd)
79
+ File.write("#{marker}", "first")
80
+ ```
81
+
82
+ ```
83
+ :::-- rundoc.ensure_later(dir: :cwd)
84
+ File.write("#{marker}", File.read("#{marker}") + ",second")
85
+ ```
86
+
87
+ ```
88
+ :::>> $ echo "hello"
89
+ ```
90
+ EOF
91
+
92
+ Rundoc::CLI.new(
93
+ io: StringIO.new,
94
+ source_path: source_path,
95
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
96
+ ).call
97
+
98
+ assert_equal "first,second", marker.read
99
+ end
100
+ end
101
+ end
102
+
103
+ def test_one_failure_does_not_stop_others
104
+ Dir.mktmpdir do |dir|
105
+ Dir.chdir(dir) do
106
+ dir = Pathname(dir)
107
+ marker = dir.join("second_ran.txt")
108
+
109
+ source_path = dir.join("RUNDOC.md")
110
+ source_path.write <<~EOF
111
+ ```
112
+ :::-- rundoc.ensure_later(dir: :cwd)
113
+ raise "intentional failure"
114
+ ```
115
+
116
+ ```
117
+ :::-- rundoc.ensure_later(dir: :cwd)
118
+ File.write("#{marker}", "yes")
119
+ ```
120
+
121
+ ```
122
+ :::>> $ echo "hello"
123
+ ```
124
+ EOF
125
+
126
+ error = assert_raises(RuntimeError) do
127
+ Rundoc::CLI.new(
128
+ io: StringIO.new,
129
+ source_path: source_path,
130
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
131
+ ).call
132
+ end
133
+ assert_match(/intentional failure/, error.message)
134
+
135
+ assert marker.exist?, "second ensure_later should still run after first fails"
136
+ end
137
+ end
138
+ end
139
+
140
+ def test_success_plus_ensure_failure_is_overall_failure
141
+ Dir.mktmpdir do |dir|
142
+ Dir.chdir(dir) do
143
+ dir = Pathname(dir)
144
+
145
+ source_path = dir.join("RUNDOC.md")
146
+ source_path.write <<~EOF
147
+ ```
148
+ :::-- rundoc.ensure_later(dir: :cwd)
149
+ raise "cleanup failed"
150
+ ```
151
+
152
+ ```
153
+ :::>> $ echo "hello"
154
+ ```
155
+ EOF
156
+
157
+ error = assert_raises(RuntimeError) do
158
+ Rundoc::CLI.new(
159
+ io: StringIO.new,
160
+ source_path: source_path,
161
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
162
+ ).call
163
+ end
164
+
165
+ assert_match(/cleanup failed/, error.message)
166
+ end
167
+ end
168
+ end
169
+
170
+ def test_dir_rundoc_root
171
+ Dir.mktmpdir do |dir|
172
+ Dir.chdir(dir) do
173
+ dir = Pathname(dir)
174
+ marker = dir.join("root_marker.txt")
175
+
176
+ source_path = dir.join("RUNDOC.md")
177
+ source_path.write <<~EOF
178
+ ```
179
+ :::>> $ mkdir subdir
180
+ :::>> $ cd subdir
181
+ ```
182
+
183
+ ```
184
+ :::-- rundoc.ensure_later(dir: :rundoc_root)
185
+ File.write("#{marker}", Dir.pwd)
186
+ ```
187
+
188
+ ```
189
+ :::>> $ echo "hello"
190
+ ```
191
+ EOF
192
+
193
+ cli = Rundoc::CLI.new(
194
+ io: StringIO.new,
195
+ source_path: source_path,
196
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
197
+ )
198
+ output_dir = cli.execution_context.output_dir.realpath.to_s
199
+
200
+ cli.call
201
+
202
+ assert marker.exist?, "ensure_later with dir: :rundoc_root should run"
203
+ assert_equal output_dir, marker.read
204
+ end
205
+ end
206
+ end
207
+
208
+ def test_output_not_in_document
209
+ Dir.mktmpdir do |dir|
210
+ Dir.chdir(dir) do
211
+ dir = Pathname(dir)
212
+
213
+ source_path = dir.join("RUNDOC.md")
214
+ source_path.write <<~EOF
215
+ ```
216
+ :::-- rundoc.ensure_later(dir: :cwd)
217
+ puts "THIS SHOULD NOT APPEAR"
218
+ ```
219
+
220
+ ```
221
+ :::>> $ echo "visible"
222
+ ```
223
+ EOF
224
+
225
+ Rundoc::CLI.new(
226
+ io: StringIO.new,
227
+ source_path: source_path,
228
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
229
+ ).call
230
+
231
+ readme = dir.join(SUCCESS_DIRNAME).join("README.md").read
232
+ refute_match(/THIS SHOULD NOT APPEAR/, readme)
233
+ assert_match(/visible/, readme)
234
+ end
235
+ end
236
+ end
237
+
238
+ def test_invalid_dir_raises
239
+ Dir.mktmpdir do |dir|
240
+ Dir.chdir(dir) do
241
+ dir = Pathname(dir)
242
+
243
+ source_path = dir.join("RUNDOC.md")
244
+ source_path.write <<~EOF
245
+ ```
246
+ :::-- rundoc.ensure_later(dir: :invalid)
247
+ puts "should not run"
248
+ ```
249
+ EOF
250
+
251
+ assert_raises(ArgumentError) do
252
+ Rundoc::CLI.new(
253
+ io: StringIO.new,
254
+ source_path: source_path,
255
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
256
+ ).call
257
+ end
258
+ end
259
+ end
260
+ end
261
+
262
+ def test_shared_binding_with_rundoc
263
+ Dir.mktmpdir do |dir|
264
+ Dir.chdir(dir) do
265
+ dir = Pathname(dir)
266
+ marker = dir.join("binding_test.txt")
267
+
268
+ source_path = dir.join("RUNDOC.md")
269
+ source_path.write <<~EOF
270
+ ```
271
+ :::-- rundoc
272
+ def my_helper
273
+ "from_rundoc"
274
+ end
275
+ ```
276
+
277
+ ```
278
+ :::-- rundoc.ensure_later(dir: :cwd)
279
+ File.write("#{marker}", my_helper)
280
+ ```
281
+
282
+ ```
283
+ :::>> $ echo "hello"
284
+ ```
285
+ EOF
286
+
287
+ Rundoc::CLI.new(
288
+ io: StringIO.new,
289
+ source_path: source_path,
290
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
291
+ ).call
292
+
293
+ assert_equal "from_rundoc", marker.read
294
+ end
295
+ end
296
+ end
297
+
298
+ def test_runs_on_failure_from_subdirectory
299
+ Dir.mktmpdir do |dir|
300
+ Dir.chdir(dir) do
301
+ dir = Pathname(dir)
302
+ marker = dir.join("ensure_ran.txt")
303
+
304
+ source_path = dir.join("RUNDOC.md")
305
+ source_path.write <<~EOF
306
+ ```
307
+ :::>> $ mkdir myapp
308
+ :::>> $ cd myapp
309
+ ```
310
+
311
+ ```
312
+ :::-- rundoc.ensure_later(dir: :cwd)
313
+ File.write("#{marker}", "cleaned")
314
+ ```
315
+
316
+ ```
317
+ :::>> $ exit 1
318
+ ```
319
+ EOF
320
+
321
+ assert_raises do
322
+ Rundoc::CLI.new(
323
+ io: StringIO.new,
324
+ source_path: source_path,
325
+ on_success_dir: dir.join(SUCCESS_DIRNAME),
326
+ on_failure_dir: dir.join(FAILURE_DIRNAME)
327
+ ).call
328
+ end
329
+
330
+ assert marker.exist?, "ensure_later from subdirectory should run even on failure"
331
+ assert_equal "cleaned", marker.read
332
+ end
333
+ end
334
+ end
335
+
336
+ def test_log_output_shows_dir_mode_name
337
+ Dir.mktmpdir do |dir|
338
+ Dir.chdir(dir) do
339
+ dir = Pathname(dir)
340
+
341
+ source_path = dir.join("RUNDOC.md")
342
+ source_path.write <<~EOF
343
+ ```
344
+ :::-- rundoc.ensure_later(dir: :cwd)
345
+ puts "hello"
346
+ ```
347
+
348
+ ```
349
+ :::>> $ echo "hello"
350
+ ```
351
+ EOF
352
+
353
+ io = StringIO.new
354
+ Rundoc::CLI.new(
355
+ io: io,
356
+ source_path: source_path,
357
+ on_success_dir: dir.join(SUCCESS_DIRNAME)
358
+ ).call
359
+
360
+ output = io.string
361
+ assert_includes output, "Registering ensure_later block (dir: cwd => /"
362
+ end
363
+ end
364
+ end
365
+ end
@@ -50,6 +50,57 @@ class IntegrationPrintTest < Minitest::Test
50
50
  end
51
51
  end
52
52
 
53
+ def test_rundoc_configure_defines_variable_accessible_from_erb
54
+ key = SecureRandom.hex
55
+ contents = <<~RUBY
56
+ ```
57
+ :::-- rundoc.configure
58
+ @shared_value = "#{key}"
59
+ ```
60
+
61
+ ```
62
+ :::-> print.erb
63
+ <%= @shared_value %>
64
+ ```
65
+ RUBY
66
+
67
+ Dir.mktmpdir do |dir|
68
+ Dir.chdir(dir) do
69
+ parsed = parse_contents(contents)
70
+ actual = parsed.to_md.gsub(Rundoc::FencedCodeBlock::AUTOGEN_WARNING, "")
71
+ assert_includes actual, key
72
+ end
73
+ end
74
+ end
75
+
76
+ def test_erb_defines_variable_accessible_from_rundoc_configure
77
+ key = SecureRandom.hex
78
+ contents = <<~RUBY
79
+ ```
80
+ :::-> print.erb
81
+ <% @from_erb = "#{key}" %>
82
+ ```
83
+
84
+ ```
85
+ :::-- rundoc.configure
86
+ @roundtripped = @from_erb + "_via_configure"
87
+ ```
88
+
89
+ ```
90
+ :::-> print.erb
91
+ <%= @roundtripped %>
92
+ ```
93
+ RUBY
94
+
95
+ Dir.mktmpdir do |dir|
96
+ Dir.chdir(dir) do
97
+ parsed = parse_contents(contents)
98
+ actual = parsed.to_md.gsub(Rundoc::FencedCodeBlock::AUTOGEN_WARNING, "")
99
+ assert_includes actual, "#{key}_via_configure"
100
+ end
101
+ end
102
+ end
103
+
53
104
  def test_erb_in_block
54
105
  contents = <<~RUBY
55
106
  ```
@@ -25,6 +25,30 @@ class BashTest < Minitest::Test
25
25
  end
26
26
  end
27
27
 
28
+ def test_pipefail_catches_early_failure
29
+ command = "false | true"
30
+ bash = Rundoc::CodeCommand::BashRunner.new(
31
+ render_command: false,
32
+ render_result: false,
33
+ io: StringIO.new,
34
+ user_args: Rundoc::CodeCommand::BashArgs.new(command)
35
+ )
36
+ error = assert_raises(RuntimeError) { bash.call }
37
+ assert_match(/exited with non zero status/, error.message)
38
+ end
39
+
40
+ def test_errexit_catches_compound_command_failure
41
+ command = "false; echo done"
42
+ bash = Rundoc::CodeCommand::BashRunner.new(
43
+ render_command: false,
44
+ render_result: false,
45
+ io: StringIO.new,
46
+ user_args: Rundoc::CodeCommand::BashArgs.new(command)
47
+ )
48
+ error = assert_raises(RuntimeError) { bash.call }
49
+ assert_match(/exited with non zero status/, error.message)
50
+ end
51
+
28
52
  def test_stdin
29
53
  command = "tail -n 2"
30
54
  bash = Rundoc::CodeCommand::BashRunner.new(
@@ -367,4 +367,29 @@ class PegParserTest < Minitest::Test
367
367
  assert_equal :rundoc, actual.keyword
368
368
  assert_equal "first = 1 # comment\nsecond = 2".strip, actual.contents.strip
369
369
  end
370
+
371
+ def test_symbol_value
372
+ input = %(:cwd)
373
+ parser = Rundoc::PegParser.new.symbol
374
+ tree = parser.parse_with_debug(input)
375
+ actual = @transformer.apply(tree)
376
+ assert_equal :cwd, actual
377
+ end
378
+
379
+ def test_symbol_in_named_args
380
+ input = %(dir: :cwd)
381
+ parser = Rundoc::PegParser.new.named_args
382
+ tree = parser.parse_with_debug(input)
383
+ actual = @transformer.apply(tree)
384
+ assert_equal({dir: :cwd}, actual)
385
+ end
386
+
387
+ def test_symbol_in_method_call
388
+ input = %(rundoc.ensure_later(dir: :cwd))
389
+ parser = Rundoc::PegParser.new.method_call
390
+ tree = parser.parse_with_debug(input)
391
+ actual = @transformer.apply(tree)
392
+ assert_equal :"rundoc.ensure_later", actual.keyword
393
+ assert_equal({dir: :cwd}, actual.original_args)
394
+ end
370
395
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rundoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 7.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Richard Schneeman
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-05 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: thor
@@ -107,6 +108,20 @@ dependencies:
107
108
  - - ">="
108
109
  - !ruby/object:Gem::Version
109
110
  version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: cgi
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 0.3.6
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 0.3.6
110
125
  - !ruby/object:Gem::Dependency
111
126
  name: rake
112
127
  requirement: !ruby/object:Gem::Requirement
@@ -212,6 +227,7 @@ files:
212
227
  - lib/rundoc/code_command/bash/cd.rb
213
228
  - lib/rundoc/code_command/comment.rb
214
229
  - lib/rundoc/code_command/deferred.rb
230
+ - lib/rundoc/code_command/empty_binding.rb
215
231
  - lib/rundoc/code_command/file_command/append.rb
216
232
  - lib/rundoc/code_command/file_command/remove.rb
217
233
  - lib/rundoc/code_command/no_such_command.rb
@@ -220,6 +236,7 @@ files:
220
236
  - lib/rundoc/code_command/print/erb.rb
221
237
  - lib/rundoc/code_command/print/text.rb
222
238
  - lib/rundoc/code_command/raw.rb
239
+ - lib/rundoc/code_command/rundoc/ensure_later.rb
223
240
  - lib/rundoc/code_command/rundoc/require.rb
224
241
  - lib/rundoc/code_command/rundoc_command.rb
225
242
  - lib/rundoc/code_command/website.rb
@@ -262,6 +279,7 @@ files:
262
279
  - test/fixtures/simple_git/rundoc.md
263
280
  - test/integration/after_build_test.rb
264
281
  - test/integration/background_stdin_test.rb
282
+ - test/integration/ensure_later_test.rb
265
283
  - test/integration/failure_test.rb
266
284
  - test/integration/pre_erb_test.rb
267
285
  - test/integration/print_test.rb
@@ -287,6 +305,7 @@ homepage: https://github.com/schneems/rundoc
287
305
  licenses:
288
306
  - MIT
289
307
  metadata: {}
308
+ post_install_message:
290
309
  rdoc_options: []
291
310
  require_paths:
292
311
  - lib
@@ -301,7 +320,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
301
320
  - !ruby/object:Gem::Version
302
321
  version: '0'
303
322
  requirements: []
304
- rubygems_version: 3.6.9
323
+ rubygems_version: 3.4.19
324
+ signing_key:
305
325
  specification_version: 4
306
326
  summary: RunDOC generates runable code from docs
307
327
  test_files: []