Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/extensions/replica-prototype/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ The canvas follows branch changes. If Hub has not discovered the branch, the rep

For a Hub started in [local development mode](https://github.com/github/replica/blob/main/hub/docs/local-development.md), open Copilot in Hub's `.local/prototypes/example` checkout and pass `"repository": "local/example"` when opening the canvas. Local repository hints only apply to matching checkouts under `.local/prototypes/` with a loopback Hub origin; they cannot redirect a remote checkout to another Hub repository. Start that example's dev server on a port reachable from the canvas before opening it.

Selecting **Publish** checks for uncommitted changes and, when present without a saved commit preference, opens a dialog offering **Commit changes & publish** or **Publish committed version only**. Dismiss it with the close icon or Escape. Copilot selects task-related changes only when committing is chosen. **Always commit changes before publishing for this prototype** remembers this permission locally for the current repository and branch, not other prototypes; later Publish clicks on that branch skip the choice and request task-related commits. After the first registered, published Build, change it through the immediately saved toggle under **Settings** at the bottom of **Edit details**. The committed-only option excludes local edits. Clean checkouts need no choice. See the [canonical publishing boundary](../../../hub/docs/prototype-canvas.md#publishing-boundary) for preference scope, failures, and target safeguards.
Selecting **Publish** checks for uncommitted changes and, when present without a saved commit preference, opens a dialog offering **Commit changes & publish** or **Publish committed version only**. Dismiss it with the close icon or Escape. The dialog lists the changed paths. Local agent skills under `.github/skills/`, `.agents/skills/`, or `.claude/skills/` do not count as prototype changes and stay uncommitted. Copilot selects task-related changes only when committing is chosen, and publishes the current commit when none are found. **Always commit changes before publishing for this prototype** remembers this permission locally for the current repository and branch, not other prototypes; later Publish clicks on that branch skip the choice and request task-related commits. After the first registered, published Build, change it through the immediately saved toggle under **Settings** at the bottom of **Edit details**. The committed-only option excludes local edits. Clean checkouts need no choice. See the [canonical publishing boundary](../../../hub/docs/prototype-canvas.md#publishing-boundary) for preference scope, failures, and target safeguards.

Large file listings are streamed rather than buffered in full. If checking local changes fails or takes too long, the dialog shows an error banner and offers only **Retry check** or **Publish committed version only**. The description, commit action, and saved-permission checkbox return only after a successful check that needs a publishing choice. Choosing the committed version leaves local edits untouched and still verifies the publication target.

The click or publishing choice sends a request to Copilot chat. The extension adds no separate confirmation prompt and does not instruct Copilot to ask again before committing or pushing. Copilot uses the host's existing tool permissions; whether a tool call prompts for approval depends on those settings. An additional prompt is not guaranteed.

Expand All @@ -278,7 +280,7 @@ The publish request invokes the plugin's [`/replica-publish-prototype` skill](sk

Pending commit-and-publish requests remain deduplicated across verified descendant commits on the same repository and branch, with identical exact origin fetch and push URL fingerprints. Committed-only requests require the same HEAD. The original captured target and request time are retained while the tracked commit advances. Changed targets and rewritten history are not absorbed into an existing request. Dispatch still requires an exact HEAD recheck; descendant matching applies only after dispatch. A matching new remote SHA or new workflow run clears the pending push state, while the original 10-minute timeout remains unchanged. This push evidence does not replace the matching imported Hub Build required to complete publication.

Copilot uses the existing push workflow, not manual Actions dispatch or another publishing path. It follows `refresh_status`, registers discovered branches with `update_metadata`, and preserves saved metadata. If it stops before pushing, it calls `report_publish_result` with the request target and a `blocked`, `canceled`, or `failed` outcome so the canvas stops waiting and offers a retry. The action rejects other canvases, targets, or modes, and any request whose push was already observed. It waits for Hub's imported Build matching the pushed commit and returns its exact URL. An older Build or Actions success alone is not enough. The local preview stays open. Entry-page checks still apply; incomplete landing-page verification is reported, not silently repaired and republished.
Copilot uses the existing push workflow, not manual Actions dispatch or another publishing path. It follows `refresh_status`, registers discovered branches with `update_metadata`, and preserves saved metadata. If it stops before pushing, it calls `report_publish_result` with the request target and a `blocked`, `canceled`, or `failed` outcome so the canvas stops waiting and offers a retry. A later push of the current HEAD clears that failure so the canvas follows the new workflow and Build. The action rejects other canvases, targets, or modes, and any request whose push was already observed. It waits for Hub's imported Build matching the pushed commit and returns its exact URL. An older Build or Actions success alone is not enough. The local preview stays open. Entry-page checks still apply; incomplete landing-page verification is reported, not silently repaired and republished.

The canvas header uses Hub's review controls: **Add a comment** sits beside a comment count with unread state, and selecting the count opens the dedicated comments sidebar. The sidebar includes Hub's search, build/page/participant/read/resolved filters, pin visibility control, conversation rows, and thread view; the **Hub** panel remains focused on connection, prototype metadata, and publication state. Select a preview marker or conversation row to open the thread beside its target and reply without leaving the canvas. Markers use the same participant avatars, overflow, unread, resolved, focus, and accessible-label behavior as Hub. When the local checkout matches the imported Build's exact commit, **Add a comment** supports the same element and highlighted-area targets as Hub and saves the new thread directly to Hub. You can also reply, edit or delete your own messages, react, and resolve or reopen threads. Live changes arrive over Hub's review channel, with periodic refresh if that connection is unavailable. Hub credentials stay in the extension process and are never sent to the local preview.

Expand Down
2 changes: 1 addition & 1 deletion .github/extensions/replica-prototype/assets/renderer.css

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions .github/extensions/replica-prototype/assets/renderer.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { runCommand } from "../run-command/index.mjs"
import {
capturePublishTarget,
continuesPublishTarget,
sameCheckout,
samePublishTarget,
} from "../publish-target/index.mjs"

Expand Down Expand Up @@ -94,20 +95,39 @@ async function synchronizePublishRequest(context, state) {
state.publishRequest.error =
"No completed push was observed. Check Copilot chat and retry when ready."
return
} else if (!(
state.stats.remoteBranch?.state === "present" &&
state.stats.remoteBranch.remoteSha === state.stats.commitSha &&
(state.publishRequest.remoteState === "absent" ||
(state.publishRequest.remoteState === "present" &&
state.publishRequest.remoteSha !== state.stats.commitSha) ||
(state.stats.workflow.run?.databaseId &&
state.stats.workflow.run.databaseId !== state.publishRequest.workflowRunId))
)) {
} else if (!observedNewPush(state)) {
return
}
}
if (
state.publishRequest.state === "failed" &&
(!state.publishRequest.target ||
sameCheckout(state.publishRequest.target, await capturePublishTarget(context))) &&
observedNewPush(state)
) {
// A later push of the current HEAD supersedes the failed request; its workflow and Build
// evidence now describe publication.
delete state.publishRequest.outcome
delete state.publishRequest.reportedAt
state.publishRequest.state = "idle"
state.publishRequest.error = null
}
if (state.publishRequest.state === "requested") {
state.publishRequest.state = "idle"
state.publishRequest.error = null
}
}

function observedNewPush(state) {
const request = state.publishRequest
return (
state.stats.remoteBranch?.state === "present" &&
state.stats.remoteBranch.remoteSha === state.stats.commitSha &&
(request.remoteState === "absent" ||
(request.remoteState === "present" && request.remoteSha !== state.stats.commitSha) ||
Boolean(
state.stats.workflow.run?.databaseId &&
state.stats.workflow.run.databaseId !== request.workflowRunId,
))
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,87 @@ fi
})
}

