diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72b6e3d..11f89ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,9 @@ jobs: release_tag: ${{ steps.resolve.outputs.release_tag }} version: ${{ steps.resolve.outputs.version }} release_target_sha: ${{ steps.resolve.outputs.release_target_sha }} + trusted_base_sha: ${{ steps.resolve.outputs.trusted_base_sha }} + merged_pr_base_sha: ${{ steps.resolve.outputs.merged_pr_base_sha }} + reviewed_pr_head_sha: ${{ steps.resolve.outputs.reviewed_pr_head_sha }} record_reused: ${{ steps.resolve.outputs.record_reused }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -55,12 +58,18 @@ jobs: GH_TOKEN: ${{ github.token }} BASE_BRANCH: main EXPECTED_RELEASE_PLEASE_LOGIN: ${{ vars.RELEASE_PLEASE_AUTOMATION_LOGIN || 'github-actions[bot]' }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + PUSH_FORCED: ${{ github.event.forced }} REPAIR_TAG: ${{ inputs.release_tag }} OPERATION: ${{ inputs.operation }} shell: bash run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ "$GITHUB_REF" != "refs/heads/${BASE_BRANCH}" ]]; then + echo "Manual release operations must run from refs/heads/${BASE_BRANCH}, not ${GITHUB_REF}." >&2 + exit 1 + fi if [[ "$OPERATION" == "maintenance" ]]; then { echo "mode=maintenance" @@ -68,6 +77,9 @@ jobs: echo "release_tag=" echo "version=" echo "release_target_sha=" + echo "trusted_base_sha=" + echo "merged_pr_base_sha=" + echo "reviewed_pr_head_sha=" echo "record_reused=false" } >> "$GITHUB_OUTPUT" exit 0 @@ -80,6 +92,7 @@ jobs: echo "Manual repair requires release_tag." >&2 exit 1 fi + workflow_policy_sha=$(git rev-parse HEAD) gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${REPAIR_TAG}" >/dev/null candidate_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${REPAIR_TAG}" --jq .sha) version=${REPAIR_TAG#v} @@ -97,29 +110,36 @@ jobs: git for-each-ref --format='%(contents)' "refs/tags/${REPAIR_TAG}" > persisted-candidate.json pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) admitted_automation_identity=$(jq -er '.automationIdentity | select(type == "string" and length > 0)' persisted-candidate.json) + if [[ "$admitted_automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then + echo "Persisted repair identity does not match the configured Release Please identity." >&2 + exit 1 + fi release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") base_branch=$(jq -r .base.ref <<< "$release_pr") + merged_pr_base_sha=$(jq -r .base.sha <<< "$release_pr") + reviewed_pr_head_sha=$(jq -r .head.sha <<< "$release_pr") automation_identity=$(jq -r .user.login <<< "$release_pr") merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") - if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$automation_identity" != "$admitted_automation_identity" || "$merge_commit_sha" != "$candidate_sha" ]]; then - echo "Repair provenance does not match a merged Release Please candidate." >&2 + if [[ "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then + echo "Fresh repair PR identity does not match the configured Release Please identity." >&2 exit 1 fi - parent_count=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}" --jq '.parents | length') - if [[ "$parent_count" != "2" ]]; then - echo "Repair provenance is not a supported two-parent merge commit." >&2 + if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$merge_commit_sha" != "$candidate_sha" ]]; then + echo "Repair provenance does not match a merged Release Please candidate." >&2 exit 1 fi + candidate_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}") + candidate_parent_shas=$(jq -c '[.parents[].sha]' <<< "$candidate_commit") + trusted_base_sha=$(git rev-parse "${candidate_sha}^1") projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') printf '%s\n' "$projection" > repair-projection.json - base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${candidate_sha}" --jq '.parents[0].sha') # Re-run the trusted base parent's admission policy. Current projection # rules may legitimately remove generated paths after an older release. - git checkout --detach "$base_parent" + git checkout --detach "$trusted_base_sha" projection_result=$(bun run scripts/release-projection.ts \ - --base "$base_parent" \ + --base "$trusted_base_sha" \ --head "$candidate_sha" \ --projection repair-projection.json \ --json) @@ -132,13 +152,20 @@ jobs: --arg automationIdentity "$automation_identity" \ --arg mergeCommit "$merge_commit_sha" \ --arg projectionDigest "$projection_digest" \ + --argjson candidateParentShas "$candidate_parent_shas" \ + --arg trustedBaseSha "$trusted_base_sha" \ + --arg mergedPrBaseSha "$merged_pr_base_sha" \ + --arg reviewedPrHeadSha "$reviewed_pr_head_sha" \ --argjson changedFiles "$(jq '[.[].filename]' repair-projection.json)" \ --argjson changedFileStatuses "$(jq '[.[].status]' repair-projection.json)" \ - '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,mergeMode:"merge",changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest}' \ + '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest,candidateParentShas:$candidateParentShas,trustedBaseSha:$trustedBaseSha,mergedPrBaseSha:$mergedPrBaseSha,reviewedPrHeadSha:$reviewedPrHeadSha}' \ > trusted-repair-candidate.json + # The historical base owns projection policy; the workflow source + # owns the current topology and identity admission contract. + git checkout --detach "$workflow_policy_sha" REPAIR_REPOSITORY="$GITHUB_REPOSITORY" \ REPAIR_BASE_BRANCH="$BASE_BRANCH" \ - REPAIR_AUTOMATION_LOGIN="$admitted_automation_identity" \ + REPAIR_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \ REPAIR_TAG="$REPAIR_TAG" \ REPAIR_SHA="$candidate_sha" \ REPAIR_VERSION="$manifest_version" \ @@ -146,9 +173,20 @@ jobs: bun -e ' import { readFileSync } from "node:fs"; import { validateRepairCandidateBinding } from "./scripts/release-validate.ts"; + const { + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + ...trustedCandidate + } = JSON.parse(readFileSync("trusted-repair-candidate.json", "utf8")); validateRepairCandidateBinding({ candidate: JSON.parse(readFileSync("persisted-candidate.json", "utf8")), - trustedCandidate: JSON.parse(readFileSync("trusted-repair-candidate.json", "utf8")), + trustedCandidate, + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, repository: process.env.REPAIR_REPOSITORY ?? "", expectedBaseBranch: process.env.REPAIR_BASE_BRANCH ?? "", expectedAutomationIdentities: [process.env.REPAIR_AUTOMATION_LOGIN ?? ""], @@ -165,11 +203,19 @@ jobs: echo "release_tag=${REPAIR_TAG}" echo "version=${version}" echo "release_target_sha=${release_target_sha}" + echo "trusted_base_sha=${trusted_base_sha}" + echo "merged_pr_base_sha=${merged_pr_base_sha}" + echo "reviewed_pr_head_sha=${reviewed_pr_head_sha}" echo "record_reused=false" } >> "$GITHUB_OUTPUT" exit 0 fi + if [[ "$PUSH_FORCED" != "false" ]]; then + echo "Push event forced flag is not a proven non-forced push." >&2 + exit 1 + fi + associated_prs=$(gh api \ -H "Accept: application/vnd.github+json" \ "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls") @@ -182,6 +228,9 @@ jobs: echo "release_tag=" echo "version=" echo "release_target_sha=" + echo "trusted_base_sha=" + echo "merged_pr_base_sha=" + echo "reviewed_pr_head_sha=" echo "record_reused=false" } >> "$GITHUB_OUTPUT" exit 0 @@ -194,6 +243,8 @@ jobs: release_pr=$(jq -c '.[0]' <<< "$release_prs") pr_number=$(jq -r .number <<< "$release_pr") base_branch=$(jq -r .base.ref <<< "$release_pr") + merged_pr_base_sha=$(jq -r .base.sha <<< "$release_pr") + reviewed_pr_head_sha=$(jq -r .head.sha <<< "$release_pr") automation_identity=$(jq -r .user.login <<< "$release_pr") merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") @@ -201,18 +252,23 @@ jobs: echo "Release Please candidate identity, base branch, merge state, or merge_commit_sha is not bound to github.sha." >&2 exit 1 fi - parent_count=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" --jq '.parents | length') - if [[ "$parent_count" != "2" ]]; then - echo "Only a two-parent merge commit is supported for publication." >&2 + candidate_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}") + candidate_parent_shas=$(jq -c '[.parents[].sha]' <<< "$candidate_commit") + if [[ ! "$PUSH_BEFORE_SHA" =~ ^[0-9a-f]{40}$ || "$PUSH_BEFORE_SHA" == "0000000000000000000000000000000000000000" ]]; then + echo "Push event before SHA is not a valid pre-merge commit." >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$GITHUB_SHA"; then + echo "Push event before SHA is not an ancestor of the release candidate." >&2 exit 1 fi + trusted_base_sha="$PUSH_BEFORE_SHA" projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') printf '%s\n' "$projection" > release-projection.json - base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" --jq '.parents[0].sha') projection_result=$(bun run scripts/release-projection.ts \ - --base "$base_parent" \ + --base "$trusted_base_sha" \ --head "$GITHUB_SHA" \ --projection release-projection.json \ --json) @@ -226,16 +282,49 @@ jobs: fi jq -nS \ - --arg repository "$GITHUB_REPOSITORY" \ + --argjson number "$pr_number" \ --arg baseBranch "$BASE_BRANCH" \ - --argjson pullRequest "$pr_number" \ --arg automationIdentity "$automation_identity" \ --arg mergeCommit "$GITHUB_SHA" \ - --arg version "$version" \ - --arg tag "$release_tag" \ --arg projectionDigest "$projection_digest" \ - '{repository:$repository,baseBranch:$baseBranch,pullRequest:$pullRequest,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,version:$version,tag:$tag,expectedTagState:"absent",projectionDigest:$projectionDigest}' \ - > candidate.json + --argjson candidateParentShas "$candidate_parent_shas" \ + --arg trustedBaseSha "$trusted_base_sha" \ + --arg mergedPrBaseSha "$merged_pr_base_sha" \ + --arg reviewedPrHeadSha "$reviewed_pr_head_sha" \ + --argjson changedFiles "$(jq '[.[].filename]' release-projection.json)" \ + --argjson changedFileStatuses "$(jq '[.[].status]' release-projection.json)" \ + '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest,candidateParentShas:$candidateParentShas,trustedBaseSha:$trustedBaseSha,mergedPrBaseSha:$mergedPrBaseSha,reviewedPrHeadSha:$reviewedPrHeadSha}' \ + > trusted-publication-candidate.json + PUBLISH_REPOSITORY="$GITHUB_REPOSITORY" \ + PUBLISH_BASE_BRANCH="$BASE_BRANCH" \ + PUBLISH_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \ + PUBLISH_SHA="$GITHUB_SHA" \ + PUBLISH_VERSION="$version" \ + bun -e ' + import { readFileSync } from "node:fs"; + import { admitPublicationCandidate } from "./scripts/release-validate.ts"; + const { + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + ...trustedCandidate + } = JSON.parse(readFileSync("trusted-publication-candidate.json", "utf8")); + const admitted = admitPublicationCandidate({ + repository: process.env.PUBLISH_REPOSITORY ?? "", + expectedBaseBranch: process.env.PUBLISH_BASE_BRANCH ?? "", + expectedAutomationIdentities: [process.env.PUBLISH_AUTOMATION_LOGIN ?? ""], + githubSha: process.env.PUBLISH_SHA ?? "", + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + manifestVersion: process.env.PUBLISH_VERSION ?? "", + tagExists: false, + candidates: [trustedCandidate], + }); + process.stdout.write(JSON.stringify(admitted) + "\n"); + ' > candidate.json record_reused=false artifact_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=publication-candidate-${GITHUB_SHA}" \ @@ -255,6 +344,9 @@ jobs: echo "release_tag=${release_tag}" echo "version=${version}" echo "release_target_sha=" + echo "trusted_base_sha=${trusted_base_sha}" + echo "merged_pr_base_sha=${merged_pr_base_sha}" + echo "reviewed_pr_head_sha=${reviewed_pr_head_sha}" echo "record_reused=${record_reused}" } >> "$GITHUB_OUTPUT" - name: Persist publication candidate before proof @@ -544,6 +636,173 @@ jobs: fi gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${artifact_id}/zip" > publication-candidate.zip unzip -p publication-candidate.zip candidate.json > persisted-candidate.json + - name: Replay current publication admission before mutation + env: + GH_TOKEN: ${{ github.token }} + MODE: ${{ needs.resolve.outputs.mode }} + CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} + RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} + TRUSTED_BASE_SHA: ${{ needs.resolve.outputs.trusted_base_sha }} + ADMITTED_MERGED_PR_BASE_SHA: ${{ needs.resolve.outputs.merged_pr_base_sha }} + ADMITTED_REVIEWED_PR_HEAD_SHA: ${{ needs.resolve.outputs.reviewed_pr_head_sha }} + CURRENT_WORKFLOW_SHA: ${{ github.sha }} + BASE_BRANCH: main + EXPECTED_RELEASE_PLEASE_LOGIN: ${{ vars.RELEASE_PLEASE_AUTOMATION_LOGIN || 'github-actions[bot]' }} + run: | + set -euo pipefail + checkout_sha=$(git rev-parse HEAD) + if [[ "$checkout_sha" != "$CANDIDATE_SHA" ]]; then + echo "Release admission replay is not checked out at the proven candidate SHA." >&2 + exit 1 + fi + pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) + admitted_automation_identity=$(jq -er '.automationIdentity | select(type == "string" and length > 0)' persisted-candidate.json) + if [[ "$admitted_automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then + echo "Persisted publication identity does not match the configured Release Please identity." >&2 + exit 1 + fi + release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") + base_branch=$(jq -r .base.ref <<< "$release_pr") + merged_pr_base_sha=$(jq -r .base.sha <<< "$release_pr") + reviewed_pr_head_sha=$(jq -r .head.sha <<< "$release_pr") + automation_identity=$(jq -r .user.login <<< "$release_pr") + merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") + merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") + if [[ "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]; then + echo "Fresh publication PR identity does not match the configured Release Please identity." >&2 + exit 1 + fi + if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$merge_commit_sha" != "$CANDIDATE_SHA" ]]; then + echo "Publication provenance no longer matches the admitted Release Please candidate." >&2 + exit 1 + fi + if [[ "$merged_pr_base_sha" != "$ADMITTED_MERGED_PR_BASE_SHA" || "$reviewed_pr_head_sha" != "$ADMITTED_REVIEWED_PR_HEAD_SHA" ]]; then + echo "Publication topology changed after initial admission." >&2 + exit 1 + fi + candidate_commit=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${CANDIDATE_SHA}") + candidate_parent_shas=$(jq -c '[.parents[].sha]' <<< "$candidate_commit") + trusted_base_sha="$TRUSTED_BASE_SHA" + replayed_first_parent=$(git rev-parse "${CANDIDATE_SHA}^1") + if [[ ! "$trusted_base_sha" =~ ^[0-9a-f]{40}$ || "$trusted_base_sha" != "$replayed_first_parent" ]]; then + echo "Release admission replay does not match the original trusted base." >&2 + exit 1 + fi + projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') + printf '%s\n' "$projection" > replay-projection.json + # The candidate's first parent owns its historical projection policy. + git checkout --detach "$trusted_base_sha" + projection_result=$(bun run scripts/release-projection.ts \ + --base "$trusted_base_sha" \ + --head "$CANDIDATE_SHA" \ + --projection replay-projection.json \ + --json) + projection_digest=$(jq -r .projectionDigest <<< "$projection_result") + manifest_version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/plugin.config.json?ref=${CANDIDATE_SHA}" --jq .content \ + | tr -d '\n' | base64 --decode | jq -r .version) + jq -nS \ + --argjson number "$pr_number" \ + --arg baseBranch "$base_branch" \ + --arg automationIdentity "$automation_identity" \ + --arg mergeCommit "$merge_commit_sha" \ + --arg projectionDigest "$projection_digest" \ + --argjson candidateParentShas "$candidate_parent_shas" \ + --arg trustedBaseSha "$trusted_base_sha" \ + --arg mergedPrBaseSha "$merged_pr_base_sha" \ + --arg reviewedPrHeadSha "$reviewed_pr_head_sha" \ + --argjson changedFiles "$(jq '[.[].filename]' replay-projection.json)" \ + --argjson changedFileStatuses "$(jq '[.[].status]' replay-projection.json)" \ + '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest,candidateParentShas:$candidateParentShas,trustedBaseSha:$trustedBaseSha,mergedPrBaseSha:$mergedPrBaseSha,reviewedPrHeadSha:$reviewedPrHeadSha}' \ + > replay-trusted-candidate.json + # The current workflow source owns the current identity and topology contract. + git fetch --no-tags origin "$CURRENT_WORKFLOW_SHA" + git checkout --detach "$CURRENT_WORKFLOW_SHA" + if [[ "$MODE" == "repair" ]]; then + git fetch --no-tags origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + repair_tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") + repair_release_target_sha="" + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + repair_release_target=$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq .targetCommitish) + repair_release_target_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${repair_release_target}" --jq .sha) + fi + REPAIR_REPOSITORY="$GITHUB_REPOSITORY" \ + REPAIR_BASE_BRANCH="$BASE_BRANCH" \ + REPAIR_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \ + REPAIR_TAG="$RELEASE_TAG" \ + REPAIR_CHECKOUT_SHA="$CANDIDATE_SHA" \ + REPAIR_TAG_SHA="$repair_tag_sha" \ + REPAIR_VERSION="$manifest_version" \ + REPAIR_RELEASE_TARGET_SHA="$repair_release_target_sha" \ + bun -e ' + import { readFileSync } from "node:fs"; + import { validateRepairCandidateBinding } from "./scripts/release-validate.ts"; + const { + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + ...trustedCandidate + } = JSON.parse(readFileSync("replay-trusted-candidate.json", "utf8")); + validateRepairCandidateBinding({ + candidate: JSON.parse(readFileSync("persisted-candidate.json", "utf8")), + trustedCandidate, + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + repository: process.env.REPAIR_REPOSITORY ?? "", + expectedBaseBranch: process.env.REPAIR_BASE_BRANCH ?? "", + expectedAutomationIdentities: [process.env.REPAIR_AUTOMATION_LOGIN ?? ""], + tag: process.env.REPAIR_TAG ?? "", + checkoutSha: process.env.REPAIR_CHECKOUT_SHA ?? "", + tagSha: process.env.REPAIR_TAG_SHA ?? "", + manifestVersion: process.env.REPAIR_VERSION ?? "", + releaseTargetSha: process.env.REPAIR_RELEASE_TARGET_SHA || undefined, + }); + ' + else + publish_tag_exists=false + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + publish_tag_exists=true + fi + PUBLISH_REPOSITORY="$GITHUB_REPOSITORY" \ + PUBLISH_BASE_BRANCH="$BASE_BRANCH" \ + PUBLISH_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN" \ + PUBLISH_SHA="$CANDIDATE_SHA" \ + PUBLISH_VERSION="$manifest_version" \ + PUBLISH_TAG_EXISTS="$publish_tag_exists" \ + bun -e ' + import { readFileSync } from "node:fs"; + import { admitPublicationCandidate } from "./scripts/release-validate.ts"; + const { + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + ...trustedCandidate + } = JSON.parse(readFileSync("replay-trusted-candidate.json", "utf8")); + admitPublicationCandidate({ + repository: process.env.PUBLISH_REPOSITORY ?? "", + expectedBaseBranch: process.env.PUBLISH_BASE_BRANCH ?? "", + expectedAutomationIdentities: [process.env.PUBLISH_AUTOMATION_LOGIN ?? ""], + githubSha: process.env.PUBLISH_SHA ?? "", + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + manifestVersion: process.env.PUBLISH_VERSION ?? "", + tagExists: process.env.PUBLISH_TAG_EXISTS === "true", + candidates: [trustedCandidate], + priorRecord: JSON.parse(readFileSync("persisted-candidate.json", "utf8")), + }); + ' + fi + git checkout --detach "$CANDIDATE_SHA" + if [[ "$(git rev-parse HEAD)" != "$CANDIDATE_SHA" ]]; then + echo "Release admission replay did not restore the proven candidate checkout." >&2 + exit 1 + fi - name: Create or verify immutable tag env: GH_TOKEN: ${{ github.token }} @@ -613,7 +872,6 @@ jobs: GH_TOKEN: ${{ github.token }} CANDIDATE_SHA: ${{ needs.resolve.outputs.candidate_sha }} RELEASE_TAG: ${{ needs.resolve.outputs.release_tag }} - BASE_BRANCH: main run: | set -euo pipefail if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then @@ -627,49 +885,14 @@ jobs: release_target=$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq .targetCommitish) release_target_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${release_target}" --jq .sha) remote_tag_sha=$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}") - checkout_sha=$(git rev-parse HEAD) - pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) - admitted_automation_identity=$(jq -er '.automationIdentity | select(type == "string" and length > 0)' persisted-candidate.json) - release_pr=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") - base_branch=$(jq -r .base.ref <<< "$release_pr") - automation_identity=$(jq -r .user.login <<< "$release_pr") - merge_commit_sha=$(jq -r .merge_commit_sha <<< "$release_pr") - merged_at=$(jq -r '.merged_at // empty' <<< "$release_pr") - if [[ -z "$merged_at" || "$base_branch" != "$BASE_BRANCH" || "$automation_identity" != "$admitted_automation_identity" || "$merge_commit_sha" != "$checkout_sha" ]]; then - echo "Publication provenance no longer matches the admitted Release Please candidate." >&2 + if [[ "$remote_tag_sha" != "$CANDIDATE_SHA" ]]; then + echo "Immutable remote tag does not target the proven candidate SHA." >&2 + exit 1 + fi + if [[ "$release_target_sha" != "$CANDIDATE_SHA" ]]; then + echo "GitHub Release target does not match the proven candidate SHA." >&2 exit 1 fi - projection=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ - | jq -sc 'add | map({filename,status,sha}) | sort_by(.filename)') - printf '%s\n' "$projection" > repair-projection.json - base_parent=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${checkout_sha}" --jq '.parents[0].sha') - projection_result=$(bun run scripts/release-projection.ts \ - --base "$base_parent" \ - --head "$checkout_sha" \ - --projection repair-projection.json \ - --json) - projection_digest=$(jq -r .projectionDigest <<< "$projection_result") - jq -nS \ - --argjson number "$pr_number" \ - --arg baseBranch "$base_branch" \ - --arg automationIdentity "$automation_identity" \ - --arg mergeCommit "$merge_commit_sha" \ - --arg projectionDigest "$projection_digest" \ - --argjson changedFiles "$(jq '[.[].filename]' repair-projection.json)" \ - --argjson changedFileStatuses "$(jq '[.[].status]' repair-projection.json)" \ - '{number:$number,baseBranch:$baseBranch,automationIdentity:$automationIdentity,mergeCommit:$mergeCommit,mergeMode:"merge",changedFiles:$changedFiles,changedFileStatuses:$changedFileStatuses,projectionDigest:$projectionDigest}' \ - > trusted-repair-candidate.json - REPAIR_TAG="$RELEASE_TAG" \ - CHECKOUT_SHA="$checkout_sha" \ - TAG_SHA="$remote_tag_sha" \ - RELEASE_TARGET_SHA="$release_target_sha" \ - PUBLICATION_CANDIDATE_PATH=persisted-candidate.json \ - TRUSTED_REPAIR_CANDIDATE_PATH=trusted-repair-candidate.json \ - bun run release:validate -- --repair \ - --repository "$GITHUB_REPOSITORY" \ - --expected-base-branch "$BASE_BRANCH" \ - --expected-automation-login "$admitted_automation_identity" \ - --json - name: Compare release assets before mutation env: GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index c8e0ef3..0b79602 100644 --- a/README.md +++ b/README.md @@ -373,8 +373,8 @@ Normal PRs merge into `main` without publishing. Each push is classified as rele flowchart LR change["Conventional PR merged"] --> releasePR["Generated release PR"] releasePR --> review["Review version and CHANGELOG"] - review --> merge["Two-parent merge into main"] - merge --> admit["Admit and persist one candidate SHA"] + review --> merge["Squash or merge into main"] + merge --> admit["Verify topology and persist one candidate SHA"] admit --> proof["Proof pinned to candidate SHA"] proof --> publish["Immutable tag, GitHub Release, archive, checksums"] ``` @@ -382,8 +382,8 @@ flowchart LR ### One-time GitHub setup 1. Open **Settings → Actions → General → Workflow permissions**. Keep the default workflow permission read-only and allow GitHub Actions to create and approve pull requests. -2. Enable squash merging for normal PRs and merge commits for release PRs. Remove any **Require linear history** rule that applies to `main`. Publication admits only a two-parent release-PR merge commit. -3. Protect `main`. Require `Conventional Commit title`, `Release impact`, `Hosted public and private Git canaries`, all four `Compatibility` checks, and `Deterministic package`. +2. Enable squash merging for all PRs, including Release Please PRs. Merge commits may remain available as an optional release-PR path. Publication does not trust a merge-mode label: it verifies either a one-parent candidate or a two-parent merge candidate against the reviewed PR and its frozen base. +3. Protect `main` in two places. In **Settings → Branches → Branch protection rules**, require `Conventional Commit title`, `Release impact`, `Hosted public and private Git canaries`, all four `Compatibility` checks, and `Deterministic package`; readiness reads that classic branch-protection endpoint. In an active, no-bypass `main` ruleset, enable **Require a pull request before merging** and **Block force pushes**. 4. Open **Settings → Rules → Rulesets**. Create an active tag ruleset for `v*` that restricts tag deletion and updates with no bypass actors. 5. Open **Settings → Environments**. Create `release` and configure required reviewers for publication and same-tag asset replacement. 6. Create the public and private canary repositories named by `plugin.config.json`, then create the `hosted-canary-qualification` environment. Add `CANARY_GH_TOKEN`: a fine-grained token for the exact configured `canary.actor`, scoped only to both canary repositories, with Contents read/write, Actions read, and metadata read. Add `CANARY_SSH_PRIVATE_KEY` and `CANARY_SSH_KNOWN_HOSTS` for the same canary identity. Qualification uses the token-backed GitHub API identity and SSH Git identity, and limits writes to create-only immutable candidate refs. Keeping Git transport on SSH allows candidates containing workflow files without broadening the API token. @@ -394,7 +394,7 @@ flowchart LR 9. Authenticate `gh` with read access to repository settings, then run `bun run readiness -- --repo OWNER/REPOSITORY`. 10. Enable release automation only after readiness reports `READY`. -The immutable `v*` tag ruleset is a human-owned safeguard outside the workflow. Release automation never receives repository-administration authority; it cannot change the ruleset or its own release environment. `bun run readiness` is read-only and fails closed when the default branch, merge mode, effective merge-history policy, required checks, Actions permissions, tag ruleset, hosted-canary environment and secret names, or workflow authority cannot be proved. It reads secret metadata only, never secret values. +The immutable `v*` tag ruleset and the no-bypass `main` ruleset are human-owned safeguards outside the workflow. The `main` ruleset prevents a force push from steering the push event's trusted pre-merge base. Release automation never receives repository-administration authority; it cannot change either ruleset or its own release environment. `bun run readiness` is read-only and fails closed when the default branch, squash path, direct-push protection, effective merge-history policy, required checks, Actions permissions, tag ruleset, hosted-canary environment and secret names, or workflow authority cannot be proved. It reads secret metadata only, never secret values. Release automation requires `RELEASE_PLEASE_TOKEN`; it does not fall back to `GITHUB_TOKEN`. GitHub suppresses workflow runs caused by `GITHUB_TOKEN`, which would leave the generated release PR without its required checks. The separate repository variable `RELEASE_PLEASE_AUTOMATION_LOGIN` records the exact login that owns the token; both the release-impact gate and publication admission bind that identity. @@ -403,15 +403,17 @@ Release automation requires `RELEASE_PLEASE_TOKEN`; it does not fall back to `GI 1. Merge normal PRs with valid Conventional Commit titles. 2. Wait for the `Release` workflow's maintenance path to create or update the release PR. No tag or GitHub Release is created here. 3. Confirm the first release is `v0.1.0`; review the proposed semantic version, exact version projection, and generated `CHANGELOG.md`. -4. Merge the release PR into `main` with a merge commit. Do not squash it. -5. Wait for the workflow to admit exactly one merged release PR bound to `github.sha`: base `main`, configured Release Please automation identity, two parents, and only the allowed version projection. -6. Confirm the workflow persisted `publication-candidate-` before proof and checked out that candidate SHA. Publication embeds that admission record in the annotated immutable release tag, so repair remains possible after the workflow artifact expires. Later movement of `main` does not change the candidate. +4. Squash-merge the release PR into `main`. A two-parent merge commit is also supported when merge commits are enabled and `main` does not require linear history. +5. Wait for the workflow to admit exactly one merged release PR bound to `github.sha`: base `main`, configured Release Please automation identity, only the allowed version projection, and a verified one-parent or two-parent topology. In both cases the first parent must equal both the trusted pre-merge base and the merged PR's frozen base, and every changed candidate blob must equal the corresponding blob from the reviewed PR head. A two-parent candidate must also bind its second parent to the reviewed PR head. +6. Confirm the workflow persisted `publication-candidate-` before proof and checked out that candidate SHA. The persisted nine-field record is unchanged; parent topology is rederived from the immutable candidate commit instead of being trusted from the tag. Publication embeds the admission record in the annotated immutable release tag, so repair remains possible after the workflow artifact expires. Later movement of `main` does not change the candidate. 7. Wait for metadata validation, four-platform proof, deterministic packaging, and generated-drift rejection. 8. Approve the protected `release` environment. The workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag target, then creates the GitHub Release with `--verify-tag --target `. 9. Confirm the Release contains the deterministic archive and `*.checksums.json`. For a public repository, confirm the archive attestation. Do not hand-edit versions or `CHANGELOG.md`. Do not create the tag first. Do not publish to npm. +The one-parent path is intended for squash merges. GitHub does not expose a reliable field that proves which merge button produced a commit, so a lineage-equivalent single-commit rebase can satisfy the same checks. This is not a rebase-only support promise: readiness still requires squash merging because release PRs can contain multiple commits and ordinary PRs must remain squashable. Arbitrary or multi-commit rebases cannot pass admission because the candidate's first parent would not equal the trusted pre-merge and frozen PR base. Manual repair repeats these topology checks from GitHub and checks both the persisted identity and the fresh PR author against `RELEASE_PLEASE_AUTOMATION_LOGIN`; neither topology nor identity is self-authorized by the persisted record. + ### Manually maintain or repair release state Manual dispatch accepts two operation values. `maintenance` is the default; it only updates the standing release PR and never publishes. `repair` requires `release_tag` set to the exact existing `vX.Y.Z` tag. This repairs an incomplete publication; it does not create a new release. diff --git a/docs/adr/0003-reviewed-versioned-releases.md b/docs/adr/0003-reviewed-versioned-releases.md index 20f5ad4..789f472 100644 --- a/docs/adr/0003-reviewed-versioned-releases.md +++ b/docs/adr/0003-reviewed-versioned-releases.md @@ -12,7 +12,11 @@ The release workflow classifies each invocation into one state: - **Publication:** a push containing exactly one eligible merged release PR admits and publishes one candidate commit. - **Repair:** manual dispatch repairs an incomplete publication from one exact existing immutable tag. -Publication admits only a release PR based on `main`, opened by the configured Release Please automation identity, merged as a two-parent commit equal to `github.sha`, and containing exactly the allowed version projection. The workflow persists a candidate record before proof. Every later checkout, validation, package, tag, Release target, and checksum binding uses that candidate SHA even if `main` advances. +Publication admits only a release PR based on `main`, opened by the configured Release Please automation identity, merged to a candidate equal to `github.sha`, and containing exactly the allowed version projection. Normal PRs and Release Please PRs may use squash merge. Admission accepts only a verified one-parent or two-parent candidate: in both cases the first parent must equal the trusted pre-merge base and the merged PR's frozen base, and every changed candidate blob must equal the corresponding blob reported for the reviewed PR head; for a two-parent candidate, the second parent must also equal the reviewed PR head. A multi-commit rebase fails that first-parent binding and is rejected. + +The one-parent path is motivated by squash merge. GitHub exposes no reliable field that proves which merge button produced a commit, so a provenance-equivalent single-commit rebase is admitted when it satisfies the same immutable-SHA, base, merged-PR association, identity, and projection invariants. This is deliberately a topology check, not a general rebase or merge-policy framework. + +The workflow persists the same nine-field candidate record before proof: `repository`, `baseBranch`, `pullRequest`, `automationIdentity`, `mergeCommit`, `version`, `tag`, `expectedTagState`, and `projectionDigest`. Topology is not added to that tag-carried record; it is rederived from the immutable candidate commit. Every later checkout, validation, package, tag, Release target, and checksum binding uses that candidate SHA even if `main` advances. After four-platform and deterministic-distribution proof, the workflow creates `vX.Y.Z` explicitly at the candidate SHA, verifies the remote tag resolves to that SHA, and creates the GitHub Release with tag verification and an explicit target. Release Please has no publication role. @@ -20,13 +24,13 @@ Packaging emits a deterministic `tar.gz` and `*.checksums.json`. The JSON binds ## Repair contract -Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, repeats the complete proof, and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release. +Manual dispatch accepts `operation=maintenance` or `operation=repair`. Maintenance is the default and only updates the standing release PR. Repair requires `operation=repair` plus `release_tag` naming an existing `vX.Y.Z` tag. Repair begins from the immutable tag, rederives candidate topology and fresh PR authorship from GitHub, and checks identity against repository configuration rather than trusting the tag-carried record. It repeats the complete proof and validates any existing GitHub Release target. It compares each asset before writing: matching assets remain untouched, missing assets are added, and mismatches fail closed. A mismatched asset may be replaced only when `replace_mismatched_assets=true` is approved through the protected `release` environment. Repair never moves the tag and never represents a new release. ## Human-owned safeguards -A human configures an active `v*` tag ruleset that restricts deletion and updates with no bypass actors. A human also configures required `main` checks, merge-commit availability without linear-history enforcement, Actions permissions, required reviewers on the `release` environment, and the `hosted-canary-qualification` environment with scoped `CANARY_GH_TOKEN`, `CANARY_SSH_PRIVATE_KEY`, and `CANARY_SSH_KNOWN_HOSTS` secrets. The token owns GitHub API calls; SSH owns Git transport, including immutable candidates that contain workflow files. Release automation receives narrow job permissions and never repository-administration authority. +A human configures an active `v*` tag ruleset that restricts deletion and updates with no bypass actors. A human also configures an active, no-bypass `main` ruleset that requires pull requests and blocks force pushes. That rule keeps the push event's pre-merge base outside an actor-controlled force-push path. Squash merging remains required for ordinary and release pull requests; two-parent merge commits are optional. The human also configures required `main` checks, Actions permissions, required reviewers on the `release` environment, and the `hosted-canary-qualification` environment with scoped `CANARY_GH_TOKEN`, `CANARY_SSH_PRIVATE_KEY`, and `CANARY_SSH_KNOWN_HOSTS` secrets. The token owns GitHub API calls; SSH owns Git transport, including immutable candidates that contain workflow files. Release automation receives narrow job permissions and never repository-administration authority. -`bun run readiness` reads GitHub and local workflow state without mutation. It fails closed unless the default branch is `main`, merge commits are enabled, classic protection and effective rulesets permit non-linear history, all release-path checks protect `main`, Actions is enabled, the immutable tag ruleset is active, the hosted-canary environment and required secret names exist, and no workflow grants repository administration. Secret values are never read. Automation is enabled only while these safeguards remain ready. +`bun run readiness` reads GitHub and local workflow state without mutation. It fails closed unless the default branch is `main`, squash merging is enabled, an active no-bypass branch ruleset requires pull requests and blocks force pushes on `main`, all release-path checks protect `main`, Actions is enabled, the immutable tag ruleset is active, the hosted-canary environment and required secret names exist, and no workflow grants repository administration. Secret values are never read. Automation is enabled only while these safeguards remain ready. Installable payload changes require a releasable Conventional Commit PR title: `feat`, `fix`, `perf`, or a breaking `!` title. Documentation-, test-, and CI-only changes are exempt. The pure Release Please version projection is exempt because it changes release identity without changing installable behavior. diff --git a/scripts/release-projection.test.ts b/scripts/release-projection.test.ts index f96e472..09709ae 100644 --- a/scripts/release-projection.test.ts +++ b/scripts/release-projection.test.ts @@ -40,6 +40,38 @@ test("runtime hook files are outside the release projection", () => { ).toThrow("unsupported path") }) +test("projection binds every changed candidate blob to the reviewed pull-request head", () => { + const file = { filename: "plugin.config.json", status: "modified", sha: "reviewed-blob" } + const versions = { + before: '{"version":"0.1.0","name":"x"}', + after: '{"version":"0.2.0","name":"x"}', + afterSha: "reviewed-blob", + } + + expect(validateReleaseProjection([file], () => versions).changedFiles).toEqual([ + "plugin.config.json", + ]) + expect(() => + validateReleaseProjection([{ ...file, sha: "different-blob" }], () => versions), + ).toThrow("reviewed head blob") +}) + +test("projection binds the complete candidate changed-file set to the reviewed pull request", () => { + const file = { filename: "plugin.config.json", status: "modified", sha: "reviewed-blob" } + const versions = { + before: '{"version":"0.1.0","name":"x"}', + after: '{"version":"0.2.0","name":"x"}', + afterSha: "reviewed-blob", + } + + expect(validateReleaseProjection([file], () => versions, [file.filename]).changedFiles).toEqual([ + file.filename, + ]) + expect(() => + validateReleaseProjection([file], () => versions, [file.filename, "plugin/runtime/extra.js"]), + ).toThrow("candidate changed-file set") +}) + test("changelog projection prepends exactly one current-version section", () => { const manifest = { before: '{".":"0.1.0"}', @@ -89,7 +121,9 @@ test("projection CLI executes the same policy against Git refs", () => { const repository = mkdtempSync(join(tmpdir(), "release-projection-cli-")) const run = (arguments_: string[]) => Bun.spawnSync({ cmd: arguments_, cwd: repository, stdout: "pipe", stderr: "pipe" }) + const unreviewedPath = "unreviewed-☃\nfile.txt" let result: ReturnType | undefined + let unsupportedPathResult: ReturnType | undefined try { for (const command of [ ["git", "init", "--quiet"], @@ -108,8 +142,12 @@ test("projection CLI executes the same policy against Git refs", () => { expect(run(["git", "add", "plugin.config.json"]).exitCode).toBe(0) expect(run(["git", "commit", "--quiet", "-m", "version"]).exitCode).toBe(0) const head = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + const headBlob = run(["git", "rev-parse", `${head}:plugin.config.json`]).stdout.toString().trim() const projection = join(repository, "projection.json") - writeFileSync(projection, '[{"filename":"plugin.config.json","status":"modified","sha":"1"}]\n') + writeFileSync( + projection, + `${JSON.stringify([{ filename: "plugin.config.json", status: "modified", sha: headBlob }])}\n`, + ) result = run([ process.execPath, @@ -123,6 +161,35 @@ test("projection CLI executes the same policy against Git refs", () => { projection, "--json", ]) + writeFileSync(join(repository, unreviewedPath), "unreviewed\n") + expect(run(["git", "add", "--", unreviewedPath]).exitCode).toBe(0) + expect(run(["git", "commit", "--quiet", "-m", "unreviewed path"]).exitCode).toBe(0) + const headWithUnreviewedPath = run(["git", "rev-parse", "HEAD"]).stdout.toString().trim() + const unreviewedBlob = run([ + "git", + "rev-parse", + `${headWithUnreviewedPath}:${unreviewedPath}`, + ]).stdout.toString().trim() + const projectionWithUnreviewedPath = join(repository, "projection-with-unreviewed-path.json") + writeFileSync( + projectionWithUnreviewedPath, + `${JSON.stringify([ + { filename: "plugin.config.json", status: "modified", sha: headBlob }, + { filename: unreviewedPath, status: "modified", sha: unreviewedBlob }, + ])}\n`, + ) + unsupportedPathResult = run([ + process.execPath, + "run", + join(import.meta.dir, "release-projection.ts"), + "--base", + base, + "--head", + headWithUnreviewedPath, + "--projection", + projectionWithUnreviewedPath, + "--json", + ]) } finally { rmSync(repository, { recursive: true, force: true }) } @@ -135,5 +202,9 @@ test("projection CLI executes the same policy against Git refs", () => { ok: true, changedFiles: ["plugin.config.json"], }) + expect(unsupportedPathResult).toBeDefined() + if (!unsupportedPathResult) throw new Error("newline-path projection fixture did not run") + expect(unsupportedPathResult.exitCode).toBe(1) + expect(unsupportedPathResult.stderr.toString()).toContain(`unsupported path: ${unreviewedPath}`) expect(RELEASE_PROJECTION_PATHS).not.toContain("plugin/runtime/hello-world.js") }) diff --git a/scripts/release-projection.ts b/scripts/release-projection.ts index 0267653..9c10887 100644 --- a/scripts/release-projection.ts +++ b/scripts/release-projection.ts @@ -82,9 +82,17 @@ export interface ReleaseProjectionFile { /** Validate the exact changed-file set, statuses, and version-only bytes. */ export function validateReleaseProjection( files: ReleaseProjectionFile[], - readVersions: (path: string) => { before: string; after: string }, + readVersions: (path: string) => { before: string; after: string; afterSha?: string }, + actualChangedFiles?: string[], ): { changedFiles: string[]; projectionDigest: string } { const sorted = [...files].sort((left, right) => compareCodeUnits(left.filename, right.filename)) + const changedFiles = sorted.map((file) => file.filename) + if (actualChangedFiles !== undefined) { + const actual = [...actualChangedFiles].sort(compareCodeUnits) + if (JSON.stringify(actual) !== JSON.stringify(changedFiles)) { + throw new Error("release projection does not match the candidate changed-file set") + } + } let expectedVersion: string | undefined if (sorted.some((file) => file.filename === "CHANGELOG.md")) { try { @@ -109,13 +117,16 @@ export function validateReleaseProjection( throw new Error(`release projection contains unsupported status: ${file.filename} ${file.status}`) } const versions = readVersions(file.filename) + if (versions.afterSha !== undefined && file.sha !== versions.afterSha) { + throw new Error(`release projection does not match the reviewed head blob: ${file.filename}`) + } if (!isReleaseProjectionVersionOnlyChange(file.filename, versions.before, versions.after, expectedVersion)) { throw new Error(`release projection changed non-version behavior: ${file.filename}`) } } const canonical = JSON.stringify(sorted) return { - changedFiles: sorted.map((file) => file.filename), + changedFiles, projectionDigest: createHash("sha256").update(canonical).digest("hex"), } } @@ -133,6 +144,32 @@ function gitFile(ref: string, path: string): string { return result.stdout.toString() } +function gitObjectId(ref: string, path: string): string { + const result = Bun.spawnSync({ + cmd: ["git", "rev-parse", "--verify", `${ref}:${path}`], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(`cannot resolve ${path} at ${ref}: ${result.stderr.toString().trim()}`) + } + return result.stdout.toString().trim() +} + +function gitChangedFiles(base: string, head: string): string[] { + const result = Bun.spawnSync({ + cmd: ["git", "diff", "--name-only", "-z", base, head, "--"], + cwd: process.cwd(), + stdout: "pipe", + stderr: "pipe", + }) + if (result.exitCode !== 0) { + throw new Error(`cannot resolve candidate changed files: ${result.stderr.toString().trim()}`) + } + return result.stdout.toString().split("\0").filter((path) => path.length > 0) +} + if (import.meta.main) { const arguments_ = process.argv.slice(2) const value = (name: string): string => { @@ -149,7 +186,8 @@ if (import.meta.main) { const result = validateReleaseProjection(files, (path) => ({ before: gitFile(base, path), after: gitFile(head, path), - })) + afterSha: gitObjectId(head, path), + }), gitChangedFiles(base, head)) console.log(JSON.stringify({ ok: true, ...result })) } catch (error) { console.error(`release:projection: ${error instanceof Error ? error.message : String(error)}`) diff --git a/scripts/release-validate.test.ts b/scripts/release-validate.test.ts index a36feae..80e968b 100644 --- a/scripts/release-validate.test.ts +++ b/scripts/release-validate.test.ts @@ -78,7 +78,6 @@ function releasePullRequest(overrides: Record = {}) { baseBranch: "main", automationIdentity: "github-actions[bot]", mergeCommit: "a".repeat(40), - mergeMode: "merge" as const, changedFiles: allowedProjection, changedFileStatuses: allowedProjection.map(() => "modified"), projectionDigest: "b".repeat(64), @@ -86,12 +85,23 @@ function releasePullRequest(overrides: Record = {}) { } } +function candidateTopology(overrides: Record = {}) { + return { + candidateParentShas: ["c".repeat(40), "d".repeat(40)], + trustedBaseSha: "c".repeat(40), + mergedPrBaseSha: "c".repeat(40), + reviewedPrHeadSha: "d".repeat(40), + ...overrides, + } +} + function admissionInput(candidates = [releasePullRequest()]) { return { repository: "myagentdojo/agent-plugin-template", expectedBaseBranch: "main", expectedAutomationIdentities: ["github-actions[bot]"], githubSha: "a".repeat(40), + ...candidateTopology(), manifestVersion: "0.1.0", tagExists: false, candidates, @@ -271,6 +281,25 @@ test("release workflow is pinned and publishes proven assets after validation", workflow.indexOf(" - name: Resolve unique candidate or immutable repair tag\n"), workflow.indexOf("\n\n associated_prs="), ) + const publishResolutionStep = workflow.slice( + workflow.indexOf(" associated_prs="), + workflow.indexOf(" - name: Persist publication candidate before proof\n"), + ) + const finalProvenanceStep = finalReleaseJob.slice( + finalReleaseJob.indexOf(" - name: Create missing GitHub Release and validate target\n"), + finalReleaseJob.indexOf(" - name: Compare release assets before mutation\n"), + ) + const replayStepStart = finalReleaseJob.indexOf( + " - name: Replay current publication admission before mutation\n", + ) + const immutableTagStepStart = finalReleaseJob.indexOf( + " - name: Create or verify immutable tag\n", + ) + const replayStep = finalReleaseJob.slice(replayStepStart, immutableTagStepStart) + const replayPublicationAdmission = replayStep.indexOf("admitPublicationCandidate") + const replayRepairAdmission = replayStep.indexOf("validateRepairCandidateBinding") + const tagPush = finalReleaseJob.indexOf('git push origin "refs/tags/${RELEASE_TAG}"') + const releaseCreate = finalReleaseJob.indexOf("gh release create") const persistedCandidateStep = finalReleaseJob.slice( finalReleaseJob.indexOf(" - name: Download persisted publication candidate\n"), finalReleaseJob.indexOf(" - name: Create or verify immutable tag\n"), @@ -284,7 +313,6 @@ test("release workflow is pinned and publishes proven assets after validation", expect(workflow).toContain("skip-github-release") expect(workflow).toContain("publication-candidate-${GITHUB_SHA}") expect(workflow).toContain("operation:") - expect(workflow).toContain("PUBLICATION_CANDIDATE_PATH") expect(workflow).toContain("publication-candidate-${CANDIDATE_SHA}") expect(workflow).toContain("tag -a \"$RELEASE_TAG\" \"$CANDIDATE_SHA\" -F persisted-candidate.json") expect(workflow).toContain("git for-each-ref --format='%(contents)'") @@ -292,24 +320,77 @@ test("release workflow is pinned and publishes proven assets after validation", expect(workflow).toContain("merge_commit_sha") expect(workflow).toContain("EXPECTED_RELEASE_PLEASE_LOGIN") expect(workflow).toContain("admitted_automation_identity") - expect(workflow).toContain('--expected-automation-login "$admitted_automation_identity"') + expect(workflow).not.toContain('--expected-automation-login "$admitted_automation_identity"') expect(workflow).toContain("scripts/release-projection.ts") - const historicalPolicyCheckout = repairValidationStep.indexOf('git checkout --detach "$base_parent"') + const historicalPolicyCheckout = repairValidationStep.indexOf( + 'git checkout --detach "$trusted_base_sha"', + ) const historicalPolicyExecution = repairValidationStep.indexOf("bun run scripts/release-projection.ts") + const currentAdmissionCheckout = repairValidationStep.indexOf( + 'git checkout --detach "$workflow_policy_sha"', + ) + const currentAdmissionExecution = repairValidationStep.indexOf("validateRepairCandidateBinding") const provenanceGuard = repairValidationStep.indexOf('if [[ -z "$merged_at"') - const parentCountGuard = repairValidationStep.indexOf('if [[ "$parent_count" != "2" ]]') + const dispatchBaseGuard = repairValidationStep.indexOf( + 'if [[ "$GITHUB_REF" != "refs/heads/${BASE_BRANCH}" ]]', + ) + const maintenanceDispatch = repairValidationStep.indexOf('if [[ "$OPERATION" == "maintenance" ]]') + expect(dispatchBaseGuard).toBeGreaterThanOrEqual(0) + expect(dispatchBaseGuard).toBeLessThan(maintenanceDispatch) expect(historicalPolicyCheckout).toBeGreaterThanOrEqual(0) expect(historicalPolicyExecution).toBeGreaterThanOrEqual(0) expect(provenanceGuard).toBeGreaterThanOrEqual(0) - expect(parentCountGuard).toBeGreaterThanOrEqual(0) expect(historicalPolicyCheckout).toBeGreaterThan(provenanceGuard) - expect(historicalPolicyCheckout).toBeGreaterThan(parentCountGuard) expect(historicalPolicyExecution).toBeGreaterThan(historicalPolicyCheckout) + expect(currentAdmissionCheckout).toBeGreaterThan(historicalPolicyExecution) + expect(currentAdmissionExecution).toBeGreaterThan(currentAdmissionCheckout) expect(workflow).not.toContain("ALLOWED_RELEASE_PATHS") expect(workflow).not.toContain("missing_paths") - expect(workflow).toContain("parent_count") + expect(workflow).not.toContain("parent_count") + expect(workflow).not.toContain("mergeMode") expect(workflow).not.toContain("compare_json_except_version") - expect(workflow).toContain('expectedTagState:"absent"') + expect(workflow).toContain("admitPublicationCandidate") + expect(workflow).toContain("PUSH_BEFORE_SHA: ${{ github.event.before }}") + expect(workflow).toContain("PUSH_FORCED: ${{ github.event.forced }}") + expect(workflow).toContain('if [[ "$PUSH_FORCED" != "false" ]]') + expect(workflow.match(/candidate_parent_shas=/g)).toHaveLength(3) + expect(publishResolutionStep).toContain('trusted_base_sha="$PUSH_BEFORE_SHA"') + expect(publishResolutionStep).toContain('merged_pr_base_sha=$(jq -r .base.sha <<< "$release_pr")') + expect(publishResolutionStep).toContain('reviewed_pr_head_sha=$(jq -r .head.sha <<< "$release_pr")') + expect(publishResolutionStep).toContain('--base "$trusted_base_sha"') + expect(publishResolutionStep).toContain("admitPublicationCandidate") + expect(repairValidationStep).toContain( + 'if [[ "$admitted_automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]', + ) + expect(repairValidationStep).toContain("workflow_policy_sha=$(git rev-parse HEAD)") + expect(repairValidationStep).toContain( + 'if [[ "$automation_identity" != "$EXPECTED_RELEASE_PLEASE_LOGIN" ]]', + ) + expect(repairValidationStep).toContain('trusted_base_sha=$(git rev-parse "${candidate_sha}^1")') + expect(repairValidationStep).not.toContain('trusted_base_sha="$merged_pr_base_sha"') + expect(repairValidationStep).toContain('--base "$trusted_base_sha"') + expect(repairValidationStep).toContain('REPAIR_AUTOMATION_LOGIN="$EXPECTED_RELEASE_PLEASE_LOGIN"') + expect(finalProvenanceStep).not.toContain("validateRepairCandidateBinding") + expect(finalProvenanceStep).toContain('if [[ "$remote_tag_sha" != "$CANDIDATE_SHA" ]]') + expect(finalProvenanceStep).toContain('if [[ "$release_target_sha" != "$CANDIDATE_SHA" ]]') + expect(replayStepStart).toBeGreaterThanOrEqual(0) + expect(replayStep).toContain('git checkout --detach "$trusted_base_sha"') + expect(replayStep).toContain('git fetch --no-tags origin "$CURRENT_WORKFLOW_SHA"') + expect(replayStep).toContain('git checkout --detach "$CURRENT_WORKFLOW_SHA"') + expect(replayStep).toContain('trusted_base_sha="$TRUSTED_BASE_SHA"') + expect(replayStep).toContain( + 'if [[ "$merged_pr_base_sha" != "$ADMITTED_MERGED_PR_BASE_SHA" || "$reviewed_pr_head_sha" != "$ADMITTED_REVIEWED_PR_HEAD_SHA" ]]', + ) + expect(replayStep).toContain('replayed_first_parent=$(git rev-parse "${CANDIDATE_SHA}^1")') + expect(replayStep).toContain('git checkout --detach "$CANDIDATE_SHA"') + expect(replayStep).toContain('if [[ "$(git rev-parse HEAD)" != "$CANDIDATE_SHA" ]]') + expect(replayStep).toContain('priorRecord: JSON.parse(readFileSync("persisted-candidate.json"') + expect(replayPublicationAdmission).toBeGreaterThanOrEqual(0) + expect(replayRepairAdmission).toBeGreaterThanOrEqual(0) + expect(tagPush).toBeGreaterThan(replayStepStart + replayPublicationAdmission) + expect(tagPush).toBeGreaterThan(replayStepStart + replayRepairAdmission) + expect(releaseCreate).toBeGreaterThan(replayStepStart + replayPublicationAdmission) + expect(releaseCreate).toBeGreaterThan(replayStepStart + replayRepairAdmission) expect(workflow).toContain("bun run prove:all") expect(workflow).toContain("@anthropic-ai/claude-code@2.1.222") expect(workflow).toContain("@openai/codex@0.146.1") @@ -332,7 +413,6 @@ test("release workflow is pinned and publishes proven assets after validation", expect(workflow.match(/release-candidate-\$\{\{ github\.run_id \}\}/g)).toHaveLength(2) expect(workflow).toContain("overwrite: true") expect(workflow).not.toContain("github.run_attempt") - expect(workflow).toContain("bun run release:validate -- --repair") const maintainJob = workflow.slice( workflow.indexOf("\n maintain:\n"), workflow.indexOf("\n compatibility:\n"), @@ -380,8 +460,8 @@ test("release workflow is pinned and publishes proven assets after validation", "group: release-publication-${{ needs.resolve.outputs.release_tag }}", ) expect(finalReleaseJob).toContain(" permissions:\n actions: read\n") - expect(finalReleaseJob).toContain("PUBLICATION_CANDIDATE_PATH=persisted-candidate.json") - expect(finalReleaseJob).toContain('--repository "$GITHUB_REPOSITORY"') + expect(repairValidationStep).toContain("candidate: JSON.parse(readFileSync(\"persisted-candidate.json\"") + expect(repairValidationStep).toContain("repository: process.env.REPAIR_REPOSITORY") expect(compareStepStart).toBeGreaterThan(-1) expect(uploadStepStart).toBeGreaterThan(compareStepStart) expect(attestationStepStart).toBeGreaterThan(uploadStepStart) @@ -691,7 +771,7 @@ test("immutable tag creation is retry-safe and rejects a rebound candidate", () expect(rebound.stderr.toString()).toContain("Immutable remote tag") }) -test("publication candidate admission binds one merged Release Please PR to github.sha", () => { +test("publication candidate admission accepts a verified two-parent candidate", () => { const candidate = admitPublicationCandidate(admissionInput()) expect(candidate).toMatchObject({ repository: "myagentdojo/agent-plugin-template", @@ -706,6 +786,32 @@ test("publication candidate admission binds one merged Release Please PR to gith }) }) +test("publication candidate admission accepts a verified one-parent candidate", () => { + const candidate = admitPublicationCandidate({ + ...admissionInput(), + candidateParentShas: ["c".repeat(40)], + }) + + expect(candidate).toMatchObject({ + pullRequest: 42, + mergeCommit: "a".repeat(40), + expectedTagState: "absent", + }) + expect(Object.keys(candidate).sort()).toEqual( + [ + "automationIdentity", + "baseBranch", + "expectedTagState", + "mergeCommit", + "projectionDigest", + "pullRequest", + "repository", + "tag", + "version", + ].sort(), + ) +}) + test("publication candidate admission rejects zero matching PRs", () => { expect(() => admitPublicationCandidate(admissionInput([]))).toThrow("exactly one merged Release Please PR") }) @@ -734,10 +840,73 @@ test("AE2: publication candidate admission rejects a merge commit unequal to git ).toThrow("github.sha") }) -test("publication candidate admission rejects unsupported merge modes", () => { +test("publication candidate admission rejects a candidate with no parents", () => { + expect(() => + admitPublicationCandidate({ ...admissionInput(), candidateParentShas: [] }), + ).toThrow("exactly one or two parents") +}) + +test("publication candidate admission rejects a candidate with three parents", () => { + expect(() => + admitPublicationCandidate({ + ...admissionInput(), + candidateParentShas: ["c".repeat(40), "d".repeat(40), "e".repeat(40)], + }), + ).toThrow("exactly one or two parents") +}) + +test("publication candidate admission rejects empty or malformed topology commit SHAs", () => { + for (const topology of [ + { candidateParentShas: [""] }, + { candidateParentShas: ["c".repeat(40), "D".repeat(40)] }, + { trustedBaseSha: "" }, + { mergedPrBaseSha: "e".repeat(39) }, + { reviewedPrHeadSha: "D".repeat(40) }, + ]) { + expect(() => + admitPublicationCandidate({ + ...admissionInput(), + ...topology, + }), + ).toThrow("topology contains an invalid commit SHA") + } +}) + +test("publication candidate admission rejects a candidate parent outside the trusted base", () => { expect(() => - admitPublicationCandidate(admissionInput([releasePullRequest({ mergeMode: "squash" })])), - ).toThrow("merge mode") + admitPublicationCandidate({ + ...admissionInput(), + candidateParentShas: ["e".repeat(40)], + }), + ).toThrow("first parent does not match the trusted base") +}) + +test("publication candidate admission rejects disagreement between trusted base authorities", () => { + expect(() => + admitPublicationCandidate({ + ...admissionInput(), + mergedPrBaseSha: "e".repeat(40), + }), + ).toThrow("first parent does not match the merged PR base") +}) + +test("publication candidate admission rejects a two-parent candidate with the wrong reviewed head", () => { + expect(() => + admitPublicationCandidate({ + ...admissionInput(), + candidateParentShas: ["c".repeat(40), "e".repeat(40)], + }), + ).toThrow("second parent does not match the reviewed PR head") +}) + +test("publication candidate admission rejects a multi-commit rebase hidden behind an allowed final projection", () => { + expect(() => + admitPublicationCandidate({ + ...admissionInput(), + candidateParentShas: ["e".repeat(40)], + candidates: [releasePullRequest()], + }), + ).toThrow("first parent does not match the trusted base") }) test("publication candidate admission rejects an existing version tag", () => { @@ -942,6 +1111,7 @@ test("manual repair requires the original publication candidate record", () => { expectedBaseBranch: "main", expectedAutomationIdentities: ["github-actions[bot]"], trustedCandidate: releasePullRequest(), + ...candidateTopology(), tag: "v0.1.0", checkoutSha: "a".repeat(40), tagSha: "a".repeat(40), @@ -957,6 +1127,7 @@ test("manual repair requires the original publication candidate record", () => { expectedBaseBranch: "main", expectedAutomationIdentities: ["github-actions[bot]"], trustedCandidate: releasePullRequest(), + ...candidateTopology(), tag: "v0.1.0", checkoutSha: "c".repeat(40), tagSha: "c".repeat(40), @@ -965,6 +1136,50 @@ test("manual repair requires the original publication candidate record", () => { ).toThrow("publication candidate") }) +test("manual repair reuses topology commit SHA validation", () => { + const candidate = admitPublicationCandidate(admissionInput()) + + expect(() => + validateRepairCandidateBinding({ + candidate, + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + trustedCandidate: releasePullRequest(), + ...candidateTopology({ reviewedPrHeadSha: "" }), + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + }), + ).toThrow("topology contains an invalid commit SHA") +}) + +test("manual repair preserves a historical projection path after the current allowlist removes it", () => { + const historicalPath = "plugin/legacy-release-metadata.json" + const candidate = admitPublicationCandidate(admissionInput()) + expect(allowedProjection).not.toContain(historicalPath) + + expect(() => + validateRepairCandidateBinding({ + candidate: { ...candidate, projectionDigest: "c".repeat(64) }, + repository: "myagentdojo/agent-plugin-template", + expectedBaseBranch: "main", + expectedAutomationIdentities: ["github-actions[bot]"], + trustedCandidate: releasePullRequest({ + changedFiles: [...allowedProjection, historicalPath], + changedFileStatuses: [...allowedProjection.map(() => "modified"), "modified"], + projectionDigest: "c".repeat(64), + }), + ...candidateTopology(), + tag: "v0.1.0", + checkoutSha: "a".repeat(40), + tagSha: "a".repeat(40), + manifestVersion: "0.1.0", + }), + ).not.toThrow() +}) + test("manual repair rejects a forged tag record against GitHub-derived provenance", () => { const forged = { ...admitPublicationCandidate(admissionInput()), @@ -978,6 +1193,7 @@ test("manual repair rejects a forged tag record against GitHub-derived provenanc expectedBaseBranch: "main", expectedAutomationIdentities: ["github-actions[bot]"], trustedCandidate: releasePullRequest(), + ...candidateTopology(), tag: "v0.1.0", checkoutSha: "a".repeat(40), tagSha: "a".repeat(40), @@ -1048,7 +1264,10 @@ test("manual repair CLI emits one bound JSON result", () => { const candidatePath = join(temporaryRoot, "candidate.json") const trustedCandidatePath = join(temporaryRoot, "trusted-candidate.json") writeFileSync(candidatePath, `${JSON.stringify(admitPublicationCandidate(admissionInput()))}\n`) - writeFileSync(trustedCandidatePath, `${JSON.stringify(releasePullRequest())}\n`) + writeFileSync( + trustedCandidatePath, + `${JSON.stringify({ ...releasePullRequest(), ...candidateTopology() })}\n`, + ) const result = validateWithArguments(temporaryRoot, [ "--repair", "--candidate", diff --git a/scripts/release-validate.ts b/scripts/release-validate.ts index a5f9a80..4c000e1 100644 --- a/scripts/release-validate.ts +++ b/scripts/release-validate.ts @@ -44,8 +44,6 @@ export interface ReleasePullRequestCandidate { automationIdentity: string /** Merge commit recorded by GitHub. */ mergeCommit: string - /** Merge mode inferred from the candidate commit shape. */ - mergeMode: "merge" | "squash" | "rebase" /** Repository-relative paths changed by the pull request. */ changedFiles: string[] /** GitHub file status parallel to each changed path. */ @@ -77,7 +75,35 @@ export interface PublicationCandidateRecord { } /** Inputs needed to fail closed while admitting a publication candidate. */ -export interface PublicationAdmissionInput { +export interface CandidateTopology { + /** Ordered parents of the candidate commit. */ + candidateParentShas: string[] + /** Trusted base SHA supplied by the publication or repair context. */ + trustedBaseSha: string + /** Frozen base SHA recorded on the merged pull request. */ + mergedPrBaseSha: string + /** Reviewed head SHA recorded on the merged pull request. */ + reviewedPrHeadSha: string +} + +const FULL_COMMIT_SHA = /^[a-f0-9]{40}$/ + +/** Reject malformed Git commit identities before topology equality comparisons. */ +function validateCandidateTopologyCommitShas(topology: CandidateTopology): void { + if ( + !Array.isArray(topology.candidateParentShas) || + ![ + ...topology.candidateParentShas, + topology.trustedBaseSha, + topology.mergedPrBaseSha, + topology.reviewedPrHeadSha, + ].every((sha) => typeof sha === "string" && FULL_COMMIT_SHA.test(sha)) + ) { + throw new Error("publication candidate topology contains an invalid commit SHA") + } +} + +export interface PublicationAdmissionInput extends CandidateTopology { /** GitHub owner/repository identity. */ repository: string /** Configured release base branch. */ @@ -145,7 +171,7 @@ export interface RepairBindingInput { } /** GitHub-derived publication facts required to reproduce repair admission. */ -export interface RepairCandidateBindingInput extends RepairBindingInput { +export interface RepairCandidateBindingInput extends RepairBindingInput, CandidateTopology { /** Untrusted publication record carried by the annotated tag. */ candidate: unknown /** Current GitHub owner/repository identity. */ @@ -254,6 +280,19 @@ export function canonicalGitHubRepositoryIdentity(repository: string): string { */ export function admitPublicationCandidate( input: PublicationAdmissionInput, +): PublicationCandidateRecord { + return admitCandidate(input, true) +} + +/** + * Admit a candidate against its supplied projection facts. + * + * Historical repair projections are executed by the historical base policy, + * so the current repair binding must not reinterpret their path allowlist. + */ +function admitCandidate( + input: PublicationAdmissionInput, + enforceCurrentProjectionPaths: boolean, ): PublicationCandidateRecord { if (input.candidates.length !== 1) { throw new Error( @@ -272,13 +311,25 @@ export function admitPublicationCandidate( if (candidate.mergeCommit !== input.githubSha) { throw new Error("publication candidate merge commit does not equal github.sha") } - if (candidate.mergeMode !== "merge") { - throw new Error(`publication candidate merge mode ${candidate.mergeMode} is unsupported`) + validateCandidateTopologyCommitShas(input) + if (input.candidateParentShas.length !== 1 && input.candidateParentShas.length !== 2) { + throw new Error("publication candidate must have exactly one or two parents") + } + const [baseParent, reviewedHeadParent] = input.candidateParentShas + if (baseParent !== input.trustedBaseSha) { + throw new Error("publication candidate first parent does not match the trusted base") + } + if (baseParent !== input.mergedPrBaseSha) { + throw new Error("publication candidate first parent does not match the merged PR base") + } + if (input.candidateParentShas.length === 2 && reviewedHeadParent !== input.reviewedPrHeadSha) { + throw new Error("publication candidate second parent does not match the reviewed PR head") } if ( candidate.changedFiles.length === 0 || new Set(candidate.changedFiles).size !== candidate.changedFiles.length || - candidate.changedFiles.some((path) => !ALLOWED_RELEASE_PROJECTION.has(path)) + (enforceCurrentProjectionPaths && + candidate.changedFiles.some((path) => !ALLOWED_RELEASE_PROJECTION.has(path))) ) { throw new Error("publication candidate changed files outside the allowed release projection") } @@ -388,16 +439,20 @@ export function validateRepairCandidateBinding( input: RepairCandidateBindingInput, ): { tag: string; commit: string; version: string } { const candidate = parsePublicationCandidateRecord(input.candidate) - admitPublicationCandidate({ + admitCandidate({ repository: input.repository, expectedBaseBranch: input.expectedBaseBranch, expectedAutomationIdentities: input.expectedAutomationIdentities, githubSha: input.checkoutSha, + candidateParentShas: input.candidateParentShas, + trustedBaseSha: input.trustedBaseSha, + mergedPrBaseSha: input.mergedPrBaseSha, + reviewedPrHeadSha: input.reviewedPrHeadSha, manifestVersion: input.manifestVersion, tagExists: false, candidates: [input.trustedCandidate], priorRecord: candidate, - }) + }, false) return validateRepairBinding(input) } @@ -518,16 +573,34 @@ function validateRepository(repositoryRoot: string) { "publication-candidate-${GITHUB_SHA}", "merge_commit_sha", "EXPECTED_RELEASE_PLEASE_LOGIN", - "parent_count", + "PUSH_BEFORE_SHA: ${{ github.event.before }}", + "PUSH_FORCED: ${{ github.event.forced }}", + 'if [[ "$GITHUB_REF" != "refs/heads/${BASE_BRANCH}" ]]', + 'if [[ "$PUSH_FORCED" != "false" ]]', + 'git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$GITHUB_SHA"', + "candidate_parent_shas", + "merged_pr_base_sha", + "reviewed_pr_head_sha", + "trusted_base_sha", + "admitPublicationCandidate", "scripts/release-projection.ts", - 'expectedTagState:"absent"', "bun run prove:all", "git diff --exit-code -- plugin/", "ubuntu-24.04-arm", "macos-15-intel", "SOURCE_COMMIT", "ref: ${{ needs.resolve.outputs.candidate_sha }}", - "bun run release:validate -- --repair", + "workflow_policy_sha=$(git rev-parse HEAD)", + 'git checkout --detach "$workflow_policy_sha"', + 'trusted_base_sha=$(git rev-parse "${candidate_sha}^1")', + 'git checkout --detach "$trusted_base_sha"', + 'TRUSTED_BASE_SHA: ${{ needs.resolve.outputs.trusted_base_sha }}', + 'ADMITTED_MERGED_PR_BASE_SHA: ${{ needs.resolve.outputs.merged_pr_base_sha }}', + 'ADMITTED_REVIEWED_PR_HEAD_SHA: ${{ needs.resolve.outputs.reviewed_pr_head_sha }}', + 'trusted_base_sha="$TRUSTED_BASE_SHA"', + 'if [[ "$merged_pr_base_sha" != "$ADMITTED_MERGED_PR_BASE_SHA" || "$reviewed_pr_head_sha" != "$ADMITTED_REVIEWED_PR_HEAD_SHA" ]]', + 'git checkout --detach "$CANDIDATE_SHA"', + 'if [[ "$(git rev-parse HEAD)" != "$CANDIDATE_SHA" ]]', "tag -a \"$RELEASE_TAG\" \"$CANDIDATE_SHA\" -F persisted-candidate.json", "git for-each-ref --format='%(contents)'", 'gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}"', @@ -557,6 +630,11 @@ function validateRepository(repositoryRoot: string) { ]) { if (!releaseWorkflow.includes(required)) throw new Error(`release workflow is missing ${required}`) } + for (const forbidden of ["parent_count", "mergeMode"]) { + if (releaseWorkflow.includes(forbidden)) { + throw new Error(`release workflow retains unsupported merge-shape metadata: ${forbidden}`) + } + } if (releaseWorkflow.includes("github.run_attempt")) { throw new Error("release workflow artifact identity must survive rerun-failed-jobs attempts") } @@ -708,9 +786,15 @@ function main(): void { if (!trustedCandidatePath) { throw new Error("GitHub-derived publication candidate is required for repair") } - const trustedCandidate = JSON.parse( - readFileSync(trustedCandidatePath, "utf8"), - ) as ReleasePullRequestCandidate + const trustedCandidateInput = JSON.parse(readFileSync(trustedCandidatePath, "utf8")) as + ReleasePullRequestCandidate & CandidateTopology + const { + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, + ...trustedCandidate + } = trustedCandidateInput const repository = parsed.repository ?? process.env.GITHUB_REPOSITORY if (!repository) throw new Error("repository identity is required for repair") const expectedBaseBranch = parsed.expectedBaseBranch ?? process.env.BASE_BRANCH @@ -726,6 +810,10 @@ function main(): void { expectedBaseBranch, expectedAutomationIdentities: [expectedAutomationLogin], trustedCandidate, + candidateParentShas, + trustedBaseSha, + mergedPrBaseSha, + reviewedPrHeadSha, tag: parsed.tag ?? process.env.REPAIR_TAG ?? "", checkoutSha: parsed.checkoutSha ?? process.env.CHECKOUT_SHA ?? "", tagSha: parsed.tagSha ?? process.env.TAG_SHA ?? "", diff --git a/scripts/repository-readiness.test.ts b/scripts/repository-readiness.test.ts index a9fa2cc..1d8b793 100644 --- a/scripts/repository-readiness.test.ts +++ b/scripts/repository-readiness.test.ts @@ -9,6 +9,7 @@ import { actionReferencesFromWorkflows, classifyActionsPermissions, classifyApiFailure, + classifyDirectPushProtection, classifyHostedCanaryConfiguration, classifyMergeHistoryPolicy, classifyRepositorySettings, @@ -42,11 +43,34 @@ function immutableTagRuleset(overrides: Record = {}): Record = {}): Record { + return { + id: 18, + name: "Require pull requests for main", + target: "branch", + enforcement: "active", + bypass_actors: [], + conditions: { + ref_name: { + include: ["~DEFAULT_BRANCH"], + exclude: [], + }, + }, + rules: [{ type: "pull_request" }, { type: "non_fast_forward" }], + ...overrides, + } +} + test("reports ready when every publication safeguard is proven", () => { const checks = [ classifyTagRuleset([immutableTagRuleset()]), - classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }]), - ...classifyRepositorySettings({ default_branch: "main", allow_merge_commit: true }), + classifyDirectPushProtection([reviewedDefaultBranchRuleset()], "main"), + classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }], true), + ...classifyRepositorySettings({ + default_branch: "main", + allow_squash_merge: true, + allow_merge_commit: false, + }), classifyActionsPermissions({ enabled: true, allowed_actions: "all" }), classifyReleaseAutomationConfiguration( { secrets: [{ name: "RELEASE_PLEASE_TOKEN" }] }, @@ -74,28 +98,171 @@ test("reports ready when every publication safeguard is proven", () => { expect(checks.every((check) => check.status === "ready")).toBe(true) }) +describe("default branch direct-push protection", () => { + test("accepts an active no-bypass pull-request and force-push-blocking ruleset targeting the default branch", () => { + expect(classifyDirectPushProtection([reviewedDefaultBranchRuleset()], "main")).toMatchObject({ + name: "direct-push-protection", + status: "ready", + repair: "", + }) + }) + + test.each([ + ["all branches", ["~ALL"]], + ["bare default branch", ["main"]], + ["full default branch ref", ["refs/heads/main"]], + ["single-segment branch wildcard", ["refs/heads/*"]], + ["recursive branch wildcard", ["refs/heads/**"]], + ] as const)("accepts %s targeting", (_description, include) => { + expect( + classifyDirectPushProtection( + [reviewedDefaultBranchRuleset({ conditions: { ref_name: { include, exclude: [] } } })], + "main", + ), + ).toMatchObject({ status: "ready" }) + }) + + test.each([ + ["absent", []], + ["disabled", [reviewedDefaultBranchRuleset({ enforcement: "disabled" })]], + [ + "bypassable", + [ + reviewedDefaultBranchRuleset({ + bypass_actors: [{ actor_type: "RepositoryRole", actor_id: 5, bypass_mode: "always" }], + }), + ], + ], + [ + "non-default branch", + [reviewedDefaultBranchRuleset({ conditions: { ref_name: { include: ["release"], exclude: [] } } })], + ], + [ + "excluded default branch", + [reviewedDefaultBranchRuleset({ conditions: { ref_name: { include: ["~ALL"], exclude: ["main"] } } })], + ], + [ + "wildcard-excluded default branch", + [reviewedDefaultBranchRuleset({ conditions: { ref_name: { include: ["~ALL"], exclude: ["refs/heads/*"] } } })], + ], + [ + "without pull-request rule", + [reviewedDefaultBranchRuleset({ rules: [{ type: "non_fast_forward" }] })], + ], + [ + "without force-push block", + [reviewedDefaultBranchRuleset({ rules: [{ type: "pull_request" }] })], + ], + ] as const)("reports the settings repair when the ruleset is %s", (_condition, rulesets) => { + const check = classifyDirectPushProtection(rulesets, "main") + + expect(check).toMatchObject({ status: "missing" }) + expect(check.repair).toContain("Block force pushes") + }) + + test.each([ + ["ruleset response", undefined, "main"], + ["empty default branch", [reviewedDefaultBranchRuleset()], ""], + ["ruleset", [{}], "main"], + ["non-record ruleset", ["not-a-ruleset"], "main"], + ["bypass actors", [reviewedDefaultBranchRuleset({ bypass_actors: {} })], "main"], + ["conditions", [reviewedDefaultBranchRuleset({ conditions: {} })], "main"], + ["rule", [reviewedDefaultBranchRuleset({ rules: [{}] })], "main"], + ] as const)("fails closed for unreadable %s", (_condition, rulesets, defaultBranch) => { + expect(classifyDirectPushProtection(rulesets, defaultBranch)).toMatchObject({ + name: "direct-push-protection", + status: "unavailable", + }) + }) + + test("skips well-formed non-branch rulesets", () => { + expect( + classifyDirectPushProtection([immutableTagRuleset(), reviewedDefaultBranchRuleset()], "main"), + ).toMatchObject({ status: "ready" }) + }) +}) + +describe("release merge methods", () => { + test("accepts squash-only repositories", () => { + expect( + classifyRepositorySettings({ + default_branch: "main", + allow_squash_merge: true, + allow_merge_commit: false, + }), + ).toContainEqual(expect.objectContaining({ name: "squash-merge", status: "ready", repair: "" })) + }) + + test("rejects merge-commit-only repositories because ordinary pull requests must remain squashable", () => { + expect( + classifyRepositorySettings({ + default_branch: "main", + allow_squash_merge: false, + allow_merge_commit: true, + }), + ).toContainEqual( + expect.objectContaining({ + name: "squash-merge", + status: "missing", + repair: expect.stringContaining("Allow squash merging"), + }), + ) + }) + + test("rejects repositories without squash merging", () => { + const check = classifyRepositorySettings({ + default_branch: "main", + allow_squash_merge: false, + allow_merge_commit: false, + }).find(({ name }) => name === "squash-merge") + + expect(check).toMatchObject({ name: "squash-merge", status: "missing" }) + expect(check?.repair).toContain("Allow squash merging") + }) + + test.each([ + ["missing squash setting", { default_branch: "main", allow_merge_commit: true }], + [ + "non-boolean setting", + { default_branch: "main", allow_squash_merge: "yes", allow_merge_commit: false }, + ], + ] as const)("fails closed for %s", (_condition, repository) => { + expect(classifyRepositorySettings(repository)).toContainEqual( + expect.objectContaining({ name: "squash-merge", status: "unavailable" }), + ) + }) +}) + describe("main merge-history policy", () => { test("accepts absent classic protection and effective rules without linear history", () => { - expect(classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }])).toMatchObject({ + expect(classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }], false)).toMatchObject({ name: "merge-history-policy", status: "ready", repair: "", }) }) - test("rejects classic branch protection requiring linear history", () => { + test("accepts classic branch protection requiring linear history when squash is enabled", () => { expect( classifyMergeHistoryPolicy( { required_linear_history: { enabled: true } }, [{ type: "required_status_checks" }], + true, ), - ).toMatchObject({ status: "missing" }) + ).toMatchObject({ status: "ready", repair: "" }) }) - test("rejects an effective ruleset requiring linear history", () => { + test("accepts an effective ruleset requiring linear history when squash is enabled", () => { expect( - classifyMergeHistoryPolicy(null, [{ type: "required_linear_history" }]), - ).toMatchObject({ status: "missing" }) + classifyMergeHistoryPolicy(null, [{ type: "required_linear_history" }], true), + ).toMatchObject({ status: "ready", repair: "" }) + }) + + test("rejects required linear history when only merge commits are enabled", () => { + const check = classifyMergeHistoryPolicy(null, [{ type: "required_linear_history" }], false) + + expect(check).toMatchObject({ status: "missing" }) + expect(check.repair).toContain("Allow squash merging") }) test("accepts explicitly disabled classic linear-history protection", () => { @@ -103,6 +270,7 @@ describe("main merge-history policy", () => { classifyMergeHistoryPolicy( { required_linear_history: { enabled: false } }, [{ type: "pull_request" }], + false, ), ).toMatchObject({ status: "ready" }) }) @@ -112,8 +280,16 @@ describe("main merge-history policy", () => { ["classic linear-history rule", { required_linear_history: {} }, []], ["effective rules", null, undefined], ["malformed effective rule", null, [{}]], + ["malformed effective rule with classic linear history", { required_linear_history: { enabled: true } }, [{}]], ] as const)("fails closed for unreadable %s", (_condition, protection, rules) => { - expect(classifyMergeHistoryPolicy(protection, rules)).toMatchObject({ + expect(classifyMergeHistoryPolicy(protection, rules, true)).toMatchObject({ + name: "merge-history-policy", + status: "unavailable", + }) + }) + + test("fails closed when squash capability is unreadable", () => { + expect(classifyMergeHistoryPolicy(null, [], undefined)).toMatchObject({ name: "merge-history-policy", status: "unavailable", }) diff --git a/scripts/repository-readiness.ts b/scripts/repository-readiness.ts index f87b7a2..4802314 100644 --- a/scripts/repository-readiness.ts +++ b/scripts/repository-readiness.ts @@ -5,9 +5,12 @@ const root = resolve(import.meta.dir, "..") const tagRulesetRepair = "Settings > Rules > Rulesets > New tag ruleset: target tags matching v*, enable Restrict deletions and Restrict updates, no bypass actors" const defaultBranchRepair = "Settings > Branches > Default branch: change the default branch to main" -const mergeCommitRepair = "Settings > General > Pull Requests: enable Allow merge commits" +const directPushRepair = + "Settings > Rules > Rulesets > Edit the default-branch ruleset: enable Require a pull request before merging and Block force pushes, then remove all bypass actors" +const squashMergeRepair = + "Settings > General > Pull Requests: enable Allow squash merging" const mergeHistoryRepair = - "Settings > Rules: remove Require linear history from every rule protecting main; release pull requests require two-parent merge commits" + "Settings > General > Pull Requests: enable Allow squash merging" const actionsRepair = "Settings > Actions > General > Actions permissions: enable the actions and reusable workflows used by this repository" const requiredChecksRepair = @@ -138,6 +141,35 @@ function matchesVersionTags(rule: Record): boolean { return include.some((pattern) => ["v*", "refs/tags/v*", "~ALL"].includes(pattern)) } +function matchesDefaultBranch(rule: Record, defaultBranch: string): boolean | undefined { + if (!isRecord(rule.conditions) || !isRecord(rule.conditions.ref_name)) return undefined + const include = rule.conditions.ref_name.include + const exclude = rule.conditions.ref_name.exclude + if ( + !Array.isArray(include) || + !include.every((pattern) => typeof pattern === "string") || + !Array.isArray(exclude) || + !exclude.every((pattern) => typeof pattern === "string") + ) { + return undefined + } + const matchesBranch = (pattern: string): boolean | undefined => { + if (["~ALL", "~DEFAULT_BRANCH", defaultBranch, `refs/heads/${defaultBranch}`].includes(pattern)) { + return true + } + try { + const glob = new Bun.Glob(pattern) + return glob.match(defaultBranch) || glob.match(`refs/heads/${defaultBranch}`) + } catch { + return undefined + } + } + const included = include.map(matchesBranch) + const excluded = exclude.map(matchesBranch) + if (included.includes(undefined) || excluded.includes(undefined)) return undefined + return included.includes(true) && !excluded.includes(true) +} + /** * Prove one active, non-bypassable ruleset makes every v* tag immutable. * @@ -187,14 +219,98 @@ export function classifyTagRuleset(rulesets: unknown): ReadinessCheck { } /** - * Classify repository metadata needed by the two-parent release design. + * Prove main cannot receive an unreviewed direct push or a force push. + * + * @param rulesets - Detailed branch rulesets from the GitHub API + * @param defaultBranch - Repository default branch + * @returns Direct-push safeguard classification + * + * @example + * ```typescript + * classifyDirectPushProtection([{ target: "branch", enforcement: "active", bypass_actors: [], conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } }, rules: [{ type: "pull_request" }, { type: "non_fast_forward" }] }], "main") + * ``` + */ +export function classifyDirectPushProtection( + rulesets: unknown, + defaultBranch: unknown, +): ReadinessCheck { + if (!Array.isArray(rulesets) || typeof defaultBranch !== "string" || defaultBranch.length === 0) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned unreadable branch rulesets or default-branch metadata; direct-push protection is unproven", + repair: directPushRepair, + } + } + for (const value of rulesets) { + if (!isRecord(value)) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned an unreadable branch ruleset; direct-push protection is unproven", + repair: directPushRepair, + } + } + if (typeof value.target !== "string") { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned an unreadable branch ruleset; direct-push protection is unproven", + repair: directPushRepair, + } + } + if (value.target !== "branch") continue + if (value.enforcement !== "active") continue + if (!Array.isArray(value.bypass_actors) || !Array.isArray(value.rules)) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned an unreadable active branch ruleset; direct-push protection is unproven", + repair: directPushRepair, + } + } + const coversDefaultBranch = matchesDefaultBranch(value, defaultBranch) + if (coversDefaultBranch === undefined) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned unreadable active branch ruleset conditions; direct-push protection is unproven", + repair: directPushRepair, + } + } + if (!coversDefaultBranch || value.bypass_actors.length > 0) continue + if (value.rules.some((rule) => !isRecord(rule) || typeof rule.type !== "string")) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned an unreadable branch rule; direct-push protection is unproven", + repair: directPushRepair, + } + } + const ruleTypes = new Set(value.rules.map((rule) => rule.type)) + if (ruleTypes.has("pull_request") && ruleTypes.has("non_fast_forward")) { + return ready( + "direct-push-protection", + `Active ${defaultBranch} branch ruleset requires pull requests and blocks force pushes with no bypass actors`, + ) + } + } + return missing( + "direct-push-protection", + `No active, no-bypass branch ruleset requires pull requests and blocks force pushes for ${defaultBranch}`, + directPushRepair, + ) +} + +/** + * Classify repository metadata needed by the verified one-parent or two-parent release design. * * @param repository - GitHub repository API response - * @returns Default-branch and merge-mode checks + * @returns Default-branch and squash-merge checks * * @example * ```typescript - * classifyRepositorySettings({ default_branch: "main", allow_merge_commit: true }) + * classifyRepositorySettings({ default_branch: "main", allow_squash_merge: true, allow_merge_commit: false }) * ``` */ export function classifyRepositorySettings(repository: unknown): ReadinessCheck[] { @@ -207,13 +323,34 @@ export function classifyRepositorySettings(repository: unknown): ReadinessCheck[ repair: defaultBranchRepair, }, { - name: "merge-commits", + name: "squash-merge", status: "unavailable", - detail: "GitHub returned unreadable repository metadata; merge mode is unproven", - repair: mergeCommitRepair, + detail: "GitHub returned unreadable repository metadata; squash merging is unproven", + repair: squashMergeRepair, }, ] } + const mergeMethodsReadable = typeof repository.allow_squash_merge === "boolean" + let mergeMethodCheck: ReadinessCheck + if (!mergeMethodsReadable) { + mergeMethodCheck = { + name: "squash-merge", + status: "unavailable", + detail: "GitHub returned an unreadable squash-merge setting; the required release path is unproven", + repair: squashMergeRepair, + } + } else if (repository.allow_squash_merge) { + mergeMethodCheck = ready( + "squash-merge", + "Squash merging is allowed for verified one-parent release candidates", + ) + } else { + mergeMethodCheck = missing( + "squash-merge", + "Squash merging is disabled; ordinary pull requests and release candidates cannot use the required squash path", + squashMergeRepair, + ) + } return [ repository.default_branch === "main" ? ready("default-branch", "Default branch is main") @@ -222,32 +359,36 @@ export function classifyRepositorySettings(repository: unknown): ReadinessCheck[ `Default branch is ${String(repository.default_branch ?? "unset")}; expected main`, defaultBranchRepair, ), - repository.allow_merge_commit === true - ? ready("merge-commits", "Merge commits are allowed for two-parent release candidates") - : missing( - "merge-commits", - "Merge commits are disabled; release publication requires a two-parent merge commit", - mergeCommitRepair, - ), + mergeMethodCheck, ] } /** - * Reject branch policies that prohibit the release design's two-parent merge commit. + * Accept readable linear-history policy only when squash merging provides a compatible release path. * * @param branchProtection - Full main branch-protection response, or null when no classic protection exists * @param effectiveRules - Effective GitHub ruleset rules for main + * @param squashMergeEnabled - Readable repository squash-merge capability * @returns Merge-history policy classification * * @example * ```typescript - * classifyMergeHistoryPolicy(null, [{ type: "required_status_checks" }]) + * classifyMergeHistoryPolicy(null, [{ type: "required_linear_history" }], true) * ``` */ export function classifyMergeHistoryPolicy( branchProtection: unknown, effectiveRules: unknown, + squashMergeEnabled: unknown, ): ReadinessCheck { + if (typeof squashMergeEnabled !== "boolean") { + return { + name: "merge-history-policy", + status: "unavailable", + detail: "GitHub returned unreadable squash-merge capability; merge history compatibility is unproven", + repair: mergeHistoryRepair, + } + } if (branchProtection !== null && !isRecord(branchProtection)) { return { name: "merge-history-policy", @@ -256,6 +397,7 @@ export function classifyMergeHistoryPolicy( repair: mergeHistoryRepair, } } + let classicRequiresLinearHistory = false if (isRecord(branchProtection) && branchProtection.required_linear_history !== undefined) { const linearHistory = branchProtection.required_linear_history if (!isRecord(linearHistory) || typeof linearHistory.enabled !== "boolean") { @@ -266,13 +408,7 @@ export function classifyMergeHistoryPolicy( repair: mergeHistoryRepair, } } - if (linearHistory.enabled) { - return missing( - "merge-history-policy", - "Classic branch protection requires linear history on main, which prohibits two-parent release merges", - mergeHistoryRepair, - ) - } + classicRequiresLinearHistory = linearHistory.enabled } if ( !Array.isArray(effectiveRules) || @@ -285,12 +421,20 @@ export function classifyMergeHistoryPolicy( repair: mergeHistoryRepair, } } - if (effectiveRules.some((rule) => rule.type === "required_linear_history")) { - return missing( - "merge-history-policy", - "An active ruleset requires linear history on main, which prohibits two-parent release merges", - mergeHistoryRepair, - ) + const rulesetRequiresLinearHistory = effectiveRules.some( + (rule) => rule.type === "required_linear_history", + ) + if (classicRequiresLinearHistory || rulesetRequiresLinearHistory) { + return squashMergeEnabled + ? ready( + "merge-history-policy", + "Main requires linear history; squash merging provides a compatible one-parent release path", + ) + : missing( + "merge-history-policy", + "Main requires linear history, but squash merging is disabled", + mergeHistoryRepair, + ) } return ready( "merge-history-policy", @@ -817,7 +961,39 @@ function checkTagRuleset(repository: string): ReadinessCheck { return classifyTagRuleset(details) } -function checkMergeHistoryPolicy(repository: string): ReadinessCheck { +function checkDirectPushProtection(repository: string, defaultBranch: unknown): ReadinessCheck { + const listed = readApi(`repos/${repository}/rulesets?includes_parents=true&per_page=100`, true) + if (!listed.ok) return apiFailure("direct-push-protection", listed, directPushRepair) + const summaries = flattenPages(listed.data) + if (!Array.isArray(summaries)) return classifyDirectPushProtection(summaries, defaultBranch) + if (summaries.some((value) => !isRecord(value) || typeof value.target !== "string")) { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned an unreadable ruleset summary; direct-push protection is unproven", + repair: directPushRepair, + } + } + const branchSummaries = summaries.filter((value) => isRecord(value) && value.target === "branch") + if (branchSummaries.length === 0) return classifyDirectPushProtection([], defaultBranch) + const details: unknown[] = [] + for (const summary of branchSummaries) { + if (!isRecord(summary) || typeof summary.id !== "number") { + return { + name: "direct-push-protection", + status: "unavailable", + detail: "GitHub returned a branch ruleset without a readable id; direct-push protection is unproven", + repair: directPushRepair, + } + } + const detailed = readApi(`repos/${repository}/rulesets/${summary.id}?includes_parents=true`) + if (!detailed.ok) return apiFailure("direct-push-protection", detailed, directPushRepair) + details.push(detailed.data) + } + return classifyDirectPushProtection(details, defaultBranch) +} + +function checkMergeHistoryPolicy(repository: string, squashMergeEnabled: unknown): ReadinessCheck { const protection = readApi(`repos/${repository}/branches/main/protection`) if (!protection.ok && !isNotFoundApiFailure(protection)) { return apiFailure("merge-history-policy", protection, mergeHistoryRepair) @@ -826,7 +1002,11 @@ function checkMergeHistoryPolicy(repository: string): ReadinessCheck { if (!effectiveRules.ok) { return apiFailure("merge-history-policy", effectiveRules, mergeHistoryRepair) } - return classifyMergeHistoryPolicy(protection.ok ? protection.data : null, effectiveRules.data) + return classifyMergeHistoryPolicy( + protection.ok ? protection.data : null, + effectiveRules.data, + squashMergeEnabled, + ) } function checkHostedCanaryConfiguration(repository: string): ReadinessCheck { @@ -887,13 +1067,21 @@ function localActionReferences(): string[] { } function runChecks(repository: string): ReadinessCheck[] { - const checks: ReadinessCheck[] = [checkTagRuleset(repository), checkMergeHistoryPolicy(repository)] const repositoryResponse = readApi(`repos/${repository}`) + const repositoryData = repositoryResponse.ok && isRecord(repositoryResponse.data) + ? repositoryResponse.data + : undefined + const squashMergeEnabled = repositoryData?.allow_squash_merge + const checks: ReadinessCheck[] = [ + checkTagRuleset(repository), + checkDirectPushProtection(repository, repositoryData?.default_branch), + checkMergeHistoryPolicy(repository, squashMergeEnabled), + ] if (repositoryResponse.ok) checks.push(...classifyRepositorySettings(repositoryResponse.data)) else { checks.push( apiFailure("default-branch", repositoryResponse, defaultBranchRepair), - apiFailure("merge-commits", repositoryResponse, mergeCommitRepair), + apiFailure("squash-merge", repositoryResponse, squashMergeRepair), ) } const actions = readApi(`repos/${repository}/actions/permissions`)