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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb6518d38e67aecd470fe2c65227af6467e70cf60ef26ece496d0bb91301e80f
|
|
4
|
+
data.tar.gz: a57fc48c3b2a1799baabf0f4477da9085c01d5406aa2762df8f5af603d49f203
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae8a1ce41d3fbb54a807509a001c9ffe72020fe586973dc74cd4b643708938920ec1cd920266c4a52c0e62c1a7f6d1e6e2c40e181b50d4b0de6925e94e65810b
|
|
7
|
+
data.tar.gz: ebfc96602d16d8c89101e1dc016537366a3055b7d16aca410c9e1a3700126a59cc6015143e39559ea75935e00403f614c71eb11e34738fcc5c72a13e78af3e8c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.0] - 2026-10-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `fun-ci COMMAND --help` (or `-h`) prints that command's usage and the
|
|
14
|
+
options it takes. Before, `init`, `check`, `prune` and `console` ignored
|
|
15
|
+
the flag and went ahead, `install-hooks` took it for a hook type, and the
|
|
16
|
+
agent commands called it an invalid option.
|
|
17
|
+
- `fun-ci init --stack NAME` writes the scripts for the stack you name
|
|
18
|
+
(`go`, `node_npm`, ...) instead of the one it detects.
|
|
19
|
+
- In a project whose stack `fun-ci init` doesn't recognise, such as a new
|
|
20
|
+
one with no code yet, it writes four placeholder scripts that pass, instead
|
|
21
|
+
of stopping with nothing written. `fun-ci check` warns about each one still
|
|
22
|
+
in place, since that stage tests nothing.
|
|
23
|
+
- `clean: [public/assets, dist]` in `.fun-ci/config` names build outputs that
|
|
24
|
+
git ignores and that must not carry over from another branch's run. When a
|
|
25
|
+
worktree moves to a commit that doesn't descend from the one it last
|
|
26
|
+
tested, fun-ci removes the ignored files under those paths first, and that
|
|
27
|
+
run's lint and build get the longer budget a new worktree gets. Other
|
|
28
|
+
ignored files, such as `node_modules`, stay. `fun-ci check` names a `clean`
|
|
29
|
+
it can't use.
|
|
30
|
+
- A failed stage's evidence names the stages of the project's other runs
|
|
31
|
+
that ran at the same time, as `other runs alongside: 7ed514c's slow` in
|
|
32
|
+
`fun-ci why`, so a fast suite over its budget beside another commit's slow
|
|
33
|
+
suite says so.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- fun-ci's worktrees, the runs' slots and the jobs', move out of `.git/` into
|
|
37
|
+
fun-ci's state directory (`~/.local/state/fun-ci/projects/`). Under
|
|
38
|
+
`.git/`, Vite's default `fs.deny` refused to serve test files, so a Vitest
|
|
39
|
+
suite with a browser-like environment failed in fun-ci and nowhere else.
|
|
40
|
+
The first run after upgrading makes new worktrees, with the longer budget
|
|
41
|
+
a new worktree gets; `fun-ci prune` removes the old ones from `.git/fun-ci/`.
|
|
42
|
+
- For Node, pnpm, Yarn and Bun projects, `fun-ci init` writes a `lint.sh`
|
|
43
|
+
that checks the syntax of the source without the packages (`node --check`
|
|
44
|
+
on `.js`, `.mjs` and `.cjs` files; `bun build --no-bundle`, TypeScript
|
|
45
|
+
included, for Bun), and runs ESLint in `build.sh` after the install, when
|
|
46
|
+
the project configures ESLint. ESLint in `lint.sh` ran beside the install:
|
|
47
|
+
in a new worktree it ran a registry ESLint and failed on a plugin it
|
|
48
|
+
couldn't find, and in a project without ESLint it always failed. A
|
|
49
|
+
TypeScript project on Node gets no check of its `.ts` files in `lint.sh`;
|
|
50
|
+
ESLint or `tsc` in `build.sh` covers them.
|
|
51
|
+
- A worktree slot nobody took for a day counts as cold, as a new one does, so
|
|
52
|
+
lint and build get the longer budget there once. A second slot, used only
|
|
53
|
+
when runs overlap, kept caches days old and ran a build over its budget.
|
|
54
|
+
`fun-ci status` now says `(cold worktree, budget 300s)`, not `new worktree`,
|
|
55
|
+
and a run in the foreground says `a cold worktree (new, just cleaned, or
|
|
56
|
+
unused for a day)`.
|
|
57
|
+
- The console holds at most the twenty branches whose newest run changed
|
|
58
|
+
last, before putting them in order, so a failure on a branch nobody has
|
|
59
|
+
touched since leaves the board once twenty others have moved on, instead
|
|
60
|
+
of staying at the top for good.
|
|
61
|
+
- `fun-ci init` refuses an option or argument it doesn't take (exit 64)
|
|
62
|
+
instead of ignoring it, so a misspelt `--everything` no longer sets up
|
|
63
|
+
only half.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
- `fun-ci console` no longer dies with a `TypeError` while a project's first
|
|
67
|
+
fetch of its trunk is under way.
|
|
68
|
+
- A run the post-commit hook starts leads a session of its own, so Ctrl-C in
|
|
69
|
+
the terminal that committed, closing it, or an agent harness ending the
|
|
70
|
+
`git commit` command no longer kills the run's slow suite.
|
|
71
|
+
- A slow suite whose process died is no longer shown as a plain failure:
|
|
72
|
+
`fun-ci why` says its process stopped before it said how the stage ended,
|
|
73
|
+
as it already did for a job.
|
|
74
|
+
- Cancelling a run, or killing a stage over its budget, also stops what the
|
|
75
|
+
stage started that moved to a process group or session of its own, as
|
|
76
|
+
Node's detached children do: a superseded run left Playwright's web server
|
|
77
|
+
holding its port, and the next run failed on it. A child forked just as
|
|
78
|
+
the group was killed is killed too.
|
|
79
|
+
|
|
10
80
|
## [2.3.0] - 2026-10-02
|
|
11
81
|
|
|
12
82
|
### Added
|
data/README.md
CHANGED
|
@@ -23,7 +23,7 @@ fun-ci init --everything
|
|
|
23
23
|
|
|
24
24
|
This detects your project type, writes four stage scripts into `.fun-ci/`, installs a `post-commit` and a `pre-push` git hook, and checks the setup. It also adds a short section to your `AGENTS.md` (or `CLAUDE.md`, if that is the only one) telling coding agents what to do after a commit; run again after an upgrade, it brings that section up to date.
|
|
25
25
|
|
|
26
|
-
`fun-ci init` has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. [docs/stacks.md](docs/stacks.md) shows how it recognises each one and the commands it writes. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.
|
|
26
|
+
`fun-ci init` has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. [docs/stacks.md](docs/stacks.md) shows how it recognises each one and the commands it writes; `--stack go` picks one by name. In a project it doesn't recognise, such as a new one with no code yet, it writes placeholder scripts that pass, and `fun-ci check` warns about each one until you replace it. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.
|
|
27
27
|
|
|
28
28
|
- `lint.sh`: linter and static analysis
|
|
29
29
|
- `build.sh`: compile or build
|
|
@@ -51,7 +51,7 @@ After each commit, the `post-commit` hook starts the pipeline in the background
|
|
|
51
51
|
|
|
52
52
|
`install-hooks` leaves a hook another tool wrote alone, and `fun-ci check` warns that no commit is tested until that hook calls fun-ci. Git LFS's own hooks are the exception: fun-ci writes its hooks in their place, and they run git-lfs's after fun-ci's, so LFS objects still reach the remote. After that, `git lfs install` says the hooks exist and exits 2, since it accepts no hook but its own; there is nothing for it to do. `git lfs update --force` would put git-lfs's back, leaving fun-ci out, until you run `fun-ci install-hooks` again.
|
|
53
53
|
|
|
54
|
-
Each run happens in a git worktree of its own
|
|
54
|
+
Each run happens in a git worktree of its own in fun-ci's state directory (`~/.local/state/fun-ci/projects/`, outside `.git/`, which tools such as Vite won't serve files from), checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as `vendor/bundle` or `node_modules` stay in a worktree from one run to the next, which keeps builds quick. A worktree fun-ci has just made has none of them yet, and one nobody used for a day has them as they were then, so in such a run lint and build get the slow suite's five minutes rather than 30 seconds, and `fun-ci status` says so on their lines. A run's build outputs that git ignores stay too, and are only as right as the commit that made them, so a worktree that last tested another branch can hand a run outputs its commit wouldn't make. List those outputs in `.fun-ci/config`, such as `clean: [public/assets, dist]`, and fun-ci removes their ignored files when a worktree moves to a commit that doesn't descend from the one it last tested; that run's lint and build get the longer budget too. Two runs can go at once; set `worktree_slots: 3` in `.fun-ci/config` for more, and `fun-ci prune` removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it without `--follow-branch`.
|
|
55
55
|
|
|
56
56
|
Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-ci/` by default), shared by every project on the machine.
|
|
57
57
|
|
|
@@ -68,7 +68,7 @@ The script's name is the job's name. Like a stage, it gets the commit's hash as
|
|
|
68
68
|
|
|
69
69
|
Commits start jobs. When the `post-commit` hook runs a pipeline, it also starts the project's due jobs on the commit just made: a job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started, so a project nobody commits to runs none. The jobs take turns, spread evenly over the day: with four jobs, the first starts at once and each next one six hours later. `job_spacing: 30m` in `.fun-ci/config` sets the gap instead, and `0` starts them together. A job waiting its turn says when it starts.
|
|
70
70
|
|
|
71
|
-
Each job runs once at a time, in a worktree of its own
|
|
71
|
+
Each job runs once at a time, in a worktree of its own beside them, at a lower priority than the stages, so it takes only the cores they leave idle and never holds up a commit's run. It has 24 hours. A newer commit never cancels a job, and a job's result changes no run, no streak and no exit code. The [design](docs/design.md#daily-and-weekly-jobs) has the rest of the rules.
|
|
72
72
|
|
|
73
73
|
A failed job keeps its evidence as a failed stage does; add entries under `evidence: jobs: <name>:` in `.fun-ci/config` to keep more. `fun-ci check` lists the jobs it found, and says which scripts it can't run.
|
|
74
74
|
|
|
@@ -80,7 +80,7 @@ A failed job keeps its evidence as a failed stage does; add entries under `evide
|
|
|
80
80
|
fun-ci console
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (`the fast suite failed after 1.4s`) and when. What needs you comes first, in one deep block; passed rows stay pale. Above them is a picture that tells you what is happening without reading a word.
|
|
83
|
+
The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (`the fast suite failed after 1.4s`) and when. What needs you comes first, in one deep block; passed rows stay pale. It holds the twenty branches whose newest run changed last, so a failure on a branch nobody touches any more leaves it in time. Above them is a picture that tells you what is happening without reading a word.
|
|
84
84
|
|
|
85
85
|

