repoactive 0.2.8.dev2__tar.gz → 0.3.1.dev1__tar.gz
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.
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/PKG-INFO +200 -11
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/README.md +199 -10
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/pyproject.toml +1 -1
- repoactive-0.3.1.dev1/src/repoactive/boxquote.py +51 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/config.py +151 -5
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/runner.py +155 -23
- repoactive-0.2.8.dev2/src/repoactive/boxquote.py +0 -32
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/.gitignore +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/LICENSE +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/__init__.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/cli.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/constants.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/graph.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/jj.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/lock.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/base.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/github.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/gitlab.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/progress.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/selection.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/settings.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/trailers.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/ui.py +0 -0
- {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/updates.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repoactive
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1.dev1
|
|
4
4
|
Summary: Script-driven code changes with automated merge requests
|
|
5
5
|
License-Expression: GPL-3.0-or-later
|
|
6
6
|
License-File: LICENSE
|
|
@@ -55,6 +55,8 @@ and (with `--mode publish`) the full MR lifecycle.
|
|
|
55
55
|
- [Example](#example)
|
|
56
56
|
- [`[platform.<name>]`](#platformname)
|
|
57
57
|
- [Config file locations](#config-file-locations)
|
|
58
|
+
- [Variables passed to job commands](#variables-passed-to-job-commands)
|
|
59
|
+
- [Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env)
|
|
58
60
|
- [Overriding values on the command line](#overriding-values-on-the-command-line)
|
|
59
61
|
- [Selecting jobs with tags](#selecting-jobs-with-tags)
|
|
60
62
|
- [Keeping unmerged branches current](#keeping-unmerged-branches-current)
|
|
@@ -467,13 +469,17 @@ block.
|
|
|
467
469
|
**Branch and commit options:**
|
|
468
470
|
|
|
469
471
|
- **`branch_prefix`** (default: `"repoactive/"`) - Prefix prepended to the
|
|
470
|
-
job name to form the branch name.
|
|
472
|
+
job name to form the branch name. Set to `""` to use the job name alone.
|
|
473
|
+
With an empty prefix the branch name is exactly the job name, so make sure
|
|
474
|
+
no job is named after your base branch (e.g. a job named `main`) - the two
|
|
475
|
+
branches would otherwise collide.
|
|
471
476
|
- **`mr_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended to
|
|
472
477
|
every MR/PR title. Set to `""` to disable.
|
|
473
478
|
- **`commit_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended
|
|
474
479
|
to every commit title. Set to `""` to disable.
|
|
475
480
|
- **`base_branch`** (default: repo default) - Target branch for all jobs
|
|
476
|
-
that do not set their own.
|
|
481
|
+
that do not set their own. May also be a jj revset expression such as
|
|
482
|
+
`trunk()`, `root()`, or a user-defined revset alias.
|
|
477
483
|
|
|
478
484
|
**Run control:**
|
|
479
485
|
|
|
@@ -484,6 +490,18 @@ block.
|
|
|
484
490
|
- **`timeout`** (default: `"2m"`) - Maximum runtime for a job's command.
|
|
485
491
|
Same format as `cooldown_period`. See
|
|
486
492
|
[Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
|
|
493
|
+
- **`shell`** (default: `/bin/sh`) - Interpreter used to run job commands. A
|
|
494
|
+
bare name is resolved on `PATH` (e.g. `"bash"`); an absolute path also
|
|
495
|
+
works. The command runs as `<shell> -c <command>`. Arguments are not
|
|
496
|
+
allowed - the value must be a single binary.
|
|
497
|
+
|
|
498
|
+
**Secrets:**
|
|
499
|
+
|
|
500
|
+
- **`secret_env`** (default: `[]`) - Names of environment variables to mark
|
|
501
|
+
as secrets config-wide. Each marked name is stripped from every job
|
|
502
|
+
command's environment, but a job still reads one only by listing it in its
|
|
503
|
+
own `secret_env`; `job-defaults` marks names, it does not grant them. See
|
|
504
|
+
[Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env).
|
|
487
505
|
|
|
488
506
|
### `[job.<name>]`
|
|
489
507
|
|
|
@@ -518,7 +536,8 @@ underscores.
|
|
|
518
536
|
**Branch and commit options:**
|
|
519
537
|
|
|
520
538
|
- **`base_branch`** (default: inherited) - Target branch for this job's
|
|
521
|
-
MR/PR.
|
|
539
|
+
MR/PR. May also be a jj revset expression such as `trunk()`, `root()`, or
|
|
540
|
+
a user-defined revset alias.
|
|
522
541
|
- **`branch_prefix`** (default: inherited) - Override the branch-name prefix
|
|
523
542
|
for this job only.
|
|
524
543
|
- **`mr_title_prefix`** (default: inherited) - Override the MR/PR title
|
|
@@ -553,9 +572,20 @@ underscores.
|
|
|
553
572
|
- **`timeout`** (default: inherited) - Maximum runtime for this job's
|
|
554
573
|
command. Set to `"0s"` to disable the timeout entirely. See
|
|
555
574
|
[Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
|
|
575
|
+
- **`shell`** (default: inherited) - Interpreter used to run this job's
|
|
576
|
+
command, overriding `job-defaults.shell`. See
|
|
577
|
+
[`job-defaults.shell`](#job-defaults).
|
|
556
578
|
- **`emits_jobs`** (default: `false`) - Generator job: the command writes
|
|
557
|
-
`*.toml` job fragments into `$
|
|
558
|
-
|
|
579
|
+
`*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
|
|
580
|
+
See [Generating jobs dynamically](#generating-jobs-dynamically).
|
|
581
|
+
|
|
582
|
+
**Secrets:**
|
|
583
|
+
|
|
584
|
+
- **`secret_env`** (default: `[]`) - Names of environment variables holding
|
|
585
|
+
secrets this job's command may read. A marked name is stripped from every
|
|
586
|
+
command's environment; only a job that lists it here has it injected back
|
|
587
|
+
for its own command. See
|
|
588
|
+
[Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env).
|
|
559
589
|
|
|
560
590
|
### Stacking MRs
|
|
561
591
|
|
|
@@ -658,6 +688,165 @@ directory of `*.toml` files, and may be repeated to merge several sources;
|
|
|
658
688
|
later sources win. Explicit paths are resolved relative to the current
|
|
659
689
|
directory, not `--repo`.
|
|
660
690
|
|
|
691
|
+
### Variables passed to job commands
|
|
692
|
+
|
|
693
|
+
`repoactive` injects a few `RA_`-prefixed environment variables into every
|
|
694
|
+
job command's environment. These are distinct from the `REPOACTIVE_`
|
|
695
|
+
variables [above](#environment-variables), which configure `repoactive`
|
|
696
|
+
itself.
|
|
697
|
+
|
|
698
|
+
| Variable | Value | Set when |
|
|
699
|
+
| ---------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
700
|
+
| `RA_JOB_NAME` | The name of the job the command belongs to. | Always. |
|
|
701
|
+
| `RA_WORKSPACE_DIR` | The temporary jj workspace created for the job (also the command's working directory). | Always. |
|
|
702
|
+
| `RA_JOB_BRANCH` | The bookmark/branch repoactive uses for the job's output. | Always. |
|
|
703
|
+
| `RA_JOB_BASE_BRANCH` | The branch the job's MR targets (`base_branch`, or `trunk()` by default). | Always. |
|
|
704
|
+
| `RA_CONFIG_SOURCE_DIR` | The directory of the config file that defined the command. | The command came from a config file (not a `--set` override or a built-in default). |
|
|
705
|
+
| `RA_JOBS_DIR` | The directory a generator writes its `*.toml` job fragments into. | The command is a [generator](#generating-jobs-dynamically) (`emits_jobs`). |
|
|
706
|
+
|
|
707
|
+
#### `RA_JOB_NAME`
|
|
708
|
+
|
|
709
|
+
`RA_JOB_NAME` holds the name of the job the command belongs to. A command
|
|
710
|
+
shared by several jobs (for instance one set in `[job-defaults]` or emitted
|
|
711
|
+
by a generator) can read it to tell which job is running:
|
|
712
|
+
|
|
713
|
+
```toml
|
|
714
|
+
[job.lint]
|
|
715
|
+
command = "./run-check.sh $RA_JOB_NAME"
|
|
716
|
+
title = "run the shared check script for this job"
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
#### `RA_WORKSPACE_DIR`
|
|
720
|
+
|
|
721
|
+
A job's command runs in a temporary jj workspace, which is also its working
|
|
722
|
+
directory. `RA_WORKSPACE_DIR` names that directory explicitly, so a command
|
|
723
|
+
that changes directory can still find its way back:
|
|
724
|
+
|
|
725
|
+
```toml
|
|
726
|
+
[job.build]
|
|
727
|
+
command = "cd subdir && make && cp result $RA_WORKSPACE_DIR/subdir/"
|
|
728
|
+
title = "build in a subdirectory"
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
#### `RA_JOB_BRANCH`
|
|
732
|
+
|
|
733
|
+
`RA_JOB_BRANCH` holds the bookmark (git branch) repoactive uses for the
|
|
734
|
+
job's output, e.g. `repoactive/uv-lock-upgrade`. The command runs on a fresh
|
|
735
|
+
commit while that bookmark still points at the **previous** run's commit, so
|
|
736
|
+
a command can inspect what it produced last time - for example to build on
|
|
737
|
+
it or diff against it:
|
|
738
|
+
|
|
739
|
+
```toml
|
|
740
|
+
[job.notes]
|
|
741
|
+
command = "jj diff --from $RA_JOB_BRANCH --to @ > /tmp/delta || true; ./make-notes.sh"
|
|
742
|
+
title = "regenerate release notes from what changed since last run"
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
The bookmark may not exist yet: a job's first run, a run that produced no
|
|
746
|
+
diff, and a generator all leave it unset on the remote, so a command that
|
|
747
|
+
reads it should tolerate a missing ref.
|
|
748
|
+
|
|
749
|
+
#### `RA_JOB_BASE_BRANCH`
|
|
750
|
+
|
|
751
|
+
`RA_JOB_BASE_BRANCH` holds the branch the job's merge request targets - its
|
|
752
|
+
`base_branch`, or `trunk()` when that is unset. A command can use it to look
|
|
753
|
+
at only what changed relative to the target, for instance to lint or
|
|
754
|
+
reformat just the affected files:
|
|
755
|
+
|
|
756
|
+
```toml
|
|
757
|
+
[job.format]
|
|
758
|
+
command = "treefmt $(git diff --name-only $RA_JOB_BASE_BRANCH)"
|
|
759
|
+
title = "format files changed since the base branch"
|
|
760
|
+
```
|
|
761
|
+
|
|
762
|
+
This is the branch you _configured_ as the base. When a job stacks on
|
|
763
|
+
another via `depends_on`, its commit actually sits on top of that other
|
|
764
|
+
job's output, so `RA_JOB_BASE_BRANCH` names the ultimate target of the
|
|
765
|
+
stack, not the immediate parent.
|
|
766
|
+
|
|
767
|
+
#### `RA_CONFIG_SOURCE_DIR`
|
|
768
|
+
|
|
769
|
+
Because the command runs in that throwaway workspace rather than in the
|
|
770
|
+
directory its config lives in, a relative path cannot reach a helper file
|
|
771
|
+
kept beside the config. `RA_CONFIG_SOURCE_DIR` bridges that: it holds the
|
|
772
|
+
directory of the config file that defined the job's command:
|
|
773
|
+
|
|
774
|
+
- a job defined in `.repoactive.toml` gets the directory holding it (the
|
|
775
|
+
repo directory, by default);
|
|
776
|
+
- a job defined in `.repoactive.d/foo.toml` gets the `.repoactive.d`
|
|
777
|
+
directory.
|
|
778
|
+
|
|
779
|
+
```toml
|
|
780
|
+
[job.fixup]
|
|
781
|
+
command = "$RA_CONFIG_SOURCE_DIR/fixup.sh"
|
|
782
|
+
title = "run the fixup script kept beside the config"
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
The value is the config file's real location on disk, so it is the same
|
|
786
|
+
whether the file was discovered automatically or named with `-c`. A command
|
|
787
|
+
whose value came from a `--set` override has no config file, so
|
|
788
|
+
`RA_CONFIG_SOURCE_DIR` is unset for it.
|
|
789
|
+
|
|
790
|
+
### Passing secrets to commands with `secret_env`
|
|
791
|
+
|
|
792
|
+
A job command inherits `repoactive`'s environment, so a secret exported
|
|
793
|
+
before the run (an LLM key, a registry token) reaches it. Left unmanaged
|
|
794
|
+
that secret reaches **every** job, whether it needs it or not. `secret_env`
|
|
795
|
+
scopes secrets to the jobs that ask for them.
|
|
796
|
+
|
|
797
|
+
The field separates two things:
|
|
798
|
+
|
|
799
|
+
- **Marking** - naming a variable in _any_ `secret_env` (a job's or
|
|
800
|
+
`[job-defaults]`') marks it a secret and **removes it from every job
|
|
801
|
+
command's environment**.
|
|
802
|
+
- **Granting** - a job reads a marked secret back only by listing it in
|
|
803
|
+
**its own** `secret_env`. `[job-defaults].secret_env` marks names but
|
|
804
|
+
grants to no job.
|
|
805
|
+
|
|
806
|
+
So a secret is present only in the jobs that name it, never ambient:
|
|
807
|
+
|
|
808
|
+
```toml
|
|
809
|
+
[job-defaults]
|
|
810
|
+
secret_env = ["OPENAI_API_KEY"]
|
|
811
|
+
|
|
812
|
+
[job.rewrite-docs]
|
|
813
|
+
command = "./llm-rewrite.sh"
|
|
814
|
+
title = "docs: rewrite with the LLM"
|
|
815
|
+
secret_env = ["OPENAI_API_KEY"]
|
|
816
|
+
|
|
817
|
+
[job.format]
|
|
818
|
+
command = "treefmt"
|
|
819
|
+
title = "format the tree"
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
Here `OPENAI_API_KEY` is marked in `[job-defaults]`, so `format` (which does
|
|
823
|
+
not grant it) runs without it in its environment; only `rewrite-docs`, which
|
|
824
|
+
lists it in its own `secret_env`, can read it.
|
|
825
|
+
|
|
826
|
+
A job that grants a secret which is **unset** in `repoactive`'s environment
|
|
827
|
+
fails at its start with a clear error, rather than letting the command fail
|
|
828
|
+
obscurely later. Secret **values** must never be written in config - a
|
|
829
|
+
`.repoactive.toml` is checked into the repo; `secret_env` names variables,
|
|
830
|
+
your environment (or your CI's secret store) supplies the values. The `RA_`
|
|
831
|
+
and `REPOACTIVE_` prefixes are reserved and rejected.
|
|
832
|
+
|
|
833
|
+
A [generated job](#generating-jobs-dynamically) may only grant a secret that
|
|
834
|
+
the static config already marks; an emitted `secret_env` naming a variable
|
|
835
|
+
no static job or `[job-defaults]` marked is rejected. Mark such a secret up
|
|
836
|
+
front in `[job-defaults].secret_env` (or on the generator), and the emitted
|
|
837
|
+
jobs grant it.
|
|
838
|
+
|
|
839
|
+
A platform's `token_env` is normally stripped from every command
|
|
840
|
+
([the platform token](#platformname) is kept out of jobs). A job that
|
|
841
|
+
genuinely needs it can opt back in by naming that variable in its own
|
|
842
|
+
`secret_env`; only that job then receives it.
|
|
843
|
+
|
|
844
|
+
> **Note:** `secret_env` scopes _which jobs_ can read a secret. It does not
|
|
845
|
+
> yet redact secret values from a command's captured output, so a command
|
|
846
|
+
> that echoes a secret it was granted can still write it into the commit
|
|
847
|
+
> message. Avoid printing granted secrets. See
|
|
848
|
+
> [ADR 0017](docs/adr/0017-secret-env-redaction.md).
|
|
849
|
+
|
|
661
850
|
### Overriding values on the command line
|
|
662
851
|
|
|
663
852
|
`--set NAME=VALUE`/`-s` tweaks individual config values without editing a
|
|
@@ -831,12 +1020,12 @@ changed anything in the current run, the job is skipped.
|
|
|
831
1020
|
|
|
832
1021
|
```toml
|
|
833
1022
|
[job.prek-autoupdate]
|
|
834
|
-
command = "
|
|
1023
|
+
command = "uvx prek autoupdate"
|
|
835
1024
|
title = "ci: update pre-commit hooks"
|
|
836
1025
|
tags = ["weekly"]
|
|
837
1026
|
|
|
838
1027
|
[job.prek-run-all]
|
|
839
|
-
command = "sh -c '
|
|
1028
|
+
command = "sh -c 'uvx prek run -a; true'"
|
|
840
1029
|
title = "ci: apply pre-commit fixes"
|
|
841
1030
|
depends_on = ["prek-autoupdate"]
|
|
842
1031
|
run_only_if_changed = ["prek-autoupdate"]
|
|
@@ -962,8 +1151,8 @@ job per package in a monorepo, one per entry in a manifest - and you don't
|
|
|
962
1151
|
want to hand-maintain them. A **generator** is an ordinary `[job.<name>]`
|
|
963
1152
|
with `emits_jobs = true`. Instead of producing a diff, its command writes
|
|
964
1153
|
one or more `*.toml` job fragments into the directory named by the
|
|
965
|
-
`
|
|
966
|
-
|
|
1154
|
+
`RA_JOBS_DIR` environment variable, and the jobs it emits run in the same
|
|
1155
|
+
invocation.
|
|
967
1156
|
|
|
968
1157
|
```toml
|
|
969
1158
|
[job.per-package]
|
|
@@ -978,7 +1167,7 @@ cooldown_period = "7d"
|
|
|
978
1167
|
The command writes fragments using the normal `[job.<name>]` syntax, e.g.
|
|
979
1168
|
|
|
980
1169
|
```toml
|
|
981
|
-
# $
|
|
1170
|
+
# $RA_JOBS_DIR/pkg-a.toml
|
|
982
1171
|
[job.upgrade-pkg-a]
|
|
983
1172
|
command = "uv lock --upgrade --package pkg-a"
|
|
984
1173
|
title = "build: upgrade pkg-a"
|
|
@@ -28,6 +28,8 @@ and (with `--mode publish`) the full MR lifecycle.
|
|
|
28
28
|
- [Example](#example)
|
|
29
29
|
- [`[platform.<name>]`](#platformname)
|
|
30
30
|
- [Config file locations](#config-file-locations)
|
|
31
|
+
- [Variables passed to job commands](#variables-passed-to-job-commands)
|
|
32
|
+
- [Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env)
|
|
31
33
|
- [Overriding values on the command line](#overriding-values-on-the-command-line)
|
|
32
34
|
- [Selecting jobs with tags](#selecting-jobs-with-tags)
|
|
33
35
|
- [Keeping unmerged branches current](#keeping-unmerged-branches-current)
|
|
@@ -440,13 +442,17 @@ block.
|
|
|
440
442
|
**Branch and commit options:**
|
|
441
443
|
|
|
442
444
|
- **`branch_prefix`** (default: `"repoactive/"`) - Prefix prepended to the
|
|
443
|
-
job name to form the branch name.
|
|
445
|
+
job name to form the branch name. Set to `""` to use the job name alone.
|
|
446
|
+
With an empty prefix the branch name is exactly the job name, so make sure
|
|
447
|
+
no job is named after your base branch (e.g. a job named `main`) - the two
|
|
448
|
+
branches would otherwise collide.
|
|
444
449
|
- **`mr_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended to
|
|
445
450
|
every MR/PR title. Set to `""` to disable.
|
|
446
451
|
- **`commit_title_prefix`** (default: `"[repoactive] "`) - Prefix prepended
|
|
447
452
|
to every commit title. Set to `""` to disable.
|
|
448
453
|
- **`base_branch`** (default: repo default) - Target branch for all jobs
|
|
449
|
-
that do not set their own.
|
|
454
|
+
that do not set their own. May also be a jj revset expression such as
|
|
455
|
+
`trunk()`, `root()`, or a user-defined revset alias.
|
|
450
456
|
|
|
451
457
|
**Run control:**
|
|
452
458
|
|
|
@@ -457,6 +463,18 @@ block.
|
|
|
457
463
|
- **`timeout`** (default: `"2m"`) - Maximum runtime for a job's command.
|
|
458
464
|
Same format as `cooldown_period`. See
|
|
459
465
|
[Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
|
|
466
|
+
- **`shell`** (default: `/bin/sh`) - Interpreter used to run job commands. A
|
|
467
|
+
bare name is resolved on `PATH` (e.g. `"bash"`); an absolute path also
|
|
468
|
+
works. The command runs as `<shell> -c <command>`. Arguments are not
|
|
469
|
+
allowed - the value must be a single binary.
|
|
470
|
+
|
|
471
|
+
**Secrets:**
|
|
472
|
+
|
|
473
|
+
- **`secret_env`** (default: `[]`) - Names of environment variables to mark
|
|
474
|
+
as secrets config-wide. Each marked name is stripped from every job
|
|
475
|
+
command's environment, but a job still reads one only by listing it in its
|
|
476
|
+
own `secret_env`; `job-defaults` marks names, it does not grant them. See
|
|
477
|
+
[Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env).
|
|
460
478
|
|
|
461
479
|
### `[job.<name>]`
|
|
462
480
|
|
|
@@ -491,7 +509,8 @@ underscores.
|
|
|
491
509
|
**Branch and commit options:**
|
|
492
510
|
|
|
493
511
|
- **`base_branch`** (default: inherited) - Target branch for this job's
|
|
494
|
-
MR/PR.
|
|
512
|
+
MR/PR. May also be a jj revset expression such as `trunk()`, `root()`, or
|
|
513
|
+
a user-defined revset alias.
|
|
495
514
|
- **`branch_prefix`** (default: inherited) - Override the branch-name prefix
|
|
496
515
|
for this job only.
|
|
497
516
|
- **`mr_title_prefix`** (default: inherited) - Override the MR/PR title
|
|
@@ -526,9 +545,20 @@ underscores.
|
|
|
526
545
|
- **`timeout`** (default: inherited) - Maximum runtime for this job's
|
|
527
546
|
command. Set to `"0s"` to disable the timeout entirely. See
|
|
528
547
|
[Limiting job runtime with `timeout`](#limiting-job-runtime-with-timeout).
|
|
548
|
+
- **`shell`** (default: inherited) - Interpreter used to run this job's
|
|
549
|
+
command, overriding `job-defaults.shell`. See
|
|
550
|
+
[`job-defaults.shell`](#job-defaults).
|
|
529
551
|
- **`emits_jobs`** (default: `false`) - Generator job: the command writes
|
|
530
|
-
`*.toml` job fragments into `$
|
|
531
|
-
|
|
552
|
+
`*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
|
|
553
|
+
See [Generating jobs dynamically](#generating-jobs-dynamically).
|
|
554
|
+
|
|
555
|
+
**Secrets:**
|
|
556
|
+
|
|
557
|
+
- **`secret_env`** (default: `[]`) - Names of environment variables holding
|
|
558
|
+
secrets this job's command may read. A marked name is stripped from every
|
|
559
|
+
command's environment; only a job that lists it here has it injected back
|
|
560
|
+
for its own command. See
|
|
561
|
+
[Passing secrets to commands with `secret_env`](#passing-secrets-to-commands-with-secret_env).
|
|
532
562
|
|
|
533
563
|
### Stacking MRs
|
|
534
564
|
|
|
@@ -631,6 +661,165 @@ directory of `*.toml` files, and may be repeated to merge several sources;
|
|
|
631
661
|
later sources win. Explicit paths are resolved relative to the current
|
|
632
662
|
directory, not `--repo`.
|
|
633
663
|
|
|
664
|
+
### Variables passed to job commands
|
|
665
|
+
|
|
666
|
+
`repoactive` injects a few `RA_`-prefixed environment variables into every
|
|
667
|
+
job command's environment. These are distinct from the `REPOACTIVE_`
|
|
668
|
+
variables [above](#environment-variables), which configure `repoactive`
|
|
669
|
+
itself.
|
|
670
|
+
|
|
671
|
+
| Variable | Value | Set when |
|
|
672
|
+
| ---------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
673
|
+
| `RA_JOB_NAME` | The name of the job the command belongs to. | Always. |
|
|
674
|
+
| `RA_WORKSPACE_DIR` | The temporary jj workspace created for the job (also the command's working directory). | Always. |
|
|
675
|
+
| `RA_JOB_BRANCH` | The bookmark/branch repoactive uses for the job's output. | Always. |
|
|
676
|
+
| `RA_JOB_BASE_BRANCH` | The branch the job's MR targets (`base_branch`, or `trunk()` by default). | Always. |
|
|
677
|
+
| `RA_CONFIG_SOURCE_DIR` | The directory of the config file that defined the command. | The command came from a config file (not a `--set` override or a built-in default). |
|
|
678
|
+
| `RA_JOBS_DIR` | The directory a generator writes its `*.toml` job fragments into. | The command is a [generator](#generating-jobs-dynamically) (`emits_jobs`). |
|
|
679
|
+
|
|
680
|
+
#### `RA_JOB_NAME`
|
|
681
|
+
|
|
682
|
+
`RA_JOB_NAME` holds the name of the job the command belongs to. A command
|
|
683
|
+
shared by several jobs (for instance one set in `[job-defaults]` or emitted
|
|
684
|
+
by a generator) can read it to tell which job is running:
|
|
685
|
+
|
|
686
|
+
```toml
|
|
687
|
+
[job.lint]
|
|
688
|
+
command = "./run-check.sh $RA_JOB_NAME"
|
|
689
|
+
title = "run the shared check script for this job"
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
#### `RA_WORKSPACE_DIR`
|
|
693
|
+
|
|
694
|
+
A job's command runs in a temporary jj workspace, which is also its working
|
|
695
|
+
directory. `RA_WORKSPACE_DIR` names that directory explicitly, so a command
|
|
696
|
+
that changes directory can still find its way back:
|
|
697
|
+
|
|
698
|
+
```toml
|
|
699
|
+
[job.build]
|
|
700
|
+
command = "cd subdir && make && cp result $RA_WORKSPACE_DIR/subdir/"
|
|
701
|
+
title = "build in a subdirectory"
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
#### `RA_JOB_BRANCH`
|
|
705
|
+
|
|
706
|
+
`RA_JOB_BRANCH` holds the bookmark (git branch) repoactive uses for the
|
|
707
|
+
job's output, e.g. `repoactive/uv-lock-upgrade`. The command runs on a fresh
|
|
708
|
+
commit while that bookmark still points at the **previous** run's commit, so
|
|
709
|
+
a command can inspect what it produced last time - for example to build on
|
|
710
|
+
it or diff against it:
|
|
711
|
+
|
|
712
|
+
```toml
|
|
713
|
+
[job.notes]
|
|
714
|
+
command = "jj diff --from $RA_JOB_BRANCH --to @ > /tmp/delta || true; ./make-notes.sh"
|
|
715
|
+
title = "regenerate release notes from what changed since last run"
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
The bookmark may not exist yet: a job's first run, a run that produced no
|
|
719
|
+
diff, and a generator all leave it unset on the remote, so a command that
|
|
720
|
+
reads it should tolerate a missing ref.
|
|
721
|
+
|
|
722
|
+
#### `RA_JOB_BASE_BRANCH`
|
|
723
|
+
|
|
724
|
+
`RA_JOB_BASE_BRANCH` holds the branch the job's merge request targets - its
|
|
725
|
+
`base_branch`, or `trunk()` when that is unset. A command can use it to look
|
|
726
|
+
at only what changed relative to the target, for instance to lint or
|
|
727
|
+
reformat just the affected files:
|
|
728
|
+
|
|
729
|
+
```toml
|
|
730
|
+
[job.format]
|
|
731
|
+
command = "treefmt $(git diff --name-only $RA_JOB_BASE_BRANCH)"
|
|
732
|
+
title = "format files changed since the base branch"
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
This is the branch you _configured_ as the base. When a job stacks on
|
|
736
|
+
another via `depends_on`, its commit actually sits on top of that other
|
|
737
|
+
job's output, so `RA_JOB_BASE_BRANCH` names the ultimate target of the
|
|
738
|
+
stack, not the immediate parent.
|
|
739
|
+
|
|
740
|
+
#### `RA_CONFIG_SOURCE_DIR`
|
|
741
|
+
|
|
742
|
+
Because the command runs in that throwaway workspace rather than in the
|
|
743
|
+
directory its config lives in, a relative path cannot reach a helper file
|
|
744
|
+
kept beside the config. `RA_CONFIG_SOURCE_DIR` bridges that: it holds the
|
|
745
|
+
directory of the config file that defined the job's command:
|
|
746
|
+
|
|
747
|
+
- a job defined in `.repoactive.toml` gets the directory holding it (the
|
|
748
|
+
repo directory, by default);
|
|
749
|
+
- a job defined in `.repoactive.d/foo.toml` gets the `.repoactive.d`
|
|
750
|
+
directory.
|
|
751
|
+
|
|
752
|
+
```toml
|
|
753
|
+
[job.fixup]
|
|
754
|
+
command = "$RA_CONFIG_SOURCE_DIR/fixup.sh"
|
|
755
|
+
title = "run the fixup script kept beside the config"
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
The value is the config file's real location on disk, so it is the same
|
|
759
|
+
whether the file was discovered automatically or named with `-c`. A command
|
|
760
|
+
whose value came from a `--set` override has no config file, so
|
|
761
|
+
`RA_CONFIG_SOURCE_DIR` is unset for it.
|
|
762
|
+
|
|
763
|
+
### Passing secrets to commands with `secret_env`
|
|
764
|
+
|
|
765
|
+
A job command inherits `repoactive`'s environment, so a secret exported
|
|
766
|
+
before the run (an LLM key, a registry token) reaches it. Left unmanaged
|
|
767
|
+
that secret reaches **every** job, whether it needs it or not. `secret_env`
|
|
768
|
+
scopes secrets to the jobs that ask for them.
|
|
769
|
+
|
|
770
|
+
The field separates two things:
|
|
771
|
+
|
|
772
|
+
- **Marking** - naming a variable in _any_ `secret_env` (a job's or
|
|
773
|
+
`[job-defaults]`') marks it a secret and **removes it from every job
|
|
774
|
+
command's environment**.
|
|
775
|
+
- **Granting** - a job reads a marked secret back only by listing it in
|
|
776
|
+
**its own** `secret_env`. `[job-defaults].secret_env` marks names but
|
|
777
|
+
grants to no job.
|
|
778
|
+
|
|
779
|
+
So a secret is present only in the jobs that name it, never ambient:
|
|
780
|
+
|
|
781
|
+
```toml
|
|
782
|
+
[job-defaults]
|
|
783
|
+
secret_env = ["OPENAI_API_KEY"]
|
|
784
|
+
|
|
785
|
+
[job.rewrite-docs]
|
|
786
|
+
command = "./llm-rewrite.sh"
|
|
787
|
+
title = "docs: rewrite with the LLM"
|
|
788
|
+
secret_env = ["OPENAI_API_KEY"]
|
|
789
|
+
|
|
790
|
+
[job.format]
|
|
791
|
+
command = "treefmt"
|
|
792
|
+
title = "format the tree"
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Here `OPENAI_API_KEY` is marked in `[job-defaults]`, so `format` (which does
|
|
796
|
+
not grant it) runs without it in its environment; only `rewrite-docs`, which
|
|
797
|
+
lists it in its own `secret_env`, can read it.
|
|
798
|
+
|
|
799
|
+
A job that grants a secret which is **unset** in `repoactive`'s environment
|
|
800
|
+
fails at its start with a clear error, rather than letting the command fail
|
|
801
|
+
obscurely later. Secret **values** must never be written in config - a
|
|
802
|
+
`.repoactive.toml` is checked into the repo; `secret_env` names variables,
|
|
803
|
+
your environment (or your CI's secret store) supplies the values. The `RA_`
|
|
804
|
+
and `REPOACTIVE_` prefixes are reserved and rejected.
|
|
805
|
+
|
|
806
|
+
A [generated job](#generating-jobs-dynamically) may only grant a secret that
|
|
807
|
+
the static config already marks; an emitted `secret_env` naming a variable
|
|
808
|
+
no static job or `[job-defaults]` marked is rejected. Mark such a secret up
|
|
809
|
+
front in `[job-defaults].secret_env` (or on the generator), and the emitted
|
|
810
|
+
jobs grant it.
|
|
811
|
+
|
|
812
|
+
A platform's `token_env` is normally stripped from every command
|
|
813
|
+
([the platform token](#platformname) is kept out of jobs). A job that
|
|
814
|
+
genuinely needs it can opt back in by naming that variable in its own
|
|
815
|
+
`secret_env`; only that job then receives it.
|
|
816
|
+
|
|
817
|
+
> **Note:** `secret_env` scopes _which jobs_ can read a secret. It does not
|
|
818
|
+
> yet redact secret values from a command's captured output, so a command
|
|
819
|
+
> that echoes a secret it was granted can still write it into the commit
|
|
820
|
+
> message. Avoid printing granted secrets. See
|
|
821
|
+
> [ADR 0017](docs/adr/0017-secret-env-redaction.md).
|
|
822
|
+
|
|
634
823
|
### Overriding values on the command line
|
|
635
824
|
|
|
636
825
|
`--set NAME=VALUE`/`-s` tweaks individual config values without editing a
|
|
@@ -804,12 +993,12 @@ changed anything in the current run, the job is skipped.
|
|
|
804
993
|
|
|
805
994
|
```toml
|
|
806
995
|
[job.prek-autoupdate]
|
|
807
|
-
command = "
|
|
996
|
+
command = "uvx prek autoupdate"
|
|
808
997
|
title = "ci: update pre-commit hooks"
|
|
809
998
|
tags = ["weekly"]
|
|
810
999
|
|
|
811
1000
|
[job.prek-run-all]
|
|
812
|
-
command = "sh -c '
|
|
1001
|
+
command = "sh -c 'uvx prek run -a; true'"
|
|
813
1002
|
title = "ci: apply pre-commit fixes"
|
|
814
1003
|
depends_on = ["prek-autoupdate"]
|
|
815
1004
|
run_only_if_changed = ["prek-autoupdate"]
|
|
@@ -935,8 +1124,8 @@ job per package in a monorepo, one per entry in a manifest - and you don't
|
|
|
935
1124
|
want to hand-maintain them. A **generator** is an ordinary `[job.<name>]`
|
|
936
1125
|
with `emits_jobs = true`. Instead of producing a diff, its command writes
|
|
937
1126
|
one or more `*.toml` job fragments into the directory named by the
|
|
938
|
-
`
|
|
939
|
-
|
|
1127
|
+
`RA_JOBS_DIR` environment variable, and the jobs it emits run in the same
|
|
1128
|
+
invocation.
|
|
940
1129
|
|
|
941
1130
|
```toml
|
|
942
1131
|
[job.per-package]
|
|
@@ -951,7 +1140,7 @@ cooldown_period = "7d"
|
|
|
951
1140
|
The command writes fragments using the normal `[job.<name>]` syntax, e.g.
|
|
952
1141
|
|
|
953
1142
|
```toml
|
|
954
|
-
# $
|
|
1143
|
+
# $RA_JOBS_DIR/pkg-a.toml
|
|
955
1144
|
[job.upgrade-pkg-a]
|
|
956
1145
|
command = "uv lock --upgrade --package pkg-a"
|
|
957
1146
|
title = "build: upgrade pkg-a"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Render and strip boxquote.el-style boxes used to embed command output in messages."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# A boxquote block: a ``,----`` opening line through its closing ``` `---- ``` line.
|
|
6
|
+
# DOTALL lets ``.*?`` span the body lines; MULTILINE anchors both delimiters to a
|
|
7
|
+
# line of their own. Non-greedy so each block stops at its own closing line.
|
|
8
|
+
_BOXQUOTE_BLOCK = re.compile(r"^,----.*?^`----[ \t]*$", re.MULTILINE | re.DOTALL)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def boxquote(msg: str, title: str = "") -> str:
|
|
12
|
+
"""Render ``msg`` inside a boxquote.el-style box.
|
|
13
|
+
|
|
14
|
+
The box opens with ``,----[ title ]`` (or just ``,----`` when ``title`` is
|
|
15
|
+
empty), each line of ``msg`` is prefixed with ``| ``, and the box closes
|
|
16
|
+
with ``` `---- ```.
|
|
17
|
+
|
|
18
|
+
A multi-line ``title`` (e.g. a multi-line job command) is bracketed across
|
|
19
|
+
several lines instead of jammed onto the opening line: the first title line
|
|
20
|
+
follows ``,----[ ``, the remaining lines are ``|``-prefixed and indented to
|
|
21
|
+
align under that first line, the closing ``]`` trails the last of them, and
|
|
22
|
+
a blank ``|`` line then separates the bracketed title from ``msg``. A
|
|
23
|
+
trailing newline on ``title`` is dropped so it does not produce an empty
|
|
24
|
+
final title line.
|
|
25
|
+
"""
|
|
26
|
+
body = "\n".join(f"| {line}" for line in msg.splitlines())
|
|
27
|
+
title = title.rstrip("\n")
|
|
28
|
+
if "\n" not in title:
|
|
29
|
+
top = f",----[ {title} ]" if title else ",----"
|
|
30
|
+
return f"{top}\n{body}\n`----"
|
|
31
|
+
open_bracket = ",----[ "
|
|
32
|
+
# Indent continuation lines so the title text aligns under the first line;
|
|
33
|
+
# the "|" replaces the leading "," so the bar still runs down the left edge.
|
|
34
|
+
indent = "|" + " " * (len(open_bracket) - 1)
|
|
35
|
+
head, *rest = title.split("\n")
|
|
36
|
+
header_lines = [f"{open_bracket}{head}", *(f"{indent}{line}" for line in rest[:-1])]
|
|
37
|
+
header_lines.append(f"{indent}{rest[-1]} ]")
|
|
38
|
+
header = "\n".join(header_lines)
|
|
39
|
+
return f"{header}\n|\n{body}\n`----"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def strip_boxquotes(text: str) -> str:
|
|
43
|
+
"""Remove every boxquote block (see :func:`boxquote`) from ``text``.
|
|
44
|
+
|
|
45
|
+
A block runs from a ``,----`` opening line to its closing ``` `---- ``` line;
|
|
46
|
+
the surrounding text is kept. Blank lines exposed by a removed block are
|
|
47
|
+
collapsed so paragraphs stay separated by a single blank line, and leading
|
|
48
|
+
and trailing blank lines are trimmed.
|
|
49
|
+
"""
|
|
50
|
+
without = _BOXQUOTE_BLOCK.sub("", text)
|
|
51
|
+
return re.sub(r"\n{3,}", "\n\n", without).strip("\n")
|
|
@@ -33,7 +33,12 @@ logger = logging.getLogger(__name__)
|
|
|
33
33
|
_DURATION_RE = re.compile(r"^(\d+)([smhdw])$")
|
|
34
34
|
_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
35
35
|
_TAG_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
36
|
-
|
|
36
|
+
# Environment-variable name grammar for secret_env entries. The RA_ and
|
|
37
|
+
# REPOACTIVE_ prefixes are repoactive's own (ADR 0016) and are rejected.
|
|
38
|
+
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
39
|
+
_RESERVED_ENV_PREFIXES = ("RA_", "REPOACTIVE_")
|
|
40
|
+
# An empty prefix is allowed (branch name == job name); hence * not +.
|
|
41
|
+
_BRANCH_PREFIX_RE = re.compile(r"^(?!/)(?!.*//)[a-zA-Z0-9_\-/]*$")
|
|
37
42
|
_DURATION_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
|
|
38
43
|
|
|
39
44
|
# Reserved tags driving job selection. A plain job carries DEFAULT_TAG, which is
|
|
@@ -63,6 +68,17 @@ class InvalidBranchPrefixError(ValueError):
|
|
|
63
68
|
)
|
|
64
69
|
|
|
65
70
|
|
|
71
|
+
class InvalidShellError(ValueError):
|
|
72
|
+
"""Raised when a shell value is not a single interpreter binary."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, value: str) -> None:
|
|
75
|
+
super().__init__(
|
|
76
|
+
f"invalid shell {value!r}: must be a single interpreter binary "
|
|
77
|
+
"(a name resolved on PATH like 'bash', or an absolute path); "
|
|
78
|
+
"arguments are not allowed"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
66
82
|
class InvalidJobNameError(ValueError):
|
|
67
83
|
"""Raised when a job name contains disallowed characters."""
|
|
68
84
|
|
|
@@ -79,6 +95,27 @@ class InvalidTagError(ValueError):
|
|
|
79
95
|
super().__init__(f"invalid tag {tag!r}: only letters, digits, '-', and '_' are allowed")
|
|
80
96
|
|
|
81
97
|
|
|
98
|
+
class InvalidSecretEnvNameError(ValueError):
|
|
99
|
+
"""Raised when a secret_env entry is not a valid environment-variable name."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str) -> None:
|
|
102
|
+
super().__init__(
|
|
103
|
+
f"invalid secret_env name {name!r}: must be a valid environment-variable name "
|
|
104
|
+
"(letters, digits, and '_', not starting with a digit)"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ReservedSecretEnvNameError(ValueError):
|
|
109
|
+
"""Raised when a secret_env entry uses a prefix reserved for repoactive."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, name: str) -> None:
|
|
112
|
+
super().__init__(
|
|
113
|
+
f"invalid secret_env name {name!r}: the "
|
|
114
|
+
f"{' and '.join(_RESERVED_ENV_PREFIXES)} prefixes are reserved for repoactive "
|
|
115
|
+
"(see docs/adr/0016-injected-env-var-prefix.md)"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
82
119
|
class NewlineInTitleError(ValueError):
|
|
83
120
|
"""Raised when a title or prefix field contains a newline character."""
|
|
84
121
|
|
|
@@ -157,6 +194,20 @@ class GeneratedByInBodyError(ValueError):
|
|
|
157
194
|
)
|
|
158
195
|
|
|
159
196
|
|
|
197
|
+
class ConfigSourceDirInBodyError(ValueError):
|
|
198
|
+
"""Raised when a [job.<name>] table sets a 'config_source_dir' field.
|
|
199
|
+
|
|
200
|
+
'config_source_dir' is set by repoactive to the directory of the config
|
|
201
|
+
source that defined the job's command; it is not a user-facing config field.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, name: str) -> None:
|
|
205
|
+
super().__init__(
|
|
206
|
+
f"job {name!r} must not set 'config_source_dir'; "
|
|
207
|
+
"that field is set by repoactive to the job's config directory"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
160
211
|
class DuplicatePlatformHostError(ValueError):
|
|
161
212
|
"""Raised when two platforms resolve to the same host.
|
|
162
213
|
|
|
@@ -235,6 +286,16 @@ def _validate_duration(value: str) -> str:
|
|
|
235
286
|
return value
|
|
236
287
|
|
|
237
288
|
|
|
289
|
+
def _validate_shell(value: str) -> str:
|
|
290
|
+
# The value maps to subprocess' executable= (run as `<shell> -c <command>`),
|
|
291
|
+
# so it must be a single binary, not an argv with flags. Reject empty and any
|
|
292
|
+
# whitespace so a "full argv" like "bash -e" fails at config load instead of
|
|
293
|
+
# silently looking up a binary literally named "bash -e".
|
|
294
|
+
if not value or any(c.isspace() for c in value):
|
|
295
|
+
raise InvalidShellError(value)
|
|
296
|
+
return value
|
|
297
|
+
|
|
298
|
+
|
|
238
299
|
def _validate_single_line(value: str, info: ValidationInfo) -> str:
|
|
239
300
|
if "\n" in value:
|
|
240
301
|
assert info.field_name is not None
|
|
@@ -242,8 +303,19 @@ def _validate_single_line(value: str, info: ValidationInfo) -> str:
|
|
|
242
303
|
return value
|
|
243
304
|
|
|
244
305
|
|
|
306
|
+
def _validate_secret_env(value: list[str]) -> list[str]:
|
|
307
|
+
for name in value:
|
|
308
|
+
if not _ENV_NAME_RE.match(name):
|
|
309
|
+
raise InvalidSecretEnvNameError(name)
|
|
310
|
+
if name.startswith(_RESERVED_ENV_PREFIXES):
|
|
311
|
+
raise ReservedSecretEnvNameError(name)
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
|
|
245
315
|
_BranchPrefix = Annotated[str, AfterValidator(_validate_branch_prefix)]
|
|
246
316
|
_Duration = Annotated[str, AfterValidator(_validate_duration)]
|
|
317
|
+
_Shell = Annotated[str, AfterValidator(_validate_shell)]
|
|
318
|
+
_SecretEnv = Annotated[list[str], AfterValidator(_validate_secret_env)]
|
|
247
319
|
# Titles and title prefixes end up in commit subjects and MR titles, which are
|
|
248
320
|
# single-line by nature.
|
|
249
321
|
_SingleLine = Annotated[str, AfterValidator(_validate_single_line)]
|
|
@@ -265,6 +337,16 @@ class JobDefaults(BaseModel):
|
|
|
265
337
|
timeout: _Duration | None = "2m"
|
|
266
338
|
auto_merge: bool = False
|
|
267
339
|
required_approvals: int | None = Field(default=None, ge=0)
|
|
340
|
+
# Interpreter for job commands, passed to subprocess as executable= and run as
|
|
341
|
+
# `<shell> -c <command>`. None means /bin/sh (the shell=True default). May be a
|
|
342
|
+
# bare name resolved on PATH (e.g. "bash") or an absolute path.
|
|
343
|
+
shell: _Shell | None = None
|
|
344
|
+
# Names of environment variables to mark as secrets config-wide: each is
|
|
345
|
+
# stripped from every job command's environment, but a job still reads one
|
|
346
|
+
# only by listing it in its own secret_env. Marks names, never grants them;
|
|
347
|
+
# deliberately not inherited into jobs like the defaulted fields above. See
|
|
348
|
+
# docs/adr/0017-secret-env-redaction.md.
|
|
349
|
+
secret_env: _SecretEnv = Field(default_factory=list)
|
|
268
350
|
|
|
269
351
|
|
|
270
352
|
# Fields Job.resolve fills in from JobDefaults when the job does not set them
|
|
@@ -278,6 +360,7 @@ _DEFAULTED_FIELDS = (
|
|
|
278
360
|
"timeout",
|
|
279
361
|
"auto_merge",
|
|
280
362
|
"required_approvals",
|
|
363
|
+
"shell",
|
|
281
364
|
)
|
|
282
365
|
|
|
283
366
|
|
|
@@ -286,6 +369,12 @@ class Job(BaseModel):
|
|
|
286
369
|
|
|
287
370
|
name: str
|
|
288
371
|
command: str
|
|
372
|
+
# Names of environment variables holding secrets this job's command may read.
|
|
373
|
+
# A name listed in any secret_env (here, another job's, or job-defaults') is
|
|
374
|
+
# stripped from every command's base environment; only a job that lists a name
|
|
375
|
+
# here has that variable injected back for its own command. See
|
|
376
|
+
# docs/adr/0017-secret-env-redaction.md.
|
|
377
|
+
secret_env: _SecretEnv = Field(default_factory=list)
|
|
289
378
|
title: _SingleLine
|
|
290
379
|
description: str | None = None
|
|
291
380
|
base_branch: str | None = None
|
|
@@ -303,13 +392,18 @@ class Job(BaseModel):
|
|
|
303
392
|
output_in_commit: bool = True
|
|
304
393
|
# When true, the command does not produce a diff to commit; instead it writes
|
|
305
394
|
# one or more *.toml job fragments into the directory named by the
|
|
306
|
-
#
|
|
395
|
+
# RA_JOBS_DIR environment variable, and those jobs are run in the same
|
|
307
396
|
# invocation. See docs/adr/0004-job-generators.md.
|
|
308
397
|
emits_jobs: bool = False
|
|
309
398
|
# Set by repoactive (never written in config) on jobs produced by a generator:
|
|
310
399
|
# the generator's name, recorded as a second Repoactive-Job trailer so the
|
|
311
400
|
# generator gets a meaningful cooldown over the whole fan-out.
|
|
312
401
|
generated_by: str | None = None
|
|
402
|
+
# Set by repoactive (never written in config): the absolute directory of the
|
|
403
|
+
# config source that defined this job's command, exported to the command as
|
|
404
|
+
# RA_CONFIG_SOURCE_DIR so it can reach files kept beside its config. None when
|
|
405
|
+
# the command came from a --set override or the built-in defaults.
|
|
406
|
+
config_source_dir: str | None = None
|
|
313
407
|
|
|
314
408
|
# the following fields will be resolved from the defaults
|
|
315
409
|
branch_prefix: _BranchPrefix | None = None
|
|
@@ -320,6 +414,7 @@ class Job(BaseModel):
|
|
|
320
414
|
timeout: _Duration | None = None
|
|
321
415
|
auto_merge: bool | None = None
|
|
322
416
|
required_approvals: int | None = Field(default=None, ge=0)
|
|
417
|
+
shell: _Shell | None = None
|
|
323
418
|
|
|
324
419
|
@field_validator("name")
|
|
325
420
|
@classmethod
|
|
@@ -432,6 +527,8 @@ class Config(BaseModel):
|
|
|
432
527
|
raise JobNameInBodyError(name)
|
|
433
528
|
if "generated_by" in body:
|
|
434
529
|
raise GeneratedByInBodyError(name)
|
|
530
|
+
if "config_source_dir" in body:
|
|
531
|
+
raise ConfigSourceDirInBodyError(name)
|
|
435
532
|
jobs.append({**body, "name": name})
|
|
436
533
|
return jobs
|
|
437
534
|
|
|
@@ -517,8 +614,18 @@ class Config(BaseModel):
|
|
|
517
614
|
return {job.branch_name() for job in self._resolved_jobs()}
|
|
518
615
|
|
|
519
616
|
def base_branches(self) -> set[str]:
|
|
520
|
-
"""
|
|
521
|
-
|
|
617
|
+
"""Bookmark names a job uses as base_branch.
|
|
618
|
+
|
|
619
|
+
These are the branches to track with ``jj bookmark track``. Values that
|
|
620
|
+
look like a revset function call (``trunk()``, ``root()``, or a
|
|
621
|
+
user-defined revset alias) are excluded: they are valid base branches but
|
|
622
|
+
not bookmarks, so tracking them would fail.
|
|
623
|
+
"""
|
|
624
|
+
return {
|
|
625
|
+
job.base_branch
|
|
626
|
+
for job in self._resolved_jobs()
|
|
627
|
+
if job.base_branch and "()" not in job.base_branch
|
|
628
|
+
}
|
|
522
629
|
|
|
523
630
|
def token_env_names(self) -> set[str]:
|
|
524
631
|
"""Names of the env vars holding platform API tokens.
|
|
@@ -529,6 +636,19 @@ class Config(BaseModel):
|
|
|
529
636
|
"""
|
|
530
637
|
return {p.token_env for p in self.platforms}
|
|
531
638
|
|
|
639
|
+
def marked_secret_names(self) -> set[str]:
|
|
640
|
+
"""Names marked as managed secrets: every secret_env in the merged config.
|
|
641
|
+
|
|
642
|
+
The union of ``job-defaults`` and every job's ``secret_env``. A marked
|
|
643
|
+
name is stripped from every job command's base environment; a job reads
|
|
644
|
+
it back only by listing it in its own ``secret_env`` (see
|
|
645
|
+
runner._run_command and docs/adr/0017-secret-env-redaction.md).
|
|
646
|
+
"""
|
|
647
|
+
names = set(self.job_defaults.secret_env)
|
|
648
|
+
for job in self.jobs:
|
|
649
|
+
names.update(job.secret_env)
|
|
650
|
+
return names
|
|
651
|
+
|
|
532
652
|
|
|
533
653
|
class ConfigShape(BaseModel):
|
|
534
654
|
"""Structural shape of a raw config source, validated before merging.
|
|
@@ -657,6 +777,10 @@ class _ConfigSource:
|
|
|
657
777
|
|
|
658
778
|
label: str
|
|
659
779
|
data: dict
|
|
780
|
+
# Absolute directory of the file this source was read from, or None for
|
|
781
|
+
# sources without a file (built-in defaults, --set overrides). Exposed to a
|
|
782
|
+
# command as RA_CONFIG_SOURCE_DIR for the job whose command this source sets.
|
|
783
|
+
source_dir: Path | None = None
|
|
660
784
|
|
|
661
785
|
|
|
662
786
|
def _built_in_defaults() -> list[_ConfigSource]:
|
|
@@ -693,7 +817,9 @@ def _parse_override(text: str) -> _ConfigSource:
|
|
|
693
817
|
|
|
694
818
|
def _read_toml_file(path: Path) -> _ConfigSource:
|
|
695
819
|
try:
|
|
696
|
-
return _ConfigSource(
|
|
820
|
+
return _ConfigSource(
|
|
821
|
+
str(path), tomllib.loads(path.read_text()), source_dir=path.resolve().parent
|
|
822
|
+
)
|
|
697
823
|
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
698
824
|
raise ConfigError(str(path), e) from e
|
|
699
825
|
|
|
@@ -710,12 +836,19 @@ def load_config(paths: list[Path], overrides: list[str] | None = None) -> Config
|
|
|
710
836
|
|
|
711
837
|
def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
|
|
712
838
|
merged = {}
|
|
839
|
+
# Directory of the config source that last set each job's command, keyed by
|
|
840
|
+
# job name. Exported to the command as RA_CONFIG_SOURCE_DIR (see
|
|
841
|
+
# Job.config_source_dir). None when that source has no file (--set, built-in).
|
|
842
|
+
command_source_dir: dict[str, Path | None] = {}
|
|
713
843
|
for source in sources:
|
|
714
844
|
data = source.data
|
|
715
845
|
try:
|
|
716
846
|
# Reject odd shapes (e.g. job.foo = "hello") up front; the merge
|
|
717
847
|
# helpers assume tables of tables and would fail cryptically.
|
|
718
848
|
shape = ConfigShape.model_validate(data)
|
|
849
|
+
for name, body in shape.job.items():
|
|
850
|
+
if "command" in body:
|
|
851
|
+
command_source_dir[name] = source.source_dir
|
|
719
852
|
jobs = merge_jobs(base=merged.get("job", {}), override=shape.job)
|
|
720
853
|
platforms = merge_platforms(
|
|
721
854
|
base=merged.get("platform", {}),
|
|
@@ -732,5 +865,18 @@ def _merge_config(sources: Iterable[_ConfigSource]) -> Config:
|
|
|
732
865
|
except (ValueError, ValidationError) as e:
|
|
733
866
|
raise ConfigError(source.label, e) from e
|
|
734
867
|
config = Config.model_validate(merged)
|
|
868
|
+
# Stamp each job with the directory of the source that defined its command.
|
|
869
|
+
# Done after validation (not injected into the raw table) so it bypasses the
|
|
870
|
+
# ConfigSourceDirInBodyError guard, which only rejects user-written values.
|
|
871
|
+
config = config.model_copy(
|
|
872
|
+
update={
|
|
873
|
+
"jobs": [
|
|
874
|
+
job.model_copy(update={"config_source_dir": str(command_source_dir[job.name])})
|
|
875
|
+
if command_source_dir.get(job.name) is not None
|
|
876
|
+
else job
|
|
877
|
+
for job in config.jobs
|
|
878
|
+
]
|
|
879
|
+
}
|
|
880
|
+
)
|
|
735
881
|
logger.debug("loaded config: %s", config.model_dump_json(indent=2))
|
|
736
882
|
return config
|
|
@@ -49,7 +49,37 @@ logger = logging.getLogger(__name__)
|
|
|
49
49
|
|
|
50
50
|
# Environment variable naming the directory a generator (``emits_jobs``) command
|
|
51
51
|
# writes its ``*.toml`` job fragments into. See docs/adr/0004-job-generators.md.
|
|
52
|
-
|
|
52
|
+
RA_JOBS_DIR_ENV = "RA_JOBS_DIR"
|
|
53
|
+
|
|
54
|
+
# Environment variable exposing to a job command the directory of the config
|
|
55
|
+
# source that defined the command (Job.config_source_dir), so the command can
|
|
56
|
+
# reach files kept beside its config. See docs/adr/0016-injected-env-var-prefix.md.
|
|
57
|
+
RA_CONFIG_SOURCE_DIR_ENV = "RA_CONFIG_SOURCE_DIR"
|
|
58
|
+
|
|
59
|
+
# Environment variable exposing to a job command the throwaway jj workspace
|
|
60
|
+
# repoactive created for it. This is always the command's working directory, but
|
|
61
|
+
# naming it explicitly lets a command that changes directory find its way back.
|
|
62
|
+
RA_WORKSPACE_DIR_ENV = "RA_WORKSPACE_DIR"
|
|
63
|
+
|
|
64
|
+
# Environment variable exposing to a job command the bookmark/branch repoactive
|
|
65
|
+
# uses for the job's output (Job.branch_name). The command runs on a fresh commit
|
|
66
|
+
# while this bookmark still points at the previous run's commit, so the command
|
|
67
|
+
# can inspect what it produced last time (e.g. `git diff $RA_JOB_BRANCH`). The
|
|
68
|
+
# bookmark may not exist yet: a first run, a run that produced no diff, or a
|
|
69
|
+
# generator never creates it.
|
|
70
|
+
RA_JOB_BRANCH_ENV = "RA_JOB_BRANCH"
|
|
71
|
+
|
|
72
|
+
# Environment variable exposing to a job command the name of the job it belongs
|
|
73
|
+
# to (Job.name), so a command shared by several jobs can tell which one is
|
|
74
|
+
# running (e.g. to label its output).
|
|
75
|
+
RA_JOB_NAME_ENV = "RA_JOB_NAME"
|
|
76
|
+
|
|
77
|
+
# Environment variable exposing to a job command the branch the job's MR targets
|
|
78
|
+
# (Job.base_branch, or "trunk()" by default), so a command can diff against its
|
|
79
|
+
# target (e.g. `git diff $RA_JOB_BASE_BRANCH`). This is the *configured* base; for a
|
|
80
|
+
# stacked job (depends_on) the immediate parent commit is another job's output,
|
|
81
|
+
# not this value.
|
|
82
|
+
RA_JOB_BASE_BRANCH_ENV = "RA_JOB_BASE_BRANCH"
|
|
53
83
|
|
|
54
84
|
# Fields an emitted job inherits from its generator when the emitted entry does
|
|
55
85
|
# not set them itself (``tags`` and ``depends_on`` are handled separately because
|
|
@@ -100,6 +130,18 @@ class CommandError(RuntimeError):
|
|
|
100
130
|
self.elapsed = elapsed
|
|
101
131
|
|
|
102
132
|
|
|
133
|
+
class MissingSecretError(RuntimeError):
|
|
134
|
+
"""A job granted a secret_env variable that is unset in repoactive's environment.
|
|
135
|
+
|
|
136
|
+
Raised before the command runs so the failure is legible (ADR 0017) instead
|
|
137
|
+
of surfacing as an obscure command error later.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, name: str) -> None:
|
|
141
|
+
super().__init__(f"requires secret {name}, not set")
|
|
142
|
+
self.name = name
|
|
143
|
+
|
|
144
|
+
|
|
103
145
|
class GeneratedJobError(ValueError):
|
|
104
146
|
"""Raised when a generator emits an invalid job set.
|
|
105
147
|
|
|
@@ -206,10 +248,12 @@ class RunContext:
|
|
|
206
248
|
plan: UpdatePlan = field(default_factory=UpdatePlan)
|
|
207
249
|
|
|
208
250
|
@property
|
|
209
|
-
def
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
251
|
+
def stripped_env_names(self) -> frozenset[str]:
|
|
252
|
+
# Names removed from every job command's base environment: the platform
|
|
253
|
+
# tokens (ADR 0006) plus every marked secret (ADR 0017). A job reads a
|
|
254
|
+
# marked secret back only by granting it in its own secret_env; see
|
|
255
|
+
# _resolve_granted_secrets.
|
|
256
|
+
return frozenset(self.config.token_env_names() | self.config.marked_secret_names())
|
|
213
257
|
|
|
214
258
|
|
|
215
259
|
def _compute_parents(job: Job, results: dict[str, JobResult]) -> list[str]:
|
|
@@ -238,22 +282,63 @@ def _kill_process_group(proc: subprocess.Popen[str]) -> None:
|
|
|
238
282
|
|
|
239
283
|
|
|
240
284
|
def _command_env(
|
|
241
|
-
*,
|
|
285
|
+
*,
|
|
286
|
+
extra_env: dict[str, str] | None,
|
|
287
|
+
stripped_env_names: frozenset[str],
|
|
288
|
+
granted_secret_env: dict[str, str] | None = None,
|
|
242
289
|
) -> dict[str, str]:
|
|
243
290
|
"""Build the environment a job command runs in.
|
|
244
291
|
|
|
245
292
|
Starts from the inherited environment (so the command still sees PATH etc.),
|
|
246
|
-
drops the platform
|
|
247
|
-
|
|
248
|
-
(
|
|
249
|
-
|
|
293
|
+
drops ``stripped_env_names`` (the platform tokens of ADR 0006 plus every
|
|
294
|
+
marked secret of ADR 0017), injects back only the secrets this job granted
|
|
295
|
+
(``granted_secret_env``), then layers on ``extra_env`` (the RA_* variables,
|
|
296
|
+
e.g. RA_JOBS_DIR for a generator) last so repoactive's own variables win.
|
|
250
297
|
"""
|
|
251
|
-
env = {k: v for k, v in os.environ.items() if k not in
|
|
298
|
+
env = {k: v for k, v in os.environ.items() if k not in stripped_env_names}
|
|
299
|
+
if granted_secret_env:
|
|
300
|
+
env.update(granted_secret_env)
|
|
252
301
|
if extra_env:
|
|
253
302
|
env.update(extra_env)
|
|
254
303
|
return env
|
|
255
304
|
|
|
256
305
|
|
|
306
|
+
def _resolve_granted_secrets(job: Job) -> dict[str, str]:
|
|
307
|
+
"""Values for the secrets ``job`` grants, read from repoactive's own environment.
|
|
308
|
+
|
|
309
|
+
Only the names in the job's own ``secret_env`` are granted; ``job-defaults``
|
|
310
|
+
marks names but grants to no job (ADR 0017). Raises MissingSecretError on the
|
|
311
|
+
first granted name that is unset, so a misconfigured job fails legibly before
|
|
312
|
+
its command runs rather than deep inside it.
|
|
313
|
+
"""
|
|
314
|
+
granted: dict[str, str] = {}
|
|
315
|
+
for name in job.secret_env:
|
|
316
|
+
try:
|
|
317
|
+
granted[name] = os.environ[name]
|
|
318
|
+
except KeyError:
|
|
319
|
+
raise MissingSecretError(name) from None
|
|
320
|
+
return granted
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _job_extra_env(job: Job, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
324
|
+
"""Extra environment for ``job``'s command: its name, branches, config dir, ``extra``.
|
|
325
|
+
|
|
326
|
+
Always adds RA_JOB_NAME (the job's name), RA_JOB_BRANCH (the bookmark
|
|
327
|
+
repoactive uses for the job's output), and RA_JOB_BASE_BRANCH (the branch the
|
|
328
|
+
job's MR targets). Adds RA_CONFIG_SOURCE_DIR when the job has a
|
|
329
|
+
``config_source_dir`` (the directory of the config source that defined its
|
|
330
|
+
command), on top of any caller-supplied entries (e.g. RA_JOBS_DIR for a
|
|
331
|
+
generator).
|
|
332
|
+
"""
|
|
333
|
+
env = dict(extra or {})
|
|
334
|
+
env[RA_JOB_NAME_ENV] = job.name
|
|
335
|
+
env[RA_JOB_BRANCH_ENV] = job.branch_name()
|
|
336
|
+
env[RA_JOB_BASE_BRANCH_ENV] = job.base_branch or "trunk()"
|
|
337
|
+
if job.config_source_dir is not None:
|
|
338
|
+
env[RA_CONFIG_SOURCE_DIR_ENV] = job.config_source_dir
|
|
339
|
+
return env
|
|
340
|
+
|
|
341
|
+
|
|
257
342
|
@contextlib.contextmanager
|
|
258
343
|
def _watchdog(proc: subprocess.Popen[str], timeout: float | None) -> Generator[threading.Event]:
|
|
259
344
|
"""Kill ``proc``'s process group if it outlives ``timeout`` seconds.
|
|
@@ -298,6 +383,9 @@ def _spawn(job: Job, cwd: Path, env: dict[str, str]) -> Generator[subprocess.Pop
|
|
|
298
383
|
proc = subprocess.Popen(
|
|
299
384
|
job.command,
|
|
300
385
|
shell=True,
|
|
386
|
+
# None keeps subprocess' shell=True default of /bin/sh; a configured shell
|
|
387
|
+
# runs the command as `<shell> -c <command>` (see Job.shell).
|
|
388
|
+
executable=job.shell,
|
|
301
389
|
cwd=cwd,
|
|
302
390
|
stdout=subprocess.PIPE,
|
|
303
391
|
stderr=subprocess.STDOUT,
|
|
@@ -323,11 +411,19 @@ def _run_command(
|
|
|
323
411
|
job: Job,
|
|
324
412
|
cwd: Path,
|
|
325
413
|
*,
|
|
326
|
-
|
|
414
|
+
stripped_env_names: frozenset[str] = frozenset(),
|
|
327
415
|
extra_env: dict[str, str] | None = None,
|
|
328
416
|
) -> CommandResult:
|
|
329
417
|
start = time.monotonic()
|
|
330
|
-
|
|
418
|
+
# Fail before the command runs if a granted secret is unset (ADR 0017).
|
|
419
|
+
granted_secret_env = _resolve_granted_secrets(job)
|
|
420
|
+
# The workspace is always cwd, but expose it explicitly so a command that
|
|
421
|
+
# cd's elsewhere can still find the workspace repoactive prepared for it.
|
|
422
|
+
env = _command_env(
|
|
423
|
+
extra_env={**(extra_env or {}), RA_WORKSPACE_DIR_ENV: str(cwd)},
|
|
424
|
+
stripped_env_names=stripped_env_names,
|
|
425
|
+
granted_secret_env=granted_secret_env,
|
|
426
|
+
)
|
|
331
427
|
|
|
332
428
|
# Stream the merged stdout/stderr line by line: keep the full output (needed
|
|
333
429
|
# for the commit message and the success result) while feeding a live tail of
|
|
@@ -498,7 +594,12 @@ def run_job(
|
|
|
498
594
|
repo.git_sync_head()
|
|
499
595
|
logger.debug("[%s] running command: %s", job.name, job.command)
|
|
500
596
|
try:
|
|
501
|
-
command_result = _run_command(
|
|
597
|
+
command_result = _run_command(
|
|
598
|
+
job,
|
|
599
|
+
repo.cwd,
|
|
600
|
+
stripped_env_names=ctx.stripped_env_names,
|
|
601
|
+
extra_env=_job_extra_env(job),
|
|
602
|
+
)
|
|
502
603
|
except CommandError:
|
|
503
604
|
# The command timed out or failed; discard only the fresh commit.
|
|
504
605
|
# The old bookmark is completely unaffected.
|
|
@@ -544,16 +645,23 @@ def _load_job_specs(jobs_dir: Path) -> dict[str, dict]:
|
|
|
544
645
|
return specs
|
|
545
646
|
|
|
546
647
|
|
|
547
|
-
def _build_generated_job(
|
|
548
|
-
*,
|
|
648
|
+
def _build_generated_job( # noqa: PLR0913
|
|
649
|
+
*,
|
|
650
|
+
generator: Job,
|
|
651
|
+
name: str,
|
|
652
|
+
spec: dict,
|
|
653
|
+
run_names: set[str],
|
|
654
|
+
all_config_names: set[str],
|
|
655
|
+
marked_secret_names: frozenset[str],
|
|
549
656
|
) -> Job:
|
|
550
657
|
"""Build one emitted ``Job`` from its raw spec, applying inheritance.
|
|
551
658
|
|
|
552
659
|
``name`` is the spec's table key. The job inherits the (resolved) generator's
|
|
553
660
|
tags, ``depends_on`` and the ``_INHERITED_FIELDS`` unless the spec overrides
|
|
554
661
|
them, and records the generator in ``generated_by``. Raises GeneratedJobError
|
|
555
|
-
on a name colliding with an existing job, a nested generator,
|
|
556
|
-
fails validation
|
|
662
|
+
on a name colliding with an existing job, a nested generator, a job that
|
|
663
|
+
fails validation, or a ``secret_env`` naming a secret the static config did
|
|
664
|
+
not already mark (``marked_secret_names``).
|
|
557
665
|
"""
|
|
558
666
|
if name in run_names or name in all_config_names:
|
|
559
667
|
raise GeneratedJobError(
|
|
@@ -571,14 +679,36 @@ def _build_generated_job(
|
|
|
571
679
|
for f in _INHERITED_FIELDS:
|
|
572
680
|
merged.setdefault(f, getattr(generator, f))
|
|
573
681
|
merged["generated_by"] = generator.name
|
|
682
|
+
# The generator's fragments live in a throwaway temp dir, so an emitted job's
|
|
683
|
+
# meaningful config location is the generator's own config source.
|
|
684
|
+
merged["config_source_dir"] = generator.config_source_dir
|
|
574
685
|
try:
|
|
575
|
-
|
|
686
|
+
job = Job.model_validate(merged)
|
|
576
687
|
except ValidationError as e:
|
|
577
688
|
raise GeneratedJobError(generator.name, f"emitted job {name!r} is invalid: {e}") from e
|
|
689
|
+
# A generated job may only grant secrets the static config already marked. The
|
|
690
|
+
# env strip set is derived from the static config (RunContext.stripped_env_names),
|
|
691
|
+
# so a secret first introduced by an emitted job would not be stripped from the
|
|
692
|
+
# other jobs' environments; requiring it be marked up front (e.g. in
|
|
693
|
+
# [job-defaults].secret_env or on the generator) keeps a secret out of every job
|
|
694
|
+
# that did not grant it. See docs/adr/0017-secret-env-redaction.md.
|
|
695
|
+
unmarked = sorted(set(job.secret_env) - marked_secret_names)
|
|
696
|
+
if unmarked:
|
|
697
|
+
raise GeneratedJobError(
|
|
698
|
+
generator.name,
|
|
699
|
+
f"emitted job {name!r} grants secret(s) not marked in the static config: "
|
|
700
|
+
f"{unmarked}; add them to [job-defaults].secret_env or the generator's secret_env",
|
|
701
|
+
)
|
|
702
|
+
return job
|
|
578
703
|
|
|
579
704
|
|
|
580
705
|
def _build_generated_jobs(
|
|
581
|
-
*,
|
|
706
|
+
*,
|
|
707
|
+
generator: Job,
|
|
708
|
+
specs: dict[str, dict],
|
|
709
|
+
run_names: set[str],
|
|
710
|
+
all_config_names: set[str],
|
|
711
|
+
marked_secret_names: frozenset[str] = frozenset(),
|
|
582
712
|
) -> list[Job]:
|
|
583
713
|
"""Turn a generator's raw specs into validated ``Job`` objects.
|
|
584
714
|
|
|
@@ -595,6 +725,7 @@ def _build_generated_jobs(
|
|
|
595
725
|
spec=spec,
|
|
596
726
|
run_names=run_names,
|
|
597
727
|
all_config_names=all_config_names,
|
|
728
|
+
marked_secret_names=marked_secret_names,
|
|
598
729
|
)
|
|
599
730
|
for name, spec in specs.items()
|
|
600
731
|
]
|
|
@@ -785,7 +916,7 @@ def _run_generator_job(
|
|
|
785
916
|
"""Run a generator and return its emitted jobs (resolved ``job`` required).
|
|
786
917
|
|
|
787
918
|
The command runs in a fresh workspace on top of ``parents`` with
|
|
788
|
-
``
|
|
919
|
+
``RA_JOBS_DIR`` pointing at an empty directory; it writes ``*.toml``
|
|
789
920
|
fragments there which are parsed once it exits. The generator itself produces
|
|
790
921
|
no diff: any working-copy change it leaves is discarded (ADR 0004), and a
|
|
791
922
|
no-op ``JobResult`` is recorded so its emitted jobs (which depend on it)
|
|
@@ -807,8 +938,8 @@ def _run_generator_job(
|
|
|
807
938
|
_run_command(
|
|
808
939
|
job,
|
|
809
940
|
repo.cwd,
|
|
810
|
-
|
|
811
|
-
extra_env={
|
|
941
|
+
stripped_env_names=ctx.stripped_env_names,
|
|
942
|
+
extra_env=_job_extra_env(job, {RA_JOBS_DIR_ENV: str(jobs_dir)}),
|
|
812
943
|
)
|
|
813
944
|
specs = _load_job_specs(jobs_dir)
|
|
814
945
|
finally:
|
|
@@ -821,6 +952,7 @@ def _run_generator_job(
|
|
|
821
952
|
specs=specs,
|
|
822
953
|
run_names={j.name for j in ctx.selection.jobs},
|
|
823
954
|
all_config_names={j.name for j in ctx.config.jobs},
|
|
955
|
+
marked_secret_names=frozenset(ctx.config.marked_secret_names()),
|
|
824
956
|
)
|
|
825
957
|
|
|
826
958
|
names = ", ".join(j.name for j in emitted) if emitted else "none"
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""Render and strip boxquote.el-style boxes used to embed command output in messages."""
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
# A boxquote block: a ``,----`` opening line through its closing ``` `---- ``` line.
|
|
6
|
-
# DOTALL lets ``.*?`` span the body lines; MULTILINE anchors both delimiters to a
|
|
7
|
-
# line of their own. Non-greedy so each block stops at its own closing line.
|
|
8
|
-
_BOXQUOTE_BLOCK = re.compile(r"^,----.*?^`----[ \t]*$", re.MULTILINE | re.DOTALL)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def boxquote(msg: str, title: str = "") -> str:
|
|
12
|
-
"""Render ``msg`` inside a boxquote.el-style box.
|
|
13
|
-
|
|
14
|
-
The first line is ``,----[ title ]`` (or just ``,----`` when ``title`` is
|
|
15
|
-
empty), each line of ``msg`` is prefixed with ``| ``, and the box closes
|
|
16
|
-
with ``` `---- ```.
|
|
17
|
-
"""
|
|
18
|
-
top = f",----[ {title} ]" if title else ",----"
|
|
19
|
-
body = "\n".join(f"| {line}" for line in msg.splitlines())
|
|
20
|
-
return f"{top}\n{body}\n`----"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def strip_boxquotes(text: str) -> str:
|
|
24
|
-
"""Remove every boxquote block (see :func:`boxquote`) from ``text``.
|
|
25
|
-
|
|
26
|
-
A block runs from a ``,----`` opening line to its closing ``` `---- ``` line;
|
|
27
|
-
the surrounding text is kept. Blank lines exposed by a removed block are
|
|
28
|
-
collapsed so paragraphs stay separated by a single blank line, and leading
|
|
29
|
-
and trailing blank lines are trimmed.
|
|
30
|
-
"""
|
|
31
|
-
without = _BOXQUOTE_BLOCK.sub("", text)
|
|
32
|
-
return re.sub(r"\n{3,}", "\n\n", without).strip("\n")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|