diff --git a/.agentplane/tasks/202605012125-PXYEPC/README.md b/.agentplane/tasks/202605012125-PXYEPC/README.md new file mode 100644 index 000000000..2d9171b73 --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/README.md @@ -0,0 +1,138 @@ +--- +id: "202605012125-PXYEPC" +title: "Automate external distribution repo publishing" +status: "DOING" +priority: "high" +owner: "CODER" +revision: 5 +origin: + system: "manual" +depends_on: [] +tags: + - "ci" + - "release" + - "workflow" +verify: [] +plan_approval: + state: "approved" + updated_at: "2026-05-01T21:26:03.962Z" + updated_by: "ORCHESTRATOR" + note: null +verification: + state: "ok" + updated_at: "2026-05-01T21:31:43.594Z" + updated_by: "CODER" + note: "External distribution publishing automation verified." +commit: null +comments: + - + author: "CODER" + body: "Start: add automated external distribution repo publication for release modules." +events: + - + type: "status" + at: "2026-05-01T21:26:16.509Z" + author: "CODER" + from: "TODO" + to: "DOING" + note: "Start: add automated external distribution repo publication for release modules." + - + type: "verify" + at: "2026-05-01T21:31:43.594Z" + author: "CODER" + state: "ok" + note: "External distribution publishing automation verified." +doc_version: 3 +doc_updated_at: "2026-05-01T21:31:43.601Z" +doc_updated_by: "CODER" +description: "Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured." +sections: + Summary: |- + Automate external distribution repo publishing + + Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. + Scope: |- + - In scope: Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. + - Out of scope: unrelated refactors not required for "Automate external distribution repo publishing". + Plan: |- + 1. Inspect generated external distribution artifacts and current publish workflow boundaries. + 2. Add a reusable script that publishes generated files into an external repo branch and opens or updates a PR with the configured token. + 3. Wire Homebrew, Scoop, and setup-agentplane modules into publish.yml after artifact rendering, preserving skipped behavior when credentials are absent. + 4. Add workflow/script contract tests for token usage, target paths, and evidence output. + 5. Run targeted release workflow tests plus lint/routing/doctor and merge through branch_pr. + Verify Steps: |- + 1. Review the requested outcome for "Automate external distribution repo publishing". Expected: the visible result matches ## Summary and stays inside approved scope. + 2. Run the most relevant validation step for this task. Expected: it succeeds without unexpected regressions in touched behavior. + 3. Compare the final result against ## Scope and record any residual follow-up in ## Findings. Expected: open edges are explicit rather than implicit. + Verification: |- + + ### 2026-05-01T21:31:43.594Z — VERIFY — ok + + By: CODER + + Note: External distribution publishing automation verified. + + VerifyStepsRef: doc_version=3, doc_updated_at=2026-05-01T21:26:16.509Z, excerpt_hash=sha256:17a734080f808ebbaa3cb657f3f59cb50c25ab7a76376dcfe3778d43c1e33493 + + + Rollback Plan: |- + - Revert task-related commit(s). + - Re-run required checks to confirm rollback safety. + Findings: |- + - Observation: Added publish-external-distribution script, wired Homebrew/Scoop/setup-agentplane PR publication steps after GitHub Release creation, and covered workflow/script contracts. Checks: bun test packages/agentplane/src/commands/release/publish-workflow-contract.test.ts packages/agentplane/src/commands/release/publish-external-distribution-script.test.ts; node scripts/publish-external-distribution.mjs --help; git diff --check; node .agentplane/policy/check-routing.mjs; bun run workflows:command-check; bun run lint:core; agentplane doctor. + Impact: Future release publish jobs can open/update external distribution PRs when HOMEBREW_TAP_TOKEN, SCOOP_BUCKET_TOKEN, and SETUP_AGENTPLANE_TOKEN are configured, while preserving skipped_missing_credentials evidence when they are absent. + Resolution: Ready for branch_pr integration. + Promotion: incident-candidate + Fixability: external +id_source: "generated" +--- +## Summary + +Automate external distribution repo publishing + +Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. + +## Scope + +- In scope: Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. +- Out of scope: unrelated refactors not required for "Automate external distribution repo publishing". + +## Plan + +1. Inspect generated external distribution artifacts and current publish workflow boundaries. +2. Add a reusable script that publishes generated files into an external repo branch and opens or updates a PR with the configured token. +3. Wire Homebrew, Scoop, and setup-agentplane modules into publish.yml after artifact rendering, preserving skipped behavior when credentials are absent. +4. Add workflow/script contract tests for token usage, target paths, and evidence output. +5. Run targeted release workflow tests plus lint/routing/doctor and merge through branch_pr. + +## Verify Steps + +1. Review the requested outcome for "Automate external distribution repo publishing". Expected: the visible result matches ## Summary and stays inside approved scope. +2. Run the most relevant validation step for this task. Expected: it succeeds without unexpected regressions in touched behavior. +3. Compare the final result against ## Scope and record any residual follow-up in ## Findings. Expected: open edges are explicit rather than implicit. + +## Verification + + +### 2026-05-01T21:31:43.594Z — VERIFY — ok + +By: CODER + +Note: External distribution publishing automation verified. + +VerifyStepsRef: doc_version=3, doc_updated_at=2026-05-01T21:26:16.509Z, excerpt_hash=sha256:17a734080f808ebbaa3cb657f3f59cb50c25ab7a76376dcfe3778d43c1e33493 + + + +## Rollback Plan + +- Revert task-related commit(s). +- Re-run required checks to confirm rollback safety. + +## Findings + +- Observation: Added publish-external-distribution script, wired Homebrew/Scoop/setup-agentplane PR publication steps after GitHub Release creation, and covered workflow/script contracts. Checks: bun test packages/agentplane/src/commands/release/publish-workflow-contract.test.ts packages/agentplane/src/commands/release/publish-external-distribution-script.test.ts; node scripts/publish-external-distribution.mjs --help; git diff --check; node .agentplane/policy/check-routing.mjs; bun run workflows:command-check; bun run lint:core; agentplane doctor. + Impact: Future release publish jobs can open/update external distribution PRs when HOMEBREW_TAP_TOKEN, SCOOP_BUCKET_TOKEN, and SETUP_AGENTPLANE_TOKEN are configured, while preserving skipped_missing_credentials evidence when they are absent. + Resolution: Ready for branch_pr integration. + Promotion: incident-candidate + Fixability: external diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/diffstat.txt b/.agentplane/tasks/202605012125-PXYEPC/pr/diffstat.txt new file mode 100644 index 000000000..d285ee4a6 --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/pr/diffstat.txt @@ -0,0 +1,5 @@ + .github/workflows/publish.yml | 49 ++++ + .../publish-external-distribution-script.test.ts | 121 ++++++++++ + .../release/publish-workflow-contract.test.ts | 31 +++ + scripts/publish-external-distribution.mjs | 251 +++++++++++++++++++++ + 4 files changed, 452 insertions(+) diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/github-body.md b/.agentplane/tasks/202605012125-PXYEPC/pr/github-body.md new file mode 100644 index 000000000..7a7762198 --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/pr/github-body.md @@ -0,0 +1,40 @@ +Task: `202605012125-PXYEPC` +Title: Automate external distribution repo publishing + +## Summary + +Automate external distribution repo publishing + +Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. + +## Scope + +- In scope: Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. +- Out of scope: unrelated refactors not required for "Automate external distribution repo publishing". + +## Verification + +- State: ok +- Note: External distribution publishing automation verified. +- Full verification checklist lives in local review.md. + +## Handoff Notes + +- No handoff notes recorded yet. Use `agentplane pr note ...` to append one. + +
+Raw evidence + +- Updated: 2026-05-01T21:31:57.672Z +- Branch: task/202605012125-PXYEPC/external-distribution-publish +- Head: 9b6156d62117 + +```text + .github/workflows/publish.yml | 49 ++++ + .../publish-external-distribution-script.test.ts | 121 ++++++++++ + .../release/publish-workflow-contract.test.ts | 31 +++ + scripts/publish-external-distribution.mjs | 251 +++++++++++++++++++++ + 4 files changed, 452 insertions(+) +``` + +
diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/github-title.txt b/.agentplane/tasks/202605012125-PXYEPC/pr/github-title.txt new file mode 100644 index 000000000..4bc4abf1c --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/pr/github-title.txt @@ -0,0 +1 @@ +task: Automate external distribution repo publishing [202605012125-PXYEPC] diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/meta.json b/.agentplane/tasks/202605012125-PXYEPC/pr/meta.json new file mode 100644 index 000000000..14d242df4 --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/pr/meta.json @@ -0,0 +1,14 @@ +{ + "base": "main", + "branch": "task/202605012125-PXYEPC/external-distribution-publish", + "created_at": "2026-05-01T21:26:16.551Z", + "head_sha": "9b6156d62117ba0b48e3adde5f1a35613d8be308", + "last_verified_at": "2026-05-01T21:31:43.594Z", + "last_verified_sha": "0a141dfaeb7908364fd189895ff84a60f0c22cf2", + "schema_version": 1, + "task_id": "202605012125-PXYEPC", + "updated_at": "2026-05-01T21:31:57.672Z", + "verify": { + "status": "pass" + } +} diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/notes.jsonl b/.agentplane/tasks/202605012125-PXYEPC/pr/notes.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/review.md b/.agentplane/tasks/202605012125-PXYEPC/pr/review.md new file mode 100644 index 000000000..a7af481ff --- /dev/null +++ b/.agentplane/tasks/202605012125-PXYEPC/pr/review.md @@ -0,0 +1,61 @@ +# PR Review + +Created: 2026-05-01T21:26:16.551Z +Branch: task/202605012125-PXYEPC/external-distribution-publish + +## Summary + +Automate external distribution repo publishing + +Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. + +## Scope + +- In scope: Publish Homebrew, Scoop, and setup-agentplane outputs to their external repositories from the release workflow when credentials are configured. +- Out of scope: unrelated refactors not required for "Automate external distribution repo publishing". + +## Verification + +### Plan + +1. Review the requested outcome for "Automate external distribution repo publishing". Expected: the visible result matches ## Summary and stays inside approved scope. +2. Run the most relevant validation step for this task. Expected: it succeeds without unexpected regressions in touched behavior. +3. Compare the final result against ## Scope and record any residual follow-up in ## Findings. Expected: open edges are explicit rather than implicit. + +### Current Status + +- State: ok +- Note: External distribution publishing automation verified. + +## Risks + +- Risk level: not recorded +- Breaking change: no + +### Rollback + +- Revert task-related commit(s). +- Re-run required checks to confirm rollback safety. + +## Handoff Notes + +- No handoff notes recorded yet. Use `agentplane pr note ...` to append one. + + +
+Raw evidence + +- Updated: 2026-05-01T21:31:57.672Z +- Branch: task/202605012125-PXYEPC/external-distribution-publish +- Head: 9b6156d62117 + +```text + .github/workflows/publish.yml | 49 ++++ + .../publish-external-distribution-script.test.ts | 121 ++++++++++ + .../release/publish-workflow-contract.test.ts | 31 +++ + scripts/publish-external-distribution.mjs | 251 +++++++++++++++++++++ + 4 files changed, 452 insertions(+) +``` + +
+ diff --git a/.agentplane/tasks/202605012125-PXYEPC/pr/verify.log b/.agentplane/tasks/202605012125-PXYEPC/pr/verify.log new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 57da1d1f2..e4aa274ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -391,6 +391,55 @@ jobs: .agentplane/.release/publish/distribution/install.ps1 .agentplane/.release/publish/distribution/SHA256SUMS .agentplane/.release/publish/distribution/release-distribution.json + - name: Publish Homebrew tap PR + shell: bash + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN || '' }} + run: | + set -euo pipefail + node scripts/publish-external-distribution.mjs \ + --module homebrew \ + --repo basilisk-labs/homebrew-tap \ + --source .agentplane/.release/publish/homebrew \ + --copy Formula/agentplane.rb:Formula/agentplane.rb \ + --version "${{ needs.detect.outputs.version }}" \ + --tag "${{ needs.detect.outputs.tag }}" \ + --sha "${{ needs.detect.outputs.sha }}" \ + --token-env HOMEBREW_TAP_TOKEN \ + --out .agentplane/.release/publish/homebrew/homebrew-publish-result.json + - name: Publish Scoop bucket PR + shell: bash + env: + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN || '' }} + run: | + set -euo pipefail + node scripts/publish-external-distribution.mjs \ + --module scoop \ + --repo basilisk-labs/scoop-bucket \ + --source .agentplane/.release/publish/scoop \ + --copy agentplane.json:bucket/agentplane.json \ + --version "${{ needs.detect.outputs.version }}" \ + --tag "${{ needs.detect.outputs.tag }}" \ + --sha "${{ needs.detect.outputs.sha }}" \ + --token-env SCOOP_BUCKET_TOKEN \ + --out .agentplane/.release/publish/scoop/scoop-publish-result.json + - name: Publish setup-agentplane PR + shell: bash + env: + SETUP_AGENTPLANE_TOKEN: ${{ secrets.SETUP_AGENTPLANE_TOKEN || '' }} + run: | + set -euo pipefail + node scripts/publish-external-distribution.mjs \ + --module setup-agentplane \ + --repo basilisk-labs/setup-agentplane \ + --source .agentplane/.release/publish/setup-agentplane \ + --copy action.yml:action.yml \ + --copy README.md:README.md \ + --version "${{ needs.detect.outputs.version }}" \ + --tag "${{ needs.detect.outputs.tag }}" \ + --sha "${{ needs.detect.outputs.sha }}" \ + --token-env SETUP_AGENTPLANE_TOKEN \ + --out .agentplane/.release/publish/setup-agentplane/setup-agentplane-publish-result.json - name: Upload release-distribution artifact if: always() uses: actions/upload-artifact@v7 diff --git a/packages/agentplane/src/commands/release/publish-external-distribution-script.test.ts b/packages/agentplane/src/commands/release/publish-external-distribution-script.test.ts new file mode 100644 index 000000000..36062458d --- /dev/null +++ b/packages/agentplane/src/commands/release/publish-external-distribution-script.test.ts @@ -0,0 +1,121 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { afterEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const SCRIPT_PATH = path.resolve(process.cwd(), "scripts/publish-external-distribution.mjs"); +const tempRoots: string[] = []; + +async function makeTempRoot() { + const root = await mkdtemp(path.join(tmpdir(), "agentplane-external-distribution-")); + tempRoots.push(root); + return root; +} + +afterEach(async () => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (!root) continue; + await rm(root, { recursive: true, force: true }); + } +}); + +describe("publish-external-distribution script", () => { + it("records a skipped evidence file when the target repository token is missing", async () => { + const root = await makeTempRoot(); + await mkdir(path.join(root, "source", "Formula"), { recursive: true }); + await writeFile(path.join(root, "source", "Formula", "agentplane.rb"), "class Agentplane\nend\n"); + const outPath = path.join(root, "result.json"); + + const { stdout } = await execFileAsync( + "node", + [ + SCRIPT_PATH, + "--module", + "homebrew", + "--repo", + "basilisk-labs/homebrew-tap", + "--source", + path.join(root, "source"), + "--copy", + "Formula/agentplane.rb:Formula/agentplane.rb", + "--version", + "0.4.1", + "--tag", + "v0.4.1", + "--sha", + "abc123", + "--token-env", + "AGENTPLANE_TEST_MISSING_TOKEN", + "--out", + outPath, + ], + { + cwd: process.cwd(), + env: { ...process.env, AGENTPLANE_TEST_MISSING_TOKEN: "" }, + }, + ); + + const payload = JSON.parse(await readFile(outPath, "utf8")) as { + module: string; + repository: string; + requiredSecret: string; + status: string; + }; + expect(stdout).toContain("homebrew external publish skipped_missing_credentials"); + expect(payload).toMatchObject({ + module: "homebrew", + repository: "basilisk-labs/homebrew-tap", + requiredSecret: "AGENTPLANE_TEST_MISSING_TOKEN", + status: "skipped_missing_credentials", + }); + }); + + it("accepts repeated --copy arguments and prints JSON evidence", async () => { + const root = await makeTempRoot(); + await mkdir(path.join(root, "source"), { recursive: true }); + await writeFile(path.join(root, "source", "action.yml"), "name: setup-agentplane\n"); + await writeFile(path.join(root, "source", "README.md"), "# setup-agentplane\n"); + const outPath = path.join(root, "result.json"); + + const { stdout } = await execFileAsync( + "node", + [ + SCRIPT_PATH, + "--module=setup-agentplane", + "--repo=basilisk-labs/setup-agentplane", + "--source", + path.join(root, "source"), + "--copy=action.yml:action.yml", + "--copy", + "README.md:README.md", + "--version=0.4.1", + "--tag=v0.4.1", + "--sha=abc123", + "--token-env=AGENTPLANE_TEST_MISSING_TOKEN", + "--out", + outPath, + "--json", + ], + { + cwd: process.cwd(), + env: { ...process.env, AGENTPLANE_TEST_MISSING_TOKEN: "" }, + }, + ); + + const stdoutPayload = JSON.parse(stdout) as { status: string; module: string }; + const filePayload = JSON.parse(await readFile(outPath, "utf8")) as { + status: string; + module: string; + }; + expect(stdoutPayload).toMatchObject({ + module: "setup-agentplane", + status: "skipped_missing_credentials", + }); + expect(filePayload).toEqual(stdoutPayload); + }); +}); diff --git a/packages/agentplane/src/commands/release/publish-workflow-contract.test.ts b/packages/agentplane/src/commands/release/publish-workflow-contract.test.ts index 39d160674..2447c86b3 100644 --- a/packages/agentplane/src/commands/release/publish-workflow-contract.test.ts +++ b/packages/agentplane/src/commands/release/publish-workflow-contract.test.ts @@ -41,6 +41,31 @@ describe("publish workflow contract", () => { expect(workflow).toContain("node scripts/render-scoop-manifest.mjs"); expect(workflow).toContain("Render setup-agentplane action"); expect(workflow).toContain("node scripts/render-setup-agentplane-action.mjs"); + expect(workflow).toContain("Publish Homebrew tap PR"); + expect(workflow).toContain("Publish Scoop bucket PR"); + expect(workflow).toContain("Publish setup-agentplane PR"); + expect(workflow).toContain("node scripts/publish-external-distribution.mjs"); + expect(workflow).toContain("HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN || '' }}"); + expect(workflow).toContain("SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN || '' }}"); + expect(workflow).toContain( + "SETUP_AGENTPLANE_TOKEN: ${{ secrets.SETUP_AGENTPLANE_TOKEN || '' }}", + ); + expect(workflow).toContain("--repo basilisk-labs/homebrew-tap"); + expect(workflow).toContain("--repo basilisk-labs/scoop-bucket"); + expect(workflow).toContain("--repo basilisk-labs/setup-agentplane"); + expect(workflow).toContain("--copy Formula/agentplane.rb:Formula/agentplane.rb"); + expect(workflow).toContain("--copy agentplane.json:bucket/agentplane.json"); + expect(workflow).toContain("--copy action.yml:action.yml"); + expect(workflow).toContain("--copy README.md:README.md"); + expect(workflow).toContain( + "--out .agentplane/.release/publish/homebrew/homebrew-publish-result.json", + ); + expect(workflow).toContain( + "--out .agentplane/.release/publish/scoop/scoop-publish-result.json", + ); + expect(workflow).toContain( + "--out .agentplane/.release/publish/setup-agentplane/setup-agentplane-publish-result.json", + ); expect(workflow).toContain("Publish GHCR image"); expect(workflow).toContain("node scripts/render-ghcr-image-metadata.mjs"); expect(workflow).toContain("docker login ghcr.io"); @@ -76,6 +101,12 @@ describe("publish workflow contract", () => { expect(workflow).toContain("bun scripts/release-task-evidence.mjs apply"); expect(workflow).toContain("Open or recover release evidence PR"); expect(workflow).toContain("Enable auto-merge for release evidence PR"); + expect(workflow.indexOf("Create GitHub Release")).toBeLessThan( + workflow.indexOf("Publish Homebrew tap PR"), + ); + expect(workflow.indexOf("Publish setup-agentplane PR")).toBeLessThan( + workflow.indexOf("Upload release-distribution artifact"), + ); for (const stepName of [ "Check for existing release evidence PR", "Open or recover release evidence PR", diff --git a/scripts/publish-external-distribution.mjs b/scripts/publish-external-distribution.mjs new file mode 100644 index 000000000..bb610edf6 --- /dev/null +++ b/scripts/publish-external-distribution.mjs @@ -0,0 +1,251 @@ +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { mkdir, writeFile, copyFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { defineScript, parseScriptArgs, runScriptMain } from "./lib/script-runtime.mjs"; + +const execFileAsync = promisify(execFile); + +function usage() { + return [ + "Usage: node scripts/publish-external-distribution.mjs [options]", + "", + "Open or update an external distribution repository PR from generated release artifacts.", + "", + "Options:", + " --module Evidence module name", + " --repo Target GitHub repository", + " --source Generated artifact source directory", + " --copy Copy source-relative file to target-relative file; repeatable", + " --version Release version", + " --tag Release tag", + " --sha Release commit SHA", + " --token-env Environment variable containing target repo token", + " --out Evidence JSON path", + " --json Emit evidence JSON to stdout", + " --help, -h Show this help text", + ].join("\n"); +} + +function parseArgs(argv, repoRoot) { + const copyArgs = []; + const passthroughArgs = []; + for (let index = 0; index < argv.length; index += 1) { + const raw = argv[index] ?? ""; + if (raw === "--copy") { + const value = argv[index + 1]; + if (!value) throw new Error("Missing value for --copy"); + copyArgs.push(value); + index += 1; + continue; + } + if (raw.startsWith("--copy=")) { + copyArgs.push(raw.slice("--copy=".length)); + continue; + } + passthroughArgs.push(raw); + } + const { flags } = parseScriptArgs(passthroughArgs, { + valueFlags: ["module", "repo", "source", "version", "tag", "sha", "token-env", "out"], + booleanFlags: ["json", "help"], + }); + return { + module: String(flags.module ?? "").trim(), + repo: String(flags.repo ?? "").trim(), + sourceDir: path.resolve(repoRoot, String(flags.source ?? "")), + copies: copyArgs, + version: String(flags.version ?? "").trim(), + tag: String(flags.tag ?? "").trim(), + sha: String(flags.sha ?? "").trim(), + tokenEnv: String(flags["token-env"] ?? "").trim(), + outPath: path.resolve(repoRoot, String(flags.out ?? "")), + json: Boolean(flags.json), + help: Boolean(flags.help), + }; +} + +function requireNonEmpty(value, label) { + if (!value) throw new Error(`Missing required ${label}.`); + return value; +} + +function parseCopySpec(value) { + const index = value.indexOf(":"); + if (index <= 0 || index === value.length - 1) { + throw new Error(`Invalid --copy value: ${value}`); + } + const from = value.slice(0, index); + const to = value.slice(index + 1); + if (path.isAbsolute(from) || path.isAbsolute(to) || from.includes("..") || to.includes("..")) { + throw new Error(`Unsafe --copy value: ${value}`); + } + return { from, to }; +} + +async function run(command, args, opts = {}) { + return execFileAsync(command, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + maxBuffer: 20 * 1024 * 1024, + }); +} + +async function writeEvidence(outPath, evidence) { + await mkdir(path.dirname(outPath), { recursive: true }); + await writeFile(outPath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8"); +} + +async function copyArtifacts(opts) { + for (const spec of opts.copies) { + const from = path.join(opts.sourceDir, spec.from); + const to = path.join(opts.cloneDir, spec.to); + await mkdir(path.dirname(to), { recursive: true }); + await copyFile(from, to); + } +} + +async function publishExternal(args) { + requireNonEmpty(args.module, "module"); + requireNonEmpty(args.repo, "repo"); + requireNonEmpty(args.sourceDir, "source"); + requireNonEmpty(args.version, "version"); + requireNonEmpty(args.tag, "tag"); + requireNonEmpty(args.sha, "sha"); + requireNonEmpty(args.tokenEnv, "token env"); + requireNonEmpty(args.outPath, "out path"); + if (args.copies.length === 0) throw new Error("At least one --copy is required."); + + const token = String(process.env[args.tokenEnv] ?? "").trim(); + const baseEvidence = { + schemaVersion: 1, + module: args.module, + repository: args.repo, + version: args.version, + tag: args.tag, + sha: args.sha, + requiredSecret: args.tokenEnv, + }; + if (!token) { + return { + ...baseEvidence, + status: "skipped_missing_credentials", + nextAction: `Add ${args.tokenEnv} and rerun this distribution publication module.`, + }; + } + + const tempRoot = mkdtempSync(path.join(os.tmpdir(), "agentplane-external-dist-")); + const cloneDir = path.join(tempRoot, "repo"); + const branch = `agentplane/${args.tag}`; + const env = { ...process.env, GH_TOKEN: token, GITHUB_TOKEN: token }; + try { + await run("gh", ["repo", "clone", args.repo, cloneDir, "--", "--depth", "1"], { env }); + await run("gh", ["auth", "setup-git", "--hostname", "github.com"], { cwd: cloneDir, env }); + await run("git", ["switch", "-C", branch], { cwd: cloneDir, env }); + await copyArtifacts({ + sourceDir: args.sourceDir, + cloneDir, + copies: args.copies.map((copy) => parseCopySpec(copy)), + }); + const { stdout: statusStdout } = await run("git", ["status", "--short"], { + cwd: cloneDir, + env, + }); + if (!statusStdout.trim()) { + return { + ...baseEvidence, + status: "unchanged", + branch, + nextAction: "No external distribution repository changes were needed.", + }; + } + await run("git", ["config", "user.name", "github-actions[bot]"], { cwd: cloneDir, env }); + await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"], { + cwd: cloneDir, + env, + }); + await run("git", ["add", "."], { cwd: cloneDir, env }); + await run("git", ["commit", "-m", `agentplane: publish ${args.version}`], { + cwd: cloneDir, + env, + }); + await run("git", ["push", "--set-upstream", "origin", branch, "--force-with-lease"], { + cwd: cloneDir, + env, + }); + + const { stdout: existingPr } = await run( + "gh", + [ + "pr", + "list", + "--repo", + args.repo, + "--state", + "open", + "--head", + branch, + "--json", + "url", + "--jq", + ".[0].url // \"\"", + ], + { cwd: cloneDir, env }, + ); + const existingPrUrl = existingPr.trim(); + let createdPrUrl = ""; + if (!existingPrUrl) { + const createdPr = await run( + "gh", + [ + "pr", + "create", + "--repo", + args.repo, + "--base", + "main", + "--head", + branch, + "--title", + `agentplane: publish ${args.version}`, + "--body", + `Publish AgentPlane ${args.version} from ${args.sha}.`, + ], + { cwd: cloneDir, env }, + ); + createdPrUrl = createdPr.stdout.trim(); + } + const prUrl = existingPrUrl || createdPrUrl; + return { + ...baseEvidence, + status: "pr_opened", + branch, + prUrl, + nextAction: `Review and merge ${prUrl}.`, + }; + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +const main = defineScript({ + name: "publish-external-distribution", + async run(context) { + const args = parseArgs(context.argv, context.cwd); + if (args.help) { + context.stdout.write(`${usage()}\n`); + return; + } + const evidence = await publishExternal(args); + await writeEvidence(args.outPath, evidence); + if (args.json) { + context.stdout.write(`${JSON.stringify(evidence)}\n`); + return; + } + context.stdout.write(`${args.module} external publish ${evidence.status}\n`); + }, +}); + +runScriptMain(main);