for (const evidence of ["none", "push", "workflow"]) {
it(`${evidence === "none" ? "keeps" : "clears"} a reported failure ${evidence === "none" ? "without" : "after"} new ${evidence === "none" ? "" : `${evidence} `}evidence for the current HEAD`, async () => {
await withContext(async (context) => {
const originalPath = process.env.PATH
const commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: context.repositoryPath,
encoding: "utf8",
}).trim()
const runFile = path.join(context.repositoryPath, "runs.json")
const remoteFile = path.join(context.repositoryPath, "remote.json")
await mkdir(path.join(context.repositoryPath, ".github/workflows"), { recursive: true })
await writeFile(
path.join(context.repositoryPath, ".github/workflows/replica.yml"),
"name: Replica\n",
)
execFileSync("git", ["remote", "add", "origin", "https://github.com/github/replica.git"], {
cwd: context.repositoryPath,
})
await writeFile(
path.join(context.repositoryPath, "gh"),
`#!/bin/sh
if [ "$1:$2" = "run:list" ]; then
cat "${runFile}"
else
cat "${remoteFile}"
fi
`,
{ mode: 0o755 },
)
const run = {
databaseId: evidence === "workflow" ? 2 : 1,
headSha: commitSha,
headBranch: "prototype",
status: "in_progress",
conclusion: null,
createdAt: "2000-01-01T00:00:00Z",
updatedAt: "2000-01-01T00:00:00Z",
}
try {
process.env.PATH = `${context.repositoryPath}:${originalPath}`
await writeFile(
remoteFile,
JSON.stringify([
{ ref: "refs/heads/prototype", object: { type: "commit", sha: commitSha } },
]),
)
await writeFile(runFile, JSON.stringify([run]))
const state = await readState(context)
state.publishRequest = {
state: "failed",
requestedAt: new Date().toISOString(),
commitSha,
target: await capturePublishTarget(context),
mode: "committed-only",
remoteState: evidence === "push" ? "absent" : "present",
remoteSha: evidence === "push" ? null : commitSha,
workflowRunId: 1,
buildNumber: null,
outcome: "blocked",
reportedAt: new Date().toISOString(),
error: "Copilot stopped before pushing.",
}
await writeState(context, state)

const refreshed = await getPrototypeState(context, { refresh: true })
if (evidence === "none") {
assert.equal(refreshed.publishRequest.state, "failed")
assert.equal(refreshed.publishStatus.state, "failed")
} else {
assert.equal(refreshed.publishRequest.state, "idle")
assert.equal(refreshed.publishRequest.error, null)
assert.equal(refreshed.publishRequest.outcome, undefined)
assert.equal(refreshed.publishStatus.state, "in_progress")
}
} finally {
process.env.PATH = originalPath
}
})
})
}

