From a833bee1dfd7acf16a4ce0b01bba4b67fb876980 Mon Sep 17 00:00:00 2001 From: Florian Ruppel Date: Thu, 11 Jun 2026 08:38:59 +0200 Subject: [PATCH 1/5] ci: build auto updater for pinned actions --- .github/dependabot.yml | 8 +- .github/workflows/update-actions.yml | 20 +++ update-pinned-actions/README.md | 26 +++ update-pinned-actions/action.yml | 46 ++++++ update-pinned-actions/update-actions.js | 204 ++++++++++++++++++++++++ 5 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/update-actions.yml create mode 100644 update-pinned-actions/README.md create mode 100644 update-pinned-actions/action.yml create mode 100644 update-pinned-actions/update-actions.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1230149..f8b4c63 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,4 @@ version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" +updates: [] +# GitHub Actions updates are handled by .github/workflows/update-actions.yml +# which pins each action to the release's commit SHA instead of a version tag. diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml new file mode 100644 index 0000000..89e92aa --- /dev/null +++ b/.github/workflows/update-actions.yml @@ -0,0 +1,20 @@ +name: Update pinned GitHub Action versions + +on: + schedule: + - cron: '0 7 * * 1,3' # Every Monday and Wednesday at 07:00 UTC + workflow_dispatch: + +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: shopware/github-actions/update-pinned-actions@main + with: + token: ${{ github.token }} diff --git a/update-pinned-actions/README.md b/update-pinned-actions/README.md new file mode 100644 index 0000000..1c8d9bb --- /dev/null +++ b/update-pinned-actions/README.md @@ -0,0 +1,26 @@ +# update-pinned-actions + +Checks all GitHub Actions in the repository for updates and opens a PR with each action pinned to the latest release's commit SHA. Shopware-owned and local (`./`) actions are skipped. + +## Usage + +```yaml +jobs: + update: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: shopware/github-actions/update-pinned-actions@main + with: + token: ${{ github.token }} +``` + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `token` | yes | — | GitHub token with `contents: write` and `pull-requests: write` | +| `base-branch` | no | `main` | Base branch for the pull request | diff --git a/update-pinned-actions/action.yml b/update-pinned-actions/action.yml new file mode 100644 index 0000000..5a78133 --- /dev/null +++ b/update-pinned-actions/action.yml @@ -0,0 +1,46 @@ +name: Update pinned GitHub Action versions +description: Check all GitHub Actions in the repository for updates and open a PR with each action pinned to the latest release's commit SHA. + +inputs: + token: + description: GitHub token with contents:write and pull-requests:write permissions + required: true + base-branch: + description: Base branch for the pull request + default: main + +runs: + using: composite + steps: + - name: Check for updates + id: check + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: node "${{ github.action_path }}/update-actions.js" + + - name: Create or update pull request + if: steps.check.outputs.changed == 'true' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: | + BRANCH="chore/update-pinned-actions" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add -A + git commit -m "chore: update pinned GitHub Action versions" + git push origin "$BRANCH" --force + + EXISTING=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [[ -z "$EXISTING" ]]; then + gh pr create \ + --title "chore: update pinned GitHub Action versions" \ + --body "$(cat "${{ steps.check.outputs.pr_body_path }}")" \ + --head "$BRANCH" \ + --base "${{ inputs.base-branch }}" + else + echo "PR #$EXISTING already open — branch updated." + fi diff --git a/update-pinned-actions/update-actions.js b/update-pinned-actions/update-actions.js new file mode 100644 index 0000000..fe93d5f --- /dev/null +++ b/update-pinned-actions/update-actions.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +function ghApi(endpoint) { + try { + const output = execSync(`gh api "${endpoint}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + return JSON.parse(output); + } catch { + return null; + } +} + +function latestRelease(owner, repo) { + const data = ghApi(`repos/${owner}/${repo}/releases/latest`); + if (!data) return null; + return { tag: data.tag_name, body: data.body ?? '', url: data.html_url }; +} + +function tagCommitSha(owner, repo, tag) { + const data = ghApi(`repos/${owner}/${repo}/git/ref/tags/${tag}`); + if (!data) return null; + + const { type, sha } = data.object; + if (type === 'tag') { + // Annotated tag — dereference to the underlying commit + const tagData = ghApi(`repos/${owner}/${repo}/git/tags/${sha}`); + return tagData?.object?.sha ?? null; + } + return sha; +} + +function findYamlFiles(root = '.') { + const files = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) { + files.push(full); + } + } + } + walk(root); + return files.sort(); +} + +// Also captures the optional version comment: uses: owner/repo@ # +const ACTION_RE = /uses:\s+([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-zA-Z0-9._/-]+)(?:\s*#\s*(\S+))?/g; + +function collectActions(yamlFiles) { + const actions = {}; + for (const file of yamlFiles) { + const content = fs.readFileSync(file, 'utf8'); + for (const [, action, ref, commentTag] of content.matchAll(ACTION_RE)) { + const owner = action.split('/')[0]; + if (owner === 'shopware' || owner.startsWith('.')) continue; + if (!actions[action]) { + actions[action] = { ref, currentTag: commentTag ?? null }; + } + } + } + return actions; +} + +function applyUpdates(yamlFiles, updates) { + for (const file of yamlFiles) { + let content = fs.readFileSync(file, 'utf8'); + let modified = false; + for (const [action, { sha, tag }] of Object.entries(updates)) { + const escaped = action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp( + `(uses:\\s+${escaped}@)[a-zA-Z0-9._/-]+( *#[^\\n]*)?`, + 'g', + ); + const updated = content.replace(re, `$1${sha} # ${tag}`); + if (updated !== content) { + content = updated; + modified = true; + } + } + if (modified) { + fs.writeFileSync(file, content); + } + } +} + +function majorVersion(tag) { + const m = tag?.match(/^v?(\d+)/); + return m ? parseInt(m[1], 10) : null; +} + +function detectBreaking(currentTag, newTag, releaseBody) { + const reasons = []; + const oldMajor = majorVersion(currentTag); + const newMajor = majorVersion(newTag); + if (oldMajor !== null && newMajor !== null && newMajor > oldMajor) { + reasons.push(`major version bump (${currentTag ?? '?'} → ${newTag})`); + } + if (/breaking.change|BREAKING.CHANGE|\bbreaking:/i.test(releaseBody)) { + reasons.push('breaking changes mentioned in release notes'); + } + return reasons; +} + +function truncate(text, max = 400) { + const trimmed = text.trim(); + return trimmed.length <= max ? trimmed : trimmed.slice(0, max).trimEnd() + '…'; +} + +function writeOutput(key, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + fs.appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +function main() { + const yamlFiles = findYamlFiles(); + const actions = collectActions(yamlFiles); + + const updates = {}; + const rows = []; + const breakingNotes = []; + + for (const action of Object.keys(actions).sort()) { + const { ref: currentRef, currentTag } = actions[action]; + const [owner, repo] = action.split('/', 2); + + process.stdout.write(`Checking ${action} ...\n`); + + const release = latestRelease(owner, repo); + if (!release) { + console.log(' No releases found, skipping'); + continue; + } + + const sha = tagCommitSha(owner, repo, release.tag); + if (!sha) { + console.log(` Could not resolve commit SHA for ${release.tag}, skipping`); + continue; + } + + if (currentRef === sha) { + console.log(` Up to date (${release.tag})`); + continue; + } + + const breaking = detectBreaking(currentTag, release.tag, release.body); + const flag = breaking.length > 0 ? ' ⚠️' : ''; + console.log(` ${currentRef} -> ${sha} (${release.tag})${flag}`); + + updates[action] = { sha, tag: release.tag }; + rows.push( + `| \`${action}\` | \`${currentRef.slice(0, 12)}\`${currentTag ? ` (${currentTag})` : ''} | \`${sha.slice(0, 12)}\` (${release.tag})${flag} |`, + ); + + if (breaking.length > 0) { + breakingNotes.push({ action, tag: release.tag, url: release.url, body: release.body, reasons: breaking }); + } + } + + if (Object.keys(updates).length === 0) { + console.log('All actions are up to date.'); + writeOutput('changed', 'false'); + return; + } + + applyUpdates(yamlFiles, updates); + writeOutput('changed', 'true'); + + const sections = [ + 'Automated update of pinned GitHub Action versions to their latest releases.\n', + '| Action | From | To |', + '|--------|------|----|', + ...rows, + ]; + + if (breakingNotes.length > 0) { + sections.push('\n---\n\n## ⚠️ Potential breaking changes\n'); + for (const { action, tag, url, body, reasons } of breakingNotes) { + sections.push(`### \`${action}\` — [${tag}](${url})`); + sections.push(reasons.map(r => `- ${r}`).join('\n')); + if (body.trim()) { + sections.push('\n**Release notes:**\n```\n' + truncate(body) + '\n```'); + } + sections.push(''); + } + } + + const prBodyPath = path.join(process.env.RUNNER_TEMP ?? os.tmpdir(), 'pr_body.md'); + fs.writeFileSync(prBodyPath, sections.join('\n')); + writeOutput('pr_body_path', prBodyPath); + + console.log(`\nUpdated ${Object.keys(updates).length} action(s), ${breakingNotes.length} with potential breaking changes.`); +} + +main(); From f44a0d446b155d94b0b4ad06b28036da99bd999e Mon Sep 17 00:00:00 2001 From: Florian Ruppel Date: Thu, 11 Jun 2026 08:45:03 +0200 Subject: [PATCH 2/5] ci: add gate to prevent unpinned actions --- .github/workflows/check-pinned-actions.yml | 16 ++++++++ check-pinned-actions/action.yml | 9 +++++ check-pinned-actions/check-pinned-actions.js | 42 ++++++++++++++++++++ lib/action-utils.js | 31 +++++++++++++++ update-pinned-actions/update-actions.js | 24 +---------- 5 files changed, 100 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/check-pinned-actions.yml create mode 100644 check-pinned-actions/action.yml create mode 100644 check-pinned-actions/check-pinned-actions.js create mode 100644 lib/action-utils.js 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/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..5fb4726 --- /dev/null +++ b/check-pinned-actions/check-pinned-actions.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const { ACTION_RE, findYamlFiles, isThirdParty } = require('../lib/action-utils'); + +const SHA_RE = /^[0-9a-f]{40}$/; + +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(); diff --git a/lib/action-utils.js b/lib/action-utils.js new file mode 100644 index 0000000..87204e8 --- /dev/null +++ b/lib/action-utils.js @@ -0,0 +1,31 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Matches: uses: owner/repo@ref (with optional trailing # tag comment) +const ACTION_RE = /uses:\s+([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-zA-Z0-9._/-]+)(?:\s*#\s*(\S+))?/g; + +function findYamlFiles(root = '.') { + const files = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '.git') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) { + files.push(full); + } + } + } + walk(root); + return files.sort(); +} + +function isThirdParty(action) { + const owner = action.split('/')[0]; + return owner !== 'shopware' && !owner.startsWith('.'); +} + +module.exports = { ACTION_RE, findYamlFiles, isThirdParty }; diff --git a/update-pinned-actions/update-actions.js b/update-pinned-actions/update-actions.js index fe93d5f..94669bc 100644 --- a/update-pinned-actions/update-actions.js +++ b/update-pinned-actions/update-actions.js @@ -5,6 +5,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execSync } = require('child_process'); +const { ACTION_RE, findYamlFiles, isThirdParty } = require('../lib/action-utils'); function ghApi(endpoint) { try { @@ -34,33 +35,12 @@ function tagCommitSha(owner, repo, tag) { return sha; } -function findYamlFiles(root = '.') { - const files = []; - function walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name === '.git') continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) { - files.push(full); - } - } - } - walk(root); - return files.sort(); -} - -// Also captures the optional version comment: uses: owner/repo@ # -const ACTION_RE = /uses:\s+([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-zA-Z0-9._/-]+)(?:\s*#\s*(\S+))?/g; - function collectActions(yamlFiles) { const actions = {}; for (const file of yamlFiles) { const content = fs.readFileSync(file, 'utf8'); for (const [, action, ref, commentTag] of content.matchAll(ACTION_RE)) { - const owner = action.split('/')[0]; - if (owner === 'shopware' || owner.startsWith('.')) continue; + if (!isThirdParty(action)) continue; if (!actions[action]) { actions[action] = { ref, currentTag: commentTag ?? null }; } From df359f3ce7e42aba88d7c1ed2168da5234a1826e Mon Sep 17 00:00:00 2001 From: Florian Ruppel Date: Thu, 11 Jun 2026 08:47:29 +0200 Subject: [PATCH 3/5] chore: update docs --- README.md | 7 +++++++ check-pinned-actions/README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 check-pinned-actions/README.md diff --git a/README.md b/README.md index e0a4d5d..35b0d84 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,13 @@ 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) | +| [update-pinned-actions](update-pinned-actions/) | Checks all actions for updates and opens a PR with each pinned to the latest release's commit SHA | [README](update-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 +``` From 97d8ce5fbff2579df58a1fa3652e050beca66831 Mon Sep 17 00:00:00 2001 From: Florian Ruppel Date: Thu, 11 Jun 2026 08:58:27 +0200 Subject: [PATCH 4/5] ci: add default reviewers to created pr --- .github/workflows/update-actions.yml | 3 ++- update-pinned-actions/README.md | 5 +++-- update-pinned-actions/action.yml | 21 +++++++++++++++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml index 89e92aa..4d466c5 100644 --- a/.github/workflows/update-actions.yml +++ b/.github/workflows/update-actions.yml @@ -17,4 +17,5 @@ jobs: - uses: shopware/github-actions/update-pinned-actions@main with: - token: ${{ github.token }} + github-token: ${{ github.token }} + reviewers: shopware/product-operations diff --git a/update-pinned-actions/README.md b/update-pinned-actions/README.md index 1c8d9bb..99b3c38 100644 --- a/update-pinned-actions/README.md +++ b/update-pinned-actions/README.md @@ -15,12 +15,13 @@ jobs: - uses: actions/checkout@v4 - uses: shopware/github-actions/update-pinned-actions@main with: - token: ${{ github.token }} + github-token: ${{ github.token }} ``` ## Inputs | Input | Required | Default | Description | |-------|----------|---------|-------------| -| `token` | yes | — | GitHub token with `contents: write` and `pull-requests: write` | +| `github-token` | yes | — | GitHub token with `contents: write` and `pull-requests: write` | | `base-branch` | no | `main` | Base branch for the pull request | +| `reviewers` | no | — | Comma-separated reviewers to request (users or `org/team-slug`, e.g. `octocat,shopware/product-operations`) | diff --git a/update-pinned-actions/action.yml b/update-pinned-actions/action.yml index 5a78133..ab1edf3 100644 --- a/update-pinned-actions/action.yml +++ b/update-pinned-actions/action.yml @@ -2,12 +2,15 @@ name: Update pinned GitHub Action versions description: Check all GitHub Actions in the repository for updates and open a PR with each action pinned to the latest release's commit SHA. inputs: - token: + github-token: description: GitHub token with contents:write and pull-requests:write permissions required: true base-branch: description: Base branch for the pull request default: main + reviewers: + description: Comma-separated list of reviewers to request (users or org/team-slug, e.g. "octocat,shopware/product-operations") + required: false runs: using: composite @@ -16,14 +19,14 @@ runs: id: check shell: bash env: - GH_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.github-token }} run: node "${{ github.action_path }}/update-actions.js" - name: Create or update pull request if: steps.check.outputs.changed == 'true' shell: bash env: - GH_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.github-token }} run: | BRANCH="chore/update-pinned-actions" @@ -36,11 +39,21 @@ runs: EXISTING=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') if [[ -z "$EXISTING" ]]; then + REVIEWERS="${{ inputs.reviewers }}" + REVIEWER_FLAGS="" + if [[ -n "$REVIEWERS" ]]; then + IFS=',' read -ra LIST <<< "$REVIEWERS" + for R in "${LIST[@]}"; do + REVIEWER_FLAGS="$REVIEWER_FLAGS --reviewer ${R// /}" + done + fi + gh pr create \ --title "chore: update pinned GitHub Action versions" \ --body "$(cat "${{ steps.check.outputs.pr_body_path }}")" \ --head "$BRANCH" \ - --base "${{ inputs.base-branch }}" + --base "${{ inputs.base-branch }}" \ + $REVIEWER_FLAGS else echo "PR #$EXISTING already open — branch updated." fi From 4db70d2bff46acd8e755e2f24942702fa0722161 Mon Sep 17 00:00:00 2001 From: Florian Ruppel Date: Fri, 12 Jun 2026 15:50:02 +0200 Subject: [PATCH 5/5] chore: revert dependabot clone --- .github/dependabot.yml | 8 +- .github/workflows/update-actions.yml | 21 --- README.md | 1 - check-pinned-actions/check-pinned-actions.js | 21 ++- lib/action-utils.js | 31 ---- update-pinned-actions/README.md | 27 --- update-pinned-actions/action.yml | 59 ------ update-pinned-actions/update-actions.js | 184 ------------------- 8 files changed, 25 insertions(+), 327 deletions(-) delete mode 100644 .github/workflows/update-actions.yml delete mode 100644 lib/action-utils.js delete mode 100644 update-pinned-actions/README.md delete mode 100644 update-pinned-actions/action.yml delete mode 100644 update-pinned-actions/update-actions.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f8b4c63..1230149 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,6 @@ version: 2 -updates: [] -# GitHub Actions updates are handled by .github/workflows/update-actions.yml -# which pins each action to the release's commit SHA instead of a version tag. +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml deleted file mode 100644 index 4d466c5..0000000 --- a/.github/workflows/update-actions.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Update pinned GitHub Action versions - -on: - schedule: - - cron: '0 7 * * 1,3' # Every Monday and Wednesday at 07:00 UTC - workflow_dispatch: - -jobs: - update: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - uses: shopware/github-actions/update-pinned-actions@main - with: - github-token: ${{ github.token }} - reviewers: shopware/product-operations diff --git a/README.md b/README.md index 35b0d84..816c406 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ A collection of reusable GitHub Actions and Workflows for Shopware extensions an | 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) | -| [update-pinned-actions](update-pinned-actions/) | Checks all actions for updates and opens a PR with each pinned to the latest release's commit SHA | [README](update-pinned-actions/README.md) | ### Deployment diff --git a/check-pinned-actions/check-pinned-actions.js b/check-pinned-actions/check-pinned-actions.js index 5fb4726..37c1427 100644 --- a/check-pinned-actions/check-pinned-actions.js +++ b/check-pinned-actions/check-pinned-actions.js @@ -2,10 +2,29 @@ 'use strict'; const fs = require('fs'); -const { ACTION_RE, findYamlFiles, isThirdParty } = require('../lib/action-utils'); +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 = []; diff --git a/lib/action-utils.js b/lib/action-utils.js deleted file mode 100644 index 87204e8..0000000 --- a/lib/action-utils.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -// Matches: uses: owner/repo@ref (with optional trailing # tag comment) -const ACTION_RE = /uses:\s+([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)@([a-zA-Z0-9._/-]+)(?:\s*#\s*(\S+))?/g; - -function findYamlFiles(root = '.') { - const files = []; - function walk(dir) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name === '.git') continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) { - files.push(full); - } - } - } - walk(root); - return files.sort(); -} - -function isThirdParty(action) { - const owner = action.split('/')[0]; - return owner !== 'shopware' && !owner.startsWith('.'); -} - -module.exports = { ACTION_RE, findYamlFiles, isThirdParty }; diff --git a/update-pinned-actions/README.md b/update-pinned-actions/README.md deleted file mode 100644 index 99b3c38..0000000 --- a/update-pinned-actions/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# update-pinned-actions - -Checks all GitHub Actions in the repository for updates and opens a PR with each action pinned to the latest release's commit SHA. Shopware-owned and local (`./`) actions are skipped. - -## Usage - -```yaml -jobs: - update: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - uses: shopware/github-actions/update-pinned-actions@main - with: - github-token: ${{ github.token }} -``` - -## Inputs - -| Input | Required | Default | Description | -|-------|----------|---------|-------------| -| `github-token` | yes | — | GitHub token with `contents: write` and `pull-requests: write` | -| `base-branch` | no | `main` | Base branch for the pull request | -| `reviewers` | no | — | Comma-separated reviewers to request (users or `org/team-slug`, e.g. `octocat,shopware/product-operations`) | diff --git a/update-pinned-actions/action.yml b/update-pinned-actions/action.yml deleted file mode 100644 index ab1edf3..0000000 --- a/update-pinned-actions/action.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Update pinned GitHub Action versions -description: Check all GitHub Actions in the repository for updates and open a PR with each action pinned to the latest release's commit SHA. - -inputs: - github-token: - description: GitHub token with contents:write and pull-requests:write permissions - required: true - base-branch: - description: Base branch for the pull request - default: main - reviewers: - description: Comma-separated list of reviewers to request (users or org/team-slug, e.g. "octocat,shopware/product-operations") - required: false - -runs: - using: composite - steps: - - name: Check for updates - id: check - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: node "${{ github.action_path }}/update-actions.js" - - - name: Create or update pull request - if: steps.check.outputs.changed == 'true' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: | - BRANCH="chore/update-pinned-actions" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -B "$BRANCH" - git add -A - git commit -m "chore: update pinned GitHub Action versions" - git push origin "$BRANCH" --force - - EXISTING=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') - if [[ -z "$EXISTING" ]]; then - REVIEWERS="${{ inputs.reviewers }}" - REVIEWER_FLAGS="" - if [[ -n "$REVIEWERS" ]]; then - IFS=',' read -ra LIST <<< "$REVIEWERS" - for R in "${LIST[@]}"; do - REVIEWER_FLAGS="$REVIEWER_FLAGS --reviewer ${R// /}" - done - fi - - gh pr create \ - --title "chore: update pinned GitHub Action versions" \ - --body "$(cat "${{ steps.check.outputs.pr_body_path }}")" \ - --head "$BRANCH" \ - --base "${{ inputs.base-branch }}" \ - $REVIEWER_FLAGS - else - echo "PR #$EXISTING already open — branch updated." - fi diff --git a/update-pinned-actions/update-actions.js b/update-pinned-actions/update-actions.js deleted file mode 100644 index 94669bc..0000000 --- a/update-pinned-actions/update-actions.js +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execSync } = require('child_process'); -const { ACTION_RE, findYamlFiles, isThirdParty } = require('../lib/action-utils'); - -function ghApi(endpoint) { - try { - const output = execSync(`gh api "${endpoint}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); - return JSON.parse(output); - } catch { - return null; - } -} - -function latestRelease(owner, repo) { - const data = ghApi(`repos/${owner}/${repo}/releases/latest`); - if (!data) return null; - return { tag: data.tag_name, body: data.body ?? '', url: data.html_url }; -} - -function tagCommitSha(owner, repo, tag) { - const data = ghApi(`repos/${owner}/${repo}/git/ref/tags/${tag}`); - if (!data) return null; - - const { type, sha } = data.object; - if (type === 'tag') { - // Annotated tag — dereference to the underlying commit - const tagData = ghApi(`repos/${owner}/${repo}/git/tags/${sha}`); - return tagData?.object?.sha ?? null; - } - return sha; -} - -function collectActions(yamlFiles) { - const actions = {}; - for (const file of yamlFiles) { - const content = fs.readFileSync(file, 'utf8'); - for (const [, action, ref, commentTag] of content.matchAll(ACTION_RE)) { - if (!isThirdParty(action)) continue; - if (!actions[action]) { - actions[action] = { ref, currentTag: commentTag ?? null }; - } - } - } - return actions; -} - -function applyUpdates(yamlFiles, updates) { - for (const file of yamlFiles) { - let content = fs.readFileSync(file, 'utf8'); - let modified = false; - for (const [action, { sha, tag }] of Object.entries(updates)) { - const escaped = action.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp( - `(uses:\\s+${escaped}@)[a-zA-Z0-9._/-]+( *#[^\\n]*)?`, - 'g', - ); - const updated = content.replace(re, `$1${sha} # ${tag}`); - if (updated !== content) { - content = updated; - modified = true; - } - } - if (modified) { - fs.writeFileSync(file, content); - } - } -} - -function majorVersion(tag) { - const m = tag?.match(/^v?(\d+)/); - return m ? parseInt(m[1], 10) : null; -} - -function detectBreaking(currentTag, newTag, releaseBody) { - const reasons = []; - const oldMajor = majorVersion(currentTag); - const newMajor = majorVersion(newTag); - if (oldMajor !== null && newMajor !== null && newMajor > oldMajor) { - reasons.push(`major version bump (${currentTag ?? '?'} → ${newTag})`); - } - if (/breaking.change|BREAKING.CHANGE|\bbreaking:/i.test(releaseBody)) { - reasons.push('breaking changes mentioned in release notes'); - } - return reasons; -} - -function truncate(text, max = 400) { - const trimmed = text.trim(); - return trimmed.length <= max ? trimmed : trimmed.slice(0, max).trimEnd() + '…'; -} - -function writeOutput(key, value) { - const outputFile = process.env.GITHUB_OUTPUT; - if (outputFile) { - fs.appendFileSync(outputFile, `${key}=${value}\n`); - } -} - -function main() { - const yamlFiles = findYamlFiles(); - const actions = collectActions(yamlFiles); - - const updates = {}; - const rows = []; - const breakingNotes = []; - - for (const action of Object.keys(actions).sort()) { - const { ref: currentRef, currentTag } = actions[action]; - const [owner, repo] = action.split('/', 2); - - process.stdout.write(`Checking ${action} ...\n`); - - const release = latestRelease(owner, repo); - if (!release) { - console.log(' No releases found, skipping'); - continue; - } - - const sha = tagCommitSha(owner, repo, release.tag); - if (!sha) { - console.log(` Could not resolve commit SHA for ${release.tag}, skipping`); - continue; - } - - if (currentRef === sha) { - console.log(` Up to date (${release.tag})`); - continue; - } - - const breaking = detectBreaking(currentTag, release.tag, release.body); - const flag = breaking.length > 0 ? ' ⚠️' : ''; - console.log(` ${currentRef} -> ${sha} (${release.tag})${flag}`); - - updates[action] = { sha, tag: release.tag }; - rows.push( - `| \`${action}\` | \`${currentRef.slice(0, 12)}\`${currentTag ? ` (${currentTag})` : ''} | \`${sha.slice(0, 12)}\` (${release.tag})${flag} |`, - ); - - if (breaking.length > 0) { - breakingNotes.push({ action, tag: release.tag, url: release.url, body: release.body, reasons: breaking }); - } - } - - if (Object.keys(updates).length === 0) { - console.log('All actions are up to date.'); - writeOutput('changed', 'false'); - return; - } - - applyUpdates(yamlFiles, updates); - writeOutput('changed', 'true'); - - const sections = [ - 'Automated update of pinned GitHub Action versions to their latest releases.\n', - '| Action | From | To |', - '|--------|------|----|', - ...rows, - ]; - - if (breakingNotes.length > 0) { - sections.push('\n---\n\n## ⚠️ Potential breaking changes\n'); - for (const { action, tag, url, body, reasons } of breakingNotes) { - sections.push(`### \`${action}\` — [${tag}](${url})`); - sections.push(reasons.map(r => `- ${r}`).join('\n')); - if (body.trim()) { - sections.push('\n**Release notes:**\n```\n' + truncate(body) + '\n```'); - } - sections.push(''); - } - } - - const prBodyPath = path.join(process.env.RUNNER_TEMP ?? os.tmpdir(), 'pr_body.md'); - fs.writeFileSync(prBodyPath, sections.join('\n')); - writeOutput('pr_body_path', prBodyPath); - - console.log(`\nUpdated ${Object.keys(updates).length} action(s), ${breakingNotes.length} with potential breaking changes.`); -} - -main();