diff --git a/.github/workflows/stash-action-test.yml b/.github/workflows/stash-action-test.yml new file mode 100644 index 00000000..12b5dd46 --- /dev/null +++ b/.github/workflows/stash-action-test.yml @@ -0,0 +1,150 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: Test Stash Action + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test-deps: + name: "check for dependencies" + runs-on: ubuntu-latest + container: quay.io/centos/centos:stream8 + steps: + - uses: actions/checkout@v4 + + - name: Run action + continue-on-error: true + id: fail + uses: ./stash/restore + with: + path: ./stash + key: this-must-fail + + - name: It did not fail + if: ${{ steps.fail.outputs.stash-hit != '' }} + run: | + echo "::error ::Dependency check should have failed!" + exit 1 + + test-save: + name: "stash/save on ${{ matrix.os }}" + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-13, macos-14] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Run unittests + shell: bash + env: + GH_TOKEN: "${{ github.token }}" + run: | + cd stash/restore + python3 test_get_stash.py + + - name: Test Save + uses: ./stash/save + with: + path: stash/ + # Create a unique key to test intra workflow artifacts. + key: test/stash-${{ matrix.os }}-${{ github.sha }} + retention-days: '1' + + - name: Show rate limit + env: + GH_TOKEN: "${{ github.token }}" + run: gh api -q '.rate' rate_limit + + - name: Test Overwrite + id: test + uses: ./stash/save + with: + path: stash/ + # Create a unique key to test intra workflow artifacts. + key: test/stash-${{ matrix.os }}-${{ github.sha }} + retention-days: '1' + + - name: Show rate limit + env: + GH_TOKEN: "${{ github.token }}" + run: gh api -q '.rate' rate_limit + + - name: Check Output + shell: bash + env: + ID: ${{ steps.test.outputs.stash-id }} + URL: ${{ steps.test.outputs.stash-url }} + run: | + if [ -z "$ID" -o -z "$URL" ]; then + echo "Output empty" + exit 1 + fi + + - name: Check if inter-workflow stash exists + id: stash + uses: ./stash/restore + with: + key: test-stash-cross-${{ matrix.os }} + path: test-stash + + - name: Show rate limit + env: + GH_TOKEN: "${{ github.token }}" + run: gh api -q '.rate' rate_limit + + - name: Save cross-workflow Stash + if: ${{ steps.stash.outputs.stash-hit == 'false' }} + uses: ./stash/save + with: + path: stash/ + key: test-stash-cross-${{ matrix.os }} + retention-days: '90' + + test-restore: + name: "stash/restore on ${{ matrix.os }}" + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-13, macos-14] + needs: test-save + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Test intra-workflow stash + uses: ./stash/restore + with: + key: test/stash-${{ matrix.os }}-${{ github.sha }} + path: intra/stash/ + + - shell: bash + run: ls -laR intra + + - name: Test inter-workflow stash + uses: ./stash/restore + with: + key: test-stash-cross-${{ matrix.os }} + path: inter/stash/ + + - shell: bash + run: ls -laR inter/ diff --git a/stash/.gitignore b/stash/.gitignore new file mode 100644 index 00000000..46447a29 --- /dev/null +++ b/stash/.gitignore @@ -0,0 +1,14 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +__pycache__ diff --git a/stash/README.md b/stash/README.md new file mode 100644 index 00000000..13baf6e3 --- /dev/null +++ b/stash/README.md @@ -0,0 +1,79 @@ + + +# Stash GitHub Action + +`Stash` provides a solution for managing large build caches in your workflows, that doesn't require any secrets and can therefore be used in fork PRs. +It's designed as an alternative to `actions/cache` which struggles with big build caches such as `.ccache` directories due to the repository wide [size limit](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy) of 10GB and the fact that caches are [immutable](https://github.com/actions/toolkit/issues/505). +With workflows running multiple configurations across PRs and merge commits this limit is quickly reached, leading to cache evictions, causing CI times to increase. + +This action is split into two distinct operations: +- `infrastructure-actions/stash/restore` for fetching a previously stored stash +- `infrastructure-actions/stash/save` for storing a new stash after a build has been completed. + +## Features + +- Each stash is uploaded as a workflow artifact. In contrasts to `actions/cache` there is no repository wide size limit for artifacts. + - There is no cache eviction, stashes will expire after 5 days by default. +- Artifact storage is free for public repositories and much cheaper than CI minutes (~ 1 Cent/1GB/day) for private repositories. +- No secrets required, stash can be used in fork PRs. +- Follows the same search scope as `actions/cache`: will look for the cache in the current workflow, current branch and finally the base branch of a PR. +This prevents untrusted user caches (e.g. from fork PR CI runs) from being used on the default branch (where actions have elevated permissions by default) or other repo or PR branches. + +## Usage + +> [!IMPORTANT] +> You have to explicitly save your stash by using `infrastructure-actions/stash/save` action, +> it will not be saved automatically by using `infrastructure-actions/stash/restore`. + +To restore a stash before your build process, use the `infrastructure-actions/stash/restore` action in your workflow: + + +```yaml +steps: +- uses: actions/checkout@v2 +- uses: infrastructure-actions/stash/restore@v1 + with: + key: 'cache-key' + path: 'path/to/cache' +``` + +After your build completes, save the stash using the `infrastructure-actions/stash/save` action: + +```yaml +steps: +- uses: infrastructure-actions/stash/save@v1 + with: + key: 'cache-key' + path: 'path/to/cache' +``` +Stashes will expire after 5 days by default. +You can set this from 1-90 days with the `retention-days` input. +Using the `save` action again in the same workflow run will overwrite the existing cache with the same key. +This does apply to each invocation in a matrix job as well! +If you want to keep the old cache, you can use a different key or set `overwrite` to `false`. + +### Inputs and Outputs + +Each action (restore and save) has specific inputs tailored to its functionality, +they are specifically modeled after `actions/cache` and `actions/upload-artifact` to provide a drop in replacement. +Please refer to the action metadata (`action.yml`) for a comprehensive list of inputs, including descriptions and default values. + +Additionally the `restore` action has an output `stash-hit` which is set to `true` if the cache was restored successfully, +`false` if no cache was restored and '' if the action failed (an error will be thrown unless `continue-on-error` is set). +A technical limitation of composite actions like `Stash` is that all outputs are **strings**. +Therefore an explicit comparison has to be used when using the output: +`if: ${{ steps.restore-stash.outputs.stash-hit == 'true' }}` diff --git a/stash/restore/action.yml b/stash/restore/action.yml new file mode 100644 index 00000000..ffb13c23 --- /dev/null +++ b/stash/restore/action.yml @@ -0,0 +1,159 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: "Stash - Restore" +description: "Restore your build cache stash." +author: assignUser + +inputs: + key: + description: > + Name of the artifact the stash is stored as. There is no `restore-key` + functionality as there is no eviction (only expiry). + + The action checks the current branch for a stash, if there is no match, + the base branch(PRs)/default branch is searched. If there is more than one + match for any branch the most recently updated stash takes precedent. + + To reduce amount of api calls (1000/h/repo limit) the name of the + current branch will be appended to the key. Key and branchname will be normalized. + required: true + + path: + description: 'The directory the stash will be restored in.' + default: ${{ github.workspace }} + + token: + description: 'GITHUB_TOKEN to use to authenticate against the artifacts api.' + default: ${{ github.token }} +outputs: + stash-hit: + description: > + A string ('true' or 'false') that indicates if a stash was restored or not. It is not + possible to make this a boolean, as composite-action outputs are always strings. Sorry. + value: ${{ steps.output.outputs.stash-hit }} + +runs: + using: 'composite' + steps: + - name: Check for dependencies + id: check-deps + shell: bash + run: | + function check_dep() { + local cmd=$1 + $(type -P $cmd > /dev/null 2>&1) || { echo "::error ::$cmd is required for this action"; missing_dep=true; } + } + + check_dep python3 + check_dep gh + check_dep jq + + if [ "$missing_dep" == "true" ]; then + exit 1 + fi + + - name: Mung Artifact Name + id: mung + shell: python3 {0} + env: + PYTHONPATH: "${{ github.action_path }}/../shared/" + stash_key: "${{ inputs.key }}" + stash_path: "${{ inputs.path }}" + ref_name: "${{ github.ref_name }}" + base_ref: "${{ github.base_ref || github.event.repository.default_branch }}" + run: | + import os + import mung as m + m.output_munged_name(output = 'stash_head') + m.output_munged_name(ref = 'base_ref', output = 'stash_base') + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f'stash_path={os.path.abspath(os.environ["stash_path"])}' + '\n') + + - name: Check for stash artifact + id: check-stash + env: + PYTHONPATH: "${{ github.action_path }}" + GH_TOKEN: "${{ inputs.token }}" + base_repo: "${{ github.repository }}" + base_repo_id: "${{ github.repository_id }}" + head_repo_id: "${{ github.event.pull_request.head.repo.id || github.repository_id }}" + base_ref: "${{ github.base_ref || github.event.repository.default_branch }}" + head_ref: "${{ github.head_ref || github.ref_name }}" + base_name: "${{ steps.mung.outputs.stash_base }}" + head_name: "${{ steps.mung.outputs.stash_head }}" + run_id: "${{ github.run_id }}" + stash_key: "${{ inputs.key }}" + + shell: python3 {0} + run: | + import get_stash as gs + + env = gs.ensure_env_var + repo = env("base_repo") + head_name = env("head_name") + base_name = env("base_name") + + + stash = gs.get_workflow_stash(repo, env("run_id"), head_name) + + if not stash: + gs.print_debug(f"Looking for stash {head_name} on current branch.") + stash = gs.get_branch_stash(repo, head_name, env("head_ref"), env("head_repo_id")) + + if not stash: + gs.print_debug(f"Looking for stash {base_name} on base branch.") + stash = gs.get_branch_stash(repo, base_name, env("base_ref"), env("base_repo_id")) + + gs.print_debug(f"Stash: {stash}") + if not stash: + print(f"Stash not found for key: {env('stash_key')}") + gs.set_output("stash_found", "false") + else: + gs.set_output("stash_name", stash["name"]) + gs.set_output("stash_run_id", stash["workflow_run"]["id"]) + print( + f"Restoring {stash['name']} from branch {stash['workflow_run']['head_branch']}." + ) + + - name: Download Stash + shell: bash + if: steps.check-stash.outputs.stash_found != 'false' + id: download + env: + GH_TOKEN: "${{ inputs.token }}" + STASH_NAME: "${{ steps.check-stash.outputs.stash_name }}" + STASH_RUN_ID: "${{ steps.check-stash.outputs.stash_run_id }}" + REPO: "${{ github.repository }}" + STASH_DIR: "${{ steps.mung.outputs.stash_path }}" + run: | + # Catch errors in the download with || to avoid the whole workflow failing + # when the download times out + gh run download "$STASH_RUN_ID" \ + --name "$STASH_NAME" \ + --dir "$STASH_DIR" \ + -R "$REPO" || download="failed" && download="success" + + echo "download=$download" >> "$GITHUB_OUTPUT" + + - name: Set stash-hit Output + id: output + if: ${{ ! cancelled() && steps.check-deps.conclusion == 'success'}} + shell: bash + run: | + if [ "${{ steps.download.outputs.download }}" == "success" ]; then + echo "stash-hit=true" >> $GITHUB_OUTPUT + else + echo "stash-hit=false" >> $GITHUB_OUTPUT + echo "No stash found for keys ${{ steps.mung.outputs.stash_head }} or ${{ steps.mung.outputs.stash_base }}." + fi diff --git a/stash/restore/get_stash.py b/stash/restore/get_stash.py new file mode 100644 index 00000000..82e783be --- /dev/null +++ b/stash/restore/get_stash.py @@ -0,0 +1,100 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +import os +import subprocess +from typing import List + + +def print_debug(msg: str): + """Print a message that is only visible when the GHA debug flag is set.""" + print(f"::debug::{msg}") + + +def set_output(name: str, value: str): + """Set a GHA output variable.""" + with open(ensure_env_var("GITHUB_OUTPUT"), "a") as f: + f.write(f"{name}={value}\n") + + +def ensure_env_var(var: str) -> str: + """Return value of envvar `var`, throw if it's not set.""" + value = os.environ.get(var) + if value is None or len(value) == 0: + raise ValueError(f"Environment variable {var} is not set") + return value + + +def run_checked(args, **kwargs): + """Run command and capture it's output and check that it exits successfully.""" + result = subprocess.run(args, **kwargs, capture_output=True, check=True, text=True) + return result + + +def jq(file: str, query: str, args: List[str] = []): + """Wrapper to run `jq` query on a file on disk or on a JSON string.""" + if os.path.isfile(file): + result = run_checked(["jq", *args, query, file]) + elif file.startswith("{"): + result = run_checked(["jq", *args, query], input=file) + else: + raise ValueError("Input 'file' not found and not valid json string") + + return result + + +def gh_api(endpoint: str, method: str = "get", options: List[str] = []): + """Wrapper to run `gh` REST API calls.""" + args = [ + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + f"--method={method}", + *options, + endpoint, + ] + result = run_checked(args) + return result + + +def ensure_json(output: str): + """Always return valid JSON.""" + if output.isspace(): + return json.loads("{}") + else: + return json.loads(output) + + +def get_workflow_stash(repo: str, run_id: str, name: str): + ops = ["-q", ".artifacts | max_by(.updated_at | fromdate)", "-f", f"name={name}"] + res = gh_api(f"repos/{repo}/actions/runs/{run_id}/artifacts", options=ops) + print_debug(f"Returned stash: {res.stdout}") + return ensure_json(res.stdout) + + +def get_branch_stash(repo: str, name: str, branch: str, repo_id: int): + query = f""" + .artifacts | map(select( + .expired == false and + .workflow_run.head_branch == "{branch}" + and .workflow_run.head_repository_id == {repo_id})) + | max_by(.updated_at | fromdate) + """ + ops = ["-q", query, "-f", f"name={name}"] + res = gh_api(f"repos/{repo}/actions/artifacts", options=ops) + print_debug(f"Returned stash: {res.stdout}") + return ensure_json(res.stdout) diff --git a/stash/restore/test.json b/stash/restore/test.json new file mode 100644 index 00000000..4a036f56 --- /dev/null +++ b/stash/restore/test.json @@ -0,0 +1 @@ +{"a": 1} \ No newline at end of file diff --git a/stash/restore/test_get_stash.py b/stash/restore/test_get_stash.py new file mode 100644 index 00000000..9e4f1fc1 --- /dev/null +++ b/stash/restore/test_get_stash.py @@ -0,0 +1,47 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +import json + +from get_stash import ensure_json, gh_api, jq + + +class TestGetStash(unittest.TestCase): + def test_jq(self): + self.assertEqual(jq('{"a": 1}', ".a", ["-j"]).stdout, "1") + + def test_jq_file(self): + self.assertEqual(jq("test.json", ".a").stdout, "1\n") + + def test_jq_error(self): + with self.assertRaises(ValueError): + jq("not_found.json", ".a") + + def test_gh_api(self): + self.assertEqual( + gh_api("rate_limit", options=["-q", ".resources.core.limit"]).stdout, + "15000\n", + ) + + def test_ensure_json(self): + # use the actual response from gh_api to guard against changes in the API + res = gh_api("rate_limit", options=["-q", ".resources.cre.limit"]) + res2 = '{"archive_download_url":"https://api.github.com/repos/assignUser/stash/actions/artifacts/1300409360/zip","created_at":"2024-03-06T00:01:41Z","expired":false,"expires_at":"2024-06-04T00:01:23Z","id":1300409360,"name":"test-stash-cross-macos-13-8_merge","node_id":"MDg6QXJ0aWZhY3QxMzAwNDA5MzYw","size_in_bytes":1303,"updated_at":"2024-03-06T00:01:41Z","url":"https://api.github.com/repos/assignUser/stash/actions/artifacts/1300409360","workflow_run":{"head_branch":"improve-filtering","head_repository_id":759960986,"head_sha":"ebaf714efc7535cdd13283e160dbb68fa446e39f","id":8164694143,"repository_id":759960986}}' + count = ensure_json(res.stdout) + self.assertDictEqual(count, {}) + self.assertDictEqual(ensure_json(res2), json.loads(res2)) + +if __name__ == "__main__": + unittest.main() diff --git a/stash/save/action.yml b/stash/save/action.yml new file mode 100644 index 00000000..1c917ece --- /dev/null +++ b/stash/save/action.yml @@ -0,0 +1,125 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +name: "Stash - Save" +description: "Stash your big build cache." +author: assignUser + +inputs: + key: + description: > + Name of the artifact the stash is stored as. There is no `restore-key` + functionality as there is no eviction (only expiry). + + The action checks the current branch for a stash, if there is no match, + the base branch(PRs)/default branch is searched. If there is more than one + match for any branch the most recently updated stash takes precedent. + + To reduce amount of api calls (1000/h/repo limit) the name of the + current branch will be appended to the key. Key and branchname will be normalized. + required: true + path: + description: > + A file, directory or wildcard pattern that describes what to upload. + For dirs and wildcards some flattening will apply: + For wildcards see: https://github.com/actions/upload-artifact?tab=readme-ov-file#upload-using-multiple-paths-and-exclusions + For dirs the top level is removed, e.g. with '.github' as the path input, + the artifact will contain the contents of '.github', so adding '.github' + as the path in the restore action is necessary. + + required: true + if-no-files-found: + description: > + The desired behavior if no files are found using the provided path. + + Available Options: + warn: Output a warning but do not fail the action + error: Fail the action with an error message + ignore: Do not output any warnings or errors, the action does not fail + default: 'warn' + retention-days: + description: > + Duration after which stash will expire in days. + Default is 5 days to bridge weekends. + + Minimum 1 day. + Maximum 90 days unless changed from the repository settings page. + default: '5' + compression-level: + description: > + The level of compression for Zlib to be applied to the artifact archive. + The value can range from 0 to 9: + - 0: No compression + - 1: Best speed + - 6: Default compression (same as GNU Gzip) + - 9: Best compression + Higher levels will result in better compression, but will take longer to complete. + For large files that are not easily compressed, a value of 0 is recommended for significantly faster uploads. + default: '6' + overwrite: + description: > + If true, a stash with a matching key will be deleted before a new one is uploaded. + If false, the action will fail if a stash for the given key already exists. + Does not fail if the artifact does not exist. + + Default is true to enable updating the stash. + Only applies within a workflow. Existing stashes are unaffected. + default: 'true' + +outputs: + stash-id: + description: > + A unique identifier for the artifact that was just uploaded. Empty if the artifact upload failed. + + This ID can be used as input to other APIs to download, delete or get more information about an artifact: https://docs.github.com/en/rest/actions/artifacts + value: ${{ steps.upload.outputs.artifact-id }} + stash-url: + description: > + A download URL for the artifact that was just uploaded. Empty if the artifact upload failed. + + This download URL only works for requests Authenticated with GitHub. Anonymous downloads will be prompted to first login. + If an anonymous download URL is needed than a short time restricted URL can be generated using the download artifact API: https://docs.github.com/en/rest/actions/artifacts#download-an-artifact + + This URL will be valid for as long as the artifact exists and the workflow run and repository exists. Once an artifact has expired this URL will no longer work. + Common uses cases for such a download URL can be adding download links to artifacts in descriptions or comments on pull requests or issues. + value: ${{ steps.upload.outputs.artifact-url }} + +runs: + using: 'composite' + steps: + - name: Check Check for dependencies + shell: bash + run: | + type -P python3 > /dev/null 2>&1 || { echo "::error ::python3 is required for this action"; exit 1; } + + - name: Mung Artifact Name + id: mung + shell: python3 {0} + env: + PYTHONPATH: "${{ github.action_path }}/../shared/" + stash_key: "${{ inputs.key }}" + ref_name: "${{ github.ref_name }}" + run: | + import mung as m + m.output_munged_name() + + - name: Upload Stash + id: upload + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: ${{ steps.mung.outputs.stash_name }} + path: ${{ inputs.path }} + retention-days: ${{ inputs.retention-days }} + if-no-files-found: ${{ inputs.if-no-files-found }} + compression-level: ${{ inputs.compression-level }} + overwrite: ${{ inputs.overwrite }} diff --git a/stash/shared/mung.py b/stash/shared/mung.py new file mode 100755 index 00000000..b2ba8f92 --- /dev/null +++ b/stash/shared/mung.py @@ -0,0 +1,57 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import re + + +def normalize(s: str) -> str: + """ + Replaces all characters in the input string `s` that are not + alphanumeric, underscores, hyphens, or periods with underscores. + """ + return re.sub(r"[^_\-.\w]", "_", s) + + +def mung(key: str, ref: str) -> str: + """Combine `key` and `ref` into a single string separated by a hyphen.""" + key = normalize(key) + ref = normalize(ref) + return f"{key}-{ref}" + + +def output_munged_name(ref="ref_name", key="stash_key", output="stash_name"): + """ + Reads the stash key and ref name from the matching environment variables, + combines them and saves the result in a GHA output variable. + + Args: + ref (str, optional): The name of the environment variable containing + the reference string. Defaults to "ref_name". + key (str, optional): The name of the environment variable containing + the key string. Defaults to "stash_key". + output (str, optional): The output variable name to be used in the + GitHub Actions output file. Defaults to "stash_name". + + Returns: + None + """ + ref = os.environ[ref] + + key = os.environ[key] + name = mung(key, ref) + + print(f"::debug::Creating output {output}={name} ") + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{output}={name}" + "\n")