diff --git a/.github/workflows/check_action_tags.yml b/.github/workflows/check_action_tags.yml new file mode 100644 index 00000000..b31d8316 --- /dev/null +++ b/.github/workflows/check_action_tags.yml @@ -0,0 +1,53 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name: Check action tags +on: + workflow_dispatch: + push: + branches: + - main + paths: + - ".github/workflows/dummy.yml" + pull_request: + paths: + - ".github/workflows/update_actions.yml" + - ".github/workflows/dummy.yml" + - gateway/* + +permissions: + contents: read + +# We want workflows on main to run in order to avoid losing data through race conditions +concurrency: "${{ github.ref }}-${{ github.workflow }}" + +jobs: + check_action_tags: + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v6 + + - run: pip install ruyaml + + - name: Update actions.yml and check action tags + # This step is similar to the one in update_actions.yml but also verifies the actions' tags + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python gateway/run_action_tags.py diff --git a/actions.yml b/actions.yml index 5084fa02..cfb444db 100644 --- a/actions.yml +++ b/actions.yml @@ -24,17 +24,13 @@ astral-sh/setup-uv: expires_at: 2026-03-15 681c641aba71e4a1c380be3ab5e12ad51f415867: tag: v7.1.6 -bytedeco/javacpp-presets/.github/actions/*: - '*': - expires_at: 2025-08-01 - keep: true dtolnay/rust-toolchain: stable: - expires_at: 2025-08-01 keep: true + # 'stable' is a branch, not a Git SHA + ignore_invalid_git_sha: true golangci/*: '*': - expires_at: 2025-08-01 keep: true pypa/gh-action-pip-audit: 1220774d901786e6f652ae159f7b6bc8fea6d266: @@ -42,39 +38,32 @@ pypa/gh-action-pip-audit: expires_at: 2026-03-31 pypa/gh-action-pypi-publish: release/v1*: - expires_at: 2025-08-01 keep: true + # 'release/v1*' is a branch wildcard, not a Git SHA + ignore_invalid_git_sha: true pytooling/actions/with-post-step: '*': - expires_at: 2025-08-01 keep: true quarto-dev/quarto-actions/*: '*': - expires_at: 2025-08-01 keep: true r-lib/actions/*: '*': - expires_at: 2025-08-01 keep: true readthedocs/actions/preview: '*': - expires_at: 2025-08-01 keep: true rustsec/*: '*': - expires_at: 2025-08-01 keep: true vhelm/chart-releaser-action: '*': - expires_at: 2025-08-01 keep: true AdoptOpenJDK/install-jdk: '*': - expires_at: 2025-08-01 keep: true BobAnkh/auto-generate-changelog: '*': - expires_at: 2025-08-01 keep: true dorny/test-reporter: dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3: @@ -93,7 +82,6 @@ DavidAnson/markdownlint-cli2-action: tag: v22.0.0 EnricoMi/publish-unit-test-result-action: '*': - expires_at: 2025-08-01 keep: true JamesIves/github-pages-deploy-action: 6c2d9db40f9296374acc17b90404b6e8864128c8: @@ -115,11 +103,9 @@ Kesin11/actions-timeline: tag: v2.2.5 PyO3/maturin-action: '*': - expires_at: 2025-08-01 keep: true TobKed/label-when-approved-action: '*': - expires_at: 2025-08-01 keep: true VirtusLab/scala-cli-setup: 68bd9c30954d20e6cb6ddaf01b3b38336f25df4b: @@ -127,15 +113,12 @@ VirtusLab/scala-cli-setup: tag: v1.10.1 actions-cool/check-user-permission: '*': - expires_at: 2025-08-01 keep: true actions-cool/issues-helper: '*': - expires_at: 2025-08-01 keep: true actions-cool/maintain-one-comment: '*': - expires_at: 2025-08-01 keep: true addnab/docker-run-action: 4f65fabd2431ebc8d299f8e5a018d79a769ae185: @@ -148,15 +131,12 @@ al-cheb/configure-pagefile-action: tag: v1.5 amannn/action-semantic-pull-request: '*': - expires_at: 2025-08-01 keep: true arduino/setup-protoc: '*': - expires_at: 2025-08-01 keep: true awalsh128/cache-apt-pkgs-action: '*': - expires_at: 2025-08-01 keep: true aws-actions/configure-aws-credentials: 00943011d9042930efac3dcd3a170e4273319bc8: @@ -168,11 +148,11 @@ azure/setup-helm: 1a275c3b69536ee54be43f2070a358922e12c8d4: tag: v4.3.1 bazel-contrib/setup-bazel: - '*': - expires_at: 2026-12-21 - keep: true 8d2cb86a3680a820c3e219597279ce3f80d17a47: - tag: v0.15.0 + tag: 0.15.0 + expires_at: 2026-03-31 + 083175551ceeceebc757ebee2127fde78840ca77: + tag: 0.18.0 betahuhn/repo-file-sync-action: 8b92be3375cf1d1b0cd579af488a9255572e4619: tag: v1.21.1 @@ -189,15 +169,12 @@ browser-actions/setup-geckodriver: 5ef1526ed36211ab6cb531ec1cfb11f924ca2dee: bufbuild/buf-breaking-action: '*': - expires_at: 2025-08-01 keep: true bufbuild/buf-lint-action: '*': - expires_at: 2025-08-01 keep: true bufbuild/buf-setup-action: '*': - expires_at: 2025-08-01 keep: true burnett01/rsync-deployments: 0dc935cdecc5f5e571865e60d2a6cdc673704823: @@ -207,46 +184,36 @@ burnett01/rsync-deployments: tag: 8.0.3 burrunan/gradle-cache-action: '*': - expires_at: 2025-08-01 keep: true bytedeco/javacpp-presets: '*': - expires_at: 2025-08-01 keep: true carloscastrojumo/github-cherry-pick-action: 503773289f4a459069c832dc628826685b75b4b3: tag: v1.0.10 carlosperate/arm-none-eabi-gcc-action: '*': - expires_at: 2025-08-01 keep: true check-spelling/check-spelling: '*': - expires_at: 2025-08-01 keep: true chromaui/action: '*': - expires_at: 2025-08-01 keep: true cicirello/javadoc-cleanup: '*': - expires_at: 2025-08-01 keep: true clechasseur/rs-cargo: '*': - expires_at: 2025-08-01 keep: true codecov/codecov-action: '*': - expires_at: 2025-08-01 keep: true codelytv/pr-size-labeler: '*': - expires_at: 2025-08-01 keep: true codspeedhq/action: '*': - expires_at: 2025-08-01 keep: true commit-check/commit-check-action: a0193b1ca486178b85d7a1db145af34cd227f81f: @@ -256,31 +223,27 @@ commit-check/commit-check-action: tag: v2.2.1 conda-incubator/setup-miniconda: '*': - expires_at: 2025-08-01 keep: true container-tools/kind-action: '*': - expires_at: 2025-08-01 keep: true container-tools/microshift-action: '*': - expires_at: 2025-08-01 keep: true coursier/cache-action: - '*': - expires_at: 2025-08-01 - keep: true + c5ca79321d170b8a18c288d9cadc2a6037166d0f: + tag: v8.0.0 bebeeb0e6f48ebad66d3783946588ecf43114433: + tag: v7.0.0 + expires_at: 2026-08-01 + 4e2615869d13561d626ed48655e1a39e5b192b3c: tag: 6.4.7 expires_at: 2026-04-28 - c5ca79321d170b8a18c288d9cadc2a6037166d0f: - tag: 6.4.7 coursier/setup-action: 039f736548afa5411c1382f40a5bd9c2d30e0383: tag: v1.3.9 expires_at: 2026-02-24 '*': - expires_at: 2025-08-01 keep: true 0ed4d7e7c42eae80e14370990582092c749253c4: tag: v2.0.0 @@ -295,7 +258,6 @@ cpp-linter/cpp-linter-action: tag: v2.15.1 crate-ci/typos: '*': - expires_at: 2025-08-01 keep: true crazy-max/ghaction-import-gpg: e89d40939c28e39f97cf32126055eeae86ba74ec: @@ -304,18 +266,15 @@ damccorm/tag-ur-it: 6fa72bbf1a2ea157b533d7e7abeafdb5855dbea5: dawidd6/action-download-artifact: '*': - expires_at: 2025-08-01 keep: true dawidd6/action-send-mail: 6d98ae34d733f9a723a9e04e94f2f24ba05e1402: tag: v6 delaguardo/setup-graalvm: '*': - expires_at: 2025-08-01 keep: true dlang-community/setup-dlang: '*': - expires_at: 2025-08-01 keep: true docker://jekyll/jekyll: sha256:400b8d1569f118bca8a3a09a25f32803b00a55d1ea241feaf5f904d66ca9c625: @@ -325,17 +284,14 @@ docker://pandoc/core: docker/setup-qemu-action: 29109295f81e9208d7d86ff1c6c12d2833863392: tag: v3.6.0 - keep: true dorny/paths-filter: de90cc6fb38fc0963ad72b210f1f284cd68cea36: tag: v3.0.2 easimon/maximize-build-space: '*': - expires_at: 2025-08-01 keep: true eps1lon/actions-label-merge-conflict: '*': - expires_at: 2025-08-01 keep: true erisu/apache-rat-action: 46fb01ce7d8f76bdcd7ab10e7af46e1ea95ca01c: @@ -345,15 +301,12 @@ erisu/license-checker-action: tag: v2.0.1 gaurav-nelson/github-action-markdown-link-check: '*': - expires_at: 2025-08-01 keep: true geekyeggo/delete-artifact: '*': - expires_at: 2025-08-01 keep: true golang/govulncheck-action: '*': - expires_at: 2025-08-01 keep: true google-github-actions/auth: 7c6bc770dae815cd3e89ee6cdf493a5fab2cc093: @@ -363,11 +316,9 @@ google-github-actions/setup-gcloud: tag: v3.0.1 gradle/actions/setup-gradle: 4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2: - expires_at: 2050-01-01 tag: v5.0.0 gr2m/twitter-together: '*': - expires_at: 2025-08-01 keep: true graalvm/setup-graalvm: eec48106e0bf45f2976c2ff0c3e22395cced8243: @@ -385,7 +336,6 @@ gradle/wrapper-validation-action: tag: v3.5.0 gsactions/commit-message-checker: '*': - expires_at: 2025-08-01 keep: true hadolint/hadolint-action: 2332a7b74a6de0dda2e2221d575162eba76ba5e5: @@ -407,140 +357,115 @@ helm/kind-action: tag: v1.13.0 houseabsolute/actions-rust-cross: '*': - expires_at: 2025-08-01 keep: true ilammy/msvc-dev-cmd: '*': - expires_at: 2025-08-01 keep: true ilammy/setup-nasm: 72793074d3c8cdda771dba85f6deafe00623038b: tag: v1.5.2 jarvusinnovations/background-action: '*': - expires_at: 2025-08-01 keep: true jasonetco/create-an-issue: 1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5: tag: v2 jidicula/clang-format-action: '*': - expires_at: 2025-08-01 keep: true Jimver/cuda-toolkit: 6008063726ffe3309d1b22e413d9e88fed91a2f2: - expires_at: 2050-01-01 tag: v0.2.29 jlumbroso/free-disk-space: '*': - expires_at: 2025-08-01 keep: true jrouly/scalafmt-native-action: 14620cde093e5ff6bfbbecd4f638370024287b9d: tag: v4 '*': - expires_at: 2025-08-01 keep: true julia-actions/julia-buildpkg: '*': - expires_at: 2025-08-01 keep: true julia-actions/julia-docdeploy: '*': - expires_at: 2025-08-01 keep: true julia-actions/julia-processcoverage: '*': - expires_at: 2025-08-01 keep: true julia-actions/julia-runtest: '*': - expires_at: 2025-08-01 keep: true julia-actions/setup-julia: '*': - expires_at: 2025-08-01 keep: true juliaregistries/tagbot: '*': - expires_at: 2025-08-01 keep: true jwgmeligmeyling/checkstyle-github-action: '*': - expires_at: 2025-08-01 keep: true jwgmeligmeyling/pmd-github-action: 322e346bd76a0757c4d54ff9209e245965aa066d: tag: v1.2 jwgmeligmeyling/spotbugs-github-action: '*': - expires_at: 2025-08-01 keep: true kiegroup/github-action-build-chain: '*': - expires_at: 2025-08-01 keep: true korandoru/hawkeye: '*': - expires_at: 2025-08-01 keep: true leafo/gh-actions-lua: '*': - expires_at: 2025-08-01 keep: true leafo/gh-actions-luarocks: 97053c556d6ce2c8e26eb7ac93743437c7af7248: + expires_at: 2026-08-01 + tag: v5 + 4c082a5fad45388feaeb0798dbd82dbd7dc65bca: tag: v5 manusa/actions-setup-minikube: b589f2d61bf96695c546929c72b38563e856059d: tag: v2.14.0 '*': - expires_at: 2025-08-01 keep: true maxim-lobanov/setup-xcode: '*': - expires_at: 2025-08-01 keep: true medyagh/setup-minikube: '*': - expires_at: 2025-08-01 keep: true mikepenz/action-junit-report: '*': - expires_at: 2025-08-01 keep: true mozilla-actions/sccache-action: 7d986dd989559c6ecdb630a3fd2557667be217ad: tag: v0.0.9 msys2/setup-msys2: '*': - expires_at: 2025-08-01 keep: true mvasigh/dispatch-action: '*': - expires_at: 2025-08-01 keep: true nanasess/setup-chromedriver: '*': - expires_at: 2025-08-01 keep: true ncipollo/release-action: b7eabc95ff50cbeeedec83973935c8f306dfcd0b: tag: v1.20.0 nick-fields/retry: '*': - expires_at: 2025-08-01 keep: true nwtgck/actions-netlify: 4cbaf4c08f1a7bfa537d6113472ef4424e4eb654: tag: v3.0.0 ocaml/setup-ocaml: '*': - expires_at: 2025-08-01 keep: true olafurpg/setup-scala: '*': - expires_at: 2025-08-01 keep: true opentofu/setup-opentofu: 000eeb8522f0572907c393e8151076c205fdba1b: @@ -550,7 +475,6 @@ opentofu/setup-opentofu: tag: v1.0.8 oracle-actions/setup-java: '*': - expires_at: 2025-08-01 keep: true orhun/git-cliff-action: d77b37db2e3f7398432d34b72a12aa3e2ba87e51: @@ -560,44 +484,36 @@ orhun/git-cliff-action: tag: v4.7.0 ossf/scorecard-action: '*': - expires_at: 2025-08-01 keep: true peaceiris/actions-gh-pages: '*': - expires_at: 2025-08-01 keep: true peaceiris/actions-hugo: '*': - expires_at: 2025-08-01 keep: true peaceiris/actions-mdbook: '*': - expires_at: 2025-08-01 keep: true peter-evans/create-or-update-comment: e8674b075228eee787fea43ef493e45ece1004c9: tag: v5.0.0 peter-evans/create-pull-request: '*': - expires_at: 2025-08-01 keep: true phoenix-actions/test-reporting: f957cd93fc2d848d556fa0d03c57bc79127b6b5e: tag: v15 pnpm/action-setup: '*': - expires_at: 2025-08-01 keep: true potiuk/cancel-workflow-runs: '*': - expires_at: 2025-08-01 keep: true pre-commit/action: 2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd: tag: v3.0.1 pyTooling/Actions: '*': - expires_at: 2025-08-01 keep: true pypa/cibuildwheel: 9c00cb4f6b517705a3794b22395aedc36257242c: @@ -610,27 +526,21 @@ pypa/cibuildwheel: tag: v3.3.1 pytooling/actions: '*': - expires_at: 2025-08-01 keep: true quarto-dev/quarto-actions: '*': - expires_at: 2025-08-01 keep: true reactivecircus/android-emulator-runner: '*': - expires_at: 2025-08-01 keep: true readthedocs/actions: '*': - expires_at: 2025-08-01 keep: true release-drafter/release-drafter: '*': - expires_at: 2025-08-01 keep: true ruby/setup-ruby: '*': - expires_at: 2025-08-01 keep: true sbt/setup-sbt: '*': @@ -645,10 +555,12 @@ sbt/setup-sbt: tag: v1.1.16 scacap/action-surefire-report: '*': - expires_at: 2025-08-01 keep: true 5609ce4db72c09db044803b344a8968fd1f315da: tag: v1.9.1 + # GH API requests from GH hosted runners fail with 403 and the following error message: + # 'Although you appear to have the correct authorization credentials, the `ScaCap` organization has an IP allow list enabled, and your IP address is not permitted to access this resource.' + ignore_gh_api_errors: true scala-steward-org/scala-steward-action: 53d486a68877f4a6d1e110e8058fe21e593db356: tag: v2.77.0 @@ -663,23 +575,18 @@ scalacenter/sbt-dependency-submission: # tag: v3.2.1 scottbrenner/puppet-lint-action: '*': - expires_at: 2025-08-01 keep: true securego/gosec: '*': - expires_at: 2025-08-01 keep: true shivammathur/setup-php: '*': - expires_at: 2025-08-01 keep: true shogo82148/actions-setup-perl: '*': - expires_at: 2025-08-01 keep: true shufo/auto-assign-reviewer-by-files: '*': - expires_at: 2025-08-01 keep: true sigstore/cosign-installer: faadad0cce49287aee09b3a48701e75088a2c6ad: @@ -690,27 +597,21 @@ snok/install-poetry: tag: v1 softprops/action-gh-release: '*': - expires_at: 2025-08-01 keep: true stCarolas/setup-maven: '*': - expires_at: 2025-08-01 keep: true subosito/flutter-action: '*': - expires_at: 2025-08-01 keep: true swatinem/rust-cache: '*': - expires_at: 2025-08-01 keep: true swift-actions/setup-swift: '*': - expires_at: 2025-08-01 keep: true taiki-e/install-action: '*': - expires_at: 2025-08-01 keep: true tcort/github-action-markdown-link-check: f3d33029dca1c4a24b87e2df648f9f4604ef6533: @@ -720,11 +621,9 @@ tcort/github-action-markdown-link-check: tag: v1.1.2 test-summary/action: '*': - expires_at: 2025-08-01 keep: true timonvs/pr-labeler-action: '*': - expires_at: 2025-08-01 keep: true untitaker/hyperlink: e66bb17cc9ae341677431edec3b893a0aa6ac747: @@ -743,28 +642,22 @@ vimtor/action-zip: tag: v1 vishalsinha21/dynamic-checklist: '*': - expires_at: 2025-08-01 keep: true wei/curl: '*': - expires_at: 2025-08-01 keep: true xpol/setup-lua: '*': - expires_at: 2025-08-01 keep: true slackapi/slack-github-action: 91efab103c0de0a537f72a35f6b8cda0ee76bf0a: tag: v2.1.1 golangci/golangci-lint-action: - 1481404843c368bc19ca9406f87d6e0fc97bdcfd: - expires_at: 2050-08-01 - tag: v7.0.0 - keep: true 55c2c1448f86e01eaae002a5a3a9624417608d84: - expires_at: 2050-08-01 tag: v6.5.2 - keep: true + expires_at: 2026-04-24 + 1481404843c368bc19ca9406f87d6e0fc97bdcfd: + tag: v7.0.0 mlugg/setup-zig: 8d6198c65fb0feaa111df26e6b467fea8345e46f: tag: v2.0.5 diff --git a/gateway/action_tags.py b/gateway/action_tags.py new file mode 100644 index 00000000..bd5b6c61 --- /dev/null +++ b/gateway/action_tags.py @@ -0,0 +1,321 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +import os +import re +from urllib.error import HTTPError + +import ruyaml + +from datetime import date +from urllib.request import Request, urlopen +from pathlib import Path +from ruyaml import CommentedMap, CommentedSeq +from gateway import ActionsYAML, load_yaml, on_gha + +re_github_actions_repo_wildcard = r"^[A-Za-z0-9-_.]+/[*]$" +re_github_actions_repo = r"^([A-Za-z0-9-_.]+/[A-Za-z0-9-_.]+)(/.+)?$" +# Something like 'pytooling/actions/with-post-step' or 'readthedocs/actions/preview'. +re_docker_image = r"^docker://.+" +re_git_sha = r"^[a-f0-9]{7,}$" + +class ActionTagsCheckResult(object): + def __init__(self, log_to_console: bool = True): + self.log_to_console = log_to_console + self.logs = [] + self.failures = [] + self.warnings = [] + + def log(self, message: str) -> None: + if self.log_to_console: + print(message) + self.logs.append(message) + + def failure(self, message: str, indent: str) -> None: + self.log(f"{indent} ❌ {message}") + self.failures.append(message) + + def warning(self, message: str, indent: str) -> None: + self.log(f"{indent} ⚡ {message}") + self.warnings.append(message) + + def has_failures(self) -> bool: + return len(self.failures) > 0 + + def has_warnings(self) -> bool: + return len(self.warnings) > 0 + + def __str__(self): + return ( + ''.join([f"FAILURE: {failure}\n" for failure in self.failures]) + + ''.join([f"WARNING: {warning}\n" for warning in self.warnings])) + + +class ApiResponse(object): + def __init__(self, req_url: str, status: int, reason: str, headers: dict[str, str], body: str): + self.req_url = req_url + self.status = status + self.reason = reason + self.headers = headers + self.body = body + + +def _gh_api_get(url_abspath: str) -> ApiResponse: + headers: dict[str, str] = { + 'Accept': 'application/vnd.github.v3+json', + } + # Use GH_TOKEN, if available. + # Unauthorized GH API requests are quite rate-limited. + # Tip: add an extra space before 'export' to prevent adding the line to the shell history. + # export GH_TOKEN=$(gh auth token) + gh_token = os.environ['GH_TOKEN'] + if gh_token: + headers['Authorization'] = f"Bearer {gh_token}" + req_url = f"https://api.github.com{url_abspath}" + request = Request(url=req_url, headers=headers) + try: + with urlopen(request) as response: + return ApiResponse(req_url, response.status, response.reason, dict(response.headers), response.read().decode('utf-8')) + except HTTPError as e: + return ApiResponse(req_url, e.code, e.reason, dict(e.headers), e.read().decode('utf-8')) + except Exception as e: + print(f"Failed to fetch '{req_url}' from GitHub API") + raise e + +def _gh_get_commit_object(owner_repo: str, sha: str) -> ApiResponse: + return _gh_api_get(f"/repos/{owner_repo}/git/commits/{sha}") + +def _gh_get_tag(owner_repo: str, tag_sha: str) -> ApiResponse: + return _gh_api_get(f"/repos/{owner_repo}/git/tags/{tag_sha}") + +def _gh_matching_tags(owner_repo: str, tag: str) -> ApiResponse: + return _gh_api_get(f"/repos/{owner_repo}/git/matching-refs/tags/{tag}") + +def verify_actions(actions: Path | ActionsYAML | str, log_to_console: bool = True, today: date = date.today()) -> ActionTagsCheckResult: + """ + Validates the contents of the actions file against GitHub. + + The function verifies that the SHAs specified in `actions.yml` exist in the GH repo. + Also ensures that the SHA exists on the Git tag if the `tag` attribute is specified. + + The algorithm roughly works like this, for each action specified in `actions.yml`: + * Issue a warning and stop if the name is like `OWNER/*` ("wildcard" repository). + Can't verify Git SHAs in this case. + * Issue a warning and stop if the name is like `docker:*` (not implemented) + * Issue an error and stop if the name doesn't start with an `OWNER/REPO` pattern. + * Each expired entry is just skipped + * If there is a wildcard reference and an SHA reference, issue an error. + + Then, for each reference for an action: + * If no `tag` is specified, let GH resolve the commit SHA. + Emit a warning to add the value of the `tag` attribute if the SHA can be resolved. + Otherwise, emit an error. + * If `tag` is specified: + * Add the SHA to the set of requested-shas-by-tag + * Call GitHub's "matching-refs" endpoint for the 'tag' value + * Emit en error if the object type is not a tag or commit. + * Also resolve 'tag' object types to 'commit' object types. + * Add each returned SHA to the set of valid-shas-by-tag. + * For each "requested tag" verify that the sets of valid and requested shas intersect. If not, emit an error. + + Args: + actions: Path to the actions list file (mandatory) + log_to_console: Whether to log messages immediately to the console (default: True) + today: The current date (default: today) + """ + if on_gha(): + print(f"::group::Verify GitHub Actions") + gh_token = os.environ['GH_TOKEN'] + if not gh_token or len(gh_token) == 0: + raise Exception("GH_TOKEN environment variable is not set or empty") + + if isinstance(actions, Path) or isinstance(actions, str): + actions = load_yaml(actions) + actions_yaml: ActionsYAML = actions + + result = ActionTagsCheckResult(log_to_console=log_to_console or on_gha()) + + for name, action in actions_yaml.items(): + gh_repo_matcher = re.match(re_github_actions_repo, name) + if gh_repo_matcher is not None: + owner_repo = gh_repo_matcher.group(1) + result.log(f"Checking GitHub action {name} in GH repo 'https://github.com/{owner_repo}'...") + valid_shas_by_tag: dict[str, set[str]] = {} + requested_shas_by_tag: dict[str, set[str]] = {} + has_wildcard = False + has_wildcard_msg_emitted = False + # Flag whether to not error out on tag/SHA mismatches due to explicitly ignored GH API errors. + has_ignored_api_errors = False + for ref, details in action.items(): + if details and 'expires_at' in details and not details.get('keep'): + expires_at: date = details.get('expires_at') + if expires_at < today: + # skip expired entries + result.log(f" .. ref '{ref}' is expired, skipping") + continue + + # noinspection PyTypedDict + ignore_gh_api_errors = details and 'ignore_gh_api_errors' in details and details['ignore_gh_api_errors'] == True + if ignore_gh_api_errors: + result.warning(f"ignore_gh_api_errors is set to true: will ignore GH API errors for action {name} ref '{ref}'", " ..") + + if ref == '*': + # "wildcard" SHA - what would we... + result.log(f" .. detected wildcard ref") + if len(requested_shas_by_tag) > 0 and not has_wildcard_msg_emitted: + result.warning(f"GitHub action {name} references a wildcard SHA but also has specific SHAs", " ..") + has_wildcard_msg_emitted = True + has_wildcard = True + continue + elif re.match(re_git_sha, ref): + result.log(f" .. detected entry with Git SHA '{ref}'") + if has_wildcard and not has_wildcard_msg_emitted: + result.warning(f"GitHub action {name} references a wildcard SHA but also has specific SHAs", " ..") + has_wildcard_msg_emitted = True + + if not details or not 'tag' in details: + result.log(f" .. no Git tag") + # https://docs.github.com/en/rest/git/commits?apiVersion=2022-11-28#get-a-commit-object + response = _gh_get_commit_object(owner_repo, ref) + match response.status: + case 200: + result.warning(f"GitHub action {name} references existing commit SHA '{ref}' but does not specify the tag name for it.", " ..") + case 404: + result.failure(f"GitHub action {name} references non existing commit SHA '{ref}': HTTP/{response.status}: {response.reason}, API URL: {response.req_url}", " ..") + case _: + m = f"Failed to fetch Git SHA '{ref}' from GitHub repo 'https://github.com/{owner_repo}': HTTP/{response.status}: {response.reason}, API URL: {response.req_url}\n{response.body}" + if ignore_gh_api_errors: + has_ignored_api_errors = True + result.warning(m, " ..") + else: + result.failure(m, " ..") + else: + tag: str = details.get('tag') + result.log(f" .. collecting Git SHAs for tag {tag}") + + if not tag in requested_shas_by_tag: + requested_shas_by_tag[tag] = set() + requested_shas_by_tag[tag].add(ref) + + if not tag in valid_shas_by_tag: + valid_shas_by_tag[tag] = set() + valid_shas_for_tag = valid_shas_by_tag[tag] + + # https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#list-matching-references + response = _gh_matching_tags(owner_repo, tag) + match response.status: + case 200: + response_json: CommentedSeq = ruyaml.YAML().load(response.body) + for msg in response_json: + tag_ref_map: CommentedMap = msg + tag_object: CommentedMap = tag_ref_map["object"] + tab_object_type: str = tag_object["type"] + tag_object_sha: str = tag_object["sha"] + result.log(f" .. GH yields {tab_object_type} SHA '{tag_object_sha}' for '{tag_ref_map['ref']}'") + match tab_object_type: + case "tag": + valid_shas_for_tag.add(tag_object_sha) + # https://docs.github.com/en/rest/git/tags?apiVersion=2022-11-28#get-a-tag + response2 = _gh_get_tag(owner_repo, tag_object_sha) + match response2.status: + case 200: + tag_object_sha = ruyaml.YAML().load(response2.body)["object"]["sha"] + valid_shas_for_tag.add(tag_object_sha) + result.log(f" .. GH returns commit SHA '{tag_object_sha}' for previous tag SHA") + case 404: + result.log(f" .. commit SHA '{tag_object_sha}' does not exist") + case _: + m = f"Failed to fetch details for Git tag '{tag}' from GitHub repo 'https://github.com/{owner_repo}': HTTP/{response2.status}: {response2.reason}, API URL: {response2.req_url}\n{response2.body}" + if ignore_gh_api_errors: + has_ignored_api_errors = True + result.warning(m, " ..") + else: + result.failure(m, " ..") + case "commit": + valid_shas_for_tag.add(tag_object_sha) + case "branch": + result.failure(f"Branch references mentioned for Git tag '{tag}' for GitHub action {name}", " ..") + case _: + result.failure(f"Invalid Git object type '{tag_object['type']}' for Git tag '{tag}' in GitHub repo 'https://github.com/{owner_repo}'", " ..") + case _: + m = f"Failed to fetch matching Git tags for '{tag}' from GitHub repo 'https://github.com/{owner_repo}': HTTP/{response.status}: {response.reason}, API URL: {response.req_url}\n{response.body}" + if ignore_gh_api_errors: + result.warning(m, " ..") + has_ignored_api_errors = True + else: + result.failure(m, " ..") + else: + ignore_invalid_git_sha = details and 'ignore_invalid_git_sha' in details and details['ignore_invalid_git_sha'] == True + if ignore_invalid_git_sha: + result.warning(f"GitHub action {name} references an invalid Git SHA but 'ignore_invalid_git_sha' is set: will ignore invalid Git SHA '{ref}'", " ..") + else: + result.failure(f"GitHub action {name} references an invalid Git SHA '{ref}'", " ..") + raise Exception("foo") + + for req_tag, req_shas in requested_shas_by_tag.items(): + result.log(f" .. checking tag '{req_tag}'") + result.log(f" .. referenced SHAs: {req_shas}") + valid_shas = valid_shas_by_tag.get(req_tag) + result.log(f" .. verified SHAs: {valid_shas if len(valid_shas)>0 else '(none)'}") + if not valid_shas: + m = f"GitHub action {name} references Git tag '{req_tag}' via SHAs '{req_shas}' but no SHAs for tag could be found - does the Git tag exist?" + if has_ignored_api_errors: + result.warning(m, "") + else: + result.failure(m, "") + elif req_shas.isdisjoint(valid_shas): + m = f"GitHub action {name} references Git tag '{req_tag}' via SHAs '{req_shas}' but none of those matches the valid SHAs '{valid_shas}'" + result.failure(m, "") + else: + result.log(f" ✅ GitHub action {name} definition for tag '{req_tag}' is good!") + + elif re.match(re_github_actions_repo_wildcard, name): + result.warning(f"Ignoring '{name}' because it uses a GitHub repository wildcard ...", "") + + elif re.match(re_docker_image, name): + result.warning(f"Ignoring '{name}' because it references a Docker image ...", "") + + else: + m = f"Cannot determine action kind for '{name}'" + result.failure(m, "") + + if on_gha(): + if result.has_failures() or result.has_warnings(): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: + f.write(f"# GitHub Actions verification result\n") + if len(result.failures) > 0: + f.write(f"## Failures ({len(result.failures)})\n") + f.write('```\n') + for msg in result.failures: + f.write(f"{msg}\n\n") + f.write('```\n') + if len(result.warnings) > 0: + f.write(f"## Warnings ({len(result.warnings)})\n") + f.write('```\n') + for msg in result.warnings: + f.write(f"{msg}\n\n") + f.write('```\n') + f.write(f"## Log\n") + f.write('```\n') + for msg in result.logs: + f.write(f"{msg}\n") + f.write('```\n') + print("::endgroup::") + + return result diff --git a/gateway/gateway.py b/gateway/gateway.py index c195f527..05340f26 100644 --- a/gateway/gateway.py +++ b/gateway/gateway.py @@ -24,6 +24,17 @@ class RefDetails(TypedDict): expires_at: date keep: NotRequired[bool] + tag: NotRequired[str] + # Action tags check: Ignore invalid Git SHA and GitHub API errors. + # Some actions are allowed to use branches. + # This should really be used in only very exceptional cases + # and MUST NEVER be used for new actions. + ignore_invalid_git_sha: NotRequired[bool] + # Action tags check: Ignore GitHub API errors in special situations, + # when for example the repository has an IP allow list enabled preventing checks using the GH API + # against such repositories. Sample error message in such cases: + # 'Although you appear to have the correct authorization credentials, the `ScaCap` organization has an IP allow list enabled, and your IP address is not permitted to access this resource.' + ignore_gh_api_errors: NotRequired[bool] ActionRefs = Dict[str, RefDetails] diff --git a/gateway/run_action_tags.py b/gateway/run_action_tags.py new file mode 100644 index 00000000..e68d91cd --- /dev/null +++ b/gateway/run_action_tags.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# Helper script to run the action tag verification. +# +# You must have a GH_TOKEN environment variable set to a GitHub PAT w/ read accessto public repos. +# +# From a local environment use: +# uvx --with ruyaml python gateway/run_action_tags.py +# Or if you're using a virtualenv: +# python gateway/run_action_tags.py + +import os +from pathlib import Path + +from action_tags import verify_actions +from gateway import update_actions, update_patterns + + +def run_main(): + if not 'GH_TOKEN' in os.environ: + raise Exception("GH_TOKEN environment variable should be must.") + + cwd = Path(os.getcwd()) + dummy_workflow = cwd / ".github/workflows/dummy.yml" + actions_yaml = cwd / "actions.yml" + approved_patterns_yaml = cwd / "approved_patterns.yml" + + if not dummy_workflow.exists() or not actions_yaml.exists() or not approved_patterns_yaml.exists(): + raise Exception(f"Missing required files: {dummy_workflow.absolute()}, {actions_yaml.absolute()}, {approved_patterns_yaml.absolute()}") + + update_actions(dummy_workflow, actions_yaml) + update_patterns(approved_patterns_yaml, actions_yaml) + + result = verify_actions(actions_yaml) + if result.has_failures(): + raise Exception(f"Verify actions result summary:\n{result}") + + +if __name__ == "__main__": + run_main() diff --git a/gateway/test_action_tags.py b/gateway/test_action_tags.py new file mode 100644 index 00000000..cdcc596b --- /dev/null +++ b/gateway/test_action_tags.py @@ -0,0 +1,170 @@ +import pytest +from action_tags import * + +def test_patterns(): + assert re.match(re_github_actions_repo, "foo/bar") + assert not re.match(re_github_actions_repo, "foo/*") + assert re.match(re_github_actions_repo, "foo/bar/.github/actions/*") + assert re.match(re_github_actions_repo, "foo/bar/.github/actions/some.yml") + assert re.match(re_docker_image, "docker://foo/bar") + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_sha_without_tag(): + # noinspection PyTypeChecker + result = verify_actions({ + "sbt/setup-sbt": { + "3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd": { + }, + }, + }) + assert result.failures == [] + assert result.warnings == [ + "GitHub action sbt/setup-sbt references existing commit SHA '3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd' but does not specify the tag name for it." + ] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_sha_non_existent(): + # noinspection PyTypeChecker + result = verify_actions({ + "sbt/setup-sbt": { + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef": { + }, + }, + }) + assert result.failures == [ + "GitHub action sbt/setup-sbt references non existing commit SHA 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef': HTTP/404: Not Found, API URL: https://api.github.com/repos/sbt/setup-sbt/git/commits/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ] + assert result.warnings == [] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_tag_sha_vs_commit_sha(): + # noinspection PyTypeChecker + result = verify_actions({ + "1Password/load-secrets-action": { + "e4feb4d8a7cd938b64370099b1893e05c58c3a84": { + "tag": "v3.0.0" + }, + }, + }) + assert " .. GH yields tag SHA 'e4feb4d8a7cd938b64370099b1893e05c58c3a84' for 'refs/tags/v3.0.0'" in result.logs + assert " .. GH returns commit SHA '13f58eec611f8e5db52ec16247f58c508398f3e6' for previous tag SHA" in result.logs + assert result.failures == [] + assert result.warnings == [] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_tag_sha_eq_commit_sha(): + # noinspection PyTypeChecker + result = verify_actions({ + "1Password/load-secrets-action": { + "13f58eec611f8e5db52ec16247f58c508398f3e6": { + "tag": "v3.0.0" + }, + }, + }) + assert " .. GH yields tag SHA 'e4feb4d8a7cd938b64370099b1893e05c58c3a84' for 'refs/tags/v3.0.0'" in result.logs + assert " .. GH returns commit SHA '13f58eec611f8e5db52ec16247f58c508398f3e6' for previous tag SHA" in result.logs + assert result.failures == [] + assert result.warnings == [] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_non_existing_tag(): + # noinspection PyTypeChecker + result = verify_actions({ + "1Password/load-secrets-action": { + "13f58eec611f8e5db52ec16247f58c508398f3e6": { + "tag": "v_ne_3.0.0" + }, + }, + }) + assert result.failures == [ + "GitHub action 1Password/load-secrets-action references Git tag 'v_ne_3.0.0' via SHAs '{'13f58eec611f8e5db52ec16247f58c508398f3e6'}' but no SHAs for tag could be found - does the Git tag exist?" + ] + assert result.warnings == [] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_non_existing_tag_sha(): + # noinspection PyTypeChecker + result = verify_actions({ + "astral-sh/setup-uv": { + "b75a909f75acd358c2196fb9a5f1299a9a8868a4": { + "tag": "v7.1.2" + }, + }, + }) + assert result.failures == [ + "GitHub action astral-sh/setup-uv references Git tag 'v7.1.2' via SHAs '{'b75a909f75acd358c2196fb9a5f1299a9a8868a4'}' but none of those matches the valid SHAs '{'85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41'}'" + ] + assert result.warnings == [] + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_repo_multiple_actions_repo_works(): + # noinspection PyTypeChecker + result = verify_actions({ + "gradle/actions/setup-gradle": { + "4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2": { + "tag": "v5.0.0" + }, + }, + "gradle/actions/wrapper-validation": { + "748248ddd2a24f49513d8f472f81c3a07d4d50e1": { + "tag": "v4.4.4" + }, + }, + }) + assert result.failures == [] + assert result.warnings == [] + assert " ✅ GitHub action gradle/actions/setup-gradle definition for tag 'v5.0.0' is good!" in result.logs + assert " ✅ GitHub action gradle/actions/wrapper-validation definition for tag 'v4.4.4' is good!" in result.logs + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_wildcard_warnings_1(): + # noinspection PyTypeChecker + _test_wildcard_warnings({ + "sbt/setup-sbt": { + '*': { + "expires_at": date(2026, 2,28), + }, + "17575ea4e18dd928fe5968dbe32294b97923d65b": { + "expires_at": date(2025, 12,29), + "tag": "v1.1.13" + }, + "3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd": { + "tag": "v1.1.14" + }, + }, + }) + +@pytest.mark.skipif(os.environ.get('GH_TOKEN') is None, reason="GH_TOKEN environment variable should be set for this test as it issues GitHub API requests.") +def test_wildcard_warnings_2(): + """ + Similar to test_wildcard_warnings_1, but with the wildcard SHA at the end. + """ + # noinspection PyTypeChecker + _test_wildcard_warnings({ + "sbt/setup-sbt": { + "17575ea4e18dd928fe5968dbe32294b97923d65b": { + "expires_at": date(2025, 12,29), + "tag": "v1.1.13" + }, + "3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd": { + "tag": "v1.1.14" + }, + '*': { + "expires_at": date(2026, 2,28), + }, + }, + }) + +def _test_wildcard_warnings(refs: ActionsYAML): + result = verify_actions(refs, today=date(2025, 12, 21)) + assert not " .. ref '*' is expired, skipping" in result.logs + assert result.failures == [] + assert result.warnings == [ + "GitHub action sbt/setup-sbt references a wildcard SHA but also has specific SHAs", + ] + + # wildcard expired + result = verify_actions(refs, today=date(2026, 3, 1)) + assert " .. ref '*' is expired, skipping" in result.logs + assert result.failures == [] + assert result.warnings == []