Repository navigation
Expand file tree
/
Copy pathRakefile
More file actions
230 lines (196 loc) · 10.5 KB
/
Copy pathRakefile
File metadata and controls
230 lines (196 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# frozen_string_literal: true
require 'bundler/setup'
require 'bundler/gem_tasks'
require 'json'
require 'open3'
CLOBBER.include 'pkg'
# See https://github.com/techknowlogick/xgo
# Releases run from .github/workflows/release.yml on a pushed `v*` tag, never from a laptop: the
# workflow installs and loads every platform gem it built before anything publishes. Bundler's
# own `release` tags, pushes and publishes from here with none of that, so it is replaced, along
# with the steps it chains.
%w[release release:guard_clean release:source_control_push release:rubygem_push].each do |name|
Rake::Task[name].clear
end
desc 'Releases run from .github/workflows/release.yml: push an annotated v* tag'
task :release do
abort 'Releases run from .github/workflows/release.yml, not from here. Push an annotated ' \
'v* tag; see Releasing in AGENTS.md.'
end
#
# Go target | Ruby gem platform | built by
# ---------------|--------------------|----------------------------------
# darwin/arm64 | arm64-darwin | native, CGO_ENABLED=1
# darwin/amd64 | x86_64-darwin | native, CGO_ENABLED=1
# linux/arm64 | aarch64-linux-gnu | xgo
# linux/amd64 | x86_64-linux-gnu | xgo
# windows/amd64 | x64-mingw-ucrt | native, CGO_ENABLED=1, on a Windows host
#
# Windows builds natively because xgo cannot: its mingw-w64 linker rejects the export definition
# Go generates for c-shared (`export_file.def:1: syntax error`). The library is a `.dll`, named
# explicitly - see LIBRARY_NAME in lib/proscenium/builder.rb.
#
# The Linux gems name their libc. A BARE `x86_64-linux` matches glibc and musl alike, so an
# Alpine host installed it and got a glibc shared library it could not load, with a dlopen
# failure naming a path and nothing to say the platform was the problem. `-gnu` is never
# selected on musl, so after this rename an Alpine host matches no platform gem at all: it gets
# the platform-less gem and Proscenium::Builder::UnsupportedPlatform, which says so.
#
# There are deliberately NO musl gems. Go's c-shared libraries cannot be dlopen'd on musl -
# they carry initial-exec TLS relocations that musl refuses by design - and Ruby's FFI loads
# this library with dlopen. The build succeeds and produces a correctly musl-linked library;
# it simply cannot be loaded, failing with:
#
# Error relocating ...: free: initial-exec TLS resolves to dynamic definition
#
# That is golang/go#54805, open since 2022. The linker flag that would fix it is in neither
# Go 1.25 nor Go 1.27. Revisit when it ships; nothing else here needs to change.
#
# Consequence for existing users: `x86_64-linux` is no longer a platform Proscenium publishes,
# so a lockfile pinned to it wants `bundle lock --add-platform x86_64-linux-gnu`. The Linux gems
# declare required_rubygems_version >= 3.3.22 (see the gemspec) so an older RubyGems, which
# cannot tell the variants apart, refuses them instead of installing the wrong one.
# Ruby => Go
PLATFORMS = {
'x86_64-darwin' => 'darwin/amd64',
'arm64-darwin' => 'darwin/arm64',
'aarch64-linux-gnu' => 'linux/arm64',
'x86_64-linux-gnu' => 'linux/amd64',
'x64-mingw-ucrt' => 'windows/amd64'
}.freeze
# Built by the Go toolchain on a host of the same OS. Everything else goes through xgo.
NATIVE_GOOS = %w[darwin windows].freeze
# The compiled library's file name for a Go OS. Must match LIBRARY_NAME in
# lib/proscenium/builder.rb, which is what loads it.
library_name = ->(goos) { goos == 'windows' ? 'proscenium.dll' : 'proscenium' }
base = FileUtils.pwd
pkg_dir = File.join(base, 'pkg')
ext_dir = 'lib/proscenium/ext'
ext_path = Pathname.new(base).join(ext_dir)
gemspec = Bundler.load_gemspec('proscenium.gemspec')
# Pushing has to be resumable. A release publishes one gem per platform, and a run that dies
# partway leaves some of them up: re-running it then failed on every gem that had already gone,
# so the version stayed half-published and the platforms that missed out resolved to an older
# release instead. Nothing warned about it.
#
# RubyGems answers a duplicate push with "has already been pushed", which is the outcome we want
# - the artifact is where it should be - so treat it as success and carry on to the next gem.
# Checked from the push's own output rather than by asking the API first, so a network blip
# cannot make us skip a gem that was never published.
push_gem = lambda do |gem_file|
out, status = Open3.capture2e('gem', 'push', gem_file)
puts out
return if status.success?
raise "gem push failed for #{gem_file}" unless out.include?('has already been pushed')
puts "---> Already published, skipping #{File.basename(gem_file)}"
end
desc 'Print the platform list as JSON, for the release workflow matrix'
task 'platforms:json' do
puts PLATFORMS.keys.to_json
end
desc 'Compile for local os/arch'
task 'compile:local' => 'clobber:ext' do
sh 'go', 'build', '-buildmode=c-shared', '-o',
"#{ext_dir}/#{library_name.call(Gem.win_platform? ? 'windows' : '')}", 'main.go'
end
desc 'Run the suites that drive real pnpm and Bun, as CI\'s package-manager job does'
# The paths are the package-manager job's in .github/workflows/main.yml; change both together.
task 'test:package_manager' do
sh({ 'STAGE_A' => '1' }, Gem.ruby, 'bin/test', 'test/package_manager/', 'test/adopted/',
'test/proxy_rule_test.rb')
end
# The Go OS this machine builds natively. Everything built natively (see NATIVE_GOOS) needs a C
# toolchain for its own OS, so a Mac cannot build the Windows gem, nor Windows the darwin ones.
host_goos = if Gem.win_platform? then 'windows'
elsif RbConfig::CONFIG['host_os'].include?('darwin') then 'darwin'
else 'linux'
end
# What `rake build` can build here: every xgo target, since xgo only needs Docker, and the native
# targets for this machine's own OS. The release workflow builds each platform on a matching
# runner instead, and is the only way to build all of them.
buildable_here = PLATFORMS.select do |_, go_platform|
goos = go_platform.split('/').first
!NATIVE_GOOS.include?(goos) || goos == host_goos
end.keys
desc 'Build the Proscenium gems this machine can build into the pkg directory.'
task build: [:clobber] + buildable_here.map { |platform| "build:#{platform}" } do
skipped = PLATFORMS.keys - buildable_here
puts "---> Skipped #{skipped.join(', ')}: they build natively on their own OS" if skipped.any?
end
desc 'Push Proscenium gems up to the gem server.'
task push: PLATFORMS.keys.map { |platform| "push:#{platform}" } << 'push:gem'
PLATFORMS.each do |ruby_platform, go_platform|
task "build:#{ruby_platform}" => ["compile:#{ruby_platform}"] do
# Set for this subprocess only. Exporting it would defeat the point: `rake build` builds the
# platform gems and then the platform-less one in a single process, and the plain gem must
# not see it. See the gate in the gemspec.
#
# Outside Bundler, because a subprocess inherits its environment and re-resolves the Gemfile,
# whose `gemspec` line then reads PROSCENIUM_PLATFORM too and cannot resolve Proscenium as a
# platform gem. `gem build` needs nothing from the bundle.
env = { 'PROSCENIUM_PACKAGE_EXT' => '1', 'PROSCENIUM_PLATFORM' => ruby_platform }
Bundler.with_unbundled_env { sh(env, 'gem', 'build', '-V') }
gem_path = Gem::Util.glob_files_in_dir("proscenium-*-#{ruby_platform}.gem", base)
.max_by { |f| File.mtime(f) }
raise "gem build produced no proscenium-*-#{ruby_platform}.gem" unless gem_path
FileUtils.mkdir_p pkg_dir
FileUtils.mv gem_path, 'pkg'
puts ''
puts "---> Built #{gemspec.version} to pkg/proscenium-#{gemspec.version}-#{ruby_platform}.gem"
end
desc "Compile for #{ruby_platform}"
task "compile:#{ruby_platform}" => 'clobber:ext' do
puts ''
puts "---> Compiling for #{ruby_platform} (#{go_platform})"
goos, goarch = go_platform.split('/')
# -mod=readonly because the release workflow's bundler-cache installs gems into vendor/bundle,
# and Go reads any vendor/ directory as module vendoring: every build then failed with
# "inconsistent vendoring". The test workflow already passes it for the same reason.
goflags = '-mod=readonly'
if NATIVE_GOOS.include?(goos)
# Environment as a hash rather than a `VAR=value` prefix, which cmd.exe does not understand.
sh({ 'GOWORK' => 'off', 'GOFLAGS' => goflags, 'GOOS' => goos, 'GOARCH' => goarch,
'CGO_ENABLED' => '1' },
'go', 'build', '-buildmode=c-shared', '-v', '-o',
"#{ext_dir}/#{library_name.call(goos)}", 'main.go')
else
# The container sees only what -env passes it, comma separated.
#
# Nothing the container creates can be renamed on a Linux host, because it creates it as
# root - and a bind mount of a missing directory is created by Docker, also as root. So
# ext/ is made here first, as whoever runs this, and -out names the output after the library
# rather than the module path, which put it in a root-owned ext/joelmoss/. xgo then hands
# the files to ext/'s owner. Without both, every Linux build failed with EACCES.
ext_path.mkpath
# -go pins the image's Go. Without it xgo pulls ghcr.io/techknowlogick/xgo:latest, which
# moves with every Go release, so the Linux gems could build with a different Go than the
# native ones, which setup-go takes from go.mod. Bump it with go.mod's go line. xgo appends
# the value to the image name as is, and the image tags are `go-<version>`: a bare 1.27.4
# names an image that does not exist.
sh %(xgo -go go-1.27.4 -env=GOWORK=off,GOFLAGS=#{goflags} -buildmode=c-shared ) +
%(-out=proscenium -dest="#{ext_dir}" -targets="#{go_platform}" .)
ext_path.glob("proscenium-#{goos}-*").each do |built|
built.rename ext_path.join(built.extname == '.h' ? 'proscenium.h' : 'proscenium')
end
raise "xgo left no library in #{ext_dir}" unless ext_path.join('proscenium').exist?
end
end
desc "Push built gem (#{ruby_platform})"
task "push:#{ruby_platform}" do
push_gem.call("pkg/proscenium-#{gemspec.version}-#{ruby_platform}.gem")
end
end
# Outside the loop above. Rake appends actions to an existing task rather than replacing it, so
# defining this once per platform gave it one action per platform - and `rake push` pushed the
# plain gem four times, prompting for an OTP on each and failing every attempt after the first.
desc 'Push built gem'
task 'push:gem' do
push_gem.call("pkg/proscenium-#{gemspec.version}.gem")
end
desc 'Clobber ext'
task 'clobber:ext' do
ext_path.rmtree
end
Rake::Task['clobber'].tap do |task|
task.enhance ['clobber:ext']
end