repoactive 0.3.0__tar.gz → 0.3.1.dev2__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 (25) hide show
  1. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/PKG-INFO +78 -1
  2. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/README.md +77 -0
  3. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/pyproject.toml +1 -1
  4. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/config.py +60 -0
  5. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/runner.py +87 -20
  6. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/.gitignore +0 -0
  7. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/LICENSE +0 -0
  8. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/__init__.py +0 -0
  9. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/boxquote.py +0 -0
  10. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/cli.py +0 -0
  11. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/constants.py +0 -0
  12. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/graph.py +0 -0
  13. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/jj.py +0 -0
  14. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/jobtree.py +0 -0
  15. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/lock.py +0 -0
  16. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/__init__.py +0 -0
  17. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/base.py +0 -0
  18. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/github.py +0 -0
  19. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/gitlab.py +0 -0
  20. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/progress.py +0 -0
  21. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/selection.py +0 -0
  22. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/settings.py +0 -0
  23. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/trailers.py +0 -0
  24. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/ui.py +0 -0
  25. {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repoactive
3
- Version: 0.3.0
3
+ Version: 0.3.1.dev2
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
@@ -56,6 +56,7 @@ and (with `--mode publish`) the full MR lifecycle.
56
56
  - [`[platform.<name>]`](#platformname)
57
57
  - [Config file locations](#config-file-locations)
58
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)
59
60
  - [Overriding values on the command line](#overriding-values-on-the-command-line)
60
61
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
61
62
  - [Keeping unmerged branches current](#keeping-unmerged-branches-current)
@@ -494,6 +495,14 @@ block.
494
495
  works. The command runs as `<shell> -c <command>`. Arguments are not
495
496
  allowed - the value must be a single binary.
496
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).
505
+
497
506
  ### `[job.<name>]`
498
507
 
499
508
  The table key is the job's unique name; the branch is always
@@ -570,6 +579,14 @@ underscores.
570
579
  `*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
571
580
  See [Generating jobs dynamically](#generating-jobs-dynamically).
572
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).
589
+
573
590
  ### Stacking MRs
574
591
 
575
592
  When `depends_on` is set, `repoactive` starts the job's script from a
@@ -770,6 +787,66 @@ whether the file was discovered automatically or named with `-c`. A command
770
787
  whose value came from a `--set` override has no config file, so
771
788
  `RA_CONFIG_SOURCE_DIR` is unset for it.
772
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
+
773
850
  ### Overriding values on the command line
774
851
 
775
852
  `--set NAME=VALUE`/`-s` tweaks individual config values without editing a
@@ -29,6 +29,7 @@ and (with `--mode publish`) the full MR lifecycle.
29
29
  - [`[platform.<name>]`](#platformname)
30
30
  - [Config file locations](#config-file-locations)
31
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)
32
33
  - [Overriding values on the command line](#overriding-values-on-the-command-line)
33
34
  - [Selecting jobs with tags](#selecting-jobs-with-tags)
34
35
  - [Keeping unmerged branches current](#keeping-unmerged-branches-current)
@@ -467,6 +468,14 @@ block.
467
468
  works. The command runs as `<shell> -c <command>`. Arguments are not
468
469
  allowed - the value must be a single binary.
469
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).
478
+
470
479
  ### `[job.<name>]`
471
480
 
472
481
  The table key is the job's unique name; the branch is always
@@ -543,6 +552,14 @@ underscores.
543
552
  `*.toml` job fragments into `$RA_JOBS_DIR` instead of producing a diff.
544
553
  See [Generating jobs dynamically](#generating-jobs-dynamically).
545
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).
562
+
546
563
  ### Stacking MRs
547
564
 
548
565
  When `depends_on` is set, `repoactive` starts the job's script from a
@@ -743,6 +760,66 @@ whether the file was discovered automatically or named with `-c`. A command
743
760
  whose value came from a `--set` override has no config file, so
744
761
  `RA_CONFIG_SOURCE_DIR` is unset for it.
745
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
+
746
823
  ### Overriding values on the command line
747
824
 
748
825
  `--set NAME=VALUE`/`-s` tweaks individual config values without editing a
@@ -10,7 +10,7 @@ only-include = ["src"]
10
10
 
11
11
  [project]
12
12
  name = "repoactive"
13
- version = "0.3.0"
13
+ version = "0.3.1.dev2"
14
14
  description = "Script-driven code changes with automated merge requests"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -33,6 +33,10 @@ 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
+ # 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_")
36
40
  # An empty prefix is allowed (branch name == job name); hence * not +.
37
41
  _BRANCH_PREFIX_RE = re.compile(r"^(?!/)(?!.*//)[a-zA-Z0-9_\-/]*$")
38
42
  _DURATION_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
@@ -91,6 +95,27 @@ class InvalidTagError(ValueError):
91
95
  super().__init__(f"invalid tag {tag!r}: only letters, digits, '-', and '_' are allowed")
92
96
 
93
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
+
94
119
  class NewlineInTitleError(ValueError):
95
120
  """Raised when a title or prefix field contains a newline character."""
96
121
 
@@ -278,9 +303,19 @@ def _validate_single_line(value: str, info: ValidationInfo) -> str:
278
303
  return value
279
304
 
280
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
+
281
315
  _BranchPrefix = Annotated[str, AfterValidator(_validate_branch_prefix)]
282
316
  _Duration = Annotated[str, AfterValidator(_validate_duration)]
283
317
  _Shell = Annotated[str, AfterValidator(_validate_shell)]
318
+ _SecretEnv = Annotated[list[str], AfterValidator(_validate_secret_env)]
284
319
  # Titles and title prefixes end up in commit subjects and MR titles, which are
285
320
  # single-line by nature.
286
321
  _SingleLine = Annotated[str, AfterValidator(_validate_single_line)]
@@ -306,6 +341,12 @@ class JobDefaults(BaseModel):
306
341
  # `<shell> -c <command>`. None means /bin/sh (the shell=True default). May be a
307
342
  # bare name resolved on PATH (e.g. "bash") or an absolute path.
308
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)
309
350
 
310
351
 
311
352
  # Fields Job.resolve fills in from JobDefaults when the job does not set them
@@ -328,6 +369,12 @@ class Job(BaseModel):
328
369
 
329
370
  name: str
330
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)
331
378
  title: _SingleLine
332
379
  description: str | None = None
333
380
  base_branch: str | None = None
@@ -589,6 +636,19 @@ class Config(BaseModel):
589
636
  """
590
637
  return {p.token_env for p in self.platforms}
591
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
+
592
652
 
593
653
  class ConfigShape(BaseModel):
594
654
  """Structural shape of a raw config source, validated before merging.
@@ -130,6 +130,18 @@ class CommandError(RuntimeError):
130
130
  self.elapsed = elapsed
131
131
 
132
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
+
133
145
  class GeneratedJobError(ValueError):
134
146
  """Raised when a generator emits an invalid job set.
135
147
 
@@ -236,10 +248,12 @@ class RunContext:
236
248
  plan: UpdatePlan = field(default_factory=UpdatePlan)
237
249
 
238
250
  @property
239
- def secret_env_names(self) -> frozenset[str]:
240
- # Token vars stripped from every job command's environment so a command
241
- # cannot read the credential repoactive uses to push/create MRs.
242
- 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())
243
257
 
244
258
 
245
259
  def _compute_parents(job: Job, results: dict[str, JobResult]) -> list[str]:
@@ -268,22 +282,44 @@ def _kill_process_group(proc: subprocess.Popen[str]) -> None:
268
282
 
269
283
 
270
284
  def _command_env(
271
- *, 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,
272
289
  ) -> dict[str, str]:
273
290
  """Build the environment a job command runs in.
274
291
 
275
292
  Starts from the inherited environment (so the command still sees PATH etc.),
276
- drops the platform token variables (``secret_env_names``) so a command cannot read
277
- the credential repoactive uses to push/create MRs, then layers on ``extra_env``
278
- (e.g. RA_JOBS_DIR for a generator). See
279
- 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.
280
297
  """
281
- 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)
282
301
  if extra_env:
283
302
  env.update(extra_env)
284
303
  return env
285
304
 
286
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
+
287
323
  def _job_extra_env(job: Job, extra: dict[str, str] | None = None) -> dict[str, str]:
288
324
  """Extra environment for ``job``'s command: its name, branches, config dir, ``extra``.
289
325
 
@@ -375,15 +411,18 @@ def _run_command(
375
411
  job: Job,
376
412
  cwd: Path,
377
413
  *,
378
- secret_env_names: frozenset[str] = frozenset(),
414
+ stripped_env_names: frozenset[str] = frozenset(),
379
415
  extra_env: dict[str, str] | None = None,
380
416
  ) -> CommandResult:
381
417
  start = time.monotonic()
418
+ # Fail before the command runs if a granted secret is unset (ADR 0017).
419
+ granted_secret_env = _resolve_granted_secrets(job)
382
420
  # The workspace is always cwd, but expose it explicitly so a command that
383
421
  # cd's elsewhere can still find the workspace repoactive prepared for it.
384
422
  env = _command_env(
385
423
  extra_env={**(extra_env or {}), RA_WORKSPACE_DIR_ENV: str(cwd)},
386
- secret_env_names=secret_env_names,
424
+ stripped_env_names=stripped_env_names,
425
+ granted_secret_env=granted_secret_env,
387
426
  )
388
427
 
389
428
  # Stream the merged stdout/stderr line by line: keep the full output (needed
@@ -558,7 +597,7 @@ def run_job(
558
597
  command_result = _run_command(
559
598
  job,
560
599
  repo.cwd,
561
- secret_env_names=ctx.secret_env_names,
600
+ stripped_env_names=ctx.stripped_env_names,
562
601
  extra_env=_job_extra_env(job),
563
602
  )
564
603
  except CommandError:
@@ -606,16 +645,23 @@ def _load_job_specs(jobs_dir: Path) -> dict[str, dict]:
606
645
  return specs
607
646
 
608
647
 
609
- def _build_generated_job(
610
- *, 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],
611
656
  ) -> Job:
612
657
  """Build one emitted ``Job`` from its raw spec, applying inheritance.
613
658
 
614
659
  ``name`` is the spec's table key. The job inherits the (resolved) generator's
615
660
  tags, ``depends_on`` and the ``_INHERITED_FIELDS`` unless the spec overrides
616
661
  them, and records the generator in ``generated_by``. Raises GeneratedJobError
617
- on a name colliding with an existing job, a nested generator, or a job that
618
- 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``).
619
665
  """
620
666
  if name in run_names or name in all_config_names:
621
667
  raise GeneratedJobError(
@@ -637,13 +683,32 @@ def _build_generated_job(
637
683
  # meaningful config location is the generator's own config source.
638
684
  merged["config_source_dir"] = generator.config_source_dir
639
685
  try:
640
- return Job.model_validate(merged)
686
+ job = Job.model_validate(merged)
641
687
  except ValidationError as e:
642
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
643
703
 
644
704
 
645
705
  def _build_generated_jobs(
646
- *, 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(),
647
712
  ) -> list[Job]:
648
713
  """Turn a generator's raw specs into validated ``Job`` objects.
649
714
 
@@ -660,6 +725,7 @@ def _build_generated_jobs(
660
725
  spec=spec,
661
726
  run_names=run_names,
662
727
  all_config_names=all_config_names,
728
+ marked_secret_names=marked_secret_names,
663
729
  )
664
730
  for name, spec in specs.items()
665
731
  ]
@@ -872,7 +938,7 @@ def _run_generator_job(
872
938
  _run_command(
873
939
  job,
874
940
  repo.cwd,
875
- secret_env_names=ctx.secret_env_names,
941
+ stripped_env_names=ctx.stripped_env_names,
876
942
  extra_env=_job_extra_env(job, {RA_JOBS_DIR_ENV: str(jobs_dir)}),
877
943
  )
878
944
  specs = _load_job_specs(jobs_dir)
@@ -886,6 +952,7 @@ def _run_generator_job(
886
952
  specs=specs,
887
953
  run_names={j.name for j in ctx.selection.jobs},
888
954
  all_config_names={j.name for j in ctx.config.jobs},
955
+ marked_secret_names=frozenset(ctx.config.marked_secret_names()),
889
956
  )
890
957
 
891
958
  names = ", ".join(j.name for j in emitted) if emitted else "none"
File without changes
File without changes