Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9d06f01
fix: bootstrap the initial plugin release
nathanvale Aug 5, 2026
83fd820
Address PR review feedback (#11)
nathanvale Aug 5, 2026
a1e3dab
fix: enforce native plugin release metadata
nathanvale Aug 5, 2026
246f1e3
Address PR review feedback (#11)
nathanvale Aug 5, 2026
6930f18
feat(init): reset recipient release lineage to 0.1.0
nathanvale Aug 5, 2026
96aad7e
feat(payload): enforce one safe regular-file-only payload inventory
nathanvale Aug 5, 2026
5396fd3
feat(metadata): own native package rules with two named profiles
nathanvale Aug 5, 2026
e6451fe
feat(release): bind proof, tag, release, and repair to one commit
nathanvale Aug 5, 2026
3127a5b
feat(ci): gate installable payload changes on releasable PR title
nathanvale Aug 5, 2026
5f1b06f
feat(readiness): verify human-owned publication safeguards read-only
nathanvale Aug 5, 2026
57cd1d3
feat(proof): prove native install, cache, trust, update, and rollback
nathanvale Aug 5, 2026
d1e859f
feat(canary): qualify publishing-system changes with hosted Git canaries
nathanvale Aug 5, 2026
048faae
docs: align operator guidance with the hardened release contract
nathanvale Aug 5, 2026
d00e0fb
docs(plan): add reviewed plugin publishing hardening plan
nathanvale Aug 5, 2026
4c4dd44
fix(ci): isolate packaging fixture from GitHub SHA
nathanvale Aug 5, 2026
04fb28a
Address PR review feedback (#11)
nathanvale Aug 5, 2026
79cd078
fix: close release and canary review gaps
nathanvale Aug 5, 2026
64b60ea
fix: exercise centralized release impact policy
nathanvale Aug 5, 2026
c55082c
fix: bind final release validation to candidate
nathanvale Aug 5, 2026
95c4fb2
fix: close release repair review gaps
nathanvale Aug 5, 2026
d32f637
fix: fetch fork canary heads from source
nathanvale Aug 5, 2026
4c95f28
fix: make hosted and repair gates fail closed
nathanvale Aug 5, 2026
ebbc03e
fix: bind release gates across rename and trust boundaries
nathanvale Aug 5, 2026
f3f8d03
fix: execute release gates from trusted code
nathanvale Aug 5, 2026
232ec8e
fix: isolate release impact in trusted workflow
nathanvale Aug 5, 2026
9c6954c
fix: preserve canary generator and transport boundaries
nathanvale Aug 5, 2026
aafeec5
fix: close release review gaps (#11)
nathanvale Aug 5, 2026
4cf8727
docs: bind release automation identity (#11)
nathanvale Aug 5, 2026
7180e3f
fix: bind package source to checkout (#11)
nathanvale Aug 5, 2026
7832ac9
fix: resolve publication review findings (#11)
nathanvale Aug 5, 2026
5cfdcdc
fix: pin bootstrap release to v0.1.0 (#11)
nathanvale Aug 5, 2026
fc0c99d
docs: clarify release automation credentials (#11)
nathanvale Aug 5, 2026
fef9097
fix: require release automation token (#11)
nathanvale Aug 5, 2026
2a9efcf
fix: harden release and canary admission (#11)
nathanvale Aug 5, 2026
6b9b3b6
fix: close release review gaps
nathanvale Aug 6, 2026
8660009
fix: fail closed on workflow policy gaps
nathanvale Aug 6, 2026
24034b5
fix: sanitize public canary publication
nathanvale Aug 6, 2026
9096fa6
fix: prove sanitized public canaries
nathanvale Aug 6, 2026
7036418
fix: preserve unusual archive filenames
nathanvale Aug 6, 2026
7ea726a
fix: detect list-item workflow actions
nathanvale Aug 6, 2026
ed5a995
fix: resolve release qualification review findings (#11)
nathanvale Aug 6, 2026
fac5e40
fix: close remaining release review gaps
nathanvale Aug 6, 2026
cb182a3
fix: check main linear history policy
nathanvale Aug 6, 2026
bf01654
fix: verify hosted canary credentials (#11)
nathanvale Aug 6, 2026
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
2 changes: 1 addition & 1 deletion .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "harness-native-plugin-prototype",
"interface": {
"displayName": "Harness Native Plugin Prototype"
"displayName": "Harness Plugin Prototype"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "Prototype"
},
"metadata": {
"description": "Marketplace for Harness Native Plugin Prototype",
"description": "Marketplace for Harness Plugin Prototype",
"version": "0.1.0"
},
"plugins": [
Expand Down
4 changes: 1 addition & 3 deletions .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
{
".": "0.1.0"
}
{}
Comment thread
myagentdojo marked this conversation as resolved.
Resolved
1 change: 1 addition & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"packages": {
".": {
"release-type": "node",
"initial-version": "0.1.0",
"package-name": "agent-plugin",
"changelog-path": "CHANGELOG.md",
"changelog-sections": [
Expand Down
3 changes: 0 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +0,0 @@
# Changelog

Release Please maintains this file from Conventional Commit titles when the generated release pull request is merged.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ Commit generated files with their source. Never hand-edit a generated manifest o
For a public GitHub repository:

```sh
claude plugin marketplace add OWNER/REPOSITORY@vX.Y.Z
claude plugin marketplace add OWNER/REPOSITORY
Comment thread
myagentdojo marked this conversation as resolved.
Resolved
claude plugin install PLUGIN_NAME@PLUGIN_NAME
```

For a private repository, configure Git credentials first. Claude accepts a GitHub shorthand, HTTPS Git URL, or SSH Git URL:

```sh
claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git#vX.Y.Z
claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git
claude plugin install PLUGIN_NAME@PLUGIN_NAME
```

Expand All @@ -56,14 +56,14 @@ claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope project
claude plugin install PLUGIN_NAME@PLUGIN_NAME --scope local
```

To move to a later release, replace `vX.Y.Z` with the new release tag. Remove the pinned marketplace entry, repeat the matching `marketplace add` command above with that tag, then reinstall the plugin:
Claude caches both the marketplace and each semantic plugin version. Refresh the marketplace before updating the plugin:

```sh
claude plugin marketplace remove PLUGIN_NAME
claude plugin install PLUGIN_NAME@PLUGIN_NAME
claude plugin marketplace update PLUGIN_NAME
claude plugin update PLUGIN_NAME@PLUGIN_NAME
```

Start a new session or run `/reload-plugins`. Review installed hooks before trusting the plugin source.
Start a new session or run `/reload-plugins`. Review installed hooks before trusting each new plugin version.

Official references: [plugin marketplaces](https://code.claude.com/docs/en/plugin-marketplaces), [plugins](https://code.claude.com/docs/en/plugins), and [plugin reference](https://code.claude.com/docs/en/plugins-reference).

Expand Down Expand Up @@ -221,8 +221,8 @@ flowchart LR
### One-time GitHub setup

1. Open **Settings → Actions → General → Workflow permissions**.
2. Enable read and write workflow permissions.
3. Allow GitHub Actions to create pull requests.
2. Keep the default workflow permission read-only; the release job requests narrowly scoped write permissions.
3. Allow GitHub Actions to create and approve pull requests.
4. Enable squash merging and keep the enforced Conventional Commit PR title.
5. Protect `main` and require the plugin CI and title checks for normal PRs.

Expand All @@ -232,15 +232,15 @@ The zero-secret configuration uses `GITHUB_TOKEN`. GitHub suppresses new workflo

1. Merge normal PRs with valid Conventional Commit titles.
2. Wait for the `Release` workflow to create or update the release PR.
3. Review its semantic version and generated `CHANGELOG.md`.
3. Confirm the first release is `v0.1.0`; review its semantic version and generated `CHANGELOG.md`.
4. Merge the release PR when the batch is ready.
5. Wait for the `Release` workflow to validate metadata, prove the four-platform payload, reject generated drift, create `vX.Y.Z`, create the GitHub Release, and attach the deterministic archive plus provenance JSON.

Do not hand-edit versions or `CHANGELOG.md`. Do not create the tag first. Do not publish to npm.

If the tag and GitHub Release exist but asset upload or attestation failed, run the `Release` workflow manually with `release_tag` set to the exact existing `vX.Y.Z` tag. The workflow checks out that tag, repeats the full proof, uploads the archive and provenance with `--clobber`, and recreates the public attestation. Leave the input empty during normal operation.

The release configuration synchronizes:
Before the first release, `.github/.release-please-manifest.json` stays empty so Release Please bootstraps `v0.1.0`. After that release, the release configuration synchronizes:

- `package.json`
- `plugin.config.json`
Expand Down
4 changes: 2 additions & 2 deletions plugin.config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"template": true,
"name": "harness-native-plugin-prototype",
"displayName": "Harness Native Plugin Prototype",
"displayName": "Harness Plugin Prototype",
"version": "0.1.0",
"description": "Prove native plugin distribution with a Bun-authored portable runtime",
"author": {
Expand All @@ -11,7 +11,7 @@
"license": "MIT",
"keywords": ["agent-plugin", "bun", "quickjs"],
"category": "Developer Tools",
"shortDescription": "Run a Bun-authored skill without Bun installed.",
"shortDescription": "Run a portable plugin skill",
"longDescription": "A portability proof with shared skills and runtime code plus native Claude Code and Codex manifests and hooks.",
"capabilities": ["Read"],
"defaultPrompts": ["Run the hello-world distribution proof."],
Expand Down
1 change: 1 addition & 0 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "harness-native-plugin-prototype",
"displayName": "Harness Plugin Prototype",
"version": "0.1.0",
"description": "Prove native plugin distribution with a Bun-authored portable runtime",
"author": {
Expand Down
4 changes: 2 additions & 2 deletions plugin/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
"skills": "./skills/",
"hooks": "./hooks/codex/hooks.json",
"interface": {
"displayName": "Harness Native Plugin Prototype",
"shortDescription": "Run a Bun-authored skill without Bun installed.",
"displayName": "Harness Plugin Prototype",
"shortDescription": "Run a portable plugin skill",
"longDescription": "A portability proof with shared skills and runtime code plus native Claude Code and Codex manifests and hooks.",
"developerName": "Prototype",
"category": "Developer Tools",
Expand Down
1 change: 1 addition & 0 deletions scripts/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ test("template user initializes both harness manifests from one metadata source"
)
expect(claudeManifest).toMatchObject({
name: "dojo-hello",
displayName: "Dojo Hello",
version: templateVersion,
skills: "./skills/",
hooks: "./hooks/claude/hooks.json",
Expand Down
45 changes: 44 additions & 1 deletion scripts/plugin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,57 @@ function serialize(value: unknown): string {
}

function validateConfig(config: PluginConfig): void {
const strictSemver =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
const codexCategories = new Set([
"Productivity",
"Creativity",
"Developer Tools",
"Business & Operations",
"Data & Analytics",
"Communication",
"Education & Research",
"Security",
"Finance",
"Healthcare",
"Travel",
"Entertainment",
"Other",
])
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.name) || config.name.length > 64) {
throw new Error("plugin name must be kebab-case and at most 64 characters")
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(config.version)) {
if (!strictSemver.test(config.version)) {
throw new Error("plugin version must use semantic versioning")
}
if (!config.displayName.trim() || !config.description.trim() || !config.author.name.trim()) {
throw new Error("displayName, description, and author.name are required")
}
if (config.displayName.length > 30) {
throw new Error("displayName must be at most 30 characters for Codex directory submission")
}
if (config.author.name.length > 80) {
throw new Error("author.name must be at most 80 characters for Codex directory submission")
}
if (
!config.shortDescription.trim() ||
config.shortDescription.includes("\n") ||
config.shortDescription.length > 30
) {
throw new Error("shortDescription must be one non-empty line of at most 30 characters")
}
if (!config.longDescription.trim() || config.longDescription.length > 4_000) {
throw new Error("longDescription must be non-empty and at most 4000 characters")
}
if (!codexCategories.has(config.category)) {
throw new Error("category must be a supported Codex directory category")
}
if (
config.capabilities.length > 20 ||
config.capabilities.some((capability) => !capability.trim() || capability.length > 120)
) {
throw new Error("capabilities accepts at most 20 non-empty entries of at most 120 characters")
}
if (!config.repository.startsWith("https://")) {
throw new Error("repository must be an absolute HTTPS URL")
}
Expand Down Expand Up @@ -127,6 +169,7 @@ function claudeManifest(config: PluginConfig): GeneratedFile {
path: "plugin/.claude-plugin/plugin.json",
contents: serialize({
name: config.name,
displayName: config.displayName,
version: config.version,
description: config.description,
author: config.author,
Expand Down
107 changes: 107 additions & 0 deletions scripts/plugin-manifest-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { existsSync, readFileSync } from "node:fs"
import { join, resolve } from "node:path"

import { expect, test } from "bun:test"

import { renderGeneratedFiles, type PluginConfig } from "./plugin-config"

const root = resolve(import.meta.dir, "..")
const pluginRoot = join(root, "plugin")
const config = JSON.parse(readFileSync(join(root, "plugin.config.json"), "utf8")) as PluginConfig
const claudeManifest = JSON.parse(
readFileSync(join(pluginRoot, ".claude-plugin", "plugin.json"), "utf8"),
)
const codexManifest = JSON.parse(
readFileSync(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8"),
)

const strictSemver =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/

test("checked-in native manifests satisfy the published harness contracts", () => {
for (const field of [
"name",
"version",
"description",
"author",
"repository",
"license",
"keywords",
"skills",
"hooks",
]) {
expect(claudeManifest[field], `Claude manifest field ${field}`).toBeDefined()
expect(codexManifest[field], `Codex manifest field ${field}`).toBeDefined()
}

expect(claudeManifest).toMatchObject({
name: config.name,
displayName: config.displayName,
version: config.version,
description: config.description,
author: config.author,
repository: config.repository,
license: config.license,
keywords: config.keywords,
})
expect(codexManifest).toMatchObject({
name: config.name,
version: config.version,
description: config.description,
author: config.author,
repository: config.repository,
license: config.license,
keywords: config.keywords,
})

expect(config.version).toMatch(strictSemver)
expect(config.displayName.length).toBeLessThanOrEqual(30)
expect(codexManifest.interface).toMatchObject({
displayName: config.displayName,
shortDescription: config.shortDescription,
longDescription: config.longDescription,
developerName: config.author.name,
category: config.category,
capabilities: config.capabilities,
defaultPrompt: config.defaultPrompts,
})
expect(config.shortDescription).not.toContain("\n")
expect(config.shortDescription.length).toBeLessThanOrEqual(30)
expect(config.longDescription.length).toBeLessThanOrEqual(4_000)
expect(config.capabilities.length).toBeLessThanOrEqual(20)
expect(config.defaultPrompts.length).toBeLessThanOrEqual(3)
expect(config.defaultPrompts.every((prompt: string) => prompt.length <= 128)).toBe(true)

for (const manifest of [claudeManifest, codexManifest]) {
for (const field of ["skills", "hooks"]) {
const path = manifest[field]
expect(path.startsWith("./"), `${field} must be plugin-relative`).toBe(true)
expect(existsSync(join(pluginRoot, path)), `${field} target must exist`).toBe(true)
}
}
})

test.each([
["name", (value: PluginConfig) => (value.name = "Not Valid"), "plugin name"],
["semantic version", (value: PluginConfig) => (value.version = "01.0.0"), "semantic versioning"],
["required display name", (value: PluginConfig) => (value.displayName = ""), "are required"],
["display name", (value: PluginConfig) => (value.displayName = "x".repeat(31)), "displayName"],
["developer name", (value: PluginConfig) => (value.author.name = "x".repeat(81)), "author.name"],
["empty short description", (value: PluginConfig) => (value.shortDescription = ""), "shortDescription"],
["short description line", (value: PluginConfig) => (value.shortDescription = "two\nlines"), "shortDescription"],
["short description length", (value: PluginConfig) => (value.shortDescription = "x".repeat(31)), "shortDescription"],
["long description", (value: PluginConfig) => (value.longDescription = ""), "longDescription"],
["long description length", (value: PluginConfig) => (value.longDescription = "x".repeat(4_001)), "longDescription"],
["category", (value: PluginConfig) => (value.category = "Unknown"), "category"],
["capability count", (value: PluginConfig) => (value.capabilities = Array(21).fill("Read")), "capabilities"],
["empty capability", (value: PluginConfig) => (value.capabilities = [""]), "capabilities"],
["capability length", (value: PluginConfig) => (value.capabilities = ["x".repeat(121)]), "capabilities"],
["repository", (value: PluginConfig) => (value.repository = "git@example.com:plugin.git"), "repository"],
["canary owner", (value: PluginConfig) => (value.canary.owner = "not_valid"), "canary.owner"],
["prompt count", (value: PluginConfig) => (value.defaultPrompts = Array(4).fill("Run")), "defaultPrompts"],
["prompt length", (value: PluginConfig) => (value.defaultPrompts = ["x".repeat(129)]), "defaultPrompts"],
] as const)("rejects invalid published %s metadata", (_name, mutate, message) => {
const invalid = structuredClone(config)
mutate(invalid)
expect(() => renderGeneratedFiles(invalid)).toThrow(message)
})
12 changes: 6 additions & 6 deletions scripts/readme-release-pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"

const readmeUrl = new URL("../README.md", import.meta.url)

test("production installation pins each marketplace checkout to a release tag", async () => {
test("production installation supports Claude updates and pins Codex releases", async () => {
const readme = await Bun.file(readmeUrl).text()
const productionInstall = readme.slice(
readme.indexOf("## Install in Claude Code"),
Expand All @@ -13,13 +13,13 @@ test("production installation pins each marketplace checkout to a release tag",
.filter((line) => /^(claude|codex) plugin marketplace add /.test(line))

expect(marketplaceAdds).toEqual([
"claude plugin marketplace add OWNER/REPOSITORY@vX.Y.Z",
"claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git#vX.Y.Z",
"claude plugin marketplace add OWNER/REPOSITORY",
"claude plugin marketplace add git@github.com:OWNER/REPOSITORY.git",
"codex plugin marketplace add OWNER/REPOSITORY --ref vX.Y.Z",
"codex plugin marketplace add git@github.com:OWNER/REPOSITORY.git --ref vX.Y.Z",
])
expect(productionInstall).not.toMatch(/codex plugin marketplace add .*--ref main/)
expect(productionInstall).not.toContain("plugin marketplace update")
expect(productionInstall).not.toContain("plugin marketplace upgrade")
expect(productionInstall.match(/Remove the pinned marketplace entry/g)).toHaveLength(2)
expect(productionInstall).toContain("claude plugin marketplace update PLUGIN_NAME")
expect(productionInstall).toContain("claude plugin update PLUGIN_NAME@PLUGIN_NAME")
expect(productionInstall.match(/Remove the pinned marketplace entry/g)).toHaveLength(1)
})
Loading