fun_ci 2.3.0 → 2.4.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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +6 -5
- data/lib/fun_ci/agent/status_text.rb +2 -2
- data/lib/fun_ci/cli.rb +2 -0
- data/lib/fun_ci/cli_command_help.rb +51 -0
- data/lib/fun_ci/cli_help.rb +9 -9
- data/lib/fun_ci/console/board_data.rb +7 -2
- data/lib/fun_ci/console/trunk_marks.rb +3 -1
- data/lib/fun_ci/evidence/about_stage.rb +1 -1
- data/lib/fun_ci/evidence/collector.rb +6 -3
- data/lib/fun_ci/evidence/outcome.rb +6 -3
- data/lib/fun_ci/jobs/locks.rb +1 -1
- data/lib/fun_ci/persistence/active_jobs.rb +2 -11
- data/lib/fun_ci/persistence/active_runs.rb +5 -3
- data/lib/fun_ci/persistence/died.rb +21 -0
- data/lib/fun_ci/persistence/job_recorder.rb +1 -0
- data/lib/fun_ci/persistence/null_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +11 -8
- data/lib/fun_ci/persistence/stage_job.rb +12 -0
- data/lib/fun_ci/pipeline/group_kill.rb +38 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +3 -1
- data/lib/fun_ci/pipeline/process_runner.rb +6 -1
- data/lib/fun_ci/pipeline/process_tree.rb +37 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +2 -1
- data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
- data/lib/fun_ci/pipeline/stage_end.rb +2 -1
- data/lib/fun_ci/pipeline/workspaces.rb +2 -1
- data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
- data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
- data/lib/fun_ci/pipeline/worktrees.rb +38 -7
- data/lib/fun_ci/setup/commands.rb +16 -7
- data/lib/fun_ci/setup/eslint_detection.rb +38 -0
- data/lib/fun_ci/setup/init_options.rb +34 -0
- data/lib/fun_ci/setup/installer.rb +41 -7
- data/lib/fun_ci/setup/placeholders.rb +30 -0
- data/lib/fun_ci/setup/project_config.rb +1 -0
- data/lib/fun_ci/setup/settings.rb +21 -1
- data/lib/fun_ci/setup/setup_checker.rb +5 -3
- data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
- data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
- data/lib/fun_ci/setup/stage_templates.rb +6 -4
- data/lib/fun_ci/setup/template_writer.rb +3 -5
- data/lib/fun_ci/version.rb +1 -1
- metadata +9 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# What to kill besides a stage's process groups: each process descended
|
|
6
|
+
# from one of their members, by parent id, that has left for a group or
|
|
7
|
+
# session of its own, as Node's detached children do. The rows must be
|
|
8
|
+
# read before the kill, since a killed parent's children move to init.
|
|
9
|
+
# A double-forked daemon has moved to init already and is not reached.
|
|
10
|
+
module ProcessTree
|
|
11
|
+
# Signal targets: -pid for an escapee leading its own group, which
|
|
12
|
+
# takes that group with it, and the pid alone of one in any other group.
|
|
13
|
+
def self.escaped(groups, rows)
|
|
14
|
+
escapees = outside(groups, descendants(in_groups(groups, rows), rows))
|
|
15
|
+
leaders = escapees.select { |row| row.pgid == row.pid }.map(&:pid)
|
|
16
|
+
leaders.map(&:-@) + escapees.reject { |row| leaders.include?(row.pgid) }.map(&:pid)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.in_groups(groups, rows) = rows.select { |row| groups.include?(row.pgid) }
|
|
20
|
+
def self.outside(groups, rows) = rows.reject { |row| groups.include?(row.pgid) }
|
|
21
|
+
|
|
22
|
+
# Bounded by the table's size, so a table whose parent ids form a cycle still ends.
|
|
23
|
+
def self.descendants(found, rows)
|
|
24
|
+
rows.size.times do
|
|
25
|
+
more = children(found.map(&:pid), rows) - found
|
|
26
|
+
return found if more.empty?
|
|
27
|
+
|
|
28
|
+
found += more
|
|
29
|
+
end
|
|
30
|
+
found
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.children(parents, rows) = rows.select { |row| parents.include?(row.ppid) }
|
|
34
|
+
private_class_method :in_groups, :outside, :descendants, :children
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -18,7 +18,8 @@ module FunCi
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def cold_slot(seconds)
|
|
21
|
-
@stdout.puts "fun-ci: a
|
|
21
|
+
@stdout.puts "fun-ci: a cold worktree (new, just cleaned, or unused for a day), so lint and build have " \
|
|
22
|
+
"#{seconds}s this once."
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def slow_launched
|
|
@@ -5,18 +5,24 @@ require_relative "../persistence/active_jobs"
|
|
|
5
5
|
require_relative "../persistence/run_status"
|
|
6
6
|
require_relative "../persistence/write_trouble"
|
|
7
7
|
require_relative "slot_lock"
|
|
8
|
+
require_relative "process_tree"
|
|
9
|
+
require_relative "../evidence/process_table"
|
|
8
10
|
|
|
9
11
|
module FunCi
|
|
10
12
|
module Pipeline
|
|
11
13
|
# Stops every process of a run. The run's own processes go first, so none
|
|
12
14
|
# of them records the run as failed once its stages die; then each stage's
|
|
13
|
-
# process group
|
|
15
|
+
# process group, and last what a stage started that left its group
|
|
16
|
+
# (ProcessTree). A run whose slot nobody holds any more has died already,
|
|
14
17
|
# and the pids it recorded may belong to other processes now, so it is
|
|
15
18
|
# left alone; so is this process.
|
|
16
19
|
class RunCanceller
|
|
17
|
-
|
|
20
|
+
# process_table: what ps lists now, as Evidence::ProcessTable rows.
|
|
21
|
+
def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?),
|
|
22
|
+
process_table: Evidence::ProcessTable.method(:now))
|
|
18
23
|
@killer = killer
|
|
19
24
|
@slot_held = slot_held
|
|
25
|
+
@process_table = process_table
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
# Stops the run and records it, and its unfinished stages, cancelled.
|
|
@@ -60,11 +66,17 @@ module FunCi
|
|
|
60
66
|
def stop(run)
|
|
61
67
|
return unless alive?(run)
|
|
62
68
|
|
|
63
|
-
(run.processes - [Process.pid] + run.stage_groups.map(&:-@)).each { |pid| kill(pid) }
|
|
69
|
+
(run.processes - [Process.pid] + run.stage_groups.map(&:-@) + escaped(run)).each { |pid| kill(pid) }
|
|
64
70
|
end
|
|
65
71
|
|
|
66
72
|
private
|
|
67
73
|
|
|
74
|
+
def escaped(run)
|
|
75
|
+
return [] if run.stage_groups.empty?
|
|
76
|
+
|
|
77
|
+
ProcessTree.escaped(run.stage_groups, @process_table.call)
|
|
78
|
+
end
|
|
79
|
+
|
|
68
80
|
# A stage its process started after `run` was read. A run's process
|
|
69
81
|
# records a stage's group before it lets the stage start, so once those
|
|
70
82
|
# processes are dead, looking again finds every stage they started.
|
|
@@ -58,7 +58,8 @@ module FunCi
|
|
|
58
58
|
status = finished.status
|
|
59
59
|
Evidence::Outcome.new(state: STATES.fetch(outcome(finished)), exit_status: status&.exitstatus,
|
|
60
60
|
signal: status&.termsig && Signal.signame(status.termsig),
|
|
61
|
-
alongside: @recorder.alongside(@job_id),
|
|
61
|
+
alongside: @recorder.alongside(@job_id),
|
|
62
|
+
elsewhere: @recorder.alongside_elsewhere(@job_id), overrun: finished.overrun)
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def keep_exit(status)
|
|
@@ -15,7 +15,8 @@ module FunCi
|
|
|
15
15
|
def self.for(project, sha)
|
|
16
16
|
return InPlace.new(project) if sha == NULL_SHA
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
config = Setup::ProjectConfig.new(project)
|
|
19
|
+
WorktreePool.new(Worktrees.new(project), size: config.worktree_slots, clean: config.clean)
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -7,23 +7,33 @@ require_relative "worktrees"
|
|
|
7
7
|
module FunCi
|
|
8
8
|
module Pipeline
|
|
9
9
|
# Hands each pipeline a worktree slot of its own under
|
|
10
|
-
#
|
|
10
|
+
# Worktrees#root, checked out at its commit, waiting
|
|
11
11
|
# while every slot is taken.
|
|
12
12
|
class WorktreePool
|
|
13
13
|
DEFAULT_SIZE = 2
|
|
14
|
+
# A slot nobody took for longer is cold: its caches are as warm as a
|
|
15
|
+
# run long ago left them, as a slot taken only when runs overlap is.
|
|
16
|
+
IDLE_SECONDS = 86_400
|
|
14
17
|
|
|
15
18
|
attr_reader :size
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
# Whether a slot last taken at `last_taken` has gone untaken too long.
|
|
21
|
+
def self.idle?(last_taken, now:) = now - last_taken > IDLE_SECONDS
|
|
22
|
+
|
|
23
|
+
# clean: the paths whose ignored files a slot loses when it moves off its
|
|
24
|
+
# line of history (Worktrees#check_out).
|
|
25
|
+
def initialize(worktrees, size: DEFAULT_SIZE, clean: [], waiter: -> { sleep 0.25 })
|
|
18
26
|
@worktrees = worktrees
|
|
19
27
|
@size = size
|
|
28
|
+
@clean = clean
|
|
20
29
|
@waiter = waiter
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
# A ColdSlot when the slot's worktree had to be made
|
|
32
|
+
# A ColdSlot when the slot's worktree had to be made, or was cleaned, or
|
|
33
|
+
# nobody took the slot for a day.
|
|
24
34
|
def acquire(sha)
|
|
25
35
|
slot = wait_for_free_slot
|
|
26
|
-
@worktrees.check_out(slot.path, sha) ? slot.as_cold : slot
|
|
36
|
+
@worktrees.check_out(slot.path, sha, clean: @clean) ? slot.as_cold : slot
|
|
27
37
|
rescue StandardError
|
|
28
38
|
slot&.release
|
|
29
39
|
raise
|
|
@@ -49,12 +59,19 @@ module FunCi
|
|
|
49
59
|
# The lock stays open for as long as the slot is held, so no block.
|
|
50
60
|
def try_lock(path)
|
|
51
61
|
lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
|
|
52
|
-
return
|
|
62
|
+
return taken(path, lock) if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
53
63
|
|
|
54
64
|
lock.close
|
|
55
65
|
nil
|
|
56
66
|
end
|
|
57
67
|
|
|
68
|
+
# Each taker stamps the lock, so its last change is when the slot was last taken.
|
|
69
|
+
def taken(path, lock)
|
|
70
|
+
idle = WorktreePool.idle?(lock.mtime, now: Time.now)
|
|
71
|
+
slot = Slot.new(path, stamped(lock), lock.path)
|
|
72
|
+
idle ? slot.as_cold : slot
|
|
73
|
+
end
|
|
74
|
+
|
|
58
75
|
def stamped(lock)
|
|
59
76
|
lock.truncate(0)
|
|
60
77
|
lock.write(Process.pid.to_s)
|
|
@@ -6,8 +6,8 @@ require_relative "worktrees"
|
|
|
6
6
|
module FunCi
|
|
7
7
|
module Pipeline
|
|
8
8
|
# Removes every slot of a project's worktree pool and every daily or
|
|
9
|
-
# weekly job's worktree,
|
|
10
|
-
# holds one. It takes each slot's lock first, so a run
|
|
9
|
+
# weekly job's worktree, those an older fun-ci kept under the git
|
|
10
|
+
# directory too, and git's entries for them, when no run or job holds one. It takes each slot's lock first, so a run
|
|
11
11
|
# starting meanwhile waits until the slot is gone and then checks it out
|
|
12
12
|
# afresh.
|
|
13
13
|
class WorktreePrune
|
|
@@ -43,8 +43,13 @@ module FunCi
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def slot_paths
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
patterns.flat_map { |pattern| Dir.glob(pattern) }.map { |path| path.delete_suffix(".lock") }.uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def patterns
|
|
50
|
+
legacy = @worktrees.legacy_dir
|
|
51
|
+
[File.join(@worktrees.root, "slot-*"), File.join(@worktrees.jobs_root, "*"),
|
|
52
|
+
File.join(legacy, "worktrees", "slot-*"), File.join(legacy, "jobs", "*")]
|
|
48
53
|
end
|
|
49
54
|
end
|
|
50
55
|
end
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
+
require "digest"
|
|
4
5
|
require_relative "git_environment"
|
|
6
|
+
require_relative "../persistence/state_dir"
|
|
5
7
|
|
|
6
8
|
module FunCi
|
|
7
9
|
module Pipeline
|
|
8
|
-
# The git commands a worktree slot needs, run against the project's
|
|
10
|
+
# The git commands a worktree slot needs, run against the project's
|
|
11
|
+
# repository. The worktrees live in fun-ci's state directory, one place
|
|
12
|
+
# per repository (all its git worktrees share it), and not under .git/,
|
|
13
|
+
# which tools such as Vite refuse to serve files from.
|
|
9
14
|
class Worktrees
|
|
10
15
|
class GitError < StandardError; end
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
# state_dir: fun-ci's own (Persistence::StateDir).
|
|
18
|
+
def initialize(project_root, state_dir: Persistence::StateDir.path(ENV))
|
|
13
19
|
@project_root = project_root
|
|
20
|
+
@state_dir = state_dir
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
def root = File.join(fun_ci_dir, "worktrees")
|
|
@@ -18,15 +25,25 @@ module FunCi
|
|
|
18
25
|
# Where each daily and weekly job has a worktree of its own (Jobs::Locks).
|
|
19
26
|
def jobs_root = File.join(fun_ci_dir, "jobs")
|
|
20
27
|
|
|
28
|
+
# Where fun-ci before 2.4 kept the worktrees: under the git directory.
|
|
29
|
+
def legacy_dir = File.join(common_dir, "fun-ci")
|
|
30
|
+
|
|
21
31
|
# Leaves +path+ as a clean detached checkout of +sha+. Ignored files stay,
|
|
22
|
-
# so caches such as vendor/bundle survive between runs in the same slot
|
|
23
|
-
#
|
|
24
|
-
|
|
32
|
+
# so caches such as vendor/bundle survive between runs in the same slot,
|
|
33
|
+
# except under the +clean+ paths when +sha+ doesn't descend from the
|
|
34
|
+
# slot's last commit: those are outputs only as right as the commit that
|
|
35
|
+
# made them. Answers whether the slot is cold: a worktree it made, which
|
|
36
|
+
# has no caches, or one it cleaned.
|
|
37
|
+
def check_out(path, sha, clean: [])
|
|
25
38
|
return add(path, sha).then { true } unless File.exist?(File.join(path, ".git"))
|
|
26
39
|
|
|
40
|
+
moved_off = clean.any? && !descends?(path, sha)
|
|
27
41
|
git(path, "checkout", "--detach", "--force", sha)
|
|
28
42
|
git(path, "clean", "-fd")
|
|
29
|
-
false
|
|
43
|
+
return false unless moved_off
|
|
44
|
+
|
|
45
|
+
git(path, "clean", "-fdX", "--", *clean)
|
|
46
|
+
true
|
|
30
47
|
end
|
|
31
48
|
|
|
32
49
|
# Has git forget the worktrees whose directories are gone.
|
|
@@ -34,8 +51,22 @@ module FunCi
|
|
|
34
51
|
|
|
35
52
|
private
|
|
36
53
|
|
|
54
|
+
# <state>/projects/<repository's name>-<digest of its git directory>.
|
|
37
55
|
def fun_ci_dir
|
|
38
|
-
|
|
56
|
+
common = common_dir
|
|
57
|
+
name = File.basename(File.dirname(common))
|
|
58
|
+
File.join(@state_dir, "projects", "#{name}-#{Digest::SHA256.hexdigest(common)[0, 12]}")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def common_dir
|
|
62
|
+
File.realpath(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether +sha+ descends from the commit checked out at +path+.
|
|
66
|
+
def descends?(path, sha)
|
|
67
|
+
last = git(path, "rev-parse", "HEAD").strip
|
|
68
|
+
_, status = Open3.capture2e(GitEnvironment::CLEAN, "git", "merge-base", "--is-ancestor", last, sha, chdir: path)
|
|
69
|
+
status.success?
|
|
39
70
|
end
|
|
40
71
|
|
|
41
72
|
def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
|
|
@@ -5,6 +5,7 @@ require_relative "hook_writer"
|
|
|
5
5
|
require_relative "git_hooks"
|
|
6
6
|
require_relative "setup_checker"
|
|
7
7
|
require_relative "legacy_hooks"
|
|
8
|
+
require_relative "init_options"
|
|
8
9
|
|
|
9
10
|
module FunCi
|
|
10
11
|
module Setup
|
|
@@ -18,13 +19,14 @@ module FunCi
|
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def init(args)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
options = InitOptions.parse(args)
|
|
23
|
+
code = Installer.run(project_root: @project_root, stdout: @stdout, stack: options.stack)
|
|
24
|
+
return code if !code.zero? || !options.everything
|
|
25
|
+
|
|
26
|
+
hooks_then_check
|
|
27
|
+
rescue InitOptions::Invalid => e
|
|
28
|
+
@stdout.puts "fun-ci init: #{e.message}"
|
|
29
|
+
64
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def install_hooks(args)
|
|
@@ -38,6 +40,13 @@ module FunCi
|
|
|
38
40
|
|
|
39
41
|
private
|
|
40
42
|
|
|
43
|
+
def hooks_then_check
|
|
44
|
+
code = install_hooks([])
|
|
45
|
+
return code unless code.zero?
|
|
46
|
+
|
|
47
|
+
check([])
|
|
48
|
+
end
|
|
49
|
+
|
|
41
50
|
def install(types)
|
|
42
51
|
hooks_dir = @hooks_dir.call(@project_root)
|
|
43
52
|
types.each do |type|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "stage_templates"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Setup
|
|
8
|
+
# The build init writes for a JavaScript project that configures ESLint:
|
|
9
|
+
# the template's install, then ESLint, which loads what the install put
|
|
10
|
+
# in place, so it can't run in lint beside it (StageTemplates). nil keeps
|
|
11
|
+
# the template's build.
|
|
12
|
+
module EslintDetection
|
|
13
|
+
RUNNERS = { node_npm: "npx eslint .", node_pnpm: "pnpm exec eslint .", node_yarn: "yarn eslint .",
|
|
14
|
+
bun: "bunx eslint ." }.freeze
|
|
15
|
+
CONFIGS = %w[eslint.config.js eslint.config.mjs eslint.config.cjs eslint.config.ts eslint.config.mts
|
|
16
|
+
eslint.config.cts .eslintrc .eslintrc.js .eslintrc.cjs .eslintrc.json .eslintrc.yml
|
|
17
|
+
.eslintrc.yaml].freeze
|
|
18
|
+
|
|
19
|
+
# names: the files at the project's top; package_json: its text.
|
|
20
|
+
def self.build(stack, names, package_json)
|
|
21
|
+
runner = RUNNERS[stack]
|
|
22
|
+
return nil unless runner && configured?(names, package_json)
|
|
23
|
+
|
|
24
|
+
"#{StageTemplates.scripts(stack)["build.sh"].lines.last.chomp} && #{runner}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.configured?(names, package_json) = names.intersect?(CONFIGS) || in_package?(package_json)
|
|
28
|
+
|
|
29
|
+
def self.in_package?(text)
|
|
30
|
+
parsed = JSON.parse(text)
|
|
31
|
+
parsed.is_a?(Hash) && parsed.key?("eslintConfig")
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
private_class_method :configured?, :in_package?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Setup
|
|
7
|
+
# The arguments of `fun-ci init [--everything] [--stack NAME]`.
|
|
8
|
+
module InitOptions
|
|
9
|
+
class Invalid < StandardError; end
|
|
10
|
+
|
|
11
|
+
Parsed = Data.define(:everything, :stack)
|
|
12
|
+
|
|
13
|
+
def self.parse(args)
|
|
14
|
+
values = { everything: false, stack: nil }
|
|
15
|
+
left = parser(values).parse(args)
|
|
16
|
+
raise Invalid, "takes no #{left.join(" ")}" if left.any?
|
|
17
|
+
|
|
18
|
+
Parsed.new(**values)
|
|
19
|
+
rescue OptionParser::ParseError => e
|
|
20
|
+
raise Invalid, e.message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Long options only, without OptionParser's own --help and --version.
|
|
24
|
+
def self.parser(values)
|
|
25
|
+
parser = OptionParser.new
|
|
26
|
+
parser.base.long.clear
|
|
27
|
+
parser.on("--everything") { values[:everything] = true }
|
|
28
|
+
parser.on("--stack NAME") { |name| values[:stack] = name }
|
|
29
|
+
parser
|
|
30
|
+
end
|
|
31
|
+
private_class_method :parser
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "project_detector"
|
|
4
4
|
require_relative "template_writer"
|
|
5
5
|
require_relative "lint_detection"
|
|
6
|
+
require_relative "eslint_detection"
|
|
7
|
+
require_relative "stage_templates"
|
|
6
8
|
require_relative "agent_instructions"
|
|
7
9
|
|
|
8
10
|
module FunCi
|
|
@@ -14,13 +16,18 @@ module FunCi
|
|
|
14
16
|
"Then fast.sh runs beside slow.sh, both on what build.sh built: have build.sh " \
|
|
15
17
|
"compile everything they need, test code included."].freeze
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
PLACEHOLDERS = "Could not detect the project type, so .fun-ci/ has placeholder scripts that pass and " \
|
|
20
|
+
"test nothing: put the project's own commands in them, or start again with --stack NAME."
|
|
21
|
+
|
|
22
|
+
# stack: a name of ProjectDetector::STACKS to write in place of the one detected.
|
|
23
|
+
def self.run(project_root:, stdout: $stdout, stack: nil)
|
|
24
|
+
new(project_root: project_root, stdout: stdout, stack: stack).run
|
|
19
25
|
end
|
|
20
26
|
|
|
21
|
-
def initialize(project_root:, stdout:)
|
|
27
|
+
def initialize(project_root:, stdout:, stack:)
|
|
22
28
|
@project_root = project_root
|
|
23
29
|
@stdout = stdout
|
|
30
|
+
@stack = stack
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
# A project set up for fun-ci, new or not, also tells agents what to do.
|
|
@@ -35,10 +42,26 @@ module FunCi
|
|
|
35
42
|
def set_up
|
|
36
43
|
return report(".fun-ci/ already exists, so init left it as it is.", 0) if initialised?
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
return
|
|
45
|
+
stack = chosen_stack
|
|
46
|
+
return unknown_stack if stack.nil?
|
|
47
|
+
return write_placeholders if stack == :unknown
|
|
48
|
+
|
|
49
|
+
write_templates(stack)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def chosen_stack
|
|
53
|
+
return ProjectDetector.new(Dir.children(@project_root)).detect if @stack.nil?
|
|
54
|
+
|
|
55
|
+
ProjectDetector::STACKS.keys.find { |stack| stack.to_s == @stack }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unknown_stack
|
|
59
|
+
report("fun-ci init: no stack named #{@stack}; the stacks are #{ProjectDetector::STACKS.keys.join(", ")}", 64)
|
|
60
|
+
end
|
|
40
61
|
|
|
41
|
-
|
|
62
|
+
def write_placeholders
|
|
63
|
+
TemplateWriter.new(StageTemplates::PLACEHOLDERS, @project_root).write
|
|
64
|
+
report(PLACEHOLDERS, 0)
|
|
42
65
|
end
|
|
43
66
|
|
|
44
67
|
def tell_agents
|
|
@@ -58,11 +81,22 @@ module FunCi
|
|
|
58
81
|
|
|
59
82
|
def write_templates(detected)
|
|
60
83
|
@stdout.puts "Detected: #{detected.to_s.tr("_", " ")}"
|
|
61
|
-
TemplateWriter.new(
|
|
84
|
+
TemplateWriter.new(scripts_for(detected), @project_root).write
|
|
62
85
|
@stdout.puts "Created .fun-ci/ with template scripts.", *SIDE_BY_SIDE
|
|
63
86
|
0
|
|
64
87
|
end
|
|
65
88
|
|
|
89
|
+
def scripts_for(detected)
|
|
90
|
+
StageTemplates.scripts(detected, lint_override: LintDetection.command(detected, @project_root),
|
|
91
|
+
build_override: EslintDetection.build(detected, Dir.children(@project_root),
|
|
92
|
+
package_json))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def package_json
|
|
96
|
+
path = File.join(@project_root, "package.json")
|
|
97
|
+
File.exist?(path) ? File.read(path) : "{}"
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
def report(message, exit_code)
|
|
67
101
|
@stdout.puts message
|
|
68
102
|
exit_code
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stage_templates"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Setup
|
|
7
|
+
# The stage scripts in .fun-ci/ that are still init's placeholders, as
|
|
8
|
+
# `fun-ci check`'s warnings.
|
|
9
|
+
class Placeholders
|
|
10
|
+
STAGES = %w[lint build fast slow].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(project_root)
|
|
13
|
+
@dir = File.join(project_root, ".fun-ci")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def warnings = STAGES.select { |stage| placeholder?(stage) }.map { |stage| warning(stage) }
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def placeholder?(stage)
|
|
21
|
+
path = File.join(@dir, "#{stage}.sh")
|
|
22
|
+
File.file?(path) && File.read(path).include?(StageTemplates::MARKER)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def warning(stage)
|
|
26
|
+
".fun-ci/#{stage}.sh is init's placeholder, so the #{stage} stage tests nothing: put the project's own in it"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -49,6 +49,7 @@ module FunCi
|
|
|
49
49
|
def job_errors = Jobs::Folders.new(@project_root).errors
|
|
50
50
|
|
|
51
51
|
def worktree_slots = settings.worktree_slots
|
|
52
|
+
def clean = settings.clean
|
|
52
53
|
def trunk = settings.trunk
|
|
53
54
|
def trunk_fetch = settings.trunk_fetch
|
|
54
55
|
def job_spacing = settings.job_spacing
|
|
@@ -43,6 +43,11 @@ module FunCi
|
|
|
43
43
|
# and the jobs share the day evenly (Jobs::DueJobs).
|
|
44
44
|
def job_spacing = seconds(setting("job_spacing"))
|
|
45
45
|
|
|
46
|
+
# The paths, inside the project, whose git-ignored files a slot loses
|
|
47
|
+
# when it moves off its line of history (Pipeline::Worktrees); none
|
|
48
|
+
# when none are given, or what is given is no such list.
|
|
49
|
+
def clean = cleaned || []
|
|
50
|
+
|
|
46
51
|
def errors
|
|
47
52
|
setting_errors
|
|
48
53
|
rescue Psych::SyntaxError => e
|
|
@@ -58,7 +63,7 @@ module FunCi
|
|
|
58
63
|
def setting_errors
|
|
59
64
|
return [".fun-ci/config must be a mapping such as `worktree_slots: 2`"] unless raw.is_a?(Hash)
|
|
60
65
|
|
|
61
|
-
[slots_error, spacing_error].compact
|
|
66
|
+
[slots_error, spacing_error, clean_error].compact
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
# The slots the file asks for, when that is a whole number above 0.
|
|
@@ -80,6 +85,21 @@ module FunCi
|
|
|
80
85
|
".fun-ci/config: job_spacing must be seconds, or a number with s, m or h, not #{given.inspect}"
|
|
81
86
|
end
|
|
82
87
|
|
|
88
|
+
def cleaned
|
|
89
|
+
given = setting("clean")
|
|
90
|
+
given if given.is_a?(Array) && given.all? { |path| inside?(path) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inside?(path) = path.is_a?(String) && !path.start_with?("/") && !path.split("/").include?("..")
|
|
94
|
+
|
|
95
|
+
def clean_error
|
|
96
|
+
given = setting("clean")
|
|
97
|
+
return nil if given.nil? || cleaned
|
|
98
|
+
|
|
99
|
+
".fun-ci/config: clean must be a list of paths inside the project, such as [public/assets], " \
|
|
100
|
+
"not #{given.inspect}"
|
|
101
|
+
end
|
|
102
|
+
|
|
83
103
|
def setting(key)
|
|
84
104
|
raw.is_a?(Hash) ? raw[key] : nil
|
|
85
105
|
rescue Psych::Exception
|
|
@@ -4,19 +4,21 @@ require_relative "project_config"
|
|
|
4
4
|
require_relative "legacy_hooks"
|
|
5
5
|
require_relative "installed_hooks"
|
|
6
6
|
require_relative "trunk_report"
|
|
7
|
+
require_relative "placeholders"
|
|
7
8
|
|
|
8
9
|
module FunCi
|
|
9
10
|
module Setup
|
|
10
11
|
class SetupChecker
|
|
11
|
-
#
|
|
12
|
-
#
|
|
12
|
+
# What it warns of: fun-ci 1.x's hooks, any in `hooks_dir` that doesn't
|
|
13
|
+
# run fun-ci, and stage scripts still init's placeholders.
|
|
13
14
|
Hooks = Data.define(:sources) do
|
|
14
15
|
def warnings = sources.flat_map(&:warnings)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# hooks_dir: where git runs the project's hooks from; nil outside a git repository.
|
|
18
19
|
def self.run(project_root:, stdout: $stdout, hooks_dir: nil)
|
|
19
|
-
|
|
20
|
+
sources = [LegacyHooks.new(project_root), InstalledHooks.new(hooks_dir), Placeholders.new(project_root)]
|
|
21
|
+
hooks = Hooks.new(sources)
|
|
20
22
|
new(config: ProjectConfig.new(project_root), hooks: hooks, stdout: stdout,
|
|
21
23
|
trunk: TrunkReport.new(project_root)).run
|
|
22
24
|
end
|
|
@@ -5,13 +5,23 @@ require_relative "shell"
|
|
|
5
5
|
module FunCi
|
|
6
6
|
module Setup
|
|
7
7
|
# JavaScript and TypeScript: Deno, Bun, and Node with its package managers.
|
|
8
|
+
# Lint runs beside the build, which installs the packages, so it checks
|
|
9
|
+
# the source alone: a linter that loads them, ESLint, goes in the build
|
|
10
|
+
# after the install (EslintDetection). Deno's lint needs no packages.
|
|
8
11
|
module StageTemplates
|
|
12
|
+
SOURCES = "find . -name node_modules -prune -o -name .git -prune -o -type f"
|
|
13
|
+
NODE_CHECK = "#{SOURCES} \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \\) -print | " \
|
|
14
|
+
'while read -r file; do node --check "$file" || exit 1; done'.freeze
|
|
15
|
+
BUN_CHECK = "#{SOURCES} \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.ts' " \
|
|
16
|
+
"-o -name '*.tsx' \\) -print | " \
|
|
17
|
+
'while read -r file; do bun build --no-bundle "$file" > /dev/null || exit 1; done'.freeze
|
|
18
|
+
|
|
9
19
|
JAVASCRIPT = {
|
|
10
20
|
deno: stages("deno lint", "deno check", "deno test", "deno task test:slow"),
|
|
11
|
-
bun: stages(
|
|
12
|
-
node_pnpm: stages(
|
|
13
|
-
node_yarn: stages(
|
|
14
|
-
node_npm: stages(
|
|
21
|
+
bun: stages(BUN_CHECK, "bun install", "bun test", "bun run test:slow"),
|
|
22
|
+
node_pnpm: stages(NODE_CHECK, "pnpm install", "pnpm test", "pnpm run test:slow"),
|
|
23
|
+
node_yarn: stages(NODE_CHECK, "yarn install", "yarn test", "yarn run test:slow"),
|
|
24
|
+
node_npm: stages(NODE_CHECK, "npm install", "npm test", "npm run test:slow")
|
|
15
25
|
}.freeze
|
|
16
26
|
end
|
|
17
27
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shell"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Setup
|
|
7
|
+
# The scripts `fun-ci init` writes where it knows no stack: each passes,
|
|
8
|
+
# says it tests nothing, and carries MARKER, by which `fun-ci check` warns
|
|
9
|
+
# of it until the project's own command takes its place.
|
|
10
|
+
module StageTemplates
|
|
11
|
+
MARKER = "# fun-ci placeholder"
|
|
12
|
+
|
|
13
|
+
def self.placeholder(stage)
|
|
14
|
+
"#{MARKER}: init knew no stack here, so put the project's #{stage} in place of this.\n" \
|
|
15
|
+
"echo \"fun-ci: .fun-ci/#{stage}.sh is init's placeholder and tests nothing\""
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
PLACEHOLDERS = stages(*%w[lint build fast slow].map { |stage| placeholder(stage) }).freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|