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);