diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2c7a079..a0579a6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,6 +26,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 outputs: + artifact-name: ${{ steps.artifact-name.outputs.name }} dist-tag: ${{ steps.version.outputs.dist-tag }} release-commit: ${{ steps.trust.outputs.release-commit }} release-tag: ${{ steps.trust.outputs.release-tag }} @@ -156,10 +157,18 @@ jobs: RELEASE_JSON="$release_json" TAG_COMMIT="$tag_commit" \ node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; - import { validateGitHubRelease } from "./scripts/release-workflow-validation.mjs"; + import { + extractReleaseNotesFromChangelog, + validateGitHubRelease, + } from "./scripts/release-workflow-validation.mjs"; const release = JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8")); + const version = JSON.parse(readFileSync("package.json", "utf8")).version; validateGitHubRelease(release, { + expectedBody: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + version, + ), htmlUrl: process.env.RELEASE_HTML_URL, releaseCommit: process.env.RELEASE_COMMIT, tag: process.env.RELEASE_TAG, @@ -202,10 +211,16 @@ jobs: --tag "${{ steps.trust.outputs.release-tag }}" npm run test:examples -- --tarball "${{ steps.pack.outputs.tarball }}" npm run test:fixtures -- --tarball "${{ steps.pack.outputs.tarball }}" + - name: Name the attempt-qualified release artifact + id: artifact-name + shell: bash + run: | + set -euo pipefail + echo "name=npm-package-${{ steps.version.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Upload the verified release artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: npm-package-${{ steps.version.outputs.version }} + name: ${{ steps.artifact-name.outputs.name }} path: ${{ steps.pack.outputs.tarball }} if-no-files-found: error retention-days: 30 @@ -280,7 +295,7 @@ jobs: - name: Download the verified release artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: npm-package-${{ needs.verify.outputs.version }} + name: ${{ needs.verify.outputs.artifact-name }} path: release-artifacts - name: Publish the exact artifact with provenance env: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 308b8b5..30a4417 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -10,7 +10,7 @@ permissions: contents: read concurrency: - group: release-please-${{ github.ref }} + group: release-please-main cancel-in-progress: false jobs: @@ -18,8 +18,9 @@ jobs: name: Prepare a reviewed release pull request or GitHub release if: vars.RELEASE_PLEASE_ENABLED == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: + actions: read contents: write issues: write pull-requests: write @@ -32,10 +33,25 @@ jobs: ref: ${{ github.sha }} - name: Require the exact current main commit env: + EVENT_NAME: ${{ github.event_name }} EXPECTED_SHA: ${{ github.sha }} + RUN_ATTEMPT: ${{ github.run_attempt }} + TRIGGERING_REF: ${{ github.ref }} shell: bash run: | set -euo pipefail + if [[ "$TRIGGERING_REF" != "refs/heads/main" ]]; then + echo "Release Please must run from refs/heads/main." >&2 + exit 1 + fi + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_ATTEMPT" != "1" ]]; then + echo "Release Please preparation reruns are forbidden; start a new dispatch." >&2 + exit 1 + fi + if [[ "$EVENT_NAME" != "workflow_dispatch" && "$EVENT_NAME" != "push" ]]; then + echo "Release Please received an unsupported event." >&2 + exit 1 + fi if [[ "$(git rev-parse HEAD)" != "$EXPECTED_SHA" ]]; then echo "The checked-out commit does not match the triggering SHA." >&2 exit 1 @@ -45,21 +61,156 @@ jobs: echo "main moved after this Release Please run was triggered." >&2 exit 1 fi - - name: Reject rerun attempts + - name: Set up Node.js 24 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + cache: npm + - name: Install validation dependencies without lifecycle scripts + run: npm ci --ignore-scripts + - name: Validate Release Please configuration before mutation + shell: bash + run: | + set -euo pipefail + node scripts/validate-release.mjs + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateReleasePleaseMutationConfiguration } from "./scripts/release-workflow-validation.mjs"; + + validateReleasePleaseMutationConfiguration( + JSON.parse(readFileSync("release-please-config.json", "utf8")), + ); + EOF + - name: Reject commit-level version overrides env: - RUN_ATTEMPT: ${{ github.run_attempt }} + STABLE_BOUNDARY: 1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca shell: bash run: | set -euo pipefail - if [[ "$RUN_ATTEMPT" != "1" ]]; then - echo "Release Please reruns are forbidden; start a new first-attempt run." >&2 + if ! git merge-base --is-ancestor "$STABLE_BOUNDARY" HEAD; then + echo "The stable 0.1.0 boundary is not an ancestor of main." >&2 exit 1 fi + commits_file="$RUNNER_TEMP/release-please-commits" + git log -z --format='%B' "${STABLE_BOUNDARY}..HEAD" > "$commits_file" + COMMITS_FILE="$commits_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateReleasePleaseCommitMessages } from "./scripts/release-workflow-validation.mjs"; + + const messages = readFileSync(process.env.COMMITS_FILE, "utf8") + .split("\0") + .filter((message) => message !== ""); + validateReleasePleaseCommitMessages(messages); + EOF + - name: Inspect the exact release state for a push run + id: release-state + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_ID: ${{ github.run_id }} + shell: bash + run: | + set -euo pipefail + run_file="$RUNNER_TEMP/release-please-run.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" > "$run_file" + run_created_at="$(RUN_FILE="$run_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateReleasePleaseRunMetadata } from "./scripts/release-workflow-validation.mjs"; + + const result = validateReleasePleaseRunMetadata( + JSON.parse(readFileSync(process.env.RUN_FILE, "utf8")), + { + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), + runId: Number(process.env.RUN_ID), + }, + ); + process.stdout.write(result.createdAt); + EOF + )" + + version="$(node --print 'require("./package.json").version')" + tag="v${version}" + attempts_file="$RUNNER_TEMP/release-please-prior-attempts.json" + printf '[]\n' > "$attempts_file" + if (( RUN_ATTEMPT > 1 )); then + for attempt in $(seq 1 $((RUN_ATTEMPT - 1))); do + attempt_file="$RUNNER_TEMP/release-please-attempt-${attempt}.json" + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${attempt}/jobs?per_page=100" \ + > "$attempt_file" + next_attempts="$RUNNER_TEMP/release-please-prior-attempts-next.json" + jq --argjson attempt "$attempt" --slurpfile jobs "$attempt_file" \ + '. + [{attempt: $attempt, jobs: $jobs[0].jobs}]' \ + "$attempts_file" > "$next_attempts" + mv "$next_attempts" "$attempts_file" + done + fi + owner="${GITHUB_REPOSITORY%%/*}" + repository_name="${GITHUB_REPOSITORY#*/}" + state_file="$RUNNER_TEMP/release-please-presence.json" + release_file="$RUNNER_TEMP/release-please-release-before.json" + # shellcheck disable=SC2016 + gh api graphql \ + -f owner="$owner" \ + -f name="$repository_name" \ + -f tag="$tag" \ + -f qualifiedName="refs/tags/${tag}" \ + -f query='query($owner: String!, $name: String!, $tag: String!, $qualifiedName: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { id } ref(qualifiedName: $qualifiedName) { name } } }' \ + > "$state_file" + + release_exists="$(jq -r '.data.repository.release != null' "$state_file")" + tag_exists="$(jq -r '.data.repository.ref != null' "$state_file")" + tag_commit="" + if [[ "$release_exists" == "true" ]]; then + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "$release_file" + else + printf 'null\n' > "$release_file" + fi + if [[ "$tag_exists" == "true" ]]; then + git fetch --no-tags origin \ + "+refs/tags/${tag}:refs/tags/${tag}" + tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" + fi + + ATTEMPTS_FILE="$attempts_file" RELEASE_FILE="$release_file" \ + RUN_CREATED_AT="$run_created_at" RUN_ID="$RUN_ID" \ + TAG_COMMIT="$tag_commit" VERSION="$version" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateReleasePresenceBeforeAction, + } from "./scripts/release-workflow-validation.mjs"; + + validateReleasePresenceBeforeAction({ + attempts: JSON.parse( + readFileSync(process.env.ATTEMPTS_FILE, "utf8"), + ), + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + release: JSON.parse(readFileSync(process.env.RELEASE_FILE, "utf8")), + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), + runCreatedAt: process.env.RUN_CREATED_AT, + runId: Number(process.env.RUN_ID), + tagCommit: process.env.TAG_COMMIT === "" ? null : process.env.TAG_COMMIT, + version: process.env.VERSION, + }); + EOF + echo "exists=${release_exists}" >> "$GITHUB_OUTPUT" + echo "run-created-at=${run_created_at}" >> "$GITHUB_OUTPUT" - name: Reject an unrelated stale Release Please branch + id: branch-state env: - EXPECTED_OWNER: cometapi-dev - RELEASE_BRANCH: release-please--branches--main--components--cometapi GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: release-please--branches--main--components--cometapi + RELEASE_EXISTS: ${{ steps.release-state.outputs.exists || 'false' }} shell: bash run: | set -euo pipefail @@ -70,7 +221,6 @@ jobs: manifest_version="" main_version="$(node --print 'require("./package.json").version')" pull_requests_file="$RUNNER_TEMP/release-please-pulls.json" - printf '[]\n' > "$pull_requests_file" remote_ref="refs/heads/${RELEASE_BRANCH}" if git ls-remote --exit-code --heads origin "$remote_ref" >/dev/null; then branch_exists="true" @@ -85,10 +235,10 @@ jobs: if git merge-base --is-ancestor "$release_ref" refs/remotes/origin/main; then is_ancestor="true" fi - gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/pulls?state=all&head=${EXPECTED_OWNER}%3A${RELEASE_BRANCH}&per_page=100" \ - | jq 'add' > "$pull_requests_file" fi + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=all&base=main&per_page=100" \ + | jq 'add' > "$pull_requests_file" BRANCH_EXISTS="$branch_exists" BRANCH_SHA="$branch_sha" \ BRANCH_VERSION="$branch_version" IS_ANCESTOR="$is_ancestor" \ @@ -97,44 +247,47 @@ jobs: node --input-type=module <<'EOF' import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; - import { validateReleasePleaseBranchState } from "./scripts/release-workflow-validation.mjs"; + import { + validateOpenReleasePullRequestCollisions, + validateReleasePleaseBranchState, + } from "./scripts/release-workflow-validation.mjs"; const branchSha = process.env.BRANCH_SHA; - const pullRequests = JSON.parse( + const rawPullRequests = JSON.parse( readFileSync(process.env.PULL_REQUESTS_FILE, "utf8"), - ) - .filter( - (pullRequest) => - pullRequest.base?.ref === "main" && - pullRequest.head?.ref === process.env.RELEASE_BRANCH && - pullRequest.head?.sha === branchSha, - ) - .map((pullRequest) => ({ - author: pullRequest.user?.login, - baseRef: pullRequest.base?.ref, - headRef: pullRequest.head?.ref, - headSha: pullRequest.head?.sha, - labels: pullRequest.labels?.map((label) => label.name), - mergeCommitIsAncestor: - typeof pullRequest.merge_commit_sha === "string" && - /^[0-9a-f]{40}$/.test(pullRequest.merge_commit_sha) && - spawnSync( - "git", - [ - "merge-base", - "--is-ancestor", - pullRequest.merge_commit_sha, - "refs/remotes/origin/main", - ], - { stdio: "ignore" }, - ).status === 0, - mergeCommitSha: pullRequest.merge_commit_sha, - mergedAt: pullRequest.merged_at, - number: pullRequest.number, - state: pullRequest.state, - title: pullRequest.title, - })); - + ); + const pullRequests = rawPullRequests.map((pullRequest) => ({ + author: pullRequest.user?.login, + baseRef: pullRequest.base?.ref, + body: pullRequest.body, + headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitIsAncestor: + typeof pullRequest.merge_commit_sha === "string" && + /^[0-9a-f]{40}$/.test(pullRequest.merge_commit_sha) && + spawnSync( + "git", + [ + "merge-base", + "--is-ancestor", + pullRequest.merge_commit_sha, + "refs/remotes/origin/main", + ], + { stdio: "ignore" }, + ).status === 0, + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + })); + validateOpenReleasePullRequestCollisions(pullRequests, { + branchSha: branchSha === "" ? null : branchSha, + releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + }); validateReleasePleaseBranchState({ branchSha, branchVersion: process.env.BRANCH_VERSION, @@ -142,23 +295,33 @@ jobs: isAncestor: process.env.IS_ANCESTOR === "true", mainVersion: process.env.MAIN_VERSION, manifestVersion: process.env.MANIFEST_VERSION, - pullRequests, + pullRequests: pullRequests.filter( + (pullRequest) => + pullRequest.baseRef === "main" && + pullRequest.headRef === process.env.RELEASE_BRANCH && + pullRequest.headSha === branchSha, + ), releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + requirePendingLabel: process.env.RELEASE_EXISTS !== "true", }); EOF - - name: Require human-owner review on a merged release PR + echo "branch-exists=${branch_exists}" >> "$GITHUB_OUTPUT" + echo "branch-sha=${branch_sha}" >> "$GITHUB_OUTPUT" + - name: Classify the release operation + id: preflight env: EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} RELEASE_BRANCH: release-please--branches--main--components--cometapi + RELEASE_EXISTS: ${{ steps.release-state.outputs.exists || 'false' }} + RUN_ATTEMPT: ${{ github.run_attempt }} shell: bash run: | set -euo pipefail release_pulls_file="$RUNNER_TEMP/release-pulls.json" - reviews_file="$RUNNER_TEMP/release-reviews.json" - permissions_file="$RUNNER_TEMP/reviewer-permissions.json" gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/pulls?state=closed&head=cometapi-dev%3A${RELEASE_BRANCH}&per_page=100" \ + "repos/${GITHUB_REPOSITORY}/pulls?state=closed&base=main&per_page=100" \ | jq 'add' > "$release_pulls_file" release_pr_number="$(RELEASE_PULLS_FILE="$release_pulls_file" node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; @@ -167,18 +330,26 @@ jobs: const pulls = JSON.parse( readFileSync(process.env.RELEASE_PULLS_FILE, "utf8"), ).map((pullRequest) => ({ + author: pullRequest.user?.login, baseRef: pullRequest.base?.ref, + body: pullRequest.body, headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, labels: pullRequest.labels?.map((label) => label.name), mergeCommitSha: pullRequest.merge_commit_sha, mergedAt: pullRequest.merged_at, number: pullRequest.number, state: pullRequest.state, + title: pullRequest.title, })); const releasePullRequest = selectPendingReleasePullRequest(pulls, { eventName: process.env.EVENT_NAME, releaseBranch: process.env.RELEASE_BRANCH, releaseCommit: process.env.GITHUB_SHA, + releaseExists: process.env.RELEASE_EXISTS === "true", + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), }); process.stdout.write( releasePullRequest === null ? "" : String(releasePullRequest.number), @@ -186,34 +357,156 @@ jobs: EOF )" if [[ -z "$release_pr_number" ]]; then - echo "This is a release-PR preparation run; no merged release PR is associated with HEAD." + echo "mode=prepare" >> "$GITHUB_OUTPUT" + echo "release-pr-number=" >> "$GITHUB_OUTPUT" + echo "This first-attempt manual run may prepare exactly one release PR." exit 0 fi + echo "mode=release" >> "$GITHUB_OUTPUT" + echo "release-pr-number=${release_pr_number}" >> "$GITHUB_OUTPUT" + - name: Require final release state before mutation + if: steps.preflight.outputs.mode == 'release' + shell: bash + run: | + set -euo pipefail + node scripts/validate-release.mjs \ + --require-final \ + --require-releasable-docs + - name: Reconfirm the branch, candidate, and review before mutation + env: + EVENT_NAME: ${{ github.event_name }} + EXPECTED_BRANCH_EXISTS: ${{ steps.branch-state.outputs.branch-exists }} + EXPECTED_BRANCH_SHA: ${{ steps.branch-state.outputs.branch-sha }} + EXPECTED_MODE: ${{ steps.preflight.outputs.mode }} + EXPECTED_PR_NUMBER: ${{ steps.preflight.outputs.release-pr-number }} + EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists || 'false' }} + EXPECTED_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: release-please--branches--main--components--cometapi + RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$EXPECTED_SHA" ]]; then + echo "main moved during Release Please preflight." >&2 + exit 1 + fi - gh api --paginate --slurp \ - "repos/${GITHUB_REPOSITORY}/pulls/${release_pr_number}/reviews?per_page=100" \ - | jq 'add' > "$reviews_file" - printf '{}\n' > "$permissions_file" - while IFS= read -r reviewer; do - permission="$(gh api \ - "repos/${GITHUB_REPOSITORY}/collaborators/${reviewer}/permission" \ - --jq '.permission' 2>/dev/null || printf 'none')" - next_permissions="$RUNNER_TEMP/reviewer-permissions-next.json" - jq --arg reviewer "$reviewer" --arg permission "$permission" \ - '. + {($reviewer): $permission}' \ - "$permissions_file" > "$next_permissions" - mv "$next_permissions" "$permissions_file" - done < <(jq -r '.[].user.login' "$reviews_file" | sort -u) + branch_exists="false" + branch_sha="" + remote_ref="refs/heads/${RELEASE_BRANCH}" + if git ls-remote --exit-code --heads origin "$remote_ref" >/dev/null; then + branch_exists="true" + git fetch --no-tags origin \ + "+${remote_ref}:refs/remotes/origin/${RELEASE_BRANCH}" + branch_sha="$(git rev-parse "refs/remotes/origin/${RELEASE_BRANCH}")" + fi + if [[ "$branch_exists" != "$EXPECTED_BRANCH_EXISTS" || "$branch_sha" != "$EXPECTED_BRANCH_SHA" ]]; then + echo "The Release Please branch changed after initial validation." >&2 + exit 1 + fi - PERMISSIONS_FILE="$permissions_file" RELEASE_PULLS_FILE="$release_pulls_file" \ - RELEASE_PR_NUMBER="$release_pr_number" REVIEWS_FILE="$reviews_file" \ + release_pulls_file="$RUNNER_TEMP/release-pulls-reconfirmed.json" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=all&base=main&per_page=100" \ + | jq 'add' > "$release_pulls_file" + BRANCH_SHA="$branch_sha" RELEASE_PULLS_FILE="$release_pulls_file" \ node --input-type=module <<'EOF' import { readFileSync } from "node:fs"; - import { validateMergedReleasePullRequest } from "./scripts/release-workflow-validation.mjs"; + import { + extractReleaseNotesFromChangelog, + selectPendingReleasePullRequest, + validateOpenReleasePullRequestCollisions, + validateReleaseCandidatePullRequest, + } from "./scripts/release-workflow-validation.mjs"; + + const branchSha = process.env.BRANCH_SHA; + const pulls = JSON.parse( + readFileSync(process.env.RELEASE_PULLS_FILE, "utf8"), + ).map((pullRequest) => ({ + author: pullRequest.user?.login, + baseRef: pullRequest.base?.ref, + body: pullRequest.body, + headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + })); + validateOpenReleasePullRequestCollisions(pulls, { + branchSha: branchSha === "" ? null : branchSha, + releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + }); + const releasePullRequest = selectPendingReleasePullRequest(pulls, { + eventName: process.env.EVENT_NAME, + releaseBranch: process.env.RELEASE_BRANCH, + releaseCommit: process.env.GITHUB_SHA, + releaseExists: process.env.EXPECTED_RELEASE_EXISTS === "true", + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), + }); + const mode = releasePullRequest === null ? "prepare" : "release"; + if (mode !== process.env.EXPECTED_MODE) { + throw new Error("Release workflow candidate changed after preflight."); + } + if (releasePullRequest !== null) { + if (String(releasePullRequest.number) !== process.env.EXPECTED_PR_NUMBER) { + throw new Error("Release workflow candidate PR number changed after preflight."); + } + const version = JSON.parse(readFileSync("package.json", "utf8")).version; + validateReleaseCandidatePullRequest({ + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + version, + ), + pullRequest: releasePullRequest, + releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + requirePendingLabel: process.env.EXPECTED_RELEASE_EXISTS !== "true", + version, + }); + } + EOF + cp "$release_pulls_file" \ + "$RUNNER_TEMP/release-pulls-before-action.json" + + if [[ "$EXPECTED_MODE" == "release" ]]; then + pull_request_file="$RUNNER_TEMP/release-pull-reconfirmed.json" + reviews_file="$RUNNER_TEMP/release-reviews-reconfirmed.json" + permissions_file="$RUNNER_TEMP/reviewer-permissions-reconfirmed.json" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${EXPECTED_PR_NUMBER}" \ + > "$pull_request_file" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls/${EXPECTED_PR_NUMBER}/reviews?per_page=100" \ + | jq 'add' > "$reviews_file" + printf '{}\n' > "$permissions_file" + while IFS= read -r reviewer; do + permission="$(gh api \ + "repos/${GITHUB_REPOSITORY}/collaborators/${reviewer}/permission" \ + --jq '.permission' 2>/dev/null || printf 'none')" + next_permissions="$RUNNER_TEMP/reviewer-permissions-reconfirmed-next.json" + jq --arg reviewer "$reviewer" --arg permission "$permission" \ + '. + {($reviewer): $permission}' \ + "$permissions_file" > "$next_permissions" + mv "$next_permissions" "$permissions_file" + done < <(jq -r '.[].user.login' "$reviews_file" | sort -u) + + PERMISSIONS_FILE="$permissions_file" PULL_REQUEST_FILE="$pull_request_file" \ + REVIEWS_FILE="$reviews_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateMergedReleasePullRequest, + } from "./scripts/release-workflow-validation.mjs"; - const pulls = JSON.parse(readFileSync(process.env.RELEASE_PULLS_FILE, "utf8")); - const rawPullRequest = pulls.find( - (pullRequest) => pullRequest.number === Number(process.env.RELEASE_PR_NUMBER), + const rawPullRequest = JSON.parse( + readFileSync(process.env.PULL_REQUEST_FILE, "utf8"), ); const permissions = JSON.parse( readFileSync(process.env.PERMISSIONS_FILE, "utf8"), @@ -221,10 +514,16 @@ jobs: const reviews = JSON.parse(readFileSync(process.env.REVIEWS_FILE, "utf8")); const version = JSON.parse(readFileSync("package.json", "utf8")).version; validateMergedReleasePullRequest({ + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + version, + ), pullRequest: { author: rawPullRequest.user?.login, baseRef: rawPullRequest.base?.ref, + body: rawPullRequest.body, headRef: rawPullRequest.head?.ref, + headRepository: rawPullRequest.head?.repo?.full_name, headSha: rawPullRequest.head?.sha, labels: rawPullRequest.labels?.map((label) => label.name), mergeCommitSha: rawPullRequest.merge_commit_sha, @@ -235,6 +534,8 @@ jobs: }, releaseBranch: process.env.RELEASE_BRANCH, releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + requirePendingLabel: process.env.EXPECTED_RELEASE_EXISTS !== "true", reviews: reviews.map((review) => ({ commitId: review.commit_id, id: review.id, @@ -246,67 +547,556 @@ jobs: version, }); EOF - - name: Reconfirm main before Release Please mutation + fi + - name: Reconfirm the exact release state before mutation + if: github.event_name == 'push' env: - EXPECTED_SHA: ${{ github.sha }} + EXPECTED_RELEASE_EXISTS: ${{ steps.release-state.outputs.exists }} + GH_TOKEN: ${{ github.token }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_CREATED_AT: ${{ steps.release-state.outputs.run-created-at }} + RUN_ID: ${{ github.run_id }} shell: bash run: | set -euo pipefail - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if [[ "$(git rev-parse refs/remotes/origin/main)" != "$EXPECTED_SHA" ]]; then - echo "main moved during Release Please preflight." >&2 + version="$(node --print 'require("./package.json").version')" + tag="v${version}" + attempts_file="$RUNNER_TEMP/release-please-prior-attempts.json" + owner="${GITHUB_REPOSITORY%%/*}" + repository_name="${GITHUB_REPOSITORY#*/}" + state_file="$RUNNER_TEMP/release-please-presence-reconfirmed.json" + release_file="$RUNNER_TEMP/release-please-release-reconfirmed.json" + # shellcheck disable=SC2016 + gh api graphql \ + -f owner="$owner" \ + -f name="$repository_name" \ + -f tag="$tag" \ + -f qualifiedName="refs/tags/${tag}" \ + -f query='query($owner: String!, $name: String!, $tag: String!, $qualifiedName: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { id } ref(qualifiedName: $qualifiedName) { name } } }' \ + > "$state_file" + release_exists="$(jq -r '.data.repository.release != null' "$state_file")" + tag_exists="$(jq -r '.data.repository.ref != null' "$state_file")" + tag_commit="" + if [[ "$release_exists" == "true" ]]; then + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "$release_file" + else + printf 'null\n' > "$release_file" + fi + if [[ "$tag_exists" == "true" ]]; then + git fetch --no-tags origin \ + "+refs/tags/${tag}:refs/tags/${tag}" + tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" + fi + ATTEMPTS_FILE="$attempts_file" RELEASE_FILE="$release_file" \ + RUN_ID="$RUN_ID" TAG_COMMIT="$tag_commit" VERSION="$version" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateReleasePresenceBeforeAction, + } from "./scripts/release-workflow-validation.mjs"; + + validateReleasePresenceBeforeAction({ + attempts: JSON.parse( + readFileSync(process.env.ATTEMPTS_FILE, "utf8"), + ), + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + release: JSON.parse(readFileSync(process.env.RELEASE_FILE, "utf8")), + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), + runCreatedAt: process.env.RUN_CREATED_AT, + runId: Number(process.env.RUN_ID), + tagCommit: process.env.TAG_COMMIT === "" ? null : process.env.TAG_COMMIT, + version: process.env.VERSION, + }); + EOF + if [[ "$release_exists" != "$EXPECTED_RELEASE_EXISTS" ]]; then + echo "The exact tag or Release changed after preflight." >&2 exit 1 fi - name: Run Release Please id: release + continue-on-error: ${{ steps.preflight.outputs.mode == 'release' }} uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json - - name: Record the exact Release Please release result + skip-github-release: ${{ steps.preflight.outputs.mode == 'prepare' }} + skip-github-pull-request: ${{ steps.preflight.outputs.mode == 'release' }} + - name: Validate the prepared release pull request + if: steps.preflight.outputs.mode == 'prepare' + env: + GH_TOKEN: ${{ github.token }} + PRS: ${{ steps.release.outputs.prs }} + PRS_CREATED: ${{ steps.release.outputs.prs_created }} + RELEASE_BRANCH: release-please--branches--main--components--cometapi + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$GITHUB_SHA" ]]; then + echo "main changed while Release Please prepared the release PR." >&2 + exit 1 + fi + case "$PRS_CREATED" in + true) prs_created="true" ;; + false | "") prs_created="false" ;; + *) + echo "Release Please returned an invalid prs_created output." >&2 + exit 1 + ;; + esac + pull_requests_file="$RUNNER_TEMP/prepared-release-pulls.json" + pull_request_file="$RUNNER_TEMP/prepared-release-pull.json" + pull_files_file="$RUNNER_TEMP/prepared-release-files.json" + changelog_file="$RUNNER_TEMP/prepared-release-changelog.md" + git fetch --no-tags origin \ + "+refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}" + release_ref="refs/remotes/origin/${RELEASE_BRANCH}" + branch_sha="$(git rev-parse "$release_ref")" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=open&base=main&per_page=100" \ + | jq 'add' > "$pull_requests_file" + release_pr_number="$(BRANCH_SHA="$branch_sha" \ + PULL_REQUESTS_FILE="$pull_requests_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateOpenReleasePullRequestCollisions } from "./scripts/release-workflow-validation.mjs"; + + const pulls = JSON.parse( + readFileSync(process.env.PULL_REQUESTS_FILE, "utf8"), + ).map((pullRequest) => ({ + baseRef: pullRequest.base?.ref, + headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, + number: pullRequest.number, + state: pullRequest.state, + })); + validateOpenReleasePullRequestCollisions(pulls, { + branchSha: process.env.BRANCH_SHA, + releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + }); + const candidates = pulls.filter( + (pullRequest) => + pullRequest.baseRef === "main" && + pullRequest.headRef === process.env.RELEASE_BRANCH && + pullRequest.headRepository === process.env.GITHUB_REPOSITORY && + pullRequest.headSha === process.env.BRANCH_SHA && + pullRequest.state === "open", + ); + if (candidates.length !== 1) { + throw new Error( + "Release workflow preparation requires exactly one canonical open release PR.", + ); + } + process.stdout.write(String(candidates[0].number)); + EOF + )" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${release_pr_number}" \ + > "$pull_request_file" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls/${release_pr_number}/files?per_page=100" \ + | jq 'add' > "$pull_files_file" + + branch_version="$(git show "$release_ref:package.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).version))')" + manifest_version="$(git show "$release_ref:.release-please-manifest.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value)["."]))')" + package_lock_version="$(git show "$release_ref:package-lock.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).version))')" + package_lock_package_version="$(git show "$release_ref:package-lock.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).packages[""].version))')" + git show "$release_ref:CHANGELOG.md" > "$changelog_file" + + BRANCH_SHA="$branch_sha" BRANCH_VERSION="$branch_version" \ + CHANGELOG_FILE="$changelog_file" MANIFEST_VERSION="$manifest_version" \ + PACKAGE_LOCK_PACKAGE_VERSION="$package_lock_package_version" \ + PACKAGE_LOCK_VERSION="$package_lock_version" PRS_CREATED_NORMALIZED="$prs_created" \ + PULL_FILES_FILE="$pull_files_file" PULL_REQUEST_FILE="$pull_request_file" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validatePreparedReleasePullRequest } from "./scripts/release-workflow-validation.mjs"; + + const rawPullRequest = JSON.parse( + readFileSync(process.env.PULL_REQUEST_FILE, "utf8"), + ); + const files = JSON.parse( + readFileSync(process.env.PULL_FILES_FILE, "utf8"), + ).map((file) => file.filename); + const actionPullRequests = + process.env.PRS === "" ? [] : JSON.parse(process.env.PRS); + const mainVersion = JSON.parse(readFileSync("package.json", "utf8")).version; + validatePreparedReleasePullRequest({ + actionPullRequests, + actionPullRequestsCreated: process.env.PRS_CREATED_NORMALIZED === "true", + branchSha: process.env.BRANCH_SHA, + branchVersion: process.env.BRANCH_VERSION, + changelog: readFileSync(process.env.CHANGELOG_FILE, "utf8"), + mainVersion, + manifestVersion: process.env.MANIFEST_VERSION, + packageLockPackageVersion: process.env.PACKAGE_LOCK_PACKAGE_VERSION, + packageLockVersion: process.env.PACKAGE_LOCK_VERSION, + pullRequest: { + author: rawPullRequest.user?.login, + baseRef: rawPullRequest.base?.ref, + body: rawPullRequest.body, + files, + headRef: rawPullRequest.head?.ref, + headRepository: rawPullRequest.head?.repo?.full_name, + headSha: rawPullRequest.head?.sha, + labels: rawPullRequest.labels?.map((label) => label.name), + mergeCommitSha: rawPullRequest.merge_commit_sha, + mergedAt: rawPullRequest.merged_at, + number: rawPullRequest.number, + state: rawPullRequest.state, + title: rawPullRequest.title, + }, + releaseBranch: process.env.RELEASE_BRANCH, + repository: process.env.GITHUB_REPOSITORY, + }); + EOF + - name: Verify and record the exact release result + if: ${{ !cancelled() && steps.preflight.outputs.mode == 'release' }} env: + ACTION_OUTCOME: ${{ steps.release.outcome }} + EXPECTED_BRANCH_EXISTS: ${{ steps.branch-state.outputs.branch-exists }} + EXPECTED_BRANCH_SHA: ${{ steps.branch-state.outputs.branch-sha }} + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: release-please--branches--main--components--cometapi RELEASE_CREATED: ${{ steps.release.outputs.release_created }} + RELEASE_EXISTED_BEFORE_ACTION: ${{ steps.release-state.outputs.exists }} RELEASE_HTML_URL: ${{ steps.release.outputs.html_url }} + RELEASE_PR_NUMBER: ${{ steps.preflight.outputs.release-pr-number }} RELEASE_SHA: ${{ steps.release.outputs.sha }} RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} RELEASE_VERSION: ${{ steps.release.outputs.version }} - RELEASES_CREATED: ${{ steps.release.outputs.releases_created }} RELEASED_PATHS: ${{ steps.release.outputs.paths_released }} + RUN_ATTEMPT: ${{ github.run_attempt }} + RUN_CREATED_AT: ${{ steps.release-state.outputs.run-created-at }} + RUN_ID: ${{ github.run_id }} shell: bash run: | set -euo pipefail + version="$(node --print 'require("./package.json").version')" + tag="v${version}" + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$GITHUB_SHA" ]]; then + echo "main changed while Release Please was running." >&2 + exit 1 + fi + branch_exists="false" + branch_sha="" + remote_ref="refs/heads/${RELEASE_BRANCH}" + if git ls-remote --exit-code --heads origin "$remote_ref" >/dev/null; then + branch_exists="true" + git fetch --no-tags origin \ + "+${remote_ref}:refs/remotes/origin/${RELEASE_BRANCH}" + branch_sha="$(git rev-parse "refs/remotes/origin/${RELEASE_BRANCH}")" + fi + if [[ "$branch_exists" != "$EXPECTED_BRANCH_EXISTS" || "$branch_sha" != "$EXPECTED_BRANCH_SHA" ]]; then + echo "The Release Please branch changed while the action was running." >&2 + exit 1 + fi + + pull_requests_after="$RUNNER_TEMP/release-pulls-after-action.json" + pull_request_file="$RUNNER_TEMP/release-pull-after.json" + reviews_file="$RUNNER_TEMP/release-reviews-after.json" + permissions_file="$RUNNER_TEMP/reviewer-permissions-after.json" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=all&base=main&per_page=100" \ + | jq 'add' > "$pull_requests_after" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${RELEASE_PR_NUMBER}" \ + > "$pull_request_file" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls/${RELEASE_PR_NUMBER}/reviews?per_page=100" \ + | jq 'add' > "$reviews_file" + printf '{}\n' > "$permissions_file" + while IFS= read -r reviewer; do + permission="$(gh api \ + "repos/${GITHUB_REPOSITORY}/collaborators/${reviewer}/permission" \ + --jq '.permission' 2>/dev/null || printf 'none')" + next_permissions="$RUNNER_TEMP/reviewer-permissions-after-next.json" + jq --arg reviewer "$reviewer" --arg permission "$permission" \ + '. + {($reviewer): $permission}' \ + "$permissions_file" > "$next_permissions" + mv "$next_permissions" "$permissions_file" + done < <(jq -r '.[].user.login' "$reviews_file" | sort -u) + + BEFORE_PULLS_FILE="$RUNNER_TEMP/release-pulls-before-action.json" \ + AFTER_PULLS_FILE="$pull_requests_after" \ + PERMISSIONS_FILE="$permissions_file" \ + PULL_REQUEST_FILE="$pull_request_file" \ + REVIEWS_FILE="$reviews_file" VERSION="$version" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateMergedReleasePullRequest, + validatePostActionPullRequestSnapshot, + } from "./scripts/release-workflow-validation.mjs"; + + const normalizePulls = (path) => + JSON.parse(readFileSync(path, "utf8")).map((pullRequest) => ({ + author: pullRequest.user?.login, + baseRef: pullRequest.base?.ref, + body: pullRequest.body, + headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + })); + validatePostActionPullRequestSnapshot( + normalizePulls(process.env.BEFORE_PULLS_FILE), + normalizePulls(process.env.AFTER_PULLS_FILE), + { releasePullRequestNumber: Number(process.env.RELEASE_PR_NUMBER) }, + ); + + const rawPullRequest = JSON.parse( + readFileSync(process.env.PULL_REQUEST_FILE, "utf8"), + ); + const permissions = JSON.parse( + readFileSync(process.env.PERMISSIONS_FILE, "utf8"), + ); + const reviews = JSON.parse(readFileSync(process.env.REVIEWS_FILE, "utf8")); + validateMergedReleasePullRequest({ + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + pullRequest: { + author: rawPullRequest.user?.login, + baseRef: rawPullRequest.base?.ref, + body: rawPullRequest.body, + headRef: rawPullRequest.head?.ref, + headRepository: rawPullRequest.head?.repo?.full_name, + headSha: rawPullRequest.head?.sha, + labels: rawPullRequest.labels?.map((label) => label.name), + mergeCommitSha: rawPullRequest.merge_commit_sha, + mergedAt: rawPullRequest.merged_at, + number: rawPullRequest.number, + state: rawPullRequest.state, + title: rawPullRequest.title, + }, + releaseBranch: process.env.RELEASE_BRANCH, + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + requirePendingLabel: false, + reviews: reviews.map((review) => ({ + commitId: review.commit_id, + id: review.id, + login: review.user?.login, + permission: permissions[review.user?.login] ?? "none", + state: review.state, + userType: review.user?.type, + })), + version: process.env.VERSION, + }); + EOF + + release_file="$RUNNER_TEMP/release-please-release-after.json" + completion_file="$RUNNER_TEMP/release-please-completion.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "$release_file" + git fetch --no-tags origin \ + "+refs/tags/${tag}:refs/tags/${tag}" + tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" + + attempts_file="$RUNNER_TEMP/release-please-attempts-through-current.json" + printf '[]\n' > "$attempts_file" + for attempt in $(seq 1 "$RUN_ATTEMPT"); do + attempt_file="$RUNNER_TEMP/release-please-completed-attempt-${attempt}.json" + attempt_ready="false" + for api_check in {1..6}; do + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${attempt}/jobs?per_page=100" \ + > "$attempt_file" + if [[ "$attempt" != "$RUN_ATTEMPT" ]] || \ + jq -e \ + --arg job "Prepare a reviewed release pull request or GitHub release" \ + --arg step "Run Release Please" \ + '[.jobs[] | select(.name == $job) | .steps[] | select(.name == $step and .status == "completed")] | length == 1' \ + "$attempt_file" >/dev/null; then + attempt_ready="true" + break + fi + if [[ "$api_check" -lt 6 ]]; then + sleep 2 + fi + done + if [[ "$attempt_ready" != "true" ]]; then + echo "Actions did not expose the completed Release Please step." >&2 + exit 1 + fi + next_attempts="$RUNNER_TEMP/release-please-attempts-through-current-next.json" + jq --argjson attempt "$attempt" --slurpfile jobs "$attempt_file" \ + '. + [{attempt: $attempt, jobs: $jobs[0].jobs}]' \ + "$attempts_file" > "$next_attempts" + mv "$next_attempts" "$attempts_file" + done + + ATTEMPTS_FILE="$attempts_file" COMPLETION_FILE="$completion_file" \ + RELEASE_FILE="$release_file" TAG_COMMIT="$tag_commit" \ + VERSION="$version" node --input-type=module <<'EOF' + import { readFileSync, writeFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateReleasePleaseCompletion, + } from "./scripts/release-workflow-validation.mjs"; + + const releasedPaths = + process.env.RELEASED_PATHS === "" + ? [] + : JSON.parse(process.env.RELEASED_PATHS); + const completion = validateReleasePleaseCompletion({ + actionResult: { + htmlUrl: process.env.RELEASE_HTML_URL, + outcome: process.env.ACTION_OUTCOME, + releaseCreated: process.env.RELEASE_CREATED === "true", + releasedPaths, + sha: process.env.RELEASE_SHA, + tagName: process.env.RELEASE_TAG_NAME, + version: process.env.RELEASE_VERSION, + }, + attempts: JSON.parse( + readFileSync(process.env.ATTEMPTS_FILE, "utf8"), + ), + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + release: JSON.parse(readFileSync(process.env.RELEASE_FILE, "utf8")), + releaseCommit: process.env.GITHUB_SHA, + releaseExistedBeforeAction: + process.env.RELEASE_EXISTED_BEFORE_ACTION === "true", + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.RUN_ATTEMPT), + runCreatedAt: process.env.RUN_CREATED_AT, + runId: Number(process.env.RUN_ID), + tagCommit: process.env.TAG_COMMIT, + version: process.env.VERSION, + }); + writeFileSync( + process.env.COMPLETION_FILE, + `${JSON.stringify(completion)}\n`, + { mode: 0o600 }, + ); + EOF + + if ! jq -e '.labels | map(.name) | index("autorelease: tagged") != null' \ + "$pull_request_file" >/dev/null; then + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${RELEASE_PR_NUMBER}/labels" \ + -f 'labels[]=autorelease: tagged' >/dev/null + fi + if jq -e '.labels | map(.name) | index("autorelease: pending") != null' \ + "$pull_request_file" >/dev/null; then + gh api --method DELETE \ + "repos/${GITHUB_REPOSITORY}/issues/${RELEASE_PR_NUMBER}/labels/autorelease%3A%20pending" \ + >/dev/null + fi + gh api "repos/${GITHUB_REPOSITORY}/pulls/${RELEASE_PR_NUMBER}" \ + > "$pull_request_file" + PULL_REQUEST_FILE="$pull_request_file" VERSION="$version" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { + extractReleaseNotesFromChangelog, + validateTaggedReleasePullRequest, + } from "./scripts/release-workflow-validation.mjs"; + + const pullRequest = JSON.parse( + readFileSync(process.env.PULL_REQUEST_FILE, "utf8"), + ); + validateTaggedReleasePullRequest({ + expectedReleaseNotes: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + pullRequest: { + author: pullRequest.user?.login, + baseRef: pullRequest.base?.ref, + body: pullRequest.body, + headRef: pullRequest.head?.ref, + headRepository: pullRequest.head?.repo?.full_name, + headSha: pullRequest.head?.sha, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + }, + releaseBranch: process.env.RELEASE_BRANCH, + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + version: process.env.VERSION, + }); + EOF + + final_release_file="$RUNNER_TEMP/release-please-release-final.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "$final_release_file" + git fetch --no-tags origin \ + "+refs/tags/${tag}:refs/tags/${tag}" + final_tag_commit="$(git rev-parse --verify "refs/tags/${tag}^{commit}")" mkdir -p release-please-result - node --input-type=module <<'EOF' + COMPLETION_FILE="$completion_file" FINAL_RELEASE_FILE="$final_release_file" \ + FINAL_TAG_COMMIT="$final_tag_commit" VERSION="$version" \ + node --input-type=module <<'EOF' import { readFileSync, writeFileSync } from "node:fs"; - import { validateReleasePleaseActionResult } from "./scripts/release-workflow-validation.mjs"; + import { + extractReleaseNotesFromChangelog, + validateGitHubRelease, + validateReleasePleaseActionResult, + } from "./scripts/release-workflow-validation.mjs"; - if (process.env.RELEASES_CREATED !== "true") { - throw new Error("Release Please did not create exactly one release."); - } - const releasedPaths = JSON.parse(process.env.RELEASED_PATHS); - if (releasedPaths.length !== 1 || releasedPaths[0] !== ".") { - throw new Error("Release Please did not release exactly the root package."); - } - const version = JSON.parse(readFileSync("package.json", "utf8")).version; + const completion = JSON.parse( + readFileSync(process.env.COMPLETION_FILE, "utf8"), + ); + validateGitHubRelease( + JSON.parse(readFileSync(process.env.FINAL_RELEASE_FILE, "utf8")), + { + expectedBody: extractReleaseNotesFromChangelog( + readFileSync("CHANGELOG.md", "utf8"), + process.env.VERSION, + ), + htmlUrl: completion.htmlUrl, + releaseCommit: process.env.GITHUB_SHA, + tag: completion.tagName, + tagCommit: process.env.FINAL_TAG_COMMIT, + }, + ); const result = { - htmlUrl: process.env.RELEASE_HTML_URL, - releaseCreated: process.env.RELEASE_CREATED === "true", + actionOutcome: completion.actionOutcome, + htmlUrl: completion.htmlUrl, + recovered: completion.recovered, + releaseCreated: completion.releaseCreated, + releaseExistedBeforeAction: completion.releaseExistedBeforeAction, + releaseSourceAttempt: completion.releaseSourceAttempt, repository: process.env.GITHUB_REPOSITORY, - runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), - runId: Number(process.env.GITHUB_RUN_ID), - schemaVersion: 1, - sha: process.env.RELEASE_SHA, - tagName: process.env.RELEASE_TAG_NAME, - version: process.env.RELEASE_VERSION, + runAttempt: Number(process.env.RUN_ATTEMPT), + runId: Number(process.env.RUN_ID), + schemaVersion: 2, + sha: completion.sha, + tagName: completion.tagName, + version: completion.version, workflowName: "Release Please", workflowPath: ".github/workflows/release-please.yml", }; validateReleasePleaseActionResult(result, { releaseCommit: process.env.GITHUB_SHA, repository: process.env.GITHUB_REPOSITORY, - runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), - runId: Number(process.env.GITHUB_RUN_ID), - version, + runAttempt: Number(process.env.RUN_ATTEMPT), + runId: Number(process.env.RUN_ID), + version: process.env.VERSION, workflowName: "Release Please", workflowPath: ".github/workflows/release-please.yml", }); @@ -317,6 +1107,7 @@ jobs: ); EOF - name: Upload the exact Release Please result + if: steps.preflight.outputs.mode == 'release' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-please-result-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/AGENTS.md b/AGENTS.md index 2416c9d..d25d86e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,11 +86,13 @@ manually against the exact merged release commit, and the publish workflow completed exact-artifact verification, the bounded live smoke, npm OIDC publication, and registry verification. -Release Please is disabled after its post-merge run generated an unreviewed -`0.2.0` temporary-branch commit and then failed to create a pull request. The -temporary branch is failure evidence only. Do not merge it, use it as a 0.2 -starting point, or re-enable Release Please until a separately authorized task -reviews the post-manual-release and pull-request authorization strategy. +Release Please remains disabled between releases after its post-0.1.0 run +generated an unreviewed `0.2.0` temporary-branch commit and then failed to create +a pull request. The temporary branch is failure evidence only. Do not merge it +or use it as a 0.2 starting point. The authorized 0.1.1 maintenance task repairs +the workflow around the current read-only-default Actions baseline with +action-created pull requests enabled; any later enablement still requires an +explicit maintainer request and the fail-closed checks in `RELEASING.md`. ## Product Contract diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ef7588..1ef4b80 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -114,34 +114,55 @@ version except `0.1.0-alpha.1` and every dist-tag except `next`. For normal stable patches, Release Please owns the reviewed version/changelog PR and the immutable tag and GitHub Release. The configuration uses an explicit -`cometapi` component and stable versioning so a root package does not fall into -the single-package tag-discovery ambiguity encountered during 0.1.0. Because a +`cometapi` component and `always-bump-patch` versioning so the authorized 0.1 +maintenance window cannot enter 0.2 implicitly and a root package does not fall +into the single-package tag-discovery ambiguity encountered during 0.1.0. The +workflow also rejects commit-level `Release-As:` notes before mutation because +Release Please applies those overrides before its patch versioning strategy. +Because a GitHub Release created with the default `GITHUB_TOKEN` does not start a separate `release.published` workflow, publication is chained from the successful Release Please workflow. The handoff accepts only the canonical repository's -successful first-attempt `push` run for `main` at the still-current exact `main` -SHA. The release workflow records `release_created`, SHA, tag, version, URL, -repository, workflow identity, run ID, and attempt in an exact-run artifact. -Publication downloads and validates that artifact before checking the tag and -immutable Release. Runs that fail while preparing a pull request are filtered -out; any successful run without the exact Release Please-created result, tag, -and immutable Release fails before live or registry access. The release outcome -and package artifact are verified independently. +successful attempt-qualified `push` run for `main` at the still-current exact +`main` SHA. The release workflow records normalized action outcome, recovery +state, pre-action Release presence, the exact Release-producing attempt, SHA, +tag, version, URL, repository, workflow identity, run ID, and attempt in a +schema-v2 exact-run artifact. Publication +downloads and validates only that attempt's artifact before checking the tag and +immutable Release. A first-attempt manual run is explicitly release-inert and +must succeed only after independently validating one canonical action-created +patch PR; its event cannot enter publication. Any successful `push` run without +the exact result artifact, tag, and immutable Release fails before live or +registry access. The release outcome and package artifact are verified +independently. Release Please and publication remain separate trust domains. Release Please does not receive npm OIDC permission; `id-token: write` remains limited to the protected publish job. Repository variables gate both flows, and reruns remain fail-closed on exact tag, artifact, dist-tag, integrity, and provenance state. -Release Please itself rejects attempt 2 or later before repository mutation; -the explicitly enabled preparation path uses a new manual dispatch, while only -a new `push` run can enter publication. A merged release PR is accepted for -tagging only after a distinct repository administrator approved its final head. -The workflow checks the triggering SHA against the fetched `main` tip both at -checkout and immediately before Release Please mutation, so an older queued run -cannot release a newer default-branch commit. -It also rejects any pending merged release PR whose merge commit is not the -current push SHA. Manual dispatch is therefore release-inert: it may prepare a -branch only when no merged release PR is awaiting a tag. +Manual preparation rejects attempt 2 or later; restart uses a new dispatch with +Release creation disabled. A `push` rerun is bounded to the same run ID, SHA, +candidate, and final-head review. It may retry while the tag and Release remain +absent. If an earlier attempt already created the Release, recovery accepts only +the exact bot-authored immutable Release at that SHA whose publication time +falls inside exactly one earlier Release Please step from the same run. +Release-mode action failure is tolerated only long enough to +prove that postcondition, reconcile the release PR to `autorelease: tagged`, and +write the attempt-qualified artifact. The authorized Actions setting lets the +default token create the PR; the resulting approval-required CI still needs a +human with write access to authorize execution, and bot review cannot satisfy +the release gate. A merged +release PR is accepted for tagging only after a distinct repository +administrator approved its final head. The workflow checks the triggering SHA, +release-branch snapshot, all open and closed PR identities, current review, and +tag/Release state immediately before Release Please mutation, then rechecks +`main`, the release branch, the complete PR snapshot, exact final-head approval, +and the Release body against `CHANGELOG` before accepting the result. It rejects any +pending merged release PR whose merge commit is not the current push SHA and +scans the complete pending merged set so a legacy, fork, alternate, older, or +additional PR cannot be tagged. Manual dispatch is therefore release-inert: it +may prepare one canonical action-created PR only when no merged release PR is +awaiting a tag. ## Testing layers diff --git a/RELEASING.md b/RELEASING.md index 058e54f..56389b2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,7 +12,7 @@ Release status is evidence-based: | Registry Alpha candidate | The exact `0.1.0-alpha.3` artifact passes package and clean-install gates after preserving the unpublished immutable alpha.2 failure record. | | Registry Alpha released | The public npm artifact installs from the `next` channel, passes post-publication verification, and has verified provenance plus any documented one-time bootstrap evidence. | | Stable released | Every stable 0.1.0 local, remote, live, review, provenance, and registry gate has recorded evidence. | -| Stable patch candidate | A maintainer-created Release Please PR has the exact version, changelog, manifest, temporary-anchor removal, complete CI matrix, and human-owner review. | +| Stable patch candidate | An action-created Release Please PR has the exact version, changelog, manifest, temporary-anchor removal, complete CI matrix, and human-owner review. | | Stable patch released | The immutable Release Please tag and GitHub Release, bounded live smoke, npm OIDC publication, and independent public-registry verification all pass. | A build, mock, valid workflow file, successful upload, or HTTP 200 proves only @@ -232,19 +232,28 @@ The repository maintains four independently auditable workflows: first failure. Standalone and release smoke jobs share one repository-wide concurrency group. Scheduled and manual live execution requires `LIVE_SMOKE_ENABLED=true`. -- `release-please.yml`: stable versioning, an explicit `cometapi` component, +- `release-please.yml`: patch-only versioning, an explicit `cometapi` component, separate pull requests, and an explicit component/version title. It requires - `RELEASE_PLEASE_ENABLED=true`, accepts only first-attempt runs, and uses the - default `GITHUB_TOKEN`. A first-attempt manual dispatch prepares the patch - branch after the variable is enabled; rerunning an older workflow is - rejected before Release Please can mutate repository state. The - repository deliberately leaves Actions pull-request authorization disabled, - so a maintainer creates the standard PR from the generated branch, applies - the `autorelease: pending` label, and obtains another administrator's approval - on the final head. The post-merge workflow verifies that exact approval even - when the PR used a squash or rebase merge. Release Please then creates the - normal tag and GitHub Release, transitions the release label, and uploads its - exact release outputs as a run-bound artifact. + `RELEASE_PLEASE_ENABLED=true` and uses the default `GITHUB_TOKEN`. The + authorized repository baseline keeps default workflow permissions read-only + and allows Actions to create pull requests; it does not make bot review valid + release approval. A manual dispatch is attempt-1-only, runs with GitHub Release + creation disabled, and prepares exactly one action-authored patch PR after the + variable is enabled. A new dispatch may revalidate an unchanged canonical PR + even when Release Please returns no PR output. The preparation run succeeds + only after independently verifying the canonical branch, title, + machine-readable body, pending label, four expected release files, and 0.1.x + patch versions. Before a post-merge `push` may create a Release, the workflow + scans every merged `main` PR carrying `autorelease: pending`, rejects legacy, + alternate, fork, older, or multiple candidates, and requires an + administrator's human approval on the exact final head. A same-run push retry + may proceed only for the same run ID, SHA, candidate, and review. An existing + tag and Release are accepted only as the exact bot-authored immutable Release + whose publication time falls within exactly one executed Release Please step + from an earlier attempt of that run. Release Please then creates or recovers + the normal tag and GitHub Release, verifies its notes byte-for-byte against the + normalized `CHANGELOG` entry, reconciles the release label, and uploads a + schema-v2 attempt-qualified result artifact. The triggering SHA must still equal the fetched `main` tip at checkout and immediately before the Release Please action; an older queued run stops before mutation. @@ -257,19 +266,23 @@ The repository maintains four independently auditable workflows: Please workflow for `main`. This indirection is required because a GitHub Release created with the default `GITHUB_TOKEN` does not trigger a new `release.published` workflow. The handoff accepts only the canonical - repository's successful, first-attempt `push` run for the still-current exact - `main` SHA. It downloads the output artifact from that exact upstream run and - requires `release_created`, SHA, tag, version, URL, repository, workflow path, - run ID, and attempt to agree before accepting the matching Release - Please-created version tag and immutable GitHub Release. Failed pull-request - preparation runs are filtered out; a successful run without that exact result - fails before live or publication work. The release path then packs and tests one exact artifact, runs the - protected release live smoke, and publishes the same file through npm OIDC. Registry token - credentials are rejected. The workflow verifies the dist-tag, integrity, - provenance attestation, signatures, deduplication, and public installation. A - publish rerun resumes after an already accepted version only when its registry - integrity matches the downloaded artifact, then repeats every bounded - registry-state and signature check. + repository's successful attempt-qualified `push` run for the still-current + exact `main` SHA. It downloads the output artifact from that exact upstream + run ID and attempt and requires schema version, normalized action outcome, + recovery state, pre-action Release presence, exact Release-producing attempt, + SHA, tag, version, URL, repository, workflow path, run ID, and attempt to agree + before accepting the + matching version tag and immutable GitHub Release. A successful manual + preparation run is release-inert and cannot enter publication; a successful + `push` run without the exact release result fails before live or publication + work. The release path then packs and tests one exact, attempt-qualified + artifact, runs the protected release live smoke, and publishes the same file + through npm OIDC. Registry token credentials are rejected. Re-running all jobs + creates a new attempt-qualified artifact, while re-running failed downstream + jobs consumes the already verified producer attempt. Publication resumes after + an already accepted version only when its registry integrity matches the + downloaded artifact, then repeats every bounded registry-state and signature + check. Third-party actions are pinned to full commit SHAs. Workflow permissions remain read-only except where a documented job requires more; `id-token: write` belongs @@ -424,8 +437,8 @@ layers: feature or fix pull request -> required offline CI -> merge to the protected default branch - -> generated Release Please branch - -> maintainer-created release PR + -> first-attempt manual Release Please preparation + -> action-created Release Please branch and release PR -> human review and merge -> immutable tag and GitHub release -> rebuild and verify exact artifact @@ -449,20 +462,60 @@ Please-created `v0.1.1` boundary normally. Before enabling the repaired workflow, create the standard `autorelease: pending` and `autorelease: tagged` labels if they are still -absent. The configuration names both labels explicitly. Because Actions -pull-request creation remains disabled, the maintainer-created release PR must -receive `autorelease: pending` before merge so Release Please can discover it -and perform the normal tagged transition with its scoped `issues: write` -permission. +absent. The configuration names both labels explicitly, and the action-created +release PR must receive `autorelease: pending` automatically. Stop if the action +cannot create or label that PR; do not replace the normal flow with a manually +authored PR. Release Please performs the normal tagged transition with its +scoped `issues: write` permission. + +Confirm through the repository Actions API that +`default_workflow_permissions=read` and +`can_approve_pull_request_reviews=true`. The latter is the explicitly authorized +0.1.1 baseline solely so the default token can create the Release Please PR. +The release workflow must never change either setting, and any later drift is a +stop condition. After enabling `RELEASE_PLEASE_ENABLED`, start a new manual dispatch on `main`; -do not rerun the skipped workflow from the repair merge. Only attempt 1 may call -Release Please. The manually dispatched preparation run cannot trigger npm -publication or create a Release: it is accepted only when no merged +do not rerun the skipped workflow from the repair merge. Only attempt 1 of a +manual dispatch may call Release Please; an unchanged PR is restarted with a +new dispatch, not a rerun. The workflow rejects any dispatch whose triggering +ref is not `refs/heads/main`, and all preparation and release runs share one +main-scoped concurrency group. The manually dispatched preparation run cannot +trigger npm publication or create a Release: the action receives explicit +`skip-github-release=true`, it is accepted only when no merged `autorelease: pending` PR exists, and `publish.yml` accepts only an upstream -`push` event. The release-PR merge creates the new first-attempt `push` run that -may tag and publish. A later push cannot tag an older outstanding release PR; -its merge SHA must equal the triggering SHA before Release Please runs. +`push` event. It must succeed after independently validating the one canonical +action-created 0.1.1 PR, including the unchanged-PR case where the action emits +no `prs` output. Before mutation, the workflow also rejects any open PR whose +head name could be mistaken for the canonical release branch, including a +same-named fork branch. +The `GITHUB_TOKEN`-created PR's `pull_request` CI starts in GitHub's +approval-required state. A human with write access must explicitly authorize +those workflow runs before their results can satisfy required checks; this is +separate from the final-head administrator review. +Remove the one-cycle `last-release-sha` from that branch, complete the +release-ready documentation, run the full matrix on its final head, and obtain +approval from a different human repository administrator. The release-PR merge +creates the `push` run that may tag and publish. A later push cannot tag an older +outstanding release PR; its merge SHA must equal the triggering SHA before +Release Please runs. A rerun may retry that same candidate while no tag or +Release exists. If Release Please created the immutable Release but failed +before producing outputs, only the same run may recover it, and only after +proving its exact SHA, tag, bot author, immutable state, target, URL, notes, and +publication inside exactly one earlier Release Please step time window. Recovery +then idempotently removes `autorelease: pending`, +adds `autorelease: tagged`, and writes a schema-v2 artifact for that attempt. +Immediately before the irreversible Release Please call, every attempt also +reconfirms `main`, the release-branch snapshot, all PR collisions, the candidate +and final-head review, final release metadata and public documentation, and the +exact tag/Release state, including removal of the one-cycle `last-release-sha`. +Because workflow concurrency does not lock `main` or PR metadata against other +actors, the maintainer must hold a short mutation freeze from the release-PR +merge until the Release Please run reaches its post-action validation. Do not +merge another `main` PR, edit the release PR, change its labels or review, or +mutate the release branch during that window. The workflow repeats those checks +after the action and stops publication on any drift, but it cannot delete or +replace an immutable Release created during an external race. The stale branch `release-please--branches--main--components--cometapi` at @@ -472,11 +525,14 @@ it still contains the documented generated 0.2.0 state, has no associated open PR, and contains no independent work. Do not delete or rewrite any other branch. -For 0.1.1, a normal `fix:` commit after 0.1.0 must produce exactly one patch PR. -Stop if the branch contains 0.2.0, if any version/manifest/changelog value is not -0.1.1, or if the generated PR is not attributable to the explicit `cometapi` -component. Merge is forbidden until Node.js 22 and 24 blocking checks, the -Node.js 26 advisory lane, minimum/locked/latest OpenAI 6.x compatibility, +For 0.1.1, `always-bump-patch` keeps every releasable Conventional Commit on the +0.1.x maintenance line; changing that strategy requires a separately authorized +later milestone. A normal `fix:` commit after 0.1.0 must produce exactly one +patch PR. Stop if the branch contains 0.2.0, if any +version/manifest/changelog value is not 0.1.1, or if the generated PR is not +attributable to the explicit `cometapi` component. Merge is forbidden until +Node.js 22 and 24 blocking checks, the Node.js 26 advisory lane, +minimum/locked/latest OpenAI 6.x compatibility, package and declaration checks, and human-owner review complete on the final head. After registry verification, restore `RELEASE_PLEASE_ENABLED=false` and keep the already enabled scheduled-smoke policy at `LIVE_SMOKE_ENABLED=true`. @@ -508,12 +564,26 @@ the draft had no tag and was not published until more than eight minutes after the run failed. Release Please therefore found no discoverable published release/tag boundary, scanned the older initial feature commit, and prepared an unrequested 0.2.0 branch update. PR creation then failed for the separate reason -that repository Actions are not authorized to create or approve pull requests. +that repository Actions were not authorized to create or approve pull requests +at that time. Publishing the manual Release later could not retroactively bound that run, and leaving `skip-github-release` enabled would continue the split discovery model. The 0.1.1 repair replaces that historical combination with explicit component -identity, one-cycle history anchoring, maintainer-created/human-reviewed release -PRs, and normal Release Please tag and GitHub Release creation. +identity, one-cycle history anchoring, action-created/human-reviewed release +PRs, and normal Release Please tag and GitHub Release creation. Repository +Actions pull-request authorization is now enabled for that scoped job; the +workflow still uses only its default token and job-local permissions. + +The recovery rules follow the pinned implementation rather than assuming the +action is atomic. Release Please 17.6.0 +[creates the Release before PR comments and label changes](https://github.com/googleapis/release-please/blob/712fcf01effd08d7b0e7b1fd3861f2cb388bc8d1/src/manifest.ts#L1258-L1319), +while the pinned action emits release outputs only after that call returns. An +unchanged release PR may also return +[no PR result](https://github.com/googleapis/release-please/blob/712fcf01effd08d7b0e7b1fd3861f2cb388bc8d1/src/manifest.ts#L1089-L1101). +Finally, commit-level `Release-As:` is rejected before the action because the +[base strategy applies it before configured versioning](https://github.com/googleapis/release-please/blob/712fcf01effd08d7b0e7b1fd3861f2cb388bc8d1/src/strategies/base.ts#L543-L570). +GitHub documents that a `GITHUB_TOKEN`-created PR's opened or synchronize event +[creates an approval-required workflow run](https://github.com/github/docs/blob/e1e4aa937308f21c411c248b4966873536bb0cba/data/reusables/actions/actions-do-not-trigger-workflows.md#L1-L6). ## Stable 0.1.0 release evidence @@ -574,8 +644,9 @@ layers: publish a package. `RELEASE_PLEASE_ENABLED` was set to `false` before the closeout push; the branch is retained as failure evidence and must not be merged or treated as the start of 0.2. Release Please remains disabled until - its post-manual-release discovery and pull-request authorization strategy are - reviewed in a separately authorized maintenance task. + the authorized 0.1.1 repair is merged, the stale branch is revalidated and + removed, and the normal action-created PR path is ready for one first-attempt + preparation run. ## Verification record diff --git a/ROADMAP.md b/ROADMAP.md index 87763d7..c5e694b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,23 +101,28 @@ ran after a draft `v0.1.0` Release had been created but before that draft was published or had a tag. With no discoverable Release Please release boundary, it scanned older Conventional Commits, including the initial feature, and generated an unrequested `0.2.0` temporary branch commit. Pull-request creation -then failed independently because the repository does not authorize GitHub -Actions to create or approve pull requests. The run did not change `main`, -create a tag or published Release, or publish to npm. +then failed independently because the repository did not authorize GitHub +Actions to create or approve pull requests at that time. The run did not change +`main`, create a tag or published Release, or publish to npm. The 0.1.1 maintenance work makes the component and stable-patch policy explicit, anchors the one repair cycle at the exact 0.1.0 release commit, and restores Release Please ownership of the reviewed version/changelog PR plus the -immutable tag and GitHub Release. The repository's Actions PR authorization is -not broadened: a maintainer creates the PR from the generated branch and a -human owner reviews it. The temporary anchor must be removed in that release PR -before merge. The explicit component does not enter the public tag; the only +immutable tag and GitHub Release. The scoped Release Please job now uses the +repository's enabled Actions PR authorization to create and label the normal PR; +a human owner reviews its exact final head. Default workflow permissions remain +read-only, bot approval is never accepted, and the workflow does not modify the +repository setting. Patch-only versioning prevents an implicit 0.2 bump during +this maintenance window. The temporary anchor must be removed in that release +PR before merge. The explicit component does not enter the public tag; the only accepted patch tag is `v0.1.1`. Publication is triggered from the successful -first-attempt Release Please push run and independently verifies that run's -exact release-created output artifact, default-branch commit, tag, immutable -Release, and package artifact before the existing bounded live smoke and npm -OIDC steps. The post-merge run also requires an administrator's approval on the -release PR's final head. +attempt-qualified Release Please push run and independently verifies that run's +schema-v2 result artifact, default-branch commit, tag, immutable Release, and +package artifact before the existing bounded live smoke and npm OIDC steps. A +push rerun is bounded to the same run ID, SHA, candidate, Release-producing +attempt, and exact Release state; manual preparation remains attempt-1-only. +The Release notes must equal the reviewed `CHANGELOG` entry. The post-merge run also +requires an administrator's approval on the release PR's final head. The exact stale branch remains failure evidence until its contents, lack of an open PR, and lack of independent work are reconfirmed immediately before @@ -279,8 +284,9 @@ component identity, patch versioning, pull-request configuration, one-cycle creation, and a trusted `workflow_run` handoff to the existing exact-artifact, bounded-live, and npm OIDC gates. Regression tests must reject stale manifest state, a 0.2 bump, missing PR configuration, an unrelated stale branch, hostile -workflow events, reruns, mismatched action outputs, missing final-head approval, -and declaration or runtime option bypasses. +workflow events, unsafe or mismatched recovery attempts, mismatched action +outputs, missing final-head approval, and declaration or runtime option +bypasses. Exit criteria: @@ -396,16 +402,19 @@ or dry-run packages but may not publish an arbitrary commit. The manual tag/Release combination used for 0.1.0 is historical evidence, not the normal patch process. Stable 0.1.x patches require explicit stable -versioning and the `cometapi` component, a maintainer-created and human-reviewed +versioning and the `cometapi` component, an action-created and human-reviewed Release Please PR, and automated immutable tag and GitHub Release creation after merge. Because the default `GITHUB_TOKEN` cannot cause a second workflow through a `release.published` event, `publish.yml` starts from successful Release Please workflow completion and re-establishes trust from exact repository state. Failed pull-request preparation runs are filtered out; any successful run -without a run-bound `release_created` result, the exact Release Please-created -tag, and immutable Release fails before live or registry access. Only a -successful canonical first-attempt `push` run for the still-current exact -`main` SHA can enter artifact verification. +without a schema-v2 attempt-bound release result, the exact tag, and immutable +Release fails before live or registry access. Only a successful canonical +attempt-qualified `push` run for the still-current exact `main` SHA can enter +artifact verification. A push retry may recover only the same run ID, SHA, +candidate, and exact bot-authored immutable Release published inside one +earlier Release Please step; manual preparation +reruns remain forbidden. Public Preview needs no registry workflow. Registry Alpha publishes from a human-reviewed immutable prerelease tag under the `next` dist-tag through OIDC diff --git a/release-please-config.json b/release-please-config.json index e989cde..3dda4a8 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -7,7 +7,7 @@ "packages": { ".": { "release-type": "node", - "versioning": "default", + "versioning": "always-bump-patch", "prerelease": false, "component": "cometapi", "skip-github-release": false, diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs index b236d97..873bfbe 100644 --- a/scripts/release-validation.mjs +++ b/scripts/release-validation.mjs @@ -951,7 +951,7 @@ function validateReleasePleaseState({ } requireExact( packageConfig.versioning, - "default", + "always-bump-patch", "Release Please versioning", ); requireExact( diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 6930a9d..ff565ff 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -1,4 +1,16 @@ const STABLE_VERSION_PATTERN = /^0\.1\.(0|[1-9]\d*)$/; +const RELEASE_PR_FILES = [ + ".release-please-manifest.json", + "CHANGELOG.md", + "package-lock.json", + "package.json", +]; +const RELEASE_PR_HEADER = ":robot: I have created a release *beep* *boop*"; +const RELEASE_PR_FOOTER = + "This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please)."; +const RELEASE_WORKFLOW_JOB = + "Prepare a reviewed release pull request or GitHub release"; +const RELEASE_WORKFLOW_STEP = "Run Release Please"; function fail(message) { throw new Error(message); @@ -24,6 +36,23 @@ function requirePositiveInteger(value, label) { } } +function requireBoolean(value, label) { + if (typeof value !== "boolean") { + fail(`Release workflow ${label} must be boolean.`); + } +} + +function requireTimestamp(value, label) { + if ( + typeof value !== "string" || + value === "" || + !Number.isFinite(Date.parse(value)) + ) { + fail(`Release workflow ${label} must be an ISO timestamp.`); + } + return Date.parse(value); +} + function stablePatch(version, label) { const match = typeof version === "string" && version.match(STABLE_VERSION_PATTERN); @@ -37,15 +66,107 @@ function releaseTitle(version) { return `chore(main): release cometapi ${version}`; } +function normalizeMarkdown(value, label) { + if (typeof value !== "string") { + fail(`Release workflow ${label} must be a string.`); + } + return value.trim().replace(/\r\n/g, "\n"); +} + +export function extractReleaseNotesFromChangelog(changelog, version) { + stablePatch(version, "CHANGELOG release version"); + const lines = normalizeMarkdown(changelog, "CHANGELOG").split("\n"); + const escapedVersion = version.replaceAll(".", "\\."); + const releaseHeading = new RegExp( + `^## \\[?${escapedVersion}\\]?(?:\\([^\\n]+\\))? (?:\\(\\d{4}-\\d{2}-\\d{2}\\)|- \\d{4}-\\d{2}-\\d{2})$`, + ); + const releaseHeadingIndexes = lines + .map((line, index) => (releaseHeading.test(line) ? index : -1)) + .filter((index) => index !== -1); + if (releaseHeadingIndexes.length !== 1) { + fail( + "Release workflow CHANGELOG must contain exactly one dated release heading for the patch.", + ); + } + const start = releaseHeadingIndexes[0]; + const nextRelease = lines.findIndex( + (line, index) => index > start && /^##\s/.test(line), + ); + const notes = lines.slice( + start, + nextRelease === -1 ? undefined : nextRelease, + ); + return normalizeMarkdown(notes.join("\n"), "CHANGELOG release notes"); +} + function requirePendingLabel(labels, label) { if (!Array.isArray(labels) || !labels.includes("autorelease: pending")) { fail(`Release workflow ${label} must have the autorelease: pending label.`); } } +export function validateReleasePleasePullRequestBody( + body, + version, + expectedReleaseNotes, +) { + stablePatch(version, "release pull request body version"); + const lines = normalizeMarkdown(body, "release pull request body").split( + "\n", + ); + const firstDelimiter = lines.indexOf("---"); + const lastDelimiter = lines.lastIndexOf("---"); + if (firstDelimiter < 0 || lastDelimiter <= firstDelimiter) { + fail( + "Release workflow release pull request body must contain the two Release Please delimiters.", + ); + } + requireEqual( + lines.slice(0, firstDelimiter).join("\n").trim(), + RELEASE_PR_HEADER, + "release pull request body header", + ); + requireEqual( + lines + .slice(lastDelimiter + 1) + .join("\n") + .trim(), + RELEASE_PR_FOOTER, + "release pull request body footer", + ); + const releaseNotes = lines + .slice(firstDelimiter + 1, lastDelimiter) + .join("\n") + .trim(); + const versionMatch = releaseNotes.match( + /^#{2,} \[?(\d+\.\d+\.\d+(?:-[^\]\s]+)?)\]?/, + ); + if (!versionMatch) { + fail( + "Release workflow release pull request body must begin its release notes with a version heading.", + ); + } + requireEqual(versionMatch[1], version, "release pull request body version"); + if (expectedReleaseNotes !== undefined) { + requireEqual( + releaseNotes, + normalizeMarkdown(expectedReleaseNotes, "expected release notes"), + "release pull request notes", + ); + } + return { version }; +} + function requireReleasePullRequest( pullRequest, - { branchSha, branchVersion, releaseBranch }, + { + branchSha, + branchVersion, + releaseBranch, + repository, + requirePending = true, + expectedReleaseNotes, + }, ) { if (pullRequest === null || typeof pullRequest !== "object") { fail("Release workflow release pull request metadata must be an object."); @@ -53,29 +174,183 @@ function requireReleasePullRequest( requirePositiveInteger(pullRequest.number, "release pull request number"); requireCommit(pullRequest.headSha, "release pull request head SHA"); requireCommit(branchSha, "expected release pull request head SHA"); + requireEqual( + pullRequest.author, + "github-actions[bot]", + "release pull request author", + ); requireEqual(pullRequest.baseRef, "main", "release pull request base"); requireEqual(pullRequest.headRef, releaseBranch, "release pull request head"); + requireEqual( + pullRequest.headRepository, + repository, + "release pull request head repository", + ); requireEqual(pullRequest.headSha, branchSha, "release pull request head SHA"); requireEqual( pullRequest.title, releaseTitle(branchVersion), "release pull request title", ); - requirePendingLabel(pullRequest.labels, "release pull request"); + if (requirePending) { + requirePendingLabel(pullRequest.labels, "release pull request"); + } else if (!Array.isArray(pullRequest.labels)) { + fail("Release workflow release pull request labels must be an array."); + } + validateReleasePleasePullRequestBody( + pullRequest.body, + branchVersion, + expectedReleaseNotes, + ); +} + +export function validateReleasePleaseCommitMessages(messages) { + if (!Array.isArray(messages)) { + fail("Release Please commit messages must be an array."); + } + for (const message of messages) { + if (typeof message !== "string") { + fail("Release Please commit messages must contain only strings."); + } + if (/^release-as\s*:/im.test(message)) { + fail( + "Release Please commit-level Release-As overrides are forbidden during 0.1.x maintenance.", + ); + } + } + return { commitCount: messages.length }; +} + +export function validateReleasePleaseMutationConfiguration(config) { + if (config === null || typeof config !== "object" || Array.isArray(config)) { + fail("Release Please configuration must be an object."); + } + const packageConfig = config.packages?.["."]; + if ( + Object.hasOwn(config, "release-as") || + (packageConfig !== null && + typeof packageConfig === "object" && + Object.hasOwn(packageConfig, "release-as")) + ) { + fail( + "Release Please configuration-level release-as overrides are forbidden during 0.1.x maintenance.", + ); + } + return { overrideFree: true }; +} + +export function validateOpenReleasePullRequestCollisions( + pullRequests, + { branchSha, releaseBranch, repository }, +) { + if (!Array.isArray(pullRequests)) { + fail("Release Please open pull requests must be an array."); + } + if (branchSha !== null && branchSha !== undefined) { + requireCommit(branchSha, "Release Please branch SHA"); + } + const conflicts = pullRequests.filter( + (pullRequest) => + pullRequest?.baseRef === "main" && + pullRequest?.headRef === releaseBranch && + pullRequest?.state === "open" && + (pullRequest?.headRepository !== repository || + pullRequest?.headSha !== branchSha), + ); + if (conflicts.length > 0) { + fail( + "Release Please found an open pull request whose head can collide with the canonical release branch.", + ); + } + return { conflictCount: 0 }; +} + +function normalizePullRequestSnapshot(pullRequests, releasePullRequestNumber) { + if (!Array.isArray(pullRequests)) { + fail("Release workflow pull request snapshot must be an array."); + } + requirePositiveInteger( + releasePullRequestNumber, + "snapshot release pull request number", + ); + const numbers = new Set(); + return pullRequests + .map((pullRequest) => { + requirePositiveInteger( + pullRequest?.number, + "snapshot pull request number", + ); + if (numbers.has(pullRequest.number)) { + fail("Release workflow pull request snapshot numbers must be unique."); + } + numbers.add(pullRequest.number); + const labels = Array.isArray(pullRequest.labels) + ? [...pullRequest.labels].sort() + : fail( + "Release workflow snapshot pull request labels must be an array.", + ); + return { + author: pullRequest.author, + baseRef: pullRequest.baseRef, + body: pullRequest.body, + headRef: pullRequest.headRef, + headRepository: pullRequest.headRepository, + headSha: pullRequest.headSha, + labels: + pullRequest.number === releasePullRequestNumber + ? labels.filter( + (label) => + label !== "autorelease: pending" && + label !== "autorelease: tagged", + ) + : labels, + mergeCommitSha: pullRequest.mergeCommitSha, + mergedAt: pullRequest.mergedAt, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + }; + }) + .sort((left, right) => left.number - right.number); +} + +export function validatePostActionPullRequestSnapshot( + before, + after, + { releasePullRequestNumber }, +) { + requireEqual( + JSON.stringify( + normalizePullRequestSnapshot(before, releasePullRequestNumber), + ), + JSON.stringify( + normalizePullRequestSnapshot(after, releasePullRequestNumber), + ), + "post-action pull request snapshot", + ); + return { unchanged: true }; } export function selectPendingReleasePullRequest( pullRequests, - { eventName, releaseBranch, releaseCommit }, + { + eventName, + releaseBranch, + releaseCommit, + releaseExists = false, + repository, + runAttempt = 1, + }, ) { if (!Array.isArray(pullRequests)) { fail("Release workflow pending release pull requests must be an array."); } requireCommit(releaseCommit, "current release commit"); + requireBoolean(releaseExists, "pre-action release existence"); + requirePositiveInteger(runAttempt, "run attempt"); const pendingReleasePullRequests = pullRequests.filter( (pullRequest) => pullRequest?.baseRef === "main" && - pullRequest?.headRef === releaseBranch && pullRequest?.state === "closed" && pullRequest?.mergedAt !== null && Array.isArray(pullRequest?.labels) && @@ -85,10 +360,53 @@ export function selectPendingReleasePullRequest( fail("Release workflow found multiple pending merged release PRs."); } if (pendingReleasePullRequests.length === 0) { + if (eventName === "push" && releaseExists) { + if (runAttempt === 1) { + fail( + "Release workflow recovery is forbidden on the first run attempt.", + ); + } + const recoveryPullRequests = pullRequests.filter( + (pullRequest) => + pullRequest?.baseRef === "main" && + pullRequest?.state === "closed" && + pullRequest?.mergedAt !== null && + pullRequest?.mergeCommitSha === releaseCommit, + ); + if (recoveryPullRequests.length !== 1) { + fail( + "Release workflow recovery requires exactly one merged pull request for the release commit.", + ); + } + const recoveryPullRequest = recoveryPullRequests[0]; + requireEqual( + recoveryPullRequest.headRef, + releaseBranch, + "recovery release pull request head", + ); + requireEqual( + recoveryPullRequest.headRepository, + repository, + "recovery release pull request head repository", + ); + return recoveryPullRequest; + } + requireEqual(eventName, "workflow_dispatch", "release preparation event"); + requireEqual(runAttempt, 1, "release preparation run attempt"); return null; } const pullRequest = pendingReleasePullRequests[0]; + requireEqual( + pullRequest.headRef, + releaseBranch, + "pending release pull request head", + ); + requireEqual( + pullRequest.headRepository, + repository, + "pending release pull request head repository", + ); requireEqual( pullRequest.mergeCommitSha, releaseCommit, @@ -101,12 +419,15 @@ export function selectPendingReleasePullRequest( export function validateReleasePleaseBranchState({ branchSha, branchVersion, + conflictingOpenPullRequests = [], exists, isAncestor, mainVersion, manifestVersion, pullRequests = [], releaseBranch, + repository, + requirePendingLabel = true, }) { if (typeof exists !== "boolean" || typeof isAncestor !== "boolean") { fail("Release Please branch state flags must be boolean."); @@ -114,12 +435,20 @@ export function validateReleasePleaseBranchState({ if (!Array.isArray(pullRequests)) { fail("Release Please branch pull requests must be an array."); } + if (!Array.isArray(conflictingOpenPullRequests)) { + fail("Release Please conflicting open pull requests must be an array."); + } + if (conflictingOpenPullRequests.length > 0) { + fail( + "Release Please found an open pull request whose head can collide with the canonical release branch.", + ); + } + const mainPatch = stablePatch(mainVersion, "main version"); if (!exists) { return { state: "missing" }; } requireCommit(branchSha, "Release Please branch SHA"); - const mainPatch = stablePatch(mainVersion, "main version"); const branchPatch = stablePatch( branchVersion, "Release Please branch version", @@ -149,6 +478,8 @@ export function validateReleasePleaseBranchState({ branchSha, branchVersion, releaseBranch, + repository, + requirePending: requirePendingLabel, }); if (pullRequest.state === "open" && pullRequest.mergedAt === null) { @@ -176,9 +507,12 @@ export function validateReleasePleaseBranchState({ } export function validateMergedReleasePullRequest({ + expectedReleaseNotes, pullRequest, releaseBranch, releaseCommit, + repository, + requirePendingLabel = true, reviews, version, }) { @@ -188,6 +522,9 @@ export function validateMergedReleasePullRequest({ branchSha: pullRequest?.headSha, branchVersion: version, releaseBranch, + repository, + requirePending: requirePendingLabel, + expectedReleaseNotes, }); if (pullRequest.state !== "closed" || pullRequest.mergedAt === null) { fail("Release workflow release pull request must be merged."); @@ -230,6 +567,449 @@ export function validateMergedReleasePullRequest({ return { pullRequestNumber: pullRequest.number }; } +export function validateReleaseCandidatePullRequest({ + expectedReleaseNotes, + pullRequest, + releaseBranch, + repository, + requirePendingLabel = true, + version, +}) { + requireReleasePullRequest(pullRequest, { + branchSha: pullRequest?.headSha, + branchVersion: version, + releaseBranch, + repository, + requirePending: requirePendingLabel, + expectedReleaseNotes, + }); + return pullRequest; +} + +export function validateTaggedReleasePullRequest({ + expectedReleaseNotes, + pullRequest, + releaseBranch, + releaseCommit, + repository, + version, +}) { + requireCommit(releaseCommit, "release commit"); + validateReleaseCandidatePullRequest({ + pullRequest, + releaseBranch, + repository, + requirePendingLabel: false, + version, + expectedReleaseNotes, + }); + if (pullRequest.state !== "closed" || pullRequest.mergedAt === null) { + fail("Release workflow tagged release pull request must be merged."); + } + requireEqual( + pullRequest.mergeCommitSha, + releaseCommit, + "tagged release pull request merge commit", + ); + if ( + !pullRequest.labels.includes("autorelease: tagged") || + pullRequest.labels.includes("autorelease: pending") + ) { + fail( + "Release workflow tagged release pull request must have only the completed autorelease state.", + ); + } + return { pullRequestNumber: pullRequest.number }; +} + +export function validatePreparedReleasePullRequest({ + actionPullRequests, + actionPullRequestsCreated, + branchSha, + branchVersion, + changelog, + mainVersion, + manifestVersion, + packageLockPackageVersion, + packageLockVersion, + pullRequest, + releaseBranch, + repository, +}) { + if (!Array.isArray(actionPullRequests) || actionPullRequests.length > 1) { + fail( + "Release workflow preparation must return at most one release pull request.", + ); + } + requireBoolean( + actionPullRequestsCreated, + "preparation pull request creation output", + ); + requireEqual( + actionPullRequests.length, + actionPullRequestsCreated ? 1 : 0, + "preparation action pull request count", + ); + + const mainPatch = stablePatch(mainVersion, "preparation main version"); + const branchPatch = stablePatch( + branchVersion, + "preparation release branch version", + ); + if (branchPatch !== mainPatch + 1) { + fail("Release workflow preparation must produce the next stable patch."); + } + requireEqual(manifestVersion, branchVersion, "preparation manifest version"); + requireEqual( + packageLockVersion, + branchVersion, + "preparation package-lock version", + ); + requireEqual( + packageLockPackageVersion, + branchVersion, + "preparation package-lock root package version", + ); + + const expectedReleaseNotes = extractReleaseNotesFromChangelog( + changelog, + branchVersion, + ); + + requireReleasePullRequest(pullRequest, { + branchSha, + branchVersion, + releaseBranch, + repository, + expectedReleaseNotes, + }); + if (pullRequest.state !== "open" || pullRequest.mergedAt !== null) { + fail("Release workflow prepared release pull request must be open."); + } + const changedFiles = Array.isArray(pullRequest.files) + ? [...pullRequest.files].sort() + : []; + requireEqual( + JSON.stringify(changedFiles), + JSON.stringify(RELEASE_PR_FILES), + "prepared release pull request files", + ); + + const actionPullRequest = actionPullRequests[0]; + if (actionPullRequest !== undefined) { + if ( + actionPullRequest === null || + typeof actionPullRequest !== "object" || + Array.isArray(actionPullRequest) + ) { + fail("Release workflow action pull request output must be an object."); + } + requirePositiveInteger( + actionPullRequest.number, + "action pull request number", + ); + requireEqual( + actionPullRequest.number, + pullRequest.number, + "action pull request number", + ); + requireEqual( + actionPullRequest.baseBranchName, + pullRequest.baseRef, + "action pull request base", + ); + requireEqual( + actionPullRequest.headBranchName, + pullRequest.headRef, + "action pull request head", + ); + requireEqual( + actionPullRequest.title, + pullRequest.title, + "action pull request title", + ); + requireEqual( + actionPullRequest.body, + pullRequest.body, + "action pull request body", + ); + requirePendingLabel(actionPullRequest.labels, "action pull request"); + if (!Array.isArray(actionPullRequest.files)) { + fail("Release workflow action pull request files must be an array."); + } + } + + return { pullRequestNumber: pullRequest.number, version: branchVersion }; +} + +function releasePublishedAt(release) { + const releasePublished = requireTimestamp( + release?.published_at, + "GitHub release publication time", + ); + return releasePublished; +} + +function requireReleasePublishedDuringRun(release, runCreatedAt) { + const runCreated = requireTimestamp(runCreatedAt, "run creation time"); + const releasePublished = releasePublishedAt(release); + if (releasePublished < runCreated) { + fail( + "Release workflow cannot recover a Release published before this run.", + ); + } +} + +export function validateReleaseAttemptEvidence( + attempts, + { includeCurrentAttempt, release, releaseCommit, runAttempt, runId }, +) { + if (!Array.isArray(attempts) || attempts.length === 0) { + fail("Release workflow attempt evidence must be a non-empty array."); + } + requireCommit(releaseCommit, "release commit"); + requirePositiveInteger(runAttempt, "run attempt"); + requirePositiveInteger(runId, "run ID"); + requireBoolean( + includeCurrentAttempt, + "attempt evidence current-attempt state", + ); + const releasePublished = releasePublishedAt(release); + const seenAttempts = new Set(); + const matchingAttempts = []; + + for (const attemptEvidence of attempts) { + const attempt = attemptEvidence?.attempt; + requirePositiveInteger(attempt, "attempt evidence number"); + if (attempt > runAttempt || seenAttempts.has(attempt)) { + fail( + "Release workflow attempt evidence must contain unique attempts no later than the current attempt.", + ); + } + seenAttempts.add(attempt); + if (!Array.isArray(attemptEvidence.jobs)) { + fail("Release workflow attempt evidence jobs must be an array."); + } + const releaseJobs = attemptEvidence.jobs.filter( + (job) => job?.name === RELEASE_WORKFLOW_JOB, + ); + if (releaseJobs.length !== 1) { + fail( + "Release workflow attempt evidence must contain exactly one release job.", + ); + } + const job = releaseJobs[0]; + requireEqual(job.run_id, runId, "attempt evidence run ID"); + requireEqual(job.run_attempt, attempt, "attempt evidence run attempt"); + requireEqual(job.head_sha, releaseCommit, "attempt evidence head SHA"); + if (!Array.isArray(job.steps)) { + fail("Release workflow attempt evidence job steps must be an array."); + } + const releaseSteps = job.steps.filter( + (step) => step?.name === RELEASE_WORKFLOW_STEP, + ); + if (releaseSteps.length !== 1) { + fail( + "Release workflow attempt evidence must contain exactly one Release Please step.", + ); + } + const step = releaseSteps[0]; + if ( + step.status !== "completed" || + (step.conclusion !== "success" && step.conclusion !== "failure") + ) { + continue; + } + const stepStarted = requireTimestamp( + step.started_at, + "Release Please step start time", + ); + const stepCompleted = requireTimestamp( + step.completed_at, + "Release Please step completion time", + ); + if (stepCompleted < stepStarted) { + fail( + "Release workflow Release Please step cannot finish before it starts.", + ); + } + if (releasePublished >= stepStarted && releasePublished <= stepCompleted) { + matchingAttempts.push(attempt); + } + } + const finalExpectedAttempt = includeCurrentAttempt + ? runAttempt + : runAttempt - 1; + if ( + finalExpectedAttempt < 1 || + seenAttempts.size !== finalExpectedAttempt || + !Array.from( + { length: finalExpectedAttempt }, + (_value, index) => index + 1, + ).every((attempt) => seenAttempts.has(attempt)) + ) { + fail( + "Release workflow attempt evidence must cover every expected run attempt.", + ); + } + + if (matchingAttempts.length !== 1) { + fail( + "Release workflow Release creation must match exactly one executed Release Please attempt.", + ); + } + return { releaseSourceAttempt: matchingAttempts[0] }; +} + +export function validateReleasePresenceBeforeAction({ + attempts, + expectedReleaseNotes, + release, + releaseCommit, + repository, + runAttempt, + runCreatedAt, + runId, + tagCommit, + version, +}) { + requireCommit(releaseCommit, "release commit"); + requirePositiveInteger(runAttempt, "run attempt"); + requirePositiveInteger(runId, "run ID"); + stablePatch(version, "release version"); + requireTimestamp(runCreatedAt, "run creation time"); + const releaseExists = release !== null && release !== undefined; + const tagExists = tagCommit !== null && tagCommit !== undefined; + if (releaseExists !== tagExists) { + fail("Release workflow tag and GitHub Release existence must agree."); + } + if (!releaseExists) { + return { exists: false }; + } + if (runAttempt === 1) { + fail("Release workflow recovery is forbidden on the first run attempt."); + } + + const tag = `v${version}`; + const htmlUrl = `https://github.com/${repository}/releases/tag/${tag}`; + validateGitHubRelease(release, { + expectedBody: expectedReleaseNotes, + htmlUrl, + releaseCommit, + tag, + tagCommit, + }); + requireReleasePublishedDuringRun(release, runCreatedAt); + const evidence = validateReleaseAttemptEvidence(attempts, { + includeCurrentAttempt: false, + release, + releaseCommit, + runAttempt, + runId, + }); + if (evidence.releaseSourceAttempt >= runAttempt) { + fail("Release workflow recovery must come from an earlier run attempt."); + } + return { exists: true, ...evidence }; +} + +export function validateReleasePleaseCompletion({ + actionResult, + attempts, + expectedReleaseNotes, + release, + releaseCommit, + releaseExistedBeforeAction, + repository, + runAttempt, + runCreatedAt, + runId, + tagCommit, + version, +}) { + if ( + actionResult === null || + typeof actionResult !== "object" || + Array.isArray(actionResult) + ) { + fail("Release Please action result must be an object."); + } + if ( + actionResult.outcome !== "success" && + actionResult.outcome !== "failure" + ) { + fail("Release Please action outcome must be success or failure."); + } + requireBoolean(releaseExistedBeforeAction, "pre-action release existence"); + requirePositiveInteger(runAttempt, "run attempt"); + requirePositiveInteger(runId, "run ID"); + if (releaseExistedBeforeAction && runAttempt === 1) { + fail("Release workflow recovery is forbidden on the first run attempt."); + } + requireCommit(releaseCommit, "release commit"); + stablePatch(version, "release version"); + const tagName = `v${version}`; + const htmlUrl = `https://github.com/${repository}/releases/tag/${tagName}`; + validateGitHubRelease(release, { + expectedBody: expectedReleaseNotes, + htmlUrl, + releaseCommit, + tag: tagName, + tagCommit, + }); + requireReleasePublishedDuringRun(release, runCreatedAt); + const evidence = validateReleaseAttemptEvidence(attempts, { + includeCurrentAttempt: true, + release, + releaseCommit, + runAttempt, + runId, + }); + if ( + (releaseExistedBeforeAction && + evidence.releaseSourceAttempt >= runAttempt) || + (!releaseExistedBeforeAction && + evidence.releaseSourceAttempt !== runAttempt) + ) { + fail( + "Release workflow Release source attempt must match its pre-action state.", + ); + } + + if (!releaseExistedBeforeAction && actionResult.outcome === "success") { + requireEqual(actionResult.releaseCreated, true, "release_created output"); + requireEqual(actionResult.sha, releaseCommit, "release output SHA"); + requireEqual(actionResult.tagName, tagName, "release output tag"); + requireEqual(actionResult.version, version, "release output version"); + requireEqual(actionResult.htmlUrl, htmlUrl, "release output URL"); + const releasedPaths = Array.isArray(actionResult.releasedPaths) + ? actionResult.releasedPaths + : []; + requireEqual( + JSON.stringify(releasedPaths), + JSON.stringify(["."]), + "released paths output", + ); + } + if (releaseExistedBeforeAction && actionResult.releaseCreated === true) { + fail( + "Release Please cannot create a Release that existed before the action.", + ); + } + + return { + actionOutcome: actionResult.outcome, + htmlUrl, + recovered: actionResult.outcome === "failure" || releaseExistedBeforeAction, + releaseCreated: true, + releaseExistedBeforeAction, + releaseSourceAttempt: evidence.releaseSourceAttempt, + sha: releaseCommit, + tagName, + version, + }; +} + export function validateReleasePleaseActionResult( result, { @@ -245,7 +1025,27 @@ export function validateReleasePleaseActionResult( if (result === null || typeof result !== "object" || Array.isArray(result)) { fail("Release Please result artifact must be an object."); } - requireEqual(result.schemaVersion, 1, "result schema version"); + requireEqual(result.schemaVersion, 2, "result schema version"); + if ( + result.actionOutcome !== "success" && + result.actionOutcome !== "failure" + ) { + fail("Release Please action outcome must be success or failure."); + } + requireBoolean(result.recovered, "result recovered state"); + requireBoolean( + result.releaseExistedBeforeAction, + "result pre-action release existence", + ); + requirePositiveInteger( + result.releaseSourceAttempt, + "result release source attempt", + ); + requireEqual( + result.recovered, + result.actionOutcome === "failure" || result.releaseExistedBeforeAction, + "result recovered state", + ); requireEqual(result.releaseCreated, true, "release_created output"); requireEqual(result.repository, repository, "result repository"); requireEqual(result.workflowName, workflowName, "result workflow name"); @@ -253,8 +1053,21 @@ export function validateReleasePleaseActionResult( requirePositiveInteger(runId, "run ID"); requirePositiveInteger(result.runId, "result run ID"); requireEqual(result.runId, runId, "result run ID"); - requireEqual(runAttempt, 1, "run attempt"); + requirePositiveInteger(runAttempt, "run attempt"); requireEqual(result.runAttempt, runAttempt, "result run attempt"); + if ( + (result.releaseExistedBeforeAction && + result.releaseSourceAttempt >= runAttempt) || + (!result.releaseExistedBeforeAction && + result.releaseSourceAttempt !== runAttempt) + ) { + fail( + "Release workflow result source attempt must match its pre-action state.", + ); + } + if (result.releaseExistedBeforeAction && runAttempt === 1) { + fail("Release workflow recovery is forbidden on the first run attempt."); + } requireCommit(releaseCommit, "release commit"); requireCommit(result.sha, "result SHA"); requireEqual(result.sha, releaseCommit, "result SHA"); @@ -293,7 +1106,7 @@ export function validateReleaseWorkflowRun( "run head repository", ); requirePositiveInteger(run?.id, "run ID"); - requireEqual(run?.run_attempt, 1, "run attempt"); + requirePositiveInteger(run?.run_attempt, "run attempt"); requireCommit(run?.head_sha, "run head SHA"); requireCommit(checkedOutSha, "checked-out SHA"); requireEqual(run.head_sha, checkedOutSha, "run head SHA"); @@ -305,9 +1118,29 @@ export function validateReleaseWorkflowRun( }; } +export function validateReleasePleaseRunMetadata( + run, + { releaseCommit, repository, runAttempt, runId }, +) { + if (run === null || typeof run !== "object" || Array.isArray(run)) { + fail("Release workflow current run metadata must be an object."); + } + requirePositiveInteger(runId, "current run ID"); + requireEqual(run.id, runId, "current run ID"); + requirePositiveInteger(runAttempt, "current run attempt"); + requireEqual(run.run_attempt, runAttempt, "current run attempt"); + requireEqual(run.event, "push", "current run event"); + requireEqual(run.head_branch, "main", "current run head branch"); + requireCommit(releaseCommit, "release commit"); + requireEqual(run.head_sha, releaseCommit, "current run head SHA"); + requireEqual(run.repository?.full_name, repository, "current run repository"); + requireTimestamp(run.created_at, "current run creation time"); + return { createdAt: run.created_at }; +} + export function validateGitHubRelease( release, - { htmlUrl, releaseCommit, tag, tagCommit }, + { expectedBody, htmlUrl, releaseCommit, tag, tagCommit }, ) { requireCommit(releaseCommit, "release commit"); requireCommit(tagCommit, "tag commit"); @@ -327,12 +1160,12 @@ export function validateGitHubRelease( "GitHub release target", ); requireEqual(release?.html_url, htmlUrl, "GitHub release URL"); - if ( - typeof release?.published_at !== "string" || - release.published_at === "" - ) { - fail("Release workflow GitHub release must be published."); - } + requireEqual( + normalizeMarkdown(release?.body, "GitHub release body"), + normalizeMarkdown(expectedBody, "expected GitHub release body"), + "GitHub release body", + ); + releasePublishedAt(release); const prerelease = tag.slice(1).includes("-"); requireEqual( diff --git a/src/client.ts b/src/client.ts index 8023ef1..17a641c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,26 +12,32 @@ type UnsupportedCometAPIOption = (typeof UNSUPPORTED_COMETAPI_OPTIONS)[number]; function sanitizeOptions>( options: T, ): Omit { - const { - provider, - workloadIdentity, - dangerouslyAllowBrowser, - ...supportedOptions - } = options; - const unsupportedOptions = { - provider, - workloadIdentity, - dangerouslyAllowBrowser, - }; - for (const option of UNSUPPORTED_COMETAPI_OPTIONS) { - if (unsupportedOptions[option] !== undefined) { + if (Reflect.get(options, option) !== undefined) { throw new OpenAIError( `The \`${option}\` option is not supported by CometAPI.`, ); } } + const supportedOptions = {} as Omit; + for (const option of Reflect.ownKeys(options)) { + if ( + UNSUPPORTED_COMETAPI_OPTIONS.includes( + option as UnsupportedCometAPIOption, + ) || + !Object.prototype.propertyIsEnumerable.call(options, option) + ) { + continue; + } + Object.defineProperty(supportedOptions, option, { + configurable: true, + enumerable: true, + value: Reflect.get(options, option), + writable: true, + }); + } + return supportedOptions; } diff --git a/tests/config.test.ts b/tests/config.test.ts index bebcddd..593194e 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -309,7 +309,7 @@ describe("CometAPI configuration", () => { options as unknown as CometAPIOptions, ); - expect(reads).toBeGreaterThan(0); + expect(reads).toBe(1); expect(error.message).toMatch(/browser-like environment/i); expectSecretFreeError(error, [browserKey], logger); }); diff --git a/tests/release-validation.test.mjs b/tests/release-validation.test.mjs index 1713809..4ef2d3b 100644 --- a/tests/release-validation.test.mjs +++ b/tests/release-validation.test.mjs @@ -120,7 +120,7 @@ function fixture(version = "0.1.0-alpha.1") { } : { "release-type": "node", - versioning: "default", + versioning: "always-bump-patch", prerelease: false, component: "cometapi", "skip-github-release": false, @@ -698,7 +698,7 @@ describe("release metadata validation", () => { "chore${scope}: release${component} ${version}", "release-type": "node", "skip-github-release": false, - versioning: "default", + versioning: "always-bump-patch", }, }, }; @@ -713,8 +713,8 @@ describe("release metadata validation", () => { it.each([ ["component", (config) => delete config.packages["."].component], [ - "default versioning", - (config) => (config.packages["."].versioning = "prerelease"), + "patch-only versioning", + (config) => (config.packages["."].versioning = "default"), ], [ "GitHub release", @@ -748,7 +748,7 @@ describe("release metadata validation", () => { "chore${scope}: release${component} ${version}", "release-type": "node", "skip-github-release": false, - versioning: "default", + versioning: "always-bump-patch", }, }, }; @@ -777,7 +777,7 @@ describe("release metadata validation", () => { "chore${scope}: release${component} ${version}", "release-type": "node", "skip-github-release": false, - versioning: "default", + versioning: "always-bump-patch", }, }, }; diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index aa4d5ec..8feaa05 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -1,12 +1,23 @@ import { describe, expect, it } from "vitest"; import { + extractReleaseNotesFromChangelog, + validatePreparedReleasePullRequest, validateGitHubRelease, validateMergedReleasePullRequest, + validateOpenReleasePullRequestCollisions, + validatePostActionPullRequestSnapshot, + validateReleasePleaseCommitMessages, + validateReleasePleaseCompletion, + validateReleasePleaseMutationConfiguration, + validateReleasePleasePullRequestBody, + validateReleasePleaseRunMetadata, + validateReleasePresenceBeforeAction, validateReleasePleaseActionResult, validateReleasePleaseBranchState, validateReleaseWorkflowRun, selectPendingReleasePullRequest, + validateTaggedReleasePullRequest, } from "../scripts/release-workflow-validation.mjs"; const REPOSITORY = "cometapi-dev/cometapi-node"; @@ -15,11 +26,38 @@ const RELEASE_SHA = "a".repeat(40); const BRANCH_SHA = "b".repeat(40); const RUN_ID = 123456789; +function releaseNotes(version = "0.1.1") { + return `## [${version}](https://github.com/cometapi-dev/cometapi-node/compare/v0.1.0...v${version}) (2026-07-29) + +### Bug Fixes + +* enforce the supported options boundary`; +} + +function releaseBody(version = "0.1.1") { + return `:robot: I have created a release *beep* *boop* +--- + + +${releaseNotes(version)} + +--- +This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).`; +} + function pullRequestFixture({ merged = false, version = "0.1.1" } = {}) { return { - author: "release-author", + author: "github-actions[bot]", baseRef: "main", + body: releaseBody(version), + files: [ + ".release-please-manifest.json", + "CHANGELOG.md", + "package-lock.json", + "package.json", + ], headRef: RELEASE_BRANCH, + headRepository: REPOSITORY, headSha: BRANCH_SHA, labels: ["autorelease: pending"], mergeCommitIsAncestor: merged, @@ -51,12 +89,16 @@ function workflowRunFixture() { function actionResultFixture() { return { + actionOutcome: "success", htmlUrl: `${releaseUrl()}`, + recovered: false, releaseCreated: true, + releaseExistedBeforeAction: false, + releaseSourceAttempt: 1, repository: REPOSITORY, runAttempt: 1, runId: RUN_ID, - schemaVersion: 1, + schemaVersion: 2, sha: RELEASE_SHA, tagName: "v0.1.1", version: "0.1.1", @@ -69,30 +111,67 @@ function releaseUrl() { return `https://github.com/${REPOSITORY}/releases/tag/v0.1.1`; } -function releaseFixture() { +function releaseFixture({ + createdAt = "2026-07-28T23:59:00Z", + publishedAt = "2026-07-29T00:01:00Z", +} = {}) { return { author: { login: "github-actions[bot]" }, + body: releaseNotes(), draft: false, html_url: releaseUrl(), immutable: true, name: "v0.1.1", prerelease: false, - published_at: "2026-07-29T00:01:00Z", + created_at: createdAt, + published_at: publishedAt, tag_name: "v0.1.1", target_commitish: RELEASE_SHA, }; } +function attemptEvidenceFixture( + attempt, + { + completedAt = "2026-07-29T00:01:30Z", + conclusion = "success", + startedAt = "2026-07-29T00:00:30Z", + } = {}, +) { + return { + attempt, + jobs: [ + { + head_sha: RELEASE_SHA, + name: "Prepare a reviewed release pull request or GitHub release", + run_attempt: attempt, + run_id: RUN_ID, + steps: [ + { + completed_at: completedAt, + conclusion, + name: "Run Release Please", + started_at: startedAt, + status: "completed", + }, + ], + }, + ], + }; +} + function branchState(overrides = {}) { return { branchSha: BRANCH_SHA, branchVersion: "0.1.1", + conflictingOpenPullRequests: [], exists: true, isAncestor: false, mainVersion: "0.1.0", manifestVersion: "0.1.1", pullRequests: [pullRequestFixture()], releaseBranch: RELEASE_BRANCH, + repository: REPOSITORY, ...overrides, }; } @@ -106,6 +185,18 @@ describe("Release Please branch validation", () => { ).toEqual({ state: "missing" }); }); + it("rejects a 0.2 main version even when the release branch is missing", () => { + expect(() => + validateReleasePleaseBranchState( + branchState({ + exists: false, + mainVersion: "0.2.0", + pullRequests: [], + }), + ), + ).toThrow(/stable 0\.1\.x/i); + }); + it("accepts a merged branch that is an ancestor of main", () => { expect( validateReleasePleaseBranchState( @@ -126,6 +217,22 @@ describe("Release Please branch validation", () => { }); }); + it("rejects an open fork PR whose head can collide with the release branch", () => { + expect(() => + validateReleasePleaseBranchState( + branchState({ + conflictingOpenPullRequests: [ + { + headRef: RELEASE_BRANCH, + headRepository: "fork/repo", + number: 32, + }, + ], + }), + ), + ).toThrow(/collide with the canonical release branch/i); + }); + it("accepts a squash-merged branch through its exact merged PR", () => { expect( validateReleasePleaseBranchState( @@ -168,6 +275,9 @@ describe("Release Please branch validation", () => { ["head SHA", (pr) => (pr.headSha = "c".repeat(40))], ["title", (pr) => (pr.title = "chore(main): release 0.2.0")], ["label", (pr) => (pr.labels = [])], + ["author", (pr) => (pr.author = "maintainer")], + ["body", (pr) => (pr.body = "ordinary pull request body")], + ["head repository", (pr) => (pr.headRepository = "fork/repo")], ])("rejects a release PR with the wrong %s", (_name, mutate) => { const pullRequest = pullRequestFixture(); mutate(pullRequest); @@ -175,7 +285,170 @@ describe("Release Please branch validation", () => { validateReleasePleaseBranchState( branchState({ pullRequests: [pullRequest] }), ), - ).toThrow(/release workflow/i); + ).toThrow(/release (?:please|workflow)/i); + }); +}); + +describe("Release Please pull request preparation", () => { + function preparedState(overrides = {}) { + const pullRequest = pullRequestFixture(); + return { + actionPullRequests: [ + { + baseBranchName: pullRequest.baseRef, + body: pullRequest.body, + files: [], + headBranchName: pullRequest.headRef, + labels: pullRequest.labels, + number: pullRequest.number, + title: pullRequest.title, + }, + ], + actionPullRequestsCreated: true, + branchSha: BRANCH_SHA, + branchVersion: "0.1.1", + changelog: `# Changelog\n\n${releaseNotes()}`, + mainVersion: "0.1.0", + manifestVersion: "0.1.1", + packageLockPackageVersion: "0.1.1", + packageLockVersion: "0.1.1", + pullRequest, + releaseBranch: RELEASE_BRANCH, + repository: REPOSITORY, + ...overrides, + }; + } + + it("accepts the single action-created 0.1.1 patch PR", () => { + expect(validatePreparedReleasePullRequest(preparedState())).toEqual({ + pullRequestNumber: 31, + version: "0.1.1", + }); + }); + + it("accepts an unchanged existing action-authored PR when the action returns no output", () => { + expect( + validatePreparedReleasePullRequest( + preparedState({ + actionPullRequests: [], + actionPullRequestsCreated: false, + }), + ), + ).toEqual({ pullRequestNumber: 31, version: "0.1.1" }); + }); + + it.each([ + [ + "multiple action PRs", + (state) => + state.actionPullRequests.push({ + ...state.actionPullRequests[0], + number: 32, + }), + ], + [ + "inconsistent creation output", + (state) => (state.actionPullRequestsCreated = false), + ], + ["wrong manifest", (state) => (state.manifestVersion = "0.2.0")], + ["wrong branch SHA", (state) => (state.branchSha = "c".repeat(40))], + ["wrong lock root", (state) => (state.packageLockVersion = "0.2.0")], + [ + "wrong lock package", + (state) => (state.packageLockPackageVersion = "0.2.0"), + ], + [ + "wrong changed files", + (state) => state.pullRequest.files.push("README.md"), + ], + ["unparseable body", (state) => (state.pullRequest.body = "ordinary PR")], + ["wrong changelog", (state) => (state.changelog = "# Changelog\n")], + ])("rejects a preparation with %s", (_name, mutate) => { + const state = preparedState(); + mutate(state); + expect(() => validatePreparedReleasePullRequest(state)).toThrow( + /release workflow/i, + ); + }); + + it("rejects commit-level Release-As overrides before mutation", () => { + expect(() => + validateReleasePleaseCommitMessages([ + "fix: ordinary patch", + "fix: override\n\nRelease-As: 0.2.0", + ]), + ).toThrow(/Release-As/i); + }); + + it("accepts ordinary conventional commits without version overrides", () => { + expect( + validateReleasePleaseCommitMessages([ + "fix: options boundary", + "fix: release workflow", + ]), + ).toEqual({ commitCount: 2 }); + }); + + it.each([ + ["root", { "release-as": "0.2.0", packages: { ".": {} } }], + ["package", { packages: { ".": { "release-as": "0.2.0" } } }], + ])( + "rejects a %s configuration-level release-as override", + (_name, config) => { + expect(() => validateReleasePleaseMutationConfiguration(config)).toThrow( + /configuration-level release-as/i, + ); + }, + ); + + it("accepts an override-free Release Please configuration", () => { + expect( + validateReleasePleaseMutationConfiguration({ + packages: { ".": { versioning: "always-bump-patch" } }, + }), + ).toEqual({ overrideFree: true }); + }); + + it("accepts a Release Please 17.6.0 machine-readable body", () => { + expect( + validateReleasePleasePullRequestBody(releaseBody(), "0.1.1"), + ).toEqual({ version: "0.1.1" }); + }); + + it("extracts exactly the reviewed patch notes from CHANGELOG", () => { + expect( + extractReleaseNotesFromChangelog( + `# Changelog\n\n${releaseNotes()}\n\n## [0.1.0] - 2026-07-28\n\nPrevious.`, + "0.1.1", + ), + ).toBe(releaseNotes()); + }); + + it("rejects a release PR whose notes differ from CHANGELOG", () => { + expect(() => + validateReleasePleasePullRequestBody( + releaseBody().replace("supported options", "different options"), + "0.1.1", + releaseNotes(), + ), + ).toThrow(/pull request notes/i); + }); + + it.each([ + [ + "the default repository PR template", + "## Summary\n\nDescribe the change.", + ], + [ + "an overflow link", + "This release is too large to preview in the pull request body. View the full release notes here: https://github.com/cometapi-dev/cometapi-node/blob/release-notes/release-notes.md", + ], + ["one delimiter", ":robot:\n---\n## [0.1.1] (2026-07-29)"], + ["the wrong version", releaseBody("0.2.0")], + ])("rejects %s as a release PR body", (_name, body) => { + expect(() => validateReleasePleasePullRequestBody(body, "0.1.1")).toThrow( + /release workflow/i, + ); }); }); @@ -198,6 +471,22 @@ describe("release PR review validation", () => { eventName: "push", releaseBranch: RELEASE_BRANCH, releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + }), + ).toBe(pullRequest); + }); + + it("selects the exact merged PR on a verified release recovery rerun", () => { + const pullRequest = pullRequestFixture({ merged: true }); + pullRequest.labels = ["autorelease: tagged"]; + expect( + selectPendingReleasePullRequest([pullRequest], { + eventName: "push", + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + releaseExists: true, + repository: REPOSITORY, + runAttempt: 2, }), ).toBe(pullRequest); }); @@ -208,10 +497,23 @@ describe("release PR review validation", () => { eventName: "workflow_dispatch", releaseBranch: RELEASE_BRANCH, releaseCommit: RELEASE_SHA, + repository: REPOSITORY, }), ).toBeNull(); }); + it("rejects a rerun attempt that would prepare a release PR", () => { + expect(() => + selectPendingReleasePullRequest([pullRequestFixture()], { + eventName: "workflow_dispatch", + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + }), + ).toThrow(/preparation run attempt/i); + }); + it.each([ [ "an older merge", @@ -231,6 +533,49 @@ describe("release PR review validation", () => { ], { eventName: "push", releaseCommit: RELEASE_SHA }, ], + [ + "an exact candidate plus an alternate pending merge", + [ + pullRequestFixture({ merged: true }), + { + ...pullRequestFixture({ merged: true }), + headRef: "release-cometapi-v0.1.0", + mergeCommitSha: "c".repeat(40), + number: 30, + }, + ], + { eventName: "push", releaseCommit: RELEASE_SHA }, + ], + [ + "a legacy branch", + [ + { + ...pullRequestFixture({ merged: true }), + headRef: "release-cometapi-v0.1.1", + }, + ], + { eventName: "push", releaseCommit: RELEASE_SHA }, + ], + [ + "a v12 branch", + [ + { + ...pullRequestFixture({ merged: true }), + headRef: "release-please--branches--main", + }, + ], + { eventName: "push", releaseCommit: RELEASE_SHA }, + ], + [ + "a fork branch", + [ + { + ...pullRequestFixture({ merged: true }), + headRepository: "fork/repo", + }, + ], + { eventName: "push", releaseCommit: RELEASE_SHA }, + ], ])( "rejects %s before Release Please runs", (_name, pullRequests, overrides) => { @@ -239,6 +584,7 @@ describe("release PR review validation", () => { eventName: overrides.eventName, releaseBranch: RELEASE_BRANCH, releaseCommit: overrides.releaseCommit, + repository: REPOSITORY, }), ).toThrow(/release workflow/i); }, @@ -250,6 +596,23 @@ describe("release PR review validation", () => { pullRequest: pullRequestFixture({ merged: true }), releaseBranch: RELEASE_BRANCH, releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + reviews: [reviewFixture()], + version: "0.1.1", + }), + ).toEqual({ pullRequestNumber: 31 }); + }); + + it("accepts the reviewed release PR after an exact Release recovery removed the pending label", () => { + const pullRequest = pullRequestFixture({ merged: true }); + pullRequest.labels = ["autorelease: tagged"]; + expect( + validateMergedReleasePullRequest({ + pullRequest, + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + requirePendingLabel: false, reviews: [reviewFixture()], version: "0.1.1", }), @@ -260,7 +623,7 @@ describe("release PR review validation", () => { ["stale commit", (review) => (review.commitId = "c".repeat(40))], ["non-admin", (review) => (review.permission = "maintain")], ["bot", (review) => (review.userType = "Bot")], - ["PR author", (review) => (review.login = "release-author")], + ["PR author", (review) => (review.login = "github-actions[bot]")], ["changes requested", (review) => (review.state = "CHANGES_REQUESTED")], ])("rejects a %s review", (_name, mutate) => { const review = reviewFixture(); @@ -270,6 +633,7 @@ describe("release PR review validation", () => { pullRequest: pullRequestFixture({ merged: true }), releaseBranch: RELEASE_BRANCH, releaseCommit: RELEASE_SHA, + repository: REPOSITORY, reviews: [review], version: "0.1.1", }), @@ -283,6 +647,7 @@ describe("release PR review validation", () => { pullRequest: pullRequestFixture({ merged: true }), releaseBranch: RELEASE_BRANCH, releaseCommit: RELEASE_SHA, + repository: REPOSITORY, reviews: [ approval, { ...approval, id: 11, state: "CHANGES_REQUESTED" }, @@ -291,9 +656,87 @@ describe("release PR review validation", () => { }), ).toThrow(/human approval/i); }); + + it("accepts the exact merged release PR after label reconciliation", () => { + const pullRequest = pullRequestFixture({ merged: true }); + pullRequest.labels = ["autorelease: tagged"]; + expect( + validateTaggedReleasePullRequest({ + pullRequest, + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + version: "0.1.1", + }), + ).toEqual({ pullRequestNumber: 31 }); + }); + + it.each([ + ["missing tagged label", []], + [ + "remaining pending label", + ["autorelease: pending", "autorelease: tagged"], + ], + ])("rejects a tagged release PR with %s", (_name, labels) => { + const pullRequest = pullRequestFixture({ merged: true }); + pullRequest.labels = labels; + expect(() => + validateTaggedReleasePullRequest({ + pullRequest, + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + version: "0.1.1", + }), + ).toThrow(/autorelease state/i); + }); }); describe("release workflow trust validation", () => { + function currentRunFixture() { + return { + created_at: "2026-07-29T00:00:00Z", + event: "push", + head_branch: "main", + head_sha: RELEASE_SHA, + id: RUN_ID, + repository: { full_name: REPOSITORY }, + run_attempt: 1, + }; + } + + it("accepts exact current run metadata and returns its original creation time", () => { + expect( + validateReleasePleaseRunMetadata(currentRunFixture(), { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 1, + runId: RUN_ID, + }), + ).toEqual({ createdAt: "2026-07-29T00:00:00Z" }); + }); + + it.each([ + ["run ID", (run) => (run.id += 1)], + ["attempt", (run) => (run.run_attempt = 2)], + ["event", (run) => (run.event = "workflow_dispatch")], + ["branch", (run) => (run.head_branch = "feature")], + ["SHA", (run) => (run.head_sha = "b".repeat(40))], + ["repository", (run) => (run.repository.full_name = "fork/repo")], + ["creation time", (run) => (run.created_at = "not-a-date")], + ])("rejects mismatched current run %s", (_name, mutate) => { + const run = currentRunFixture(); + mutate(run); + expect(() => + validateReleasePleaseRunMetadata(run, { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 1, + runId: RUN_ID, + }), + ).toThrow(/release workflow/i); + }); + it("accepts the exact successful first-attempt main run", () => { expect( validateReleaseWorkflowRun(workflowRunFixture(), { @@ -318,7 +761,6 @@ describe("release workflow trust validation", () => { ["workflow name", (event) => (event.workflow_run.name = "Other")], ["workflow path", (event) => (event.workflow_run.path = "other.yml")], ["workflow SHA", (event) => (event.workflow_run.head_sha = "b".repeat(40))], - ["rerun", (event) => (event.workflow_run.run_attempt = 2)], ])("rejects a hostile or stale %s", (_name, mutate) => { const event = workflowRunFixture(); mutate(event); @@ -332,6 +774,241 @@ describe("release workflow trust validation", () => { ).toThrow(/release workflow/i); }); + it("accepts an exact successful recovery rerun", () => { + const event = workflowRunFixture(); + event.workflow_run.run_attempt = 2; + expect( + validateReleaseWorkflowRun(event, { + checkedOutSha: RELEASE_SHA, + repository: REPOSITORY, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toEqual({ releaseCommit: RELEASE_SHA, runAttempt: 2, runId: RUN_ID }); + }); + + it.each([0, "2", 1.5])("rejects invalid run attempt %j", (runAttempt) => { + const event = workflowRunFixture(); + event.workflow_run.run_attempt = runAttempt; + expect(() => + validateReleaseWorkflowRun(event, { + checkedOutSha: RELEASE_SHA, + repository: REPOSITORY, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toThrow(/run attempt/i); + }); + + it("requires an exact immutable release published during the same run before recovery", () => { + expect( + validateReleasePresenceBeforeAction({ + attempts: [attemptEvidenceFixture(1)], + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + }), + ).toEqual({ exists: true, releaseSourceAttempt: 1 }); + }); + + it.each([ + ["first attempt", { runAttempt: 1 }], + ["pre-run publication", { runCreatedAt: "2026-07-29T00:02:00Z" }], + ["missing tag", { tagCommit: null }], + [ + "release outside the prior action step", + { release: releaseFixture({ publishedAt: "2026-07-29T00:01:31Z" }) }, + ], + ])("rejects a %s release recovery", (_name, overrides) => { + expect(() => + validateReleasePresenceBeforeAction({ + attempts: [attemptEvidenceFixture(1)], + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + ...overrides, + }), + ).toThrow(/release workflow/i); + }); + + it.each([ + [ + "skipped Release Please step", + (attempts) => (attempts[0].jobs[0].steps[0].conclusion = "skipped"), + ], + ["wrong run ID", (attempts) => (attempts[0].jobs[0].run_id = RUN_ID + 1)], + [ + "wrong head SHA", + (attempts) => (attempts[0].jobs[0].head_sha = "b".repeat(40)), + ], + ])("rejects recovery attempt evidence with a %s", (_name, mutate) => { + const attempts = [attemptEvidenceFixture(1)]; + mutate(attempts); + expect(() => + validateReleasePresenceBeforeAction({ + attempts, + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + }), + ).toThrow(/release workflow/i); + }); + + it("accepts an absent release before any exact attempt", () => { + expect( + validateReleasePresenceBeforeAction({ + attempts: [], + expectedReleaseNotes: releaseNotes(), + release: null, + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 1, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: null, + version: "0.1.1", + }), + ).toEqual({ exists: false }); + }); + + it("accepts a Release Please failure after it created the exact immutable release", () => { + expect( + validateReleasePleaseCompletion({ + actionResult: { outcome: "failure" }, + attempts: [attemptEvidenceFixture(1, { conclusion: "failure" })], + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + releaseExistedBeforeAction: false, + repository: REPOSITORY, + runAttempt: 1, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + }), + ).toMatchObject({ + actionOutcome: "failure", + recovered: true, + releaseCreated: true, + releaseExistedBeforeAction: false, + releaseSourceAttempt: 1, + tagName: "v0.1.1", + }); + }); + + it("accepts a later attempt that creates the release after a pre-mutation failure", () => { + expect( + validateReleasePleaseCompletion({ + actionResult: { + htmlUrl: releaseUrl(), + outcome: "success", + releaseCreated: true, + releasedPaths: ["."], + sha: RELEASE_SHA, + tagName: "v0.1.1", + version: "0.1.1", + }, + attempts: [ + attemptEvidenceFixture(1, { + completedAt: "2026-07-29T00:00:10Z", + conclusion: "failure", + startedAt: "2026-07-29T00:00:05Z", + }), + attemptEvidenceFixture(2), + ], + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + releaseExistedBeforeAction: false, + repository: REPOSITORY, + runAttempt: 2, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + }), + ).toMatchObject({ + actionOutcome: "success", + recovered: false, + releaseExistedBeforeAction: false, + releaseSourceAttempt: 2, + }); + }); + + it.each([ + ["success", true], + ["failure", true], + ])( + "accepts an exact pre-existing release when the recovery action reports %s", + (outcome, recovered) => { + expect( + validateReleasePleaseCompletion({ + actionResult: { outcome, releaseCreated: false }, + attempts: [ + attemptEvidenceFixture(1), + attemptEvidenceFixture(2, { + completedAt: "2026-07-29T00:02:30Z", + conclusion: outcome, + startedAt: "2026-07-29T00:02:00Z", + }), + ], + expectedReleaseNotes: releaseNotes(), + release: releaseFixture(), + releaseCommit: RELEASE_SHA, + releaseExistedBeforeAction: true, + repository: REPOSITORY, + runAttempt: 2, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: RELEASE_SHA, + version: "0.1.1", + }), + ).toMatchObject({ + actionOutcome: outcome, + recovered, + releaseSourceAttempt: 1, + }); + }, + ); + + it("rejects an action failure that did not create the release", () => { + expect(() => + validateReleasePleaseCompletion({ + actionResult: { outcome: "failure" }, + attempts: [attemptEvidenceFixture(1, { conclusion: "failure" })], + expectedReleaseNotes: releaseNotes(), + release: null, + releaseCommit: RELEASE_SHA, + releaseExistedBeforeAction: false, + repository: REPOSITORY, + runAttempt: 1, + runCreatedAt: "2026-07-29T00:00:00Z", + runId: RUN_ID, + tagCommit: null, + version: "0.1.1", + }), + ).toThrow(/release workflow/i); + }); + it("accepts the exact Release Please outputs artifact", () => { expect( validateReleasePleaseActionResult(actionResultFixture(), { @@ -352,14 +1029,25 @@ describe("release workflow trust validation", () => { }); it.each([ + ["schema", (result) => (result.schemaVersion = 1)], + ["action outcome", (result) => (result.actionOutcome = "cancelled")], ["release_created", (result) => (result.releaseCreated = false)], ["repository", (result) => (result.repository = "fork/repo")], ["run ID", (result) => (result.runId += 1)], - ["run attempt", (result) => (result.runAttempt = 2)], + ["run attempt", (result) => (result.runAttempt += 1)], + ["recovery flag", (result) => (result.recovered = true)], + ["recovery flag type", (result) => (result.recovered = "false")], + [ + "pre-action release flag type", + (result) => (result.releaseExistedBeforeAction = "false"), + ], + ["release source attempt", (result) => (result.releaseSourceAttempt = 2)], ["SHA", (result) => (result.sha = "b".repeat(40))], ["tag", (result) => (result.tagName = "v0.2.0")], ["version", (result) => (result.version = "0.2.0")], ["URL", (result) => (result.htmlUrl = `${releaseUrl()}-other`)], + ["workflow name", (result) => (result.workflowName = "Other")], + ["workflow path", (result) => (result.workflowPath = "other.yml")], ])("rejects a mismatched action result %s", (_name, mutate) => { const result = actionResultFixture(); mutate(result); @@ -373,12 +1061,72 @@ describe("release workflow trust validation", () => { workflowName: "Release Please", workflowPath: ".github/workflows/release-please.yml", }), - ).toThrow(/release workflow/i); + ).toThrow(/release (?:please|workflow)/i); + }); + + it("accepts an exact run-bound recovery artifact", () => { + const result = actionResultFixture(); + result.actionOutcome = "failure"; + result.recovered = true; + result.releaseExistedBeforeAction = true; + result.releaseSourceAttempt = 1; + result.runAttempt = 2; + expect( + validateReleasePleaseActionResult(result, { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + runId: RUN_ID, + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toMatchObject({ tag: "v0.1.1", version: "0.1.1" }); }); + it("accepts an exact second-attempt artifact that created the release", () => { + const result = actionResultFixture(); + result.releaseSourceAttempt = 2; + result.runAttempt = 2; + expect( + validateReleasePleaseActionResult(result, { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 2, + runId: RUN_ID, + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toMatchObject({ tag: "v0.1.1", version: "0.1.1" }); + }); + + it.each([ + [1, 2], + [2, 1], + ])( + "rejects attempt %i evidence for workflow attempt %i", + (artifactAttempt, workflowAttempt) => { + const result = actionResultFixture(); + result.runAttempt = artifactAttempt; + expect(() => + validateReleasePleaseActionResult(result, { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: workflowAttempt, + runId: RUN_ID, + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toThrow(/run attempt/i); + }, + ); + it("accepts the exact immutable Release Please release", () => { expect( validateGitHubRelease(releaseFixture(), { + expectedBody: releaseNotes(), htmlUrl: releaseUrl(), releaseCommit: RELEASE_SHA, tag: "v0.1.1", @@ -396,11 +1144,13 @@ describe("release workflow trust validation", () => { ["wrong URL", (release) => (release.html_url = `${releaseUrl()}-other`)], ["unpublished", (release) => (release.published_at = null)], ["manual author", (release) => (release.author.login = "maintainer")], + ["unreviewed notes", (release) => (release.body = "changed notes")], ])("rejects a %s GitHub release", (_name, mutate) => { const release = releaseFixture(); mutate(release); expect(() => validateGitHubRelease(release, { + expectedBody: releaseNotes(), htmlUrl: releaseUrl(), releaseCommit: RELEASE_SHA, tag: "v0.1.1", @@ -412,6 +1162,7 @@ describe("release workflow trust validation", () => { it("rejects a tag that does not resolve to the workflow commit", () => { expect(() => validateGitHubRelease(releaseFixture(), { + expectedBody: releaseNotes(), htmlUrl: releaseUrl(), releaseCommit: RELEASE_SHA, tag: "v0.1.1", @@ -419,4 +1170,57 @@ describe("release workflow trust validation", () => { }), ).toThrow(/tag commit/i); }); + + it("rejects a same-name fork PR introduced before the final mutation check", () => { + expect(() => + validateOpenReleasePullRequestCollisions( + [ + { + baseRef: "main", + headRef: RELEASE_BRANCH, + headRepository: "fork/repo", + headSha: "c".repeat(40), + state: "open", + }, + ], + { + branchSha: null, + releaseBranch: RELEASE_BRANCH, + repository: REPOSITORY, + }, + ), + ).toThrow(/collide/i); + }); + + it("accepts only the release label transition after the action", () => { + const before = [pullRequestFixture({ merged: true })]; + const after = JSON.parse(JSON.stringify(before)); + after[0].labels = ["autorelease: tagged"]; + expect( + validatePostActionPullRequestSnapshot(before, after, { + releasePullRequestNumber: 31, + }), + ).toEqual({ unchanged: true }); + }); + + it.each([ + ["body", (pullRequest) => (pullRequest.body = "changed")], + ["head SHA", (pullRequest) => (pullRequest.headSha = "c".repeat(40))], + [ + "new pull request", + (_pullRequest, after) => after.push({ ...after[0], number: 32 }), + ], + ])( + "rejects a post-action pull request snapshot with a changed %s", + (_name, mutate) => { + const before = [pullRequestFixture({ merged: true })]; + const after = JSON.parse(JSON.stringify(before)); + mutate(after[0], after); + expect(() => + validatePostActionPullRequestSnapshot(before, after, { + releasePullRequestNumber: 31, + }), + ).toThrow(/snapshot/i); + }, + ); }); diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index abb324c..bcca2b4 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { URL } from "node:url"; import { describe, expect, it } from "vitest"; @@ -173,6 +174,7 @@ describe("GitHub Actions workflow contract", () => { expect(liveSmoke).not.toMatch(/^\s+[^:\n]+: write$/m); const releasePlease = job(workflow("release-please.yml"), "release-please"); + expect(releasePlease).toMatch(/^ {6}actions: read$/m); expect(releasePlease).toMatch(/^ {6}contents: write$/m); expect(releasePlease).toMatch(/^ {6}issues: write$/m); expect(releasePlease).toMatch(/^ {6}pull-requests: write$/m); @@ -190,23 +192,81 @@ describe("GitHub Actions workflow contract", () => { expect(releasePlease).toContain( "googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7", ); - expect(releasePlease).not.toContain("skip-github-release: true"); + expect(releasePlease).toContain( + "skip-github-release: ${{ steps.preflight.outputs.mode == 'prepare' }}", + ); + expect(releasePlease).toContain( + "skip-github-pull-request: ${{ steps.preflight.outputs.mode == 'release' }}", + ); + expect(releasePlease).toContain( + "continue-on-error: ${{ steps.preflight.outputs.mode == 'release' }}", + ); expect(contents).not.toContain("token:"); expect(contents).toMatch(/^ {2}workflow_dispatch:$/m); + expect(contents).toContain("group: release-please-main"); expect(releasePlease).toContain("RUN_ATTEMPT: ${{ github.run_attempt }}"); + expect(releasePlease).toContain("TRIGGERING_REF: ${{ github.ref }}"); + expect(releasePlease).toContain( + 'if [[ "$TRIGGERING_REF" != "refs/heads/main" ]]; then', + ); expect(releasePlease).toContain("ref: ${{ github.sha }}"); expect( matches( releasePlease, /git fetch --no-tags origin \+refs\/heads\/main:refs\/remotes\/origin\/main/g, ), - ).toHaveLength(2); + ).toHaveLength(4); expect(releasePlease).toContain("validateMergedReleasePullRequest"); + expect(releasePlease).toContain("validatePreparedReleasePullRequest"); expect(releasePlease).toContain("selectPendingReleasePullRequest"); + expect(releasePlease).toContain("validateReleasePleaseCommitMessages"); + expect(releasePlease).toContain( + "validateReleasePleaseMutationConfiguration", + ); + expect(releasePlease).toContain("validateReleasePleaseRunMetadata"); + expect(releasePlease).toContain("validateReleasePresenceBeforeAction"); + expect(releasePlease).toContain("validateReleasePleaseCompletion"); + expect(releasePlease).toContain("validateTaggedReleasePullRequest"); + expect(releasePlease).toContain( + 'if [[ "$EVENT_NAME" == "workflow_dispatch" && "$RUN_ATTEMPT" != "1" ]]; then', + ); + expect(releasePlease).not.toContain( + "Release Please reruns are forbidden; start a new first-attempt run.", + ); + expect(releasePlease.indexOf("npm ci --ignore-scripts")).toBeLessThan( + releasePlease.indexOf("node scripts/validate-release.mjs"), + ); + expect(releasePlease).toMatch( + /if: steps\.preflight\.outputs\.mode == 'release'[\s\S]*node scripts\/validate-release\.mjs \\\n[\s\S]*--require-final \\\n[\s\S]*--require-releasable-docs/, + ); expect(releasePlease).toContain( "release-please-result-${{ github.run_id }}-${{ github.run_attempt }}", ); expect(releasePlease).toContain("validateReleasePleaseActionResult"); + expect(releasePlease).toContain("schemaVersion: 2"); + expect(releasePlease).toContain("actionOutcome"); + expect(releasePlease).toContain("recovered"); + expect(releasePlease).toContain("releaseExistedBeforeAction"); + expect(releasePlease).toContain("releaseSourceAttempt"); + expect(releaseWorkflowValidation).toContain( + "validateReleaseAttemptEvidence", + ); + expect(releasePlease).toContain("validatePostActionPullRequestSnapshot"); + expect(releasePlease).toContain("extractReleaseNotesFromChangelog"); + expect(releasePlease).toContain("/attempts/${attempt}/jobs?per_page=100"); + expect(releasePlease).toContain("release-pulls-before-action.json"); + expect(releasePlease).toContain("autorelease%3A%20pending"); + expect(releasePlease).toContain("labels[]=autorelease: tagged"); + expect( + releasePlease.indexOf( + "Reconfirm the branch, candidate, and review before mutation", + ), + ).toBeLessThan(releasePlease.indexOf("Run Release Please")); + expect( + releasePlease.indexOf( + "Reconfirm the exact release state before mutation", + ), + ).toBeLessThan(releasePlease.indexOf("Run Release Please")); expect(releasePleaseConfig["last-release-sha"]).toBe( "1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca", @@ -221,10 +281,27 @@ describe("GitHub Actions workflow contract", () => { "chore${scope}: release${component} ${version}", "release-type": "node", "skip-github-release": false, - versioning: "default", + versioning: "always-bump-patch", }); }); + it("parses every inline Node workflow validator", () => { + const blocks = matches( + workflow("release-please.yml"), + /node --input-type=module <<'EOF'\n([\s\S]*?)\n\s+EOF/g, + ); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + const checked = spawnSync( + process.execPath, + ["--input-type=module", "--check", "-"], + { encoding: "utf8", input: block[1] }, + ); + expect(checked.stderr).toBe(""); + expect(checked.status).toBe(0); + } + }); + it("starts publication only from the completed Release Please workflow", () => { const publish = workflow("publish.yml"); expect(publish).toMatch( @@ -246,12 +323,40 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain( "release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}", ); + expect(verify).toContain("run-id: ${{ github.event.workflow_run.id }}"); + const resultDownload = verify.slice( + verify.indexOf("Download the exact Release Please result"), + verify.indexOf("Reject an untrusted Release Please workflow run"), + ); + expect(resultDownload).not.toContain("pattern:"); + expect(resultDownload).not.toContain("merge-multiple:"); + expect(resultDownload).not.toContain("release-please-result-1"); + expect(resultDownload).not.toContain("github.run_attempt"); expect(verify).toContain("validateReleasePleaseActionResult"); expect(verify).toContain("validateGitHubRelease"); + expect(verify).toContain("runAttempt: run.runAttempt"); + expect(verify).toContain("runId: run.runId"); expect(releaseWorkflowValidation).toContain("run?.run_attempt"); - expect(releaseWorkflowValidation).toContain("result.releaseCreated"); + expect(releaseWorkflowValidation).toContain( + 'requireEqual(result.schemaVersion, 2, "result schema version")', + ); + expect(releaseWorkflowValidation).toContain("result.actionOutcome"); + expect(releaseWorkflowValidation).toContain("result.recovered"); + expect(releaseWorkflowValidation).toContain( + "result.releaseExistedBeforeAction", + ); expect(releaseWorkflowValidation).toContain("release?.immutable"); expect(releaseWorkflowValidation).toContain("release?.target_commitish"); + + expect(verify).toContain( + "artifact-name: ${{ steps.artifact-name.outputs.name }}", + ); + expect(verify).toContain( + "npm-package-${{ steps.version.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}", + ); + expect(job(publish, "publish")).toContain( + "name: ${{ needs.verify.outputs.artifact-name }}", + ); }); it("rejects an unrelated divergent Release Please branch", () => { @@ -264,7 +369,17 @@ describe("GitHub Actions workflow contract", () => { ); expect(releasePlease).toContain("branch_version="); expect(releasePlease).toContain("manifest_version="); - expect(releasePlease).toContain("pullRequest.head?.sha === branchSha"); + expect(releasePlease).toContain("pullRequest.headSha === branchSha"); expect(releasePlease).toContain("validateReleasePleaseBranchState"); + expect(releasePlease).toContain( + "pulls?state=closed&base=main&per_page=100", + ); + expect( + matches(releasePlease, /pulls\?state=all&base=main&per_page=100/g).length, + ).toBeGreaterThanOrEqual(2); + expect(releasePlease).toContain("validateOpenReleasePullRequestCollisions"); + expect(releasePlease).not.toContain( + "pulls?state=closed&head=cometapi-dev%3A${RELEASE_BRANCH}", + ); }); });