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.
Files changed (26) hide show
  1. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/PKG-INFO +200 -11
  2. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/README.md +199 -10
  3. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/pyproject.toml +1 -1
  4. repoactive-0.3.1.dev1/src/repoactive/boxquote.py +51 -0
  5. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/config.py +151 -5
  6. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/runner.py +155 -23
  7. repoactive-0.2.8.dev2/src/repoactive/boxquote.py +0 -32
  8. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/.gitignore +0 -0
  9. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/LICENSE +0 -0
  10. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/__init__.py +0 -0
  11. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/cli.py +0 -0
  12. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/constants.py +0 -0
  13. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/graph.py +0 -0
  14. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/jj.py +0 -0
  15. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/jobtree.py +0 -0
  16. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/lock.py +0 -0
  17. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/__init__.py +0 -0
  18. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/base.py +0 -0
  19. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/github.py +0 -0
  20. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/platforms/gitlab.py +0 -0
  21. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/progress.py +0 -0
  22. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/selection.py +0 -0
  23. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/settings.py +0 -0
  24. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/trailers.py +0 -0
  25. {repoactive-0.2.8.dev2 → repoactive-0.3.1.dev1}/src/repoactive/ui.py +0 -0
  26. {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.2.8.dev2
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 `$REPOACTIVE_JOBS_DIR` instead of producing a
558
- diff. See [Generating jobs dynamically](#generating-jobs-dynamically).
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 = "./scripts/prek.sh autoupdate"
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 './scripts/prek.sh run -a; true'"
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
- `REPOACTIVE_JOBS_DIR` environment variable, and the jobs it emits run in the
966
- same invocation.
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
- # $REPOACTIVE_JOBS_DIR/pkg-a.toml
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 `$REPOACTIVE_JOBS_DIR` instead of producing a
531
- diff. See [Generating jobs dynamically](#generating-jobs-dynamically).
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 = "./scripts/prek.sh autoupdate"
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 './scripts/prek.sh run -a; true'"
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
- `REPOACTIVE_JOBS_DIR` environment variable, and the jobs it emits run in the
939
- same invocation.
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
- # $REPOACTIVE_JOBS_DIR/pkg-a.toml
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"
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.2.8.dev2"
13
+ version = "0.3.1.dev1"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -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
- _BRANCH_PREFIX_RE = re.compile(r"^(?!/)(?!.*//)[a-zA-Z0-9_\-/]+$")
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
- # REPOACTIVE_JOBS_DIR environment variable, and those jobs are run in the same
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
- """All branches a job uses as base_branch."""
521
- return {job.base_branch for job in self._resolved_jobs() if job.base_branch}
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(str(path), tomllib.loads(path.read_text()))
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
- REPOACTIVE_JOBS_DIR_ENV = "REPOACTIVE_JOBS_DIR"
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 secret_env_names(self) -> frozenset[str]:
210
- # Token vars stripped from every job command's environment so a command
211
- # cannot read the credential repoactive uses to push/create MRs.
212
- return frozenset(self.config.token_env_names())
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
- *, extra_env: dict[str, str] | None, secret_env_names: frozenset[str]
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 token variables (``secret_env_names``) so a command cannot read
247
- the credential repoactive uses to push/create MRs, then layers on ``extra_env``
248
- (e.g. REPOACTIVE_JOBS_DIR for a generator). See
249
- docs/adr/0006-job-commands-are-trusted.md.
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 secret_env_names}
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
- secret_env_names: frozenset[str] = frozenset(),
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
- env = _command_env(extra_env=extra_env, secret_env_names=secret_env_names)
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(job, repo.cwd, secret_env_names=ctx.secret_env_names)
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
- *, generator: Job, name: str, spec: dict, run_names: set[str], all_config_names: set[str]
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, or a job that
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
- return Job.model_validate(merged)
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
- *, generator: Job, specs: dict[str, dict], run_names: set[str], all_config_names: set[str]
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
- ``REPOACTIVE_JOBS_DIR`` pointing at an empty directory; it writes ``*.toml``
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
- secret_env_names=ctx.secret_env_names,
811
- extra_env={REPOACTIVE_JOBS_DIR_ENV: str(jobs_dir)},
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