expect-pty 0.5.3 → 0.7.1
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 +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +122 -130
- data/docs/API.md +113 -0
- data/docs/MIGRATION.md +68 -0
- data/lib/expect/cleanup.rb +34 -0
- data/lib/expect/interaction.rb +228 -210
- data/lib/expect/logging.rb +117 -162
- data/lib/expect/matcher.rb +42 -38
- data/lib/expect/pattern.rb +10 -3
- data/lib/expect/pattern_list.rb +39 -9
- data/lib/expect/redactor.rb +12 -5
- data/lib/expect/relay.rb +106 -81
- data/lib/expect/relay_writer.rb +7 -5
- data/lib/expect/result.rb +33 -9
- data/lib/expect/session.rb +554 -0
- data/lib/expect/session_resources.rb +19 -18
- data/lib/expect/version.rb +2 -2
- data/lib/expect.rb +68 -578
- data/sig/expect.rbs +279 -0
- metadata +14 -123
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/lib/expect/configuration.rb +0 -122
- data/lib/expect/terminal.rb +0 -102
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/lib/expect/terminal.rb
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "shellwords"
|
|
4
|
-
|
|
5
|
-
# 会话终端的模式和窗口尺寸接口;人工接管期间的临时恢复由 interaction.rb 负责。
|
|
6
|
-
class Expect
|
|
7
|
-
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
8
|
-
# 辅助进程与管道独立记账,不能覆盖主会话 PID;失败时保留原异常并有界回收。
|
|
9
|
-
def stty(*modes)
|
|
10
|
-
return "" unless tty?
|
|
11
|
-
|
|
12
|
-
modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
|
|
13
|
-
modes = ["-g"] if modes.empty?
|
|
14
|
-
reader, sink = IO.pipe
|
|
15
|
-
resources = SessionResources.new(reader, writer: sink, own: true)
|
|
16
|
-
resources.pid = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
|
|
17
|
-
sink.close
|
|
18
|
-
output = reader.read
|
|
19
|
-
status = wait_stty(resources.pid)
|
|
20
|
-
resources.pid = nil
|
|
21
|
-
raise IOError, "stty failed: #{output.strip}" unless status.success?
|
|
22
|
-
|
|
23
|
-
output.strip
|
|
24
|
-
rescue Errno::ENOENT
|
|
25
|
-
failed = true
|
|
26
|
-
raise IOError, "stty executable not found in PATH; install the system terminal utilities"
|
|
27
|
-
rescue Exception # rubocop:disable Lint/RescueException -- 中断同样先回收辅助进程,再原样传播;不读取调用者的旧 $!。
|
|
28
|
-
failed = true
|
|
29
|
-
raise
|
|
30
|
-
ensure
|
|
31
|
-
cleanup(failed:) { cleanup_stty(resources, reader, sink) }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# 读取终端的 [行数, 列数];底层并非终端或句柄已关闭时保留原生 IO 异常。
|
|
35
|
-
def winsize = to_io.winsize
|
|
36
|
-
|
|
37
|
-
# 更新终端尺寸,由内核通知前台进程。
|
|
38
|
-
def winsize=(size)
|
|
39
|
-
to_io.winsize = size
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
# 正常路径阻塞取得真实状态,不额外轮询或固定等待;EINTR 不重新执行 stty。
|
|
45
|
-
def wait_stty(pid)
|
|
46
|
-
Process.waitpid2(pid).last
|
|
47
|
-
rescue Errno::EINTR
|
|
48
|
-
retry
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# 即使账本初始化被中断,局部变量中的管道仍须关闭;单端失败不跳过其他清理。
|
|
52
|
-
# 无主异常时由调用方传播首个常规清理错误。
|
|
53
|
-
def cleanup_stty(resources, reader, sink)
|
|
54
|
-
failure = nil
|
|
55
|
-
[reader, sink].compact.each do |io|
|
|
56
|
-
io.close unless io.closed?
|
|
57
|
-
rescue IOError, SystemCallError => error
|
|
58
|
-
failure ||= error
|
|
59
|
-
end
|
|
60
|
-
begin
|
|
61
|
-
reap_stty(resources) if resources
|
|
62
|
-
rescue IOError, SystemCallError => error
|
|
63
|
-
failure ||= error
|
|
64
|
-
end
|
|
65
|
-
raise failure if failure
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# 每阶段共享固定的 50ms 单调时钟预算:自然退出、TERM、KILL;中断不续期。
|
|
69
|
-
# 仍无法同步回收或系统调用失败时交给 detach 的后台 wait,不伪造退出状态。
|
|
70
|
-
def reap_stty(resources)
|
|
71
|
-
return unless resources.owner == Process.pid
|
|
72
|
-
|
|
73
|
-
[nil, "TERM", "KILL"].each do |signal|
|
|
74
|
-
deadline = Expect.monotonic + 0.05
|
|
75
|
-
loop do
|
|
76
|
-
begin
|
|
77
|
-
resources.reap
|
|
78
|
-
return resources.status unless resources.pid && resources.owner == Process.pid
|
|
79
|
-
|
|
80
|
-
if signal
|
|
81
|
-
begin
|
|
82
|
-
Process.kill(signal, resources.pid)
|
|
83
|
-
rescue Errno::ESRCH
|
|
84
|
-
# 退出可发生在非阻塞 wait 与信号之间,仍需继续 wait。
|
|
85
|
-
nil
|
|
86
|
-
end
|
|
87
|
-
signal = nil
|
|
88
|
-
end
|
|
89
|
-
rescue Errno::EINTR
|
|
90
|
-
# 重试沿用本阶段期限,连续 EINTR 也必须交还控制权。
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
remaining = deadline - Expect.monotonic
|
|
94
|
-
break unless remaining.positive?
|
|
95
|
-
|
|
96
|
-
sleep [remaining, 0.005].min
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
ensure
|
|
100
|
-
Process.detach(resources.pid) if resources.pid && resources.owner == Process.pid
|
|
101
|
-
end
|
|
102
|
-
end
|
data/script/ci
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
|
|
4
|
-
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
5
|
-
|
|
6
|
-
bundle exec rake
|
|
7
|
-
bundle exec ruby examples/dialogue.rb
|
|
8
|
-
|
|
9
|
-
# 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
|
|
10
|
-
for benchmark in matching relay send_slow scaling redactor; do
|
|
11
|
-
bundle exec ruby "benchmark/${benchmark}.rb" --smoke
|
|
12
|
-
done
|
|
13
|
-
|
|
14
|
-
mkdir -p tmp/ci
|
|
15
|
-
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
16
|
-
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
|
17
|
-
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
18
|
-
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
|
19
|
-
fi
|
|
20
|
-
gem build expect-pty.gemspec --output "$gem_file"
|
|
21
|
-
|
|
22
|
-
# 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
|
|
23
|
-
smoke_dir="$(mktemp -d)"
|
|
24
|
-
trap 'rm -rf "$smoke_dir"' EXIT
|
|
25
|
-
gem_file="$(pwd)/$gem_file"
|
|
26
|
-
bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
|
|
27
|
-
bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
cd "$smoke_dir"
|
|
31
|
-
# 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
|
|
32
|
-
for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
|
|
33
|
-
unset "$bundle_variable"
|
|
34
|
-
done
|
|
35
|
-
unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
|
|
36
|
-
export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
|
|
37
|
-
gem install --local --no-document "$gem_file"
|
|
38
|
-
|
|
39
|
-
cat > smoke.rb <<'RUBY'
|
|
40
|
-
require "expect/pty"
|
|
41
|
-
require "rbconfig"
|
|
42
|
-
|
|
43
|
-
spec = Gem.loaded_specs.fetch("expect-pty")
|
|
44
|
-
# 临时目录可能经由符号链接访问,先统一为实际路径再检查。
|
|
45
|
-
install_root = File.realpath(ENV.fetch("GEM_HOME"))
|
|
46
|
-
abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
|
|
47
|
-
abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
|
|
48
|
-
unexpected = %w[minitest rake rubocop parallel logger].select { |name| Gem.loaded_specs.key?(name) }
|
|
49
|
-
abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
50
|
-
spec.runtime_dependencies.each do |dependency|
|
|
51
|
-
loaded = Gem.loaded_specs.fetch(dependency.name)
|
|
52
|
-
abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
|
|
56
|
-
%w[forwardable io-console io-wait shellwords stringio].each do |name|
|
|
57
|
-
abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
|
|
58
|
-
end
|
|
59
|
-
child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
|
|
60
|
-
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
|
|
61
|
-
session.winsize = [30, 100]
|
|
62
|
-
abort "io-console terminal size failed" unless session.winsize == [30, 100]
|
|
63
|
-
# BSD stty 设置模式会清空待读输出,先收取提示,使子进程停在等待输入的位置。
|
|
64
|
-
abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
|
|
65
|
-
mode = session.stty
|
|
66
|
-
abort "installed stty query failed" if mode.empty?
|
|
67
|
-
session.stty(mode)
|
|
68
|
-
|
|
69
|
-
session.puts("expect-ruby")
|
|
70
|
-
abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
|
|
71
|
-
status = session.soft_close(timeout: 5)
|
|
72
|
-
abort "installed gem child did not exit successfully" unless status&.success?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# 使用已安装包验证公共过滤器随包分发,且结构化诊断无需加载可选的 Logger。
|
|
76
|
-
filter = Expect::Redactor.new(["secret"], replacement: "[REDACTED]")
|
|
77
|
-
filtered = filter.append("sec") + filter.append("ret!") + filter.finish
|
|
78
|
-
abort "installed public redactor failed" unless filtered == "[REDACTED]!"
|
|
79
|
-
abort "installed complete-text redactor failed" unless Expect::Redactor.redact("sec", ["secret"]) == "sec"
|
|
80
|
-
reader, producer = IO.pipe
|
|
81
|
-
events = []
|
|
82
|
-
transcript = StringIO.new
|
|
83
|
-
begin
|
|
84
|
-
Expect.open(reader, own: true, buffer_limit: 5, debug_level: 2,
|
|
85
|
-
diagnostic_output: ->(event) { events << event }) do |session|
|
|
86
|
-
session.redact("secret")
|
|
87
|
-
session.log_to(transcript)
|
|
88
|
-
session.buffer = "ready"
|
|
89
|
-
expired = session.expect_result("ready", deadline: Expect.monotonic)
|
|
90
|
-
abort "expired deadline consumed buffered text" unless expired.timeout? && session.buffer == "ready"
|
|
91
|
-
abort "zero timeout lost buffered text" unless session.expect("ready", timeout: 0) == 1
|
|
92
|
-
producer.write("secret!")
|
|
93
|
-
producer.close
|
|
94
|
-
result = session.expect_result(:eof, deadline: Expect.monotonic + 5)
|
|
95
|
-
abort "installed gem did not preserve capped input" unless result.eof? && result.before == "cret!"
|
|
96
|
-
abort "incorrect installed buffer accounting" unless session.buffer_discarded_bytes == 2
|
|
97
|
-
abort "installed redaction failed" unless transcript.string == "[FILTERED]!"
|
|
98
|
-
abort "diagnostics leaked secret" if events.any? { |event| event[:message].include?("secret") }
|
|
99
|
-
abort "filtered diagnostic missing" unless events.any? { |event| event[:message].include?("[FILTERED]") }
|
|
100
|
-
end
|
|
101
|
-
ensure
|
|
102
|
-
reader.close unless reader.closed?
|
|
103
|
-
producer.close unless producer.closed?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY, core contracts and runtime dependencies passed"
|
|
107
|
-
RUBY
|
|
108
|
-
ruby smoke.rb plain
|
|
109
|
-
|
|
110
|
-
# 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
|
|
111
|
-
# 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
|
|
112
|
-
if [ -f "$bundler_gem" ]; then
|
|
113
|
-
gem install --local --no-document "$bundler_gem"
|
|
114
|
-
else
|
|
115
|
-
gem install --no-document bundler --version "$bundler_version"
|
|
116
|
-
fi
|
|
117
|
-
export BUNDLE_IGNORE_CONFIG=true
|
|
118
|
-
printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
|
|
119
|
-
bundle lock --local
|
|
120
|
-
bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
|
|
121
|
-
)
|
data/script/release.rb
DELETED
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
require "digest"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
require "json"
|
|
7
|
-
require "net/http"
|
|
8
|
-
require "open3"
|
|
9
|
-
require "optparse"
|
|
10
|
-
require "rubygems/package"
|
|
11
|
-
require "tmpdir"
|
|
12
|
-
require_relative "../lib/expect/version"
|
|
13
|
-
|
|
14
|
-
# 本地使用 gem / gh 已有的登录状态;CI 传入测试过的包,发布阶段不重新构建。
|
|
15
|
-
class Release
|
|
16
|
-
REPOSITORY = "gatework/expect-ruby"
|
|
17
|
-
GEM_HOST = "https://rubygems.org"
|
|
18
|
-
|
|
19
|
-
def initialize(artifact: nil, dry_run: false, rubygems_only: false)
|
|
20
|
-
@version = Expect::VERSION
|
|
21
|
-
@tag = "v#{@version}"
|
|
22
|
-
@artifact = File.expand_path(artifact || "tmp/ci/expect-pty-#{@version}.gem")
|
|
23
|
-
@dry_run = dry_run
|
|
24
|
-
@rubygems_only = rubygems_only
|
|
25
|
-
@build = artifact.nil?
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def run
|
|
29
|
-
notes = self.class.read_notes("CHANGELOG.md", @version)
|
|
30
|
-
unless @dry_run || capture("git", "status", "--porcelain").empty?
|
|
31
|
-
raise "Commit all source changes before publishing"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
@commit = capture("git", "rev-parse", "HEAD") unless @dry_run
|
|
35
|
-
|
|
36
|
-
command("bash", "script/ci") if @build
|
|
37
|
-
# 独占目录中的副本贯穿校验和上传,其他构建不会改变本次发布的字节。
|
|
38
|
-
directory = File.join("tmp", "release", @version)
|
|
39
|
-
FileUtils.mkdir_p(directory)
|
|
40
|
-
directory = Dir.mktmpdir("candidate-", directory)
|
|
41
|
-
candidate = File.join(directory, "expect-pty-#{@version}.gem")
|
|
42
|
-
FileUtils.cp(@artifact, candidate)
|
|
43
|
-
@artifact = File.expand_path(candidate)
|
|
44
|
-
verify_package
|
|
45
|
-
@sha256 = Digest::SHA256.file(@artifact).hexdigest
|
|
46
|
-
@checksum_file = File.join(directory, "SHA256SUMS")
|
|
47
|
-
@notes_file = File.join(directory, "release-notes.md")
|
|
48
|
-
File.write(@checksum_file, "#{@sha256} #{File.basename(@artifact)}\n")
|
|
49
|
-
File.write(@notes_file, "#{notes}\n", encoding: "UTF-8")
|
|
50
|
-
puts "Verified #{@tag}: #{@sha256}\nArtifact: #{@artifact}"
|
|
51
|
-
return puts "Dry run complete: #{@artifact}" if @dry_run
|
|
52
|
-
|
|
53
|
-
if @rubygems_only
|
|
54
|
-
verify_local_source
|
|
55
|
-
else
|
|
56
|
-
verify_remote_source
|
|
57
|
-
verify_registry_checksum(registry_version)
|
|
58
|
-
publish_github
|
|
59
|
-
end
|
|
60
|
-
publish_rubygems
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# 有未归档的变更时拒绝发布,避免把新接口放进旧版本或遗漏发布说明。
|
|
64
|
-
def self.read_notes(path, version)
|
|
65
|
-
changelog = File.read(path, encoding: "UTF-8")
|
|
66
|
-
raise "Invalid UTF-8 in #{path}" unless changelog.valid_encoding?
|
|
67
|
-
|
|
68
|
-
release_notes(changelog, version)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# 纯章节解析与文件编码边界分离,本地入口和工作流共用 read_notes。
|
|
72
|
-
def self.release_notes(changelog, version)
|
|
73
|
-
raise "Use a stable X.Y.Z version" unless /\A\d+\.\d+\.\d+\z/.match?(version)
|
|
74
|
-
|
|
75
|
-
sections = changelog.split(/^## /).drop(1).map do |section|
|
|
76
|
-
heading, body = section.split("\n", 2)
|
|
77
|
-
[heading.strip, body]
|
|
78
|
-
end
|
|
79
|
-
unreleased = sections.find { |heading, _body| heading == "Unreleased" }
|
|
80
|
-
if unreleased && !unreleased[1].to_s.strip.empty?
|
|
81
|
-
raise "Move Unreleased changes into the versioned changelog before releasing"
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
section = sections.find do |heading, _body|
|
|
85
|
-
/\A#{Regexp.escape(version)}(?: - \d{4}-\d{2}-\d{2})?\z/.match?(heading)
|
|
86
|
-
end
|
|
87
|
-
raise "Missing release notes for #{version}" unless section && !section[1].to_s.strip.empty?
|
|
88
|
-
|
|
89
|
-
section[1].strip
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private
|
|
93
|
-
|
|
94
|
-
def capture(*)
|
|
95
|
-
capture_bytes(*).strip
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
|
|
99
|
-
def capture_bytes(*arguments)
|
|
100
|
-
output, error, status = Open3.capture3(*arguments, binmode: true)
|
|
101
|
-
raise "#{arguments.first} failed: #{error.strip}" unless status.success?
|
|
102
|
-
|
|
103
|
-
output
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def command(*arguments)
|
|
107
|
-
raise "#{arguments.first} failed" unless system(*arguments)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def github(path, missing: false)
|
|
111
|
-
output, error, status = Open3.capture3("gh", "api", "repos/#{REPOSITORY}/#{path}")
|
|
112
|
-
return nil if missing && !status.success? && error.include?("HTTP 404")
|
|
113
|
-
raise "GitHub API failed: #{error.strip}" unless status.success?
|
|
114
|
-
|
|
115
|
-
JSON.parse(output)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def verify_local_source
|
|
119
|
-
return if capture("git", "rev-parse", "HEAD") == @commit && capture("git", "status", "--porcelain").empty?
|
|
120
|
-
|
|
121
|
-
raise "Source changed during verification; commit the changes and start again"
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def verify_remote_source
|
|
125
|
-
verify_local_source
|
|
126
|
-
|
|
127
|
-
comparison = github("compare/#{@commit}...main")
|
|
128
|
-
raise "Push this commit to #{REPOSITORY}/main first" unless %w[ahead identical].include?(comparison.fetch("status"))
|
|
129
|
-
|
|
130
|
-
return unless github("git/ref/tags/#{@tag}", missing: true)
|
|
131
|
-
return if github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
|
|
132
|
-
|
|
133
|
-
raise "Remote tag #{@tag} points to another commit"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# 检查包内每个文件,防止拿旧包或其他提交的产物发布到当前标签。
|
|
137
|
-
def verify_package
|
|
138
|
-
package = Gem::Package.new(@artifact)
|
|
139
|
-
expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
|
|
140
|
-
fields = %i[name version platform summary description authors licenses homepage required_ruby_version
|
|
141
|
-
required_rubygems_version require_paths metadata dependencies extensions executables bindir
|
|
142
|
-
post_install_message]
|
|
143
|
-
unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
|
|
144
|
-
raise "Artifact metadata does not match the gemspec"
|
|
145
|
-
end
|
|
146
|
-
raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
|
|
147
|
-
|
|
148
|
-
committed = committed_files if @commit
|
|
149
|
-
# 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
|
|
150
|
-
File.open(@artifact, "rb") do |io|
|
|
151
|
-
Gem::Package::TarReader.new(io) do |archive|
|
|
152
|
-
data = archive.find { |entry| entry.full_name == "data.tar.gz" }
|
|
153
|
-
package.open_tar_gz(data) do |tar|
|
|
154
|
-
tar.each do |entry|
|
|
155
|
-
file = entry.full_name
|
|
156
|
-
bytes = entry.read
|
|
157
|
-
raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
|
|
158
|
-
|
|
159
|
-
verify_committed_file(file, bytes, entry.header.mode, committed) if committed
|
|
160
|
-
next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
|
|
161
|
-
|
|
162
|
-
raise "Artifact executable permissions differ: #{file}"
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
|
|
170
|
-
# 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
|
|
171
|
-
def committed_files
|
|
172
|
-
capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
|
|
173
|
-
metadata, file = record.split("\t", 2)
|
|
174
|
-
mode, type, object = metadata.split
|
|
175
|
-
[file, [mode.to_i(8), type, object]]
|
|
176
|
-
end
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def verify_committed_file(file, bytes, mode, committed)
|
|
180
|
-
committed_mode, type, object = committed[file.b]
|
|
181
|
-
unless type == "blob" && committed_mode & 0o170000 == 0o100000
|
|
182
|
-
raise "Artifact file is not in release commit: #{file}"
|
|
183
|
-
end
|
|
184
|
-
unless bytes == capture_bytes("git", "cat-file", "blob", object)
|
|
185
|
-
raise "Artifact differs from release commit: #{file}"
|
|
186
|
-
end
|
|
187
|
-
return if mode & 0o111 == committed_mode & 0o111
|
|
188
|
-
|
|
189
|
-
raise "Artifact executable permissions differ from release commit: #{file}"
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def get(path)
|
|
193
|
-
uri = URI("#{GEM_HOST}#{path}")
|
|
194
|
-
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|
|
195
|
-
http.get(uri.request_uri)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def registry_version
|
|
200
|
-
response = get("/api/v2/rubygems/expect-pty/versions/#{@version}.json")
|
|
201
|
-
return nil if response.code == "404"
|
|
202
|
-
raise "RubyGems lookup failed: HTTP #{response.code}" unless response.code == "200"
|
|
203
|
-
|
|
204
|
-
JSON.parse(response.body)
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def verify_registry_checksum(version)
|
|
208
|
-
return unless version
|
|
209
|
-
return if !version.fetch("yanked") && version.fetch("sha") == @sha256
|
|
210
|
-
|
|
211
|
-
raise "RubyGems already has different or yanked bytes for #{@version}; use the original artifact or a new version"
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# gh 同时查找已发布版本和遗留草稿,REST 按标签查询只能找到已发布版本。
|
|
215
|
-
def github_release
|
|
216
|
-
output, error, status = Open3.capture3("gh", "release", "view", @tag, "--repo", REPOSITORY,
|
|
217
|
-
"--json", "isDraft,assets")
|
|
218
|
-
return nil if !status.success? && error.strip == "release not found"
|
|
219
|
-
raise "GitHub Release lookup failed: #{error.strip}" unless status.success?
|
|
220
|
-
|
|
221
|
-
JSON.parse(output)
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# 先补齐 GitHub Release;RubyGems 认证失败时,已上传的原包仍可用于重试。
|
|
225
|
-
def publish_github
|
|
226
|
-
release = github_release
|
|
227
|
-
assets = [@artifact, @checksum_file]
|
|
228
|
-
if release
|
|
229
|
-
existing, missing = assets.partition do |asset|
|
|
230
|
-
entry = release.fetch("assets").find { |item| item.fetch("name") == File.basename(asset) }
|
|
231
|
-
if entry && entry.fetch("state") != "uploaded"
|
|
232
|
-
raise "Incomplete GitHub asset: #{entry.fetch("name")}; stop any active upload, remove the incomplete " \
|
|
233
|
-
"asset in GitHub Release, then retry with the same artifact"
|
|
234
|
-
end
|
|
235
|
-
entry
|
|
236
|
-
end
|
|
237
|
-
verify_github_assets(existing)
|
|
238
|
-
missing.each { |asset| command("gh", "release", "upload", @tag, asset, "--repo", REPOSITORY) }
|
|
239
|
-
verify_github_assets(missing)
|
|
240
|
-
if release.fetch("isDraft")
|
|
241
|
-
command("gh", "release", "edit", @tag, "--repo", REPOSITORY, "--target", @commit, "--draft=false")
|
|
242
|
-
end
|
|
243
|
-
else
|
|
244
|
-
command("gh", "release", "create", @tag, *assets, "--repo", REPOSITORY, "--target", @commit,
|
|
245
|
-
"--title", @tag, "--notes-file", @notes_file)
|
|
246
|
-
verify_github_assets(assets)
|
|
247
|
-
end
|
|
248
|
-
unless github("compare/#{@tag}...#{@commit}").fetch("status") == "identical"
|
|
249
|
-
raise "Published tag points to another commit"
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
puts "GitHub Release: https://github.com/#{REPOSITORY}/releases/tag/#{@tag}"
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def verify_github_assets(assets)
|
|
256
|
-
# 新上传的附件也读回核验,工作流成功不代替远端产物校验。
|
|
257
|
-
Dir.mktmpdir("expect-release-readback-") do |directory|
|
|
258
|
-
assets.each do |asset|
|
|
259
|
-
name = File.basename(asset)
|
|
260
|
-
command("gh", "release", "download", @tag, "--repo", REPOSITORY, "--pattern", name, "--dir", directory)
|
|
261
|
-
unless Digest::SHA256.file(File.join(directory, name)).hexdigest == Digest::SHA256.file(asset).hexdigest
|
|
262
|
-
raise "GitHub Release asset verification failed: #{name}"
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def publish_rubygems
|
|
269
|
-
version = registry_version
|
|
270
|
-
verify_registry_checksum(version)
|
|
271
|
-
unless version
|
|
272
|
-
if ENV["GITHUB_ACTIONS"] == "true" && ENV.fetch("GEM_HOST_API_KEY", "").empty?
|
|
273
|
-
raise "Set the repository Actions secret RUBYGEMS_API_KEY, or publish locally with the existing gem login"
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
pushed = system("gem", "push", @artifact, "--host", GEM_HOST)
|
|
277
|
-
# 推送返回失败也先读取远端,避免连接中断后盲目重复上传。
|
|
278
|
-
version = registry_version
|
|
279
|
-
verify_registry_checksum(version)
|
|
280
|
-
raise "gem push failed; check RubyGems authentication and retry with the same artifact" unless pushed || version
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
6.times do |attempt|
|
|
284
|
-
response = get("/downloads/expect-pty-#{@version}.gem")
|
|
285
|
-
if response.code == "200"
|
|
286
|
-
raise "Downloaded RubyGems artifact checksum differs" unless Digest::SHA256.hexdigest(response.body) == @sha256
|
|
287
|
-
|
|
288
|
-
return puts "RubyGems: #{GEM_HOST}/gems/expect-pty/versions/#{@version} (SHA256 verified)"
|
|
289
|
-
end
|
|
290
|
-
raise "RubyGems download failed: HTTP #{response.code}" unless response.code == "404"
|
|
291
|
-
|
|
292
|
-
sleep 2 unless attempt == 5
|
|
293
|
-
end
|
|
294
|
-
raise "RubyGems download is not available yet; retry with the same artifact"
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
if $PROGRAM_NAME == __FILE__
|
|
299
|
-
options = {}
|
|
300
|
-
parser = OptionParser.new do |arguments|
|
|
301
|
-
arguments.banner = "Usage: ruby script/release.rb [--rubygems-only] [--dry-run] [--artifact PATH]"
|
|
302
|
-
arguments.on("--rubygems-only", "Publish only to RubyGems using the existing gem login; no GitHub access") do
|
|
303
|
-
options[:rubygems_only] = true
|
|
304
|
-
end
|
|
305
|
-
arguments.on("--artifact PATH", "Publish an already verified gem without rebuilding") do |path|
|
|
306
|
-
options[:artifact] = File.expand_path(path)
|
|
307
|
-
end
|
|
308
|
-
arguments.on("--dry-run", "Build and verify locally without publishing") { options[:dry_run] = true }
|
|
309
|
-
end
|
|
310
|
-
begin
|
|
311
|
-
parser.parse!
|
|
312
|
-
raise OptionParser::InvalidArgument, ARGV.join(" ") unless ARGV.empty?
|
|
313
|
-
|
|
314
|
-
Dir.chdir(File.expand_path("..", __dir__)) { Release.new(**options).run }
|
|
315
|
-
rescue StandardError => error
|
|
316
|
-
warn "Release aborted: #{error.message}"
|
|
317
|
-
exit 1
|
|
318
|
-
end
|
|
319
|
-
end
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "test_helper"
|
|
4
|
-
|
|
5
|
-
class BufferAccountingTest < ExpectTest
|
|
6
|
-
def test_limit_discards_are_cumulative_and_distinct_from_consumption
|
|
7
|
-
session, = pipe_session(buffer_limit: 4)
|
|
8
|
-
assert_equal 0, session.buffer_discarded_bytes
|
|
9
|
-
session.buffer = "abcdef"
|
|
10
|
-
assert_equal "cdef", session.buffer
|
|
11
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
12
|
-
session.buffer_limit = 2
|
|
13
|
-
assert_equal "ef", session.buffer
|
|
14
|
-
assert_equal 4, session.buffer_discarded_bytes
|
|
15
|
-
assert_equal 1, session.expect("e", timeout: 0)
|
|
16
|
-
assert_equal "f", session.clear_buffer
|
|
17
|
-
session.close
|
|
18
|
-
assert_equal 4, session.buffer_discarded_bytes
|
|
19
|
-
refute_respond_to session, :buffer_discarded_bytes=
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def test_invalid_limit_and_buffer_leave_accounting_unchanged
|
|
23
|
-
session, = pipe_session(buffer_limit: 4)
|
|
24
|
-
session.buffer = "abcdef"
|
|
25
|
-
assert_raises(ArgumentError) { session.buffer_limit = 0 }
|
|
26
|
-
assert_raises(ArgumentError) { session.buffer = nil }
|
|
27
|
-
assert_equal "cdef", session.buffer
|
|
28
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
29
|
-
session.buffer_limit = nil
|
|
30
|
-
session.buffer = "unlimited"
|
|
31
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def test_continuous_output_keeps_a_bounded_window_and_complete_log
|
|
35
|
-
session, writer = pipe_session(buffer_limit: 64)
|
|
36
|
-
logged = 0
|
|
37
|
-
session.log_to { |data| logged += data.bytesize }
|
|
38
|
-
32.times do
|
|
39
|
-
writer.write("x" * 4096)
|
|
40
|
-
assert_nil session.expect("missing", timeout: 0)
|
|
41
|
-
assert_equal 64, session.buffer.bytesize
|
|
42
|
-
end
|
|
43
|
-
assert_equal 131_072, logged
|
|
44
|
-
assert_equal logged - 64, session.buffer_discarded_bytes
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def test_preserved_match_does_not_count_as_discard
|
|
48
|
-
session, = pipe_session(buffer_limit: 4, preserve_buffer: true)
|
|
49
|
-
session.buffer = "abcdef"
|
|
50
|
-
2.times { assert_equal 1, session.expect("ef", timeout: 0) }
|
|
51
|
-
assert_equal "cdef", session.buffer
|
|
52
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def test_relay_tail_is_only_counted_when_matching_applies_the_limit
|
|
56
|
-
session, writer = pipe_session(buffer_limit: 2)
|
|
57
|
-
output = StringIO.new
|
|
58
|
-
session.listeners = [output]
|
|
59
|
-
session.on_sequence("STOP")
|
|
60
|
-
writer.write("prefixSTOPtail")
|
|
61
|
-
assert_same session, Expect.interconnect(session, timeout: 1)
|
|
62
|
-
assert_equal "prefix", output.string
|
|
63
|
-
assert_equal "tail", session.buffer
|
|
64
|
-
assert_equal 0, session.buffer_discarded_bytes
|
|
65
|
-
assert_equal 1, session.expect("il", timeout: 0)
|
|
66
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
67
|
-
session.expect(timeout: 0)
|
|
68
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def test_logging_failure_preserves_the_retained_input_and_discard_count
|
|
72
|
-
session, writer = pipe_session(buffer_limit: 4)
|
|
73
|
-
failure = IOError.new("log failed")
|
|
74
|
-
session.log_to { raise failure }
|
|
75
|
-
writer.write("abcdef")
|
|
76
|
-
assert_same failure, session.expect_result("ef", timeout: 1).error
|
|
77
|
-
assert_equal "cdef", session.buffer
|
|
78
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
79
|
-
session.log_output = nil
|
|
80
|
-
assert_equal 1, session.expect("ef", timeout: 0)
|
|
81
|
-
assert_equal 2, session.buffer_discarded_bytes
|
|
82
|
-
end
|
|
83
|
-
end
|