|
|
86
86
|
|
|
@@ -246,6 +246,7 @@ Secrets in the stage's environment are masked before anything is kept: the value
|
|
|
246
246
|
```
|
|
247
247
|
fun-ci init Scaffold .fun-ci/ for the detected project type
|
|
248
248
|
fun-ci init --everything init + install-hooks + check in one step
|
|
249
|
+
fun-ci init --stack NAME Scaffold .fun-ci/ for the stack named, such as go
|
|
249
250
|
fun-ci install-hooks Install post-commit and pre-push hooks
|
|
250
251
|
fun-ci install-hooks post-commit Install a single hook type
|
|
251
252
|
fun-ci check Verify .fun-ci/ setup
|
|
@@ -264,7 +265,7 @@ fun-ci trigger --background <commit> <branch> Start the pipeline in the backgr
|
|
|
264
265
|
fun-ci prune Remove fun-ci's worktrees when no pipeline is running
|
|
265
266
|
```
|
|
266
267
|
|
|
267
|
-
`fun-ci --help` lists every option.
|
|
268
|
+
`fun-ci --help` lists every option, and `fun-ci COMMAND --help` a command's own.
|
|
268
269
|
|
|
269
270
|
## Upgrading to 2.0
|
|
270
271
|
|
|
@@ -54,11 +54,11 @@ module FunCi
|
|
|
54
54
|
" #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Only a worktree
|
|
57
|
+
# Only a cold worktree (WorktreePool) gives a stage more than its usual budget
|
|
58
58
|
# (AT-1.14), which a run in the background says nowhere else.
|
|
59
59
|
def self.longer(stage)
|
|
60
60
|
usual = Pipeline::DEFAULT_BUDGETS[stage.name]
|
|
61
|
-
stage.budget && usual && stage.budget > usual ? " (
|
|
61
|
+
stage.budget && usual && stage.budget > usual ? " (cold worktree, budget #{stage.budget}s)" : ""
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def self.footer(report)
|
data/lib/fun_ci/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../fun_ci"
|
|
4
4
|
require_relative "cli_help"
|
|
5
|
+
require_relative "cli_command_help"
|
|
5
6
|
require_relative "pipeline/trigger_params"
|
|
6
7
|
require_relative "persistence/state_dir"
|
|
7
8
|
require_relative "setup/commands"
|
|
@@ -37,6 +38,7 @@ module FunCi
|
|
|
37
38
|
return CliHelp.version(@io.stdout) if subcommand == "--version"
|
|
38
39
|
|
|
39
40
|
return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
|
|
41
|
+
return CliCommandHelp.help(@io.stdout, subcommand) if CliCommandHelp.asked?(args.drop(1))
|
|
40
42
|
|
|
41
43
|
dispatch(subcommand, args.drop(1))
|
|
42
44
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_help"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
# What `fun-ci COMMAND --help` prints in place of running the command: its
|
|
7
|
+
# usage, what it is for and the options it takes, the last two read from
|
|
8
|
+
# `fun-ci --help`, whose options name in brackets the commands taking them.
|
|
9
|
+
module CliCommandHelp
|
|
10
|
+
FLAGS = %w[-h --help].freeze
|
|
11
|
+
USAGES = {
|
|
12
|
+
"trigger" => "fun-ci trigger [--background] <commit-hash> <branch>",
|
|
13
|
+
"console" => "fun-ci console",
|
|
14
|
+
"init" => "fun-ci init [--everything] [--stack NAME]",
|
|
15
|
+
"install-hooks" => "fun-ci install-hooks [post-commit | pre-push]",
|
|
16
|
+
"check" => "fun-ci check",
|
|
17
|
+
"prune" => "fun-ci prune",
|
|
18
|
+
"status" => "fun-ci status [REV] [--need LEVEL] [--trunk] [--json]",
|
|
19
|
+
"wait" => "fun-ci wait [REV] [--need LEVEL] [--within TIME] [--follow-branch] [--trunk] [--json]",
|
|
20
|
+
"runs" => "fun-ci runs [-n N] [--branch NAME] [--json]",
|
|
21
|
+
"events" => "fun-ci events [--follow] [--only failures]",
|
|
22
|
+
"why" => "fun-ci why [REV] [STAGE | trunk] [--need LEVEL] [--raw] [--json]\n " \
|
|
23
|
+
"fun-ci why --job NAME [--raw] [--json]",
|
|
24
|
+
"jobs" => "fun-ci jobs [--json]",
|
|
25
|
+
"cancel" => "fun-ci cancel --job NAME",
|
|
26
|
+
"extract" => "fun-ci extract STAGE --output FILE [--exit N | --timed-out] [--json]"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.asked?(args) = args.any? { |arg| FLAGS.include?(arg) }
|
|
30
|
+
|
|
31
|
+
def self.help(out, command)
|
|
32
|
+
out.puts "Usage: #{USAGES.fetch(command)}", "", purpose(command)
|
|
33
|
+
options = options_of(command)
|
|
34
|
+
out.puts "", "Options:", *options unless options.empty?
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.purpose(command) = CliHelp::TEXT[/^ #{Regexp.escape(command)} +(.+)$/, 1]
|
|
39
|
+
|
|
40
|
+
def self.options_of(command)
|
|
41
|
+
lines = CliHelp::TEXT.lines.select { |line| takes?(line, command) }
|
|
42
|
+
lines.map { |line| line.sub(/\([^)]+\) /, "") }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.takes?(option_line, command)
|
|
46
|
+
commands = option_line[/^\s+-\S.*?\(([^)]+)\)/, 1]
|
|
47
|
+
!commands.nil? && commands.split(", ").include?(command)
|
|
48
|
+
end
|
|
49
|
+
private_class_method :purpose, :options_of, :takes?
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fun_ci/cli_help.rb
CHANGED
|
@@ -36,13 +36,13 @@ module FunCi
|
|
|
36
36
|
extract Try a stage's evidence extractors on a saved output
|
|
37
37
|
|
|
38
38
|
Options:
|
|
39
|
-
-h, --help Show this help message
|
|
39
|
+
-h, --help Show this help message; after a command, that command's usage
|
|
40
40
|
--version Show version
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
--background Run the pipeline in the background and return at once
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
Command options:
|
|
43
|
+
--background (trigger) Run the pipeline in the background and return at once
|
|
44
|
+
--everything (init) Run init + install-hooks + check in one step
|
|
45
|
+
--stack NAME (init) Write the scripts for this stack, such as go, instead of the one detected
|
|
46
46
|
--need LEVEL (status, wait, why) What must pass: build, fast (default) or all
|
|
47
47
|
--within TIME (wait) Give up undecided after 30, 30s or 5m
|
|
48
48
|
--follow-branch (wait) Move on to the newer commit that superseded the run
|
|
@@ -53,13 +53,13 @@ module FunCi
|
|
|
53
53
|
--branch NAME (runs) Only runs on this branch
|
|
54
54
|
--follow (events) Keep printing events as they happen, until stopped
|
|
55
55
|
--only failures (events) Print only failed stages, superseded runs and failed jobs
|
|
56
|
-
--
|
|
56
|
+
--output FILE (extract) The saved output of the stage to read
|
|
57
|
+
--exit N (extract) The exit status the stage ended with (default 1)
|
|
58
|
+
--timed-out (extract) The stage ran out of time instead of exiting
|
|
59
|
+
--json (status, wait, runs, why, jobs, extract) Print JSON instead of text; events always print JSON lines
|
|
57
60
|
Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
|
|
58
61
|
4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
|
|
59
62
|
|
|
60
|
-
Init options:
|
|
61
|
-
--everything Run init + install-hooks + check in one step
|
|
62
|
-
|
|
63
63
|
Examples:
|
|
64
64
|
fun-ci init --everything
|
|
65
65
|
fun-ci trigger abc1234 main
|
|
@@ -19,6 +19,9 @@ module FunCi
|
|
|
19
19
|
# How many of a branch's runs are read to fold its cancelled ones and
|
|
20
20
|
# find its standing against the trunk.
|
|
21
21
|
BRANCH_WINDOW = 100
|
|
22
|
+
# How many branches the board holds at most: those whose newest run
|
|
23
|
+
# changed most recently, so old failures leave it in time.
|
|
24
|
+
RECENT_BRANCHES = 20
|
|
22
25
|
|
|
23
26
|
# `clock` answers the time now, which decides which jobs are due.
|
|
24
27
|
def initialize(db, page_size: 15, run_canceller: Pipeline::RunCanceller.new, clock: -> { Time.now })
|
|
@@ -34,7 +37,7 @@ module FunCi
|
|
|
34
37
|
def resize(page_size) = @paging.resize(page_size)
|
|
35
38
|
|
|
36
39
|
# Whether there are branches beyond those `runs` loads.
|
|
37
|
-
def more? =
|
|
40
|
+
def more? = heads(@paging.limit + 1).size > @paging.limit
|
|
38
41
|
|
|
39
42
|
# Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
|
|
40
43
|
def record_dead_slow_suites = @run_canceller.record_dead(@db)
|
|
@@ -55,7 +58,7 @@ module FunCi
|
|
|
55
58
|
# and then those run most recently, in RowOrder: a run of cancelled
|
|
56
59
|
# runs folded into one, with its stages and the branch's standing against the trunk.
|
|
57
60
|
def runs
|
|
58
|
-
RowOrder.of(
|
|
61
|
+
RowOrder.of(heads(@paging.limit).map { |head| branch_row(head) })
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
# The projects among `runs` whose trunk is stale.
|
|
@@ -74,6 +77,8 @@ module FunCi
|
|
|
74
77
|
|
|
75
78
|
private
|
|
76
79
|
|
|
80
|
+
def heads(limit) = Persistence::PipelineRun.branch_heads(@db, limit: limit, among: RECENT_BRANCHES)
|
|
81
|
+
|
|
77
82
|
def branch_row(head)
|
|
78
83
|
branch_runs = Persistence::PipelineRun.of_branch(@db, head[:project_path], head[:branch], limit: BRANCH_WINDOW)
|
|
79
84
|
row = CancelledFolding.fold(branch_runs).first
|
|
@@ -44,10 +44,12 @@ module FunCi
|
|
|
44
44
|
|
|
45
45
|
def stale_since(project, now)
|
|
46
46
|
last = Persistence::TrunkFetches.new(@db, project).last
|
|
47
|
-
return nil unless last && (last.error ||
|
|
47
|
+
return nil unless last && (last.error || old?(last.fetched_at, now))
|
|
48
48
|
|
|
49
49
|
{ project: project, since: last.fetched_at&.to_i }
|
|
50
50
|
end
|
|
51
|
+
|
|
52
|
+
def old?(fetched_at, now) = !fetched_at.nil? && now - fetched_at > Trunk::Shown::FRESH_FOR
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
end
|
|
@@ -10,7 +10,7 @@ module FunCi
|
|
|
10
10
|
# command to read.
|
|
11
11
|
module AboutStage
|
|
12
12
|
def self.of(sources, outcome, output)
|
|
13
|
-
About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
|
|
13
|
+
About.new(**outcome.to_h.except(:overrun, :elsewhere), **started(sources), **where(sources, output),
|
|
14
14
|
stage: sources.stage, budget: sources.budget, commit: sources.commit)
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -37,7 +37,7 @@ module FunCi
|
|
|
37
37
|
def collect(output, outcome = FAILED)
|
|
38
38
|
result = extracted(output, outcome)
|
|
39
39
|
overrun = outcome.overrun || NOTHING
|
|
40
|
-
parts = [["fun-ci", own_facts(outcome
|
|
40
|
+
parts = [["fun-ci", own_facts(outcome)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
|
|
41
41
|
Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -76,10 +76,13 @@ module FunCi
|
|
|
76
76
|
|
|
77
77
|
def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
|
|
78
78
|
|
|
79
|
-
def own_facts(
|
|
80
|
-
|
|
79
|
+
def own_facts(outcome)
|
|
80
|
+
named = { "alongside" => outcome.alongside, "other runs alongside" => outcome.elsewhere }
|
|
81
|
+
Findings.new(facts: named.reject { |_, stages| stages.empty? }.map { |name, stages| fact(name, stages) })
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
def fact(name, stages) = { name: name, value: stages.join(", ") }
|
|
85
|
+
|
|
83
86
|
def tail(output)
|
|
84
87
|
text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
|
|
85
88
|
Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
|
|
@@ -4,12 +4,15 @@ module FunCi
|
|
|
4
4
|
module Evidence
|
|
5
5
|
# How a failed stage ended, known once it has: its state in the words
|
|
6
6
|
# `status --json` uses, its exit status or signal, and the other stages
|
|
7
|
-
# that shared its slot while it ran,
|
|
7
|
+
# that shared its slot while it ran, the stages of the project's other runs
|
|
8
|
+
# that ran meanwhile (elsewhere), and for an overrun, what was found
|
|
8
9
|
# before the kill (an Extraction::Result).
|
|
9
|
-
Outcome = Data.define(:state, :exit_status, :signal, :alongside, :overrun)
|
|
10
|
+
Outcome = Data.define(:state, :exit_status, :signal, :alongside, :elsewhere, :overrun)
|
|
10
11
|
|
|
11
12
|
class Outcome
|
|
12
|
-
def initialize(**given)
|
|
13
|
+
def initialize(**given)
|
|
14
|
+
super(exit_status: nil, signal: nil, alongside: [], elsewhere: [], overrun: nil, **given)
|
|
15
|
+
end
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
end
|
data/lib/fun_ci/jobs/locks.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative "../pipeline/slot"
|
|
|
6
6
|
module FunCi
|
|
7
7
|
module Jobs
|
|
8
8
|
# Each job's worktree and the lock beside it, under a project's
|
|
9
|
-
#
|
|
9
|
+
# Worktrees#jobs_root: `<name>` and `<name>.lock`. Whoever holds
|
|
10
10
|
# a job's lock runs it, so it runs once at a time, and a lock dies with
|
|
11
11
|
# the process that held it.
|
|
12
12
|
class Locks
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "active_runs"
|
|
5
|
-
|
|
6
|
-
require_relative "../evidence/document"
|
|
5
|
+
require_relative "died"
|
|
7
6
|
|
|
8
7
|
module FunCi
|
|
9
8
|
module Persistence
|
|
@@ -25,14 +24,6 @@ module FunCi
|
|
|
25
24
|
# [id, lock file] of each job run still running.
|
|
26
25
|
def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE #{ACTIVE}")
|
|
27
26
|
|
|
28
|
-
# What a run whose process died keeps: that it has no result, and why
|
|
29
|
-
# that may be. It keeps no end, since when it was found dead says
|
|
30
|
-
# nothing of how long it ran.
|
|
31
|
-
DIED_FACT = { name: "ended", extractor: "fun-ci",
|
|
32
|
-
value: "its process stopped before it said how the run ended (the machine slept or " \
|
|
33
|
-
"restarted, or something killed it), so this run has no result" }.freeze
|
|
34
|
-
DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
|
|
35
|
-
|
|
36
27
|
def self.cancelled(db, id) = ended(db, id, "cancelled", ACTIVE)
|
|
37
28
|
|
|
38
29
|
# A run whose process is gone without recording how it ended: failed,
|
|
@@ -41,7 +32,7 @@ module FunCi
|
|
|
41
32
|
def self.died(db, id)
|
|
42
33
|
ended(db, id, "cancelled", "status = 'scheduled'")
|
|
43
34
|
db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
|
|
44
|
-
[
|
|
35
|
+
[Died.evidence("run"), id])
|
|
45
36
|
end
|
|
46
37
|
|
|
47
38
|
# The ids of a project's job's runs still running.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "time"
|
|
4
4
|
require_relative "pipeline_run"
|
|
5
5
|
require_relative "stage_job"
|
|
6
|
+
require_relative "died"
|
|
6
7
|
|
|
7
8
|
module FunCi
|
|
8
9
|
module Persistence
|
|
@@ -39,10 +40,11 @@ module FunCi
|
|
|
39
40
|
"AND pipeline_runs.pid IS NOT NULL")
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Records the slow stage failed,
|
|
43
|
+
# Records the slow stage failed, and that its process died, unless its
|
|
44
|
+
# result was recorded meanwhile.
|
|
43
45
|
def self.slow_suite_died(db, job_id)
|
|
44
|
-
db.execute("UPDATE stage_jobs SET status = 'failed', completed_at =
|
|
45
|
-
[Time.now.utc.iso8601(3), job_id])
|
|
46
|
+
db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ?, evidence = ? " \
|
|
47
|
+
"WHERE id = ? AND status = 'running'", [Time.now.utc.iso8601(3), Died.evidence("stage"), job_id])
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
def self.cancelled(db, run)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../evidence/document"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Persistence
|
|
8
|
+
# The evidence kept of a stage or job run whose process died: that it has
|
|
9
|
+
# no result, and why that may be, so it reads apart from a failure of the
|
|
10
|
+
# code. It keeps no end, since when it was found dead says nothing of how
|
|
11
|
+
# long it ran.
|
|
12
|
+
module Died
|
|
13
|
+
def self.evidence(what)
|
|
14
|
+
fact = { name: "ended", extractor: "fun-ci",
|
|
15
|
+
value: "its process stopped before it said how the #{what} ended (the machine slept or " \
|
|
16
|
+
"restarted, or something killed it), so this #{what} has no result" }
|
|
17
|
+
JSON.generate(Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [fact]).to_h)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -19,6 +19,7 @@ module FunCi
|
|
|
19
19
|
def trunk_heads(**) = {}
|
|
20
20
|
def trunk_fetches = TrunkRecording::NoFetches.new
|
|
21
21
|
def alongside(_job_id) = []
|
|
22
|
+
def alongside_elsewhere(_job_id) = []
|
|
22
23
|
def stage_process(_job_id, _pid); end
|
|
23
24
|
def slot_taken(_lock_file); end
|
|
24
25
|
def foreground_done; end
|
|
@@ -97,6 +97,7 @@ module FunCi
|
|
|
97
97
|
|
|
98
98
|
# The stages that shared the slot with this one (architecture.md, "Evidence of a failed stage").
|
|
99
99
|
def alongside(job_id) = tolerating { StageJob.alongside(@db, job_id) } || []
|
|
100
|
+
def alongside_elsewhere(job_id) = tolerating { StageJob.alongside_elsewhere(@db, job_id) } || []
|
|
100
101
|
|
|
101
102
|
def keep_exit(job_id, exit_status, signal)
|
|
102
103
|
tolerating { StageJob.keep_exit(@db, job_id, exit_status, signal) }
|
|
@@ -56,9 +56,8 @@ module FunCi
|
|
|
56
56
|
db.execute("UPDATE pipeline_runs SET trunk_started_at = ? WHERE id = ?", [at.utc.iso8601, id])
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def self.update_status(db, id, new_status)
|
|
60
|
-
|
|
61
|
-
db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
|
|
59
|
+
def self.update_status(db, id, new_status, at: Time.now)
|
|
60
|
+
db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, at.utc.iso8601, id])
|
|
62
61
|
end
|
|
63
62
|
|
|
64
63
|
def self.recent(db, limit: 20)
|
|
@@ -69,11 +68,15 @@ module FunCi
|
|
|
69
68
|
NEEDING = "CASE status WHEN 'failed' THEN 0 WHEN 'timed_out' THEN 1 WHEN 'running' THEN 2 " \
|
|
70
69
|
"WHEN 'scheduled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END"
|
|
71
70
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
# Each branch's newest run.
|
|
72
|
+
HEADS = "SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch"
|
|
73
|
+
|
|
74
|
+
# The newest run of each of `limit` branches, of the `among` whose newest
|
|
75
|
+
# run changed most recently: those that most need you, then those run
|
|
76
|
+
# most recently, in that order.
|
|
77
|
+
def self.branch_heads(db, limit:, among:)
|
|
78
|
+
query(db, "WHERE id IN (SELECT id FROM pipeline_runs WHERE id IN (#{HEADS}) " \
|
|
79
|
+
"ORDER BY updated_at DESC, id DESC LIMIT ?) ORDER BY #{NEEDING}, id DESC LIMIT ?", among, limit)
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
# A project's branch's runs, newest first.
|
|
@@ -51,6 +51,18 @@ module FunCi
|
|
|
51
51
|
[id]).flatten
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Stages of the project's other runs running at any moment between this
|
|
55
|
+
# stage's start and now, as "<short commit>'s <stage>".
|
|
56
|
+
def self.alongside_elsewhere(db, id)
|
|
57
|
+
db.execute("SELECT runs.commit_hash, others.stage FROM stage_jobs AS others " \
|
|
58
|
+
"JOIN pipeline_runs AS runs ON runs.id = others.pipeline_run_id " \
|
|
59
|
+
"JOIN stage_jobs AS me ON me.id = ? JOIN pipeline_runs AS mine ON mine.id = me.pipeline_run_id " \
|
|
60
|
+
"WHERE runs.project_path IS mine.project_path AND runs.id != mine.id " \
|
|
61
|
+
"AND others.started_at IS NOT NULL " \
|
|
62
|
+
"AND (others.completed_at IS NULL OR others.completed_at > me.started_at) ORDER BY others.id",
|
|
63
|
+
[id]).map { |sha, stage| "#{sha[0, 7]}'s #{stage}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
54
66
|
def self.ids(db) = db.execute("SELECT id FROM stage_jobs").flatten
|
|
55
67
|
|
|
56
68
|
def self.find(db, id)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Kills a process group and what left it (ProcessTree): TERM, then KILL.
|
|
6
|
+
# A member forking as the KILL arrives leaves a child in the group that
|
|
7
|
+
# the signal missed, so the group is killed again while any member lives,
|
|
8
|
+
# a bounded number of times, since a mutant may stop it ever going.
|
|
9
|
+
module GroupKill
|
|
10
|
+
ROUNDS = 10
|
|
11
|
+
|
|
12
|
+
# escaped: further targets, -pgid for a group, pid for a process.
|
|
13
|
+
def self.kill(pid, escaped, signal: Process.method(:kill))
|
|
14
|
+
%w[TERM KILL].product([-pid, *escaped]).each { |name, target| send_to(signal, name, target) }
|
|
15
|
+
ROUNDS.times do
|
|
16
|
+
return unless lives?(pid, signal)
|
|
17
|
+
|
|
18
|
+
send_to(signal, "KILL", -pid)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# On macOS a group left with only zombies answers EPERM, not ESRCH.
|
|
23
|
+
def self.lives?(pid, signal)
|
|
24
|
+
signal.call(0, -pid)
|
|
25
|
+
true
|
|
26
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.send_to(signal, name, target)
|
|
31
|
+
signal.call(name, target)
|
|
32
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
33
|
+
# Gone already.
|
|
34
|
+
end
|
|
35
|
+
private_class_method :lives?, :send_to
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -66,8 +66,10 @@ module FunCi
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Trigger requires this file, through TriggerCommand, so it is loaded
|
|
69
|
-
# here, where it is used, rather than above.
|
|
69
|
+
# here, where it is used, rather than above. The run leads a session of
|
|
70
|
+
# its own, so that ending the shell that committed doesn't end it.
|
|
70
71
|
def self.run_in_child(commit_hash:, branch:, db_path:)
|
|
72
|
+
Process.setsid
|
|
71
73
|
require_relative "trigger"
|
|
72
74
|
recorder = Persistence::DbRecorder.new(Persistence::Database.connection(db_path))
|
|
73
75
|
trigger = trigger(commit_hash, branch, recorder)
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require_relative "gate"
|
|
4
4
|
require_relative "git_environment"
|
|
5
5
|
require_relative "output_window"
|
|
6
|
+
require_relative "process_tree"
|
|
7
|
+
require_relative "group_kill"
|
|
8
|
+
require_relative "../evidence/process_table"
|
|
6
9
|
|
|
7
10
|
module FunCi
|
|
8
11
|
module Pipeline
|
|
@@ -112,8 +115,10 @@ module FunCi
|
|
|
112
115
|
[output, status, false]
|
|
113
116
|
end
|
|
114
117
|
|
|
118
|
+
# With the group go what it started that left it (ProcessTree), found
|
|
119
|
+
# before the kill orphans them (GroupKill).
|
|
115
120
|
def kill_process_group(pid)
|
|
116
|
-
|
|
121
|
+
GroupKill.kill(pid, ProcessTree.escaped([pid], Evidence::ProcessTable.now))
|
|
117
122
|
ignoring_errors { Process.waitpid(pid) }
|
|
118
123
|
end
|
|
119
124
|
|