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.
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/PKG-INFO +78 -1
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/README.md +77 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/pyproject.toml +1 -1
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/config.py +60 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/runner.py +87 -20
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/.gitignore +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/LICENSE +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/__init__.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/boxquote.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/cli.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/constants.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/graph.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/jj.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/jobtree.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/lock.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/__init__.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/base.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/github.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/platforms/gitlab.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/progress.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/selection.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/settings.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/trailers.py +0 -0
- {repoactive-0.3.0 → repoactive-0.3.1.dev2}/src/repoactive/ui.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
|
|
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
|
-
*,
|
|
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
|
|
277
|
-
|
|
278
|
-
(
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*,
|
|
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,
|
|
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
|
-
|
|
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
|
-
*,
|
|
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
|
-
|
|
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
|
|
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
|