diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..66fb87b --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,51 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ +# +# ============================================================================== +# Dependabot Configuration +# +# This configuration automates security updates for third-party GitHub Actions. +# To protect our downstream consumers, all actions must be pinned to a +# 40-character commit SHA rather than a mutable version tag. +# +# Custom rulesets in place: +# 1. Monthly Schedule: Checks for updates once a month to minimize noise. +# 2. Major Version Lock: Automated major updates (e.g., v9 to v10) are blocked +# to prevent unexpected breaking code changes. +# 3. PR Grouping: All discovered minor/patch updates are bundled into a single +# monthly Pull Request instead of flooding the repository notifications. +# +# How to manually trigger a Major Update: +# To safely upgrade an action to its next major version baseline: +# 1. Open the workflow file. +# 2. Leave the old SHA exactly as it is, but manually change the trailing +# comment tag to the new target baseline version (e.g., update the text +# from "# v4.0.0" to "# v5.0.0"). +# 3. Push your change. Dependabot will instantly recognize the target update +# and generate a new PR containing the correct, matching v5 commit hash. +# ============================================================================== + +version: 2 +updates: + # Enable automatic tracking for GitHub Actions dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + + # Ignore rules to block major version jumps + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] + + groups: + # Group all action updates into a single PR to reduce noise + # Rule applies across every single third-party action used in all workflows + github-actions-dependencies: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 41944d9..8fddf4d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,14 @@ # PR Summary + + Code Reviewer: - + -## Code Quality Checklist +## :white_check_mark: Code Quality Checklist (_Some checks are automatically carried out via the CI pipeline_) @@ -16,16 +17,12 @@ Code Reviewer: - [ ] The modified workflow's README has been updated, if required - [ ] The changes have been sufficiently tested (please describe) -## AI Assistance and Attribution +## :robot: AI Assistance and Attribution -- [ ] Some of the content of this change has been produced with the assistance - of _Generative AI tool name_ (e.g., Met Office Github Copilot Enterprise, - Github Copilot Personal, ChatGPT GPT-4, etc) and I have followed the - [Simulation Systems AI policy](https://metoffice.github.io/simulation-systems/FurtherDetails/ai.html) - (including attribution labels) +- [ ] Some of the content of this change has been produced with the assistance of _Generative AI tool name_ (e.g., Met Office GitHub Copilot Enterprise, GitHub Copilot Personal, ChatGPT GPT-4, etc) and I have followed the [Simulation Systems AI policy](https://metoffice.github.io/simulation-systems/FurtherDetails/ai.html) (including attribution labels) -# Code Review +## :computer: Code Review -- [ ] The changes are approriate and testing has been sufficient +- [ ] The changes are appropriate and testing has been sufficient diff --git a/.github/workflows/build-sphinx-docs.yaml b/.github/workflows/build-sphinx-docs.yaml index c50fcb8..09e7bfc 100644 --- a/.github/workflows/build-sphinx-docs.yaml +++ b/.github/workflows/build-sphinx-docs.yaml @@ -16,7 +16,7 @@ on: default: 'documentation' requirements: description: 'Path to the requirements file to install dependencies from' - required: true + required: false type: string default: 'requirements.txt' sphinx-opts: @@ -40,11 +40,18 @@ on: type: number default: 5 +permissions: + contents: read + jobs: build-sphinx-docs: name: Build Sphinx Docs runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout }} + permissions: + contents: read # Required to read repository file configurations + pages: write # Required to deploy to GitHub Pages + env: DOCS_DIR: ${{ inputs.docs-directory }} REQS_FILE: ${{ inputs.requirements }} @@ -52,10 +59,12 @@ jobs: BUILD_DIR: ${{ inputs.build-directory }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Set up Python 3.12 - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' cache: 'pip' @@ -80,7 +89,7 @@ jobs: - name: Upload artifact to GitHub Pages if: ${{ (github.event_name == 'push' || github.event_name == 'merge_group') && github.ref_name == 'main'}} - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: name: github-pages path: "$BUILD_DIR/html" diff --git a/.github/workflows/call-track-review-project.yaml b/.github/workflows/call-track-review-project.yaml index eeab646..32f5e16 100644 --- a/.github/workflows/call-track-review-project.yaml +++ b/.github/workflows/call-track-review-project.yaml @@ -1,3 +1,9 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ + name: Track Review Project on: @@ -7,12 +13,20 @@ on: - completed workflow_dispatch: +# Best Practice: Explicitly strip all top-level default permissions +permissions: {} + jobs: track_review_project: + # Granular permissions are safely locked directly to this specific execution context + permissions: + actions: read # Required to view the status of the upstream triggered workflow run + repository-projects: write # Required to add or mutate tasks inside GitHub Project 376 uses: MetOffice/growss/.github/workflows/track-review-project.yaml@main - secrets: inherit + secrets: + PROJECT_ACTION_PAT: ${{ secrets.PROJECT_ACTION_PAT }} # Optional inputs (with default values) with: - runner: "ubuntu-22.04" + runner: "ubuntu-slim" project_org: "MetOffice" project_number: 376 diff --git a/.github/workflows/call-trigger-project-workflow.yaml b/.github/workflows/call-trigger-project-workflow.yaml index f22c433..54b7ea1 100644 --- a/.github/workflows/call-trigger-project-workflow.yaml +++ b/.github/workflows/call-trigger-project-workflow.yaml @@ -1,3 +1,9 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ + name: Trigger Review Project on: @@ -6,7 +12,11 @@ on: pull_request_review: pull_request_review_comment: +permissions: {} + jobs: trigger_project_workflow: + permissions: + contents: read # Required for checking out code or referencing internal workflow actions + pull-requests: write # Required to modify or comment on pull requests within this pipeline process uses: MetOffice/growss/.github/workflows/trigger-project-workflow.yaml@main - secrets: inherit diff --git a/.github/workflows/check-cr-approved.yaml b/.github/workflows/check-cr-approved.yaml index 1ad5afc..833be05 100644 --- a/.github/workflows/check-cr-approved.yaml +++ b/.github/workflows/check-cr-approved.yaml @@ -9,10 +9,13 @@ name: Check CR Approval on: workflow_call: -permissions: read-all +permissions: + contents: read # Required to evaluate metadata from the triggering GitHub event context + pull-requests: read # Required to check the PR reviews and approval status jobs: cr_check: + name: CR Approval runs-on: ubuntu-24.04 timeout-minutes: 2 @@ -24,6 +27,12 @@ jobs: REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | + # Ensure context variables are present, don't run from a non-PR event + if [[ -z "$PR_NUMBER" ]]; then + echo "::error::This workflow must be triggered by a pull_request event." + exit 1 + fi + echo "Running on PR #$PR_NUMBER in Repository $REPO" # -- 1. Extract the assigned Code Reviewer from the PR body diff --git a/.github/workflows/cla-check.yaml b/.github/workflows/cla-check.yaml index cac5370..68493c6 100644 --- a/.github/workflows/cla-check.yaml +++ b/.github/workflows/cla-check.yaml @@ -18,25 +18,30 @@ on: type: string default: 'https://github.com/MetOffice/Momentum/blob/main/CLA.md' -permissions: - contents: read - pull-requests: write # Required to add labels and comments +permissions: {} jobs: - check-cla: + manage-cla: + name: check-cla + permissions: + contents: read + pull-requests: write # Required to add labels and comments runs-on: ${{ inputs.runner }} steps: - name: Checkout Base Branch - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + persist-credentials: false token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ github.ref }} path: base_branch - name: Validate Author Username id: validate_author + env: + PR_AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} run: | - AUTHOR="${{ github.event.pull_request.user.login }}" + AUTHOR="$PR_AUTHOR_LOGIN" # GitHub usernames: alphanumeric + hyphen, 1-39 chars, # cannot start/end with hyphen, cannot contain consecutive hyphens if [[ ! "$AUTHOR" =~ ^[a-zA-Z0-9]([a-zA-Z0-9]|-[a-zA-Z0-9]){0,37}[a-zA-Z0-9]?$ ]]; then @@ -50,45 +55,50 @@ jobs: - name: Determine if contributor exists in base id: check_contributor_base working-directory: ./base_branch + env: + VALIDATED_AUTHOR: ${{ steps.validate_author.outputs.validated_author }} run: | - AUTHOR="${{ steps.validate_author.outputs.validated_author }}" + AUTHOR="$VALIDATED_AUTHOR" if [ -f "CONTRIBUTORS.md" ]; then if grep -qP "^\s*\|\s*\Q$AUTHOR\E\s*\|" CONTRIBUTORS.md; then - echo "on_base=true" >> $GITHUB_OUTPUT + echo "on_base=true" >> "$GITHUB_OUTPUT" echo "🎉 $AUTHOR has already signed the CLA on base branch." else - echo "on_base=false" >> $GITHUB_OUTPUT + echo "on_base=false" >> "$GITHUB_OUTPUT" echo "âš ī¸ $AUTHOR not on base. Proceeding to check PR branch." fi else # If CONTRIBUTORS.md file doesn't exist, we must check PR branch - echo "on_base=undefined" >> $GITHUB_OUTPUT + echo "on_base=undefined" >> "$GITHUB_OUTPUT" echo "🔴 CONTRIBUTORS.md file does not exist on base. Proceeding to check PR branch." fi - name: Checkout PR Branch - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} path: pr_branch + allow-unsafe-pr-checkout: true # allow checking out PR head refs from forks - name: Determine if contributor exists in PR branch id: check_contributor_pr working-directory: pr_branch + env: + AUTHOR: ${{ steps.validate_author.outputs.validated_author }} run: | - AUTHOR="${{ steps.validate_author.outputs.validated_author }}" if [ -f "CONTRIBUTORS.md" ]; then # if grep -qE "\|\s*$AUTHOR\s*\|" CONTRIBUTORS.md; then if grep -qP "^\s*\|\s*\Q$AUTHOR\E\s*\|" CONTRIBUTORS.md; then - echo "on_pr=true" >> $GITHUB_OUTPUT + echo "on_pr=true" >> "$GITHUB_OUTPUT" echo "✅ $AUTHOR is in the CONTRIBUTORS.md file on PR branch." else - echo "on_pr=false" >> $GITHUB_OUTPUT + echo "on_pr=false" >> "$GITHUB_OUTPUT" echo "âš ī¸ $AUTHOR is not in the CONTRIBUTORS.md file on PR branch." fi else - echo "on_pr=undefined" >> $GITHUB_OUTPUT + echo "on_pr=undefined" >> "$GITHUB_OUTPUT" echo "🔴 CONTRIBUTORS.md file does not exist on PR branch." fi @@ -96,60 +106,90 @@ jobs: id: check_merge_ref working-directory: ./base_branch shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.number }} run: | - ref="refs/pull/${{ github.event.number }}/merge" - hash=$(git ls-remote origin $ref) - if [[ -z $hash ]]; then - echo "merge_ref_defined=false" >> $GITHUB_ENV + # Use gh api to query pull request metadata directly + # without network git ls-remote authentication + mergeable=$(gh api "repos/$REPOSITORY/pulls/$PR_NUMBER" --jq ".mergeable" 2>/dev/null || echo "undefined") + if [ "$mergeable" != "undefined" ]; then + echo "merge_ref_defined=true" >> "$GITHUB_ENV" else - echo "merge_ref_defined=true" >> $GITHUB_ENV + echo "merge_ref_defined=false" >> "$GITHUB_ENV" fi - name: Checkout Merge Branch if: env.merge_ref_defined == 'true' id: checkout_merge - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 continue-on-error: true with: token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false ref: "refs/pull/${{ github.event.number }}/merge" path: merge_branch + allow-unsafe-pr-checkout: true # allow checking out PR head refs from forks - name: Check if CONTRIBUTORS.md was modified in PR id: check_contributors_modified + shell: bash + env: + # This token is safely scoped to job-level permissions + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + # Use the safe PR number context to completely avoid the private fork 404 + PR_NUMBER: ${{ github.event.number }} run: | - if [[ "$merge_ref_defined" == "false" ]]; then - echo "::warning::Merge conflicts mean the check for modification of a signed CONTRIBUTORS.md file was not completed. Allowing this to pass while conflict exists." - echo "modified=false" >> $GITHUB_OUTPUT + if [ "$merge_ref_defined" = "false" ]; then + echo "::warning::Merge conflicts detected. The CONTRIBUTORS.md modification check was skipped, but the workflow has been allowed to pass. Please resolve conflicts." + echo "modified=false" >> "$GITHUB_OUTPUT" else - cd merge_branch || exit 1 - - git fetch origin "${{ github.event.pull_request.base.ref }}" - - # Only check for exact CONTRIBUTORS.md file (not subdirectories) - CHANGED_FILES=$(git diff --name-only origin/$GITHUB_BASE_REF -- CONTRIBUTORS.md) - - if [ -n "$CHANGED_FILES" ]; then - echo "modified=true" >> $GITHUB_OUTPUT - echo "📝 CONTRIBUTORS.md file was modified in this PR." + # 1. Fetch raw CONTRIBUTORS.md from the base branch path securely + gh api "repos/$REPOSITORY/contents/CONTRIBUTORS.md?ref=$BASE_REF" \ + --jq '.content' | base64 -d > base_raw.txt 2>/dev/null || touch base_raw.txt + + # 2. Fetch CONTRIBUTORS.md from the repository using the unified PR head reference. + # This completely bypasses the private fork 404 authentication barrier. + gh api "repos/$REPOSITORY/contents/CONTRIBUTORS.md?ref=refs/pull/$PR_NUMBER/head" \ + --jq '.content' | base64 -d > pr_raw.txt 2>/dev/null || touch pr_raw.txt + + # 3. Clean and normalise files using pure shell primitives + tr -d '[:space:]' < base_raw.txt > base_clean.txt 2>/dev/null || touch base_clean.txt + tr -d '[:space:]' < pr_raw.txt > pr_clean.txt 2>/dev/null || touch pr_clean.txt + + # 4. Perform a direct text comparison on the squashed data + if ! cmp -s base_clean.txt pr_clean.txt; then + echo "modified=true" >> "$GITHUB_OUTPUT" + echo "📝 CONTRIBUTORS.md content was substantively modified (additions or deletions detected)." else - echo "modified=false" >> $GITHUB_OUTPUT - echo "â„šī¸ CONTRIBUTORS.md file was NOT modified in this PR." + echo "modified=false" >> "$GITHUB_OUTPUT" + echo "â„šī¸ CONTRIBUTORS.md content was NOT substantively modified (whitespace only or unchanged)." fi + + # Clean up the localised workspace + rm -f base_raw.txt pr_raw.txt base_clean.txt pr_clean.txt fi - # -- Step 3: Manage PR Labels, Comments, and Final Status (Consolidated) + # -- Manage PR Labels, Comments, and Final Status (Consolidated) - name: Manage CLA Status, Labels, and Comments - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 # Using 'always()' here so this step runs regardless of previous # success/failure to manage labels correctly if: always() + env: + SIGNED_ON_BASE: ${{ steps.check_contributor_base.outputs.on_base }} + SIGNED_ON_PR: ${{ steps.check_contributor_pr.outputs.on_pr }} + CONTRIBUTORS_MODIFIED: ${{ steps.check_contributors_modified.outputs.modified }} + CLA_URL: ${{ inputs.cla-url }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const signedOnBase = '${{ steps.check_contributor_base.outputs.on_base }}' === 'true'; - const signedOnPr = '${{ steps.check_contributor_pr.outputs.on_pr }}' === 'true'; - const contributorsModified = '${{ steps.check_contributors_modified.outputs.modified }}' === 'true'; + const signedOnBase = process.env.SIGNED_ON_BASE === 'true'; + const signedOnPr = process.env.SIGNED_ON_PR === 'true'; + const contributorsModified = process.env.CONTRIBUTORS_MODIFIED === 'true'; const issue_number = context.issue.number; const owner = context.repo.owner; const repo = context.repo.repo; @@ -161,7 +201,7 @@ jobs: process.exit(1); } - const claUrl = '${{ inputs.cla-url }}'; + const claUrl = process.env.CLA_URL; // Validate CLA URL try { const url = new URL(claUrl); diff --git a/.github/workflows/deploy-sphinx-docs.yaml b/.github/workflows/deploy-sphinx-docs.yaml index 0e8071e..4a062f4 100644 --- a/.github/workflows/deploy-sphinx-docs.yaml +++ b/.github/workflows/deploy-sphinx-docs.yaml @@ -19,19 +19,24 @@ on: required: false type: number default: 5 + +permissions: + contents: read + jobs: deploy-sphinx-docs: + name: Deploy Sphinx Docs runs-on: ${{ inputs.runner }} environment: name: github-pages permissions: - contents: read - pages: write - id-token: write + contents: read # Required to read repository file configurations + pages: write # Required to deploy to GitHub Pages + id-token: write # Required to authenticate with GitHub Pages steps: - name: Configure GitHub Pages id: configure - uses: actions/configure-pages@v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy GitHub Pages id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/fortran-lint.yaml b/.github/workflows/fortran-lint.yaml index 35f3e05..008e4ad 100644 --- a/.github/workflows/fortran-lint.yaml +++ b/.github/workflows/fortran-lint.yaml @@ -68,32 +68,51 @@ on: type: boolean default: false +# Global baseline configuration: prevents default read/write fallback permissions +permissions: + contents: read # Required to check out code and scan files using fortitude linter + jobs: fortran-linting: name: Fortran runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout }} - + permissions: + contents: read # Required to check out code and scan files using fortitude linter env: UV_PYTHON: ${{ inputs.python-version }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup uv and Python ${{ env.UV_PYTHON }} - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 with: python-version: ${{ env.UV_PYTHON }} enable-cache: ${{ inputs.enable-cache }} - name: Run Fortitude Linter + env: + FORTITUDE_VERSION: ${{ inputs.fortitude-version }} + FILE_EXTENSIONS: ${{ inputs.file-extensions }} + CONFIG_PATH: ${{ inputs.config-path }} + SOURCE_PATH: ${{ inputs.source-path }} + RESPECT_GITIGNORE: ${{ inputs.respect-gitignore }} + SHOW_FIXES: ${{ inputs.show-fixes }} + SHOW_STATISTICS: ${{ inputs.show-statistics }} run: | - uvx --from fortitude-lint==${{ inputs.fortitude-version }} fortitude check \ - ${{ inputs.respect-gitignore && '--respect-gitignore' || '' }} \ - ${{ inputs.show-fixes && '--show-fixes' || '' }} \ - ${{ inputs.show-statistics && '--statistics' || '' }} \ - --file-extensions=${{ inputs.file-extensions }} \ - ${{ inputs.config-path && format('--config-file={0}', inputs.config-path) || '' }} \ - ${{ inputs.source-path }} + ARGS=() + + [ "$RESPECT_GITIGNORE" = "true" ] && ARGS+=("--respect-gitignore") + [ "$SHOW_FIXES" = "true" ] && ARGS+=("--show-fixes") + [ "$SHOW_STATISTICS" = "true" ] && ARGS+=("--statistics") + + uvx --from "fortitude-lint==$FORTITUDE_VERSION" fortitude check \ + "${ARGS[@]}" \ + "--file-extensions=$FILE_EXTENSIONS" \ + ${CONFIG_PATH:+"--config-file=$CONFIG_PATH"} \ + "$SOURCE_PATH" continue-on-error: ${{ !inputs.fail-on-error }} diff --git a/.github/workflows/label-pr.yaml b/.github/workflows/label-pr.yaml index 111565a..24bf4bf 100644 --- a/.github/workflows/label-pr.yaml +++ b/.github/workflows/label-pr.yaml @@ -9,10 +9,16 @@ name: Label PR on: workflow_call: +permissions: {} + jobs: label: + name: Label runs-on: ubuntu-latest if: ${{ github.event.pull_request.head.repo.fork }} + permissions: + contents: read # Required for checking out code or referencing internal workflow actions + pull-requests: write # Required for the composite action to add or remove labels on the PR steps: - name: Add label `contributor` if: ${{ github.event.action == 'opened' }} diff --git a/.github/workflows/sphinx-docs.yaml b/.github/workflows/sphinx-docs.yaml index 7409492..c3ed2f5 100644 --- a/.github/workflows/sphinx-docs.yaml +++ b/.github/workflows/sphinx-docs.yaml @@ -88,17 +88,19 @@ on: description: 'URL of the deployed GitHub Pages site' value: ${{ jobs.build-and-deploy.outputs.pages-url }} -permissions: - contents: read - pages: write # Required for deploying to GitHub Pages - id-token: write # Required for authentication in uv sync when using pyproject.toml +permissions: {} jobs: build-and-deploy: + name: Build and Deploy runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout-minutes }} outputs: pages-url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: read + pages: write # Required for deploying to GitHub Pages + id-token: write # Required for authentication in uv sync when using pyproject.toml env: PYTHON_VRSN: ${{ inputs.python-version }} VENV_PATH: ${{ inputs.venv-path }} @@ -124,14 +126,19 @@ jobs: run: echo "SPHINX_DEBUG=-v --show-traceback" >> "$GITHUB_ENV" - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Setup uv with Python ${{ env.PYTHON_VRSN }} - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: python-version: ${{ env.PYTHON_VRSN }} + enable-cache: true + cache-dependency-glob: | + **/pyproject.toml + ${{ env.SOURCE_DIR }}/conf.py + ignore-nothing-to-cache: true - name: Detect dependency source id: detect-deps @@ -196,17 +203,17 @@ jobs: echo "flags=$FLAGS" >> "$GITHUB_OUTPUT" [ -z "$FLAGS" ] && echo "::warning::'$EXTRAS' not found in $PYPROJECT." || echo "::notice::Resolved flags: '$FLAGS'" - - name: Cache uv venv - if: steps.detect-deps.outputs.source != 'conf' + - name: Cache virtual environment + id: cache-venv uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.VENV_PATH }} - key: ${{ runner.os }}-${{ env.PYTHON_VRSN }}-${{ hashFiles('pyproject.toml', format('{0}/pyproject.toml', inputs.docs-dir), format('{0}/pyproject.toml', inputs.source-dir)) }} + key: ${{ runner.os }}-${{ env.PYTHON_VRSN }}-${{ hashFiles('pyproject.toml', format('{0}/pyproject.toml', inputs.docs-dir), format('{0}/pyproject.toml', inputs.source-dir), format('{0}/conf.py', inputs.source-dir)) }} restore-keys: | ${{ runner.os }}-${{ env.PYTHON_VRSN }}- - name: Create virtual environment - run: uv venv --python ${PYTHON_VRSN} --allow-existing ${VENV_PATH} + run: uv venv --python "${PYTHON_VRSN}" --allow-existing "${VENV_PATH}" - name: Install dependencies from conf.py (extensions + html_theme) if: steps.detect-deps.outputs.source == 'conf' @@ -283,7 +290,7 @@ jobs: run: echo "${GITHUB_WORKSPACE}/${VENV_PATH}/bin" >> "$GITHUB_PATH" - name: Lint Sphinx docs - run: sphinx-lint ${SOURCE_DIR} + run: sphinx-lint "${SOURCE_DIR}" - name: Detect Makefile in docs directory id: detect-makefile @@ -315,7 +322,6 @@ jobs: "${DOCS_DIR}/${BUILD_DIR}/html" - name: Minimize uv cache - if: steps.detect-deps.outputs.source != 'conf' run: uv cache prune --ci # -- Deploy to GitHub Pages only on push to upstream main diff --git a/.github/workflows/track-review-project.yaml b/.github/workflows/track-review-project.yaml index a743eed..fe0a316 100644 --- a/.github/workflows/track-review-project.yaml +++ b/.github/workflows/track-review-project.yaml @@ -25,26 +25,35 @@ on: required: false type: number default: 376 # Simulation Systems Review Tracker + secrets: + PROJECT_ACTION_PAT: + description: 'The PAT used to authorize changes to Organization Project Board 376' + required: true permissions: - actions: read - contents: read - pull-requests: write + contents: read # Required to check out code or parse files safely jobs: track_project: + name: Track Review Project runs-on: ${{ inputs.runner }} timeout-minutes: 5 + permissions: + actions: read # Required to query the artifact lists + contents: read # Required to check out code or parse files safely + pull-requests: write # Required to assign authors or append metadata notes directly on the pull request target env: REPO: ${{ github.repository }} steps: # Required as running from the workflow_run trigger - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Download PR data artifact - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -55,7 +64,7 @@ jobs: }); let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { - return artifact.name == "context.json" + return artifact.name == "context" })[0]; let download = await github.rest.actions.downloadArtifact({ @@ -71,7 +80,7 @@ jobs: - name: Extract PR Data run: | unzip -q context.zip - jq -r '"PR_NUM=\(.pr_num)\nPR_ID=\(.pr_id)"' context.json >> $GITHUB_ENV + jq -r '"PR_NUM=\(.pr_num)\nPR_ID=\(.pr_id)"' context.json >> "$GITHUB_ENV" - name: Assign Author if no assignees continue-on-error: true @@ -92,23 +101,34 @@ jobs: # Extract reviewers from PR body pr_body=$(echo "$pr_data" | jq -r '.body') - sr_expected=$( (echo $pr_body | grep -iq "Sci/Tech Review" && echo "True") || echo "False") - scitech_reviewer=$(echo "$pr_body" | perl -00 -ne 's///gs; print if(s/.*Sci\/Tech Reviewer:\s*@([\w\-]{1,39})\b.*/\1\n/s);') || true - code_reviewer=$(echo "$pr_body" | perl -00 -ne 's///gs; print if(s/.*Code Reviewer:\s*@([\w\-]{1,39})\b.*/\1\n/s);') || true + + # Using safe Here-strings to preserve multi-line content + if grep -iq "Sci/Tech Review" <<< "$pr_body"; then + sr_expected="True" + else + sr_expected="False" + fi + # Pass string via Here-strings to prevent line skipping or shell expression leaks + scitech_reviewer=$(perl -00 -ne 's///gs; print if(s/.*Sci\/Tech Reviewer:\s*@([\w\-]{1,39})\b.*/\1\n/s);' <<< "$pr_body") || true + code_reviewer=$(perl -00 -ne 's///gs; print if(s/.*Code Reviewer:\s*@([\w\-]{1,39})\b.*/\1\n/s);' <<< "$pr_body") || true echo "Expected SciTech Reviewer: ${scitech_reviewer:-None}" echo "Expected Code Reviewer: ${code_reviewer:-None}" - echo "SCITECH_REVIEWER=$scitech_reviewer" >> $GITHUB_ENV - echo "CODE_REVIEWER=$code_reviewer" >> $GITHUB_ENV + { + echo "SCITECH_REVIEWER=$scitech_reviewer" + echo "CODE_REVIEWER=$code_reviewer" + } >> "$GITHUB_ENV" # If the PR is in Draft, then don't request reviews and set state as "In Progress" isDraft=$(echo "$pr_data" | jq -r '.isDraft') if [[ "$isDraft" == "true" ]]; then echo "PR is still in draft. Not requesting reviews or moving from In Progress" required_state="In Progress" - echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV - echo "SR_REQUEST_REQUIRED=false" >> $GITHUB_ENV - echo "CR_REQUEST_REQUIRED=false" >> $GITHUB_ENV + { + echo "REQUIRED_STATE=$required_state" + echo "SR_REQUEST_REQUIRED=false" + echo "CR_REQUEST_REQUIRED=false" + } >> "$GITHUB_ENV" exit 0 fi @@ -117,7 +137,7 @@ jobs: state=$(echo "$pr_data" | jq -r '.state') if [[ "$state" != "OPEN" ]]; then required_state="Done" - echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV + echo "REQUIRED_STATE=$required_state" >> "$GITHUB_ENV" exit 0 fi @@ -133,7 +153,7 @@ jobs: cr_approved=$(echo "$reviews" | jq --arg cr "$code_reviewer" '[.reviews[] | select(.author.login == $cr and .state == "APPROVED")] | length') cr_changes=$(echo "$reviews" | jq --arg cr "$code_reviewer" '[.reviews[] | select(.author.login == $cr and (.state == "CHANGES_REQUESTED" or .state == "COMMENTED"))] | length') - [[ "$sr_expected" == "True" ]] && [[ ! -z "$scitech_reviewer" ]] && [[ "$sr_requested" -eq 0 ]] && [[ "$sr_approved" -eq 0 ]] && [[ "$sr_changes" -eq 0 ]] && echo "SR_REQUEST_REQUIRED=true" >> $GITHUB_ENV + [[ "$sr_expected" == "True" ]] && [[ ! -z "$scitech_reviewer" ]] && [[ "$sr_requested" -eq 0 ]] && [[ "$sr_approved" -eq 0 ]] && [[ "$sr_changes" -eq 0 ]] && echo "SR_REQUEST_REQUIRED=true" >> "$GITHUB_ENV" if [[ "$sr_expected" == "False" ]]; then echo "Not expecting a SciTech Review for this repository" @@ -149,13 +169,13 @@ jobs: else required_state="SciTech Review" fi - echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV + echo "REQUIRED_STATE=$required_state" >> "$GITHUB_ENV" exit 0 fi # SR complete or trivial PR so move to code review states - [[ "$sr_approved" -gt 0 ]] && [[ ! -z "$code_reviewer" ]] && [[ "$cr_requested" -eq 0 ]] && [[ "$cr_approved" -eq 0 ]] && [[ "$cr_changes" -eq 0 ]] && echo "CR_REQUEST_REQUIRED=true" >> $GITHUB_ENV + [[ "$sr_approved" -gt 0 ]] && [[ ! -z "$code_reviewer" ]] && [[ "$cr_requested" -eq 0 ]] && [[ "$cr_approved" -eq 0 ]] && [[ "$cr_changes" -eq 0 ]] && echo "CR_REQUEST_REQUIRED=true" >> "$GITHUB_ENV" if [[ -z "$code_reviewer" ]] && [[ "$cr_changes" -eq 0 ]]; then required_state="In Progress" @@ -167,7 +187,7 @@ jobs: required_state="Code Review" fi - echo "REQUIRED_STATE=$required_state" >> $GITHUB_ENV + echo "REQUIRED_STATE=$required_state" >> "$GITHUB_ENV" - name: Request SciTech review, if needed if: env.SR_REQUEST_REQUIRED == 'true' && env.REQUIRED_STATE == 'SciTech Review' && env.SCITECH_REVIEWER != '' @@ -187,8 +207,10 @@ jobs: - name: Update project tracking env: GH_TOKEN: ${{ secrets.PROJECT_ACTION_PAT }} + PROJECT_ORG: ${{ inputs.project_org }} + PROJECT_NUMBER: ${{ inputs.project_number }} run: | - # Get project data + # Get project metadata project_data=$(gh api graphql -f query=' query($org: String!, $number: Int!) { organization(login: $org){ @@ -202,15 +224,24 @@ jobs: } } } - }' -f org=${{ inputs.project_org }} -F number=${{ inputs.project_number }}) + }' -f org="$PROJECT_ORG" -F number="$PROJECT_NUMBER") + + project_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.id // empty') + if [ -z "$project_id" ]; then + echo "::error::Could not resolve Project ID. Check PAT permissions or Project Number." + exit 1 + fi - project_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.id') status_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id') status_option_id=$(echo "$project_data" | jq -r --arg status "$REQUIRED_STATE" '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name==$status) | .id') scitech_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "SciTech Review") | .id') code_field_id=$(echo "$project_data" | jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Code Review") | .id') - # Add PR to project and update fields + # Map reviewers safely to prevent raw GraphQL empty text payload crashes + final_cr_val="${CODE_REVIEWER:-None}" + final_sr_val="${SCITECH_REVIEWER:-None}" + + # Securely add PR link element to board layout item_id=$(gh api graphql -f query=' mutation($project:ID!, $pr:ID!) { addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) { @@ -218,10 +249,23 @@ jobs: } }' -f project="$project_id" -f pr="$PR_ID" --jq '.data.addProjectV2ItemById.item.id') - # Update all fields in single mutation + if [[ -z "$item_id" ]]; then + echo "::warning::Could not resolve Project Board Item ID. PR may already be tracked." + exit 0 + fi + + # Commit all updates via single atomic multi-mutation to prevent partial update states on the board gh api graphql -f query=' mutation($project: ID!, $item: ID!, $status_field: ID!, $status_value: String!, $cr_field: ID!, $cr_value: String!, $sr_field: ID!, $sr_value: String!) { set_status: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $status_field, value: {singleSelectOptionId: $status_value}}) { projectV2Item { id } } set_code_review: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $cr_field, value: {text: $cr_value}}) { projectV2Item { id } } set_scitech_review: updateProjectV2ItemFieldValue(input: {projectId: $project, itemId: $item, fieldId: $sr_field, value: {text: $sr_value}}) { projectV2Item { id } } - }' -f project="$project_id" -f item="$item_id" -f status_field="$status_field_id" -f status_value="$status_option_id" -f cr_field="$code_field_id" -f cr_value="$CODE_REVIEWER" -f sr_field="$scitech_field_id" -f sr_value="$SCITECH_REVIEWER" --silent + }' \ + -f project="$project_id" \ + -f item="$item_id" \ + -f status_field="$status_field_id" \ + -f status_value="$status_option_id" \ + -f cr_field="$code_field_id" \ + -f cr_value="$final_cr_val" \ + -f sr_field="$scitech_field_id" \ + -f sr_value="$final_sr_val" --silent diff --git a/.github/workflows/trigger-project-workflow.yaml b/.github/workflows/trigger-project-workflow.yaml index 6fa7e72..9a5ae13 100644 --- a/.github/workflows/trigger-project-workflow.yaml +++ b/.github/workflows/trigger-project-workflow.yaml @@ -13,13 +13,16 @@ on: workflow_call: permissions: - contents: read - pull-requests: write + contents: read # Required to evaluate metadata from the triggering GitHub event context jobs: trigger_project: + name: Trigger Project runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read # Required to evaluate metadata from the triggering GitHub event context + pull-requests: write # Required to modify or comment on pull requests within this pipeline process env: PR_NUM: ${{ github.event.pull_request.number }} PR_ID: ${{ github.event.pull_request.node_id }} @@ -27,8 +30,8 @@ jobs: - name: Triggering Workflow run: | echo "{\"pr_num\": $PR_NUM, \"pr_id\": \"$PR_ID\"}" >> context.json - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: context.json + name: context path: context.json retention-days: 1 diff --git a/.github/workflows/umdp3_fixer.yaml b/.github/workflows/umdp3_fixer.yaml index 8e63e2e..85414e9 100644 --- a/.github/workflows/umdp3_fixer.yaml +++ b/.github/workflows/umdp3_fixer.yaml @@ -20,29 +20,36 @@ on: type: number default: 10 -permissions: read-all +permissions: + contents: read jobs: umdp3_fixer: + name: UMDP3 Fixer runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout }} steps: - name: Checkout Branch - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: path: pr_branch token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false + - name: Checkout SimSys_Scripts - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: MetOffice/SimSys_Scripts sparse-checkout: umdp3_fixer path: SimSys_Scripts + persist-credentials: false + - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: 3.14 + - name: Run UMDP3 Fixer working-directory: ./SimSys_Scripts/umdp3_fixer run: | diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index c1e170a..7ea8679 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -16,10 +16,10 @@ concurrency: group: ${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -permissions: read-all +permissions: + contents: read jobs: - validate: name: QC runs-on: ubuntu-24.04 @@ -30,16 +30,18 @@ jobs: UV_PYTHON: "3.14" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Setup uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: python-version: ${{ env.UV_PYTHON }} enable-cache: true - name: Restore uv cache - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.UV_CACHE_DIR }} key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} @@ -56,6 +58,7 @@ jobs: - name: Python Lint if: always() run: uv run ruff check --respect-gitignore . + - name: Python Format Check if: always() run: uv run ruff format --check --diff -s --respect-gitignore . @@ -64,19 +67,43 @@ jobs: if: always() shell: bash run: | + # Identify potential shell scripts by looking for files with typical + # shell script extensions (.sh, .bash, .etc) or no extension, while excluding + # files in version control directories and virtual environments. mapfile -d '' potential_files < <( find . -type f \( -name "*.*sh" -o ! -name "*.*" \) \ -not -path "*.git/*" -not -path "*.venv/*" -print0 ) if [ ${#potential_files[@]} -eq 0 ]; then - echo "No shell scripts to check." + echo "::notice::No files found with matching pattern to inspect." + exit 0 + fi + + # Extract shell scripts into an array, safely handling grep exit codes + mapfile -t shell_files < <(printf "%s\0" "${potential_files[@]}" \ + | xargs -0 file | grep "shell script" | cut -d: -f1 || true) + + if [ ${#shell_files[@]} -eq 0 ]; then + echo "::notice::No shell scripts detected." exit 0 fi - printf "%s\0" "${potential_files[@]}" \ - | xargs -0 file | grep "shell script" | cut -d: -f1 \ - | xargs -r uv run shellcheck -S warning \ - && echo "All checks passed!" + + # Run shellcheck on discovered files, treating warnings as errors + uv run shellcheck -S warning "${shell_files[@]}" && echo "✅ All checks passed!" continue-on-error: true + - name: Actionlint + if: always() + run: uv run actionlint -shellcheck "shellcheck -S warning" .github/workflows/*.yaml + + - name: Zizmor (GitHub Actions security) + if: always() + run: uv run zizmor .github/workflows/ + + - name: Markdown Lint + if: always() + run: | + uv run rumdl check . + - name: Minimize uv cache run: uv cache prune --ci diff --git a/.github/workflows/validate_symlinks.yaml b/.github/workflows/validate_symlinks.yaml index d668cef..c478a5b 100644 --- a/.github/workflows/validate_symlinks.yaml +++ b/.github/workflows/validate_symlinks.yaml @@ -20,18 +20,21 @@ on: type: number default: 5 -permissions: read-all +permissions: + contents: read jobs: validate_symlinks: + name: Validate Symlinks runs-on: ${{ inputs.runner }} timeout-minutes: ${{ inputs.timeout }} steps: - name: Checkout Branch - uses: actions/checkout@v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: Find broken symlinks run: | broken_links=$(find . -path '.git' -prune -o -xtype l -printf '%p -> %l\n' || true) diff --git a/.github/zizmor.yaml b/.github/zizmor.yaml new file mode 100644 index 0000000..750c124 --- /dev/null +++ b/.github/zizmor.yaml @@ -0,0 +1,23 @@ +# ------------------------------------------------------------------------------ +# (c) Crown copyright Met Office. All rights reserved. +# The file LICENCE, distributed with this code, contains details of the terms +# under which the code may be used. +# ------------------------------------------------------------------------------ + +# Rules for Zizmor GitHub workflow linter. This file is used to configure which +# rules to ignore for specific workflow files, allowing for exceptions to be +# made where necessary while still enforcing best practices across the codebase. +rules: + unpinned-uses: + ignore: + - "call-track-review-project.yaml" + - "call-trigger-project-workflow.yaml" + - "label-pr.yaml" + dangerous-triggers: + ignore: + - "call-track-review-project.yaml" + - "call-trigger-project-workflow.yaml" + secrets-inherit: + ignore: + - "call-track-review-project.yaml" + - "call-trigger-project-workflow.yaml" diff --git a/.yamllint b/.yamllint index d11dac5..e07b8bf 100644 --- a/.yamllint +++ b/.yamllint @@ -1,11 +1,14 @@ --- extends: default -ignore: | - .venv/ - .svn/ +ignore: + - ".venv/" + - ".svn/" rules: document-start: disable line-length: disable truthy: disable + comments: + min-spaces-from-content: 1 + comments-indentation: disable diff --git a/README.md b/README.md index 243bb9a..5ade82b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GRoWSS - GitHub Reusable Workflows for Simulation Systems :recycle: +# :recycle: GRoWSS - GitHub Reusable Workflows for Simulation Systems [![CI](https://github.com/MetOffice/growss/actions/workflows/validate.yaml/badge.svg?branch=main)](https://github.com/MetOffice/growss/actions/workflows/validate.yaml) @@ -15,13 +15,18 @@ checking your files before opening a pull request. For example, you can run ## Troubleshooting -This section intends to help users and developers quickly identify and resolve common issues which may be encountered while using the growss repository. If an issue which isn't listed here is found please notify us via discussions on the simulation systems [Q&A](https://github.com/MetOffice/simulation-systems/discussions/categories/q-a) channel. +This section intends to help users and developers quickly identify and resolve +common issues which may be encountered while using the growss repository. If an +issue which isn't listed here is found please notify us via discussions on the +simulation systems +[Q&A](https://github.com/MetOffice/simulation-systems/discussions/categories/q-a) +channel. ### YAML file being called from growss is not found -1. Navigate to the **Settings** of the repository initiating the growss workflow. +1. Navigate to the **Settings** of the repository initiating the growss + workflow. 2. Select **Actions** > **General** from the left-hand sidebar. 3. Scroll to the **Workflow permissions** section. -4. Select **Read and write permissions** to allow the workflow to access other repositories within the MetOffice organisation. - - +4. Select **Read and write permissions** to allow the workflow to access other + repositories within the MetOffice organisation. diff --git a/cla-check/README.md b/cla-check/README.md index 948bd30..19e400e 100644 --- a/cla-check/README.md +++ b/cla-check/README.md @@ -103,12 +103,14 @@ To sign the CLA for your first contribution: ## Permissions Required -This workflow requires the following permissions: +Permissions are managed internally at the job level. The reusable workflow sets +`permissions: {}` at the workflow level and grants only the minimum required +permissions directly to the job: ```yaml permissions: - contents: read # To checkout repository code - pull-requests: write # To add labels and post comments + contents: read # To checkout repository code + pull-requests: write # To add labels and post comments ``` ## Security Considerations @@ -122,6 +124,15 @@ permissions: - **Validates username format** to prevent malicious input - **Never checks out untrusted code** - only inspects CONTRIBUTORS.md file - **Scoped file checking** - only monitors the exact CONTRIBUTORS.md file +- **Template injection prevention** - all `github.*` and `inputs.*` context + values are passed via `env:` vars and read through `process.env` / shell + variables rather than being interpolated directly into scripts +- **API-based merge-ref detection** - uses `gh api` to query PR mergeability + instead of `git ls-remote`, avoiding unauthenticated git network calls from + forks +- **GitHub Contents API for CONTRIBUTORS.md comparison** - fetches and compares + file content via the GitHub API with base64 decoding and whitespace + normalisation, avoiding authenticated git fetches from private fork contexts ## Licence diff --git a/labeler/action.yaml b/labeler/action.yaml index 9d367e4..9d8dbe9 100644 --- a/labeler/action.yaml +++ b/labeler/action.yaml @@ -35,35 +35,43 @@ runs: using: composite steps: - name: Validate inputs - shell: python + shell: bash + env: + ACTION: ${{ inputs.action }} run: | - action_opts = ["remove", "add"] - action = "${{ inputs.action }}" - if action not in action_opts: - raise ValueError(f"Action has to be one of {action_opts}.") + if [[ "$ACTION" != "remove" && "$ACTION" != "add" ]]; then + echo "::error::Action has to be one of ['remove', 'add']." + exit 1 + fi - name: Remove label `${{ inputs.label }}` from issue ${{ inputs.issue }} if: ${{ inputs.action == 'remove' }} shell: bash run: | set -e - if gh issue --repo "${{ inputs.repository }}" view "${{ inputs.issue }}" > /dev/null 2>&1; then - gh issue --repo "${{ inputs.repository }}" edit "${{ inputs.issue }}" --remove-label "${{ inputs.label }}" + if gh issue --repo "$REPOSITORY" view "$ISSUE" > /dev/null 2>&1; then + gh issue --repo "$REPOSITORY" edit "$ISSUE" --remove-label "$LABEL" else - gh pr --repo "${{ inputs.repository }}" edit "${{ inputs.issue }}" --remove-label "${{ inputs.label }}" + gh pr --repo "$REPOSITORY" edit "$ISSUE" --remove-label "$LABEL" fi env: GH_TOKEN: ${{ inputs.token }} + REPOSITORY: ${{ inputs.repository }} + ISSUE: ${{ inputs.issue }} + LABEL: ${{ inputs.label }} - name: Add label `${{ inputs.label }}` to issue ${{ inputs.issue }} if: ${{ inputs.action == 'add' }} shell: bash run: | set -e - if gh issue --repo "${{ inputs.repository }}" view "${{ inputs.issue }}" > /dev/null 2>&1; then - gh issue --repo "${{ inputs.repository }}" edit "${{ inputs.issue }}" --add-label "${{ inputs.label }}" + if gh issue --repo "$REPOSITORY" view "$ISSUE" > /dev/null 2>&1; then + gh issue --repo "$REPOSITORY" edit "$ISSUE" --add-label "$LABEL" else - gh pr --repo "${{ inputs.repository }}" edit "${{ inputs.issue }}" --add-label "${{ inputs.label }}" + gh pr --repo "$REPOSITORY" edit "$ISSUE" --add-label "$LABEL" fi env: GH_TOKEN: ${{ inputs.token }} + REPOSITORY: ${{ inputs.repository }} + ISSUE: ${{ inputs.issue }} + LABEL: ${{ inputs.label }} diff --git a/pyproject.toml b/pyproject.toml index 640237b..e5ec9b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,21 @@ description = "GitHub Reusable Workflows for Simulation Systems" readme = "README.md" requires-python = ">=3.12.9" dependencies = [ + "actionlint-py==1.7.12.24", "ruff==0.14.4", "shellcheck-py==0.11.0.1", "yamllint==1.37.1", + "rumdl==0.2.10", + "zizmor==1.25.2", ] + +[tool.rumdl] +respect-gitignore = true +exclude = [ + ".github/pull_request_template.md", +] +[tool.rumdl.MD013] +line-length = 80 # Keeps normal paragraph text restricted to 80 chars +code-blocks = false # Disables the 80 char limit strictly for fenced code blocks +tables = false # Fixed: Disables the 80 char limit strictly for tables +headings = false diff --git a/track-review-project/README.md b/track-review-project/README.md index ab41186..f958e4b 100644 --- a/track-review-project/README.md +++ b/track-review-project/README.md @@ -1,7 +1,7 @@ # Track Review Project Action -This action modifies the Simulation Systems Review Tracker project entries in -pull requests. It will automatically fill the reviewer fields based on the +This action modifies the Simulation Systems Review Tracker project (376) entries +in pull requests. It will automatically fill the reviewer fields based on the entries in the pull request template. It will change the state based on reviews requested and performed. If a CR hasn't been requested, it will do so when moving to the CR state. It will also add the PR author as an assignee if none @@ -28,13 +28,21 @@ on: - completed workflow_dispatch: +permissions: + contents: read + jobs: track_review_project: + permissions: + actions: read + contents: read + pull-requests: write uses: MetOffice/growss/.github/workflows/track-review-project.yaml@main - secrets: inherit + secrets: + PROJECT_ACTION_PAT: ${{ secrets.PROJECT_ACTION_PAT }} # Optional inputs (with default values) with: - runner: "ubuntu-22.04" + runner: "ubuntu-24.04" project_org: "MetOffice" project_number: 376 @@ -49,8 +57,16 @@ on: pull_request_review: pull_request_review_comment: +permissions: + contents: read + jobs: trigger_project_workflow: - uses: MetOffice/growss/.github/workflows/trigger-project-workflow.yaml@main - secrets: inherit + # Only run on upstream repository + if: github.repository == 'MetOffice/' + permissions: + actions: read # Required to evaluate the event trigger metadata safely + contents: read # Required to check out code or parse files safely + pull-requests: write # Required for the downstream engine to add comments or update metadata on the PR + uses: MetOffice/growss/.github/workflows/trigger-project-workflow.yaml@main ```