diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebdcea8..96c3206 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -491,6 +491,8 @@ jobs: contents: write id-token: write attestations: write + issues: write + pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -735,3 +737,22 @@ jobs: uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 with: subject-path: dist/*.tar.gz + - name: Reconcile Release Please lineage + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + pr_number=$(jq -er '.pullRequest | select(type == "number" and . > 0 and . == floor)' persisted-candidate.json) + labels_endpoint="repos/${GITHUB_REPOSITORY}/issues/${pr_number}/labels" + + gh api --method POST "$labels_endpoint" -f 'labels[]=autorelease: tagged' >/dev/null + current_labels=$(gh api "$labels_endpoint" --jq '.[].name') + if grep -Fxq 'autorelease: pending' <<< "$current_labels"; then + gh api --method DELETE "${labels_endpoint}/autorelease%3A%20pending" >/dev/null + fi + + current_labels=$(gh api "$labels_endpoint" --jq '.[].name') + if ! grep -Fxq 'autorelease: tagged' <<< "$current_labels" || grep -Fxq 'autorelease: pending' <<< "$current_labels"; then + echo "Release Please lineage did not converge for PR ${pr_number}." >&2 + exit 1 + fi diff --git a/scripts/release-validate.test.ts b/scripts/release-validate.test.ts index c70a136..087bcb5 100644 --- a/scripts/release-validate.test.ts +++ b/scripts/release-validate.test.ts @@ -1,4 +1,12 @@ -import { cpSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs" +import { + chmodSync, + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" import { tmpdir } from "node:os" import { basename, join, resolve } from "node:path" @@ -367,6 +375,116 @@ test("release workflow is pinned and publishes proven assets after validation", expect(workflow).not.toContain("endswith($repository)") }) +test("successful publication closes the provenance-bound Release Please lineage", () => { + const workflowSource = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8") + const workflow = Bun.YAML.parse(workflowSource) as { + jobs: { + release: { + permissions: Record + steps: Array<{ name?: string; run?: string }> + } + } + } + const releaseJob = workflow.jobs.release + const lineageStep = releaseJob.steps.find( + (step) => step.name === "Reconcile Release Please lineage", + ) + + expect(releaseJob.permissions.issues).toBe("write") + expect(releaseJob.permissions["pull-requests"]).toBe("read") + expect(lineageStep?.run).toContain("persisted-candidate.json") + expect(lineageStep?.run).toContain("autorelease: tagged") + expect(lineageStep?.run).toContain("autorelease%3A%20pending") + expect(lineageStep?.run).toContain("Release Please lineage did not converge") + expect(lineageStep?.run).toBeString() + + const temporaryRoot = mkdtempSync(join(tmpdir(), "release-lineage-")) + const fakeBin = join(temporaryRoot, "bin") + const labelsPath = join(temporaryRoot, "labels.txt") + const logPath = join(temporaryRoot, "gh.log") + try { + mkdirSync(fakeBin) + writeFileSync(join(temporaryRoot, "persisted-candidate.json"), '{"pullRequest":18}\n') + writeFileSync(labelsPath, "autorelease: pending\n") + const fakeGh = join(fakeBin, "gh") + writeFileSync( + fakeGh, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$GH_FAKE_LOG" +[[ "$*" == *"repos/myagentdojo/agent-plugin-template/issues/18/labels"* ]] +if [[ "$*" == *"--method POST"* ]]; then + label="" + for argument in "$@"; do + case "$argument" in + 'labels[]='*) label="\${argument#*=}" ;; + esac + done + if [[ -z "$label" ]]; then + echo 'missing labels[] POST field' >&2 + exit 2 + fi + grep -Fxq "$label" "$GH_FAKE_LABELS" || printf '%s\\n' "$label" >> "$GH_FAKE_LABELS" +elif [[ "$*" == *"--method DELETE"* ]]; then + if [[ "\${GH_FAKE_MODE:-}" == "delete_forbidden" ]]; then + echo 'gh: Forbidden (HTTP 403)' >&2 + exit 1 + fi + if [[ "\${GH_FAKE_MODE:-}" == "stale_labels" ]]; then + exit 0 + fi + if ! grep -Fxq 'autorelease: pending' "$GH_FAKE_LABELS"; then + echo 'gh: Not Found (HTTP 404)' >&2 + exit 1 + fi + grep -Fxv 'autorelease: pending' "$GH_FAKE_LABELS" > "$GH_FAKE_LABELS.next" || true + mv "$GH_FAKE_LABELS.next" "$GH_FAKE_LABELS" +else + cat "$GH_FAKE_LABELS" +fi +`, + ) + chmodSync(fakeGh, 0o755) + + const runLineage = (mode = "") => + Bun.spawnSync({ + cmd: ["bash", "-c", lineageStep?.run ?? ""], + cwd: temporaryRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + GITHUB_REPOSITORY: "myagentdojo/agent-plugin-template", + GH_FAKE_LABELS: labelsPath, + GH_FAKE_LOG: logPath, + GH_FAKE_MODE: mode, + }, + stdout: "pipe", + stderr: "pipe", + }) + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = runLineage() + expect(result.exitCode, result.stderr.toString()).toBe(0) + expect(readFileSync(labelsPath, "utf8")).toBe("autorelease: tagged\n") + } + expect(readFileSync(logPath, "utf8").match(/--method DELETE/g)).toHaveLength(1) + + writeFileSync(labelsPath, "autorelease: pending\n") + const deleteFailure = runLineage("delete_forbidden") + expect(deleteFailure.exitCode).not.toBe(0) + expect(deleteFailure.stderr.toString()).toContain("Forbidden (HTTP 403)") + + writeFileSync(labelsPath, "autorelease: pending\n") + const staleLabels = runLineage("stale_labels") + expect(staleLabels.exitCode).not.toBe(0) + expect(staleLabels.stderr.toString()).toContain( + "Release Please lineage did not converge for PR 18.", + ) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + test("immutable tag creation is retry-safe and rejects a rebound candidate", () => { const workflowSource = readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8") const workflow = Bun.YAML.parse(workflowSource) as {