diff --git a/.github/workflows/e2e-request.yml b/.github/workflows/e2e-request.yml deleted file mode 100644 index d33bc5a..0000000 --- a/.github/workflows/e2e-request.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: GitLab E2E request - -# The privileged workflow verifies this file's Git blob before it trusts this -# run-name format. Keep all request metadata in GitHub's run record, not artifacts. -run-name: "PR #${{ github.event.pull_request.number }}: ${{ github.event.action }} ${{ github.event.label.name }} at ${{ github.event.pull_request.head.sha }}" - -on: - pull_request: - branches: [main] - # New commits need a fresh review and label event. - types: [labeled] - -permissions: {} - -jobs: - request: - if: github.event.label.name == 'run-e2e' - runs-on: ubuntu-latest - timeout-minutes: 1 - steps: - - run: echo 'GitLab E2E requested. The workflow on main will validate this request.' diff --git a/.github/workflows/fallow.yml b/.github/workflows/fallow.yml index 3c0eb5f..67f96f2 100644 --- a/.github/workflows/fallow.yml +++ b/.github/workflows/fallow.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 10 permissions: contents: read - pull-requests: write + pull-requests: read checks: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Setup Vite+ and install dependencies - uses: ./ + uses: $/. with: node-version-file: .node-version @@ -33,6 +33,5 @@ jobs: uses: fallow-rs/fallow@fccff887da0d83db1711379e0a3696dfc2460fdd # v3.33.1 with: command: audit - # Fork and Dependabot PRs keep annotations and the job summary. - comment: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' }} + comment: false branded-token: false diff --git a/.github/workflows/gitlab-e2e.yml b/.github/workflows/gitlab-e2e.yml index ce64e7f..2f73f31 100644 --- a/.github/workflows/gitlab-e2e.yml +++ b/.github/workflows/gitlab-e2e.yml @@ -1,13 +1,18 @@ name: GitLab E2E +run-name: "GitLab E2E: ${{ github.event.pull_request.head.sha || inputs.setup_ref || github.sha }} / ${{ inputs.suite || 'full' }}" on: - workflow_run: - workflows: [GitLab E2E request] - types: [completed] + pull_request: + branches: [main] + types: [labeled] workflow_dispatch: inputs: + pull_request: + description: "Optional PR number. Requires setup_ref to be its reviewed head SHA." + required: false + default: "" setup_ref: - description: "Enter an exact setup-vp commit SHA or release tag. If empty, the workflow uses this commit." + description: "Exact commit SHA or release tag. If empty, test this workflow commit." required: false default: "" suite: @@ -28,128 +33,94 @@ jobs: gitlab-e2e: name: GitLab E2E if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.path == '.github/workflows/e2e-request.yml') + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && github.event.action == 'labeled' && + github.event.label.name == 'run-e2e' && + github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 35 - outputs: - pr_number: ${{ steps.parameters.outputs.pr_number }} - setup_vp_ref: ${{ steps.parameters.outputs.setup_vp_ref }} - suite: ${{ steps.parameters.outputs.suite }} - vite_plus_version: ${{ steps.parameters.outputs.vite_plus_version }} - pipeline_url: ${{ steps.trigger.outputs.pipeline_url }} - # Keep this privileged job API-only: no PR checkout, artifacts, or caches. + # Fork PRs use manual dispatch on main. Never check out or execute PR files. steps: - name: Resolve test parameters id: parameters env: EVENT_NAME: ${{ github.event_name }} - REQUEST_EVENT: ${{ github.event.workflow_run.event }} - REQUEST_CONCLUSION: ${{ github.event.workflow_run.conclusion }} - REQUEST_PATH: ${{ github.event.workflow_run.path }} - REQUEST_TITLE: ${{ github.event.workflow_run.display_title }} - REQUEST_ACTOR: ${{ github.event.workflow_run.actor.login }} - REQUEST_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - REQUEST_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} - MANUAL_SETUP_REF: ${{ inputs.setup_ref }} - MANUAL_SUITE: ${{ inputs.suite }} - MANUAL_VITE_PLUS_VERSION: ${{ inputs.vite_plus_version }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name }} + LABEL_ACTOR: ${{ github.event.sender.login }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request }} + SETUP_REF: ${{ github.event.pull_request.head.sha || inputs.setup_ref }} + TEST_SUITE: ${{ inputs.suite }} + VITE_PLUS_VERSION: ${{ inputs.vite_plus_version }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - should_run=false - setup_vp_ref="" - suite=full - vite_plus_version=latest - report_pr_number="" - skip_reason='GitLab E2E requires a successful run-e2e label request for a PR or a manual workflow_dispatch run.' - - resolve_pr_request() { - local request_pattern='^PR #([1-9][0-9]*): labeled run-e2e at ([0-9a-f]{40})$' - if [ "$EVENT_NAME" != "workflow_run" ] || [ "$REQUEST_EVENT" != "pull_request" ] || - [ "$REQUEST_CONCLUSION" != "success" ] || - [ "$REQUEST_PATH" != ".github/workflows/e2e-request.yml" ] || - ! [[ "$REQUEST_TITLE" =~ $request_pattern ]]; then - return - fi + case "$EVENT_NAME" in + workflow_dispatch) ;; + pull_request) + if [ "$EVENT_ACTION" != labeled ] || [ "$EVENT_LABEL" != run-e2e ] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ && "$SETUP_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::GitLab E2E requires a run-e2e label event with an exact PR head SHA." + exit 1 + fi + # Check the original labeler, including when someone reruns this job. + permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${LABEL_ACTOR}/permission" --jq '.permission')" + case "$permission" in + admin | write) ;; + *) echo "::error::Adding run-e2e requires repository write access."; exit 1 ;; + esac + TEST_SUITE=full + VITE_PLUS_VERSION=latest + ;; + *) echo "::error::Unsupported GitLab E2E event."; exit 1 ;; + esac + + setup_vp_ref="${SETUP_REF:-$GITHUB_SHA}" + suite="${TEST_SUITE:-full}" + vite_plus_version="${VITE_PLUS_VERSION:-latest}" + ref_pattern='^([0-9a-f]{40}|v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?)$' + if ! [[ "$setup_vp_ref" =~ $ref_pattern ]]; then + echo "::error::setup_ref must be an exact commit SHA or release tag." + exit 1 + fi + case "$suite" in + full | required) ;; + *) echo "::error::Select the full or required suite."; exit 1 ;; + esac + if [[ "$vite_plus_version" == *$'\n'* || "$vite_plus_version" == *$'\r'* ]]; then + echo "::error::vite_plus_version must be a single line." + exit 1 + fi - local pr_number="${BASH_REMATCH[1]}" - if [ "${BASH_REMATCH[2]}" != "$REQUEST_HEAD_SHA" ]; then - echo "::error::The request title does not match the workflow run's head SHA." + if [ -n "$PR_NUMBER" ]; then + if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ && "$SETUP_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Enter a valid PR number and its reviewed 40-character head SHA." exit 1 fi - - # Check the original labeler's access, even on reruns. - # GitHub maps the maintain role to write. - local permission - permission="$(gh api "repos/${GITHUB_REPOSITORY}/collaborators/${REQUEST_ACTOR}/permission" --jq '.permission')" - case "$permission" in - admin | write) ;; - *) - echo "::error::Adding run-e2e requires repository write access to approve a test run." - exit 1 - ;; - esac - - # Compare Git blobs to reject altered request triggers or run names. - local workflow_url="repos/${GITHUB_REPOSITORY}/contents/${REQUEST_PATH}" - local trusted_blob request_blob - trusted_blob="$(gh api "${workflow_url}?ref=${GITHUB_SHA}" --jq '.sha')" - if ! request_blob="$(gh api "${workflow_url}?ref=${REQUEST_HEAD_SHA}" --jq '.sha')" || - [ "$request_blob" != "$trusted_blob" ]; then - echo "::error::The PR must include e2e-request.yml unchanged from main. Update the branch, then remove and re-add run-e2e." + pull_request="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}")" + if ! jq -e --arg sha "$SETUP_REF" --arg repo "$GITHUB_REPOSITORY" \ + '.state == "open" and .head.sha == $sha and .head.repo != null and + .base.repo.full_name == $repo and .base.ref == "main"' \ + <<< "$pull_request" > /dev/null; then + echo "::error::The PR changed or no longer targets main. Review its current head and start a new run." exit 1 fi - - # Fork runs can have an empty workflow_run.pull_requests array. - # Resolve the PR from the verified title and cross-check its head. - local pull_request - pull_request="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")" - if ! jq -e --arg sha "$REQUEST_HEAD_SHA" --arg head_repo "$REQUEST_HEAD_REPOSITORY" --arg repo "$GITHUB_REPOSITORY" \ - '.state == "open" and .head.sha == $sha and .head.repo.full_name == $head_repo and - .base.repo.full_name == $repo and .base.ref == "main" and any(.labels[]; .name == "run-e2e")' \ + if [ "$EVENT_NAME" = pull_request ] && ! jq -e --arg repo "$GITHUB_REPOSITORY" \ + '.head.repo.full_name == $repo and any(.labels[]; .name == "run-e2e")' \ <<< "$pull_request" > /dev/null; then - skip_reason='This approval is stale or does not match the PR: check its head, base, and run-e2e label. Review the current commit, then remove and re-add run-e2e to test it.' - return + echo "::error::Label requests require a same-repository PR with run-e2e. Use manual dispatch on main for forks." + exit 1 fi - - should_run=true - setup_vp_ref="$REQUEST_HEAD_SHA" - report_pr_number="$pr_number" - } - - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - should_run=true - setup_vp_ref="${MANUAL_SETUP_REF:-$GITHUB_SHA}" - suite="${MANUAL_SUITE:-full}" - vite_plus_version="${MANUAL_VITE_PLUS_VERSION:-latest}" - else - resolve_pr_request fi { - echo "should_run=$should_run" echo "setup_vp_ref=$setup_vp_ref" echo "suite=$suite" echo "vite_plus_version=$vite_plus_version" - echo "pr_number=$report_pr_number" } >> "$GITHUB_OUTPUT" - if [ "$should_run" = "false" ]; then - echo "$skip_reason" - { - echo "### GitLab E2E" - echo - echo "$skip_reason" - } >> "$GITHUB_STEP_SUMMARY" - fi - - name: Trigger GitLab pipeline - if: steps.parameters.outputs.should_run == 'true' id: trigger env: GITLAB_PROJECT_ID: "85578524" @@ -193,7 +164,6 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Wait for GitLab pipeline - if: steps.parameters.outputs.should_run == 'true' env: GITLAB_PROJECT_ID: "85578524" GITLAB_PIPELINE_ID: ${{ steps.trigger.outputs.pipeline_id }} @@ -246,90 +216,3 @@ jobs: fi echo "::error::GitLab pipeline finished with status $final_status." exit 1 - - report-result: - name: Report GitLab E2E result - needs: gitlab-e2e - if: always() && needs.gitlab-e2e.outputs.pr_number != '' - runs-on: ubuntu-latest - permissions: - pull-requests: write - concurrency: - group: gitlab-e2e-comment-${{ needs.gitlab-e2e.outputs.pr_number }} - cancel-in-progress: false - steps: - - name: Update PR comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PR_NUMBER: ${{ needs.gitlab-e2e.outputs.pr_number }} - SETUP_VP_REF: ${{ needs.gitlab-e2e.outputs.setup_vp_ref }} - TEST_SUITE: ${{ needs.gitlab-e2e.outputs.suite }} - VITE_PLUS_VERSION: ${{ needs.gitlab-e2e.outputs.vite_plus_version }} - PIPELINE_URL: ${{ needs.gitlab-e2e.outputs.pipeline_url }} - RESULT: ${{ needs.gitlab-e2e.result }} - with: - script: | - const { PR_NUMBER, SETUP_VP_REF, TEST_SUITE, VITE_PLUS_VERSION, PIPELINE_URL, RESULT } = process.env; - const issue = { ...context.repo, issue_number: Number(PR_NUMBER) }; - const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: issue.issue_number }); - if (pr.head.sha !== SETUP_VP_REF) { - core.info('The PR head changed; skip this result.'); - return; - } - - const marker = ''; - const comments = await github.paginate(github.rest.issues.listComments, { ...issue, per_page: 100 }); - let previous; - for (const comment of comments) { - if (!comment.body?.startsWith(marker)) continue; - if (comment.user?.login === 'github-actions[bot]') { - previous = comment; - break; - } - // Reuse a result posted by a maintainer, including before automation was added. - if (!comment.user) continue; - let permission; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - ...context.repo, username: comment.user.login, - }); - permission = data.permission; - } catch (error) { - if (error.status === 404) continue; - throw error; - } - if (permission === 'admin' || permission === 'write') { - previous = comment; - break; - } - } - - const attempt = Number(process.env.GITHUB_RUN_ATTEMPT); - const previousRun = previous?.body.match(//); - if (previousRun && (Number(previousRun[1]) > context.runId || - (Number(previousRun[1]) === context.runId && Number(previousRun[2]) > attempt))) { - core.info('A newer run already reported its result.'); - return; - } - - let title; - if (RESULT === 'success') title = '✅ GitLab E2E passed'; - else if (RESULT === 'cancelled') title = '⚠️ GitLab E2E run cancelled'; - else if (!PIPELINE_URL) title = '❌ GitLab E2E could not start'; - else title = '❌ GitLab E2E did not complete successfully'; - - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/attempts/${attempt}`; - const links = [PIPELINE_URL && `[GitLab pipeline](${PIPELINE_URL})`, `[GitHub Actions run](${runUrl})`].filter(Boolean); - const body = [ - marker, - ``, - '', `### ${title}`, '', - `Commit: \`${SETUP_VP_REF}\` `, - `Suite: \`${TEST_SUITE}\` · Vite+ version: \`${VITE_PLUS_VERSION}\``, - '', links.join(' · '), - ].join('\n'); - if (previous) { - await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body }); - } else { - await github.rest.issues.createComment({ ...issue, body }); - } diff --git a/.github/workflows/publish-bundle.yml b/.github/workflows/publish-bundle.yml index a00d51e..e52cc32 100644 --- a/.github/workflows/publish-bundle.yml +++ b/.github/workflows/publish-bundle.yml @@ -1,17 +1,20 @@ name: Publish rebuilt action bundle -# workflow_run loads this publisher from the default branch. Never check out -# PR code, restore its caches, or execute anything from the downloaded artifact. +# Maintainers dispatch this publisher on main after reviewing a completed build. +# Never check out PR code, restore its caches, or execute downloaded artifacts. on: - workflow_run: - workflows: [Rebuild action bundle] - types: [completed] + workflow_dispatch: + inputs: + run_id: + description: "Successful Rebuild action bundle run ID to publish." + required: true + type: string permissions: {} jobs: publish: - if: github.event.workflow_run.conclusion == 'success' + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -22,15 +25,23 @@ jobs: - name: Validate the build request id: request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BUILD_RUN_ID: ${{ inputs.run_id }} with: script: | - const run = context.payload.workflow_run; + const runId = process.env.BUILD_RUN_ID; + if (!/^[1-9][0-9]*$/.test(runId) || !Number.isSafeInteger(Number(runId)) || + String(Number(runId)) !== runId) { + throw new Error('Enter a valid build run ID.'); + } + const { data: run } = await github.rest.actions.getWorkflowRun({ + ...context.repo, run_id: Number(runId), + }); const repository = `${context.repo.owner}/${context.repo.repo}`; const path = '.github/workflows/rebuild-bundle.yml'; - if (run.event !== 'pull_request' || run.conclusion !== 'success' || + if (run.event !== 'pull_request' || run.status !== 'completed' || run.conclusion !== 'success' || run.path !== path || run.head_repository?.full_name !== repository) { - core.info('This run is not a successful same-repository PR rebuild.'); - return; + throw new Error('Select a successful, completed same-repository PR rebuild.'); } const match = /^Rebuild PR #([1-9][0-9]*) at ([0-9a-f]{40})$/.exec(run.display_title); @@ -61,16 +72,19 @@ jobs: } core.setOutput('pr-number', pr.number); core.setOutput('branch', pr.head.ref); + core.setOutput('head-sha', run.head_sha); + core.setOutput('run-id', run.id); + core.setOutput('run-attempt', run.run_attempt); core.setOutput('ready', 'true'); - name: Download bundles from this build attempt if: steps.request.outputs.ready == 'true' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: rebuilt-bundles-${{ github.event.workflow_run.run_attempt }} + name: rebuilt-bundles-${{ steps.request.outputs.run-attempt }} path: ${{ runner.temp }}/rebuilt-bundles github-token: ${{ github.token }} - run-id: ${{ github.event.workflow_run.id }} + run-id: ${{ steps.request.outputs.run-id }} - name: Validate bundles and prepare commit id: prepare @@ -79,6 +93,7 @@ jobs: env: PR_NUMBER: ${{ steps.request.outputs.pr-number }} PR_BRANCH: ${{ steps.request.outputs.branch }} + PR_HEAD_SHA: ${{ steps.request.outputs.head-sha }} with: script: | const fs = require('node:fs'); @@ -86,7 +101,7 @@ jobs: const { createHash } = require('node:crypto'); const expected = { number: Number(process.env.PR_NUMBER), - head: { sha: context.payload.workflow_run.head_sha, ref: process.env.PR_BRANCH }, + head: { sha: process.env.PR_HEAD_SHA, ref: process.env.PR_BRANCH }, }; const repository = `${context.repo.owner}/${context.repo.repo}`; const { data: pr } = await github.rest.pulls.get({ diff --git a/.github/workflows/rebuild-bundle.yml b/.github/workflows/rebuild-bundle.yml index 037a31c..d2b315a 100644 --- a/.github/workflows/rebuild-bundle.yml +++ b/.github/workflows/rebuild-bundle.yml @@ -3,7 +3,7 @@ run-name: "Rebuild PR #${{ github.event.pull_request.number }} at ${{ github.eve # The `needs-bundle-rebuild` label (Renovate adds it for bumps to deps compiled # into dist/, or to the vite-plus bundler) triggers a read-only build. -# publish-bundle.yml runs on a separate runner and commits the artifact. +# A maintainer dispatches publish-bundle.yml on main to commit a reviewed build. # `synchronize` retries after rebases; publishing stops when dist/ is unchanged. # The publisher verifies this workflow's Git blob before trusting its run name. on: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f81da0..47c984f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,9 @@ on: required: false default: "" +permissions: + contents: read + # Push / PR / merge_group runs always test the latest published Vite+. A manual # workflow_dispatch can set pr_version to a PR number or commit SHA; the install # script picks it up via VP_PR_VERSION, which overrides VP_VERSION and pulls the @@ -22,6 +25,19 @@ env: VP_PR_VERSION: ${{ github.event.inputs.pr_version }} jobs: + zizmor: + name: Audit GitHub Actions + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - name: Run zizmor + uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 + with: + advanced-security: false + annotations: true + test: strategy: fail-fast: false @@ -32,7 +48,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ - uses: ./ + uses: $/. with: run-install: false cache: false @@ -97,7 +113,7 @@ jobs: - name: Setup Vite+ for the first time id: first-setup - uses: ./ + uses: $/. with: working-directory: ${{ runner.temp }}/reuse-project node-version: "22.23.1" @@ -126,7 +142,7 @@ jobs: - name: Setup the same Vite+ version again id: second-setup - uses: ./ + uses: $/. with: working-directory: ${{ runner.temp }}/reuse-project node-version: "22.23.1" @@ -171,7 +187,7 @@ jobs: run: node test/reuse-installation-integrity.mjs prepare - name: Install Vite+ - uses: ./ + uses: $/. with: version: "0.3.3" run-install: false @@ -181,7 +197,7 @@ jobs: run: node test/reuse-installation-integrity.mjs remove-binding - name: Repair the incomplete installation - uses: ./ + uses: $/. with: version: "0.3.3" run-install: false @@ -206,7 +222,7 @@ jobs: echo "VP_HOME=$RUNNER_TEMP/setup-vp-restored" >> "$GITHUB_ENV" bash test/record-cli-downloads.sh - name: Install from the user's version-file inputs - uses: ./ + uses: $/. with: working-directory: test/reuse-project version-file: package.json @@ -253,7 +269,7 @@ jobs: bash test/record-cli-downloads.sh - name: Reuse the restored installation id: setup - uses: ./ + uses: $/. with: working-directory: test/reuse-project version-file: package.json @@ -322,11 +338,13 @@ jobs: - name: Verify installation shell: bash + env: + PREVIEW_VERSION: ${{ steps.preview.outputs.version }} run: | vp env current vp i -g cowsay@1.6.0 vp env doctor - vp --version | grep -F "${{ steps.preview.outputs.version }}" + vp --version | grep -F -- "$PREVIEW_VERSION" test-node-version: strategy: @@ -354,7 +372,7 @@ jobs: # Runs `vp env use ` then `vp install` in the test project. - name: Setup Vite+ with Node.js ${{ matrix.node-version }} id: setup - uses: ./ + uses: $/. with: node-version: ${{ matrix.node-version }} run-install: | @@ -403,7 +421,7 @@ jobs: - name: Setup Vite+ without a Node.js input id: setup - uses: ./ + uses: $/. with: working-directory: ${{ runner.temp }}/test-project run-install: false @@ -452,7 +470,7 @@ jobs: - name: Setup Vite+ without Node.js manager id: setup - uses: ./ + uses: $/. with: node-manager: false run-install: | @@ -466,34 +484,39 @@ jobs: shell: bash env: OUTPUT_NODE_VERSION: ${{ steps.setup.outputs.node-version }} + BEFORE_NODE_PATH: ${{ steps.before.outputs.path }} + BEFORE_NODE_VERSION: ${{ steps.before.outputs.version }} run: | ACTUAL_PATH=$(node -p 'process.execPath' | tr -d '\r') ACTUAL_VERSION=$(node --version | tr -d '\r') - echo "node before: ${{ steps.before.outputs.path }} (${{ steps.before.outputs.version }})" + echo "node before: $BEFORE_NODE_PATH ($BEFORE_NODE_VERSION)" echo "node after: $ACTUAL_PATH ($ACTUAL_VERSION)" - if [ "$ACTUAL_PATH" != "${{ steps.before.outputs.path }}" ]; then - echo "::error::node resolves to $ACTUAL_PATH; expected ${{ steps.before.outputs.path }}" + if [ "$ACTUAL_PATH" != "$BEFORE_NODE_PATH" ]; then + echo "::error::node resolves to $ACTUAL_PATH; expected $BEFORE_NODE_PATH" exit 1 fi - if [ "$ACTUAL_VERSION" != "${{ steps.before.outputs.version }}" ]; then - echo "::error::node is $ACTUAL_VERSION; expected ${{ steps.before.outputs.version }}" + if [ "$ACTUAL_VERSION" != "$BEFORE_NODE_VERSION" ]; then + echo "::error::node is $ACTUAL_VERSION; expected $BEFORE_NODE_VERSION" exit 1 fi - test "v$OUTPUT_NODE_VERSION" = "${{ steps.before.outputs.version }}" + test "v$OUTPUT_NODE_VERSION" = "$BEFORE_NODE_VERSION" - name: Verify vp commands use the system Node.js shell: bash + env: + BEFORE_NODE_PATH: ${{ steps.before.outputs.path }} + BEFORE_NODE_VERSION: ${{ steps.before.outputs.version }} run: | EXEC_PATH=$(vp exec node -p 'process.execPath' | tr -d '\r') EXEC_VERSION=$(vp exec node --version | tr -d '\r') echo "vp exec node -p process.execPath: $EXEC_PATH" echo "vp exec node --version: $EXEC_VERSION" - if [ "$EXEC_PATH" != "${{ steps.before.outputs.path }}" ]; then - echo "::error::vp resolved Node.js at $EXEC_PATH; expected system ${{ steps.before.outputs.path }}" + if [ "$EXEC_PATH" != "$BEFORE_NODE_PATH" ]; then + echo "::error::vp resolved Node.js at $EXEC_PATH; expected system $BEFORE_NODE_PATH" exit 1 fi - if [ "$EXEC_VERSION" != "${{ steps.before.outputs.version }}" ]; then - echo "::error::vp resolved Node.js $EXEC_VERSION; expected system ${{ steps.before.outputs.version }}" + if [ "$EXEC_VERSION" != "$BEFORE_NODE_VERSION" ]; then + echo "::error::vp resolved Node.js $EXEC_VERSION; expected system $BEFORE_NODE_VERSION" exit 1 fi @@ -514,7 +537,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ with package-manager configuration - uses: ./ + uses: $/. with: package-manager: ${{ matrix.config.input }} run-install: false @@ -608,7 +631,7 @@ jobs: esac - name: Setup Vite+ (auto-detect, no version input) - uses: ./ + uses: $/. id: setup with: working-directory: ${{ runner.temp }}/default-version-${{ matrix.scenario }} @@ -656,7 +679,7 @@ jobs: touch pnpm-lock.yaml - name: Setup Vite+ with pnpm cache (cache-save=${{ matrix.cache-save }}) - uses: ./ + uses: $/. id: setup with: run-install: false @@ -665,10 +688,14 @@ jobs: cache-dependency-path: test-project/pnpm-lock.yaml - name: Verify installation + shell: bash + env: + INSTALLED_VERSION: ${{ steps.setup.outputs.version }} + CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | vp --version - echo "Installed version: ${{ steps.setup.outputs.version }}" - echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" + echo "Installed version: $INSTALLED_VERSION" + echo "Cache hit: $CACHE_HIT" test-cache-npm: runs-on: ubuntu-latest @@ -683,7 +710,7 @@ jobs: echo '{"name":"test-project","lockfileVersion":3}' > package-lock.json - name: Setup Vite+ with npm cache - uses: ./ + uses: $/. id: setup with: run-install: false @@ -691,10 +718,14 @@ jobs: cache-dependency-path: test-project/package-lock.json - name: Verify installation + shell: bash + env: + INSTALLED_VERSION: ${{ steps.setup.outputs.version }} + CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | vp --version - echo "Installed version: ${{ steps.setup.outputs.version }}" - echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" + echo "Installed version: $INSTALLED_VERSION" + echo "Cache hit: $CACHE_HIT" test-cache-yarn: runs-on: ubuntu-latest @@ -709,7 +740,7 @@ jobs: touch yarn.lock - name: Setup Vite+ with yarn cache - uses: ./ + uses: $/. id: setup with: run-install: false @@ -717,10 +748,14 @@ jobs: cache-dependency-path: test-project/yarn.lock - name: Verify installation + shell: bash + env: + INSTALLED_VERSION: ${{ steps.setup.outputs.version }} + CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | vp --version - echo "Installed version: ${{ steps.setup.outputs.version }}" - echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" + echo "Installed version: $INSTALLED_VERSION" + echo "Cache hit: $CACHE_HIT" test-cache-bun: strategy: @@ -739,7 +774,7 @@ jobs: touch ${{ matrix.lockfile }} - name: Setup Vite+ with bun cache (${{ matrix.lockfile }}) - uses: ./ + uses: $/. id: setup with: run-install: false @@ -747,10 +782,14 @@ jobs: cache-dependency-path: test-project/${{ matrix.lockfile }} - name: Verify installation + shell: bash + env: + INSTALLED_VERSION: ${{ steps.setup.outputs.version }} + CACHE_HIT: ${{ steps.setup.outputs.cache-hit }} run: | vp --version - echo "Installed version: ${{ steps.setup.outputs.version }}" - echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}" + echo "Installed version: $INSTALLED_VERSION" + echo "Cache hit: $CACHE_HIT" test-vp-exec: strategy: @@ -762,7 +801,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ - uses: ./ + uses: $/. with: run-install: false cache: false @@ -789,7 +828,7 @@ jobs: echo '{"name":"test-project","private":true,"scripts":{"hello":"node -e \"console.log(1+1)\""}}' > "$DIR/package.json" - name: Setup Vite+ with install - uses: ./ + uses: $/. with: run-install: | - cwd: ${{ runner.temp }}/test-project @@ -809,7 +848,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ with registry-url - uses: ./ + uses: $/. with: run-install: false cache: false @@ -838,7 +877,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ - uses: ./ + uses: $/. with: run-install: false cache: false @@ -910,7 +949,7 @@ jobs: } > "${RUNNER_TEMP//\\//}/test-project/.yarnrc.yml" - name: Setup Vite+ with sfw + ${{ matrix.package-manager }} - uses: ./ + uses: $/. with: sfw: true run-install: | @@ -953,7 +992,7 @@ jobs: echo '{"name":"test-project","private":true,"dependencies":{"is-odd":"^3.0.1"}}' > /tmp/test-project/package.json - name: Setup Vite+ with sfw (musl) - uses: ./ + uses: $/. with: sfw: true run-install: | @@ -970,7 +1009,7 @@ jobs: test-sfw-blocks-malicious: # Temporarily skip while Socket alert lookups fail. Restore in #161: # https://github.com/voidzero-dev/setup-vp/issues/161 - if: ${{ false }} + if: false # Verifies sfw actually intercepts a known-malicious package, not just # that it wraps the install. Uses `lodahs` (lodash typosquat), the same # canary SocketDev's own workflows use: @@ -1003,7 +1042,7 @@ jobs: echo '{"name":"test-project","private":true,"dependencies":{"is-odd":"^3.0.1"}}' > "$DIR/package.json" - name: Setup Vite+ with sfw and install benign dep - uses: ./ + uses: $/. with: sfw: true run-install: | @@ -1068,7 +1107,7 @@ jobs: mode: firewall-free - name: Setup Vite+ with sfw (composition path) - uses: ./ + uses: $/. id: setup-vp with: sfw: true @@ -1097,7 +1136,7 @@ jobs: - name: Assert composed sfw blocks malicious package (lodahs) # Temporarily skip while Socket alert lookups fail. Restore in #161: # https://github.com/voidzero-dev/setup-vp/issues/161 - if: ${{ false }} + if: false shell: bash working-directory: ${{ runner.temp }}/test-project # Proves the composition path actually enforces, not just that sfw is @@ -1150,7 +1189,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ - uses: ./ + uses: $/. with: run-install: true cache: false @@ -1190,7 +1229,7 @@ jobs: steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ and test dependencies - uses: ./ + uses: $/. with: node-version-file: .node-version # Some tests inspect or execute dist/, so build on each runner first. @@ -1211,7 +1250,7 @@ jobs: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 - name: Setup Vite+ with cache - uses: ./ + uses: $/. id: setup with: cache: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5be2546..18253c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,7 +53,7 @@ The action uses the exact `fallow` version in `package.json`. After `vp install` vp exec fallow audit --base origin/main ``` -[`.fallowrc.json`](.fallowrc.json) declares the three runtime entry points. Fallow excludes generated files under `dist/` by default. Same-repository pull requests receive a result comment. Fork and Dependabot pull requests use annotations and the job summary. +[`.fallowrc.json`](.fallowrc.json) declares the three runtime entry points. Fallow excludes generated files under `dist/` by default. Results appear in annotations, CI logs, and the job summary. The workflow does not write PR comments. ## Integration Design @@ -64,25 +64,55 @@ Use the shared primitives under [`src/ci/`](src/ci/) for portable runtime behavi ## GitLab End-to-End Tests -Use the dedicated [GitLab test project](https://gitlab.com/fengmk2/setup-vp-gitlab-test) to test the remote integration. GitLab CI has limited capacity, so the [GitLab E2E workflow](.github/workflows/gitlab-e2e.yml) runs only for pull requests approved with the `run-e2e` label or manual `workflow_dispatch` requests. The pipeline loads the template, bootstrap script, and compiled runtime from the exact approved PR commit or the manually selected commit or release tag. +Use the dedicated [GitLab test project](https://gitlab.com/fengmk2/setup-vp-gitlab-test) to test the remote integration. The [GitLab E2E workflow](.github/workflows/gitlab-e2e.yml) accepts `run-e2e` label requests for same-repository PRs and manual `workflow_dispatch` requests on `main`. The job uses the GitLab trigger secret without checking out or executing PR files on the GitHub runner. ### Request a Run -After reviewing the commit, a maintainer with write access can add `run-e2e` to run the full GitLab suite. This applies to both same-repository and fork PRs. Approve the Actions run if prompted. +For a same-repository PR, review its current head commit, including workflow changes, then add `run-e2e`: -For each new commit, review the changes and remove and re-add `run-e2e`. Read the PR result comment for the status and GitLab pipeline link after each run. +```bash +gh pr edit 123 --add-label run-e2e +``` + +The workflow checks that the labeler has repository write access and runs the full suite at the label event's head SHA. After each new commit, review the changes, then remove and re-add `run-e2e`. Keeping the label on a PR does not approve later pushes. The handler rejects stale heads, closed PRs, and requests whose label was removed. -Release PRs must pass the full suite on their current head commit before merging. Follow the [release procedure](#releasing) to request and confirm this result. +### Manual Runs and Fork PRs + +Fork PRs use manual dispatch because `pull_request` workflows do not receive the GitLab secret from the base repository. For a manual PR run, set the PR number and record its current head SHA. Review that commit before starting the workflow: + +```bash +pr_number=123 +reviewed_sha="$(gh pr view "$pr_number" --json headRefOid --jq .headRefOid)" +``` + +After review, start the full suite from the trusted workflow on `main`: + +```bash +gh workflow run gitlab-e2e.yml --ref main \ + -f pull_request="$pr_number" \ + -f setup_ref="$reviewed_sha" \ + -f suite=full +``` -Pushes, merge queue commits, merges, and release tags do not automatically start GitLab pipelines. +Manual dispatch works for same-repository and fork PRs. The workflow rejects a request if the PR head changes before validation. Each new commit needs another review and request. For both label and manual requests, check the workflow's logs and job summary for the tested commit, suite, pipeline link, and result. The workflow does not write PR comments. -For a manual run, use `workflow_dispatch`. Set `setup_ref` to an exact commit SHA or release tag, or leave it empty to test the selected workflow commit. Select `suite` (`full` by default) and `vite_plus_version` (`latest` by default). +For a standalone run, omit `pull_request`. Set `setup_ref` to an exact commit SHA or release tag, or leave it empty to test the workflow commit. Select `suite` (`full` by default) and `vite_plus_version` (`latest` by default). Manual runs selected on branches other than `main` skip the job. + +Pushes, merge queue commits, merges, and release tags do not automatically start GitLab pipelines. Release PRs must pass the full suite on their current head commit before merging; follow the [release procedure](#releasing). ## Dependency Updates -For same-repository PRs with the `needs-bundle-rebuild` label, [the build workflow](.github/workflows/rebuild-bundle.yml) builds the exact PR commit with read-only access. [The publisher](.github/workflows/publish-bundle.yml) runs separately from the default branch and uses a repository-scoped GitHub App token to update only the three bundles in `dist/`. It validates the bundle files before creating the token and does not execute PR code or downloaded bundles. It skips stale PR heads and unchanged bundles; the commit fails if the branch head changes after validation. +For same-repository PRs with the `needs-bundle-rebuild` label, [the build workflow](.github/workflows/rebuild-bundle.yml) builds the exact PR commit with read-only access. After a successful build, a maintainer reviews its commit and dispatches [the publisher](.github/workflows/publish-bundle.yml) on `main` with that build's run ID: + +```bash +gh workflow run publish-bundle.yml --ref main -f run_id=123456789 +``` + +Replace `123456789` with the completed **Rebuild action bundle** run ID from its Actions URL. Publishing is manual; the label only starts the unprivileged build. -The PR's `rebuild-bundle.yml` must match the default branch before the publisher accepts the build. After changes to that workflow reach the default branch, update existing dependency branches and rerun the build. The publisher must be on the default branch to receive build completion events. +The publisher reads the build record from GitHub and verifies its workflow, repository, and current PR head. It validates the three bundle files before creating a repository-scoped GitHub App token. It never executes PR code or downloaded bundles. It skips stale PR heads and unchanged bundles; the commit fails if the branch head changes after validation. + +The PR's `rebuild-bundle.yml` must match the default branch before the publisher accepts the build. After changes to that workflow reach the default branch, update existing dependency branches and rerun the build before dispatching the publisher. Renovate opens a PR when SocketDev publishes a new `sfw-free` release. The custom manager in [`.github/renovate.json`](.github/renovate.json) updates the shared `SFW_VERSION` pin in `src/ci/install-sfw.ts` for all three integrations. @@ -98,17 +128,11 @@ Publish releases as Git tags, not as an npm package. Keep `package.json.version` Run the [required checks](#before-committing) and commit the release changes, including any updated bundles. The bootstrap and template tests compare these defaults with `package.json.version`, so an omitted update fails CI. Do not resolve `latest` at runtime or reuse the frozen `v1` tag. -2. Review the release PR's current head commit, then add the `run-e2e` label to request the full GitLab suite. From the release PR branch: - - ```bash - gh pr edit --add-label run-e2e - ``` - - Follow the [GitLab E2E procedures](#gitlab-end-to-end-tests). After any new commit, including an update from `main`, review the changes and remove and re-add `run-e2e`. +2. Review the release PR's current head commit, then add `run-e2e` for a same-repository PR or dispatch the full suite from `main` for a fork. Follow the [GitLab E2E procedures](#gitlab-end-to-end-tests). After any new commit, including an update from `main`, review the changes and remove and re-add the label or dispatch another run for the new SHA. -3. Merge the release PR only after GitHub CI and the full GitLab suite pass. Confirm that the latest PR result comment says **GitLab E2E passed**, reports `Suite: full`, and names the current PR head SHA. A successful `GitLab E2E request` workflow only confirms the request; it does not confirm that GitLab tests passed. +3. Merge the release PR only after GitHub CI and the full GitLab suite pass. Check the GitLab E2E job summary: its result must be `success`, its suite must be `full`, and its tested commit must still match the PR head. - Keep the PR open if verification fails, is cancelled, or has no result for the current head. Fix the failure, including outdated fixtures in the dedicated GitLab test project when needed, then remove and re-add `run-e2e`. Wait for a passing result before merging. Do not defer this check to a manual run after merging or bypass it with an administrator merge. + Keep the PR open if verification fails, is cancelled, or has no result for the current head. Fix the failure, including outdated fixtures in the dedicated GitLab test project when needed, then request another run. Wait for a passing result before merging. Do not defer this check until after merging or bypass it with an administrator merge. 4. Update `main` and confirm that `HEAD` is the release PR's merge commit. Confirm that all three bundles in `dist/` are in sync. The working tree must stay clean after building: diff --git a/src/bundle-publish-request.test.ts b/src/bundle-publish-request.test.ts index 775faf9..2c1f81c 100644 --- a/src/bundle-publish-request.test.ts +++ b/src/bundle-publish-request.test.ts @@ -19,18 +19,20 @@ function fixture() { sha: trustedSha, payload: { repository: { default_branch: "main" }, - workflow_run: { - id: 42, - event: "pull_request", - conclusion: "success", - path: ".github/workflows/rebuild-bundle.yml", - head_repository: { full_name: "upstream/setup-vp" }, - head_branch: "renovate/vite-plus", - head_sha: head, - display_title: `Rebuild PR #123 at ${head}`, - }, }, }; + const run = { + id: 42, + run_attempt: 2, + status: "completed", + event: "pull_request", + conclusion: "success", + path: ".github/workflows/rebuild-bundle.yml", + head_repository: { full_name: "upstream/setup-vp" }, + head_branch: "renovate/vite-plus", + head_sha: head, + display_title: `Rebuild PR #123 at ${head}`, + }; const pr = { number: 123, state: "open", @@ -40,6 +42,7 @@ function fixture() { }; const github = { rest: { + actions: { getWorkflowRun: vi.fn().mockResolvedValue({ data: run }) }, repos: { getContent: vi.fn().mockResolvedValue({ data: { type: "file", sha: "workflow-blob" } }), }, @@ -53,9 +56,15 @@ function fixture() { outputs[name] = value; }, }; + const env = { BUILD_RUN_ID: "42" }; const execute = (step: { with: { script: string } }): Promise => - runInNewContext(`(async () => {\n${step.with.script}\n})()`, { github, context, core }); - return { context, pr, github, outputs, execute }; + runInNewContext(`(async () => {\n${step.with.script}\n})()`, { + github, + context, + core, + process: { env }, + }); + return { context, run, env, pr, github, outputs, execute }; } it("identifies the PR and exact build commit in the run name", () => { @@ -69,6 +78,7 @@ it("identifies the PR and exact build commit in the run name", () => { expect(prepare.env).toEqual({ PR_NUMBER: "${{ steps.request.outputs.pr-number }}", PR_BRANCH: "${{ steps.request.outputs.branch }}", + PR_HEAD_SHA: "${{ steps.request.outputs.head-sha }}", }); }); @@ -76,7 +86,18 @@ describe("bundle build provenance", () => { it("accepts a labeled PR only after checking its workflow and current head", async () => { const f = fixture(); await f.execute(validation); - expect(f.outputs).toEqual({ "pr-number": 123, branch: "renovate/vite-plus", ready: "true" }); + expect(f.outputs).toEqual({ + "pr-number": 123, + branch: "renovate/vite-plus", + "head-sha": head, + "run-id": 42, + "run-attempt": 2, + ready: "true", + }); + expect(f.github.rest.actions.getWorkflowRun).toHaveBeenCalledWith({ + ...f.context.repo, + run_id: 42, + }); expect(f.github.rest.repos.getContent).toHaveBeenNthCalledWith(1, { ...f.context.repo, path: ".github/workflows/rebuild-bundle.yml", @@ -90,15 +111,27 @@ describe("bundle build provenance", () => { expect(f.github.rest.pulls.get).toHaveBeenCalledWith({ ...f.context.repo, pull_number: 123 }); }); + it.each(["", "0", "-1", "1.5", "1e2", "42\n", "9007199254740992", "$(exit 1)"])( + "rejects an invalid run ID %j before using the API", + async (runId) => { + const f = fixture(); + f.env.BUILD_RUN_ID = runId; + await expect(f.execute(validation)).rejects.toThrow("valid build run ID"); + expect(f.github.rest.actions.getWorkflowRun).not.toHaveBeenCalled(); + expect(f.outputs).toEqual({}); + }, + ); + it.each([ { event: "push" }, + { status: "in_progress" }, { conclusion: "failure" }, { path: ".github/workflows/impostor.yml" }, { head_repository: { full_name: "fork/setup-vp" } }, - ])("ignores an ineligible run: %j", async (changes) => { + ])("rejects an ineligible run: %j", async (changes) => { const f = fixture(); - Object.assign(f.context.payload.workflow_run, changes); - await f.execute(validation); + Object.assign(f.run, changes); + await expect(f.execute(validation)).rejects.toThrow("completed same-repository PR rebuild"); expect(f.outputs).toEqual({}); expect(f.github.rest.repos.getContent).not.toHaveBeenCalled(); }); @@ -107,7 +140,7 @@ describe("bundle build provenance", () => { "rejects a malformed or mismatched run title: %s", async (title) => { const f = fixture(); - f.context.payload.workflow_run.display_title = title; + f.run.display_title = title; await expect(f.execute(validation)).rejects.toThrow("run head SHA"); expect(f.outputs).toEqual({}); }, @@ -123,14 +156,21 @@ describe("bundle build provenance", () => { expect(f.github.rest.pulls.get).not.toHaveBeenCalled(); }); - it.each(["workflow", "pull request"])("fails closed if the %s API fails", async (target) => { - const f = fixture(); - const request = - target === "workflow" ? f.github.rest.repos.getContent : f.github.rest.pulls.get; - request.mockRejectedValue(new Error("API failure")); - await expect(f.execute(validation)).rejects.toThrow("API failure"); - expect(f.outputs).toEqual({}); - }); + it.each(["run", "workflow", "pull request"])( + "fails closed if the %s API fails", + async (target) => { + const f = fixture(); + const request = + target === "run" + ? f.github.rest.actions.getWorkflowRun + : target === "workflow" + ? f.github.rest.repos.getContent + : f.github.rest.pulls.get; + request.mockRejectedValue(new Error("API failure")); + await expect(f.execute(validation)).rejects.toThrow("API failure"); + expect(f.outputs).toEqual({}); + }, + ); it.each(["commit", "fork", "base repository", "branch", "default branch", "closed", "label"])( "skips after a change to the PR's %s", @@ -143,7 +183,7 @@ describe("bundle build provenance", () => { if (change === "default branch") { f.pr.head.ref = "main"; f.pr.base.ref = "release"; - f.context.payload.workflow_run.head_branch = "main"; + f.run.head_branch = "main"; } if (change === "closed") f.pr.state = "closed"; if (change === "label") f.pr.labels = []; diff --git a/src/gitlab/report-result.test.ts b/src/gitlab/report-result.test.ts deleted file mode 100644 index fb622df..0000000 --- a/src/gitlab/report-result.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { readFileSync } from "node:fs"; -import { runInNewContext } from "node:vm"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { parse as parseYaml } from "yaml"; - -const workflow = parseYaml( - readFileSync(new URL("../../.github/workflows/gitlab-e2e.yml", import.meta.url), "utf8"), -); -const report = workflow.jobs["report-result"]; -const headSha = "a".repeat(40); -const marker = ""; -const pipelineUrl = "https://gitlab.com/example/project/-/pipelines/42"; - -function comment(login = "github-actions[bot]", runId = 100, attempt = 1) { - return { - id: 42, - user: { login }, - body: `${marker}\n\nPrevious result`, - }; -} - -function reporter(env: Record = {}) { - const issues = { - listComments: vi.fn(), - createComment: vi.fn(), - updateComment: vi.fn(), - }; - const github = { - paginate: vi.fn().mockResolvedValue([]), - rest: { - issues, - pulls: { get: vi.fn().mockResolvedValue({ data: { head: { sha: headSha } } }) }, - repos: { - getCollaboratorPermissionLevel: vi - .fn() - .mockResolvedValue({ data: { permission: "write" } }), - }, - }, - }; - return { - github, - issues, - run: () => - runInNewContext(`(async () => { ${report.steps[0].with.script} })()`, { - github, - context: { - repo: { owner: "upstream", repo: "setup-vp" }, - runId: 200, - serverUrl: "https://github.com", - }, - core: { info: vi.fn() }, - process: { - env: { - PR_NUMBER: "123", - SETUP_VP_REF: headSha, - TEST_SUITE: "full", - VITE_PLUS_VERSION: "latest", - PIPELINE_URL: pipelineUrl, - RESULT: "success", - GITHUB_RUN_ATTEMPT: "1", - ...env, - }, - }, - }), - }; -} - -describe("GitLab E2E result comment", () => { - it("isolates comment writes in an API-only job for validated PRs, including failed runs", () => { - expect(report.needs).toBe("gitlab-e2e"); - expect(report.if).toBe("always() && needs.gitlab-e2e.outputs.pr_number != ''"); - expect(report.permissions).toEqual({ "pull-requests": "write" }); - expect(report.concurrency).toEqual({ - group: "gitlab-e2e-comment-${{ needs.gitlab-e2e.outputs.pr_number }}", - "cancel-in-progress": false, - }); - expect(report.steps).toHaveLength(1); - expect(report.steps[0].uses).toMatch(/^actions\/github-script@[a-f0-9]{40}$/); - expect(report.steps[0].with.script).not.toContain("${{"); - }); - - it("creates a result with the tested commit and pipeline and run links", async () => { - const { run, github, issues } = reporter(); - await run(); - expect(github.paginate).toHaveBeenCalledWith(issues.listComments, { - owner: "upstream", - repo: "setup-vp", - issue_number: 123, - per_page: 100, - }); - expect(issues.createComment).toHaveBeenCalledOnce(); - const body = issues.createComment.mock.calls[0][0].body; - expect(body).toContain("### ✅ GitLab E2E passed"); - expect(body).toContain(`Commit: \`${headSha}\``); - expect(body).toContain("Suite: `full` · Vite+ version: `latest`"); - expect(body).toContain(`[GitLab pipeline](${pipelineUrl})`); - expect(body).toContain("https://github.com/upstream/setup-vp/actions/runs/200/attempts/1"); - expect(body).toContain(""); - expect(issues.updateComment).not.toHaveBeenCalled(); - }); - - it.each(["github-actions[bot]", "maintainer"])( - "reuses a result posted by %s on a rerun", - async (login) => { - const { run, github, issues } = reporter({ GITHUB_RUN_ATTEMPT: "2" }); - github.paginate.mockResolvedValue([comment(login, 200)]); - await run(); - expect(issues.updateComment).toHaveBeenCalledWith({ - owner: "upstream", - repo: "setup-vp", - comment_id: 42, - body: expect.stringContaining(""), - }); - expect(issues.createComment).not.toHaveBeenCalled(); - if (login === "maintainer") { - expect(github.rest.repos.getCollaboratorPermissionLevel).toHaveBeenCalledWith({ - owner: "upstream", - repo: "setup-vp", - username: "maintainer", - }); - } else { - expect(github.rest.repos.getCollaboratorPermissionLevel).not.toHaveBeenCalled(); - } - }, - ); - - it.each([ - ["failure", pipelineUrl, "❌ GitLab E2E did not complete successfully"], - ["failure", "", "❌ GitLab E2E could not start"], - ["cancelled", pipelineUrl, "⚠️ GitLab E2E run cancelled"], - ])("updates the result for %s with pipeline URL '%s'", async (result, url, title) => { - const { run, github, issues } = reporter({ RESULT: result, PIPELINE_URL: url }); - github.paginate.mockResolvedValue([comment()]); - await run(); - const body = issues.updateComment.mock.calls[0][0].body; - expect(body).toContain(`### ${title}`); - expect(body.includes("[GitLab pipeline]")).toBe(Boolean(url)); - expect(issues.createComment).not.toHaveBeenCalled(); - }); - - it("does not report a result after the PR head changes", async () => { - const { run, github, issues } = reporter(); - github.rest.pulls.get.mockResolvedValue({ data: { head: { sha: "b".repeat(40) } } }); - await run(); - expect(github.paginate).not.toHaveBeenCalled(); - expect(issues.createComment).not.toHaveBeenCalled(); - expect(issues.updateComment).not.toHaveBeenCalled(); - }); - - it.each([ - [201, 1], - [200, 2], - ])("does not overwrite a newer run %s attempt %s", async (runId, attempt) => { - const { run, github, issues } = reporter(); - github.paginate.mockResolvedValue([comment("github-actions[bot]", runId, attempt)]); - await run(); - expect(issues.createComment).not.toHaveBeenCalled(); - expect(issues.updateComment).not.toHaveBeenCalled(); - }); - - it("ignores copied markers from users without write access", async () => { - const { run, github, issues } = reporter(); - github.paginate.mockResolvedValue([comment("contributor", 999), comment()]); - github.rest.repos.getCollaboratorPermissionLevel.mockResolvedValue({ - data: { permission: "read" }, - }); - await run(); - expect(issues.updateComment).toHaveBeenCalledOnce(); - expect(issues.createComment).not.toHaveBeenCalled(); - }); - - it("fails without creating a duplicate when the comment API is unavailable", async () => { - const { run, github, issues } = reporter(); - github.paginate.mockRejectedValue(new Error("API unavailable")); - await expect(run()).rejects.toThrow("API unavailable"); - expect(issues.createComment).not.toHaveBeenCalled(); - expect(issues.updateComment).not.toHaveBeenCalled(); - }); -}); diff --git a/src/gitlab/workflow.test.ts b/src/gitlab/workflow.test.ts index a8a4e98..1275057 100644 --- a/src/gitlab/workflow.test.ts +++ b/src/gitlab/workflow.test.ts @@ -1,5 +1,5 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vite-plus/test"; @@ -8,13 +8,9 @@ import { parse as parseYaml } from "yaml"; const workflow = parseYaml( readFileSync(new URL("../../.github/workflows/gitlab-e2e.yml", import.meta.url), "utf8"), ); -const requestWorkflow = parseYaml( - readFileSync(new URL("../../.github/workflows/e2e-request.yml", import.meta.url), "utf8"), -); const steps = workflow.jobs["gitlab-e2e"].steps as Array<{ id?: string; uses?: string; - if?: string; env?: Record; run: string; }>; @@ -25,7 +21,6 @@ const tempDirs: string[] = []; type ParameterResult = SpawnSyncReturns & { outputs: Record; - summary: string; calls: string; }; @@ -35,27 +30,20 @@ afterEach(() => { } }); -function resolveParameters(overrides: Record = {}): ParameterResult { +function resolveParameters(overrides: Record = {}): ParameterResult { const dir = mkdtempSync(join(tmpdir(), "setup-vp-gitlab-workflow-")); tempDirs.push(dir); const output = join(dir, "output"); - const summary = join(dir, "summary"); const calls = join(dir, "calls"); - for (const file of [output, summary, calls]) { - writeFileSync(file, ""); - } + for (const file of [output, calls]) writeFileSync(file, ""); writeFileSync( join(dir, "gh"), `#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "$*" >> "$MOCK_GH_CALLS" -if [ -n "$MOCK_GH_FAILURE" ] && [[ "$*" == *"$MOCK_GH_FAILURE"* ]]; then - exit 1 -fi +if [ "$MOCK_GH_FAILURE" = true ] || [[ -n "$MOCK_GH_FAILURE" && "$*" == *"$MOCK_GH_FAILURE"* ]]; then exit 1; fi case "$*" in - */permission*) printf '%s\\n' "$MOCK_PERMISSION" ;; - *"contents/.github/workflows/e2e-request.yml?ref=$GITHUB_SHA"*) printf '%s\\n' trusted-blob ;; - *"contents/.github/workflows/e2e-request.yml?ref=$REQUEST_HEAD_SHA"*) printf '%s\\n' "$MOCK_REQUEST_BLOB" ;; + "api repos/upstream/setup-vp/collaborators/reviewer/permission --jq .permission") printf '%s\\n' "$MOCK_PERMISSION" ;; "api repos/upstream/setup-vp/pulls/123") printf '%s\\n' "$MOCK_PR" ;; *) exit 90 ;; esac @@ -69,25 +57,19 @@ esac PATH: `${dir}:${process.env.PATH}`, GH_TOKEN: "test-token", GITHUB_OUTPUT: output, - GITHUB_STEP_SUMMARY: summary, GITHUB_REPOSITORY: "upstream/setup-vp", GITHUB_SHA: baseSha, - GITHUB_ACTOR: "rerunner", - EVENT_NAME: "workflow_run", - REQUEST_EVENT: "pull_request", - REQUEST_CONCLUSION: "success", - REQUEST_PATH: ".github/workflows/e2e-request.yml", - REQUEST_TITLE: `PR #123: labeled run-e2e at ${headSha}`, - REQUEST_ACTOR: "reviewer", - REQUEST_HEAD_SHA: headSha, - REQUEST_HEAD_REPOSITORY: "contributor/setup-vp", - MANUAL_SETUP_REF: "", - MANUAL_SUITE: "", - MANUAL_VITE_PLUS_VERSION: "", + EVENT_NAME: "workflow_dispatch", + EVENT_ACTION: "", + EVENT_LABEL: "", + LABEL_ACTOR: "reviewer", + PR_NUMBER: "", + SETUP_REF: "", + TEST_SUITE: "", + VITE_PLUS_VERSION: "", MOCK_GH_CALLS: calls, - MOCK_GH_FAILURE: "", + MOCK_GH_FAILURE: "false", MOCK_PERMISSION: "write", - MOCK_REQUEST_BLOB: "trusted-blob", ...overrides, }, }); @@ -100,201 +82,212 @@ esac .filter(Boolean) .map((line) => line.split("=")), ), - summary: readFileSync(summary, "utf8"), calls: readFileSync(calls, "utf8"), }; } -describe("GitLab E2E workflow", () => { - it("only accepts PR label requests or manual runs and never checks out PR code", () => { - expect(requestWorkflow.on).toEqual({ - pull_request: { branches: ["main"], types: ["labeled"] }, - }); - expect(requestWorkflow.permissions).toEqual({}); - expect(requestWorkflow.jobs.request.if).toBe("github.event.label.name == 'run-e2e'"); - expect(workflow.on).toEqual({ - workflow_run: { - workflows: [requestWorkflow.name], - types: ["completed"], - }, - workflow_dispatch: { - inputs: { - setup_ref: expect.objectContaining({ required: false, default: "" }), - suite: expect.objectContaining({ - type: "choice", - options: ["full", "required"], - default: "full", - }), - vite_plus_version: expect.objectContaining({ required: false, default: "latest" }), - }, - }, - }); - expect(workflow.jobs["gitlab-e2e"].if.replace(/\s+/g, " ")).toBe( - "github.event_name == 'workflow_dispatch' || " + - "(github.event_name == 'workflow_run' && " + - "github.event.workflow_run.event == 'pull_request' && " + - "github.event.workflow_run.conclusion == 'success' && " + - "github.event.workflow_run.path == '.github/workflows/e2e-request.yml')", +describe("GitLab E2E requests", () => { + it("limits label runs to same-repository PRs and manual runs to main", () => { + expect(Object.keys(workflow.on)).toEqual(["pull_request", "workflow_dispatch"]); + expect(workflow.on.pull_request).toEqual({ branches: ["main"], types: ["labeled"] }); + expect(workflow.on.workflow_dispatch.inputs.pull_request).toEqual( + expect.objectContaining({ required: false, default: "" }), ); - expect(requestWorkflow["run-name"]).toBe( - "PR #${{ github.event.pull_request.number }}: ${{ github.event.action }} ${{ github.event.label.name }} at ${{ github.event.pull_request.head.sha }}", + expect(Object.keys(workflow.jobs)).toEqual(["gitlab-e2e"]); + expect(workflow.jobs["gitlab-e2e"].if.replace(/\s+/g, " ")).toBe( + "(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || " + + "(github.event_name == 'pull_request' && github.event.action == 'labeled' && " + + "github.event.label.name == 'run-e2e' && " + + "github.event.pull_request.head.repo.full_name == github.repository)", ); expect(workflow.permissions).toEqual({ contents: "read", "pull-requests": "read" }); expect(steps.every((step) => !step.uses && !step.run.includes("${{"))).toBe(true); - expect(parameters.env?.REQUEST_HEAD_SHA).toBe("${{ github.event.workflow_run.head_sha }}"); - expect(parameters.env?.REQUEST_ACTOR).toBe("${{ github.event.workflow_run.actor.login }}"); - expect(parameters.env?.MANUAL_SETUP_REF).toBe("${{ inputs.setup_ref }}"); - expect(parameters.env?.MANUAL_SUITE).toBe("${{ inputs.suite }}"); - expect(parameters.env?.MANUAL_VITE_PLUS_VERSION).toBe("${{ inputs.vite_plus_version }}"); - for (const step of steps.slice(1)) { - expect(step.if).toBe("steps.parameters.outputs.should_run == 'true'"); - } + expect(parameters.env).toEqual({ + EVENT_NAME: "${{ github.event_name }}", + EVENT_ACTION: "${{ github.event.action }}", + EVENT_LABEL: "${{ github.event.label.name }}", + LABEL_ACTOR: "${{ github.event.sender.login }}", + PR_NUMBER: "${{ github.event.pull_request.number || inputs.pull_request }}", + SETUP_REF: "${{ github.event.pull_request.head.sha || inputs.setup_ref }}", + TEST_SUITE: "${{ inputs.suite }}", + VITE_PLUS_VERSION: "${{ inputs.vite_plus_version }}", + GH_TOKEN: "${{ github.token }}", + }); + expect(existsSync(new URL("../../.github/workflows/e2e-request.yml", import.meta.url))).toBe( + false, + ); + const trigger = steps.find((step) => step.id === "trigger")!; + expect(trigger.env?.GITLAB_TRIGGER_TOKEN).toBe("${{ secrets.GITLAB_TRIGGER_TOKEN }}"); + expect(trigger.run).toContain("$GITHUB_STEP_SUMMARY"); + expect(steps.at(-1)!.run).toContain("GitLab pipeline finished with status"); }); it.each([ ["defaults", {}, baseSha, "full", "latest"], [ "overrides", - { - MANUAL_SETUP_REF: headSha, - MANUAL_SUITE: "required", - MANUAL_VITE_PLUS_VERSION: "0.3.1", - }, + { SETUP_REF: headSha, TEST_SUITE: "required", VITE_PLUS_VERSION: "0.3.1" }, headSha, "required", "0.3.1", ], - ["release tag", { MANUAL_SETUP_REF: "v1.20.0" }, "v1.20.0", "full", "latest"], - ])("preserves manual run %s", (_name, env, ref, suite, version) => { - const result = resolveParameters({ EVENT_NAME: "workflow_dispatch", ...env }); + ["release tag", { SETUP_REF: "v1.20.0" }, "v1.20.0", "full", "latest"], + ["prerelease tag", { SETUP_REF: "v1.20.0-beta.1" }, "v1.20.0-beta.1", "full", "latest"], + ])("preserves standalone run %s", (_name, env, ref, suite, version) => { + const result = resolveParameters(env); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs).toEqual({ setup_vp_ref: ref, suite, vite_plus_version: version }); + expect(result.calls).toBe(""); + }); + + it.each([ + { SETUP_REF: "main" }, + { SETUP_REF: "$(exit 1)" }, + { SETUP_REF: `${headSha}\nextra=true` }, + { TEST_SUITE: "unknown" }, + { VITE_PLUS_VERSION: "latest\nextra=true" }, + { VITE_PLUS_VERSION: "latest\rextra=true" }, + { PR_NUMBER: "-1", SETUP_REF: headSha }, + { PR_NUMBER: "123\n", SETUP_REF: headSha }, + { PR_NUMBER: "123" }, + { PR_NUMBER: "123", SETUP_REF: "v1.20.0" }, + ])("rejects invalid dispatch inputs %j before API access or outputs", (env) => { + const result = resolveParameters(env); + expect(result.status).toBe(1); + expect(result.outputs).toEqual({}); + expect(result.calls).toBe(""); + }); +}); + +describe("GitLab E2E label approval", () => { + const approvedPr = { + state: "open", + head: { sha: headSha, repo: { full_name: "upstream/setup-vp" } }, + base: { ref: "main", repo: { full_name: "upstream/setup-vp" } }, + labels: [{ name: "run-e2e" }], + }; + function resolveLabel(overrides: Record = {}) { + return resolveParameters({ + EVENT_NAME: "pull_request", + EVENT_ACTION: "labeled", + EVENT_LABEL: "run-e2e", + PR_NUMBER: "123", + SETUP_REF: headSha, + MOCK_PR: JSON.stringify(approvedPr), + ...overrides, + }); + } + + it.each(["write", "admin"])("accepts a labeler with %s access", (permission) => { + const result = resolveLabel({ + MOCK_PERMISSION: permission, + TEST_SUITE: "required", + VITE_PLUS_VERSION: "0.3.1", + GITHUB_ACTOR: "rerunner", + GITHUB_TRIGGERING_ACTOR: "rerunner", + }); expect(result.status, result.stderr).toBe(0); expect(result.outputs).toEqual({ - should_run: "true", - setup_vp_ref: ref, - suite, - vite_plus_version: version, - pr_number: "", + setup_vp_ref: headSha, + suite: "full", + vite_plus_version: "latest", }); + expect(result.calls).toBe( + "api repos/upstream/setup-vp/collaborators/reviewer/permission --jq .permission\n" + + "api repos/upstream/setup-vp/pulls/123\n", + ); + }); + + it.each(["read", "triage", "none", ""])("rejects a labeler with %s access", (permission) => { + const result = resolveLabel({ MOCK_PERMISSION: permission }); + expect(result.status).toBe(1); + expect(result.outputs).toEqual({}); + expect(result.calls).not.toContain("/pulls/"); + }); + + it.each([ + { EVENT_NAME: "push" }, + { EVENT_NAME: "pull_request_target" }, + { EVENT_ACTION: "synchronize" }, + { EVENT_ACTION: "unlabeled" }, + { EVENT_LABEL: "bug" }, + { PR_NUMBER: "" }, + { PR_NUMBER: "123\n" }, + { SETUP_REF: "" }, + { SETUP_REF: "v1.20.0" }, + { SETUP_REF: `${headSha}\nextra=true` }, + ])("rejects an invalid label event %j before API access", (env) => { + const result = resolveLabel(env); + expect(result.status).toBe(1); + expect(result.outputs).toEqual({}); expect(result.calls).toBe(""); - expect(result.summary).toBe(""); + }); + + it.each([ + ["new commit", { head: { ...approvedPr.head, sha: baseSha } }], + ["fork", { head: { ...approvedPr.head, repo: { full_name: "contributor/setup-vp" } } }], + ["deleted repository", { head: { ...approvedPr.head, repo: null } }], + ["base change", { base: { ...approvedPr.base, ref: "release" } }], + ["closed PR", { state: "closed" }], + ["removed label", { labels: [] }], + ])("rejects a label request after a %s", (_reason, changes) => { + const result = resolveLabel({ MOCK_PR: JSON.stringify({ ...approvedPr, ...changes }) }); + expect(result.status).toBe(1); + expect(result.outputs).toEqual({}); + }); + + it.each(["/permission", "/pulls/"])("fails closed if the %s API fails", (endpoint) => { + const result = resolveLabel({ MOCK_GH_FAILURE: endpoint }); + expect(result.status).toBe(1); + expect(result.outputs).toEqual({}); }); }); describe.each(["upstream/setup-vp", "contributor/setup-vp"])( - "GitLab E2E approval from %s", + "GitLab E2E reviewed commit from %s", (headRepository) => { const approvedPr = { state: "open", head: { sha: headSha, repo: { full_name: headRepository } }, base: { ref: "main", repo: { full_name: "upstream/setup-vp" } }, - labels: [{ name: "run-e2e" }], }; - - function resolveRequest(overrides: Record = {}): ParameterResult { + function resolveRequest(overrides: Record = {}) { return resolveParameters({ - REQUEST_HEAD_REPOSITORY: headRepository, + PR_NUMBER: "123", + SETUP_REF: headSha, MOCK_PR: JSON.stringify(approvedPr), ...overrides, }); } - - it.each(["write", "admin"])( - "runs the full suite at the event SHA with %s access", - (permission) => { - const result = resolveRequest({ MOCK_PERMISSION: permission }); - expect(result.status, result.stderr).toBe(0); - expect(result.outputs).toEqual({ - should_run: "true", - setup_vp_ref: headSha, - suite: "full", - vite_plus_version: "latest", - pr_number: "123", - }); - expect(result.calls).toContain("collaborators/reviewer/permission"); - expect(result.calls).not.toContain("/files"); - }, - ); - - it.each(["read", "none", ""])("rejects approval with %s permission", (permission) => { - const result = resolveRequest({ MOCK_PERMISSION: permission }); - expect(result.status).toBe(1); - expect(result.stdout).toContain("requires repository write access"); - expect(result.outputs.should_run).toBeUndefined(); - expect(result.calls).not.toContain("/pulls/"); + it("tests only the reviewed head, including PRs from forks", () => { + const result = resolveRequest(); + expect(result.status, result.stderr).toBe(0); + expect(result.outputs).toEqual({ + setup_vp_ref: headSha, + suite: "full", + vite_plus_version: "latest", + }); + expect(result.calls).toBe("api repos/upstream/setup-vp/pulls/123\n"); }); - - it.each(["/permission", "/pulls/", `?ref=${baseSha}`, `?ref=${headSha}`])( - "fails closed when the %s API request fails", - (endpoint) => { - const result = resolveRequest({ MOCK_GH_FAILURE: endpoint }); - expect(result.status).toBe(1); - expect(result.outputs.should_run).toBeUndefined(); - }, - ); - it.each([ ["new commit", { head: { ...approvedPr.head, sha: "c".repeat(40) } }], - [ - "different head repository", - { head: { ...approvedPr.head, repo: { full_name: "other/setup-vp" } } }, - ], + ["deleted fork", { head: { ...approvedPr.head, repo: null } }], ["base change", { base: { ...approvedPr.base, ref: "release" } }], [ "different repository", { base: { ...approvedPr.base, repo: { full_name: "other/setup-vp" } } }, ], ["closed PR", { state: "closed" }], - ["removed label", { labels: [{ name: "other-label" }] }], - ["missing label", { labels: [] }], - ])("skips a stale approval after a %s", (_reason, changes) => { + ])("rejects a stale request after a %s", (_reason, changes) => { const result = resolveRequest({ MOCK_PR: JSON.stringify({ ...approvedPr, ...changes }) }); - expect(result.status, result.stderr).toBe(0); - expect(result.outputs.should_run).toBe("false"); - expect(result.outputs.pr_number).toBe(""); - expect(result.summary).toContain("This approval is stale"); - }); - - it.each([ - ["unrelated label", { REQUEST_TITLE: `PR #123: labeled bug at ${headSha}` }], - ["new push", { REQUEST_TITLE: `PR #123: synchronize run-e2e at ${headSha}` }], - ["different event", { REQUEST_EVENT: "push" }], - ["failed request", { REQUEST_CONCLUSION: "failure" }], - ["different workflow", { REQUEST_PATH: ".github/workflows/fake-request.yml" }], - ["malformed title", { REQUEST_TITLE: "PR #123: labeled run-e2e at $(exit 1)" }], - ])("does not approve a privileged run for a %s", (_reason, env) => { - const result = resolveRequest(env); - expect(result.status, result.stderr).toBe(0); - expect(result.outputs.should_run).toBe("false"); - expect(result.outputs.pr_number).toBe(""); - expect(result.calls).toBe(""); - }); - - it("rejects a title that claims a different commit than the request run", () => { - const result = resolveRequest({ REQUEST_TITLE: `PR #123: labeled run-e2e at ${baseSha}` }); expect(result.status).toBe(1); - expect(result.outputs.should_run).toBeUndefined(); - expect(result.calls).toBe(""); + expect(result.outputs).toEqual({}); + expect(result.stdout).toContain("Review its current head"); }); - - it("rejects a modified request workflow even with a valid title and write access", () => { - const result = resolveRequest({ MOCK_REQUEST_BLOB: "modified-blob" }); + it("fails closed when the PR API fails", () => { + const result = resolveRequest({ MOCK_GH_FAILURE: "true" }); expect(result.status).toBe(1); - expect(result.stdout).toContain("must include e2e-request.yml unchanged from main"); - expect(result.outputs.should_run).toBeUndefined(); - expect(result.calls).not.toContain("/pulls/"); + expect(result.outputs).toEqual({}); }); - - it.each(["pull_request", "push", "merge_group"])( - "skips %s events even when the PR has the approval label", - (event) => { - const result = resolveRequest({ EVENT_NAME: event }); - expect(result.status, result.stderr).toBe(0); - expect(result.outputs.should_run).toBe("false"); - expect(result.outputs.setup_vp_ref).toBe(""); - expect(result.outputs.pr_number).toBe(""); - expect(result.calls).toBe(""); - expect(result.summary).toContain("GitLab E2E requires a successful run-e2e label request"); - }, - ); }, ); diff --git a/src/rebuild-bundle-workflow.test.ts b/src/rebuild-bundle-workflow.test.ts index 23fb3a8..2fa068f 100644 --- a/src/rebuild-bundle-workflow.test.ts +++ b/src/rebuild-bundle-workflow.test.ts @@ -69,8 +69,8 @@ function fixture() { }; const context = { repo: { owner: "upstream", repo: "setup-vp" }, - payload: { workflow_run: { head_sha: headSha, head_branch: pr.head.ref } }, }; + const request = { headSha, branch: pr.head.ref }; const tree = { truncated: false, tree: bundleFiles.map((file) => ({ @@ -101,7 +101,8 @@ function fixture() { env: { RUNNER_TEMP: dir, PR_NUMBER: String(pr.number), - PR_BRANCH: context.payload.workflow_run.head_branch, + PR_BRANCH: request.branch, + PR_HEAD_SHA: request.headSha, }, }, }); @@ -112,6 +113,7 @@ function fixture() { pr, tree, context, + request, github, core, prepare: () => run(prepareStep), @@ -146,9 +148,13 @@ describe("bundle rebuild credential isolation", () => { }); it("creates a contents-only App token after validation in a default-branch workflow", () => { - expect(publisher.on).toEqual({ - workflow_run: { workflows: [workflow.name], types: ["completed"] }, + expect(Object.keys(publisher.on)).toEqual(["workflow_dispatch"]); + expect(publisher.on.workflow_dispatch.inputs.run_id).toEqual({ + description: "Successful Rebuild action bundle run ID to publish.", + required: true, + type: "string", }); + expect(publisher.jobs.publish.if).toBe("github.ref == 'refs/heads/main'"); expect(publisher.permissions).toEqual({}); expect(publisher.jobs.publish.permissions).toEqual({ actions: "read", @@ -164,10 +170,10 @@ describe("bundle rebuild credential isolation", () => { ]); expect(commitSteps.every((step) => !step.run && /@[a-f0-9]{40}$/.test(step.uses!))).toBe(true); expect(commitSteps[1].with).toEqual({ - name: "rebuilt-bundles-${{ github.event.workflow_run.run_attempt }}", + name: "rebuilt-bundles-${{ steps.request.outputs.run-attempt }}", path: "${{ runner.temp }}/rebuilt-bundles", "github-token": "${{ github.token }}", - "run-id": "${{ github.event.workflow_run.id }}", + "run-id": "${{ steps.request.outputs.run-id }}", }); expect(buildSteps.at(-1)!.with!.name).toBe("rebuilt-bundles-${{ github.run_attempt }}"); expect(commitSteps[3].with).toEqual({ @@ -315,7 +321,7 @@ describe("bundle artifact validation and commit", () => { const f = fixture(); const branch = "renovate/$(echo-injected)"; f.pr.head.ref = branch; - f.context.payload.workflow_run.head_branch = branch; + f.request.branch = branch; const content = "`${process.env.APP_TOKEN}`\n$(echo injected)\n\u0000\u00e9"; writeFileSync(join(f.root, "index.mjs"), content); await f.prepare();