diff --git a/.circleci/config.yml b/.circleci/config.yml index 4c56948a5c..6c525fcd11 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,6 +4,14 @@ version: 2.1 orbs: win: circleci/windows@5.0 +parameters: + # Set only by .github/workflows/fork-ci-mirror.yml through the CircleCI + # trigger API, after a maintainer approved a fork PR head with the ci:run + # label. GitHub App pipelines never fire for fork PRs on their own. + fork_pr: + type: string + default: "" + jobs: pr-check: executor: @@ -22,6 +30,7 @@ jobs: # from the environment with [AllowEmptyString] defaults. CBX_PR_URL: << pipeline.event.context.github.pr_url >> CBX_PR_BASE_SHA: << pipeline.event.github.pull_request.base.sha >> + CBX_FORK_PR: << pipeline.parameters.fork_pr >> steps: - checkout - run: @@ -81,7 +90,8 @@ workflows: # Semantic CI boundary: release-integration micro PRs target port/upstream-*. # A micro-named head aimed directly at main still receives full hosted CI. # Guard the PR-only value behind event.name so push pipelines compile safely. - when: pipeline.event.name == "push" or (pipeline.event.name == "pull_request" and not (pipeline.event.github.pull_request.base.ref starts-with "port/upstream-")) + # API pipelines run only with fork_pr set (the fork-ci-mirror workflow). + when: pipeline.event.name == "push" or (pipeline.event.name == "pull_request" and not (pipeline.event.github.pull_request.base.ref starts-with "port/upstream-")) or pipeline.parameters.fork_pr != "" jobs: - pr-check: filters: diff --git a/.github/CI.md b/.github/CI.md index 2b05fa0687..a42dd22a12 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -45,10 +45,23 @@ build one: with this project's environment variables. 2. Add the `ci:run` label. .github/workflows/fork-ci-mirror.yml pushes exactly that head commit to ci/pr- in this repository. -3. CircleCI builds the ci/pr- push. The trigger gate in - scripts/circleci-pr-common.ps1 lets those branches through, and the - docs-only gate finds the PR base from the number. The check lands on the - same commit, so it shows on the fork PR. +3. The same workflow starts a CircleCI pipeline through the trigger API, + with the config from main and the code from ci/pr-. A plain push + is not enough: CircleCI ignores a branch push that brings no new commits, + and the fork head is already in this repository through its pull ref. + The pipeline's fork_pr parameter lets it through the trigger gate in + scripts/circleci-pr-common.ps1, and the docs-only gate finds the PR base + from the number. The check lands on the same commit, so it shows on the + fork PR. + +One-time setup for step 3: + +- Actions secret CIRCLECI_TOKEN: a CircleCI personal API token (User + Settings -> Personal API Tokens) from a user with access to this project. +- Actions variable CIRCLECI_PROJECT_SLUG: Project Settings -> Overview, for + example circleci//. +- Actions variable CIRCLECI_PIPELINE_DEFINITION_ID: Project Settings -> + Pipelines, the id of the pipeline that runs .circleci/config.yml. Any new push to the fork PR removes the label; re-add it after reviewing the new commits. Closing the PR removes the mirror branch. GITHUB_TOKEN cannot diff --git a/.github/workflows/fork-ci-mirror.yml b/.github/workflows/fork-ci-mirror.yml index 0a2d705159..52b9a3ead4 100644 --- a/.github/workflows/fork-ci-mirror.yml +++ b/.github/workflows/fork-ci-mirror.yml @@ -3,15 +3,17 @@ name: Fork PR CI mirror # CircleCI GitHub App pipelines never fire for pull requests from forks, so # fork PRs used to merge with no hosted Windows check. A maintainer approves a # fork PR's current head by adding the `ci:run` label. This workflow then -# copies exactly that commit to ci/pr- in this repository. CircleCI -# builds the push (scripts/circleci-pr-common.ps1 lets ci/pr- through), -# and its check lands on the same commit, so it shows on the fork PR. +# copies exactly that commit to ci/pr- in this repository and starts a +# CircleCI pipeline for it through the trigger API (secret CIRCLECI_TOKEN, +# variables CIRCLECI_PROJECT_SLUG and CIRCLECI_PIPELINE_DEFINITION_ID). The +# check lands on the same commit, so it shows on the fork PR. # # Safety: # - pull_request_target runs this file from the base branch, never the fork's # copy. No step checks out, installs, or executes fork code here; the job -# only moves a git ref. The fork code runs later on CircleCI, which is the -# reason for the label gate. +# only moves a git ref and calls the CircleCI API. The fork code runs later +# on CircleCI, which is the reason for the label gate. CIRCLECI_TOKEN is +# exposed only to that API step, never to fork code. # - Only users with triage access or higher can add labels. # - Any new push to the fork PR removes the label, so every new head needs a # fresh maintainer approval before CircleCI builds it. @@ -60,7 +62,34 @@ jobs: test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA" # The mirror branch belongs to this workflow; a re-approval replaces it. git -c http.extraheader="$auth" push --quiet --force origin "${HEAD_SHA}:refs/heads/ci/pr-${PR_NUMBER}" - echo "Mirrored ${HEAD_SHA} to ci/pr-${PR_NUMBER}; CircleCI pr-check will report on this commit." >> "$GITHUB_STEP_SUMMARY" + echo "Mirrored ${HEAD_SHA} to ci/pr-${PR_NUMBER}." >> "$GITHUB_STEP_SUMMARY" + + # CircleCI ignores a branch push that brings no new commits, and the + # fork head is already in this repository through its pull ref. So the + # pipeline is started through the trigger API: config from main (the + # fork cannot change the CI definition), code from ci/pr-. + - name: Start the CircleCI pipeline + env: + CIRCLECI_TOKEN: ${{ secrets.CIRCLECI_TOKEN }} + PROJECT_SLUG: ${{ vars.CIRCLECI_PROJECT_SLUG }} + DEFINITION_ID: ${{ vars.CIRCLECI_PIPELINE_DEFINITION_ID }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ -z "$CIRCLECI_TOKEN" ] || [ -z "$PROJECT_SLUG" ] || [ -z "$DEFINITION_ID" ]; then + echo "::error::Set secret CIRCLECI_TOKEN and variables CIRCLECI_PROJECT_SLUG and CIRCLECI_PIPELINE_DEFINITION_ID (see .github/CI.md)." + exit 1 + fi + body=$(jq -n --arg def "$DEFINITION_ID" --arg pr "$PR_NUMBER" \ + '{definition_id: $def, config: {branch: "main"}, checkout: {branch: ("ci/pr-" + $pr)}, parameters: {fork_pr: $pr}}') + status=$(curl -sS -o response.json -w '%{http_code}' -X POST \ + -H "Circle-Token: ${CIRCLECI_TOKEN}" -H 'Content-Type: application/json' \ + --data "$body" "https://circleci.com/api/v2/project/${PROJECT_SLUG}/pipeline/run") + if [ "$status" != "201" ]; then + echo "::error::CircleCI trigger returned HTTP ${status}: $(jq -r '.message // empty' response.json)" + exit 1 + fi + echo "CircleCI pipeline #$(jq -r .number response.json) started for ci/pr-${PR_NUMBER}; pr-check will report on the fork PR's head commit." >> "$GITHUB_STEP_SUMMARY" revoke: name: Require re-approval after a new push diff --git a/scripts/circleci-pr-common.ps1 b/scripts/circleci-pr-common.ps1 index 53427742da..eb1e401adc 100644 --- a/scripts/circleci-pr-common.ps1 +++ b/scripts/circleci-pr-common.ps1 @@ -34,7 +34,8 @@ function Get-TriggerGateDecision { param( [AllowEmptyString()][string]$BudgetMode, [AllowEmptyString()][string]$Branch, - [AllowEmptyString()][string]$PrUrl + [AllowEmptyString()][string]$PrUrl, + [AllowEmptyString()][string]$ForkPr = '' ) # Gate 1 - budget: CI_BUDGET_MODE=off is the emergency stop @@ -55,7 +56,7 @@ function Get-TriggerGateDecision { # those pushes run the checks too. $isPr = -not [string]::IsNullOrWhiteSpace($PrUrl) $isMainPush = $Branch -in @('main', 'master') - $isForkMirror = -not [string]::IsNullOrWhiteSpace((Get-ForkMirrorPrNumber -Branch $Branch)) + $isForkMirror = -not [string]::IsNullOrWhiteSpace((Get-ForkMirrorPrNumber -Branch $Branch -ForkPr $ForkPr)) if (-not $isPr -and -not $isMainPush -and -not $isForkMirror) { return [pscustomobject]@{ Skip = $true @@ -71,12 +72,18 @@ function Get-TriggerGateDecision { <# .SYNOPSIS - PR number of a ci/pr- fork-mirror branch, or '' for any other - branch. The fork-ci-mirror workflow is the only writer of these branches. + PR number of an approved fork mirror, or '' when the pipeline is not one. + The fork-ci-mirror workflow is the only writer of ci/pr- + branches, and the only caller that sets the fork_pr pipeline parameter + (passed in as ForkPr) through the CircleCI trigger API. #> function Get-ForkMirrorPrNumber { - param([AllowEmptyString()][string]$Branch) + param( + [AllowEmptyString()][string]$Branch, + [AllowEmptyString()][string]$ForkPr = '' + ) + if ($ForkPr -match '^[1-9]\d*$') { return $ForkPr } if ($Branch -match '^ci/pr-([1-9]\d*)$') { return $Matches[1] } return '' } diff --git a/scripts/circleci-pr-gates.ps1 b/scripts/circleci-pr-gates.ps1 index 37db10d499..795a35cd37 100644 --- a/scripts/circleci-pr-gates.ps1 +++ b/scripts/circleci-pr-gates.ps1 @@ -42,6 +42,10 @@ param( # push pipelines). [AllowEmptyString()][string]$PrBaseSha = $env:CBX_PR_BASE_SHA, + # Pipeline parameter fork_pr, exported as CBX_FORK_PR. Set only on API + # pipelines started by .github/workflows/fork-ci-mirror.yml. + [AllowEmptyString()][string]$ForkPr = $env:CBX_FORK_PR, + # CIRCLE_SHA1. [AllowEmptyString()][string]$Sha = $env:CIRCLE_SHA1, @@ -74,7 +78,7 @@ function Invoke-GateHalt { } # Gates 1 and 2: budget emergency stop, then PR/main-master scope. -$trigger = Get-TriggerGateDecision -BudgetMode $BudgetMode -Branch $Branch -PrUrl $PrUrl +$trigger = Get-TriggerGateDecision -BudgetMode $BudgetMode -Branch $Branch -PrUrl $PrUrl -ForkPr $ForkPr if ($trigger.Skip) { Invoke-GateHalt -Reason $trigger.Reason exit 0 @@ -94,7 +98,7 @@ if ($isMainPush) { # PR association without a populated event value (e.g. api trigger): # resolve the base from the public GitHub pulls API. try { - $prNumber = Get-ForkMirrorPrNumber -Branch $Branch + $prNumber = Get-ForkMirrorPrNumber -Branch $Branch -ForkPr $ForkPr if ($PrUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] } if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' } if ([string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_USERNAME) -or [string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_REPONAME)) { diff --git a/scripts/circleci-pr.tests.ps1 b/scripts/circleci-pr.tests.ps1 index 20338cd0e0..a079051153 100644 --- a/scripts/circleci-pr.tests.ps1 +++ b/scripts/circleci-pr.tests.ps1 @@ -98,6 +98,15 @@ foreach ($branch in @('ci/pr-', 'ci/pr-0', 'ci/pr-12x', 'ci/pr-7/extra', 'x/ci/p } Assert-Equal (Get-ForkMirrorPrNumber -Branch 'ci/pr-750') '750' 'mirror PR number parsed' Assert-Equal (Get-ForkMirrorPrNumber -Branch 'main') '' 'non-mirror branch has no PR number' +$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'pull/750/head' -PrUrl '' -ForkPr '750' +Assert-True (-not $decision.Skip) 'API fork pipeline with fork_pr runs whatever the branch name' +$decision = Get-TriggerGateDecision -BudgetMode 'off' -Branch '' -PrUrl '' -ForkPr '750' +Assert-True $decision.Skip 'budget off wins over fork_pr' +foreach ($forkPr in @('0', 'abc', '7 ', '-1', '12x')) { + $decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'feature/x' -PrUrl '' -ForkPr $forkPr + Assert-True $decision.Skip "malformed fork_pr '$forkPr' skips" +} +Assert-Equal (Get-ForkMirrorPrNumber -Branch 'ci/pr-12' -ForkPr '750') '750' 'fork_pr wins over the branch name' Write-Host '==> Hosted Node provisioning guard' $prRunnerText = Get-Content -Raw -LiteralPath (Join-Path $scriptRoot 'run-circleci-pr-check.ps1')