it("caches until a forced refresh and persists the same state ID", async () => {
await withContext(async (context) => {
const withSession = { ...context, sessionId: "example-session-id" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ it("resolves the marketplace's relative source to the shipped plugin", async ()
)
const manifest = JSON.parse(await readFile(new URL(`${plugin.source}/plugin.json`, root)))
assert.equal(manifest.name, plugin.name)
assert.equal(manifest.version, "1.3.1")
assert.equal(manifest.version, "1.4.0")
const skill = await readFile(
new URL(`${plugin.source}/skills/replica-publish-prototype/SKILL.md`, root),
"utf8",
Expand Down
2 changes: 1 addition & 1 deletion .github/extensions/replica-prototype/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "replica-prototype",
"description": "Wrap a running prototype with Replica publish controls and repository status.",
"version": "1.3.1",
"version": "1.4.0",
"author": {
"name": "GitHub"
},
Expand Down
18 changes: 10 additions & 8 deletions .github/extensions/replica-prototype/prepare-publish/index.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { capturePublishTarget, samePublishTarget } from "../publish-target/index.mjs"
import { runCommand } from "../run-command/index.mjs"
import { readWorkingTreeChanges } from "../working-tree-changes/index.mjs"

export async function preparePublish(context, preferences) {
const target = await capturePublishTarget(context)
const status = await runCommand(
"git",
["status", "--porcelain", "--untracked-files=normal"],
context.repositoryPath,
)
if (!status.ok) throw new Error(`Could not check uncommitted changes: ${status.error}`)
const changes = await readWorkingTreeChanges(context.repositoryPath)
const remember = await preferences.read(target)
if (!samePublishTarget(target, await capturePublishTarget(context)))
throw Object.assign(new Error("The checkout changed while preparing to publish. Try again."), {
statusCode: 409,
})
return { target, workingTree: status.stdout.trim() ? "changed" : "clean", remember }
return {
target,
workingTree: changes.state,
...(changes.state === "unavailable"
? { workingTreeError: changes.error }
: { changes: { files: changes.files, localSkills: changes.localSkills } }),
remember,
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { writeFile } from "node:fs/promises"
import { mkdir, rm, writeFile } from "node:fs/promises"
import path from "node:path"
import { it } from "node:test"

import { createPublishPreferences } from "../publish-preferences/index.mjs"
import { withContext } from "../test-support/with-context/index.mjs"
import { preparePublish } from "./index.mjs"

it("retains a verified target and saved preference after a failed check, then checks again on retry", async () => {
await withContext(async (context) => {
const preferences = createPublishPreferences(path.join(context.sessionWorkspacePath, "prefs"))
await writeFile(
path.join(context.repositoryPath, ".git/info/exclude"),
"replica-prototype/\nprefs/\n",
)
const initial = await preparePublish(context, preferences)
await preferences.write(initial.target, true)
const index = path.join(context.repositoryPath, ".git/index")
await writeFile(index, "invalid Git index")
const unavailable = await preparePublish(context, preferences)
assert.deepEqual(unavailable.target, initial.target)
assert.equal(unavailable.workingTree, "unavailable")
assert.equal(unavailable.remember, true)
assert.equal(typeof unavailable.workingTreeError, "string")
assert.ok(unavailable.workingTreeError.length > 0)
assert.equal("changes" in unavailable, false)
await rm(index)
await writeFile(path.join(context.repositoryPath, "prototype.txt"), "Local changes")
const retried = await preparePublish(context, preferences)
assert.equal(retried.workingTree, "changed")
assert.deepEqual(retried.changes.files, {
paths: ["prototype.txt"],
total: 1,
})
assert.equal("workingTreeError" in retried, false)
})
})

it("checks fresh staged, unstaged and untracked changes without touching Git or dispatching", async () => {
await withContext(async (context) => {
const preferences = createPublishPreferences(path.join(context.sessionWorkspacePath, "prefs"))
Expand All @@ -30,8 +60,18 @@ it("checks fresh staged, unstaged and untracked changes without touching Git or
git("commit", "-qm", "Fixture")
git("config", "status.showUntrackedFiles", "no")
assert.equal((await preparePublish(context, preferences)).workingTree, "clean")
await mkdir(path.join(context.repositoryPath, ".github/skills/local"), { recursive: true })
await writeFile(path.join(context.repositoryPath, ".github/skills/local/SKILL.md"), "skill")
const localOnly = await preparePublish(context, preferences)
assert.equal(localOnly.workingTree, "clean")
assert.deepEqual(localOnly.changes, {
files: { paths: [], total: 0 },
localSkills: { paths: [".github/skills/local/SKILL.md"], total: 1 },
})
await writeFile(file, "unstaged")
assert.equal((await preparePublish(context, preferences)).workingTree, "changed")
const unstaged = await preparePublish(context, preferences)
assert.equal(unstaged.workingTree, "changed")
assert.deepEqual(unstaged.changes.files, { paths: ["tracked.txt"], total: 1 })
git("add", "tracked.txt")
const staged = git("diff", "--cached")
const head = git("rev-parse", "HEAD").trim()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function capturePublishTarget(context) {
}
}

function sameCheckout(left, right) {
export function sameCheckout(left, right) {
return (
left?.repository === right.repository &&
left?.branch === right.branch &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path"
import { readRepositoryName } from "../read-repository-name/index.mjs"
import { runCommand } from "../run-command/index.mjs"
import { readRemoteBranch } from "../read-remote-branch/index.mjs"
import { readWorkingTreeChanges } from "../working-tree-changes/index.mjs"

const WORKFLOW_PATH = ".github/workflows/replica.yml"

Expand All @@ -12,7 +13,7 @@ export async function readRepositoryStats(context) {
await Promise.all([
runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], context.repositoryPath),
runCommand("git", ["rev-parse", "HEAD"], context.repositoryPath),
runCommand("git", ["status", "--porcelain"], context.repositoryPath),
readWorkingTreeChanges(context.repositoryPath),
fileExists(path.join(context.repositoryPath, WORKFLOW_PATH)),
readRepositoryName(context.repositoryPath),
])
Expand All @@ -31,11 +32,7 @@ export async function readRepositoryStats(context) {
commitResult.ok && /^[0-9a-f]{40}$/.test(commitResult.stdout.trim())
? commitResult.stdout.trim()
: null
const workingTree = statusResult.ok
? statusResult.stdout.trim() === ""
? "clean"
: "changed"
: "unavailable"
const workingTree = statusResult.state
const [comparison, workflow, remoteBranch] = await Promise.all([
localBranch
? readBranchComparison(context.repositoryPath)
Expand Down Expand Up @@ -79,7 +76,7 @@ export async function readRepositoryStats(context) {
commitSha,
publishedUrl: context.target.publishedUrl,
workingTree,
workingTreeError: statusResult.ok ? null : statusResult.error,
workingTreeError: statusResult.error ?? null,
replicaWorkflowExists: workflowExists,
workflow,
remoteBranch,
Expand Down
Loading
Loading