Add helm-lint, shellcheck, and tag-check actions

These are the remaining verification jobs on our main list, outside
of sanity tests.
Since none of these have any overlap in their implementation (that is,
no two jobs use the exact same combination of actions), no new shared
workflows are being added.

Signed-off-by: Eric Ball <eball@linuxfoundation.org>
Change-Id: I912f0d5a8c1baed84657bccfadbf9c7caecbcdc2
diff --git a/.github/actions/helm-lint-action/README.md b/.github/actions/helm-lint-action/README.md
new file mode 100644
index 0000000..3714283
--- /dev/null
+++ b/.github/actions/helm-lint-action/README.md
@@ -0,0 +1,77 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# Helm Lint Action
+
+A GitHub Action that lints and tests Helm charts using the chart-testing tool.
+
+## Description
+
+This composite action validates Helm charts by linting their structure and installing them on a kind cluster. It only processes charts that have changed compared to the target branch.
+
+## Usage
+
+```yaml
+- name: Lint and Test Charts
+  uses: ./.github/actions/helm-lint-action
+  with:
+    target-branch: main
+```
+
+### Inputs
+
+| Input | Description | Required |
+|-------|-------------|----------|
+| `target-branch` | Target branch to compare against for detecting changed charts | Yes |
+
+### Example Workflow
+
+```yaml
+name: Helm Chart CI
+
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+
+jobs:
+  lint-test:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Lint and test Helm charts
+        uses: ./.github/actions/helm-lint-action
+        with:
+          target-branch: main
+```
+
+## How It Works
+
+The action performs the following steps:
+
+1. Checks out the repository with full git history
+2. Sets up Helm and Python (required for chart-testing)
+3. Installs the chart-testing CLI tool
+4. Detects charts that changed compared to the target branch
+5. Lints the changed charts
+6. Creates a kind Kubernetes cluster
+7. Installs and tests the changed charts on the cluster
+
+## Requirements
+
+- Repository must contain valid Helm charts
+- Charts should follow Helm best practices for linting to pass
+
+## Configuration
+
+For advanced configuration, add a `ct.yaml` file to your repository root. See the [chart-testing documentation](https://github.com/helm/chart-testing) for available options.
+
+## Dependencies
+
+This action uses the following tools:
+
+- [helm/chart-testing-action](https://github.com/helm/chart-testing-action) - Chart testing CLI
+- [azure/setup-helm](https://github.com/azure/setup-helm) - Helm installation
+- [helm/kind-action](https://github.com/helm/kind-action) - Kind cluster creation
diff --git a/.github/actions/helm-lint-action/action.yaml b/.github/actions/helm-lint-action/action.yaml
new file mode 100644
index 0000000..cdf0cc3
--- /dev/null
+++ b/.github/actions/helm-lint-action/action.yaml
@@ -0,0 +1,53 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+
+name: Lint and Test Charts
+description: "Lint and test Helm charts"
+
+inputs:
+  target-branch:
+    description: "Target branch to compare against for detecting changed charts"
+    required: true
+
+runs:
+  using: "composite"
+  steps:
+    - name: Checkout
+      uses: actions/checkout@v5
+      with:
+        fetch-depth: 0
+
+    - name: Set up Helm
+      uses: azure/setup-helm@v4.3.1
+
+    - uses: actions/setup-python@v6.0.0
+      with:
+        python-version: "3.x"
+        check-latest: true
+
+    - name: Set up chart-testing
+      uses: helm/chart-testing-action@v2.8.0
+
+    - name: Run chart-testing (list-changed)
+      id: list-changed
+      shell: bash
+      run: |
+        changed=$(ct list-changed --target-branch ${{ inputs.target-branch }})
+        if [[ -n "$changed" ]]; then
+          echo "changed=true" >> "$GITHUB_OUTPUT"
+        fi
+
+    - name: Run chart-testing (lint)
+      if: steps.list-changed.outputs.changed == 'true'
+      shell: bash
+      run: ct lint --target-branch ${{ inputs.target-branch }}
+
+    - name: Create kind cluster
+      if: steps.list-changed.outputs.changed == 'true'
+      uses: helm/kind-action@v1.12.0
+
+    - name: Run chart-testing (install)
+      if: steps.list-changed.outputs.changed == 'true'
+      shell: bash
+      run: ct install --target-branch ${{ inputs.target-branch }}
diff --git a/.github/actions/shellcheck-action/README.md b/.github/actions/shellcheck-action/README.md
new file mode 100644
index 0000000..fa5b0a8
--- /dev/null
+++ b/.github/actions/shellcheck-action/README.md
@@ -0,0 +1,75 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# ShellCheck Action
+
+A GitHub Action that lints shell scripts using ShellCheck.
+
+## Description
+
+This composite action finds all shell scripts (`.sh` and `.bash` files) in the specified path and validates them using ShellCheck. It reports any issues and fails if linting errors are found.
+
+## Usage
+
+```yaml
+- name: Lint Shell Scripts
+  uses: ./.github/actions/shellcheck-action
+```
+
+### Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `path` | Path to search for shell scripts | No | `.` |
+| `severity` | Minimum severity level (error, warning, info, style) | No | `style` |
+| `exclude` | Comma-separated list of ShellCheck codes to exclude | No | `""` |
+
+### Example Workflow
+
+```yaml
+name: Shell Script Lint
+
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+
+jobs:
+  shellcheck:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Lint shell scripts
+        uses: ./.github/actions/shellcheck-action
+        with:
+          path: scripts
+          severity: warning
+          exclude: SC1090,SC2034
+```
+
+## How It Works
+
+The action performs the following steps:
+
+1. Installs ShellCheck if not already present
+2. Finds all `.sh` and `.bash` files in the specified path
+3. Runs ShellCheck on each file with the configured severity level
+4. Reports pass/fail status for each file
+5. Fails the action if any script has linting errors
+
+## Severity Levels
+
+- `error` - Only show errors
+- `warning` - Show errors and warnings
+- `info` - Show errors, warnings, and informational messages
+- `style` - Show all issues including style suggestions (default)
+
+## Requirements
+
+- Ubuntu runner (ShellCheck is installed via apt-get)
+- Repository checkout step should precede this action
diff --git a/.github/actions/shellcheck-action/action.yaml b/.github/actions/shellcheck-action/action.yaml
new file mode 100644
index 0000000..e3004c0
--- /dev/null
+++ b/.github/actions/shellcheck-action/action.yaml
@@ -0,0 +1,85 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+
+name: ShellCheck Lint
+description: "Lint shell scripts with ShellCheck"
+
+inputs:
+  path:
+    description: "Path to search for shell scripts"
+    required: false
+    default: "."
+  severity:
+    description: "Minimum severity level (error, warning, info, style)"
+    required: false
+    default: "style"
+  exclude:
+    description: "Comma-separated list of ShellCheck codes to exclude (e.g., SC1090,SC2034)"
+    required: false
+    default: ""
+
+runs:
+  using: "composite"
+  steps:
+    - name: Install ShellCheck
+      shell: bash
+      run: |
+        if ! command -v shellcheck &> /dev/null; then
+          echo "Installing ShellCheck..."
+          sudo apt-get update
+          sudo apt-get install -y shellcheck
+        fi
+        echo "ShellCheck version:"
+        shellcheck --version
+
+    - name: Run ShellCheck
+      shell: bash
+      run: |
+        set +e
+        fail_shellcheck=0
+        files_checked=0
+
+        echo "=> Linting shell scripts with ShellCheck"
+        echo "   Path: ${{ inputs.path }}"
+        echo "   Severity: ${{ inputs.severity }}"
+
+        # Build exclude argument if provided
+        exclude_arg=""
+        if [[ -n "${{ inputs.exclude }}" ]]; then
+          exclude_arg="--exclude=${{ inputs.exclude }}"
+          echo "   Excluding: ${{ inputs.exclude }}"
+        fi
+
+        # Find and check all shell scripts
+        while IFS= read -r -d '' script; do
+          echo ""
+          echo "==> CHECKING: ${script}"
+          files_checked=$((files_checked + 1))
+
+          if [[ -n "${exclude_arg}" ]]; then
+            shellcheck --severity="${{ inputs.severity }}" "${exclude_arg}" "${script}"
+          else
+            shellcheck --severity="${{ inputs.severity }}" "${script}"
+          fi
+
+          rc=$?
+          if [[ $rc -ne 0 ]]; then
+            echo "==> FAILED: ${script}"
+            fail_shellcheck=1
+          else
+            echo "==> PASSED: ${script}"
+          fi
+        done < <(find "${{ inputs.path }}" \( -name "*.sh" -o -name "*.bash" \) -type f -print0)
+
+        echo ""
+        echo "=> Summary: ${files_checked} file(s) checked"
+
+        if [[ ${files_checked} -eq 0 ]]; then
+          echo "=> No shell scripts found"
+        elif [[ ${fail_shellcheck} -eq 0 ]]; then
+          echo "=> All checks passed"
+        else
+          echo "=> Some checks failed"
+          exit 1
+        fi
diff --git a/.github/actions/tag-check-action/README.md b/.github/actions/tag-check-action/README.md
new file mode 100644
index 0000000..446ed29
--- /dev/null
+++ b/.github/actions/tag-check-action/README.md
@@ -0,0 +1,88 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+SPDX-FileCopyrightText: 2026 The Linux Foundation
+-->
+
+# Tag Check Action
+
+A GitHub Action that validates version tags and Dockerfile parent images.
+
+## Description
+
+This composite action validates that repository versions follow SemVer conventions, checks for duplicate git tags, and ensures Dockerfile parent images specify proper versions.
+
+## Usage
+
+```yaml
+- name: Validate Tags
+  uses: ./.github/actions/tag-check-action
+```
+
+### Inputs
+
+| Input | Description | Required | Default |
+|-------|-------------|----------|---------|
+| `semver-strict` | Require strict SemVer versions | No | `false` |
+| `dockerparent-strict` | Require versioned parent images in Dockerfiles | No | `true` |
+| `path` | Path to the repository to check | No | `.` |
+
+### Example Workflow
+
+```yaml
+name: Version Validation
+
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+
+jobs:
+  tag-check:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Validate version tags
+        uses: ./.github/actions/tag-check-action
+        with:
+          semver-strict: true
+```
+
+## How It Works
+
+The action performs the following steps:
+
+1. Reads version from `VERSION`, `package.json`, or `pom.xml`
+2. Validates version sequence (parent version must exist)
+3. Checks if version is a valid SemVer release
+4. For release versions:
+   - Verifies the tag doesn't already exist
+   - Validates Dockerfile parent images have proper versions
+
+## Version File Detection
+
+The action looks for versions in this order:
+
+1. `VERSION` file (first line)
+2. `package.json` (version field)
+3. `pom.xml` (project version)
+
+For Go projects (`go.mod` present), tags are prefixed with `v`.
+
+## Dockerfile Validation
+
+Parent images are validated to ensure they use:
+
+- SemVer versions (e.g., `1.2.3`)
+- SHA256 hashes (e.g., `@sha256:abc123...`)
+- Major.Minor versions (e.g., `16.04`, `3.8-alpine`)
+- Special cases: `scratch`, `gcr.io/distroless/static:nonroot`
+
+## Requirements
+
+- Repository checkout with full history (`fetch-depth: 0`)
+- Ubuntu runner (xmllint is installed via apt-get if needed)
diff --git a/.github/actions/tag-check-action/action.yaml b/.github/actions/tag-check-action/action.yaml
new file mode 100644
index 0000000..86e2b36
--- /dev/null
+++ b/.github/actions/tag-check-action/action.yaml
@@ -0,0 +1,256 @@
+---
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: 2026 The Linux Foundation
+
+name: Tag Check
+description: "Validate version tags and Dockerfile parent images"
+
+inputs:
+  semver-strict:
+    description: "Require strict SemVer versions (fail if version is not a release)"
+    required: false
+    default: "false"
+  dockerparent-strict:
+    description: "Require versioned parent images in Dockerfiles"
+    required: false
+    default: "true"
+  path:
+    description: "Path to the repository to check"
+    required: false
+    default: "."
+
+runs:
+  using: "composite"
+  steps:
+    - name: Install dependencies
+      shell: bash
+      run: |
+        if ! command -v xmllint &> /dev/null; then
+          echo "Installing libxml2-utils for xmllint..."
+          sudo apt-get update
+          sudo apt-get install -y libxml2-utils
+        fi
+
+    - name: Run tag validation
+      shell: bash
+      env:
+        SEMVER_STRICT: ${{ inputs.semver-strict == 'true' && '1' || '0' }}
+        DOCKERPARENT_STRICT: ${{ inputs.dockerparent-strict == 'true' && '1' || '0' }}
+      run: |
+        set -eu -o pipefail
+
+        VERSIONFILE=""
+        NEW_VERSION=""
+        TAG_VERSION=""
+        releaseversion=0
+        fail_validation=0
+
+        cd "${{ inputs.path }}"
+
+        # Find the version string in the repo
+        function read_version {
+          if [ -f "VERSION" ]; then
+            NEW_VERSION=$(head -n1 "VERSION")
+            VERSIONFILE="VERSION"
+
+            # If this is a golang project, use v-prefixed versions
+            if [ -f "Gopkg.toml" ] || [ -f "go.mod" ]; then
+              echo "Go-based project found, using v-prefixed version for git tags: v${NEW_VERSION}"
+              TAG_VERSION="v${NEW_VERSION}"
+            else
+              TAG_VERSION="${NEW_VERSION}"
+            fi
+
+          elif [ -f "package.json" ]; then
+            NEW_VERSION=$(python3 -c 'import json,sys;obj=json.load(sys.stdin); print(obj["version"])' < package.json)
+            TAG_VERSION="$NEW_VERSION"
+            VERSIONFILE="package.json"
+
+          elif [ -f "pom.xml" ]; then
+            NEW_VERSION=$(xmllint --xpath '/*[local-name()="project"]/*[local-name()="version"]/text()' pom.xml)
+            TAG_VERSION="$NEW_VERSION"
+            VERSIONFILE="pom.xml"
+
+          else
+            echo "ERROR: No versioning file found (VERSION, package.json, or pom.xml)"
+            fail_validation=1
+            return
+          fi
+
+          echo "Found version '$NEW_VERSION' in '$VERSIONFILE'"
+        }
+
+        # Check if the version is a released SemVer version
+        function check_if_releaseversion {
+          if [[ "$NEW_VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
+            echo "Version '$NEW_VERSION' is a SemVer release version"
+            releaseversion=1
+          else
+            if [ "$SEMVER_STRICT" -eq "1" ]; then
+              echo "ERROR: Version '$NEW_VERSION' is not a SemVer release version (SEMVER_STRICT enabled)"
+              fail_validation=1
+            else
+              echo "Version '$NEW_VERSION' is not a SemVer release version, skipping release checks"
+            fi
+          fi
+        }
+
+        # Check if the version tag already exists
+        function is_git_tag_duplicated {
+          for existing_tag in $existing_tags; do
+            if [ "$TAG_VERSION" = "$existing_tag" ]; then
+              echo "ERROR: Duplicate tag found: $existing_tag"
+              fail_validation=2
+            fi
+          done
+        }
+
+        # Parse semver into components
+        function semverParseInto() {
+          local RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z-]*\)'
+          eval $2=\`echo $1 \| sed -e "s#$RE#\1#"\`
+          eval $3=\`echo $1 \| sed -e "s#$RE#\2#"\`
+          eval $4=\`echo $1 \| sed -e "s#$RE#\3#"\`
+          eval $5=\`echo $1 \| sed -e "s#$RE#\4#"\`
+        }
+
+        # Validate version sequence (check parent version exists)
+        function is_valid_version {
+          local MAJOR=0 MINOR=0 PATCH=0 SPECIAL=""
+          local C_MAJOR=0 C_MINOR=0 C_PATCH=0 C_SPECIAL=""
+
+          semverParseInto "$NEW_VERSION" MAJOR MINOR PATCH SPECIAL
+
+          found_parent=false
+          parent_version=""
+
+          # If minor == 0, check for MAJOR-1.x.x release
+          if [[ "$MINOR" == 0 ]]; then
+            new_major=$((MAJOR - 1))
+            parent_version="$new_major.x.x"
+            for existing_tag in $existing_tags; do
+              semverParseInto "$existing_tag" C_MAJOR C_MINOR C_PATCH C_SPECIAL
+              if [[ "$new_major" == "$C_MAJOR" ]]; then
+                found_parent=true
+              fi
+            done
+          fi
+
+          # If patch == 0, check for MAJOR.MINOR-1.x release
+          if [[ "$PATCH" == 0 ]]; then
+            new_minor=$((MINOR - 1))
+            parent_version="$MAJOR.$new_minor.x"
+            for existing_tag in $existing_tags; do
+              semverParseInto "$existing_tag" C_MAJOR C_MINOR C_PATCH C_SPECIAL
+              if [[ "$new_minor" == "$C_MINOR" ]]; then
+                found_parent=true
+              fi
+            done
+          fi
+
+          # If patch != 0, check for MAJOR.MINOR.PATCH-1 release
+          if [[ "$PATCH" != 0 ]]; then
+            new_patch=$((PATCH - 1))
+            parent_version="$MAJOR.$MINOR.$new_patch"
+            for existing_tag in $existing_tags; do
+              semverParseInto "$existing_tag" C_MAJOR C_MINOR C_PATCH C_SPECIAL
+              if [[ "$MAJOR" == "$C_MAJOR" && "$MINOR" == "$C_MINOR" && "$new_patch" == "$C_PATCH" ]]; then
+                found_parent=true
+              fi
+            done
+          fi
+
+          # Initial version (0.x.x) has no parent requirement
+          if [[ "$MAJOR" == 0 ]]; then
+            found_parent=true
+          fi
+
+          if [[ $found_parent == false ]]; then
+            echo "ERROR: Invalid version $NEW_VERSION - expected parent version $parent_version does not exist"
+            fail_validation=1
+          fi
+        }
+
+        # Check Dockerfile parent images have proper versions
+        function dockerfile_parentcheck {
+          if [ "$DOCKERPARENT_STRICT" -eq "0" ]; then
+            echo "DOCKERPARENT_STRICT disabled - skipping Dockerfile parent checks"
+            return
+          fi
+
+          while IFS= read -r -d '' dockerfile; do
+            echo ""
+            echo "Checking Dockerfile: '$dockerfile'"
+
+            IFS=$'\n'
+            df_parents=($(grep "^FROM" "$dockerfile" || true))
+
+            for df_parent in "${df_parents[@]}"; do
+              df_pattern="[FfRrOoMm] +(--platform=[^ ]+ +)?([^@: ]+)(:([^: ]+)|@sha[^ ]+)?"
+              if [[ "$df_parent" =~ $df_pattern ]]; then
+                p_image="${BASH_REMATCH[2]}"
+                p_sha="${BASH_REMATCH[3]}"
+                p_version="${BASH_REMATCH[4]}"
+
+                if [[ "${p_image}" == "scratch" ]]; then
+                  echo "  OK: Using versionless 'scratch' parent"
+                elif [[ "${p_image}:${p_version}" == "gcr.io/distroless/static:nonroot" ]]; then
+                  echo "  OK: Using static distroless image: '${p_image}:${p_version}'"
+                elif [[ "${p_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
+                  echo "  OK: Parent '$p_image:$p_version' uses SemVer version"
+                elif [[ "${p_sha}" =~ ^@sha256:[0-9a-f]{64}.*$ ]]; then
+                  echo "  OK: Parent '$p_image' uses sha256 hash"
+                elif [[ "${p_version}" =~ ^.*([0-9]+)\.([0-9]+).*$ ]]; then
+                  echo "  OK: Parent '$p_image:$p_version' uses acceptable version format"
+                elif [[ -z "${p_version}" ]]; then
+                  echo "  ERROR: Parent '$p_image' has no version specified"
+                  fail_validation=1
+                else
+                  echo "  ERROR: Parent '$p_image:$p_version' does not use a proper version"
+                  fail_validation=1
+                fi
+              else
+                echo "  ERROR: Could not parse parent image in: $df_parent"
+              fi
+            done
+          done < <(find "." -name 'Dockerfile*' ! -path "*/vendor/*" ! -name "*dockerignore" -print0)
+        }
+
+        # Main execution
+        echo "=> Tag Check Validation"
+        echo ""
+        echo "Git remotes:"
+        git remote -v || echo "  (no remotes)"
+        echo ""
+
+        echo "Existing git tags:"
+        existing_tags=$(git tag -l)
+        if [[ -n "$existing_tags" ]]; then
+          echo "$existing_tags"
+        else
+          echo "  (no tags)"
+          existing_tags=""
+        fi
+        echo ""
+
+        read_version
+
+        if [[ -n "$NEW_VERSION" ]]; then
+          is_valid_version
+          check_if_releaseversion
+
+          if [ "$releaseversion" -eq "1" ]; then
+            echo ""
+            echo "=> Running release version checks..."
+            is_git_tag_duplicated
+            dockerfile_parentcheck
+          fi
+        fi
+
+        echo ""
+        if [ "$fail_validation" -eq "0" ]; then
+          echo "=> All checks passed"
+        else
+          echo "=> Validation failed (exit code: $fail_validation)"
+          exit $fail_validation
+        fi