Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/require-human-release-approval.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 0 additions & 6 deletions .github/workflows/release-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests/release-workflow-immutability.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');

Expand Down Expand Up @@ -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.'
Expand Down
Loading