From c0afc729d88f6ca08fe7054d971d5c98154ccd5b Mon Sep 17 00:00:00 2001 From: sepo-agent <279869237+sepo-agent@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:21:57 -0400 Subject: [PATCH 1/2] Add gated release publish workflow --- .agent/docs/technical-details/versioning.md | 13 + .../src/__tests__/publish-release-cli.test.ts | 223 +++++++++++++++ .agent/src/cli/publish-release.ts | 41 +++ .agent/src/release-publish.ts | 258 ++++++++++++++++++ .github/prompts/agent-release.md | 1 + .github/workflows/agent-release-publish.yml | 75 +++++ 6 files changed, 611 insertions(+) create mode 100644 .agent/src/__tests__/publish-release-cli.test.ts create mode 100644 .agent/src/cli/publish-release.ts create mode 100644 .agent/src/release-publish.ts create mode 100644 .github/workflows/agent-release-publish.yml diff --git a/.agent/docs/technical-details/versioning.md b/.agent/docs/technical-details/versioning.md index 3ceb757c..1dd7cbdf 100644 --- a/.agent/docs/technical-details/versioning.md +++ b/.agent/docs/technical-details/versioning.md @@ -36,3 +36,16 @@ Prepare: - The release prompt may update files, including `.agent/CHANGELOG.md`, and open a PR, but must not create git tags, GitHub Releases, or package publications. + +Publish: + +- After a marked release PR is merged into the default branch, `Agent / Release + / Publish` validates that the PR changed `.agent/package.json` and + `.agent/CHANGELOG.md`, reads the canonical version from `.agent/package.json`, + verifies `.agent/CHANGELOG.md` has notes for that version, and creates the + corresponding `vX.Y.Z` tag and GitHub Release at the merge commit. +- The publish workflow is hard-gated to `self-evolving/repo` so forks and + installed repositories do not publish upstream Sepo releases. +- Manual `workflow_dispatch` runs can provide `version`, `target_sha`, and + `dry_run` for validation or recovery. The requested version must match + `.agent/package.json` at the target commit. diff --git a/.agent/src/__tests__/publish-release-cli.test.ts b/.agent/src/__tests__/publish-release-cli.test.ts new file mode 100644 index 00000000..010bbe99 --- /dev/null +++ b/.agent/src/__tests__/publish-release-cli.test.ts @@ -0,0 +1,223 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; +import { strict as assert } from "node:assert"; + +const repoRoot = resolve(__dirname, "../../.."); +const targetSha = "1234567890abcdef1234567890abcdef12345678"; + +function parseGithubOutput(path: string): Map { + const raw = readFileSync(path, "utf8"); + const outputs = new Map(); + const blocks = raw.matchAll(/^([^<\n]+)<<([^\n]+)\n([\s\S]*?)\n\2$/gm); + for (const [, name, , value] of blocks) { + outputs.set(name, value); + } + return outputs; +} + +function writeReleaseFiles(workspace: string): void { + mkdirSync(join(workspace, ".agent"), { recursive: true }); + writeFileSync( + join(workspace, ".agent/package.json"), + JSON.stringify({ name: "@self-evolving/sepo", version: "0.4.0" }, null, 2), + "utf8", + ); + writeFileSync( + join(workspace, ".agent/CHANGELOG.md"), + [ + "# Changelog", + "", + "## 0.4.0 - 2026-06-04", + "", + "### Added", + "", + "- Publish release workflow.", + "", + "## 0.3.1 - 2026-06-04", + "", + "### Fixed", + "", + "- Prior release.", + "", + ].join("\n"), + "utf8", + ); +} + +function writeFakeGh(tempDir: string, mode: "eligible" | "unmarked" | "publish"): string { + const callsPath = join(tempDir, "gh-calls.txt"); + writeFileSync(callsPath, "", "utf8"); + const prBody = mode === "unmarked" ? "## Summary" : "## Summary "; + writeFileSync( + join(tempDir, "gh"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "$GH_CALLS" +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + printf '{"body":"${prBody}","state":"MERGED","mergedAt":"2026-06-04T00:00:00Z","mergeCommit":{"oid":"${targetSha}"},"files":[{"path":".agent/package.json"},{"path":".agent/CHANGELOG.md"}]}\\n' + exit 0 +fi +if [ "$1" = "api" ]; then + printf 'HTTP 404: Not Found\\n' >&2 + exit 1 +fi +if [ "$1" = "release" ] && [ "$2" = "create" ]; then + printf 'https://github.com/self-evolving/repo/releases/tag/v0.4.0\\n' + exit 0 +fi +exit 1 +`, + { encoding: "utf8", mode: 0o755 }, + ); + return callsPath; +} + +test("publish-release dry-run validates release PR marker and files", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); + try { + const workspace = join(tempDir, "workspace"); + mkdirSync(workspace); + writeReleaseFiles(workspace); + const outputPath = join(tempDir, "github-output.txt"); + writeFileSync(outputPath, "", "utf8"); + const callsPath = writeFakeGh(tempDir, "eligible"); + + execFileSync("node", [".agent/dist/cli/publish-release.js"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + GH_CALLS: callsPath, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: join(tempDir, "summary.md"), + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_WORKSPACE: workspace, + PR_NUMBER: "51", + TARGET_SHA: targetSha, + DRY_RUN: "true", + RUNNER_TEMP: tempDir, + }, + }); + + const outputs = parseGithubOutput(outputPath); + assert.equal(outputs.get("conclusion"), "dry-run"); + assert.equal(outputs.get("tag"), "v0.4.0"); + assert.equal(outputs.get("target_sha"), targetSha); + + const calls = readFileSync(callsPath, "utf8"); + assert.match(calls, /pr view 51/); + assert.match(calls, /api repos\/self-evolving\/repo\/git\/ref\/tags\/v0\.4\.0/); + assert.doesNotMatch(calls, /release create/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("publish-release skips merged PRs without the release marker", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); + try { + const workspace = join(tempDir, "workspace"); + mkdirSync(workspace); + writeReleaseFiles(workspace); + const outputPath = join(tempDir, "github-output.txt"); + writeFileSync(outputPath, "", "utf8"); + const callsPath = writeFakeGh(tempDir, "unmarked"); + + execFileSync("node", [".agent/dist/cli/publish-release.js"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + GH_CALLS: callsPath, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: join(tempDir, "summary.md"), + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_WORKSPACE: workspace, + PR_NUMBER: "52", + TARGET_SHA: targetSha, + RUNNER_TEMP: tempDir, + }, + }); + + const outputs = parseGithubOutput(outputPath); + assert.equal(outputs.get("conclusion"), "skipped"); + assert.match(outputs.get("reason") || "", /not marked as a Sepo release PR/); + + const calls = readFileSync(callsPath, "utf8"); + assert.doesNotMatch(calls, /release create/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("publish-release creates a GitHub Release for manual recovery", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); + try { + const workspace = join(tempDir, "workspace"); + mkdirSync(workspace); + writeReleaseFiles(workspace); + const outputPath = join(tempDir, "github-output.txt"); + writeFileSync(outputPath, "", "utf8"); + const callsPath = writeFakeGh(tempDir, "publish"); + + execFileSync("node", [".agent/dist/cli/publish-release.js"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + GH_CALLS: callsPath, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: join(tempDir, "summary.md"), + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_WORKSPACE: workspace, + TARGET_SHA: targetSha, + VERSION: "0.4.0", + RUNNER_TEMP: tempDir, + }, + }); + + const outputs = parseGithubOutput(outputPath); + assert.equal(outputs.get("conclusion"), "published"); + assert.equal(outputs.get("release_url"), "https://github.com/self-evolving/repo/releases/tag/v0.4.0"); + + const calls = readFileSync(callsPath, "utf8"); + assert.match(calls, /release create v0\.4\.0/); + assert.match(calls, /--target 1234567890abcdef1234567890abcdef12345678/); + assert.match(calls, /--notes-file/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("publish-release rejects requested versions that do not match package version", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); + try { + const workspace = join(tempDir, "workspace"); + mkdirSync(workspace); + writeReleaseFiles(workspace); + const callsPath = writeFakeGh(tempDir, "publish"); + + const result = spawnSync("node", [".agent/dist/cli/publish-release.js"], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + GH_CALLS: callsPath, + GITHUB_STEP_SUMMARY: join(tempDir, "summary.md"), + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_WORKSPACE: workspace, + TARGET_SHA: targetSha, + VERSION: "0.4.1", + RUNNER_TEMP: tempDir, + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /requested version 0\.4\.1 does not match/); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/.agent/src/cli/publish-release.ts b/.agent/src/cli/publish-release.ts new file mode 100644 index 00000000..5f36453d --- /dev/null +++ b/.agent/src/cli/publish-release.ts @@ -0,0 +1,41 @@ +// CLI: publish a prepared Sepo release by creating the tag and GitHub Release. +// Usage: node .agent/dist/cli/publish-release.js +// Env: GITHUB_REPOSITORY, VERSION, TARGET_SHA, PR_NUMBER, DRY_RUN, RUNNER_TEMP +// Outputs: conclusion, reason, version, tag, target_sha, release_url, notes_file + +import { appendFileSync } from "node:fs"; +import { publishRelease, emitPublishReleaseResult } from "../release-publish.js"; + +function parseBoolean(value: string): boolean { + return /^(1|true|yes|on)$/i.test(value.trim()); +} + +function appendSummary(lines: string[]): void { + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (!summaryFile) return; + appendFileSync(summaryFile, `${lines.join("\n")}\n`); +} + +try { + const result = publishRelease({ + repo: process.env.GITHUB_REPOSITORY || "", + workspace: process.env.GITHUB_WORKSPACE || process.cwd(), + runnerTemp: process.env.RUNNER_TEMP || "/tmp", + versionInput: process.env.VERSION || "", + targetShaInput: process.env.TARGET_SHA || "", + prNumber: process.env.PR_NUMBER || "", + dryRun: parseBoolean(process.env.DRY_RUN || ""), + }); + emitPublishReleaseResult(result); + appendSummary([ + `Release publish: ${result.conclusion}`, + `Version: ${result.version}`, + `Tag: ${result.tag}`, + `Target: ${result.targetSha}`, + result.releaseUrl ? `Release: ${result.releaseUrl}` : `Reason: ${result.reason}`, + ]); +} catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error(message); + process.exitCode = 1; +} diff --git a/.agent/src/release-publish.ts b/.agent/src/release-publish.ts new file mode 100644 index 00000000..1e080a0a --- /dev/null +++ b/.agent/src/release-publish.ts @@ -0,0 +1,258 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { gh } from "./github.js"; +import { setOutput } from "./output.js"; +import { parseReleaseVersion, type ReleaseVersion } from "./release-version.js"; + +export const RELEASE_PR_MARKER = ""; + +const REQUIRED_RELEASE_FILES = [".agent/package.json", ".agent/CHANGELOG.md"]; + +export interface PublishReleaseOptions { + repo: string; + workspace: string; + runnerTemp: string; + versionInput?: string; + targetShaInput?: string; + prNumber?: string; + dryRun?: boolean; +} + +export interface PublishReleaseResult { + conclusion: "published" | "dry-run" | "skipped"; + reason: string; + version: string; + tag: string; + targetSha: string; + releaseUrl: string; + notesFile: string; +} + +interface PackageJson { + version?: unknown; +} + +interface PullRequestFile { + path?: unknown; +} + +interface PullRequestView { + body?: unknown; + files?: PullRequestFile[]; + mergeCommit?: { + oid?: unknown; + } | null; + mergedAt?: unknown; + state?: unknown; + title?: unknown; + url?: unknown; +} + +function commandErrorText(err: unknown): string { + const record = err as { message?: unknown; stderr?: unknown; stdout?: unknown }; + return [record.message, record.stderr, record.stdout] + .map((part) => { + if (Buffer.isBuffer(part)) return part.toString("utf8"); + return typeof part === "string" ? part : ""; + }) + .filter(Boolean) + .join("\n"); +} + +function isNotFoundError(err: unknown): boolean { + return /\b404\b|not found/i.test(commandErrorText(err)); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function readPackageVersion(workspace: string): ReleaseVersion { + const packagePath = join(workspace, ".agent/package.json"); + const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; + if (typeof parsed.version !== "string" || !parsed.version.trim()) { + throw new Error(".agent/package.json must contain a version string"); + } + return parseReleaseVersion(parsed.version); +} + +export function extractChangelogNotes(changelog: string, version: string): string { + const headingRe = new RegExp(`^##\\s+\\[?${escapeRegExp(version)}\\]?(?:\\s|$).*?$`, "m"); + const match = headingRe.exec(changelog); + if (!match) { + throw new Error(`.agent/CHANGELOG.md must contain a section for ${version}`); + } + + const sectionStart = match.index + match[0].length; + const rest = changelog.slice(sectionStart); + const nextHeading = rest.search(/^##\s+/m); + const section = (nextHeading >= 0 ? rest.slice(0, nextHeading) : rest).trim(); + if (!section) { + throw new Error(`.agent/CHANGELOG.md section for ${version} must contain release notes`); + } + return `${section}\n`; +} + +function writeNotesFile(notes: string, runnerTemp: string, version: string): string { + const file = join(runnerTemp || "/tmp", `release-notes-${version}-${randomBytes(8).toString("hex")}.md`); + writeFileSync(file, notes, "utf8"); + return file; +} + +function currentHead(workspace: string): string { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd: workspace, + stdio: "pipe", + maxBuffer: 1024 * 1024, + }).toString("utf8").trim(); +} + +function fetchPullRequest(repo: string, prNumber: string): PullRequestView { + const raw = gh([ + "pr", + "view", + prNumber, + "--repo", + repo, + "--json", + "title,body,state,mergedAt,mergeCommit,files,url", + ]); + return JSON.parse(raw) as PullRequestView; +} + +function changedFilePaths(pr: PullRequestView): Set { + return new Set((Array.isArray(pr.files) ? pr.files : []).map((file) => String(file.path || ""))); +} + +function validateReleasePr(repo: string, prNumber: string, targetSha: string): string | null { + const pr = fetchPullRequest(repo, prNumber); + if (String(pr.state || "").toUpperCase() !== "MERGED" || !String(pr.mergedAt || "").trim()) { + return `PR #${prNumber} is not merged`; + } + + const body = String(pr.body || ""); + if (!body.includes(RELEASE_PR_MARKER)) { + return `PR #${prNumber} is not marked as a Sepo release PR`; + } + + const paths = changedFilePaths(pr); + const missing = REQUIRED_RELEASE_FILES.filter((path) => !paths.has(path)); + if (missing.length > 0) { + return `PR #${prNumber} is missing required release file changes: ${missing.join(", ")}`; + } + + const mergeOid = String(pr.mergeCommit?.oid || "").trim(); + if (mergeOid && targetSha && mergeOid !== targetSha) { + throw new Error(`target SHA ${targetSha} does not match PR #${prNumber} merge commit ${mergeOid}`); + } + + return null; +} + +function ensureTagAbsent(repo: string, tag: string): void { + try { + gh(["api", `repos/${repo}/git/ref/tags/${tag}`]); + } catch (err: unknown) { + if (isNotFoundError(err)) return; + throw new Error(`could not verify whether ${tag} already exists: ${commandErrorText(err)}`); + } + throw new Error(`release tag ${tag} already exists`); +} + +function createRelease(repo: string, parsed: ReleaseVersion, targetSha: string, notesFile: string): string { + const args = [ + "release", + "create", + parsed.tag, + "--repo", + repo, + "--target", + targetSha, + "--title", + parsed.tag, + "--notes-file", + notesFile, + ]; + if (parsed.prereleaseLabel) args.push("--prerelease"); + return gh(args).trim(); +} + +function fallbackReleaseUrl(repo: string, tag: string): string { + return `https://github.com/${repo}/releases/tag/${tag}`; +} + +export function publishRelease(opts: PublishReleaseOptions): PublishReleaseResult { + const repo = opts.repo.trim(); + if (!repo) throw new Error("Missing required env: GITHUB_REPOSITORY"); + + const packageVersion = readPackageVersion(opts.workspace); + const requested = opts.versionInput?.trim() ? parseReleaseVersion(opts.versionInput).version : ""; + if (requested && requested !== packageVersion.version) { + throw new Error(`requested version ${requested} does not match .agent/package.json version ${packageVersion.version}`); + } + + const targetSha = (opts.targetShaInput || "").trim() || currentHead(opts.workspace); + if (!/^[0-9a-f]{40}$/i.test(targetSha)) { + throw new Error(`target SHA must be a full 40-character commit SHA, got ${targetSha || "(empty)"}`); + } + + if (opts.prNumber?.trim()) { + const skipReason = validateReleasePr(repo, opts.prNumber.trim(), targetSha); + if (skipReason) { + return { + conclusion: "skipped", + reason: skipReason, + version: packageVersion.version, + tag: packageVersion.tag, + targetSha, + releaseUrl: "", + notesFile: "", + }; + } + } + + const notes = extractChangelogNotes( + readFileSync(join(opts.workspace, ".agent/CHANGELOG.md"), "utf8"), + packageVersion.version, + ); + const notesFile = writeNotesFile(notes, opts.runnerTemp, packageVersion.version); + + ensureTagAbsent(repo, packageVersion.tag); + + if (opts.dryRun) { + return { + conclusion: "dry-run", + reason: `Would create ${packageVersion.tag} at ${targetSha}`, + version: packageVersion.version, + tag: packageVersion.tag, + targetSha, + releaseUrl: "", + notesFile, + }; + } + + const releaseUrl = createRelease(repo, packageVersion, targetSha, notesFile) || fallbackReleaseUrl(repo, packageVersion.tag); + return { + conclusion: "published", + reason: `Created ${packageVersion.tag} at ${targetSha}`, + version: packageVersion.version, + tag: packageVersion.tag, + targetSha, + releaseUrl, + notesFile, + }; +} + +export function emitPublishReleaseResult(result: PublishReleaseResult): void { + setOutput("conclusion", result.conclusion); + setOutput("reason", result.reason); + setOutput("version", result.version); + setOutput("tag", result.tag); + setOutput("target_sha", result.targetSha); + setOutput("release_url", result.releaseUrl); + setOutput("notes_file", result.notesFile); + console.log(`${result.conclusion}: ${result.reason}`); + if (result.releaseUrl) console.log(result.releaseUrl); +} diff --git a/.github/prompts/agent-release.md b/.github/prompts/agent-release.md index 6ead155a..fe88e073 100644 --- a/.github/prompts/agent-release.md +++ b/.github/prompts/agent-release.md @@ -13,6 +13,7 @@ Instructions: 8. Run lightweight, directly relevant checks when applicable. 9. Do not create git tags. Do not create or edit GitHub Releases. Do not publish packages. 10. Do not commit. Leave changes in the working tree. +11. Include the hidden marker `` in the PR body so the publish workflow can recognize merged release PRs. Return exactly one JSON object and nothing else: diff --git a/.github/workflows/agent-release-publish.yml b/.github/workflows/agent-release-publish.yml new file mode 100644 index 00000000..62c27300 --- /dev/null +++ b/.github/workflows/agent-release-publish.yml @@ -0,0 +1,75 @@ +name: Agent / Release / Publish + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + version: + description: "Optional Sepo version to publish, for example 0.3.1" + required: false + default: "" + target_sha: + description: "Optional default-branch commit SHA to tag; defaults to the checked-out HEAD" + required: false + default: "" + dry_run: + description: "Validate without creating the tag or GitHub Release" + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: read + id-token: write # required for GitHub Actions OIDC broker exchange + +concurrency: + group: agent-release-publish-${{ github.repository }}-${{ github.event.pull_request.number || inputs.version || inputs.target_sha || github.run_id }} + cancel-in-progress: false + +jobs: + publish: + if: >- + vars.AGENT_ENABLED != 'false' && + github.repository == 'self-evolving/repo' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == github.event.repository.default_branch + ) + ) + runs-on: ${{ fromJson(vars.AGENT_RUNS_ON || '["ubuntu-latest"]') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || inputs.target_sha || github.event.repository.default_branch }} + token: ${{ github.token }} + + - name: Resolve GitHub auth + id: auth + uses: ./.github/actions/resolve-github-auth + with: + app_id: ${{ secrets.AGENT_APP_ID }} + app_private_key: ${{ secrets.AGENT_APP_PRIVATE_KEY }} + pat: ${{ secrets.AGENT_PAT }} + fallback_token: ${{ github.token }} + + - name: Setup agent runtime + uses: ./.github/actions/setup-agent-runtime + with: + install_codex: "false" + install_claude: "false" + + - name: Publish release + env: + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + GH_TOKEN: ${{ steps.auth.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + TARGET_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || inputs.target_sha || '' }} + VERSION: ${{ inputs.version || '' }} + run: node .agent/dist/cli/publish-release.js From 48e1a1a2a35d28c621fa2bc3797627a4ff7c7ea4 Mon Sep 17 00:00:00 2001 From: sepo-agent <279869237+sepo-agent@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:40:42 -0400 Subject: [PATCH 2/2] Secure manual release publish target SHA --- .../src/__tests__/publish-release-cli.test.ts | 91 +++++++++++++++---- .agent/src/release-publish.ts | 52 +++++++---- .github/workflows/agent-release-publish.yml | 2 +- 3 files changed, 110 insertions(+), 35 deletions(-) diff --git a/.agent/src/__tests__/publish-release-cli.test.ts b/.agent/src/__tests__/publish-release-cli.test.ts index 010bbe99..714fd7ca 100644 --- a/.agent/src/__tests__/publish-release-cli.test.ts +++ b/.agent/src/__tests__/publish-release-cli.test.ts @@ -6,7 +6,6 @@ import { test } from "node:test"; import { strict as assert } from "node:assert"; const repoRoot = resolve(__dirname, "../../.."); -const targetSha = "1234567890abcdef1234567890abcdef12345678"; function parseGithubOutput(path: string): Map { const raw = readFileSync(path, "utf8"); @@ -18,11 +17,34 @@ function parseGithubOutput(path: string): Map { return outputs; } -function writeReleaseFiles(workspace: string): void { +function runGit(workspace: string, args: string[]): string { + return execFileSync("git", args, { + cwd: workspace, + encoding: "utf8", + stdio: "pipe", + }).trim(); +} + +function commitReleaseFiles(workspace: string, message: string): string { + runGit(workspace, ["add", ".agent/package.json", ".agent/CHANGELOG.md"]); + runGit(workspace, ["commit", "-m", message]); + return runGit(workspace, ["rev-parse", "HEAD"]); +} + +function initReleaseRepo(workspace: string, version = "0.4.0"): string { + mkdirSync(workspace); + runGit(workspace, ["init"]); + runGit(workspace, ["config", "user.name", "Sepo Test"]); + runGit(workspace, ["config", "user.email", "sepo-test@example.com"]); + writeReleaseFiles(workspace, version); + return commitReleaseFiles(workspace, `release ${version}`); +} + +function writeReleaseFiles(workspace: string, version = "0.4.0"): void { mkdirSync(join(workspace, ".agent"), { recursive: true }); writeFileSync( join(workspace, ".agent/package.json"), - JSON.stringify({ name: "@self-evolving/sepo", version: "0.4.0" }, null, 2), + JSON.stringify({ name: "@self-evolving/sepo", version }, null, 2), "utf8", ); writeFileSync( @@ -30,7 +52,7 @@ function writeReleaseFiles(workspace: string): void { [ "# Changelog", "", - "## 0.4.0 - 2026-06-04", + `## ${version} - 2026-06-04`, "", "### Added", "", @@ -47,7 +69,7 @@ function writeReleaseFiles(workspace: string): void { ); } -function writeFakeGh(tempDir: string, mode: "eligible" | "unmarked" | "publish"): string { +function writeFakeGh(tempDir: string, mode: "eligible" | "unmarked" | "publish", targetSha: string): string { const callsPath = join(tempDir, "gh-calls.txt"); writeFileSync(callsPath, "", "utf8"); const prBody = mode === "unmarked" ? "## Summary" : "## Summary "; @@ -78,11 +100,10 @@ test("publish-release dry-run validates release PR marker and files", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); try { const workspace = join(tempDir, "workspace"); - mkdirSync(workspace); - writeReleaseFiles(workspace); + const targetSha = initReleaseRepo(workspace); const outputPath = join(tempDir, "github-output.txt"); writeFileSync(outputPath, "", "utf8"); - const callsPath = writeFakeGh(tempDir, "eligible"); + const callsPath = writeFakeGh(tempDir, "eligible", targetSha); execFileSync("node", [".agent/dist/cli/publish-release.js"], { cwd: repoRoot, @@ -119,11 +140,10 @@ test("publish-release skips merged PRs without the release marker", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); try { const workspace = join(tempDir, "workspace"); - mkdirSync(workspace); - writeReleaseFiles(workspace); + const targetSha = initReleaseRepo(workspace); const outputPath = join(tempDir, "github-output.txt"); writeFileSync(outputPath, "", "utf8"); - const callsPath = writeFakeGh(tempDir, "unmarked"); + const callsPath = writeFakeGh(tempDir, "unmarked", targetSha); execFileSync("node", [".agent/dist/cli/publish-release.js"], { cwd: repoRoot, @@ -156,11 +176,12 @@ test("publish-release creates a GitHub Release for manual recovery", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); try { const workspace = join(tempDir, "workspace"); - mkdirSync(workspace); - writeReleaseFiles(workspace); + const targetSha = initReleaseRepo(workspace); + writeReleaseFiles(workspace, "0.4.1"); + commitReleaseFiles(workspace, "move default branch forward"); const outputPath = join(tempDir, "github-output.txt"); writeFileSync(outputPath, "", "utf8"); - const callsPath = writeFakeGh(tempDir, "publish"); + const callsPath = writeFakeGh(tempDir, "publish", targetSha); execFileSync("node", [".agent/dist/cli/publish-release.js"], { cwd: repoRoot, @@ -184,20 +205,54 @@ test("publish-release creates a GitHub Release for manual recovery", () => { const calls = readFileSync(callsPath, "utf8"); assert.match(calls, /release create v0\.4\.0/); - assert.match(calls, /--target 1234567890abcdef1234567890abcdef12345678/); + assert.match(calls, new RegExp(`--target ${targetSha}`)); assert.match(calls, /--notes-file/); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); +test("publish-release rejects manual target SHAs outside trusted default history", () => { + const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); + try { + const workspace = join(tempDir, "workspace"); + initReleaseRepo(workspace); + const defaultBranch = runGit(workspace, ["branch", "--show-current"]); + runGit(workspace, ["checkout", "-b", "untrusted-target"]); + writeReleaseFiles(workspace, "0.4.1"); + const untrustedSha = commitReleaseFiles(workspace, "unmerged release target"); + runGit(workspace, ["checkout", defaultBranch]); + const callsPath = writeFakeGh(tempDir, "publish", untrustedSha); + + const result = spawnSync("node", [".agent/dist/cli/publish-release.js"], { + cwd: repoRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${tempDir}:${process.env.PATH || ""}`, + GH_CALLS: callsPath, + GITHUB_REPOSITORY: "self-evolving/repo", + GITHUB_WORKSPACE: workspace, + TARGET_SHA: untrustedSha, + VERSION: "0.4.1", + RUNNER_TEMP: tempDir, + }, + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /not reachable from the checked-out trusted HEAD/); + assert.equal(readFileSync(callsPath, "utf8"), ""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + test("publish-release rejects requested versions that do not match package version", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-publish-release-")); try { const workspace = join(tempDir, "workspace"); - mkdirSync(workspace); - writeReleaseFiles(workspace); - const callsPath = writeFakeGh(tempDir, "publish"); + const targetSha = initReleaseRepo(workspace); + const callsPath = writeFakeGh(tempDir, "publish", targetSha); const result = spawnSync("node", [".agent/dist/cli/publish-release.js"], { cwd: repoRoot, diff --git a/.agent/src/release-publish.ts b/.agent/src/release-publish.ts index 1e080a0a..f72c91da 100644 --- a/.agent/src/release-publish.ts +++ b/.agent/src/release-publish.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { readFileSync, writeFileSync } from "node:fs"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; import { gh } from "./github.js"; @@ -69,9 +69,24 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function readPackageVersion(workspace: string): ReleaseVersion { - const packagePath = join(workspace, ".agent/package.json"); - const parsed = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; +function git(workspace: string, args: string[]): string { + return execFileSync("git", args, { + cwd: workspace, + stdio: "pipe", + maxBuffer: 1024 * 1024, + }).toString("utf8"); +} + +function readTargetFile(workspace: string, targetSha: string, path: string): string { + try { + return git(workspace, ["show", `${targetSha}:${path}`]); + } catch (err: unknown) { + throw new Error(`${path} must exist at target SHA ${targetSha}: ${commandErrorText(err)}`); + } +} + +function readPackageVersion(workspace: string, targetSha: string): ReleaseVersion { + const parsed = JSON.parse(readTargetFile(workspace, targetSha, ".agent/package.json")) as PackageJson; if (typeof parsed.version !== "string" || !parsed.version.trim()) { throw new Error(".agent/package.json must contain a version string"); } @@ -102,11 +117,15 @@ function writeNotesFile(notes: string, runnerTemp: string, version: string): str } function currentHead(workspace: string): string { - return execFileSync("git", ["rev-parse", "HEAD"], { - cwd: workspace, - stdio: "pipe", - maxBuffer: 1024 * 1024, - }).toString("utf8").trim(); + return git(workspace, ["rev-parse", "HEAD"]).trim(); +} + +function ensureTargetShaReachableFromHead(workspace: string, targetSha: string): void { + try { + git(workspace, ["merge-base", "--is-ancestor", targetSha, "HEAD"]); + } catch { + throw new Error(`target SHA ${targetSha} is not reachable from the checked-out trusted HEAD`); + } } function fetchPullRequest(repo: string, prNumber: string): PullRequestView { @@ -187,16 +206,17 @@ export function publishRelease(opts: PublishReleaseOptions): PublishReleaseResul const repo = opts.repo.trim(); if (!repo) throw new Error("Missing required env: GITHUB_REPOSITORY"); - const packageVersion = readPackageVersion(opts.workspace); - const requested = opts.versionInput?.trim() ? parseReleaseVersion(opts.versionInput).version : ""; - if (requested && requested !== packageVersion.version) { - throw new Error(`requested version ${requested} does not match .agent/package.json version ${packageVersion.version}`); - } - const targetSha = (opts.targetShaInput || "").trim() || currentHead(opts.workspace); if (!/^[0-9a-f]{40}$/i.test(targetSha)) { throw new Error(`target SHA must be a full 40-character commit SHA, got ${targetSha || "(empty)"}`); } + ensureTargetShaReachableFromHead(opts.workspace, targetSha); + + const packageVersion = readPackageVersion(opts.workspace, targetSha); + const requested = opts.versionInput?.trim() ? parseReleaseVersion(opts.versionInput).version : ""; + if (requested && requested !== packageVersion.version) { + throw new Error(`requested version ${requested} does not match .agent/package.json version ${packageVersion.version}`); + } if (opts.prNumber?.trim()) { const skipReason = validateReleasePr(repo, opts.prNumber.trim(), targetSha); @@ -214,7 +234,7 @@ export function publishRelease(opts: PublishReleaseOptions): PublishReleaseResul } const notes = extractChangelogNotes( - readFileSync(join(opts.workspace, ".agent/CHANGELOG.md"), "utf8"), + readTargetFile(opts.workspace, targetSha, ".agent/CHANGELOG.md"), packageVersion.version, ); const notesFile = writeNotesFile(notes, opts.runnerTemp, packageVersion.version); diff --git a/.github/workflows/agent-release-publish.yml b/.github/workflows/agent-release-publish.yml index 62c27300..0352e598 100644 --- a/.github/workflows/agent-release-publish.yml +++ b/.github/workflows/agent-release-publish.yml @@ -46,7 +46,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || inputs.target_sha || github.event.repository.default_branch }} + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event.repository.default_branch }} token: ${{ github.token }} - name: Resolve GitHub auth