Harden workflow against template injection in run: blocks #184
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Accessibility Checker Plugin with Ref Param | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| ref_param: | |
| description: "Ref param string to set in EDAC_REF_PARAM (optional)" | |
| required: false | |
| type: string | |
| pull_request: | |
| types: [labeled] | |
| release: | |
| types: [created, published] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: read # wordpress-playground-action verifies the artifact exists before linking | |
| jobs: | |
| build-plugin: | |
| name: Build plugin zip | |
| runs-on: ubuntu-latest | |
| # Run on release, manual, or PR when a specific label is added | |
| if: | | |
| github.event_name == 'release' || | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'gha-build') || contains(github.event.pull_request.labels.*.name, 'gha-build-all'))) | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine mode and ref param for this run | |
| id: setref | |
| run: | | |
| MODE="${{ github.event_name }}" | |
| echo "mode=$MODE" >> $GITHUB_OUTPUT | |
| # Manual run: take input if provided; else empty | |
| if [ "$MODE" = "workflow_dispatch" ]; then | |
| REF_INPUT="${{ github.event.inputs.ref_param }}" | |
| echo "ref_param=${REF_INPUT}" >> $GITHUB_OUTPUT | |
| # For manual mode: if ref is provided, skip primary build | |
| if [ -n "$REF_INPUT" ]; then | |
| echo "skip_primary=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "skip_primary=false" >> $GITHUB_OUTPUT | |
| fi | |
| # Release: use fixed ref 'woocommerce' | |
| elif [ "$MODE" = "release" ]; then | |
| echo "ref_param=woocommerce" >> $GITHUB_OUTPUT | |
| echo "skip_primary=false" >> $GITHUB_OUTPUT | |
| # PR labeled: check which label | |
| else | |
| # Check if gha-build-all label is present (build both) | |
| if echo "${{ github.event.pull_request.labels.*.name }}" | grep -q "gha-build-all"; then | |
| echo "ref_param=woocommerce" >> $GITHUB_OUTPUT | |
| echo "skip_primary=false" >> $GITHUB_OUTPUT | |
| else | |
| # gha-build label (build primary only) | |
| echo "ref_param=" >> $GITHUB_OUTPUT | |
| echo "skip_primary=false" >> $GITHUB_OUTPUT | |
| fi | |
| fi | |
| - name: Show selected mode/ref | |
| env: | |
| # head_ref is a branch name chosen by whoever opened the PR, and git | |
| # permits characters like " and ; in it - so spliced into the script | |
| # text it is executable. This is the actionlint/zizmor | |
| # template-injection warning this workflow has carried for a while. | |
| MODE: ${{ steps.setref.outputs.mode }} | |
| HEAD_REF: ${{ github.head_ref }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| REF_PARAM: ${{ steps.setref.outputs.ref_param }} | |
| run: | | |
| echo "Triggered on: $GITHUB_EVENT_NAME" | |
| echo "Mode: ${MODE}" | |
| echo "Head ref: ${HEAD_REF}" | |
| echo "PR number: ${PR_NUMBER}" | |
| echo "Release tag: ${RELEASE_TAG}" | |
| echo "Ref param: ${REF_PARAM}" | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| cache: 'npm' | |
| cache-dependency-path: package-lock.json | |
| - name: Cache node_modules | |
| id: cache-node-modules | |
| uses: actions/cache@v4 | |
| with: | |
| path: node_modules | |
| key: ${{ runner.os }}-node_modules-${{ hashFiles('**/package-lock.json') }} | |
| restore-keys: | | |
| ${{ runner.os }}-node_modules- | |
| - name: Set up PHP | |
| uses: shivammathur/setup-php@v2 | |
| with: | |
| php-version: '8.2' | |
| tools: composer | |
| coverage: none | |
| - name: Cache Composer dependencies | |
| uses: actions/cache@v4 | |
| with: | |
| path: vendor | |
| key: ${{ runner.os }}-composer-vendor-${{ hashFiles('**/composer.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-composer-vendor- | |
| - name: Install Composer dependencies | |
| run: | | |
| if [ -f composer.json ]; then composer install --no-dev --prefer-dist --no-progress --no-interaction; else echo "No composer.json"; fi | |
| - name: Install npm dependencies | |
| if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('package.json') != '' | |
| run: npm ci --prefer-offline --no-audit | |
| - name: Extract plugin version and commit hash | |
| id: version | |
| env: | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| VERSION=$(grep "Version:" accessibility-checker.php | head -1 | sed 's/.*Version:[[:space:]]*\([^ ]*\).*/\1/') | |
| # On a pull_request, actions/checkout builds the ephemeral | |
| # refs/pull/N/merge commit, so `git rev-parse HEAD` is a SHA that does | |
| # not appear in the PR's commit list and means nothing to a reviewer. | |
| # Use head.sha there so the artifact filename, the build comment and | |
| # the PR's own commit list all agree on one SHA. | |
| if [ -n "${HEAD_SHA}" ]; then | |
| SHORT_SHA="${HEAD_SHA:0:8}" | |
| else | |
| SHORT_SHA=$(git rev-parse --short=8 HEAD) | |
| fi | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT | |
| echo "built_at=$(date -u +'%Y-%m-%d %H:%M UTC')" >> $GITHUB_OUTPUT | |
| echo "Plugin version: $VERSION" | |
| echo "Short commit hash: $SHORT_SHA" | |
| - name: Verify EDAC_REF_PARAM is empty before first build | |
| if: steps.setref.outputs.skip_primary != 'true' | |
| run: | | |
| grep -n "define( 'EDAC_REF_PARAM', '' )" accessibility-checker.php || { echo "EDAC_REF_PARAM should be empty for first build"; exit 1; } | |
| - name: Build primary dist zip (empty ref) | |
| if: steps.setref.outputs.skip_primary != 'true' | |
| env: | |
| # VERSION comes from the plugin's own `Version:` header, which is part | |
| # of the PR diff and so attacker-controlled on a fork PR. A ${{ }} | |
| # splice lands in the script text before bash parses it, making a | |
| # crafted version string executable; via env: it stays inert data. | |
| VERSION: ${{ steps.version.outputs.version }} | |
| SHORT_SHA: ${{ steps.version.outputs.short_sha }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| echo "Building primary zip with empty ref..." | |
| npm run dist | |
| ZIP_PATH=$(ls -1 dist/*.zip build/*.zip 2>/dev/null | head -n 1 || true) | |
| if [ -z "$ZIP_PATH" ]; then | |
| ZIP_PATH=$(ls -1 *.zip 2>/dev/null | head -n 1 || true) | |
| fi | |
| if [ -z "$ZIP_PATH" ]; then | |
| echo "Error: Could not locate produced zip after npm run dist" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p builds | |
| # Construct the new filename based on trigger mode | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| # PR: accessibility-checker-{version}-{prnumber}-{hash}.zip | |
| PRIMARY_ZIP_NAME="accessibility-checker-${VERSION}-${PR_NUMBER}-${SHORT_SHA}.zip" | |
| elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| # Manual: accessibility-checker-{version}-{hash}.zip | |
| PRIMARY_ZIP_NAME="accessibility-checker-${VERSION}-${SHORT_SHA}.zip" | |
| elif [ "${{ github.event_name }}" = "release" ]; then | |
| # Release: accessibility-checker-{version}.zip | |
| PRIMARY_ZIP_NAME="accessibility-checker-${VERSION}.zip" | |
| else | |
| # Fallback | |
| PRIMARY_ZIP_NAME="accessibility-checker-${VERSION}-${SHORT_SHA}.zip" | |
| fi | |
| mv "$ZIP_PATH" "builds/$PRIMARY_ZIP_NAME" | |
| PRIMARY_ZIP_BASENAME="${PRIMARY_ZIP_NAME%.zip}" | |
| unzip -q "builds/$PRIMARY_ZIP_NAME" -d "builds/$PRIMARY_ZIP_BASENAME" | |
| PRIMARY_ZIP_FOLDER="builds/$PRIMARY_ZIP_BASENAME" | |
| PRIMARY_ZIP_NAME_NO_EXT="${PRIMARY_ZIP_NAME%.zip}" | |
| echo "PRIMARY_ZIP_PATH=builds/$PRIMARY_ZIP_NAME" >> $GITHUB_ENV | |
| echo "PRIMARY_ZIP_NAME=$PRIMARY_ZIP_NAME" >> $GITHUB_ENV | |
| echo "PRIMARY_ZIP_NAME_NO_EXT=$PRIMARY_ZIP_NAME_NO_EXT" >> $GITHUB_ENV | |
| echo "PRIMARY_ZIP_FOLDER=$PRIMARY_ZIP_FOLDER" >> $GITHUB_ENV | |
| echo "Produced primary zip: builds/$PRIMARY_ZIP_NAME" | |
| echo "Produced primary folder: $PRIMARY_ZIP_FOLDER" | |
| - name: Upload primary build artifact | |
| if: steps.setref.outputs.skip_primary != 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ env.PRIMARY_ZIP_NAME_NO_EXT }} | |
| path: ${{ env.PRIMARY_ZIP_FOLDER }} | |
| if-no-files-found: error | |
| - name: Check if ref build is needed | |
| id: check_ref | |
| run: | | |
| MODE="${{ steps.setref.outputs.mode }}" | |
| REF_VALUE="${{ steps.setref.outputs.ref_param }}" | |
| # For manual: build ref if ref is provided | |
| # For release: always build ref (woocommerce) | |
| # For PR: never build ref | |
| if [ "$MODE" = "workflow_dispatch" ] && [ -n "$REF_VALUE" ]; then | |
| echo "need_ref_build=true" >> $GITHUB_OUTPUT | |
| echo "Ref build needed with value: $REF_VALUE" | |
| elif [ "$MODE" = "release" ] && [ -n "$REF_VALUE" ]; then | |
| echo "need_ref_build=true" >> $GITHUB_OUTPUT | |
| echo "Ref build needed with value: $REF_VALUE" | |
| else | |
| echo "need_ref_build=false" >> $GITHUB_OUTPUT | |
| echo "No ref build needed (mode=$MODE, ref='$REF_VALUE')" | |
| fi | |
| - name: Update EDAC_REF_PARAM for second build | |
| if: steps.check_ref.outputs.need_ref_build == 'true' | |
| run: | | |
| chmod +x ./scripts/update-ref-param.sh | |
| ./scripts/update-ref-param.sh "${{ steps.setref.outputs.ref_param }}" | |
| - name: Verify EDAC_REF_PARAM change | |
| if: steps.check_ref.outputs.need_ref_build == 'true' | |
| run: | | |
| grep -n "EDAC_REF_PARAM" accessibility-checker.php || { echo "EDAC_REF_PARAM not found"; exit 1; } | |
| echo "Updated EDAC_REF_PARAM contents:" | |
| grep "EDAC_REF_PARAM" accessibility-checker.php | |
| - name: Build ref dist zip (custom ref) | |
| if: steps.check_ref.outputs.need_ref_build == 'true' | |
| env: | |
| VERSION: ${{ steps.version.outputs.version }} | |
| SHORT_SHA: ${{ steps.version.outputs.short_sha }} | |
| REF_VALUE: ${{ steps.setref.outputs.ref_param }} | |
| run: | | |
| echo "Building ref zip with custom ref value..." | |
| npm run dist | |
| ZIP_PATH=$(ls -1 dist/*.zip build/*.zip 2>/dev/null | head -n 1 || true) | |
| if [ -z "$ZIP_PATH" ]; then | |
| ZIP_PATH=$(ls -1 *.zip 2>/dev/null | head -n 1 || true) | |
| fi | |
| if [ -z "$ZIP_PATH" ]; then | |
| echo "Error: Could not locate produced zip after npm run dist" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p builds | |
| # Ref build naming: accessibility-checker-{version}-ref-{refvalue}-{hash}.zip | |
| REF_ZIP_NAME="accessibility-checker-${VERSION}-ref-${REF_VALUE}-${SHORT_SHA}.zip" | |
| mv "$ZIP_PATH" "builds/$REF_ZIP_NAME" | |
| REF_ZIP_BASENAME="${REF_ZIP_NAME%.zip}" | |
| unzip -q "builds/$REF_ZIP_NAME" -d "builds/$REF_ZIP_BASENAME" | |
| REF_ZIP_FOLDER="builds/$REF_ZIP_BASENAME" | |
| REF_ZIP_NAME_NO_EXT="${REF_ZIP_NAME%.zip}" | |
| echo "REF_ZIP_PATH=builds/$REF_ZIP_NAME" >> $GITHUB_ENV | |
| echo "REF_ZIP_NAME=$REF_ZIP_NAME" >> $GITHUB_ENV | |
| echo "REF_ZIP_NAME_NO_EXT=$REF_ZIP_NAME_NO_EXT" >> $GITHUB_ENV | |
| echo "REF_ZIP_FOLDER=$REF_ZIP_FOLDER" >> $GITHUB_ENV | |
| echo "Produced ref zip: builds/$REF_ZIP_NAME" | |
| echo "Produced ref folder: $REF_ZIP_FOLDER" | |
| - name: Upload ref build artifact | |
| if: steps.check_ref.outputs.need_ref_build == 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ env.REF_ZIP_NAME_NO_EXT }} | |
| path: ${{ env.REF_ZIP_FOLDER }} | |
| if-no-files-found: error | |
| # Skipped on release: nightly.link serves Actions artifacts, not release assets. | |
| # plugin-slug is pinned so the activation path never depends on slug inference. | |
| - name: Playground link | |
| id: playground | |
| if: steps.setref.outputs.skip_primary != 'true' && github.event_name != 'release' | |
| uses: pattonwebz/wordpress-playground-action@v0 | |
| with: | |
| artifact-name: ${{ env.PRIMARY_ZIP_NAME_NO_EXT }} | |
| plugin-slug: accessibility-checker | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| post-comment: ${{ github.event_name == 'pull_request' }} | |
| pr-number: ${{ github.event.pull_request.number }} | |
| comment-template: | | |
| ✅ Accessibility Checker build | |
| - **Artifact**: [Download {artifact_name}.zip]({artifact_url}) | |
| - **Workflow run**: [View logs]({run_url}) | |
| - **Playground**: [Open with plugin preinstalled]({playground_url}) | |
| <sub>Built from [`${{ steps.version.outputs.short_sha }}`](${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}) · ${{ steps.version.outputs.built_at }}</sub> | |
| - name: Upload to release (both zips) | |
| if: github.event_name == 'release' | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| files: | | |
| ${{ env.PRIMARY_ZIP_PATH }} | |
| ${{ steps.check_ref.outputs.need_ref_build == 'true' && env.REF_ZIP_PATH || '' }} | |
| fail_on_unmatched_files: true | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Remove triggering label from PR | |
| # always(): a failed build must still drop the label. Re-adding a label | |
| # that is already present emits no `labeled` event, so leaving it on | |
| # would make the trigger dead until someone removed it by hand. | |
| if: always() && github.event_name == 'pull_request' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const prNumber = context.payload.pull_request.number; | |
| const labels = context.payload.pull_request.labels.map(l => l.name); | |
| const labelToRemove = labels.includes('gha-build-all') ? 'gha-build-all' : 'gha-build'; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| name: labelToRemove, | |
| }); | |
| console.log(`Removed label '${labelToRemove}' from PR #${prNumber}`); | |
| } catch (e) { | |
| console.log(`Could not remove label '${labelToRemove}' from PR #${prNumber}: ${e.message}`); | |
| } | |
| - name: Summary | |
| env: | |
| PRIMARY_PLAYGROUND_URL: ${{ steps.playground.outputs.playground-url }} | |
| MODE: ${{ steps.setref.outputs.mode }} | |
| SKIP_PRIMARY: ${{ steps.setref.outputs.skip_primary }} | |
| PRIMARY_ZIP_PATH: ${{ env.PRIMARY_ZIP_PATH }} | |
| REF_ZIP_PATH: ${{ env.REF_ZIP_PATH }} | |
| REF_PARAM: ${{ steps.setref.outputs.ref_param }} | |
| run: | | |
| echo "=== Build Summary ===" | |
| echo "Mode: ${{ steps.setref.outputs.mode }}" | |
| echo "Primary zip (empty ref): ${PRIMARY_ZIP_PATH}" | |
| if [ -n "${PRIMARY_PLAYGROUND_URL:-}" ]; then | |
| echo "Playground (primary): ${PRIMARY_PLAYGROUND_URL}" | |
| elif [ "${MODE}" = "release" ]; then | |
| echo "Playground (primary): not built (releases ship the zip as a release asset)" | |
| elif [ "${SKIP_PRIMARY}" = "true" ]; then | |
| echo "Playground (primary): not built (this dispatch passed a ref param, so only the ref zip was built)" | |
| else | |
| echo "Playground (primary): not built for this run" | |
| fi | |
| if [ "${{ steps.check_ref.outputs.need_ref_build }}" = "true" ]; then | |
| echo "Ref zip (ref=${REF_PARAM}): ${REF_ZIP_PATH}" | |
| else | |
| echo "Ref zip: Not built" | |
| fi | |
| if [ "${{ github.event_name }}" = "release" ]; then | |
| echo "Uploaded to release: ${{ github.event.release.html_url }}" | |
| fi |