diff --git a/.github/workflows/check-pinned-actions.yml b/.github/workflows/check-pinned-actions.yml new file mode 100644 index 0000000..6356f14 --- /dev/null +++ b/.github/workflows/check-pinned-actions.yml @@ -0,0 +1,16 @@ +name: Check pinned GitHub Action versions + +on: + pull_request: + paths: + - '**/*.yml' + - '**/*.yaml' + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: shopware/github-actions/check-pinned-actions@main diff --git a/README.md b/README.md index e0a4d5d..816c406 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,12 @@ A collection of reusable GitHub Actions and Workflows for Shopware extensions an | [downstream](downstream/) | Triggers a downstream workflow and waits for it to finish | [README](downstream/README.md) | | [upstream-connect](upstream-connect/) | Connects to upstream from downstream run | [README](upstream-connect/README.md) | +### Action Pinning + +| Action | Description | Link | +|--------|-------------|------| +| [check-pinned-actions](check-pinned-actions/) | Fails if any non-Shopware action is not pinned to a commit SHA — use as a PR gate | [README](check-pinned-actions/README.md) | + ### Deployment | Action | Description | Link | diff --git a/check-pinned-actions/README.md b/check-pinned-actions/README.md new file mode 100644 index 0000000..cc14461 --- /dev/null +++ b/check-pinned-actions/README.md @@ -0,0 +1,30 @@ +# check-pinned-actions + +Fails if any non-Shopware GitHub Action in the repository is not pinned to a full commit SHA. Shopware-owned and local (`./`) actions are skipped. + +Intended as a PR gate to prevent unpinned actions from being merged. + +## Usage + +```yaml +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: shopware/github-actions/check-pinned-actions@main +``` + +## No inputs + +This action takes no inputs. It exits with a non-zero status and prints each violation if unpinned actions are found, e.g.: + +``` +Found 2 unpinned action(s): + + .github/workflows/ci.yml:12 actions/checkout@v4 + .github/workflows/ci.yml:18 codecov/codecov-action@v3 + +Pin each action to a full commit SHA, e.g.: + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 +``` diff --git a/check-pinned-actions/action.yml b/check-pinned-actions/action.yml new file mode 100644 index 0000000..58c20c2 --- /dev/null +++ b/check-pinned-actions/action.yml @@ -0,0 +1,9 @@ +name: Check pinned GitHub Action versions +description: Fails if any non-Shopware GitHub Action is not pinned to a full commit SHA. + +runs: + using: composite + steps: + - name: Check for unpinned actions + shell: bash + run: node "${{ github.action_path }}/check-pinned-actions.js" diff --git a/check-pinned-actions/check-pinned-actions.js b/check-pinned-actions/check-pinned-actions.js new file mode 100644 index 0000000..37c1427 --- /dev/null +++ b/check-pinned-actions/check-pinned-actions.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const ACTION_RE = /uses:\s+([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-zA-Z0-9._/-]+)(?:\s*#\s*(\S+))?/g; +const SHA_RE = /^[0-9a-f]{40}$/; + +function findYamlFiles(root = '.') { + const results = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const full = path.join(root, entry.name); + if (entry.isDirectory()) { + results.push(...findYamlFiles(full)); + } else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) { + results.push(full); + } + } + return results; +} + +function isThirdParty(action) { + const owner = action.split('/')[0]; + return owner !== 'shopware' && !owner.startsWith('.'); +} + +function main() { + const yamlFiles = findYamlFiles(); + const violations = []; + + for (const file of yamlFiles) { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + for (const [, action, ref] of lines[i].matchAll(ACTION_RE)) { + if (!isThirdParty(action)) continue; + + if (!SHA_RE.test(ref)) { + violations.push({ file, line: i + 1, action, ref }); + } + } + } + } + + if (violations.length === 0) { + console.log('All third-party actions are pinned to a commit SHA.'); + return; + } + + console.error(`\nFound ${violations.length} unpinned action(s):\n`); + for (const { file, line, action, ref } of violations) { + console.error(` ${file}:${line} ${action}@${ref}`); + } + console.error('\nPin each action to a full commit SHA, e.g.:'); + console.error(' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n'); + process.exit(1); +} + +main();