├── requirements.dev.txt ├── requirements.test.txt ├── tests ├── includes │ ├── actions │ │ ├── script │ │ │ ├── script.sh │ │ │ ├── script.py │ │ │ └── action.yaml │ │ ├── complex-if │ │ │ └── action.yaml │ │ ├── sometimes │ │ │ └── action.yaml │ │ ├── basic │ │ │ └── action.yaml │ │ └── recursive │ │ │ ├── remote-other │ │ │ └── action.yaml │ │ │ ├── local │ │ │ └── action.yaml │ │ │ └── remote │ │ │ └── action.yaml │ └── workflows │ │ └── basic │ │ └── workflow.yml └── workflows │ ├── workflows-basic.yml │ ├── actions-ifexpands.yml │ ├── actions-local.yml │ └── actions-remote.yml ├── requirements.setup.txt ├── pytest.ini ├── requirements.txt ├── pyproject.toml ├── actions_includes ├── __main__.py ├── output.py ├── check.py ├── yaml_map.py ├── files.py ├── __init__.py └── expressions.py ├── .github ├── includes │ └── actions │ │ ├── local │ │ ├── update-top.py │ │ └── action.yml │ │ ├── prepare-for-docker-build │ │ └── action.yml │ │ └── wait-on-docker-image │ │ └── action.yaml └── workflows │ ├── test.yml │ ├── publish-to-pypi.yml │ ├── publish-docker-image.yml │ ├── test.actions-ifexpands.yml │ ├── test.workflows-basic.yml │ ├── test.actions-local.yml │ └── test.actions-remote.yml ├── action.yml ├── docker └── Dockerfile ├── setup.py ├── .gitignore ├── Makefile ├── README.md └── LICENSE /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | twine 2 | -------------------------------------------------------------------------------- /requirements.test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /tests/includes/actions/script/script.sh: -------------------------------------------------------------------------------- 1 | echo "Hello from bash" 2 | -------------------------------------------------------------------------------- /tests/includes/actions/script/script.py: -------------------------------------------------------------------------------- 1 | print('Hello from Python!') 2 | -------------------------------------------------------------------------------- /requirements.setup.txt: -------------------------------------------------------------------------------- 1 | setuptools>=42 2 | setuptools_scm>=3.4 3 | wheel 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --doctest-modules --doctest-continue-on-failure --pyargs actions_includes 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements.setup.txt 2 | -r requirements.test.txt 3 | -r requirements.dev.txt 4 | -e . 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel", 5 | "setuptools_scm[toml]>=3.4", 6 | ] 7 | -------------------------------------------------------------------------------- /actions_includes/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import sys 21 | 22 | from . import main 23 | 24 | if __name__ == "__main__": 25 | sys.exit(main()) 26 | -------------------------------------------------------------------------------- /.github/includes/actions/local/update-top.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import pathlib 4 | import os 5 | 6 | __dir__ = pathlib.Path(__file__).parent.resolve() 7 | 8 | local_action_yml = __dir__ / 'action.yml' 9 | top_action_yml = (__dir__ / '..' / '..' / '..' / '..' / 'action.yml').resolve() 10 | 11 | print('Local action.yml file at:', local_action_yml) 12 | print( 'Top action.yml file at:', top_action_yml) 13 | 14 | with open(local_action_yml) as f: 15 | action_data = f.read() 16 | 17 | action_data = action_data.replace( 18 | '../../../../docker/Dockerfile', 19 | 'docker://ghcr.io/mithro/actions-includes/image:main', 20 | ) 21 | 22 | action_data = action_data.replace( 23 | 'name: actions-includes', 24 | """\ 25 | # WARNING! Don't modify this file, modify 26 | # ./.github/includes/actions/local/action.yml 27 | # and then run `make action.yml`. 28 | 29 | name: actions-includes""") 30 | 31 | with open(top_action_yml, 'w') as f: 32 | f.write(action_data) 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | on: 18 | push: 19 | pull_request: 20 | 21 | jobs: 22 | 23 | test: 24 | name: Basic Test 25 | 26 | runs-on: ubuntu-latest 27 | 28 | steps: 29 | 30 | - uses: actions/checkout@v2 31 | 32 | - uses: ./ 33 | continue-on-error: true 34 | with: 35 | workflow: action.yml 36 | -------------------------------------------------------------------------------- /tests/includes/actions/script/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-actions-script 18 | description: "Basic includable action which uses includes-script." 19 | 20 | runs: 21 | using: "includes" 22 | 23 | steps: 24 | - name: Included Python Script 25 | includes-script: script.py 26 | 27 | - name: Included Shell script 28 | includes-script: script.sh 29 | -------------------------------------------------------------------------------- /.github/includes/actions/local/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: actions-includes 18 | description: 'Allow actions to include other actions.' 19 | inputs: 20 | workflow: 21 | description: 'Workflow file to check include expansion' 22 | required: true 23 | runs: 24 | using: 'docker' 25 | image: ../../../../docker/Dockerfile 26 | args: 27 | - ${{ inputs.workflow }} 28 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | # WARNING! Don't modify this file, modify 18 | # ./.github/includes/actions/local/action.yml 19 | # and then run `make action.yml`. 20 | 21 | name: actions-includes 22 | description: 'Allow actions to include other actions.' 23 | inputs: 24 | workflow: 25 | description: 'Workflow file to check include expansion' 26 | required: true 27 | runs: 28 | using: 'docker' 29 | image: docker://ghcr.io/mithro/actions-includes/image:main 30 | args: 31 | - ${{ inputs.workflow }} 32 | -------------------------------------------------------------------------------- /tests/includes/actions/complex-if/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: test-includes-actions-complex-if 18 | description: "Action to be included in other tests with complex if statement." 19 | 20 | inputs: 21 | use-a: 22 | description: '' 23 | required: true 24 | default: true 25 | use-b: 26 | description: 'Use the last step' 27 | required: true 28 | default: false 29 | 30 | runs: 31 | using: "includes" 32 | 33 | steps: 34 | - name: Step 35 | if: inputs.use-a && inputs.use-b 36 | run: | 37 | echo "Hello world!" 38 | -------------------------------------------------------------------------------- /actions_includes/output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import pprint 21 | import os 22 | import sys 23 | 24 | 25 | def printerr(*args, **kw): 26 | print(*args, file=sys.stderr, **kw) 27 | 28 | 29 | DEBUG = bool(os.environ.get('DEBUG', False)) 30 | 31 | 32 | def printdbg(*args, **kw): 33 | if DEBUG: 34 | args = list(args) 35 | for i in range(0, len(args)): 36 | if not isinstance(args[i], str): 37 | args[i] = pprint.pformat(args[i]) 38 | print(*args, file=sys.stderr, **kw) 39 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:experimental 2 | 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | FROM python:alpine 20 | 21 | RUN \ 22 | apk add --no-cache git 23 | 24 | RUN \ 25 | python --version \ 26 | && pip --version 27 | 28 | # Install the actions-includes tool 29 | COPY ./*.tar.gz / 30 | RUN \ 31 | ls -l /*.tar.gz \ 32 | && pip install /*.tar.gz --progress-bar off \ 33 | && rm -rf ~/.cache/pip \ 34 | && rm /*.tar.gz \ 35 | && mkdir -p /github/workspace 36 | 37 | WORKDIR /github/workspace 38 | 39 | ENTRYPOINT ["python3", "-m", "actions_includes.check"] 40 | CMD ["--help"] 41 | -------------------------------------------------------------------------------- /tests/includes/actions/sometimes/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-actions-sometimes 18 | description: "Basic action to be included in other tests." 19 | 20 | inputs: 21 | message: 22 | description: 'Message to output' 23 | required: true 24 | 25 | runs: 26 | using: "includes" 27 | 28 | steps: 29 | - name: Test 30 | if: ${{ !startsWith(runner.os, 'Linux') }} 31 | run: true 32 | 33 | - name: Message not polite? 34 | if: ${{ !startsWith(inputs.message, 'Hello') }} 35 | run: | 36 | echo "No hello?" 37 | 38 | - name: Output message 39 | run: | 40 | echo "${{ inputs.message }}" 41 | 42 | - name: Be Polite 43 | if: startsWith(inputs.message, 'Hello') 44 | run: | 45 | echo "Goodbye!" 46 | -------------------------------------------------------------------------------- /tests/workflows/workflows-basic.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | on: 18 | push: 19 | pull_request: 20 | 21 | jobs: 22 | docker-image-build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - includes: /wait-on-docker-image 26 | 27 | First: 28 | includes: ./tests/includes/workflows/basic 29 | needs: docker-image-build 30 | with: 31 | message: 'Hello World' 32 | use-first: true 33 | 34 | Second: 35 | includes: ./tests/includes/workflows/basic 36 | needs: docker-image-build 37 | with: 38 | message: 'Hello World' 39 | use-first: false 40 | use-last: true 41 | 42 | null: 43 | includes: ./tests/includes/workflows/basic 44 | needs: docker-image-build 45 | with: 46 | message: 'Hello World' 47 | use-first: true 48 | use-last: true 49 | -------------------------------------------------------------------------------- /.github/includes/actions/prepare-for-docker-build/action.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: Prepare the data to build the Docker image 18 | 19 | runs: 20 | using: "composite" 21 | steps: 22 | - name: Install build dependencies 23 | shell: bash 24 | run: | 25 | pip install -U pip 26 | pip install -U setuptools wheel twine 27 | 28 | - name: Install package dependencies 29 | shell: bash 30 | run: | 31 | python setup.py install 32 | 33 | - name: Build distribution 📦 34 | shell: bash 35 | run: | 36 | python setup.py sdist bdist_wheel 37 | 38 | - name: Check distribution 📦 39 | shell: bash 40 | run: | 41 | twine check dist/* 42 | 43 | - name: Copy into dist into docker build directory 44 | shell: bash 45 | run: | 46 | cp dist/* ./docker/ 47 | -------------------------------------------------------------------------------- /.github/includes/actions/wait-on-docker-image/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: wait-on-docker-image 18 | description: "Wait for the action's Docker Image to be built before starting." 19 | 20 | runs: 21 | using: "includes" 22 | 23 | steps: 24 | - id: wait 25 | name: Wait for Docker Image build 26 | uses: fountainhead/action-wait-for-check@v1.0.0 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | checkName: Push Docker image to GitHub Packages 30 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 31 | - name: Docker Image Check 32 | env: 33 | STATUS: ${{ steps.wait.outputs.conclusion }} 34 | run: | 35 | if [[ "$STATUS" != "success" ]]; then 36 | echo "::error {{ $STATUS }}" 37 | exit 1 38 | fi 39 | -------------------------------------------------------------------------------- /tests/includes/actions/basic/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-action-basic 18 | description: "Basic action to be included in other tests." 19 | 20 | inputs: 21 | use-first: 22 | description: 'Use the first step' 23 | required: true 24 | default: true 25 | use-last: 26 | description: 'Use the last step' 27 | required: true 28 | default: false 29 | message: 30 | description: 'Message to use in the middle step' 31 | required: true 32 | 33 | runs: 34 | using: "includes" 35 | 36 | steps: 37 | - name: First step 38 | if: inputs.use-first 39 | run: | 40 | echo "First step!" 41 | 42 | - name: Middle step 43 | run: | 44 | echo "${{ inputs.message }}" 45 | 46 | - name: Last step 47 | if: inputs.use-last 48 | run: | 49 | echo "Last step!" 50 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | 10 | build-n-publish: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.8', '3.9'] 15 | fail-fast: false 16 | name: ${{ matrix.python-version }} 17 | 18 | steps: 19 | 20 | - uses: actions/checkout@v2 21 | with: 22 | # Always clone the full depth so git-describe works. 23 | fetch-depth: 0 24 | submodules: true 25 | 26 | - name: Set up Python 🐍 ${{ matrix.python-version }} 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install build dependencies 32 | run: | 33 | pip install -U pip 34 | pip install -U setuptools wheel twine 35 | 36 | - name: Install package dependencies 37 | run: python setup.py install 38 | 39 | - name: Build distribution 📦 40 | run: python setup.py sdist bdist_wheel 41 | 42 | - name: Check distribution 📦 43 | run: twine check dist/* 44 | 45 | - name: Publish to Test PyPI 46 | env: 47 | TWINE_USERNAME: __token__ 48 | TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} 49 | run: twine upload --skip-existing --repository testpypi dist/* 50 | 51 | - name: Publish to PyPI 52 | env: 53 | TWINE_USERNAME: __token__ 54 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 55 | run: twine upload --skip-existing dist/* 56 | -------------------------------------------------------------------------------- /tests/includes/actions/recursive/remote-other/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-actions-recursive-remote-other 18 | description: "Testing `includes:` can be used inside a `includes` action." 19 | 20 | inputs: 21 | use-first: 22 | description: 'Use the first step' 23 | required: true 24 | default: true 25 | use-last: 26 | description: 'Use the last step' 27 | required: true 28 | default: false 29 | 30 | runs: 31 | using: "includes" 32 | 33 | steps: 34 | - name: Include action with normal steps. 35 | includes: mithro/actions-includes/include@other 36 | 37 | - name: Include action with includes-script step. 38 | includes: mithro/actions-includes/include-script@other 39 | 40 | - name: Include action with includes step for local action. 41 | includes: mithro/actions-includes/recursive/local@other 42 | 43 | - name: Include action with includes step for remote action. 44 | includes: mithro/actions-includes/recursive/remote@other 45 | -------------------------------------------------------------------------------- /tests/includes/actions/recursive/local/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-actions-recursive-local 18 | description: "Testing `includes:` pointing at local files can be used inside a `includes` action." 19 | 20 | inputs: 21 | use-first: 22 | description: 'Use the first step' 23 | required: true 24 | default: true 25 | use-last: 26 | description: 'Use the last step' 27 | required: true 28 | default: false 29 | 30 | runs: 31 | using: "includes" 32 | 33 | steps: 34 | - includes: ./tests/includes/actions/basic 35 | if: inputs.use-first 36 | with: 37 | message: 'First step' 38 | use-first: false 39 | use-last: false 40 | 41 | - includes: ./tests/includes/actions/basic 42 | with: 43 | message: 'Middle step' 44 | use-first: ${{ inputs.use-first }} 45 | use-last: ${{ inputs.use-last }} 46 | 47 | - includes: ./tests/includes/actions/basic 48 | if: inputs.use-last 49 | with: 50 | message: 'Last step' 51 | use-first: false 52 | use-last: false 53 | -------------------------------------------------------------------------------- /tests/includes/actions/recursive/remote/action.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: tests-includes-actions-recursive-remote 18 | description: "Testing `includes:` can be used inside a `includes` action." 19 | 20 | inputs: 21 | use-first: 22 | description: 'Use the first step' 23 | required: true 24 | default: true 25 | use-last: 26 | description: 'Use the last step' 27 | required: true 28 | default: false 29 | 30 | runs: 31 | using: "includes" 32 | 33 | steps: 34 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 35 | if: inputs.use-first 36 | with: 37 | message: 'First step' 38 | use-first: false 39 | use-last: false 40 | 41 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 42 | with: 43 | message: 'Middle step' 44 | use-first: ${{ inputs.use-first }} 45 | use-last: ${{ inputs.use-last }} 46 | 47 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 48 | if: inputs.use-last 49 | with: 50 | message: 'Last step' 51 | use-first: false 52 | use-last: false 53 | -------------------------------------------------------------------------------- /tests/includes/workflows/basic/workflow.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: includes-basic 18 | description: "Basic job to be included in other tests." 19 | 20 | inputs: 21 | use-first: 22 | description: 'Use the first job' 23 | required: true 24 | default: true 25 | use-last: 26 | description: 'Use the last job' 27 | required: true 28 | default: false 29 | message: 30 | description: 'Message to use in the middle step' 31 | required: true 32 | 33 | jobs: 34 | FirstJob1: 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: First step 38 | run: | 39 | echo "First job" 40 | 41 | FirstJob2: 42 | runs-on: ubuntu-latest 43 | if: inputs.use-first 44 | steps: 45 | - name: First step 46 | run: | 47 | echo "First job" 48 | 49 | MiddleJob: 50 | runs-on: ubuntu-latest 51 | needs: FirstJob1 52 | steps: 53 | - name: Middle step 54 | run: | 55 | echo "${{ inputs.message }}" 56 | 57 | LastJob: 58 | if: inputs.use-last 59 | runs-on: ubuntu-latest 60 | needs: [FirstJob1, MiddleJob] 61 | steps: 62 | - name: Last step 63 | run: | 64 | echo "Last job!" 65 | -------------------------------------------------------------------------------- /tests/workflows/actions-ifexpands.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | on: 18 | push: 19 | pull_request: 20 | 21 | jobs: 22 | docker-image-build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - includes: /wait-on-docker-image 26 | 27 | test-setting-default-false-to-true: 28 | needs: docker-image-build 29 | strategy: 30 | matrix: 31 | use-first: [true, false] 32 | use-last: [true, false] 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - includes: ./tests/includes/actions/basic 37 | with: 38 | message: 'Hello World' 39 | use-first: ${{ matrix.use-first }} 40 | 41 | - includes: ./tests/includes/actions/basic 42 | with: 43 | message: 'Hello World' 44 | use-first: ${{ matrix.use-first }} 45 | use-last: ${{ matrix.use-first }} 46 | 47 | - includes: ./tests/includes/actions/basic 48 | with: 49 | message: 'Hello World' 50 | use-first: ${{ matrix.use-last }} 51 | use-last: ${{ matrix.use-last }} 52 | 53 | - includes: ./tests/includes/actions/basic 54 | with: 55 | message: 'Hello World' 56 | use-last: ${{ matrix.use-last }} 57 | 58 | - includes: ./tests/includes/actions/complex-if 59 | with: 60 | use-a: ${{ matrix.use-first }} 61 | use-b: ${{ matrix.use-last }} 62 | 63 | - includes: ./tests/includes/actions/complex-if 64 | with: 65 | use-a: ${{ matrix.use-first }} 66 | 67 | - includes: ./tests/includes/actions/complex-if 68 | with: 69 | use-b: ${{ matrix.use-last }} 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | import setuptools 20 | 21 | 22 | def get_version(): 23 | from setuptools_scm.version import get_local_node_and_date 24 | def clean_scheme(version): 25 | return get_local_node_and_date(version) if version.dirty else '' 26 | 27 | return { 28 | 'write_to': 'actions_includes/version.py', 29 | 'version_scheme': 'post-release', 30 | 'local_scheme': clean_scheme, 31 | } 32 | 33 | 34 | with open("README.md", "r") as fh: 35 | long_description = fh.read() 36 | 37 | 38 | setuptools.setup( 39 | # Package human readable information 40 | name="actions-includes", 41 | use_scm_version = get_version, 42 | author="Tim 'mithro' Ansell", 43 | author_email="tansell@google.com", 44 | description="""\ 45 | Tool for flattening include statements in GitHub actions workflow.yml files.""", 46 | long_description=long_description, 47 | long_description_content_type="text/markdown", 48 | license="Apache 2.0", 49 | license_files=["LICENSE"], 50 | classifiers=[ 51 | "Programming Language :: Python :: 3", 52 | "License :: OSI Approved :: Apache Software License", 53 | "Operating System :: OS Independent", 54 | ], 55 | url="https://github.com/mithro/actions-includes", 56 | project_urls={ 57 | "Bug Tracker": "https://github.com/mithro/actions-includes/issues", 58 | "Source Code": "https://github.com/mithro/actions-includes", 59 | }, 60 | # Package contents control 61 | packages=setuptools.find_packages(), 62 | zip_safe=True, 63 | include_package_data=True, 64 | entry_points={ 65 | 'console_scripts': [ 66 | 'actions_includes=actions_include:main', 67 | 'actions_include_check=actions_includes.check:main', 68 | ], 69 | }, 70 | # Requirements 71 | python_requires='>=3.8', # Needs ordered dictionaries 72 | install_requires = [ 73 | "ruamel.yaml<0.18", # TODO: Update code to support ruamel.yaml 0.18+ API (see issue #50) 74 | ], 75 | setup_requires = [ 76 | "setuptools>=42", 77 | "wheel", 78 | "setuptools_scm[toml]>=3.4", 79 | ], 80 | ) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Generated version file 141 | actions_includes/version.py 142 | docker/*.tar.gz 143 | 144 | # Git worktrees 145 | .worktrees/ 146 | -------------------------------------------------------------------------------- /actions_includes/check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import os 21 | import sys 22 | import urllib.request 23 | import pathlib 24 | import difflib 25 | import argparse 26 | 27 | 28 | import actions_includes 29 | 30 | 31 | USER, REPO = (None, None) 32 | SHA = None 33 | 34 | 35 | # Download the workflow's yaml data 36 | def get_file(filename): 37 | workflow_url = f"https://raw.githubusercontent.com/{USER}/{REPO}/{SHA}/{filename}" 38 | print("Downloading:", workflow_url) 39 | return urllib.request.urlopen(workflow_url).read().decode('utf-8') 40 | 41 | 42 | def main(): 43 | ap = argparse.ArgumentParser( 44 | prog="check", 45 | description="Assert a workflow produced by actions-includes is up to date") 46 | ap.add_argument("workflow", type=str, 47 | help="Path to workflow file to check, relative to repo root") 48 | args = ap.parse_args() 49 | 50 | global USER, REPO 51 | global SHA 52 | USER, REPO = os.environ['GITHUB_REPOSITORY'].split('/', 1) 53 | SHA = os.environ['GITHUB_SHA'] 54 | 55 | workflow_file = args.workflow 56 | workflow_data = get_file(workflow_file) 57 | 58 | # Workout what the source workflow file name was 59 | startpos = workflow_data.find(actions_includes.MARKER) 60 | endpos = workflow_data.find('\n', startpos) 61 | if startpos == -1 or endpos == -1: 62 | print() 63 | print('Unable to find generation marker in', workflow_file) 64 | print('-'*75) 65 | print(workflow_data) 66 | print('-'*75) 67 | sys.exit(1) 68 | 69 | workflow_srcfile = workflow_data[startpos+len(actions_includes.MARKER):endpos] 70 | workflow_srcpath = (pathlib.Path('/'+workflow_file).parent / workflow_srcfile).resolve() 71 | workflow_src = actions_includes.RemoteFilePath(USER, REPO, SHA, str(workflow_srcpath)[1:]) 72 | print() 73 | print('Source of', workflow_file, 'is', workflow_srcfile, 'found at', workflow_src) 74 | print() 75 | new_workflow_data = actions_includes.expand_workflow(workflow_src, workflow_file, True) 76 | print() 77 | print('Workflow file at', workflow_file, 'should be:') 78 | print('-'*75) 79 | print(new_workflow_data) 80 | print('-'*75) 81 | print() 82 | 83 | 84 | diff = list(difflib.unified_diff( 85 | workflow_data.splitlines(True), 86 | new_workflow_data.splitlines(True), 87 | fromfile='a/'+workflow_file, 88 | tofile='b/'+workflow_file, 89 | )) 90 | if diff: 91 | print("Found the following differences:") 92 | print('-'*75) 93 | sys.stdout.writelines(diff) 94 | print('-'*75) 95 | 96 | return len(diff) 97 | 98 | 99 | if __name__ == "__main__": 100 | sys.exit(main()) 101 | -------------------------------------------------------------------------------- /tests/workflows/actions-local.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | on: 18 | push: 19 | pull_request: 20 | 21 | jobs: 22 | docker-image-build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - includes: /wait-on-docker-image 26 | 27 | test-setting-default-false-to-true: 28 | needs: docker-image-build 29 | runs-on: ubuntu-latest 30 | steps: 31 | - run: | 32 | echo "Pre-step" 33 | - includes: ./tests/includes/actions/basic 34 | with: 35 | message: 'Hello World' 36 | use-last: true 37 | - run: | 38 | echo "Post-step" 39 | 40 | test-setting-default-true-to-false: 41 | needs: docker-image-build 42 | runs-on: ubuntu-latest 43 | steps: 44 | - run: | 45 | echo "Pre-step" 46 | - includes: ./tests/includes/actions/basic 47 | with: 48 | message: 'Hello World' 49 | use-first: false 50 | - run: | 51 | echo "Post-step" 52 | 53 | test-basic: 54 | needs: docker-image-build 55 | runs-on: ubuntu-latest 56 | steps: 57 | - includes: ./tests/includes/actions/basic 58 | with: 59 | message: 'Hello World' 60 | 61 | test-recursive-local-with-local: 62 | needs: docker-image-build 63 | runs-on: ubuntu-latest 64 | steps: 65 | - includes: ./tests/includes/actions/recursive/local 66 | with: 67 | use-first: true 68 | use-last: true 69 | 70 | - includes: ./tests/includes/actions/recursive/local 71 | 72 | - includes: ./tests/includes/actions/recursive/local 73 | with: 74 | use-first: false 75 | use-last: false 76 | 77 | test-recursive-local-with-remote: 78 | needs: docker-image-build 79 | runs-on: ubuntu-latest 80 | steps: 81 | - includes: ./tests/includes/actions/recursive/remote 82 | with: 83 | use-first: true 84 | use-last: true 85 | 86 | - includes: ./tests/includes/actions/recursive/remote 87 | 88 | - includes: ./tests/includes/actions/recursive/remote 89 | with: 90 | use-first: false 91 | use-last: false 92 | 93 | test-includes-script: 94 | needs: docker-image-build 95 | runs-on: ubuntu-latest 96 | steps: 97 | - includes: ./tests/includes/actions/script 98 | 99 | test-recursive-remote-other: 100 | needs: docker-image-build 101 | runs-on: ubuntu-latest 102 | steps: 103 | - includes: ./tests/includes/actions/recursive/remote-other 104 | 105 | test-include-some1: 106 | needs: docker-image-build 107 | runs-on: ubuntu-latest 108 | steps: 109 | - includes: ./tests/includes/actions/sometimes 110 | with: 111 | message: Hello everyone! 112 | 113 | test-include-some2: 114 | needs: docker-image-build 115 | runs-on: ubuntu-latest 116 | steps: 117 | - includes: ./tests/includes/actions/sometimes 118 | with: 119 | message: Everyone! 120 | -------------------------------------------------------------------------------- /tests/workflows/actions-remote.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | on: 18 | push: 19 | pull_request: 20 | 21 | jobs: 22 | docker-image-build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - includes: /wait-on-docker-image 26 | 27 | test-setting-default-false-to-true: 28 | needs: docker-image-build 29 | runs-on: ubuntu-latest 30 | steps: 31 | - run: | 32 | echo "Pre-step" 33 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 34 | with: 35 | message: 'Hello World' 36 | use-last: true 37 | - run: | 38 | echo "Post-step" 39 | 40 | test-setting-default-true-to-false: 41 | needs: docker-image-build 42 | runs-on: ubuntu-latest 43 | steps: 44 | - run: | 45 | echo "Pre-step" 46 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 47 | with: 48 | message: 'Hello World' 49 | use-first: false 50 | - run: | 51 | echo "Post-step" 52 | 53 | test-basic: 54 | needs: docker-image-build 55 | runs-on: ubuntu-latest 56 | steps: 57 | - includes: mithro/actions-includes/tests/includes/actions/basic@main 58 | with: 59 | message: 'Hello World' 60 | 61 | test-recursive-remote-with-local: 62 | needs: docker-image-build 63 | runs-on: ubuntu-latest 64 | steps: 65 | - includes: mithro/actions-includes/tests/includes/actions/recursive/local@main 66 | with: 67 | use-first: true 68 | use-last: true 69 | 70 | - includes: mithro/actions-includes/tests/includes/actions/recursive/local@main 71 | 72 | - includes: mithro/actions-includes/tests/includes/actions/recursive/local@main 73 | with: 74 | use-first: false 75 | use-last: false 76 | 77 | test-recursive-remote-with-remote: 78 | needs: docker-image-build 79 | runs-on: ubuntu-latest 80 | steps: 81 | - includes: mithro/actions-includes/tests/includes/actions/recursive/remote@main 82 | with: 83 | use-first: true 84 | use-last: true 85 | 86 | - includes: mithro/actions-includes/tests/includes/actions/recursive/remote@main 87 | 88 | - includes: mithro/actions-includes/tests/includes/actions/recursive/remote@main 89 | with: 90 | use-first: false 91 | use-last: false 92 | 93 | test-other-location: 94 | needs: docker-image-build 95 | runs-on: ubuntu-latest 96 | steps: 97 | - includes: mithro/actions-includes/include@other 98 | 99 | test-includes-script: 100 | needs: docker-image-build 101 | runs-on: ubuntu-latest 102 | steps: 103 | - includes: mithro/actions-includes/tests/includes/actions/script@main 104 | 105 | test-recursive-remote-other: 106 | needs: docker-image-build 107 | runs-on: ubuntu-latest 108 | steps: 109 | - includes: mithro/actions-includes/tests/includes/actions/recursive/remote-other@main 110 | 111 | test-include-some1: 112 | needs: docker-image-build 113 | runs-on: ubuntu-latest 114 | steps: 115 | - includes: mithro/actions-includes/tests/includes/actions/sometimes@main 116 | with: 117 | message: Hello everyone! 118 | 119 | test-include-some2: 120 | needs: docker-image-build 121 | runs-on: ubuntu-latest 122 | steps: 123 | - includes: mithro/actions-includes/tests/includes/actions/sometimes@main 124 | with: 125 | message: Everyone! 126 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker-image.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | name: Publish Docker image 18 | 19 | on: 20 | push: 21 | pull_request: 22 | 23 | jobs: 24 | 25 | push_to_registry: 26 | name: Push Docker image to GitHub Packages 27 | runs-on: ubuntu-latest 28 | 29 | permissions: 30 | contents: read 31 | packages: write 32 | 33 | # Run a local registry 34 | services: 35 | registry: 36 | image: registry:2 37 | ports: 38 | - 5000:5000 39 | 40 | steps: 41 | 42 | - uses: actions/checkout@v2 43 | with: 44 | # Always clone the full depth so git-describe works. 45 | fetch-depth: 0 46 | submodules: true 47 | 48 | - name: Set up Python 🐍 49 | uses: actions/setup-python@v2 50 | with: 51 | python-version: 3.9 52 | 53 | - uses: ./.github/includes/actions/prepare-for-docker-build 54 | 55 | - name: Push To 56 | id: push_to 57 | shell: python 58 | run: | 59 | import os 60 | 61 | def g(k): 62 | v = os.environ.get(k, 'MISSING-{}-VALUE'.format(k)) 63 | print("Got {}='{}'".format(k, v)) 64 | return v 65 | 66 | gh_repo = g('GITHUB_REPOSITORY') 67 | gh_event = g('GITHUB_EVENT_NAME') 68 | 69 | i = [] 70 | 71 | print("Adding local service.") 72 | i.append("localhost:5000/"+gh_repo) 73 | 74 | # Use GITHUB_TOKEN for authentication (always available) 75 | if gh_event == 'push': 76 | print("Adding GitHub Container Repository (ghcr.io)") 77 | i.append("ghcr.io/{}/image".format(gh_repo)) 78 | else: 79 | print("Skipping GitHub Container Repository (ghcr.io) for non-push events") 80 | 81 | l = ",".join(i) 82 | print("Final locations:", repr(l)) 83 | 84 | # Use environment file instead of deprecated set-output 85 | with open(os.environ['GITHUB_OUTPUT'], 'a') as f: 86 | f.write("images={}\n".format(l)) 87 | 88 | - name: Docker meta 89 | id: docker_meta 90 | uses: crazy-max/ghaction-docker-meta@v1 91 | with: 92 | images: ${{ steps.push_to.outputs.images }} 93 | tag-sha: true 94 | 95 | - name: Set up QEMU 96 | uses: docker/setup-qemu-action@v1 97 | 98 | - name: Set up Docker Buildx 99 | uses: docker/setup-buildx-action@v1 100 | with: 101 | driver-opts: network=host 102 | 103 | - name: Login to GHCR 104 | if: ${{ contains(steps.push_to.outputs.images, 'ghcr.io') }} 105 | uses: docker/login-action@v3 106 | with: 107 | registry: ghcr.io 108 | username: ${{ github.actor }} 109 | password: ${{ secrets.GITHUB_TOKEN }} 110 | 111 | # - name: Login to local registry 112 | # uses: docker/login-action@v1 113 | # with: 114 | # registry: localhost:5000 115 | 116 | - name: Build and push 117 | uses: docker/build-push-action@v2 118 | id: docker_build 119 | with: 120 | context: docker 121 | # platforms: linux/amd64,linux/arm64,linux/386 122 | push: true 123 | tags: | 124 | ${{ steps.docker_meta.outputs.tags }} 125 | localhost:5000/mithro/actions-includes:latest 126 | labels: ${{ steps.docker_meta.outputs.labels }} 127 | 128 | - name: Inspect 129 | run: docker buildx imagetools inspect localhost:5000/$GITHUB_REPOSITORY:latest 130 | -------------------------------------------------------------------------------- /.github/workflows/test.actions-ifexpands.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | # !! WARNING !! 18 | # Do not modify this file directly! 19 | # !! WARNING !! 20 | # 21 | # It is generated from: ../../tests/workflows/actions-ifexpands.yml 22 | # using the script from https://github.com/mithro/actions-includes@main 23 | 24 | on: 25 | push: 26 | pull_request: 27 | jobs: 28 | docker-image-build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: ⏰ 📝 - Get source code 32 | uses: actions/checkout@v2 33 | - name: ⏰ 📝 - Setting up Python for local docker build 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.9 37 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 38 | uses: ./.github/includes/actions/prepare-for-docker-build 39 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 40 | uses: ./.github/includes/actions/local 41 | if: runner.os == 'Linux' 42 | continue-on-error: false 43 | with: 44 | workflow: .github/workflows/test.actions-ifexpands.yml 45 | - id: wait 46 | name: Wait for Docker Image build 47 | uses: fountainhead/action-wait-for-check@v1.0.0 48 | with: 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | checkName: Push Docker image to GitHub Packages 51 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 52 | - name: Docker Image Check 53 | env: 54 | STATUS: ${{ steps.wait.outputs.conclusion }} 55 | run: | 56 | if [[ "$STATUS" != "success" ]]; then 57 | echo "::error {{ $STATUS }}" 58 | exit 1 59 | fi 60 | test-setting-default-false-to-true: 61 | needs: docker-image-build 62 | strategy: 63 | matrix: 64 | use-first: [true, false] 65 | use-last: [true, false] 66 | runs-on: ubuntu-latest 67 | steps: 68 | - name: ⏰ 📝 - Get source code 69 | uses: actions/checkout@v2 70 | - name: ⏰ 📝 - Setting up Python for local docker build 71 | uses: actions/setup-python@v2 72 | with: 73 | python-version: 3.9 74 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 75 | uses: ./.github/includes/actions/prepare-for-docker-build 76 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 77 | uses: ./.github/includes/actions/local 78 | if: runner.os == 'Linux' 79 | continue-on-error: false 80 | with: 81 | workflow: .github/workflows/test.actions-ifexpands.yml 82 | - name: First step 83 | if: ${{ matrix.use-first }} 84 | run: | 85 | echo "First step!" 86 | - name: Middle step 87 | run: | 88 | echo "Hello World" 89 | - name: First step 90 | if: ${{ matrix.use-first }} 91 | run: | 92 | echo "First step!" 93 | - name: Middle step 94 | run: | 95 | echo "Hello World" 96 | - name: Last step 97 | if: ${{ matrix.use-first }} 98 | run: | 99 | echo "Last step!" 100 | - name: First step 101 | if: ${{ matrix.use-last }} 102 | run: | 103 | echo "First step!" 104 | - name: Middle step 105 | run: | 106 | echo "Hello World" 107 | - name: Last step 108 | if: ${{ matrix.use-last }} 109 | run: | 110 | echo "Last step!" 111 | - name: First step 112 | run: | 113 | echo "First step!" 114 | - name: Middle step 115 | run: | 116 | echo "Hello World" 117 | - name: Last step 118 | if: ${{ matrix.use-last }} 119 | run: | 120 | echo "Last step!" 121 | - name: Step 122 | if: ${{ matrix.use-first && matrix.use-last }} 123 | run: | 124 | echo "Hello world!" 125 | - name: Step 126 | if: ${{ matrix.use-last }} 127 | run: | 128 | echo "Hello world!" 129 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | ACTIVATE=[[ -e venv/bin/activate ]] && source venv/bin/activate; 20 | 21 | SHELL := /bin/bash 22 | 23 | clean: 24 | rm -rf build dist actions_includes.egg-info 25 | 26 | .PHONY: clean 27 | 28 | venv-clean: 29 | rm -rf venv 30 | 31 | .PHONY: venv-clean 32 | 33 | venv: $(wildcard requirements*.txt) 34 | virtualenv --python=python3 venv 35 | ${ACTIVATE} pip install -r requirements.txt 36 | ${ACTIVATE} pip install -e . 37 | 38 | .PHONY: venv 39 | 40 | enter: 41 | ${ACTIVATE} bash 42 | 43 | .PHONY: enter 44 | 45 | 46 | build: clean 47 | ${ACTIVATE} python setup.py sdist bdist_wheel 48 | 49 | .PHONY: build 50 | 51 | # Run Python test suite 52 | test: 53 | ${ACTIVATE} pytest --verbose 54 | 55 | .PHONY: test 56 | 57 | # PYPI_TEST = --repository-url https://test.pypi.org/legacy/ 58 | PYPI_TEST = --repository testpypi 59 | 60 | upload-test: build 61 | ${ACTIVATE} twine upload ${PYPI_TEST} dist/* 62 | 63 | .PHONY: upload-test 64 | 65 | upload: build 66 | ${ACTIVATE} twine upload --verbose dist/* 67 | 68 | .PHONY: upload 69 | 70 | help: 71 | ${ACTIVATE} python setup.py --help-commands 72 | 73 | .PHONY: help 74 | 75 | # Docker image building 76 | image: build 77 | rm -f ./docker/*.tar.gz 78 | cp dist/*.tar.gz ./docker/ 79 | docker build -t actions-includes docker 80 | 81 | .PHONY: image 82 | 83 | # Example GitHub action container launch command; 84 | # --name ghcriomithroactionsincludes_1d5649 85 | # --label 5588e4 86 | # --workdir /github/workspace 87 | # --rm 88 | # -e INPUT_WORKFLOW 89 | # -e HOME 90 | # -e GITHUB_JOB 91 | # -e GITHUB_REF 92 | # -e GITHUB_SHA 93 | # -e GITHUB_REPOSITORY 94 | # -e GITHUB_REPOSITORY_OWNER 95 | # -e GITHUB_RUN_ID 96 | # -e GITHUB_RUN_NUMBER 97 | # -e GITHUB_RETENTION_DAYS 98 | # -e GITHUB_ACTOR 99 | # -e GITHUB_WORKFLOW 100 | # -e GITHUB_HEAD_REF 101 | # -e GITHUB_BASE_REF 102 | # -e GITHUB_EVENT_NAME 103 | # -e GITHUB_SERVER_URL 104 | # -e GITHUB_API_URL 105 | # -e GITHUB_GRAPHQL_URL 106 | # -e GITHUB_WORKSPACE 107 | # -e GITHUB_ACTION 108 | # -e GITHUB_EVENT_PATH 109 | # -e GITHUB_ACTION_REPOSITORY 110 | # -e GITHUB_ACTION_REF 111 | # -e GITHUB_PATH 112 | # -e GITHUB_ENV 113 | # -e RUNNER_OS 114 | # -e RUNNER_TOOL_CACHE 115 | # -e RUNNER_TEMP 116 | # -e RUNNER_WORKSPACE 117 | # -e ACTIONS_RUNTIME_URL 118 | # -e ACTIONS_RUNTIME_TOKEN 119 | # -e ACTIONS_CACHE_URL 120 | # -e GITHUB_ACTIONS=true 121 | # -e CI=true 122 | # -v "/var/run/docker.sock":"/var/run/docker.sock" 123 | # -v "/home/runner/work/_temp/_github_home":"/github/home" 124 | # -v "/home/runner/work/_temp/_github_workflow":"/github/workflow" 125 | # -v "/home/runner/work/_temp/_runner_file_commands":"/github/file_commands" 126 | # -v "/home/runner/work/actions-includes/actions-includes":"/github/workspace" 127 | # ghcr.io/mithro/actions-includes 128 | # "action.yml" 129 | 130 | image-test: image 131 | GITHUB_REPOSITORY=mithro/actions-includes \ 132 | GITHUB_SHA=$$(git rev-parse HEAD) \ 133 | docker run \ 134 | --workdir /github/workspace \ 135 | --rm \ 136 | -e GITHUB_SHA \ 137 | -e GITHUB_REPOSITORY \ 138 | -v "$$PWD":"/github/workspace" \ 139 | actions-includes \ 140 | .github/workflows/local.yml 141 | 142 | .PHONY: image 143 | 144 | 145 | # Update the GitHub action workflows 146 | WORKFLOWS = $(addprefix .github/workflows/test.,$(notdir $(wildcard ./tests/workflows/*.yml))) 147 | 148 | export GITHUB_REPOSITORY := mithro/actions-includes 149 | .github/workflows/test.%.yml: tests/workflows/%.yml actions_includes/__init__.py 150 | @echo 151 | @echo "Updating $@" 152 | @echo "--------------------------------------" 153 | ${ACTIVATE} cd tests && python -m actions_includes ../$< ../$@ 154 | @echo "--------------------------------------" 155 | 156 | update-workflows: $(WORKFLOWS) 157 | @true 158 | 159 | .PHONY: update-workflows 160 | 161 | 162 | action.yml: .github/includes/actions/local/action.yml .github/includes/actions/local/update-top.py 163 | .github/includes/actions/local/update-top.py 164 | 165 | # Redirect anything else to setup.py 166 | %: 167 | ${ACTIVATE} python setup.py $@ 168 | -------------------------------------------------------------------------------- /actions_includes/yaml_map.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import pprint 21 | 22 | from collections import defaultdict 23 | 24 | 25 | 26 | class YamlMap: 27 | """ 28 | 29 | >>> y = YamlMap() 30 | >>> '1' in y 31 | False 32 | >>> y['1'] = 1 33 | >>> '1' in y 34 | True 35 | >>> y['1'] 36 | 1 37 | >>> y['1'] = 2 38 | >>> '1' in y 39 | True 40 | >>> y['1'] 41 | Traceback (most recent call last): 42 | ... 43 | actions_includes.yaml_map.YamlMap.MultiKeyError: 'Multi key: 1 == [1, 2]' 44 | >>> y['2'] = 3 45 | >>> y['1'] = 4 46 | >>> list(y.items()) 47 | [('1', 1), ('1', 2), ('2', 3), ('1', 4)] 48 | 49 | >>> del y['1'] 50 | >>> list(y.items()) 51 | [('2', 3)] 52 | 53 | >>> y['3'] = 4 54 | >>> y.replace('2', 5) 55 | >>> list(y.items()) 56 | [('2', 5), ('3', 4)] 57 | 58 | """ 59 | _MARKER = [] 60 | 61 | class MultiKeyError(KeyError): 62 | pass 63 | 64 | def __init__(self, d=None): 65 | self.__i = 0 66 | self._keys = defaultdict(list) 67 | self._values = {} 68 | self._order = [] 69 | if d: 70 | if hasattr(d, 'items'): 71 | d = d.items() 72 | for k, v in d: 73 | self[k] = v 74 | 75 | def __getitem__(self, k): 76 | if k not in self._keys: 77 | raise KeyError(f'No such key: {k}') 78 | r = [self._values[i] for i in self._keys[k]] 79 | if len(r) == 1: 80 | return r[0] 81 | raise self.MultiKeyError(f'Multi key: {k} == {r}') 82 | 83 | def __setitem__(self, k, v): 84 | self.__i += 1 85 | assert self.__i not in self._values 86 | assert self.__i not in self._order 87 | self._keys[k].append(self.__i) 88 | self._values[self.__i] = v 89 | self._order.append((self.__i, k)) 90 | 91 | def replace(self, k, v, allow_missing=False): 92 | if k not in self._keys: 93 | if not allow_missing: 94 | raise KeyError(f'No such key: {k}') 95 | else: 96 | self[k] = None 97 | i = self._keys[k] 98 | if len(i) > 1: 99 | raise self.MultiKeyError(f'Multi key: {k} == {i}') 100 | self._values[i[0]] = v 101 | 102 | def __delitem__(self, k): 103 | if k not in self._keys: 104 | raise KeyError(f'No such key: {k}') 105 | to_remove = self._keys[k] 106 | for i in to_remove: 107 | del self._values[i] 108 | self._order.remove((i, k)) 109 | del self._keys[k] 110 | 111 | def get(self, k, default=_MARKER): 112 | try: 113 | return self[k] 114 | except KeyError: 115 | if default is not self._MARKER: 116 | return default 117 | raise 118 | 119 | def __contains__(self, k): 120 | return k in self._keys 121 | 122 | class map_items: 123 | def __init__(self, m): 124 | self.m = m 125 | 126 | def __iter__(self): 127 | for i, k in self.m._order: 128 | v = self.m._values[i] 129 | yield (k, v) 130 | 131 | def __len__(self): 132 | return len(self.m) 133 | 134 | def items(self): 135 | return self.map_items(self) 136 | 137 | class map_keys: 138 | def __init__(self, m): 139 | self.m = m 140 | 141 | def __iter__(self): 142 | for i, k in self.m._order: 143 | yield k 144 | 145 | def __len__(self): 146 | return len(self.m) 147 | 148 | def keys(self): 149 | return self.map_keys(self) 150 | 151 | class map_values: 152 | def __init__(self, m): 153 | self.m = m 154 | 155 | def __iter__(self): 156 | for i, _ in self.m._order: 157 | yield self.m._values[i] 158 | 159 | def __len__(self): 160 | return len(self.m) 161 | 162 | def values(self): 163 | return self.map_values(self) 164 | 165 | def __iter__(self): 166 | return iter(self.keys()) 167 | 168 | def __len__(self): 169 | return len(self._order) 170 | 171 | def __repr__(self): 172 | return repr(list(self.items())) 173 | 174 | def pop(self, k): 175 | v = self[k] 176 | del self[k] 177 | return v 178 | 179 | @staticmethod 180 | def presenter(dumper, data): 181 | return dumper.represent_mapping('tag:yaml.org,2002:map', data) 182 | 183 | @staticmethod 184 | def _pprint(p, object, stream, indent, allowance, context, level): 185 | _sort_dicts = p._sort_dicts 186 | p._sort_dicts = False 187 | p._pprint_dict(object, stream, indent, allowance, context, level) 188 | p._sort_dicts = _sort_dicts 189 | 190 | 191 | pprint.PrettyPrinter._dispatch[YamlMap.__repr__] = YamlMap._pprint 192 | 193 | 194 | def construct_yaml_map(self, node): 195 | if isinstance(node, MappingNode): 196 | self.flatten_mapping(node) 197 | data = YamlMap() 198 | yield data 199 | for key_node, value_node in node.value: 200 | key = self.construct_object(key_node, deep=True) 201 | val = self.construct_object(value_node, deep=True) 202 | data[key] = val 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # :warning: This project is **mostly** abandoned. 3 | 4 | You are probably better using 5 | [Composite Actions](https://docs.github.com/en/actions/creating-actions/creating-a-composite-action) 6 | or 7 | [Reusable workflows](https://docs.github.com/en/actions/using-workflows/reusing-workflows). 8 | 9 | -------------------------------- 10 | -------------------------------- 11 | 12 | # actions-includes 13 | 14 | [![License](https://img.shields.io/github/license/mithro/actions-includes.svg)](https://github.com/mithro/actions-includes/blob/master/LICENSE) 15 | [![GitHub issues](https://img.shields.io/github/issues/mithro/actions-includes)](https://github.com/mithro/actions-includes/issues) 16 | ![PyPI](https://img.shields.io/pypi/v/actions-includes) 17 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/actions-includes) 18 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/actions-includes) 19 | 20 | 21 | Allows including an action inside another action (by preprocessing the YAML file). 22 | 23 | Instead of using `uses` or `run` in your action step, use the keyword `includes`. 24 | 25 | Once you are using the `includes` argument, the workflows can be expanded using this tool as follows: 26 | ```sh 27 | # python -m actions_include 28 | 29 | python -m actions_includes ./.github/workflows-src/workflow-a.yml ./.github/workflows/workflow-a.yml 30 | ``` 31 | 32 | ## Usage with docker 33 | 34 | ```sh 35 | docker container run --rm -it -v $(pwd):/github/workspace --entrypoint="" ghcr.io/mithro/actions-includes/image:main python -m actions_includes ./.github/workflows-src/workflow-a.yml ./.github/workflows/workflow-a.yml 36 | ``` 37 | 38 | ## `includes:` step 39 | 40 | ```yaml 41 | 42 | steps: 43 | - name: Other step 44 | run: | 45 | command 46 | 47 | - includes: {action-name} 48 | with: 49 | {inputs} 50 | 51 | - name: Other step 52 | run: | 53 | command 54 | ``` 55 | 56 | The `{action-name}` follows the same syntax as the standard GitHub action 57 | [`uses`](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstepsuses) 58 | and the action referenced should look exactly like a 59 | [GitHub "composite action"](https://docs.github.com/en/actions/creating-actions/creating-a-composite-run-steps-action) 60 | **except** `runs.using` should be `includes`. 61 | 62 | For example; 63 | - `{owner}/{repo}@{ref}` - Public action in `github.com/{owner}/{repo}` 64 | - `{owner}/{repo}/{path}@{ref}` - Public action under `{path}` in 65 | `github.com/{owner}/{repo}`. 66 | - `./{path}` - Local action under local `{path}`, IE `./.github/actions/{action-name}`. 67 | 68 | As it only makes sense to reference composite actions, the `docker://` form isn't supported. 69 | 70 | As you frequently want to include local actions, `actions-includes` extends the 71 | `{action-name}` syntax to also support: 72 | 73 | - `/{name}` - Local action under `./.github/includes/actions/{name}`. 74 | 75 | This is how composite actions should have worked. 76 | 77 | ## `includes-script:` 78 | You can include a script (e.g., a Python or shell script) in your workflow.yml file using the `includes-script` step. 79 | 80 | Example script file: `script.py` 81 | > ```python 82 | > print('Hello world') 83 | > ``` 84 | 85 | To include the script, reference it in an `includes-script` action in your `workflow.yml`, like so: 86 | > ```yaml 87 | > steps: 88 | > - name: Other step 89 | > run: | 90 | > command 91 | > 92 | > - name: Hello 93 | > includes-script: script.py 94 | > 95 | > - name: Other step 96 | > run: | 97 | > command 98 | > ``` 99 | 100 | When the workflow.yml is processed by running 101 | ```python -m actions_includes.py workflow.in.yml workflow.out.yml```, 102 | the resultant `workflow.out.yml` looks like this: 103 | > ```yaml 104 | > steps: 105 | > - name: Other step 106 | > run: | 107 | > command 108 | > 109 | > - name: Hello 110 | > shell: python 111 | > run: | 112 | > print('Hello world') 113 | > 114 | > - name: Other step 115 | > run: | 116 | > command 117 | > ``` 118 | The `shell` parameter is deduced from the file extension, 119 | but it is possible to use a custom shell by setting the 120 | `shell` parameter manually. 121 | 122 | ## Using a pre-commit hook 123 | When you use actions-includes, it may be useful to add a pre-commit hook 124 | (see https://git-scm.com/docs/githooks) to your project so that your workflow 125 | files are always pre-processed before they reach GitHub. 126 | 127 | ### With a git hooks package 128 | There are multiple packages (notably `pre-commit`; 129 | see https://pre-commit.com/) that support adding pre-commit hooks. 130 | 131 | In the case of using the `pre-commit` package, you can add an entry 132 | such as the following to your `pre-commit-config.yaml` file: 133 | > ``` 134 | > - repo: local 135 | > hooks: 136 | > - id: preprocess-workflows 137 | > name: Preprocess workflow.yml 138 | > entry: python -m actions_includes.py workflow.in.yml workflow.out.yml 139 | > language: system 140 | > always-run: true 141 | > ``` 142 | 143 | ### Without a git hooks package 144 | Alternatively, to add a pre-commit hook without installing another 145 | package, you can simply create or modify `.git/hooks/pre-commit` 146 | (relative to your project root). A sample file typically 147 | lives at `.git/hooks/pre-commit.sample`. 148 | 149 | The pre-commit hook should run the commands that are necessary to 150 | pre-process your workflows. So, your `.git/hooks/pre-commit` file 151 | might look something like this: 152 | > ```sh 153 | > #!/bin/bash 154 | > 155 | > python -m actions_includes.py workflow.in.yml workflow.out.yml || { 156 | > echo "Failed to preprocess workflow file.""" 157 | > } 158 | > ``` 159 | 160 | To track this script in source code management, you'll have to 161 | put it in a non-ignored file in your project that is then copied to 162 | `.git/hooks/pre-commit` as part of your project setup. See 163 | https://github.com/ModularHistory/modularhistory for an example 164 | of a project that does this with a setup script (`setup.sh`). 165 | -------------------------------------------------------------------------------- /actions_includes/files.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import os.path 21 | import pathlib 22 | import urllib 23 | import urllib.request 24 | 25 | from collections import namedtuple 26 | 27 | from .output import printerr, printdbg 28 | 29 | 30 | """ 31 | Functions for dealing with files that could either be local on disk or on in a 32 | remote GitHub repository. 33 | """ 34 | 35 | class FilePath: 36 | pass 37 | 38 | 39 | _LocalFilePath = namedtuple('LocalFilePath', 'repo_root path') 40 | class LocalFilePath(_LocalFilePath, FilePath): 41 | def __new__(cls, repo_root, path): 42 | if isinstance(repo_root, str): 43 | repo_root = pathlib.Path(repo_root) 44 | if isinstance(path, str): 45 | path = pathlib.Path(path) 46 | return super().__new__(cls, repo_root, path) 47 | 48 | def __str__(self): 49 | return os.path.join(self.repo_root, self.path) 50 | 51 | 52 | _RemoteFilePath = namedtuple('RemoteFilePath', 'user repo ref path') 53 | class RemoteFilePath(_RemoteFilePath, FilePath): 54 | def __str__(self): 55 | return '{user}/{repo}/{path}@{ref}'.format( 56 | user=self.user, repo=self.repo, path=self.path, ref=self.ref) 57 | 58 | 59 | def parse_remote_path(action_name): 60 | """Convert action name into a FilePath object.""" 61 | assert not action_name.startswith('docker://'), action_name 62 | if '@' not in action_name: 63 | action_name = action_name + '@main' 64 | 65 | repo_plus_path, ref = action_name.split('@', 1) 66 | assert '@' not in ref, action_name 67 | if repo_plus_path.count('/') == 1: 68 | repo_plus_path += '/' 69 | 70 | user, repo, path = repo_plus_path.split('/', 2) 71 | return RemoteFilePath(user, repo, ref, path) 72 | 73 | 74 | def get_filepath(current, filepath, filetype=None): 75 | """ 76 | >>> localfile_current = LocalFilePath(pathlib.Path('/path'), 'abc.yaml') 77 | >>> remotefile_current = RemoteFilePath('user', 'repo', 'ref', 'abc.yaml') 78 | 79 | Local path on local current becomes a local path. 80 | >>> fp = get_filepath(localfile_current, './.github/actions/blah') 81 | >>> fp 82 | LocalFilePath(repo_root=PosixPath('/path'), path=PosixPath('.github/actions/blah')) 83 | >>> str(fp) 84 | '/path/.github/actions/blah' 85 | 86 | >>> fp = get_filepath(localfile_current, '/blah', 'action') 87 | >>> fp 88 | LocalFilePath(repo_root=PosixPath('/path'), path=PosixPath('.github/includes/actions/blah')) 89 | >>> str(fp) 90 | '/path/.github/includes/actions/blah' 91 | 92 | >>> fp = get_filepath(localfile_current, '/blah', 'workflow') 93 | >>> fp 94 | LocalFilePath(repo_root=PosixPath('/path'), path=PosixPath('.github/includes/workflows/blah')) 95 | >>> str(fp) 96 | '/path/.github/includes/workflows/blah' 97 | 98 | Local path on current remote gets converted to a remote path. 99 | >>> fp = get_filepath(remotefile_current, './.github/actions/blah') 100 | >>> fp 101 | RemoteFilePath(user='user', repo='repo', ref='ref', path='.github/actions/blah') 102 | >>> str(fp) 103 | 'user/repo/.github/actions/blah@ref' 104 | 105 | >>> fp = get_filepath(remotefile_current, '/blah', 'workflow') 106 | >>> fp 107 | RemoteFilePath(user='user', repo='repo', ref='ref', path='.github/includes/workflows/blah') 108 | >>> str(fp) 109 | 'user/repo/.github/includes/workflows/blah@ref' 110 | 111 | """ 112 | 113 | # Resolve '/$XXX' to './.github/actions/$XXX' 114 | assert isinstance(filepath, str), (type(filepath), filepath) 115 | if filepath.startswith('/'): 116 | assert filetype is not None, (current, filepath, filetype) 117 | filepath = '/'.join( 118 | ['.', '.github', 'includes', filetype+'s', filepath[1:]]) 119 | 120 | if filepath.startswith('./'): 121 | assert '@' not in filepath, ( 122 | "Local name {} shouldn't have an @ in it".format(filepath)) 123 | 124 | # If new is local but current is remote, rewrite to a remote. 125 | if isinstance(current, RemoteFilePath) and filepath.startswith('./'): 126 | old_filepath = filepath 127 | new_action = current._replace(path=filepath[2:]) 128 | filepath = '{user}/{repo}/{path}@{ref}'.format(**new_action._asdict()) 129 | printerr('Rewrite local action {} in remote repo {} to: {}'.format( 130 | old_filepath, current, filepath)) 131 | 132 | # Local file 133 | if filepath.startswith('./'): 134 | assert isinstance(current, LocalFilePath), (current, filepath) 135 | localpath = (current.repo_root / filepath[2:]).resolve() 136 | repopath = localpath.relative_to(current.repo_root) 137 | return current._replace(path=repopath) 138 | 139 | # Remote file 140 | else: 141 | return parse_remote_path(filepath) 142 | 143 | 144 | DOWNLOAD_CACHE = {} 145 | 146 | 147 | def get_filepath_data(filepath): 148 | # Get local data 149 | if isinstance(filepath, LocalFilePath): 150 | filename = filepath.repo_root / filepath.path 151 | if not filename.exists(): 152 | return IOError('{} does not exist'.format(filename)) 153 | with open(filename) as f: 154 | return f.read() 155 | 156 | # Download remote data 157 | elif isinstance(filepath, RemoteFilePath): 158 | if filepath not in DOWNLOAD_CACHE: 159 | url = 'https://raw.githubusercontent.com/{user}/{repo}/{ref}/{path}'.format( 160 | **filepath._asdict()) 161 | 162 | printerr("Trying to download {} ..".format(url), end=' ') 163 | try: 164 | yaml_data = urllib.request.urlopen(url).read().decode('utf-8') 165 | printerr('Success!') 166 | except urllib.error.URLError as e: 167 | yaml_data = e 168 | printerr('Failed ({})!'.format(e)) 169 | 170 | DOWNLOAD_CACHE[filepath] = yaml_data 171 | return DOWNLOAD_CACHE[filepath] 172 | else: 173 | assert False 174 | -------------------------------------------------------------------------------- /.github/workflows/test.workflows-basic.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | # !! WARNING !! 18 | # Do not modify this file directly! 19 | # !! WARNING !! 20 | # 21 | # It is generated from: ../../tests/workflows/workflows-basic.yml 22 | # using the script from https://github.com/mithro/actions-includes@main 23 | 24 | on: 25 | push: 26 | pull_request: 27 | jobs: 28 | docker-image-build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: ⏰ 📝 - Get source code 32 | uses: actions/checkout@v2 33 | - name: ⏰ 📝 - Setting up Python for local docker build 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.9 37 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 38 | uses: ./.github/includes/actions/prepare-for-docker-build 39 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 40 | uses: ./.github/includes/actions/local 41 | if: runner.os == 'Linux' 42 | continue-on-error: false 43 | with: 44 | workflow: .github/workflows/test.workflows-basic.yml 45 | - id: wait 46 | name: Wait for Docker Image build 47 | uses: fountainhead/action-wait-for-check@v1.0.0 48 | with: 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | checkName: Push Docker image to GitHub Packages 51 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 52 | - name: Docker Image Check 53 | env: 54 | STATUS: ${{ steps.wait.outputs.conclusion }} 55 | run: | 56 | if [[ "$STATUS" != "success" ]]; then 57 | echo "::error {{ $STATUS }}" 58 | exit 1 59 | fi 60 | FirstFirstJob1: 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: ⏰ 📝 - Get source code 64 | uses: actions/checkout@v2 65 | - name: ⏰ 📝 - Setting up Python for local docker build 66 | uses: actions/setup-python@v2 67 | with: 68 | python-version: 3.9 69 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 70 | uses: ./.github/includes/actions/prepare-for-docker-build 71 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 72 | uses: ./.github/includes/actions/local 73 | if: runner.os == 'Linux' 74 | continue-on-error: false 75 | with: 76 | workflow: .github/workflows/test.workflows-basic.yml 77 | - name: First step 78 | run: | 79 | echo "First job" 80 | needs: docker-image-build 81 | FirstFirstJob2: 82 | runs-on: ubuntu-latest 83 | steps: 84 | - name: ⏰ 📝 - Get source code 85 | uses: actions/checkout@v2 86 | - name: ⏰ 📝 - Setting up Python for local docker build 87 | uses: actions/setup-python@v2 88 | with: 89 | python-version: 3.9 90 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 91 | uses: ./.github/includes/actions/prepare-for-docker-build 92 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 93 | uses: ./.github/includes/actions/local 94 | if: runner.os == 'Linux' 95 | continue-on-error: false 96 | with: 97 | workflow: .github/workflows/test.workflows-basic.yml 98 | - name: First step 99 | run: | 100 | echo "First job" 101 | needs: docker-image-build 102 | FirstMiddleJob: 103 | runs-on: ubuntu-latest 104 | needs: 105 | - docker-image-build 106 | - FirstFirstJob1 107 | steps: 108 | - name: ⏰ 📝 - Get source code 109 | uses: actions/checkout@v2 110 | - name: ⏰ 📝 - Setting up Python for local docker build 111 | uses: actions/setup-python@v2 112 | with: 113 | python-version: 3.9 114 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 115 | uses: ./.github/includes/actions/prepare-for-docker-build 116 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 117 | uses: ./.github/includes/actions/local 118 | if: runner.os == 'Linux' 119 | continue-on-error: false 120 | with: 121 | workflow: .github/workflows/test.workflows-basic.yml 122 | - name: Middle step 123 | run: | 124 | echo "Hello World" 125 | SecondFirstJob1: 126 | runs-on: ubuntu-latest 127 | steps: 128 | - name: ⏰ 📝 - Get source code 129 | uses: actions/checkout@v2 130 | - name: ⏰ 📝 - Setting up Python for local docker build 131 | uses: actions/setup-python@v2 132 | with: 133 | python-version: 3.9 134 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 135 | uses: ./.github/includes/actions/prepare-for-docker-build 136 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 137 | uses: ./.github/includes/actions/local 138 | if: runner.os == 'Linux' 139 | continue-on-error: false 140 | with: 141 | workflow: .github/workflows/test.workflows-basic.yml 142 | - name: First step 143 | run: | 144 | echo "First job" 145 | needs: docker-image-build 146 | SecondMiddleJob: 147 | runs-on: ubuntu-latest 148 | needs: 149 | - docker-image-build 150 | - SecondFirstJob1 151 | steps: 152 | - name: ⏰ 📝 - Get source code 153 | uses: actions/checkout@v2 154 | - name: ⏰ 📝 - Setting up Python for local docker build 155 | uses: actions/setup-python@v2 156 | with: 157 | python-version: 3.9 158 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 159 | uses: ./.github/includes/actions/prepare-for-docker-build 160 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 161 | uses: ./.github/includes/actions/local 162 | if: runner.os == 'Linux' 163 | continue-on-error: false 164 | with: 165 | workflow: .github/workflows/test.workflows-basic.yml 166 | - name: Middle step 167 | run: | 168 | echo "Hello World" 169 | SecondLastJob: 170 | runs-on: ubuntu-latest 171 | needs: 172 | - docker-image-build 173 | - SecondFirstJob1 174 | - SecondMiddleJob 175 | steps: 176 | - name: ⏰ 📝 - Get source code 177 | uses: actions/checkout@v2 178 | - name: ⏰ 📝 - Setting up Python for local docker build 179 | uses: actions/setup-python@v2 180 | with: 181 | python-version: 3.9 182 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 183 | uses: ./.github/includes/actions/prepare-for-docker-build 184 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 185 | uses: ./.github/includes/actions/local 186 | if: runner.os == 'Linux' 187 | continue-on-error: false 188 | with: 189 | workflow: .github/workflows/test.workflows-basic.yml 190 | - name: Last step 191 | run: | 192 | echo "Last job!" 193 | FirstJob1: 194 | runs-on: ubuntu-latest 195 | steps: 196 | - name: ⏰ 📝 - Get source code 197 | uses: actions/checkout@v2 198 | - name: ⏰ 📝 - Setting up Python for local docker build 199 | uses: actions/setup-python@v2 200 | with: 201 | python-version: 3.9 202 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 203 | uses: ./.github/includes/actions/prepare-for-docker-build 204 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 205 | uses: ./.github/includes/actions/local 206 | if: runner.os == 'Linux' 207 | continue-on-error: false 208 | with: 209 | workflow: .github/workflows/test.workflows-basic.yml 210 | - name: First step 211 | run: | 212 | echo "First job" 213 | needs: docker-image-build 214 | FirstJob2: 215 | runs-on: ubuntu-latest 216 | steps: 217 | - name: ⏰ 📝 - Get source code 218 | uses: actions/checkout@v2 219 | - name: ⏰ 📝 - Setting up Python for local docker build 220 | uses: actions/setup-python@v2 221 | with: 222 | python-version: 3.9 223 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 224 | uses: ./.github/includes/actions/prepare-for-docker-build 225 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 226 | uses: ./.github/includes/actions/local 227 | if: runner.os == 'Linux' 228 | continue-on-error: false 229 | with: 230 | workflow: .github/workflows/test.workflows-basic.yml 231 | - name: First step 232 | run: | 233 | echo "First job" 234 | needs: docker-image-build 235 | MiddleJob: 236 | runs-on: ubuntu-latest 237 | needs: 238 | - docker-image-build 239 | - FirstJob1 240 | steps: 241 | - name: ⏰ 📝 - Get source code 242 | uses: actions/checkout@v2 243 | - name: ⏰ 📝 - Setting up Python for local docker build 244 | uses: actions/setup-python@v2 245 | with: 246 | python-version: 3.9 247 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 248 | uses: ./.github/includes/actions/prepare-for-docker-build 249 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 250 | uses: ./.github/includes/actions/local 251 | if: runner.os == 'Linux' 252 | continue-on-error: false 253 | with: 254 | workflow: .github/workflows/test.workflows-basic.yml 255 | - name: Middle step 256 | run: | 257 | echo "Hello World" 258 | LastJob: 259 | runs-on: ubuntu-latest 260 | needs: 261 | - docker-image-build 262 | - FirstJob1 263 | - MiddleJob 264 | steps: 265 | - name: ⏰ 📝 - Get source code 266 | uses: actions/checkout@v2 267 | - name: ⏰ 📝 - Setting up Python for local docker build 268 | uses: actions/setup-python@v2 269 | with: 270 | python-version: 3.9 271 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 272 | uses: ./.github/includes/actions/prepare-for-docker-build 273 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 274 | uses: ./.github/includes/actions/local 275 | if: runner.os == 'Linux' 276 | continue-on-error: false 277 | with: 278 | workflow: .github/workflows/test.workflows-basic.yml 279 | - name: Last step 280 | run: | 281 | echo "Last job!" 282 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /.github/workflows/test.actions-local.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | # !! WARNING !! 18 | # Do not modify this file directly! 19 | # !! WARNING !! 20 | # 21 | # It is generated from: ../../tests/workflows/actions-local.yml 22 | # using the script from https://github.com/mithro/actions-includes@main 23 | 24 | on: 25 | push: 26 | pull_request: 27 | jobs: 28 | docker-image-build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: ⏰ 📝 - Get source code 32 | uses: actions/checkout@v2 33 | - name: ⏰ 📝 - Setting up Python for local docker build 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.9 37 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 38 | uses: ./.github/includes/actions/prepare-for-docker-build 39 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 40 | uses: ./.github/includes/actions/local 41 | if: runner.os == 'Linux' 42 | continue-on-error: false 43 | with: 44 | workflow: .github/workflows/test.actions-local.yml 45 | - id: wait 46 | name: Wait for Docker Image build 47 | uses: fountainhead/action-wait-for-check@v1.0.0 48 | with: 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | checkName: Push Docker image to GitHub Packages 51 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 52 | - name: Docker Image Check 53 | env: 54 | STATUS: ${{ steps.wait.outputs.conclusion }} 55 | run: | 56 | if [[ "$STATUS" != "success" ]]; then 57 | echo "::error {{ $STATUS }}" 58 | exit 1 59 | fi 60 | test-setting-default-false-to-true: 61 | needs: docker-image-build 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: ⏰ 📝 - Get source code 65 | uses: actions/checkout@v2 66 | - name: ⏰ 📝 - Setting up Python for local docker build 67 | uses: actions/setup-python@v2 68 | with: 69 | python-version: 3.9 70 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 71 | uses: ./.github/includes/actions/prepare-for-docker-build 72 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 73 | uses: ./.github/includes/actions/local 74 | if: runner.os == 'Linux' 75 | continue-on-error: false 76 | with: 77 | workflow: .github/workflows/test.actions-local.yml 78 | - run: | 79 | echo "Pre-step" 80 | - name: First step 81 | run: | 82 | echo "First step!" 83 | - name: Middle step 84 | run: | 85 | echo "Hello World" 86 | - name: Last step 87 | run: | 88 | echo "Last step!" 89 | - run: | 90 | echo "Post-step" 91 | test-setting-default-true-to-false: 92 | needs: docker-image-build 93 | runs-on: ubuntu-latest 94 | steps: 95 | - name: ⏰ 📝 - Get source code 96 | uses: actions/checkout@v2 97 | - name: ⏰ 📝 - Setting up Python for local docker build 98 | uses: actions/setup-python@v2 99 | with: 100 | python-version: 3.9 101 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 102 | uses: ./.github/includes/actions/prepare-for-docker-build 103 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 104 | uses: ./.github/includes/actions/local 105 | if: runner.os == 'Linux' 106 | continue-on-error: false 107 | with: 108 | workflow: .github/workflows/test.actions-local.yml 109 | - run: | 110 | echo "Pre-step" 111 | - name: Middle step 112 | run: | 113 | echo "Hello World" 114 | - run: | 115 | echo "Post-step" 116 | test-basic: 117 | needs: docker-image-build 118 | runs-on: ubuntu-latest 119 | steps: 120 | - name: ⏰ 📝 - Get source code 121 | uses: actions/checkout@v2 122 | - name: ⏰ 📝 - Setting up Python for local docker build 123 | uses: actions/setup-python@v2 124 | with: 125 | python-version: 3.9 126 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 127 | uses: ./.github/includes/actions/prepare-for-docker-build 128 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 129 | uses: ./.github/includes/actions/local 130 | if: runner.os == 'Linux' 131 | continue-on-error: false 132 | with: 133 | workflow: .github/workflows/test.actions-local.yml 134 | - name: First step 135 | run: | 136 | echo "First step!" 137 | - name: Middle step 138 | run: | 139 | echo "Hello World" 140 | test-recursive-local-with-local: 141 | needs: docker-image-build 142 | runs-on: ubuntu-latest 143 | steps: 144 | - name: ⏰ 📝 - Get source code 145 | uses: actions/checkout@v2 146 | - name: ⏰ 📝 - Setting up Python for local docker build 147 | uses: actions/setup-python@v2 148 | with: 149 | python-version: 3.9 150 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 151 | uses: ./.github/includes/actions/prepare-for-docker-build 152 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 153 | uses: ./.github/includes/actions/local 154 | if: runner.os == 'Linux' 155 | continue-on-error: false 156 | with: 157 | workflow: .github/workflows/test.actions-local.yml 158 | - name: Middle step 159 | run: | 160 | echo "First step" 161 | - name: First step 162 | run: | 163 | echo "First step!" 164 | - name: Middle step 165 | run: | 166 | echo "Middle step" 167 | - name: Last step 168 | run: | 169 | echo "Last step!" 170 | - name: Middle step 171 | run: | 172 | echo "Last step" 173 | - name: Middle step 174 | run: | 175 | echo "First step" 176 | - name: First step 177 | run: | 178 | echo "First step!" 179 | - name: Middle step 180 | run: | 181 | echo "Middle step" 182 | - name: Middle step 183 | run: | 184 | echo "Middle step" 185 | test-recursive-local-with-remote: 186 | needs: docker-image-build 187 | runs-on: ubuntu-latest 188 | steps: 189 | - name: ⏰ 📝 - Get source code 190 | uses: actions/checkout@v2 191 | - name: ⏰ 📝 - Setting up Python for local docker build 192 | uses: actions/setup-python@v2 193 | with: 194 | python-version: 3.9 195 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 196 | uses: ./.github/includes/actions/prepare-for-docker-build 197 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 198 | uses: ./.github/includes/actions/local 199 | if: runner.os == 'Linux' 200 | continue-on-error: false 201 | with: 202 | workflow: .github/workflows/test.actions-local.yml 203 | - name: Middle step 204 | run: | 205 | echo "First step" 206 | - name: First step 207 | run: | 208 | echo "First step!" 209 | - name: Middle step 210 | run: | 211 | echo "Middle step" 212 | - name: Last step 213 | run: | 214 | echo "Last step!" 215 | - name: Middle step 216 | run: | 217 | echo "Last step" 218 | - name: Middle step 219 | run: | 220 | echo "First step" 221 | - name: First step 222 | run: | 223 | echo "First step!" 224 | - name: Middle step 225 | run: | 226 | echo "Middle step" 227 | - name: Middle step 228 | run: | 229 | echo "Middle step" 230 | test-includes-script: 231 | needs: docker-image-build 232 | runs-on: ubuntu-latest 233 | steps: 234 | - name: ⏰ 📝 - Get source code 235 | uses: actions/checkout@v2 236 | - name: ⏰ 📝 - Setting up Python for local docker build 237 | uses: actions/setup-python@v2 238 | with: 239 | python-version: 3.9 240 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 241 | uses: ./.github/includes/actions/prepare-for-docker-build 242 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 243 | uses: ./.github/includes/actions/local 244 | if: runner.os == 'Linux' 245 | continue-on-error: false 246 | with: 247 | workflow: .github/workflows/test.actions-local.yml 248 | - name: Included Python Script 249 | run: | 250 | print('Hello from Python!') 251 | shell: python 252 | - name: Included Shell script 253 | run: | 254 | echo "Hello from bash" 255 | shell: bash 256 | test-recursive-remote-other: 257 | needs: docker-image-build 258 | runs-on: ubuntu-latest 259 | steps: 260 | - name: ⏰ 📝 - Get source code 261 | uses: actions/checkout@v2 262 | - name: ⏰ 📝 - Setting up Python for local docker build 263 | uses: actions/setup-python@v2 264 | with: 265 | python-version: 3.9 266 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 267 | uses: ./.github/includes/actions/prepare-for-docker-build 268 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 269 | uses: ./.github/includes/actions/local 270 | if: runner.os == 'Linux' 271 | continue-on-error: false 272 | with: 273 | workflow: .github/workflows/test.actions-local.yml 274 | - name: First step 275 | run: | 276 | echo "First step!" 277 | - name: Last step 278 | run: | 279 | echo "Last step!" 280 | - name: Included Python Script 281 | run: | 282 | print('Hello from Python!') 283 | shell: python 284 | - name: Included Shell script 285 | run: | 286 | echo "Hello from bash" 287 | shell: bash 288 | - name: First step 289 | run: | 290 | echo "First step!" 291 | - name: Last step 292 | run: | 293 | echo "Last step!" 294 | - name: First step 295 | run: | 296 | echo "First step!" 297 | - name: Last step 298 | run: | 299 | echo "Last step!" 300 | - name: Middle step 301 | run: | 302 | echo "First step" 303 | - name: First step 304 | run: | 305 | echo "First step!" 306 | - name: Middle step 307 | run: | 308 | echo "Middle step" 309 | test-include-some1: 310 | needs: docker-image-build 311 | runs-on: ubuntu-latest 312 | steps: 313 | - name: ⏰ 📝 - Get source code 314 | uses: actions/checkout@v2 315 | - name: ⏰ 📝 - Setting up Python for local docker build 316 | uses: actions/setup-python@v2 317 | with: 318 | python-version: 3.9 319 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 320 | uses: ./.github/includes/actions/prepare-for-docker-build 321 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 322 | uses: ./.github/includes/actions/local 323 | if: runner.os == 'Linux' 324 | continue-on-error: false 325 | with: 326 | workflow: .github/workflows/test.actions-local.yml 327 | - name: Test 328 | if: ${{ !startsWith(runner.os, 'Linux') }} 329 | run: true 330 | - name: Output message 331 | run: | 332 | echo "Hello everyone!" 333 | - name: Be Polite 334 | run: | 335 | echo "Goodbye!" 336 | test-include-some2: 337 | needs: docker-image-build 338 | runs-on: ubuntu-latest 339 | steps: 340 | - name: ⏰ 📝 - Get source code 341 | uses: actions/checkout@v2 342 | - name: ⏰ 📝 - Setting up Python for local docker build 343 | uses: actions/setup-python@v2 344 | with: 345 | python-version: 3.9 346 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 347 | uses: ./.github/includes/actions/prepare-for-docker-build 348 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 349 | uses: ./.github/includes/actions/local 350 | if: runner.os == 'Linux' 351 | continue-on-error: false 352 | with: 353 | workflow: .github/workflows/test.actions-local.yml 354 | - name: Test 355 | if: ${{ !startsWith(runner.os, 'Linux') }} 356 | run: true 357 | - name: Message not polite? 358 | run: | 359 | echo "No hello?" 360 | - name: Output message 361 | run: | 362 | echo "Everyone!" 363 | -------------------------------------------------------------------------------- /.github/workflows/test.actions-remote.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # SPDX-License-Identifier: Apache-2.0 16 | 17 | # !! WARNING !! 18 | # Do not modify this file directly! 19 | # !! WARNING !! 20 | # 21 | # It is generated from: ../../tests/workflows/actions-remote.yml 22 | # using the script from https://github.com/mithro/actions-includes@main 23 | 24 | on: 25 | push: 26 | pull_request: 27 | jobs: 28 | docker-image-build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: ⏰ 📝 - Get source code 32 | uses: actions/checkout@v2 33 | - name: ⏰ 📝 - Setting up Python for local docker build 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.9 37 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 38 | uses: ./.github/includes/actions/prepare-for-docker-build 39 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 40 | uses: ./.github/includes/actions/local 41 | if: runner.os == 'Linux' 42 | continue-on-error: false 43 | with: 44 | workflow: .github/workflows/test.actions-remote.yml 45 | - id: wait 46 | name: Wait for Docker Image build 47 | uses: fountainhead/action-wait-for-check@v1.0.0 48 | with: 49 | token: ${{ secrets.GITHUB_TOKEN }} 50 | checkName: Push Docker image to GitHub Packages 51 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 52 | - name: Docker Image Check 53 | env: 54 | STATUS: ${{ steps.wait.outputs.conclusion }} 55 | run: | 56 | if [[ "$STATUS" != "success" ]]; then 57 | echo "::error {{ $STATUS }}" 58 | exit 1 59 | fi 60 | test-setting-default-false-to-true: 61 | needs: docker-image-build 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: ⏰ 📝 - Get source code 65 | uses: actions/checkout@v2 66 | - name: ⏰ 📝 - Setting up Python for local docker build 67 | uses: actions/setup-python@v2 68 | with: 69 | python-version: 3.9 70 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 71 | uses: ./.github/includes/actions/prepare-for-docker-build 72 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 73 | uses: ./.github/includes/actions/local 74 | if: runner.os == 'Linux' 75 | continue-on-error: false 76 | with: 77 | workflow: .github/workflows/test.actions-remote.yml 78 | - run: | 79 | echo "Pre-step" 80 | - name: First step 81 | run: | 82 | echo "First step!" 83 | - name: Middle step 84 | run: | 85 | echo "Hello World" 86 | - name: Last step 87 | run: | 88 | echo "Last step!" 89 | - run: | 90 | echo "Post-step" 91 | test-setting-default-true-to-false: 92 | needs: docker-image-build 93 | runs-on: ubuntu-latest 94 | steps: 95 | - name: ⏰ 📝 - Get source code 96 | uses: actions/checkout@v2 97 | - name: ⏰ 📝 - Setting up Python for local docker build 98 | uses: actions/setup-python@v2 99 | with: 100 | python-version: 3.9 101 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 102 | uses: ./.github/includes/actions/prepare-for-docker-build 103 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 104 | uses: ./.github/includes/actions/local 105 | if: runner.os == 'Linux' 106 | continue-on-error: false 107 | with: 108 | workflow: .github/workflows/test.actions-remote.yml 109 | - run: | 110 | echo "Pre-step" 111 | - name: Middle step 112 | run: | 113 | echo "Hello World" 114 | - run: | 115 | echo "Post-step" 116 | test-basic: 117 | needs: docker-image-build 118 | runs-on: ubuntu-latest 119 | steps: 120 | - name: ⏰ 📝 - Get source code 121 | uses: actions/checkout@v2 122 | - name: ⏰ 📝 - Setting up Python for local docker build 123 | uses: actions/setup-python@v2 124 | with: 125 | python-version: 3.9 126 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 127 | uses: ./.github/includes/actions/prepare-for-docker-build 128 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 129 | uses: ./.github/includes/actions/local 130 | if: runner.os == 'Linux' 131 | continue-on-error: false 132 | with: 133 | workflow: .github/workflows/test.actions-remote.yml 134 | - name: First step 135 | run: | 136 | echo "First step!" 137 | - name: Middle step 138 | run: | 139 | echo "Hello World" 140 | test-recursive-remote-with-local: 141 | needs: docker-image-build 142 | runs-on: ubuntu-latest 143 | steps: 144 | - name: ⏰ 📝 - Get source code 145 | uses: actions/checkout@v2 146 | - name: ⏰ 📝 - Setting up Python for local docker build 147 | uses: actions/setup-python@v2 148 | with: 149 | python-version: 3.9 150 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 151 | uses: ./.github/includes/actions/prepare-for-docker-build 152 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 153 | uses: ./.github/includes/actions/local 154 | if: runner.os == 'Linux' 155 | continue-on-error: false 156 | with: 157 | workflow: .github/workflows/test.actions-remote.yml 158 | - name: Middle step 159 | run: | 160 | echo "First step" 161 | - name: First step 162 | run: | 163 | echo "First step!" 164 | - name: Middle step 165 | run: | 166 | echo "Middle step" 167 | - name: Last step 168 | run: | 169 | echo "Last step!" 170 | - name: Middle step 171 | run: | 172 | echo "Last step" 173 | - name: Middle step 174 | run: | 175 | echo "First step" 176 | - name: First step 177 | run: | 178 | echo "First step!" 179 | - name: Middle step 180 | run: | 181 | echo "Middle step" 182 | - name: Middle step 183 | run: | 184 | echo "Middle step" 185 | test-recursive-remote-with-remote: 186 | needs: docker-image-build 187 | runs-on: ubuntu-latest 188 | steps: 189 | - name: ⏰ 📝 - Get source code 190 | uses: actions/checkout@v2 191 | - name: ⏰ 📝 - Setting up Python for local docker build 192 | uses: actions/setup-python@v2 193 | with: 194 | python-version: 3.9 195 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 196 | uses: ./.github/includes/actions/prepare-for-docker-build 197 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 198 | uses: ./.github/includes/actions/local 199 | if: runner.os == 'Linux' 200 | continue-on-error: false 201 | with: 202 | workflow: .github/workflows/test.actions-remote.yml 203 | - name: Middle step 204 | run: | 205 | echo "First step" 206 | - name: First step 207 | run: | 208 | echo "First step!" 209 | - name: Middle step 210 | run: | 211 | echo "Middle step" 212 | - name: Last step 213 | run: | 214 | echo "Last step!" 215 | - name: Middle step 216 | run: | 217 | echo "Last step" 218 | - name: Middle step 219 | run: | 220 | echo "First step" 221 | - name: First step 222 | run: | 223 | echo "First step!" 224 | - name: Middle step 225 | run: | 226 | echo "Middle step" 227 | - name: Middle step 228 | run: | 229 | echo "Middle step" 230 | test-other-location: 231 | needs: docker-image-build 232 | runs-on: ubuntu-latest 233 | steps: 234 | - name: ⏰ 📝 - Get source code 235 | uses: actions/checkout@v2 236 | - name: ⏰ 📝 - Setting up Python for local docker build 237 | uses: actions/setup-python@v2 238 | with: 239 | python-version: 3.9 240 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 241 | uses: ./.github/includes/actions/prepare-for-docker-build 242 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 243 | uses: ./.github/includes/actions/local 244 | if: runner.os == 'Linux' 245 | continue-on-error: false 246 | with: 247 | workflow: .github/workflows/test.actions-remote.yml 248 | - name: First step 249 | run: | 250 | echo "First step!" 251 | - name: Last step 252 | run: | 253 | echo "Last step!" 254 | test-includes-script: 255 | needs: docker-image-build 256 | runs-on: ubuntu-latest 257 | steps: 258 | - name: ⏰ 📝 - Get source code 259 | uses: actions/checkout@v2 260 | - name: ⏰ 📝 - Setting up Python for local docker build 261 | uses: actions/setup-python@v2 262 | with: 263 | python-version: 3.9 264 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 265 | uses: ./.github/includes/actions/prepare-for-docker-build 266 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 267 | uses: ./.github/includes/actions/local 268 | if: runner.os == 'Linux' 269 | continue-on-error: false 270 | with: 271 | workflow: .github/workflows/test.actions-remote.yml 272 | - name: Included Python Script 273 | run: | 274 | print('Hello from Python!') 275 | shell: python 276 | - name: Included Shell script 277 | run: | 278 | echo "Hello from bash" 279 | shell: bash 280 | test-recursive-remote-other: 281 | needs: docker-image-build 282 | runs-on: ubuntu-latest 283 | steps: 284 | - name: ⏰ 📝 - Get source code 285 | uses: actions/checkout@v2 286 | - name: ⏰ 📝 - Setting up Python for local docker build 287 | uses: actions/setup-python@v2 288 | with: 289 | python-version: 3.9 290 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 291 | uses: ./.github/includes/actions/prepare-for-docker-build 292 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 293 | uses: ./.github/includes/actions/local 294 | if: runner.os == 'Linux' 295 | continue-on-error: false 296 | with: 297 | workflow: .github/workflows/test.actions-remote.yml 298 | - name: First step 299 | run: | 300 | echo "First step!" 301 | - name: Last step 302 | run: | 303 | echo "Last step!" 304 | - name: Included Python Script 305 | run: | 306 | print('Hello from Python!') 307 | shell: python 308 | - name: Included Shell script 309 | run: | 310 | echo "Hello from bash" 311 | shell: bash 312 | - name: First step 313 | run: | 314 | echo "First step!" 315 | - name: Last step 316 | run: | 317 | echo "Last step!" 318 | - name: First step 319 | run: | 320 | echo "First step!" 321 | - name: Last step 322 | run: | 323 | echo "Last step!" 324 | - name: Middle step 325 | run: | 326 | echo "First step" 327 | - name: First step 328 | run: | 329 | echo "First step!" 330 | - name: Middle step 331 | run: | 332 | echo "Middle step" 333 | test-include-some1: 334 | needs: docker-image-build 335 | runs-on: ubuntu-latest 336 | steps: 337 | - name: ⏰ 📝 - Get source code 338 | uses: actions/checkout@v2 339 | - name: ⏰ 📝 - Setting up Python for local docker build 340 | uses: actions/setup-python@v2 341 | with: 342 | python-version: 3.9 343 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 344 | uses: ./.github/includes/actions/prepare-for-docker-build 345 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 346 | uses: ./.github/includes/actions/local 347 | if: runner.os == 'Linux' 348 | continue-on-error: false 349 | with: 350 | workflow: .github/workflows/test.actions-remote.yml 351 | - name: Test 352 | if: ${{ !startsWith(runner.os, 'Linux') }} 353 | run: true 354 | - name: Output message 355 | run: | 356 | echo "Hello everyone!" 357 | - name: Be Polite 358 | run: | 359 | echo "Goodbye!" 360 | test-include-some2: 361 | needs: docker-image-build 362 | runs-on: ubuntu-latest 363 | steps: 364 | - name: ⏰ 📝 - Get source code 365 | uses: actions/checkout@v2 366 | - name: ⏰ 📝 - Setting up Python for local docker build 367 | uses: actions/setup-python@v2 368 | with: 369 | python-version: 3.9 370 | - name: ⏰ 📝 - Setting up remaining bit for local docker build 371 | uses: ./.github/includes/actions/prepare-for-docker-build 372 | - name: ⏰ 🛂 📖 - Checking workflow expansion is up to date (local) 373 | uses: ./.github/includes/actions/local 374 | if: runner.os == 'Linux' 375 | continue-on-error: false 376 | with: 377 | workflow: .github/workflows/test.actions-remote.yml 378 | - name: Test 379 | if: ${{ !startsWith(runner.os, 'Linux') }} 380 | run: true 381 | - name: Message not polite? 382 | run: | 383 | echo "No hello?" 384 | - name: Output message 385 | run: | 386 | echo "Everyone!" 387 | -------------------------------------------------------------------------------- /actions_includes/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import copy 21 | import hashlib 22 | import os 23 | import pathlib 24 | import pprint 25 | import subprocess 26 | import argparse 27 | 28 | from ruamel import yaml 29 | from ruamel.yaml import resolver 30 | from ruamel.yaml import util 31 | from ruamel.yaml.comments import CommentedMap 32 | from ruamel.yaml.constructor import RoundTripConstructor 33 | from ruamel.yaml.nodes import MappingNode 34 | 35 | from pprint import pprint as p 36 | 37 | from . import expressions as exp 38 | from .files import LocalFilePath, RemoteFilePath 39 | from .files import get_filepath 40 | from .files import get_filepath_data 41 | from .output import printerr, printdbg 42 | 43 | 44 | MARKER = "It is generated from: " 45 | INCLUDE_ACTION_NAME = 'mithro/actions-includes@main' 46 | 47 | # ----------------------------------------------------------------------------- 48 | # Get Data 49 | # ----------------------------------------------------------------------------- 50 | 51 | ACTION_YAML_NAMES = [ 52 | '/action.yml', 53 | '/action.yaml', 54 | ] 55 | 56 | 57 | def get_action_data(current_action, action_name): 58 | if isinstance(action_name, str): 59 | action_dirpath = get_filepath(current_action, action_name, 'action') 60 | else: 61 | assert isinstance(action_name, files.FilePath), (type(action_name), action_name) 62 | action_dirpath = action_name 63 | printerr("get_action_data:", current_action, action_name, action_dirpath) 64 | 65 | errors = {} 66 | for f in ACTION_YAML_NAMES: 67 | action_filepath = action_dirpath._replace(path=str(action_dirpath.path)+f) 68 | 69 | data = get_filepath_data(action_filepath) 70 | 71 | errors[action_filepath] = data 72 | if isinstance(data, str): 73 | break 74 | else: 75 | raise IOError( 76 | '\n'.join([ 77 | 'Did not find {} (in {}), errors:'.format( 78 | action_name, current_action), 79 | ] + [ 80 | ' {}: {}'.format(k, str(v)) 81 | for k, v in sorted(errors.items()) 82 | ])) 83 | 84 | printerr("Including:", action_filepath) 85 | yaml_data = yaml_load(action_filepath, data) 86 | assert 'runs' in yaml_data, (type(yaml_data), yaml_data) 87 | assert yaml_data['runs'].get( 88 | 'using', None) == 'includes', pprint.pformat(yaml_data) 89 | return action_filepath, yaml_data 90 | 91 | 92 | JOBS_YAML_NAMES = [ 93 | '/workflow.yml', 94 | '/workflow.yaml', 95 | ] 96 | 97 | 98 | def get_workflow_data(current_workflow, jobs_name): 99 | jobs_dirpath = get_filepath(current_workflow, jobs_name, 'workflow') 100 | printerr("get_workflow_data:", current_workflow, jobs_name, jobs_dirpath) 101 | 102 | errors = {} 103 | for f in JOBS_YAML_NAMES: 104 | jobs_filepath = jobs_dirpath._replace(path=str(jobs_dirpath.path)+f) 105 | 106 | data = get_filepath_data(jobs_filepath) 107 | 108 | errors[jobs_filepath] = data 109 | if isinstance(data, str): 110 | break 111 | else: 112 | raise IOError( 113 | '\n'.join([ 114 | 'Did not find {} (in {}), errors:'.format( 115 | jobs_name, current_workflow), 116 | ] + [ 117 | ' {}: {}'.format(k, str(v)) 118 | for k, v in sorted(errors.items()) 119 | ])) 120 | 121 | printerr("Including:", jobs_filepath) 122 | yaml_data = yaml_load(jobs_filepath, data) 123 | assert 'jobs' in yaml_data, pprint.pformat(yaml_data) 124 | return jobs_filepath, yaml_data 125 | 126 | 127 | 128 | # ----------------------------------------------------------------------------- 129 | 130 | 131 | def expand_input_expressions(yaml_item, context): 132 | """ 133 | 134 | >>> expand_input_expressions('${{ hello }}', {'hello': 'world'}) 135 | 'world' 136 | >>> expand_input_expressions(exp.Value('hello'), {'hello': 'world'}) 137 | 'world' 138 | 139 | >>> expand_input_expressions('${{ hello }}-${{ world }}', {'hello': 'world'}) 140 | 'world-${{ world }}' 141 | 142 | >>> step = { 143 | ... 'if': "startswith(inputs.os, 'ubuntu')", 144 | ... 'name': '🚧 Build distribution 📦', 145 | ... 'uses': 'RalfG/python-wheels-manylinux-build@v0.3.3-manylinux2010_x86_64', 146 | ... 'str': '${{ inputs.empty }}', 147 | ... 'with': { 148 | ... 'build-requirements': 'cython', 149 | ... 'pre-build-command': 'bash ', 150 | ... 'python-versions': '${{ manylinux-versions[inputs.python-version] }}' 151 | ... }, 152 | ... } 153 | >>> inputs = { 154 | ... 'os': exp.Lookup('matrix', 'os'), 155 | ... 'python-version': exp.Lookup('matrix', 'python-version'), 156 | ... 'root_branch': 'refs/heads/master', 157 | ... 'root_user': 'SymbiFlow', 158 | ... 'empty': '', 159 | ... } 160 | >>> p(expand_input_expressions(step, {'inputs': inputs, 'manylinux-versions': {'blah'}})) 161 | {'if': "startswith(inputs.os, 'ubuntu')", 162 | 'name': '🚧 Build distribution 📦', 163 | 'str': '', 164 | 'uses': 'RalfG/python-wheels-manylinux-build@v0.3.3-manylinux2010_x86_64', 165 | 'with': {'build-requirements': 'cython', 166 | 'pre-build-command': 'bash ', 167 | 'python-versions': Lookup('manylinux-versions', Lookup('matrix', 'python-version'))}} 168 | 169 | >>> step = CommentedMap({'f': exp.Lookup('a', 'b')}) 170 | >>> list(step.items()) 171 | [('f', Lookup('a', 'b'))] 172 | >>> step = expand_input_expressions(step, {'a': {'b': 'c'}}) 173 | >>> list(step.items()) 174 | [('f', 'c')] 175 | 176 | >>> ref1 = CommentedMap({'a': 'b'}) 177 | >>> ref2 = CommentedMap({'a': 'c', 'd': 'e'}) 178 | >>> step = CommentedMap({'f': 'g'}) 179 | >>> step.add_yaml_merge([(0, ref1)]) 180 | >>> step.add_yaml_merge([(1, ref2)]) 181 | >>> list(step.non_merged_items()) 182 | [('f', 'g')] 183 | >>> list(step.items()) 184 | [('f', 'g'), ('a', 'b'), ('d', 'e')] 185 | 186 | >>> ref1 = CommentedMapExpression(exp.Value('hello')) 187 | >>> step = CommentedMap({'f': 'g'}) 188 | >>> step.add_yaml_merge([(0, ref1)]) 189 | >>> list(step.items()) 190 | [('f', 'g')] 191 | >>> step = expand_input_expressions(step, {'hello': yaml.comments.CommentedMap({'a': 'b'})}) 192 | >>> list(step.items()) 193 | [('f', 'g'), ('a', 'b')] 194 | 195 | >>> ref1 = CommentedMapExpression(exp.Lookup('a', 'b')) 196 | >>> step = CommentedMap({'f': 'g'}) 197 | >>> step.add_yaml_merge([(0, ref1)]) 198 | >>> list(step.items()) 199 | [('f', 'g')] 200 | >>> step = expand_input_expressions(step, {'a': {'b': yaml.comments.CommentedMap({'c': 'd'})}}) 201 | >>> list(step.items()) 202 | [('f', 'g'), ('c', 'd')] 203 | 204 | """ 205 | marker = [] 206 | new_yaml_item = marker 207 | if isinstance(yaml_item, CommentedMapExpression): 208 | if yaml_item.exp_value is not None: 209 | assert isinstance(yaml_item.exp_value, exp.Expression), yaml_item.node 210 | 211 | new_value = expand_input_expressions(yaml_item.exp_value, context) 212 | if isinstance(new_value, exp.Expression): 213 | new_yaml_item = CommentedMapExpression() 214 | new_yaml_item.node = MapExpressionNode('tag:github.com,2020:expression', new_value) 215 | return new_yaml_item 216 | 217 | assert isinstance(new_value, dict), (new_value, yaml_item.exp_value, context) 218 | yaml_item = yaml.comments.CommentedMap(new_value) 219 | 220 | if isinstance(yaml_item, yaml.comments.CommentedMap): 221 | new_merge_attrib = [] 222 | for name, mapping in getattr(yaml_item, yaml.comments.merge_attrib, []): 223 | new_mapping = expand_input_expressions(mapping, context) 224 | assert isinstance(new_mapping, yaml.comments.CommentedMap), (type(new_mapping), pprint.pformat(new_mapping)) 225 | new_merge_attrib.append((name, new_mapping)) 226 | 227 | new_yaml_item = yaml.comments.CommentedMap() 228 | yaml_item.copy_attributes(new_yaml_item) 229 | setattr(new_yaml_item, yaml.comments.merge_attrib, []) 230 | 231 | for k, v in yaml_item.non_merged_items(): 232 | new_yaml_item[k] = expand_input_expressions(v, context) 233 | 234 | if new_merge_attrib: 235 | new_yaml_item.add_yaml_merge(new_merge_attrib) 236 | 237 | elif isinstance(yaml_item, dict): 238 | new_yaml_item = {} 239 | for k, v in list(yaml_item.items()): 240 | new_yaml_item[k] = expand_input_expressions(v, context) 241 | elif isinstance(yaml_item, list): 242 | new_yaml_item = [] 243 | for i in range(0, len(yaml_item)): 244 | new_yaml_item.append(expand_input_expressions(yaml_item[i], context)) 245 | elif isinstance(yaml_item, exp.Expression): 246 | new_yaml_item = exp.simplify(yaml_item, context) 247 | elif isinstance(yaml_item, str): 248 | if '${{' in yaml_item: 249 | new_yaml_item = exp.eval(yaml_item, context) 250 | else: 251 | new_yaml_item = yaml_item 252 | elif isinstance(yaml_item, (bool, int, float, None.__class__)): 253 | return yaml_item 254 | else: 255 | raise TypeError('{} ({!r})'.format(type(yaml_item), yaml_item)) 256 | 257 | assert new_yaml_item is not marker 258 | 259 | return new_yaml_item 260 | 261 | 262 | def get_needs(d): 263 | needs = d.get('needs', []) 264 | if isinstance(needs, str): 265 | needs = [needs] 266 | return needs 267 | 268 | 269 | def get_if_exp(d): 270 | if 'if' not in d: 271 | return True 272 | 273 | v = d['if'] 274 | if isinstance(v, CommentedMapExpression): 275 | v = v.exp_value 276 | if isinstance(v, exp.Expression): 277 | return v 278 | if not isinstance(v, str): 279 | return v 280 | v = v.strip() 281 | if not v.startswith('${{'): 282 | assert '${{' not in v, (v, d) 283 | assert not v.endswith('}}'), (v, d) 284 | v = "${{ %s }}" % v 285 | return exp.parse(v) 286 | 287 | 288 | def resolve_paths(root_filepath, data): 289 | assert isinstance(root_filepath, files.FilePath), (type(root_filepath), root_filepath) 290 | assert isinstance(data, dict), (type(data), data) 291 | for k, v in data.items(): 292 | if isinstance(v, dict): 293 | resolve_paths(root_filepath, v) 294 | continue 295 | 296 | assert isinstance(k, str), (type(k), k) 297 | if not k.startswith('includes'): 298 | continue 299 | 300 | assert isinstance(v, str), (type(v), v) 301 | data[k] = files.get_filepath(root_filepath, v, filetype='action') 302 | 303 | 304 | 305 | def build_inputs(target_yamldata, include_yamldata, current_filepath): 306 | """ 307 | 308 | >>> def w(**kw): 309 | ... return {'with': kw} 310 | 311 | >>> target = {'inputs': {'arg1': {'default': 1}, 'arg2': {'required': True}}} 312 | >>> p(build_inputs(target, w(arg1=2, arg2=3), None)) 313 | {'arg1': 2, 'arg2': 3} 314 | >>> p(build_inputs(target, w(arg2=3), None)) 315 | {'arg1': 1, 'arg2': 3} 316 | 317 | >>> p(build_inputs(target, w(arg1=4), None)) 318 | Traceback (most recent call last): 319 | ... 320 | KeyError: "with statement was missing required argument 'arg2'... 321 | 322 | >>> p(build_inputs(target, w(arg1=2, arg2=3, arg3=4), None)) 323 | Traceback (most recent call last): 324 | ... 325 | KeyError: 'with statement had unused extra arguments: arg3: 4' 326 | 327 | >>> p(build_inputs(target, w(arg1=2, arg2=3, arg3=4, arg4='a'), None)) 328 | Traceback (most recent call last): 329 | ... 330 | KeyError: "with statement had unused extra arguments: arg3: 4, arg4: 'a'" 331 | 332 | >>> target = {'inputs': {'args1': None}} 333 | >>> lp = LocalFilePath(repo_root='/path', path='.github/actions/blah') 334 | >>> rp = RemoteFilePath(user='user', repo='repo', ref='ref', path='.github/includes/workflows/blah') 335 | >>> p(build_inputs(target, w(args1={'includes': '/a'}), lp)) 336 | {'args1': {'includes': LocalFilePath(repo_root=PosixPath('/path'), path=PosixPath('.github/includes/actions/a'))}} 337 | >>> p(build_inputs(target, w(args1={'includes': '/a'}), rp)) 338 | {'args1': {'includes': RemoteFilePath(user='user', repo='repo', ref='ref', path='.github/includes/actions/a')}} 339 | 340 | """ 341 | 342 | # Calculate the inputs dictionary 343 | with_data = copy.copy(include_yamldata.get('with', {})) 344 | 345 | # FIXME: This is a hack to make sure that paths used in include values are 346 | # relative to the file they are defined in, not the place they are used. 347 | if current_filepath is not None: 348 | resolve_paths(current_filepath, with_data) 349 | 350 | inputs = {} 351 | for in_name, in_info in target_yamldata.get('inputs', {}).items(): 352 | if not in_info: 353 | in_info = {} 354 | 355 | marker = {} 356 | v = marker 357 | 358 | # Set the default value 359 | if 'default' in in_info: 360 | v = in_info['default'] 361 | 362 | # Override with the provided value 363 | if in_name in with_data: 364 | v = with_data.pop(in_name) 365 | 366 | # Check the value is set if required. 367 | if in_info.get('required', False): 368 | if v is marker: 369 | raise KeyError( 370 | "with statement was missing required argument {!r}, got with:\n{}".format( 371 | in_name, pprint.pformat(include_yamldata.get('with', '*nothing*')), 372 | ), 373 | ) 374 | 375 | inputs[in_name] = v 376 | 377 | if with_data: 378 | raise KeyError( 379 | "with statement had unused extra arguments: {}".format( 380 | ", ".join('%s: %r' % (k,v) for k,v in with_data.items()) 381 | ) 382 | ) 383 | return inputs 384 | 385 | 386 | # ----------------------------------------------------------------------------- 387 | 388 | 389 | def add_github_context(context): 390 | github = {} 391 | for k in os.environ.keys(): 392 | if not k.startswith('GITHUB_'): 393 | continue 394 | github[k[7:].lower()] = os.environ[k] 395 | 396 | if not github: 397 | # FIXME: pull the data from the local git repository. 398 | github['sha'] = git_root_output = subprocess.check_output( 399 | ['git', 'rev-parse', 'HEAD']) 400 | 401 | assert not 'github' in context, pprint.format(context) 402 | context['github'] = github 403 | 404 | 405 | def step_type(m): 406 | if 'run' in m: 407 | return 'run' 408 | elif 'uses' in m: 409 | return 'uses' 410 | elif 'includes' in m: 411 | return 'includes' 412 | elif 'includes-script' in m: 413 | return 'includes-script' 414 | else: 415 | raise ValueError('Unknown step type:\n' + pprint.pformat(m) + '\n') 416 | 417 | 418 | def expand_step_includes(current_filepath, include_step): 419 | assert step_type(include_step) == 'includes', (current_filepath, include_step) 420 | 421 | include_filepath, include_yamldata = get_action_data(current_filepath, include_step['includes']) 422 | assert 'runs' in include_yamldata, pprint.pformat(include_yamldata) 423 | assert 'steps' in include_yamldata['runs'], pprint.pformat(include_yamldata) 424 | 425 | try: 426 | input_data = build_inputs(include_yamldata, include_step, current_filepath) 427 | except KeyError as e: 428 | raise SyntaxError('{}: {} while processing {} included with\n{}'.format( 429 | current_filepath, e, include_filepath, pprint.pformat(include_step))) 430 | if 'inputs' in include_yamldata: 431 | del include_yamldata['inputs'] 432 | 433 | context = dict(include_yamldata) 434 | context['inputs'] = input_data 435 | 436 | printdbg('\nInclude Step:') 437 | printdbg(include_step) 438 | printdbg('Inputs:') 439 | printdbg(input_data) 440 | printdbg('Before data:') 441 | printdbg(include_yamldata) 442 | 443 | # Do the input replacements in the yaml file. 444 | include_yamldata = expand_input_expressions(include_yamldata, context) 445 | 446 | printdbg('---') 447 | printdbg('After data:\n', pprint.pformat(include_yamldata)) 448 | printdbg('\n', end='') 449 | 450 | assert 'runs' in include_yamldata, pprint.pformat(include_yamldata) 451 | assert 'steps' in include_yamldata['runs'], pprint.pformat(include_yamldata) 452 | 453 | current_if = get_if_exp(include_step) 454 | 455 | out = [] 456 | for i, step in enumerate(include_yamldata['runs']['steps']): 457 | step_if = exp.AndF(current_if, get_if_exp(step)) 458 | 459 | printdbg(f'Step {i} -', step.get('name', '????')) 460 | printdbg(' Before If:', repr(step_if)) 461 | step_if = exp.simplify(step_if, context) 462 | printdbg(' After If:', repr(step_if)) 463 | 464 | if isinstance(step_if, exp.Expression): 465 | step['if'] = step_if 466 | elif step_if in (False, None, ''): 467 | continue 468 | else: 469 | assert step_if is True, (step_if, repr(step_if)) 470 | if 'if' in step: 471 | del step['if'] 472 | 473 | out.append((include_filepath, step)) 474 | 475 | return out 476 | 477 | 478 | def expand_step_includes_script(current_filepath, v): 479 | assert step_type(v) == 'includes-script', (current_filepath, v) 480 | 481 | script = v.pop('includes-script') 482 | script_file = str((pathlib.Path('/'+current_filepath.path).parent / script).resolve())[1:] 483 | printerr(f"Including script: {script} (relative to {current_filepath}) found at {script_file}") 484 | 485 | script_filepath = get_filepath(current_filepath, './'+script_file) 486 | script_data = get_filepath_data(script_filepath) 487 | 488 | v['run'] = script_data 489 | if 'shell' not in v: 490 | if script.endswith('.py'): 491 | # Standard shell, no `{0}` needed. 492 | v['shell'] = 'python' 493 | elif script.endswith('.ps1'): 494 | # Standard shell, no `{0}` needed. 495 | v['shell'] = 'pwsh' 496 | elif script.endswith('.cmd'): 497 | # Standard shell, no `{0}` needed. 498 | v['shell'] = 'cmd' 499 | elif script.endswith('.rb'): 500 | # Non-standard shell, `{0}` needed. 501 | v['shell'] = 'ruby {0}' 502 | elif script.endswith('.pl'): 503 | # Non-standard shell, `{0}` needed. 504 | v['shell'] = 'perl {0}' 505 | elif script.endswith('.cmake'): 506 | # Non-standard shell, `{0}` needed. 507 | v['shell'] = 'cmake -P {0}' 508 | elif script.endswith('.sh'): 509 | # Standard shell, no `{0}` needed. 510 | v['shell'] = 'bash' 511 | 512 | return expand_step_run(current_filepath, v) 513 | 514 | 515 | def expand_step_uses(current_filepath, v): 516 | assert step_type(v) == 'uses', (current_filepath, v) 517 | # Support the `/{name}` format on `uses` values. 518 | if v['uses'].startswith('/'): 519 | v['uses'] = './.github/includes/actions' + v['uses'] 520 | 521 | return v 522 | 523 | 524 | def expand_step_run(current_filepath, v): 525 | assert step_type(v) == 'run', (current_filepath, v) 526 | return v 527 | 528 | 529 | # ----------------------------------------------------------------------------- 530 | 531 | 532 | def expand_job_steps(current_filepath, job_data): 533 | assert 'steps' in job_data, pprint.pformat(job_data) 534 | 535 | steps = list((current_filepath, s) for s in job_data['steps']) 536 | 537 | new_steps = [] 538 | while steps: 539 | step_filepath, step_data = steps.pop(0) 540 | 541 | st = step_type(step_data) 542 | if st != 'includes': 543 | new_steps.append({ 544 | 'run': expand_step_run, 545 | 'uses': expand_step_uses, 546 | 'includes-script': expand_step_includes_script, 547 | }[st](step_filepath, step_data)) 548 | else: 549 | steps_to_add = expand_step_includes(step_filepath, step_data) 550 | while steps_to_add: 551 | new_step_filepath, new_step_data = steps_to_add.pop(-1) 552 | steps.insert(0, (new_step_filepath, new_step_data)) 553 | 554 | job_data = copy.copy(job_data) 555 | job_data['steps'] = new_steps 556 | return job_data 557 | 558 | 559 | def expand_job_include(current_filepath, include_job): 560 | assert 'includes' in include_job, pprint.pformat(include_job) 561 | 562 | include_filepath, include_yamldata = get_workflow_data( 563 | current_filepath, include_job['includes']) 564 | assert 'jobs' in include_yamldata, pprint.pformat(include_yamldata) 565 | 566 | try: 567 | input_data = build_inputs(include_yamldata, include_job, current_filepath) 568 | except KeyError as e: 569 | raise SyntaxError('{} while processing {} included with:\n{}'.format( 570 | e, include_filepath, pprint.pformat(include_job))) 571 | del include_yamldata['inputs'] 572 | 573 | context = dict(include_yamldata) 574 | context['inputs'] = input_data 575 | 576 | printdbg('') 577 | printdbg('Include Job:') 578 | printdbg(include_job) 579 | printdbg('Inputs:') 580 | printdbg(input_data) 581 | printdbg('') 582 | printdbg('Before job data:') 583 | printdbg(include_yamldata) 584 | 585 | # Do the input replacements in the yaml file. 586 | printdbg('---') 587 | include_yamldata = expand_input_expressions(include_yamldata, context) 588 | printdbg('---') 589 | 590 | printdbg('After job data:') 591 | printdbg(include_yamldata) 592 | printdbg('') 593 | 594 | return include_filepath, include_yamldata, context 595 | 596 | 597 | def expand_workflow_jobs(current_workflow, current_workflow_data): 598 | assert 'jobs' in current_workflow_data, pprint.pformat(current_workflow_data) 599 | 600 | jobs = list((current_workflow, k, v) for k, v in current_workflow_data['jobs'].items()) 601 | 602 | new_jobs = [] 603 | 604 | while jobs: 605 | current_filepath, job_name, job_data = jobs.pop(0) 606 | printdbg('\nJob:', f'{job_name}#{len(new_jobs)}') 607 | 608 | if job_name is None: 609 | job_name = '' 610 | 611 | if 'includes' not in job_data: 612 | # Once all the job includes have been expanded, we can expand the 613 | # steps. 614 | job_data = expand_job_steps(current_filepath, job_data) 615 | new_jobs.append((job_name, job_data)) 616 | continue 617 | 618 | current_needs = get_needs(job_data) 619 | current_if = get_if_exp(job_data) 620 | 621 | include_filepath, included_data, context = expand_job_include(current_filepath, job_data) 622 | assert 'jobs' in included_data, pprint.pformat(included_data) 623 | 624 | included_jobs = list(included_data['jobs'].items()) 625 | while included_jobs: 626 | included_job_name, included_job_data = included_jobs.pop(-1) 627 | new_job_name = job_name+included_job_name 628 | 629 | new_needs = current_needs + [job_name+n for n in get_needs(included_job_data)] 630 | if new_needs: 631 | if len(new_needs) == 1: 632 | new_needs = new_needs.pop(0) 633 | included_job_data['needs'] = new_needs 634 | 635 | new_if = exp.AndF(current_if, get_if_exp(included_job_data)) 636 | 637 | printdbg(new_job_name) 638 | printdbg('Before Job If:', repr(new_if)) 639 | new_if = exp.simplify(new_if, context) 640 | printdbg(' After Job If:', repr(new_if)) 641 | 642 | if isinstance(new_if, exp.Expression): 643 | included_job_data['if'] = current_if 644 | elif new_if in (False, None, ''): 645 | continue 646 | else: 647 | assert new_if is True, (new_if, repr(new_if)) 648 | if 'if' in included_job_data: 649 | del included_job_data['if'] 650 | 651 | jobs.insert(0, (include_filepath, new_job_name, included_job_data)) 652 | 653 | new_workflow = copy.copy(current_workflow_data) 654 | 655 | job_names = [] 656 | # Set all the new jobs 657 | for job_name, job_data in new_jobs: 658 | new_workflow['jobs'][job_name] = job_data 659 | job_names.append(job_name) 660 | # Remove any jobs of the older jobs which still exist. 661 | for job_name in list(new_workflow['jobs'].keys()): 662 | if job_name not in job_names: 663 | del new_workflow['jobs'][job_name] 664 | return new_workflow 665 | 666 | 667 | # ============================================================== 668 | # PyYaml Loader / Dumper Customization 669 | # ============================================================== 670 | 671 | 672 | resolver.BaseResolver.add_implicit_resolver( 673 | u'tag:github.com,2020:expression', 674 | util.RegExp(u'^(?:\\${{[^}]*}})$'), 675 | [u'$'], # - a list of first characters to match 676 | ) 677 | 678 | 679 | def construct_expression(self, node): 680 | if isinstance(node, MapExpressionNode): 681 | return CommentedMapExpression(node.value) 682 | 683 | assert isinstance(node, yaml.nodes.ScalarNode), (type(node), node) 684 | 685 | v = node.value 686 | if isinstance(v, str): 687 | v = exp.parse(v) 688 | 689 | assert isinstance(v, exp.Expression), (type(v), v) 690 | 691 | return v 692 | 693 | 694 | RoundTripConstructor.add_constructor(u'tag:github.com,2020:expression', construct_expression) 695 | 696 | 697 | # ============================================================== 698 | 699 | 700 | class CommentedMapExpression(yaml.comments.CommentedMap): 701 | def __init__(self, a0, *args, **kw): 702 | yaml.comments.CommentedMap.__init__(self, [], *args, **kw) 703 | 704 | if isinstance(a0, str): 705 | a0 = exp.parse(a0) 706 | 707 | self.exp_value = a0 708 | 709 | def __repr__(self): 710 | return 'CommentedMap({!r})'.format(self.exp_value) 711 | 712 | 713 | class MapExpressionNode(yaml.nodes.MappingNode): 714 | def __init__(self, tag, value, *args, **kw): 715 | yaml.nodes.MappingNode.__init__(self, tag, value, *args, **kw) 716 | 717 | def __repr__(self): 718 | return 'MapNode({!r})'.format(self.value) 719 | 720 | 721 | class RoundTripConstructorWithExp(RoundTripConstructor): 722 | def construct_mapping(self, node, maptyp, deep=False): # type: ignore 723 | for i, (key_node, value_node) in enumerate(node.value): 724 | # Upgrade a ScalarNode into a MappingNode so it can be added as a merge reference 725 | if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == 'tag:github.com,2020:expression': 726 | assert isinstance(value_node, yaml.nodes.ScalarNode), value_node 727 | new_node = MapExpressionNode('tag:github.com,2020:expression', value_node.value) 728 | node.value[i] = (key_node, new_node) 729 | 730 | return RoundTripConstructor.construct_mapping(self, node, maptyp, deep) 731 | 732 | 733 | class RoundTripLoaderWithExp( 734 | yaml.reader.Reader, 735 | yaml.scanner.RoundTripScanner, 736 | yaml.parser.RoundTripParser, 737 | yaml.composer.Composer, 738 | RoundTripConstructorWithExp, 739 | yaml.resolver.VersionedResolver, 740 | ): 741 | def __init__(self, stream, version=None, preserve_quotes=None): 742 | # type: (StreamTextType, Optional[VersionType], Optional[bool]) -> None 743 | # self.reader = Reader.__init__(self, stream) 744 | self.comment_handling = None # issue 385 745 | yaml.reader.Reader.__init__(self, stream, loader=self) 746 | yaml.scanner.RoundTripScanner.__init__(self, loader=self) 747 | yaml.parser.RoundTripParser.__init__(self, loader=self) 748 | yaml.composer.Composer.__init__(self, loader=self) 749 | RoundTripConstructorWithExp.__init__(self, preserve_quotes=preserve_quotes, loader=self) 750 | yaml.resolver.VersionedResolver.__init__(self, version, loader=self) 751 | 752 | 753 | # ============================================================== 754 | 755 | 756 | def exp_presenter(dumper, data): 757 | return dumper.represent_scalar('tag:github.com,2020:expression', '${{ '+str(data)+' }}') 758 | 759 | 760 | yaml.representer.RoundTripRepresenter.add_multi_representer(exp.Expression, exp_presenter) 761 | 762 | 763 | def map_exp_presenter(dumper, data): 764 | print("-->", data.node) 765 | return data.node 766 | 767 | 768 | yaml.representer.RoundTripRepresenter.add_representer(MapExpressionNode, map_exp_presenter) 769 | 770 | 771 | # ============================================================== 772 | 773 | 774 | def str_presenter(dumper, data): 775 | """Use the bar form for multiline strings.""" 776 | if '\n' in data: 777 | return dumper.represent_scalar( 778 | 'tag:yaml.org,2002:str', data, style='|') 779 | return dumper.represent_scalar('tag:yaml.org,2002:str', data) 780 | 781 | 782 | def none_presenter(dumper, data): 783 | """Make empty values appear as nothing rather than 'null'""" 784 | assert data is None, data 785 | return dumper.represent_scalar('tag:yaml.org,2002:null', '') 786 | 787 | 788 | class On: 789 | """`on` == `true`, so enable forcing it back to `on`""" 790 | 791 | @staticmethod 792 | def presenter(dumper, data): 793 | return dumper.represent_scalar('tag:yaml.org,2002:bool', 'on') 794 | 795 | 796 | yaml.representer.RoundTripRepresenter.add_representer(str, str_presenter) 797 | yaml.representer.RoundTripRepresenter.add_representer(None.__class__, none_presenter) 798 | yaml.representer.RoundTripRepresenter.add_representer(On, On.presenter) 799 | 800 | 801 | def yaml_load(current_action, yaml_data): 802 | """ 803 | 804 | >>> d = yaml_load(None, ''' 805 | ... jobs: 806 | ... First: 807 | ... if: ${{ hello }} 808 | ... ''') 809 | >>> p(d) 810 | {'jobs': {'First': {'if': Value(hello)}}} 811 | >>> yaml_dump(None, d) 812 | 'jobs:\\n First:\\n if: ${{ hello }}\\n' 813 | """ 814 | 815 | md5sum = hashlib.md5(yaml_data.encode('utf-8')).hexdigest() 816 | printerr(f'Loading yaml file {current_action} with contents md5 of {md5sum}') 817 | printdbg(yaml_data) 818 | return yaml.load(yaml_data, Loader=RoundTripLoaderWithExp) 819 | 820 | 821 | class RoundTripDumperWithoutAliases(yaml.RoundTripDumper): 822 | def ignore_aliases(self, data): 823 | return True 824 | 825 | 826 | def yaml_dump(current_action, data): 827 | return yaml.dump(data, allow_unicode=True, width=1000, Dumper=RoundTripDumperWithoutAliases) 828 | 829 | 830 | # Enable pretty printing for the ruamel.yaml.comments objects 831 | # -------------------------------------------------------------- 832 | 833 | 834 | def commentedmapexp_pprint(p, object, stream, indent, allowance, context, level): 835 | assert isinstance(object, CommentedMapExpression), object 836 | p._format(object.exp_value, stream, indent, allowance, context, level) 837 | 838 | 839 | def commentedmap_pprint(p, object, stream, indent, allowance, context, level): 840 | _sort_dicts = p._sort_dicts 841 | p._sort_dicts = False 842 | 843 | m = getattr(object, yaml.comments.merge_attrib, []) 844 | if m: 845 | write = stream.write 846 | write('<') 847 | if p._indent_per_level > 1: 848 | write((p._indent_per_level - 1) * ' ') 849 | l = [(None, dict(object.non_merged_items()))]+m 850 | while l: 851 | _, o = l.pop(0) 852 | p._format(o, stream, indent, allowance, context, level) 853 | if l: 854 | write('+') 855 | write('>') 856 | else: 857 | p._pprint_dict(object, stream, indent, allowance, context, level) 858 | 859 | p._sort_dicts = _sort_dicts 860 | 861 | 862 | def commentedseq_pprint(p, object, stream, indent, allowance, context, level): 863 | assert isinstance(object, yaml.comments.CommentedSeq), (type(object), object) 864 | p._pprint_list(list(object), stream, indent, allowance, context, level) 865 | 866 | 867 | def commentedset_pprint(p, object, stream, indent, allowance, context, level): 868 | assert isinstance(object, yaml.comments.CommentedSet), (type(object), object) 869 | p._pprint_set(set(object), stream, indent, allowance, context, level) 870 | 871 | 872 | def commentedmap_repr(self): 873 | if self.merge: 874 | l = [(None, dict(self.non_merged_items()))]+self.merge 875 | return '<{}>'.format('+'.join(repr(d) for _, d in l)) 876 | else: 877 | return repr(dict(self)) 878 | 879 | yaml.comments.CommentedMap.__repr__ = commentedmap_repr 880 | 881 | 882 | pprint.PrettyPrinter._dispatch[CommentedMapExpression.__repr__] = commentedmapexp_pprint 883 | pprint.PrettyPrinter._dispatch[yaml.comments.CommentedMap.__repr__] = commentedmap_pprint 884 | pprint.PrettyPrinter._dispatch[yaml.comments.CommentedKeyMap.__repr__] = commentedmap_pprint 885 | pprint.PrettyPrinter._dispatch[yaml.comments.CommentedSeq.__repr__] = commentedseq_pprint 886 | pprint.PrettyPrinter._dispatch[yaml.comments.CommentedSet.__repr__] = commentedset_pprint 887 | 888 | # -------------------------------------------------------------- 889 | 890 | 891 | # ============================================================== 892 | 893 | 894 | def expand_workflow(current_workflow, to_path, insert_check_steps: bool): 895 | src_path = os.path.relpath('/'+str(current_workflow.path), start='/'+str(os.path.dirname(to_path))) 896 | 897 | workflow_filepath = get_filepath(current_workflow, './'+str(current_workflow.path)) 898 | printerr('Expanding workflow file from:', workflow_filepath) 899 | printerr(' to:', to_path) 900 | workflow_data = get_filepath_data(workflow_filepath) 901 | if not isinstance(workflow_data, str): 902 | raise workflow_data 903 | workflow_data = workflow_data.splitlines() 904 | output = [] 905 | while workflow_data[0] and workflow_data[0][0] == '#': 906 | output.append(workflow_data.pop(0)) 907 | 908 | output.append(""" 909 | # !! WARNING !! 910 | # Do not modify this file directly! 911 | # !! WARNING !! 912 | # 913 | # {} 914 | # using the script from https://github.com/{} 915 | """.format(MARKER+str(src_path), INCLUDE_ACTION_NAME)) 916 | 917 | data = yaml_load(current_workflow, '\n'.join(workflow_data)) 918 | data = expand_workflow_jobs(current_workflow, data) 919 | new_data = {} 920 | if True in data: 921 | new_data[On()] = data.pop(True) 922 | for k, v in data.items(): 923 | new_data[k] = v 924 | data = new_data 925 | 926 | to_insert = [] 927 | 928 | is_actions_include = os.environ.get('GITHUB_REPOSITORY', '').endswith('/actions-includes') 929 | if is_actions_include: 930 | # Checkout the repo 931 | to_insert.append({ 932 | 'name': '⏰ 📝 - Get source code', 933 | 'uses': 'actions/checkout@v2', 934 | }) 935 | # Setup python 936 | to_insert.append({ 937 | 'name': '⏰ 📝 - Setting up Python for local docker build', 938 | 'uses': 'actions/setup-python@v2', 939 | 'with': {'python-version': 3.9}, 940 | }) 941 | # Prepare for building the docker image locally 942 | to_insert.append({ 943 | 'name': '⏰ 📝 - Setting up remaining bit for local docker build', 944 | 'uses': './.github/includes/actions/prepare-for-docker-build', 945 | }) 946 | # Use the local docker image 947 | include_action = './.github/includes/actions/local' 948 | include_name = '⏰ 🛂 📖 - Checking workflow expansion is up to date (local)' 949 | else: 950 | # Use the action at mithro/actions-includes 951 | include_action = INCLUDE_ACTION_NAME 952 | include_name = '⏰ 🛂 📕 - Checking workflow expansion is up to date' 953 | 954 | to_insert.append({ 955 | 'name': include_name, 956 | 'uses': include_action, 957 | # FIXME: This check should run on all platforms. 958 | 'if': "runner.os == 'Linux'", 959 | 'continue-on-error': False, 960 | 'with': { 961 | 'workflow': str(to_path), 962 | }, 963 | }) 964 | 965 | for j in data['jobs'].values(): 966 | assert 'steps' in j, pprint.pformat(j) 967 | steps = j['steps'] 968 | assert isinstance(steps, list), pprint.pformat(j) 969 | 970 | for s in reversed(to_insert): 971 | if insert_check_steps: 972 | steps.insert(0, s) 973 | 974 | printdbg('') 975 | printdbg('Final yaml data:') 976 | printdbg('-'*75) 977 | printdbg(data) 978 | printdbg('-'*75) 979 | 980 | output.append(yaml_dump(current_workflow, data)) 981 | 982 | return '\n'.join(output) 983 | 984 | 985 | def main(): 986 | ap = argparse.ArgumentParser( 987 | prog="actions-includes", 988 | description="Allows including an action inside another action") 989 | ap.add_argument("in_workflow", metavar="input-workflow", type=str, 990 | help="Path to input workflow relative to repo root") 991 | ap.add_argument("out_workflow", metavar="output-workflow", type=str, 992 | help="Path where flattened workflow will be written, relative to repo root") 993 | ap.add_argument("--no-check", action="store_true", 994 | help="Don't insert extra step in jobs to check that the workflow is up to date") 995 | args = ap.parse_args() 996 | 997 | tfile = None 998 | try: 999 | git_root_output = subprocess.check_output( 1000 | ['git', 'rev-parse', '--show-toplevel']) 1001 | 1002 | repo_root = pathlib.Path(git_root_output.decode( 1003 | 'utf-8').strip()).resolve() 1004 | 1005 | from_filename = args.in_workflow 1006 | to_filename = args.out_workflow 1007 | insert_check = not args.no_check 1008 | 1009 | from_path = pathlib.Path(from_filename).resolve().relative_to(repo_root) 1010 | if to_filename == '-': 1011 | printerr("Expanding", from_filename, "to stdout") 1012 | 1013 | outdir = repo_root / '.github' / 'workflows' 1014 | outdir.mkdir(parents=True, exist_ok=True) 1015 | outfile = os.path.basename(from_filename) 1016 | outpath = outdir / outfile 1017 | i = 0 1018 | while outpath.exists(): 1019 | printerr("File", outpath, "exists") 1020 | outpath = outdir / f'{i}.{outfile}' 1021 | i += 1 1022 | 1023 | tfile = outpath 1024 | to_abspath = outpath.resolve() 1025 | to_path = to_abspath.relative_to(repo_root) 1026 | else: 1027 | printerr("Expanding", from_filename, "into", to_filename) 1028 | to_abspath = pathlib.Path(to_filename).resolve() 1029 | to_path = to_abspath.relative_to(repo_root) 1030 | 1031 | current_action = LocalFilePath(repo_root, str(from_path)) 1032 | out_data = expand_workflow(current_action, to_path, insert_check) 1033 | 1034 | with open(to_abspath, 'w') as f: 1035 | f.write(out_data) 1036 | 1037 | return 0 1038 | finally: 1039 | if tfile is not None and os.path.exists(tfile): 1040 | with open(tfile) as f: 1041 | print(f.read()) 1042 | 1043 | os.unlink(tfile) 1044 | tfile = None 1045 | -------------------------------------------------------------------------------- /actions_includes/expressions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright 2021 Google LLC 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # SPDX-License-Identifier: Apache-2.0 18 | 19 | 20 | import collections.abc 21 | import json 22 | import re 23 | 24 | from pprint import pprint as p 25 | 26 | 27 | class ExpressionShortName(type): 28 | def __repr__(self): 29 | return "".format(self.__name__) 30 | 31 | 32 | class Expression(metaclass=ExpressionShortName): 33 | pass 34 | 35 | 36 | def to_literal(v): 37 | """ 38 | >>> to_literal(None) 39 | 'null' 40 | >>> to_literal(True) 41 | 'true' 42 | >>> to_literal(False) 43 | 'false' 44 | >>> to_literal(711) 45 | '711' 46 | >>> to_literal(-710) 47 | '-710' 48 | >>> to_literal(2.0) 49 | '2.0' 50 | >>> to_literal(-2.0) 51 | '-2.0' 52 | >>> to_literal('Mona the Octocat') 53 | "'Mona the Octocat'" 54 | >>> to_literal("It's open source") 55 | "'It''s open source'" 56 | >>> to_literal(Value("hello")) 57 | 'hello' 58 | >>> to_literal(Lookup("hello", "testing")) 59 | 'hello.testing' 60 | >>> to_literal(ContainsF(Lookup("hello"), "testing")) 61 | "contains(hello, 'testing')" 62 | """ 63 | if isinstance(v, Expression): 64 | return str(v) 65 | # myNull: ${{ null }} 66 | elif v is None: 67 | return 'null' 68 | # myBoolean: ${{ false }} 69 | elif v is True: 70 | return 'true' 71 | elif v is False: 72 | return 'false' 73 | # myIntegerNumber: ${{ 711 }} 74 | # myFloatNumber: ${{ -9.2 }} 75 | # myExponentialNumber: ${{ -2.99-e2 }} 76 | elif isinstance(v, (int, float)): 77 | return str(v) 78 | # myString: ${{ 'Mona the Octocat' }} 79 | # myEscapedString: ${{ 'It''s open source!' }} 80 | elif isinstance(v, str): 81 | if "'" in v: 82 | v = v.replace("'", "''") 83 | return "'{}'".format(v) 84 | # myHexNumber: ${{ 0xff }} 85 | raise ValueError('Unknown literal? {!r}'.format(v)) 86 | 87 | 88 | INT = re.compile('^-?[0-9]+$') 89 | FLOAT = re.compile('^-?[0-9]+\\.[0-9]+$') 90 | HEX = re.compile('^0x[0-9a-fA-F]+$') 91 | EXP = re.compile('^(-?[0-9]+\\.\\[0-9]+)-?[eE]([0-9.]+)$') 92 | VALUE = re.compile('^[a-zA-Z][_a-zA-Z0-9\\-]*$') 93 | LOOKUP = re.compile('(?:\\.(?:[a-zA-Z][_a-zA-Z0-9\\-]*|\\*))|(?:\\[[^\\]]+\\])') 94 | 95 | S = "('[^']*')+" 96 | I = "[a-zA-Z.\\-0-9_\\[\\]*]+" 97 | 98 | BITS = re.compile('((?P{})|(?P{}))'.format(S, I)) 99 | 100 | 101 | def swizzle(l): 102 | """ 103 | 104 | >>> p(INFIX_FUNCTIONS) 105 | {'!=': , 106 | '&&': , 107 | '==': , 108 | '||': } 109 | 110 | >>> swizzle([1, '&&', 2]) 111 | (, 1, 2) 112 | >>> swizzle([1, '&&', 2, '&&', 3]) 113 | (, 1, (, 2, 3)) 114 | >>> swizzle(['!', 1, '&&', 2, '&&', 3]) 115 | (, (, 1), (, 2, 3)) 116 | >>> swizzle(['!', [1, '&&', 2, '&&', 3]]) 117 | (, (, 1, (, 2, 3))) 118 | 119 | >>> swizzle([(SuccessF,)]) 120 | (,) 121 | 122 | """ 123 | if isinstance(l, (list, tuple)): 124 | if len(l) > 1: 125 | if l[0] in ('!',): 126 | l = [(NotF, swizzle(l[1]))]+l[2:] 127 | l = swizzle(l) 128 | if len(l) > 2: 129 | a = swizzle(l[0]) 130 | b = l[1] 131 | c = swizzle(l[2:]) 132 | 133 | assert isinstance(b, collections.abc.Hashable), \ 134 | "Unhashable object: {} (type: {}) from {!r}".format(b, type(b), l) 135 | 136 | if b in INFIX_FUNCTIONS: 137 | return (INFIX_FUNCTIONS[b], a, c) 138 | if isinstance(l, list) and len(l) == 1: 139 | return swizzle(l[0]) 140 | return l 141 | 142 | 143 | def tokenizer(s): 144 | """ 145 | >>> s = re.compile(S) 146 | >>> [m.group(0) for m in s.finditer("'hello'")] 147 | ["'hello'"] 148 | >>> [m.group(0) for m in s.finditer("'hello''world'")] 149 | ["'hello''world'"] 150 | >>> i = re.compile(I) 151 | >>> [m.group(0) for m in i.finditer("null true false 711 2.0 hello.testing -9.2 -2.99-e2")] 152 | ['null', 'true', 'false', '711', '2.0', 'hello.testing', '-9.2', '-2.99-e2'] 153 | >>> list(tokenizer("true || inputs.value")) 154 | [, True, Lookup('inputs', 'value')] 155 | 156 | >>> p(tokenizer("secrets.GITHUB_TOKEN")) 157 | Lookup('secrets', 'GITHUB_TOKEN') 158 | >>> p(tokenizer("inputs.use-me")) 159 | Lookup('inputs', 'use-me') 160 | 161 | >>> p(tokenizer("fromJSON(env.test)")) 162 | (, Lookup('env', 'test')) 163 | 164 | >>> p(tokenizer("!startsWith(runner.os, 'Linux')")) 165 | (, 166 | (, Lookup('runner', 'os'), 'Linux')) 167 | 168 | >>> p(tokenizer("!startsWith(matrix.os, 'ubuntu') && (true && null && startsWith('ubuntu-latest', 'ubuntu'))")) 169 | (, 170 | (, 171 | (, Lookup('matrix', 'os'), 'ubuntu')), 172 | (, 173 | True, 174 | (, 175 | None, 176 | (, 'ubuntu-latest', 'ubuntu')))) 177 | >>> p(tokenizer("!startsWith(matrix.os, 'ubuntu') && (true && startsWith('ubuntu-latest', 'ubuntu'))")) 178 | (, 179 | (, 180 | (, Lookup('matrix', 'os'), 'ubuntu')), 181 | (, 182 | True, 183 | (, 'ubuntu-latest', 'ubuntu'))) 184 | 185 | >>> p(tokenizer("a != b")) 186 | (, Value(a), Value(b)) 187 | 188 | >>> p(tokenizer("manylinux-versions[inputs.python-version]")) 189 | Lookup('manylinux-versions', Lookup('inputs', 'python-version')) 190 | 191 | >>> p(tokenizer("contains(needs.*.result, 'failure')")) 192 | (, Lookup('needs', '*', 'result'), 'failure') 193 | 194 | >>> p(tokenizer('success()')) 195 | (,) 196 | 197 | >>> p(tokenizer('hashFiles()')) 198 | (,) 199 | >>> p(tokenizer("hashFiles('**/package-lock.json')")) 200 | (, '**/package-lock.json') 201 | >>> p(tokenizer("hashFiles('**/package-lock.json', '**/Gemfile.lock')")) 202 | (, '**/package-lock.json', '**/Gemfile.lock') 203 | """ 204 | try: 205 | tree = [] 206 | def split(s): 207 | i = 0 208 | while True: 209 | try: 210 | m = BITS.search(s, i) 211 | except TypeError as e: 212 | print(BITS, repr(s), i) 213 | raise 214 | 215 | if not m: 216 | b = s[i:].strip() 217 | else: 218 | b = s[i:m.start(0)].strip() 219 | if b: 220 | for i in b: 221 | if not i.strip(): 222 | continue 223 | yield i 224 | 225 | if not m: 226 | return 227 | yield from_literal(m.group(0)) 228 | i = m.end(0) 229 | 230 | stack = [[]] 231 | for i in split(s): 232 | if i == '(': 233 | stack.append([]) 234 | continue 235 | elif i == ')': 236 | i = swizzle(stack.pop(-1)) 237 | if stack[-1]: 238 | l = stack[-1][-1] 239 | if str(l)+str(i) in INFIX_FUNCTIONS: 240 | stack[-1][-1] += i 241 | continue 242 | elif isinstance(l, type) and issubclass(l, BinFunction): 243 | assert len(i) == 3, (l, i) 244 | assert i[1] == ',', (l, i) 245 | #r = l(i[0], i[2]) 246 | #print('Eval: {}({}, {}) = {}'.format(l, i[0], i[2], r)) 247 | stack[-1][-1] = (l, i[0], i[2]) 248 | continue 249 | elif isinstance(l, type) and issubclass(l, UnaryFunction): 250 | stack[-1][-1] = (l, i) 251 | continue 252 | elif isinstance(l, type) and issubclass(l, VarArgsFunction): 253 | o = [l] 254 | if isinstance(i, (list, tuple)): 255 | for j, a in enumerate(i): 256 | if j % 2 == 1: 257 | assert a == ',', (j, a, i) 258 | else: 259 | o.append(a) 260 | else: 261 | o.append(i) 262 | stack[-1][-1] = tuple(o) 263 | continue 264 | elif isinstance(l, type) and issubclass(l, EmptyFunction): 265 | assert len(i) == 0, (l, i) 266 | stack[-1][-1] = (l,) 267 | continue 268 | stack[-1].append(i) 269 | 270 | assert len(stack) == 1, stack 271 | return swizzle(stack[0]) 272 | except Exception as e: 273 | raise TypeError('Error while parsing: {!r}'.format(s)) from e 274 | 275 | 276 | 277 | def var_eval(v, context): 278 | assert isinstance(v, Var), (v, context) 279 | assert isinstance(context, dict), (v, context) 280 | if isinstance(v, Value): 281 | if v in context: 282 | return context[v] 283 | else: 284 | return v 285 | assert isinstance(v, Lookup), (v, context) 286 | 287 | ov = list(v) 288 | for i, j in enumerate(ov): 289 | if isinstance(j, Var): 290 | ov[i] = var_eval(j, context) 291 | 292 | ctx = context 293 | cv = list(ov) 294 | while len(cv) > 0: 295 | j = cv.pop(0) 296 | if j not in ctx: 297 | cv.insert(0, j) 298 | break 299 | ctx = ctx[j] 300 | 301 | if not cv: 302 | return ctx 303 | else: 304 | return Lookup(ov) 305 | 306 | 307 | 308 | def tokens_eval(t, context={}): 309 | """ 310 | >>> tokens_eval(True) 311 | True 312 | >>> tokens_eval(False) 313 | False 314 | >>> tokens_eval((NotF, True)) 315 | False 316 | >>> tokens_eval((NotF, False)) 317 | True 318 | >>> tokens_eval((NotF, Value('a'))) 319 | not(Value(a)) 320 | 321 | >>> tokens_eval((StartsWithF, 'ubuntu-latest', 'ubuntu')) 322 | True 323 | >>> tokens_eval((StartsWithF, 'Windows', 'ubuntu')) 324 | False 325 | 326 | >>> tokens_eval((AndF, Value('a'), False)) 327 | False 328 | 329 | >>> tokens_eval((AndF, False, Value('a'))) 330 | False 331 | 332 | >>> tokens_eval((AndF, Value('a'), True)) 333 | Value(a) 334 | 335 | >>> tokens_eval((AndF, True, Value('a'))) 336 | Value(a) 337 | 338 | >>> tokens_eval((OrF, Value('a'), False)) 339 | Value(a) 340 | 341 | >>> tokens_eval((OrF, False, Value('a'))) 342 | Value(a) 343 | 344 | >>> tokens_eval((OrF, Value('a'), True)) 345 | True 346 | 347 | >>> tokens_eval((OrF, True, Value('a'))) 348 | True 349 | 350 | >>> tokens_eval((SuccessF,)) 351 | success() 352 | 353 | >>> tokens_eval((OrF, (SuccessF,), Value('a'))) 354 | or(success(), Value(a)) 355 | 356 | >>> tokens_eval(Value("inputs")) 357 | Value(inputs) 358 | >>> tokens_eval(Value("inputs"), {'inputs': 'testing'}) 359 | 'testing' 360 | >>> l = Lookup("inputs", "value") 361 | >>> tokens_eval(l) 362 | Lookup('inputs', 'value') 363 | >>> tokens_eval(l, {'inputs': {'value': 'testing'}}) 364 | 'testing' 365 | >>> tokens_eval(l, {'inputs': {'value': Value('testing')}}) 366 | Value(testing) 367 | 368 | >>> l1 = Lookup(Value("a"), "b") 369 | >>> l2 = Lookup("c", Value("a")) 370 | >>> c = {'c': {'b': Value('z'), 'c': Value('y')}, 'd':{'b': Value('x')}} 371 | >>> tokens_eval(l1, c) 372 | Lookup(Value(a), 'b') 373 | >>> c['a'] = 'c' ; tokens_eval(l1, c), tokens_eval(l2, c) 374 | (Value(z), Value(y)) 375 | >>> c['a'] = 'd' ; tokens_eval(l1, c), tokens_eval(l2, c) 376 | (Value(x), Lookup('c', 'd')) 377 | 378 | >>> tokens_eval(Lookup('a', Value('b'), 'c'), {'b': Lookup('x', 'y')}) 379 | Lookup('a', Lookup('x', 'y'), 'c') 380 | 381 | """ 382 | assert not isinstance(t, list), t 383 | 384 | if isinstance(t, Var) and context: 385 | t = var_eval(t, context) 386 | 387 | if isinstance(t, tuple) and not isinstance(t, Lookup): 388 | assert isinstance(type(t[0]), type), t 389 | assert issubclass(t[0], Function), t 390 | t = list(t) 391 | f = t.pop(0) 392 | args = [] 393 | while len(t) > 0: 394 | args.append(tokens_eval(t.pop(0), context)) 395 | return f(*args) 396 | 397 | return t 398 | 399 | 400 | 401 | class Function(Expression): 402 | def __copy__(self): 403 | return type(self)(self.args) 404 | 405 | def __deepcopy__(self, memo=None): 406 | return type(self)(*self.args) 407 | 408 | 409 | class VarArgsFunction(Function): 410 | def __new__(cls, *args): 411 | o = Function.__new__(cls) 412 | o.args = args 413 | return o 414 | 415 | def __repr__(self): 416 | return '{}({})'.format(self.name, ', '.join(repr(a) for a in self.args)) 417 | 418 | def __str__(self): 419 | return '{}({})'.format(self.name, ', '.join(to_literal(a) for a in self.args)) 420 | 421 | 422 | class BinFunction(Function): 423 | @property 424 | def args(self): 425 | return [self.a, self.b] 426 | 427 | @args.setter 428 | def args(self, v): 429 | v = list(v) 430 | assert len(v) == 2, v 431 | self.a = v.pop(0) 432 | self.b = v.pop(0) 433 | assert not v, v 434 | 435 | def __new__(cls, a, b): 436 | if isinstance(a, (Value, Lookup)) or isinstance(b, (Value, Lookup)): 437 | o = Function.__new__(cls) 438 | o.a = a 439 | o.b = b 440 | return o 441 | a = str(a) 442 | b = str(b) 443 | return cls.realf(a, b) 444 | 445 | def __repr__(self): 446 | return '{}({!r}, {!r})'.format(self.name, self.a, self.b) 447 | 448 | def __str__(self): 449 | a = to_literal(self.a) 450 | b = to_literal(self.b) 451 | return '{}({}, {})'.format(self.name, a, b) 452 | 453 | 454 | class UnaryFunction(Function): 455 | @property 456 | def args(self): 457 | return [self.a] 458 | 459 | @args.setter 460 | def args(self, v): 461 | v = list(v) 462 | assert len(v) == 1, v 463 | self.a = v.pop(0) 464 | assert not v, v 465 | 466 | def __new__(cls, a): 467 | if isinstance(a, Expression): 468 | o = Function.__new__(cls) 469 | o.a = a 470 | return o 471 | return cls.realf(a) 472 | 473 | def __repr__(self): 474 | return '{}({!r})'.format(self.name, self.a) 475 | 476 | def __str__(self): 477 | a = to_literal(self.a) 478 | return '{}({})'.format(self.name, a) 479 | 480 | 481 | # Operators 482 | # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#operators 483 | 484 | 485 | class NotF(UnaryFunction): 486 | """ 487 | >>> a1 = NotF(Value('a')) 488 | >>> a1 489 | not(Value(a)) 490 | >>> str(a1) 491 | '!a' 492 | >>> a2 = NotF(NotF(Value('a'))) 493 | >>> a2 494 | not(not(Value(a))) 495 | >>> str(a2) 496 | '!!a' 497 | 498 | >>> a3 = NotF(OrF(Value('a'), Value('b'))) 499 | >>> a3 500 | not(or(Value(a), Value(b))) 501 | >>> str(a3) 502 | '!(a || b)' 503 | 504 | >>> a4 = NotF(StartsWithF(Value('a'), Value('b'))) 505 | >>> a4 506 | not(startsWith(Value(a), Value(b))) 507 | >>> str(a4) 508 | '!startsWith(a, b)' 509 | 510 | >>> a5 = NotF(EqF(Value('a'), None)) 511 | >>> a5 512 | not(eq(Value(a), None)) 513 | >>> str(a5) 514 | '!(a == null)' 515 | 516 | >>> NotF(True) 517 | False 518 | 519 | >>> NotF(False) 520 | True 521 | 522 | >>> NotF(None) 523 | True 524 | 525 | >>> NotF('') 526 | True 527 | 528 | """ 529 | name = 'not' 530 | 531 | @classmethod 532 | def realf(cls, v): 533 | return not bool(v) 534 | 535 | def __str__(self): 536 | if isinstance(self.args[0], InfixFunction): 537 | return '!({})'.format(str(self.args[0])) 538 | return '!'+str(self.args[0]) 539 | 540 | 541 | INFIX_FUNCTIONS = {} 542 | 543 | 544 | class InfixFunctionMeta(ExpressionShortName): 545 | def __init__(self, *args, **kw): 546 | if self.op != None: 547 | INFIX_FUNCTIONS[self.op] = self 548 | type.__init__(self, *args, **kw) 549 | 550 | 551 | class InfixFunction(Function, metaclass=InfixFunctionMeta): 552 | name = None 553 | op = None 554 | 555 | def __repr__(self): 556 | return '{}({})'.format(self.name, ', '.join(repr(i) for i in self.args)) 557 | 558 | def __str__(self): 559 | return ' {} '.format(self.op).join(to_literal(i) for i in self.args) 560 | 561 | 562 | class BinInfixFunction(InfixFunction, BinFunction): 563 | def __new__(cls, *args): 564 | 565 | assert len(args) == 2, (cls, args) 566 | a, b = args 567 | if not isinstance(a, Expression) and not isinstance(b, Expression): 568 | return cls.realf(a, b) 569 | if isinstance(a, (Value, Lookup)) and isinstance(b, (Value, Lookup)): 570 | v = cls.expf(a, b) 571 | if v in (False, True): 572 | return v 573 | 574 | o = Function.__new__(cls) 575 | o.args = (a, b) 576 | return o 577 | 578 | @staticmethod 579 | def realf(a, b): 580 | raise NotImplemented 581 | 582 | @staticmethod 583 | def expf(a, b): 584 | raise NotImplemented 585 | 586 | 587 | class EqF(BinInfixFunction): 588 | """ 589 | >>> a1 = EqF(Value('a'), Value('b')) 590 | >>> a1 591 | eq(Value(a), Value(b)) 592 | >>> str(a1) 593 | 'a == b' 594 | 595 | >>> EqF(True, Value('b')) 596 | eq(True, Value(b)) 597 | >>> EqF(Value('a'), True) 598 | eq(Value(a), True) 599 | 600 | >>> a2 = EqF(Value('a'), True) 601 | >>> a2 602 | eq(Value(a), True) 603 | >>> str(a2) 604 | 'a == true' 605 | 606 | >>> a3 = EqF(None, Value('b')) 607 | >>> a3 608 | eq(None, Value(b)) 609 | >>> str(a3) 610 | 'null == b' 611 | 612 | >>> a4 = EqF("Hello", Lookup('a', 'b')) 613 | >>> a4 614 | eq('Hello', Lookup('a', 'b')) 615 | >>> str(a4) 616 | "'Hello' == a.b" 617 | 618 | >>> a5 = EqF("'ello", Lookup('a', 'b')) 619 | >>> a5 620 | eq("'ello", Lookup('a', 'b')) 621 | >>> str(a5) 622 | "'''ello' == a.b" 623 | 624 | >>> EqF(1, 1) 625 | True 626 | 627 | >>> EqF(1, 10) 628 | False 629 | 630 | >>> EqF(Value('a'), Value('a')) 631 | True 632 | 633 | """ 634 | name = 'eq' 635 | op = '==' 636 | 637 | @staticmethod 638 | def realf(a, b): 639 | return a == b 640 | 641 | @staticmethod 642 | def expf(a, b): 643 | if a == b: 644 | return True 645 | 646 | 647 | class NotEqF(BinInfixFunction): 648 | """ 649 | >>> a1 = NotEqF(Value('a'), Value('b')) 650 | >>> a1 651 | neq(Value(a), Value(b)) 652 | >>> str(a1) 653 | 'a != b' 654 | 655 | >>> NotEqF(True, Value('b')) 656 | neq(True, Value(b)) 657 | >>> NotEqF(Value('a'), True) 658 | neq(Value(a), True) 659 | 660 | >>> NotEqF(1, 1) 661 | False 662 | 663 | >>> NotEqF(1, 10) 664 | True 665 | 666 | >>> NotEqF(Value('a'), Value('a')) 667 | False 668 | 669 | """ 670 | name = 'neq' 671 | op = '!=' 672 | 673 | @staticmethod 674 | def realf(a, b): 675 | return a != b 676 | 677 | @staticmethod 678 | def expf(a, b): 679 | if a == b: 680 | return False 681 | 682 | 683 | 684 | class OrF(InfixFunction): 685 | """ 686 | >>> a1 = OrF(Value('a'), Value('b')) 687 | >>> a1 688 | or(Value(a), Value(b)) 689 | >>> str(a1) 690 | 'a || b' 691 | 692 | >>> OrF(True, Value('a')) 693 | True 694 | >>> OrF(Value('a'), True) 695 | True 696 | 697 | >>> OrF(False, Value('a')) 698 | Value(a) 699 | 700 | >>> OrF(Value('a'), False) 701 | Value(a) 702 | 703 | >>> OrF(Value('a'), Value('a')) 704 | Value(a) 705 | """ 706 | name = 'or' 707 | op = '||' 708 | 709 | def __new__(cls, *args): 710 | 711 | nargs = [] 712 | for a in args: 713 | if a is True: 714 | return True 715 | elif a in (False, None, ''): 716 | continue 717 | elif a in nargs: 718 | continue 719 | else: 720 | nargs.append(a) 721 | 722 | if not nargs: 723 | return False 724 | 725 | if len(nargs) == 1: 726 | return nargs.pop(0) 727 | 728 | o = Function.__new__(cls) 729 | o.args = nargs 730 | return o 731 | 732 | 733 | class AndF(InfixFunction): 734 | """ 735 | 736 | # Simplifying booleans 737 | >>> AndF(True, True) 738 | True 739 | >>> AndF(True, False) 740 | False 741 | >>> AndF(False, True) 742 | False 743 | >>> AndF(False, False) 744 | False 745 | 746 | # Keeping normal groups 747 | >>> a1 = AndF(Value('a'), Value('b')) 748 | >>> a1 749 | and(Value(a), Value(b)) 750 | >>> str(a1) 751 | 'a && b' 752 | 753 | >>> AndF(True, Value('a')) 754 | Value(a) 755 | >>> AndF(Value('a'), True) 756 | Value(a) 757 | 758 | >>> AndF(False, Value('a')) 759 | False 760 | >>> AndF(Value('a'), False) 761 | False 762 | 763 | >>> AndF(Value('a'), Value('a')) 764 | Value(a) 765 | """ 766 | name = 'and' 767 | op = '&&' 768 | 769 | def __new__(cls, *args): 770 | 771 | nargs = [] 772 | for a in args: 773 | if a in (False, None, ''): 774 | return False 775 | elif a is True: 776 | continue 777 | elif a in nargs: 778 | continue 779 | else: 780 | nargs.append(a) 781 | 782 | if not nargs: 783 | return True 784 | 785 | if len(nargs) == 1: 786 | return nargs.pop(0) 787 | 788 | o = Function.__new__(cls) 789 | o.args = nargs 790 | return o 791 | 792 | 793 | # Functions 794 | # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#functions 795 | 796 | 797 | NAMED_FUNCTIONS = {} 798 | 799 | 800 | class NamedFunctionMeta(ExpressionShortName): 801 | def __init__(self, *args, **kw): 802 | if self.name != None: 803 | NAMED_FUNCTIONS[self.name.lower()] = self 804 | type.__init__(self, *args, **kw) 805 | 806 | 807 | class NamedFunction(Function, metaclass=NamedFunctionMeta): 808 | name = None 809 | 810 | 811 | class ContainsF(BinFunction, NamedFunction): 812 | """ 813 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#contains 814 | 815 | >>> ContainsF('Hello world', 'mo') 816 | False 817 | >>> ContainsF('Hello world', 'lo') 818 | True 819 | >>> ContainsF('Hello world', 'He') 820 | True 821 | >>> ContainsF('Hello world', 'ld') 822 | True 823 | >>> repr(ContainsF(Value('a'), 'Ho')) 824 | "contains(Value(a), 'Ho')" 825 | >>> str(ContainsF(Value('a'), 'Ho')) 826 | "contains(a, 'Ho')" 827 | 828 | """ 829 | name = 'contains' 830 | 831 | @classmethod 832 | def realf(cls, a, b): 833 | assert isinstance(a, str), (a, b) 834 | assert isinstance(b, str), (a, b) 835 | return b in a 836 | 837 | 838 | class StartsWithF(BinFunction, NamedFunction): 839 | """ 840 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#startswith 841 | 842 | >>> StartsWithF('Hello world', 'He') 843 | True 844 | >>> StartsWithF('Hello world', 'Ho') 845 | False 846 | >>> repr(StartsWithF(Value('a'), 'Ho')) 847 | "startsWith(Value(a), 'Ho')" 848 | >>> str(StartsWithF(Value('a'), 'Ho')) 849 | "startsWith(a, 'Ho')" 850 | >>> str(StartsWithF(Value('a'), "M'lady")) 851 | "startsWith(a, 'M''lady')" 852 | 853 | """ 854 | name = 'startsWith' 855 | 856 | @classmethod 857 | def realf(cls, a, b): 858 | assert isinstance(a, str), (a, b) 859 | assert isinstance(b, str), (a, b) 860 | return a.startswith(b) 861 | 862 | 863 | class EndsWithF(BinFunction, NamedFunction): 864 | """ 865 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#endswith 866 | 867 | >>> EndsWithF('Hello world', 'He') 868 | False 869 | >>> EndsWithF('Hello world', 'ld') 870 | True 871 | >>> repr(EndsWithF(Value('a'), 'Ho')) 872 | "endsWith(Value(a), 'Ho')" 873 | >>> str(EndsWithF(Value('a'), 'Ho')) 874 | "endsWith(a, 'Ho')" 875 | 876 | """ 877 | name = 'endsWith' 878 | 879 | @classmethod 880 | def realf(cls, a, b): 881 | assert isinstance(a, str), (a, b) 882 | assert isinstance(b, str), (a, b) 883 | return a.endswith(b) 884 | 885 | 886 | # FIXME: https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#format 887 | # FIXME: https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#join 888 | 889 | 890 | class ToJSONF(UnaryFunction, NamedFunction): 891 | """ 892 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#tojson 893 | 894 | >>> f = ToJSONF(Value('a')) 895 | >>> repr(f) 896 | 'toJSON(Value(a))' 897 | >>> str(f) 898 | 'toJSON(a)' 899 | 900 | >>> a = ToJSONF(True) 901 | >>> a 902 | 'true' 903 | >>> type(a) 904 | 905 | 906 | """ 907 | name = 'toJSON' 908 | 909 | @classmethod 910 | def realf(cls, a): 911 | return json.dumps(a) 912 | 913 | 914 | class FromJSONF(UnaryFunction, NamedFunction): 915 | """ 916 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#fromjson 917 | 918 | >>> f = FromJSONF(Value('a')) 919 | >>> repr(f) 920 | 'fromJSON(Value(a))' 921 | >>> str(f) 922 | 'fromJSON(a)' 923 | 924 | >>> a = FromJSONF('{"a": null, "b": 1.0, "c": false}') 925 | >>> p(a) 926 | {'a': None, 'b': 1.0, 'c': False} 927 | >>> type(a) 928 | 929 | 930 | """ 931 | name = 'fromJSON' 932 | 933 | @classmethod 934 | def realf(cls, a): 935 | assert isinstance(a, str), a 936 | return json.loads(a) 937 | 938 | 939 | class HashFilesF(VarArgsFunction, NamedFunction): 940 | """ 941 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#hashfiles 942 | 943 | >>> f = HashFilesF(Value('a')) 944 | >>> repr(f) 945 | 'hashFiles(Value(a))' 946 | >>> str(f) 947 | 'hashFiles(a)' 948 | 949 | >>> a = HashFilesF(Value('a'), Value('b')) 950 | >>> repr(a) 951 | 'hashFiles(Value(a), Value(b))' 952 | >>> str(a) 953 | 'hashFiles(a, b)' 954 | 955 | """ 956 | name = 'hashFiles' 957 | 958 | 959 | # Job status check functions 960 | # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#job-status-check-functions 961 | 962 | 963 | class EmptyFunction(Function): 964 | @property 965 | def args(self): 966 | return [] 967 | 968 | @args.setter 969 | def args(self, v): 970 | v = list(v) 971 | assert len(v) == 0, v 972 | 973 | #def __new__(cls): 974 | # return Function.__new__(cls) 975 | 976 | def __repr__(self): 977 | return '{}()'.format(self.name) 978 | 979 | def __str__(self): 980 | return '{}()'.format(self.name) 981 | 982 | 983 | class SuccessF(EmptyFunction, NamedFunction): 984 | """ 985 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#success 986 | 987 | >>> f = SuccessF() 988 | >>> repr(f) 989 | 'success()' 990 | >>> str(f) 991 | 'success()' 992 | """ 993 | name = 'success' 994 | 995 | 996 | class AlwaysF(EmptyFunction, NamedFunction): 997 | """ 998 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always 999 | 1000 | >>> f = AlwaysF() 1001 | >>> repr(f) 1002 | 'always()' 1003 | >>> str(f) 1004 | 'always()' 1005 | """ 1006 | name = 'always' 1007 | 1008 | 1009 | class CancelledF(EmptyFunction, NamedFunction): 1010 | """ 1011 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#cancelled 1012 | 1013 | >>> f = CancelledF() 1014 | >>> repr(f) 1015 | 'cancelled()' 1016 | >>> str(f) 1017 | 'cancelled()' 1018 | """ 1019 | name = 'cancelled' 1020 | 1021 | 1022 | class FailureF(EmptyFunction, NamedFunction): 1023 | """ 1024 | https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#failure 1025 | 1026 | >>> f = FailureF() 1027 | >>> repr(f) 1028 | 'failure()' 1029 | >>> str(f) 1030 | 'failure()' 1031 | """ 1032 | name = 'failure' 1033 | 1034 | 1035 | 1036 | # Variables in statements. 1037 | class Var(Expression): 1038 | pass 1039 | 1040 | 1041 | class Value(str, Var): 1042 | """ 1043 | >>> p(NAMED_FUNCTIONS) 1044 | {'always': , 1045 | 'cancelled': , 1046 | 'contains': , 1047 | 'endswith': , 1048 | 'failure': , 1049 | 'fromjson': , 1050 | 'hashfiles': , 1051 | 'startswith': , 1052 | 'success': , 1053 | 'tojson': } 1054 | 1055 | >>> v = Value('hello') 1056 | >>> print(v) 1057 | hello 1058 | >>> print(repr(v)) 1059 | Value(hello) 1060 | >>> Value('startsWith') 1061 | 1062 | >>> Value('startsWith') 1063 | 1064 | 1065 | """ 1066 | def __new__(cls, s): 1067 | if s.lower() in NAMED_FUNCTIONS: 1068 | return NAMED_FUNCTIONS[s.lower()] 1069 | assert '.' not in s, s 1070 | return str.__new__(cls, s) 1071 | 1072 | def __str__(self): 1073 | return str.__str__(self) 1074 | 1075 | def __repr__(self): 1076 | return 'Value('+str.__str__(self)+')' 1077 | 1078 | 1079 | class Lookup(tuple, Var): 1080 | """ 1081 | >>> l = Lookup("a", "b") 1082 | >>> print(l) 1083 | a.b 1084 | >>> print(repr(l)) 1085 | Lookup('a', 'b') 1086 | 1087 | >>> l = Lookup(["1", "2"]) 1088 | >>> print(l) 1089 | 1.2 1090 | >>> print(repr(l)) 1091 | Lookup('1', '2') 1092 | 1093 | >>> l = Lookup(["1", Value("a")]) 1094 | >>> print(l) 1095 | 1[a] 1096 | >>> print(repr(l)) 1097 | Lookup('1', Value(a)) 1098 | 1099 | >>> l = Lookup("hello") 1100 | >>> print(l) 1101 | hello 1102 | >>> print(repr(l)) 1103 | Value(hello) 1104 | """ 1105 | def __new__(cls, *args): 1106 | if len(args) == 1: 1107 | if isinstance(args[0], str): 1108 | return Value(args[0]) 1109 | elif len(args) > 1: 1110 | args = (args,) 1111 | return tuple.__new__(cls, *args) 1112 | 1113 | def __str__(self): 1114 | o = [] 1115 | for i in self: 1116 | if isinstance(i, Var): 1117 | o.append('[{}]'.format(i)) 1118 | elif isinstance(i, str): 1119 | if o: 1120 | o.append('.') 1121 | o.append(i) 1122 | else: 1123 | raise ValueError(i) 1124 | return ''.join(o) 1125 | 1126 | def __repr__(self): 1127 | return 'Lookup({})'.format(", ".join(repr(i) for i in self)) 1128 | 1129 | 1130 | def from_literal(v): 1131 | """ 1132 | >>> repr(from_literal('null')) 1133 | 'None' 1134 | 1135 | >>> from_literal('true') 1136 | True 1137 | 1138 | >>> from_literal('false') 1139 | False 1140 | 1141 | >>> from_literal('711') 1142 | 711 1143 | >>> from_literal('-711') 1144 | -711 1145 | 1146 | >>> from_literal('2.0') 1147 | 2.0 1148 | >>> from_literal('-2.0') 1149 | -2.0 1150 | 1151 | >>> from_literal("'Mona the Octocat'") 1152 | 'Mona the Octocat' 1153 | 1154 | >>> from_literal("'It''s open source'") 1155 | "It's open source" 1156 | 1157 | >>> from_literal("'It''s open source'") 1158 | "It's open source" 1159 | 1160 | >>> from_literal("a.b") 1161 | Lookup('a', 'b') 1162 | >>> from_literal("a[b]") 1163 | Lookup('a', Value(b)) 1164 | >>> from_literal("a[12]") 1165 | Lookup('a', 12) 1166 | >>> from_literal("a[b.c]") 1167 | Lookup('a', Lookup('b', 'c')) 1168 | >>> from_literal("a.b.c") 1169 | Lookup('a', 'b', 'c') 1170 | >>> from_literal("needs.*.result") 1171 | Lookup('needs', '*', 'result') 1172 | >>> from_literal("a[b].c") 1173 | Lookup('a', Value(b), 'c') 1174 | >>> from_literal("a[b][c]") 1175 | Lookup('a', Value(b), Value(c)) 1176 | 1177 | >>> from_literal("inputs") 1178 | Value(inputs) 1179 | 1180 | """ 1181 | v = v.strip() 1182 | 1183 | if v == 'null': 1184 | return None 1185 | elif v == 'true': 1186 | return True 1187 | elif v == 'false': 1188 | return False 1189 | elif INT.match(v): 1190 | return int(v) 1191 | elif FLOAT.match(v): 1192 | return float(v) 1193 | elif HEX.match(v) or EXP.match(v): 1194 | return v 1195 | elif v[0] == "'" and v[-1] == "'": 1196 | return v[1:-1].replace("''", "'") 1197 | 1198 | m = LOOKUP.search(v) 1199 | if m: 1200 | args = [v[:m.start(0)]] 1201 | for m in LOOKUP.finditer(v): 1202 | s = m.group() 1203 | if s.startswith('.'): 1204 | args.append(s[1:]) 1205 | elif s.startswith('['): 1206 | assert s.endswith(']'), (s, v) 1207 | args.append(from_literal(s[1:-1])) 1208 | return Lookup(args) 1209 | 1210 | if VALUE.match(v): 1211 | return Value(v) 1212 | 1213 | raise ValueError('Unknown literal? {!r}'.format(v)) 1214 | 1215 | 1216 | def simplify(exp, context={}): 1217 | """ 1218 | 1219 | >>> simplify("true") 1220 | True 1221 | >>> simplify("false") 1222 | False 1223 | >>> simplify("''") 1224 | '' 1225 | >>> str(simplify("null")) 1226 | 'None' 1227 | 1228 | >>> simplify("inputs") 1229 | Value(inputs) 1230 | >>> simplify("inputs", {'inputs': 'testing'}) 1231 | 'testing' 1232 | >>> simplify("inputs.value") 1233 | Lookup('inputs', 'value') 1234 | >>> simplify("inputs.value", {'inputs': {'value': 'testing'}}) 1235 | 'testing' 1236 | 1237 | >>> simplify("inputs.value", {'inputs': {'value': Value('testing')}}) 1238 | Value(testing) 1239 | 1240 | >>> simplify("true || inputs.value") 1241 | True 1242 | 1243 | >>> simplify("false && inputs.value") 1244 | False 1245 | 1246 | >>> simplify("startsWith(a, 'testing')") 1247 | startsWith(Value(a), 'testing') 1248 | 1249 | >>> simplify("startsWith('testing-123', 'testing')") 1250 | True 1251 | 1252 | >>> simplify("startsWith('output', 'testing')") 1253 | False 1254 | 1255 | >>> a = simplify("!startsWith(matrix.os, 'ubuntu') && (true && startsWith('ubuntu-latest', 'ubuntu'))") 1256 | >>> a 1257 | not(startsWith(Lookup('matrix', 'os'), 'ubuntu')) 1258 | >>> print(str(a)) 1259 | !startsWith(matrix.os, 'ubuntu') 1260 | >>> simplify("!startsWith(matrix.os, 'ubuntu') && (true && null && startsWith('ubuntu-latest', 'ubuntu'))") 1261 | False 1262 | 1263 | >>> b = simplify("a[b].c") 1264 | >>> b 1265 | Lookup('a', Value(b), 'c') 1266 | >>> str(b) 1267 | 'a[b].c' 1268 | 1269 | >>> ctx = {'a': {'x': {'c': False}, 'y': {'c': True}}} 1270 | >>> c = simplify("a[b].c", ctx) 1271 | >>> c 1272 | Lookup('a', Value(b), 'c') 1273 | >>> str(c) 1274 | 'a[b].c' 1275 | 1276 | >>> ctx['b'] = 'x' 1277 | >>> c = simplify("a[b].c", ctx) 1278 | >>> c 1279 | False 1280 | >>> str(c) 1281 | 'False' 1282 | 1283 | >>> ctx['b'] = 'y' 1284 | >>> c = simplify("a[b].c", ctx) 1285 | >>> c 1286 | True 1287 | >>> str(c) 1288 | 'True' 1289 | 1290 | >>> ctx = {'a': {'x': {'c': False}, 'y': {'c': True}}} 1291 | >>> ctx['b'] = Lookup('other', 'place') 1292 | >>> c = simplify("a[b].c", ctx) 1293 | >>> c 1294 | Lookup('a', Lookup('other', 'place'), 'c') 1295 | >>> str(c) 1296 | 'a[other.place].c' 1297 | 1298 | >>> simplify('manylinux-versions[inputs.python-version]', {'inputs': {'python-version': 12}}) 1299 | Lookup('manylinux-versions', 12) 1300 | 1301 | >>> simplify(parse('${{ inputs.empty }}'), {'inputs': {'empty': ''}}) 1302 | '' 1303 | 1304 | """ 1305 | if isinstance(exp, Expression): 1306 | exp = str(exp) 1307 | elif not isinstance(exp, str): 1308 | return exp 1309 | 1310 | assert isinstance(exp, str), (exp, repr(exp)) 1311 | 1312 | o = tokens_eval(tokenizer(exp), context) 1313 | if isinstance(o, Value): 1314 | if o in context: 1315 | o = context[o] 1316 | 1317 | return o 1318 | 1319 | 1320 | def parse(s): 1321 | """ 1322 | >>> parse(True) 1323 | True 1324 | >>> parse(False) 1325 | False 1326 | >>> parse('hello') 1327 | 'hello' 1328 | >>> parse('${{ hello }}') 1329 | Value(hello) 1330 | >>> parse('${{ hello && world }}') 1331 | and(Value(hello), Value(world)) 1332 | >>> parse('${{ hello && true }}') 1333 | Value(hello) 1334 | >>> parse('${{ hello || true }}') 1335 | True 1336 | 1337 | >>> parse('a[b].c || false') 1338 | 'a[b].c || false' 1339 | 1340 | >>> parse('') 1341 | '' 1342 | 1343 | >>> parse('${{ success() }}') 1344 | success() 1345 | 1346 | >>> parse('${{ hashFiles() }}') 1347 | hashFiles() 1348 | >>> parse("${{ hashFiles('**/package-lock.json') }}") 1349 | hashFiles('**/package-lock.json') 1350 | >>> parse("${{ hashFiles('**/package-lock.json', '**/Gemfile.lock') }}") 1351 | hashFiles('**/package-lock.json', '**/Gemfile.lock') 1352 | """ 1353 | if isinstance(s, str): 1354 | exp = s.strip() 1355 | if exp.startswith('${{'): 1356 | assert exp.endswith('}}'), exp 1357 | return simplify(exp[3:-2].strip()) 1358 | return s 1359 | 1360 | 1361 | RE_EXP = re.compile('\\${{(.*?)}}', re.DOTALL) 1362 | 1363 | 1364 | def eval(s, context): 1365 | """ 1366 | 1367 | >>> eval('Hello', {}) 1368 | 'Hello' 1369 | 1370 | >>> eval('Hello ${{ a }}! You are ${{ b }}.', {'a': 'world', 'b': 'awesome'}) 1371 | 'Hello world! You are awesome.' 1372 | 1373 | >>> eval('Hello ${{ a }}! You are ${{ b }}.', {'a': 1, 'b': 2}) 1374 | 'Hello 1! You are 2.' 1375 | 1376 | >>> eval('${{ a }}', {'a': 1}) 1377 | 1 1378 | 1379 | >>> eval(' ${{ a }}', {'a': 1}) 1380 | ' 1' 1381 | 1382 | """ 1383 | 1384 | exp_bits = s[:3] + s[-2:] 1385 | mid_bits = s[3:-2] 1386 | if exp_bits == '${{}}' and '${{' not in mid_bits: 1387 | newe = parse(s) 1388 | return simplify(newe, context) 1389 | 1390 | assert isinstance(s, str), (type(s), repr(s)) 1391 | def replace_exp(m): 1392 | e = m.group(1).strip() 1393 | v = simplify(e, context) 1394 | if isinstance(v, Expression): 1395 | return '${{ %s }}' % (v,) 1396 | else: 1397 | return str(v) 1398 | 1399 | new_s = RE_EXP.sub(replace_exp, s) 1400 | return new_s 1401 | --------------------------------------------------------------------------------