diff --git a/.changeset/require-human-release-approval.md b/.changeset/require-human-release-approval.md new file mode 100644 index 0000000000..b7ab418a11 --- /dev/null +++ b/.changeset/require-human-release-approval.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Require a human approval on the final release PR head before publishing committed protocol artifacts, and leave generated documentation snapshots open for human review. diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index b5ab3af4ac..f923cd2518 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -125,9 +125,3 @@ jobs: add-paths: | dist/docs/${{ steps.version.outputs.version }}/ docs.json - - - name: Enable auto-merge - if: steps.create-pr.outputs.pull-request-number - run: gh pr merge --auto --squash "${{ steps.create-pr.outputs.pull-request-number }}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 900049249b..0075d183ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,50 @@ jobs: echo "No committed release artifacts detected for ${VERSION}." fi + # Branch rules can be bypassed by repository administrators and GitHub + # Apps. Before publishing committed artifacts, independently verify that + # the merged release PR received a human approval on its final head SHA. + - name: Require human approval for committed release artifacts + if: steps.release-artifacts.outputs.has_release_artifacts == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + pulls="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + "/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls")" + pr_number="$(jq -r \ + --arg base "${GITHUB_REF_NAME}" \ + '[.[] | select(.merged_at != null and .base.ref == $base)] | sort_by(.merged_at) | last | .number // empty' \ + <<< "${pulls}")" + + if [ -z "${pr_number}" ]; then + echo "::error::Release artifact commit ${GITHUB_SHA} is not associated with a merged PR targeting ${GITHUB_REF_NAME}." + exit 1 + fi + + pr="$(gh api "/repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")" + head_sha="$(jq -r '.head.sha' <<< "${pr}")" + author="$(jq -r '.user.login' <<< "${pr}")" + reviews="$(gh api --paginate "/repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/reviews" | jq -s 'add')" + approved_count="$(jq \ + --arg head "${head_sha}" \ + '[.[] | select(.user.type == "User")] + | sort_by(.user.login, .submitted_at) + | group_by(.user.login) + | map(last) + | map(select(.state == "APPROVED" and .commit_id == $head)) + | length' \ + <<< "${reviews}")" + + if [ "${approved_count}" -lt 1 ]; then + echo "::error::Release PR #${pr_number} by ${author} has no human approval on final head ${head_sha}. Refusing to publish artifacts." + exit 1 + fi + + echo "Release PR #${pr_number} has ${approved_count} human approval(s) on final head ${head_sha}." + - name: Create Release Pull Request or Tag Release if: steps.release-relevance.outputs.relevant == 'true' && steps.release-artifacts.outputs.has_release_artifacts != 'true' id: changesets diff --git a/tests/release-workflow-immutability.test.cjs b/tests/release-workflow-immutability.test.cjs index 1451239cd1..d13a107c5c 100644 --- a/tests/release-workflow-immutability.test.cjs +++ b/tests/release-workflow-immutability.test.cjs @@ -7,6 +7,10 @@ const assert = require('assert'); const repoRoot = path.join(__dirname, '..'); const workflowPath = path.join(repoRoot, '.github/workflows/release.yml'); const workflow = fs.readFileSync(workflowPath, 'utf8'); +const releaseDocsWorkflow = fs.readFileSync( + path.join(repoRoot, '.github/workflows/release-docs.yml'), + 'utf8' +); const eolReleaseBranches = ['2.5-maintenance', '2.6.x']; const activeWorkflowPaths = [ 'apps-web-check.yml', @@ -33,6 +37,7 @@ function extractStep(name) { const releaseRelevance = extractStep('Detect release-relevant push'); const artifactDetection = extractStep('Detect committed release artifacts'); +const approvalGate = extractStep('Require human approval for committed release artifacts'); const changesetsStep = extractStep('Create Release Pull Request or Tag Release'); const uploadStep = extractStep('Upload protocol tarball to GitHub Release'); @@ -64,6 +69,30 @@ assert( 'Release artifact detection must be based on artifact paths changed by the triggering commit.' ); +assert( + approvalGate.includes("if: steps.release-artifacts.outputs.has_release_artifacts == 'true'"), + 'Human approval must be required whenever a commit contains release artifacts.' +); + +assert( + approvalGate.includes('/commits/${GITHUB_SHA}/pulls') && + approvalGate.includes('.base.ref == $base') && + approvalGate.includes('.merged_at != null'), + 'The approval gate must resolve the merged PR associated with the release commit and branch.' +); + +assert( + approvalGate.includes('select(.user.type == "User")') && + approvalGate.includes('map(last)') && + approvalGate.includes('select(.state == "APPROVED" and .commit_id == $head)'), + 'Only human approvals submitted against the final release PR head may authorize publication.' +); + +assert( + !releaseDocsWorkflow.includes('gh pr merge --auto'), + 'Release documentation snapshots must wait for human review instead of enabling auto-merge.' +); + assert( changesetsStep.includes("HUSKY: '0'"), 'Changesets automation must not rerun local pre-commit hooks while creating its release commit.'