diff --git a/.github/workflows/dependabot-pr.yml b/.github/workflows/dependabot-pr.yml index 29d65609..272ebc81 100644 --- a/.github/workflows/dependabot-pr.yml +++ b/.github/workflows/dependabot-pr.yml @@ -25,7 +25,7 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} # Check out the head of the actual branch, not the PR fetch-depth: 0 # otherwise, you will fail to push refs to dest repo token: ${{ secrets.DEPENDABOT_WORKFLOW_TOKEN }} - - uses: pyiron/actions/update-env-files@actions-4.0.13 + - uses: pyiron/actions/update-env-files@main - name: UpdateDependabotPR commit run: | git config --local user.email "pyiron@mpie.de" diff --git a/.github/workflows/hatch-release.yml b/.github/workflows/hatch-release.yml index e1af3886..c6eb9d36 100644 --- a/.github/workflows/hatch-release.yml +++ b/.github/workflows/hatch-release.yml @@ -106,11 +106,11 @@ jobs: - name: Install npm dependencies if: inputs.use-node run: npm install - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.env-files }} - - uses: pyiron/actions/update-pyproject-dependencies@actions-4.0.13 + - uses: pyiron/actions/update-pyproject-dependencies@main with: input-toml: ${{ inputs.input-toml }} lower-bound-yaml: ${{ inputs.lower-bound-yaml }} diff --git a/.github/workflows/pr-labeled.yml b/.github/workflows/pr-labeled.yml index f86b78e0..6ae134d9 100644 --- a/.github/workflows/pr-labeled.yml +++ b/.github/workflows/pr-labeled.yml @@ -48,10 +48,10 @@ jobs: tests-and-coverage: if: contains(github.event.pull_request.labels.*.name, 'run_coverage') - uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@actions-4.0.13 + uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@main secrets: inherit code-ql: if: contains(github.event.pull_request.labels.*.name, 'run_CodeQL') - uses: pyiron/actions/.github/workflows/codeql.yml@actions-4.0.13 + uses: pyiron/actions/.github/workflows/codeql.yml@main secrets: inherit diff --git a/.github/workflows/push-pull.yml b/.github/workflows/push-pull.yml index 1cdb5064..3998fe57 100644 --- a/.github/workflows/push-pull.yml +++ b/.github/workflows/push-pull.yml @@ -96,6 +96,11 @@ on: description: 'An optional path to a file containing the names of notebooks to NOT build' default: .ci_support/exclude required: false + notebooks-secret-env-map: + type: string + description: 'Optional newline-separated SECRET_NAME or ENV_NAME=SECRET_NAME entries to export for notebook execution' + default: '' + required: false tests-env-files: type: string description: 'Paths to an arbitrary number of (space-separated) conda environment yaml files' @@ -212,11 +217,11 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} # Check out the head of the actual branch, not the PR fetch-depth: 0 # otherwise, you will fail to push refs to dest repo if: ${{ inputs.do-commit-updated-env }} - - uses: pyiron/actions/write-docs-env@actions-4.0.13 + - uses: pyiron/actions/write-docs-env@main with: env-files: ${{ inputs.docs-env-files }} if: ${{ inputs.do-commit-updated-env }} - - uses: pyiron/actions/write-environment@actions-4.0.13 + - uses: pyiron/actions/write-environment@main with: env-files: ${{ inputs.notebooks-env-files }} output-env-file: .binder/environment.yml @@ -243,11 +248,11 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/build-docs@actions-4.0.13 + - uses: pyiron/actions/build-docs@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.docs-env-files }} @@ -258,11 +263,17 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/build-notebooks@actions-4.0.13 + - uses: pyiron/actions/export-secret-env@main + if: ${{ inputs.notebooks-secret-env-map != '' }} + env: + PYIRON_ALL_SECRETS_JSON: ${{ toJSON(secrets) }} + with: + secret-env-map: ${{ inputs.notebooks-secret-env-map }} + - uses: pyiron/actions/build-notebooks@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.notebooks-env-files }} @@ -299,11 +310,11 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/unit-tests@actions-4.0.13 + - uses: pyiron/actions/unit-tests@main with: python-version: ${{ matrix.python-version }} env-files: ${{ inputs.tests-env-files }} @@ -316,7 +327,7 @@ jobs: coverage: needs: commit-updated-env if: ${{ inputs.do-codecov || inputs.do-coveralls || inputs.do-codacy }} - uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@actions-4.0.13 + uses: pyiron/actions/.github/workflows/tests-and-coverage.yml@main secrets: inherit with: tests-env-files: ${{ inputs.tests-env-files }} @@ -336,11 +347,11 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/unit-tests@actions-4.0.13 + - uses: pyiron/actions/unit-tests@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.tests-env-files }} @@ -355,11 +366,11 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/unit-tests@actions-4.0.13 + - uses: pyiron/actions/unit-tests@main with: python-version: ${{ inputs.alternate-tests-python-version }} env-files: ${{ inputs.alternate-tests-env-files }} @@ -375,7 +386,7 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/pip-check@actions-4.0.13 + - uses: pyiron/actions/pip-check@main with: python-version: ${{ inputs.python-version }} @@ -430,7 +441,7 @@ jobs: python-version: ${{ inputs.python-version }} architecture: x64 - if: ${{ inputs.mypy-env-files != '' }} - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.mypy-env-files }} diff --git a/.github/workflows/pyproject-release.yml b/.github/workflows/pyproject-release.yml index ece5f322..72703e5d 100644 --- a/.github/workflows/pyproject-release.yml +++ b/.github/workflows/pyproject-release.yml @@ -83,11 +83,11 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.env-files }} - - uses: pyiron/actions/update-pyproject-dependencies@actions-4.0.13 + - uses: pyiron/actions/update-pyproject-dependencies@main with: input-toml: ${{ inputs.input-toml }} lower-bound-yaml: ${{ inputs.lower-bound-yaml }} diff --git a/.github/workflows/tests-and-coverage.yml b/.github/workflows/tests-and-coverage.yml index fa277002..b95fa3f4 100644 --- a/.github/workflows/tests-and-coverage.yml +++ b/.github/workflows/tests-and-coverage.yml @@ -69,11 +69,11 @@ jobs: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - - uses: pyiron/actions/add-to-python-path@actions-4.0.13 + - uses: pyiron/actions/add-to-python-path@main if: inputs.extra-python-paths != '' with: path-dirs: ${{ inputs.extra-python-paths }} - - uses: pyiron/actions/unit-tests@actions-4.0.13 + - uses: pyiron/actions/unit-tests@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.tests-env-files }} diff --git a/.gitignore b/.gitignore index 5aeb0fff..848f1a94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *.pyc .DS_Store .idea/ +.dir-locals.el +.codex +.codex/ \ No newline at end of file diff --git a/.support/export_secret_env.py b/.support/export_secret_env.py new file mode 100644 index 00000000..0d61996e --- /dev/null +++ b/.support/export_secret_env.py @@ -0,0 +1,120 @@ +"""Export selected GitHub Actions secrets into later-step environment variables.""" + +from __future__ import annotations + +import json +import os +import re +import sys +import uuid + +NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def workflow_escape(value: str) -> str: + """Escape text embedded in a GitHub workflow command.""" + return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def fail(message: str) -> None: + print(f"::error::{workflow_escape(message)}", file=sys.stderr) + raise SystemExit(1) + + +def parse_secret_env_map(raw_map: str) -> dict[str, str]: + """Parse mapping lines into environment-name to secret-name pairs.""" + env_to_secret_name: dict[str, str] = {} + + for line_number, raw_line in enumerate(raw_map.splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + + if "=" in line: + env_name, secret_name = (part.strip() for part in line.split("=", 1)) + else: + env_name = secret_name = line + + if not env_name or not secret_name: + fail(f"Invalid secret env mapping on line {line_number}.") + if not NAME_PATTERN.fullmatch(env_name): + fail( + f"Invalid environment variable name {env_name!r} on line {line_number}." + ) + if not NAME_PATTERN.fullmatch(secret_name): + fail(f"Invalid secret name {secret_name!r} on line {line_number}.") + if env_name in env_to_secret_name: + fail(f"Duplicate environment variable mapping for {env_name!r}.") + + env_to_secret_name[env_name] = secret_name + + return env_to_secret_name + + +def load_secrets() -> dict[str, str]: + """Load the full secret object supplied only to the trusted export step.""" + raw_secrets = os.environ.get("PYIRON_ALL_SECRETS_JSON") + if not raw_secrets: + fail("PYIRON_ALL_SECRETS_JSON is required when exporting selected secrets.") + + try: + secrets = json.loads(raw_secrets) + except json.JSONDecodeError as exc: + fail(f"Failed to parse PYIRON_ALL_SECRETS_JSON: {exc}") + + if not isinstance(secrets, dict): + fail("PYIRON_ALL_SECRETS_JSON must decode to a JSON object.") + + return {str(name): str(value) for name, value in secrets.items()} + + +def choose_github_env_delimiter(value: str) -> str: + """Choose a delimiter that is not present as a complete value line.""" + value_lines = set(value.splitlines()) + while True: + delimiter = f"PYIRON_SECRET_{uuid.uuid4().hex}" + if delimiter not in value_lines: + return delimiter + + +def append_github_env(env_name: str, value: str) -> None: + """Append one environment variable using GitHub's multiline-safe format.""" + github_env = os.environ.get("GITHUB_ENV") + if not github_env: + fail("GITHUB_ENV is not set.") + + delimiter = choose_github_env_delimiter(value) + with open(github_env, "a", encoding="utf-8") as env_file: + env_file.write(f"{env_name}<<{delimiter}\n{value}\n{delimiter}\n") + + +def main() -> int: + env_to_secret_name = parse_secret_env_map( + os.environ.get("PYIRON_SECRET_ENV_MAP", "") + ) + if not env_to_secret_name: + return 0 + + secrets = load_secrets() + missing = [ + secret_name + for secret_name in env_to_secret_name.values() + if secret_name not in secrets + ] + if missing: + fail("Requested secret(s) are not available: " + ", ".join(sorted(missing))) + + for env_name, secret_name in env_to_secret_name.items(): + value = secrets[secret_name] + if value: + print(f"::add-mask::{workflow_escape(value)}") + append_github_env(env_name, value) + + print( + f"Exported {len(env_to_secret_name)} selected secret environment variable(s)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/README.md b/README.md index 3701e540..be6dbd19 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,26 @@ Notebooks are found in all sub directories of `/notebooks`. The files listed in A wrapper combining `conda-incubator/setup-miniconda` and `actions/cache` along with our own `write-environment` that allows you to easily make a cached conda environment from any number (>=1) of conda environment yaml files. +### `export-secret-env` + +Exports a selected set of GitHub Actions secrets to the `$GITHUB_ENV` file so they are available in later steps of the same job. + +The `secret-env-map` input accepts newline-separated entries as either `SECRET_NAME` or `ENV_NAME=SECRET_NAME`. +This is intended for reusable workflows that receive inherited secrets, but only want to pass an explicit allowlist onward to runtime code such as notebook execution. + +The action needs the available secrets as JSON via `PYIRON_ALL_SECRETS_JSON`. +A minimal use looks like: + +```yaml +- uses: pyiron/actions/export-secret-env@main + env: + PYIRON_ALL_SECRETS_JSON: ${{ toJSON(secrets) }} + with: + secret-env-map: | + SECRET_NAME + ENV_VAR_NAME=SECRET_NAME +``` + ### `pip-check` Builds your environment with the `cached-minforge` action and then runs `pip check`. @@ -119,7 +139,7 @@ on: jobs: pyiron: - uses: pyiron/actions/.github/workflows/push-pull.yml@actions-4.0.13 + uses: pyiron/actions/.github/workflows/push-pull.yml@main secrets: inherit ``` diff --git a/build-docs/action.yml b/build-docs/action.yml index ff82744d..0339b7cc 100644 --- a/build-docs/action.yml +++ b/build-docs/action.yml @@ -21,11 +21,11 @@ inputs: runs: using: 'composite' steps: - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.standard-docs-env-file }} ${{ inputs.env-files }} - - uses: pyiron/actions/pyiron-config@actions-4.0.13 + - uses: pyiron/actions/pyiron-config@main - name: Build sphinx documentation shell: bash -l {0} run: | diff --git a/build-notebooks/action.yml b/build-notebooks/action.yml index 2504ebf1..ae45028a 100644 --- a/build-notebooks/action.yml +++ b/build-notebooks/action.yml @@ -28,11 +28,11 @@ inputs: runs: using: 'composite' steps: - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.standard-notebooks-env-file }} ${{ inputs.env-files }} - - uses: pyiron/actions/pyiron-config@actions-4.0.13 + - uses: pyiron/actions/pyiron-config@main - name: Build notebooks shell: bash -l {0} run: $GITHUB_ACTION_PATH/../.support/build_notebooks.sh ${{ inputs.notebooks-dir }} ${{ inputs.exclusion-file }} ${{ inputs.kernel }} diff --git a/cached-miniforge/action.yml b/cached-miniforge/action.yml index b4b51760..1d176bbd 100644 --- a/cached-miniforge/action.yml +++ b/cached-miniforge/action.yml @@ -62,7 +62,7 @@ inputs: runs: using: "composite" steps: - - uses: pyiron/actions/write-environment@actions-4.0.13 + - uses: pyiron/actions/write-environment@main with: env-files: ${{ inputs.env-files }} - name: Calculate cache label info diff --git a/export-secret-env/action.yml b/export-secret-env/action.yml new file mode 100644 index 00000000..ab664b37 --- /dev/null +++ b/export-secret-env/action.yml @@ -0,0 +1,18 @@ +name: 'Export selected secrets' +description: 'Export selected GitHub Actions secrets to later steps as environment variables' + +inputs: + secret-env-map: + description: 'Newline-separated SECRET_NAME or ENV_NAME=SECRET_NAME entries' + default: '' + required: false + +runs: + using: 'composite' + steps: + - name: Export selected secret env vars + if: ${{ inputs.secret-env-map != '' }} + shell: bash -l {0} + env: + PYIRON_SECRET_ENV_MAP: ${{ inputs.secret-env-map }} + run: python "$GITHUB_ACTION_PATH/../.support/export_secret_env.py" diff --git a/pip-check/action.yml b/pip-check/action.yml index 3646304b..b94825bd 100644 --- a/pip-check/action.yml +++ b/pip-check/action.yml @@ -13,7 +13,7 @@ inputs: runs: using: 'composite' steps: - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ inputs.env-files }} diff --git a/unit-tests/action.yml b/unit-tests/action.yml index 497d5c17..e98caa2c 100644 --- a/unit-tests/action.yml +++ b/unit-tests/action.yml @@ -72,11 +72,11 @@ runs: echo "ENV_FILES = ${ENV_FILES}" echo "env_files=$ENV_FILES" >> $GITHUB_OUTPUT - - uses: pyiron/actions/cached-miniforge@actions-4.0.13 + - uses: pyiron/actions/cached-miniforge@main with: python-version: ${{ inputs.python-version }} env-files: ${{ steps.prepare-env-files.outputs.env_files }} - - uses: pyiron/actions/pyiron-config@actions-4.0.13 + - uses: pyiron/actions/pyiron-config@main - name: Test shell: bash -l {0} run: |